diff --git a/INSTALL.md b/INSTALL.md index 97b440d07..8af7b6f05 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -28,21 +28,26 @@ See [instructions](./docker/README.md) 1. Install [Go environment](https://golang.org/doc/install). Make sure Go version is at least 1.9. Building with Go 1.8 or below **will fail**! -2. Make sure either [RethinkDB](https://www.rethinkdb.com/docs/install/) or MySQL (or MariaDB or Percona) is installed and running. MySQL 5.7 or above is required. MySQL 5.6 or below **will not work**. +2. Make sure either [RethinkDB](https://www.rethinkdb.com/docs/install/) or MySQL (or MariaDB or Percona) is installed and running. MySQL 5.7 or above is required. MySQL 5.6 or below **will not work**. MongoDB (v4.2 and above) also available but it is experimental on not tested in production. 3. Fetch, build Tinode server and tinode-db database initializer: - **RethinkDb**: ``` - go get -tags rethinkdb github.com/tinode/chat/server && go install -tags rethinkdb github.com/tinode/chat/server - go get -tags rethinkdb github.com/tinode/chat/tinode-db && go install -tags rethinkdb github.com/tinode/chat/tinode-db + go get -tags rethinkdb github.com/tinode/chat/server && go install -tags rethinkdb -output $GOPATH/bin/tinode github.com/tinode/chat/server + go get -tags rethinkdb github.com/tinode/chat/tinode-db && go install -tags rethinkdb -output $GOPATH/bin/init-db github.com/tinode/chat/tinode-db ``` - **MySQL**: ``` - go get -tags mysql github.com/tinode/chat/server && go install -tags mysql github.com/tinode/chat/server - go get -tags mysql github.com/tinode/chat/tinode-db && go install -tags mysql github.com/tinode/chat/tinode-db + go get -tags mysql github.com/tinode/chat/server && go install -tags mysql -output $GOPATH/bin/tinode github.com/tinode/chat/server + go get -tags mysql github.com/tinode/chat/tinode-db && go install -tags mysql -output $GOPATH/bin/init-db github.com/tinode/chat/tinode-db + ``` + - **MongoDB**: + ``` + go get -tags mongodb github.com/tinode/chat/server && go install -tags mongodb -output $GOPATH/bin/tinode github.com/tinode/chat/server + go get -tags mongodb github.com/tinode/chat/tinode-db && go install -tags mongodb -output $GOPATH/bin/init-db github.com/tinode/chat/tinode-db ``` - Note the required **`-tags rethinkdb`** or **`-tags mysql`** build option. + Note the required **`-tags rethinkdb`**, **`-tags mysql`** or **`-tags mongodb`** build option. You may also optionally define `main.buildstamp` for the server by adding a build option, for instance, with a timestamp: ``` @@ -59,11 +64,7 @@ See [instructions](./docker/README.md) }, ``` -5. Download javascript client for testing: - - https://github.com/tinode/webapp/archive/master.zip - - https://github.com/tinode/tinode-js/archive/master.zip - -6. Now that you have built the binaries, follow instructions in the _Installing from Binaries_ section for running the binaries except in step 3 the initializer is called `tinode-db` (`tinode-db.exe` on Windows), not `init-db`. +5. Now that you have built the binaries, follow instructions in the _Running a Standalone Server_ section. ## Running a Standalone Server @@ -76,28 +77,34 @@ See [instructions](./docker/README.md) ``` mysql.server start ``` + - **MongoDB**: https://docs.mongodb.com/manual/administration/install-community/ + + MongoDB should run as single node replicaset. See https://docs.mongodb.com/manual/administration/replica-set-deployment/ + ``` + mongod + ``` 2. Run DB initializer ``` - $GOPATH/bin/tinode-db -config=$GOPATH/src/github.com/tinode/chat/tinode-db/tinode.conf + $GOPATH/bin/init-db -config=$GOPATH/src/github.com/tinode/chat/tinode-db/tinode.conf ``` add `-data=$GOPATH/src/github.com/tinode/chat/tinode-db/data.json` flag if you want sample data to be loaded: ``` - $GOPATH/bin/tinode-db -config=$GOPATH/src/github.com/tinode/chat/tinode-db/tinode.conf -data=$GOPATH/src/github.com/tinode/chat/tinode-db/data.json + $GOPATH/bin/init-db -config=$GOPATH/src/github.com/tinode/chat/tinode-db/tinode.conf -data=$GOPATH/src/github.com/tinode/chat/tinode-db/data.json ``` DB intializer needs to be run only once per installation. See [instructions](tinode-db/README.md) for more options. -3. Unpack JS client to a directory, for instance `$HOME/tinode/webapp/` by first unzipping `https://github.com/tinode/webapp/archive/master.zip` then extract `tinode.js` from `https://github.com/tinode/tinode-js/archive/master.zip` to the same directory. +3. Unpack JS client to a directory, for instance `$HOME/tinode/webapp/` by unzipping `https://github.com/tinode/webapp/archive/master.zip` and `https://github.com/tinode/tinode-js/archive/master.zip` to the same directory. -4. Run server +4. Run the server ``` - $GOPATH/bin/server -config=$GOPATH/src/github.com/tinode/chat/server/tinode.conf -static_data=$HOME/tinode/webapp/ + $GOPATH/bin/tinode -config=$GOPATH/src/github.com/tinode/chat/server/tinode.conf -static_data=$HOME/tinode/webapp/ ``` 5. Test your installation by pointing your browser to [http://localhost:6060/](http://localhost:6060/). The static files from the `-static_data` path are served at web root `/`. You can change this by editing the line `static_mount` in the config file. -If you are running Tinode alongside another webserver, such as Apache or nginx, keep in mind that you need to launch the webapp from the URL served by Tinode. Otherwise it won't work. +**Important!** If you are running Tinode alongside another webserver, such as Apache or nginx, keep in mind that you need to launch the webapp from the URL served by Tinode. Otherwise it won't work. ## Running a Cluster @@ -137,8 +144,8 @@ If you are running Tinode alongside another webserver, such as Apache or nginx, If you are testing the cluster with all nodes running on the same host, you also must override the `listen` and `grpc_listen` ports. Here is an example for launching two cluster nodes from the same host using the same config file: ``` -./server -config=./tinode.conf -static_data=./webapp/ -listen=:6060 -grpc_listen=:6080 -cluster_self=one & -./server -config=./tinode.conf -static_data=./webapp/ -listen=:6061 -grpc_listen=:6081 -cluster_self=two & +$GOPATH/bin/tinode -config=./tinode.conf -static_data=./webapp/ -listen=:6060 -grpc_listen=:6080 -cluster_self=one & +$GOPATH/bin/tinode -config=./tinode.conf -static_data=./webapp/ -listen=:6061 -grpc_listen=:6081 -cluster_self=two & ``` A bash script [run-cluster.sh](./server/run-cluster.sh) may be found useful. diff --git a/README.md b/README.md index 056e65c67..d9c287d85 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Tinode Instant Messaging Server - Instant messaging server. Backend in pure [Go](http://golang.org) (license [GPL 3.0](http://www.gnu.org/licenses/gpl-3.0.en.html)), client-side binding in Java, Javascript, and Swift, as well as [gRPC](https://grpc.io/) client support for C++, C#, Go, Java, Node, PHP, Python, Ruby, Objective-C, etc. (license [Apache 2.0](http://www.apache.org/licenses/LICENSE-2.0)). Wire transport is JSON over websocket (long polling is also available) for custom bindings, or [protobuf](https://developers.google.com/protocol-buffers/) with gRPC. Persistent storage [RethinkDB](http://rethinkdb.com/) and MySQL. A third-party [DynamoDB adapter](https://github.com/riandyrn/chat/tree/master/server/db/dynamodb) also exists. Other databases can be supported by writing custom adapters. + Instant messaging server. Backend in pure [Go](http://golang.org) (license [GPL 3.0](http://www.gnu.org/licenses/gpl-3.0.en.html)), client-side binding in Java, Javascript, and Swift, as well as [gRPC](https://grpc.io/) client support for C++, C#, Go, Java, Node, PHP, Python, Ruby, Objective-C, etc. (license [Apache 2.0](http://www.apache.org/licenses/LICENSE-2.0)). Wire transport is JSON over websocket (long polling is also available) for custom bindings, or [protobuf](https://developers.google.com/protocol-buffers/) with gRPC. Persistent storage [RethinkDB](http://rethinkdb.com/), MySQL and MongoDB (experimental). A third-party [DynamoDB adapter](https://github.com/riandyrn/chat/tree/master/server/db/dynamodb) also exists. Other databases can be supported by writing custom adapters. Tinode is *not* XMPP/Jabber. It is *not* compatible with XMPP. It's meant as a replacement for XMPP. On the surface, it's a lot like open source WhatsApp or Telegram. Version 0.16. This is beta-quality software: feature-complete but probably with a few bugs. Follow [instructions](INSTALL.md) to install and run or use one of the cloud services below. Read [API documentation](docs/API.md). - + ## Why? @@ -32,7 +32,7 @@ TinodeWeb, a single page web app, is available at https://web.tinode.co/ ([sourc ### Android -[Tindroid](https://github.com/tinode/tindroid) is stable and functional. See the screenshots below. A [debug APK](https://github.com/tinode/tindroid/releases/latest) is provided for convenience. Currently available in English, Russian. More translations are welcome. +[Tinode for Android](https://play.google.com/store/apps/details?id=co.tinode.tindroidx) a.k.a Tindroid is stable and functional ([source](https://github.com/tinode/tindroid)). See the screenshots below. A [debug APK](https://github.com/tinode/tindroid/releases/latest) is also provided for convenience. Currently available in English, Russian. More translations are welcome. ### iOS @@ -40,11 +40,6 @@ TinodeWeb, a single page web app, is available at https://web.tinode.co/ ([sourc [Tinode for iOS](https://apps.apple.com/app/reference-to-tinodios-here/id123) a.k.a. Tinodios is stable and functional ([source](https://github.com/tinode/ios)). See the screenshots below. Currently available in English, Simplified Chinese. More translations are welcome. -### Android - -[Tinode for Android](https://play.google.com/store/apps/details?id=co.tinode.tindroid) a.k.a. Tindroid is stable and functional ([source](https://github.com/tinode/tindroid)). See the screenshots below. - - ## Demo/Sandbox A sandboxed demo service is available at https://sandbox.tinode.co/. @@ -88,9 +83,9 @@ If you register a new account you are asked for an email address to send validat * Support for client-side data caching. * Ability to block unwanted communication server-side. * Anonymous users (important for use cases related to tech support over chat). -* Android and [web](https://caniuse.com/#feat=push-api) push notifications using [FCM](https://firebase.google.com/docs/cloud-messaging/). -* Storage and out of band transfer of large objects like video files using a local file system or Amazon S3. -* Plugins to extend functionality like enabling chatbots. +* Push notifications using [FCM](https://firebase.google.com/docs/cloud-messaging/). +* Storage and out of band transfer of large objects like video files using local file system or Amazon S3. +* Plugins to extend functionality, for example to enable chatbots. ### Planned diff --git a/build-all.sh b/build-all.sh index d65b72fa6..a4a4194b9 100755 --- a/build-all.sh +++ b/build-all.sh @@ -8,7 +8,7 @@ goplat=( darwin windows linux ) # Supported CPU architectures: amd64 goarc=( amd64 ) # Supported database tags -dbtags=( mysql rethinkdb ) +dbtags=( mysql mongodb rethinkdb ) for line in $@; do eval "$line" diff --git a/chatbot/python/README.md b/chatbot/python/README.md index 6043741ca..480f7ea7f 100644 --- a/chatbot/python/README.md +++ b/chatbot/python/README.md @@ -97,6 +97,10 @@ Quotes are read from `quotes.txt` by default. The file is plain text with one qu ``` $ docker run -p 6060:18080 -d --name tinode-srv --env PLUGIN_PYTHON_CHAT_BOT_ENABLED=true --volume botdata:/botdata --network tinode-net tinode/tinode-mysql:latest ``` + 3. **MongoDB**: + ``` + $ docker run -p 6060:18080 -d --name tinode-srv --env PLUGIN_PYTHON_CHAT_BOT_ENABLED=true --volume botdata:/botdata --network tinode-net tinode/tinode-mongodb:latest + ``` 3. Run the chatbot ``` @@ -109,6 +113,7 @@ Quotes are read from `quotes.txt` by default. The file is plain text with one qu You may replace the `:latest` with a different tag. See all available tags here: * [Tinode-MySQL tags](https://hub.docker.com/r/tinode/tinode-mysql/tags/) * [Tinode-RethinkDB tags](https://hub.docker.com/r/tinode/tinode-rethink/tags/) + * [Tinode-MongoDB tags](https://hub.docker.com/r/tinode/tinode-mongodb/tags/) * [Chatbot tags](https://hub.docker.com/r/tinode/chatbot/tags/) In general try to use docker images all with the same tag. diff --git a/docker-build.sh b/docker-build.sh index b4be55dfd..f855e39aa 100755 --- a/docker-build.sh +++ b/docker-build.sh @@ -21,7 +21,7 @@ if [[ ${ver[2]} != *"-"* ]]; then FULLRELEASE=1 fi -dbtags=( mysql rethinkdb ) +dbtags=( mysql mongodb rethinkdb ) # Build an images for various DB backends for dbtag in "${dbtags[@]}" diff --git a/docker-release.sh b/docker-release.sh index 3b8bdca97..99da8148d 100755 --- a/docker-release.sh +++ b/docker-release.sh @@ -20,7 +20,7 @@ if [[ ${ver[2]} != *"-"* ]]; then FULLRELEASE=1 fi -dbtags=( mysql rethinkdb ) +dbtags=( mysql mongodb rethinkdb ) # Read dockerhub login/password from a separate file source .dockerhub diff --git a/docker/README.md b/docker/README.md index e98aa2b4b..7222efe95 100644 --- a/docker/README.md +++ b/docker/README.md @@ -13,17 +13,28 @@ All images are available at https://hub.docker.com/r/tinode/ 1. **RethinkDB**: If you've decided to use RethinkDB backend, run the official RethinkDB Docker container: ``` - $ docker run --name rethinkdb --network tinode-net -d rethinkdb:2.3 + $ docker run --name rethinkdb --network tinode-net --restart always -d rethinkdb:2.3 ``` See [instructions](https://hub.docker.com/_/rethinkdb/) for more options. 2. **MySQL**: If you've decided to use MySQL backend, run the official MySQL Docker container: ``` - $ docker run --name mysql --network tinode-net --env MYSQL_ALLOW_EMPTY_PASSWORD=yes -d mysql:5.7 + $ docker run --name mysql --network tinode-net --restart always --env MYSQL_ALLOW_EMPTY_PASSWORD=yes -d mysql:5.7 ``` See [instructions](https://hub.docker.com/_/mysql/) for more options. MySQL 5.7 or above is required. - The name `rethinkdb` or `mysql` in the `--name` assignment is important. It's used by other containers as a database's host name. + 3. **MongoDB**: If you've decided to use MongoDB backend, run the official MongoDB Docker container and initialise it as single node replica set (you can change "rs0" if you wish): + ``` + $ docker run --name mongodb --network tinode-net --restart always -d mongo:latest --replSet "rs0" + $ docker exec -it mongodb mongo + + # And inside mongo shell: + > rs.initiate( {"_id": "rs0", "members": [ {"_id": 0, "host": "mongodb:27017"} ]} ) + > quit() + ``` + See [instructions](https://hub.docker.com/_/mongo/) for more options. MongoDB 4.2 or above is required. + + The name `rethinkdb`, `mysql` or `mongodb` in the `--name` assignment is important. It's used by other containers as a database's host name. 4. Run the Tinode container for the appropriate database: @@ -37,6 +48,11 @@ All images are available at https://hub.docker.com/r/tinode/ $ docker run -p 6060:18080 -d --name tinode-srv --network tinode-net tinode/tinode-mysql:latest ``` + 3. **MongoDB**: + ``` + $ docker run -p 6060:18080 -d --name tinode-srv --network tinode-net tinode/tinode-mongodb:latest + ``` + See [below](#supported-environment-variables) for more options. The port mapping `-p 6060:18080` tells Docker to map container's port 18080 to host's port 6060 making server accessible at http://localhost:6060/. The container will initialize the database with test data on the first run. @@ -44,6 +60,7 @@ All images are available at https://hub.docker.com/r/tinode/ You may replace `:latest` with a different tag. See all all available tags here: * [MySQL tags](https://hub.docker.com/r/tinode/tinode-mysql/tags/) * [RethinkDB tags](https://hub.docker.com/r/tinode/tinode-rethink/tags/) + * [MongoDB tags](https://hub.docker.com/r/tinode/tinode-mongodb/tags/) (comming soon) 5. Test the installation by pointing your browser to [http://localhost:6060/](http://localhost:6060/). @@ -75,13 +92,23 @@ Also, the database is automatically created if missing. ### Enable push notifications -Download and save the file with the [FCM service account credentials](https://cloud.google.com/docs/authentication/production). -Assuming your Firebase credentials file is named `myproject-1234-firebase-adminsdk-abc12-abcdef012345.json` and it's saved at `/Users/jdoe/`, your Sender ID is `141421356237`, and VAPID key (a.k.a. "Web Push certificates") is `83_Or_So_Random_Looking_Characters`, start the container with the following parameters (using MySQL container as an example): +Tinode uses Google Firebase Cloud Messaging (FCM) to send pushes. +Follow [instructions](../docs/faq.md#q-how-to-setup-fcm-push-notifications) for obtaining the required FCM credentials. + +* Download and save the [FCM service account credentials](https://cloud.google.com/docs/authentication/production) file. +* Obtain values for `apiKey`, `messagingSenderId`, `projectId`, `appId`, `messagingVapidKey`. +Assuming your Firebase credentials file is named `myproject-1234-firebase-adminsdk-abc12-abcdef012345.json` +and it's saved at `/Users/jdoe/`, web API key is `AIRaNdOmX4ULR-X6ranDomzZ2bHdRanDomq2tbQ`, Sender ID `141421356237`, +Project ID `myproject-1234`, App ID `1:141421356237:web:abc7de1234fab56cd78abc`, VAPID key (a.k.a. "Web Push certificates") +is `83_Or_So_Random_Looking_Characters`, start the container with the following parameters (using MySQL container as an example): ``` $ docker run -p 6060:18080 -d --name tinode-srv --network tinode-net \ -v /Users/jdoe:/fcm \ --env FCM_CRED_FILE=/fcm/myproject-1234-firebase-adminsdk-abc12-abcdef012345.json \ + --env FCM_API_KEY=AIRaNdOmX4ULR-X6ranDomzZ2bHdRanDomq2tbQ \ + --env FCM_APP_ID=1:141421356237:web:abc7de1234fab56cd78abc \ + --env FCM_PROJECT_ID=myproject-1234 \ --env FCM_SENDER_ID=141421356237 \ --env FCM_VAPID_KEY=83_Or_So_Random_Looking_Characters \ tinode/tinode-mysql:latest @@ -111,8 +138,11 @@ You can specify the following environment variables when issuing `docker run` co | `EXT_CONFIG` | string | | Path to external config file to use instead of the built-in one. If this parameter is used all other variables except `RESET_DB`, `FCM_SENDER_ID`, `FCM_VAPID_KEY` are ignored. | | `EXT_STATIC_DIR` | string | | Path to external directory containing static data (e.g. Tinode Webapp files) | | `FCM_CRED_FILE` | string | | Path to json file with FCM server-side service account credentials which will be used to send push notifications. | -| `FCM_SENDER_ID` | string | | FCM sender ID for receiving push notifications in the web client | -| `FCM_VAPID_KEY` | string | | Also called 'Web Client certificate' in the FCM console. Required by the web client to receive push notifications. | +| `FCM_API_KEY` | string | | Firebase API key; required for receiving push notifications in the web client | +| `FCM_APP_ID` | string | | Firebase web app ID; required for receiving push notifications in the web client | +| `FCM_PROJECT_ID` | string | | Firebase project ID; required for receiving push notifications in the web client | +| `FCM_SENDER_ID` | string | | Firebase FCM sender ID; required for receiving push notifications in the web client | +| `FCM_VAPID_KEY` | string | | Also called 'Web Client certificate' in the FCM console; required by the web client to receive push notifications. | | `FCM_INCLUDE_ANDROID_NOTIFICATION` | boolean | true | If true, pushes a data + notification message, otherwise a data-only message. [More info](https://firebase.google.com/docs/cloud-messaging/concept-options). | | `MEDIA_HANDLER` | string | `fs` | Handler of large files, either `fs` or `s3` | | `MYSQL_DSN` | string | `'root@tcp(mysql)/tinode'` | MySQL [DSN](https://github.com/go-sql-driver/mysql#dsn-data-source-name). | diff --git a/docker/tinode/Dockerfile b/docker/tinode/Dockerfile index 987549486..ea54699ff 100644 --- a/docker/tinode/Dockerfile +++ b/docker/tinode/Dockerfile @@ -11,7 +11,7 @@ FROM alpine:latest -ARG VERSION=0.16.2 +ARG VERSION=0.16.3 ENV VERSION=$VERSION LABEL maintainer="Tinode Team " @@ -21,7 +21,9 @@ LABEL version=$VERSION # Build-time options. # Database selector. Builds for RethinkDB by default. -# Alternatively use `--build-arg TARGET_DB=mysql` to build for MySQL. +# Alternatively use +# `--build-arg TARGET_DB=mysql` to build for MySQL or +# `--build-arg TARGET_DB=mongodb` to build for MongoDB. ARG TARGET_DB=rethinkdb ENV TARGET_DB=$TARGET_DB diff --git a/docker/tinode/config.template b/docker/tinode/config.template index 9e2a80d4b..9228bf97c 100644 --- a/docker/tinode/config.template +++ b/docker/tinode/config.template @@ -1,5 +1,6 @@ { "listen": ":18080", + "api_path": "/", "cache_control": 39600, "static_mount": "/", "grpc_listen": ":16061", @@ -63,6 +64,11 @@ "rethinkdb": { "database": "tinode", "addresses": "rethinkdb" + }, + "mongodb": { + "database": "tinode", + "addresses": "mongodb", + "replica_set": "rs0" } } }, diff --git a/docker/tinode/entrypoint.sh b/docker/tinode/entrypoint.sh index 6e46c6f9c..c6b244bf0 100644 --- a/docker/tinode/entrypoint.sh +++ b/docker/tinode/entrypoint.sh @@ -57,7 +57,15 @@ fi # If push notifications are enabled, generate client-side firebase config file. if [ ! -z "$FCM_PUSH_ENABLED" ] ; then # Write client config to $STATIC_DIR/firebase-init.js - echo "const FIREBASE_INIT={messagingSenderId: \"$FCM_SENDER_ID\", messagingVapidKey: \"$FCM_VAPID_KEY\"};"$'\n' > $STATIC_DIR/firebase-init.js + cat > $STATIC_DIR/firebase-init.js <<- EOM +const FIREBASE_INIT = { + apiKey: "$FCM_API_KEY", + appId: "$FCM_APP_ID", + messagingSenderId: "$FCM_SENDER_ID", + projectId: "$FCM_PROJECT_ID", + messagingVapidKey: "$FCM_VAPID_KEY" +}; +EOM else # Create an empty firebase-init.js echo "" > $STATIC_DIR/firebase-init.js diff --git a/docs/API.md b/docs/API.md index db1794e01..12f3b36a0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -686,8 +686,9 @@ The `{sub}` message may include a `get` and `set` fields which mirror `{get}` an ```js sub: { id: "1a2b3", // string, client-provided message id, optional - topic: "me", // topic to be subscribed or attached to - + topic: "me", // topic to be subscribed or attached to + bkg: true, // request to attach to topic is issued by an automated agent, server should delay sending + // presence notifications because the agent is expected to disconnect very quickly // Object with topic initialisation data, new topics & new // subscriptions only, mirrors {set} message set: { @@ -873,6 +874,8 @@ get: { Query topic description. Server responds with a `{meta}` message containing requested data. See `{meta}` for details. If `ims` is specified and data has not been updated, the message will skip `public` and `private` fields. +See [Public and Private Fields](#public-and-private-fields) for `private` and `public` format considerations. + * `{get what="sub"}` Get a list of subscribers. Server responds with a `{meta}` message containing a list of subscribers. See `{meta}` for details. @@ -893,8 +896,6 @@ The `id` field of the data messages is not provided as it's common for data mess Query message deletion history. Server responds with a `{meta}` message containing a list of deleted message ranges. -See [Public and Private Fields](#public-and-private-fields) for `private` and `public` format considerations. - * `{get what="cred"}` Query [credentials](#credentail-validation). Server responds with a `{meta}` message containing an array of credentials. Supported for `me` topic only. diff --git a/docs/app-store.svg b/docs/app-store.svg index 47118fbaf..dd3811e3e 100644 --- a/docs/app-store.svg +++ b/docs/app-store.svg @@ -1,102 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/docs/drafty.md b/docs/drafty.md index 25aedbc6c..704eedfba 100644 --- a/docs/drafty.md +++ b/docs/drafty.md @@ -1,6 +1,6 @@ # Drafty: Rich Message Format -Drafty is a text format used by Tinode to style messages. The intent of Drafty is to be expressive just enough without opening too many possibilities for security issues. Drafty is influenced by FB's [draft.js](https://draftjs.org/) specification. As of the time of this writing [Javascript](/tinode/example-react-js/blob/master/drafty.js) and [Java](/tinode/android-example/blob/master/tinodesdk/src/main/java/co/tinode/tinodesdk/model/Drafty.java) implementations exist. +Drafty is a text format used by Tinode to style messages. The intent of Drafty is to be expressive just enough without opening too many possibilities for security issues. One may think of it as JSON-encapsulated [markdown](https://en.wikipedia.org/wiki/Markdown). Drafty is influenced by FB's [draft.js](https://draftjs.org/) specification. As of the time of this writing [Javascript](https://github.com/tinode/tinode-js/blob/master/src/drafty.js), [Java](https://github.com/tinode/tindroid/blob/master/tinodesdk/src/main/java/co/tinode/tinodesdk/model/Drafty.java) and [Swift](https://github.com/tinode/ios/blob/master/TinodeSDK/model/Drafty.swift) implementations exist. A [Go implementation](https://github.com/tinode/chat/blob/master/server/drafty/drafty.go) can convert Drafy to plain text. ## Example diff --git a/docs/faq.md b/docs/faq.md index 84fff7abd..e3e13cb3c 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -15,22 +15,25 @@ docker cp name-of-the-container:/var/log/tinode.log ./tinode.log Alternatively, you can instruct the docker container to save the logs to a directory on the host by mapping a host directory to `/var/log/` in the container. Add `-v /where/to/save/logs:/var/log` to the `docker run` command. ### Q: How to setup FCM push notifications?
-**A**: If you running the server directly: +**A**: Enabling push notifications requires two steps: + * enable push sending from the server + * enable receiving pushes in the clients + +#### Server and TinodeWeb + 1. Create a project at https://firebase.google.com/ if you have not done so already. 2. Follow instructions at https://cloud.google.com/iam/docs/creating-managing-service-account-keys to download the credentials file. 3. Update the server config [`tinode.conf`](../server/tinode.conf#L255), section `"push"` -> `"name": "fcm"`. Do _ONE_ of the following: * _Either_ enter the path to the downloaded credentials file into `"credentials_file"`. * _OR_ copy the file contents to `"credentials"`.

Remove the other entry. I.e. if you have updated `"credentials_file"`, remove `"credentials"` and vice versa. -4. Update [TinodeWeb](/tinode/webapp/) config [`firebase-init.js`](https://github.com/tinode/webapp/blob/master/firebase-init.js): update `messagingSenderId` and `messagingVapidKey`. See more info at https://github.com/tinode/webapp/#push_notifications -5. 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. See more info at https://github.com/tinode/tindroid/#push_notifications -6. 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. See more info at https://github.com/tinode/ios/#push_notifications +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 -If you are using the [Docker image](https://hub.docker.com/u/tinode): -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. Follow instructions in the Docker [README](../docker#enable-push-notifications) to enable push notifications for the `TinodeWeb`. -4. Add `google-services.json` to [Tindroid](/tinode/tindroid/#push_notifications), `GoogleService-Info.plist` to [Tinodios](/tinode/ios/#push_notifications), recompile the apps. +#### 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. +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. +See more info at https://github.com/tinode/ios/#push_notifications ### Q: How can new users be added to Tinode?
diff --git a/pbx/model.pb.go b/pbx/model.pb.go index f632be1e2..26898eb31 100644 --- a/pbx/model.pb.go +++ b/pbx/model.pb.go @@ -50,7 +50,7 @@ func (x AuthLevel) String() string { return proto.EnumName(AuthLevel_name, int32(x)) } func (AuthLevel) EnumDescriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{0} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{0} } type InfoNote int32 @@ -76,7 +76,7 @@ func (x InfoNote) String() string { return proto.EnumName(InfoNote_name, int32(x)) } func (InfoNote) EnumDescriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{1} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{1} } // Plugin response codes @@ -111,7 +111,7 @@ func (x RespCode) String() string { return proto.EnumName(RespCode_name, int32(x)) } func (RespCode) EnumDescriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{2} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{2} } type Crud int32 @@ -137,7 +137,7 @@ func (x Crud) String() string { return proto.EnumName(Crud_name, int32(x)) } func (Crud) EnumDescriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{3} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{3} } // What to delete, either "msg" to delete messages (default) or "topic" to delete the topic or "sub" @@ -171,7 +171,7 @@ func (x ClientDel_What) String() string { return proto.EnumName(ClientDel_What_name, int32(x)) } func (ClientDel_What) EnumDescriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{18, 0} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{18, 0} } type ServerPres_What int32 @@ -224,7 +224,7 @@ func (x ServerPres_What) String() string { return proto.EnumName(ServerPres_What_name, int32(x)) } func (ServerPres_What) EnumDescriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{27, 0} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{27, 0} } // Dummy placeholder message. @@ -238,7 +238,7 @@ func (m *Unused) Reset() { *m = Unused{} } func (m *Unused) String() string { return proto.CompactTextString(m) } func (*Unused) ProtoMessage() {} func (*Unused) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{0} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{0} } func (m *Unused) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_Unused.Unmarshal(m, b) @@ -271,7 +271,7 @@ func (m *DefaultAcsMode) Reset() { *m = DefaultAcsMode{} } func (m *DefaultAcsMode) String() string { return proto.CompactTextString(m) } func (*DefaultAcsMode) ProtoMessage() {} func (*DefaultAcsMode) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{1} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{1} } func (m *DefaultAcsMode) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_DefaultAcsMode.Unmarshal(m, b) @@ -320,7 +320,7 @@ func (m *AccessMode) Reset() { *m = AccessMode{} } func (m *AccessMode) String() string { return proto.CompactTextString(m) } func (*AccessMode) ProtoMessage() {} func (*AccessMode) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{2} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{2} } func (m *AccessMode) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_AccessMode.Unmarshal(m, b) @@ -369,7 +369,7 @@ func (m *SetSub) Reset() { *m = SetSub{} } func (m *SetSub) String() string { return proto.CompactTextString(m) } func (*SetSub) ProtoMessage() {} func (*SetSub) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{3} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{3} } func (m *SetSub) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_SetSub.Unmarshal(m, b) @@ -422,7 +422,7 @@ func (m *ClientCred) Reset() { *m = ClientCred{} } func (m *ClientCred) String() string { return proto.CompactTextString(m) } func (*ClientCred) ProtoMessage() {} func (*ClientCred) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{4} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{4} } func (m *ClientCred) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_ClientCred.Unmarshal(m, b) @@ -484,7 +484,7 @@ func (m *SetDesc) Reset() { *m = SetDesc{} } func (m *SetDesc) String() string { return proto.CompactTextString(m) } func (*SetDesc) ProtoMessage() {} func (*SetDesc) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{5} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{5} } func (m *SetDesc) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_SetDesc.Unmarshal(m, b) @@ -547,7 +547,7 @@ func (m *GetOpts) Reset() { *m = GetOpts{} } func (m *GetOpts) String() string { return proto.CompactTextString(m) } func (*GetOpts) ProtoMessage() {} func (*GetOpts) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{6} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{6} } func (m *GetOpts) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_GetOpts.Unmarshal(m, b) @@ -626,7 +626,7 @@ func (m *GetQuery) Reset() { *m = GetQuery{} } func (m *GetQuery) String() string { return proto.CompactTextString(m) } func (*GetQuery) ProtoMessage() {} func (*GetQuery) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{7} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{7} } func (m *GetQuery) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_GetQuery.Unmarshal(m, b) @@ -692,7 +692,7 @@ func (m *SetQuery) Reset() { *m = SetQuery{} } func (m *SetQuery) String() string { return proto.CompactTextString(m) } func (*SetQuery) ProtoMessage() {} func (*SetQuery) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{8} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{8} } func (m *SetQuery) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_SetQuery.Unmarshal(m, b) @@ -752,7 +752,7 @@ func (m *SeqRange) Reset() { *m = SeqRange{} } func (m *SeqRange) String() string { return proto.CompactTextString(m) } func (*SeqRange) ProtoMessage() {} func (*SeqRange) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{9} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{9} } func (m *SeqRange) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_SeqRange.Unmarshal(m, b) @@ -803,7 +803,7 @@ func (m *ClientHi) Reset() { *m = ClientHi{} } func (m *ClientHi) String() string { return proto.CompactTextString(m) } func (*ClientHi) ProtoMessage() {} func (*ClientHi) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{10} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{10} } func (m *ClientHi) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_ClientHi.Unmarshal(m, b) @@ -893,7 +893,7 @@ func (m *ClientAcc) Reset() { *m = ClientAcc{} } func (m *ClientAcc) String() string { return proto.CompactTextString(m) } func (*ClientAcc) ProtoMessage() {} func (*ClientAcc) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{11} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{11} } func (m *ClientAcc) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_ClientAcc.Unmarshal(m, b) @@ -994,7 +994,7 @@ func (m *ClientLogin) Reset() { *m = ClientLogin{} } func (m *ClientLogin) String() string { return proto.CompactTextString(m) } func (*ClientLogin) ProtoMessage() {} func (*ClientLogin) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{12} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{12} } func (m *ClientLogin) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_ClientLogin.Unmarshal(m, b) @@ -1050,6 +1050,7 @@ type ClientSub struct { SetQuery *SetQuery `protobuf:"bytes,3,opt,name=set_query,json=setQuery" json:"set_query,omitempty"` // mirrors {get} GetQuery *GetQuery `protobuf:"bytes,4,opt,name=get_query,json=getQuery" json:"get_query,omitempty"` + Background bool `protobuf:"varint,5,opt,name=background" json:"background,omitempty"` XXX_NoUnkeyedLiteral struct{} `json:"-"` XXX_unrecognized []byte `json:"-"` XXX_sizecache int32 `json:"-"` @@ -1059,7 +1060,7 @@ func (m *ClientSub) Reset() { *m = ClientSub{} } func (m *ClientSub) String() string { return proto.CompactTextString(m) } func (*ClientSub) ProtoMessage() {} func (*ClientSub) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{13} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{13} } func (m *ClientSub) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_ClientSub.Unmarshal(m, b) @@ -1107,6 +1108,13 @@ func (m *ClientSub) GetGetQuery() *GetQuery { return nil } +func (m *ClientSub) GetBackground() bool { + if m != nil { + return m.Background + } + return false +} + // Unsubscribe {leave} request message type ClientLeave struct { Id string `protobuf:"bytes,1,opt,name=id" json:"id,omitempty"` @@ -1121,7 +1129,7 @@ func (m *ClientLeave) Reset() { *m = ClientLeave{} } func (m *ClientLeave) String() string { return proto.CompactTextString(m) } func (*ClientLeave) ProtoMessage() {} func (*ClientLeave) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{14} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{14} } func (m *ClientLeave) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_ClientLeave.Unmarshal(m, b) @@ -1178,7 +1186,7 @@ func (m *ClientPub) Reset() { *m = ClientPub{} } func (m *ClientPub) String() string { return proto.CompactTextString(m) } func (*ClientPub) ProtoMessage() {} func (*ClientPub) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{15} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{15} } func (m *ClientPub) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_ClientPub.Unmarshal(m, b) @@ -1247,7 +1255,7 @@ func (m *ClientGet) Reset() { *m = ClientGet{} } func (m *ClientGet) String() string { return proto.CompactTextString(m) } func (*ClientGet) ProtoMessage() {} func (*ClientGet) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{16} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{16} } func (m *ClientGet) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_ClientGet.Unmarshal(m, b) @@ -1302,7 +1310,7 @@ func (m *ClientSet) Reset() { *m = ClientSet{} } func (m *ClientSet) String() string { return proto.CompactTextString(m) } func (*ClientSet) ProtoMessage() {} func (*ClientSet) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{17} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{17} } func (m *ClientSet) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_ClientSet.Unmarshal(m, b) @@ -1365,7 +1373,7 @@ func (m *ClientDel) Reset() { *m = ClientDel{} } func (m *ClientDel) String() string { return proto.CompactTextString(m) } func (*ClientDel) ProtoMessage() {} func (*ClientDel) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{18} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{18} } func (m *ClientDel) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_ClientDel.Unmarshal(m, b) @@ -1450,7 +1458,7 @@ func (m *ClientNote) Reset() { *m = ClientNote{} } func (m *ClientNote) String() string { return proto.CompactTextString(m) } func (*ClientNote) ProtoMessage() {} func (*ClientNote) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{19} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{19} } func (m *ClientNote) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_ClientNote.Unmarshal(m, b) @@ -1516,7 +1524,7 @@ func (m *ClientMsg) Reset() { *m = ClientMsg{} } func (m *ClientMsg) String() string { return proto.CompactTextString(m) } func (*ClientMsg) ProtoMessage() {} func (*ClientMsg) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{20} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{20} } func (m *ClientMsg) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_ClientMsg.Unmarshal(m, b) @@ -1916,7 +1924,7 @@ func (m *ServerCred) Reset() { *m = ServerCred{} } func (m *ServerCred) String() string { return proto.CompactTextString(m) } func (*ServerCred) ProtoMessage() {} func (*ServerCred) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{21} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{21} } func (m *ServerCred) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_ServerCred.Unmarshal(m, b) @@ -1979,7 +1987,7 @@ func (m *TopicDesc) Reset() { *m = TopicDesc{} } func (m *TopicDesc) String() string { return proto.CompactTextString(m) } func (*TopicDesc) ProtoMessage() {} func (*TopicDesc) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{22} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{22} } func (m *TopicDesc) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_TopicDesc.Unmarshal(m, b) @@ -2107,7 +2115,7 @@ func (m *TopicSub) Reset() { *m = TopicSub{} } func (m *TopicSub) String() string { return proto.CompactTextString(m) } func (*TopicSub) ProtoMessage() {} func (*TopicSub) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{23} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{23} } func (m *TopicSub) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_TopicSub.Unmarshal(m, b) @@ -2244,7 +2252,7 @@ func (m *DelValues) Reset() { *m = DelValues{} } func (m *DelValues) String() string { return proto.CompactTextString(m) } func (*DelValues) ProtoMessage() {} func (*DelValues) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{24} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{24} } func (m *DelValues) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_DelValues.Unmarshal(m, b) @@ -2294,7 +2302,7 @@ func (m *ServerCtrl) Reset() { *m = ServerCtrl{} } func (m *ServerCtrl) String() string { return proto.CompactTextString(m) } func (*ServerCtrl) ProtoMessage() {} func (*ServerCtrl) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{25} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{25} } func (m *ServerCtrl) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_ServerCtrl.Unmarshal(m, b) @@ -2370,7 +2378,7 @@ func (m *ServerData) Reset() { *m = ServerData{} } func (m *ServerData) String() string { return proto.CompactTextString(m) } func (*ServerData) ProtoMessage() {} func (*ServerData) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{26} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{26} } func (m *ServerData) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_ServerData.Unmarshal(m, b) @@ -2460,7 +2468,7 @@ func (m *ServerPres) Reset() { *m = ServerPres{} } func (m *ServerPres) String() string { return proto.CompactTextString(m) } func (*ServerPres) ProtoMessage() {} func (*ServerPres) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{27} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{27} } func (m *ServerPres) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_ServerPres.Unmarshal(m, b) @@ -2568,7 +2576,7 @@ func (m *ServerMeta) Reset() { *m = ServerMeta{} } func (m *ServerMeta) String() string { return proto.CompactTextString(m) } func (*ServerMeta) ProtoMessage() {} func (*ServerMeta) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{28} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{28} } func (m *ServerMeta) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_ServerMeta.Unmarshal(m, b) @@ -2652,7 +2660,7 @@ func (m *ServerInfo) Reset() { *m = ServerInfo{} } func (m *ServerInfo) String() string { return proto.CompactTextString(m) } func (*ServerInfo) ProtoMessage() {} func (*ServerInfo) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{29} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{29} } func (m *ServerInfo) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_ServerInfo.Unmarshal(m, b) @@ -2720,7 +2728,7 @@ func (m *ServerMsg) Reset() { *m = ServerMsg{} } func (m *ServerMsg) String() string { return proto.CompactTextString(m) } func (*ServerMsg) ProtoMessage() {} func (*ServerMsg) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{30} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{30} } func (m *ServerMsg) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_ServerMsg.Unmarshal(m, b) @@ -2959,7 +2967,7 @@ func (m *ServerResp) Reset() { *m = ServerResp{} } func (m *ServerResp) String() string { return proto.CompactTextString(m) } func (*ServerResp) ProtoMessage() {} func (*ServerResp) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{31} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{31} } func (m *ServerResp) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_ServerResp.Unmarshal(m, b) @@ -3018,7 +3026,7 @@ func (m *Session) Reset() { *m = Session{} } func (m *Session) String() string { return proto.CompactTextString(m) } func (*Session) ProtoMessage() {} func (*Session) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{32} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{32} } func (m *Session) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_Session.Unmarshal(m, b) @@ -3099,7 +3107,7 @@ func (m *ClientReq) Reset() { *m = ClientReq{} } func (m *ClientReq) String() string { return proto.CompactTextString(m) } func (*ClientReq) ProtoMessage() {} func (*ClientReq) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{33} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{33} } func (m *ClientReq) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_ClientReq.Unmarshal(m, b) @@ -3145,7 +3153,7 @@ func (m *SearchQuery) Reset() { *m = SearchQuery{} } func (m *SearchQuery) String() string { return proto.CompactTextString(m) } func (*SearchQuery) ProtoMessage() {} func (*SearchQuery) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{34} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{34} } func (m *SearchQuery) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_SearchQuery.Unmarshal(m, b) @@ -3194,7 +3202,7 @@ func (m *SearchFound) Reset() { *m = SearchFound{} } func (m *SearchFound) String() string { return proto.CompactTextString(m) } func (*SearchFound) ProtoMessage() {} func (*SearchFound) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{35} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{35} } func (m *SearchFound) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_SearchFound.Unmarshal(m, b) @@ -3248,7 +3256,7 @@ func (m *TopicEvent) Reset() { *m = TopicEvent{} } func (m *TopicEvent) String() string { return proto.CompactTextString(m) } func (*TopicEvent) ProtoMessage() {} func (*TopicEvent) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{36} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{36} } func (m *TopicEvent) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_TopicEvent.Unmarshal(m, b) @@ -3305,7 +3313,7 @@ func (m *AccountEvent) Reset() { *m = AccountEvent{} } func (m *AccountEvent) String() string { return proto.CompactTextString(m) } func (*AccountEvent) ProtoMessage() {} func (*AccountEvent) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{37} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{37} } func (m *AccountEvent) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_AccountEvent.Unmarshal(m, b) @@ -3378,7 +3386,7 @@ func (m *SubscriptionEvent) Reset() { *m = SubscriptionEvent{} } func (m *SubscriptionEvent) String() string { return proto.CompactTextString(m) } func (*SubscriptionEvent) ProtoMessage() {} func (*SubscriptionEvent) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{38} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{38} } func (m *SubscriptionEvent) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_SubscriptionEvent.Unmarshal(m, b) @@ -3466,7 +3474,7 @@ func (m *MessageEvent) Reset() { *m = MessageEvent{} } func (m *MessageEvent) String() string { return proto.CompactTextString(m) } func (*MessageEvent) ProtoMessage() {} func (*MessageEvent) Descriptor() ([]byte, []int) { - return fileDescriptor_model_e206556c072fd950, []int{39} + return fileDescriptor_model_9dca0b1e9e29c29a, []int{39} } func (m *MessageEvent) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_MessageEvent.Unmarshal(m, b) @@ -3909,169 +3917,170 @@ var _Plugin_serviceDesc = grpc.ServiceDesc{ Metadata: "model.proto", } -func init() { proto.RegisterFile("model.proto", fileDescriptor_model_e206556c072fd950) } +func init() { proto.RegisterFile("model.proto", fileDescriptor_model_9dca0b1e9e29c29a) } -var fileDescriptor_model_e206556c072fd950 = []byte{ - // 2566 bytes of a gzipped FileDescriptorProto +var fileDescriptor_model_9dca0b1e9e29c29a = []byte{ + // 2580 bytes of a gzipped FileDescriptorProto 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xac, 0x19, 0x4d, 0x93, 0xe3, 0x46, 0x75, 0x64, 0xc9, 0xb2, 0xf4, 0xec, 0xcc, 0x6a, 0x9b, 0x25, 0x71, 0x26, 0x24, 0x99, 0x55, 0xbe, 0xb6, 0x26, 0xc9, 0x42, 0xed, 0x12, 0x12, 0x20, 0x17, 0x67, 0xec, 0x9d, 0x19, 0xd8, 0x19, 0x0f, 0xf2, 0x4c, 0xb8, 0x50, 0xe5, 0x92, 0xa5, 0xb6, 0xad, 0x8a, 0x2c, 0x79, 0xa4, 0x96, 0x93, 0x5c, - 0xa8, 0x82, 0xe2, 0xc2, 0x81, 0x3b, 0xc5, 0x85, 0x3f, 0x00, 0x67, 0xaa, 0xf8, 0x09, 0xe1, 0xc6, - 0x95, 0x5f, 0x40, 0x51, 0x14, 0x7f, 0x81, 0x7a, 0xfd, 0x21, 0x4b, 0x9e, 0xf1, 0x66, 0x36, 0x70, - 0xeb, 0x7e, 0xef, 0xf5, 0xeb, 0xf7, 0xfd, 0x9e, 0x5a, 0xd0, 0x5e, 0xa4, 0x21, 0x8d, 0x1f, 0x2e, - 0xb3, 0x94, 0xa5, 0x44, 0x5f, 0x4e, 0xbe, 0x70, 0x2d, 0x30, 0x2f, 0x93, 0x22, 0xa7, 0xa1, 0xfb, - 0x11, 0xec, 0xf6, 0xe9, 0xd4, 0x2f, 0x62, 0xd6, 0x0b, 0xf2, 0xd3, 0x34, 0xa4, 0x84, 0x80, 0xe1, - 0x17, 0x6c, 0xde, 0xd5, 0xf6, 0xb5, 0x07, 0xb6, 0xc7, 0xd7, 0x1c, 0x96, 0xa4, 0x49, 0xb7, 0x21, - 0x61, 0x49, 0x9a, 0xb8, 0x3f, 0x00, 0xe8, 0x05, 0x01, 0xcd, 0xcb, 0x53, 0x9f, 0xfb, 0x09, 0x53, - 0xa7, 0x70, 0x4d, 0xee, 0x41, 0x73, 0x16, 0xad, 0xa8, 0x3a, 0x26, 0x36, 0xee, 0x07, 0x60, 0x8e, - 0x28, 0x1b, 0x15, 0x13, 0xf2, 0x12, 0xb4, 0x8a, 0x9c, 0x66, 0xe3, 0x28, 0x94, 0xc7, 0x4c, 0xdc, - 0x9e, 0x84, 0xc8, 0x0c, 0x45, 0x56, 0xd7, 0xe1, 0xda, 0x4d, 0x00, 0x0e, 0xe3, 0x88, 0x26, 0xec, - 0x30, 0xa3, 0x21, 0x79, 0x11, 0xcc, 0x05, 0x65, 0xf3, 0xb4, 0x3c, 0x29, 0x76, 0x78, 0xe5, 0xca, - 0x8f, 0x0b, 0x75, 0x54, 0x6c, 0xc8, 0x1e, 0x58, 0x19, 0xcd, 0x97, 0x69, 0x92, 0xd3, 0xae, 0xce, - 0x11, 0xe5, 0x1e, 0x39, 0x2d, 0xfd, 0xcc, 0x5f, 0xe4, 0x5d, 0x63, 0x5f, 0x7b, 0xd0, 0xf1, 0xe4, - 0xce, 0xbd, 0x82, 0xd6, 0x88, 0xb2, 0x3e, 0xcd, 0x03, 0xf2, 0x7d, 0x68, 0x87, 0xc2, 0x46, 0x63, - 0x3f, 0xc8, 0xf9, 0x8d, 0xed, 0x47, 0xdf, 0x7a, 0xb8, 0x9c, 0x7c, 0xf1, 0xb0, 0x6e, 0x3b, 0x0f, - 0xc2, 0x72, 0xcf, 0x19, 0x17, 0x93, 0x38, 0x0a, 0xb8, 0x2c, 0xc8, 0x98, 0xef, 0x48, 0x17, 0x5a, - 0xcb, 0x2c, 0x5a, 0xf9, 0x4c, 0xc8, 0xd2, 0xf1, 0xd4, 0xd6, 0xfd, 0xb3, 0x06, 0xad, 0x23, 0xca, - 0x86, 0x4b, 0x96, 0x93, 0x03, 0xb8, 0x1b, 0x4d, 0xc7, 0x8b, 0x34, 0x8c, 0xa6, 0x11, 0x0d, 0xc7, - 0x79, 0x94, 0x04, 0x94, 0xdf, 0xac, 0x7b, 0x77, 0xa2, 0xe9, 0xa9, 0x84, 0x8f, 0x10, 0x8c, 0xe6, - 0x42, 0xc3, 0x29, 0x73, 0xe1, 0x1a, 0x0d, 0xc1, 0xd2, 0x65, 0x14, 0x48, 0x7d, 0xc5, 0x86, 0xbc, - 0x0c, 0x16, 0xe7, 0x84, 0x26, 0x47, 0x75, 0x9b, 0x5e, 0x8b, 0xef, 0x4f, 0x42, 0xf2, 0x0a, 0xd8, - 0x13, 0x3a, 0x4d, 0x33, 0x8e, 0x6b, 0x72, 0x9c, 0x25, 0x00, 0x27, 0xdc, 0xac, 0x71, 0xb4, 0x88, - 0x58, 0xd7, 0xe4, 0x08, 0xb1, 0x71, 0x7f, 0xad, 0x81, 0x75, 0x44, 0xd9, 0xcf, 0x0a, 0x9a, 0x7d, - 0xc9, 0x03, 0x60, 0xee, 0xaf, 0x03, 0x60, 0xee, 0x33, 0xb2, 0x0f, 0x46, 0x48, 0x73, 0x61, 0x80, - 0xf6, 0xa3, 0x0e, 0xb7, 0x98, 0x54, 0xd0, 0xe3, 0x18, 0xf2, 0x1a, 0xe8, 0x79, 0x31, 0xe1, 0x42, - 0x6e, 0x12, 0x20, 0x82, 0x73, 0xf0, 0x99, 0xcf, 0x85, 0xbd, 0xce, 0xc1, 0x67, 0xbe, 0xfb, 0x1b, - 0x0d, 0xac, 0x91, 0x12, 0x42, 0x5d, 0xa8, 0x55, 0xc8, 0xa5, 0x17, 0xe5, 0x85, 0xaf, 0x8a, 0x0b, - 0x85, 0x44, 0x6d, 0x45, 0x30, 0x2a, 0x26, 0xe2, 0x3e, 0x02, 0x06, 0xf3, 0x67, 0x79, 0x57, 0xdf, - 0xd7, 0x51, 0x0b, 0x5c, 0x93, 0x37, 0xc0, 0x08, 0x32, 0x1a, 0x4a, 0x19, 0xee, 0xf0, 0x33, 0xeb, - 0x50, 0xf4, 0x38, 0xd2, 0x7d, 0x0f, 0xa5, 0xb8, 0xf2, 0xfc, 0x64, 0x46, 0x89, 0x03, 0x7a, 0x9c, - 0x7e, 0xce, 0x85, 0x68, 0x7a, 0xb8, 0x24, 0xbb, 0xd0, 0x98, 0x47, 0xfc, 0xd2, 0xa6, 0xd7, 0x98, - 0x47, 0xee, 0xef, 0x35, 0xb0, 0x04, 0x8b, 0xe3, 0x08, 0x91, 0x65, 0x06, 0x34, 0xa2, 0x90, 0xbc, - 0x0a, 0xc0, 0xd3, 0xc2, 0x9f, 0xd1, 0x84, 0x49, 0xa7, 0xda, 0x08, 0xe9, 0x21, 0x00, 0xb9, 0xaf, - 0x68, 0x26, 0xfd, 0x8a, 0x4b, 0x74, 0x5d, 0x48, 0x57, 0xd1, 0xda, 0xad, 0xb6, 0x67, 0x09, 0x80, - 0xc8, 0xa5, 0xd8, 0x4f, 0x66, 0xdc, 0xa5, 0xb6, 0xc7, 0xd7, 0x98, 0x0f, 0xcb, 0xd8, 0x67, 0xd3, - 0x34, 0x5b, 0x70, 0x8f, 0xda, 0x5e, 0xb9, 0x77, 0xff, 0xa5, 0x81, 0x2d, 0x44, 0xeb, 0x05, 0xc1, - 0x35, 0xd9, 0x2a, 0x29, 0xdb, 0xa8, 0xa5, 0xec, 0x8b, 0x60, 0xe6, 0xc1, 0x9c, 0x2e, 0x54, 0x82, - 0xc9, 0x1d, 0x87, 0xd3, 0x20, 0xa3, 0x4c, 0xa5, 0x97, 0xd8, 0xf1, 0x88, 0x4a, 0x67, 0x51, 0xc2, - 0xe5, 0xb2, 0x3c, 0xb1, 0x29, 0xcd, 0x6f, 0x56, 0xcc, 0xaf, 0x7c, 0xda, 0xda, 0xea, 0x53, 0xe5, - 0x20, 0x6b, 0x5f, 0xdf, 0xea, 0x20, 0x91, 0x10, 0x9f, 0xd1, 0xa4, 0x6b, 0x73, 0x39, 0xc4, 0xc6, - 0xcd, 0xa0, 0x2d, 0x28, 0x9f, 0xf2, 0xfb, 0x37, 0xd5, 0x5d, 0x6b, 0xd5, 0xd8, 0xa2, 0x95, 0x5e, - 0xd3, 0x6a, 0x1d, 0x2a, 0xdb, 0x25, 0x71, 0x7f, 0x5b, 0x5a, 0x18, 0x8b, 0xe0, 0xe6, 0x95, 0x65, - 0xe2, 0x36, 0xaa, 0x89, 0x7b, 0x00, 0x76, 0x4e, 0xd9, 0xf8, 0x0a, 0xa3, 0x5c, 0x66, 0xcb, 0x0b, - 0xca, 0x12, 0x3c, 0xf4, 0x3d, 0x2b, 0x57, 0x49, 0x70, 0x00, 0xf6, 0xac, 0xa4, 0x35, 0x2a, 0xb4, - 0x47, 0x25, 0xed, 0x4c, 0xae, 0xdc, 0x93, 0x52, 0x7f, 0xea, 0xaf, 0xe8, 0x2d, 0x85, 0xb9, 0x07, - 0xcd, 0x22, 0x51, 0x69, 0x6b, 0x79, 0x62, 0xe3, 0xfe, 0xad, 0x54, 0xeb, 0xfc, 0xd6, 0x6a, 0xbd, - 0x04, 0xad, 0x24, 0x1d, 0xd3, 0x60, 0x9e, 0x4a, 0x5e, 0x66, 0x92, 0x0e, 0x82, 0x79, 0x4a, 0xde, - 0x03, 0x63, 0x4e, 0x7d, 0x65, 0xc8, 0x6e, 0xc5, 0x90, 0xe7, 0xc5, 0xe4, 0xe1, 0x31, 0xf5, 0xc3, - 0x41, 0xc2, 0xb2, 0x2f, 0x3d, 0x4e, 0x85, 0x25, 0x35, 0x48, 0x13, 0x86, 0xe9, 0xd2, 0x14, 0x25, - 0x55, 0x6e, 0xf7, 0x3e, 0x04, 0xbb, 0x24, 0xc6, 0xcc, 0xf9, 0x8c, 0x7e, 0x29, 0x85, 0xc2, 0x65, - 0xbd, 0x5d, 0x74, 0x64, 0xbb, 0xf8, 0x51, 0xe3, 0x23, 0xcd, 0xfd, 0x54, 0x29, 0x73, 0x44, 0xd9, - 0x2d, 0x95, 0x79, 0x03, 0x9a, 0xd7, 0xfd, 0x53, 0xda, 0x5c, 0xe0, 0xd6, 0x7c, 0x47, 0xff, 0x1b, - 0xdf, 0xd1, 0x06, 0xdf, 0xdf, 0x35, 0x14, 0xe3, 0x3e, 0x8d, 0x6f, 0xc9, 0xf8, 0x1d, 0x59, 0xb2, - 0x91, 0xef, 0xae, 0x6c, 0x68, 0x25, 0x8f, 0x87, 0x3f, 0x9f, 0xfb, 0x4c, 0xd6, 0xf1, 0xb7, 0xa1, - 0x15, 0xd2, 0x78, 0x9c, 0xd3, 0x2b, 0xe9, 0x10, 0x25, 0x83, 0x28, 0x78, 0x9e, 0x19, 0xd2, 0x78, - 0x44, 0xaf, 0xaa, 0xd5, 0xa1, 0x59, 0xab, 0x0e, 0x2a, 0x2f, 0xcc, 0x67, 0x94, 0x50, 0x4c, 0xfe, - 0xb9, 0x9f, 0x85, 0x3c, 0xd1, 0x2d, 0x8f, 0xaf, 0xdd, 0x0f, 0xc1, 0x40, 0x39, 0x48, 0x0b, 0xf4, - 0xd3, 0xd1, 0x91, 0xb3, 0x43, 0x6c, 0x68, 0x5e, 0x0c, 0xcf, 0x4f, 0x0e, 0x1d, 0x0d, 0x61, 0xa3, - 0xcb, 0x4f, 0x9c, 0x06, 0xb1, 0xc0, 0xb8, 0x1c, 0x0d, 0x3c, 0x47, 0xc7, 0xd5, 0xa1, 0x37, 0xe8, - 0x3b, 0x86, 0xfb, 0x0b, 0x35, 0x2e, 0x9c, 0xa5, 0x8c, 0xae, 0xf5, 0xd7, 0xaa, 0xfa, 0xdf, 0x97, - 0xfa, 0x37, 0xb8, 0xfe, 0x42, 0xa7, 0x93, 0x64, 0x9a, 0xe2, 0x11, 0xa9, 0xf9, 0xb7, 0x31, 0xd1, - 0xaf, 0x50, 0x21, 0x5d, 0x74, 0xbe, 0x9c, 0x5e, 0x9d, 0x84, 0xee, 0x3f, 0x74, 0x65, 0xed, 0xd3, - 0x7c, 0x46, 0x5e, 0xe7, 0xd5, 0x5d, 0xab, 0x78, 0x47, 0xd5, 0xf6, 0xe3, 0x1d, 0x2c, 0xf7, 0xc4, - 0x05, 0xdd, 0x0f, 0x54, 0x1b, 0xdc, 0xad, 0x50, 0xf4, 0x82, 0xe0, 0x78, 0xc7, 0x43, 0x24, 0x79, - 0xa0, 0x0a, 0xa2, 0xf0, 0xb2, 0x53, 0xa1, 0xe2, 0xb5, 0xe9, 0x78, 0x47, 0x15, 0x49, 0x57, 0xb4, - 0x30, 0xe3, 0x1a, 0xb7, 0x51, 0x31, 0x41, 0x6e, 0xd8, 0xc7, 0x90, 0x1b, 0x66, 0x34, 0xf7, 0xc3, - 0x06, 0x37, 0x84, 0x73, 0x6e, 0x3c, 0xe5, 0x5d, 0xd0, 0x97, 0xc5, 0x44, 0x7a, 0x66, 0xb7, 0x9e, - 0x68, 0xc8, 0x6d, 0x59, 0x4c, 0x90, 0x66, 0x46, 0x99, 0xac, 0xc0, 0x55, 0x9a, 0x23, 0xca, 0x90, - 0x66, 0x46, 0x19, 0x97, 0x8a, 0xb2, 0xae, 0x75, 0x5d, 0x2a, 0x41, 0x93, 0x0b, 0x9a, 0x90, 0xc6, - 0xbc, 0x02, 0xd7, 0x69, 0xfa, 0x34, 0x46, 0x9a, 0x90, 0xc6, 0xe4, 0x2d, 0x30, 0x92, 0x94, 0xd1, - 0x2e, 0x5c, 0x0b, 0x15, 0x74, 0xcb, 0xf1, 0x8e, 0xc7, 0xd1, 0x64, 0x1f, 0x3a, 0x69, 0x32, 0x9e, - 0xd0, 0xb9, 0x1f, 0x4f, 0xc7, 0xe9, 0xb4, 0xdb, 0xe6, 0x8e, 0x85, 0x34, 0xf9, 0x84, 0x83, 0x86, - 0x53, 0xf2, 0x3e, 0x00, 0xce, 0xae, 0xe3, 0x98, 0xae, 0x68, 0xdc, 0xed, 0x70, 0x1f, 0x8b, 0x3b, - 0x7b, 0x05, 0x9b, 0x3f, 0x45, 0xa8, 0x67, 0xfb, 0x6a, 0xf9, 0x89, 0x0d, 0xad, 0x53, 0x9a, 0xe7, - 0xfe, 0x8c, 0xba, 0x67, 0x00, 0x23, 0x9a, 0xad, 0x68, 0xf6, 0x0d, 0x46, 0x4d, 0x02, 0x46, 0x98, - 0x26, 0x54, 0x96, 0x33, 0xbe, 0x76, 0xbf, 0x6a, 0x80, 0x7d, 0x81, 0x11, 0xd7, 0x17, 0x13, 0x08, - 0x04, 0x19, 0xf5, 0x19, 0x0d, 0xc7, 0x72, 0x5c, 0xd2, 0x3d, 0x5b, 0x42, 0x7a, 0x8c, 0x77, 0xff, - 0x65, 0xa8, 0xd0, 0x0d, 0x81, 0x96, 0x10, 0x81, 0x66, 0x69, 0x11, 0xcc, 0x05, 0x5a, 0x17, 0x68, - 0x09, 0xe9, 0x31, 0xf2, 0x2e, 0x98, 0x38, 0x82, 0x06, 0xb9, 0x0c, 0x8f, 0x1b, 0xa7, 0x54, 0x49, - 0x42, 0xee, 0x63, 0x58, 0xe6, 0x32, 0x44, 0x84, 0xa5, 0xd7, 0x13, 0x3d, 0x46, 0x65, 0x5e, 0x89, - 0x7f, 0xb3, 0x12, 0xff, 0x98, 0xe8, 0x19, 0xf5, 0x43, 0x84, 0xb7, 0x38, 0xdc, 0xc4, 0xad, 0x42, - 0x04, 0x2b, 0x44, 0x58, 0x0a, 0x11, 0xac, 0x4e, 0x42, 0x64, 0x84, 0x25, 0x24, 0x0a, 0xb9, 0xf7, - 0x9b, 0x5e, 0x33, 0xa4, 0xb1, 0x18, 0x1b, 0xe4, 0x90, 0x0c, 0xdb, 0x86, 0xe4, 0x76, 0x7d, 0x48, - 0xfe, 0xab, 0x0e, 0x16, 0x37, 0x26, 0x36, 0xcf, 0xba, 0xb1, 0xb4, 0x1b, 0x8c, 0x15, 0xd2, 0x98, - 0xd6, 0x6d, 0x29, 0x21, 0x3d, 0x86, 0x97, 0xa7, 0x49, 0x1c, 0x95, 0xde, 0x92, 0x3b, 0x65, 0x17, - 0xe3, 0x19, 0x76, 0xa9, 0x18, 0xa0, 0xb9, 0xcd, 0x00, 0x66, 0xcd, 0x00, 0x6b, 0x4d, 0x5b, 0xdb, - 0x34, 0xb5, 0x6a, 0x9a, 0x56, 0xab, 0xa9, 0x5d, 0xab, 0xa6, 0x65, 0x35, 0x83, 0x6a, 0x35, 0xab, - 0x47, 0x46, 0x7b, 0x33, 0x32, 0xd6, 0x9e, 0xec, 0x54, 0x3d, 0xb9, 0xf6, 0xcb, 0x0b, 0x55, 0xbf, - 0xbc, 0x09, 0xbb, 0xb1, 0x9f, 0xb3, 0x71, 0x4e, 0x69, 0x32, 0x66, 0xd1, 0x82, 0x76, 0x77, 0x39, - 0xc3, 0x0e, 0x42, 0x47, 0x94, 0x26, 0x17, 0xd1, 0x82, 0x92, 0xef, 0xc2, 0xbd, 0x35, 0x55, 0x65, - 0x66, 0xbd, 0xc3, 0xe5, 0xba, 0xab, 0x68, 0x2f, 0xd5, 0xec, 0xea, 0xfe, 0x04, 0xec, 0x3e, 0x8d, - 0x3f, 0xc5, 0x4c, 0xc9, 0x2b, 0x57, 0x6b, 0xd5, 0xab, 0x2b, 0xcd, 0xa6, 0xf1, 0x8c, 0x66, 0xe3, - 0x7e, 0xa5, 0x95, 0x69, 0xca, 0xb2, 0xdb, 0xb6, 0x3c, 0x02, 0x46, 0x80, 0x5f, 0x96, 0xa2, 0x9a, - 0xf3, 0x35, 0x1f, 0x3a, 0xe9, 0x17, 0x4c, 0x4e, 0xce, 0x7c, 0x4d, 0x1e, 0x97, 0x5f, 0x85, 0x4d, - 0x2e, 0xc3, 0x2b, 0x52, 0x06, 0x75, 0xdd, 0xc3, 0x73, 0x8e, 0x15, 0x43, 0x88, 0x24, 0xdd, 0xfb, - 0x21, 0xb4, 0x2b, 0xe0, 0xe7, 0x1a, 0x37, 0xfe, 0xd0, 0x50, 0xca, 0xf4, 0x7d, 0xe6, 0x6f, 0xe9, - 0x57, 0xfb, 0xd0, 0x99, 0x66, 0xe9, 0x62, 0x5c, 0x9f, 0xc0, 0x01, 0x61, 0x97, 0x22, 0x32, 0xbe, - 0x03, 0x36, 0x3a, 0x2b, 0x67, 0xfe, 0x62, 0xc9, 0xe3, 0x0c, 0x43, 0x40, 0x01, 0x36, 0xd2, 0x41, - 0xdf, 0x4c, 0x87, 0x75, 0x84, 0x18, 0xd5, 0x08, 0x79, 0x5f, 0x8e, 0x62, 0xc2, 0x10, 0x2f, 0x57, - 0x0c, 0x81, 0xa2, 0x3e, 0x6b, 0x16, 0x33, 0xff, 0x4f, 0xb3, 0xd8, 0x5f, 0x74, 0x65, 0x9c, 0xf3, - 0x8c, 0xe6, 0x5b, 0x8c, 0xe3, 0x80, 0x9e, 0x67, 0xca, 0xdb, 0xb8, 0x24, 0x0f, 0x6a, 0xe3, 0xcd, - 0xbd, 0x8a, 0xe0, 0xc8, 0xa6, 0x3a, 0xdf, 0xd4, 0xbf, 0xb8, 0x8c, 0xcd, 0x2f, 0xae, 0xb5, 0x61, - 0x9a, 0x37, 0xa7, 0x8e, 0xb9, 0x25, 0x7e, 0x5b, 0xcf, 0x1a, 0x96, 0xde, 0x84, 0x5d, 0xe6, 0x67, - 0x38, 0xa9, 0x2b, 0x7f, 0x5a, 0xfc, 0xe2, 0x8e, 0x80, 0x4a, 0x8f, 0xba, 0xf0, 0x82, 0x1f, 0xb0, - 0x34, 0x1b, 0xd7, 0x4b, 0x41, 0x9b, 0x03, 0x25, 0x8d, 0xac, 0x57, 0xb0, 0xbd, 0x5e, 0xb9, 0x85, - 0x9c, 0xa3, 0x4c, 0x68, 0x0c, 0xcf, 0x9c, 0x1d, 0x9c, 0x9d, 0x86, 0x4f, 0x9e, 0x38, 0x1a, 0x02, - 0x2e, 0x7b, 0x8e, 0x8e, 0x80, 0xcb, 0xf3, 0xbe, 0x63, 0xe0, 0x08, 0x75, 0x34, 0x3c, 0x1b, 0x38, - 0x4d, 0x04, 0xf5, 0x0e, 0x47, 0x8e, 0x89, 0xa0, 0x8b, 0x81, 0x77, 0xea, 0xb4, 0xd4, 0x18, 0x66, - 0x21, 0xc8, 0x1b, 0xf4, 0xfa, 0x8e, 0x2d, 0x56, 0x87, 0x9f, 0x3a, 0x80, 0xc8, 0xfe, 0xe0, 0xa9, - 0xd3, 0xe6, 0xf4, 0xbd, 0xa3, 0x91, 0xd3, 0x71, 0xff, 0x5e, 0xe6, 0xe8, 0x29, 0x65, 0xfe, 0x2d, - 0x73, 0xd4, 0x95, 0x1f, 0x7c, 0x7a, 0x65, 0x4c, 0x28, 0xdb, 0xa7, 0xfc, 0xe4, 0x7b, 0x5d, 0xcd, - 0x40, 0x6b, 0x03, 0xab, 0xa6, 0xa0, 0x1e, 0x0e, 0xf8, 0xa8, 0xd1, 0xac, 0xf0, 0x28, 0x2b, 0x8f, - 0x18, 0x34, 0x6e, 0xfa, 0xd6, 0x54, 0x73, 0x6a, 0xab, 0xf2, 0xfd, 0xb6, 0x1e, 0x05, 0xe4, 0xf7, - 0xdb, 0x2f, 0x95, 0x4e, 0x38, 0x2b, 0x7e, 0xe3, 0x54, 0xbd, 0x5f, 0x8b, 0xce, 0xaf, 0x19, 0x3e, - 0xab, 0x09, 0xe9, 0xfe, 0x47, 0x03, 0x5b, 0x1a, 0x35, 0x9f, 0xe1, 0xbc, 0x14, 0xb0, 0x2c, 0x96, - 0xe3, 0xe7, 0x9d, 0x8d, 0x3a, 0x85, 0xf3, 0x12, 0xa2, 0x91, 0x8c, 0x3f, 0xa4, 0x34, 0xae, 0x91, - 0x61, 0x16, 0x23, 0x19, 0xa2, 0x91, 0x6c, 0x99, 0xd1, 0x5c, 0xda, 0xfe, 0xce, 0x46, 0xce, 0x20, - 0x19, 0xa2, 0x91, 0x6c, 0x41, 0xcb, 0x67, 0x99, 0x2a, 0x19, 0xfa, 0x19, 0xc9, 0x10, 0x8d, 0x64, - 0x51, 0x32, 0x4d, 0x6b, 0x13, 0xc6, 0xda, 0x74, 0x48, 0x16, 0xd5, 0x4c, 0x68, 0x56, 0x4c, 0x58, - 0x1d, 0xc8, 0x7e, 0x55, 0x86, 0x91, 0x47, 0xf3, 0x25, 0x79, 0x0b, 0xcc, 0x9c, 0xf9, 0xac, 0x10, - 0x4f, 0x71, 0xca, 0x78, 0x88, 0x3a, 0xe4, 0xe3, 0x8d, 0x40, 0x92, 0xb7, 0xc1, 0xcc, 0xb3, 0xd5, - 0x22, 0x9f, 0xd5, 0x06, 0xef, 0xd2, 0x72, 0x9e, 0xc4, 0x92, 0x37, 0xa1, 0x19, 0xc4, 0x48, 0xa6, - 0x5f, 0x9b, 0x4b, 0x91, 0x4c, 0x20, 0xdd, 0x7f, 0x6a, 0xd0, 0x1a, 0xd1, 0x3c, 0x8f, 0xd2, 0x04, - 0xeb, 0x45, 0x2e, 0x96, 0xeb, 0xb7, 0x4b, 0x5b, 0x42, 0x4e, 0x9e, 0xf1, 0x48, 0x52, 0x1f, 0x49, - 0xf5, 0xaf, 0x19, 0x49, 0xc9, 0xeb, 0xd0, 0xce, 0xe8, 0x22, 0x65, 0x74, 0xec, 0x87, 0x61, 0x26, - 0xeb, 0x12, 0x08, 0x50, 0x2f, 0x0c, 0xb3, 0x8d, 0xba, 0xd5, 0xdc, 0xac, 0x5b, 0xb5, 0x77, 0x21, - 0x73, 0xe3, 0x5d, 0x68, 0x0f, 0xac, 0xd8, 0x4f, 0x66, 0x85, 0x3f, 0xa3, 0xbc, 0x53, 0xd8, 0x5e, - 0xb9, 0x77, 0x87, 0xea, 0xeb, 0xc6, 0xa3, 0x57, 0x98, 0x49, 0x68, 0x1c, 0xed, 0x46, 0xe3, 0x20, - 0x8a, 0xec, 0x83, 0x81, 0xca, 0xd7, 0x9e, 0xf9, 0xa4, 0xa9, 0x3c, 0x8e, 0x71, 0x3f, 0x86, 0xf6, - 0x88, 0xfa, 0x59, 0x30, 0x17, 0x2f, 0x14, 0x5b, 0x1f, 0x7e, 0xef, 0xa9, 0x4f, 0x5d, 0x59, 0x10, - 0xc4, 0xb7, 0xed, 0x95, 0x3a, 0xfd, 0x24, 0x2d, 0x92, 0xf0, 0xb6, 0xee, 0xbf, 0x91, 0x17, 0x1e, - 0xce, 0x68, 0x5e, 0xc4, 0x8c, 0x3f, 0xf1, 0x5d, 0xab, 0x1d, 0x12, 0xe9, 0xce, 0x00, 0x38, 0x6c, - 0xb0, 0x42, 0x43, 0xde, 0x07, 0xd3, 0x0f, 0x58, 0x94, 0x26, 0xf2, 0x46, 0x5b, 0x58, 0x21, 0x2b, - 0x42, 0x4f, 0x22, 0xb0, 0x9a, 0x24, 0x7e, 0xf9, 0x4e, 0xc4, 0xd7, 0xb7, 0x29, 0x64, 0xee, 0x9f, - 0x34, 0xe8, 0xf4, 0x82, 0x20, 0x2d, 0x12, 0x76, 0xeb, 0xbb, 0xb6, 0xc6, 0xd7, 0xc6, 0x43, 0xb5, - 0xfe, 0xbc, 0x0f, 0xd5, 0x46, 0x6d, 0x32, 0x55, 0x05, 0xd2, 0x5a, 0x17, 0x48, 0xf7, 0xdf, 0x1a, - 0xdc, 0x1d, 0x15, 0x93, 0x3c, 0xc8, 0xa2, 0x25, 0xca, 0x72, 0x6b, 0x99, 0xb7, 0xbe, 0xff, 0x28, - 0x4d, 0xf4, 0x9a, 0x26, 0xeb, 0xde, 0x6a, 0x54, 0x7b, 0xeb, 0xf3, 0x8f, 0xdd, 0x6f, 0xc8, 0x5f, - 0x09, 0xad, 0x9b, 0x9b, 0x23, 0x47, 0x6e, 0x9f, 0xc1, 0xdd, 0x0b, 0xe8, 0xc8, 0x22, 0x74, 0x6b, - 0x4d, 0xef, 0x8b, 0x7c, 0xb9, 0xb9, 0xd0, 0xf2, 0x84, 0x39, 0x78, 0x0c, 0x76, 0x99, 0xf0, 0xd8, - 0x2d, 0xcf, 0xb0, 0xe1, 0xee, 0xe0, 0xaa, 0x77, 0x36, 0x3c, 0x73, 0x80, 0xaf, 0x2e, 0x2f, 0x8e, - 0x9d, 0x7b, 0xbc, 0xbd, 0x0e, 0x87, 0x17, 0xce, 0x6b, 0x07, 0x6f, 0x83, 0xa5, 0xfa, 0x43, 0xd9, - 0x7e, 0x77, 0xca, 0xf6, 0xcb, 0x3b, 0xf9, 0x4f, 0xcf, 0x9d, 0xc6, 0xc1, 0xc7, 0x60, 0xa9, 0x5c, - 0x20, 0x1d, 0xb0, 0x0e, 0x87, 0x67, 0x17, 0x27, 0x67, 0x97, 0x92, 0x7f, 0xdf, 0x1b, 0x9e, 0x3b, - 0x1a, 0x69, 0x43, 0xcb, 0x1b, 0x8c, 0xce, 0x87, 0x67, 0x7d, 0xa7, 0x21, 0x36, 0xe7, 0x4f, 0x7b, - 0x87, 0x03, 0x47, 0x3f, 0x38, 0x00, 0x03, 0xb5, 0x21, 0x00, 0xe6, 0xa1, 0x37, 0xe8, 0x5d, 0xe0, - 0x39, 0x00, 0xf3, 0xf2, 0xbc, 0x8f, 0x6b, 0x0d, 0xd7, 0xfd, 0xc1, 0xd3, 0xc1, 0xc5, 0xc0, 0x69, - 0x3c, 0xfa, 0x31, 0x18, 0x67, 0x78, 0xcb, 0x63, 0x68, 0x4b, 0x23, 0x3d, 0x4d, 0xd3, 0x25, 0xd9, - 0xa8, 0x11, 0x7b, 0x1b, 0x75, 0xd7, 0xdd, 0x79, 0xa0, 0x7d, 0x4f, 0x7b, 0xf4, 0xc7, 0x06, 0x98, - 0xe7, 0x71, 0x31, 0x8b, 0x12, 0xf2, 0x3e, 0x58, 0x4f, 0xa2, 0x8c, 0x1e, 0xa7, 0x39, 0xad, 0x1d, - 0xf6, 0xe8, 0xd5, 0x5e, 0xd5, 0x80, 0xa8, 0x96, 0xbb, 0x43, 0xde, 0x03, 0xe3, 0x49, 0x94, 0x84, - 0xc4, 0x91, 0xa8, 0xb2, 0xae, 0xec, 0x55, 0x21, 0xbc, 0x56, 0xb8, 0x3b, 0xe4, 0x5d, 0x68, 0xc9, - 0xfc, 0x22, 0x77, 0x95, 0xf7, 0xcb, 0x6c, 0xdb, 0x13, 0x7f, 0x00, 0xe4, 0xbf, 0xb0, 0x1d, 0xf2, - 0x0e, 0x34, 0x79, 0x82, 0x92, 0x3b, 0xeb, 0x64, 0xbd, 0x91, 0xf0, 0x03, 0xe8, 0x54, 0xd3, 0x80, - 0xbc, 0x28, 0x6e, 0xde, 0xcc, 0x8c, 0xcd, 0x63, 0xef, 0x96, 0x3d, 0x4d, 0x0a, 0x53, 0x0d, 0xae, - 0x0d, 0xe2, 0x89, 0xc9, 0x7f, 0xd8, 0x3d, 0xfe, 0x6f, 0x00, 0x00, 0x00, 0xff, 0xff, 0x33, 0xdf, - 0x14, 0x83, 0xbf, 0x1b, 0x00, 0x00, + 0xa8, 0x82, 0xe2, 0xca, 0x9d, 0xe2, 0xc2, 0x99, 0x2a, 0x38, 0x53, 0xc5, 0x4f, 0x08, 0x37, 0xae, + 0xfc, 0x02, 0x8a, 0xa2, 0xf8, 0x0b, 0xd4, 0xeb, 0x0f, 0x59, 0xf2, 0x8c, 0x37, 0xb3, 0x81, 0x5b, + 0xf7, 0x7b, 0xaf, 0x5f, 0xbf, 0xef, 0xf7, 0xd4, 0x82, 0xf6, 0x22, 0x0d, 0x69, 0xfc, 0x70, 0x99, + 0xa5, 0x2c, 0x25, 0xfa, 0x72, 0xf2, 0x85, 0x6b, 0x81, 0x79, 0x99, 0x14, 0x39, 0x0d, 0xdd, 0x8f, + 0x60, 0xb7, 0x4f, 0xa7, 0x7e, 0x11, 0xb3, 0x5e, 0x90, 0x9f, 0xa6, 0x21, 0x25, 0x04, 0x0c, 0xbf, + 0x60, 0xf3, 0xae, 0xb6, 0xaf, 0x3d, 0xb0, 0x3d, 0xbe, 0xe6, 0xb0, 0x24, 0x4d, 0xba, 0x0d, 0x09, + 0x4b, 0xd2, 0xc4, 0xfd, 0x01, 0x40, 0x2f, 0x08, 0x68, 0x5e, 0x9e, 0xfa, 0xdc, 0x4f, 0x98, 0x3a, + 0x85, 0x6b, 0x72, 0x0f, 0x9a, 0xb3, 0x68, 0x45, 0xd5, 0x31, 0xb1, 0x71, 0x3f, 0x00, 0x73, 0x44, + 0xd9, 0xa8, 0x98, 0x90, 0x97, 0xa0, 0x55, 0xe4, 0x34, 0x1b, 0x47, 0xa1, 0x3c, 0x66, 0xe2, 0xf6, + 0x24, 0x44, 0x66, 0x28, 0xb2, 0xba, 0x0e, 0xd7, 0x6e, 0x02, 0x70, 0x18, 0x47, 0x34, 0x61, 0x87, + 0x19, 0x0d, 0xc9, 0x8b, 0x60, 0x2e, 0x28, 0x9b, 0xa7, 0xe5, 0x49, 0xb1, 0xc3, 0x2b, 0x57, 0x7e, + 0x5c, 0xa8, 0xa3, 0x62, 0x43, 0xf6, 0xc0, 0xca, 0x68, 0xbe, 0x4c, 0x93, 0x9c, 0x76, 0x75, 0x8e, + 0x28, 0xf7, 0xc8, 0x69, 0xe9, 0x67, 0xfe, 0x22, 0xef, 0x1a, 0xfb, 0xda, 0x83, 0x8e, 0x27, 0x77, + 0xee, 0x15, 0xb4, 0x46, 0x94, 0xf5, 0x69, 0x1e, 0x90, 0xef, 0x43, 0x3b, 0x14, 0x36, 0x1a, 0xfb, + 0x41, 0xce, 0x6f, 0x6c, 0x3f, 0xfa, 0xd6, 0xc3, 0xe5, 0xe4, 0x8b, 0x87, 0x75, 0xdb, 0x79, 0x10, + 0x96, 0x7b, 0xce, 0xb8, 0x98, 0xc4, 0x51, 0xc0, 0x65, 0x41, 0xc6, 0x7c, 0x47, 0xba, 0xd0, 0x5a, + 0x66, 0xd1, 0xca, 0x67, 0x42, 0x96, 0x8e, 0xa7, 0xb6, 0xee, 0x9f, 0x35, 0x68, 0x1d, 0x51, 0x36, + 0x5c, 0xb2, 0x9c, 0x1c, 0xc0, 0xdd, 0x68, 0x3a, 0x5e, 0xa4, 0x61, 0x34, 0x8d, 0x68, 0x38, 0xce, + 0xa3, 0x24, 0xa0, 0xfc, 0x66, 0xdd, 0xbb, 0x13, 0x4d, 0x4f, 0x25, 0x7c, 0x84, 0x60, 0x34, 0x17, + 0x1a, 0x4e, 0x99, 0x0b, 0xd7, 0x68, 0x08, 0x96, 0x2e, 0xa3, 0x40, 0xea, 0x2b, 0x36, 0xe4, 0x65, + 0xb0, 0x38, 0x27, 0x34, 0x39, 0xaa, 0xdb, 0xf4, 0x5a, 0x7c, 0x7f, 0x12, 0x92, 0x57, 0xc0, 0x9e, + 0xd0, 0x69, 0x9a, 0x71, 0x5c, 0x93, 0xe3, 0x2c, 0x01, 0x38, 0xe1, 0x66, 0x8d, 0xa3, 0x45, 0xc4, + 0xba, 0x26, 0x47, 0x88, 0x8d, 0xfb, 0x6b, 0x0d, 0xac, 0x23, 0xca, 0x7e, 0x56, 0xd0, 0xec, 0x4b, + 0x1e, 0x00, 0x73, 0x7f, 0x1d, 0x00, 0x73, 0x9f, 0x91, 0x7d, 0x30, 0x42, 0x9a, 0x0b, 0x03, 0xb4, + 0x1f, 0x75, 0xb8, 0xc5, 0xa4, 0x82, 0x1e, 0xc7, 0x90, 0xd7, 0x40, 0xcf, 0x8b, 0x09, 0x17, 0x72, + 0x93, 0x00, 0x11, 0x9c, 0x83, 0xcf, 0x7c, 0x2e, 0xec, 0x75, 0x0e, 0x3e, 0xf3, 0xdd, 0xdf, 0x68, + 0x60, 0x8d, 0x94, 0x10, 0xea, 0x42, 0xad, 0x42, 0x2e, 0xbd, 0x28, 0x2f, 0x7c, 0x55, 0x5c, 0x28, + 0x24, 0x6a, 0x2b, 0x82, 0x51, 0x31, 0x11, 0xf7, 0x11, 0x30, 0x98, 0x3f, 0xcb, 0xbb, 0xfa, 0xbe, + 0x8e, 0x5a, 0xe0, 0x9a, 0xbc, 0x01, 0x46, 0x90, 0xd1, 0x50, 0xca, 0x70, 0x87, 0x9f, 0x59, 0x87, + 0xa2, 0xc7, 0x91, 0xee, 0x7b, 0x28, 0xc5, 0x95, 0xe7, 0x27, 0x33, 0x4a, 0x1c, 0xd0, 0xe3, 0xf4, + 0x73, 0x2e, 0x44, 0xd3, 0xc3, 0x25, 0xd9, 0x85, 0xc6, 0x3c, 0xe2, 0x97, 0x36, 0xbd, 0xc6, 0x3c, + 0x72, 0x7f, 0xa7, 0x81, 0x25, 0x58, 0x1c, 0x47, 0x88, 0x2c, 0x33, 0xa0, 0x11, 0x85, 0xe4, 0x55, + 0x00, 0x9e, 0x16, 0xfe, 0x8c, 0x26, 0x4c, 0x3a, 0xd5, 0x46, 0x48, 0x0f, 0x01, 0xc8, 0x7d, 0x45, + 0x33, 0xe9, 0x57, 0x5c, 0xa2, 0xeb, 0x42, 0xba, 0x8a, 0xd6, 0x6e, 0xb5, 0x3d, 0x4b, 0x00, 0x44, + 0x2e, 0xc5, 0x7e, 0x32, 0xe3, 0x2e, 0xb5, 0x3d, 0xbe, 0xc6, 0x7c, 0x58, 0xc6, 0x3e, 0x9b, 0xa6, + 0xd9, 0x82, 0x7b, 0xd4, 0xf6, 0xca, 0xbd, 0xfb, 0x2f, 0x0d, 0x6c, 0x21, 0x5a, 0x2f, 0x08, 0xae, + 0xc9, 0x56, 0x49, 0xd9, 0x46, 0x2d, 0x65, 0x5f, 0x04, 0x33, 0x0f, 0xe6, 0x74, 0xa1, 0x12, 0x4c, + 0xee, 0x38, 0x9c, 0x06, 0x19, 0x65, 0x2a, 0xbd, 0xc4, 0x8e, 0x47, 0x54, 0x3a, 0x8b, 0x12, 0x2e, + 0x97, 0xe5, 0x89, 0x4d, 0x69, 0x7e, 0xb3, 0x62, 0x7e, 0xe5, 0xd3, 0xd6, 0x56, 0x9f, 0x2a, 0x07, + 0x59, 0xfb, 0xfa, 0x56, 0x07, 0x89, 0x84, 0xf8, 0x8c, 0x26, 0x5d, 0x9b, 0xcb, 0x21, 0x36, 0x6e, + 0x06, 0x6d, 0x41, 0xf9, 0x94, 0xdf, 0xbf, 0xa9, 0xee, 0x5a, 0xab, 0xc6, 0x16, 0xad, 0xf4, 0x9a, + 0x56, 0xeb, 0x50, 0xd9, 0x2e, 0x89, 0xfb, 0xc7, 0xd2, 0xc2, 0x58, 0x04, 0x37, 0xaf, 0x2c, 0x13, + 0xb7, 0x51, 0x4d, 0xdc, 0x03, 0xb0, 0x73, 0xca, 0xc6, 0x57, 0x18, 0xe5, 0x32, 0x5b, 0x5e, 0x50, + 0x96, 0xe0, 0xa1, 0xef, 0x59, 0xb9, 0x4a, 0x82, 0x03, 0xb0, 0x67, 0x25, 0xad, 0x51, 0xa1, 0x3d, + 0x2a, 0x69, 0x67, 0x8a, 0xf6, 0x35, 0x80, 0x89, 0x1f, 0x7c, 0x36, 0xcb, 0xd2, 0x22, 0x09, 0xa5, + 0x2f, 0x2a, 0x10, 0xf7, 0xa4, 0xb4, 0x0f, 0xf5, 0x57, 0xf4, 0x96, 0xc2, 0xde, 0x83, 0x66, 0x91, + 0xa8, 0xb4, 0xb6, 0x3c, 0xb1, 0x71, 0xff, 0x56, 0xaa, 0x7d, 0x7e, 0x6b, 0xb5, 0x5f, 0x82, 0x56, + 0x92, 0x8e, 0x69, 0x30, 0x4f, 0x25, 0x2f, 0x33, 0x49, 0x07, 0xc1, 0x3c, 0x25, 0xef, 0x81, 0x31, + 0xa7, 0xbe, 0x32, 0x74, 0xb7, 0x62, 0xe8, 0xf3, 0x62, 0xf2, 0xf0, 0x98, 0xfa, 0xe1, 0x20, 0x61, + 0xd9, 0x97, 0x1e, 0xa7, 0xc2, 0x92, 0x1b, 0xa4, 0x09, 0xc3, 0x74, 0x6a, 0x8a, 0x92, 0x2b, 0xb7, + 0x7b, 0x1f, 0x82, 0x5d, 0x12, 0x63, 0x66, 0x7d, 0x46, 0xbf, 0x94, 0x42, 0xe1, 0xb2, 0xde, 0x4e, + 0x3a, 0xb2, 0x9d, 0xfc, 0xa8, 0xf1, 0x91, 0xe6, 0x7e, 0xaa, 0x94, 0x39, 0xa2, 0xec, 0x96, 0xca, + 0xbc, 0x01, 0xcd, 0xeb, 0xfe, 0x2b, 0x7d, 0x22, 0x70, 0x6b, 0xbe, 0xa3, 0xff, 0x8d, 0xef, 0x68, + 0x83, 0xef, 0x6f, 0x1b, 0x8a, 0x71, 0x9f, 0xc6, 0xb7, 0x64, 0xfc, 0x8e, 0x2c, 0xe9, 0xc8, 0x77, + 0x57, 0x36, 0xbc, 0x92, 0xc7, 0xc3, 0x9f, 0xcf, 0x7d, 0x26, 0xeb, 0xfc, 0xdb, 0xd0, 0x0a, 0x69, + 0x3c, 0xce, 0xe9, 0x95, 0x74, 0x88, 0x92, 0x41, 0x14, 0x44, 0xcf, 0x0c, 0x69, 0x3c, 0xa2, 0x57, + 0xd5, 0xea, 0xd1, 0xac, 0x55, 0x0f, 0x95, 0x37, 0xe6, 0x33, 0x4a, 0x2c, 0x16, 0x87, 0xb9, 0x9f, + 0x85, 0xbc, 0x10, 0x58, 0x1e, 0x5f, 0xbb, 0x1f, 0x82, 0x81, 0x72, 0x90, 0x16, 0xe8, 0xa7, 0xa3, + 0x23, 0x67, 0x87, 0xd8, 0xd0, 0xbc, 0x18, 0x9e, 0x9f, 0x1c, 0x3a, 0x1a, 0xc2, 0x46, 0x97, 0x9f, + 0x38, 0x0d, 0x62, 0x81, 0x71, 0x39, 0x1a, 0x78, 0x8e, 0x8e, 0xab, 0x43, 0x6f, 0xd0, 0x77, 0x0c, + 0xf7, 0x17, 0x6a, 0x9c, 0x38, 0x4b, 0x19, 0x5d, 0xeb, 0xaf, 0x55, 0xf5, 0xbf, 0x2f, 0xf5, 0x6f, + 0x70, 0xfd, 0x85, 0x4e, 0x27, 0xc9, 0x34, 0xc5, 0x23, 0x52, 0xf3, 0x6f, 0x63, 0x21, 0xb8, 0x42, + 0x85, 0x74, 0xd1, 0x19, 0x73, 0x7a, 0x75, 0x12, 0xba, 0xff, 0xd0, 0x95, 0xb5, 0x4f, 0xf3, 0x19, + 0x79, 0x9d, 0x57, 0x7f, 0xad, 0xe2, 0x1d, 0x55, 0xfb, 0x8f, 0x77, 0xb0, 0x1d, 0x10, 0x17, 0x74, + 0x3f, 0x50, 0x6d, 0x72, 0xb7, 0x42, 0xd1, 0x0b, 0x82, 0xe3, 0x1d, 0x0f, 0x91, 0xe4, 0x81, 0x2a, + 0x98, 0xc2, 0xcb, 0x4e, 0x85, 0x8a, 0xd7, 0xae, 0xe3, 0x1d, 0x55, 0x44, 0x5d, 0xd1, 0xe2, 0x8c, + 0x6b, 0xdc, 0x46, 0xc5, 0x04, 0xb9, 0x61, 0x9f, 0x43, 0x6e, 0x98, 0xd1, 0xdc, 0x0f, 0x1b, 0xdc, + 0x10, 0xce, 0xb9, 0xf1, 0x94, 0x77, 0x41, 0x5f, 0x16, 0x13, 0xe9, 0x99, 0xdd, 0x7a, 0xa2, 0x21, + 0xb7, 0x65, 0x31, 0x41, 0x9a, 0x19, 0x65, 0xb2, 0x42, 0x57, 0x69, 0x8e, 0x28, 0x43, 0x9a, 0x19, + 0x65, 0x5c, 0x2a, 0xca, 0xba, 0xd6, 0x75, 0xa9, 0x04, 0x4d, 0x2e, 0x68, 0x42, 0x1a, 0xf3, 0x0a, + 0x5d, 0xa7, 0xe9, 0xd3, 0x18, 0x69, 0x42, 0x1a, 0x93, 0xb7, 0xc0, 0x48, 0x52, 0x46, 0xbb, 0x70, + 0x2d, 0x54, 0xd0, 0x2d, 0xc7, 0x3b, 0x1e, 0x47, 0x93, 0x7d, 0xe8, 0xa4, 0xc9, 0x78, 0x42, 0xe7, + 0x7e, 0x3c, 0x1d, 0xa7, 0xd3, 0x6e, 0x9b, 0x3b, 0x16, 0xd2, 0xe4, 0x13, 0x0e, 0x1a, 0x4e, 0xc9, + 0xfb, 0x00, 0x38, 0xdb, 0x8e, 0x63, 0xba, 0xa2, 0x71, 0xb7, 0xc3, 0x7d, 0x2c, 0xee, 0xec, 0x15, + 0x6c, 0xfe, 0x14, 0xa1, 0x9e, 0xed, 0xab, 0xe5, 0x27, 0x36, 0xb4, 0x4e, 0x69, 0x9e, 0xfb, 0x33, + 0xea, 0x9e, 0x01, 0x8c, 0x68, 0xb6, 0xa2, 0xd9, 0x37, 0x18, 0x45, 0x09, 0x18, 0x61, 0x9a, 0x50, + 0x59, 0xce, 0xf8, 0xda, 0xfd, 0xaa, 0x01, 0xf6, 0x05, 0x46, 0x5c, 0x5f, 0x4c, 0x28, 0x10, 0x64, + 0xd4, 0x67, 0x34, 0x1c, 0xcb, 0x71, 0x4a, 0xf7, 0x6c, 0x09, 0xe9, 0x31, 0x3e, 0x1d, 0x2c, 0x43, + 0x85, 0x6e, 0x08, 0xb4, 0x84, 0x08, 0x34, 0x4b, 0x8b, 0x60, 0x2e, 0xd0, 0xba, 0x40, 0x4b, 0x48, + 0x8f, 0x91, 0x77, 0xc1, 0xc4, 0x11, 0x35, 0xc8, 0x65, 0x78, 0xdc, 0x38, 0xc5, 0x4a, 0x12, 0x72, + 0x1f, 0xc3, 0x32, 0x97, 0x21, 0x22, 0x2c, 0xbd, 0x9e, 0xf8, 0x31, 0x2a, 0xf3, 0x4a, 0xfc, 0x9b, + 0x95, 0xf8, 0xc7, 0x44, 0xcf, 0xa8, 0x1f, 0x22, 0xbc, 0xc5, 0xe1, 0x26, 0x6e, 0x15, 0x22, 0x58, + 0x21, 0xc2, 0x52, 0x88, 0x60, 0x75, 0x12, 0x22, 0x23, 0x2c, 0x21, 0x51, 0xc8, 0xbd, 0xdf, 0xf4, + 0x9a, 0x21, 0x8d, 0xc5, 0x58, 0x21, 0x87, 0x68, 0xd8, 0x36, 0x44, 0xb7, 0xeb, 0x43, 0xf4, 0x5f, + 0x75, 0xb0, 0xb8, 0x31, 0xb1, 0xb9, 0xd6, 0x8d, 0xa5, 0xdd, 0x60, 0xac, 0x90, 0xc6, 0xb4, 0x6e, + 0x4b, 0x09, 0xe9, 0x31, 0xbc, 0x3c, 0x4d, 0xe2, 0xa8, 0xf4, 0x96, 0xdc, 0x29, 0xbb, 0x18, 0xcf, + 0xb0, 0x4b, 0xc5, 0x00, 0xcd, 0x6d, 0x06, 0x30, 0x6b, 0x06, 0x58, 0x6b, 0xda, 0xda, 0xa6, 0xa9, + 0x55, 0xd3, 0xb4, 0x5a, 0x4d, 0xed, 0x5a, 0x35, 0x2d, 0xab, 0x19, 0x54, 0xab, 0x59, 0x3d, 0x32, + 0xda, 0x9b, 0x91, 0xb1, 0xf6, 0x64, 0xa7, 0xea, 0xc9, 0xb5, 0x5f, 0x5e, 0xa8, 0xfa, 0xe5, 0x4d, + 0xd8, 0x8d, 0xfd, 0x9c, 0x8d, 0x73, 0x4a, 0x93, 0x31, 0x8b, 0x16, 0xb4, 0xbb, 0xcb, 0x19, 0x76, + 0x10, 0x3a, 0xa2, 0x34, 0xb9, 0x88, 0x16, 0x94, 0x7c, 0x17, 0xee, 0xad, 0xa9, 0x2a, 0x33, 0xed, + 0x1d, 0x2e, 0xd7, 0x5d, 0x45, 0x7b, 0xa9, 0x66, 0x5b, 0xf7, 0x27, 0x60, 0xf7, 0x69, 0xfc, 0x29, + 0x66, 0x4a, 0x5e, 0xb9, 0x5a, 0xab, 0x5e, 0x5d, 0x69, 0x36, 0x8d, 0x67, 0x34, 0x1b, 0xf7, 0x2b, + 0xad, 0x4c, 0x53, 0x96, 0xdd, 0xb6, 0xe5, 0x11, 0x30, 0x02, 0xfc, 0xf2, 0x14, 0xd5, 0x9c, 0xaf, + 0xf9, 0x50, 0x4a, 0xbf, 0x60, 0x72, 0xb2, 0xe6, 0x6b, 0xf2, 0xb8, 0xfc, 0x6a, 0x6c, 0x72, 0x19, + 0x5e, 0x91, 0x32, 0xa8, 0xeb, 0x1e, 0x9e, 0x73, 0xac, 0x18, 0x42, 0x24, 0xe9, 0xde, 0x0f, 0xa1, + 0x5d, 0x01, 0x3f, 0xd7, 0xb8, 0xf1, 0xfb, 0x86, 0x52, 0xa6, 0xef, 0x33, 0x7f, 0x4b, 0xbf, 0xda, + 0x87, 0xce, 0x34, 0x4b, 0x17, 0xe3, 0xfa, 0x84, 0x0e, 0x08, 0xbb, 0x14, 0x91, 0xf1, 0x1d, 0xb0, + 0xd1, 0x59, 0x39, 0xf3, 0x17, 0x4b, 0x1e, 0x67, 0x18, 0x02, 0x0a, 0xb0, 0x91, 0x0e, 0xfa, 0x66, + 0x3a, 0xac, 0x23, 0xc4, 0xa8, 0x46, 0xc8, 0xfb, 0x72, 0x14, 0x13, 0x86, 0x78, 0xb9, 0x62, 0x08, + 0x14, 0xf5, 0x59, 0xb3, 0x98, 0xf9, 0x7f, 0x9a, 0xc5, 0xfe, 0xa2, 0x2b, 0xe3, 0x9c, 0x67, 0x34, + 0xdf, 0x62, 0x1c, 0x07, 0xf4, 0x3c, 0x53, 0xde, 0xc6, 0x25, 0x79, 0x50, 0x1b, 0x6f, 0xee, 0x55, + 0x04, 0x47, 0x36, 0xd5, 0xf9, 0xa6, 0xfe, 0x45, 0x66, 0x6c, 0x7e, 0x91, 0xad, 0x0d, 0xd3, 0xbc, + 0x39, 0x75, 0xcc, 0x2d, 0xf1, 0xdb, 0x7a, 0xd6, 0xb0, 0xf4, 0x26, 0xec, 0x32, 0x3f, 0xc3, 0x49, + 0x5e, 0xf9, 0xd3, 0xe2, 0x17, 0x77, 0x04, 0x54, 0x7a, 0xd4, 0x85, 0x17, 0xfc, 0x80, 0xa5, 0xd9, + 0xb8, 0x5e, 0x0a, 0xda, 0x1c, 0x28, 0x69, 0x64, 0xbd, 0x82, 0xed, 0xf5, 0xca, 0x2d, 0xe4, 0x1c, + 0x65, 0x42, 0x63, 0x78, 0xe6, 0xec, 0xe0, 0xec, 0x34, 0x7c, 0xf2, 0xc4, 0xd1, 0x10, 0x70, 0xd9, + 0x73, 0x74, 0x04, 0x5c, 0x9e, 0xf7, 0x1d, 0x03, 0x47, 0xa8, 0xa3, 0xe1, 0xd9, 0xc0, 0x69, 0x22, + 0xa8, 0x77, 0x38, 0x72, 0x4c, 0x04, 0x5d, 0x0c, 0xbc, 0x53, 0xa7, 0xa5, 0xc6, 0x30, 0x0b, 0x41, + 0xde, 0xa0, 0xd7, 0x77, 0x6c, 0xb1, 0x3a, 0xfc, 0xd4, 0x01, 0x44, 0xf6, 0x07, 0x4f, 0x9d, 0x36, + 0xa7, 0xef, 0x1d, 0x8d, 0x9c, 0x8e, 0xfb, 0xf7, 0x32, 0x47, 0x4f, 0x29, 0xf3, 0x6f, 0x99, 0xa3, + 0xae, 0xfc, 0x20, 0xd4, 0x2b, 0x63, 0x42, 0xd9, 0x3e, 0xe5, 0x27, 0xe1, 0xeb, 0x6a, 0x06, 0x5a, + 0x1b, 0x58, 0x35, 0x05, 0xf5, 0xb0, 0xc0, 0x47, 0x8d, 0x66, 0x85, 0x47, 0x59, 0x79, 0xc4, 0xa0, + 0x71, 0xd3, 0xb7, 0xa8, 0x9a, 0x53, 0x5b, 0x95, 0xef, 0xbb, 0xf5, 0x28, 0x20, 0xbf, 0xef, 0x7e, + 0xa9, 0x74, 0xc2, 0x59, 0xf1, 0x1b, 0xa7, 0xea, 0xfd, 0x5a, 0x74, 0x7e, 0xcd, 0xf0, 0x59, 0x4d, + 0x48, 0xf7, 0x3f, 0x1a, 0xd8, 0xd2, 0xa8, 0xf9, 0x0c, 0xe7, 0xa5, 0x80, 0x65, 0xb1, 0x1c, 0x3f, + 0xef, 0x6c, 0xd4, 0x29, 0x9c, 0x97, 0x10, 0x8d, 0x64, 0xfc, 0xa1, 0xa5, 0x71, 0x8d, 0x0c, 0xb3, + 0x18, 0xc9, 0x10, 0x8d, 0x64, 0xcb, 0x8c, 0xe6, 0xd2, 0xf6, 0x77, 0x36, 0x72, 0x06, 0xc9, 0x10, + 0x8d, 0x64, 0x0b, 0x5a, 0x3e, 0xdb, 0x54, 0xc9, 0xd0, 0xcf, 0x48, 0x86, 0x68, 0x24, 0x8b, 0x92, + 0x69, 0x5a, 0x9b, 0x30, 0xd6, 0xa6, 0x43, 0xb2, 0xa8, 0x66, 0x42, 0xb3, 0x62, 0xc2, 0xea, 0x40, + 0xf6, 0xab, 0x32, 0x8c, 0x3c, 0x9a, 0x2f, 0xc9, 0x5b, 0x60, 0xe6, 0xcc, 0x67, 0x85, 0x78, 0xaa, + 0x53, 0xc6, 0x43, 0xd4, 0x21, 0x1f, 0x6f, 0x04, 0x92, 0xbc, 0x0d, 0x66, 0x9e, 0xad, 0x16, 0xf9, + 0xac, 0x36, 0x78, 0x97, 0x96, 0xf3, 0x24, 0x96, 0xbc, 0x09, 0xcd, 0x20, 0x46, 0x32, 0xfd, 0xda, + 0x5c, 0x8a, 0x64, 0x02, 0xe9, 0xfe, 0x53, 0x83, 0xd6, 0x88, 0xe6, 0x79, 0x94, 0x26, 0x58, 0x2f, + 0x72, 0xb1, 0x5c, 0xbf, 0x6d, 0xda, 0x12, 0x72, 0xf2, 0x8c, 0x47, 0x94, 0xfa, 0x48, 0xaa, 0x7f, + 0xcd, 0x48, 0x4a, 0x5e, 0x87, 0x76, 0x46, 0x17, 0x29, 0xa3, 0x63, 0x3f, 0x0c, 0x33, 0x59, 0x97, + 0x40, 0x80, 0x7a, 0x61, 0x98, 0x6d, 0xd4, 0xad, 0xe6, 0x66, 0xdd, 0xaa, 0xbd, 0x1b, 0x99, 0x1b, + 0xef, 0x46, 0x7b, 0x60, 0xc5, 0x7e, 0x32, 0x2b, 0xfc, 0x19, 0xe5, 0x9d, 0xc2, 0xf6, 0xca, 0xbd, + 0x3b, 0x54, 0x5f, 0x37, 0x1e, 0xbd, 0xc2, 0x4c, 0x42, 0xe3, 0x68, 0x37, 0x1a, 0x07, 0x51, 0x64, + 0x1f, 0x0c, 0x54, 0xbe, 0xf6, 0x0c, 0x28, 0x4d, 0xe5, 0x71, 0x8c, 0xfb, 0x31, 0xb4, 0x47, 0xd4, + 0xcf, 0x82, 0xb9, 0x78, 0x95, 0xd8, 0xfa, 0x30, 0x7c, 0x4f, 0x7d, 0xea, 0xca, 0x82, 0x20, 0xbe, + 0x6d, 0xaf, 0xd4, 0xe9, 0x27, 0x69, 0x91, 0x84, 0xb7, 0x75, 0xff, 0x8d, 0xbc, 0xf0, 0x70, 0x46, + 0xf3, 0x22, 0x66, 0xfc, 0x09, 0xf0, 0x5a, 0xed, 0x90, 0x48, 0x77, 0x06, 0xc0, 0x61, 0x83, 0x15, + 0x1a, 0xf2, 0x3e, 0x98, 0x7e, 0xc0, 0xa2, 0x34, 0x91, 0x37, 0xda, 0xc2, 0x0a, 0x59, 0x11, 0x7a, + 0x12, 0x81, 0xd5, 0x24, 0xf1, 0xcb, 0x77, 0x24, 0xbe, 0xbe, 0x4d, 0x21, 0x73, 0xff, 0xa4, 0x41, + 0xa7, 0x17, 0x04, 0x69, 0x91, 0xb0, 0x5b, 0xdf, 0xb5, 0x35, 0xbe, 0x36, 0x1e, 0xb2, 0xf5, 0xe7, + 0x7d, 0xc8, 0x36, 0x6a, 0x93, 0xa9, 0x2a, 0x90, 0xd6, 0xba, 0x40, 0xba, 0xff, 0xd6, 0xe0, 0xee, + 0xa8, 0x98, 0xe4, 0x41, 0x16, 0x2d, 0x51, 0x96, 0x5b, 0xcb, 0xbc, 0xf5, 0xfd, 0x47, 0x69, 0xa2, + 0xd7, 0x34, 0x59, 0xf7, 0x56, 0xa3, 0xda, 0x5b, 0x9f, 0x7f, 0xec, 0x7e, 0x43, 0xfe, 0x6a, 0x68, + 0xdd, 0xdc, 0x1c, 0x39, 0x72, 0xfb, 0x0c, 0xee, 0x5e, 0x40, 0x47, 0x16, 0xa1, 0x5b, 0x6b, 0x7a, + 0x5f, 0xe4, 0xcb, 0xcd, 0x85, 0x96, 0x27, 0xcc, 0xc1, 0x63, 0xb0, 0xcb, 0x84, 0xc7, 0x6e, 0x79, + 0x86, 0x0d, 0x77, 0x07, 0x57, 0xbd, 0xb3, 0xe1, 0x99, 0x03, 0x7c, 0x75, 0x79, 0x71, 0xec, 0xdc, + 0xe3, 0xed, 0x75, 0x38, 0xbc, 0x70, 0x5e, 0x3b, 0x78, 0x1b, 0x2c, 0xd5, 0x1f, 0xca, 0xf6, 0xbb, + 0x53, 0xb6, 0x5f, 0xde, 0xc9, 0x7f, 0x7a, 0xee, 0x34, 0x0e, 0x3e, 0x06, 0x4b, 0xe5, 0x02, 0xe9, + 0x80, 0x75, 0x38, 0x3c, 0xbb, 0x38, 0x39, 0xbb, 0x94, 0xfc, 0xfb, 0xde, 0xf0, 0xdc, 0xd1, 0x48, + 0x1b, 0x5a, 0xde, 0x60, 0x74, 0x3e, 0x3c, 0xeb, 0x3b, 0x0d, 0xb1, 0x39, 0x7f, 0xda, 0x3b, 0x1c, + 0x38, 0xfa, 0xc1, 0x01, 0x18, 0xa8, 0x0d, 0x01, 0x30, 0x0f, 0xbd, 0x41, 0xef, 0x02, 0xcf, 0x01, + 0x98, 0x97, 0xe7, 0x7d, 0x5c, 0x6b, 0xb8, 0xee, 0x0f, 0x9e, 0x0e, 0x2e, 0x06, 0x4e, 0xe3, 0xd1, + 0x8f, 0xc1, 0x38, 0xc3, 0x5b, 0x1e, 0x43, 0x5b, 0x1a, 0xe9, 0x69, 0x9a, 0x2e, 0xc9, 0x46, 0x8d, + 0xd8, 0xdb, 0xa8, 0xbb, 0xee, 0xce, 0x03, 0xed, 0x7b, 0xda, 0xa3, 0x3f, 0x34, 0xc0, 0x3c, 0x8f, + 0x8b, 0x59, 0x94, 0x90, 0xf7, 0xc1, 0x7a, 0x12, 0x65, 0xf4, 0x38, 0xcd, 0x69, 0xed, 0xb0, 0x47, + 0xaf, 0xf6, 0xaa, 0x06, 0x44, 0xb5, 0xdc, 0x1d, 0xf2, 0x1e, 0x18, 0x4f, 0xa2, 0x24, 0x24, 0x8e, + 0x44, 0x95, 0x75, 0x65, 0xaf, 0x0a, 0xe1, 0xb5, 0xc2, 0xdd, 0x21, 0xef, 0x42, 0x4b, 0xe6, 0x17, + 0xb9, 0xab, 0xbc, 0x5f, 0x66, 0xdb, 0x9e, 0xf8, 0x43, 0x20, 0xff, 0x95, 0xed, 0x90, 0x77, 0xa0, + 0xc9, 0x13, 0x94, 0xdc, 0x59, 0x27, 0xeb, 0x8d, 0x84, 0x1f, 0x40, 0xa7, 0x9a, 0x06, 0xe4, 0x45, + 0x71, 0xf3, 0x66, 0x66, 0x6c, 0x1e, 0x7b, 0xb7, 0xec, 0x69, 0x52, 0x98, 0x6a, 0x70, 0x6d, 0x10, + 0x4f, 0x4c, 0xfe, 0x43, 0xef, 0xf1, 0x7f, 0x03, 0x00, 0x00, 0xff, 0xff, 0x88, 0xae, 0xfa, 0x12, + 0xdf, 0x1b, 0x00, 0x00, } diff --git a/pbx/model.proto b/pbx/model.proto index 85ce8216b..57380b98d 100644 --- a/pbx/model.proto +++ b/pbx/model.proto @@ -187,6 +187,8 @@ message ClientSub { // mirrors {get} GetQuery get_query = 4; + + bool background = 5; } // Unsubscribe {leave} request message diff --git a/py_grpc/tinode_grpc/model_pb2.py b/py_grpc/tinode_grpc/model_pb2.py index 766675388..3cd5d2147 100644 --- a/py_grpc/tinode_grpc/model_pb2.py +++ b/py_grpc/tinode_grpc/model_pb2.py @@ -20,7 +20,7 @@ package='pbx', syntax='proto3', serialized_options=None, - serialized_pb=_b('\n\x0bmodel.proto\x12\x03pbx\"\x08\n\x06Unused\",\n\x0e\x44\x65\x66\x61ultAcsMode\x12\x0c\n\x04\x61uth\x18\x01 \x01(\t\x12\x0c\n\x04\x61non\x18\x02 \x01(\t\")\n\nAccessMode\x12\x0c\n\x04want\x18\x01 \x01(\t\x12\r\n\x05given\x18\x02 \x01(\t\"\'\n\x06SetSub\x12\x0f\n\x07user_id\x18\x01 \x01(\t\x12\x0c\n\x04mode\x18\x02 \x01(\t\"M\n\nClientCred\x12\x0e\n\x06method\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x10\n\x08response\x18\x03 \x01(\t\x12\x0e\n\x06params\x18\x04 \x01(\x0c\"T\n\x07SetDesc\x12(\n\x0b\x64\x65\x66\x61ult_acs\x18\x01 \x01(\x0b\x32\x13.pbx.DefaultAcsMode\x12\x0e\n\x06public\x18\x02 \x01(\x0c\x12\x0f\n\x07private\x18\x03 \x01(\x0c\"u\n\x07GetOpts\x12\x19\n\x11if_modified_since\x18\x01 \x01(\x03\x12\x0c\n\x04user\x18\x02 \x01(\t\x12\r\n\x05topic\x18\x03 \x01(\t\x12\x10\n\x08since_id\x18\x04 \x01(\x05\x12\x11\n\tbefore_id\x18\x05 \x01(\x05\x12\r\n\x05limit\x18\x06 \x01(\x05\"k\n\x08GetQuery\x12\x0c\n\x04what\x18\x01 \x01(\t\x12\x1a\n\x04\x64\x65sc\x18\x02 \x01(\x0b\x32\x0c.pbx.GetOpts\x12\x19\n\x03sub\x18\x03 \x01(\x0b\x32\x0c.pbx.GetOpts\x12\x1a\n\x04\x64\x61ta\x18\x04 \x01(\x0b\x32\x0c.pbx.GetOpts\"m\n\x08SetQuery\x12\x1a\n\x04\x64\x65sc\x18\x01 \x01(\x0b\x32\x0c.pbx.SetDesc\x12\x18\n\x03sub\x18\x02 \x01(\x0b\x32\x0b.pbx.SetSub\x12\x0c\n\x04tags\x18\x03 \x03(\t\x12\x1d\n\x04\x63red\x18\x04 \x01(\x0b\x32\x0f.pbx.ClientCred\"#\n\x08SeqRange\x12\x0b\n\x03low\x18\x01 \x01(\x05\x12\n\n\x02hi\x18\x02 \x01(\x05\"j\n\x08\x43lientHi\x12\n\n\x02id\x18\x01 \x01(\t\x12\x12\n\nuser_agent\x18\x02 \x01(\t\x12\x0b\n\x03ver\x18\x03 \x01(\t\x12\x11\n\tdevice_id\x18\x04 \x01(\t\x12\x0c\n\x04lang\x18\x05 \x01(\t\x12\x10\n\x08platform\x18\x06 \x01(\t\"\xaf\x01\n\tClientAcc\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0f\n\x07user_id\x18\x02 \x01(\t\x12\x0e\n\x06scheme\x18\x03 \x01(\t\x12\x0e\n\x06secret\x18\x04 \x01(\x0c\x12\r\n\x05login\x18\x05 \x01(\x08\x12\x0c\n\x04tags\x18\x06 \x03(\t\x12\x1a\n\x04\x64\x65sc\x18\x07 \x01(\x0b\x32\x0c.pbx.SetDesc\x12\x1d\n\x04\x63red\x18\x08 \x03(\x0b\x32\x0f.pbx.ClientCred\x12\r\n\x05token\x18\t \x01(\x0c\"X\n\x0b\x43lientLogin\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0e\n\x06scheme\x18\x02 \x01(\t\x12\x0e\n\x06secret\x18\x03 \x01(\x0c\x12\x1d\n\x04\x63red\x18\x04 \x03(\x0b\x32\x0f.pbx.ClientCred\"j\n\tClientSub\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12 \n\tset_query\x18\x03 \x01(\x0b\x32\r.pbx.SetQuery\x12 \n\tget_query\x18\x04 \x01(\x0b\x32\r.pbx.GetQuery\"7\n\x0b\x43lientLeave\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\r\n\x05unsub\x18\x03 \x01(\x08\"\x9d\x01\n\tClientPub\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x0f\n\x07no_echo\x18\x03 \x01(\x08\x12&\n\x04head\x18\x04 \x03(\x0b\x32\x18.pbx.ClientPub.HeadEntry\x12\x0f\n\x07\x63ontent\x18\x05 \x01(\x0c\x1a+\n\tHeadEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"D\n\tClientGet\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x1c\n\x05query\x18\x03 \x01(\x0b\x32\r.pbx.GetQuery\"D\n\tClientSet\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x1c\n\x05query\x18\x03 \x01(\x0b\x32\r.pbx.SetQuery\"\xe0\x01\n\tClientDel\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12!\n\x04what\x18\x03 \x01(\x0e\x32\x13.pbx.ClientDel.What\x12\x1e\n\x07\x64\x65l_seq\x18\x04 \x03(\x0b\x32\r.pbx.SeqRange\x12\x0f\n\x07user_id\x18\x05 \x01(\t\x12\x1d\n\x04\x63red\x18\x06 \x01(\x0b\x32\x0f.pbx.ClientCred\x12\x0c\n\x04hard\x18\x07 \x01(\x08\"7\n\x04What\x12\x07\n\x03MSG\x10\x00\x12\t\n\x05TOPIC\x10\x01\x12\x07\n\x03SUB\x10\x02\x12\x08\n\x04USER\x10\x03\x12\x08\n\x04\x43RED\x10\x04\"H\n\nClientNote\x12\r\n\x05topic\x18\x01 \x01(\t\x12\x1b\n\x04what\x18\x02 \x01(\x0e\x32\r.pbx.InfoNote\x12\x0e\n\x06seq_id\x18\x03 \x01(\x05\"\x8e\x03\n\tClientMsg\x12\x1b\n\x02hi\x18\x01 \x01(\x0b\x32\r.pbx.ClientHiH\x00\x12\x1d\n\x03\x61\x63\x63\x18\x02 \x01(\x0b\x32\x0e.pbx.ClientAccH\x00\x12!\n\x05login\x18\x03 \x01(\x0b\x32\x10.pbx.ClientLoginH\x00\x12\x1d\n\x03sub\x18\x04 \x01(\x0b\x32\x0e.pbx.ClientSubH\x00\x12!\n\x05leave\x18\x05 \x01(\x0b\x32\x10.pbx.ClientLeaveH\x00\x12\x1d\n\x03pub\x18\x06 \x01(\x0b\x32\x0e.pbx.ClientPubH\x00\x12\x1d\n\x03get\x18\x07 \x01(\x0b\x32\x0e.pbx.ClientGetH\x00\x12\x1d\n\x03set\x18\x08 \x01(\x0b\x32\x0e.pbx.ClientSetH\x00\x12\x1d\n\x03\x64\x65l\x18\t \x01(\x0b\x32\x0e.pbx.ClientDelH\x00\x12\x1f\n\x04note\x18\n \x01(\x0b\x32\x0f.pbx.ClientNoteH\x00\x12\x14\n\x0con_behalf_of\x18\x0b \x01(\t\x12\"\n\nauth_level\x18\x0c \x01(\x0e\x32\x0e.pbx.AuthLevelB\t\n\x07Message\"9\n\nServerCred\x12\x0e\n\x06method\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x0c\n\x04\x64one\x18\x03 \x01(\x08\"\xed\x01\n\tTopicDesc\x12\x12\n\ncreated_at\x18\x01 \x01(\x03\x12\x12\n\nupdated_at\x18\x02 \x01(\x03\x12\x12\n\ntouched_at\x18\x03 \x01(\x03\x12#\n\x06\x64\x65\x66\x61\x63s\x18\x04 \x01(\x0b\x32\x13.pbx.DefaultAcsMode\x12\x1c\n\x03\x61\x63s\x18\x05 \x01(\x0b\x32\x0f.pbx.AccessMode\x12\x0e\n\x06seq_id\x18\x06 \x01(\x05\x12\x0f\n\x07read_id\x18\x07 \x01(\x05\x12\x0f\n\x07recv_id\x18\x08 \x01(\x05\x12\x0e\n\x06\x64\x65l_id\x18\t \x01(\x05\x12\x0e\n\x06public\x18\n \x01(\x0c\x12\x0f\n\x07private\x18\x0b \x01(\x0c\"\xad\x02\n\x08TopicSub\x12\x12\n\nupdated_at\x18\x01 \x01(\x03\x12\x12\n\ndeleted_at\x18\x02 \x01(\x03\x12\x0e\n\x06online\x18\x03 \x01(\x08\x12\x1c\n\x03\x61\x63s\x18\x04 \x01(\x0b\x32\x0f.pbx.AccessMode\x12\x0f\n\x07read_id\x18\x05 \x01(\x05\x12\x0f\n\x07recv_id\x18\x06 \x01(\x05\x12\x0e\n\x06public\x18\x07 \x01(\x0c\x12\x0f\n\x07private\x18\x08 \x01(\x0c\x12\x0f\n\x07user_id\x18\t \x01(\t\x12\r\n\x05topic\x18\n \x01(\t\x12\x12\n\ntouched_at\x18\x0b \x01(\x03\x12\x0e\n\x06seq_id\x18\x0c \x01(\x05\x12\x0e\n\x06\x64\x65l_id\x18\r \x01(\x05\x12\x16\n\x0elast_seen_time\x18\x0e \x01(\x03\x12\x1c\n\x14last_seen_user_agent\x18\x0f \x01(\t\";\n\tDelValues\x12\x0e\n\x06\x64\x65l_id\x18\x01 \x01(\x05\x12\x1e\n\x07\x64\x65l_seq\x18\x02 \x03(\x0b\x32\r.pbx.SeqRange\"\x9f\x01\n\nServerCtrl\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x0c\n\x04\x63ode\x18\x03 \x01(\x05\x12\x0c\n\x04text\x18\x04 \x01(\t\x12+\n\x06params\x18\x05 \x03(\x0b\x32\x1b.pbx.ServerCtrl.ParamsEntry\x1a-\n\x0bParamsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"\xcf\x01\n\nServerData\x12\r\n\x05topic\x18\x01 \x01(\t\x12\x14\n\x0c\x66rom_user_id\x18\x02 \x01(\t\x12\x11\n\ttimestamp\x18\x07 \x01(\x03\x12\x12\n\ndeleted_at\x18\x03 \x01(\x03\x12\x0e\n\x06seq_id\x18\x04 \x01(\x05\x12\'\n\x04head\x18\x05 \x03(\x0b\x32\x19.pbx.ServerData.HeadEntry\x12\x0f\n\x07\x63ontent\x18\x06 \x01(\x0c\x1a+\n\tHeadEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"\xe4\x02\n\nServerPres\x12\r\n\x05topic\x18\x01 \x01(\t\x12\x0b\n\x03src\x18\x02 \x01(\t\x12\"\n\x04what\x18\x03 \x01(\x0e\x32\x14.pbx.ServerPres.What\x12\x12\n\nuser_agent\x18\x04 \x01(\t\x12\x0e\n\x06seq_id\x18\x05 \x01(\x05\x12\x0e\n\x06\x64\x65l_id\x18\x06 \x01(\x05\x12\x1e\n\x07\x64\x65l_seq\x18\x07 \x03(\x0b\x32\r.pbx.SeqRange\x12\x16\n\x0etarget_user_id\x18\x08 \x01(\t\x12\x15\n\ractor_user_id\x18\t \x01(\t\x12\x1c\n\x03\x61\x63s\x18\n \x01(\x0b\x32\x0f.pbx.AccessMode\"u\n\x04What\x12\x06\n\x02ON\x10\x00\x12\x07\n\x03OFF\x10\x01\x12\x06\n\x02UA\x10\x03\x12\x07\n\x03UPD\x10\x04\x12\x08\n\x04GONE\x10\x05\x12\x07\n\x03\x41\x43S\x10\x06\x12\x08\n\x04TERM\x10\x07\x12\x07\n\x03MSG\x10\x08\x12\x08\n\x04READ\x10\t\x12\x08\n\x04RECV\x10\n\x12\x07\n\x03\x44\x45L\x10\x0b\x12\x08\n\x04TAGS\x10\x0c\"\xab\x01\n\nServerMeta\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x1c\n\x04\x64\x65sc\x18\x03 \x01(\x0b\x32\x0e.pbx.TopicDesc\x12\x1a\n\x03sub\x18\x04 \x03(\x0b\x32\r.pbx.TopicSub\x12\x1b\n\x03\x64\x65l\x18\x05 \x01(\x0b\x32\x0e.pbx.DelValues\x12\x0c\n\x04tags\x18\x06 \x03(\t\x12\x1d\n\x04\x63red\x18\x07 \x03(\x0b\x32\x0f.pbx.ServerCred\"^\n\nServerInfo\x12\r\n\x05topic\x18\x01 \x01(\t\x12\x14\n\x0c\x66rom_user_id\x18\x02 \x01(\t\x12\x1b\n\x04what\x18\x03 \x01(\x0e\x32\r.pbx.InfoNote\x12\x0e\n\x06seq_id\x18\x04 \x01(\x05\"\xca\x01\n\tServerMsg\x12\x1f\n\x04\x63trl\x18\x01 \x01(\x0b\x32\x0f.pbx.ServerCtrlH\x00\x12\x1f\n\x04\x64\x61ta\x18\x02 \x01(\x0b\x32\x0f.pbx.ServerDataH\x00\x12\x1f\n\x04pres\x18\x03 \x01(\x0b\x32\x0f.pbx.ServerPresH\x00\x12\x1f\n\x04meta\x18\x04 \x01(\x0b\x32\x0f.pbx.ServerMetaH\x00\x12\x1f\n\x04info\x18\x05 \x01(\x0b\x32\x0f.pbx.ServerInfoH\x00\x12\r\n\x05topic\x18\x06 \x01(\tB\t\n\x07Message\"j\n\nServerResp\x12\x1d\n\x06status\x18\x01 \x01(\x0e\x32\r.pbx.RespCode\x12\x1e\n\x06srvmsg\x18\x02 \x01(\x0b\x32\x0e.pbx.ServerMsg\x12\x1d\n\x05\x63lmsg\x18\x03 \x01(\x0b\x32\x0e.pbx.ClientMsg\"\xa0\x01\n\x07Session\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x0f\n\x07user_id\x18\x02 \x01(\t\x12\"\n\nauth_level\x18\x03 \x01(\x0e\x32\x0e.pbx.AuthLevel\x12\x13\n\x0bremote_addr\x18\x04 \x01(\t\x12\x12\n\nuser_agent\x18\x05 \x01(\t\x12\x11\n\tdevice_id\x18\x06 \x01(\t\x12\x10\n\x08language\x18\x07 \x01(\t\"D\n\tClientReq\x12\x1b\n\x03msg\x18\x01 \x01(\x0b\x32\x0e.pbx.ClientMsg\x12\x1a\n\x04sess\x18\x02 \x01(\x0b\x32\x0c.pbx.Session\"-\n\x0bSearchQuery\x12\x0f\n\x07user_id\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\"Z\n\x0bSearchFound\x12\x1d\n\x06status\x18\x01 \x01(\x0e\x32\r.pbx.RespCode\x12\r\n\x05query\x18\x02 \x01(\t\x12\x1d\n\x06result\x18\x03 \x03(\x0b\x32\r.pbx.TopicSub\"S\n\nTopicEvent\x12\x19\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\t.pbx.Crud\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x1c\n\x04\x64\x65sc\x18\x03 \x01(\x0b\x32\x0e.pbx.TopicDesc\"\x82\x01\n\x0c\x41\x63\x63ountEvent\x12\x19\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\t.pbx.Crud\x12\x0f\n\x07user_id\x18\x02 \x01(\t\x12(\n\x0b\x64\x65\x66\x61ult_acs\x18\x03 \x01(\x0b\x32\x13.pbx.DefaultAcsMode\x12\x0e\n\x06public\x18\x04 \x01(\x0c\x12\x0c\n\x04tags\x18\x08 \x03(\t\"\xb0\x01\n\x11SubscriptionEvent\x12\x19\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\t.pbx.Crud\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x0f\n\x07user_id\x18\x03 \x01(\t\x12\x0e\n\x06\x64\x65l_id\x18\x04 \x01(\x05\x12\x0f\n\x07read_id\x18\x05 \x01(\x05\x12\x0f\n\x07recv_id\x18\x06 \x01(\x05\x12\x1d\n\x04mode\x18\x07 \x01(\x0b\x32\x0f.pbx.AccessMode\x12\x0f\n\x07private\x18\x08 \x01(\x0c\"G\n\x0cMessageEvent\x12\x19\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\t.pbx.Crud\x12\x1c\n\x03msg\x18\x02 \x01(\x0b\x32\x0f.pbx.ServerData*3\n\tAuthLevel\x12\x08\n\x04NONE\x10\x00\x12\x08\n\x04\x41NON\x10\n\x12\x08\n\x04\x41UTH\x10\x14\x12\x08\n\x04ROOT\x10\x1e*&\n\x08InfoNote\x12\x08\n\x04READ\x10\x00\x12\x08\n\x04RECV\x10\x01\x12\x06\n\x02KP\x10\x02*<\n\x08RespCode\x12\x0c\n\x08\x43ONTINUE\x10\x00\x12\x08\n\x04\x44ROP\x10\x01\x12\x0b\n\x07RESPOND\x10\x02\x12\x0b\n\x07REPLACE\x10\x03**\n\x04\x43rud\x12\n\n\x06\x43REATE\x10\x00\x12\n\n\x06UPDATE\x10\x01\x12\n\n\x06\x44\x45LETE\x10\x02\x32;\n\x04Node\x12\x33\n\x0bMessageLoop\x12\x0e.pbx.ClientMsg\x1a\x0e.pbx.ServerMsg\"\x00(\x01\x30\x01\x32\x9f\x02\n\x06Plugin\x12-\n\x08\x46ireHose\x12\x0e.pbx.ClientReq\x1a\x0f.pbx.ServerResp\"\x00\x12,\n\x04\x46ind\x12\x10.pbx.SearchQuery\x1a\x10.pbx.SearchFound\"\x00\x12+\n\x07\x41\x63\x63ount\x12\x11.pbx.AccountEvent\x1a\x0b.pbx.Unused\"\x00\x12\'\n\x05Topic\x12\x0f.pbx.TopicEvent\x1a\x0b.pbx.Unused\"\x00\x12\x35\n\x0cSubscription\x12\x16.pbx.SubscriptionEvent\x1a\x0b.pbx.Unused\"\x00\x12+\n\x07Message\x12\x11.pbx.MessageEvent\x1a\x0b.pbx.Unused\"\x00\x62\x06proto3') + serialized_pb=_b('\n\x0bmodel.proto\x12\x03pbx\"\x08\n\x06Unused\",\n\x0e\x44\x65\x66\x61ultAcsMode\x12\x0c\n\x04\x61uth\x18\x01 \x01(\t\x12\x0c\n\x04\x61non\x18\x02 \x01(\t\")\n\nAccessMode\x12\x0c\n\x04want\x18\x01 \x01(\t\x12\r\n\x05given\x18\x02 \x01(\t\"\'\n\x06SetSub\x12\x0f\n\x07user_id\x18\x01 \x01(\t\x12\x0c\n\x04mode\x18\x02 \x01(\t\"M\n\nClientCred\x12\x0e\n\x06method\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x10\n\x08response\x18\x03 \x01(\t\x12\x0e\n\x06params\x18\x04 \x01(\x0c\"T\n\x07SetDesc\x12(\n\x0b\x64\x65\x66\x61ult_acs\x18\x01 \x01(\x0b\x32\x13.pbx.DefaultAcsMode\x12\x0e\n\x06public\x18\x02 \x01(\x0c\x12\x0f\n\x07private\x18\x03 \x01(\x0c\"u\n\x07GetOpts\x12\x19\n\x11if_modified_since\x18\x01 \x01(\x03\x12\x0c\n\x04user\x18\x02 \x01(\t\x12\r\n\x05topic\x18\x03 \x01(\t\x12\x10\n\x08since_id\x18\x04 \x01(\x05\x12\x11\n\tbefore_id\x18\x05 \x01(\x05\x12\r\n\x05limit\x18\x06 \x01(\x05\"k\n\x08GetQuery\x12\x0c\n\x04what\x18\x01 \x01(\t\x12\x1a\n\x04\x64\x65sc\x18\x02 \x01(\x0b\x32\x0c.pbx.GetOpts\x12\x19\n\x03sub\x18\x03 \x01(\x0b\x32\x0c.pbx.GetOpts\x12\x1a\n\x04\x64\x61ta\x18\x04 \x01(\x0b\x32\x0c.pbx.GetOpts\"m\n\x08SetQuery\x12\x1a\n\x04\x64\x65sc\x18\x01 \x01(\x0b\x32\x0c.pbx.SetDesc\x12\x18\n\x03sub\x18\x02 \x01(\x0b\x32\x0b.pbx.SetSub\x12\x0c\n\x04tags\x18\x03 \x03(\t\x12\x1d\n\x04\x63red\x18\x04 \x01(\x0b\x32\x0f.pbx.ClientCred\"#\n\x08SeqRange\x12\x0b\n\x03low\x18\x01 \x01(\x05\x12\n\n\x02hi\x18\x02 \x01(\x05\"j\n\x08\x43lientHi\x12\n\n\x02id\x18\x01 \x01(\t\x12\x12\n\nuser_agent\x18\x02 \x01(\t\x12\x0b\n\x03ver\x18\x03 \x01(\t\x12\x11\n\tdevice_id\x18\x04 \x01(\t\x12\x0c\n\x04lang\x18\x05 \x01(\t\x12\x10\n\x08platform\x18\x06 \x01(\t\"\xaf\x01\n\tClientAcc\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0f\n\x07user_id\x18\x02 \x01(\t\x12\x0e\n\x06scheme\x18\x03 \x01(\t\x12\x0e\n\x06secret\x18\x04 \x01(\x0c\x12\r\n\x05login\x18\x05 \x01(\x08\x12\x0c\n\x04tags\x18\x06 \x03(\t\x12\x1a\n\x04\x64\x65sc\x18\x07 \x01(\x0b\x32\x0c.pbx.SetDesc\x12\x1d\n\x04\x63red\x18\x08 \x03(\x0b\x32\x0f.pbx.ClientCred\x12\r\n\x05token\x18\t \x01(\x0c\"X\n\x0b\x43lientLogin\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0e\n\x06scheme\x18\x02 \x01(\t\x12\x0e\n\x06secret\x18\x03 \x01(\x0c\x12\x1d\n\x04\x63red\x18\x04 \x03(\x0b\x32\x0f.pbx.ClientCred\"~\n\tClientSub\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12 \n\tset_query\x18\x03 \x01(\x0b\x32\r.pbx.SetQuery\x12 \n\tget_query\x18\x04 \x01(\x0b\x32\r.pbx.GetQuery\x12\x12\n\nbackground\x18\x05 \x01(\x08\"7\n\x0b\x43lientLeave\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\r\n\x05unsub\x18\x03 \x01(\x08\"\x9d\x01\n\tClientPub\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x0f\n\x07no_echo\x18\x03 \x01(\x08\x12&\n\x04head\x18\x04 \x03(\x0b\x32\x18.pbx.ClientPub.HeadEntry\x12\x0f\n\x07\x63ontent\x18\x05 \x01(\x0c\x1a+\n\tHeadEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"D\n\tClientGet\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x1c\n\x05query\x18\x03 \x01(\x0b\x32\r.pbx.GetQuery\"D\n\tClientSet\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x1c\n\x05query\x18\x03 \x01(\x0b\x32\r.pbx.SetQuery\"\xe0\x01\n\tClientDel\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12!\n\x04what\x18\x03 \x01(\x0e\x32\x13.pbx.ClientDel.What\x12\x1e\n\x07\x64\x65l_seq\x18\x04 \x03(\x0b\x32\r.pbx.SeqRange\x12\x0f\n\x07user_id\x18\x05 \x01(\t\x12\x1d\n\x04\x63red\x18\x06 \x01(\x0b\x32\x0f.pbx.ClientCred\x12\x0c\n\x04hard\x18\x07 \x01(\x08\"7\n\x04What\x12\x07\n\x03MSG\x10\x00\x12\t\n\x05TOPIC\x10\x01\x12\x07\n\x03SUB\x10\x02\x12\x08\n\x04USER\x10\x03\x12\x08\n\x04\x43RED\x10\x04\"H\n\nClientNote\x12\r\n\x05topic\x18\x01 \x01(\t\x12\x1b\n\x04what\x18\x02 \x01(\x0e\x32\r.pbx.InfoNote\x12\x0e\n\x06seq_id\x18\x03 \x01(\x05\"\x8e\x03\n\tClientMsg\x12\x1b\n\x02hi\x18\x01 \x01(\x0b\x32\r.pbx.ClientHiH\x00\x12\x1d\n\x03\x61\x63\x63\x18\x02 \x01(\x0b\x32\x0e.pbx.ClientAccH\x00\x12!\n\x05login\x18\x03 \x01(\x0b\x32\x10.pbx.ClientLoginH\x00\x12\x1d\n\x03sub\x18\x04 \x01(\x0b\x32\x0e.pbx.ClientSubH\x00\x12!\n\x05leave\x18\x05 \x01(\x0b\x32\x10.pbx.ClientLeaveH\x00\x12\x1d\n\x03pub\x18\x06 \x01(\x0b\x32\x0e.pbx.ClientPubH\x00\x12\x1d\n\x03get\x18\x07 \x01(\x0b\x32\x0e.pbx.ClientGetH\x00\x12\x1d\n\x03set\x18\x08 \x01(\x0b\x32\x0e.pbx.ClientSetH\x00\x12\x1d\n\x03\x64\x65l\x18\t \x01(\x0b\x32\x0e.pbx.ClientDelH\x00\x12\x1f\n\x04note\x18\n \x01(\x0b\x32\x0f.pbx.ClientNoteH\x00\x12\x14\n\x0con_behalf_of\x18\x0b \x01(\t\x12\"\n\nauth_level\x18\x0c \x01(\x0e\x32\x0e.pbx.AuthLevelB\t\n\x07Message\"9\n\nServerCred\x12\x0e\n\x06method\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x0c\n\x04\x64one\x18\x03 \x01(\x08\"\xed\x01\n\tTopicDesc\x12\x12\n\ncreated_at\x18\x01 \x01(\x03\x12\x12\n\nupdated_at\x18\x02 \x01(\x03\x12\x12\n\ntouched_at\x18\x03 \x01(\x03\x12#\n\x06\x64\x65\x66\x61\x63s\x18\x04 \x01(\x0b\x32\x13.pbx.DefaultAcsMode\x12\x1c\n\x03\x61\x63s\x18\x05 \x01(\x0b\x32\x0f.pbx.AccessMode\x12\x0e\n\x06seq_id\x18\x06 \x01(\x05\x12\x0f\n\x07read_id\x18\x07 \x01(\x05\x12\x0f\n\x07recv_id\x18\x08 \x01(\x05\x12\x0e\n\x06\x64\x65l_id\x18\t \x01(\x05\x12\x0e\n\x06public\x18\n \x01(\x0c\x12\x0f\n\x07private\x18\x0b \x01(\x0c\"\xad\x02\n\x08TopicSub\x12\x12\n\nupdated_at\x18\x01 \x01(\x03\x12\x12\n\ndeleted_at\x18\x02 \x01(\x03\x12\x0e\n\x06online\x18\x03 \x01(\x08\x12\x1c\n\x03\x61\x63s\x18\x04 \x01(\x0b\x32\x0f.pbx.AccessMode\x12\x0f\n\x07read_id\x18\x05 \x01(\x05\x12\x0f\n\x07recv_id\x18\x06 \x01(\x05\x12\x0e\n\x06public\x18\x07 \x01(\x0c\x12\x0f\n\x07private\x18\x08 \x01(\x0c\x12\x0f\n\x07user_id\x18\t \x01(\t\x12\r\n\x05topic\x18\n \x01(\t\x12\x12\n\ntouched_at\x18\x0b \x01(\x03\x12\x0e\n\x06seq_id\x18\x0c \x01(\x05\x12\x0e\n\x06\x64\x65l_id\x18\r \x01(\x05\x12\x16\n\x0elast_seen_time\x18\x0e \x01(\x03\x12\x1c\n\x14last_seen_user_agent\x18\x0f \x01(\t\";\n\tDelValues\x12\x0e\n\x06\x64\x65l_id\x18\x01 \x01(\x05\x12\x1e\n\x07\x64\x65l_seq\x18\x02 \x03(\x0b\x32\r.pbx.SeqRange\"\x9f\x01\n\nServerCtrl\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x0c\n\x04\x63ode\x18\x03 \x01(\x05\x12\x0c\n\x04text\x18\x04 \x01(\t\x12+\n\x06params\x18\x05 \x03(\x0b\x32\x1b.pbx.ServerCtrl.ParamsEntry\x1a-\n\x0bParamsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"\xcf\x01\n\nServerData\x12\r\n\x05topic\x18\x01 \x01(\t\x12\x14\n\x0c\x66rom_user_id\x18\x02 \x01(\t\x12\x11\n\ttimestamp\x18\x07 \x01(\x03\x12\x12\n\ndeleted_at\x18\x03 \x01(\x03\x12\x0e\n\x06seq_id\x18\x04 \x01(\x05\x12\'\n\x04head\x18\x05 \x03(\x0b\x32\x19.pbx.ServerData.HeadEntry\x12\x0f\n\x07\x63ontent\x18\x06 \x01(\x0c\x1a+\n\tHeadEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"\xe4\x02\n\nServerPres\x12\r\n\x05topic\x18\x01 \x01(\t\x12\x0b\n\x03src\x18\x02 \x01(\t\x12\"\n\x04what\x18\x03 \x01(\x0e\x32\x14.pbx.ServerPres.What\x12\x12\n\nuser_agent\x18\x04 \x01(\t\x12\x0e\n\x06seq_id\x18\x05 \x01(\x05\x12\x0e\n\x06\x64\x65l_id\x18\x06 \x01(\x05\x12\x1e\n\x07\x64\x65l_seq\x18\x07 \x03(\x0b\x32\r.pbx.SeqRange\x12\x16\n\x0etarget_user_id\x18\x08 \x01(\t\x12\x15\n\ractor_user_id\x18\t \x01(\t\x12\x1c\n\x03\x61\x63s\x18\n \x01(\x0b\x32\x0f.pbx.AccessMode\"u\n\x04What\x12\x06\n\x02ON\x10\x00\x12\x07\n\x03OFF\x10\x01\x12\x06\n\x02UA\x10\x03\x12\x07\n\x03UPD\x10\x04\x12\x08\n\x04GONE\x10\x05\x12\x07\n\x03\x41\x43S\x10\x06\x12\x08\n\x04TERM\x10\x07\x12\x07\n\x03MSG\x10\x08\x12\x08\n\x04READ\x10\t\x12\x08\n\x04RECV\x10\n\x12\x07\n\x03\x44\x45L\x10\x0b\x12\x08\n\x04TAGS\x10\x0c\"\xab\x01\n\nServerMeta\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x1c\n\x04\x64\x65sc\x18\x03 \x01(\x0b\x32\x0e.pbx.TopicDesc\x12\x1a\n\x03sub\x18\x04 \x03(\x0b\x32\r.pbx.TopicSub\x12\x1b\n\x03\x64\x65l\x18\x05 \x01(\x0b\x32\x0e.pbx.DelValues\x12\x0c\n\x04tags\x18\x06 \x03(\t\x12\x1d\n\x04\x63red\x18\x07 \x03(\x0b\x32\x0f.pbx.ServerCred\"^\n\nServerInfo\x12\r\n\x05topic\x18\x01 \x01(\t\x12\x14\n\x0c\x66rom_user_id\x18\x02 \x01(\t\x12\x1b\n\x04what\x18\x03 \x01(\x0e\x32\r.pbx.InfoNote\x12\x0e\n\x06seq_id\x18\x04 \x01(\x05\"\xca\x01\n\tServerMsg\x12\x1f\n\x04\x63trl\x18\x01 \x01(\x0b\x32\x0f.pbx.ServerCtrlH\x00\x12\x1f\n\x04\x64\x61ta\x18\x02 \x01(\x0b\x32\x0f.pbx.ServerDataH\x00\x12\x1f\n\x04pres\x18\x03 \x01(\x0b\x32\x0f.pbx.ServerPresH\x00\x12\x1f\n\x04meta\x18\x04 \x01(\x0b\x32\x0f.pbx.ServerMetaH\x00\x12\x1f\n\x04info\x18\x05 \x01(\x0b\x32\x0f.pbx.ServerInfoH\x00\x12\r\n\x05topic\x18\x06 \x01(\tB\t\n\x07Message\"j\n\nServerResp\x12\x1d\n\x06status\x18\x01 \x01(\x0e\x32\r.pbx.RespCode\x12\x1e\n\x06srvmsg\x18\x02 \x01(\x0b\x32\x0e.pbx.ServerMsg\x12\x1d\n\x05\x63lmsg\x18\x03 \x01(\x0b\x32\x0e.pbx.ClientMsg\"\xa0\x01\n\x07Session\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x0f\n\x07user_id\x18\x02 \x01(\t\x12\"\n\nauth_level\x18\x03 \x01(\x0e\x32\x0e.pbx.AuthLevel\x12\x13\n\x0bremote_addr\x18\x04 \x01(\t\x12\x12\n\nuser_agent\x18\x05 \x01(\t\x12\x11\n\tdevice_id\x18\x06 \x01(\t\x12\x10\n\x08language\x18\x07 \x01(\t\"D\n\tClientReq\x12\x1b\n\x03msg\x18\x01 \x01(\x0b\x32\x0e.pbx.ClientMsg\x12\x1a\n\x04sess\x18\x02 \x01(\x0b\x32\x0c.pbx.Session\"-\n\x0bSearchQuery\x12\x0f\n\x07user_id\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\"Z\n\x0bSearchFound\x12\x1d\n\x06status\x18\x01 \x01(\x0e\x32\r.pbx.RespCode\x12\r\n\x05query\x18\x02 \x01(\t\x12\x1d\n\x06result\x18\x03 \x03(\x0b\x32\r.pbx.TopicSub\"S\n\nTopicEvent\x12\x19\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\t.pbx.Crud\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x1c\n\x04\x64\x65sc\x18\x03 \x01(\x0b\x32\x0e.pbx.TopicDesc\"\x82\x01\n\x0c\x41\x63\x63ountEvent\x12\x19\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\t.pbx.Crud\x12\x0f\n\x07user_id\x18\x02 \x01(\t\x12(\n\x0b\x64\x65\x66\x61ult_acs\x18\x03 \x01(\x0b\x32\x13.pbx.DefaultAcsMode\x12\x0e\n\x06public\x18\x04 \x01(\x0c\x12\x0c\n\x04tags\x18\x08 \x03(\t\"\xb0\x01\n\x11SubscriptionEvent\x12\x19\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\t.pbx.Crud\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x0f\n\x07user_id\x18\x03 \x01(\t\x12\x0e\n\x06\x64\x65l_id\x18\x04 \x01(\x05\x12\x0f\n\x07read_id\x18\x05 \x01(\x05\x12\x0f\n\x07recv_id\x18\x06 \x01(\x05\x12\x1d\n\x04mode\x18\x07 \x01(\x0b\x32\x0f.pbx.AccessMode\x12\x0f\n\x07private\x18\x08 \x01(\x0c\"G\n\x0cMessageEvent\x12\x19\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\t.pbx.Crud\x12\x1c\n\x03msg\x18\x02 \x01(\x0b\x32\x0f.pbx.ServerData*3\n\tAuthLevel\x12\x08\n\x04NONE\x10\x00\x12\x08\n\x04\x41NON\x10\n\x12\x08\n\x04\x41UTH\x10\x14\x12\x08\n\x04ROOT\x10\x1e*&\n\x08InfoNote\x12\x08\n\x04READ\x10\x00\x12\x08\n\x04RECV\x10\x01\x12\x06\n\x02KP\x10\x02*<\n\x08RespCode\x12\x0c\n\x08\x43ONTINUE\x10\x00\x12\x08\n\x04\x44ROP\x10\x01\x12\x0b\n\x07RESPOND\x10\x02\x12\x0b\n\x07REPLACE\x10\x03**\n\x04\x43rud\x12\n\n\x06\x43REATE\x10\x00\x12\n\n\x06UPDATE\x10\x01\x12\n\n\x06\x44\x45LETE\x10\x02\x32;\n\x04Node\x12\x33\n\x0bMessageLoop\x12\x0e.pbx.ClientMsg\x1a\x0e.pbx.ServerMsg\"\x00(\x01\x30\x01\x32\x9f\x02\n\x06Plugin\x12-\n\x08\x46ireHose\x12\x0e.pbx.ClientReq\x1a\x0f.pbx.ServerResp\"\x00\x12,\n\x04\x46ind\x12\x10.pbx.SearchQuery\x1a\x10.pbx.SearchFound\"\x00\x12+\n\x07\x41\x63\x63ount\x12\x11.pbx.AccountEvent\x1a\x0b.pbx.Unused\"\x00\x12\'\n\x05Topic\x12\x0f.pbx.TopicEvent\x1a\x0b.pbx.Unused\"\x00\x12\x35\n\x0cSubscription\x12\x16.pbx.SubscriptionEvent\x1a\x0b.pbx.Unused\"\x00\x12+\n\x07Message\x12\x11.pbx.MessageEvent\x1a\x0b.pbx.Unused\"\x00\x62\x06proto3') ) _AUTHLEVEL = _descriptor.EnumDescriptor( @@ -48,8 +48,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=5064, - serialized_end=5115, + serialized_start=5084, + serialized_end=5135, ) _sym_db.RegisterEnumDescriptor(_AUTHLEVEL) @@ -75,8 +75,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=5117, - serialized_end=5155, + serialized_start=5137, + serialized_end=5175, ) _sym_db.RegisterEnumDescriptor(_INFONOTE) @@ -106,8 +106,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=5157, - serialized_end=5217, + serialized_start=5177, + serialized_end=5237, ) _sym_db.RegisterEnumDescriptor(_RESPCODE) @@ -133,8 +133,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=5219, - serialized_end=5261, + serialized_start=5239, + serialized_end=5281, ) _sym_db.RegisterEnumDescriptor(_CRUD) @@ -184,8 +184,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=1712, - serialized_end=1767, + serialized_start=1732, + serialized_end=1787, ) _sym_db.RegisterEnumDescriptor(_CLIENTDEL_WHAT) @@ -246,8 +246,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=3520, - serialized_end=3637, + serialized_start=3540, + serialized_end=3657, ) _sym_db.RegisterEnumDescriptor(_SERVERPRES_WHAT) @@ -935,6 +935,13 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='background', full_name='pbx.ClientSub.background', index=4, + number=5, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -948,7 +955,7 @@ oneofs=[ ], serialized_start=1077, - serialized_end=1183, + serialized_end=1203, ) @@ -992,8 +999,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1185, - serialized_end=1240, + serialized_start=1205, + serialized_end=1260, ) @@ -1030,8 +1037,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1357, - serialized_end=1400, + serialized_start=1377, + serialized_end=1420, ) _CLIENTPUB = _descriptor.Descriptor( @@ -1088,8 +1095,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1243, - serialized_end=1400, + serialized_start=1263, + serialized_end=1420, ) @@ -1133,8 +1140,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1402, - serialized_end=1470, + serialized_start=1422, + serialized_end=1490, ) @@ -1178,8 +1185,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1472, - serialized_end=1540, + serialized_start=1492, + serialized_end=1560, ) @@ -1252,8 +1259,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1543, - serialized_end=1767, + serialized_start=1563, + serialized_end=1787, ) @@ -1297,8 +1304,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1769, - serialized_end=1841, + serialized_start=1789, + serialized_end=1861, ) @@ -1408,8 +1415,8 @@ name='Message', full_name='pbx.ClientMsg.Message', index=0, containing_type=None, fields=[]), ], - serialized_start=1844, - serialized_end=2242, + serialized_start=1864, + serialized_end=2262, ) @@ -1453,8 +1460,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2244, - serialized_end=2301, + serialized_start=2264, + serialized_end=2321, ) @@ -1554,8 +1561,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2304, - serialized_end=2541, + serialized_start=2324, + serialized_end=2561, ) @@ -1683,8 +1690,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2544, - serialized_end=2845, + serialized_start=2564, + serialized_end=2865, ) @@ -1721,8 +1728,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2847, - serialized_end=2906, + serialized_start=2867, + serialized_end=2926, ) @@ -1759,8 +1766,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=3023, - serialized_end=3068, + serialized_start=3043, + serialized_end=3088, ) _SERVERCTRL = _descriptor.Descriptor( @@ -1817,8 +1824,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2909, - serialized_end=3068, + serialized_start=2929, + serialized_end=3088, ) @@ -1855,8 +1862,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1357, - serialized_end=1400, + serialized_start=1377, + serialized_end=1420, ) _SERVERDATA = _descriptor.Descriptor( @@ -1927,8 +1934,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=3071, - serialized_end=3278, + serialized_start=3091, + serialized_end=3298, ) @@ -2022,8 +2029,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=3281, - serialized_end=3637, + serialized_start=3301, + serialized_end=3657, ) @@ -2095,8 +2102,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=3640, - serialized_end=3811, + serialized_start=3660, + serialized_end=3831, ) @@ -2147,8 +2154,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=3813, - serialized_end=3907, + serialized_start=3833, + serialized_end=3927, ) @@ -2216,8 +2223,8 @@ name='Message', full_name='pbx.ServerMsg.Message', index=0, containing_type=None, fields=[]), ], - serialized_start=3910, - serialized_end=4112, + serialized_start=3930, + serialized_end=4132, ) @@ -2261,8 +2268,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4114, - serialized_end=4220, + serialized_start=4134, + serialized_end=4240, ) @@ -2334,8 +2341,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4223, - serialized_end=4383, + serialized_start=4243, + serialized_end=4403, ) @@ -2372,8 +2379,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4385, - serialized_end=4453, + serialized_start=4405, + serialized_end=4473, ) @@ -2410,8 +2417,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4455, - serialized_end=4500, + serialized_start=4475, + serialized_end=4520, ) @@ -2455,8 +2462,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4502, - serialized_end=4592, + serialized_start=4522, + serialized_end=4612, ) @@ -2500,8 +2507,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4594, - serialized_end=4677, + serialized_start=4614, + serialized_end=4697, ) @@ -2559,8 +2566,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4680, - serialized_end=4810, + serialized_start=4700, + serialized_end=4830, ) @@ -2639,8 +2646,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4813, - serialized_end=4989, + serialized_start=4833, + serialized_end=5009, ) @@ -2677,8 +2684,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4991, - serialized_end=5062, + serialized_start=5011, + serialized_end=5082, ) _SETDESC.fields_by_name['default_acs'].message_type = _DEFAULTACSMODE @@ -3157,8 +3164,8 @@ file=DESCRIPTOR, index=0, serialized_options=None, - serialized_start=5263, - serialized_end=5322, + serialized_start=5283, + serialized_end=5342, methods=[ _descriptor.MethodDescriptor( name='MessageLoop', @@ -3181,8 +3188,8 @@ file=DESCRIPTOR, index=1, serialized_options=None, - serialized_start=5325, - serialized_end=5612, + serialized_start=5345, + serialized_end=5632, methods=[ _descriptor.MethodDescriptor( name='FireHose', diff --git a/server/cluster.go b/server/cluster.go index e0e56cffa..ce4faa232 100644 --- a/server/cluster.go +++ b/server/cluster.go @@ -122,8 +122,9 @@ type ClusterReq struct { Sess *ClusterSess // True if the original session has disconnected SessGone bool - // For {pres} messages, indicates whether to break reply loop. - PresWantReply bool + + // UNroutable components of {pres} message which have to be sent intra-cluster. + SrvPres ClusterPresExt } // ClusterResp is a Master to Proxy response message. @@ -133,6 +134,27 @@ type ClusterResp struct { FromSID string } +// ClusterPresExt encapsulates externally unroutable parameters of {pres} message which have to be sent intra-cluster. +type ClusterPresExt struct { + // Flag to break the reply loop + WantReply bool + + // Additional access mode filters when senting to topic's online members. Both filter conditions must be true. + // send only to those who have this access mode. + FilterIn int + // skip those who have this access mode. + FilterOut int + + // When sending to 'me', skip sessions subscribed to this topic + SkipTopic string + + // Send to sessions of a single user only + SingleUser string + + // Exclude sessions of a single user + ExcludeUser string +} + // Handle outbound node communication: read messages from the channel, forward to remote nodes. // FIXME(gene): this will drain the outbound queue in case of a failure: all unprocessed messages will be dropped. // Maybe it's a good thing, maybe not. @@ -392,9 +414,6 @@ func (c *Cluster) Route(msg *ClusterReq, rejected *bool) error { return nil } msg.SrvMsg.rcptto = msg.RcptTo - if msg.SrvMsg.Pres != nil && msg.PresWantReply { - msg.SrvMsg.Pres.wantReply = true - } globals.hub.route <- msg.SrvMsg return nil } @@ -580,9 +599,6 @@ func (c *Cluster) routeToTopicIntraCluster(topic string, msg *ServerComMessage) Fingerprint: c.fingerprint, RcptTo: topic, SrvMsg: msg} - if msg.Pres != nil && msg.Pres.wantReply { - req.PresWantReply = true - } return n.route(req) } diff --git a/server/datamodel.go b/server/datamodel.go index 5b4dacc86..d71fb8d46 100644 --- a/server/datamodel.go +++ b/server/datamodel.go @@ -151,10 +151,14 @@ type MsgClientSub struct { Id string `json:"id,omitempty"` Topic string `json:"topic"` - // mirrors {set} + // The subscription request is non-interactive, i.e. issued by a service on behalf of a user. + // This affects presence notifications. + Background bool `json:"bkg,omitempty"` + + // Mirrors {set}. Set *MsgSetQuery `json:"set,omitempty"` - // mirrors {get} + // Mirrors {get}. Get *MsgGetQuery `json:"get,omitempty"` } @@ -457,25 +461,26 @@ type MsgServerPres struct { // to allow different handling on the client Acs *MsgAccessMode `json:"dacs,omitempty"` - // UNroutable params + // UNroutable params. All marked with `json:"-"` to exclude from json marshalling. + // They are still serialized for intra-cluster communication. // Flag to break the reply loop - wantReply bool + WantReply bool `json:"-"` // Additional access mode filters when senting to topic's online members. Both filter conditions must be true. // send only to those who have this access mode. - filterIn int + FilterIn int `json:"-"` // skip those who have this access mode. - filterOut int + FilterOut int `json:"-"` // When sending to 'me', skip sessions subscribed to this topic - skipTopic string + SkipTopic string `json:"-"` // Send to sessions of a single user only - singleUser string + SingleUser string `json:"-"` // Exclude sessions of a single user - excludeUser string + ExcludeUser string `json:"-"` } // MsgServerMeta is a topic metadata {meta} update. diff --git a/server/db/mongodb/adapter.go b/server/db/mongodb/adapter.go new file mode 100644 index 000000000..ae29208ce --- /dev/null +++ b/server/db/mongodb/adapter.go @@ -0,0 +1,2166 @@ +// +build mongodb + +package mongodb + +import ( + "context" + "encoding/json" + "errors" + "log" + "strconv" + "strings" + "time" + + "github.com/tinode/chat/server/auth" + "github.com/tinode/chat/server/store" + t "github.com/tinode/chat/server/store/types" + b "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + mdb "go.mongodb.org/mongo-driver/mongo" + mdbopts "go.mongodb.org/mongo-driver/mongo/options" +) + +// adapter holds MongoDB connection data. +type adapter struct { + conn *mdb.Client + db *mdb.Database + dbName string + maxResults int + version int + ctx context.Context + useTransactions bool +} + +const ( + defaultHost = "localhost:27017" + defaultDatabase = "tinode" + + adpVersion = 110 + adapterName = "mongodb" + + defaultMaxResults = 1024 +) + +// See https://godoc.org/go.mongodb.org/mongo-driver/mongo/options#ClientOptions for explanations. +type configType struct { + Addresses interface{} `json:"addresses,omitempty"` + ConnectTimeout int `json:"timeout,omitempty"` + + // Options separately from ClientOptions (custom options): + Database string `json:"database,omitempty"` + ReplicaSet string `json:"replica_set,omitempty"` + + AuthSource string `json:"auth_source,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` +} + +// Open initializes mongodb session +func (a *adapter) Open(jsonconfig json.RawMessage) error { + if a.conn != nil { + return errors.New("adapter mongodb is already connected") + } + + var err error + var config configType + if err = json.Unmarshal(jsonconfig, &config); err != nil { + return errors.New("adapter mongodb failed to parse config: " + err.Error()) + } + + var opts mdbopts.ClientOptions + + if config.Addresses == nil { + opts.SetHosts([]string{defaultHost}) + } else if host, ok := config.Addresses.(string); ok { + opts.SetHosts([]string{host}) + } else if hosts, ok := config.Addresses.([]string); ok { + opts.SetHosts(hosts) + } else { + return errors.New("adapter mongodb failed to parse config.Addresses") + } + + if config.Database == "" { + a.dbName = defaultDatabase + } else { + a.dbName = config.Database + } + + if config.ReplicaSet == "" { + log.Println("MongoDB configured as standalone or replica_set option not set. Transaction support is disabled.") + } else { + opts.SetReplicaSet(config.ReplicaSet) + a.useTransactions = true + } + + if config.Username != "" { + var passwordSet bool + if config.AuthSource == "" { + config.AuthSource = "admin" + } + if config.Password != "" { + passwordSet = true + } + opts.SetAuth( + mdbopts.Credential{ + AuthMechanism: "SCRAM-SHA-256", + AuthSource: config.AuthSource, + Username: config.Username, + Password: config.Password, + PasswordSet: passwordSet, + }) + } + + if a.maxResults <= 0 { + a.maxResults = defaultMaxResults + } + + a.ctx = context.Background() + a.conn, err = mdb.Connect(a.ctx, &opts) + a.db = a.conn.Database(a.dbName) + if err != nil { + return err + } + a.version = -1 + + return nil +} + +// Close the adapter +func (a *adapter) Close() error { + var err error + if a.conn != nil { + err = a.conn.Disconnect(a.ctx) + a.conn = nil + a.version = -1 + } + return err +} + +// IsOpen checks if the adapter is ready for use +func (a *adapter) IsOpen() bool { + return a.conn != nil +} + +// GetDbVersion returns current database version. +func (a *adapter) GetDbVersion() (int, error) { + if a.version > 0 { + return a.version, nil + } + + var result struct { + Key string `bson:"_id"` + Value int + } + if err := a.db.Collection("kvmeta").FindOne(a.ctx, b.M{"_id": "version"}).Decode(&result); err != nil { + if err == mdb.ErrNoDocuments { + err = errors.New("Database not initialized") + } + return -1, err + } + + a.version = result.Value + return result.Value, nil +} + +// CheckDbVersion checks if the actual database version matches adapter version. +func (a *adapter) CheckDbVersion() error { + version, err := a.GetDbVersion() + if err != nil { + return err + } + + if version != adpVersion { + return errors.New("Invalid database version " + strconv.Itoa(version) + + ". Expected " + strconv.Itoa(adpVersion)) + } + + return nil +} + +// Version returns adapter version +func (a *adapter) Version() int { + return adpVersion +} + +// GetName returns the name of the adapter +func (a *adapter) GetName() string { + return adapterName +} + +// SetMaxResults configures how many results can be returned in a single DB call. +func (a *adapter) SetMaxResults(val int) error { + if val <= 0 { + a.maxResults = defaultMaxResults + } else { + a.maxResults = val + } + + return nil +} + +func getIdxOpts(field string) mdb.IndexModel { + return mdb.IndexModel{Keys: b.M{field: 1}} +} + +// CreateDb creates the database optionally dropping an existing database first. +func (a *adapter) CreateDb(reset bool) error { + if reset { + log.Print("Dropping database...") + if err := a.db.Drop(a.ctx); err != nil { + return err + } + } else if a.isDbInitialized() { + return errors.New("Database already initialized") + } + // Collections (tables) do not need to be explicitly created since MongoDB creates them with first write operation + + indexes := []struct { + Collection string + Field string + IndexOpts mdb.IndexModel + }{ + // Users + // Index on 'user.deletedat' for finding soft-deleted users + { + Collection: "users", + Field: "deletedat", + }, + // Index on 'user.tags' array so user can be found by tags + { + Collection: "users", + Field: "tags", + }, + // Index for 'user.devices.deviceid' to ensure Device ID uniqueness across users. + // Partial filter set to avoid unique constraint for null values (when user object have no devices). + { + Collection: "users", + IndexOpts: mdb.IndexModel{ + Keys: b.M{"devices.deviceid": 1}, + Options: mdbopts.Index(). + SetUnique(true). + SetPartialFilterExpression(b.M{"devices.deviceid": b.M{"$exists": true}}), + }, + }, + + // User authentication records {_id, userid, secret} + // Should be able to access user's auth records by user id + { + Collection: "auth", + Field: "userid", + }, + + // Subscription to a topic. The primary key is a topic:user string + { + Collection: "subscriptions", + Field: "user", + }, + { + Collection: "subscriptions", + Field: "topic", + }, + + // Topics stored in database + // Index on 'owner' field for deleting users. + { + Collection: "topics", + Field: "owner", + }, + // Index on 'topic.tags' array so topics can be found by tags. + // These tags are not unique as opposite to 'user.tags'. + { + Collection: "topics", + Field: "tags", + }, + + // Stored message + // Compound index of 'topic - seqid' for selecting messages in a topic. + { + Collection: "messages", + IndexOpts: mdb.IndexModel{Keys: b.M{"topic": 1, "seqid": 1}}, + }, + // Compound index of hard-deleted messages + { + Collection: "messages", + IndexOpts: mdb.IndexModel{Keys: b.M{"topic": 1, "delid": 1}}, + }, + // Compound multi-index of soft-deleted messages: each message gets multiple compound index entries like + // [topic, user1, delid1], [topic, user2, delid2],... + { + Collection: "messages", + IndexOpts: mdb.IndexModel{Keys: b.M{"topic": 1, "deletedfor.user": 1, "deletedfor.delid": 1}}, + }, + + // Log of deleted messages + // Compound index of 'topic - delid' + { + Collection: "dellog", + IndexOpts: mdb.IndexModel{Keys: b.M{"topic": 1, "delid": 1}}, + }, + + // User credentials - contact information such as "email:jdoe@example.com" or "tel:+18003287448": + // Id: "method:credential" like "email:jdoe@example.com". See types.Credential. + // Index on 'credentials.user' to be able to query credentials by user id. + { + Collection: "credentials", + Field: "user", + }, + + // Records of file uploads. See types.FileDef. + // Index on 'fileuploads.user' to be able to get records by user id. + { + Collection: "fileuploads", + Field: "user", + }, + // Index on 'fileuploads.usecount' to be able to delete unused records at once. + { + Collection: "fileuploads", + Field: "usecount", + }, + } + + var err error + for _, idx := range indexes { + if idx.Field != "" { + _, err = a.db.Collection(idx.Collection).Indexes().CreateOne(a.ctx, mdb.IndexModel{Keys: b.M{idx.Field: 1}}) + } else { + _, err = a.db.Collection(idx.Collection).Indexes().CreateOne(a.ctx, idx.IndexOpts) + } + if err != nil { + return err + } + } + + // Collection "kvmeta" with metadata key-value pairs. + // Key in "_id" field. + // Record current DB version. + if _, err := a.db.Collection("kvmeta").InsertOne(a.ctx, map[string]interface{}{"_id": "version", "value": adpVersion}); err != nil { + return err + } + + // Create system topic 'sys'. + if err := createSystemTopic(a); err != nil { + return err + } + + return nil +} + +// TODO: UpgradeDb upgrades database to the current adapter version. +func (a *adapter) UpgradeDb() error { + return nil +} + +// Create system topic 'sys'. +func createSystemTopic(a *adapter) error { + now := t.TimeNow() + _, err := a.db.Collection("topics").InsertOne(a.ctx, &t.Topic{ + ObjHeader: t.ObjHeader{ + Id: "sys", + CreatedAt: now, + UpdatedAt: now}, + TouchedAt: now, + Access: t.DefaultAccess{Auth: t.ModeNone, Anon: t.ModeNone}, + Public: map[string]interface{}{"fn": "System"}, + }) + return err +} + +// User management + +// UserCreate creates user record +func (a *adapter) UserCreate(usr *t.User) error { + if _, err := a.db.Collection("users").InsertOne(a.ctx, &usr); err != nil { + return err + } + + return nil +} + +// UserGet fetches a single user by user id. If user is not found it returns (nil, nil) +func (a *adapter) UserGet(id t.Uid) (*t.User, error) { + var user t.User + + filter := b.M{"_id": id.String(), "deletedat": b.M{"$exists": false}} + if err := a.db.Collection("users").FindOne(a.ctx, filter).Decode(&user); err != nil { + if err == mdb.ErrNoDocuments { // User not found + return nil, nil + } else { + return nil, err + } + } + user.Public = unmarshalBsonD(user.Public) + return &user, nil +} + +// UserGetAll returns user records for a given list of user IDs +func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) { + uids := make([]interface{}, len(ids)) + for i, id := range ids { + uids[i] = id.String() + } + + var users []t.User + filter := b.M{"_id": b.M{"$in": uids}, "deletedat": b.M{"$exists": false}} + cur, err := a.db.Collection("users").Find(a.ctx, filter) + if err != nil { + return nil, err + } + defer cur.Close(a.ctx) + + for cur.Next(a.ctx) { + var user t.User + if err := cur.Decode(&user); err != nil { + return nil, err + } + user.Public = unmarshalBsonD(user.Public) + users = append(users, user) + } + return users, nil +} + +func (a *adapter) maybeStartTransaction(sess mdb.Session) error { + if a.useTransactions { + return sess.StartTransaction() + } + return nil +} + +func (a *adapter) maybeCommitTransaction(ctx context.Context, sess mdb.Session) error { + if a.useTransactions { + return sess.CommitTransaction(ctx) + } + return nil +} + +// UserDelete deletes user record +func (a *adapter) UserDelete(uid t.Uid, hard bool) error { + topicIds, err := a.db.Collection("topics").Distinct(a.ctx, "_id", b.M{"owner": uid.String()}) + if err != nil { + return err + } + topicFilter := b.M{"topic": b.M{"$in": topicIds}} + + var sess mdb.Session + if sess, err = a.conn.StartSession(); err != nil { + return err + } + defer sess.EndSession(a.ctx) + + if err = a.maybeStartTransaction(sess); err != nil { + return err + } + if err = mdb.WithSession(a.ctx, sess, func(sc mdb.SessionContext) error { + if hard { + // Can't delete user's messages in all topics because we cannot notify topics of such deletion. + // Or we have to delete these messages one by one. + // For now, just leave the messages there marked as sent by "not found" user. + + // Delete topics where the user is the owner: + + // 1. Delete dellog + // 2. Decrement fileuploads. + // 3. Delete all messages. + // 4. Delete subscriptions. + + // Delete user's subscriptions in all topics. + if err = a.subsDel(sc, b.M{"user": uid.String()}, true); err != nil { + return err + } + + // Delete dellog + _, err = a.db.Collection("dellog").DeleteMany(sc, topicFilter) + if err != nil { + return err + } + + // Decrement fileuploads UseCounter + // First get array of attachments IDs that were used in messages of topics from topicIds + // Then decrement the usecount field of these file records + err := a.fileDecrementUseCounter(sc, b.M{"topic": b.M{"$in": topicIds}}) + if err != nil { + return err + } + + // Delete messages + _, err = a.db.Collection("messages").DeleteMany(sc, topicFilter) + if err != nil { + return err + } + + // Delete subscriptions + _, err = a.db.Collection("subscriptions").DeleteMany(sc, topicFilter) + if err != nil { + return err + } + + // And finally delete the topics. + if _, err = a.db.Collection("topics").DeleteMany(sc, b.M{"owner": uid.String()}); err != nil { + return err + } + + // Delete user's authentication records. + if _, err = a.authDelAllRecords(sc, uid); err != nil { + return err + } + + // Delete credentials. + if err = a.credDel(sc, uid, "", ""); err != nil { + return err + } + + // And finally delete the user. + if _, err = a.db.Collection("users").DeleteOne(sc, b.M{"_id": uid.String()}); err != nil { + return err + } + } else { + // Disable user's subscriptions. + if err = a.subsDel(sc, b.M{"user": uid.String()}, false); err != nil { + return err + } + + // Disable subscriptions for topics where the user is the owner. + // Disable topics where the user is the owner. + now := t.TimeNow() + disable := b.M{"$set": b.M{"deletedat": now, "updatedat": now}} + + if _, err = a.db.Collection("subscriptions").UpdateMany(sc, topicFilter, disable); err != nil { + return err + } + if _, err = a.db.Collection("topics").UpdateMany(sc, b.M{"_id": b.M{"$in": topicIds}}, disable); err != nil { + return err + } + if _, err = a.db.Collection("users").UpdateMany(sc, b.M{"_id": uid.String()}, disable); err != nil { + return err + } + } + + // Finally commit all changes + return a.maybeCommitTransaction(sc, sess) + }); err != nil { + return err + } + + return err +} + +// UserGetDisabled returns IDs of users which were soft-deleted since given time. +func (a *adapter) UserGetDisabled(since time.Time) ([]t.Uid, error) { + filter := b.M{"deletedat": b.M{"$gte": since}} + findOpts := mdbopts.FindOptions{Projection: b.M{"_id": 1}} + cur, err := a.db.Collection("users").Find(a.ctx, filter, &findOpts) + if err != nil { + return nil, err + } + defer cur.Close(a.ctx) + + var uids []t.Uid + var userId map[string]string + for cur.Next(a.ctx) { + if err := cur.Decode(&userId); err != nil { + return nil, err + } + uids = append(uids, t.ParseUid(userId["_id"])) + } + return uids, nil +} + +// UserUpdate updates user record +func (a *adapter) UserUpdate(uid t.Uid, update map[string]interface{}) error { + // to get round the hardcoded "UpdatedAt" key in store.Users.Update() + update = normalizeUpdateMap(update) + + _, err := a.db.Collection("users").UpdateOne(a.ctx, b.M{"_id": uid.String()}, b.M{"$set": update}) + return err +} + +// UserUpdateTags adds, removes, or resets user's tags +func (a *adapter) UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]string, error) { + // Compare to nil vs checking for zero length: zero length reset is valid. + if reset != nil { + // Replace Tags with the new value + return reset, a.UserUpdate(uid, map[string]interface{}{"tags": reset}) + } + + var user t.User + err := a.db.Collection("users").FindOne(a.ctx, b.M{"_id": uid.String()}).Decode(&user) + if err != nil { + return nil, err + } + + // Mutate the tag list. + newTags := user.Tags + if len(add) > 0 { + newTags = union(newTags, add) + } + if len(remove) > 0 { + newTags = diff(newTags, remove) + } + + update := map[string]interface{}{"tags": newTags} + if err := a.UserUpdate(uid, update); err != nil { + return nil, err + } + + // Get the new tags + var tags map[string][]string + findOpts := mdbopts.FindOne().SetProjection(b.M{"tags": 1, "_id": 0}) + err = a.db.Collection("users").FindOne(a.ctx, b.M{"_id": uid.String()}, findOpts).Decode(&tags) + if err != nil { + return nil, err + } + + return tags["tags"], nil +} + +// UserGetByCred returns user ID for the given validated credential. +func (a *adapter) UserGetByCred(method, value string) (t.Uid, error) { + var userId map[string]string + err := a.db.Collection("credentials").FindOne(a.ctx, + b.M{"_id": method + ":" + value}, + mdbopts.FindOne().SetProjection(b.M{"user": 1, "_id": 0}), + ).Decode(&userId) + if err != nil { + if err == mdb.ErrNoDocuments { + return t.ZeroUid, nil + } + return t.ZeroUid, err + } + + return t.ParseUid(userId["user"]), nil +} + +// UserUnreadCount returns the total number of unread messages in all topics with +// the R permission. +func (a *adapter) UserUnreadCount(uid t.Uid) (int, error) { + pipeline := b.A{ + b.M{"$match": b.M{"user": uid.String()}}, + // Join documents from two collection + b.M{"$lookup": b.M{ + "from": "topics", + "localField": "topic", + "foreignField": "_id", + "as": "fromTopics"}, + }, + // Merge two documents into one + b.M{"$replaceRoot": b.M{"newRoot": b.M{"$mergeObjects": b.A{b.M{"$arrayElemAt": b.A{"$fromTopics", 0}}, "$$ROOT"}}}}, + + b.M{"$match": b.M{ + "deletedat": b.M{"$exists": false}, + // Filter by access mode + "modewant": b.M{"$bitsAllSet": b.A{1}}, + "modegiven": b.M{"$bitsAllSet": b.A{1}}}}, + + b.M{"$group": b.M{"_id": nil, "unreadCount": b.M{"$sum": b.M{"$subtract": b.A{"$seqid", "$readseqid"}}}}}, + } + cur, err := a.db.Collection("subscriptions").Aggregate(a.ctx, pipeline) + if err != nil { + return 0, err + } + defer cur.Close(a.ctx) + + var result []struct { + Id interface{} `bson:"_id"` + UnreadCount int `bson:"unreadCount"` + } + if err = cur.All(a.ctx, &result); err != nil { + return 0, err + } + if len(result) == 0 { // Not found + return 0, nil + } + return result[0].UnreadCount, nil +} + +// Credential management + +// CredUpsert adds or updates a validation record. Returns true if inserted, false if updated. +// 1. if credential is validated: +// 1.1 Hard-delete unconfirmed equivalent record, if exists. +// 1.2 Insert new. Report error if duplicate. +// 2. if credential is not validated: +// 2.1 Check if validated equivalent exist. If so, report an error. +// 2.2 Soft-delete all unvalidated records of the same method. +// 2.3 Undelete existing credential. Return if successful. +// 2.4 Insert new credential record. +func (a *adapter) CredUpsert(cred *t.Credential) (bool, error) { + credCollection := a.db.Collection("credentials") + + cred.Id = cred.Method + ":" + cred.Value + + if !cred.Done { + // Check if the same credential is already validated. + var result1 t.Credential + err := credCollection.FindOne(a.ctx, b.M{"_id": cred.Id}).Decode(&result1) + if result1 != (t.Credential{}) { + // Someone has already validated this credential. + return false, t.ErrDuplicate + } else if err != nil && err != mdb.ErrNoDocuments { // if no result -> continue + return false, err + } + + // Soft-delete all unvalidated records of this user and method. + _, err = credCollection.UpdateMany(a.ctx, + b.M{"user": cred.User, "method": cred.Method, "done": false}, + b.M{"$set": b.M{"deletedat": t.TimeNow()}}) + if err != nil { + return false, err + } + + // If credential is not confirmed, it should not block others + // from attempting to validate it: make index user-unique instead of global-unique. + cred.Id = cred.User + ":" + cred.Id + + // Check if this credential has already been added by the user. + var result2 t.Credential + err = credCollection.FindOne(a.ctx, b.M{"_id": cred.Id}).Decode(&result2) + if result2 != (t.Credential{}) { + _, err = credCollection.UpdateOne(a.ctx, + b.M{"_id": cred.Id}, + b.M{ + "$unset": b.M{"deletedat": ""}, + "$set": b.M{"updatedat": cred.UpdatedAt, "resp": cred.Resp}}) + if err != nil { + return false, err + } + + // The record was updated, all is fine. + return false, nil + } else if err != nil && err != mdb.ErrNoDocuments { + return false, err + } + } else { + // Hard-delete potentially present unvalidated credential. + _, err := credCollection.DeleteOne(a.ctx, b.M{"_id": cred.User + ":" + cred.Id}) + if err != nil { + return false, err + } + } + + // Insert a new record. + _, err := credCollection.InsertOne(a.ctx, cred) + if isDuplicateErr(err) { + return true, t.ErrDuplicate + } + + return true, err +} + +// CredGetActive returns the currently active credential record for the given method. +func (a *adapter) CredGetActive(uid t.Uid, method string) (*t.Credential, error) { + var cred t.Credential + + filter := b.M{ + "user": uid.String(), + "deletedat": b.M{"$exists": false}, + "method": method, + "done": false} + + if err := a.db.Collection("credentials").FindOne(a.ctx, filter).Decode(&cred); err != nil { + if err == mdb.ErrNoDocuments { // Cred not found + return nil, t.ErrNotFound + } else { + return nil, err + } + } + + return &cred, nil +} + +// CredGetAll returns credential records for the given user and method, validated only or all. +func (a *adapter) CredGetAll(uid t.Uid, method string, validatedOnly bool) ([]t.Credential, error) { + filter := b.M{"user": uid.String()} + if method != "" { + filter["method"] = method + } + if validatedOnly { + filter["done"] = true + } else { + filter["deletedat"] = b.M{"$exists": false} + } + + cur, err := a.db.Collection("credentials").Find(a.ctx, filter) + if err != nil { + return nil, err + } + defer cur.Close(a.ctx) + + var credentials []t.Credential + if err := cur.All(a.ctx, &credentials); err != nil { + return nil, err + } + return credentials, nil +} + +// CredDel deletes credentials for the given method/value. If method is empty, deletes all +// user's credentials. +func (a *adapter) credDel(ctx context.Context, uid t.Uid, method, value string) error { + credCollection := a.db.Collection("credentials") + filter := b.M{"user": uid.String()} + if method != "" { + filter["method"] = method + if value != "" { + filter["value"] = value + } + } else { + _, err := credCollection.DeleteMany(ctx, filter) + return err + } + + // Hard-delete all confirmed values or values with no attempts at confirmation. + hardDeleteFilter := copyBsonMap(filter) + hardDeleteFilter["$or"] = b.A{ + b.M{"done": true}, + b.M{"retries": 0}} + if _, err := credCollection.DeleteMany(ctx, hardDeleteFilter); err != nil { + return err + } + + // Soft-delete all other values. + _, err := credCollection.UpdateMany(ctx, filter, b.M{"$set": b.M{"deletedat": t.TimeNow()}}) + return err +} + +func (a *adapter) CredDel(uid t.Uid, method, value string) error { + return a.credDel(a.ctx, uid, method, value) +} + +// CredConfirm marks given credential as validated. +func (a *adapter) CredConfirm(uid t.Uid, method string) error { + cred, err := a.CredGetActive(uid, method) + if err != nil { + return err + } + + cred.Done = true + cred.UpdatedAt = t.TimeNow() + if _, err = a.CredUpsert(cred); err != nil { + return err + } + + _, _ = a.db.Collection("credentials").DeleteOne(a.ctx, b.M{"_id": uid.String() + ":" + cred.Method + ":" + cred.Value}) + return nil +} + +// CredFail increments count of failed validation attepmts for the given credentials. +func (a *adapter) CredFail(uid t.Uid, method string) error { + filter := b.M{ + "user": uid.String(), + "deletedat": b.M{"$exists": false}, + "method": method, + "done": false} + + update := b.M{ + "$inc": b.M{"retries": 1}, + "$set": b.M{"updatedat": t.TimeNow()}} + _, err := a.db.Collection("credentials").UpdateOne(a.ctx, filter, update) + return err +} + +// Authentication management for the basic authentication scheme + +// AuthGetUniqueRecord returns authentication record for a given unique value i.e. login. +func (a *adapter) AuthGetUniqueRecord(unique string) (t.Uid, auth.Level, []byte, time.Time, error) { + var record struct { + UserId string + AuthLvl auth.Level + Secret []byte + Expires time.Time + } + + filter := b.M{"_id": unique} + findOpts := mdbopts.FindOne().SetProjection(b.M{ + "userid": 1, + "authlvl": 1, + "secret": 1, + "expires": 1, + }) + err := a.db.Collection("auth").FindOne(a.ctx, filter, findOpts).Decode(&record) + if err != nil { + if err == mdb.ErrNoDocuments { + return t.ZeroUid, 0, nil, time.Time{}, nil + } + return t.ZeroUid, 0, nil, time.Time{}, err + } + + return t.ParseUid(record.UserId), record.AuthLvl, record.Secret, record.Expires, nil +} + +// AuthGetRecord returns authentication record given user ID and method. +func (a *adapter) AuthGetRecord(uid t.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) { + var record struct { + Id string `bson:"_id"` + AuthLvl auth.Level + Secret []byte + Expires time.Time + } + + filter := b.M{"userid": uid.String(), "scheme": scheme} + findOpts := mdbopts.FindOne().SetProjection(b.M{ + "authlvl": 1, + "secret": 1, + "expires": 1, + }) + err := a.db.Collection("auth").FindOne(a.ctx, filter, findOpts).Decode(&record) + if err != nil { + if err == mdb.ErrNoDocuments { + return "", 0, nil, time.Time{}, t.ErrNotFound + } + return "", 0, nil, time.Time{}, err + } + + return record.Id, record.AuthLvl, record.Secret, record.Expires, nil +} + +// AuthAddRecord creates new authentication record +func (a *adapter) AuthAddRecord(uid t.Uid, scheme, unique string, authLvl auth.Level, secret []byte, expires time.Time) error { + authRecord := b.M{ + "_id": unique, + "userid": uid.String(), + "scheme": scheme, + "authlvl": authLvl, + "secret": secret, + "expires": expires} + if _, err := a.db.Collection("auth").InsertOne(a.ctx, authRecord); err != nil { + if isDuplicateErr(err) { + return t.ErrDuplicate + } + return err + } + return nil +} + +// AuthDelScheme deletes an existing authentication scheme for the user. +func (a *adapter) AuthDelScheme(uid t.Uid, scheme string) error { + _, err := a.db.Collection("auth").DeleteOne(a.ctx, + b.M{ + "userid": uid.String(), + "scheme": scheme}) + return err +} + +func (a *adapter) authDelAllRecords(ctx context.Context, uid t.Uid) (int, error) { + res, err := a.db.Collection("auth").DeleteMany(ctx, b.M{"userid": uid.String()}) + return int(res.DeletedCount), err +} + +// AuthDelAllRecords deletes all records of a given user. +func (a *adapter) AuthDelAllRecords(uid t.Uid) (int, error) { + return a.authDelAllRecords(a.ctx, uid) +} + +// AuthUpdRecord modifies an authentication record. +func (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string, + authLvl auth.Level, secret []byte, expires time.Time) error { + // The primary key is immutable. If '_id' has changed, we have to replace the old record with a new one: + // 1. Check if '_id' has changed. + // 2. If not, execute update by '_id' + // 3. If yes, first insert the new record (it may fail due to dublicate '_id') then delete the old one. + + var err error + var record struct { + Unique string `bson:"_id"` + } + findOpts := mdbopts.FindOne().SetProjection(b.M{"_id": 1}) + filter := b.M{"userid": uid.String(), "scheme": scheme} + if err = a.db.Collection("auth").FindOne(a.ctx, filter, findOpts).Decode(&record); err != nil { + return err + } + + if record.Unique == unique { + _, err = a.db.Collection("auth").UpdateOne(a.ctx, + b.M{"_id": unique}, + b.M{"$set": b.M{ + "authlvl": authLvl, + "secret": secret, + "expires": expires}}) + } else { + if err = a.AuthAddRecord(uid, scheme, unique, authLvl, secret, expires); err != nil { + return err + } + if err = a.AuthDelScheme(uid, scheme); err != nil { + return err + } + } + + return err +} + +// Topic management + +func (a *adapter) undeleteSubscription(sub *t.Subscription) error { + _, err := a.db.Collection("subscriptions").UpdateOne(a.ctx, + b.M{"_id": sub.Id}, + b.M{ + "$unset": b.M{"deletedat": ""}, + "$set": b.M{ + "updatedat": sub.UpdatedAt, + "createdat": sub.CreatedAt, + "modegiven": sub.ModeGiven}}) + return err +} + +// TopicCreate creates a topic +func (a *adapter) TopicCreate(topic *t.Topic) error { + _, err := a.db.Collection("topics").InsertOne(a.ctx, &topic) + return err +} + +// TopicCreateP2P creates a p2p topic +func (a *adapter) TopicCreateP2P(initiator, invited *t.Subscription) error { + initiator.Id = initiator.Topic + ":" + initiator.User + // Don't care if the initiator changes own subscription + replOpts := mdbopts.Replace().SetUpsert(true) + _, err := a.db.Collection("subscriptions").ReplaceOne(a.ctx, b.M{"_id": initiator.Id}, initiator, replOpts) + if err != nil { + return err + } + + // If the second subscription exists, don't overwrite it. Just make sure it's not deleted. + invited.Id = invited.Topic + ":" + invited.User + _, err = a.db.Collection("subscriptions").InsertOne(a.ctx, invited) + if err != nil { + // Is this a duplicate subscription? + if !isDuplicateErr(err) { + // It's a genuine DB error + return err + } + // Undelete the second subsription if it exists: remove DeletedAt, update CreatedAt and UpdatedAt, + // update ModeGiven. + err = a.undeleteSubscription(invited) + if err != nil { + return err + } + } + + topic := &t.Topic{ + ObjHeader: t.ObjHeader{Id: initiator.Topic}, + TouchedAt: initiator.GetTouchedAt()} + topic.ObjHeader.MergeTimes(&initiator.ObjHeader) + return a.TopicCreate(topic) +} + +// TopicGet loads a single topic by name, if it exists. If the topic does not exist the call returns (nil, nil) +func (a *adapter) TopicGet(topic string) (*t.Topic, error) { + var tpc = new(t.Topic) + err := a.db.Collection("topics").FindOne(a.ctx, b.M{"_id": topic}).Decode(tpc) + if err != nil { + if err == mdb.ErrNoDocuments { + return nil, nil + } + return nil, err + } + return tpc, nil +} + +// TopicsForUser loads subscriptions for a given user. Reads public value. +func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) { + // Fetch user's subscriptions + filter := b.M{"user": uid.String()} + if !keepDeleted { + // Filter out rows with defined deletedat + filter["deletedat"] = b.M{"$exists": false} + } + limit := a.maxResults + if opts != nil { + // Ignore IfModifiedSince - we must return all entries + // Those unmodified will be stripped of Public & Private. + + if opts.Topic != "" { + filter["topic"] = opts.Topic + } + if opts.Limit > 0 && opts.Limit < limit { + limit = opts.Limit + } + } + + findOpts := mdbopts.Find().SetLimit(int64(limit)) + cur, err := a.db.Collection("subscriptions").Find(a.ctx, filter, findOpts) + if err != nil { + return nil, err + } + + // Fetch subscriptions. Two queries are needed: users table (me & p2p) and topics table (p2p and grp). + // Prepare a list of Separate subscriptions to users vs topics + var sub t.Subscription + join := make(map[string]t.Subscription) // Keeping these to make a join with table for .private and .access + topq := make([]string, 0, 16) + usrq := make([]string, 0, 16) + for cur.Next(a.ctx) { + if err = cur.Decode(&sub); err != nil { + return nil, err + } + tcat := t.GetTopicCat(sub.Topic) + + // skip 'me' or 'fnd' subscription + if tcat == t.TopicCatMe || tcat == t.TopicCatFnd { + continue + + // p2p subscription, find the other user to get user.Public + } else if tcat == t.TopicCatP2P { + uid1, uid2, _ := t.ParseP2P(sub.Topic) + if uid1 == uid { + usrq = append(usrq, uid2.String()) + } else { + usrq = append(usrq, uid1.String()) + } + topq = append(topq, sub.Topic) + + // grp subscription + } else { + topq = append(topq, sub.Topic) + } + join[sub.Topic] = sub + } + cur.Close(a.ctx) + + var subs []t.Subscription + if len(topq) > 0 || len(usrq) > 0 { + subs = make([]t.Subscription, 0, len(join)) + } + + if len(topq) > 0 { + // Fetch grp & p2p topics + cur, err = a.db.Collection("topics").Find(a.ctx, b.M{"_id": b.M{"$in": topq}}) + if err != nil { + return nil, err + } + + var top t.Topic + for cur.Next(a.ctx) { + if err = cur.Decode(&top); err != nil { + return nil, err + } + sub = join[top.Id] + sub.ObjHeader.MergeTimes(&top.ObjHeader) + sub.SetSeqId(top.SeqId) + sub.SetTouchedAt(top.TouchedAt) + sub.Private = unmarshalBsonD(sub.Private) + if t.GetTopicCat(sub.Topic) == t.TopicCatGrp { + // all done with a grp topic + sub.SetPublic(unmarshalBsonD(top.Public)) + subs = append(subs, sub) + } else { + // put back the updated value of a p2p subsription, will process further below + join[top.Id] = sub + } + } + cur.Close(a.ctx) + } + + // Fetch p2p users and join to p2p tables + if len(usrq) > 0 { + filter := b.M{"_id": b.M{"$in": usrq}} + if !keepDeleted { + filter["deletedat"] = b.M{"$exists": false} + } + cur, err = a.db.Collection("users").Find(a.ctx, filter) + if err != nil { + return nil, err + } + + var usr t.User + for cur.Next(a.ctx) { + if err = cur.Decode(&usr); err != nil { + return nil, err + } + + uid2 := t.ParseUid(usr.Id) + if sub, ok := join[uid.P2PName(uid2)]; ok { + sub.ObjHeader.MergeTimes(&usr.ObjHeader) + sub.SetPublic(unmarshalBsonD(usr.Public)) + sub.SetWith(uid2.UserId()) + sub.SetDefaultAccess(usr.Access.Auth, usr.Access.Anon) + sub.SetLastSeenAndUA(usr.LastSeen, usr.UserAgent) + subs = append(subs, sub) + } + } + cur.Close(a.ctx) + } + + return subs, nil +} + +// UsersForTopic loads users' subscriptions for a given topic. Public is loaded. +func (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) { + tcat := t.GetTopicCat(topic) + + // Fetch topic subscribers + // Fetch all subscribed users. The number of users is not large + filter := b.M{"topic": topic} + if !keepDeleted && tcat != t.TopicCatP2P { + // Filter out rows with DeletedAt being not null. + // P2P topics must load all subscriptions otherwise it will be impossible + // to swap Public values. + filter["deletedat"] = b.M{"$exists": false} + } + + limit := a.maxResults + var oneUser t.Uid + if opts != nil { + // Ignore IfModifiedSince - we must return all entries + // Those unmodified will be stripped of Public & Private. + + if !opts.User.IsZero() { + if tcat != t.TopicCatP2P { + filter["user"] = opts.User.String() + } + oneUser = opts.User + } + if opts.Limit > 0 && opts.Limit < limit { + limit = opts.Limit + } + } + + cur, err := a.db.Collection("subscriptions").Find(a.ctx, filter, mdbopts.Find().SetLimit(int64(limit))) + if err != nil { + return nil, err + } + + // Fetch subscriptions + var sub t.Subscription + var subs []t.Subscription + join := make(map[string]t.Subscription) + usrq := make([]interface{}, 0, 16) + for cur.Next(a.ctx) { + if err = cur.Decode(&sub); err != nil { + return nil, err + } + join[sub.User] = sub + usrq = append(usrq, sub.User) + } + cur.Close(a.ctx) + + if len(usrq) > 0 { + subs = make([]t.Subscription, 0, len(usrq)) + + // Fetch users by a list of subscriptions + cur, err = a.db.Collection("users").Find(a.ctx, b.M{ + "_id": b.M{"$in": usrq}, + "deletedat": b.M{"$exists": false}}) + if err != nil { + return nil, err + } + + var usr t.User + for cur.Next(a.ctx) { + if err = cur.Decode(&usr); err != nil { + return nil, err + } + if sub, ok := join[usr.Id]; ok { + sub.ObjHeader.MergeTimes(&usr.ObjHeader) + sub.Private = unmarshalBsonD(sub.Private) + sub.SetPublic(unmarshalBsonD(usr.Public)) + subs = append(subs, sub) + } + } + cur.Close(a.ctx) + } + + if t.GetTopicCat(topic) == t.TopicCatP2P && len(subs) > 0 { + // Swap public values of P2P topics as expected. + if len(subs) == 1 { + // User is deleted. Nothing we can do. + subs[0].SetPublic(nil) + } else { + pub := subs[0].GetPublic() + subs[0].SetPublic(subs[1].GetPublic()) + subs[1].SetPublic(pub) + } + + // Remove deleted and unneeded subscriptions + if !keepDeleted || !oneUser.IsZero() { + var xsubs []t.Subscription + for i := range subs { + if (subs[i].DeletedAt != nil && !keepDeleted) || (!oneUser.IsZero() && subs[i].Uid() != oneUser) { + continue + } + xsubs = append(xsubs, subs[i]) + } + subs = xsubs + } + } + + return subs, nil +} + +// OwnTopics loads a slice of topic names where the user is the owner. +func (a *adapter) OwnTopics(uid t.Uid) ([]string, error) { + filter := b.M{"owner": uid.String(), "deletedat": b.M{"$exists": false}} + findOpts := mdbopts.Find().SetProjection(b.M{"_id": 1}) + cur, err := a.db.Collection("topics").Find(a.ctx, filter, findOpts) + if err != nil { + return nil, err + } + + var res map[string]string + var names []string + for cur.Next(a.ctx) { + if err := cur.Decode(&res); err == nil { + names = append(names, res["_id"]) + } else { + return nil, err + } + } + cur.Close(a.ctx) + + return names, nil +} + +// TopicShare creates topic subscriptions +func (a *adapter) TopicShare(subs []*t.Subscription) error { + // Assign Ids. + for i := 0; i < len(subs); i++ { + subs[i].Id = subs[i].Topic + ":" + subs[i].User + } + + // Subscription could have been marked as deleted (DeletedAt != nil). If it's marked + // as deleted, unmark by clearing the DeletedAt field of the old subscription and + // updating times and ModeGiven. + for _, sub := range subs { + _, err := a.db.Collection("subscriptions").InsertOne(a.ctx, sub) + if err != nil { + if isDuplicateErr(err) { + if err = a.undeleteSubscription(sub); err != nil { + return err + } + } else { + return err + } + } + } + + return nil +} + +// TopicDelete deletes topic, subscription, messages +func (a *adapter) TopicDelete(topic string, hard bool) error { + var err error + if err = a.SubsDelForTopic(topic, hard); err != nil { + return err + } + + if hard { + if err = a.MessageDeleteList(topic, nil); err != nil { + return err + } + } + + filter := b.M{"_id": topic} + if hard { + _, err = a.db.Collection("topics").DeleteOne(a.ctx, filter) + } else { + now := t.TimeNow() + _, err = a.db.Collection("topics").UpdateOne(a.ctx, filter, b.M{"$set": b.M{ + "updatedat": now, + "deletedat": now, + }}) + } + return err +} + +// TopicUpdateOnMessage increments Topic's or User's SeqId value and updates TouchedAt timestamp. +func (a *adapter) TopicUpdateOnMessage(topic string, msg *t.Message) error { + return a.topicUpdate(topic, map[string]interface{}{"seqid": msg.SeqId, "touchedat": msg.CreatedAt}) +} + +// TopicUpdate updates topic record. +func (a *adapter) TopicUpdate(topic string, update map[string]interface{}) error { + return a.topicUpdate(topic, normalizeUpdateMap(update)) +} + +// TopicOwnerChange updates topic's owner +func (a *adapter) TopicOwnerChange(topic string, newOwner t.Uid) error { + return a.topicUpdate(topic, map[string]interface{}{"owner": newOwner.String()}) +} + +func (a *adapter) topicUpdate(topic string, update map[string]interface{}) error { + _, err := a.db.Collection("topics").UpdateOne(a.ctx, + b.M{"_id": topic}, + b.M{"$set": update}) + + return err +} + +// Topic subscriptions + +// SubscriptionGet reads a subscription of a user to a topic +func (a *adapter) SubscriptionGet(topic string, user t.Uid) (*t.Subscription, error) { + sub := new(t.Subscription) + err := a.db.Collection("subscriptions").FindOne(a.ctx, b.M{ + "_id": topic + ":" + user.String(), + "deletedat": b.M{"$exists": false}}).Decode(sub) + if err != nil { + if err == mdb.ErrNoDocuments { + return nil, nil + } + return nil, err + } + + return sub, nil +} + +// SubsForUser gets a list of topics of interest for a given user. Does NOT load Public value. +func (a *adapter) SubsForUser(user t.Uid, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) { + filter := b.M{"user": user.String()} + if !keepDeleted { + filter["deletedat"] = b.M{"$exists": false} + } + limit := a.maxResults + + if opts != nil { + // Ignore IfModifiedSince - we must return all entries + // Those unmodified will be stripped of Public & Private. + + if opts.Topic != "" { + filter["topic"] = opts.Topic + } + if opts.Limit > 0 && opts.Limit < limit { + limit = opts.Limit + } + } + findOpts := new(mdbopts.FindOptions).SetLimit(int64(limit)) + + cur, err := a.db.Collection("subscriptions").Find(a.ctx, filter, findOpts) + if err != nil { + return nil, err + } + defer cur.Close(a.ctx) + + var subs []t.Subscription + var ss t.Subscription + for cur.Next(a.ctx) { + if err := cur.Decode(&ss); err != nil { + return nil, err + } + ss.Private = unmarshalBsonD(ss.Private) + subs = append(subs, ss) + } + + return subs, cur.Err() +} + +// SubsForTopic gets a list of subscriptions to a given topic.. Does NOT load Public value. +func (a *adapter) SubsForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) { + filter := b.M{"topic": topic} + if !keepDeleted { + filter["deletedat"] = b.M{"$exists": false} + } + + limit := a.maxResults + if opts != nil { + // Ignore IfModifiedSince - we must return all entries + // Those unmodified will be stripped of Public & Private. + + if !opts.User.IsZero() { + filter["user"] = opts.User.String() + } + if opts.Limit > 0 && opts.Limit < limit { + limit = opts.Limit + } + } + findOpts := new(mdbopts.FindOptions).SetLimit(int64(limit)) + + cur, err := a.db.Collection("subscriptions").Find(a.ctx, filter, findOpts) + if err != nil { + return nil, err + } + defer cur.Close(a.ctx) + + var subs []t.Subscription + var ss t.Subscription + for cur.Next(a.ctx) { + if err := cur.Decode(&ss); err != nil { + return nil, err + } + ss.Private = unmarshalBsonD(ss.Private) + subs = append(subs, ss) + } + + return subs, cur.Err() +} + +// SubsUpdate updates pasrt of a subscription object. Pass nil for fields which don't need to be updated +func (a *adapter) SubsUpdate(topic string, user t.Uid, update map[string]interface{}) error { + // to get round the hardcoded pass of "Private" key + update = normalizeUpdateMap(update) + + filter := b.M{} + if !user.IsZero() { + // Update one topic subscription + filter["_id"] = topic + ":" + user.String() + } else { + // Update all topic subscriptions + filter["topic"] = topic + } + _, err := a.db.Collection("subscriptions").UpdateOne(a.ctx, filter, b.M{"$set": update}) + return err +} + +// SubsDelete deletes a single subscription +func (a *adapter) SubsDelete(topic string, user t.Uid) error { + now := t.TimeNow() + _, err := a.db.Collection("subscriptions").UpdateOne(a.ctx, + b.M{"_id": topic + ":" + user.String()}, + b.M{"$set": b.M{"updatedat": now, "deletedat": now}}) + return err +} + +func (a *adapter) subsDel(ctx context.Context, filter b.M, hard bool) error { + var err error + if hard { + _, err = a.db.Collection("subscriptions").DeleteMany(ctx, filter) + } else { + now := t.TimeNow() + _, err = a.db.Collection("subscriptions").UpdateMany(ctx, filter, + b.M{"$set": b.M{"updatedat": now, "deletedat": now}}) + } + return err +} + +// SubsDelForTopic deletes all subscriptions to the given topic +func (a *adapter) SubsDelForTopic(topic string, hard bool) error { + filter := b.M{"topic": topic} + return a.subsDel(a.ctx, filter, hard) +} + +// SubsDelForUser deletes all subscriptions of the given user +func (a *adapter) SubsDelForUser(user t.Uid, hard bool) error { + filter := b.M{"user": user.String()} + return a.subsDel(a.ctx, filter, hard) +} + +// Search +func (a *adapter) getFindPipeline(req, opt []string) (map[string]struct{}, b.A) { + index := make(map[string]struct{}) + var allTags []interface{} + for _, tag := range append(req, opt...) { + allTags = append(allTags, tag) + index[tag] = struct{}{} + } + + pipeline := b.A{ + b.M{"$match": b.M{ + "tags": b.M{"$in": allTags}, + "deletedat": b.M{"$exists": false}, + }}, + + b.M{"$project": b.M{"_id": 1, "access": 1, "createdat": 1, "updatedat": 1, "public": 1, "tags": 1}}, + + b.M{"$unwind": "$tags"}, + + b.M{"$match": b.M{"tags": b.M{"$in": allTags}}}, + + b.M{"$group": b.M{ + "_id": "$_id", + "access": b.M{"$first": "$access"}, + "createdat": b.M{"$first": "$createdat"}, + "updatedat": b.M{"$first": "$updatedat"}, + "public": b.M{"$first": "$public"}, + "tags": b.M{"$addToSet": "$tags"}, + "matchedTagsCount": b.M{"$sum": 1}, + }}, + + b.M{"$sort": b.M{"matchedTagsCount": -1}}, + } + + if len(req) > 0 { + var reqTags []interface{} + for _, tag := range req { + reqTags = append(reqTags, tag) + } + + // Filter out documents where 'tags' intersection with 'reqTags' is an empty array + pipeline = append(pipeline, + b.M{"$match": b.M{"$expr": b.M{"$ne": b.A{b.M{"$size": b.M{"$setIntersection": b.A{"$tags", reqTags}}}, 0}}}}) + } + + return index, append(pipeline, b.M{"$limit": a.maxResults}) +} + +// FindUsers searches for new contacts given a list of tags +func (a *adapter) FindUsers(uid t.Uid, req, opt []string) ([]t.Subscription, error) { + index, pipeline := a.getFindPipeline(req, opt) + cur, err := a.db.Collection("users").Aggregate(a.ctx, pipeline) + if err != nil { + return nil, err + } + defer cur.Close(a.ctx) + + var user t.User + var sub t.Subscription + var subs []t.Subscription + for cur.Next(a.ctx) { + if err = cur.Decode(&user); err != nil { + return nil, err + } + if user.Id == uid.String() { + // Skip the caller + continue + } + sub.CreatedAt = user.CreatedAt + sub.UpdatedAt = user.UpdatedAt + sub.User = user.Id + sub.SetPublic(unmarshalBsonD(user.Public)) + sub.SetDefaultAccess(user.Access.Auth, user.Access.Anon) + tags := make([]string, 0, 1) + for _, tag := range user.Tags { + if _, ok := index[tag]; ok { + tags = append(tags, tag) + } + } + sub.Private = tags + subs = append(subs, sub) + } + + return subs, nil +} + +// FindTopics searches for group topics given a list of tags +func (a *adapter) FindTopics(req, opt []string) ([]t.Subscription, error) { + index, pipeline := a.getFindPipeline(req, opt) + cur, err := a.db.Collection("topics").Aggregate(a.ctx, pipeline) + if err != nil { + return nil, err + } + defer cur.Close(a.ctx) + + var topic t.Topic + var sub t.Subscription + var subs []t.Subscription + for cur.Next(a.ctx) { + if err = cur.Decode(&topic); err != nil { + return nil, err + } + + sub.CreatedAt = topic.CreatedAt + sub.UpdatedAt = topic.UpdatedAt + sub.User = topic.Id + sub.SetPublic(unmarshalBsonD(topic.Public)) + sub.SetDefaultAccess(topic.Access.Auth, topic.Access.Anon) + tags := make([]string, 0, 1) + for _, tag := range topic.Tags { + if _, ok := index[tag]; ok { + tags = append(tags, tag) + } + } + sub.Private = tags + subs = append(subs, sub) + } + + return subs, nil +} + +// Messages + +// MessageSave saves message to database +func (a *adapter) MessageSave(msg *t.Message) error { + _, err := a.db.Collection("messages").InsertOne(a.ctx, msg) + return err +} + +// MessageGetAll returns messages matching the query +func (a *adapter) MessageGetAll(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.Message, error) { + var limit = a.maxResults + var lower, upper int + requester := forUser.String() + if opts != nil { + if opts.Since > 0 { + lower = opts.Since + } + if opts.Before > 0 { + upper = opts.Before + } + + if opts.Limit > 0 && opts.Limit < limit { + limit = opts.Limit + } + } + filter := b.M{ + "topic": topic, + "delid": b.M{"$exists": false}, + "deletedfor.user": b.M{"$ne": requester}, + } + if upper == 0 { + filter["seqid"] = b.M{"$gte": lower} + } else { + filter["seqid"] = b.M{"$gte": lower, "$lte": upper} + } + findOpts := mdbopts.Find().SetSort(b.M{"topic": -1, "seqid": -1}) + findOpts.SetLimit(int64(limit)) + + cur, err := a.db.Collection("messages").Find(a.ctx, filter, findOpts) + if err != nil { + return nil, err + } + defer cur.Close(a.ctx) + + var msgs []t.Message + for cur.Next(a.ctx) { + var msg t.Message + if err = cur.Decode(&msg); err != nil { + return nil, err + } + msg.Content = unmarshalBsonD(msg.Content) + msgs = append(msgs, msg) + } + + return msgs, nil +} + +func (a *adapter) messagesHardDelete(topic string) error { + var err error + + // TODO: handle file uploads + filter := b.M{"topic": topic} + if _, err = a.db.Collection("dellog").DeleteMany(a.ctx, filter); err != nil { + return err + } + + if _, err = a.db.Collection("messages").DeleteMany(a.ctx, filter); err != nil { + return err + } + + if err = a.fileDecrementUseCounter(a.ctx, filter); err != nil { + return err + } + + return err +} + +// MessageDeleteList marks messages as deleted. +// Soft- or Hard- is defined by forUser value: forUSer.IsZero == true is hard. +func (a *adapter) MessageDeleteList(topic string, toDel *t.DelMessage) error { + var err error + + if toDel == nil { + return a.messagesHardDelete(topic) + } + + // Only some messages are being deleted + + // Start with making a log entry + _, err = a.db.Collection("dellog").InsertOne(a.ctx, toDel) + if err != nil { + return err + } + + filter := b.M{ + "topic": topic, + // Skip already hard-deleted messages. + "delid": b.M{"$exists": false}, + } + if len(toDel.SeqIdRanges) > 1 || toDel.SeqIdRanges[0].Hi <= toDel.SeqIdRanges[0].Low { + rangeFilter := b.A{} + for _, rng := range toDel.SeqIdRanges { + if rng.Hi == 0 { + rangeFilter = append(rangeFilter, b.M{"seqid": b.M{"$gte": rng.Low}}) + } else { + rangeFilter = append(rangeFilter, b.M{"seqid": b.M{"$gte": rng.Low, "$lte": rng.Hi}}) + } + } + filter["$or"] = rangeFilter + } else { + filter["seqid"] = b.M{"$gte": toDel.SeqIdRanges[0].Low, "$lte": toDel.SeqIdRanges[0].Hi} + } + + if toDel.DeletedFor == "" { + if err = a.fileDecrementUseCounter(a.ctx, filter); err != nil { + return err + } + // Hard-delete individual messages. Message is not deleted but all fields with content + // are replaced with nulls. + _, err = a.db.Collection("messages").UpdateMany(a.ctx, filter, b.M{"$set": b.M{ + "deletedat": t.TimeNow(), + "delid": toDel.DelId, + "from": "", + "head": nil, + "content": nil, + "attachments": nil}}) + } else { + // Soft-deleting: adding DelId to DeletedFor + + // Skip messages already soft-deleted for the current user + filter["deletedfor.user"] = b.M{"$ne": toDel.DeletedFor} + _, err = a.db.Collection("messages").UpdateMany(a.ctx, filter, + b.M{"$addToSet": b.M{ + "deletedfor": &t.SoftDelete{ + User: toDel.DeletedFor, + DelId: toDel.DelId, + }}}) + } + + // If operation has failed, remove dellog record. + if err != nil { + _, _ = a.db.Collection("dellog").DeleteOne(a.ctx, b.M{"_id": toDel.Id}) + } + return err +} + +// MessageGetDeleted returns a list of deleted message Ids. +func (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) { + var limit = a.maxResults + var lower, upper int + if opts != nil { + if opts.Since > 0 { + lower = opts.Since + } + if opts.Before > 0 { + upper = opts.Before + } + if opts.Limit > 0 && opts.Limit < limit { + limit = opts.Limit + } + } + filter := b.M{ + "topic": topic, + "$or": b.A{ + b.M{"deletedfor": forUser.String()}, + b.M{"deletedfor": ""}, + }} + if upper == 0 { + filter["delid"] = b.M{"$gte": lower} + } else { + filter["delid"] = b.M{"$gte": lower, "$lte": upper} + } + findOpts := mdbopts.Find(). + SetSort(b.M{"topic": 1, "delid": 1}). + SetLimit(int64(limit)) + + cur, err := a.db.Collection("dellog").Find(a.ctx, filter, findOpts) + if err != nil { + return nil, err + } + defer cur.Close(a.ctx) + + var dmsgs []t.DelMessage + if err = cur.All(a.ctx, &dmsgs); err != nil { + return nil, err + } + + return dmsgs, nil +} + +// MessageAttachments connects given message to a list of file record IDs. +func (a *adapter) MessageAttachments(msgId t.Uid, fids []string) error { + now := t.TimeNow() + _, err := a.db.Collection("messages").UpdateOne(a.ctx, + b.M{"_id": msgId.String()}, + b.M{"$set": b.M{"updatedat": now, "attachments": fids}}) + if err != nil { + return err + } + + ids := make([]interface{}, len(fids)) + for i, id := range fids { + ids[i] = id + } + _, err = a.db.Collection("fileuploads").UpdateMany(a.ctx, + b.M{"_id": b.M{"$in": ids}}, + b.M{ + "$set": b.M{"updatedat": now}, + "$inc": b.M{"usecount": 1}}) + + return err +} + +// Devices (for push notifications) + +// DeviceUpsert creates or updates a device record +func (a *adapter) DeviceUpsert(uid t.Uid, dev *t.DeviceDef) error { + userId := uid.String() + var user t.User + err := a.db.Collection("users").FindOne(a.ctx, b.M{ + "_id": userId, + "devices.deviceid": dev.DeviceId}).Decode(&user) + + if err == nil && user.Id != "" { // current user owns this device + // ArrayFilter used to avoid adding another (duplicate) device object. Update that device data + updOpts := mdbopts.Update().SetArrayFilters(mdbopts.ArrayFilters{ + Filters: []interface{}{b.M{"dev.deviceid": dev.DeviceId}}}) + _, err = a.db.Collection("users").UpdateOne(a.ctx, + b.M{"_id": userId}, + b.M{"$set": b.M{ + "devices.$[dev].platform": dev.Platform, + "devices.$[dev].lastseen": dev.LastSeen, + "devices.$[dev].lang": dev.Lang}}, + updOpts) + return err + } else if err == mdb.ErrNoDocuments { // device is free or owned by other user + err = a.deviceInsert(userId, dev) + + if isDuplicateErr(err) { + // Other user owns this device. + // We need to delete this device from that user and then insert again + if _, err = a.db.Collection("users").UpdateOne(a.ctx, + b.M{"devices.deviceid": dev.DeviceId}, + b.M{"$pull": b.M{"devices": b.M{"deviceid": dev.DeviceId}}}); err != nil { + + return err + } + return a.deviceInsert(userId, dev) + } + if err != nil { + return err + } + return nil + } + + return err +} + +// deviceInsert adds device object to user.devices array +func (a *adapter) deviceInsert(userId string, dev *t.DeviceDef) error { + filter := b.M{"_id": userId} + _, err := a.db.Collection("users").UpdateOne(a.ctx, filter, + b.M{"$push": b.M{"devices": dev}}) + + if err != nil && strings.Contains(err.Error(), "must be an array") { + // field 'devices' is not array. Make it array with 'dev' as its first element + _, err = a.db.Collection("users").UpdateOne(a.ctx, filter, + b.M{"$set": b.M{"devices": []interface{}{dev}}}) + } + + return err +} + +// DeviceGetAll returns all devices for a given set of users +func (a *adapter) DeviceGetAll(uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) { + ids := make([]interface{}, len(uids)) + for i, id := range uids { + ids[i] = id.String() + } + + filter := b.M{"_id": b.M{"$in": ids}} + findOpts := mdbopts.Find().SetProjection(b.M{"_id": 1, "devices": 1}) + cur, err := a.db.Collection("users").Find(a.ctx, filter, findOpts) + if err != nil { + return nil, 0, err + } + defer cur.Close(a.ctx) + + result := make(map[t.Uid][]t.DeviceDef) + count := 0 + var uid t.Uid + for cur.Next(a.ctx) { + var row struct { + Id string `bson:"_id"` + Devices []t.DeviceDef + } + if err = cur.Decode(&row); err != nil { + return nil, 0, err + } + if row.Devices != nil && len(row.Devices) > 0 { + if err := uid.UnmarshalText([]byte(row.Id)); err != nil { + continue + } + + result[uid] = row.Devices + count++ + } + } + return result, count, cur.Err() +} + +// DeviceDelete deletes a device record +func (a *adapter) DeviceDelete(uid t.Uid, deviceID string) error { + var err error + filter := b.M{"_id": uid.String()} + update := b.M{} + if deviceID == "" { + update["$set"] = b.M{"devices": []interface{}{}} + } else { + update["$pull"] = b.M{"devices": b.M{"deviceid": deviceID}} + } + _, err = a.db.Collection("users").UpdateOne(a.ctx, filter, update) + return err +} + +// File upload records. The files are stored outside of the database. + +// FileStartUpload initializes a file upload +func (a *adapter) FileStartUpload(fd *t.FileDef) error { + _, err := a.db.Collection("fileuploads").InsertOne(a.ctx, fd) + return err +} + +// FileFinishUpload marks file upload as completed, successfully or otherwise. +func (a *adapter) FileFinishUpload(fid string, status int, size int64) (*t.FileDef, error) { + if _, err := a.db.Collection("fileuploads").UpdateOne(a.ctx, + b.M{"_id": fid}, + b.M{"$set": b.M{ + "updatedat": t.TimeNow(), + "status": status, + "size": size}}); err != nil { + + return nil, err + } + + return a.FileGet(fid) +} + +// FileGet fetches a record of a specific file +func (a *adapter) FileGet(fid string) (*t.FileDef, error) { + var fd t.FileDef + err := a.db.Collection("fileuploads").FindOne(a.ctx, b.M{"_id": fid}).Decode(&fd) + if err != nil { + if err == mdb.ErrNoDocuments { + return nil, nil + } + return nil, err + } + + return &fd, nil +} + +// FileDeleteUnused deletes records where UseCount is zero. If olderThan is non-zero, deletes +// unused records with UpdatedAt before olderThan. +// Returns array of FileDef.Location of deleted filerecords so actual files can be deleted too. +func (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, error) { + findOpts := mdbopts.Find() + filter := b.M{"$or": b.A{ + b.M{"usecount": 0}, + b.M{"usecount": b.M{"$exists": false}}}} + if !olderThan.IsZero() { + filter["updatedat"] = b.M{"$lt": olderThan} + } + if limit > 0 { + findOpts.SetLimit(int64(limit)) + } + + findOpts.SetProjection(b.M{"location": 1, "_id": 0}) + cur, err := a.db.Collection("fileuploads").Find(a.ctx, filter, findOpts) + if err != nil { + return nil, err + } + defer cur.Close(a.ctx) + + var result map[string]string + var locations []string + for cur.Next(a.ctx) { + if err := cur.Decode(&result); err != nil { + return nil, err + } + locations = append(locations, result["location"]) + } + + _, err = a.db.Collection("fileuploads").DeleteMany(a.ctx, filter) + return locations, err +} + +// Given a filter query against 'messages' collection, decrement corresponding use counter in 'fileuploads' table. +func (a *adapter) fileDecrementUseCounter(ctx context.Context, msgFilter b.M) error { + // Copy msgFilter + filter := b.M{} + for k, v := range msgFilter { + filter[k] = v + } + filter["attachments"] = b.M{"$exists": true} + fileIds, err := a.db.Collection("messages").Distinct(ctx, "attachments", filter) + if err != nil { + return err + } + + _, err = a.db.Collection("fileuploads").UpdateMany(ctx, + b.M{"_id": b.M{"$in": fileIds}}, + b.M{"$inc": b.M{"usecount": -1}}) + + return err +} + +func (a *adapter) isDbInitialized() bool { + var result map[string]int + + findOpts := mdbopts.FindOneOptions{Projection: b.M{"value": 1, "_id": 0}} + if err := a.db.Collection("kvmeta").FindOne(a.ctx, b.M{"_id": "version"}, &findOpts).Decode(&result); err != nil { + return false + } + return true +} + +// Required for running adapter tests. +func GetAdapter() *adapter { + return &adapter{} +} + +func init() { + store.RegisterAdapter(&adapter{}) +} + +func contains(s []string, e string) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} + +func union(userTags []string, addTags []string) []string { + for _, tag := range addTags { + if !contains(userTags, tag) { + userTags = append(userTags, tag) + } + } + return userTags +} + +func diff(userTags []string, removeTags []string) []string { + var result []string + for _, tag := range userTags { + if !contains(removeTags, tag) { + result = append(result, tag) + } + } + return result +} + +// normalizeUpdateMap turns keys that hardcoded as CamelCase into lowercase (MongoDB uses lowercase by default) +func normalizeUpdateMap(update map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}, len(update)) + for key, value := range update { + result[strings.ToLower(key)] = value + } + + return result +} + +// Recursive unmarshalling of bson.D type. +// Mongo drivers unmarshalling into interface{} creates bson.D object for maps and bson.A object for slices. +// We need manually unmarshal them into correct type - bson.M (map[string]interface{}). +func unmarshalBsonD(bsonObj interface{}) interface{} { + if obj, ok := bsonObj.(b.D); ok && len(obj) != 0 { + result := make(b.M, 0) + for k, v := range obj.Map() { + result[k] = unmarshalBsonD(v) + } + return result + } else if obj, ok := bsonObj.(primitive.Binary); ok { + // primitive.Binary is a struct type with Subtype and Data fields. We need only Data ([]byte) + return obj.Data + } else if obj, ok := bsonObj.(b.A); ok { + // in case of array of bson.D objects + result := make(b.A, 0) + for _, elem := range obj { + result = append(result, unmarshalBsonD(elem)) + } + return result + } + // Just return value as is + return bsonObj +} + +func copyBsonMap(mp b.M) b.M { + result := b.M{} + for k, v := range mp { + result[k] = v + } + return result +} +func isDuplicateErr(err error) bool { + if err == nil { + return false + } + + msg := err.Error() + return strings.Contains(msg, "duplicate key error") +} diff --git a/server/db/mongodb/blank.go b/server/db/mongodb/blank.go new file mode 100644 index 000000000..9a517e415 --- /dev/null +++ b/server/db/mongodb/blank.go @@ -0,0 +1,7 @@ +// +build !mongodb + +// This file is needed for conditional compilation. It's used when +// the build tag 'mongodb' is not defined. Otherwise the adapter.go +// is compiled. + +package mongodb diff --git a/server/db/mongodb/schema.md b/server/db/mongodb/schema.md new file mode 100644 index 000000000..4accc4af9 --- /dev/null +++ b/server/db/mongodb/schema.md @@ -0,0 +1,321 @@ +# 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 +* `deletedat` currently unused +* `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` currently unused +* `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" , + "deletedat": 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 + * `deletedat` currently unused + * `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` currently unused + * `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 , + "updatedat": "2019-10-11T12:13:14.522Z" , + "usebt": false +} +``` + +### Table `subscriptions` +The table stores relationships between users and topics. + +Fields: + * `_id` used for object retrieval + * `createdat` timestamp when the user was created + * `updatedat` timestamp when user metadata was updated + * `deletedat` currently unused + * `readseqid` id of the message last read by the user + * `recvseqid` id of the message last received by user device + * `delid` topic-sequential ID of the soft-deletion operation + * `topic` name of the topic subscribed to + * `user` subscriber's user ID + * `modewant` access mode that user wants when accessing the topic + * `modegiven` access mode granted to user by the topic + * `private` application-defined data, accessible by the user only + +Indexes: + * `_id` primary key composed as "_topic name_':'_user ID_" + * `user` index + * `topic` index + +Sample: +```json +{ + "_id": "grpjajVKrHn0PU:v2JyG4OLSoA" , + "createdat": "2019-10-11T12:13:14.522Z" , + "updatedat": "2019-10-11T12:13:14.522Z" , + "deletedat": null , + "user": "v2JyG4OLSoA", + "topic": "grpjajVKrHn0PU" , + "recvseqid": 0 , + "readseqid": 0 , + "modewant": 47 , + "modegiven": 47 , + "private": "Kirgudu" , + "state": 0 +} +``` + +### Table `messages` +The table stores `{data}` messages + +Fields: +* `_id` currently unused, primary key +* `createdat` timestamp when the message was created +* `updatedat` initially equal to CreatedAt, for deleted messages equal to DeletedAt +* `deletedfor` array of user IDs which soft-deleted the message + * `delid` topic-sequential ID of the soft-deletion operation + * `user` ID of the user who soft-deleted the message +* `from` ID of the user who generated this message +* `topic` which received this message +* `seqid` messages ID - sequential number of the message in the topic +* `head` message headers +* `attachments` denormalized IDs of files attached to the message +* `content` application-defined message payload + +Indexes: + * `_id` primary key + +Sample: +```json +{ + "_id": "LLXKEe9W4Bs" , + "createdat": "2019-10-11T12:13:14.522Z" , + "updatedat": "2019-10-11T12:13:14.522Z", + "deletedfor": [ + { + "delid": 1 , + "user": "wTI0jO9rEqY" + } + ] , + "seqid": 3 , + "topic": "p2pJhbJnya8z5PBMjSM72sSpg", + "from": "wTI0jO9rEqY" , + "head": { + "mime": "text/x-drafty" + } , + "content": { + "fmt": [ + { + "len": 6 , + "tp": "ST" + } + ] , + "txt": "Hello!" + } +} +``` + +### Table `dellog` +The table stores records of message deletions + +Fields: +* `_id` currently unused, primary key +* `createdat` timestamp when the record was created +* `updatedat` timestamp equal to CreatedAt +* `delid` topic-sequential ID of the deletion operation. +* `deletedfor` ID of the user for soft-deletions, blank string for hard-deletions +* `topic` affected topic +* `seqidranges` array of ranges of deleted message IDs (see `messages.seqid`) + +Indexes: + * `_id` primary key +* `topic_delid` compound index `["Topic", "DelId"]` + +Sample: +```json +{ + "_id": "9LfrjW349Rc", + "createdat": "2019-10-11T12:13:14.522Z", + "updatedat": "2019-10-11T12:13:14.522Z", + "topic": "grpGx7fpjQwVC0", + "delid": 18, + "deletedfor": "xY-YHx09-WI", + "seqidranges": [ + { + "low": 20, + "hi": 25 + } + ] +} +``` + +### Table `credentials` +The tables stores user credentials used for validation. + +* `_id` credential, primary key +* `createdat` timestamp when the record was created +* `updatedat` timestamp when the last validation attempt was performed (successful or not). +* `method` validation method +* `done` indicator if the credential is validated +* `resp` expected validation response +* `retries` number of failed attempts at validation +* `user` id of the user who owns this credential +* `value` value of the credential + +Indexes: +* `_id` Primary key composed either as `user`:`method`:`value` for unconfirmed credentials or as `method`:`value` for confirmed. +* `user` Index + +Sample: +```json +{ + "Id": "tel:+17025550001", + "CreatedAt": "2019-10-11T12:13:14.522Z", + "UpdatedAt": "2019-10-11T12:13:14.522Z", + "Method": "tel" , + "Done": true , + "Resp": "123456" , + "Retries": 0 , + "User": "k3srBRk9RYw" , + "Value": "+17025550001" +} +``` + +### Table `fileuploads` +The table stores records of uploaded files. The files themselves are stored outside of the database. +* `_id` unique user-visible file name, primary key +* `createdat` timestamp when the record was created +* `updatedat` timestamp of when th upload has cmpleted or failed +* `user` id of the user who uploaded this file. +* `location` actual location of the file on the server. +* `mimetype` file content type as a [Mime](https://en.wikipedia.org/wiki/MIME) string. +* `size` size of the file in bytes. Could be 0 if upload has not completed yet. +* `usecount` count of messages referencing this file. +* `status` upload status: 0 pending, 1 completed, -1 failed. + +Indexes: + * `_id` file name, primary key + * `user` index + * `usecount` index + +Sample: +```json +{ + "_id": "sFmjlQ_kA6A" , + "createdat": "2019-10-11T12:13:14.522Z" , + "updatedat": "2019-10-11T12:13:14.522Z" , + "location": "uploads/sFmjlQ_kA6A" , + "mimetype": "image/jpeg" , + "size": 54961090 , + "usecount": 3, + "status": 1 , + "user": "7j-RR1V7O3Y" +} +``` \ No newline at end of file diff --git a/server/db/mongodb/tests/mongo_test.go b/server/db/mongodb/tests/mongo_test.go new file mode 100644 index 000000000..8fe28012f --- /dev/null +++ b/server/db/mongodb/tests/mongo_test.go @@ -0,0 +1,1254 @@ +// To test another db backend: +// 1) Create GetAdapter function inside your db backend adapter package (like one inside mongodb adapter) +// 2) Uncomment your db backend package ('backend' named package) +// 3) Write own initConnectionToDb and 'db' variable +// 4) Replace mongodb specific db queries inside test to your own queries. +// 5) Run. + +package tests + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "reflect" + "testing" + "time" + + jcr "github.com/DisposaBoy/JsonConfigReader" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + adapter "github.com/tinode/chat/server/db" + b "go.mongodb.org/mongo-driver/bson" + mdb "go.mongodb.org/mongo-driver/mongo" + mdbopts "go.mongodb.org/mongo-driver/mongo/options" + + //backend "github.com/tinode/chat/server/db/rethinkdb" + //backend "github.com/tinode/chat/server/db/mysql" + backend "github.com/tinode/chat/server/db/mongodb" + "github.com/tinode/chat/server/store/types" +) + +type configType struct { + // If Reset=true test will recreate database every time it runs + Reset bool `json:"reset_db_data"` + // Configurations for individual adapters. + Adapters map[string]json.RawMessage `json:"adapters"` +} + +var config configType +var adp adapter.Adapter +var db *mdb.Database +var ctx context.Context + +func TestCreateDb(t *testing.T) { + if err := adp.CreateDb(config.Reset); err != nil { + t.Fatal(err) + } +} + +// ================== Create tests ================================ +func TestUserCreate(t *testing.T) { + for _, user := range users { + if err := adp.UserCreate(user); err != nil { + t.Error(err) + } + } + count, err := db.Collection("users").CountDocuments(ctx, b.M{}) + if err != nil { + t.Error(err) + } + if count == 0 { + t.Error("No users created!") + } +} + +func TestCredUpsert(t *testing.T) { + // Test just inserts: + for i := 0; i < 2; i++ { + inserted, err := adp.CredUpsert(creds[i]) + if err != nil { + t.Fatal(err) + } + if !inserted { + t.Error("Should be inserted, but updated") + } + } + + // Test duplicate: + _, err := adp.CredUpsert(creds[1]) + if err != types.ErrDuplicate { + t.Error("Should return duplicate error but got", err) + } + _, err = adp.CredUpsert(creds[2]) + if err != types.ErrDuplicate { + t.Error("Should return duplicate error but got", err) + } + + // Test add new unvalidated credentials + inserted, err := adp.CredUpsert(creds[3]) + if err != nil { + t.Fatal(err) + } + if !inserted { + t.Error("Should be inserted, but updated") + } + inserted, err = adp.CredUpsert(creds[3]) + if err != nil { + t.Fatal(err) + } + if inserted { + t.Error("Should be updated, but inserted") + } + + // Just insert other creds (used in other tests) + for _, cred := range creds[4:] { + _, err = adp.CredUpsert(cred) + if err != nil { + t.Fatal(err) + } + } +} + +func TestAuthAddRecord(t *testing.T) { + for _, rec := range recs { + err := adp.AuthAddRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, rec.Id, + rec.AuthLvl, rec.Secret, rec.Expires) + if err != nil { + t.Fatal(err) + } + } + //Test duplicate + err := adp.AuthAddRecord(types.ParseUserId("usr"+users[0].Id), recs[0].Scheme, recs[0].Id, + recs[0].AuthLvl, recs[0].Secret, recs[0].Expires) + if err != types.ErrDuplicate { + t.Fatal("Should be duplicate error but got", err) + } +} + +func TestTopicCreate(t *testing.T) { + err := adp.TopicCreate(topics[0]) + if err != nil { + t.Error(err) + } + for _, tpc := range topics[3:] { + err = adp.TopicCreate(tpc) + if err != nil { + t.Error(err) + } + } +} + +func TestTopicCreateP2P(t *testing.T) { + err := adp.TopicCreateP2P(subs[2], subs[3]) + if err != nil { + t.Fatal(err) + } + + oldModeGiven := subs[2].ModeGiven + subs[2].ModeGiven = 255 + err = adp.TopicCreateP2P(subs[4], subs[2]) + if err != nil { + t.Fatal(err) + } + var got types.Subscription + err = db.Collection("subscriptions").FindOne(ctx, b.M{"_id": subs[2].Id}).Decode(&got) + if err != nil { + t.Fatal(err) + } + if got.ModeGiven == oldModeGiven { + t.Error("ModeGiven update failed") + } +} + +func TestTopicShare(t *testing.T) { + err := adp.TopicShare(subs) + if err != nil { + t.Fatal(err) + } +} + +func TestMessageSave(t *testing.T) { + for _, msg := range msgs { + err := adp.MessageSave(msg) + if err != nil { + t.Fatal(err) + } + } +} + +func TestFileStartUpload(t *testing.T) { + for _, f := range files { + err := adp.FileStartUpload(f) + if err != nil { + t.Fatal(err) + } + } +} + +// ================== Read tests ================================== +func TestUserGet(t *testing.T) { + // Test not found + got, err := adp.UserGet(types.ParseUserId("dummyuserid")) + if err == nil && got != nil { + t.Error("user should be nil.") + } + + got, err = adp.UserGet(types.ParseUserId("usr" + users[0].Id)) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, users[0]) { + t.Errorf(mismatchErrorString("User", got, users[0])) + } +} + +func TestUserGetAll(t *testing.T) { + // Test not found + got, err := adp.UserGetAll(types.ParseUserId("dummyuserid"), types.ParseUserId("otherdummyid")) + if err == nil && got != nil { + t.Error("result users should be nil.") + } + + got, err = adp.UserGetAll(types.ParseUserId("usr"+users[0].Id), types.ParseUserId("usr"+users[1].Id)) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatal(mismatchErrorString("resultUsers length", len(got), 2)) + } + for i, usr := range got { + if !reflect.DeepEqual(&usr, users[i]) { + t.Error(mismatchErrorString("User", &usr, users[i])) + } + } +} + +func TestUserGetDisabled(t *testing.T) { + // Test before deletion date + got, err := adp.UserGetDisabled(users[2].DeletedAt.Add(-10 * time.Hour)) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Fatal(mismatchErrorString("uids length", len(got), 1)) + } + if got[0].String() != users[2].Id { + t.Error(mismatchErrorString("userId", got[0].String(), users[2].Id)) + } + + // Test after deletion date + got, err = adp.UserGetDisabled(users[2].DeletedAt.Add(10 * time.Hour)) + if err != nil { + t.Fatal(err) + } + if got != nil { + t.Fatal(mismatchErrorString("result", got, nil)) + } +} + +func TestUserGetByCred(t *testing.T) { + // Test not found + got, err := adp.UserGetByCred("foo", "bar") + if err != nil { + t.Fatal(err) + } + if got != types.ZeroUid { + t.Error("result uid should be ZeroUid") + } + + got, err = adp.UserGetByCred(creds[0].Method, creds[0].Value) + if got != types.ParseUserId("usr"+creds[0].User) { + t.Error(mismatchErrorString("Uid", got, types.ParseUserId("usr"+creds[0].User))) + } +} + +func TestCredGetActive(t *testing.T) { + got, err := adp.CredGetActive(types.ParseUserId("usr"+users[2].Id), "tel") + if err != nil { + t.Error(err) + } + if !reflect.DeepEqual(got, creds[3]) { + t.Errorf(mismatchErrorString("Credential", got, creds[3])) + } + + // Test not found + _, err = adp.CredGetActive(types.ParseUserId("dummyusrid"), "") + if err != types.ErrNotFound { + t.Error("Err should be types.ErrNotFound, but got", err) + } +} + +func TestCredGetAll(t *testing.T) { + got, err := adp.CredGetAll(types.ParseUserId("usr"+users[2].Id), "", false) + if err != nil { + t.Fatal(err) + } + if len(got) != 3 { + t.Errorf(mismatchErrorString("Credentials length", len(got), 3)) + } + + got, err = adp.CredGetAll(types.ParseUserId("usr"+users[2].Id), "tel", false) + if len(got) != 2 { + t.Errorf(mismatchErrorString("Credentials length", len(got), 2)) + } + + got, err = adp.CredGetAll(types.ParseUserId("usr"+users[2].Id), "", true) + if len(got) != 1 { + t.Errorf(mismatchErrorString("Credentials length", len(got), 1)) + } + + got, err = adp.CredGetAll(types.ParseUserId("usr"+users[2].Id), "tel", true) + if len(got) != 1 { + t.Errorf(mismatchErrorString("Credentials length", len(got), 1)) + } +} + +func TestUserUnreadCount(t *testing.T) { + count, err := adp.UserUnreadCount(types.ParseUserId("usr" + users[2].Id)) + if err != nil { + t.Fatal(err) + } + if count != 100 { + t.Error(mismatchErrorString("UnreadCount", count, 100)) + } + + // Test not found + count, err = adp.UserUnreadCount(types.ParseUserId("dummyuserid")) + if err != nil { + t.Fatal(err) + } + if count != 0 { + t.Error(mismatchErrorString("UnreadCount", count, 0)) + } +} + +func TestAuthGetUniqueRecord(t *testing.T) { + uid, authLvl, secret, expires, err := adp.AuthGetUniqueRecord("basic:alice") + if err != nil { + t.Fatal(err) + } + if uid != types.ParseUserId("usr"+recs[0].UserId) || + authLvl != recs[0].AuthLvl || + bytes.Compare(secret, recs[0].Secret) != 0 || + expires != recs[0].Expires { + + got := fmt.Sprintf("%v %v %v %v", uid, authLvl, secret, expires) + want := fmt.Sprintf("%v %v %v %v", recs[0].UserId, recs[0].AuthLvl, recs[0].Secret, recs[0].Expires) + t.Errorf(mismatchErrorString("Auth record", got, want)) + } + + // Test not found + uid, _, _, _, err = adp.AuthGetUniqueRecord("qwert:asdfg") + if err == nil && !uid.IsZero() { + t.Error("Auth record found but shouldn't. Uid:", uid.String()) + } +} + +func TestAuthGetRecord(t *testing.T) { + recId, authLvl, secret, expires, err := adp.AuthGetRecord(types.ParseUserId("usr"+recs[0].UserId), "basic") + if err != nil { + t.Fatal(err) + } + if recId != recs[0].Id || + authLvl != recs[0].AuthLvl || + bytes.Compare(secret, recs[0].Secret) != 0 || + expires != recs[0].Expires { + + got := fmt.Sprintf("%v %v %v %v", recId, authLvl, secret, expires) + want := fmt.Sprintf("%v %v %v %v", recs[0].Id, recs[0].AuthLvl, recs[0].Secret, recs[0].Expires) + t.Errorf(mismatchErrorString("Auth record", got, want)) + } + + // Test not found + recId, _, _, _, err = adp.AuthGetRecord(types.ParseUserId("dummyuserid"), "scheme") + if err != types.ErrNotFound { + t.Error("Auth record found but shouldn't. recId:", recId) + } +} + +func TestTopicGet(t *testing.T) { + got, err := adp.TopicGet(topics[0].Id) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, topics[0]) { + t.Errorf(mismatchErrorString("Topic", got, topics[0])) + } + // Test not found + got, err = adp.TopicGet("asdfasdfasdf") + if err != nil { + t.Fatal(err) + } + if got != nil { + t.Error("Topic should be nil but got:", got) + } +} + +func TestTopicsForUser(t *testing.T) { + qOpts := types.QueryOpt{ + Topic: "p2p9AVDamaNCRbfKzGSh3mE0w", + Limit: 999, + } + gotSubs, err := adp.TopicsForUser(types.ParseUserId("usr"+users[0].Id), false, &qOpts) + if err != nil { + t.Fatal(err) + } + if len(gotSubs) != 1 { + t.Errorf(mismatchErrorString("Subs length", len(gotSubs), 1)) + } + + gotSubs, err = adp.TopicsForUser(types.ParseUserId("usr"+users[1].Id), true, nil) + if err != nil { + t.Fatal(err) + } + if len(gotSubs) != 2 { + t.Errorf(mismatchErrorString("Subs length", len(gotSubs), 2)) + } +} + +func TestUsersForTopic(t *testing.T) { + qOpts := types.QueryOpt{ + User: types.ParseUserId("usr" + users[0].Id), + Limit: 999, + } + gotSubs, err := adp.UsersForTopic("grpgRXf0rU4uR4", false, &qOpts) + if err != nil { + t.Fatal(err) + } + if len(gotSubs) != 1 { + t.Errorf(mismatchErrorString("Subs length", len(gotSubs), 1)) + } + + gotSubs, err = adp.UsersForTopic("grpgRXf0rU4uR4", true, nil) + if err != nil { + t.Fatal(err) + } + if len(gotSubs) != 2 { + t.Errorf(mismatchErrorString("Subs length", len(gotSubs), 2)) + } + + gotSubs, err = adp.UsersForTopic("p2p9AVDamaNCRbfKzGSh3mE0w", false, nil) + if err != nil { + t.Fatal(err) + } + if len(gotSubs) != 2 { + t.Errorf(mismatchErrorString("Subs length", len(gotSubs), 2)) + } +} + +func TestOwnTopics(t *testing.T) { + gotSubs, err := adp.OwnTopics(types.ParseUserId("usr" + users[0].Id)) + if err != nil { + t.Fatal(err) + } + if len(gotSubs) != 1 { + t.Fatalf("Got topic length %v instead of %v", len(gotSubs), 1) + } + if gotSubs[0] != topics[0].Id { + t.Errorf("Got topic %v instead of %v", gotSubs[0], topics[0].Id) + } +} + +func TestSubscriptionGet(t *testing.T) { + got, err := adp.SubscriptionGet(topics[0].Id, types.ParseUserId("usr"+users[0].Id)) + if err != nil { + t.Error(err) + } + opts := cmpopts.IgnoreUnexported(types.Subscription{}, types.ObjHeader{}) + if !cmp.Equal(got, subs[0], opts) { + t.Errorf(mismatchErrorString("Subs", got, subs[0])) + } + // Test not found + got, err = adp.SubscriptionGet("dummytopic", types.ParseUserId("dummyuserid")) + if err != nil { + t.Error(err) + } + if got != nil { + t.Error("result sub should be nil.") + } +} + +func TestSubsForUser(t *testing.T) { + qOpts := types.QueryOpt{ + Topic: topics[0].Id, + Limit: 999, + } + gotSubs, err := adp.SubsForUser(types.ParseUserId("usr"+users[0].Id), false, &qOpts) + if err != nil { + t.Error(err) + } + if len(gotSubs) != 1 { + t.Errorf(mismatchErrorString("Subs length", len(gotSubs), 1)) + } + + // Test not found + gotSubs, err = adp.SubsForUser(types.ParseUserId("dummyuserid"), false, nil) + if err != nil { + t.Error(err) + } + if len(gotSubs) != 0 { + t.Errorf(mismatchErrorString("Subs length", len(gotSubs), 0)) + } +} + +func TestSubsForTopic(t *testing.T) { + qOpts := types.QueryOpt{ + User: types.ParseUserId("usr" + users[0].Id), + Limit: 999, + } + gotSubs, err := adp.SubsForTopic(topics[0].Id, false, &qOpts) + if err != nil { + t.Error(err) + } + if len(gotSubs) != 1 { + t.Errorf(mismatchErrorString("Subs length", len(gotSubs), 1)) + } + // Test not found + gotSubs, err = adp.SubsForTopic("dummytopicid", false, nil) + if err != nil { + t.Error(err) + } + if len(gotSubs) != 0 { + t.Errorf(mismatchErrorString("Subs length", len(gotSubs), 0)) + } +} + +func TestFindUsers(t *testing.T) { + reqTags := []string{"alice", "bob", "carol"} + gotSubs, err := adp.FindUsers(types.ParseUserId("usr"+users[2].Id), reqTags, nil) + if err != nil { + t.Error(err) + } + if len(gotSubs) != 2 { + t.Errorf(mismatchErrorString("result length", len(gotSubs), 3)) + } +} + +func TestFindTopics(t *testing.T) { + reqTags := []string{"travel", "qwer", "asdf", "zxcv"} + gotSubs, err := adp.FindTopics(reqTags, nil) + if err != nil { + t.Error(err) + } + if len(gotSubs) != 3 { + t.Fatal(mismatchErrorString("result length", len(gotSubs), 3)) + } +} + +func TestMessageGetAll(t *testing.T) { + opts := types.QueryOpt{ + Since: 1, + Before: 2, + Limit: 999, + } + gotMsgs, err := adp.MessageGetAll(topics[0].Id, types.ParseUserId("usr"+users[0].Id), &opts) + if err != nil { + t.Fatal(err) + } + if len(gotMsgs) != 1 { + t.Error(mismatchErrorString("Messages length", len(gotMsgs), 1)) + } + gotMsgs, err = adp.MessageGetAll(topics[0].Id, types.ParseUserId("usr"+users[0].Id), nil) + if len(gotMsgs) != 2 { + t.Error(mismatchErrorString("Messages length", len(gotMsgs), 2)) + } + gotMsgs, err = adp.MessageGetAll(topics[0].Id, types.ZeroUid, nil) + if len(gotMsgs) != 3 { + t.Error(mismatchErrorString("Messages length", len(gotMsgs), 3)) + } +} + +func TestFileGet(t *testing.T) { + // General test done during TestFileFinishUpload(). + + // Test not found + got, err := adp.FileGet("dummyfileid") + if err != nil { + if got != nil { + t.Error("File found but shouldn't:", got) + } + } +} + +// ================== Update tests ================================ +func TestUserUpdate(t *testing.T) { + update := map[string]interface{}{ + "UserAgent": "Test Agent v0.11", + "UpdatedAt": now.Add(30 * time.Minute), + } + err := adp.UserUpdate(types.ParseUserId("usr"+users[0].Id), update) + if err != nil { + t.Fatal(err) + } + + var got types.User + err = db.Collection("users").FindOne(ctx, b.M{"_id": users[0].Id}).Decode(&got) + if err != nil { + t.Fatal(err) + } + if got.UserAgent != "Test Agent v0.11" { + t.Errorf(mismatchErrorString("UserAgent", got.UserAgent, "Test Agent v0.11")) + } + if got.UpdatedAt == got.CreatedAt { + t.Error("UpdatedAt field not updated") + } +} + +func TestUserUpdateTags(t *testing.T) { + addTags := []string{"tag1", "Alice"} + removeTags := []string{"alice", "tag1", "tag2"} + resetTags := []string{"Alice", "tag111", "tag333"} + got, err := adp.UserUpdateTags(types.ParseUserId("usr"+users[0].Id), addTags, nil, nil) + if err != nil { + t.Fatal(err) + } + want := []string{"alice", "tag1", "Alice"} + if !reflect.DeepEqual(got, want) { + t.Errorf(mismatchErrorString("Tags", got, want)) + + } + got, err = adp.UserUpdateTags(types.ParseUserId("usr"+users[0].Id), nil, removeTags, nil) + want = []string{"Alice"} + if !reflect.DeepEqual(got, want) { + t.Errorf(mismatchErrorString("Tags", got, want)) + + } + got, err = adp.UserUpdateTags(types.ParseUserId("usr"+users[0].Id), nil, nil, resetTags) + want = []string{"Alice", "tag111", "tag333"} + if !reflect.DeepEqual(got, want) { + t.Errorf(mismatchErrorString("Tags", got, want)) + + } + got, err = adp.UserUpdateTags(types.ParseUserId("usr"+users[0].Id), addTags, removeTags, nil) + want = []string{"Alice", "tag111", "tag333"} + if !reflect.DeepEqual(got, want) { + t.Errorf(mismatchErrorString("Tags", got, want)) + + } + got, err = adp.UserUpdateTags(types.ParseUserId("usr"+users[0].Id), addTags, removeTags, nil) + want = []string{"Alice", "tag111", "tag333"} + if !reflect.DeepEqual(got, want) { + t.Errorf(mismatchErrorString("Tags", got, want)) + + } +} + +func TestCredFail(t *testing.T) { + err := adp.CredFail(types.ParseUserId("usr"+creds[3].User), "tel") + if err != nil { + t.Error(err) + } + + // Check if fields updated + var got types.Credential + err = db.Collection("credentials").FindOne(ctx, b.M{ + "user": creds[3].User, + "method": "tel", + "value": creds[3].Value}).Decode(&got) + if got.Retries != 1 { + t.Errorf(mismatchErrorString("Retries count", got.Retries, 1)) + } + if got.UpdatedAt == got.CreatedAt { + t.Error("UpdatedAt field not updated") + } +} + +func TestCredConfirm(t *testing.T) { + err := adp.CredConfirm(types.ParseUserId("usr"+creds[3].User), "tel") + if err != nil { + t.Fatal(err) + } + + // Test fields are updated + var got types.Credential + err = db.Collection("credentials").FindOne(ctx, b.M{ + "user": creds[3].User, + "method": "tel", + "value": creds[3].Value}).Decode(&got) + if err != nil { + t.Fatal(err) + } + if got.UpdatedAt == got.CreatedAt { + t.Error("Credential not updated correctly") + } + // and uncomfirmed credential deleted + err = db.Collection("credentials").FindOne(ctx, b.M{"_id": creds[3].User + ":" + got.Method + ":" + got.Value}).Decode(&got) + if err != mdb.ErrNoDocuments { + t.Error("Uncomfirmed credential not deleted") + } +} + +func TestAuthUpdRecord(t *testing.T) { + rec := recs[1] + newSecret := []byte{'s', 'e', 'c', 'r', 'e', 't'} + err := adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, rec.Id, + rec.AuthLvl, newSecret, rec.Expires) + if err != nil { + t.Fatal(err) + } + var got AuthRecord + err = db.Collection("auth").FindOne(ctx, b.M{"_id": rec.Id}).Decode(&got) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(got.Secret, rec.Secret) { + t.Errorf(mismatchErrorString("Secret", got.Secret, rec.Secret)) + } + + // Test with auth ID (unique) change + newId := "basic:bob12345" + err = adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, newId, + rec.AuthLvl, newSecret, rec.Expires) + if err != nil { + t.Fatal(err) + } + // Test if old ID deleted + err = db.Collection("auth").FindOne(ctx, b.M{"_id": rec.Id}).Decode(&got) + if err == nil || err != mdb.ErrNoDocuments { + t.Errorf("Unique not changed. Got error: %v; ID: %v", err, got.Id) + } + if bytes.Equal(got.Secret, rec.Secret) { + t.Errorf(mismatchErrorString("Secret", got.Secret, rec.Secret)) + } + if bytes.Equal(got.Secret, rec.Secret) { + t.Errorf(mismatchErrorString("Secret", got.Secret, rec.Secret)) + } +} + +func TestTopicUpdateOnMessage(t *testing.T) { + msg := types.Message{ + ObjHeader: types.ObjHeader{ + CreatedAt: now.Add(33 * time.Minute), + }, + SeqId: 66, + } + err := adp.TopicUpdateOnMessage(topics[2].Id, &msg) + if err != nil { + t.Fatal(err) + } + var got types.Topic + err = db.Collection("topics").FindOne(ctx, b.M{"_id": topics[2].Id}).Decode(&got) + if err != nil { + t.Fatal(err) + } + if got.TouchedAt != msg.CreatedAt || got.SeqId != msg.SeqId { + t.Errorf(mismatchErrorString("TouchedAt", got.TouchedAt, msg.CreatedAt)) + t.Errorf(mismatchErrorString("SeqId", got.SeqId, msg.SeqId)) + } +} + +func TestTopicUpdate(t *testing.T) { + update := map[string]interface{}{ + "UpdatedAt": now.Add(55 * time.Minute), + } + err := adp.TopicUpdate(topics[0].Id, update) + if err != nil { + t.Fatal(err) + } + var got types.Topic + err = db.Collection("topics").FindOne(ctx, b.M{"_id": topics[0].Id}).Decode(&got) + if got.UpdatedAt != update["UpdatedAt"] { + t.Errorf(mismatchErrorString("UpdatedAt", got.UpdatedAt, update["UpdatedAt"])) + } +} + +func TestTopicOwnerChange(t *testing.T) { + err := adp.TopicOwnerChange(topics[0].Id, types.ParseUserId("usr"+users[1].Id)) + if err != nil { + t.Fatal(err) + } + var got types.Topic + err = db.Collection("topics").FindOne(ctx, b.M{"_id": topics[0].Id}).Decode(&got) + if got.Owner != users[1].Id { + t.Errorf(mismatchErrorString("Owner", got.Owner, users[1].Id)) + } +} + +func TestSubsUpdate(t *testing.T) { + update := map[string]interface{}{ + "UpdatedAt": now.Add(22 * time.Minute), + } + err := adp.SubsUpdate(topics[0].Id, types.ParseUserId("usr"+users[0].Id), update) + if err != nil { + t.Fatal(err) + } + var got types.Subscription + err = db.Collection("subscriptions").FindOne(ctx, b.M{"_id": topics[0].Id + ":" + users[0].Id}).Decode(&got) + if got.UpdatedAt != update["UpdatedAt"] { + t.Errorf(mismatchErrorString("UpdatedAt", got.UpdatedAt, update["UpdatedAt"])) + } + + err = adp.SubsUpdate(topics[1].Id, types.ZeroUid, update) + if err != nil { + t.Fatal(err) + } + err = db.Collection("subscriptions").FindOne(ctx, b.M{"topic": topics[1].Id}).Decode(&got) + if got.UpdatedAt != update["UpdatedAt"] { + t.Errorf(mismatchErrorString("UpdatedAt", got.UpdatedAt, update["UpdatedAt"])) + } +} + +func TestSubsDelete(t *testing.T) { + err := adp.SubsDelete(topics[1].Id, types.ParseUserId("usr"+users[0].Id)) + if err != nil { + t.Fatal(err) + } + var got types.Subscription + err = db.Collection("subscriptions").FindOne(ctx, b.M{"_id": topics[1].Id + ":" + users[0].Id}).Decode(&got) + if got.DeletedAt == nil { + t.Errorf(mismatchErrorString("DeletedAt", got.DeletedAt, nil)) + } +} + +func TestDeviceUpsert(t *testing.T) { + err := adp.DeviceUpsert(types.ParseUserId("usr"+users[0].Id), devs[0]) + if err != nil { + t.Fatal(err) + } + var got types.User + err = db.Collection("users").FindOne(ctx, b.M{"_id": users[0].Id}).Decode(&got) + if err != nil { + t.Error(err) + } + if !reflect.DeepEqual(got.DeviceArray[0], devs[0]) { + t.Error(mismatchErrorString("Device", got.DeviceArray[0], devs[0])) + } + // Test update + devs[0].Platform = "Web" + err = adp.DeviceUpsert(types.ParseUserId("usr"+users[0].Id), devs[0]) + if err != nil { + t.Fatal(err) + } + err = db.Collection("users").FindOne(ctx, b.M{"_id": users[0].Id}).Decode(&got) + if err != nil { + t.Error(err) + } + if got.DeviceArray[0].Platform != "Web" { + t.Error("Device not updated.", got.DeviceArray[0]) + } + // Test add same device to another user + err = adp.DeviceUpsert(types.ParseUserId("usr"+users[1].Id), devs[0]) + if err != nil { + t.Fatal(err) + } + err = db.Collection("users").FindOne(ctx, b.M{"_id": users[1].Id}).Decode(&got) + if err != nil { + t.Error(err) + } + if got.DeviceArray[0].Platform != "Web" { + t.Error("Device not updated.", got.DeviceArray[0]) + } + + err = adp.DeviceUpsert(types.ParseUserId("usr"+users[2].Id), devs[1]) + if err != nil { + t.Error(err) + } +} + +func TestMessageAttachments(t *testing.T) { + fids := []string{files[0].Id, files[1].Id} + err := adp.MessageAttachments(types.ParseUid(msgs[1].Id), fids) + if err != nil { + t.Fatal(err) + } + var got map[string][]string + findOpts := mdbopts.FindOne().SetProjection(b.M{"attachments": 1, "_id": 0}) + err = db.Collection("messages").FindOne(ctx, b.M{"_id": msgs[1].Id}, findOpts).Decode(&got) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got["attachments"], fids) { + t.Error(mismatchErrorString("Attachments", got["attachments"], fids)) + } + var got2 map[string]int + findOpts = mdbopts.FindOne().SetProjection(b.M{"usecount": 1, "_id": 0}) + err = db.Collection("fileuploads").FindOne(ctx, b.M{"_id": files[0].Id}, findOpts).Decode(&got2) + if err != nil { + t.Fatal(err) + } + if got2["usecount"] != 1 { + t.Error(mismatchErrorString("UseCount", got2["usecount"], 1)) + } +} + +func TestFileFinishUpload(t *testing.T) { + got, err := adp.FileFinishUpload(files[0].Id, types.UploadCompleted, 22222) + if err != nil { + t.Fatal(err) + } + if got.Status != types.UploadCompleted { + t.Error(mismatchErrorString("Status", got.Status, types.UploadCompleted)) + } + if got.Size != 22222 { + t.Error(mismatchErrorString("Size", got.Size, 22222)) + } +} + +// ================== Other tests ================================= +func TestDeviceGetAll(t *testing.T) { + uid0 := types.ParseUserId("usr" + users[0].Id) + uid1 := types.ParseUserId("usr" + users[1].Id) + uid2 := types.ParseUserId("usr" + users[2].Id) + gotDevs, count, err := adp.DeviceGetAll(uid0, uid1, uid2) + if err != nil { + t.Fatal(err) + } + if count != 2 { + t.Fatal(mismatchErrorString("count", count, 2)) + } + if !reflect.DeepEqual(gotDevs[uid1][0], *devs[0]) { + t.Error(mismatchErrorString("Device", gotDevs[uid1][0], *devs[0])) + } + if !reflect.DeepEqual(gotDevs[uid2][0], *devs[1]) { + t.Error(mismatchErrorString("Device", gotDevs[uid2][0], *devs[1])) + } +} + +func TestDeviceDelete(t *testing.T) { + err := adp.DeviceDelete(types.ParseUserId("usr"+users[1].Id), devs[0].DeviceId) + if err != nil { + t.Fatal(err) + } + var got types.User + err = db.Collection("users").FindOne(ctx, b.M{"_id": users[1].Id}).Decode(&got) + if err != nil { + t.Fatal(err) + } + if len(got.DeviceArray) != 0 { + t.Error("Device not deleted:", got.DeviceArray) + } + + err = adp.DeviceDelete(types.ParseUserId("usr"+users[2].Id), "") + if err != nil { + t.Fatal(err) + } + err = db.Collection("users").FindOne(ctx, b.M{"_id": users[2].Id}).Decode(&got) + if err != nil { + t.Fatal(err) + } + if len(got.DeviceArray) != 0 { + t.Error("Device not deleted:", got.DeviceArray) + } +} + +// ================== Delete tests ================================ +func TestCredDel(t *testing.T) { + err := adp.CredDel(types.ParseUserId("usr"+users[0].Id), "email", "alice@test.example.com") + if err != nil { + t.Fatal(err) + } + var got []map[string]interface{} + cur, err := db.Collection("credentials").Find(ctx, b.M{"method": "email", "value": "alice@test.example.com"}) + if err != nil { + t.Fatal(err) + } + if err = cur.All(ctx, &got); err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Error("Got result but shouldn't", got) + } + + err = adp.CredDel(types.ParseUserId("usr"+users[1].Id), "", "") + if err != nil { + t.Fatal(err) + } + cur, err = db.Collection("credentials").Find(ctx, b.M{"user": users[1].Id}) + if err != nil { + t.Fatal(err) + } + if err = cur.All(ctx, &got); err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Error("Got result but shouldn't", got) + } +} + +func TestAuthDelScheme(t *testing.T) { + // tested during TestAuthUpdRecord +} + +func TestAuthDelAllRecords(t *testing.T) { + delCount, err := adp.AuthDelAllRecords(types.ParseUserId("usr" + recs[0].UserId)) + if err != nil { + t.Fatal(err) + } + if delCount != 1 { + t.Errorf(mismatchErrorString("delCount", delCount, 1)) + } + + // With dummy user + delCount, err = adp.AuthDelAllRecords(types.ParseUserId("dummyuserid")) + if delCount != 0 { + t.Errorf(mismatchErrorString("delCount", delCount, 0)) + } +} + +func TestSubsDelForTopic(t *testing.T) { + // Soft + err := adp.SubsDelForTopic(topics[1].Id, false) + if err != nil { + t.Fatal(err) + } + var got types.Subscription + err = db.Collection("subscriptions").FindOne(ctx, b.M{"topic": topics[1].Id}).Decode(&got) + if got.DeletedAt == nil { + t.Errorf(mismatchErrorString("DeletedAt", got.DeletedAt, nil)) + } + // Hard + err = adp.SubsDelForTopic(topics[1].Id, true) + if err != nil { + t.Fatal(err) + } + err = db.Collection("subscriptions").FindOne(ctx, b.M{"topic": topics[1].Id}).Decode(&got) + if err != mdb.ErrNoDocuments { + t.Error("Sub not deleted. Err:", err) + } +} + +func TestSubsDelForUser(t *testing.T) { + // Tested during TestUserDelete (both hard and soft deletions) +} + +func TestMessageDeleteList(t *testing.T) { + toDel := types.DelMessage{ + ObjHeader: types.ObjHeader{ + Id: uGen.GetStr(), + CreatedAt: now, + UpdatedAt: now, + }, + Topic: topics[1].Id, + DeletedFor: users[2].Id, + DelId: 1, + SeqIdRanges: []types.Range{{Low: 9}, {Low: 3, Hi: 7}}, + } + err := adp.MessageDeleteList(toDel.Topic, &toDel) + if err != nil { + t.Fatal(err) + } + + var got []types.Message + cur, err := db.Collection("messages").Find(ctx, b.M{"topic": toDel.Topic}) + if err != nil { + t.Fatal(err) + } + if err = cur.All(ctx, &got); err != nil { + t.Fatal(err) + } + for _, msg := range got { + if msg.SeqId == 1 && msg.DeletedFor != nil { + t.Error("Message with SeqID=1 should not be deleted") + } + if msg.SeqId == 5 && msg.DeletedFor == nil { + t.Error("Message with SeqID=5 should be deleted") + } + if msg.SeqId == 11 && msg.DeletedFor == nil { + t.Error("Message with SeqID=5 should be deleted") + } + } + // + toDel = types.DelMessage{ + ObjHeader: types.ObjHeader{ + Id: uGen.GetStr(), + CreatedAt: now, + UpdatedAt: now, + }, + Topic: topics[0].Id, + DelId: 3, + SeqIdRanges: []types.Range{{Low: 1, Hi: 3}}, + } + err = adp.MessageDeleteList(toDel.Topic, &toDel) + if err != nil { + t.Fatal(err) + } + cur, err = db.Collection("messages").Find(ctx, b.M{"topic": toDel.Topic}) + if err != nil { + t.Fatal(err) + } + if err = cur.All(ctx, &got); err != nil { + t.Fatal(err) + } + for _, msg := range got { + if msg.Content != nil { + t.Error("Message not deleted:", msg) + } + } + + err = adp.MessageDeleteList(topics[0].Id, nil) + if err != nil { + t.Fatal(err) + } + cur, err = db.Collection("messages").Find(ctx, b.M{"topic": topics[0].Id}) + if err != nil { + t.Fatal(err) + } + if err = cur.All(ctx, &got); err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Error("Result should be empty:", got) + } +} + +func TestTopicDelete(t *testing.T) { + err := adp.TopicDelete(topics[1].Id, false) + if err != nil { + t.Fatal() + } + var got types.Topic + cur, err := db.Collection("topics").Find(ctx, b.M{"topic": topics[1].Id}) + if err != nil { + t.Fatal(err) + } + for cur.Next(ctx) { + if err = cur.Decode(&got); err != nil { + t.Error(err) + } + if got.DeletedAt == nil { + t.Error("Soft delete failed:", got) + } + } + + err = adp.TopicDelete(topics[0].Id, true) + if err != nil { + t.Fatal() + } + + var got2 []types.Topic + cur, err = db.Collection("topics").Find(ctx, b.M{"topic": topics[0].Id}) + if err != nil { + t.Fatal(err) + } + if err = cur.All(ctx, &got2); err != nil { + t.Fatal(err) + } + if len(got2) != 0 { + t.Error("Hard delete failed:", got2) + } +} + +func TestFileDeleteUnused(t *testing.T) { + locs, err := adp.FileDeleteUnused(now.Add(1*time.Minute), 999) + if err != nil { + t.Fatal(err) + } + if len(locs) == 0 { + t.Error(mismatchErrorString("Locations length", len(locs), 0)) + } +} + +func TestUserDelete(t *testing.T) { + err := adp.UserDelete(types.ParseUserId("usr"+users[0].Id), false) + if err != nil { + t.Fatal(err) + } + var got types.User + err = db.Collection("users").FindOne(ctx, b.M{"_id": users[0].Id}).Decode(&got) + if err != nil { + t.Fatal(err) + } + if got.DeletedAt == nil { + t.Error("User soft delete failed", got) + } + + err = adp.UserDelete(types.ParseUserId("usr"+users[1].Id), true) + if err != nil { + t.Fatal(err) + } + err = db.Collection("users").FindOne(ctx, b.M{"_id": users[1].Id}).Decode(&got) + if err != mdb.ErrNoDocuments { + t.Error("User hard delete failed", err) + } +} + +// ================== Other tests ================================= +func TestMessageGetDeleted(t *testing.T) { + qOpts := types.QueryOpt{ + Since: 1, + Before: 10, + Limit: 999, + } + got, err := adp.MessageGetDeleted(topics[1].Id, types.ParseUserId("usr"+users[2].Id), &qOpts) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Error(mismatchErrorString("result length", len(got), 1)) + } +} + +// ================================================================ +func mismatchErrorString(key string, got, want interface{}) string { + return fmt.Sprintf("%v mismatch:\nGot = %v\nWant = %v", key, got, want) +} + +func initConnectionToDb() { + var adpConfig struct { + Addresses interface{} `json:"addresses,omitempty"` + Database string `json:"database,omitempty"` + } + + if err := json.Unmarshal(config.Adapters[adp.GetName()], &adpConfig); err != nil { + log.Fatal("adapter mongodb failed to parse config: " + err.Error()) + } + + var opts mdbopts.ClientOptions + + if adpConfig.Addresses == nil { + opts.SetHosts([]string{"localhost:27017"}) + } else if host, ok := adpConfig.Addresses.(string); ok { + opts.SetHosts([]string{host}) + } else if hosts, ok := adpConfig.Addresses.([]string); ok { + opts.SetHosts(hosts) + } else { + log.Fatal("adapter mongodb failed to parse config.Addresses") + } + + if adpConfig.Database == "" { + adpConfig.Database = "tinode_test" + } + + ctx = context.Background() + conn, err := mdb.Connect(ctx, &opts) + if err != nil { + log.Fatal(err) + } + + db = conn.Database(adpConfig.Database) +} + +func init() { + adp = backend.GetAdapter() + conffile := flag.String("config", "./test.conf", "config of the database connection") + + if file, err := os.Open(*conffile); err != nil { + log.Fatal("Failed to read config file:", err) + } else if err = json.NewDecoder(jcr.New(file)).Decode(&config); err != nil { + log.Fatal("Failed to parse config file:", err) + } + + if adp == nil { + log.Fatal("Database adapter is missing") + } + if adp.IsOpen() { + log.Print("Connection is already opened") + } + + err := adp.Open(config.Adapters[adp.GetName()]) + if err != nil { + log.Fatal(err) + } + + if err := uGen.Init(11, []byte("testtesttesttest")); err != nil { + log.Fatal(err) + } + + initConnectionToDb() + initData() +} diff --git a/server/db/mongodb/tests/test.conf b/server/db/mongodb/tests/test.conf new file mode 100644 index 000000000..2c1ee2d3c --- /dev/null +++ b/server/db/mongodb/tests/test.conf @@ -0,0 +1,12 @@ +{ + "reset_db_data": true, + "adapters": { + "mongodb": { + "database": "tinode_test", + "replica_set": "rs0", + "addresses": "localhost:27017", + //"username": "tinode_test", + //"password": "tinode_test", + } + } +} diff --git a/server/db/mongodb/tests/test_data.go b/server/db/mongodb/tests/test_data.go new file mode 100644 index 000000000..254bacd55 --- /dev/null +++ b/server/db/mongodb/tests/test_data.go @@ -0,0 +1,337 @@ +package tests + +import ( + "time" + + "github.com/tinode/chat/server/auth" + "github.com/tinode/chat/server/store/types" +) + +type AuthRecord struct { + Id string `bson:"_id"` + UserId string + Scheme string + AuthLvl auth.Level + Secret []byte + Expires time.Time +} + +var uGen types.UidGenerator +var users []*types.User +var creds []*types.Credential +var recs []AuthRecord +var topics []*types.Topic +var subs []*types.Subscription +var msgs []*types.Message +var devs []*types.DeviceDef +var files []*types.FileDef +var now time.Time + +func initUsers() { + 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: "xQLrX3WPS2o", + }, + UserAgent: "Tindroid v1.2.3", + Tags: []string{"carol"}, + }) + for _, user := range users { + user.InitTimes() + } + deletedAt := now.Add(10 * time.Minute) + users[2].DeletedAt = &deletedAt +} +func initCreds() { + 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) +} +func initAuthRecords() { + recs = append(recs, AuthRecord{ + Id: "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, AuthRecord{ + Id: "basic:bob", + UserId: users[1].Id, + Scheme: "basic", + AuthLvl: auth.LevelAuth, + Secret: []byte{'b', 'o', 'b'}, + Expires: now.Add(24 * time.Hour), + }) +} +func initTopics() { + topics = append(topics, &types.Topic{ + ObjHeader: types.ObjHeader{ + Id: "grpgRXf0rU4uR4", + CreatedAt: now, + UpdatedAt: now, + }, + TouchedAt: now, + Owner: users[0].Id, + SeqId: 111, + Tags: []string{"travel", "zxcv"}, + }) + 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, + Tags: []string{"qwer"}, + }) + topics = append(topics, &types.Topic{ + ObjHeader: types.ObjHeader{ + Id: "p2pQvr1xwKU01LfKzGSh3mE0w", + CreatedAt: now, + UpdatedAt: now, + }, + TouchedAt: now, + SeqId: 333, + Tags: []string{"asdf"}, + }) +} +func initSubs() { + subs = append(subs, &types.Subscription{ + ObjHeader: types.ObjHeader{ + CreatedAt: now, + UpdatedAt: now, + }, + 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, + }, + 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, + }, + 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, + }, + 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, + }, + User: users[2].Id, + Topic: topics[3].Id, + RecvSeqId: 555, + ReadSeqId: 455, + ModeWant: 47, + ModeGiven: 47, + }) + for _, sub := range subs { + sub.SetTouchedAt(now) + } +} +func initMessages() { + 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 _, msg := range msgs { + msg.InitTimes() + msg.SetUid(uGen.Get()) + } +} +func initDevices() { + 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", + }) +} +func initFileDefs() { + files = append(files, &types.FileDef{ + ObjHeader: types.ObjHeader{ + Id: uGen.GetStr(), + CreatedAt: now, + UpdatedAt: now, + }, + Status: types.UploadStarted, + User: users[0].Id, + MimeType: "application/pdf", + Location: "uploads/qwerty.pdf", + }) + files = append(files, &types.FileDef{ + ObjHeader: types.ObjHeader{ + Id: uGen.GetStr(), + CreatedAt: now, + UpdatedAt: now, + }, + Status: types.UploadStarted, + User: users[0].Id, + Location: "uploads/asdf.txt", + }) +} +func initData() { + now = types.TimeNow() + initUsers() + initCreds() + initAuthRecords() + initTopics() + initSubs() + initMessages() + initDevices() + initFileDefs() +} diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index a8f4cd89e..101a126b3 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -33,7 +33,7 @@ const ( defaultDSN = "root:@tcp(localhost:3306)/tinode?parseTime=true" defaultDatabase = "tinode" - adpVersion = 109 + adpVersion = 110 adapterName = "mysql" @@ -497,6 +497,8 @@ func (a *adapter) UpgradeDb() error { } if a.version == 108 { + // Perform database upgrade from version 108 to version 109. + tx, err := a.db.Begin() if err != nil { return err @@ -514,6 +516,17 @@ func (a *adapter) UpgradeDb() error { } } + if a.version == 109 { + // Perform database upgrade from version 109 to version 110. + if _, err := a.db.Exec(`UPDATE topics SET touchedat=updatedat WHERE touchedat IS NULL`); err != nil { + return err + } + + if err := bumpVersion(a, 110); err != nil { + return err + } + } + if a.version != adpVersion { return errors.New("Failed to perform database upgrade to version " + strconv.Itoa(adpVersion) + ". DB is still at " + strconv.Itoa(a.version)) @@ -523,9 +536,9 @@ func (a *adapter) UpgradeDb() error { func createSystemTopic(tx *sql.Tx) error { now := t.TimeNow() - sql := `INSERT INTO topics(createdat,updatedat,name,access,public) - VALUES(?,?,'sys','{"Auth": "N","Anon": "N"}','{"fn": "System"}')` - _, err := tx.Exec(sql, now, now) + sql := `INSERT INTO topics(createdat,updatedat,touchedat,name,access,public) + VALUES(?,?,?,'sys','{"Auth": "N","Anon": "N"}','{"fn": "System"}')` + _, err := tx.Exec(sql, now, now, now) return err } @@ -1901,7 +1914,7 @@ func (a *adapter) MessageGetAll(topic string, forUser t.Uid, opts *t.QueryOpt) ( rows, err := a.db.Queryx( "SELECT m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,m.head,m.content"+ " FROM messages AS m LEFT JOIN dellog AS d"+ - " ON d.topic=m.topic AND m.seqid BETWEEN d.low AND d.hi AND d.deletedfor=?"+ + " ON d.topic=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=?"+ " WHERE m.delid=0 AND m.topic=? AND m.seqid BETWEEN ? AND ? AND d.deletedfor IS NULL"+ " ORDER BY m.seqid DESC LIMIT ?", unum, topic, lower, upper, limit) @@ -1959,6 +1972,7 @@ func (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOp if err != nil { return nil, err } + defer rows.Close() var dmsgs []t.DelMessage var dmsg t.DelMessage @@ -1967,6 +1981,7 @@ func (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOp dmsgs = nil break } + if dellog.Delid != dmsg.DelId { if dmsg.DelId > 0 { dmsgs = append(dmsgs, dmsg) @@ -1975,20 +1990,22 @@ func (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOp dmsg.Topic = dellog.Topic if dellog.Deletedfor > 0 { dmsg.DeletedFor = store.EncodeUid(dellog.Deletedfor).String() + } else { + dmsg.DeletedFor = "" } - if dmsg.SeqIdRanges == nil { - dmsg.SeqIdRanges = []t.Range{} - } + dmsg.SeqIdRanges = nil } if dellog.Hi <= dellog.Low+1 { dellog.Hi = 0 } dmsg.SeqIdRanges = append(dmsg.SeqIdRanges, t.Range{dellog.Low, dellog.Hi}) } - if dmsg.DelId > 0 { - dmsgs = append(dmsgs, dmsg) + + if err == nil { + if dmsg.DelId > 0 { + dmsgs = append(dmsgs, dmsg) + } } - rows.Close() return dmsgs, err } diff --git a/server/db/rethinkdb/adapter.go b/server/db/rethinkdb/adapter.go index 4913066b0..100a21933 100644 --- a/server/db/rethinkdb/adapter.go +++ b/server/db/rethinkdb/adapter.go @@ -28,7 +28,7 @@ const ( defaultHost = "localhost:28015" defaultDatabase = "tinode" - adpVersion = 109 + adpVersion = 110 adapterName = "rethinkdb" @@ -398,6 +398,17 @@ func (a *adapter) UpgradeDb() error { } } + if a.version == 109 { + // Perform database upgrade from versions 109 to version 110. + + // TouchedAt is a required field now, but it's OK if it's missing. + // Bumping version to keep RDB in sync with MySQL versions. + + if err := bumpVersion(a, 110); err != nil { + return err + } + } + if a.version != adpVersion { return errors.New("Failed to perform database upgrade to version " + strconv.Itoa(adpVersion) + ". DB is still at " + strconv.Itoa(a.version)) @@ -412,8 +423,9 @@ func createSystemTopic(a *adapter) error { ObjHeader: t.ObjHeader{Id: "sys", CreatedAt: now, UpdatedAt: now}, - Access: t.DefaultAccess{Auth: t.ModeNone, Anon: t.ModeNone}, - Public: map[string]interface{}{"fn": "System"}, + TouchedAt: now, + Access: t.DefaultAccess{Auth: t.ModeNone, Anon: t.ModeNone}, + Public: map[string]interface{}{"fn": "System"}, }).RunWrite(a.conn) return err } diff --git a/server/hdl_grpc.go b/server/hdl_grpc.go index 41335e629..bcdd6e186 100644 --- a/server/hdl_grpc.go +++ b/server/hdl_grpc.go @@ -80,6 +80,10 @@ func (sess *Session) writeGrpcLoop() { // channel closed return } + if len(sess.send) > sendQueueLimit { + log.Println("grpc: outbound queue limit exceeded", sess.sid) + return + } if err := grpcWrite(sess, msg); err != nil { log.Println("grpc: write", sess.sid, err) return diff --git a/server/hdl_longpoll.go b/server/hdl_longpoll.go index 4c2a3ebc8..7fdeaf50a 100644 --- a/server/hdl_longpoll.go +++ b/server/hdl_longpoll.go @@ -23,10 +23,12 @@ func (sess *Session) writeOnce(wrt http.ResponseWriter, req *http.Request) { for { select { case msg, ok := <-sess.send: - if !ok { - log.Println("longPoll: writeOnce reading from a closed channel", sess.sid) - } else if err := lpWrite(wrt, msg); err != nil { - log.Println("longPoll: writeOnce failed", sess.sid, err) + if ok { + if len(sess.send) > sendQueueLimit { + log.Println("longPoll: outbound queue limit exceeded", sess.sid) + } else if err := lpWrite(wrt, msg); err != nil { + log.Println("longPoll: writeOnce failed", sess.sid, err) + } } return diff --git a/server/hdl_websock.go b/server/hdl_websock.go index 556443f75..2777264ba 100644 --- a/server/hdl_websock.go +++ b/server/hdl_websock.go @@ -79,6 +79,10 @@ func (sess *Session) writeLoop() { // Channel closed. return } + if len(sess.send) > sendQueueLimit { + log.Println("ws: outbound queue limit exceeded", sess.sid) + return + } if err := wsWrite(sess.ws, websocket.TextMessage, msg); err != nil { if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure, websocket.CloseNormalClosure) { diff --git a/server/hub.go b/server/hub.go index 0761350af..f4497f9c5 100644 --- a/server/hub.go +++ b/server/hub.go @@ -10,6 +10,7 @@ package main import ( + "container/list" "log" "strings" "sync" @@ -31,8 +32,6 @@ type sessionJoin struct { // In case of p2p topics, it's true if the other user's subscription was // created (as a part of new topic creation or just alone). created bool - // True if the topic was just activated (loaded into memory). - loaded bool // True if this is a new subscription. newsub bool // True if this topic is created internally. @@ -166,20 +165,21 @@ func (h *Hub) run() { // Topic does not exist or not loaded. t = &Topic{name: sreg.topic, xoriginal: sreg.pkt.topic, - sessions: make(map[*Session]types.UidSlice), + sessions: make(map[*Session]perSessionData), broadcast: make(chan *ServerComMessage, 256), reg: make(chan *sessionJoin, 32), unreg: make(chan *sessionLeave, 32), meta: make(chan *metaReq, 32), + defrNotif: new(list.List), perUser: make(map[types.Uid]perUserData), exit: make(chan *shutDown, 1), } // Topic is created in suspended state because it's not yet configured. - t.suspend() + t.markSuspended() // Save topic now to prevent race condition. h.topicPut(sreg.topic, t) - // Create the topic. + // Configure the topic. go topicInit(t, sreg, h) } else { @@ -333,10 +333,9 @@ func (h *Hub) topicUnreg(sess *Session, topic string, msg *ClientComMessage, rea if 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.suspend() - + oldStatus := t.markSuspended() if err := store.Topics.Delete(topic, true); err != nil { - t.resume() + t.setStatus(oldStatus) sess.queueOut(ErrUnknown(msg.id, msg.topic, now)) return err } diff --git a/server/init_topic.go b/server/init_topic.go index ee5d76c47..065e2d19f 100644 --- a/server/init_topic.go +++ b/server/init_topic.go @@ -96,14 +96,17 @@ func topicInit(t *Topic, sreg *sessionJoin, h *Hub) { statsInc("TotalTopics", 1) usersRegisterTopic(t, true) - sreg.loaded = true - // Topic will check access rights, send invite to p2p user, send {ctrl} message to the initiator session if !sreg.internal { t.reg <- sreg } - t.resume() + switch t.cat { + case types.TopicCatFnd, types.TopicCatSys: + t.markLoaded() + default: + t.markNew() + } go t.run(h) } diff --git a/server/main.go b/server/main.go index 4b9c57307..b0020033b 100644 --- a/server/main.go +++ b/server/main.go @@ -35,6 +35,7 @@ import ( _ "github.com/tinode/chat/server/auth/token" // Database backends + _ "github.com/tinode/chat/server/db/mongodb" _ "github.com/tinode/chat/server/db/mysql" _ "github.com/tinode/chat/server/db/rethinkdb" @@ -87,7 +88,10 @@ const ( // maxDeleteCount is the maximum allowed number of messages to delete in one call. defaultMaxDeleteCount = 1024 - // Mount point where static content is served, http://host-name/ + // Base URL path for serving the streaming API. + defaultApiPath = "/" + + // Mount point where static content is served, http://host-name defaultStaticMount = "/" // Local path to static content @@ -182,12 +186,15 @@ type mediaConfig struct { // Contentx of the configuration file type configType struct { - // Default HTTP(S) address:port to listen on for websocket and long polling clients. Either a + // 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 overriden from the command line, see option --api_path. + ApiPath string `json:"api_path"` // Cache-Control value for static content. CacheControl int `json:"cache_control"` // Address:port to listen for gRPC clients. If blank gRPC support will not be initialized. @@ -196,7 +203,7 @@ type configType struct { // 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. + // 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"` @@ -240,6 +247,7 @@ func main() { // Path to static content. var staticPath = flag.String("static_data", defaultStaticPath, "File path to directory with static files to be served.") var listenOn = flag.String("listen", "", "Override address and port to listen on for HTTP(S) clients.") + var apiPath = flag.String("api_path", "", "Override the base URL path where API is served.") var listenGrpc = flag.String("grpc_listen", "", "Override address and port to listen on for gRPC clients.") var tlsEnabled = flag.Bool("tls_enabled", false, "Override config value for enabling TLS.") var clusterSelf = flag.String("cluster_self", "", "Override the name of the current cluster node.") @@ -254,8 +262,25 @@ func main() { var config configType if file, err := os.Open(*configfile); err != nil { log.Fatal("Failed to read config file: ", err) - } else if err = json.NewDecoder(jcr.New(file)).Decode(&config); err != nil { - log.Fatal("Failed to parse config file: ", err) + } else { + if err = json.NewDecoder(jcr.New(file)).Decode(&config); err != nil { + // Need to reset file to start in order to convert byte offset to line number and character position. + // Ignore possible error: can't use it anyway. + file.Seek(0, 0) + switch jerr := err.(type) { + case *json.UnmarshalTypeError: + lnum, cnum, _ := offsetToLineAndChar(file, jerr.Offset) + log.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, _ := offsetToLineAndChar(file, jerr.Offset) + log.Fatalf("Syntax error in config file at %d:%d (offset %d bytes): %s", + lnum, cnum, jerr.Offset, jerr.Error()) + default: + log.Fatal("Failed to parse config file: ", err) + } + } + file.Close() } if *listenOn != "" { @@ -537,15 +562,31 @@ func main() { log.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 += "/" + } + } + log.Printf("API served from root URL path '%s'", config.ApiPath) + // Handle websocket clients. - mux.HandleFunc("/v0/channels", serveWebSocket) + mux.HandleFunc(config.ApiPath+"v0/channels", serveWebSocket) // Handle long polling clients. Enable compression. - mux.Handle("/v0/channels/lp", gh.CompressHandler(http.HandlerFunc(serveLongPoll))) + mux.Handle(config.ApiPath+"v0/channels/lp", gh.CompressHandler(http.HandlerFunc(serveLongPoll))) if config.Media != nil { // Handle uploads of large files. - mux.Handle("/v0/file/u/", gh.CompressHandler(http.HandlerFunc(largeFileUpload))) + mux.Handle(config.ApiPath+"v0/file/u/", gh.CompressHandler(http.HandlerFunc(largeFileUpload))) // Serve large files. - mux.Handle("/v0/file/s/", gh.CompressHandler(http.HandlerFunc(largeFileServe))) + mux.Handle(config.ApiPath+"v0/file/s/", gh.CompressHandler(http.HandlerFunc(largeFileServe))) log.Println("Large media handling enabled", config.Media.UseHandler) } diff --git a/server/pbconverter.go b/server/pbconverter.go index e328ae1ff..278d35b09 100644 --- a/server/pbconverter.go +++ b/server/pbconverter.go @@ -241,10 +241,11 @@ func pbCliSerialize(msg *ClientComMessage) *pbx.ClientMsg { Cred: pbClientCredsSerialize(msg.Login.Cred)}} case msg.Sub != nil: pkt.Message = &pbx.ClientMsg_Sub{Sub: &pbx.ClientSub{ - Id: msg.Sub.Id, - Topic: msg.Sub.Topic, - SetQuery: pbSetQuerySerialize(msg.Sub.Set), - GetQuery: pbGetQuerySerialize(msg.Sub.Get)}} + Id: msg.Sub.Id, + Topic: msg.Sub.Topic, + Background: msg.Sub.Background, + SetQuery: pbSetQuerySerialize(msg.Sub.Set), + GetQuery: pbGetQuerySerialize(msg.Sub.Get)}} case msg.Leave != nil: pkt.Message = &pbx.ClientMsg_Leave{Leave: &pbx.ClientLeave{ Id: msg.Leave.Id, @@ -338,10 +339,11 @@ func pbCliDeserialize(pkt *pbx.ClientMsg) *ClientComMessage { } } else if sub := pkt.GetSub(); sub != nil { msg.Sub = &MsgClientSub{ - Id: sub.GetId(), - Topic: sub.GetTopic(), - Get: pbGetQueryDeserialize(sub.GetGetQuery()), - Set: pbSetQueryDeserialize(sub.GetSetQuery()), + Id: sub.GetId(), + Topic: sub.GetTopic(), + Background: sub.GetBackground(), + Get: pbGetQueryDeserialize(sub.GetGetQuery()), + Set: pbSetQueryDeserialize(sub.GetSetQuery()), } } else if leave := pkt.GetLeave(); leave != nil { msg.Leave = &MsgClientLeave{ @@ -510,7 +512,7 @@ func pbGetQueryDeserialize(in *pbx.GetQuery) *MsgGetQuery { } } if sub := in.GetSub(); sub != nil { - msg.Desc = &MsgGetOpts{ + msg.Sub = &MsgGetOpts{ IfModifiedSince: int64ToTime(sub.GetIfModifiedSince()), Limit: int(sub.GetLimit()), } diff --git a/server/pres.go b/server/pres.go index 1f43de2f5..0ebb9e6ec 100644 --- a/server/pres.go +++ b/server/pres.go @@ -90,7 +90,7 @@ func (t *Topic) loadContacts(uid types.Uid) error { // "+dis" disable subscription withot removing it, the opposite of "en". // The "+en/rem/dis" command itself is stripped from the notification. func (t *Topic) presProcReq(fromUserID, what string, wantReply bool) string { - if !t.isReady() { + if !t.isActive() { return "" } @@ -209,7 +209,7 @@ func (t *Topic) presProcReq(fromUserID, what string, wantReply bool) string { globals.hub.route <- &ServerComMessage{ // 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}, + Pres: &MsgServerPres{Topic: "me", What: replyAs, Src: t.name, WantReply: reqReply}, rcptto: fromUserID} } @@ -225,7 +225,7 @@ func (t *Topic) presUsersOfInterest(what, ua string) { // Push update to subscriptions for topic := range t.perSubs { globals.hub.route <- &ServerComMessage{ - Pres: &MsgServerPres{Topic: "me", What: what, Src: t.name, UserAgent: ua, wantReply: (what == "on")}, + Pres: &MsgServerPres{Topic: "me", What: what, Src: t.name, UserAgent: ua, WantReply: (what == "on")}, rcptto: topic} } } @@ -236,7 +236,7 @@ func presUsersOfInterestOffline(uid types.Uid, subs []types.Subscription, what s // Push update to subscriptions for _, sub := range subs { globals.hub.route <- &ServerComMessage{ - Pres: &MsgServerPres{Topic: "me", What: what, Src: uid.UserId(), wantReply: false}, + Pres: &MsgServerPres{Topic: "me", What: what, Src: uid.UserId(), WantReply: false}, rcptto: sub.Topic} } } @@ -269,8 +269,8 @@ func (t *Topic) presSubsOnline(what, src string, params *presParams, 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(pf.filterIn), filterOut: int(pf.filterOut), - singleUser: pf.singleUser, excludeUser: pf.excludeUser}, + FilterIn: int(pf.filterIn), FilterOut: int(pf.filterOut), + SingleUser: pf.singleUser, ExcludeUser: pf.excludeUser}, rcptto: t.name, skipSid: skipSid} } @@ -345,7 +345,7 @@ func (t *Topic) presSubsOffline(what string, params *presParams, filter *presFil Pres: &MsgServerPres{Topic: "me", What: what, Src: t.original(uid), Acs: params.packAcs(), AcsActor: actor, AcsTarget: target, SeqId: params.seqID, DelId: params.delID, - skipTopic: skipTopic}, + SkipTopic: skipTopic}, rcptto: user, skipSid: skipSid} } } @@ -419,7 +419,7 @@ func (t *Topic) presSingleUserOffline(uid types.Uid, what string, params *presPa 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}, + WantReply: strings.HasPrefix(what, "?unkn"), SkipTopic: skipTopic}, rcptto: user, skipSid: skipSid} } } diff --git a/server/push/fcm/push_fcm.go b/server/push/fcm/push_fcm.go index 5a4d8c195..05376e524 100644 --- a/server/push/fcm/push_fcm.go +++ b/server/push/fcm/push_fcm.go @@ -181,7 +181,12 @@ func sendNotifications(rcpt *push.Receipt, config *configType) { Priority: "high", } if config.IncludeAndroidNotification { + // 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. msg.Android.Notification = &fcm.AndroidNotification{ + // Android uses Tag value to group notifications together: + // show just one notification per topic. + Tag: rcpt.Payload.Topic, Title: "New message", Body: data["content"], Icon: config.Icon, @@ -192,15 +197,16 @@ func sendNotifications(rcpt *push.Receipt, config *configType) { // iOS uses Badge to show the total unread message count. badge := rcpt.To[uid].Unread // Need to duplicate these in APNS.Payload.Aps.Alert so - // iOS may call NotificationServiceExtension (if present). + // iOS may call NotificationServiceExtension (if present). title := "New message" - body := data["content"] + body := data["content"] msg.APNS = &fcm.APNSConfig{ Payload: &fcm.APNSPayload{ Aps: &fcm.Aps{ - Badge: &badge, - MutableContent: true, - Sound: "default", + Badge: &badge, + ContentAvailable: true, + MutableContent: true, + Sound: "default", Alert: &fcm.ApsAlert{ Title: title, Body: body, diff --git a/server/session.go b/server/session.go index 2feb25310..68835123f 100644 --- a/server/session.go +++ b/server/session.go @@ -35,7 +35,12 @@ const ( ) // Wait time before abandoning the outbound send operation. -const sendTimeout = time.Microsecond * 150 +// Timeout is rather long to make sure it's longer than Linux preeption time: +// https://elixir.bootlin.com/linux/latest/source/kernel/sched/fair.c#L38 +const sendTimeout = time.Millisecond * 7 + +// Maximum number of queued messages before session is considered stale and dropped. +const sendQueueLimit = 128 var minSupportedVersionValue = parseVersion(minSupportedVersion) @@ -407,6 +412,7 @@ func (s *Session) subscribe(msg *ClientComMessage) { } } + // Session can subscribe to topic on behalf of a single user at a time. if sub := s.getSub(expanded); sub != nil { s.queueOut(InfoAlreadySubscribed(msg.id, msg.topic, msg.timestamp)) } else if remoteNodeName := globals.cluster.nodeNameForTopicIfRemote(expanded); remoteNodeName != "" { @@ -780,6 +786,8 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss // Authenticate the session. s.uid = rec.Uid s.authLvl = rec.AuthLevel + // Reset expiration time. + rec.Lifetime = 0 } features |= auth.FeatureValidated diff --git a/server/sessionstore.go b/server/sessionstore.go index 9e7c605e7..de159d65a 100644 --- a/server/sessionstore.go +++ b/server/sessionstore.go @@ -60,9 +60,9 @@ func (ss *SessionStore) NewSession(conn interface{}, sid string) (*Session, int) if s.proto != NONE { s.subs = make(map[string]*Subscription) - s.send = make(chan interface{}, 256) // buffered - s.stop = make(chan interface{}, 1) // Buffered by 1 just to make it non-blocking - s.detach = make(chan string, 64) // buffered + s.send = make(chan interface{}, sendQueueLimit+32) // buffered + s.stop = make(chan interface{}, 1) // Buffered by 1 just to make it non-blocking + s.detach = make(chan string, 64) // buffered if globals.cluster != nil { s.remoteSubs = make(map[string]*RemoteSubscription) } diff --git a/server/store/store.go b/server/store/store.go index 1c1465a09..c877d3204 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -302,7 +302,9 @@ func (UsersObjMapper) UpdateLastSeen(uid types.Uid, userAgent string, when time. // Update is a generic user data update. func (UsersObjMapper) Update(uid types.Uid, update map[string]interface{}) error { - update["UpdatedAt"] = types.TimeNow() + if _, ok := update["UpdatedAt"]; !ok { + update["UpdatedAt"] = types.TimeNow() + } return adp.UserUpdate(uid, update) } @@ -454,7 +456,9 @@ func (TopicsObjMapper) GetSubsAny(topic string, opts *types.QueryOpt) ([]types.S // Update is a generic topic update. func (TopicsObjMapper) Update(topic string, update map[string]interface{}) error { - update["UpdatedAt"] = types.TimeNow() + if _, ok := update["UpdatedAt"]; !ok { + update["UpdatedAt"] = types.TimeNow() + } return adp.TopicUpdate(topic, update) } diff --git a/server/store/types/types.go b/server/store/types/types.go index 32fba5be9..303d38f0f 100644 --- a/server/store/types/types.go +++ b/server/store/types/types.go @@ -214,7 +214,7 @@ func (us UidSlice) find(uid Uid) (int, bool) { return idx, idx < l && us[idx] == uid } -// Add uid to UidSlice keeping it sorted. +// Add uid to UidSlice keeping it sorted. Duplicates are ignored. func (us *UidSlice) Add(uid Uid) bool { idx, found := us.find(uid) if found { @@ -297,11 +297,14 @@ func ParseP2P(p2p string) (uid1, uid2 Uid, err error) { // ObjHeader is the header shared by all stored objects. type ObjHeader struct { - Id string // using string to get around rethinkdb's problems with unit64 + // using string to get around rethinkdb's problems with uint64; + // `bson:"_id"` tag is for mongodb to use as primary key '_id' + // 'omitempty' causes mongodb automaticaly create "_id" field if field not set explicitly + Id string `bson:"_id,omitempty"` id Uid CreatedAt time.Time UpdatedAt time.Time - DeletedAt *time.Time `json:"DeletedAt,omitempty"` + DeletedAt *time.Time `json:"DeletedAt,omitempty" bson:",omitempty"` } // Uid assigns Uid header field. @@ -371,7 +374,7 @@ func (ss StringSlice) Value() (driver.Value, error) { // User is a representation of a DB-stored user record. type User struct { - ObjHeader + ObjHeader `bson:",inline"` State int @@ -392,7 +395,9 @@ type User struct { Tags StringSlice // Info on known devices, used for push notifications - Devices map[string]*DeviceDef + Devices map[string]*DeviceDef `bson:"__devices,skip,omitempty"` + // Same for mongodb scheme. Ignore in other db backends if its not suitable. + DeviceArray []*DeviceDef `json:"-" bson:"devices"` } // AccessMode is a definition of access mode bits. @@ -659,7 +664,7 @@ func (da DefaultAccess) Value() (driver.Value, error) { // Credential hold data needed to validate and check validity of a credential like email or phone. type Credential struct { - ObjHeader + ObjHeader `bson:",inline"` // Credential owner User string // Verification method (email, tel, captcha, etc) @@ -676,7 +681,7 @@ type Credential struct { // Subscription to a topic type Subscription struct { - ObjHeader + ObjHeader `bson:",inline"` // User who has relationship with the topic User string // Topic subscribed to @@ -809,7 +814,7 @@ type perUserData struct { // Topic stored in database. Topic's name is Id type Topic struct { - ObjHeader + ObjHeader `bson:",inline"` // Timestamp when the last message has passed through the topic TouchedAt time.Time @@ -917,16 +922,16 @@ func (mh MessageHeaders) Value() (driver.Value, error) { // Message is a stored {data} message type Message struct { - ObjHeader + ObjHeader `bson:",inline"` // ID of the hard-delete operation - DelId int `json:"DelId,omitempty"` + DelId int `json:"DelId,omitempty" bson:",omitempty"` // List of users who have marked this message as soft-deleted - DeletedFor []SoftDelete `json:"DeletedFor,omitempty"` + DeletedFor []SoftDelete `json:"DeletedFor,omitempty" bson:",omitempty"` SeqId int Topic string // Sender's user ID as string (without 'usr' prefix), could be empty. From string - Head MessageHeaders `json:"Head,omitempty"` + Head MessageHeaders `json:"Head,omitempty" bson:",omitempty"` Content interface{} } @@ -934,7 +939,7 @@ type Message struct { // If the range contains just one ID, Hi is set to 0 type Range struct { Low int - Hi int `json:"Hi,omitempty"` + Hi int `json:"Hi,omitempty" bson:",omitempty"` } // RangeSorter is a helper type required by 'sort' package. @@ -994,7 +999,7 @@ func (rs RangeSorter) Normalize() RangeSorter { // DelMessage is a log entry of a deleted message range. type DelMessage struct { - ObjHeader + ObjHeader `bson:",inline"` Topic string DeletedFor string DelId int @@ -1073,7 +1078,7 @@ const ( // FileDef is a stored record of a file upload type FileDef struct { - ObjHeader + ObjHeader `bson:",inline"` // Status of upload Status int // User who created the file diff --git a/server/tinode.conf b/server/tinode.conf index 605c0fe5b..799f84b12 100644 --- a/server/tinode.conf +++ b/server/tinode.conf @@ -1,12 +1,16 @@ // The JSON comments are somewhat brittle. Don't try anything too fancy. { - // Default HTTP(S) address:port to listen on for websocket and long polling clients. Either a + // HTTP(S) address:port to listen on for websocket and long polling clients. Either a // numeric value or a canonical name, e.g. ":80" or ":https". May include the host name, e.g. // "localhost:80" or "hostname.example.com:https". // It could be blank: if TLS is not configured it will default to ":80", otherwise to ":443". // Can be overridden from the command line, see option --listen. "listen": ":6060", + // Base URL path for serving streaming and large file API calls. + // Can be overridden from the command line, see option --api_path. + "api_path": "/", + // Cach-Control header for static content. 11 hours. "cache_control": 39600, @@ -171,13 +175,36 @@ "database": "tinode" }, - // RethinkDB configuration. https://godoc.org/github.com/rethinkdb/rethinkdb-go#ConnectOpts - // for other possible options. + // RethinkDB configuration. See + // https://godoc.org/github.com/rethinkdb/rethinkdb-go#ConnectOpts for other possible + // options. "rethinkdb": { // Address(es) of RethinkDB node(s): either a string or an array of strings. "addresses": "localhost:28015", // Name of the main database. "database": "tinode" + }, + + // MongoDB configuration. + "mongodb": { + // Address(es) of MongoDB node(s): either a string or an array of strings. + "addresses": "localhost:27017", + // Name of the main database. + "database": "tinode", + // Name of replica set of mongodb instance. Remove this line to use a standalone instance. + // If replica_set is disabled, transactions will be disabled as well. + "replica_set": "rs0", + + // Authentication options. Uncomment if auth is configured on your MongoDB. + // "auth_source" is name of database that has the collection with the user credentials. + // Default is 'admin' if not set: + // "auth_source": "admin", + + // Username: + // "username": "tinode", + + // Password: + // "password": "tinode" } } }, diff --git a/server/topic.go b/server/topic.go index 407354510..c8eded31b 100644 --- a/server/topic.go +++ b/server/topic.go @@ -9,6 +9,7 @@ package main import ( + "container/list" "errors" "log" "sort" @@ -21,6 +22,10 @@ import ( "github.com/tinode/chat/server/store/types" ) +// Time between subscription of a background session and when the notifications are sent. +// If session unsubscribes in this time frame notifications are not sent at all. +const deferredNotificationsTimeout = time.Second * 5 + // Topic is an isolated communication channel type Topic struct { // Đ•xpanded/unique name of the topic. @@ -75,32 +80,28 @@ type Topic struct { // The map keys are UserIds for P2P topics and grpXXX for group topics. perSubs map[string]perSubsData - // Sessions attached to this topic. - // A session may represent more than one subsription. - sessions map[*Session]types.UidSlice + // Sessions attached to this topic. The UID kept here may not match Session.uid if session is + // subscribed on behalf of another user. + sessions map[*Session]perSessionData + // Queue of delayed presence updates from successful service (background) subscriptions. + defrNotif *list.List // Inbound {data} and {pres} messages from sessions or other topics, already converted to SCM. Buffered = 256 broadcast chan *ServerComMessage - // Channel for receiving {get}/{set} requests, buffered = 32 meta chan *metaReq - // Subscribe requests from sessions, buffered = 32 reg chan *sessionJoin - // Unsubscribe requests from sessions, buffered = 32 unreg chan *sessionLeave - // Track the most active sessions to report User Agent changes. Buffered = 32 uaChange chan string - // Channel to terminate topic -- either the topic is deleted or system is being shut down. Buffered = 1. exit chan *shutDown - // Flag which tells topic to stop acception requests: hub is in the process of shutting it down - suspended atomicBool -} -type atomicBool int32 + // Flag which tells topic lifecycle status: ready, suspended, marked for deletion. + status int32 +} // perUserData holds topic's cache of per-subscriber data type perUserData struct { @@ -108,6 +109,7 @@ type perUserData struct { created time.Time updated time.Time + // Count of subscription online and announced (presence not deferred). online int // Last t.lastId reported by user through {pres} as received or read @@ -136,6 +138,14 @@ type perSubsData struct { enabled bool } +// Data related to a subscription of a session to a topic. +type perSessionData struct { + // ID of the subscribed user (asUid); not necessarily the session owner. + uid types.Uid + // Reference to a list bucket with deferred notification or nil if no notifications are deferred. + ref *list.Element +} + // Reasons why topic is being shut down. const ( // StopNone no reason given/default. @@ -166,18 +176,21 @@ func (t *Topic) run(hub *Hub) { killTimer.Stop() // Notifies about user agent change. 'me' only - var uaTimer *time.Timer + uaTimer := time.NewTimer(time.Minute) var currentUA string - uaTimer = time.NewTimer(time.Minute) uaTimer.Stop() + // Ticker for deferred presence notifications. + defrNotifTimer := time.NewTimer(time.Millisecond * 500) + for { select { case sreg := <-t.reg: // Request to add a connection to this topic - if !t.isReady() { - sreg.sess.queueOut(ErrLocked(sreg.pkt.id, t.original(sreg.sess.uid), types.TimeNow())) + if !t.isActive() { + asUid := types.ParseUserId(sreg.pkt.from) + sreg.sess.queueOut(ErrLocked(sreg.pkt.id, t.original(asUid), types.TimeNow())) } else { // The topic is alive, so stop the kill timer, if it's ticking. We don't want the topic to die // while processing the call @@ -195,7 +208,6 @@ func (t *Topic) run(hub *Hub) { log.Printf("topic[%s] subscription failed %v, sid=%s", t.name, err, sreg.sess.sid) } } - case leave := <-t.unreg: // Remove connection from topic; session may continue to function now := types.TimeNow() @@ -203,7 +215,7 @@ func (t *Topic) run(hub *Hub) { // userId.IsZero() == true when the entire session is being dropped. asUid := leave.userId - if !t.isReady() { + if !t.isActive() { if !asUid.IsZero() && leave.id != "" { leave.sess.queueOut(ErrLocked(leave.id, t.original(asUid), now)) } @@ -217,46 +229,46 @@ func (t *Topic) run(hub *Hub) { continue } - } else { - // Just leaving the topic without unsubscribing - removedUids := t.remSession(leave.sess, asUid) + } else if pssd := t.remSession(leave.sess, asUid); pssd != nil { + // Just leaving the topic without unsubscribing if user is subscribed. - for _, uid := range removedUids { - pud := t.perUser[uid] + pud := t.perUser[pssd.uid] + if pssd.ref == nil { pud.online-- - switch t.cat { - case types.TopicCatMe: - mrs := t.mostRecentSession() - if mrs == nil { - // Last session - mrs = leave.sess - } else { - // Change UA to the most recent live session and announce it. Don't block. - select { - case t.uaChange <- mrs.userAgent: - default: - } - } - // Update user's last online timestamp & user agent - if err := store.Users.UpdateLastSeen(uid, mrs.userAgent, now); err != nil { - log.Println(err) - } - case types.TopicCatFnd: - // Remove ephemeral query. - t.fndRemovePublic(leave.sess) - case types.TopicCatGrp: - if pud.online == 0 { - // User is going offline: notify online subscribers on 'me' - t.presSubsOnline("off", uid.UserId(), nilPresParams, - &presFilters{filterIn: types.ModeRead}, "") + } + + switch t.cat { + case types.TopicCatMe: + mrs := t.mostRecentSession() + if mrs == nil { + // Last session + mrs = leave.sess + } else { + // Change UA to the most recent live session and announce it. Don't block. + select { + case t.uaChange <- mrs.userAgent: + default: } } + // Update user's last online timestamp & user agent + if err := store.Users.UpdateLastSeen(asUid, mrs.userAgent, now); err != nil { + log.Println(err) + } + case types.TopicCatFnd: + // Remove ephemeral query. + t.fndRemovePublic(leave.sess) + case types.TopicCatGrp: + if pud.online == 0 { + // User is going offline: notify online subscribers on 'me' + t.presSubsOnline("off", asUid.UserId(), nilPresParams, + &presFilters{filterIn: types.ModeRead}, "") + } + } - t.perUser[uid] = pud + t.perUser[pssd.uid] = pud - if leave.id != "" { - leave.sess.queueOut(NoErr(leave.id, t.original(uid), now)) - } + if leave.id != "" { + leave.sess.queueOut(NoErr(leave.id, t.original(asUid), now)) } } @@ -271,7 +283,7 @@ func (t *Topic) run(hub *Hub) { var pushRcpt *push.Receipt asUid := types.ParseUserId(msg.from) if msg.Data != nil { - if !t.isReady() { + if !t.isActive() { msg.sess.queueOut(ErrLocked(msg.id, t.original(asUid), msg.timestamp)) continue } @@ -280,6 +292,7 @@ func (t *Topic) run(hub *Hub) { userData, userFound := t.perUser[from] // Anyone is allowed to post to 'sys' topic. if t.cat != types.TopicCatSys { + // If it's not 'sys' check write permission. if !(userData.modeWant & userData.modeGiven).IsWriter() { msg.sess.queueOut(ErrPermissionDenied(msg.id, t.original(asUid), msg.timestamp)) @@ -325,12 +338,12 @@ func (t *Topic) run(hub *Hub) { pluginMessage(msg.Data, plgActCreate) } else if msg.Pres != nil { - if !t.isReady() { + if !t.isActive() { // Ignore presence update - topic is being deleted continue } - what := t.presProcReq(msg.Pres.Src, msg.Pres.What, msg.Pres.wantReply) + what := t.presProcReq(msg.Pres.Src, msg.Pres.What, msg.Pres.WantReply) if t.xoriginal != msg.Pres.Topic || what == "" { // This is just a request for status, don't forward it to sessions continue @@ -339,7 +352,7 @@ func (t *Topic) run(hub *Hub) { // "what" may have changed, i.e. unset or "+command" removed ("on+en" -> "on") msg.Pres.What = what } else if msg.Info != nil { - if !t.isReady() { + if !t.isActive() { // Ignore info messages - topic is being deleted continue } @@ -349,8 +362,8 @@ func (t *Topic) run(hub *Hub) { continue } - uid := types.ParseUserId(msg.Info.From) - pud := t.perUser[uid] + from := types.ParseUserId(msg.Info.From) + pud := t.perUser[from] // Filter out "kp" from users with no 'W' permission (or people without a subscription) if msg.Info.What == "kp" && !(pud.modeGiven & pud.modeWant).IsWriter() { @@ -388,7 +401,7 @@ func (t *Topic) run(hub *Hub) { recv = pud.recvID } - if err := store.Subs.Update(t.name, uid, + if err := store.Subs.Update(t.name, from, map[string]interface{}{ "RecvSeqId": pud.recvID, "ReadSeqId": pud.readID}, @@ -399,52 +412,52 @@ func (t *Topic) run(hub *Hub) { } // Read/recv updated: notify user's other sessions of the change - t.presPubMessageCount(uid, recv, read, msg.skipSid) + t.presPubMessageCount(from, recv, read, msg.skipSid) // Update cached count of unread messages - usersUpdateUnread(uid, unread, true) + usersUpdateUnread(from, unread, true) - t.perUser[uid] = pud + t.perUser[from] = pud } } // Broadcast the message. Only {data}, {pres}, {info} are broadcastable. // {meta} and {ctrl} are sent to the session only if msg.Data != nil || msg.Pres != nil || msg.Info != nil { - for sess := range t.sessions { + for sess, pssd := range t.sessions { if sess.sid == msg.skipSid { continue } if msg.Pres != nil { // Skip notifying - already notified on topic. - if msg.Pres.skipTopic != "" && sess.getSub(msg.Pres.skipTopic) != nil { + if msg.Pres.SkipTopic != "" && sess.getSub(msg.Pres.SkipTopic) != nil { continue } // Notification addressed to a single user only - if msg.Pres.singleUser != "" && sess.uid.UserId() != msg.Pres.singleUser { + if msg.Pres.SingleUser != "" && pssd.uid.UserId() != msg.Pres.SingleUser { continue } // Check presence filters - pud := t.perUser[sess.uid] + pud := t.perUser[pssd.uid] // Send "gone" notification even if the topic is muted. if (!(pud.modeGiven & pud.modeWant).IsPresencer() && msg.Pres.What != "gone") || - (msg.Pres.filterIn != 0 && int(pud.modeGiven&pud.modeWant)&msg.Pres.filterIn == 0) || - (msg.Pres.filterOut != 0 && int(pud.modeGiven&pud.modeWant)&msg.Pres.filterOut != 0) { + (msg.Pres.FilterIn != 0 && int(pud.modeGiven&pud.modeWant)&msg.Pres.FilterIn == 0) || + (msg.Pres.FilterOut != 0 && int(pud.modeGiven&pud.modeWant)&msg.Pres.FilterOut != 0) { continue } } else { // Check if the user has Read permission - pud := t.perUser[sess.uid] + pud := t.perUser[pssd.uid] if !(pud.modeGiven & pud.modeWant).IsReader() { continue } // Don't send key presses from one user's session to the other sessions of the same user. - if msg.Info != nil && msg.Info.What == "kp" && msg.Info.From == sess.uid.UserId() { + if msg.Info != nil && msg.Info.What == "kp" && msg.Info.From == pssd.uid.UserId() { continue } } @@ -453,18 +466,18 @@ func (t *Topic) run(hub *Hub) { // For p2p topics topic name is dependent on receiver switch { case msg.Data != nil: - msg.Data.Topic = t.original(sess.uid) + msg.Data.Topic = t.original(pssd.uid) case msg.Pres != nil: - msg.Pres.Topic = t.original(sess.uid) + msg.Pres.Topic = t.original(pssd.uid) case msg.Info != nil: - msg.Info.Topic = t.original(sess.uid) + msg.Info.Topic = t.original(pssd.uid) } } if sess.queueOut(msg) { // Update device map with the device ID which should NOT receive the notification. if pushRcpt != nil { - if addr, ok := pushRcpt.To[sess.uid]; ok { + if addr, ok := pushRcpt.To[pssd.uid]; ok { addr.Delivered++ if sess.deviceID != "" { // List of device IDs which already received the message. Push should @@ -472,7 +485,7 @@ func (t *Topic) run(hub *Hub) { // The same device ID may appear twice. addr.Devices = append(addr.Devices, sess.deviceID) } - pushRcpt.To[sess.uid] = addr + pushRcpt.To[pssd.uid] = addr } } } else { @@ -576,10 +589,36 @@ func (t *Topic) run(hub *Hub) { } } case ua := <-t.uaChange: - // process an update to user agent from one of the sessions + // Process an update to user agent from one of the sessions currentUA = ua uaTimer.Reset(uaTimerDelay) + case <-defrNotifTimer.C: + // Handle deferred presence notifications from a successful service (background) subscription. + if !t.isActive() { + continue + } + + // Process events older than this timestamp. + expiration := time.Now().Add(-deferredNotificationsTimeout) + // Iterate through the list until all sufficiently old events are processed. + for elem := t.defrNotif.Back(); elem != nil; elem = t.defrNotif.Back() { + sreg := elem.Value.(*sessionJoin) + if expiration.Before(sreg.pkt.timestamp) { + // All done. Remaining events are newer. + break + } + t.defrNotif.Remove(elem) + if pssd, ok := t.sessions[sreg.sess]; ok { + userData := t.perUser[pssd.uid] + userData.online++ + t.perUser[pssd.uid] = userData + pssd.ref = nil + t.sessions[sreg.sess] = pssd + } + t.sendSubNotifications(types.ParseUserId(sreg.pkt.from), sreg) + } + case <-uaTimer.C: // Publish user agent changes after a delay if currentUA == "" || currentUA == t.userAgent { @@ -591,6 +630,7 @@ func (t *Topic) run(hub *Hub) { case <-killTimer.C: // Topic timeout hub.unreg <- &topicUnreg{topic: t.name} + defrNotifTimer.Stop() if t.cat == types.TopicCatMe { uaTimer.Stop() t.presUsersOfInterest("off", currentUA) @@ -684,37 +724,72 @@ func (t *Topic) handleSubscription(h *Hub, sreg *sessionJoin) error { return err } - pud := t.perUser[asUid] + // Send notifications. - switch t.cat { - case types.TopicCatMe: - // Notify user's contact that the given user is online now. - if sreg.loaded { - if err := t.loadContacts(asUid); err != nil { - log.Println("topic: failed to load contacts", t.name, err.Error()) - } - // User online: notify users of interest without forcing response (no +en here). - t.presUsersOfInterest("on", sreg.sess.userAgent) + // Some notifications are always sent immediately. + t.sendImmediateSubNotifications(asUid, sreg) + + pssd, ok := t.sessions[sreg.sess] + if msgsub.Background && ok { + // Notifications are delayed. + pssd.ref = t.defrNotif.PushFront(sreg) + t.sessions[sreg.sess] = pssd + } else { + // Remaining notifications are also sent immediately. + t.sendSubNotifications(asUid, sreg) + } + + if getWhat&constMsgMetaDesc != 0 { + // Send get.desc as a {meta} packet. + if err := t.replyGetDesc(sreg.sess, asUid, sreg.pkt.id, msgsub.Get.Desc); err != nil { + log.Printf("topic[%s] handleSubscription Get.Desc failed: %v sid=%s", t.name, err, sreg.sess.sid) } + } - case types.TopicCatGrp: - // Enable notifications for a new group topic, if appropriate. - if sreg.loaded { - status := "on" - if (pud.modeGiven & pud.modeWant).IsPresencer() { - status += "+en" - } + if getWhat&constMsgMetaSub != 0 { + // Send get.sub response as a separate {meta} packet + if err := t.replyGetSub(sreg.sess, asUid, authLevel, sreg.pkt.id, msgsub.Get.Sub); err != nil { + log.Printf("topic[%s] handleSubscription Get.Sub failed: %v sid=%s", t.name, err, sreg.sess.sid) + } + } - // Notify topic subscribers that the topic is online now. - t.presSubsOffline(status, nilPresParams, nilPresFilters, "", false) - } else if pud.online == 1 { - // If this is the first session of the user in the topic. - // Notify other online group members that the user is online now. - t.presSubsOnline("on", asUid.UserId(), nilPresParams, - &presFilters{filterIn: types.ModeRead}, sreg.sess.sid) + if getWhat&constMsgMetaTags != 0 { + // Send get.tags response as a separate {meta} packet + if err := t.replyGetTags(sreg.sess, asUid, sreg.pkt.id); err != nil { + log.Printf("topic[%s] handleSubscription Get.Tags failed: %v sid=%s", t.name, err, sreg.sess.sid) } + } - case types.TopicCatP2P: + if getWhat&constMsgMetaCred != 0 { + // Send get.tags response as a separate {meta} packet + if err := t.replyGetCreds(sreg.sess, asUid, sreg.pkt.id); err != nil { + log.Printf("topic[%s] handleSubscription Get.Cred failed: %v sid=%s", t.name, err, sreg.sess.sid) + } + } + + if getWhat&constMsgMetaData != 0 { + // Send get.data response as {data} packets + if err := t.replyGetData(sreg.sess, asUid, sreg.pkt.id, msgsub.Get.Data); err != nil { + log.Printf("topic[%s] handleSubscription Get.Data failed: %v sid=%s", t.name, err, sreg.sess.sid) + } + } + + if getWhat&constMsgMetaDel != 0 { + // Send get.del response as a separate {meta} packet + if err := t.replyGetDel(sreg.sess, asUid, sreg.pkt.id, msgsub.Get.Del); err != nil { + log.Printf("topic[%s] handleSubscription Get.Del failed: %v sid=%s", t.name, err, sreg.sess.sid) + } + } + + return nil +} + +// Send immediate presence notification in response to a subscription. +// These notifications are always sent immediately even if background is requested. +func (t *Topic) sendImmediateSubNotifications(asUid types.Uid, sreg *sessionJoin) { + pud := t.perUser[asUid] + + if t.cat == types.TopicCatP2P { uid2 := t.p2pOtherUser(asUid) pud2 := t.perUser[uid2] @@ -753,50 +828,42 @@ func (t *Topic) handleSubscription(h *Hub, sreg *sessionJoin) error { actor: asUid.UserId()}, sreg.sess.sid, false) } +} - if getWhat&constMsgMetaDesc != 0 { - // Send get.desc as a {meta} packet. - if err := t.replyGetDesc(sreg.sess, asUid, sreg.pkt.id, msgsub.Get.Desc); err != nil { - log.Printf("topic[%s] handleSubscription Get.Desc failed: %v sid=%s", t.name, err, sreg.sess.sid) - } - } - - if getWhat&constMsgMetaSub != 0 { - // Send get.sub response as a separate {meta} packet - if err := t.replyGetSub(sreg.sess, asUid, authLevel, sreg.pkt.id, msgsub.Get.Sub); err != nil { - log.Printf("topic[%s] handleSubscription Get.Sub failed: %v sid=%s", t.name, err, sreg.sess.sid) - } - } - - if getWhat&constMsgMetaTags != 0 { - // Send get.tags response as a separate {meta} packet - if err := t.replyGetTags(sreg.sess, asUid, sreg.pkt.id); err != nil { - log.Printf("topic[%s] handleSubscription Get.Tags failed: %v sid=%s", t.name, err, sreg.sess.sid) - } - } +// Send immediate or deferred presence notification in response to a subscription. +func (t *Topic) sendSubNotifications(asUid types.Uid, sreg *sessionJoin) { + pud := t.perUser[asUid] - if getWhat&constMsgMetaCred != 0 { - // Send get.tags response as a separate {meta} packet - if err := t.replyGetCreds(sreg.sess, asUid, sreg.pkt.id); err != nil { - log.Printf("topic[%s] handleSubscription Get.Cred failed: %v sid=%s", t.name, err, sreg.sess.sid) + switch t.cat { + case types.TopicCatMe: + // Notify user's contact that the given user is online now. + if !t.isLoaded() { + t.markLoaded() + if err := t.loadContacts(asUid); err != nil { + log.Println("topic: failed to load contacts", t.name, err.Error()) + } + // User online: notify users of interest without forcing response (no +en here). + t.presUsersOfInterest("on", sreg.sess.userAgent) } - } - if getWhat&constMsgMetaData != 0 { - // Send get.data response as {data} packets - if err := t.replyGetData(sreg.sess, asUid, sreg.pkt.id, msgsub.Get.Data); err != nil { - log.Printf("topic[%s] handleSubscription Get.Data failed: %v sid=%s", t.name, err, sreg.sess.sid) - } - } + case types.TopicCatGrp: + // Enable notifications for a new group topic, if appropriate. + if !t.isLoaded() { + t.markLoaded() + status := "on" + if (pud.modeGiven & pud.modeWant).IsPresencer() { + status += "+en" + } - if getWhat&constMsgMetaDel != 0 { - // Send get.del response as a separate {meta} packet - if err := t.replyGetDel(sreg.sess, asUid, sreg.pkt.id, msgsub.Get.Del); err != nil { - log.Printf("topic[%s] handleSubscription Get.Del failed: %v sid=%s", t.name, err, sreg.sess.sid) + // Notify topic subscribers that the topic is online now. + t.presSubsOffline(status, nilPresParams, nilPresFilters, "", false) + } else if pud.online == 1 { + // If this is the first session of the user in the topic. + // Notify other online group members that the user is online now. + t.presSubsOnline("on", asUid.UserId(), nilPresParams, + &presFilters{filterIn: types.ModeRead}, sreg.sess.sid) } } - - return nil } // subCommonReply generates a response to a subscription request @@ -841,7 +908,7 @@ func (t *Topic) subCommonReply(h *Hub, sreg *sessionJoin) error { var err error var changed bool // Create new subscription or modify an existing one. - if changed, err = t.requestSub(h, sreg.sess, asUid, asLvl, sreg.pkt.id, mode, private); err != nil { + if changed, err = t.requestSub(h, sreg.sess, asUid, asLvl, sreg.pkt.id, mode, private, msgsub.Background); err != nil { return err } @@ -874,14 +941,17 @@ func (t *Topic) subCommonReply(h *Hub, sreg *sessionJoin) error { // User requests or updates a self-subscription to a topic. Called as a // result of {sub} or {meta set=sub}. +// Returns changed == true if user's accessmode has changed. +// +// h - hub +// sess - originating session +// asUid - id of the user making the request +// asLvl - access level of the user making the request +// pktID - id of {sub} or {set} packet +// want - requested access mode +// private - private value to assign to the subscription +// background - presence notifications are deferred // -// h - hub -// sess - originating session -// asUid - id of the user making the request -// asLvl - access level of the user making the request -// pktID - id of {sub} or {set} packet -// want - requested access mode -// private - private value to assign to the subscription // Handle these cases: // A. User is trying to subscribe for the first time (no subscription) // B. User is already subscribed, just joining without changing anything @@ -889,7 +959,7 @@ func (t *Topic) subCommonReply(h *Hub, sreg *sessionJoin) error { // D. User is already subscribed, changing modeWant // E. User is accepting ownership transfer (requesting ownership transfer is not permitted) func (t *Topic) requestSub(h *Hub, sess *Session, asUid types.Uid, asLvl auth.Level, - pktID, want string, private interface{}) (bool, error) { + pktID, want string, private interface{}, background bool) (bool, error) { now := types.TimeNow() toriginal := t.original(asUid) @@ -1099,9 +1169,6 @@ func (t *Topic) requestSub(h *Hub, sess *Session, asUid types.Uid, asLvl auth.Le t.presSingleUserOffline(asUid, "off+dis", nilPresParams, "", false) } - // The user is online in topic. - userData.online++ - // Apply changes. t.perUser[asUid] = userData @@ -1139,10 +1206,17 @@ func (t *Topic) requestSub(h *Hub, sess *Session, asUid types.Uid, asLvl auth.Le uaChange: t.uaChange}) t.addSession(sess, asUid) + // The user is online in the topic. Increment the counter if notifications are not deferred. + if !background { + userData.online++ + t.perUser[asUid] = userData + } + return changed, nil } -// approveSub processes a request to initiate an invite or approve a subscription request from another user: +// approveSub processes a request to initiate an invite or approve a subscription request from another user. +// Returns changed == true if user's access mode has changed. // Handle these cases: // A. Sharer or Approver is inviting another user for the first time (no prior subscription) // B. Sharer or Approver is re-inviting another user (adjusting modeGiven, modeWant is still Unset) @@ -1346,7 +1420,7 @@ func (t *Topic) replyGetDesc(sess *Session, asUid types.Uid, id string, opts *Ms } if t.cat == types.TopicCatGrp && (pud.modeGiven & pud.modeWant).IsPresencer() { - desc.Online = len(t.sessions) > 0 + desc.Online = t.isOnline() } if ifUpdated { desc.Private = pud.private @@ -1476,6 +1550,7 @@ func (t *Topic) replySetDesc(sess *Session, asUid types.Uid, set *MsgClientSet) } if len(core) > 0 { + core["UpdatedAt"] = now switch t.cat { case types.TopicCatMe: err = store.Users.Update(asUid, core) @@ -1514,6 +1589,7 @@ func (t *Topic) replySetDesc(sess *Session, asUid types.Uid, set *MsgClientSet) if private, ok := sub["Private"]; ok { pud := t.perUser[asUid] pud.private = private + pud.updated = now t.perUser[asUid] = pud } @@ -1527,6 +1603,8 @@ func (t *Topic) replySetDesc(sess *Session, asUid types.Uid, set *MsgClientSet) // He will be notified separately (see below). t.presSubsOffline("upd", nilPresParams, &presFilters{excludeUser: asUid.UserId()}, sess.sid, false) } + + t.updated = now } // Notify user's other sessions. t.presSingleUserOffline(asUid, "upd", nilPresParams, sess.sid, false) @@ -1548,11 +1626,7 @@ func (t *Topic) replyGetSub(sess *Session, asUid types.Uid, authLevel auth.Level } userData := t.perUser[asUid] - // FIXME: The (t.cat != types.TopicCatMe && t.cat != types.TopicCatFnd) is unnecessary. - // It's here for backwards compatibility only. - if t.cat != types.TopicCatMe && t.cat != types.TopicCatFnd && - !(userData.modeGiven & userData.modeWant).IsSharer() { - + if !(userData.modeGiven & userData.modeWant).IsSharer() { sess.queueOut(ErrPermissionDenied(id, t.original(asUid), now)) return errors.New("user does not have S permission") } @@ -1815,7 +1889,7 @@ func (t *Topic) replySetSub(h *Hub, sess *Session, pkt *ClientComMessage) error var changed bool if target == asUid { // Request new subscription or modify own subscription - changed, err = t.requestSub(h, sess, asUid, asLvl, pkt.id, set.Sub.Mode, nil) + changed, err = t.requestSub(h, sess, asUid, asLvl, pkt.id, set.Sub.Mode, nil, false) } else { // Request to approve/change someone's subscription changed, err = t.approveSub(h, sess, asUid, target, set) @@ -1937,7 +2011,7 @@ func (t *Topic) replySetTags(sess *Session, asUid types.Uid, set *MsgClientSet) } else { added, removed := stringSliceDelta(t.tags, tags) if len(added) > 0 || len(removed) > 0 { - update := map[string]interface{}{"Tags": types.StringSlice(tags)} + update := map[string]interface{}{"Tags": types.StringSlice(tags), "UpdatedAt": now} if t.cat == types.TopicCatMe { err = store.Users.Update(asUid, update) } else if t.cat == types.TopicCatGrp { @@ -2362,7 +2436,7 @@ func (t *Topic) evictUser(uid types.Uid, unsub bool, skip string) { msg := NoErrEvicted("", t.original(uid), now) msg.Ctrl.Params = map[string]interface{}{"unsub": unsub} for sess := range t.sessions { - if len(t.remSession(sess, uid)) > 0 { + if t.remSession(sess, uid) != nil { sess.detach <- t.name if sess.sid != skip { sess.queueOut(msg) @@ -2512,24 +2586,47 @@ func (t *Topic) mostRecentSession() *Session { return sess } -func (t *Topic) suspend() { - atomic.StoreInt32((*int32)(&t.suspended), 1) +const ( + // Topic is just created. + topicStatusNew = 0 + // Topic is fully initialized. + topicStatusLoaded = 1 + // Topic is suspended. + topicStatusSuspended = 2 + // Topic is in the process of being deleted. + topicStatusMarkedDeleted = 3 +) + +func (t *Topic) markSuspended() int32 { + return atomic.SwapInt32((*int32)(&t.status), topicStatusSuspended) } func (t *Topic) markDeleted() { - atomic.StoreInt32((*int32)(&t.suspended), 2) + atomic.StoreInt32((*int32)(&t.status), topicStatusMarkedDeleted) +} + +func (t *Topic) markNew() { + atomic.StoreInt32((*int32)(&t.status), topicStatusNew) +} + +func (t *Topic) markLoaded() { + atomic.StoreInt32((*int32)(&t.status), topicStatusLoaded) } -func (t *Topic) resume() { - atomic.StoreInt32((*int32)(&t.suspended), 0) +func (t *Topic) setStatus(status int32) { + atomic.StoreInt32((*int32)(&t.status), status) } -func (t *Topic) isReady() bool { - return atomic.LoadInt32((*int32)(&t.suspended)) == 0 +func (t *Topic) isActive() bool { + return atomic.LoadInt32((*int32)(&t.status)) <= topicStatusLoaded +} + +func (t *Topic) isLoaded() bool { + return atomic.LoadInt32((*int32)(&t.status)) == topicStatusLoaded } func (t *Topic) isDeleted() bool { - return atomic.LoadInt32((*int32)(&t.suspended)) == 2 + return atomic.LoadInt32((*int32)(&t.status)) == topicStatusMarkedDeleted } // Get topic name suitable for the given client @@ -2640,41 +2737,37 @@ func (t *Topic) subsCount() int { return len(t.perUser) } -func (t *Topic) addSession(s *Session, user types.Uid) types.UidSlice { - uids := t.sessions[s] - if uids == nil { - uids = types.UidSlice{user} - } else { - uids.Add(user) +// Add session record. 'user' may be different from s.uid. +func (t *Topic) addSession(s *Session, asUid types.Uid) bool { + if _, ok := t.sessions[s]; ok { + return false } - t.sessions[s] = uids - return uids + t.sessions[s] = perSessionData{uid: asUid} + return true } -// Removes Uid from the session record. If user.IsZero() then removes all UIDs. -// Returns a slice of removed UIDs (all, just one or nil). -func (t *Topic) remSession(s *Session, user types.Uid) types.UidSlice { - uids := t.sessions[s] - if uids == nil { - return nil +// Removes session record if 'asUid' matches subscribed user. +func (t *Topic) remSession(s *Session, asUid types.Uid) *perSessionData { + if pssd, ok := t.sessions[s]; ok && (pssd.uid == asUid || asUid.IsZero()) { + // Check for deferred presence notification and cancel it if found. + if pssd.ref != nil { + t.defrNotif.Remove(pssd.ref) + } + delete(t.sessions, s) + return &pssd } + return nil +} - if user.IsZero() { - // All uids are removed. - delete(t.sessions, s) - } else if uids.Rem(user) { - // One uid is removed - if len(uids) > 0 { - t.sessions[s] = uids - } else { - delete(t.sessions, s) +func (t *Topic) isOnline() bool { + // Some sessions may be background sessions. They should not be counted. + for _, pssd := range t.sessions { + // At least one non-background session. + if pssd.ref == nil { + return true } - uids = types.UidSlice{user} - } else { - // No uids removed - uids = nil } - return uids + return false } func topicCat(name string) types.TopicCat { diff --git a/server/user.go b/server/user.go index 15b3f507b..0df38dcc6 100644 --- a/server/user.go +++ b/server/user.go @@ -658,8 +658,8 @@ func usersRegisterTopic(t *Topic, add bool) { return } - if len(t.perUser) == 0 { - // me and fnd topics + if t.cat == types.TopicCatFnd || t.cat == types.TopicCatMe { + // Ignoring me and fnd topics. return } diff --git a/server/utils.go b/server/utils.go index dc6baa3e6..b7b0d0c66 100644 --- a/server/utils.go +++ b/server/utils.go @@ -3,10 +3,12 @@ package main import ( + "bufio" "crypto/tls" "encoding/json" "errors" "fmt" + "io" "path/filepath" "reflect" "regexp" @@ -734,3 +736,38 @@ func mergeMaps(dst, src map[string]interface{}) (map[string]interface{}, bool) { return dst, changed } + +// Calculate line and character position from byte offset into a file. +func offsetToLineAndChar(r io.Reader, offset int64) (int, int, error) { + if offset < 0 { + return -1, -1, errors.New("offset value cannot be negative") + } + + br := bufio.NewReader(r) + + // Count lines and characters. + lnum := 1 + cnum := 0 + // Number of bytes consumed. + var count int64 + for { + ch, size, err := br.ReadRune() + if err == io.EOF { + return -1, -1, errors.New("offset value too large") + } + count += int64(size) + + if ch == '\n' { + lnum++ + cnum = 0 + } else { + cnum++ + } + + if count >= offset { + break + } + } + + return lnum, cnum, nil +} diff --git a/server/validate/email/validate.go b/server/validate/email/validate.go index d06a3aab3..6e7118243 100644 --- a/server/validate/email/validate.go +++ b/server/validate/email/validate.go @@ -147,6 +147,9 @@ func (v *validator) PreCheck(cred string, params interface{}) error { return t.ErrMalformed } + // Normalize email to make sure Unicode case collisions don't lead to security problems. + addr.Address = strings.ToLower(addr.Address) + // If a whitelist of domains is provided, make sure the email belongs to the list. if len(v.Domains) > 0 { // Parse email into user and domain parts. @@ -178,6 +181,9 @@ func (v *validator) Request(user t.Uid, email, lang, resp string, tmpToken []byt return false, t.ErrFailed } + // Normalize email to make sure Unicode case collisions don't lead to security problems. + email = strings.ToLower(email) + token := make([]byte, base64.URLEncoding.EncodedLen(len(tmpToken))) base64.URLEncoding.Encode(token, tmpToken) @@ -211,6 +217,9 @@ func (v *validator) Request(user t.Uid, email, lang, resp string, tmpToken []byt // ResetSecret sends a message with instructions for resetting an authentication secret. func (v *validator) ResetSecret(email, scheme, lang string, tmpToken []byte) error { + // Normalize email to make sure Unicode case collisions don't lead to security problems. + email = strings.ToLower(email) + token := make([]byte, base64.URLEncoding.EncodedLen(len(tmpToken))) base64.URLEncoding.Encode(token, tmpToken) body := new(bytes.Buffer) diff --git a/tinode-db/README.md b/tinode-db/README.md index 2123bbf1e..faa193183 100644 --- a/tinode-db/README.md +++ b/tinode-db/README.md @@ -10,6 +10,9 @@ This utility initializes the `tinode` database and optionally loads it with samp - **MySQL** `go build -tags mysql` or `go build -i -tags mysql` to automatically install missing dependencies. + - **MongoDB** + `go build -tags mongodb` or `go build -i -tags mongodb` to automatically install missing dependencies. + ## Run @@ -27,8 +30,9 @@ Configuration file options: - `uid_key` is a base64-encoded 16 byte XTEA encryption key to (weakly) encrypt object IDs so they don't appear sequential. You probably want to use your own key in production. - `store_config.adapters.mysql` and `store_config.adapters.rethinkdb` are database-specific sections: - `database` is the name of the database to generate. - - `addresses` is RethinkDB's host and port number to connect to. An array of hosts can be provided as well `["host1", "host2"]`. + - `addresses` is RethinkDB/MongoDB's host and port number to connect to. An array of hosts can be provided as well `["host1", "host2"]`. - `dsn` is MySQL's Data Source Name. + - `replica_set` is MongoDB's Replicaset name. The `uid_key` is only used if the sample data is being loaded. It should match the key of a production server and should be kept private. @@ -40,3 +44,4 @@ Avatar photos curtesy of https://www.pexels.com/ under [CC0 license](https://www * [RethinkDB schema](https://github.com/tinode/chat/tree/master/server/db/rethinkdb/schema.md) * [MySQL schema](https://github.com/tinode/chat/tree/master/server/db/mysql/schema.sql) +* [MongoDB schema](https://github.com/tinode/chat/tree/master/server/db/mongodb/schema.md) diff --git a/tinode-db/main.go b/tinode-db/main.go index 6f8119c57..8aa1d67e3 100644 --- a/tinode-db/main.go +++ b/tinode-db/main.go @@ -13,6 +13,7 @@ import ( "time" jcr "github.com/DisposaBoy/JsonConfigReader" + _ "github.com/tinode/chat/server/db/mongodb" _ "github.com/tinode/chat/server/db/mysql" _ "github.com/tinode/chat/server/db/rethinkdb" "github.com/tinode/chat/server/store" diff --git a/tinode-db/tinode.conf b/tinode-db/tinode.conf index 264fcfee6..8a2e3a545 100644 --- a/tinode-db/tinode.conf +++ b/tinode-db/tinode.conf @@ -9,6 +9,14 @@ "rethinkdb": { "database": "tinode", "addresses": "localhost:28015" + }, + "mongodb": { + "database": "tinode", + "addresses": "localhost:27017", + "replica_set": "rs0", + //"auth_source": "admin", + //"username": "tinode", + //"password": "tinode", } } } diff --git a/tn-cli/tn-cli.py b/tn-cli/tn-cli.py index bb4414947..cd63aad54 100644 --- a/tn-cli/tn-cli.py +++ b/tn-cli/tn-cli.py @@ -982,7 +982,7 @@ def save_cookie(params): def print_server_params(params): servParams = [] for p in params: - servParams.append(p + ": " + json.loads(params[p])) + servParams.append(p + ": " + str(json.loads(params[p]))) stdoutln("\r<= Connected to server: " + "; ".join(servParams)) if __name__ == '__main__':