[
  {
    "path": ".gitattributes",
    "content": "model_pb2.py binary\nmodel.pb.go binary\n\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve Tinode\ntitle: ''\nlabels: 'bug'\nassignees: ''\n\n---\n\n**If you are not reporting a bug, please post to https://groups.google.com/d/forum/tinode instead.**\n---\n\n### Subject of the issue\nDescribe your issue here.\n\n### Your environment\n#### Server-side\n- [ ] web.tinode.co, api.tinode.co\n- [ ] sandbox.tinode.co\n- [ ] Your own setup:\n  * platform (Windows, Mac, Linux etc)\n  * version of Tinode server, e.g. `0.15.2-rc3`\n  * database backend\n  * cluster or standalone\n\n#### Client-side\n- [ ] TinodeWeb/tinodejs: javascript client\n  * Browser make and version.\n  * IMPORTANT! Use `index-dev.html` to reproduce the problem, not `index.html`.\n- [ ] Tindroid: Android app\n  * Android API level (e.g. 25).\n  * Emulator or hardware, if hardware describe it.\n- [ ] Tinodios: iOS app\n  * iOS version\n  * Simulator or hardware, if hardware describe it.\n- [ ] tn-cli\n  * Python version\n- [ ] Chatbot\n  * Python version\n- Version of the client, e.g. `0.15.1`\n- [ ] Your own client. Describe it:\n  * Transport (gRPC, websocket, long polling)\n  * Programming language.\n  * gRPC version, if applicable.\n\n\n### Steps to reproduce\nTell us how to reproduce this issue.\n\n### Expected behaviour\nTell us what should happen.\n\n### Actual behaviour\nTell us what happens instead.\n\n### Server-side log\nCopy server-side log here. You may also attach it to the issue as a file.\n\n### Client-side log\nCopy client-side log here (Android logcat, Javascript console, etc). You may also attach it to the issue as a file. When posting console log from Webapp, please use `index-dev.html`, not `index.html`; `index.html` uses minified javascript which produces unusable logs).\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: 'feature request'\nassignees: ''\n\n---\n\n**If you are not requesting a feature, please post to https://groups.google.com/d/forum/tinode instead.**\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing\n\nWe're happy you want to contribute! You can help us in different ways:\n\n- [Open an issue](https://github.com/tinode/chat/issues) with suggestions for improvements\n- Fork this repository and submit a pull request\n- Improve the documentation\n\n\nTo submit a pull request, fork the [repository](https://github.com/tinode/chat) and then clone your fork:\n\n    git clone git@github.com:<your-repo-name>/chat.git\n\nMake your suggested changes, `git push` and then [submit a pull request](https://github.com/tinode/chat/compare/). Note that before we can accept your pull requests, you need to sign our [Contributor License Agreement](docs/CLA.md).\n\n\n## Why is the Contributor License Agreement necessary?\n\nWe very much appreciate your wanting to contribute to Tinode Chat, but we need to add you to the contributors list first. Note that the [agreement](docs/CLA.md) is not a transfer of copyright ownership, this simply is a license agreement for contributions. You also do not change your rights to use your own contributions for any other purpose. For some background on why contributor license agreements are necessary, you can read FAQs from many other open source projects:\n\n- Django's [CLA FAQ](https://www.djangoproject.com/foundation/cla/faq/)\n- A [chapter](http://producingoss.com/en/copyright-assignment.html) from Karl Fogel's _Producing Open Source Software_ on CLAs\n- The [Wikipedia article on CLAs](http://en.wikipedia.org/wiki/Contributor_license_agreement)\n\nThis is part of the legal framework of the open-source ecosystem that adds some red tape, but protects both the contributor and the company / foundation behind the project. It also gives us the option to relicense the code with a more permissive license in the future.\n"
  },
  {
    "path": "INSTALL.md",
    "content": "# Installing Tinode\n\nThe config file [`tinode.conf`](./server/tinode.conf) contains extensive instructions on configuring the server.\n\n## Installing from Binaries\n\n1. Visit the [Releases page](https://github.com/tinode/chat/releases/), choose the latest or otherwise the most suitable release. From the list of binaries download the one for your database (supported: MySQL, PostgreSQL, MongoDB, RethinkDB) and platform (Linux ARM or Intel, Windows, Mac ARM or Intel). Once the binary is downloaded, unpack it to a directory of your choosing, `cd` to that directory.\n\n2. Make sure your database is running. Make sure it's configured to accept connections from `localhost`. In case of MySQL, Tinode will try to connect as `root` without the password. In case of PostgreSQL, Tinode will try connect as `postgres` with the password `postgres`. See notes below (_Building from Source_, section 4) on how to configure Tinode to use a different user or a password. MySQL 5.7 or above is required (use InnoDB, not MyISAM storage engine). MySQL 5.6 or below **will not work**, use of MyISAM **will cause problems**. PostgreSQL 13 or above is required. PostgreSQL 12 or below **will not work**. MongoDB 4.4 or above is required. MongoDB 4.2 and below **will not work**.\n\n3. Run the database initializer `init-db` (or `init-db.exe` on Windows):\n\t```\n\t./init-db -data=data.json\n\t```\n\n4. Run the `tinode` (or `tinode.exe` on Windows) server. It will work without any parameters.\n\t```\n\t./tinode\n\t```\n\n5. Test your installation by pointing your browser to http://localhost:6060/\n\n\n## Docker\n\nSee [instructions](./docker/README.md)\n\n\n## Building from Source\n\n1. Install [Go environment](https://golang.org/doc/install). The installation instructions below are for Go 1.18 and newer. Building with the latest Go environment is recommended.\n\n2. OPTIONAL only if you intend to modify the code: Install [protobuf](https://developers.google.com/protocol-buffers/) and [gRPC](https://grpc.io/docs/languages/go/quickstart/) including [code generator](https://developers.google.com/protocol-buffers/docs/reference/go-generated) for Go.\n\n3. Make sure one of the following databases is installed and running:\n * MySQL 5.7 or above configured with `InnoDB` engine (8.x preferred). MySQL 5.6 or below **will not work**.\n * PostgreSQL 13 or above. PostgreSQL 12 or below **will not work**.\n * MongoDB 4.4 or above (8.x preferred). MongoDB 4.2 and below **will not work**.\n * RethinkDB (deprecated, support will be dropped in 2027 unless RethinkDB team resumes development).\n\n4. Fetch, build Tinode server and tinode-db database initializer:\n  - **MySQL**:\n\t```\n\tgo install -tags mysql github.com/tinode/chat/server@latest\n\tgo install -tags mysql github.com/tinode/chat/tinode-db@latest\n\t```\n  - **PostgreSQL**:\n\t```\n\tgo install -tags postgres github.com/tinode/chat/server@latest\n\tgo install -tags postgres github.com/tinode/chat/tinode-db@latest\n\t```\n  - **MongoDB**:\n\t```\n\tgo install -tags mongodb github.com/tinode/chat/server@latest\n\tgo install -tags mongodb github.com/tinode/chat/tinode-db@latest\n\t```\n  - **RethinkDb**:\n\t```\n\tgo install -tags rethinkdb github.com/tinode/chat/server@latest\n\tgo install -tags rethinkdb github.com/tinode/chat/tinode-db@latest\n\t```\n  - **All** (bundle all of the above DB adapters):\n\t```\n\tgo install -tags \"mysql rethinkdb mongodb postgres\" github.com/tinode/chat/server@latest\n\tgo install -tags \"mysql rethinkdb mongodb postgres\" github.com/tinode/chat/tinode-db@latest\n\t```\n\n    The steps above install Tinode binaries at `$GOPATH/bin/`, sorces and supporting files are located at `$GOPATH/pkg/mod/github.com/tinode/chat@vX.XX.X/` where `X.XX.X` is the version you installed, such as `0.19.1`.\n\n    Note the required **`-tags rethinkdb`**, **`-tags mysql`**, **`-tags mongodb`** or **`-tags postgres`** build option.\n\n    You may also optionally define `main.buildstamp` for the server by adding a build option, for instance, with a timestamp:\n    ```\n    go install -tags mysql -ldflags \"-X main.buildstamp=`date -u '+%Y%m%dT%H:%M:%SZ'`\" github.com/tinode/chat/server@latest\n    ```\n    The value of `buildstamp` will be sent by the server to the clients.\n\n    Building with Go 1.17 or below **will fail**!\n\n5. Open `tinode.conf` (located at `$GOPATH/pkg/mod/github.com/tinode/chat@vX.XX.X/server/`). Check that the database connection parameters are correct for your database. If you are using MySQL make sure [DSN](https://github.com/go-sql-driver/mysql#dsn-data-source-name) in `\"mysql\"` section is appropriate for your MySQL installation. Option `parseTime=true` is required.\n```js\n\t\"mysql\": {\n\t\t\"dsn\": \"root@tcp(localhost)/tinode?parseTime=true\",\n\t\t\"database\": \"tinode\"\n\t},\n```\n\n6. Make sure you specify the adapter name in your `tinode.conf`. E.g. you want to run Tinode with MySQL:\n```js\n\t\"store_config\": {\n\t\t...\n\t\t\"use_adapter\": \"mysql\",\n\t\t...\n\t},\n```\n\n7. Now that you have built the binaries, follow instructions in the _Running a Standalone Server_ section.\n\n\n## Running a Standalone Server\n\nIf you followed instructions in the previous section then the Tinode binaries are installed in `$GOPATH/bin/`, the sources and supporting files are located in `$GOPATH/pkg/mod/github.com/tinode/chat@vX.XX.X/`, where `X.XX.X` is the version you installed, for example `0.19.1`.\n\nSwitch to sources directory (replace `X.XX.X` with your actual version, such as `0.19.1`):\n```\ncd $GOPATH/pkg/mod/github.com/tinode/chat@vX.XX.X\n```\n\n1. Make sure your database is running:\n - **MySQL**: https://dev.mysql.com/doc/mysql-startstop-excerpt/5.7/en/mysql-server.html\n\t```\n\tmysql.server start\n\t```\n - **PostgreSQL**: https://www.postgresql.org/docs/current/app-pg-ctl.html\n\t```\n\tpg_ctl start\n\t```\n - **MongoDB**: https://docs.mongodb.com/manual/administration/install-community/\nMongoDB should run as single node replicaset. See https://docs.mongodb.com/manual/administration/replica-set-deployment/\n\t```\n\tmongod\n\t```\n - **RethinkDB**: https://www.rethinkdb.com/docs/start-a-server/\n\t```\n\trethinkdb --bind all --daemon\n\t```\n\n2. Run DB initializer\n\t```\n\t$GOPATH/bin/tinode-db -config=./tinode-db/tinode.conf\n\t```\n\tadd `-data=./tinode-db/data.json` flag if you want sample data to be loaded:\n\t```\n\t$GOPATH/bin/tinode-db -config=./tinode-db/tinode.conf -data=./tinode-db/data.json\n\t```\n\n\tDB initializer needs to be run only once per installation. See [instructions](tinode-db/README.md) for more options.\n\n3. 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.\n\n4. Copy or symlink template directory `./server/templ` to `$GOPATH/bin/templ`\n\t```\n\tln -s ./server/templ $GOPATH/bin\n\t```\n\n5. Run the server\n\t```\n\t$GOPATH/bin/server -config=./server/tinode.conf -static_data=$HOME/tinode/webapp/\n\t```\n\n6. 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.\n\n**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.\n\n\n## Running a Cluster\n\n- Install and run the database, run DB initializer, unpack JS files, and link or copy template directory as described in the previous section. Both MySQL and RethinkDB supports [cluster](https://www.mysql.com/products/cluster/) [mode](https://www.rethinkdb.com/docs/start-a-server/#a-rethinkdb-cluster-using-multiple-machines). You may consider it for added resiliency.\n\n- Cluster expects at least two nodes. A minimum of three nodes is recommended.\n\n- The following section configures the cluster.\n\n```\n\t\"cluster_config\": {\n\t\t// Name of the current node.\n\t\t\"self\": \"\",\n\t\t// List of all cluster nodes, including the current one.\n\t\t\"nodes\": [\n\t\t\t{\"name\": \"one\", \"addr\":\"localhost:12001\"},\n\t\t\t{\"name\": \"two\", \"addr\":\"localhost:12002\"},\n\t\t\t{\"name\": \"three\", \"addr\":\"localhost:12003\"}\n\t\t],\n\t\t// Configuration of failover feature. Don't change.\n\t\t\"failover\": {\n\t\t\t\"enabled\": true,\n\t\t\t\"heartbeat\": 100,\n\t\t\t\"vote_after\": 8,\n\t\t\t\"node_fail_after\": 16\n\t\t}\n\t}\n```\n* `self` is the name of the current node. Generally it's more convenient to specify the name of the current node at the command line using `cluster_self` option. Command line value overrides the config file value. If the value is not provided either in the config file or through the command line, the clustering is disabled.\n* `nodes` defines individual cluster nodes. The sample defines three nodes named `one`, `two`, and `tree` running at the localhost at the specified cluster communication ports. Cluster addresses don't need to be exposed to the outside world.\n* `failover` is an experimental feature which migrates topics from failed cluster nodes keeping them accessible:\n  * `enabled` turns on failover mode; failover mode requires at least three nodes in the cluster.\n  * `heartbeat` interval in milliseconds between heartbeats sent by the leader node to follower nodes to ensure they are accessible.\n  * `vote_after` number of failed heartbeats before a new leader node is elected.\n  * `node_fail_after` number of heartbeats that a follower node misses before it's considered to be down.\n\nIf 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:\n```\n$GOPATH/bin/tinode -config=./server/tinode.conf -static_data=./server/webapp/ -listen=:6060 -grpc_listen=:6080 -cluster_self=one &\n$GOPATH/bin/tinode -config=./server/tinode.conf -static_data=./server/webapp/ -listen=:6061 -grpc_listen=:6081 -cluster_self=two &\n```\nA bash script [run-cluster.sh](./server/run-cluster.sh) may be found useful.\n\n### Enabling Push Notifications\n\nFollow [instructions](./docs/faq.md#q-how-to-setup-push-notifications-with-google-fcm).\n\n\n### Enabling Video Calls\n\nVideo calls use [WebRTC](https://en.wikipedia.org/wiki/WebRTC). WebRTC is a peer to peer protocol: once the call is established, the client applications exchange data directly. Direct data exchange is efficient but creates a problem when the parties are not accessible from the internet. WebRTC solves it by means of [ICE](https://en.wikipedia.org/wiki/Interactive_Connectivity_Establishment) servers  which implement protocols [TURN(S)](https://en.wikipedia.org/wiki/Traversal_Using_Relays_around_NAT) and [STUN](https://en.wikipedia.org/wiki/STUN) as fallback.\n\nTinode does not provide ICE servers out of the box. You must install and configure (or purchase) your own servers otherwise video and voice calling will not be available.\n\nOnce you obtain the ICE TURN/STUN configuration from your service provider, add it to `tinode.conf` section `\"webrtc\"` - `\"ice_servers\"` (or `\"ice_servers_file\"`). Also change `\"webrtc\"` - `\"enabled\"` to `true`. An example configuration is provided in the `tinode.conf` for illustration only. IT WILL NOT FUNCTION because it uses dummy values instead of actual server addresses.\n\nYou may find this information useful for choosing the servers: https://gist.github.com/yetithefoot/7592580\n\n\n### Note on Running the Server in Background\n\nThere is [no clean way](https://github.com/golang/go/issues/227) to daemonize a Go process internally. One must use external tools such as shell `&` operator, `systemd`, `launchd`, `SMF`, `daemon tools`, `runit`, etc. to run the process in the background.\n\nSpecific note for [nohup](https://en.wikipedia.org/wiki/Nohup) users: an `exit` must be issued immediately after `nohup` call to close the foreground session cleanly:\n\n```\nnohup $GOPATH/bin/server -config=./server/tinode.conf -static_data=$HOME/tinode/webapp/ &\nexit\n```\n\nOtherwise `SIGHUP` may be received by the server if the shell connection is broken before the ssh session has terminated (indicated by `Connection to XXX.XXX.XXX.XXX port 22: Broken pipe`). In such a case the server will shutdown because `SIGHUP` is intercepted by the server and interpreted as a shutdown request.\n\nFor more details see https://github.com/tinode/chat/issues/25.\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <http://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<http://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<http://www.gnu.org/philosophy/why-not-lgpl.html>.\n"
  },
  {
    "path": "README.md",
    "content": "# Tinode Instant Messaging Server\n\n<img src=\"docs/logo.svg\" align=\"left\" width=128 height=128> Instant messaging full stack. Backend in pure [Go](http://golang.org) (license [GPL 3.0](http://www.gnu.org/licenses/gpl-3.0.en.html)), clients for Android (Java), iOS (Swift), and web (ReactJS), as well as [gRPC](https://grpc.io/) client support for C++, C#, Go, Java, Node, PHP, Python, Ruby, Objective-C, etc (all clients licensed under [Apache 2.0](http://www.apache.org/licenses/LICENSE-2.0)). Wire transport is JSON over websocket (long polling is also available) or [protobuf](https://developers.google.com/protocol-buffers/) with gRPC.\n\nThis is beta-quality software: feature-complete and stable but probably with a few bugs or missing features. Follow [instructions](INSTALL.md) to install and run or use one of the cloud services below. Read [API documentation](docs/API.md).\n\nTinode 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.\n\n<a href=\"https://apps.apple.com/us/app/tinode/id1483763538\"><img src=\"docs/app-store.svg\" height=36></a> <a href=\"https://play.google.com/store/apps/details?id=co.tinode.tindroidx\"><img src=\"docs/play-store.svg\" height=36></a> <a href=\"https://web.tinode.co/\"><img src=\"docs/web-app.svg\" height=36></a>\n\n## Why?\n\nThe promise of [XMPP](http://xmpp.org/) was to deliver federated instant messaging: anyone would be able to spin up an IM server capable of exchanging messages with any other XMPP server in the world. Unfortunately, XMPP never delivered on this promise. Instant messengers are still a bunch of incompatible walled gardens, similar to what AoL of the late 1990s was to the open Internet.\n\nThe goal of this project is to deliver on XMPP's original vision: create a modern open platform for federated instant messaging with an emphasis on mobile communication. A secondary goal is to create a decentralized IM platform that is much harder to track and block by the governments.\n\nAn explicit NON-goal: we are not building yet another Slack replacement.\n\n## Installing and running\n\nSee [general instructions](./INSTALL.md) or [docker-specific instructions](./docker/README.md).\n\n## Getting support\n\n* Read [API documentation](docs/API.md) and [FAQ](docs/faq.md). Read configuration instructions contained in the [`tinode.conf`](./server/tinode.conf) file.\n* For support, general questions, discussions post to [https://groups.google.com/d/forum/tinode](https://groups.google.com/d/forum/tinode).\n* For bugs and feature requests [open an issue](https://github.com/tinode/chat/issues/new/choose).\n* Use https://tinode.co/contact for commercial inquiries.\n\n## Helping out\n\n* If you appreciate our work, please help spread the word! Sharing on Reddit, HN, and other communities helps more than you think.\n* Consider buying paid support: https://tinode.co/support.html\n* If you are a software developer, send us your pull requests with bug fixes and new features.\n* If you use the app and discover bugs or missing features, let us know by filing bug reports and feature requests. Vote for existing [feature requests](https://github.com/tinode/chat/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3A%22feature+request%22) you find most valuable.\n* If you speak a language other than English, [translate](docs/translations.md) the apps into your language. You may also review and improve existing translations.\n* If you are a UI/UX expert, help us polish the app UI.\n* Use it: install it for your colleagues or friends at work or at home.\n\n## Public service\n\nA [public Tinode service](https://web.tinode.co/) is available. You can use it just like any other instant messenger. Keep in mind that demo accounts present in [sandbox](https://sandbox.tinode.co/) are not available in the public service. You must register an account using valid email in order to use the service.\n\n### Web\n\nTinodeWeb, a single page web app, is available at https://web.tinode.co/ ([source](https://github.com/tinode/webapp/)). See screenshots below.\n\n### Android\n\n[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.\n\n### iOS\n\n[Tinode for iOS](https://apps.apple.com/us/app/tinode/id1483763538) a.k.a. Tinodios is stable and functional ([source](https://github.com/tinode/ios)). See the screenshots below.\n\n\n## Demo/Sandbox\n\nA sandboxed demo service is available at https://sandbox.tinode.co/.\n\nLog in as one of `alice`, `bob`, `carol`, `dave`, `frank`. Password is `<login>123`, e.g. login for `alice` is `alice123`. You can discover other users by email or phone by prefixing them with `email:` or `tel:` respectively. Emails are `<login>@example.com`, e.g. `alice@example.com`, phones are `+17025550001` through `+17025550009`.\n\nWhen you register a new account you are asked for an email address to send validation code to. For demo purposes you may use `123456` as a universal validation code. The code you get in the email is also valid.\n\n### Sandbox Notes\n\n* The sandbox server is reset (all data wiped) every night at 3:15am Pacific time. An error message `User not found or offline` means the server was reset while you were connected. If you see it on the web, reload and relogin. On Android log out and re-login. If the database was changed, delete the app then reinstall.\n* Sandbox user `Tino` is a [basic chatbot](./chatbot) which responds with a [random quote](http://fortunes.cat-v.org/) to any message.\n* As generally accepted, when you register a new account you are asked for an email address. The server will send an email with a verification code to that address and you can use it to validate the account. To make things easier for testing, the server will also accept `123456` as a verification code. Remove line `\"debug_response\": \"123456\"` from `tinode.conf` to disable this option.\n* The sandbox server is configured to use [ACME](https://letsencrypt.org/) TLS [implementation](https://godoc.org/golang.org/x/crypto/acme) with hard-coded requirement for [SNI](https://en.wikipedia.org/wiki/Server_Name_Indication). If you are unable to connect then the most likely reason is your TLS client's missing support for SNI. Use a different client.\n* The default web app loads a single minified javascript bundle and minified CSS. The un-minified version is also available at https://sandbox.tinode.co/index-dev.html\n* [Docker images](https://hub.docker.com/u/tinode/) with the same demo are available.\n* You are welcome to test your client software against the sandbox, hack it, etc. No DDoS-ing though please.\n\n## Features\n\n### Supported\n\n* Multiple native platforms:\n  * [Android](https://github.com/tinode/tindroid/) (Java)\n  * [iOS](https://github.com/tinode/ios) (Swift)\n  * [Web](https://github.com/tinode/webapp/) (React.js)\n  * Scriptable [command line](tn-cli/) (Python)\n* User features:\n  * One-on-one and group messaging.\n  * Video and voice calls. Voice messages.\n  * Channels with unlimited number of read-only subscribers.\n  * All chats are synchronized across all devices.\n  * Granular access control with permissions for various actions.\n  * User search/discovery.\n  * Rich formatting of messages markdown-style: \\*style\\* &rarr; **style**, with inline images, videos, file attachments.\n  * Forms and templated responses suitable for chatbots.\n  * Verified/staff/untrusted account markers.\n  * Leave notes to self, bookmark (save) messages.\n  * Message status notifications: message delivery to server; received and read notifications; typing notifications.\n  * Most recent message preview in contact list.\n  * Server-generated presence notifications for people, group chats.\n  * Forwarding and replying to messages.\n  * Editing sent messages.\n  * Pinned chats and messages.\n  * Customizable message backgrounds (wallpapers).\n  * Light/dark/system UI themes.\n* Administration:\n  * Granular access control with permissions for various actions.\n  * Support for custom authentication backends.\n  * Ability to block unwanted communication server-side.\n  * Anonymous users (important for use cases related to tech support over chat).\n  * Plugins to extend functionality, for example, to support moderation or chatbots.\n  * Scriptable [command-line tool](tn-cli/) for server administration.\n* Performance, reliability and development:\n  * Sharded clustering with failover.\n  * Storage and out of band transfer of large objects like images or document files using local file system or Amazon S3 (other storage systems can be supported with [media handlers](https://github.com/tinode/chat/blob/master/server/media/media.go#L21)).\n  * JSON or [protobuf version 3](https://developers.google.com/protocol-buffers/) wire protocols.\n  * Bindings for various programming languages:\n    * Javascript with no external dependencies.\n    * Java with dependencies on [Jackson](https://github.com/FasterXML/jackson), [Java-Websocket](https://github.com/TooTallNate/Java-WebSocket), [ICU4J](https://github.com/unicode-org/icu). Suitable for Android but with no Android SDK dependencies.\n    * Swift with no external dependencies.\n    * C/C++, C#, Go, Python, PHP, Ruby and many other languages using [gRPC](https://grpc.io/docs/languages/).\n  * Choice of a database backend. Other databases can be added by writing [adapters](server/db/adapter.go).\n    * MySQL (and MariaDB, Percona as long as they remain SQL and wire protocol compatible)\n    * PostgreSQL\n    * MongoDB\n    * [RethinkDB](http://rethinkdb.com/). Support is deprecated and will be dropped in 2027 because RethinkDB is no longer being developed (unless its development resumes).\n\n### Planned\n\n* [Federation](https://en.wikipedia.org/wiki/Federation_(information_technology)).\n* Location and contacts sharing.\n* Previews of attached documents, links.\n* Recording video messages.\n* Video/audio broadcasting.\n* Group video/audio calls.\n* Attaching music/audio other than voice messages.\n* Different levels of message persistence (from strict persistence to \"store until delivered\" to purely ephemeral messaging).\n* Message encryption at rest.\n* End to end encryption with [OTR](https://en.wikipedia.org/wiki/Off-the-Record_Messaging) for one-on-one messaging and undecided method for group messaging.\n* Full text search in messages.\n\n### Translations\n\nAll client software has support for [internationalization](docs/translations.md). The following translations are provided:\n\n| Language | Server | Webapp | Android | iOS |\n| --- | :---: | :---: | :---: | :---: |\n| English | &check; | &check; | &check; | &check; |\n| Arabic |   | &check; |   |   |\n| Chinese simplified | &check; | &check; | &check; | &check; |\n| Chinese traditional | &check; | &check; | &check; | &check; |\n| French | &check; | &check; | &check; |   |\n| German |   | &check; | &check; |   |\n| Hindi |   |   | &check; |   |\n| Italian |   | &check; | &check; | &check; |\n| Korean |   | &check; | &check; |   |\n| Portuguese | &check; |   | &check; |   |\n| Romanian |   | &check; | &check; |   |\n| Russian | &check; | &check; | &check; | &check; |\n| Spanish | &check; | &check; | &check; | &check; |\n| Thai |   | &check; |   |   |\n| Ukrainian | &check; | &check; | &check; | &check; |\n| Vietnamese | &check; | &check; |   |   |\n\nMore translations are [welcome](docs/translations.md). In addition to languages listed above, particularly interested in Bengali, Indonesian, Urdu, Japanese, Turkish, Persian.\n\n## Third-Party\n\n### Projects\n\n* [Arango DB adapter](https://github.com/gfxlabs/chat/tree/master/server/db/arango) (outdated)\n* [DynamoDB adapter](https://github.com/riandyrn/chat/tree/master/server/db/dynamodb) (outdated)\n\n### Licenses\n\n* Demo avatars and some other graphics are from https://www.pexels.com/ under [CC0 license](https://www.pexels.com/photo-license/) and https://pixabay.com/ under their [license](https://pixabay.com/service/license/).\n* Web and Android background patterns are from http://subtlepatterns.com/ under [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) license.\n* Android icons are from https://material.io/tools/icons/ under [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0.html) license.\n\n## Screenshots\n\n### [Android](https://github.com/tinode/tindroid/)\n\n<p align=\"center\">\n<img src=\"docs/android-contacts.png\" alt=\"Android screenshot: list of chats\" width=250 />\n<img src=\"docs/android-chat.png\" alt=\"Android screenshot: one conversation\" width=250 />\n<img src=\"docs/android-video-call.png\" alt=\"Android screenshot: video call\" width=250 />\n</p>\n\n### [iOS](https://github.com/tinode/ios)\n\n<p align=\"center\">\n<img src=\"docs/ios-contacts.png\" alt=\"iOS screenshot: list of chats\" width=250 /> <img src=\"docs/ios-chat.png\" alt=\"iOS screenshot: one conversation\" width=250 /> <img src=\"docs/ios-video-call.png\" alt=\"iOS screenshot: video call\" width=\"250\" />\n</p>\n\n### [Desktop Web](https://github.com/tinode/webapp/)\n\n<p align=\"center\">\n  <img src=\"docs/web-desktop.jpg\" alt=\"Desktop web: full app\" width=810 />\n</p>\n\n### [Mobile Web](https://github.com/tinode/webapp/)\n\n<p align=\"center\">\n  <img src=\"docs/web-mob-contacts.png\" alt=\"Mobile web: contacts\" width=250 /> <img src=\"docs/web-mob-chat.png\" alt=\"Mobile web: chat\" width=250 /> <img src=\"docs/web-mob-video-call.png\" alt=\"Mobile web: topic info\" width=250 />\n</p>\n\n\n#### SEO Strings\n\nWords 'chat' and 'instant messaging' in Chinese, Russian, Persian and a few other languages.\n\n* 聊天室 即時通訊\n* чат мессенджер\n* インスタントメッセージ\n* 인스턴트 메신저\n* پیام رسان فوری\n* تراسل فوري\n* فوری پیغام رسانی\n* Nhắn tin tức thời\n* anlık mesajlaşma sohbet\n* mensageiro instantâneo\n* pesan instan\n* mensajería instantánea\n* চ্যাট ইন্সট্যান্ট মেসেজিং\n* चैट त्वरित संदेश\n* তাৎক্ষণিক বার্তা আদান প্রদান\n"
  },
  {
    "path": "README_ko.md",
    "content": "# Tinode 인스턴트 메시징 서버\n\n## This document is outdated. For up to date info use [README.md](./README.md)\n\n\n<img src=\"docs/logo.svg\" align=\"left\" width=128 height=128> 인스턴트 메시징 서버. C++, C#, [Go](http://golang.org), Java, Node, PHP, Python, Ruby, Objective-C 등에 대한 [gRPC](https://grpc.io/) 클라이언트 지원은 물론 순수Go(라이선스 [GPL 3.0](http://www.gnu.org/licenses/gpl-3.0.en.html))의 백엔드와 Java,Javasript 및 Swift의 클라이언트 측 바인딩(라이선스 [Apache 2.0](http://www.apache.org/licenses/LICENSE-2.0)). 와이어 전송은 사용자 정의 바인딩을 위해 웹소켓을 통한 JSON(롱 폴링도 가능) 즉, gRPC와 [protobuf](https://developers.google.com/protocol-buffers/). 영구 저장소 [RethinkDB](http://rethinkdb.com/), MySQL 및 MongoDB(실험적). 지원되지 않는 타사 [DynamoDB adapter](https://github.com/riandyrn/chat/tree/master/server/db/dynamodb) adapter도 있습니다. 사용자 정의 어댑터를 작성하여 다른 데이터베이스를 지원할 수 있습니다.\n\nTinode는 XMPP/ Jabber 가 아닙니다. Tinode는 XMPP와 호환되지 않습니다. XMPP를 대체하기 위한 것입니다. 표면적으로는 오픈소스 WhatsApp 또는 Telegram과 매우 유사합니다.\n\n버전 0.16은 베타 급 소프트웨어입니다. 기능은 완전하지만 몇 가지 버그가 있습니다. 아래 클라우드 서비스 중 하나를 설치 및 실행하거나 사용하려면 [지시사항](INSTALL.md)을 따르십시오. [API 설명서](docs/API.md)를 읽으십시오.\n\n<a href=\"https://apps.apple.com/us/app/tinode/id1483763538\"><img src=\"docs/app-store.svg\" height=36></a> <a href=\"https://play.google.com/store/apps/details?id=co.tinode.tindroidx\"><img src=\"docs/play-store.svg\" height=36></a> <a href=\"https://web.tinode.co/\"><img src=\"docs/web-app.svg\" height=36></a>\n\n## Why?\n\n[XMPP](http://xmpp.org/)의 약속은 연합된 인스턴스 메시징을 제공하는 것입니다. 누구나 전세계의 다른 XMPP서버와 메시지를 교환할 수 있는 IM 서버를 가동할 수 있습니다. 불행하게도, XMPP는 이 약속을 이행하지 않았습니다. 인스턴트 메신저들은 1990년대 후반의 AoL공개 인터넷과 비슷한, 양립할 수 없는 벽으로 둘러싸인 정원의 무리들입니다.\n\n이 프로젝트의 목표는 XMPP의 원래 비전인 모바일 통신을 강조하여 연합 인스턴트 메시징을 위한 현대적인 개방형 플랫폼을 만드는 것입니다. 두 번째 목표는 정부가 추적하고 차단하기 훨씬 어려운 분산형 IM플랫폼을 만드는 것입니다.\n\nXMPP: XML에 기반한 메시지 지향 통신 프로토콜\n\nIM: Instant Messenger\n\n## 설치 및 실행\n\n[일반 지침](./INSTALL.md) 또는 [도커별 지침](./docker/README.md)을 참조하십시오.\n\n## 지원받기\n\n* [API 설명서](docs/API.md) 및 [FAQ](docs/faq.md)를 읽으십시오.\n* 지원, 일반적인 질문, 토로은[https://groups.google.com/d/forum/tinode](https://groups.google.com/d/forum/tinode).에 게시하십시오.\n* 버그 및 기능 요청에 대해서는 [issue](https://github.com/tinode/chat/issues/new)를 여십시오.\n\n\n## 공공서비스\n\n[Tinode 공공 서비스](https://web.tinode.co/)는 지금 바로 사용할 수 있습니다. 다른 메신저들처럼 사용하면 됩니다. [샌드박스](https://sandbox.tinode.co/)에 있는 데모 계정은 공공 서비스에서 사용할 수 없습니다. 서비스를 이용하려면 유효한 이메일을 사용하여 계정을 등록해야 합니다.\n\n### 웹\n\nTinode웹은 단일 페이지의 웹으로 https://web.tinode.co/ ([원본](https://github.com/tinode/webapp/))에서 이용이 가능합니다. . 아래에 있는 스크린 샷을 참고하세요. 현재 영어, 중국어 간체, 러시아어를 지원합니다. 더 많은 번역을 환영합니다.\n\n### 안드로이드\n\nTindroid라고 불리는 [안드로이드 버전의 Tinode](https://play.google.com/store/apps/details?id=co.tinode.tindroidx) 는 안정적으로 가동됩니다.  ([원본](https://github.com/tinode/tindroid)).  아래에 있는 스크린 샷을 참고하세요. 편의를 위해 [디버그 APK](https://github.com/tinode/tindroid/releases/latest)도 제공합니다. 현재 영어, 중국어 간체, 러시아어를 지원합니다. 더 많은 번역을 환영합니다.\n\n### iOS\n\nTinodios라고 불리는 [iOS 버전의 Tinode](https://apps.apple.com/app/reference-to-tinodios-here/id123) 안정적으로 가동됩니다.([원본](https://github.com/tinode/ios)). 아래에 있는 스크린샷을 참고하세요. 현재 영어와 중국어 간체를 지원합니다. 더 많은 번역을 환영합니다.\n\n\n## 데모/샌드박스\n샌드박스 데모 버전은 https://sandbox.tinode.co/ 에서 이용 가능합니다.\n\nalice, bob, carol, dave, frank 중 하나로 로그인할 수 있습니다. 비밀번호는 <이름>123으로 예를 들어, alice의 비밀번호는 alice123입니다. 사용자 이름을 맨 앞에쓴 <이름>@example.com 형식의 이메일이나 +17025550001 부터 +17025550009의 전화번호를 이용해서 다른 사용자들을 찾을 수 있습니다.\n\n새로운 계정을 등록하면 유효성 검사 코드를 보낼 이메일 주소를 묻는 메시지가 나타납니다. 데모의 목적으로 123456을 범용 유효성 검사 코드로 사용할 수 있습니다. 실제 이메일로 받은 코드도 유효합니다.\n\n### 샌드박스 노트\n\n* 샌드박스 서버는 태평양 표준시 기준 매일 오전 3시 15분에 초기화됩니다(모든 데이터가 지워짐). 사용자를 찾을 수 없습니다 또는 오프라인 같은 오류 메시지는 서버에 연결하는 동안 서버가 초기화 되었음을 의미합니다. 만약 해당 오류 메시지가 표시되면 새로고침 후 다시 로그인 하세요. 안드로이드에서 로그아웃 후 다시 로그인 하세요. 만약 데이터베이스가 변경된 경우에는 앱을 삭제했다가 다시 설치하면 됩니다.\n* 샌드박스 유저 Tino는 [기본적인 챗봇](./chatbot)으로 모든 메시지에 [임의의 인용구](http://fortunes.cat-v.org/)로 응답합니다.\n* 일반적으로 새로운 계정을 등록하면 이메일 주소를 묻는 메시지가 표시됩니다. 서버는 유효성 검사 코드가 포함된 메일을 보내며 이를 사용하여 계정을 검증하는 데 사용할 수 있습니다. 테스트를 보다 쉽게 할 수 있도록 서버는 유효성 검사 코드로 123456을 또한 허용합니다. Tinode.conf에서 ”debug_response”: “123456”행을 제거하여 이 옵션을 비활성화 시킬 수 있습니다.\n* 샌드박스 서버는 [SNI](https://en.wikipedia.org/wiki/Server_Name_Indication)에 대한 하드 코딩된 요구사항과 함께 [ACME](https://letsencrypt.org/) TLS [구현](https://godoc.org/golang.org/x/crypto/acme)을 사용하도록 구성되었습니다. 만약 연결할 수 없을 경우 TLS 클라이언트의 SNI 지원 누락일 가능성이 높습니다. 그 경우 다른 클라이언트를 사용하세요.\n* 기본 웹 앱은 하나의 축소된 자바스크립트 번들과 축소된 CSS를 가져옵니다. 축소되지 않은 버전은 https://sandbox.tinode.co/index-dev.html 에서도 제공됩니다.\n* 데모가 같은 [도커 이미지](https://hub.docker.com/u/tinode/)도 사용 가능합니다.\n* 샌드박스에 대해 소프트웨어를 테스트하고 해킹하는 작업, 기타 작업들을 수행할 수 있습니다. DDos는 절대 사용하지 마세요.\n\n## 특징\n\n### 지원 기능\n\n* [Android](https://github.com/tinode/tindroid/), [iOS](https://github.com/tinode/ios), [web](https://github.com/tinode/webapp/), 그리고 [command line](tn-cli/) 클라이언트.\n* 1대1 메시징.\n* 모든 구성원의 접근 권한을 가진 그룹 메시징을 개별적으로 관리한다. 최대 구성원 수는 설정할 수 있다(기본적으로 128명).\n* 다양한 작업에 대한 권한을 가진 항목 액세스 제어\n* 서버에서 생성한 사용자 및 주제에 대한 존재 알림.\n* 맞춤형 인증 지원\n* failover를 통한 Sharded clustering\n* 영구 메시지 저장소, 페이지가 지정된 메시지 기록\n* 외부 의존성이 없는 javascript 바인딩.\n* Android SDK dependencies.Java 바인딩(의존성: [Jackson](https://github.com/FasterXML/jackson), [Java-Websocket](https://github.com/TooTallNate/Java-WebSocket)). Android에 적합하지만 Android SDK 종속성이 없음.\n* TCP 또는 Unix 소켓을 통한 Webocket, long polling, 및 [gRPC](https://grpc.io/).\n* JSON 또는[protobuf 버전 3](https://developers.google.com/protocol-buffers/) 와이어 프로토콜.\n* [암호화](https://letsencrypt.org/) 또는 기존 인증서를 내장한 [TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security) 옵션\n* 사용자 검색/발견.\n* 풍부한 메시지 형식, 마크다운 스타일: \\*style\\* &rarr; **style**.\n* 인라인 이미지 및 첨부 파일.\n* 챗봇에 적합한 양식 및 템플리트 응답.\n* 메시지 상태 알림: 서버로 메시지 전달; 수신 및 읽기 알림; 입력 알림.\n* 클라이언트 측 데이터 캐싱 지원.\n* 원하지 않는 통신 서버를 차단하는 기능.\n* 익명 사용자(대화 중 기술 지원 관련 사용 사례에 중요성).\n* [FCM](https://firebase.google.com/docs/cloud-messaging/) 또는 [TNPG](server/push/tnpg/)를 사용하여 알림을 푸시.\n* 로컬 파일 시스템 또는 Amazon S3를 사용하여 비디오 파일과 같은 대형 오브젝트의 저장 및 대역 외 전송.\n* 챗봇을 활성화하기 위해 기능을 확장하는 플러그인.\n\n### 계획\n\n* [연방(연합,연맹)](https://en.wikipedia.org/wiki/Federation_(information_technology)).\n* 일대일 메시징을 위한 [OTR](https://en.wikipedia.org/wiki/Off-the-Record_Messaging)과 그룹 메시징을 위한 미확정 방법으로 End to end 암호화.\n* bearer token 액세스 제어를 가진 무제한 회원(또는 수십만 명)의 그룹 메시징.\n* 자동 예비 시스템.\n* 메시지 지속성 다른 수준(엄격한 지속성부터 \"전달될 때까지 저장\"까지, 완전히 짧은 메시징까지).\n\n### 번역\n\n모든 클라이언트 소프트웨어는 국제화를 지원한다. 번역은 영어, 중국어 간체, 러시아어(iOS 제외)에 제공된다. 더 많은 번역을 환영한다. 특히 스페인어, 아랍어, 독일어, 페르시아어, 인도네시아어, 포르투갈어, 힌디어, 벵골어에 관심이 많다.\n\n## 타사 라이선스\n\n* 데모 아바타와 일부 다른 그래픽은 [CC0](https://www.pexels.com/photo-license/) 라이센스에 따라 https://www.pexels.com/에서 제공된다.\n* 웹 및 안드로이드 배경 패턴은 [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) 라이센스에 따라 http://subtlepatterns.com/ 에서 제공된다.\n* Android 아이콘은 [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0.html) 라이센스의 https://material.io/tools/icons/ 에서 제공된다.\n* 일부 iOS 아이콘은 [CC BY-ND 3.0](https://icons8.com/license) 라이센스에 따라 https://icons8.com/ 에서 제공된다.\n\n## 스크린샷\n\n### [안드로이드](https://github.com/tinode/tindroid/)\n\n<p align=\"center\">\n<img src=\"docs/android-contacts.png\" alt=\"Android screenshot: list of chats\" width=270 />\n<img src=\"docs/android-chat.png\" alt=\"Android screenshot: one conversation\" width=270 />\n<img src=\"docs/android-account.png\" alt=\"Android screenshot: account settings\" width=270 />\n</p>\n\n### [iOS](https://github.com/tinode/ios)\n\n<p align=\"center\">\n<img src=\"docs/ios-contacts.png\" alt=\"iOS screenshot: list of chats\" width=207 /> <img src=\"docs/ios-chat.png\" alt=\"iOS screenshot: one conversation\" width=207 /> <img src=\"docs/ios-acc-personal.png\" alt=\"iOS screenshot: account settings\" width=\"207\" />\n</p>\n\n### [데스크탑 웹](https://github.com/tinode/webapp/)\n\n<p align=\"center\">\n  <img src=\"docs/web-desktop-2.png\" alt=\"Desktop web: full app\" width=810 />\n</p>\n\n### [모바일 웹](https://github.com/tinode/webapp/)\n\n<p align=\"center\">\n  <img src=\"docs/web-mob-contacts-1.png\" alt=\"Mobile web: contacts\" width=250 /> <img src=\"docs/web-mob-chat-1.png\" alt=\"Mobile web: chat\" width=250 /> <img src=\"docs/web-mob-info-1.png\" alt=\"Mobile web: topic info\" width=250 />\n</p>\n\n\n#### SEO 문자열\n\n중국어, 러시아어, 페르시아어 및 다른 몇 가지 언어로 '챗'과 '인스턴트 메시징'을 표시한다.\n\n* 聊天室 即時通訊\n* чат мессенджер\n* インスタントメッセージ\n* 인스턴트 메신저\n* پیام‌رسانی فوری گپ\n* تراسل فوري\n* Nhắn tin tức thời\n* anlık mesajlaşma sohbet\n* mensageiro instantâneo\n* pesan instan\n* mensajería instantánea\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Reporting a Vulnerability\n\nPlease report a vulnerability to `security@tinode.co`.\n\n## Do NOT to report:\n\n * Firebase initialization tokens. The Firebase tokens are really public: they must be distributed with the client applications and consequently are not private by design.\n * Exposed `/pprof` and/or `/expvar`. We know they are exposed. It's intentional and harmless.\n * Exposed Prometheus metrics `/metrics`. Like above, it's intentional and harmless.\n * DMARC policy is not enabled `p=none`. We know and that's the way we like it for now.\n * Weak cipher suites (TLS 1.0) at `*.tinode.co`. Yes, we know. Does not look serious/important.\n"
  },
  {
    "path": "build-all.sh",
    "content": "#!/bin/bash\n\n# This script builds and archives binaries and supporting files for mac, linux, and windows.\n# If directory ./server/static exists, it's asumed to contain TinodeWeb and then it's also\n# copied and archived.\n\n# Supported OSs: mac (darwin), windows, linux.\ngoplat=( darwin darwin windows linux linux )\n\n# CPUs architectures: amd64 and arm64. The same order as OSs.\ngoarc=( amd64 arm64 amd64 amd64 arm64 )\n\n# Number of platform+architectures.\nbuildCount=${#goplat[@]}\n\n# Supported database tags\ndbadapters=( mysql mongodb rethinkdb postgres )\ndbtags=( ${dbadapters[@]} alldbs )\n\nfor line in $@; do\n  eval \"$line\"\ndone\n\nversion=${tag#?}\n\nif [ -z \"$version\" ]; then\n  # Get last git tag as release version. Tag looks like 'v.1.2.3', so strip 'v'.\n  version=`git describe --tags`\n  version=${version#?}\nfi\n\necho \"Releasing $version\"\n\nGOSRC=..\n\npushd ${GOSRC}/chat > /dev/null\n\n# Prepare directory for the new release\nrm -fR ./releases/${version}\nmkdir ./releases/${version}\n\n# Tar on Mac is inflexible about directories. Let's just copy release files to\n# one directory.\nrm -fR ./releases/tmp\nmkdir -p ./releases/tmp/templ\n\n# Copy templates and database initialization files\ncp ./server/tinode.conf ./releases/tmp\ncp ./server/templ/*.templ ./releases/tmp/templ\ncp ./tinode-db/data.json ./releases/tmp\ncp ./tinode-db/*.jpg ./releases/tmp\ncp ./tinode-db/credentials.sh ./releases/tmp\n\n# Create directories for and copy TinodeWeb files.\nif [[ -d ./server/static ]]\nthen\n  mkdir -p ./releases/tmp/static/img\n  mkdir ./releases/tmp/static/img/bkg\n  mkdir ./releases/tmp/static/css\n  mkdir ./releases/tmp/static/audio\n  mkdir ./releases/tmp/static/src\n  mkdir ./releases/tmp/static/umd\n\n  cp ./server/static/img/*.png ./releases/tmp/static/img\n  cp ./server/static/img/*.svg ./releases/tmp/static/img\n  cp ./server/static/img/*.jpeg ./releases/tmp/static/img\n  cp ./server/static/img/bkg/*.png ./releases/tmp/static/img/bkg\n  cp ./server/static/img/bkg/*.jpg ./releases/tmp/static/img/bkg\n  cp ./server/static/img/bkg/*.json ./releases/tmp/static/img/bkg\n  cp ./server/static/audio/*.m4a ./releases/tmp/static/audio\n  cp ./server/static/css/*.css ./releases/tmp/static/css\n  cp ./server/static/index.html ./releases/tmp/static\n  cp ./server/static/index-dev.html ./releases/tmp/static\n  cp ./server/static/version.js ./releases/tmp/static\n  cp ./server/static/umd/*.js ./releases/tmp/static/umd\n  cp ./server/static/manifest.json ./releases/tmp/static\n  cp ./server/static/service-worker.js ./releases/tmp/static\n  # Create empty FCM client-side config.\n  echo 'const FIREBASE_INIT = {};' > ./releases/tmp/static/firebase-init.js\nelse\n  echo \"TinodeWeb not found, skipping\"\nfi\n\nfor (( i=0; i<${buildCount}; i++ ));\ndo\n  plat=\"${goplat[$i]}\"\n  arc=\"${goarc[$i]}\"\n\n  # Use .exe file extension for binaries on Windows.\n  ext=\"\"\n  if [ \"$plat\" = \"windows\" ]; then\n    ext=\".exe\"\n  fi\n\n  # Remove possibly existing keygen from previous build.\n  rm -f ./releases/tmp/keygen\n  rm -f ./releases/tmp/keygen.exe\n\n  # Keygen is database-independent\n  env GOOS=\"${plat}\" GOARCH=\"${arc}\" go build -ldflags \"-s -w\" -o ./releases/tmp/keygen${ext} ./keygen > /dev/null\n\n  for dbtag in \"${dbtags[@]}\"\n  do\n    echo \"Building ${dbtag}-${plat}/${arc}...\"\n\n    # Remove possibly existing binaries from previous build.\n    rm -f ./releases/tmp/tinode\n    rm -f ./releases/tmp/tinode.exe\n    rm -f ./releases/tmp/init-db\n    rm -f ./releases/tmp/init-db.exe\n\n    # Build tinode server and database initializer for RethinkDb and MySQL.\n    # For 'alldbs' tag, we compile in all available DB adapters.\n    if [ \"$dbtag\" = \"alldbs\" ]; then\n      buildtag=\"${dbadapters[@]}\"\n    else\n      buildtag=$dbtag\n    fi\n\n    env GOOS=\"${plat}\" GOARCH=\"${arc}\" go build \\\n      -ldflags \"-s -w -X main.buildstamp=`git describe --tags`\" -tags \"${buildtag}\" \\\n      -o ./releases/tmp/tinode${ext} ./server > /dev/null\n    env GOOS=\"${plat}\" GOARCH=\"${arc}\" go build \\\n      -ldflags \"-s -w\" -tags \"${buildtag}\" -o ./releases/tmp/init-db${ext} ./tinode-db > /dev/null\n\n    # Build archive. All platforms but Windows use tar for archiving. Windows uses zip.\n    if [ \"$plat\" = \"windows\" ]; then\n      # Remove possibly existing archive.\n      rm -f ./releases/${version}/tinode-${dbtag}.\"${plat}-${arc}\".zip\n      # Generate a new one\n      pushd ./releases/tmp > /dev/null\n      zip -q -r ../${version}/tinode-${dbtag}.\"${plat}-${arc}\".zip ./*\n      popd > /dev/null\n    else\n      plat2=$plat\n      # Rename 'darwin' tp 'mac'\n      if [ \"$plat\" = \"darwin\" ]; then\n        plat2=mac\n      fi\n\n      # Remove possibly existing archive.\n      rm -f ./releases/${version}/tinode-${dbtag}.\"${plat2}-${arc}\".tar.gz\n      # Generate a new one\n      tar -C ./releases/tmp -zcf ./releases/${version}/tinode-${dbtag}.\"${plat2}-${arc}\".tar.gz .\n    fi\n  done\ndone\n\n# Build chatbot release\necho \"Building python code...\"\n\n./build-py-grpc.sh\n\n# Release chatbot\necho \"Packaging chatbot.py...\"\nrm -fR ./releases/tmp\nmkdir -p ./releases/tmp\n\ncp ${GOSRC}/chat/chatbot/python/chatbot.py ./releases/tmp\ncp ${GOSRC}/chat/chatbot/python/quotes.txt ./releases/tmp\ncp ${GOSRC}/chat/chatbot/python/requirements.txt ./releases/tmp\n\ntar -C ${GOSRC}/chat/releases/tmp -zcf ./releases/${version}/py-chatbot.tar.gz .\npushd ./releases/tmp > /dev/null\nzip -q -r ../${version}/py-chatbot.zip ./*\npopd > /dev/null\n\n# Release tn-cli\necho \"Packaging tn-cli...\"\n\nrm -fR ./releases/tmp\nmkdir -p ./releases/tmp\n\ncp ${GOSRC}/chat/tn-cli/*.py ./releases/tmp\ncp ${GOSRC}/chat/tn-cli/*.txt ./releases/tmp\n\ntar -C ${GOSRC}/chat/releases/tmp -zcf ./releases/${version}/tn-cli.tar.gz .\npushd ./releases/tmp > /dev/null\nzip -q -r ../${version}/tn-cli.zip ./*\npopd > /dev/null\n\n# Clean up temporary files\nrm -fR ./releases/tmp\n\npopd > /dev/null\n"
  },
  {
    "path": "build-py-grpc.sh",
    "content": "#!/bin/bash\n\necho \"Packaging python tinode-grpc...\"\n\npushd ./pbx > /dev/null\n\n# Generate grpc bindings from the proto file.\n./py-generate.sh v=3\n\npushd ../py_grpc > /dev/null\n\n# Generate version file from git tags\npython3 version.py\n\n# Generate tinode-grpc package\npython3 -m build > /dev/null\n\npopd > /dev/null\npopd > /dev/null\n"
  },
  {
    "path": "chatbot/LICENSE",
    "content": "The code in this folder and nested folders is licensed under Apache 2.0\nhttp://www.apache.org/licenses/LICENSE-2.0\n"
  },
  {
    "path": "chatbot/README.md",
    "content": "# Tinode ChatBot Examples\n\n* [Python chatbot](python/)\n* [Karuha](https://github.com/Visecy/Karuha) - third party chatbot framework.\n* [C# .Net/.NetCore chatbot](https://github.com/tinode/csharpbot)\n"
  },
  {
    "path": "chatbot/csharp/README.md",
    "content": "# Tinode Chatbot Example for .Net or .NetCore\n\nMoved to a separate repo: https://github.com/tinode/csharpbot\n"
  },
  {
    "path": "chatbot/python/.gitignore",
    "content": ".tn-cookie"
  },
  {
    "path": "chatbot/python/README.md",
    "content": "# Tinode Chatbot\n\nThis is a simple chatbot for Tinode using [gRPC API](../../pbx/). It's written in Python as a demonstration\nthat the API is language-independent.\n\nThe chat bot subscribes to events stream using Plugin API and logs in to Tinode server as a regular user over gRPC interface (see `grpc_listen` in [tinode.conf](../../server/tinode.conf) file). The event stream API is used to listen for creation of new accounts. When a new account is created, the bot initiates a p2p topic with the new user. Then it listens for messages sent to the topic and responds to each with a random quote from `quotes.txt` file.\n\nGenerated files are provided for convenience in a [separate folder](../../py_grpc/tinode_grpc). You may re-generate them if needed:\n```\npython -m pip install grpcio-tools\npython -m grpc_tools.protoc -../../pbx --python_out=. --grpc_python_out=. ../../pbx/model.proto\n```\n\nChatbot expects gRPC binding to be provided as `tinode-grpc`. If you want to use them locally, first copy `model_pb2.py` and `model_pb2_grpc.py` to the same folder as `chatbot.py` then find the lines\n```\nfrom tinode_grpc import pb\nfrom tinode_grpc import pbx\n```\nin `chatbot.py` and replace them with\n```\nimport model_pb2 as pb\nimport model_pb2_grpc as pbx\n```\n\n## Installing and running\n\n### Using PIP\n\n#### Prerequisites\n\n[gRPC](https://grpc.io/) requires [python](https://www.python.org/) 2.7 or 3.4 or higher.\nMake sure [pip](https://pip.pypa.io/en/stable/installing/) 9.0.1 or higher is installed.\n```\n$ python -m pip install --upgrade pip\n```\nIf you cannot upgrade pip due to a system-owned installation, you can run install it in a `virtualenv`:\n```\n$ python -m pip install virtualenv\n$ virtualenv venv\n$ source venv/bin/activate\n$ python -m pip install --upgrade pip\n```\n\n#### Install dependencies:\n```\n$ python -m pip install -r requirements.txt\n```\n\nOn El Capitan OSX, you may get the following error:\n```\n$ OSError: [Errno 1] Operation not permitted: '/tmp/pip-qwTLbI-uninstall/System/Library/Frameworks/Python.framework/Versions/2.7/Extras/lib/python/six-1.4.1-py2.7.egg-info'\n```\nYou can work around this using:\n```\n$ python -m pip install tinode_grpc --ignore-installed\n```\n\n### Run the chatbot\n\nStart the [tinode server](../../INSTALL.md) first. Then start the chatbot with credentials of the user you want to be your bot, `alice` in this example:\n```\npython chatbot.py --login-basic=alice:alice123\n```\nIf you want to run the bot in the background, start it as\n```\nnohup python chatbot.py --login-basic=alice:alice123 &\n```\nRun `python chatbot.py -h` for more options.\n\nIf you are using python 2, keep in mind that `condition.wait()` [is forever buggy](https://bugs.python.org/issue8844). As a consequence of this bug the bot cannot be terminated with a SIGINT. It has to be stopped with a SIGKILL.\n\nYou can use cookie file to store credentials. Sample cookie files are provided as `basic-cookie.sample` and `token-cookie.sample`. Once authenticated the bot will store the token in the cookie file, `.tn-cookie` by default. If you have a cookie file with the desired credentials, you can run the bot with no parameters:\n```\npython chatbot.py\n```\n\nIf the server is configured to use TLS, i.e. running as `httpS://my-server.example.com/`, the gRPC endpoint also uses the same SSL certificate. In that case add the `--ssl` option when starting the chatbot. If you want the chatbot to connect to the secure server over a local network or under a different name rather than the `my-server.example.com`, for instance as `localhost`, you must specify the SSL domain name to use, otherwise the server will not be able to find the right SSL certificate:\n```\npython chatbot.py --host=localhost:16060 --ssl --ssl-host=my-server.example.com\n```\n\nQuotes are read from `quotes.txt` by default. The file is plain text with one quote per line.\n\n\n### Using Docker\n\n**Warning!** Although the chatbot itself is less than 11KB, the chatbot Docker image is 175MB: the `:slim` Python 3 image is about 140MB, gRPC adds another ~30MB.\n\n1. Follow [instructions](../../docker/README.md) to build and run dockerized Tinode chat server up to and including _step 3_.\n\n2. In _step 4_ run the server adding `--env PLUGIN_PYTHON_CHAT_BOT_ENABLED=true` and `--volume botdata:/botdata` to the command line:\n\t1. **RethinkDB**:\n\t```\n\t$ docker run -p 6060:18080 -d --name tinode-srv --env PLUGIN_PYTHON_CHAT_BOT_ENABLED=true --volume botdata:/botdata --network tinode-net tinode/tinode-rethink:latest\n\t```\n\t2. **MySQL**:\n\t```\n\t$ 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\n\t```\n\t3. **MongoDB**:\n\t```\n\t$ 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\n\t```\n\n3. Run the chatbot\n\t```\n\t$ docker run -d --name tino-chatbot --network tinode-net --volume botdata:/botdata tinode/chatbot:latest\n\t```\n\n4. Test that the bot is functional by pointing your browser to http://localhost:6060/, login and talk to user `Tino`. The user should respond to every message with a random quote.\n\n\nYou may replace the `:latest` with a different tag. See all available tags here:\n * [Tinode-MySQL tags](https://hub.docker.com/r/tinode/tinode-mysql/tags/)\n * [Tinode-RethinkDB tags](https://hub.docker.com/r/tinode/tinode-rethink/tags/)\n * [Tinode-MongoDB tags](https://hub.docker.com/r/tinode/tinode-mongodb/tags/)\n * [Chatbot tags](https://hub.docker.com/r/tinode/chatbot/tags/)\n\nIn general try to use docker images all with the same tag.\n"
  },
  {
    "path": "chatbot/python/basic-cookie.sample",
    "content": "{\"schema\": \"basic\", \"secret\": \"alice:alice123\"}\n"
  },
  {
    "path": "chatbot/python/chatbot.py",
    "content": "\"\"\"Python implementation of a Tinode chatbot.\"\"\"\n\n# For compatibility between python 2 and 3\nfrom __future__ import print_function\n\nimport argparse\nimport base64\nfrom concurrent import futures\nfrom datetime import datetime\nimport json\nimport os\ntry:\n    from importlib.metadata import version\nexcept ImportError:\n    # Fallback for Python < 3.8\n    from importlib_metadata import version\nimport platform\ntry:\n    import Queue as queue\nexcept ImportError:\n    import queue\nimport random\nimport signal\nimport sys\nimport time\n\nimport grpc\nfrom google.protobuf.json_format import MessageToDict\n\n# Import generated grpc modules\nfrom tinode_grpc import pb\nfrom tinode_grpc import pbx\n\n# For compatibility with python2\nif sys.version_info[0] >= 3:\n    unicode = str\n\nAPP_NAME = \"Tino-chatbot\"\nAPP_VERSION = \"1.2.3\"\nLIB_VERSION = version(\"tinode_grpc\")\n\n# Maximum length of string to log. Shorten longer strings.\nMAX_LOG_LEN = 64\n\n# User ID of the current user\nbotUID = None\n\n# Dictionary wich contains lambdas to be executed when server response is received\nonCompletion = {}\n\n# This is needed for gRPC ssl to work correctly.\nos.environ[\"GRPC_SSL_CIPHER_SUITES\"] = \"HIGH+ECDSA\"\n\ndef log(*args):\n    print(datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3], *args)\n\n# Add bundle for future execution\ndef add_future(tid, bundle):\n    onCompletion[tid] = bundle\n\n# Shorten long strings for logging.\ndef clip_long_string(obj):\n    if isinstance(obj, unicode) or isinstance(obj, str):\n        if len(obj) > MAX_LOG_LEN:\n            return '<' + str(len(obj)) + ' bytes: ' + obj[:12] + '...' + obj[-12:] + '>'\n        return obj\n    elif isinstance(obj, (list, tuple)):\n        return [clip_long_string(item) for item in obj]\n    elif isinstance(obj, dict):\n        return dict((key, clip_long_string(val)) for key, val in obj.items())\n    else:\n        return obj\n\ndef to_json(msg):\n    return json.dumps(clip_long_string(MessageToDict(msg)))\n\n# Resolve or reject the future\ndef exec_future(tid, code, text, params):\n    bundle = onCompletion.get(tid)\n    if bundle != None:\n        del onCompletion[tid]\n        try:\n            if code >= 200 and code < 400:\n                arg = bundle.get('arg')\n                bundle.get('onsuccess')(arg, params)\n            else:\n                log(\"Error: {} {} ({})\".format(code, text, tid))\n                onerror = bundle.get('onerror')\n                if onerror:\n                    onerror(bundle.get('arg'), {'code': code, 'text': text})\n        except Exception as err:\n            log(\"Error handling server response\", err)\n\n# List of active subscriptions\nsubscriptions = {}\ndef add_subscription(topic):\n    subscriptions[topic] = True\n\ndef del_subscription(topic):\n    subscriptions.pop(topic, None)\n\ndef subscription_failed(topic, errcode):\n    if topic == 'me':\n        # Failed 'me' subscription means the bot is disfunctional.\n        if errcode.get('code') == 502:\n            # Cluster unreachable. Break the loop and retry in a few seconds.\n            client_post(None)\n        else:\n            exit(1)\n\ndef login_error(unused, errcode):\n    # Check for 409 \"already authenticated\".\n    if errcode.get('code') != 409:\n        exit(1)\n\ndef server_version(params):\n    if params == None:\n        return\n    log(\"Server:\", params['build'].decode('ascii'), params['ver'].decode('ascii'))\n\ndef next_id():\n    next_id.tid += 1\n    return str(next_id.tid)\nnext_id.tid = 100\n\n# Quotes from the fortune cookie file\nquotes = []\n\ndef next_quote():\n    idx = random.randrange(0, len(quotes))\n    # Make sure quotes are not repeated\n    while idx == next_quote.idx:\n        idx = random.randrange(0, len(quotes))\n    next_quote.idx = idx\n    return quotes[idx]\nnext_quote.idx = 0\n\n# This is the class for the server-side gRPC endpoints\nclass Plugin(pbx.PluginServicer):\n    def Account(self, acc_event, context):\n        action = None\n        if acc_event.action == pb.CREATE:\n            action = \"created\"\n            # TODO: subscribe to the new user.\n\n        elif acc_event.action == pb.UPDATE:\n            action = \"updated\"\n        elif acc_event.action == pb.DELETE:\n            action = \"deleted\"\n        else:\n            action = \"unknown\"\n\n        log(\"Account\", action, \":\", acc_event.user_id, acc_event.public)\n\n        return pb.Unused()\n\nqueue_out = queue.Queue()\n\ndef client_generate():\n    while True:\n        msg = queue_out.get()\n        if msg == None:\n            return\n        log(\"out:\", to_json(msg))\n        yield msg\n\ndef client_post(msg):\n    queue_out.put(msg)\n\ndef client_reset():\n    # Drain the queue\n    try:\n        while queue_out.get(False) != None:\n            pass\n    except queue.Empty:\n        pass\n\ndef hello():\n    tid = next_id()\n    add_future(tid, {\n        'onsuccess': lambda unused, params: server_version(params),\n    })\n    return pb.ClientMsg(hi=pb.ClientHi(id=tid, user_agent=APP_NAME + \"/\" + APP_VERSION + \" (\" +\n        platform.system() + \"/\" + platform.release() + \"); gRPC-python/\" + LIB_VERSION,\n        ver=LIB_VERSION, lang=\"EN\"))\n\ndef login(cookie_file_name, scheme, secret):\n    tid = next_id()\n    add_future(tid, {\n        'arg': cookie_file_name,\n        'onsuccess': lambda fname, params: on_login(fname, params),\n        'onerror': lambda unused, errcode: login_error(unused, errcode),\n    })\n    return pb.ClientMsg(login=pb.ClientLogin(id=tid, scheme=scheme, secret=secret))\n\ndef subscribe(topic):\n    tid = next_id()\n    add_future(tid, {\n        'arg': topic,\n        'onsuccess': lambda topicName, unused: add_subscription(topicName),\n        'onerror': lambda topicName, errcode: subscription_failed(topicName, errcode),\n    })\n    return pb.ClientMsg(sub=pb.ClientSub(id=tid, topic=topic))\n\ndef leave(topic):\n    tid = next_id()\n    add_future(tid, {\n        'arg': topic,\n        'onsuccess': lambda topicName, unused: del_subscription(topicName)\n    })\n    return pb.ClientMsg(leave=pb.ClientLeave(id=tid, topic=topic))\n\ndef publish(topic, text):\n    tid = next_id()\n    return pb.ClientMsg(pub=pb.ClientPub(id=tid, topic=topic, no_echo=True,\n        head={\"auto\": json.dumps(True).encode('utf-8')}, content=json.dumps(text).encode('utf-8')))\n\ndef note_read(topic, seq):\n    return pb.ClientMsg(note=pb.ClientNote(topic=topic, what=pb.READ, seq_id=seq))\n\ndef init_server(listen):\n    # Launch plugin server: accept connection(s) from the Tinode server.\n    server = grpc.server(futures.ThreadPoolExecutor(max_workers=16))\n    pbx.add_PluginServicer_to_server(Plugin(), server)\n    server.add_insecure_port(listen)\n    server.start()\n\n    log(\"Plugin server running at '\"+listen+\"'\")\n\n    return server\n\ndef init_client(addr, schema, secret, cookie_file_name, secure, ssl_host):\n    log(\"Connecting to\", \"secure\" if secure else \"\", \"server at\", addr,\n        \"SNI=\"+ssl_host if ssl_host else \"\")\n\n    channel = None\n    if secure:\n        opts = (('grpc.ssl_target_name_override', ssl_host),) if ssl_host else None\n        channel = grpc.secure_channel(addr, grpc.ssl_channel_credentials(), opts)\n    else:\n        channel = grpc.insecure_channel(addr)\n\n    # Call the server\n    stream = pbx.NodeStub(channel).MessageLoop(client_generate())\n\n    # Session initialization sequence: {hi}, {login}, {sub topic='me'}\n    client_post(hello())\n    client_post(login(cookie_file_name, schema, secret))\n\n    return stream\n\ndef client_message_loop(stream):\n    try:\n        # Read server responses\n        for msg in stream:\n            log(datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3], \"in:\", to_json(msg))\n\n            if msg.HasField(\"ctrl\"):\n                # Run code on command completion\n                exec_future(msg.ctrl.id, msg.ctrl.code, msg.ctrl.text, msg.ctrl.params)\n\n            elif msg.HasField(\"data\"):\n                # log(\"message from:\", msg.data.from_user_id)\n\n                # Protection against the bot talking to self from another session.\n                if msg.data.from_user_id != botUID:\n                    # Respond to message.\n                    # Mark received message as read\n                    client_post(note_read(msg.data.topic, msg.data.seq_id))\n                    # Insert a small delay to prevent accidental DoS self-attack.\n                    time.sleep(0.1)\n                    # Respond with a witty quote\n                    client_post(publish(msg.data.topic, next_quote()))\n\n            elif msg.HasField(\"pres\"):\n                # log(\"presence:\", msg.pres.topic, msg.pres.what)\n                # Wait for peers to appear online and subscribe to their topics\n                if msg.pres.topic == 'me':\n                    if (msg.pres.what == pb.ServerPres.ON or msg.pres.what == pb.ServerPres.MSG) \\\n                            and subscriptions.get(msg.pres.src) == None:\n                        client_post(subscribe(msg.pres.src))\n                    elif msg.pres.what == pb.ServerPres.OFF and subscriptions.get(msg.pres.src) != None:\n                        client_post(leave(msg.pres.src))\n\n            else:\n                # Ignore everything else\n                pass\n\n    except grpc._channel._Rendezvous as err:\n        log(\"Disconnected:\", err)\n\ndef read_auth_cookie(cookie_file_name):\n    \"\"\"Read authentication token from a file\"\"\"\n    cookie = open(cookie_file_name, 'r')\n    params = json.load(cookie)\n    cookie.close()\n    schema = params.get(\"schema\")\n    secret = None\n    if schema == None:\n        return None, None\n    if schema == 'token':\n        secret = base64.b64decode(params.get('secret').encode('utf-8'))\n    else:\n        secret = params.get('secret').encode('utf-8')\n    return schema, secret\n\ndef on_login(cookie_file_name, params):\n    global botUID\n    client_post(subscribe('me'))\n\n    \"\"\"Save authentication token to file\"\"\"\n    if params == None or cookie_file_name == None:\n        return\n\n    if 'user' in params:\n        botUID = params['user'].decode(\"ascii\")[1:-1]\n\n    # Protobuf map 'params' is not a python object or dictionary. Convert it.\n    nice = {'schema': 'token'}\n    for key_in in params:\n        if key_in == 'token':\n            key_out = 'secret'\n        else:\n            key_out = key_in\n        nice[key_out] = json.loads(params[key_in].decode('utf-8'))\n\n    try:\n        cookie = open(cookie_file_name, 'w')\n        json.dump(nice, cookie)\n        cookie.close()\n    except Exception as err:\n        log(\"Failed to save authentication cookie\", err)\n\ndef load_quotes(file_name):\n    with open(file_name) as f:\n        for line in f:\n            quotes.append(line.strip())\n\n    return len(quotes)\n\ndef run(args):\n    schema = None\n    secret = None\n\n    if args.login_token:\n        \"\"\"Use token to login\"\"\"\n        schema = 'token'\n        secret = args.login_token.encode('ascii')\n        log(\"Logging in with token\", args.login_token)\n\n    elif args.login_basic:\n        \"\"\"Use username:password\"\"\"\n        schema = 'basic'\n        secret = args.login_basic.encode('utf-8')\n        log(\"Logging in with login:password\", args.login_basic)\n\n    else:\n        \"\"\"Try reading the cookie file\"\"\"\n        try:\n            schema, secret = read_auth_cookie(args.login_cookie)\n            log(\"Logging in with cookie file\", args.login_cookie)\n        except Exception as err:\n            log(\"Failed to read authentication cookie\", err)\n\n    if schema:\n        # Load random quotes from file\n        log(\"Loaded {} quotes\".format(load_quotes(args.quotes)))\n\n        # Start Plugin server\n        server = init_server(args.listen)\n\n        # Initialize and launch client\n        client = init_client(args.host, schema, secret, args.login_cookie, args.ssl, args.ssl_host)\n\n        # Setup closure for graceful termination\n        def exit_gracefully(signo, stack_frame):\n            log(\"Terminated with signal\", signo)\n            server.stop(0)\n            client.cancel()\n            sys.exit(0)\n\n        # Add signal handlers\n        signal.signal(signal.SIGINT, exit_gracefully)\n        signal.signal(signal.SIGTERM, exit_gracefully)\n\n        # Run blocking message loop in a cycle to handle\n        # server being down.\n        while True:\n            client_message_loop(client)\n            time.sleep(3)\n            client_reset()\n            client = init_client(args.host, schema, secret, args.login_cookie, args.ssl, args.ssl_host)\n\n        # Close connections gracefully before exiting\n        server.stop(None)\n        client.cancel()\n\n    else:\n        log(\"Error: authentication scheme not defined\")\n\n\nif __name__ == '__main__':\n    \"\"\"Parse command-line arguments. Extract server host name, listen address, authentication scheme\"\"\"\n    random.seed()\n\n    purpose = \"Tino, Tinode's chatbot.\"\n    log(purpose)\n    parser = argparse.ArgumentParser(description=purpose)\n    parser.add_argument('--host', default='localhost:16060', help='address of Tinode server gRPC endpoint')\n    parser.add_argument('--ssl', action='store_true', help='use SSL to connect to the server')\n    parser.add_argument('--ssl-host', help='SSL host name to use instead of default (useful for connecting to localhost)')\n    parser.add_argument('--listen', default='0.0.0.0:40051', help='address to listen on for incoming Plugin API calls')\n    parser.add_argument('--login-basic', help='login using basic authentication username:password')\n    parser.add_argument('--login-token', help='login using token authentication')\n    parser.add_argument('--login-cookie', default='.tn-cookie', help='read credentials from the provided cookie file')\n    parser.add_argument('--quotes', default='quotes.txt', help='file with messages for the chatbot to use, one message per line')\n    args = parser.parse_args()\n\n    run(args)\n"
  },
  {
    "path": "chatbot/python/quotes.txt",
    "content": "\u0007login: \u001b\n$3,000,000\n1 bulls, 3 cows\nA Cray is the best machine for simulating the performance of a Cray.\nA consistent indentation style is the hobgoblin of little minds.\nA gentleman is one who is never rude unintentionally. -Noel Coward\nA man must destroy himself before others can destroy him. -Mong Tse\nA philosopher does not need a torch to gather glow-worms by at mid-day. --Earnest Bramah\nA song in time is worth a dime.\nA woman is only a woman, but a good cigar is a smoke. -Rudyard Kipling\nAdmiration is our polite recognition of another's resemblance to ourselves.\nAll the good ones are taken.\nAn atheist is a man with no invisible means of support.\nAny country with \"democratic\" in the title isn't.\nAre we not men?\nAttend winter sheep meetings. Learning never ends!\nBe the sea, and see me be.\nBeware: the light at the end of the tunnel may be New Jersey.\nBubble bubble, toil and trouble; cast that float into a double.\nC'est dommage, mais c'est vrai.\nCaution: Do not view laser light with remaining eye.\nCogito cogito ergo cogito sum.\nCrazee Edeee, his prices are INSANE!!!\nDeath to all fanatics!\nDisk crisis, please clean up!\nDo not meddle in the mouth.\nDon't be overly suspicious where it's not warranted.\nDon't let your thoughts get in a rut. The knife which spreads may also cut.\nDon't worry if it doesn't work right; if everything did, you'd be out of a job.\nE Pluribus Unix.\nEither we are alone or we are not. Either way is mind-boggling.\nEven these days, it's not as easy to go crazy as you think.\nEverybody should believe in something -- I believe I'll have another drink.\nExercise is the Yuppie version of bulemia.\nFar too noisy, my dear Mozart. Far too many notes. -Emperor Ferdinand.\nFirst things first. Why not send for the Nazis right now.\nFortran est; non potest legi.\nGeneralizations are useful. The work contained in them can be reckoned as labor\nGod does not play dice.\nGood day to avoid cops. Crawl to work.\nGreat shot, kid. That was one in a million.\nHave you done your Christmas chopping yet? -anon. White House Advisor 12/24/81\nHe was al coltissh, ful of ragerye,/And ful of jargon as a flekke pye. -Chaucer\nHe who listens last is the last one listening.\nHistory is a race between education and catastrophe. -H. G. Wells\nHow do I love thee? Hand me my calculator...\nI am a high-pressure guy, and I didn't take this job to conduct a going-out-of-business sale. - A.A. Penzias\nI don't even know what street Canada is on. - Al Capone\nI have the most perfect confidence in your indiscretion.\nI must have slipped a disk; my pack hurts.\nI think that I shall never see a billboard as lovely as a tree. -Ogden Nash\nI'd rather have my mail delivered by Lockheed than ride in a plane built by the Post Office.\nIOT trap -- core dumped\nIf I had to choose between System V and 4.2, I'd resign. - Peter Honeyman\nIf butterflies had teeth like tigers they would never make it out of the hangar.\nIf it's not broken, don't fix it.\nIf the shoe fits, buy the other one, too.\nIf you don't care where you are, then you ain't lost.\nIf you take the last cup, make a new pot.\nIf your experiment needs statistics, you ought to have done a better experiment. - E. Rutherford\nIn challenging a kzin, a simple scream of rage is sufficient.\nIn this world, truth can wait; she's used to it.\nIt is better to have loved and lost than just to have lost.\nIt is useless to put on your brakes when you're upside down. -Paul Newman\nIt's a small world, but I'd hate to have to paint it.\nIt's hard to love someone who looks down on you because your hands get bloody protecting him.\nIt's not the time between the takes that takes the time - it's changing your mind between the takes that takes the time. -Stage Dept.\nJoin me and I will complete your training.\nLando's not a system, he's a man. He's a gambler, scoundrel. You'd like him.\nLet sleeping wraiths lie.\nLike winter snow on summer lawn, time past is time gone.\nLose a few, lose a few.\nMacro context switch under way, please do not log out!\nMan is in doubt to deem himself a god or beast. -Alexander Pope\nMay you live all the days of your life.\nMind your own business, Spock. I'm sick of your halfbreed interference.\nMultilevel standards are like onions. They're smelly and make you cry a lot. -Ron Natalie\nNever attribute to malice what can be found in scientific american, under computer recreations.\nNew career ideas are worth pursuing.\nNo man's life, liberty or property are safe while the legislature is in session.\nNon serviam.\nNothing of interest ever happened on this day.\nOh, so there you are!\nOne scythe fits all.\nOtto's too so-so to toss soot, too sot to toot SOSs. So?\nPeople get it into their heads that this is a democracy. Well it isn't. -gwl\nPersonality is a flimsy thing on which to build an art. -John Cage\nPopulus vult decipi.\nPromptness is its own reward, if one lives by the clock instead of the sword.\nReal programmers can't say `lint' without adding `hbaxcu' -Wm Leler\nRemember the Unknown Buffalo.\nRog-O-Matic callidus est.\nSay \"no\" to long-sleeved shirts!! Support your right to bare arms!\nSeñor, if you hurry from here, you will wait longer there. -Mexico taxi driver\nSmoked carp is terrible unless you're out of smoked salmon.\nSome men are discovered; others are found out.\nSpecialization is for insects. -Robt. A. Heinlein\nStop searching. Happiness is right next to you.\nSystem going down for 5 minutes -- back up in barrels.\nTechnological unemployment is total today -- 300 years ago we were all farmers.\nThat's the nice thing about standards -- there's so many to choose from. -trb\nThe Luddites always lose. Always.\nThe average legislator is somewhere nearly all the time. -Herb Nore\nThe dawning of the Information Age is bringing about dramatic changes in the fundamental fabric of our civilization. - AA Penzias\nThe first piece of luggage out of the chute doesn't belong to anyone, ever.\nThe hippo has no sting, but the wise man would rather be sat upon by the bee.\nThe meek shall inherit the earth -- they are pronounced \"o\".\nThe one interesting fact about the Diplodocus is that the accent is on the second syllable.\nThe plural of spouse is spice.\nThe skeletons in the cupboard will all come out in the wash.\nThe universe is laughing behind your back.\nThem that dishes it out need not fall over every time someone blows hard.\nThere is no fear in love; but perfect love casteth out fear.\nThere'll always be an England - if not it would be necessary to invent one.\nThese widows, sir, are the most perverse creatures in the world. - Joseph Addison.\nThings will be bright in P.M. A cop will shine a light in your face.\nThis space available. Call 686-7600 for details.\nThose who in quarrels interpose must often wipe a bloody nose.\nTo be is to be related.\nTo play billiards well is a sign of an ill-spent youth.\nToto, I've a feeling we're not in any immediate danger of having just committed suicide!\nTry a new system or a different approach.\nUneasy lies the head that wears a crown.\nVeni, vidi, maeni, mo, cacha tigrem baedas to, iffi hollers, ledem go, veni, vidi, maeni, mo.\nWarning: this fortune may change your life.\nWe don't know half of what we know.\nWe retard what we cannot repel, we palliate what we cannot cure. -Johnson\nWe're too close to System Test.\nWhat garlic is to salad, insanity is to art.\nWhen all else fails, read the instructions.\nWhen in trouble or in doubt, run in circles; scream and shout.\nWhenever I see his fingernails, I thank God I don't have to look at his feet.\nWhom the gods must destroy they first must drive insane.\nWithout alkaloids, life itself would be impossible.\nYes, the red switch.\nYou can tell a man by the company that keeps him.\nYou cannot buy beer; you can only rent it.\nYou have bills.\nYou look strong enough to pull the ears off a Gundark!\nYou should go home.\nYou will never find a more wretched hive of scum and villainy.\nYour computer account is overdrawn. Please reauthorize.\nZero is greater than minus zero, but don't ask by how much. -6600 ref. manual\n`is false when preceded by its quotation' is false when preceded by its quotation.\nfortune: not found\npic: 5 X 58008 picture shrunk to 0.000603365 X 7\nuuxqt cmd (rnews ) status (ucsfcgl!uucp 256)\nIn days of yore, the crab and the crayfish lived in the forest.\n* Method As described above, see details below.\nWhatever happens, happens because it must.\nBoost, don't knock\npass 2 error:(file ) more than 100 args?\nNepal premier won't resign.\ninit: /dev/console: getty failing, sleeping\nSentence without verb.\nThat's the way I got promoted, by eating everything. -pjw\nPittsburgh has become a kind of knowledge aircraft carrier, its \"top-guns\" scattered regularly around the planet.\nWhen the music stops, the house of cards collapses and the emperor is found to be wearing no clothes.\nNever put snow on a frostbitten part.\nPut a smoke detector in your vacation cottage.\nDraw up a family fire-escape plan.\nA mathematician is a machine for turning coffee into theorems. -Paul Erdös\nI'm TRYING to be a back end! - A Hume\nThere are only 26 calls and most of them are trivial.\n162 is unimplemented\nIncest more common than thought in United States\nThe product classroom is marked pass-fail.\nISDN is real and implementable.\nTo dissimulate is to feign not to have what one has. - J Baudrillard\nVacuums are nothings. We only mention them to let them know we know they're there.\nTwo wrongs don't make a right; three lefts do.\n?12 Machine check during machine check.\nThe downside of having an architecture is wart-for-wart compatibility. - Bob Willard, DEC\nIt doesn't matter if you don't know how your program works, so long as it's parallel -R. O'Keefe\nsendmail[94] AA00493: SYSERR: net hang reading from coma: Connection timed out during greeting wait with coma\nPerformance doesn't matter if your product is sufficiently feature-rich. --SF system engineer\nPLEASE LOG IN TO 3B20'S AT 4800 BAUD.\nSQUASH, do not crush (seen on a vegetable crate)\nIf you get to meet sufficently important people, it's ok to debase yourself. -pjw\nThe system is ready.\nA watermelon will not ripen in your armpit.\nContrary to English and other similar languages, Turkish can be hyphenated with a simple 4 state finite-state machine.\nnop...session...attach...clone...walk...open...\nSpare me your sorrow's tears.\nHe is one inch good, one foot evil.\nHeaven cannot use two suns or a house two masters.\nTo give ground is sometimes the best victory.\nNo word can cut kindness.\nLet wisdom and virtue be the two wheels of your cart.\nWillow branches never snap under the weight of snow.\nOnly a monkey tries to catch the full moon in the pond.\nDon't lug dirt to a hilltop.\nDon't paint on water or carve on ice.\nThe nail that raises its head is hammered down.\nWho can tell the he-crow from his mate?\nHe is wise who knows what is enough.\nHis hand was bitten by his own dog.\nTo kill a general first shoot his horse.\nWhat is left unsaid is rich as flowers.\nIf you are in a hurry go round-about.\nBetter be ignorant than mistaught.\nYou can't judge widows or horses without handling them.\nDon't use the ox-cleaver to kill a hen.\nTwo hearts: and only one body.\nBread is better than blossoms.\nGood medicine has often a bitter smack.\nFirst among blossoms the cherry: among men the warrior.\nYou can't wrap up the wind or tie down the shadow.\nYou cannot live in the same world with your father's murderer.\nEven a starving hawk won't lower himself to eat corn.\nKeep your mouth shut, your eyes open.\nSome ride in palanquins, some bear palanquins: some weave sandals for palanquin-bearers.\nTuning filesystem for rot 0...\nTwo things will make you lose your earrings, and one of them's dancing. -Bonnie Raitt.\nWhen the humans are away, the monkeys enter the hut, eat up the maize, and rearrange the furniture.\nThe problem is not getting ksh to execute any particular command, the problem is recognizing that there might be a problem.\ndiff: usage diff [whatever] etc.\nIntense opportunities for reorganization\nStringent ideas in the upper echelon mind\nLeveraged brainpower at the labs\n>>>>>>> REMOVE ALL YOUR FILES AND DIRECTORIES NOW! <<<<<<<\nUX:lp: ERROR: Can't establish contact with the LP print service.\nAwk is one of the world's greatest collections of surprises. -Doug McIlroy\nIf you think awk is the perfect programming language for the problem, you don't understand the problem yet. -Rob Pike\n``Workers of the World, forgive us!'' (a banner in a Moscow counter-rally, Oct 8, 1989)\nThe first step is to determine what the remaining steps are. -Mark Horton\nIf you do something stupid on UNIX you generally get strange behavior. -Doug Gwyn\nI'd still like you to explain that worm to me - Judge Munson to Robert T. Morris\nLike raisins in a bread pudding, the moments lie within the body of Henry.\nA real gentleman never takes bases unless he really has to.\nThere are two proteins involved in DNA synthesis, they are called DNAsynthase 1 and DNAsynthase 3.\nDrawing on my fine command of the English language, I said nothing. -Robert Benchley\nHay, be seedy! He-effigy, hate-shy jaky yellow man, oh peek, you are rusty, you've edible, you ex-wise he!\nThe FSF is not overly concerned about security. - FSF\nMake: Don't know how your program works\nOSIfy, v.: To make code impenetrable.\nRule 3: If the character is comprised of a container without another radical, then\nAn economic reality of our time: computerized job deskilling. - a book review in Science\nEstne ebriamen de furfure avenaceo factum?\nThe isomer with the higher dipole moment has the higher physical constants, regardless of the heat content. Van Arkel Rule\nOther factors being equal, the metal which is most susceptible to failure is that with the lowest boiling point. Mogro-Campero Rule\nThe solid particle erosion rate of annealed face-centered cubic metals is inversely related to their hardness. Finnie-Wolak-Kabil Rule\nIn dichroic crystals, the faster ray is less absorbed. Babinet Rule\nIf you can't stand the heat, get a pool.\nA rolling stone is a singing rock group.\nUX:mail: INFO: No mail.\nReal software has its own 800 support line. - Stu Feldman\nMarine math: 2 beers times 39 Marines is 49 cases.\nWhen times are bad, people feel compelled to overeat.\nOf the physical pages in use, 3436738592 pages are permanently allocated to VMS.\nIf you carry on, head-to-head, on `solution' you don't get anywhere\nThis is like ignoring both the speed limit and the odometer in your car. It won't get you far. -Kenneth P. Birman\n/bin/ls: exec header invalid\nMy pile of equipment is bigger than your pile of equipment -- philw\nConnected to 192.65.218.43.\nas1: Error: ../bpvvv.c, line 1324: Too many float literals--compile with \"-Wb,-nopool\"\nThe helpful thought for which you look/Is written somewhere in a book.\nIs the tool broadly supported or maintained?\nI'm pulling *something* here. - Dom Marotta\nI'm drawing a line under the sand. - John Major\nThey bit the wrong chicken's head off with their own teeth and got blood all over their shirt - nls\nI just want a bare-boned, straight EMACS. - Rae McLellan\n*** Message content is not printable: delete, write or save it to a file ***\n1181258 SAVECORE SEGMENTATION FAULT WHILE TRYING TO SAVE CORE\nEverything that can ever be invented has been invented -Charles H. Duell,\nDan Quayle addressing the United Negro College Fund\nHere in Nuremberg, information hiding is much more popular in the organization than it is in the software...\nLicense Error : The license for this product(SPARCompiler C) has expired\nMy favorite thing about the Internet is that you get to go into the private world of real creeps without having to smell them. - Penn Jillette\nMusic is the pleasure the human soul experiences from counting without being aware that it is counting. -Leibnitz\nMillenium parties/with loud music, lights and joy;/then, how cold!\nwarning: Hit heuristic-fence-post without finding enclosing function for address 0xfa2e470\nWar in the Clinton era is just P.R. by other means. - Michael Hirschorn\nApplication Developer's Architecture Guide\n! TeX capacity exceeded, sorry [main memory size=263001].\nHis hand was bitten by his own side in an offhand way. - Mark V. Shaney\nSperm bank sued for tossing samples - Headline in Star-Ledger\nboot: nop...cfs...session...no physical memory\nComputers come in putty-colored boxes and have AUTOEXEC.BAT files and run screen-savers with flying toasters, and brains do not. - Steven Pinker\nWe have discovered a pervasive nonstationarity.\nverb = a > b ? 'a': c > d ? 'd': 'c';\nIt's not only stupid it's wrong!\nLucky Numbers 12, 14, 19, 24, 36, 43\nThe goal of the experimental trials with the artificial heart is to \"double the life span of these patients\" to 60 days, Lederman said.\ncanlock: corrupted 0xcafebabe\nDevil Duckie, when you float, it's like I'm bathing in a flaming moat!\nHe lost both Ali-pilaf and Vali-pilaf.\nHe is a man who gives bread.\nWe cut salt and bread together.\nYou can't make stew with cheap meat.\nAbsquotilate it in style, you old skunk,..and show the gentlemen what you can do.\nCookie import from '/usr/dhog/lib/cookies' failed: fil\nFortunately, Mac OS X supports *that* feature! - Brendan Connell\nThe full documentation for cat is maintained as a Texinfo manual. If the info and cat programs are properly installed at your site, the command \"info cat\" should give you access to the complete manual.\nThat's nothing.. RMS sent me a .doc file the other day...\nNotegroups! They kill you!\nWelcome, rqzgc_mfuv8\nIsn't it funny how people say they'll never grow up to be their parents, then one day they look in the mirror and they're moving aircraft carriers into the Gulf region? - the Onion\nWhere would Christianity be if Jesus got eight to fifteen years, with time off for good behavior? -- New York Senator James H. Donovan on capital punishment.\nFew, few the bird make her nest.\nTo become of bishop miller.\nThe dress don't make the monk.\nHe is mad to bind.\nHe turns as a weath turcocl.\nAfter the paunch comes the dance.\nLinux programmer's mind: don't invalidate the D-cache unless it wasn't enabled\nPractically noiseless and impossible to explode. - ad for the 1897 Oldsmobile\nThe essence of XML is this: the problem it solves is not hard, and it does not solve the problem well. - Phil Wadler, POPL 2003\nIf you are idle for more than 1000 hours, the system will log you out. Please save reviews frequently.\nSetting up your SIP account will allow you to call both other SIP users as well as pstn phones. - Wim Sweldens\nWhat's grey? A melted penguin.\nLinux: the world's best text adventure game.\ni know what jmk did. he added reentrancy for threads. - boyd, about uintptr\nI recently visited plan9.bell-labs.com/wiki/plan9 and I would like to offer my web design services. I can help you with your Plan 9 website. - spam\ngcc is the holy cow of compilers, not the holy grail. - forsyth\nwe live in a world of dogmas, probably trying to fight it with a stronger dogma is a bad idea, but trying to fight it with preumpted objectivism clearly doesn't work. - uriel\nLucent is the best place 4 me to work. (Please keep confidential)\nSubject: There's More to Nevada Than You Think\n/* keep the code below somewhat more readonable; not used elsewhere */\nswapon: /dev/disk/by-uuid/928aaabd-5744-4f0a-b9ef-aa101f286514: Invalid argument - Linux\nI guess the idea is that ... even though the structures are all different.\nrusty: kernel pseudo files are not the place for chit-chat\n"
  },
  {
    "path": "chatbot/python/requirements.txt",
    "content": "futures>=3.2.0; python_version<'3'\ngrpcio>=1.40.0\ntinode-grpc>=0.20.0b3\nimportlib-metadata>=1.0; python_version<'3.8'\n"
  },
  {
    "path": "chatbot/python/setup.py",
    "content": "import setuptools\nfrom subprocess import Popen, PIPE\n\nwith open('README.md', 'r') as fh:\n    long_description = fh.read()\n\nsetuptools.setup(\n    name=\"tinode-chatbot\",\n    version=git_version(),\n    author=\"Tinode Authors\",\n    author_email=\"info@tinode.co\",\n    description=\"Tinode demo chatbot.\",\n    long_description=long_description,\n    long_description_content_type=\"text/markdown\",\n    url=\"https://github.com/tinode/chat\",\n    packages=setuptools.find_packages(),\n    install_requires=['grpcio>=1.40.0'],\n    classifiers=(\n        \"Programming Language :: Python :: 3\",\n        \"License :: OSI Approved :: Apache 2.0\",\n        \"Operating System :: OS Independent\",\n    ),\n)\n\ndef git_version():\n    try:\n        p = Popen(['git', 'describe', '--tags'],\n                  stdout=PIPE, stderr=PIPE)\n        p.stderr.close()\n        line = p.stdout.readlines()[0]\n        return line.strip()\n\n    except:\n        return None\n"
  },
  {
    "path": "chatbot/python/token-cookie.sample",
    "content": "{\"schema\": \"token\", \"secret\": \"mtXWlt9ERZCKsw9aFAABAFGGCnxinE8ruLE21t6SQfck4uBKCIy44kerjmOh4h1+\", \"expires\": \"2017-11-18T04:14:02Z\"}\n"
  },
  {
    "path": "docker/README.md",
    "content": "# Using Docker to run Tinode\n\nAll images are available at https://hub.docker.com/r/tinode/\n\n1. [Install Docker](https://docs.docker.com/install/) 1.8 or above. The provided dockerfiles are dependent on [Docker networking](https://docs.docker.com/network/) which may not work with the older Docker.\n\n2. Create a bridge network. It's used to connect Tinode container with the database container.\n\t```\n\t$ docker network create tinode-net\n\t```\n\n3. Decide which database backend you want to use: MySQL, PostgreSQL, MongoDB or RethinkDB. Run the selected database container, attaching it to `tinode-net` network:\n\n\t1. **MySQL**: If you've decided to use MySQL backend, run the official MySQL Docker container:\n\t```\n\t$ docker run --name mysql --network tinode-net --restart always --env MYSQL_ALLOW_EMPTY_PASSWORD=yes -d mysql:5.7\n\t```\n\tSee [instructions](https://hub.docker.com/_/mysql/) for more options. MySQL 5.7 or above is required.\n\n\t2. **PostgreSQL**: If you've decided to use PostgreSQL backend, run the official PostgreSQL Docker container:\n\t```\n\t$ docker run --name postgres --network tinode-net --restart always --env POSTGRES_PASSWORD=postgres -d postgres:13\n\t```\n\tSee [instructions](https://hub.docker.com/_/postgres/) for more options. PostgresSQL 13 or above is required.\n\n\tThe name `rethinkdb`, `mysql`, `mongodb` or `postgres` in the `--name` assignment is important. It's used by other containers as a database's host name.\n\n\t3. **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):\n   ```\n   $ docker run --name mongodb --network tinode-net --restart always -d mongo:latest --replSet \"rs0\"\n   $ docker exec -it mongodb mongosh\n\n   # And inside mongo shell:\n   > rs.initiate( {\"_id\": \"rs0\", \"members\": [ {\"_id\": 0, \"host\": \"mongodb:27017\"} ]} )\n   > quit()\n   ```\n\tSee [instructions](https://hub.docker.com/_/mongo/) for more options. MongoDB 4.2 or above is required.\n\n\t4. **RethinkDB**: If you've decided to use RethinkDB backend, run the official RethinkDB Docker container:\n\t```\n\t$ docker run --name rethinkdb --network tinode-net --restart always -d rethinkdb:2.3\n\t```\n\tSee [instructions](https://hub.docker.com/_/rethinkdb/) for more options.\n\n4. Run the Tinode container for the appropriate database:\n\n\t1. **MySQL**:\n\t```\n\t$ docker run -p 6060:6060 -d --name tinode-srv --network tinode-net tinode/tinode-mysql:latest\n\t```\n\n\t2. **PostgreSQL**:\n\t```\n\t$ docker run -p 6060:6060 -d --name tinode-srv --network tinode-net tinode/tinode-postgres:latest\n\t```\n\n\t3. **MongoDB**:\n\t```\n\t$ docker run -p 6060:6060 -d --name tinode-srv --network tinode-net tinode/tinode-mongodb:latest\n\t```\n\n\t4. **RethinkDB**:\n\t```\n\t$ docker run -p 6060:6060 -d --name tinode-srv --network tinode-net tinode/tinode-rethinkdb:latest\n\t```\n\n\tYou can also run Tinode with the `tinode/tinode` image (which has all of the above DB adapters compiled in). You will need to specify the database adapter via `STORE_USE_ADAPTER` environment variable. E.g. for `mysql`, the command line will look like\n\t```\n\t$ docker run -p 6060:6060 -d -e STORE_USE_ADAPTER mysql --name tinode-srv --network tinode-net tinode/tinode:latest\n\t```\n\n\tSee [below](#supported-environment-variables) for more options.\n\n\tThe port mapping `-p 5678:1234` tells Docker to map container's port 1234 to host's port 5678 making server accessible at http://localhost:5678/. The container will initialize the database with test data on the first run.\n\n\tYou may replace `:latest` with a different tag. See all all available tags here:\n\t * [MySQL tags](https://hub.docker.com/r/tinode/tinode-mysql/tags/)\n\t * [PostgreSQL tags](https://hub.docker.com/r/tinode/tinode-postgresql/tags/) (beta version)\n\t * [MongoDB tags](https://hub.docker.com/r/tinode/tinode-mongodb/tags/)\n\t * [RethinkDB tags](https://hub.docker.com/r/tinode/tinode-rethink/tags/)\n\t * [All bundle tags](https://hub.docker.com/r/tinode/tinode/tags/)\n\n5. Test the installation by pointing your browser to [http://localhost:6060/](http://localhost:6060/).\n\n## Optional\n\n### External config file\n\nThe container comes with a built-in config file which can be customized with values from the environment variables (see [Supported environment variables](#supported_environment_variables) below). If changes are extensive it may be more convenient to replace the built-in config file with a custom one. In that case map the config file located on your host (e.g. `/users/jdoe/new_tinode.conf`) to container (e.g. `/tinode.conf`) using [Docker volumes](https://docs.docker.com/storage/volumes/) `--volume /users/jdoe/new_tinode.conf:/tinode.conf` then instruct the container to use the new config `--env EXT_CONFIG=/tinode.conf`:\n```\n$ docker run -p 6060:6060 -d --name tinode-srv --network tinode-net \\\n\t\t--volume /users/jdoe/new_tinode.conf:/tinode.conf \\\n\t\t--env EXT_CONFIG=/tinode.conf \\\n\t\ttinode/tinode-mysql:latest\n```\nWhen `EXT_CONFIG` is set, most other environment variables are ignored. Consult [the table](#supported-environment-variables) below for a full list.\n\n\n### Resetting or upgrading the database\n\nThe database schema may change from time to time. An error `Invalid database version 101. Expected 103` means the schema has changed and needs to be updated, in this case from version 101 to version 103. You need to either reset or upgrade the database to continue:\n\nShut down the Tinode container and remove it:\n```\n$ docker stop tinode-srv && docker rm tinode-srv\n```\nthen repeat step 4 adding `--env RESET_DB=true` to reset or `--env UPGRADE_DB=true` to upgrade.\n\nAlso, the database is automatically created if missing.\n\n\n### Enable push notifications\n\nTinode uses Google Firebase Cloud Messaging (FCM) to send pushes.\nFollow [instructions](../docs/faq.md#q-how-to-setup-fcm-push-notifications) for obtaining the required FCM credentials.\n\n* Download and save the [FCM service account credentials](https://cloud.google.com/docs/authentication/production) file.\n* Obtain values for `apiKey`, `messagingSenderId`, `projectId`, `appId`, `messagingVapidKey`.\nAssuming your Firebase credentials file is named `myproject-1234-firebase-adminsdk-abc12-abcdef012345.json`\nand it's saved at `/Users/jdoe/`, web API key is `AIRaNdOmX4ULR-X6ranDomzZ2bHdRanDomq2tbQ`, Sender ID `141421356237`,\nProject ID `myproject-1234`, App ID `1:141421356237:web:abc7de1234fab56cd78abc`, VAPID key (a.k.a. \"Web Push certificates\")\nis `83_Or_So_Random_Looking_Characters`, start the container with the following parameters (using MySQL container as an example):\n\n```\n$ docker run -p 6060:6060 -d --name tinode-srv --network tinode-net \\\n\t\t-v /Users/jdoe:/config \\\n\t\t--env FCM_CRED_FILE=/config/myproject-1234-firebase-adminsdk-abc12-abcdef012345.json \\\n\t\t--env FCM_API_KEY=AIRaNdOmX4ULR-X6ranDomzZ2bHdRanDomq2tbQ \\\n\t\t--env FCM_APP_ID=1:141421356237:web:abc7de1234fab56cd78abc \\\n\t\t--env FCM_PROJECT_ID=myproject-1234 \\\n\t\t--env FCM_SENDER_ID=141421356237 \\\n\t\t--env FCM_VAPID_KEY=83_Or_So_Random_Looking_Characters \\\n\t\ttinode/tinode-mysql:latest\n```\n\n### Configure video calling\n\nTinode uses [WebRTC](https://webrtc.org/) for video and audio calls. WebRTC needs [Interactive Communication Establishment (ICE)](https://tools.ietf.org/id/draft-ietf-ice-rfc5245bis-13.html) [TURN(S)](https://en.wikipedia.org/wiki/Traversal_Using_Relays_around_NAT) and/or [STUN](https://en.wikipedia.org/wiki/STUN) servers to traverse [NAT](https://en.wikipedia.org/wiki/Network_address_translation), otherwise calls may not work. Tinode does not include TURN(S) or STUN out of the box. You need to obtain and configure your own service. Once you setup your TURN(S) and/or STUN service, save its configuration to a file, for example `/Users/jdoe/turn-config.json` and provide path to this file when starting the container:\n\n```\n$ docker run -p 6060:6060 -d --name tinode-srv --network tinode-net \\\n\t\t-v /Users/jdoe:/config \\\n\t\t--env ICE_SERVERS_FILE=/config/turn-config.json \\\n\t\t< ... other config parameters ... >\n\t\ttinode/tinode-mysql:latest\n```\n\nThe config file uses the following format:\n```json\n[\n  {\n    \"urls\": [\n      \"stun:stun.example.com\"\n    ]\n  },\n  {\n    \"username\": \"user-name-to-use-for-authentication-with-the-server\",\n    \"credential\": \"your-password\",\n    \"urls\": [\n      \"turn:turn.example.com:80?transport=udp\",\n      \"turn:turn.example.com:3478?transport=tcp\",\n      \"turns:turn.example.com:443?transport=tcp\",\n    ]\n  }\n]\n```\n\n[XIRSYS](https://xirsys.com/) offers a free tier for developers. We are in no way affiliated with XIRSYS. We do not endorse or otherwise take any responsibility for your use of their services.\n\n### Run the chatbot\n\nSee [instructions](../chatbot/python/).\n\nThe chatbot password is generated only when the database is initialized or reset. It's saved to `/botdata` directory in the container. If you want to keep the data available between container changes, such as image upgrades, make sure the `/botdata` is a mounted volume (i.e. you always launch the container with `--volume botdata:/botdata` option).\n\n## Supported environment variables\n\nYou can specify the following environment variables when issuing `docker run` command:\n\n| Variable | Type | Default | Purpose |\n| --- | --- | --- | --- |\n| `ACC_GC_ENABLED`[^2] | bool | `false` | Enable/diable automatic deletion of unfinished account registrations. |\n| `AUTH_TOKEN_KEY`[^2] | string | `wfaY2RgF2S1OQI/ZlK+LS​rp1KB2jwAdGAIHQ7JZn+Kc=` | base64-encoded 32 random bytes used as salt for authentication tokens. |\n| `AWS_ACCESS_KEY_ID`[^2] | string |  | AWS Access Key ID when using `s3` media handler. |\n| `AWS_CORS_ORIGINS`[^2] | string | `[\"*\"]` | Allowed origins ([CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin)) URL for downloads. Generally use your server URL and its aliases. |\n| `AWS_REGION`[^2] | string |  | AWS Region when using `s3` media handler |\n| `AWS_S3_BUCKET`[^2] | string |  | Name of the AWS S3 bucket when using `s3` media handler. |\n| `AWS_S3_ENDPOINT`[^2] | string |  | An endpoint URL (hostname only or fully qualified URI) to override the default endpoint; can be of any S3-compatible service, such as `minio-api.x.io` |\n| `AWS_SECRET_ACCESS_KEY`[^2] | string |  | AWS [Secret Access Key](https://aws.amazon.com/blogs/security/wheres-my-secret-access-key/) when using `s3` media handler. |\n| `CLUSTER_SELF` | string |  | Node name if the server is running in a Tinode cluster. |\n| `DEBUG_EMAIL_VERIFICATION_CODE`[^2] | string |  | Enable dummy email verification code, e.g. `123456`. Disabled by default (empty string). |\n| `DEFAULT_COUNTRY_CODE`[^2] | string | `US` | 2-letter country code to assign to sessions by default when the country isn't specified by the client explicitly and it's impossible to infer it. |\n| `EXT_CONFIG`[^1] | string |  | Path to external config file to use instead of the built-in one. If this parameter is used, most other variables are ignored[^1]. |\n| `EXT_STATIC_DIR` | string |  | Path to external directory containing static data (e.g. Tinode Webapp files). |\n| `FCM_CRED_FILE`[^2] | string |  | Path to JSON file with FCM server-side service account credentials which will be used to send push notifications. |\n| `FCM_API_KEY` | string |  | Firebase API key; required for receiving push notifications in the web client. |\n| `FCM_APP_ID` | string |  | Firebase web app ID; required for receiving push notifications in the web client. |\n| `FCM_PROJECT_ID` | string |  | Firebase project ID; required for receiving push notifications in the web client. |\n| `FCM_SENDER_ID` | string |  | Firebase FCM sender ID; required for receiving push notifications in the web client. |\n| `FCM_VAPID_KEY` | string |  | Also called 'Web Client certificate' in the FCM console; required by the web client to receive push notifications. |\n| `FCM_INCLUDE_ANDROID_NOTIFICATION`[^2] | 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). |\n| `FCM_MEASUREMENT_ID` | string |  | Google Analytics ID of the form `G-123ABCD789`. |\n| `FS_CORS_ORIGINS`[^2] | string | `[\"*\"]` | Cors origins when media is served from the file system. See `AWS_CORS_ORIGINS` for details. |\n| `ICE_SERVERS_FILE`[^2] | string |  | Path to JSON file with configuration of ICE servers to be used for video calls. |\n| `MEDIA_HANDLER`[^2] | string | `fs` | Handler of large files, either `fs` or `s3`. |\n| `MYSQL_DSN`[^2] | string | <code>'root@tcp(mysql)/tinode?&#8203;parseTime=true&#8203;&collation=utf8mb4_0900_ai_ci'</code> | MySQL [DSN](https://github.com/go-sql-driver/mysql#dsn-data-source-name). |\n| `PLUGIN_PYTHON_CHAT_BOT_ENABLED`[^2] | bool | `false` | Enable calling into the plugin provided by Python chatbot. |\n| `POSTGRES_DSN`[^2] | string | <code>'postgresql://postgres:postgres@&#8203;localhost:5432/tinode?&#8203;sslmode=disable&#8203;&connect_timeout=10'</code> |  PostgreSQL [DSN](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING). |\n| `RESET_DB` | bool | `false` | Drop and recreate the database. |\n| `SAMPLE_DATA` | string |  _see comment →_ | File with sample data to load. Default `data.json` when resetting or generating new DB, none when upgrading. Use `` (empty string) to disable. |\n| `SMTP_AUTH_MECHANISM`[^2] | string | `\"plain\"` | SMTP authentication mechanism to use; one of \"login\", \"cram-md5\", \"plain\". |\n| `SMTP_DOMAINS`[^2] | string |  | White list of email domains; when non-empty, accept registrations with emails from these domains only (email verification). |\n| `SMTP_HELO_HOST`[^2] | string | _see comment →_ | FQDN to use in SMTP HELO/EHLO command; if missing, the hostname from `SMTP_HOST_URL` is used. |\n| `SMTP_HOST_URL`[^2] | string | `'http://localhost:6060/'` | URL of the host where the webapp is running (email verification). |\n| `SMTP_LOGIN`[^2] | string |  | Optional login to use for authentication with the SMTP server (email verification). |\n| `SMTP_PASSWORD`[^2] | string |  | Optional password to use for authentication with the SMTP server (email verification). |\n| `SMTP_PORT`[^2] | number |  | Port number of the SMTP server to use for sending verification emails, e.g. `25` or `587`. |\n| `SMTP_SENDER` | string |  | [RFC 5322](https://tools.ietf.org/html/rfc5322) email address to use in the `FROM` field of verification emails, e.g. `'\"John Doe\" <jdoe@example.com>'`. |\n| `SMTP_SERVER`[^2] | string |  | Name of the SMTP server to use for sending verification emails, e.g. `smtp.gmail.com`. If SMTP_SERVER is not defined, email verification will be disabled. |\n| `STORE_USE_ADAPTER`[^2] | string |  | DB adapter name (specify with `tinode/tinode` container only). |\n| `TEL_HOST_URL`[^2] | string | `'http://localhost:6060/'` | URL of the host where the webapp is running for phone verification. |\n| `TEL_SENDER`[^2] | string |  | Sender name to pass to SMS sending service. |\n| `TLS_CONTACT_ADDRESS`[^2] | string |  | Optional email to use as contact for [LetsEncrypt](https://letsencrypt.org/) certificates, e.g. `jdoe@example.com`. |\n| `TLS_DOMAIN_NAME`[^2] | string |  | If non-empty, enables TLS (http**s**) and configures domain name of your container, e.g. `www.example.com`. In order for TLS to work you have to expose your HTTPS port to the Internet and correctly configure DNS. It WILL FAIL with `localhost` or unroutable IPs. |\n| `TNPG_AUTH_TOKEN` | string |  | Tinode Push Gateway authentication token. |\n| `TNPG_ORG`[^2] | string |  | Tinode Push Gateway organization name as registered at https://console.tinode.co |\n| `UID_ENCRYPTION_KEY`[^2] | string | `la6YsO+bNX/+XIkOqc5Svw==` | base64-encoded 16 random bytes used as an encryption key for user IDs. |\n| `UPGRADE_DB` | bool | `false` | Upgrade database schema, if necessary. |\n| `WAIT_FOR` | string |  | If non-empty, waits for the specified database `host:port` to be available before starting the server. |\n\n[^1]: If set, variables marked with the footnote `[2]` are ignored.\n[^2]: Ignored if `EXT_CONFIG` is set.\n\nA convenient way to generate a desired number of random bytes and base64-encode them on Linux and Mac:\n```\n$ openssl rand -base64 <desired length>\n```\n\n## Metrics Exporter\n\nSee [monitoring/exporter/README](../monitoring/exporter/README.md) for information on the Exporter.\nContainer is also available as a part of the Tinode docker distribution: `tinode/exporter`.\nRun it with\n\n```\n$ docker run -p 6222:6222 -d --name tinode-exporter --network tinode-net \\\n\t\t--env SERVE_FOR=<prometheus|influxdb> \\\n\t\t--env TINODE_ADDR=<tinode metrics endpoint> \\\n\t\t... <monitoring service specific vars> \\\n\t\ttinode/exporter:latest\n```\n\nAvailable variables:\n\n| Variable | Type | Default | Function |\n| --- | --- | --- | --- |\n| `SERVE_FOR` | string | `` | Monitoring service: `prometheus` or `influxdb` |\n| `TINODE_ADDR` | string | `http://localhost/stats/expvar/` | Tinode metrics path |\n| `INFLUXDB_VERSION` | string | `1.7` | InfluxDB version (`1.7` or `2.0`) |\n| `INFLUXDB_ORGANIZATION` | string | `org` | InfluxDB organization |\n| `INFLUXDB_PUSH_INTERVAL` | int | `60` | Exporter metrics push interval in seconds |\n| `INFLUXDB_PUSH_ADDRESS` | string | `https://mon.tinode.co/intake` | InfluxDB backend url |\n| `INFLUXDB_AUTH_TOKEN` | string | `` | InfluxDB auth token |\n| `PROM_NAMESPACE` | string | `tinode` | Prometheus namespace |\n| `PROM_METRICS_PATH` | string | `/metrics` | Exporter webserver path that Prometheus server scrapes |\n"
  },
  {
    "path": "docker/chatbot/Dockerfile",
    "content": "# Dockerfile builds an image with a chatbot (Tino) for Tinode.\n\nFROM python:3.13-slim\n\nARG VERSION=0.25\nARG LOGIN_AS=\nARG TINODE_HOST=tinode-srv:16060\nENV VERSION=$VERSION\nARG BINVERS=$VERSION\n\nLABEL maintainer=\"Tinode Team <info@tinode.co>\"\nLABEL name=\"TinodeChatbot\"\nLABEL version=$VERSION\n\nRUN mkdir -p /usr/src/bot\n\nWORKDIR /usr/src/bot\n\n# Volume with login cookie. Not created automatically.\n# VOLUME /botdata\n\n# Get tarball with the chatbot code and data.\nADD https://github.com/tinode/chat/releases/download/v${BINVERS}/py-chatbot.tar.gz .\n# Unpack chatbot, delete archive\nRUN tar -xzf py-chatbot.tar.gz \\\n\t&& rm py-chatbot.tar.gz\n\nRUN pip install --no-cache-dir -r requirements.txt\n\n# Healthcheck: try to connect to the gRPC port (40051)\nHEALTHCHECK --interval=1m --timeout=3s --start-period=15s \\\n  CMD python -c \"import socket; s=socket.create_connection(('localhost', 40051), timeout=1); s.close()\" || exit 1\n\n# Use docker's command line parameter `-e LOGIN_AS=user:password` to login as someone other than Tino.\n\nCMD [\"/bin/sh\", \"-c\", \"python chatbot.py --login-basic=${LOGIN_AS} --login-cookie=/botdata/.tn-cookie --host=${TINODE_HOST} >> /var/log/chatbot.log\"]\n\n# Plugin port\nEXPOSE 40051\n"
  },
  {
    "path": "docker/docker-compose/README.md",
    "content": "# Docker compose for end-to-end setup.\n\nThese reference docker-compose files will run Tinode with the MySql backend either as [a single-instance](single-instance.yml) or [a 3-node cluster](cluster.yml) setup.\n\n```\ndocker-compose -f <name of the file> [-f <name of override file>] up -d\n```\n\nBy default, this command starts up a mysql instance, Tinode server(s) and Tinode exporter(s).\nTinode server(s) is(are) configured similar to [Tinode Demo/Sandbox](../../README.md#demosandbox) and\nmaps its web port to the host's port 6060 (6061, 6062). Tinode exporter(s) serve(s) metrics for InfluxDB.\n\nReference configuration for the following databases is also available in the override files:\n* [PostgreSQL 15.2](https://hub.docker.com/_/postgres/tags)\n* [MongoDB 4.2.3](https://hub.docker.com/_/mongo/tags)\n* [RethinkDB 2.4.2](https://hub.docker.com/_/rethinkdb/tags)\n\n\n## Commands\n\n### Full stack\nTo bring up the full stack, you can use the following commands:\n* MySql:\n  - Single-instance setup: `docker-compose -f single-instance.yml up -d`\n  - Cluster: `docker-compose -f cluster.yml up -d`\n* PostgreSQL:\n  - Single-instance setup: `docker-compose -f single-instance.yml -f single-instance.postgres.yml up -d`\n  - Cluster: `docker-compose -f cluster.yml -f cluster.postgres.yml up -d`\n* MongoDb:\n  - Single-instance setup: `docker-compose -f single-instance.yml -f single-instance.mongodb.yml up -d`\n  - Cluster: `docker-compose -f cluster.yml -f cluster.mongodb.yml up -d`\n* RethinkDb:\n  - Single-instance setup: `docker-compose -f single-instance.yml -f single-instance.rethinkdb.yml up -d`\n  - Cluster: `docker-compose -f cluster.yml -f cluster.rethinkdb.yml up -d`\n\nYou can run individual/separate components of the setup by providing their names to the `docker-compose` command.\nE.g. to start the Tinode server in the single-instance MySql setup,\n```\ndocker-compose -f single-instance.yml up -d tinode-0\n```\n\n### Database resets and/or version upgrades\nTo reset the database or upgrade the database version, you can set `RESET_DB` or `UPGRADE_DB` environment variable to true when starting Tinode with docker-compose.\nE.g. for upgrading the database in MongoDb cluster setup, use:\n```\nUPGRADE_DB=true docker-compose -f cluster.yml -f cluster.mongodb.yml up -d tinode-0\n```\n\nFor resetting the database in RethinkDb single-instance setup, run:\n```\nRESET_DB=true docker-compose -f single-instance.yml -f single-instance.rethinkdb.yml up -d tinode-0\n```\n\n## Troubleshooting\nPrint out and verify your docker-compose configuration by running:\n```\ndocker-compose -f <name of the file> [-f <name of override file>] config\n```\n\nIf the Tinode server(s) are failing, you can print the job's stdout/stderr with:\n```\ndocker logs tinode-<instance number>\n```\n\nAdditionally, you can examine the jobs `tinode.log` file. To download it from the container, run:\n```\ndocker cp tinode-<instance number>:/var/log/tinode.log .\n```\n"
  },
  {
    "path": "docker/docker-compose/cluster.mongodb.yml",
    "content": "version: '3.8'\n\nx-mongodb-tinode-env-vars: &mongodb-tinode-env-vars\n  \"STORE_USE_ADAPTER\": \"mongodb\"\n\nservices:\n  db:\n    image: mongo:4.2.3\n    container_name: mongodb\n    entrypoint: [ \"/usr/bin/mongod\", \"--bind_ip_all\", \"--replSet\", \"rs0\" ]\n    healthcheck:\n      test: [\"CMD\", \"curl -f http://localhost:28017/ || exit 1\"]\n\n  # Initializes MongoDb replicaset.\n  initdb:\n    image: mongo:4.2.3\n    container_name: initdb\n    depends_on:\n      - db\n    command: >\n      bash -c \"echo 'Starting replica set initialize';\n      until mongo --host mongodb --eval 'print(\\\"waited for connection\\\")'; do sleep 2; done;\n      echo 'Connection finished';\n      echo 'Creating replica set';\n      echo \\\"rs.initiate({'_id': 'rs0', \"members\": [ {'_id': 0, 'host': 'mongodb:27017'} ]})\\\" | mongo --host mongodb\"\n\n  tinode-0:\n    environment:\n      << : *mongodb-tinode-env-vars\n      \"WAIT_FOR\": \"mongodb:27017\"\n\n  tinode-1:\n    environment:\n      << : *mongodb-tinode-env-vars\n\n  tinode-2:\n    environment:\n      << : *mongodb-tinode-env-vars\n"
  },
  {
    "path": "docker/docker-compose/cluster.postgres.yml",
    "content": "version: '3.8'\n\nx-postgres-tinode-env-vars: &postgres-tinode-env-vars\n  \"STORE_USE_ADAPTER\": \"postgres\"\n\nservices:\n  db:\n    image: postgres:15.2\n    container_name: postgres\n    healthcheck:\n      test: [\"CMD-SHELL\", \"pg_isready\"]\n\n  tinode-0:\n    environment:\n      << : *postgres-tinode-env-vars\n      \"WAIT_FOR\": \"postgres:5432\"\n\n  tinode-1:\n    environment:\n      << : *postgres-tinode-env-vars\n\n  tinode-2:\n    environment:\n      << : *postgres-tinode-env-vars\n"
  },
  {
    "path": "docker/docker-compose/cluster.rethinkdb.yml",
    "content": "version: '3.8'\n\nx-rethinkdb-tinode-env-vars: &rethinkdb-tinode-env-vars\n  \"STORE_USE_ADAPTER\": \"rethinkdb\"\n\nservices:\n  db:\n    image: rethinkdb:2.4.2\n    container_name: rethinkdb\n    healthcheck:\n      test: [\"CMD\", \"curl -f http://localhost:8080/ || exit 1\"]\n\n  tinode-0:\n    environment:\n      << : *rethinkdb-tinode-env-vars\n      \"WAIT_FOR\": \"rethinkdb:8080\"\n\n  tinode-1:\n    environment:\n      << : *rethinkdb-tinode-env-vars\n\n  tinode-2:\n    environment:\n      << : *rethinkdb-tinode-env-vars\n"
  },
  {
    "path": "docker/docker-compose/cluster.yml",
    "content": "# Reference configuration for a simple 3-node Tinode cluster.\n# Includes:\n# * Mysql database\n# * 3 Tinode servers\n# * 3 exporters\n\nversion: '3.8'\n\n# Base Tinode template.\nx-tinode:\n  &tinode-base\n  depends_on:\n    - db\n  image: tinode/tinode:latest\n  restart: always\n\nx-exporter:\n  &exporter-base\n  image: tinode/exporter:latest\n  restart: always\n\nx-tinode-env-vars: &tinode-env-vars\n  \"STORE_USE_ADAPTER\": \"mysql\"\n  \"PPROF_URL\": \"/pprof\"\n  # You can provide your own tinode config by setting EXT_CONFIG env var and binding your configuration file to\n  # \"EXT_CONFIG\": \"/etc/tinode/tinode.conf\"\n  \"WAIT_FOR\": \"mysql:3306\"\n  # Push notifications.\n  # Modify as appropriate.\n  # Tinode Push Gateway configuration.\n  \"TNPG_PUSH_ENABLED\": \"false\"\n  # \"TNPG_USER\": \"<user name>\"\n  # \"TNPG_AUTH_TOKEN\": \"<token>\"\n  # FCM specific server configuration.\n  \"FCM_PUSH_ENABLED\": \"false\"\n  # \"FCM_CRED_FILE\": \"<path to FCM credentials file>\"\n  # \"FCM_INCLUDE_ANDROID_NOTIFICATION\": false\n  #\n  # FCM Web client configuration.\n  \"FCM_API_KEY\": \"AIzaSyD6X4ULR-RUsobvs1zZ2bHdJuPz39q2tbQ\"\n  \"FCM_APP_ID\": \"1:114126160546:web:aca6ea2981feb81fb44dfb\"\n  \"FCM_PROJECT_ID\": \"tinode-1000\"\n  \"FCM_SENDER_ID\": 114126160546\n  \"FCM_VAPID_KEY\": \"BOgQVPOMzIMXUpsYGpbVkZoEBc0ifKY_f2kSU5DNDGYI6i6CoKqqxDd7w7PJ3FaGRBgVGJffldETumOx831jl58\"\n  \"FCM_MEASUREMENT_ID\": \"G-WNJDQR34L3\"\n  # iOS app universal links configuration.\n  # \"IOS_UNIV_LINKS_APP_ID\": \"<ios universal links app id>\"\n  # Video calls\n  \"WEBRTC_ENABLED\": \"false\"\n  # \"ICE_SERVERS_FILE\": \"<path to ICE servers config>\"\n\nx-exporter-env-vars: &exporter-env-vars\n  \"TINODE_ADDR\": \"http://tinode.host:18080/stats/expvar/\"\n  # InfluxDB configation:\n  \"SERVE_FOR\": \"influxdb\"\n  \"INFLUXDB_VERSION\": 1.7\n  \"INFLUXDB_ORGANIZATION\": \"<your organization>\"\n  \"INFLUXDB_PUSH_INTERVAL\": 30\n  \"INFLUXDB_PUSH_ADDRESS\": \"https://mon.tinode.co/intake\"\n  \"INFLUXDB_AUTH_TOKEN\": \"<auth token>\"\n  # Prometheus configuration:\n  # \"SERVE_FOR\": \"prometheus\"\n  # \"PROM_NAMESPACE\": \"tinode\"\n  # \"PROM_METRICS_PATH\": \"/metrics\"\n\nservices:\n  db:\n    image: mysql:8.0\n    container_name: mysql\n    restart: always\n    # Use your own volume.\n    # volumes:\n    #   - <mysql directory in your file system>:/var/lib/mysql\n    environment:\n      - MYSQL_ALLOW_EMPTY_PASSWORD=yes\n    healthcheck:\n      test: [\"CMD\", \"mysqladmin\" ,\"ping\", \"-h\", \"localhost\"]\n      timeout: 5s\n      retries: 10\n    security_opt:\n      - seccomp=unconfined\n\n  # Tinode servers.\n  tinode-0:\n    << : *tinode-base\n    container_name: tinode-0\n    hostname: tinode-0\n    # You can mount your volumes as necessary:\n    # volumes:\n    #   # E.g. external config (assuming EXT_CONFIG is set).\n    #   - <path to your tinode.conf>:/etc/tinode/tinode.conf\n    #   # Logs directory.\n    #   - <path to your tinode-0 logs directory>:/var/log\n    ports:\n      - \"6060:6060\"\n    environment:\n      << : *tinode-env-vars\n      \"CLUSTER_SELF\": \"tinode-0\"\n      \"RESET_DB\": ${RESET_DB:-false}\n      \"UPGRADE_DB\": ${UPGRADE_DB:-false}\n\n  tinode-1:\n    << : *tinode-base\n    container_name: tinode-1\n    hostname: tinode-1\n    # You can mount your volumes as necessary:\n    # volumes:\n    #   # E.g. external config (assuming EXT_CONFIG is set).\n    #   - <path to your tinode.conf>:/etc/tinode/tinode.conf\n    #   # Logs directory.\n    #   - <path to your tinode-1 logs directory>:/var/log\n    ports:\n      - \"6061:6060\"\n    environment:\n      << : *tinode-env-vars\n      \"CLUSTER_SELF\": \"tinode-1\"\n      # Wait for tinode-0, not the database since\n      # we let tinode-0 perform all database initialization and upgrade work.\n      \"WAIT_FOR\": \"tinode-0:6060\"\n      \"NO_DB_INIT\": \"true\"\n\n  tinode-2:\n    << : *tinode-base\n    container_name: tinode-2\n    hostname: tinode-2\n    # You can mount your volumes as necessary:\n    # volumes:\n    #   # E.g. external config (assuming EXT_CONFIG is set).\n    #   - <path to your tinode.conf>:/etc/tinode/tinode.conf\n    #   # Logs directory.\n    #   - <path to your tinode-2 logs directory>:/var/log\n    ports:\n      - \"6062:6060\"\n    environment:\n      << : *tinode-env-vars\n      \"CLUSTER_SELF\": \"tinode-2\"\n      # Wait for tinode-0, not the database since\n      # we let tinode-0 perform all database initialization and upgrade work.\n      \"WAIT_FOR\": \"tinode-0:6060\"\n      \"NO_DB_INIT\": \"true\"\n\n  # Monitoring.\n  # Exporters are paired with tinode instances.\n  exporter-0:\n    << : *exporter-base\n    container_name: exporter-0\n    hostname: exporter-0\n    depends_on:\n      - tinode-0\n    ports:\n      - 6222:6222\n    links:\n      - tinode-0:tinode.host\n    environment:\n      << : *exporter-env-vars\n      \"INSTANCE\": \"tinode-0\"\n      \"WAIT_FOR\": \"tinode-0:6060\"\n\n  exporter-1:\n    << : *exporter-base\n    container_name: exporter-1\n    hostname: exporter-1\n    depends_on:\n      - tinode-1\n    ports:\n      - 6223:6222\n    links:\n      - tinode-1:tinode.host\n    environment:\n      << : *exporter-env-vars\n      \"INSTANCE\": \"tinode-1\"\n      \"WAIT_FOR\": \"tinode-1:6060\"\n\n  exporter-2:\n    << : *exporter-base\n    container_name: exporter-2\n    hostname: exporter-2\n    depends_on:\n      - tinode-2\n    ports:\n      - 6224:6222\n    links:\n      - tinode-2:tinode.host\n    environment:\n      << : *exporter-env-vars\n      \"INSTANCE\": \"tinode-2\"\n      \"WAIT_FOR\": \"tinode-2:6060\"\n"
  },
  {
    "path": "docker/docker-compose/single-instance.mongodb.yml",
    "content": "version: '3.8'\n\nx-mongodb-tinode-env-vars: &mongodb-tinode-env-vars\n  \"STORE_USE_ADAPTER\": \"mongodb\"\n\nservices:\n  db:\n    image: mongo:4.2.3\n    container_name: mongodb\n    entrypoint: [ \"/usr/bin/mongod\", \"--bind_ip_all\", \"--replSet\", \"rs0\" ]\n    healthcheck:\n      test: [\"CMD\", \"curl -f http://localhost:28017/ || exit 1\"]\n\n  # Initializes MongoDb replicaset.\n  initdb:\n    image: mongo:4.2.3\n    container_name: initdb\n    depends_on:\n      - db\n    command: >\n      bash -c \"echo 'Starting replica set initialize';\n      until mongo --host mongodb --eval 'print(\\\"waited for connection\\\")'; do sleep 2; done;\n      echo 'Connection finished';\n      echo 'Creating replica set';\n      echo \\\"rs.initiate({'_id': 'rs0', \"members\": [ {'_id': 0, 'host': 'mongodb:27017'} ]})\\\" | mongo --host mongodb\"\n\n  tinode-0:\n    environment:\n      << : *mongodb-tinode-env-vars\n      \"WAIT_FOR\": \"mongodb:27017\"\n"
  },
  {
    "path": "docker/docker-compose/single-instance.postgres.yml",
    "content": "version: '3.8'\n\nx-postgres-tinode-env-vars: &postgres-tinode-env-vars\n  \"STORE_USE_ADAPTER\": \"postgres\"\n\nservices:\n  db:\n    image: postgres:15.2\n    container_name: postgres\n    healthcheck:\n      test: [\"CMD-SHELL\", \"pg_isready\"]\n\n  tinode-0:\n    environment:\n      << : *postgres-tinode-env-vars\n      \"WAIT_FOR\": \"postgres:5432\"\n"
  },
  {
    "path": "docker/docker-compose/single-instance.rethinkdb.yml",
    "content": "version: '3.8'\n\nx-rethinkdb-tinode-env-vars: &rethinkdb-tinode-env-vars\n  \"STORE_USE_ADAPTER\": \"rethinkdb\"\n\nservices:\n  db:\n    image: rethinkdb:2.4.0\n    container_name: rethinkdb\n    healthcheck:\n      test: [\"CMD\", \"curl -f http://localhost:8080/ || exit 1\"]\n\n  tinode-0:\n    environment:\n      << : *rethinkdb-tinode-env-vars\n      \"WAIT_FOR\": \"rethinkdb:8080\"\n"
  },
  {
    "path": "docker/docker-compose/single-instance.yml",
    "content": "# Reference configuration for a simple Tinode server.\n# Includes:\n# * Mysql database\n# * Tinode server\n# * Tinode exporters\n\nversion: '3.8'\n\n# Base Tinode template.\nx-tinode:\n  &tinode-base\n  depends_on:\n    - db\n  image: tinode/tinode:latest\n  restart: always\n\nx-tinode-env-vars: &tinode-env-vars\n  \"STORE_USE_ADAPTER\": \"mysql\"\n  \"PPROF_URL\": \"/pprof\"\n  # You can provide your own tinode config by setting EXT_CONFIG env var and binding your configuration file to\n  # \"EXT_CONFIG\": \"/etc/tinode/tinode.conf\"\n  \"WAIT_FOR\": \"mysql:3306\"\n  # Push notifications.\n  # Modify as appropriate.\n  # Tinode Push Gateway configuration.\n  \"TNPG_PUSH_ENABLED\": \"false\"\n  # \"TNPG_USER\": \"<user name>\"\n  # \"TNPG_AUTH_TOKEN\": \"<token>\"\n  # FCM specific server configuration.\n  \"FCM_PUSH_ENABLED\": \"false\"\n  # \"FCM_CRED_FILE\": \"<path to FCM credentials file>\"\n  # \"FCM_INCLUDE_ANDROID_NOTIFICATION\": false\n  #\n  # FCM Web client configuration.\n  \"FCM_API_KEY\": \"AIzaSyD6X4ULR-RUsobvs1zZ2bHdJuPz39q2tbQ\"\n  \"FCM_APP_ID\": \"1:114126160546:web:aca6ea2981feb81fb44dfb\"\n  \"FCM_PROJECT_ID\": \"tinode-1000\"\n  \"FCM_SENDER_ID\": 114126160546\n  \"FCM_VAPID_KEY\": \"BOgQVPOMzIMXUpsYGpbVkZoEBc0ifKY_f2kSU5DNDGYI6i6CoKqqxDd7w7PJ3FaGRBgVGJffldETumOx831jl58\"\n  \"FCM_MEASUREMENT_ID\": \"G-WNJDQR34L3\"\n  # iOS app universal links configuration.\n  # \"IOS_UNIV_LINKS_APP_ID\": \"<ios universal links app id>\"\n  # Video calls\n  \"WEBRTC_ENABLED\": \"false\"\n  # \"ICE_SERVERS_FILE\": \"<path to ICE servers config>\"\n\nx-exporter-env-vars: &exporter-env-vars\n  \"TINODE_ADDR\": \"http://tinode.host:6060/stats/expvar/\"\n  # InfluxDB configation:\n  \"SERVE_FOR\": \"influxdb\"\n  \"INFLUXDB_VERSION\": 1.7\n  \"INFLUXDB_ORGANIZATION\": \"<your organization>\"\n  \"INFLUXDB_PUSH_INTERVAL\": 30\n  \"INFLUXDB_PUSH_ADDRESS\": \"https://mon.tinode.co/intake\"\n  \"INFLUXDB_AUTH_TOKEN\": \"<auth token>\"\n  # Prometheus configuration:\n  # \"SERVE_FOR\": \"prometheus\"\n  # \"PROM_NAMESPACE\": \"tinode\"\n  # \"PROM_METRICS_PATH\": \"/metrics\"\n\nservices:\n  db:\n    image: mysql:8.0\n    container_name: mysql\n    restart: always\n    # Use your own volume.\n    # volumes:\n    #   - <mysql directory in your file system>:/var/lib/mysql\n    environment:\n      - MYSQL_ALLOW_EMPTY_PASSWORD=yes\n    healthcheck:\n      test: [\"CMD\", \"mysqladmin\" ,\"ping\", \"-h\", \"localhost\"]\n      timeout: 5s\n      retries: 10\n    security_opt:\n      - seccomp=unconfined\n\n  # Tinode.\n  tinode-0:\n    << : *tinode-base\n    container_name: tinode-0\n    hostname: tinode-0\n    # You can mount your volumes as necessary:\n    # volumes:\n    #   # E.g. external config (assuming EXT_CONFIG is set).\n    #   - <path to your tinode.conf>:/etc/tinode/tinode.conf\n    #   # Logs directory.\n    #   - <path to your tinode-0 logs directory>:/var/log\n    ports:\n      - \"6060:6060\"\n    environment:\n      << : *tinode-env-vars\n      \"RESET_DB\": ${RESET_DB:-false}\n      \"UPGRADE_DB\": ${UPGRADE_DB:-false}\n\n  # Monitoring.\n  # Exporters are paired with tinode instances.\n  exporter-0:\n    container_name: exporter-0\n    hostname: exporter-0\n    depends_on:\n      - tinode-0\n    image: tinode/exporter:latest\n    restart: always\n    ports:\n      - \"6222:6222\"\n    links:\n      - tinode-0:tinode.host\n    environment:\n      << : *exporter-env-vars\n      \"WAIT_FOR\": \"tinode-0:6060\"\n"
  },
  {
    "path": "docker/exporter/Dockerfile",
    "content": "FROM alpine:3.14\n\nARG VERSION=0.16.4\nENV VERSION=$VERSION\n\nLABEL maintainer=\"Tinode Team <info@tinode.co>\"\nLABEL name=\"TinodeMetricExporter\"\nLABEL version=$VERSION\n\nENV SERVE_FOR=\"\"\nENV WAIT_FOR=\"\"\n\nENV TINODE_ADDR=http://localhost/stats/expvar/\nENV INSTANCE=\"exporter-instance\"\n\nENV INFLUXDB_VERSION=1.7\nENV INFLUXDB_ORGANIZATION=\"org\"\nENV INFLUXDB_PUSH_INTERVAL=60\nENV INFLUXDB_PUSH_ADDRESS=\"\"\nENV INFLUXDB_AUTH_TOKEN=\"\"\n\nENV PROM_NAMESPACE=\"tinode\"\nENV PROM_METRICS_PATH=\"/metrics\"\n\nWORKDIR /opt/tinode\n\nRUN apk add --no-cache bash\n\n# Fetch exporter build from Github.\nADD https://github.com/tinode/chat/releases/download/v$VERSION/exporter.linux-amd64 ./exporter\n\nCOPY entrypoint.sh .\nRUN chmod +x exporter && chmod +x entrypoint.sh\n\nENTRYPOINT ./entrypoint.sh\n\nEXPOSE 6222\n"
  },
  {
    "path": "docker/exporter/entrypoint.sh",
    "content": "#!/bin/bash\n\n# Check if environment variables (provided as argument list) are set.\nfunction check_vars() {\n  local varnames=( \"$@\" )\n  for varname in \"${varnames[@]}\"\n  do\n    eval value=\\$${varname}\n    if [ -z \"$value\" ] ; then\n      echo \"$varname env var must be specified.\"\n      exit 1\n    fi\n  done\n}\n\n# Make sure the system uses /etc/hosts when resolving domain names\n# (needed for docker-compose's `extra_hosts` param to work correctly).\n# See https://github.com/gliderlabs/docker-alpine/issues/367,\n# https://github.com/golang/go/issues/35305 for details.\necho \"hosts: files dns\" > /etc/nsswitch.conf\n\n# Accept http requests at.\nLISTEN_AT=\":6222\"\n\n# Required env vars.\ncommon_vars=( TINODE_ADDR INSTANCE SERVE_FOR )\n\ninflux_varnames=( INFLUXDB_VERSION INFLUXDB_ORGANIZATION INFLUXDB_PUSH_INTERVAL \\\n  INFLUXDB_PUSH_ADDRESS INFLUXDB_AUTH_TOKEN )\n\nprometheus_varnames=( PROM_NAMESPACE PROM_METRICS_PATH )\n\ncheck_vars \"${common_vars[@]}\"\n\n# Common arguments.\nargs=(\"--tinode_addr=${TINODE_ADDR}\" \"--instance=${INSTANCE}\" \"--listen_at=${LISTEN_AT}\" \"--serve_for=${SERVE_FOR}\")\n\n# Platform-specific arguments.\ncase \"$SERVE_FOR\" in\n\"prometheus\")\n  check_vars \"${prometheus_varnames[@]}\"\n  args+=(\"--prom_namespace=${PROM_NAMESPACE}\" \"--prom_metrics_path=${PROM_METRICS_PATH}\")\n  if [ ! -z \"$PROM_TIMEOUT\" ]; then\n    args+=(\"--prom_timeout=${PROM_TIMEOUT}\")\n  fi\n  ;;\n\"influxdb\")\n  check_vars \"${influxdb_varnames[@]}\"\n  args+=(\"--influx_db_version=${INFLUXDB_VERSION}\" \\\n         \"--influx_organization=${INFLUXDB_ORGANIZATION}\" \\\n         \"--influx_push_interval=${INFLUXDB_PUSH_INTERVAL}\" \\\n         \"--influx_push_addr=${INFLUXDB_PUSH_ADDRESS}\" \\\n         \"--influx_auth_token=${INFLUXDB_AUTH_TOKEN}\")\n  if [ ! -z \"$INFLUXDB_BUCKET\" ]; then\n    args+=(\"--influx_bucket=${INFLUXDB_BUCKET}\")\n  fi\n  ;;\n*)\n  echo \"\\$SERVE_FOR must be set to either 'prometheus' or 'influxdb'\"\n  exit 1\n  ;;\nesac\n\n# Wait for Tinode server if needed.\nif [ ! -z \"$WAIT_FOR\" ] ; then\n\tIFS=':' read -ra TND <<< \"$WAIT_FOR\"\n\tif [ ${#TND[@]} -ne 2 ]; then\n\t\techo \"\\$WAIT_FOR (${WAIT_FOR}) env var should be in form HOST:PORT\"\n\t\texit 1\n\tfi\n\tuntil nc -z -v -w5 ${TND[0]} ${TND[1]}; do echo \"waiting for ${WAIT_FOR}...\"; sleep 5; done\nfi\n\n./exporter \"${args[@]}\"\n"
  },
  {
    "path": "docker/tinode/Dockerfile",
    "content": "# Docker file builds an image with a tinode chat server.\n#\n# In order to run the image you have to link it to a running database container. For example, to\n# to use RethinkDB (named 'rethinkdb') and map the port where the tinode server accepts connections:\n#\n# $ docker run -p 6060:6060 -d --link rethinkdb \\\n#\t--env UID_ENCRYPTION_KEY=base64+encoded+16+bytes= \\\n#\t--env API_KEY_SALT=base64+encoded+32+bytes \\\n#\t--env AUTH_TOKEN_KEY=base64+encoded+32+bytes \\\n#\ttinode-server\n\nFROM alpine:3.22\n\nARG VERSION=0.25\nENV VERSION=$VERSION\nARG BINVERS=$VERSION\n\nLABEL maintainer=\"Tinode Team <info@tinode.co>\"\nLABEL name=\"TinodeChatServer\"\nLABEL version=$VERSION\n\n# Build-time options.\n\n# Database selector. Builds for MySQL by default.\n# Alternatively use one of: postgres mongodb rethinkdb for a corresponsing\n# DB backend or alldbs to build a generic Tinode docker image, for example:\n# `--build-arg TARGET_DB=postgres` to build for PostgreSQL.\nARG TARGET_DB=mysql\nENV TARGET_DB=$TARGET_DB\n\n# Runtime options.\n\n# Specifies the database host:port pair to wait for before running Tinode.\n# Ignored if empty.\nENV WAIT_FOR=\n\n# An option to reset database.\nENV RESET_DB=false\n\n# An option to upgrade database.\nENV UPGRADE_DB=false\n\n# Option to skip DB initialization when it's missing.\nENV NO_DB_INIT=false\n\n# Load sample data to database from data.json.\nARG SAMPLE_DATA=data.json\nENV SAMPLE_DATA=$SAMPLE_DATA\n\n# Default country code to use in communication.\nENV DEFAULT_COUNTRY_CODE=US\n\n# The MySQL DSN connection.\nENV MYSQL_DSN='root@tcp(mysql)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci'\n\n# The PostgreSQL DSN connection.\nENV POSTGRES_DSN='postgresql://postgres:postgres@localhost:5432/tinode?sslmode=disable&connect_timeout=10'\n\n# Disable chatbot plugin by default.\nENV PLUGIN_PYTHON_CHAT_BOT_ENABLED=false\n\n# Default handler for large files\nENV MEDIA_HANDLER=fs\n\n# Whitelisted domains for file and S3 large media handler.\nENV FS_CORS_ORIGINS='[\"*\"]'\nENV AWS_CORS_ORIGINS='[\"*\"]'\n\n# AWS S3 parameters\nENV AWS_ACCESS_KEY_ID=\nENV AWS_SECRET_ACCESS_KEY=\nENV AWS_REGION=\nENV AWS_S3_BUCKET=\nENV AWS_S3_ENDPOINT=\n\n# Default externally-visible hostname for email verification.\nENV SMTP_HOST_URL='http://localhost:6060'\n# Email parameters decalarations.\nENV SMTP_SERVER=\nENV SMTP_PORT=\nENV SMTP_SENDER=\nENV SMTP_LOGIN=\nENV SMTP_PASSWORD=\nENV SMTP_AUTH_MECHANISM=\nENV SMTP_HELO_HOST=\nENV EMAIL_VERIFICATION_REQUIRED=\nENV DEBUG_EMAIL_VERIFICATION_CODE=\n\n# Whitelist of permitted email domains for email verification (empty list means all domains are permitted)\nENV SMTP_DOMAINS=''\n\n# Various encryption and salt keys. Replace with your own in production.\n\n# Salt used to generate the API key. Don't change it unless you also change the\n# API key in the webapp & Android.\nENV API_KEY_SALT=T713/rYYgW7g4m3vG6zGRh7+FM1t0T8j13koXScOAj4=\n\n# Key used to sign authentication tokens.\nENV AUTH_TOKEN_KEY=wfaY2RgF2S1OQI/ZlK+LSrp1KB2jwAdGAIHQ7JZn+Kc=\n\n# Key to initialize UID generator\nENV UID_ENCRYPTION_KEY=la6YsO+bNX/+XIkOqc5Svw==\n\n# Disable TLS by default.\nENV TLS_ENABLED=false\nENV TLS_DOMAIN_NAME=\nENV TLS_CONTACT_ADDRESS=\n\n# Disable push notifications by default.\nENV FCM_PUSH_ENABLED=false\n# Declare FCM-related vars\nENV FCM_API_KEY=\nENV FCM_APP_ID=\nENV FCM_SENDER_ID=\nENV FCM_PROJECT_ID=\nENV FCM_VAPID_KEY=\nENV FCM_MEASUREMENT_ID=\n\n# Enable Android-specific notifications by default.\nENV FCM_INCLUDE_ANDROID_NOTIFICATION=true\n\n# Disable push notifications via Tinode Push Gateway.\nENV TNPG_PUSH_ENABLED=false\n\n# Tinode Push Gateway authentication token.\nENV TNPG_AUTH_TOKEN=\n\n# Tinode Push Gateway organization name as registered at console.tinode.co\nENV TNPG_ORG=\n\n# Video calls configuration.\nENV WEBRTC_ENABLED=false\nENV ICE_SERVERS_FILE=\n\n# Use the target db by default.\n# When TARGET_DB is \"alldbs\", it is the user's responsibility\n# to set STORE_USE_ADAPTER to the desired db adapter correctly.\nENV STORE_USE_ADAPTER=$TARGET_DB\n\n# Url path for exposing the server's internal status. E.g. '/status'\nENV SERVER_STATUS_PATH=''\n\n# Garbage collection of unfinished account registrations.\nENV ACC_GC_ENABLED=false\n\n# Install root certificates, they are needed for email validator to work\n# with the TLS SMTP servers like Gmail or Mailjet. Also add bash and grep.\nRUN apk update && \\\n\tapk add --no-cache ca-certificates bash grep\n\nWORKDIR /opt/tinode\n\n# Copy config template to the container.\nCOPY config.template .\nCOPY entrypoint.sh .\n\n# Get the desired Tinode build.\nADD https://github.com/tinode/chat/releases/download/v$BINVERS/tinode-$TARGET_DB.linux-amd64.tar.gz .\n\n# Unpack the Tinode archive.\nRUN tar -xzf tinode-$TARGET_DB.linux-amd64.tar.gz \\\n\t&& rm tinode-$TARGET_DB.linux-amd64.tar.gz\n\n# Create directory for chatbot data.\nRUN mkdir /botdata\n\n# Make scripts runnable\nRUN chmod +x entrypoint.sh\nRUN chmod +x credentials.sh\n\n# Healthcheck: check if port 6060 is open\nHEALTHCHECK --interval=1m --timeout=3s --start-period=30s \\\n  CMD nc -z localhost 6060 || exit 1\n\n# Generate config from template and run the server.\nENTRYPOINT [\"./entrypoint.sh\"]\n\n# HTTP, gRPC, cluster ports\nEXPOSE 6060 16060 12000-12003\n"
  },
  {
    "path": "docker/tinode/config.template",
    "content": "{\n\t\"listen\": \":6060\",\n\t\"api_path\": \"/\",\n\t\"cache_control\": 39600,\n\t\"static_mount\": \"/\",\n\t\"grpc_listen\": \":16060\",\n\t\"grpc_keepalive_enabled\": true,\n\t\"api_key_salt\": \"$API_KEY_SALT\",\n\t\"max_message_size\": 4194304,\n\t\"max_subscriber_count\": 128,\n\t\"max_tag_count\": 16,\n\t\"expvar\": \"/stats/expvar/\",\n\t\"server_status\": \"$SERVER_STATUS_PATH\",\n\t\"use_x_forwarded_for\": true,\n\t\"default_country_code\": \"$DEFAULT_COUNTRY_CODE\",\n\n\t\"media\": {\n\t\t\"use_handler\": \"$MEDIA_HANDLER\",\n\t\t\"max_size\": 33554432,\n\t\t\"gc_period\": 60,\n\t\t\"gc_block_size\": 100,\n\t\t\"handlers\": {\n\t\t\t\"fs\": {\n\t\t\t\t\"upload_dir\": \"uploads\",\n\t\t\t\t\"cache_control\": \"max-age=86400\",\n\t\t\t\t\"cors_origins\": $FS_CORS_ORIGINS\n\t\t\t},\n\t\t\t\"s3\":{\n\t\t\t\t\"access_key_id\": \"$AWS_ACCESS_KEY_ID\",\n\t\t\t\t\"secret_access_key\": \"$AWS_SECRET_ACCESS_KEY\",\n\t\t\t\t\"region\": \"$AWS_REGION\",\n\t\t\t\t\"bucket\": \"$AWS_S3_BUCKET\",\n\t\t\t\t\"endpoint\": \"$AWS_S3_ENDPOINT\",\n\t\t\t\t\"presign_ttl\": 3600,\n\t\t\t\t\"cache_control\": \"max-age=86400\",\n\t\t\t\t\"cors_origins\": $AWS_CORS_ORIGINS\n\t\t\t}\n\t\t}\n\t},\n\n\t\"tls\": {\n\t\t\"enabled\": $TLS_ENABLED,\n\t\t\"http_redirect\": \":80\",\n\t\t\"strict_max_age\": 604800,\n\t\t\"autocert\": {\n\t\t\t\"cache\": \"/etc/letsencrypt/live/$TLS_DOMAIN_NAME\",\n\t\t\t\"email\": \"$TLS_CONTACT_ADDRESS\",\n\t\t\t\"domains\": [\"$TLS_DOMAIN_NAME\"]\n\t\t}\n\t},\n\n\t\"auth_config\": {\n\t\t\"logical_names\": [],\n\t\t\"basic\": {\n\t\t\t\"add_to_tags\": true,\n\t\t\t\"min_login_length\": 3,\n\t\t\t\"min_password_length\": 6\n\t\t},\n\t\t\"token\": {\n\t\t\t\"expire_in\": 1209600,\n\t\t\t\"serial_num\": 1,\n\t\t\t\"key\": \"$AUTH_TOKEN_KEY\"\n\t\t},\n\t\t\"code\": {\n\t\t\t\"expire_in\": 900,\n\t\t\t\"max_retries\": 3,\n\t\t\t\"code_length\": 6\n\t\t}\n\t},\n\n\t\"store_config\": {\n\t\t\"uid_key\": \"$UID_ENCRYPTION_KEY\",\n\t\t\"max_results\": 1024,\n\t\t\"use_adapter\": \"$STORE_USE_ADAPTER\",\n\t\t\"adapters\": {\n\t\t\t\"mysql\": {\n\t\t\t\t\"database\": \"tinode\",\n\t\t\t\t\"dsn\": \"$MYSQL_DSN\"\n\t\t\t},\n\t\t\t\"postgres\": {\n\t\t\t\t\"database\": \"tinode\",\n\t\t\t\t\"dsn\": \"$POSTGRES_DSN\"\n\t\t\t},\n\t\t\t\"rethinkdb\": {\n\t\t\t\t\"database\": \"tinode\",\n\t\t\t\t\"addresses\": \"rethinkdb\"\n\t\t\t},\n\t\t\t\"mongodb\": {\n\t\t\t\t\"database\": \"tinode\",\n\t\t\t\t\"addresses\": \"mongodb\",\n\t\t\t\t\"replica_set\": \"rs0\"\n\t\t\t}\n\t\t}\n\t},\n\n\t\"acc_validation\": {\n\t\t\"email\": {\n\t\t\t\"add_to_tags\": true,\n\t\t\t\"required\": [$EMAIL_VERIFICATION_REQUIRED],\n\t\t\t\"config\": {\n\t\t\t\t\"host_url\": \"$SMTP_HOST_URL\",\n\t\t\t\t\"smtp_server\": \"$SMTP_SERVER\",\n\t\t\t\t\"smtp_port\": \"$SMTP_PORT\",\n\t\t\t\t\"sender\": \"$SMTP_SENDER\",\n\t\t\t\t\"login\": \"$SMTP_LOGIN\",\n\t\t\t\t\"sender_password\": \"$SMTP_PASSWORD\",\n\t\t\t\t\"auth_mechanism\": \"$SMTP_AUTH_MECHANISM\",\n\t\t\t\t\"smtp_helo_host\": \"$SMTP_HELO_HOST\",\n\t\t\t\t\"languages\": [\"en\", \"es\", \"fr\", \"ru\", \"vi\", \"zh\"],\n\t\t\t\t\"validation_templ\": \"./templ/email-validation-{{.Language}}.templ\",\n\t\t\t\t\"reset_secret_templ\": \"./templ/email-password-reset-{{.Language}}.templ\",\n\t\t\t\t\"max_retries\": 3,\n\t\t\t\t\"domains\": [$SMTP_DOMAINS],\n\t\t\t\t\"debug_response\": \"$DEBUG_EMAIL_VERIFICATION_CODE\"\n\t\t\t}\n\t\t},\n\n\t\t\"tel\": {\n\t\t\t\"add_to_tags\": true,\n\t\t\t\"config\": {\n\t\t\t\t\"host_url\": \"$TEL_HOST_URL\",\n\t\t\t\t\"languages\": [\"en\", \"es\", \"fr\", \"pt\", \"ru\", \"vi\", \"zh\"],\n\t\t\t\t\"sender\": \"$TEL_SENDER\",\n\t\t\t\t\"universal_templ\": \"./templ/sms-universal-{{.Language}}.templ\",\n\t\t\t\t\"max_retries\": 3,\n\t\t\t\t\"debug_response\": \"$DEBUG_TEL_VERIFICATION_CODE\"\n\t\t\t}\n\t\t}\n\t},\n\n\t\"acc_gc_config\": {\n\t\t\"enabled\": $ACC_GC_ENABLED,\n\t\t\"gc_period\": 3600,\n\t\t\"gc_block_size\": 10,\n\t\t\"gc_min_account_age\": 48\n\t},\n\n\t\"push\": [\n\t\t{\n\t\t\t\"name\":\"tnpg\",\n\t\t\t\"config\": {\n\t\t\t\t\"enabled\": $TNPG_PUSH_ENABLED,\n\t\t\t\t\"token\": \"$TNPG_AUTH_TOKEN\",\n\t\t\t\t\"org\": \"$TNPG_ORG\"\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\t\"name\":\"fcm\",\n\t\t\t\"config\": {\n\t\t\t\t\"enabled\": $FCM_PUSH_ENABLED,\n\t\t\t\t\"project_id\": \"$FCM_PROJECT_ID\",\n\t\t\t\t\"credentials_file\": \"$FCM_CRED_FILE\",\n\t\t\t\t\"time_to_live\": 3600,\n\t\t\t\t\"android\": {\n\t\t\t\t\t\"enabled\": $FCM_INCLUDE_ANDROID_NOTIFICATION,\n\t\t\t\t\t\"icon\": \"ic_logo_push\",\n\t\t\t\t\t\"icon_color\": \"#3949AB\",\n\t\t\t\t\t\"click_action\": \".MessageActivity\",\n\t\t\t\t\t\"msg\": {\n\t\t\t\t\t\t\"title_loc_key\": \"new_message\",\n\t\t\t\t\t\t\"title\": \"\",\n\t\t\t\t\t\t\"body_loc_key\": \"\",\n\t\t\t\t\t\t\"body\": \"\"\n\t\t\t\t\t},\n\t\t\t\t\t\"sub\": {\n\t\t\t\t\t\t\"title_loc_key\": \"new_chat\",\n\t\t\t\t\t\t\"body_loc_key\": \"\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t],\n\n\t\"webrtc\": {\n\t\t\"enabled\": $WEBRTC_ENABLED,\n\t\t\"call_establishment_timeout\": 30,\n\t\t\"ice_servers_file\": \"$ICE_SERVERS_FILE\"\n\t},\n\n\t\"cluster_config\": {\n\t\t\"self\": \"\",\n\t\t\"nodes\": [\n\t\t\t{\"name\": \"tinode-0\", \"addr\": \"tinode-0:12000\"},\n\t\t\t{\"name\": \"tinode-1\", \"addr\": \"tinode-1:12001\"},\n\t\t\t{\"name\": \"tinode-2\", \"addr\": \"tinode-2:12002\"}\n\t\t],\n\t\t\"failover\": {\n\t\t\t\"enabled\": true,\n\t\t\t\"heartbeat\": 100,\n\t\t\t\"vote_after\": 8,\n\t\t\t\"node_fail_after\": 16\n\t\t}\n\t},\n\n\t\"plugins\": [\n\t\t{\n\t\t\t\"enabled\": $PLUGIN_PYTHON_CHAT_BOT_ENABLED,\n\t\t\t\"name\": \"python_chat_bot\",\n\t\t\t\"timeout\": 20000,\n\t\t\t\"filters\": {\n\t\t\t\t\"account\": \"C\"\n\t\t\t},\n\t\t\t\"failure_code\": 0,\n\t\t\t\"failure_text\": null,\n\t\t\t\"service_addr\": \"tcp://localhost:40051\"\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "docker/tinode/entrypoint.sh",
    "content": "#!/bin/bash\n\n# If EXT_CONFIG is set, use it as a config file.\nif [ ! -z \"$EXT_CONFIG\" ] ; then\n\tCONFIG=\"$EXT_CONFIG\"\n\n\t# Enable push notifications.\n\tif [ ! -z \"$FCM_SENDER_ID\" ] ; then\n\t\tFCM_PUSH_ENABLED=true\n\tfi\n\nelse\n\tCONFIG=working.config\n\n\t# Remove the old config.\n\trm -f working.config\n\n\t# The 'alldbs' is not a valid adapter name.\n\tif [ \"$TARGET_DB\" = \"alldbs\" ] ; then\n\t\tTARGET_DB=\n\tfi\n\n\t# Enable email verification if $SMTP_SERVER is defined.\n\tif [ ! -z \"$SMTP_SERVER\" ] ; then\n\t\tEMAIL_VERIFICATION_REQUIRED='\"auth\"'\n\tfi\n\n\t# Enable TLS (httpS).\n\tif [ ! -z \"$TLS_DOMAIN_NAME\" ] ; then\n\t\tTLS_ENABLED=true\n\tfi\n\n\t# Enable push notifications.\n\tif [ ! -z \"$FCM_CRED_FILE\" ] ; then\n\t\tFCM_PUSH_ENABLED=true\n\tfi\n\n\tif [ ! -z \"$TNPG_AUTH_TOKEN\" ] ; then\n\t\tTNPG_PUSH_ENABLED=true\n\tfi\n\n\tif [ ! -z \"$ICE_SERVERS_FILE\" ] ; then\n\t\tWEBRTC_ENABLED=true\n\tfi\n\n\t# Generate a new 'working.config' from template and environment\n\twhile IFS='' read -r line || [[ -n $line ]] ; do\n\t\twhile [[ \"$line\" =~ (\\$[A-Z_][A-Z_0-9]*) ]] ; do\n\t\t\tLHS=${BASH_REMATCH[1]}\n\t\t\tRHS=\"$(eval echo \"\\\"$LHS\\\"\")\"\n\t\t\tline=${line//$LHS/\"$RHS\"}\n\t\tdone\n\t\techo \"$line\" >> working.config\n\tdone < config.template\nfi\n\n# If external static dir is defined, use it.\n# Otherwise, fall back to \"./static\".\nif [ ! -z \"$EXT_STATIC_DIR\" ] ; then\n\tSTATIC_DIR=$EXT_STATIC_DIR\nelse\n\tSTATIC_DIR=\"./static\"\nfi\n\n# Do not load data when upgrading database.\nif [ \"$UPGRADE_DB\" = \"true\" ] ; then\n\tSAMPLE_DATA=\nfi\n\n# If push notifications are enabled, generate client-side firebase config file.\nif [ ! -z \"$FCM_PUSH_ENABLED\" ] || [ ! -z \"$TNPG_PUSH_ENABLED\" ] ; then\n\t# Write client config to $STATIC_DIR/firebase-init.js\n\tcat > $STATIC_DIR/firebase-init.js <<- EOM\nconst FIREBASE_INIT = {\n  apiKey: \"$FCM_API_KEY\",\n  appId: \"$FCM_APP_ID\",\n  messagingSenderId: \"$FCM_SENDER_ID\",\n  projectId: \"$FCM_PROJECT_ID\",\n  messagingVapidKey: \"$FCM_VAPID_KEY\",\n  measurementId: \"$FCM_MEASUREMENT_ID\"\n};\nEOM\nelse\n\t# Create an empty firebase-init.js\n\techo \"\" > $STATIC_DIR/firebase-init.js\nfi\n\nif [ ! -z \"$IOS_UNIV_LINKS_APP_ID\" ] ; then\n\t# Write config to $STATIC_DIR/apple-app-site-association config file.\n\t# See https://developer.apple.com/library/archive/documentation/General/Conceptual/AppSearch/UniversalLinks.html for details.\n\tcat > $STATIC_DIR/apple-app-site-association <<- EOM\n{\n  \"applinks\": {\n    \"apps\": [],\n    \"details\": [\n      {\n        \"appID\": \"$IOS_UNIV_LINKS_APP_ID\",\n        \"paths\": [ \"*\" ]\n      }\n    ]\n  }\n}\nEOM\nfi\n\n# Wait for database if needed.\nif [ ! -z \"$WAIT_FOR\" ] ; then\n\tIFS=':' read -ra DB <<< \"$WAIT_FOR\"\n\tif [ ${#DB[@]} -ne 2 ]; then\n\t\techo \"\\$WAIT_FOR (${WAIT_FOR}) env var should be in form HOST:PORT\"\n\t\texit 1\n\tfi\n\tuntil nc -z -v -w5 ${DB[0]} ${DB[1]}; do echo \"waiting for ${WAIT_FOR}...\"; sleep 3; done\nfi\n\n# Initialize the database if it has not been initialized yet or if data reset/upgrade has been requested.\ninit_stdout=./init-db-stdout.txt\n./init-db \\\n\t--reset=${RESET_DB} \\\n\t--upgrade=${UPGRADE_DB} \\\n\t--config=${CONFIG} \\\n\t--data=${SAMPLE_DATA} \\\n\t--no_init=${NO_DB_INIT} \\\n\t1>${init_stdout}\nif [ $? -ne 0 ]; then\n\techo \"./init-db failed. Quitting.\"\n\texit 1\nfi\n\n# If sample data was provided, try to find Tino password.\nif [ ! -z \"$SAMPLE_DATA\" ] ; then\n\tgrep \"usr;tino;\" $init_stdout > /botdata/tino-password\nfi\n\nif [ -s /botdata/tino-password ] ; then\n\t# Convert Tino's authentication credentials into a cookie file.\n\n\t# /botdata/tino-password could be empty if DB was not updated. In such a case the\n\t# /botdata/.tn-cookie will not be modified.\n\t./credentials.sh /botdata/.tn-cookie < /botdata/tino-password\nfi\n\nargs=(\"--config=${CONFIG}\" \"--static_data=$STATIC_DIR\" \"--cluster_self=$CLUSTER_SELF\" \"--pprof_url=$PPROF_URL\")\n\n# Run the tinode server.\n./tinode \"${args[@]}\" 2>> /var/log/tinode.log\n"
  },
  {
    "path": "docker-build.sh",
    "content": "#!/bin/bash\n\n# Build Tinode docker linux/amd64 images.\n# You may have to install buildx https://docs.docker.com/buildx/working-with-buildx/\n# if your build host and target architectures are different (e.g. building on a Mac\n# with Apple silicon).\n\nfor line in $@; do\n  eval \"$line\"\ndone\n\ntag=${tag#?}\n\nif [ -z \"$tag\" ]; then\n    echo \"Must provide tag as 'tag=v1.2.3'\"\n    exit 1\nfi\n\n# Convert tag into a version\nver=( ${tag//./ } )\n\n# if version contains a dash, it's not a full releave, i.e. v0.1.15.5-rc1\nif [[ ${ver[2]} != *\"-\"* ]]; then\n  FULLRELEASE=1\nfi\n\n# Use buildx if the current platform is not x86.\nbuildcmd='build'\nif [ `uname -m` != 'x86_64' ]; then\n  buildcmd='buildx build --platform=linux/amd64'\nfi\n\n# If explicit DB is specified, build just one, otherwise build all.\nif [ \"$db\" ]; then\n  dbtags=( \"$db\" )\nelse\n  dbtags=( mysql postgres mongodb rethinkdb alldbs )\nfi\n\n# Build an images for various DB backends\nfor dbtag in \"${dbtags[@]}\"\ndo\n  if [ \"$dbtag\" == \"alldbs\" ]; then\n    # For alldbs, container name is tinode/tinode.\n    name=\"tinode/tinode\"\n  else\n    # Otherwise, tinode/tinode-$dbtag.\n    name=\"tinode/tinode-${dbtag}\"\n  fi\n  separator=\n  rmitags=\"${name}:${ver[0]}.${ver[1]}.${ver[2]}\"\n  buildtags=\"--tag ${name}:${ver[0]}.${ver[1]}.${ver[2]}\"\n  if [ -n \"$FULLRELEASE\" ]; then\n    rmitags=\"${rmitags} ${name}:latest ${name}:${ver[0]}.${ver[1]}\"\n    buildtags=\"${buildtags} --tag ${name}:latest --tag ${name}:${ver[0]}.${ver[1]}\"\n  fi\n  docker rmi ${rmitags}\n  docker ${buildcmd} --build-arg VERSION=$tag --build-arg TARGET_DB=${dbtag} ${buildtags} docker/tinode\ndone\n\nif [ \"$db\" ]; then\n  exit 0\nfi\n\n# Build chatbot image\nbuildtags=\"--tag tinode/chatbot:${ver[0]}.${ver[1]}.${ver[2]}\"\nrmitags=\"tinode/chatbot:${ver[0]}.${ver[1]}.${ver[2]}\"\nif [ -n \"$FULLRELEASE\" ]; then\n  rmitags=\"${rmitags} tinode/chatbot:latest tinode/chatbot:${ver[0]}.${ver[1]}\"\n  buildtags=\"${buildtags}  --tag tinode/chatbot:latest --tag tinode/chatbot:${ver[0]}.${ver[1]}\"\nfi\ndocker rmi ${rmitags}\ndocker ${buildcmd} --build-arg VERSION=$tag ${buildtags} docker/chatbot\n\n# Build exporter image\nbuildtags=\"--tag tinode/exporter:${ver[0]}.${ver[1]}.${ver[2]}\"\nrmitags=\"tinode/exporter:${ver[0]}.${ver[1]}.${ver[2]}\"\nif [ -n \"$FULLRELEASE\" ]; then\n  rmitags=\"${rmitags} tinode/exporter:latest tinode/exporter:${ver[0]}.${ver[1]}\"\n  buildtags=\"${buildtags}  --tag tinode/exporter:latest --tag tinode/exporter:${ver[0]}.${ver[1]}\"\nfi\ndocker rmi ${rmitags}\ndocker ${buildcmd} --build-arg VERSION=$tag ${buildtags} docker/exporter\n"
  },
  {
    "path": "docker-release.sh",
    "content": "#!/bin/bash\n\n# Publish Tinode docker images to hub.docker.com\n\nfunction containerName() {\n  if [ \"$1\" == \"alldbs\" ]; then\n    # For alldbs, container name is simply tinode.\n    local name=\"tinode\"\n  else\n    # Otherwise, tinode-$dbtag.\n    local name=\"tinode-${dbtag}\"\n  fi\n  echo $name\n}\n\nfor line in $@; do\n  eval \"$line\"\ndone\n\ntag=${tag#?}\n\nif [ -z \"$tag\" ]; then\n    echo \"Must provide tag as 'tag=v1.2.3' or 'v1.2.3-abc0'\"\n    exit 1\nfi\n\n# Convert tag into a version\nver=( ${tag//./ } )\n\nif [[ ${ver[2]} != *\"-\"* ]]; then\n  FULLRELEASE=1\nfi\n\nif [ \"$db\" ]; then\n  dbtags=( \"$db\" )\nelse\n  dbtags=( mysql postgres mongodb rethinkdb alldbs )\nfi\n\n# Read dockerhub login/password from a separate file\nsource .dockerhub\n\n# Login to docker hub\ndocker login -u $user -p $pass\n\n# Deploy images for various DB backends\nfor dbtag in \"${dbtags[@]}\"\ndo\n  name=\"$(containerName $dbtag)\"\n  # Deploy tagged image\n  if [ -n \"$FULLRELEASE\" ]; then\n    docker push tinode/${name}:latest\n    docker push tinode/${name}:\"${ver[0]}.${ver[1]}\"\n  fi\n  docker push tinode/${name}:\"${ver[0]}.${ver[1]}.${ver[2]}\"\ndone\n\nif [ \"$db\" ]; then\n  exit 0\nfi\n\n# Deploy chatbot images\nif [ -n \"$FULLRELEASE\" ]; then\n  docker push tinode/chatbot:latest\n  docker push tinode/chatbot:\"${ver[0]}.${ver[1]}\"\nfi\ndocker push tinode/chatbot:\"${ver[0]}.${ver[1]}.${ver[2]}\"\n\n# Deploy exporter images\nif [ -n \"$FULLRELEASE\" ]; then\n  docker push tinode/exporter:latest\n  docker push tinode/exporter:\"${ver[0]}.${ver[1]}\"\nfi\ndocker push tinode/exporter:\"${ver[0]}.${ver[1]}.${ver[2]}\"\n\ndocker logout\n"
  },
  {
    "path": "docs/API.md",
    "content": "<!-- TOC depthfrom:1 depthto:6 withlinks:true updateonsave:true orderedlist:false -->\n\n- [Server API](#server-api)\n  - [How it Works?](#how-it-works)\n  - [General Considerations](#general-considerations)\n  - [Connecting to the Server](#connecting-to-the-server)\n    - [gRPC](#grpc)\n    - [WebSocket](#websocket)\n    - [Long Polling](#long-polling)\n    - [Out of Band Large Files](#out-of-band-large-files)\n    - [Running Behind a Reverse Proxy](#running-behind-a-reverse-proxy)\n  - [Users](#users)\n    - [Authentication](#authentication)\n      - [Creating an Account](#creating-an-account)\n      - [Logging in](#logging-in)\n      - [Changing Authentication Parameters](#changing-authentication-parameters)\n      - [Resetting a Password, i.e. \"Forgot Password\"](#resetting-a-password-ie-forgot-password)\n    - [Suspending a User](#suspending-a-user)\n    - [Credential Validation](#credential-validation)\n    - [Access Control](#access-control)\n  - [Topics](#topics)\n    - [me Topic](#me-topic)\n    - [fnd and Tags: Finding Users and Topics](#fnd-and-tags-finding-users-and-topics)\n      - [Query Language](#query-language)\n      - [Incremental Updates to Queries](#incremental-updates-to-queries)\n      - [Query Rewrite](#query-rewrite)\n      - [Possible Use Cases](#possible-use-cases)\n    - [Peer to Peer Topics](#peer-to-peer-topics)\n    - [Group Topics](#group-topics)\n    - [sys Topic](#sys-topic)\n  - [Using Server-Issued Message IDs](#using-server-issued-message-ids)\n  - [User Agent and Presence Notifications](#user-agent-and-presence-notifications)\n  - [Trusted, Public, Private, Auxiliary Fields](#trusted-public-private-auxiliary-fields)\n    - [Trusted](#trusted)\n    - [Public](#public)\n    - [Private](#private)\n    - [Auxiliary](#auxiliary)\n  - [Format of Content](#format-of-content)\n  - [Out-of-Band Handling of Large Files](#out-of-band-handling-of-large-files)\n    - [Uploading](#uploading)\n    - [Downloading](#downloading)\n  - [Push Notifications](#push-notifications)\n    - [Tinode Push Gateway](#tinode-push-gateway)\n    - [Google FCM](#google-fcm)\n    - [Stdout](#stdout)\n  - [Video Calls](#video-calls)\n  - [Link Previews](#link-previews)\n  - [Messages](#messages)\n    - [Client to Server Messages](#client-to-server-messages)\n      - [{hi}](#hi)\n      - [{acc}](#acc)\n      - [{login}](#login)\n      - [{sub}](#sub)\n      - [{leave}](#leave)\n      - [{pub}](#pub)\n      - [{get}](#get)\n      - [{set}](#set)\n      - [{del}](#del)\n      - [{note}](#note)\n    - [Server to Client Messages](#server-to-client-messages)\n      - [{data}](#data)\n      - [{ctrl}](#ctrl)\n      - [{meta}](#meta)\n      - [{pres}](#pres)\n      - [{info}](#info)\n\n<!-- /TOC -->\n\n# Server API\n\n## How it Works?\n\nTinode is an IM router and a store. Conceptually it loosely follows a [publish-subscribe](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) model.\n\nServer connects sessions, users, and topics. Session is a network connection between a client application and the server. User represents a human being who connects to the server with a session. Topic is a named communication channel which routes content between sessions.\n\nUsers and topics are assigned unique IDs. User ID is a string with 'usr' prefix followed by base64-URL-encoded pseudo-random 64-bit number, e.g. `usr2il9suCbuko`. Topic IDs are described below.\n\nClients such as mobile or web applications create sessions by connecting to the server over a websocket or through long polling. Client authentication is required in order to perform most operations. Client authenticates the session by sending a `{login}` packet. See [Authentication](#authentication) section for details. Once authenticated, the client receives a token which is used for authentication later. Multiple simultaneous sessions may be established by the same user. Logging out is not supported (and not needed).\n\nOnce the session is established, the user can start interacting with other users through topics. The following\ntopic types are available:\n\n* `me` is a topic for managing one's profile and receiving notifications about other topics; `me` topic exists for every user.\n* `fnd` topic is used for finding other users and topics; `fnd` topic also exists for every user.\n* Peer to peer topic is a communication channel strictly between two users. Each participant sees topic name as the ID of the other participant: 'usr' prefix followed by a base64-URL-encoded numeric part of user ID, e.g. `usr2il9suCbuko`.\n* Group topic is a channel for multi-user communication. It's named as 'grp' followed by 11 pseudo-random characters, i.e. `grpYiqEXb4QY6s`. Group topics must be explicitly created.\n\nSession joins a topic by sending a `{sub}` packet. Packet `{sub}` serves three functions: creating a new topic, subscribing user to a topic, and attaching session to a topic. See [`{sub}`](#sub) section below for details.\n\nOnce the session has joined the topic, the user may start generating content by sending `{pub}` packets. The content is delivered to other attached sessions as `{data}` packets.\n\nThe user may query or update topic metadata by sending `{get}` and `{set}` packets.\n\nChanges to topic metadata, such as changes in topic description, or when other users join or leave the topic, is reported to live sessions with `{pres}` (presence) packet. The `{pres}` packet is sent either to the topic being affected or to the `me` topic.\n\nWhen user's `me` topic comes online (i.e. an authenticated session attaches to `me` topic), a `{pres}` packet is sent to `me` topics of all other users, who have peer to peer subscriptions with the first user.\n\n## General Considerations\n\nTimestamps are always represented as [RFC 3339](http://tools.ietf.org/html/rfc3339)-formatted string with precision up to milliseconds and timezone always set to UTC, e.g. `\"2015-10-06T18:07:29.841Z\"`.\n\nWhenever base64 encoding is mentioned, it means base64 URL encoding with padding characters stripped, see [RFC 4648](http://tools.ietf.org/html/rfc4648).\n\nThe `{data}` packets have server-issued sequential IDs: base-10 numbers starting at 1 and incrementing by one with every message. They are guaranteed to be unique per topic.\n\nIn order to connect requests to responses, client may assign message IDs to all packets set to the server. These IDs are strings defined by the client. Client should make them unique at least per session. The client-assigned IDs are not interpreted by the server, they are returned to the client as is.\n\n## Connecting to the Server\n\nThere are three ways to access the server over the network: websocket, long polling, and [gRPC](https://grpc.io/).\n\nWhen the client establishes a connection to the server over HTTP(S), such as over a websocket or long polling, the server offers the following endpoints:\n * `/v0/channels` for websocket connections\n * `/v0/channels/lp` for long polling\n * `/v0/file/u` for file uploads\n * `/v0/file/s` for serving files (downloads)\n\n`v0` denotes API version (currently zero). Every HTTP(S) request must include the API key. The server checks for the API key in the following order:\n* HTTP header `X-Tinode-APIKey`\n* URL query parameter `apikey` (/v0/file/s/abcdefg.jpeg?apikey=...)\n* Form value `apikey`\n* Cookie `apikey`\n\nA default API key is included with every demo app for convenience. Generate your own key for production using [`keygen` utility](../keygen).\n\nOnce the connection is opened, the client must issue a `{hi}` message to the server. Server responds with a `{ctrl}` message which indicates either success or an error. The `params` field of the response contains server's protocol version `\"params\":{\"ver\":\"0.15\"}` and may include other values.\n\n### gRPC\n\nSee definition of the gRPC API in the [proto file](../pbx/model.proto). gRPC API has slightly more functionality than the API described in this document: it allows the `root` user to send messages on behalf of other users as well as delete users.\n\nThe `bytes` fields in protobuf messages expect JSON-encoded UTF-8 content. For example, a string should be quoted before being converted to bytes as UTF-8: `[]byte(\"\\\"some string\\\"\")` (Go), `'\"another string\"'.encode('utf-8')` (Python 3).\n\n### WebSocket\n\nMessages are sent in text frames, one message per frame. Binary frames are reserved for future use. By default server allows connections with any value in the `Origin` header.\n\n### Long Polling\n\nLong polling works over `HTTP POST` (preferred) or `GET`. In response to client's very first request server sends a `{ctrl}` message containing `sid` (session ID) in `params`. Long polling client must include `sid` in every subsequent request either in the URL or in the request body.\n\nServer allows connections from all origins, i.e. `Access-Control-Allow-Origin: *`\n\n### Out of Band Large Files\n\nLarge files are sent out of band using `HTTP POST` as `Content-Type: multipart/form-data`. See [below](#out-of-band-handling-of-large-files) for details.\n\n### Running Behind a Reverse Proxy\n\nTinode server can be set up to run behind a reverse proxy, such as NGINX. For efficiency it can accept client connections from Unix sockets by setting `listen` and/or `grpc_listen` config parameters to the path of the Unix socket file, e.g. `unix:/run/tinode.sock`. The server may also be configured to read peer's IP address from `X-Forwarded-For` HTTP header by setting `use_x_forwarded_for` config parameter to `true`.\n\n## Users\n\nUser is meant to represent a person, an end-user: producer and consumer of messages.\n\nUsers are generally assigned one of the two authentication levels: authenticated `auth` or anonymous `anon`. The third level `root` is only accessible over `gRPC` where it permits the `root` to send messages on behalf of other users.\n\nWhen a connection is first established, the client application can send either an `{acc}` or a `{login}` message which authenticates the user at one the levels.\n\nEach user is assigned a unique ID. The IDs are composed as `usr` followed by base64-encoded 64-bit numeric value, e.g. `usr2il9suCbuko`. Users also have the following properties:\n\n* `created`: timestamp when the user record was created\n* `updated`: timestamp of when user's `public` or `trusted` was last updated\n* `status`: state of the account\n* `username`: unique string used in `basic` authentication; username is not accessible to other users\n* `defacs`: object describing user's default access mode for peer to peer conversations with authenticated and anonymous users; see [Access control](#access-control) for details\n  * `auth`: default access mode for authenticated `auth` users\n  * `anon`: default access for anonymous `anon` users\n* `trusted`: an application-defined object issued by the system administration. Anyone can read it but only system administrators can change it.\n* `public`: an application-defined object that describes the user. Anyone can query user for `public` data.\n* `private`: an application-defined object that is unique to the current user and accessible only by the user.\n* `tags`: [discovery](#fnd-and-tags-finding-users-and-topics) and credentials.\n\nUser's account has a state. The following states are defined:\n * `ok` (normal): the default state which means the account is not restricted in any way and can be used normally;\n * `susp` (suspended): the user is prevented from accessing the account as well as not found through [search](#fnd-and-tags-finding-users-and-topics); the state can be assigned by the administrator and fully reversible.\n * `del` (soft-deleted): user is marked as deleted but user's data is retained; un-deleting the user is not currenly supported.\n * `undef` (undefined): used internally by authenticators; should not be used elsewhere.\n\nA user may maintain multiple simultaneous connections (sessions) with the server. Each session is tagged with a client-provided `User Agent` string intended to differentiate client software.\n\nLogging out is not supported by design. If an application needs to change the user, it should open a new connection and authenticate it with the new user credentials.\n\n\n### Authentication\n\nAuthentication is conceptually similar to [SASL](https://en.wikipedia.org/wiki/Simple_Authentication_and_Security_Layer): it's provided as a set of adapters each implementing a different authentication method. Authenticators are used during account registration [`{acc}`](#acc) and during [`{login}`](#login). The server comes with the following authentication methods out of the box:\n\n * `token` provides authentication by a cryptographic token.\n * `basic` provides authentication by a login-password pair.\n * `anonymous` is designed for cases where users are temporary, such as handling customer support requests through chat.\n * `rest` is a [meta-method](../server/auth/rest/) which allows use of external authentication systems by means of JSON RPC.\n\nAny other authentication method can be implemented using adapters.\n\nThe `token` is intended to be the primary means of authentication. Tokens are designed in such a way that token authentication is light weight. For instance, token authenticator generally does not make any database calls, all processing is done in-memory. All other authentication methods are intended to be used only to obtain or refresh the token. Once the token is obtained, subsequent logins should use it.\n\nThe `basic` authentication scheme expects `secret` to be a base64-encoded string of a string composed of a user name followed by a colon `:` followed by a plan text password. User name in the `basic` scheme must not contain the colon character `:` (ASCII 0x3A).\n\nThe `anonymous` scheme can be used to create accounts, it cannot be used for logging in: a user creates an account using `anonymous` scheme and obtains a cryptographic token which it uses for subsequent `token` logins. If the token is lost or expired, the user is no longer able to access the account.\n\nCompiled-in authenticator names may be changed by using `logical_names` configuration feature. For example, a custom `rest` authenticator may be exposed as `basic` instead of default one or `token` authenticator could be hidden from users. The feature is activated by providing an array of mappings in the config file: `logical_name:actual_name` to rename or `actual_name:` to hide. For instance, to use a `rest` service for basic authentication use `\"logical_names\": [\"basic:rest\"]`.\n\n\n#### Creating an Account\n\nWhen a new account is created, the user must inform the server which authentication method will be later used to gain access to this account as well as provide shared secret, if appropriate. Only `basic` and `anonymous` can be used during account creation. The `basic` requires the user to generate and send a unique login and password to the server. The `anonymous` does not exchange secrets.\n\nUser may optionally set `{acc login=true}` to use the new account for immediate authentication. When `login=false` (or not set), the new account is created but the authentication status of the session which created the account remains unchanged. When `login=true` the server will attempt to authenticate the session with the new account, the `{ctrl}` response to the `{acc}` request will contain the authentication token on success. This is particularly important for the `anonymous` authentication because that's the only time when the authentication token can be retrieved.\n\n#### Logging in\n\nLogging in is performed by issuing a `{login}` request. Logging in is possible with `basic` and `token` only. Response to any login is a `{ctrl}` message with either a code 200 and a token which can be used in subsequent logins with `token` authentication, or a code 300 request for additional information, such as verifying credentials or responding to a method-dependent challenge in multi-step authentication, or a code 4xx error.\n\nToken has server-configured expiration time so it needs to be periodically refreshed.\n\n#### Changing Authentication Parameters\n\nUser may change authentication parameters, such as changing login and password, by issuing an `{acc}` request. Only `basic` authentication currently supports changing parameters:\n```js\nacc: {\n  id: \"1a2b3\", // string, client-provided message id, optional\n  user: \"usr2il9suCbuko\", // user being affected by the change, optional\n  token: \"XMg...g1Gp8+BO0=\", // authentication token if the session\n                             // is not yet authenticated, optional.\n  scheme: \"basic\", // authentication scheme being updated.\n  secret: base64encode(\"new_username:new_password\") // new parameters\n}\n```\nIn order to change just the password, `username` should be left empty, i.e. `secret: base64encode(\":new_password\")`.\n\nIf the session is not authenticated, the request must include a `token`. It can be a regular authentication token obtained during login, or a restricted token received through [Resetting a Password](#resetting-a-password) process. If the session is authenticated, the token must not be included. If the request is authenticated for access level `ROOT`, then the `user` may be set to a valid ID of another user. Otherwise it must be blank (defaulting to the current user) or equal to the ID of the current user.\n\n\n#### Resetting a Password, i.e. \"Forgot Password\"\n\nTo reset login or password, (or any other authentication secret, if such action is supported by the authenticator), one sends a `{login}` message with the `scheme` set to `reset` and the `secret` containing a base64-encoded string \"`authentication scheme to reset secret for`:`reset method`:`reset method value`\". Most basic case of resetting a password by email is\n```js\nlogin: {\n  id: \"1a2b3\",\n  scheme: \"reset\",\n  secret: base64encode(\"basic:email:jdoe@example.com\")\n}\n```\nwhere `jdoe@example.com` is an earlier validated user's email.\n\nIf the email matches the registration, the server will send a message using specified method and address with instructions for resetting the secret. The email contains a restricted security token which the user can include into an `{acc}` request with the new secret as described in [Changing Authentication Parameters](#changing-authentication-parameters).\n\n### Suspending a User\n\nUser's account can be suspended by service administrator. Once the account is suspended, the user is no longer able to login and use the service.\n\nOnly the `root` user may suspend the account. To suspend the account the root user sends the following message:\n```js\nacc: {\n  id: \"1a2b3\", // string, client-provided message id, optional\n  user: \"usr2il9suCbuko\", // user being affected by the change\n  status: \"susp\"\n}\n```\nSending the same message with `status: \"ok\"` un-suspends the account. A root user may check account status by executing `{get what=\"desc\"}` command against user's `me` topic.\n\n\n### Credential Validation\n\nServer may be optionally configured to require validation of certain credentials associated with the user accounts and authentication scheme. For instance, it's possible to require user to provide a unique email or a phone number, or to solve a captcha as a condition of account registration.\n\nThe server supports verification of email out of the box with just a configuration change. is mostly functional, verification of phone numbers is not functional because a commercial subscription is needed in order to be able to send text messages (SMS).\n\nIf certain credentials are required, then user must maintain them in validated state at all times. It means if a required credential has to be changed, the user must first add and validate the new credential and only then remove the old one.\n\nCredentials are initially assigned at registration time by sending an `{acc}` message, added using `{set topic=\"me\"}`, deleted using `{del topic=\"me\"}`, and queries by `{get topic=\"me\"}` messages. Credentials are verified by the client by sending either a `{login}` or an `{acc}` message.\n\n\n### Access Control\n\nAccess control manages user's access to topics through access control lists (ACLs). The access is assigned individually to each user-topic pair (subscription).\n\nAccess control is mostly usable for group topics. Its usability for `me` and P2P topics is limited to managing presence notifications and banning uses from initiating or continuing P2P conversations. All channel readers are given the same permissions.\n\nUser's access to a topic is defined by two sets of permissions: user's desired permissions \"want\", and permissions granted to user by topic's manager(s) \"given\". Each permission is represented by a bit in a bitmap. It can be either present or absent. The actual access is determined as a bitwise AND of wanted and given permissions. The permissions are communicated in messages as a set of ASCII characters, where presence of a character means a set permission bit:\n\n* No access: `N` is not a permission per se but an indicator that permissions are explicitly cleared/not set. It usually indicates that the default permissions should *not* be applied.\n* Join: `J`, permission to subscribe to a topic\n* Read: `R`, permission to receive `{data}` packets\n* Write: `W`, permission to `{pub}` to topic\n* Presence: `P`, permission to receive presence updates `{pres}`\n* Approve: `A`, permission to approve requests to join a topic, remove and ban members; a user with such permission is topic's administrator\n* Sharing: `S`, permission to invite other people to join the topic\n* Delete: `D`, permission to hard-delete messages; only owners can completely delete topics\n* Owner: `O`, user is the topic owner; the owner can assign any other permission to any topic member, change topic description, delete topic; topic may have a single owner only; some topics have no owner\n\nWhen a user subscribes to a topic or starts a chat with another user, the access permissions are either set explicitly or assigned by default `defacs`. Access permissions can be modified by sending `{set}` messages.\n\nA client may set explicit permissions in `{sub}` and `{set}` messages. If the permissions are missing or set to an empty string (not `N`!), Tinode will use default permissions `defacs` assigned earlir. If no default permissions are found, the authenticated users in group topics will receive a `JRWPS` access, in P2P topics will get `JRWPA`; anonymous users will receive `N` (no access) which means every subscription request must be explicitly approved by the topic manager.\n\nDefault access is defined for two categories of users: authenticated and anonymous. The default access value is applied as a \"given\" permission to all new subscriptions. Topic's default access is established at the topic creation time by `{sub.desc.defacs}` and can be subsequently modified by the owner by sending `{set}` messages. Likewise, user's default access is established at the account creation time by `{acc.desc.defacs}` and can be modified by the user by sending a `{set}` message to `me` topic.\n\n\n## Topics\n\nTopic is a named communication channel for one or more people. Topics have persistent properties. These topic properties can be queried by `{get what=\"desc\"}` message.\n\nTopic properties independent of the user making the query:\n* `created`: timestamp of topic creation time\n* `updated`: timestamp of when topic's `trusted`, `public`, or `private` was last updated\n* `touched`: timestamp of the last message sent to the topic\n* `defacs`: object describing topic's default access mode for authenticated and anonymous users; see [Access control](#access-control) for details\n * `auth`: default access mode for authenticated users\n * `anon`: default access for anonymous users\n* `seq`: integer server-issued sequential ID of the latest `{data}` message sent through the topic\n* `trusted`: an application-defined object issued by the system administrators. Anyone can read it but only administrators can change it.\n* `public`: an application-defined object that describes the topic. Anyone who can subscribe to topic can receive topic's `public` data, only topic `owner` can change it.\n\nUser-dependent topic properties:\n* `acs`: object describing given user's current access permissions; see [Access control](#access-control) for details\n * `want`: access permission requested by this user\n * `given`: access permissions given to this user\n* `private`: an application-defined object that is unique to the current user (topic subscriber).\n\nTopic usually have subscribers. One of the subscribers may be designated as topic owner (`O` access permission) with full access permissions. The list of subscribers can be queries with a `{get what=\"sub\"}` message. The list of subscribers is returned in a `sub` section of a `{meta}` message.\n\n### `me` Topic\n\nTopic `me` is automatically created for every user at the account creation time. It serves as means of managing account information, receiving presence notification from people and topics of interest. Topic `me` has no owner. The topic cannot be deleted or unsubscribed from. One can `leave` the topic which will stop all relevant communication and indicate that the user is offline (although the user may still be logged in and may continue to use other topics).\n\nJoining or leaving `me` generates a `{pres}` presence update sent to all users who have peer to peer topics with the given user and `P` permissions set.\n\nTopic `me` is read-only. `{pub}` messages to `me` are rejected.\n\nMessage `{get what=\"desc\"}` to `me` is automatically replied with a `{meta}` message containing `desc` section with the topic parameters (see intro to [Topics](#topics) section). The `public` parameters of `me` topic is data that the user wants to show to his/her connections. Changing it changes `public` not just for the `me` topic, but also everywhere where user's `public` is shown, such as `public` of all user's peer to peer topics.\n\nMessage `{get what=\"sub\"}` to `me` is different from any other topic as it returns the list of topics that the current user is subscribed to as opposite to the expected user's subscription to `me`.\n* seq: server-issued numeric id of the last message in the topic\n* recv: seq value self-reported by the current user as received\n* read: seq value self-reported by the current user as read\n* seen: for P2P subscriptions, timestamp of user's last presence and User Agent string are reported\n * when: timestamp when the user was last online\n * ua: user agent string of the user's client software last used\n\nMessage `{get what=\"data\"}` to `me` is rejected.\n\nInternally the `me` topics are not persisted separately from the users. The `me` topics don't exist in the `topics` table or collection, they are created in memory from the `users` database record.\n\n### `slf` Topic\n\nTopic `slf` (self) provides mechanism for storing information, like bookmarks or saved messages. Messages sent to `slf` are accessible only by the user who sent them.\n\nThis topic is created automatically when the user subscribes to it for the first time.\n\n### `fnd` and Tags: Finding Users and Topics\n\nTopic `fnd` is automatically created for every user at the account creation time. It serves as an endpoint for discovering other users and group topics. Users and group topics can be discovered by `tags`. Tags are optionally assigned at the topic or user creation time then can be updated by using `{set what=\"tags\"}` against a `me` or a group topic.\n\nA tag is an arbitrary case-insensitive Unicode string (forced to lowercase on the server) up to 96 characters long which may contain characters from `Letter` and `Number` Unicode [classes/categories](https://en.wikipedia.org/wiki/Unicode_character_property#General_Category) as well as any of the following ASCII characters: `_`, `.`, `+`, `-`, `@`, `#`, `!`, `?`.\n\nTag may have a prefix which serves as a namespace. The prefix is a 2-16 character string which starts with a letter [a-z] and may contain lowercase ASCII letters and numbers followed by a colon `:`, ex. prefixed phone tag `tel:+14155551212` or prefixed email tag `email:alice@example.com`. Some prefixed tags are optionally enforced to be unique. In that case only one user or topic may have such a tag. Certain tags may be forced to be immutable to the user, i.e. user's attempts to add or remove an immutable tag will be rejected by the server.\n\nThe tags are indexed server-side and used in user and topic discovery. Search returns users and topics sorted by the number of matched tags in descending order.\n\nIn order to find users or topics, a user sets either `public` or `private` parameter of the `fnd` topic to a search query (see [Query language](#query-language)) then issues a `{get topic=\"fnd\" what=\"sub\"}` request. If both `public` and `private` are set, the `public` query is used. The `private` query is persisted across sessions and devices, i.e. all user's sessions see the same `private` query. The value of the `public` query is ephemeral, i.e. it's not saved to database and not shared between user's sessions. The `private` query is intended for large queries which do not change often, such as finding matches for everyone in user's contact list on a mobile phone. The `public` query is intended to be short and specific, such as finding some topic or a user who is not in the contact list.\n\nThe system responds with a `{meta}` message with the `sub` section listing details of the found users or topics formatted as subscriptions.\n\nTopic `fnd` is read-only. `{pub}` messages to `fnd` are rejected.\n\n_CURRENTLY UNSUPPORTED_ When a new user registers with tags matching the given query, the `fnd` topic will receive `{pres}` notification for the new user.\n\n[Plugins](../pbx) support `Find` service which can be used to replace default search with a custom one.\n\nInternally the `fnd` topics are not persisted separately from the users. The `fnd` topics don't exist in the `topics` table or collection, they are created in memory from the `users` database record.\n\n#### Query Language\n\nTinode query language is used to define search queries for finding users and topics. The query is a string containing atomic terms separated by spaces or commas. The individual query terms are matched against user's or topic's tags. The individual terms may be written in an RTL language but the query as a whole is parsed left to right. Spaces are treated as the `AND` operator, commas (as well as commas preceded and/or followed by a space) as the `OR` operator. The order of operators is ignored: all `AND` tags are grouped together, all `OR` tags are grouped together. `OR` takes precedence over `AND`: if a tag is preceded of followed by a comma, it's an `OR` tag, otherwise an `AND`. For example, `aaa bbb, ccc` (`aaa AND bbb OR ccc`) is interpreted as `(bbb OR ccc) AND aaa`.\n\nQuery terms containing spaces must convert spaces to underscores ` ` -> `_`, e.g. `new york` -> `new_york`.\n\n**Some examples:**\n* `flowers`: find topics or users which contain tag `flowers`.\n* `flowers travel`: find topics or users which contain both tags `flowers` and `travel`.\n* `flowers, travel`: find topics or users which contain either tag `flowers` or `travel` (or both).\n* `flowers travel, puppies`: find topics or users which contain `flowers` and either `travel` or `puppies`, i.e. `(travel OR puppies) AND flowers`.\n* `flowers, travel puppies, kittens`: find topics or users which contain either one of `flowers`, `travel`, `puppies`, or `kittens`, i.e. `flowers OR travel OR puppies OR kittens`. The space between `travel` and `puppies` is treated as `OR` due to `OR` taking precedence over `AND`.\n\n#### Incremental Updates to Queries\n\n_CURRENTLY UNSUPPORTED_ Queries, particularly `fnd.private` could be arbitrarily large, limited only by the limits on the message size, and by the limits on the query size in the underlying database. Instead of rewriting the entire query to add or remove a term, terms can be added or removed incrementally.\n\nThe incremental update request is processed left to right. It may contain the same term multiple times, i.e. `-a_tag+a_tag` is a valid request.\n\n#### Query Rewrite\n\nFinding users by login, phone or email requires query terms to be written with prefixes, i.e. `email:alice@example.com` instead of `alice@example.com`. This may present a problem to end users because it requires them to learn the query language. Tinode solves this problem by implementing _query rewrite_ on the server: if query term (tag) does not contain a prefix, server rewrites it by adding the appropriate prefix. In queries to `fnd.public` the original term is also kept (query `alice@example.com` is rewritten as `email:alice@example.com OR alice@example.com`), in queries to `fnd.private` only the rewritten term is kept (`alice@example.com` is rewritten as `email:alice@example.com`). All terms that look like email, for instance, `alice@example.com` are rewritten to `email:alice@example.com OR alice@example.com`. Terms which look like phone numbers are converted to [E.164](https://en.wikipedia.org/wiki/E.164) and also rewritten as `tel:+14155551212 OR +14155551212`. In addition, in queries to `fnd.public` all other unprefixed terms which look like logins are rewritten as logins: `alice` -> `basic:alice OR alice`.\n\nAs described above, tags which look like phone numbers are converted to E.164 format. Such conversion requires an ISO 3166-1 alpha-2 country code. The following logic is used when converting phone number tags to E.164:\n* If the tag already contains a country calling code, it's used as is: `+1(415)555-1212` -> `+14155551212`.\n* If the tag has no prefix, country code is taken from the locale value set by the client in `lang` field of the `{hi}` message.\n* If client has not provided the code in the `hi.lang`, the country code is taken from `default_country_code` field of the `tinode.conf`.\n* If no `default_country_code` is set in `tinode.conf`, `US` country code is used.\n\n#### Possible Use Cases\n* Restricting users to organisations.\n  An immutable tag(s) may be assigned to the user which denotes the organisation the user belongs to. When the user searches for other users or topics, the search can be restricted to always contain the tag. This approach can be used to segment users into organisations with limited visibility into each other.\n\n* Search by geographical location.\n  Client software may periodically assign a [geohash](https://en.wikipedia.org/wiki/Geohash) tag to the user based on current location. Searching for users in a given area would mean matching on geohash tags.\n\n* Search by numerical range, such as age range.\n  The approach is similar to geohashing. The entire range of numbers is covered by the smallest possible power of 2, for instance the range of human ages is covered by 2<sup>7</sup>=128 years. The entire range is split in two halves: the range 0-63 is denoted by 0, 64-127 by 1. The operation is repeated with each subrange, i.e. 0-31 is 00, 32-63 is 01, 0-15 is 000, 32-47 is 010. Once completed, the age 30 will belong to the following ranges: 0 (0-63), 00 (0-31), 001 (16-31), 0011 (24-31), 00111 (28-31), 001111 (30-31), 0011110 (30). A 30 y.o. user is assigned a few tags to indicate the age, i.e. `age:00111`, `age:001111`,  and `age:0011110`. Technically, all 7 tags may be assigned but usually it's impractical. To query for anyone in the age range 28-35 convert the range into a minimal number of tags: `age:00111` (28-31), `age:01000` (32-35). This query will match the 30 y.o. user by tag `age:00111`.\n\n\n### Peer to Peer Topics\n\nPeer to peer (P2P) topics represent communication channels between strictly two users. The name of the topic is different for each of the two participants. Each of them sees the name of the topic as the user ID of the other participant: `usr` followed by base64 URL-encoded ID of the user. For example, if two users `usrOj0B3-gSBSs` and `usrIU_LOVwRNsc` start a P2P topic, the first one will see it as `usrIU_LOVwRNsc`, the second as `usrOj0B3-gSBSs`. The P2P topic has no owner.\n\nA P2P topic is created by one user subscribing to topic with the name equal to the ID of the other user. For instance, user `usrOj0B3-gSBSs` can establish a P2P topic with user `usrIU_LOVwRNsc` by sending a `{sub topic=\"usrIU_LOVwRNsc\"}`. Tinode will respond with a `{ctrl}` packet with the name of the newly created topic as described above. The other user will receive a `{pres}` message on `me` topic with updated access permissions.\n\nInternally, P2P topics are stored as `p2p` followed by base64 URL encoded concatenation of two 64-bit user IDs, with the lower numeric value ID first: `p2pm7PvMGmdcx_uVkDRaSTbwA`.\n\nThe 'public' parameter of P2P topics is user-dependent. For instance a P2P topic between users A and B would show user A's 'public' to user B and vice versa. If a user updates 'public', all user's P2P topics will automatically update 'public' too.\n\nThe 'private' parameter of a P2P topic is defined by each participant individually as with any other topic type.\n\n### Group Topics\n\nGroup topic represents a communication channel between multiple users. The name of a group topic is `grp` or `chn` followed by a string of characters from base64 URL-encoding set. No other assumptions can be made about internal structure or length of the group name.\n\nGroup topics support limited number of subscribers (controlled by a `max_subscriber_count` parameter in configuration file) with access permissions of each subscriber managed individually. Group topics may also be enabled to support any number of read-only users - `readers`. All `readers` have the same access permissions. Group topics with enabled `readers` are called `channels`.\n\nA group topic is created by sending a `{sub}` message with the topic field set to string `new` or `nch` optionally followed by any characters, e.g. `new` or `newAbC123` are equivalent. Tinode will respond with a `{ctrl}` message with the name of the newly created topic, i.e. `{sub topic=\"new\"}` is replied with `{ctrl topic=\"grpmiKBkQVXnm3P\"}`. If topic creation fails, the error is reported on the original topic name, i.e. `new` or `newAbC123`. The user who created the topic becomes topic owner. Ownership can be transferred to another user with a `{set}` message but one user must remain the owner at all times.\n\nA `channel` topic is different from the non-channel group topic in the following ways:\n\n * Channel topic is created by sending `{sub topic=\"nch\"}`. Sending `{sub topic=\"new\"}` will create a group topic without enabling channel functionality.\n * Sending `{sub topic=\"chnAbC123\"}` will create a `reader` subscription to a channel. A non-channel topic will reject such subscription request.\n * When searching for topics using [`fnd`](#fnd-and-tags-finding-users-and-topics), channels will show addresses with `chn` prefixes, non-channel topic will show with `grp` prefixes.\n * Messages received by readers on channels have no `From` field. Normal subscribers will receive messages with `From` containing ID of the sender.\n * Default permissions for a channel and non-channel group topics are different: channel group topic grants no permissions at all.\n * A subscriber joining or leaving the topic (regular or channel-enabled) generates a `{pres}` message to all other subscribers who are currently in the joined state with the topic and have appropriate permissions. Reader joining or leaving the channel generates no `{pres}` message.\n\n### `sys` Topic\n\nThe `sys` topic serves as an always available channel of communication with the system administrators. A normal non-root user cannot subscribe to `sys` but can publish to it without subscription. Existing clients use this channel to report abuse by sending a Drafty-formatted `{pub}` message with the report as JSON attachment. A root user can subscribe to `sys` topic. Once subscribed, the root user will receive messages sent to `sys` topic by other users.\n\n## Using Server-Issued Message IDs\n\nTinode provides basic support for client-side caching of `{data}` messages in the form of server-issued sequential message IDs. The client may request the last message id from the topic by issuing a `{get what=\"desc\"}` message. If the returned ID is greater than the ID of the latest received message, the client knows that the topic has unread messages and their count. The client may fetch these messages using `{get what=\"data\"}` message. The client may also paginate history retrieval by using message IDs.\n\n## User Agent and Presence Notifications\n\nA user is reported as being online when one or more of user's sessions are attached to the `me` topic. Client-side software identifies itself to the server using `ua` (user agent) field of the `{login}` message. The _user agent_ is published in `{meta}` and `{pres}` messages in the following way:\n\n * When user's first session attaches to `me`, the _user agent_ from that session is broadcast in the `{pres what=\"on\" ua=\"...\"}` message.\n * When multiple user sessions are attached to `me`, the _user agent_ of the session where the most recent action has happened is reported in `{pres what=\"ua\" ua=\"...\"}`; the 'action' in this context means any message sent by the client. To avoid potentially excessive traffic, user agent changes are broadcast no more frequently than once a minute.\n * When user's last session detaches from `me`, the _user agent_ from that session is recorded together with the timestamp; the user agent is broadcast in the `{pres what=\"off\"  ua=\"...\"}` message and subsequently reported as the last online timestamp and user agent.\n\nAn empty `ua=\"\"` _user agent_ is not reported. I.e. if user attaches to `me` with non-empty _user agent_ then does so with an empty one, the change is not reported. An empty _user agent_ may be disallowed in the future.\n\n## Trusted, Public, Private, Auxiliary Fields\n\nTopics have `trusted`, `public`, `aux` fields, subscriptions have `private` fields. The primary difference between these fields is in access control:\n\n * `trusted`: writable by `ROOT` users, readable by anyone.\n * `public`: writable by the `owner` or the user, readable by anyone.\n * `aux`: writable by topic administrators, readable by subscribers.\n * `private`: readable and writable only by the user who created the subscription.\n\nGenerally, the fields are application-defined. The server does not enforce any particular structure of these fields except for `fnd` topic. At the same time, client software should use the same format for interoperability reasons. The following sections describe the format of these fields as they are implemented by all official clients.\n\nAlthough it's not yet enforced, if a third-party application defines custom keys, the key names should start with an `x-` followed by the application's fully qualified domain name, e.g. `x-example.com-value: \"abc\"`. The fields should contain primitive types only, i.e. `string`, `boolean`, `number`, or `null`.\n\n### Trusted\n\nThe format of the optional `trusted` field in group and peer to peer topics is a set of key-value pairs; `fnd` and `sys` topics do not have the `trusted`. The field is writable by `ROOT` users, readable by anyone who has access to the topic or user. The following optional keys are currently defined:\n```js\ntrusted: {\n  verified: true, // boolean, an indicator of a verified/trustworthy user or topic.\n  staff: true,    // boolean, an indicator that the user or topic\n                  // is a part of/belongs to the server administration.\n  danger: true    // boolean, an indicator that the user or topic are untrustworthy.\n}\n```\n\n### Public\n\nThe format of the `public` field in group, peer to peer, systems topics is expected to be [theCard](./thecard.md). The field is writable by by the user for users, the topic owner for topics. The field is readable by anyone who has access to topic or user.\n\nThe `fnd` topic expects `public` to be a string representing a [search query](#query-language)).\n\n### Private\n\nThe format of the `private` field in group and peer to peer topics is a set of key-value pairs. The field is writable and readable by the user only. The following keys are currently defined:\n```js\nprivate: {\n  comment: \"some comment\", // string, optional user comment about a topic or a peer user\n  arch: true, // boolean, indicator that the topic is archived by the user, i.e.\n              // should not be shown in the UI with other non-archived topics.\n  accepted: \"JRWS\", // string, 'given' mode accepted by the user.\n  tpins: [\"grpmiKBkQVXnm3P\", \"usrIU_LOVwRNsc\"] // array of topic IDs to pin to the top of\n              // the contacts list; 'me' topic only.\n}\n```\n\nThe `fnd` topic expects `private` to be a string representing a [search query](#query-language)).\n\n### Auxiliary\n\nThe format of the `aux` field is a set of key-value pairs. The `aux` is writable by topic admins and readable by all topic subscribers. The following keys are currently defined:\n\n```js\naux: {\n  pins: [1001, 23456] // array of integer message IDs to pin to the top of the message list.\n}\n```\n\n## Format of Content\n\nFormat of `content` field in `{pub}` and `{data}` is application-defined and as such the server does not enforce any particular structure of the field. At the same time, client software should use the same format for interoperability reasons. Currently the following two types of `content` are supported:\n * Plain text\n * [Drafty](./drafty.md)\n\nIf Drafty is used, a message header `\"head\": {\"mime\": \"text/x-drafty\"}` must be set.\n\n\n## Out-of-Band Handling of Large Files\n\nLarge files create problems when sent in-band for multiple reasons:\n * limits on database storage as in-band messages are stored in database fields\n * in-band messages must be downloaded completely as a part of downloading chat history\n\nTinode provides two endpoints for handling large files: `/v0/file/u` for uploading files and `v0/file/s` for downloading. The endpoints require the client to provide both [API key](#connecting-to-the-server) and login credentials. The server checks credentials in the following order:\n\n**Login credentials**\n * HTTP header `Authorization` (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization)\n * URL query parameters `auth` and `secret` (/v0/file/s/abcdefg.jpeg?auth=...&secret=...)\n * Form values `auth` and `secret`\n * Cookies `auth` and `secret`\n\n### Uploading\n\nTo upload a file first create an RFC 2388 multipart request then send it to the server using HTTP POST. The server responds to the request either with a `307 Temporary Redirect` with the new upload URL, or with a `200 OK` and a `{ctrl}` message in response body:\n\n```js\nctrl: {\n  params: {\n    url: \"/v0/file/s/mfHLxDWFhfU.pdf\"\n  },\n  code: 200,\n  text: \"ok\",\n  ts: \"2018-07-06T18:47:51.265Z\"\n}\n```\nIf `307 Temporary Redirect` is returned, the client must retry the upload at the provided URL. The URL returned in `307` response should be used for just this one upload. All subsequent uploads should try the default URL first.\n\nThe `ctrl.params.url` contains the path to the uploaded file at the current server. It could be either the full path like `/v0/file/s/mfHLxDWFhfU.pdf`, a relative path like `./mfHLxDWFhfU.pdf`, or just the file name `mfHLxDWFhfU.pdf`. Anything but the full path is interpreted against the default *download* endpoint `/v0/file/s/`. For instance, if `mfHLxDWFhfU.pdf` is returned then the file is located at `http(s)://current-tinode-server/v0/file/s/mfHLxDWFhfU.pdf`.\n\nOnce the URL of the file is received, either immediately or after following the redirect, the client may use the URL to send a `{pub}` message with the uploaded file as an attachment, or, if the file is an image, as an avatar image for a topic or user profile (see [theCard](./thecard.md)). For example, the URL can be used in a [Drafty](./drafty.md)-formatted `pub.content` field:\n\n```js\n{\n  pub: {\n    id: \"121103\",\n    topic: \"grpnG99YhENiQU\",\n    head: {\n      mime: \"text/x-drafty\"\n    },\n    content: {\n      ent: [\n      {\n        data: {\n        mime: \"image/jpeg\",\n        name: \"roses-are-red.jpg\",\n        ref:  \"/v0/file/s/sJOD_tZDPz0.jpg\",\n        size: 437265\n      },\n        tp: \"EX\"\n      }\n    ],\n    fmt: [\n      {\n        at: -1,\n      key:0,\n      len:1\n      }\n    ]\n    }\n  },\n  extra: {\n    attachments: [\"/v0/file/s/sJOD_tZDPz0.jpg\"]\n  }\n}\n```\n\nIt's important to list the used URLs in the `extra: attachments[...]` field. Tinode server uses this field to maintain the uploaded file's use counter. Once the counter drops to zero for the given file (for instance, because a message with the shared URL was deleted or because the client failed to include the URL in the `extra.attachments` field), the server will garbage collect the file. Only relative URLs should be used. Absolute URLs in the `extra.attachments` field are ignored. The URL value is expected to be the `ctrl.params.url` returned in response to upload.\n\n### Downloading\n\nThe serving endpoint `/v0/file/s` serves files in response to HTTP GET requests. The client must evaluate relative URLs against this endpoint, i.e. if it receives a URL `mfHLxDWFhfU.pdf` or `./mfHLxDWFhfU.pdf` it should interpret it as a path `/v0/file/s/mfHLxDWFhfU.pdf` at the current Tinode HTTP server.\n\n_Important!_ As a security measure, the client should not send security credentials if the download URL is absolute and leads to another server.\n\n## Push Notifications\n\nTinode uses compile-time adapters for handling push notifications. The server comes with [Tinode Push Gateway](../server/push/tnpg/), [Google FCM](https://firebase.google.com/docs/cloud-messaging/), and `stdout` adapters. Tinode Push Gateway and Google FCM support Android with [Play Services](https://developers.google.com/android/guides/overview) (may not be supported by some Chinese phones), iOS devices and all major web browsers excluding Safari. The `stdout` adapter does not actually send push notifications. It's mostly useful for debugging, testing and logging. Other types of push notifications such as [TPNS](https://intl.cloud.tencent.com/product/tpns) can be handled by writing appropriate adapters.\n\nIf you are writing a custom plugin, the notification payload is the following:\n```js\n{\n  topic: \"grpnG99YhENiQU\", // Topic which received the message.\n  xfrom: \"usr2il9suCbuko\", // ID of the user who sent the message.\n  ts: \"2019-01-06T18:07:30.038Z\", // message timestamp in RFC3339 format.\n  seq: \"1234\", // sequential ID of the message (integer value sent as text).\n  mime: \"text/x-drafty\", // optional message MIME-Type.\n  content: \"Lorem ipsum dolor sit amet, consectetur adipisci\", // The first 80 characters of the message content as plain text.\n}\n```\n\n### Tinode Push Gateway\n\nTinode Push Gateway (TNPG) is a proprietary Tinode service which sends push notifications on behalf of Tinode. Internally it uses Google FCM and as such supports the same platforms as FCM. The main advantage of using TNPG over FCM is simplicity of configuration: mobile clients do not need to be recompiled, all is needed is a [configuration update](../server/push/tnpg/) on a server.\n\n### Google FCM\n\n[Google FCM](https://firebase.google.com/docs/cloud-messaging/) supports Android with [Play Services](https://developers.google.com/android/guides/overview), iPhone and iPad devices, and all major web browsers excluding Safari. In order to use FCM mobile clients (iOS, Android) must be recompiled with credentials obtained from Google. See [instructions](../server/push/fcm/) for details.\n\n### Stdout\n\nThe `stdout` adapter is mostly useful for debugging and logging. It writes push payload to `STDOUT` where it can be redirected to file or read by some other process.\n\n## Video Calls\n\n[See separate document](call-establishment.md).\n\n## Link Previews\n\nTinode provides an optional service which helps client applications generate link (URL) previews for inclusion into messages. The enpoint of this service (if enabled) is located at `/v0/urlpreview`. The service takes a single parameter `url`:\n\n```\n/v0/urlpreview?url=https%3A%2F%2Ftinode.co\n```\nThe first several kilobytes of the document at the given URL is fetched by issuing an HTTP(S) GET request. If the returned document has content-type `text/html`, the HTML is parsed for page title, description, and image URL. The result is formatted as JSON and returned as\n\n```json\n{\"title\": \"Page title\", \"description\": \"This is a page description\", \"image_url\": \"https://tinode.co/img/logo64x64.png\"}\n```\n\nThe link preview service requires authentication. It's exactly the same as authentication for [Out of Band Large Files](#out-of-band-handling-of-large-files).\n\n## Messages\n\nA message is a logically associated set of data. Messages are passed as JSON-formatted UTF-8 text.\n\nAll client to server messages may have an optional `id` field. It's set by the client as means to receive an acknowledgement from the server that the message was received and processed. The `id` is expected to be a session-unique string but it can be any string. The server does not attempt to interpret it other than to check JSON validity. The `id` is returned unchanged by the server when it replies to the client message.\n\nServer requires strictly valid JSON, including double quotes around field names. For brevity the notation below omits double quotes around field names as well as outer curly brackets. Examples use `//` comments only for expressiveness. The comments cannot be used in actual communication with the server.\n\nFor messages that update application-defined data, such as `{set}` `private` or `public` fields, when server-side\ndata needs to be cleared, use a string with a single Unicode DEL character \"&#x2421;\" (`\\u2421`). I.e. sending `\"public\": null` will not clear the field, but sending `\"public\": \"␡\"` will.\n\nAny unrecognized fields are silently ignored by the server.\n\n### Client to Server Messages\n\nEvery client to server message contains the main payload described in the sections below and an optional top-level field `extra`:\n```js\n{\n  abc: { ... }, // Main payload, see sections below.\n  extra: {\n    attachments: [\"/v0/file/s/sJOD_tZDPz0.jpg\"], // Array of out-of-band attachments which have to be exempted from GC.\n    obo: \"usr2il9suCbuko\", // Alternative user ID set by the root user (obo = On Behalf Of).\n    authlevel: \"auth\"  // Altered authentication level set by the root user.\n  }\n}\n```\nThe `attachments` array lists URLs of files uploaded out of band. Such listing increments use counter of these files. Once the use counter drops to 0, the files will be automatically deleted.\nThe `obo` (On Behalf Of) can be set by the `root` user. If the `obo` is set, the server will treat the message as if it came from the specified user as opposite to the actual sender.\nThe `authlevel` is supplementary to the `obo` and permits setting custom authentication level for the user. A an `\"auth\"` level is used if the field is unset.\n\n#### `{hi}`\n\nHandshake message client uses to inform the server of its version and user agent. This message must be the first that\nthe client sends to the server. Server responds with a `{ctrl}` which contains server build `build`, wire protocol version `ver`,\nsession ID `sid` in case of long polling, as well as server constraints, all in `ctrl.params`.\n\n```js\nhi: {\n  id: \"1a2b3\",     // string, client-provided message id, optional\n  ver: \"0.15.8-rc2\", // string, version of the wire protocol supported by the client, required\n  ua: \"JS/1.0 (Windows 10)\", // string, user agent identifying client software,\n                   // optional\n  dev: \"L1iC2...dNtk2\", // string, unique value which identifies this specific\n                   // connected device for the purpose of push notifications; not\n                   // interpreted by the server.\n                   // see [Push notifications support](#push-notifications-support); optional\n  platf: \"android\", // string, underlying OS for the purpose of push notifications, one of\n                   // \"android\", \"ios\", \"web\"; if missing, the server will try its best to\n                   // detect the platform from the user agent string; optional\n  lang: \"en-US\"    // human language of the client device; optional\n}\n```\nThe user agent `ua` is expected to follow [RFC 7231 section 5.5.3](http://tools.ietf.org/html/rfc7231#section-5.5.3) recommendation but the format is not enforced. The message can be sent more than once to update `ua`, `dev` and `lang` values. If sent more than once, the `ver` field of the second and subsequent messages must be either unchanged or not set.\n\n#### `{acc}`\n\nMessage `{acc}` creates users or updates `tags` or authentication credentials `scheme` and `secret` of exiting users. To create a new user set `user` to the string `new` optionally followed by any character sequence, e.g. `newr15gsr`. Either authenticated or anonymous session can send an `{acc}` message to create a new user. To update authentication data or validate a credential of the current user leave `user` unset.\n\nThe `{acc}` message **cannot** be used to modify `desc` or `cred` of an existing user. Update user's `me` topic instead.\n\n```js\nacc: {\n  id: \"1a2b3\", // string, client-provided message id, optional\n  user: \"newABC123\", // string, \"new\" optionally followed by any characters to create a new user,\n              // default: current user, optional\n  token: \"XMgS...8+BO0=\", // string, authentication token to use for the request if the\n               // session is not authenticated, optional\n  // Temporary authentication parameters for one-off actions, like password reset.\n  tmpscheme: \"code\", // name of the temp wuth scheme\n  tmpsecret: \"XMgS...8+BO0=\", // temp auth secret\n  status: \"ok\", // change user's status; no default value, optional.\n  authlevel: \"auth\", // authentication level of the user when UserID is set and not equal\n              // to the current user; Either \"\", \"auth\" or \"anon\"; default: \"\"\n  scheme: \"basic\", // authentication scheme for this account, required;\n               // \"basic\" and \"anon\" are currently supported for account creation.\n  secret: base64encode(\"username:password\"), // string, base64 encoded secret for the chosen\n              // authentication scheme; to delete a scheme use a string with a single DEL\n              // Unicode character \"\\u2421\"; \"token\" and \"basic\" cannot be deleted\n  login: true, // boolean, use the newly created account to authenticate current session,\n              // i.e. create account and immediately use it to login.\n  tags: [\"alice johnson\",... ], // array of tags for user discovery; see 'fnd' topic for\n              // details, optional (if missing, user will not be discoverable other than\n              // by login)\n  cred: [  // account credentials which require verification, such as email or phone number.\n    {\n      meth: \"email\", // string, verification method, e.g. \"email\", \"tel\", \"recaptcha\", etc.\n      val: \"alice@example.com\", // string, credential to verify such as email or phone\n      resp: \"178307\", // string, verification response, optional\n      params: { ... } // parameters, specific to the verification method, optional\n    },\n  ...\n  ],\n\n  desc: {  // object, user initialisation data closely matching that of table\n           // initialisation; used only when creating an account; optional\n    defacs: {\n      auth: \"JRWS\", // string, default access mode for peer to peer conversations\n                   // between this user and other authenticated users\n      anon: \"N\"  // string, default access mode for peer to peer conversations\n                 // between this user and anonymous (un-authenticated) users\n    }, // Default access mode for user's peer to peer topics\n    public: { ... }, // application-defined payload to describe user,\n                // available to everyone\n    private: { ... } // private application-defined payload available only to user\n                // through 'me' topic\n  }\n}\n```\n\nServer responds with a `{ctrl}` message with `params` containing details of the new user account such as user ID and, in case of `login: true`, authentication token. If `desc.defacs` is missing, the server will assign server-default access permissions to new account.\n\nThe only supported authentication schemes for account creation are `basic` and `anonymous`.\n\n#### `{login}`\n\nLogin is used to authenticate the current session.\n\n```js\nlogin: {\n  id: \"1a2b3\",     // string, client-provided message id, optional\n  scheme: \"basic\", // string, authentication scheme; \"basic\",\n                   // \"token\", and \"reset\" are currently supported\n  secret: base64encode(\"username:password\"), // string, base64-encoded secret for the chosen\n                  // authentication scheme, required\n  cred: [\n    {\n      meth: \"email\", // string, verification method, e.g. \"email\", \"tel\", \"captcha\", etc, required\n      resp: \"178307\" // string, verification response, required\n    },\n  ...\n  ],   // response to a request for credential verification, optional\n}\n```\n\nServer responds to a `{login}` packet with a `{ctrl}` message. The `params` of the message contains the id of the logged in user as `user`. The `token` contains an encrypted string which can be used for authentication. Expiration time of the token is passed as `expires`.\n\n#### `{sub}`\n\nThe `{sub}` packet serves the following functions:\n * creating a new topic\n * subscribing user to an existing topic\n * attaching session to a previously subscribed topic\n * fetching topic data\n\nUser creates a new group topic by sending `{sub}` packet with the `topic` field set to `new12321` (regular topic) or `nch12321` (channel) where `12321` denotes any string including an empty string. Server will create a topic and respond back to the session with the name of the newly created topic.\n\nUser creates a new peer to peer topic by sending `{sub}` packet with `topic` set to peer's user ID.\n\nThe user is always subscribed to and the session is attached to the newly created topic.\n\nIf the user had no relationship with the topic, sending `{sub}` packet creates it. Subscribing means to establish a relationship between session's user and the topic where no relationship existed in the past.\n\nJoining (attaching to) a topic means for the session to start consuming content from the topic. Server automatically differentiates between subscribing and joining/attaching based on context: if the user had no prior relationship with the topic, the server subscribes the user then attaches the current session to the topic. If relationship existed, the server only attaches the session to the topic. When subscribing, the server checks user's access permissions against topic's access control list. It may grant immediate access, deny access, may generate a request for approval from topic managers.\n\nServer replies to the `{sub}` with a `{ctrl}`.\n\nThe `{sub}` message may include a `get` and `set` fields which mirror `{get}` and `{set}` messages. If included, server will treat them as a subsequent `{set}` and `{get}` messages on the same topic. If the `get` is set, the reply may include `{meta}` and `{data}` messages.\n\n\n```js\nsub: {\n  id: \"1a2b3\",  // string, client-provided message id, optional\n  topic: \"me\",  // topic to be subscribed or attached to\n  bkg: true,    // request to attach to topic is issued by an automated agent, server should delay sending\n                // presence notifications because the agent is expected to disconnect very quickly\n  // Object with topic initialisation data, new topics & new\n  // subscriptions only, mirrors {set} message\n  set: {\n  // New topic parameters, mirrors {set desc}\n    desc: {\n      defacs: {\n        auth: \"JRWS\", // string, default access for new authenticated subscribers\n        anon: \"N\"    // string, default access for new anonymous (un-authenticated)\n                     // subscribers\n      }, // Default access mode for the new topic\n      trusted: { ... }, // application-defined payload assigned by the system administration\n      public: { ... }, // application-defined payload to describe topic\n      private: { ... } // per-user private application-defined content\n    }, // object, optional\n\n    // Subscription parameters, mirrors {set sub}. 'sub.user' must be blank\n    sub: {\n      mode: \"JRWS\", // string, requested access mode, optional;\n                   // default: server-defined\n    }, // object, optional\n\n    tags: [ // array of strings, update to tags (see fnd topic description), optional.\n        \"email:alice@example.com\", \"tel:1234567890\"\n    ],\n\n    cred: { // update to credentials, optional.\n      meth: \"email\", // string, verification method, e.g. \"email\", \"tel\", \"recaptcha\", etc.\n      val: \"alice@example.com\", // string, credential to verify such as email or phone\n      resp: \"178307\", // string, verification response, optional\n      params: { ... } // parameters, specific to the verification method, optional\n    },\n\n    aux: { ... } // update auxiliary data.\n  },\n\n  get: {\n    // Metadata to request from the topic; space-separated list, valid strings\n    // are \"desc\", \"sub\", \"data\", \"tags\"; default: request nothing; unknown strings are\n    // ignored; see {get  what} for details\n    what: \"desc sub data\", // string, optional\n\n    // Optional parameters for {get what=\"desc\"}\n    desc: {\n      ims: \"2015-10-06T18:07:30.038Z\" // timestamp, \"if modified since\" - return\n              // public and private values only if at least one of them has been\n              // updated after the stated timestamp, optional\n    },\n\n    // Optional parameters for {get what=\"sub\"}\n    sub: {\n      ims: \"2015-10-06T18:07:30.038Z\", // timestamp, \"if modified since\" - return\n              // only those subscriptions which have been modified after the stated\n              // timestamp, optional\n      user: \"usr2il9suCbuko\", // string, return results for a single user,\n                            // any topic other than 'me', optional\n      topic: \"usr2il9suCbuko\", // string, return results for a single topic,\n                            // 'me' topic only, optional\n      limit: 20 // integer, limit the number of returned objects\n    },\n\n    // Optional parameters for {get what=\"data\"}, see {get what=\"data\"} for details\n    data: {\n      since: 123, // integer, load messages with server-issued IDs greater or equal\n            // to this (inclusive/closed), optional\n      before: 321, // integer, load messages with server-issued sequential IDs less\n            // than this (exclusive/open), optional\n      limit: 20, // integer, limit the number of returned objects,\n                 // default: 32, optional\n    } // object, optional\n  }\n}\n```\n\nSee [Trusted, Public, and Private Fields](#trusted-public-and-private-fields) for `trusted`, `private`, and `public` format considerations.\n\n#### `{leave}`\n\nThis is a counterpart to `{sub}` message. It also serves two functions:\n* leaving the topic without unsubscribing (`unsub=false`)\n* unsubscribing (`unsub=true`)\n\nServer responds to `{leave}` with a `{ctrl}` packet. Leaving without unsubscribing affects just the current session. Leaving with unsubscribing will affect all user's sessions.\n\n```js\nleave: {\n  id: \"1a2b3\",  // string, client-provided message id, optional\n  topic: \"grp1XUtEhjv6HND\",   // string, topic to leave, unsubscribe, or\n                              // delete, required\n  unsub: true // boolean, leave and unsubscribe, optional, default: false\n}\n```\n\n#### `{pub}`\n\nThe message is used to distribute content to topic subscribers.\n\n```js\npub: {\n  id: \"1a2b3\", // string, client-provided message id, optional\n  topic: \"grp1XUtEhjv6HND\", // string, topic to publish to, required\n  noecho: false, // boolean, suppress echo (see below), optional\n  head: { key: \"value\", ... }, // set of string key-value pairs, optional\n  content: { ... }  // object, application-defined content to publish\n               // to topic subscribers, required\n}\n```\n\nTopic subscribers receive the `content` in the [`{data}`](#data) message. By default the originating session gets a copy of `{data}` like any other session currently attached to the topic. If for some reason the originating session does not want to receive the copy of the data it just published, set `noecho` to `true`.\n\nSee [Format of Content](#format-of-content) for `content` format considerations.\n\nThe following values are currently defined for the `head` field:\n\n * `attachments`: an array of paths indicating media attached to this message `[\"/v0/file/s/sJOD_tZDPz0.jpg\"]`.\n * `auto`: `true` when the message was sent automatically, i.e. by a chatbot or an auto-responder.\n * `forwarded`: an indicator that the message is a forwarded message, a unique ID of the original message, `\"grp1XUtEhjv6HND:123\"`.\n * `mentions`: an array of user IDs mentioned (`@alice`) in the message: `[\"usr1XUtEhjv6HND\", \"usr2il9suCbuko\"]`.\n * `mime`: MIME-type of the message content, `\"text/x-drafty\"`; a `null` or a missing value is interpreted as `\"text/plain\"`.\n * `replace`: an indicator that the message is a correction/replacement for another message, a topic-unique ID of the message being updated/replaced, `\":123\"`\n * `reply`: an indicator that the message is a reply to another message, a unique ID of the original message, `\"grp1XUtEhjv6HND:123\"`.\n * `sender`: a user ID of the sender added by the server when the message is sent on behalf of another user, `\"usr1XUtEhjv6HND\"`.\n * `thread`: an indicator that the message is a part of a conversation thread, a topic-unique ID of the first message in the thread, `\":123\"`; `thread` is intended for tagging a flat list of messages as opposite to creating a tree.\n * `webrtc`: a string representing the state of the video call the message represents. Possible values:\n   * `\"started\"`: call has been initiated and being established\n   * `\"accepted\"`: call has been accepted and established\n   * `\"finished\"`: previously successfully established call has been ended\n   * `\"missed\"`: call timed out before getting established\n   * `\"declined\"`: call was hung up by the callee before getting established\n   * `\"busy\"`: the call was declined due to the callee being in another call.\n   * `\"disconnected\"`: call was terminated by the server for other reasons (e.g. due to an error)\n * `webrtc-duration`: a number representing a video call duration (in milliseconds).\n\nApplication-specific fields should start with an `x-<application-name>-`. Although the server does not enforce this rule yet, it may start doing so in the future.\n\nThe unique message ID should be formed as `<topic_name>:<seqId>` whenever possible, such as `\"grp1XUtEhjv6HND:123\"`. If the topic is omitted, i.e. `\":123\"`, it's assumed to be the current topic.\n\n#### `{get}`\n\nQuery topic for metadata, such as description or a list of subscribers, or query message history. The requester must be [subscribed and attached](#sub) to the topic to receive the full response. Some limited `desc` and `sub` information is available without being attached.\n\n```js\nget: {\n  id: \"1a2b3\", // string, client-provided message id, optional\n  topic: \"grp1XUtEhjv6HND\", // string, name of topic to request data from\n  what: \"sub desc data del cred\", // string, space-separated list of parameters to query;\n                        // unknown values are ignored; required\n\n  // Optional parameters for {get what=\"desc\"}\n  desc: {\n    ims: \"2015-10-06T18:07:30.038Z\" // timestamp, \"if modified since\" - return\n          // public and private values only if at least one of them has been\n          // updated after the stated timestamp, optional\n  },\n\n  // Optional parameters for {get what=\"sub\"}\n  sub: {\n    ims: \"2015-10-06T18:07:30.038Z\", // timestamp, \"if modified since\" - return\n          // public and private values only if at least one of them has been\n          // updated after the stated timestamp, optional\n    user: \"usr2il9suCbuko\", // string, return results for a single user,\n                          // any topic other than 'me', optional\n    topic: \"usr2il9suCbuko\", // string, return results for a single topic,\n                           // 'me' topic only, optional\n    limit: 20 // integer, limit the number of returned objects\n  },\n\n  // Optional parameters for {get what=\"data\"}\n  data: {\n    since: 123, // integer, load messages with server-issued IDs greater or equal\n                // to this (inclusive/closed), optional\n    before: 321, // integer, load messages with server-issed sequential IDs less\n               // than this (exclusive/open), optional\n    limit: 20, // integer, limit the number of returned objects, default: 32,\n               // optional\n  },\n\n  // Optional parameters for {get what=\"del\"}\n  del: {\n    since: 5, // integer, load deleted ranges with the delete transaction IDs greater\n              // or equal to this (inclusive/closed), optional\n    before: 12, // integer, load deleted ranges with the delete transaction IDs less\n                // than this (exclusive/open), optional\n    limit: 25, // integer, limit the number of returned objects, default: 32,\n               // optional\n  }\n}\n```\n\n* `{get what=\"desc\"}`\n\nQuery topic description. Server responds with a `{meta}` message containing requested data. See `{meta}` for details.\nIf `ims` is specified and data has not been updated, the message will skip `trusted`, `public`, and `private` fields.\n\nLimited information is available without [attaching](#sub) to topic first.\n\nSee [Trusted, Public, and Private Fields](#trusted-public-and-private-fields) for `trusted`, `private`, and `public` format considerations.\n\n* `{get what=\"sub\"}`\n\nGet a list of subscribers. Server responds with a `{meta}` message containing a list of subscribers. See `{meta}` for details.\nFor `me` topic the request returns a list of user's subscriptions. If `ims` is specified and data has not been updated,\nresponds with a `{ctrl}` \"not modified\" message.\n\nOnly user's own subscription is returned without [attaching](#sub) to topic first.\n\n* `{get what=\"tags\"}`\n\nQuery indexed tags. Server responds with a `{meta}` message containing an array of string tags. See `{meta}` and `fnd` topic for details.\nSupported only for `me` and group topics.\n\n* `{get what=\"data\"}`\n\nQuery message history. Server sends `{data}` messages matching parameters provided in the `data` field of the query.\nThe `id` field of the data messages is not provided as it's common for data messages. When all `{data}` messages are transmitted, a `{ctrl}` message is sent.\n\n* `{get what=\"del\"}`\n\nQuery message deletion history. Server responds with a `{meta}` message containing a list of deleted message ranges.\n\n* `{get what=\"cred\"}`\n\nQuery [credentials](#credentail-validation). Server responds with a `{meta}` message containing an array of credentials. Supported for `me` topic only.\n\n* `{get what=\"aux\"}`\n\nQuery auxiliary topic data. Server responds with a `{meta}` message containing an object with auxiliary key-value pairs.\n\n\n#### `{set}`\n\nUpdate topic metadata, delete messages or topic. The requester is generally expected to be [subscribed and attached](#sub) to the topic. Only `desc.private` and requester's `sub.mode` can be updated without attaching first.\n\n```js\nset: {\n  id: \"1a2b3\", // string, client-provided message id, optional\n  topic: \"grp1XUtEhjv6HND\", // string, name of topic to update, required\n\n  // Optional payload to update topic description\n  desc: {\n    defacs: { // new default access mode\n      auth: \"JRWP\",  // access permissions for authenticated users\n      anon: \"JRW\" // access permissions for anonymous users\n    },\n    trusted: { ... }, // application-defined payload assigned by the system administration\n    public: { ... }, // application-defined payload to describe topic\n    private: { ... } // per-user private application-defined content\n  },\n\n  // Optional payload to update subscription(s)\n  sub: {\n    user: \"usr2il9suCbuko\", // string, user affected by this request;\n                            // default (empty) means current user\n    mode: \"JRWP\" // string, access mode change, either given ('user'\n                 // is defined) or requested ('user' undefined)\n  }, // object, payload for what == \"sub\"\n\n  // Optional update to tags (see fnd topic description)\n  tags: [ // array of strings\n    \"email:alice@example.com\", \"tel:1234567890\"\n  ],\n\n  cred: { // Optional update to credentials.\n    meth: \"email\", // string, verification method, e.g. \"email\", \"tel\", \"recaptcha\", etc.\n    val: \"alice@example.com\", // string, credential to verify such as email or phone\n    resp: \"178307\", // string, verification response, optional\n    params: { ... } // parameters, specific to the verification method, optional\n  },\n\n  aux: { ... } // application-defined key-value pairs\n}\n```\n\n#### `{del}`\n\nDelete messages, subscriptions, topics, users.\n\n```js\ndel: {\n  id: \"1a2b3\", // string, client-provided message id, optional\n  topic: \"grp1XUtEhjv6HND\", // string, topic affected, required for \"topic\", \"sub\",\n               // \"msg\"\n  what: \"msg\", // string, one of \"topic\", \"sub\", \"msg\", \"user\", \"cred\"; what\n               // to delete - the entire topic, a subscription, some or all messages,\n               // a user, a credential; optional, default: \"msg\"\n  hard: false, // boolean, request to hard-delete vs mark as deleted; in case of\n               // what=\"msg\" delete for all users vs current user only;\n               // optional, default: false\n  delseq: [{low: 123, hi: 125}, {low: 156}], // array of ranges of message IDs\n               // to delete, inclusive-exclusive, i.e. [low, hi), optional\n  user: \"usr2il9suCbuko\" // string, user being deleted (what=\"user\") or whose\n               // subscription is being deleted (what=\"sub\"), optional\n  cred: { // credential to delete ('me' topic only).\n    meth: \"email\", // string, verification method, e.g. \"email\", \"tel\", etc.\n    val: \"alice@example.com\" // string, credential being deleted\n  }\n}\n```\n\n`what=\"msg\"`\n\nUser can soft-delete `hard=false` (default) or hard-delete `hard=true` messages. Soft-deleting messages hides them from the requesting user but does not delete them from storage. An `R` permission is required to soft-delete messages. Hard-deleting messages deletes message content from storage (`head`, `content`) leaving a message stub. It affects all users. A `D` permission is needed to hard-delete messages. Messages can be deleted in bulk by specifying one or more message ID ranges in `delseq` parameter. Each delete operation is assigned a unique `delete ID`. The greatest `delete ID` is reported back in the `clear` of the `{meta}` message.\n\n`what=\"sub\"`\n\nDeleting a subscription removes specified user from topic subscribers. It requires an `A` permission. A user cannot delete own subscription. A `{leave}` should be used instead. If the subscription is soft-deleted (default), it's marked as deleted without actually deleting a record from storage.\n\n`what=\"topic\"`\n\nDeleting a topic deletes the topic including all subscriptions, and all messages. Only the owner can delete a topic.\n\n`what=\"user\"`\n\nDeleting a user is a very heavy operation. Use caution.\n\n`what=\"cred\"`\n\nDelete credential. Validated credentials and those with no attempts at validation are hard-deleted. Credentials with failed attempts at validation are soft-deleted which prevents their reuse by the same user.\n\n\n#### `{note}`\n\nClient-generated ephemeral notification for forwarding to other clients currently attached to the topic, such as typing notifications or delivery receipts. The message is \"fire and forget\": not stored to disk per se and not acknowledged by the server. Messages deemed invalid are silently dropped.\nThe `{note.recv}` and `{note.read}` do alter persistent state on the server. The value is stored and reported back in the corresponding fields of the `{meta.sub}` message.\n\n```js\nnote: {\n  topic: \"grp1XUtEhjv6HND\", // string, topic to notify, required\n  what: \"kp\", // string, action type of the notification.\n  seq: 123,   // integer, ID of the message being acknowledged, required for\n              // 'recv' & 'read'.\n  unread: 10, // integer, client-reported total count of unread messages, optional.\n  event: \"ringing\", // string, subaction; surrently used only by video/audio calls,\n                    // when what=\"call\".\n  payload: {  // object, required payload for 'call' and 'data'.\n    ...\n  }\n}\n```\n\nThe following actions types are currently defined:\n * call: a video call status update.\n * data: a generic packet of structured data, usually a form response.\n * kp: key press, i.e. a typing notification. The client should use it to indicate that the user is composing a new message.\n * kpa: audio message is in the process of recording.\n * kpv: video message is in the process of recording.\n * read: a `{data}` message is seen (read) by the user. It implies `recv` as well.\n * recv: a `{data}` message is received by the client software but may not yet seen by user.\n\nThe `read` and `recv` notifications may optionally include `unread` value which is the total count of unread messages as determined by this client. The per-user `unread` count is maintained by the server: it's incremented when new `{data}` messages are sent to user and reset to the values reported by the `{note unread=...}` message. The `unread` value is never decremented by the server. The value is included in push notifications to be shown on a badge on iOS:\n<p align=\"center\">\n  <img src=\"./ios-pill-128.png\" alt=\"Tinode iOS icon with a pill counter\" width=64 height=64 />\n</p>\n\n\n### Server to Client Messages\n\nMessages to a session generated in response to a specific request contain an `id` field equal to the id of the\noriginating message. The `id` is not interpreted by the server.\n\nMost server to client messages have a `ts` field which is a timestamp when the message was generated by the server.\n\n#### `{data}`\n\nContent published in the topic. These messages are the only messages persisted in database; `{data}` messages are\nbroadcast to all topic subscribers with an `R` permission.\n\n```js\ndata: {\n  topic: \"grp1XUtEhjv6HND\", // string, topic which distributed this message,\n                            // always present\n  from: \"usr2il9suCbuko\", // string, id of the user who published the\n                          // message; could be missing if the message was\n                          // generated by the server\n  head: { key: \"value\", ... }, // set of string key-value pairs, passed\n                               // unchanged from {pub}, optional\n  ts: \"2015-10-06T18:07:30.038Z\", // string, timestamp\n  seq: 123, // integer, server-issued sequential ID\n  content: { ... } // object, application-defined content exactly as published\n              // by the user in the {pub} message\n}\n```\n\nData messages have a `seq` field which holds a sequential numeric ID generated by the server. The IDs are guaranteed to be unique within a topic. IDs start from 1 and sequentially increment with every successful [`{pub}`](#pub) message received by the topic.\n\nSee [Format of Content](#format-of-content) for `content` format considerations.\n\nSee [`{pub}`](#pub) message for the possible values of the `head` field.\n\n\n#### `{ctrl}`\n\nGeneric response indicating an error or a success condition. The message is sent to the originating session.\n\n```js\nctrl: {\n  id: \"1a2b3\", // string, client-provided message id, optional\n  topic: \"grp1XUtEhjv6HND\", // string, topic name, if this is a response in context\n                            // of a topic, optional\n  code: 200, // integer, code indicating success or failure of the request, follows\n             // the HTTP status codes model, always present\n  text: \"OK\", // string, text with more details about the result, always present\n  params: { ... }, // object, generic response parameters, context-dependent,\n                   // optional\n  ts: \"2015-10-06T18:07:30.038Z\", // string, timestamp\n}\n```\n\n#### `{meta}`\n\nInformation about topic metadata or subscribers, sent in response to `{get}`, `{set}` or `{sub}` message to the originating session.\n\n```js\nmeta: {\n  id: \"1a2b3\", // string, client-provided message id, optional\n  topic: \"grp1XUtEhjv6HND\", // string, topic name, if this is a response in\n                            // context of a topic, optional\n  ts: \"2015-10-06T18:07:30.038Z\", // string, timestamp\n  desc: {\n    created: \"2015-10-24T10:26:09.716Z\",\n    updated: \"2015-10-24T10:26:09.716Z\",\n    status: \"ok\", // account status; included for `me` topic only, and only if\n                  // the request is sent by a root-authenticated session.\n    defacs: { // topic's default access permissions; present only if the current\n              //user has 'S' permission\n      auth: \"JRWP\", // default access for authenticated users\n      anon: \"N\" // default access for anonymous users\n    },\n    acs: {  // user's actual access permissions\n      want: \"JRWP\", // string, requested access permission\n      given: \"JRWP\", // string, granted access permission\n    mode: \"JRWP\" // string, combination of want and given\n    },\n    seq: 123, // integer, server-issued id of the last {data} message\n    read: 112, // integer, ID of the message user claims through {note} message\n              // to have read, optional\n    recv: 115, // integer, like 'read', but received, optional\n    clear: 12, // integer, in case some messages were deleted, the greatest ID\n               // of a deleted message, optional\n    trusted: { ... }, // application-defined payload writable by the system\n                      // administration, readable by all\n    public: { ... }, // application-defined data writable by topic owner,\n                     // readable by all\n    private: { ... } // application-defined data that's available to the current\n                     // user only\n  }, // object, topic description, optional\n  sub:  [ // array of objects, topic subscribers or user's subscriptions, optional\n    {\n      user: \"usr2il9suCbuko\", // string, ID of the user this subscription\n                            // describes, absent when querying 'me'.\n      updated: \"2015-10-24T10:26:09.716Z\", // timestamp of the last change in the\n                                           // subscription, present only for\n                                           // requester's own subscriptions\n      touched: \"2017-11-02T09:13:55.530Z\", // timestamp of the last message in the\n                                           // topic (may also include other events\n                                           // in the future, such as new subscribers)\n      acs: {  // user's access permissions\n        want: \"JRWP\", // string, requested access permission, present for user's own\n              // subscriptions and when the requester is topic's manager or owner\n        given: \"JRWP\", // string, granted access permission, optional exactly as 'want'\n        mode: \"JRWP\" // string, combination of want and given\n      },\n      read: 112, // integer, ID of the message user claims through {note} message\n                 // to have read, optional.\n      recv: 315, // integer, like 'read', but received, optional.\n      clear: 12, // integer, in case some messages were deleted, the greatest ID\n                 // of a deleted message, optional.\n      trusted: { ... }, // application-defined payload assigned by the system\n                        // administration\n      public: { ... }, // application-defined user's 'public' object, absent when\n                       // querying P2P topics.\n      private: { ... } // application-defined user's 'private' object.\n      online: true, // boolean, current online status of the user; if this is a\n                    // group or a p2p topic, it's user's online status in the topic,\n                    // i.e. if the user is attached and listening to messages; if this\n                    // is a response to a 'me' query, it tells if the topic is\n                    // online; p2p is considered online if the other party is\n                    // online, not necessarily attached to topic; a group topic\n                    // is considered online if it has at least one active\n                    // subscriber.\n\n      // The following fields are present only when querying 'me' topic\n\n      topic: \"grp1XUtEhjv6HND\", // string, topic this subscription describes\n      seq: 321, // integer, server-issued id of the last {data} message\n\n      // The following field is present only when querying 'me' topic and the\n      // topic described is a P2P topic\n      seen: { // object, if this is a P2P topic, info on when the peer was last\n              //online\n        when: \"2015-10-24T10:26:09.716Z\", // timestamp\n        ua: \"Tinode/1.0 (Android 5.1)\" // string, user agent of peer's client\n      }\n    },\n    ...\n  ],\n  tags: [ // array of tags that the topic or user (in case of \"me\" topic) is indexed by\n    \"email:alice@example.com\", \"tel:+1234567890\", \"flowers\"\n  ],\n  cred: [ // array of user's credentials\n    {\n      meth: \"email\", // string, validation method\n      val: \"alice@example.com\", // string, credential value\n      done: true     // validation status\n    },\n    ...\n  ],\n  del: {\n    clear: 3, // ID of the latest applicable 'delete' transaction\n    delseq: [{low: 15}, {low: 22, hi: 28}, ...], // ranges of IDs of deleted messages\n  },\n  aux: { ... } // application-defined key-value pairs writable by topic managers,\n               // readable by topic subscribers.\n}\n```\n\n#### `{pres}`\n\nTinode uses `{pres}` message to inform clients of important events. A separate [document](https://docs.google.com/spreadsheets/d/e/2PACX-1vStUDHb7DPrD8tF5eANLu4YIjRkqta8KOhLvcj2precsjqR40eDHvJnnuuS3bw-NcWsP1QKc7GSTYuX/pubhtml?gid=1959642482&single=true) explains all possible use cases.\n\n```js\npres: {\n  topic: \"me\", // string, topic which receives the notification, always present\n  src: \"grp1XUtEhjv6HND\", // string, topic or user affected by the change, always present\n  what: \"on\", // string, action type, what's changed, always present\n  seq: 123, // integer, \"what\" is \"msg\", a server-issued ID of the message,\n            // optional\n  clear: 15, // integer, \"what\" is \"del\", an update to the delete transaction ID.\n  delseq: [{low: 123}, {low: 126, hi: 136}], // array of ranges, \"what\" is \"del\",\n             // ranges of IDs of deleted messages, optional\n  ua: \"Tinode/1.0 (Android 2.2)\", // string, a User Agent string identifying the client\n             // software if \"what\" is \"on\" or \"ua\", optional\n  act: \"usr2il9suCbuko\",  // string, user who performed the action, optional\n  tgt: \"usrRkDVe0PYDOo\",  // string, user affected by the action, optional\n  acs: {want: \"+AS-D\", given: \"+S\"} // object, changes to access mode, \"what\" is \"acs\",\n                          // optional\n}\n```\n\nThe following action types are currently defined:\n\n * on: topic or user came online\n * off: topic or user went offline\n * ua: user agent changed, for example user was logged in with one client, then logged in with another\n * upd: topic description has changed\n * tags: topic tags have changed\n * aux: topic aux data has changed\n * acs: access permissions have changed\n * gone: topic is no longer available, for example, it was deleted or you were unsubscribed from it\n * term: subscription to topic has been terminated, you may try to resubscribe\n * msg: a new message is available\n * read: one or more messages have been read by the recipient\n * recv: one or more messages have been received by the recipient\n * del: messages were deleted\n\n\nThe `{pres}` messages are purely transient: they are not stored and no attempt is made to deliver them later if the destination is temporarily unavailable.\n\nTimestamp is not present in `{pres}` messages.\n\n\n#### `{info}`\n\nForwarded client-generated notification `{note}`. Server guarantees that the message complies with this specification and that content of `topic` and `from` fields is correct. The other content is copied from the `{note}` message verbatim and may potentially be incorrect or misleading if the originator  so desires.\n\n```js\ninfo: {\n  topic: \"grp1XUtEhjv6HND\", // string, topic affected, always present\n  src: \"usrRkDVe0PYDOo\",  // string, topic where the even has occurred;\n                          // present only when \"topic\": \"me\"\n  from: \"usr2il9suCbuko\", // string, id of the user who published the\n                          // message, always present\n  what: \"read\", // string, one of \"kp\", \"recv\", \"read\", \"data\", see client-side {note},\n                // always present\n  seq: 123, // integer, ID of the message that client has acknowledged,\n            // guaranteed 0 < read <= recv <= {ctrl.params.seq}; present for recv &\n            // read\n  event: \"ringing\", // string, used by video/audio calls\n  payload: { ... }  // object, arbitrary payload, used by video calls\n}\n```\n"
  },
  {
    "path": "docs/CLA.md",
    "content": "# Tinode Individual Contributor License Agreement\n\nIn order to clarify the intellectual property license granted with Contributions from any person or entity,\n[Tinode LLC](https://tinode.co) must have a Contributor License Agreement (\"CLA\") on file that has been signed by each Contributor,\nindicating agreement to the license terms below. This license is for your protection as a Contributor as\nwell as the protection of Tinode LLC; it does not change your rights to use your own Contributions for any\nother purpose.\n\nYou accept and agree to the following terms and conditions for Your present and future Contributions\nsubmitted to Tinode. Except for the license granted herein to Tinode LLC and recipients of software distributed\nby Tinode LLC, You reserve all right, title, and interest in and to Your Contributions.\n\n1. Definitions.\n\"You\" (or \"Your\") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with Tinode LLC. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, \"control\" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.\n\"Contribution\" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to Tinode LLC for inclusion in, or documentation of, any of the products owned or managed by Tinode LLC (the \"Work\"). For the purposes of this definition, \"submitted\" means any form of electronic, verbal, or written communication sent to Tinode LLC or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, Tinode LLC for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as \"Not a Contribution.\"\n\n2. Grant of Copyright License.\nSubject to the terms and conditions of this Agreement, You hereby grant to Tinode LLC and to recipients of software distributed by Tinode LLC a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works.\n\n3. Grant of Patent License.\nSubject to the terms and conditions of this Agreement, You hereby grant to Tinode LLC and to recipients of software distributed by Tinode LLC a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed.\n\n4. You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to Tinode, or that your employer has executed a separate Corporate CLA with Tinode LLC.\n\n5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions.\n\n6. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON- INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.\n\n7. Should You wish to submit work that is not Your original creation, You may submit it to Tinode separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as \"Submitted on behalf of a third-party: [named here]\".\n\n8. You agree to notify Tinode LLC of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect.\n\n---\n\n## [SIGN NOW](https://docs.google.com/forms/d/e/1FAIpQLSfmtJDHzFOJTzIv5jZ-gHRxVU0ysTdIMJakv1xgUUCu_RGeKQ/formResponse)\n"
  },
  {
    "path": "docs/call-establishment.md",
    "content": "# Video Call Establishment Flow\n\nTinode supports peer to peer video calls over [WebRTC](https://webrtc.org/). The diagram below illustrates a call establishment flow between two users `Alice` and `Bob`. The flow is conceptually similar to [SIP](https://en.wikipedia.org/wiki/Session_Initiation_Protocol), but uses native Tinode messages for transport.\n\nNotes:\n- All communication is proxied by the Tinode server.\n- Client-to-server events are dispatched in `{note}` messages with the call's `topic` and `seq` fields set.\n- Server-to-client data are routed in `{info}` messages on `me` topic with the call's `src` (call topic) and `seq` fields set (and/or in data push notifications).\n- It's assumed that both Alice and Bob may have multiple devices.\n\n## Details\n### Call phases\nThe flow may be broken down into 4 phases:\n* Steps 1-5: call initiation\n* Steps 6-7: call acceptance\n* Steps 8-15: metadata exchange\n* Steps 16-17: call termination\n\n```mermaid\nsequenceDiagram\n    participant A as Alice\n    participant S as Tinode Server\n    participant B as Bob\n    rect rgb(212, 242, 255)\n        Note over A: Alice initiates a call\n        A->>S: 1. {pub head:webrtc=started}\n        S->>A: 2. {ctrl params:seq=123}\n        S->>+B: 3. {info seq=123 event=invite}\n        S-->>B: or {data seq=123 head:webrtc=started} <br/> push notification\n        B->>-S: 4. {note seq=123 event=ringing}\n        S->>A: 5. {info seq=123 event=ringing}\n    end\n    Note over S: Bob's client ringing<br/>waiting for Bob to accept\n    rect rgb(212, 242, 255)\n        Note over B: Bob accepts the call\n        B->>S: 6. {note seq=123 event=accept}\n        S->>A: 7a. {info seq=123 event=accept}\n        S->>B: 7b. {info seq=123 event=accept}\n        S-->>B: {data seq=124 head:webrtc=accepted,replace=123}\n        S-->>A: {data seq=124 head:webrtc=accepted,replace=123}\n    end\n    Note over S: Call accepted, peer metadata exchange\n    A->>S: 8. {note seq=123 event=offer}\n    S->>+B: 9. {info seq=123 event=offer}\n    B->>-S: 10. {note seq=123 event=answer}\n    S->>A: 11. {info seq=123 event=answer}\n    rect rgb(212, 242, 255)\n        Note over S: ICE candidate exchange\n        loop\n            A->>S: 12. {note seq=123 event=ice-candidate}\n            S->>B: 13. {info seq=123 event=ice-candidate}\n            B->>S: 14. {note seq=123 event=ice-candidate}\n            S->>A: 15. {info seq=123 event=ice-candidate}\n        end\n    end\n    Note over S: Call established<br/>conversation in progress\n    rect rgb(212, 242, 255)\n        Note over S: Call termination\n        alt\n            A->>S: 16a. {note seq=123 event=hang-up}\n            B->>S: 16b. {note seq=123 event=hang-up}\n        end\n        alt\n            S->>B: 17a. {info seq=123 event=hang-up}\n            S->>A: 17b. {info seq=123 event=hang-up}\n        end\n        S-->>B: {data seq=125 head:webrtc=finished,replace=123}\n        S-->>A: {data seq=125 head:webrtc=finished,replace=123}\n    end\n```\n\n### Call Establishment & Termination steps\n\n#### Call initiation\n1. `Alice` initiates a call by posting a video call message (with `webrtc=started` header)\n2. Server replies with a `{ctrl}` message containing the `seq` id of the call.\n3. Server routes an `invite` event message to `Bob` (all clients).\n  - Additionally, server sends data push notifications containing a `webrtc=started` field to `Bob`.\n  - Upon receiving either of the above, `Bob` displays the incoming call UI.\n4. `Bob` replies with a `ringing` event.\n5. Server relays the `ringing` event to `Alice`. The latter now plays the ringing sound.\n  - Note that `Alice` may receive multiple `ringing` events as each separate instance of `Bob` acknowldges receipt of the call invitation separately.\n  - `Alice` and server will wait for up to a server configured timeout for `Bob` to accept the call and then hang up.\n  - At this point, the call is officially **initiated**.\n\n#### Call acceptance\n6. `Bob` accepts the call by sending an `accept` event.\n7. (a) and (b): Server routes `accept` event to `Alice` and `Bob`.\n  - Additionally, the server broadcasts a replacement for the call data message with `webrtc=accepted` header.\n  - Push notifications for the replacement message are sent as well.\n  - `Bob`'s sessions except the one that accepted the call may silently dismiss the incoming call UI.\n  - At this point, the call is officially **accepted**.\n\n#### Metadata exchange\n8. `Alice` sends an `offer` event containing an SDP payload.\n9. Server routes the `offer` to `Bob`.\n10. Upon receiving the `offer`, `Bob` replies with an `answer` event containing an SDP payload.\n11. Server forwards `Bob`'s `answer` event to `Alice`.\n\nSteps 12-15 are Ice candidate exchange between `Alice` and `Bob`.\nAt this point the call is officially **established**. `Alice` and `Bob` can see and hear each other.\n\n#### Call termination\n16. `Alice` sends a `hang-up` event to server.\n17. Server routes a `hang-up` event to `Bob`.\n\nAdditionally, the server broadcasts a replacement for the call data message with `webrtc=finished` header.\nPush notifications for the replacement message are sent as well.\n\n"
  },
  {
    "path": "docs/drafty.md",
    "content": "# Drafty: Rich Message Format\n\nDrafty 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 and previews.\n\n## Example\n\n> this is **bold**, `code` and _italic_, ~~strike~~<br/>\n>  combined **bold and _italic_**<br/>\n>  an url: https://www.example.com/abc#fragment and another _[https://web.tinode.co](https://web.tinode.co)_<br/>\n>  this is a [@mention](#) and a [#hashtag](#) in a string<br/>\n> second [#hashtag](#)<br/>\n\nSample Drafty-JSON representation of the text above:\n```js\n{\n   \"txt\":  \"this is bold, code and italic, strike combined bold and italic an url: https://www.example.com/abc#fragment and another www.tinode.co this is a @mention and a #hashtag in a string second #hashtag\",\n   \"fmt\": [\n       { \"at\":8, \"len\":4,\"tp\":\"ST\" },{ \"at\":14, \"len\":4, \"tp\":\"CO\" },{ \"at\":23, \"len\":6, \"tp\":\"EM\"},\n       { \"at\":31, \"len\":6, \"tp\":\"DL\" },{ \"tp\":\"BR\", \"len\":1, \"at\":37 },{ \"at\":56, \"len\":6, \"tp\":\"EM\" },\n       { \"at\":47, \"len\":15, \"tp\":\"ST\" },{ \"tp\":\"BR\", \"len\":1, \"at\":62 },{ \"at\":120, \"len\":13, \"tp\":\"EM\" },\n       { \"at\":71, \"len\":36, \"key\":0 },{ \"at\":120, \"len\":13, \"key\":1 },{ \"tp\":\"BR\", \"len\":1, \"at\":133 },\n       { \"at\":144, \"len\":8, \"key\":2 },{ \"at\":159, \"len\":8, \"key\":3 },{ \"tp\":\"BR\", \"len\":1, \"at\":179 },\n       { \"at\":187, \"len\":8, \"key\":3 },{ \"tp\":\"BR\", \"len\":1, \"at\":195 }\n   ],\n   \"ent\": [\n       { \"tp\":\"LN\", \"data\":{ \"url\":\"https://www.example.com/abc#fragment\" } },\n       { \"tp\":\"LN\", \"data\":{ \"url\":\"http://www.tinode.co\" } },\n       { \"tp\":\"MN\", \"data\":{ \"val\":\"mention\" } },\n       { \"tp\":\"HT\", \"data\":{ \"val\":\"hashtag\" } }\n   ]\n}\n```\n\n## Structure\n\nDrafty object has three fields: plain text `txt`, inline markup `fmt`, and entities `ent`.\n\n### Plain Text\n\nThe message to be sent is converted to plain Unicode text with all markup stripped and kept in `txt` field. In general, a valid Drafy may contain just the `txt` field.\n\n### Inline Formatting `fmt`\n\nInline formatting is an array of individual styles in the `fmt` field. Each style is represented by an object with at least `at` and `len` fields. The `at` value means 0-based offset into `txt`, `len` is the number of characters to apply formatting to. The third value of style is either `tp` or `key`.\n\nIf `tp` is provided, it means the style is a basic text decoration:\n * `BR`: line break.\n * `CO`: code or monotyped text, possibly with different background: `monotype`.\n * `DL`: deleted or strikethrough text: ~~strikethrough~~.\n * `EM`: emphasized text, usually represented as italic: _italic_.\n * `FM`: form / set of fields; may also be represented as an entity.\n * `HD`: hidden content.\n * `HL`: highlighted text, such as text in a different color or with a different background; the color cannot be specified.\n * `RW`: logical grouping of formats, a row; may also be represented as an entity.\n * `ST`: strong or bold text: **bold**.\n\nIf key is provided, it's a 0-based index into the `ent` array which contains extended style parameters such as an image or an URL:\n * `AU`: embedded audio.\n * `BN`: interactive button.\n * `EX`: generic attachment.\n * `FM`: form / set of fields; may also be represented as a basic decoration.\n * `HT`: hashtag, e.g. [#hashtag](#).\n * `IM`: inline image.\n * `LN`: link (URL) [https://api.tinode.co](https://api.tinode.co).\n * `MN`: mention such as [@tinode](#).\n * `RW`: logical grouping of formats, a row; may also be represented as a basic decoration.\n * `VC`: video (and audio) calls.\n * `VD`: inline video.\n\nExamples:\n * `{ \"at\":8, \"len\":4, \"tp\":\"ST\"}`: apply formatting `ST` (strong/bold) to 4 characters starting at offset 8 into `txt`.\n * `{ \"at\":144, \"len\":8, \"key\":2 }`: insert entity `ent[2]` into position 144, the entity spans 8 characters.\n * `{ \"at\":-1, \"len\":0, \"key\":4 }`: show the `ent[4]` as a file attachment, don't apply any styling to text.\n\nThe clients should be able to handle missing `at`, `key`, and `len` values. Missing values are assumed to be equal to `0`.\n\nThe indexes `at` and `len` are measured in [Unicode code points](https://developer.mozilla.org/en-US/docs/Glossary/Code_point), not bytes or characters. The behavior of multi-codepoint glyphs such as emojis with Fitzpatrick skin tone modifiers, variation selectors, or grouped with `ZWJ` is currently undefined.\n\n#### `FM`: a form, an ordered set or fields\n\nForm provides means to add paragraph-level formatting to a logical group of elements. It may be represented as a text decoration or as an entity.\n<table>\n<tr><th>Do you agree?</th></tr>\n<tr><td><a href=\"\">Yes</a></td></tr>\n<tr><td><a href=\"\">No</a></td></tr>\n</table>\n\n```js\n{\n \"txt\": \"Do you agree? Yes No\",\n \"fmt\": [\n   {\"len\": 20, \"tp\": \"FM\"}, // missing 'at' is zero: \"at\": 0\n   {\"len\": 13, \"tp\": \"ST\"}\n   {\"at\": 13, \"len\": 1, \"tp\": \"BR\"},\n   {\"at\": 14, \"len\": 3}, // missing 'key' is zero: \"key\": 0\n   {\"at\": 17, \"len\": 1, \"tp\": \"BR\"},\n   {\"at\": 18, \"len\": 2, \"key\": 1},\n ],\n \"ent\": [\n   {\"tp\": \"BN\", \"data\": {\"name\": \"yes\", \"act\": \"pub\", \"val\": \"oh yes!\"}},\n   {\"tp\": \"BN\", \"data\": {\"name\": \"no\", \"act\": \"pub\"}}\n ]\n}\n```\nIf a `Yes` button is pressed in the example above, the client is expected to send a message to the server with the following content:\n```js\n{\n \"txt\": \"Yes\",\n \"fmt\": [{\n   \"at\":-1\n }],\n \"ent\": [{\n   \"tp\": \"EX\",\n   \"data\": {\n     \"mime\": \"text/x-drafty-fr\", // drafty form-response.\n     \"val\": {\n       \"seq\": 15, // seq id of the message containing the form.\n       \"resp\": {\"yes\": \"oh yes!\"}\n     }\n   }\n }]\n}\n```\n\nThe form may be optionally represented as an entity:\n```js\n{\n  \"tp\": \"FM\",\n  \"data\": {\n    \"su\": true\n  }\n}\n```\nThe `data.su` describes how interactive form elements behave after the click. An `\"su\": true` indicates that the form is `single use`: the form should change after the first interaction to show that it's no longer accepting input.\n\n### Entities `ent`\n\nIn general, an entity is a text decoration which requires additional (possibly large) data. An entity is represented by an object with two fields: `tp` indicates type of the entity, `data` is type-dependent styling information. Unknown fields are ignored.\n\n#### `AU`: embedded audio record\n`AU` is an audio record. The `data` contains the following fields:\n```js\n{\n  \"tp\": \"AU\",\n  \"data\": {\n    \"mime\": \"audio/aac\",\n    \"val\": \"Rt53jUU...iVBORw0KGgoA==\",\n    \"ref\": \"/v0/file/s/e769gvt1ILE.m4v\",\n    \"preview\": \"Aw4JKBkAAAAKMSM...vHxgcJhsgESAY\"\n    \"duration\": 180000,\n    \"name\": \"ding_dong.m4a\",\n    \"size\": 595496\n  }\n}\n```\n * `mime`: data type, such as 'audio/ogg'.\n * `val`: optional in-band audio data: base64-encoded audio bits.\n * `ref`: optional reference to out-of-band audio data. Either `val` or `ref` must be present.\n * `preview`: base64-encoded array of bytes to generate a visual preview; each byte is an amplitude bar.\n * `duration`: duration of the record in milliseconds.\n * `name`: optional name of the original file.\n * `size`: optional size of the file in bytes.\n\nTo create a message with just a single audio record and no text, use the following Drafty:\n```js\n{\n  txt: \" \",\n  fmt: [{len: 1}],\n  ent: [{tp: \"AU\", data: {<your audio data here>}]}\n}\n```\n\n_IMPORTANT Security Consideration_: the `val` and `ref` fields may contain malicious payload. The client should restrict URL scheme in the `ref` field to `http` and `https` only. The client should present content of `val` field to the user only if it's correctly converted to an audio.\n\n\n#### `BN`: interactive button\n`BN` offers an option to send data to a server, either origin server or another one. The `data` contains the following fields:\n```js\n{\n  \"tp\": \"BN\",\n  \"data\": {\n    \"name\": \"confirmation\",\n    \"act\": \"url\",\n    \"val\": \"some-value\",\n    \"ref\": \"https://www.example.com/path/?foo=bar\"\n  }\n}\n```\n* `act`: type of action in response to button click:\n  * `pub`: send a Drafty-formatted `{pub}` message to the current topic with the form data as an attachment:\n  ```js\n  { \"tp\":\"EX\", \"data\":{ \"mime\":\"text/x-drafty-fr\", \"val\": { \"seq\": 3, \"resp\": { \"confirmation\": \"some-value\" } } } }\n  ```\n  * `url`: issue an `HTTP GET` request to the URL from the `data.ref` field. The following query parameters are appended to the URL: `<name>=<val>`, `uid=<current-user-ID>`, `topic=<topic name>`, `seq=<message sequence ID>`.\n  * `note`: send a `{note}` message to the current topic with `what` set to `data` (not currently implemented, contact us if you need it).\n  ```js\n  { \"what\": \"data\", \"data\": { \"mime\": \"text/x-drafty-fr\", \"val\": { \"seq\": 3, \"resp\": { \"confirmation\": \"some-value\" } } }\n  ```\n* `name`: optional name of the button which is reported back to the server.\n* `val`: additional opaque data.\n* `ref`: the URL for the `url` action.\n\nIf the `name` is provided but `val` is not, `val` is assumed to be `1`. If `name` is undefined then nether `name` nor `val` are sent.\n\nThe button in the example above will send an HTTP GET to https://www.example.com/path/?foo=bar&confirmation=some-value&uid=usrFsk73jYRR&topic=grpnG99YhENiQU&seq=3 assuming the current user ID is `usrFsk73jYRR`, the topic is `grpnG99YhENiQU`, and the sequence ID of message with the button is `3`.\n\n_IMPORTANT Security Consideration_: the client should restrict URL scheme in the `ref` field to `http` and `https` only.\n\n\n#### `EX`: file attachment\n`EX` is an attachment which the client should not try to interpret. The `data` contains the following fields:\n```js\n{\n  \"tp\": \"EX\",\n  \"data\": {\n    \"mime\", \"text/plain\",\n    \"val\", \"Q3l0aG9uPT0w...PT00LjAuMAo=\",\n    \"ref\": \"/v0/file/s/abcdef12345.txt\",\n    \"name\", \"requirements.txt\",\n    \"size\": 1234\n  }\n}\n```\n* `mime`: data type, such as 'application/octet-stream'.\n* `val`: optional in-band base64-encoded file data.\n* `ref`: optional reference to out-of-band file data. Either `val` or `ref` must be present.\n* `name`: optional name of the original file.\n* `size`: optional size of the file in bytes.\n\nTo generate a message with the file attachment shown as a downloadable file, use the following format:\n```js\n{\n  at: -1,\n  len: 0,\n  key: <EX entity reference>\n}\n```\n\n_IMPORTANT Security Consideration_: the `ref` fields may contain malicious payload. The client should restrict URL scheme in the `ref` field to `http` and `https` only.\n\n\n#### `IM`: inline image or attached image with inline preview\n`IM` is an image. The `data` contains the following fields:\n```js\n{\n  \"tp\": \"IM\",\n  \"data\": {\n    \"mime\": \"image/png\",\n    \"val\": \"Rt53jUU...iVBORw0KGgoA==\",\n    \"ref\": \"/v0/file/s/abcdef12345.jpg\",\n    \"width\": 512,\n    \"height\": 512,\n    \"name\": \"sample_image.png\",\n    \"size\": 123456\n  }\n}\n```\n * `mime`: data type, such as 'image/jpeg'.\n * `val`: optional in-band image data: base64-encoded image bits.\n * `ref`: optional reference to out-of-band image data. Either `val` or `ref` must be present.\n * `width`, `height`: linear dimensions of the image in pixels.\n * `name`: optional name of the original file.\n * `size`: optional size of the file in bytes.\n\nTo create a message with just a single image and no text, use the following Drafty:\n```js\n{\n  txt: \" \",\n  fmt: [{len: 1}],\n  ent: [{tp: \"IM\", data: {<your image data here>}]}\n}\n```\n\n_IMPORTANT Security Consideration_: the `val` and `ref` fields may contain malicious payload. The client should restrict URL scheme in the `ref` field to `http` and `https` only. The client should present content of `val` field to the user only if it's correctly converted to an image.\n\n\n#### `LN`: link (URL)\n`LN` is an URL. The `data` contains a single `url` field:\n`{ \"tp\": \"LN\", \"data\": { \"url\": \"https://www.example.com/abc#fragment\" } }`\nThe `url` could be any valid URL that the client knows how to interpret, for instance it could be email or phone URL too: `email:alice@example.com` or `tel:+17025550001`.\n\n_IMPORTANT Security Consideration_: the `url` field may be maliciously constructed. The client should disable certain URL schemes such as `javascript:` and `data:`.\n\n\n#### `MN`: mention such as [@alice](#)\nMention `data` contains a single `val` field with ID of the mentioned user:\n```js\n{ \"tp\":\"MN\", \"data\":{ \"val\":\"usrFsk73jYRR\" } }\n```\n\n\n#### `HT`: hashtag, e.g. [#tinode](#)\nHashtag `data` contains a single `val` field with the hashtag value which the client software needs to interpret, for instance it could be a search term:\n```js\n{ \"tp\":\"HT\", \"data\":{ \"val\":\"tinode\" } }\n```\n\n#### `VC`: video call control message\nVideo call `data` contains current state of the call and its duration:\n```js\n{\n  \"tp\": \"VC\",\n  \"data\": {\n    \"duration\": 10000,\n    \"state\": \"disconnected\",\n    \"incoming\": false,\n    \"aonly\": true\n  }\n}\n```\n\n* `duration`: call duration in milliseconds.\n* `state`: surrent call state; supported states:\n\t* `accepted`: a call is established (ongoing).\n\t* `busy`: a call cannot be established because the callee is already in another call.\n\t* `finished`: a previously establied call has successfully finished.\n\t* `disconnected`: the call is dropped, for example because of an error.\n\t* `missed`: the call is missed, i.e. the callee didn't pick up the phone.\n\t* `declined`: the call is declined, i.e. the callee hung up before picking up.\n* `incoming`: true if the call is incoming, otherwise the call is outgoing.\n* `aonly`: true if this is an audio-only call (no video).\n\nThe `VC` may also be represented as a format `\"fmt\": [{\"len\": 1, \"tp\": \"VC\"}]` with no entity. In such a case all call information is contained in the `head` fields of the enclosing message.\n\n#### `VD`: video with preview\n`VD` represents a video recording. The `data` contains the following fields:\n```js\n{\n  \"tp\": \"VD\",\n  \"data\": {\n    \"mime\": \"video/webm\",\n    \"ref\": \"/v0/file/s/abcdef12345.webm\",\n    \"preview\": \"AsTrsU...k86n00Ggo==\"\n    \"preref\": \"/v0/file/s/abcdef54321.jpeg\",\n    \"premime\": \"image/jpeg\",\n    \"width\": 640,\n    \"height\": 360,\n    \"duration\": 32000,\n    \"name\": \" bigbuckbunny.webm\",\n    \"size\": 1234567\n  }\n}\n```\n * `mime`: data type of the video, such as 'video/webm'.\n * `val`: optional in-band video data: base64-encoded video bits, usually not present (null).\n * `ref`: optional reference to an out-of-band video data. Either `val` or `ref` must be present.\n * `preview`: optional base64-encoded screencapture image from the video (poster).\n * `preref`: optional reference to an out-of-band screencapture image from the video (poster).\n * `premime`: data type of the optional screencapture image (poster); assumed 'image/jpeg' if missing.\n * `width`, `height`: linear dimensions of the video and poster in pixels.\n * `duration`: duration of the video in milliseconds.\n * `name`: optional name of the original file.\n * `size`: optional size of the file in bytes.\n\nTo create a message with just a single video and no text, use the following Drafty:\n```js\n{\n  txt: \" \",\n  fmt: [{len: 1}],\n  ent: [{tp: \"VD\", data: {<your video data here>}]}\n}\n```\n\n_IMPORTANT Security Consideration_: the `val`, `ref`, `preview` fields may contain malicious payload. The client should restrict URL scheme in the `ref` and `preview` fields to `http` and `https` only. The client should present content of `val` field to the user only if it's correctly converted to a video.\n"
  },
  {
    "path": "docs/faq.md",
    "content": "# Frequently Asked Questions\n\n### Q: Where can I find server logs when running in Docker?<br/>\n**A**: The log is in the container at `/var/log/tinode.log`. Attach to a running container with command\n```\ndocker exec -it name-of-the-running-container /bin/bash\n```\nThen, for instance, see the log with `tail -50 /var/log/tinode.log`\n\nIf the container has stopped already, you can copy the log out of the container (saving it to `./tinode.log`):\n```\ndocker cp name-of-the-container:/var/log/tinode.log ./tinode.log\n```\n\nAlternatively, 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.\n\n\n### Q: What are the options for enabling push notifications?<br/>\n**A**: You can use [Tinode Push Gateway (TNPG)](https://github.com/tinode/chat/tree/master/server/push/tnpg) or you can use [Google FCM](https://firebase.google.com/docs/cloud-messaging):\n * _Tinode Push Gateway_ uses Tinode servers to send pushes on your behalf. It requires minumum setup: your server sends request to TNPG, which forwards it to Google FCM or Apple APNS.\n * _Google FCM_ does not rely on Tinode infrastructure for pushes but requires you to build and release your own mobile apps (iOS and Android).\n\n\n### Q: How to setup push notifications with Tinode Push Gateway?<br/>\n**A**: Enabling TNPG push notifications requires two steps:\n * register at [console.tinode.co](https://console.tinode.co) and obtain a TNPG token.\n * configure server with the token.\nSee detailed instructions [here](../server/push/tnpg/).\n\n\n### Q: How to setup push notifications with Google FCM?<br/>\n**A**: This option requires you to build and release your own mobile apps. If you do not want to do it, use the TNPG option above.\n\nEnabling FCM push notifications requires the following steps:\n * enable push sending from the server.\n * enable receiving pushes in the clients.\n\n#### Server and TinodeWeb\n\n1. Create a project at https://firebase.google.com/ if you have not done so already.\n2. Follow instructions at https://cloud.google.com/iam/docs/creating-managing-service-account-keys to download the credentials file.\n3. Update the server config [`tinode.conf`](../server/tinode.conf#L255), section `\"push\"` -> `\"name\": \"fcm\"`. Do _ONE_ of the following:\n  * _Either_ enter the path to the downloaded credentials file into `\"credentials_file\"`.\n  * _OR_ copy the file contents to `\"credentials\"`.<br/><br/>\n    Remove the other entry. I.e. if you have updated `\"credentials_file\"`, remove `\"credentials\"` and vice versa.\n4. 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\n\n#### iOS and Android\n1. If you are using an Android client, add `google-services.json` to [Tindroid](/tinode/tindroid/) by following instructions at https://developers.google.com/android/guides/google-services-plugin and recompile the client. You may also optionally submit it to Google Play Store.\nSee more info at https://github.com/tinode/tindroid/#push_notifications\n2. If you are using an iOS client, add `GoogleService-Info.plist` to [Tinodios](/tinode/ios/) by following instructions at https://firebase.google.com/docs/cloud-messaging/ios/client) and recompile the client. You may optionally submit the app to Apple AppStore.\nSee more info at https://github.com/tinode/ios/#push_notifications\n\n\n### Q: How to add new users?<br/>\n**A**: There are three ways to create accounts:\n* A user can create a new account using one of the applications (web, Android, iOS).\n* A new account can be created using [tn-cli](../tn-cli/) (`acc` command or `useradd` macro). The process can be scripted.\n* If the user already exists in an external database, the Tinode account can be automatically created on the first login using the [rest authenticator](../server/auth/rest/).\n\n\n### Q: How do I make my installation private?<br/>\n**A**: If you want to restrict registrations to only those people whom you approve, then the simplest way is to restrict Tinode registrations to an email domain you control: register a custom domain, set up a catch-all email forwarding service at your domain registrar (usually free). Then use your domain name in Tinode config (`\"acc_validation\" -> \"email\" -> \"domains\"`, for example `\"domains\": [\"my-domain.com\"]`). You will receive registration emails at your catch-all email box and you will be able to forward validation codes to your users manually. Alternatively, if you have a lot of users, you can use [rest authenticator](../server/auth/rest/).\n\n\n### Q: How to create a `root` user?<br/>\n**A**: Starting with Tinode version 0.18 the `root` access can be granted to a user by running the following command:\n```sh\n./tinode-db -auth=ROOT -uid=usrAbcDef123 -scheme=basic\n```\nStarting with 0.21 you can use a simpler command:\n```sh\n./tinode-db -make_root=usrAbcDef123\n```\nWhere `usrAbcDef123` is the ID of the user to update.\n\nIn version 0.17 and older the `root` access can be granted to a user only by executing a database query.\nFirst create or choose the user you want to promote to `root` then execute the query:\n* RethinkDB:\n```js\nr.db('tinode').table('auth').get('basic:login-of-the-user-to-make-root').update({authLvl: 30})\n```\n* MySQL, PostgreSQL:\n```sql\nUSE 'tinode';\nUPDATE auth SET authlvl=30 WHERE uname='basic:login-of-the-user-to-make-root';\n```\n* MongoDB:\n```js\ndb.getCollection('auth').updateOne({_id: 'basic:login-of-the-user-to-make-root'}, {$set: {authlvl: 30}})\n```\nThe test database has a stock user `xena` which has root access.\n\n\n### Q: Once the number of network connections reaches about 1000 per node, all kinds of problems start. Is this a bug?<br/>\n**A**: It is likely not a bug. To ensure good server performance Linux limits the total number of open file descriptors (live network connections, open files) for each process at the kernel level. The default limit is usually 1024. There are other possible restrictions on the number of file descriptors. The problems you are experiencing are likely caused by exceeding one of the Linux-imposed limits. Please seek assistance of a system administrator.\n\n\n### Q: What is the difference between a group topic and a channel?<br/>\n**A**: Channel is a special case of a group topic. Normal group topics allow limited number of subscribers (128 by default). Each subscriber can be managed individually: invited, removed, banned, promoted to administrator or owner, other access permissions can be personally adjusted. Group topics with enabled channel functionality additionally permit an unlimited number of `readers`. The readers have read-only access to the topic, they cannot be managed individually, cannot be invited or removed, they cannot post messages. Readers do not generate presence notifications when joining or un-joining the topic and do not receive presence notifications from normal group members. Readers receive channel messages with `From` field set to `null`, i.e. they do not know who personally posted any given message to the channel. Readers cannot delete channel messages.\n\n\n### Q: What is the proper way to format gRPC {pub content}?<br/>\n**A**: The gPRC sends `content` field of a `{pub}` message as a byte array while the client applications expect it to be valid JSON. Consequently, you have to format the field to be valid JSON before passing it to gRPC. For example, to send a plain text `Hello world` message you have to send a quoted string `\"Hello world\"`. In most cases the string you pass to the gRPC call would look like `\"\\\"Hello world\\\"\"` or `'\"Hello world\"'`.\n\n\n### Q: How to fix PostgreSQL initialization failing with 'missing database' error?<br/>\n**A**: PostgreSQL has a (mis)feature: a DB connection must always select a database. If the connection tries to use a database which does not exist (even with intent to create it), the connection fails. When Tinode is started for the first time, it tries to create a database, usually `tinode` (see `tinode.conf`, `\"store_config\": {\"adapters\": {\"postgres\": {\"DBName\": \"tinode\"}}}`. The database `tinode` obviously does not exist, so Tinode connection falls back to 'default' database which has the same name as the name of the connecting PostgreSQL user. The default configuration specifies user as `postgres` (`\"User\": \"postgres\"`), the database `postgres` always exists, so the connection succeeds and everything works as expected. But if you change the user to anything other than `postgres`, let's say `tinodeadmin`, then trouble starts: the database with the name `tinodeadmin` does not exist and the connection fails. If you want to change the user name to anything other than `postgres`, then you must create either a database `tinode` (or whatever you named your Tinode database) or an empty database with the same name as your user `tinodeadmin`. For example:\n```\n$ psql\n\tpostgres=# create database tinode;\n\texit\n```\n"
  },
  {
    "path": "docs/monitoring.md",
    "content": "# Monitoring Tinode server\n\nTinode server can optionally expose runtime statistics as a json document at a configurable HTTP(S) endpoint. The feature is enabled by adding a string parameter `expvar` to the config file. The value of the `expvar` is the URL path pointing to the location where the variables are served. In addition to the config file, the feature can be enabled from the command line by adding an `--expvar` parameter. The feature is disabled if the value of `expvar` is an empty string `\"\"` or a dash `\"-\"`. A non-blank value of the command line parameter overrides the config file value.\n\nThe feature is enabled in the default config file to publish stats at `/debug/vars`.\n\nAs of the time of this writing the following stats are published:\n\n* `memstats`: Go's memory statistics as described at https://golang.org/pkg/runtime/#MemStats\n* `cmdline`: server's command line parameters as an array of strings.\n* `TotalSessions`: the count of all sessions which were created during server's life time.\n* `LiveSessions`: the number of sessions currently live, regardless of authentication status.\n* `TotalTopics`: the count of all topics activated during servers's life time.\n* `LiveTopics`: the number of currently active topics.\n"
  },
  {
    "path": "docs/thecard.md",
    "content": "# theCard: Person/Topic Description Format\n\nTinode uses `theCard` to store and transmit descriptions of people and topics. The format is conceptually similar to [vCard](https://www.rfc-editor.org/rfc/rfc6350.txt) 3.0.\n\nWhen `JSON` is used to represent `theCard` data, it does it differently than [jCard](https://tools.ietf.org/html/rfc7095). `theCard` and `jCard` are incompatible. The main difference is that `theCard` uses objects to represent logically related data while `jCard` uses ordered arrays.\n\n`theCard` is structured as an object:\n\n```js\n{\n  fn: \"John Doe\", // string, formatted name of the person or topic.\n  photo: { // object, avatar photo; either 'data' or 'ref' must be present, all other fields are optional.\n    type: \"jpeg\", // string, MIME type but with 'image/' dropped.\n    data: \"Rt53jUU...iVBORw0KGgoA==\", // string, base64-encoded binary image data\n    ref: \"https://api.tinode.co/file/s/abcdef12345.jpg\", // string, URL of the image.\n    width: 512, // integer, image width in pixels.\n    height: 512, // integer, image height in pixels.\n    size: 123456 // integer, image size in bytes.\n  },\n  note: \"Some notes\", // string, description of a person or a topic.\n  //\n  // None of the following fields are implemented by any known client:\n  //\n  n: { // object, person's structured name.\n    surname: \"Miner\", // surname or last or family name.\n    given: \"Coal\", // first or given name.\n    additional: \"Diamond\", // additional name, such as middle name or patronymic.\n    prefix: \"Dr.\", // prefix, such as honorary title or gender designation.\n    suffix: \"Jr.\", // suffix, such as 'Jr' or 'II'.\n  },\n  org: { // object, organization the person or topic belongs to.\n    fn: \"Most Evil Corp\", // string, formatted name of the organisation.\n    title: \"CEO\", // string, person's job title at the organisation.\n  },\n  comm: [ // array of objects defining means of communication with the the person or topic.\n    {\n      des: [\"home\", \"voice\"], // contact designation, optional.\n      proto: \"tel\", // communication protocol, required\n      value: \"+17025551234\" // phone number.\n    },\n    {\n      des: [\"work\"],\n      proto: \"email\",\n      value: \"alice@example.com\", // email address\n    },\n    {\n      des: [\"other\"],\n      proto: \"tinode\",\n      value: \"tinode:topic/usrRkDVe0PYDOo\", // tinode ID URI, may include server address.\n    },\n    {\n      proto: \"http\", // should be used for either http or https website addresses.\n      value: \"https://tinode.co\", // actual address of a website.\n    }, ...\n  ],\n  bday: { // object, person's birthday.\n    y: 1970, // integer, year\n    m: 1, // integer, month 1..12\n    d: 15 // integer, day 1..31\n  },\n}\n```\n\nAll fields are optional. Tinode clients currently use only `fn`, `photo`, `org`, `note`, `comm` fields. If other fields are needed in the future, then they will be adopted from the correspondent [vCard](https://www.rfc-editor.org/rfc/rfc6350.txt) fields.\n"
  },
  {
    "path": "docs/translations.md",
    "content": "# Localizing Tinode\n\n**IMPORTANT!** Please use `devel` branches for translations.\n\n## Server\n\nThe server sends emails or SMS to users upon creation of a new account and when the user requests to reset the password:\n\n* [/server/templ/email-validation-en.templ](../server/templ/email-validation-en.templ)\n* [/server/templ/email-password-reset-en.templ](../server/templ/email-password-reset-en.templ)\n* [/server/templ/sms-validation-en.templ](../server/templ/sms-validation-en.templ)\n\nCreate a copy of the files naming them `email-password-reset-XX.teml`, `email-validation-XX.templ`, `sms-validation-XX.templ` where `XX` is the [ISO-631-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) code of the new language. Translate the content and send a pull request with the new files. If you don't know how to create a pull request then just sent the translated files in any way you can.\n\n\n## Webapp\n\nThe translations are located in two places: [/src/i18n/](https://github.com/tinode/webapp/tree/devel/src/i18n/) and [/service-worker.js](https://github.com/tinode/webapp/blob/devel/service-worker.js#L11).\n\nIn order to add a translation, copy `/src/i18n/en.json` to a file named `/src/i18n/XX.json` where `XX` is the [BCP-47](https://tools.ietf.org/rfc/bcp/bcp47.txt) code of the new language. If in doubt how to choose the BCP-47 language code, use a two letter [ISO-631-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes). Only the `\"translation\":` line has to be translated, the `\"defaultMessage\"`, `\"description\"` etc should NOT be translated, they serve as help only, i.e.:\n\n```js\n\"action_block_contact\": {\n  \"translation\": \"Bloquear contacto\", // <<<---- Only this string needs to be translated\n  \"defaultMessage\": \"Block Contact\",  // This is the default message in English\n  \"description\": \"Flat button [Block Contact]\", // This is an explanation where/how the string is used.\n  \"missing\": false,\n  \"obsolete\": false\n},\n```\n\nWhen translating the `service-worker.js`, just add the strings directly to the file. Only two strings need to be translated \"New message\" and \"New chat\":\n\n```js\nconst i18n = {\n  ...\n  'XX': {\n    'new_message': \"New message\",\n    'new_chat': \"New chat\",\n  },\n  ...\n```\n\nPlease send a pull request with the new files. If you don't know how to create a pull request just sent the files in any way you can.\n\n\n## Android\n\nA single file needs to be translated: [/tinode/tindroid/app/src/main/res/values/strings.xml](https://github.com/tinode/tindroid/blob/devel/app/src/main/res/values/strings.xml)\n\nCreate a new directory `values-XX` in [app/src/main/res](https://github.com/tinode/tindroid/tree/devel/app/src/main/res), where `XX` is a two-letter [ISO-631-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) code , optionally followed by a two letter [ISO 3166-1-alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) region code (preceded by lowercase r). For example `values-pt.xml` would contain Portuguese translations while `values-pt-rBR.xml` is for _Brazilian_ Portuguese translations.\n\nMake a copy of the file with English strings, place it to the new directory. Translate all the strings not marked with `translatable=\"false\"` (the strings with `translatable=\"false\"` don't need to be included at all) then send a pull request with the new file. If you don't know how to create a pull request then just sent the file in any way you can.\n\n\n## iOS\n\nUnfortunately iOS localization process is very convoluted and generally requires `Xcode` which runs only on Mac. Unless you are familiar with the iOS development, please create a [feature request](https://github.com/tinode/ios/issues/new?assignees=&labels=&template=feature_request.md&title=) for the desired language and we will send you the file for translation.\n\nIf you feel brave enough, you can translate the [following .xliff file](https://github.com/tinode/ios/blob/devel/Localizations/en.xcloc/Localized%20Contents/en.xliff) and then send it to us in any way you can. Translate all the strings between the `<target>` tags:\n\n```xml\n<trans-unit id=\"Action failed: %@\" xml:space=\"preserve\"> <!-- Do NOT change this line -->\n  <source>Action failed: %@</source> <!-- This is the default message in English. -->\n  <target>Se ha producido un error al realizar la acción: %@</target> <!-- Only this string \"target\" needs to be translated. -->\n  <note>Toast notification</note> <!-- This is an explanation where/how the string is used. -->\n</trans-unit>\n```\n\nIf you are familiar with Xcode and localization for iOS, the exported localizations are located at [/Localizations](https://github.com/tinode/ios/tree/devel/Localizations).\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/tinode/chat\n\ngo 1.24.0\n\nrequire (\n\tfirebase.google.com/go v3.13.0+incompatible\n\tgithub.com/aws/aws-sdk-go v1.55.7\n\tgithub.com/go-sql-driver/mysql v1.9.3\n\tgithub.com/golang/mock v1.6.0\n\tgithub.com/google/go-cmp v0.7.0\n\tgithub.com/gorilla/handlers v1.5.2\n\tgithub.com/gorilla/websocket v1.5.3\n\tgithub.com/jackc/pgconn v1.14.3\n\tgithub.com/jackc/pgx/v4 v4.18.3\n\tgithub.com/jmoiron/sqlx v1.4.0\n\tgithub.com/nyaruka/phonenumbers v1.6.3\n\tgithub.com/prometheus/client_golang v1.22.0\n\tgithub.com/prometheus/common v0.65.0\n\tgithub.com/rivo/uniseg v0.4.7\n\tgithub.com/tinode/jsonco v1.0.0\n\tgithub.com/tinode/snowflake v1.0.0\n\tgo.mongodb.org/mongo-driver v1.17.4\n\tgolang.org/x/crypto v0.45.0\n\tgolang.org/x/oauth2 v0.30.0\n\tgolang.org/x/text v0.31.0\n\tgoogle.golang.org/api v0.241.0\n\tgoogle.golang.org/grpc v1.73.0\n\tgoogle.golang.org/protobuf v1.36.6\n\tgopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.2\n)\n\nrequire (\n\tcel.dev/expr v0.24.0 // indirect\n\tcloud.google.com/go/auth v0.16.2 // indirect\n\tcloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect\n\tcloud.google.com/go/monitoring v1.24.2 // indirect\n\tfilippo.io/edwards25519 v1.1.0 // indirect\n\tgithub.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect\n\tgithub.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect\n\tgithub.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect\n\tgithub.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect\n\tgithub.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect\n\tgithub.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect\n\tgithub.com/go-jose/go-jose/v4 v4.1.1 // indirect\n\tgithub.com/go-logr/logr v1.4.3 // indirect\n\tgithub.com/go-logr/stdr v1.2.2 // indirect\n\tgithub.com/golang-jwt/jwt/v5 v5.2.2 // indirect\n\tgithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect\n\tgithub.com/pkg/errors v0.9.1 // indirect\n\tgithub.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect\n\tgithub.com/spiffe/go-spiffe/v2 v2.5.0 // indirect\n\tgithub.com/zeebo/errs v1.4.0 // indirect\n\tgo.opentelemetry.io/auto/sdk v1.1.0 // indirect\n\tgo.opentelemetry.io/contrib/detectors/gcp v1.37.0 // indirect\n\tgo.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect\n\tgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect\n\tgo.opentelemetry.io/otel v1.37.0 // indirect\n\tgo.opentelemetry.io/otel/metric v1.37.0 // indirect\n\tgo.opentelemetry.io/otel/sdk v1.37.0 // indirect\n\tgo.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect\n\tgo.opentelemetry.io/otel/trace v1.37.0 // indirect\n\tgolang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect\n\tgolang.org/x/net v0.47.0 // indirect\n)\n\nrequire (\n\tcloud.google.com/go v0.121.3 // indirect\n\tcloud.google.com/go/compute/metadata v0.7.0 // indirect\n\tcloud.google.com/go/firestore v1.18.0 // indirect\n\tcloud.google.com/go/iam v1.5.2 // indirect\n\tcloud.google.com/go/longrunning v0.6.7 // indirect\n\tcloud.google.com/go/storage v1.55.0 // indirect\n\tgithub.com/beorn7/perks v1.0.1 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/felixge/httpsnoop v1.0.4 // indirect\n\tgithub.com/golang/protobuf v1.5.4 // indirect\n\tgithub.com/golang/snappy v1.0.0 // indirect\n\tgithub.com/google/s2a-go v0.1.9 // indirect\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect\n\tgithub.com/googleapis/gax-go/v2 v2.15.0 // indirect\n\tgithub.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect\n\tgithub.com/jackc/chunkreader/v2 v2.0.1 // indirect\n\tgithub.com/jackc/pgio v1.0.0 // indirect\n\tgithub.com/jackc/pgpassfile v1.0.0 // indirect\n\tgithub.com/jackc/pgproto3/v2 v2.3.3 // indirect\n\tgithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect\n\tgithub.com/jackc/pgtype v1.14.4 // indirect\n\tgithub.com/jackc/puddle v1.3.0 // indirect\n\tgithub.com/jmespath/go-jmespath v0.4.0 // indirect\n\tgithub.com/klauspost/compress v1.18.0 // indirect\n\tgithub.com/montanaflynn/stats v0.7.1 // indirect\n\tgithub.com/opentracing/opentracing-go v1.2.0 // indirect\n\tgithub.com/prometheus/client_model v0.6.2 // indirect\n\tgithub.com/prometheus/procfs v0.17.0 // indirect\n\tgithub.com/sirupsen/logrus v1.9.3 // indirect\n\tgithub.com/twilio/twilio-go v1.26.5\n\tgithub.com/xdg-go/pbkdf2 v1.0.0 // indirect\n\tgithub.com/xdg-go/scram v1.1.2 // indirect\n\tgithub.com/xdg-go/stringprep v1.0.4 // indirect\n\tgithub.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect\n\tgolang.org/x/sync v0.18.0 // indirect\n\tgolang.org/x/sys v0.38.0 // indirect\n\tgolang.org/x/time v0.12.0 // indirect\n\tgoogle.golang.org/appengine v1.6.8 // indirect\n\tgoogle.golang.org/genproto v0.0.0-20250707201910-8d1bb00bc6a7 // indirect\n\tgoogle.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect\n\tgoogle.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect\n\tgopkg.in/cenkalti/backoff.v2 v2.2.1 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=\ncel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=\ncloud.google.com/go v0.121.3 h1:84RD+hQXNdY5Sw/MWVAx5O9Aui/rd5VQ9HEcdN19afo=\ncloud.google.com/go v0.121.3/go.mod h1:6vWF3nJWRrEUv26mMB3FEIU/o1MQNVPG1iHdisa2SJc=\ncloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=\ncloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=\ncloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=\ncloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=\ncloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=\ncloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=\ncloud.google.com/go/firestore v1.18.0 h1:cuydCaLS7Vl2SatAeivXyhbhDEIR8BDmtn4egDhIn2s=\ncloud.google.com/go/firestore v1.18.0/go.mod h1:5ye0v48PhseZBdcl0qbl3uttu7FIEwEYVaWm0UIEOEU=\ncloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8=\ncloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE=\ncloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=\ncloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA=\ncloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=\ncloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=\ncloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=\ncloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=\ncloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2znf0=\ncloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY=\ncloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=\ncloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=\nfilippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=\nfilippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=\nfirebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=\nfirebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=\ngithub.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=\ngithub.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4=\ngithub.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw=\ngithub.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM=\ngithub.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs=\ngithub.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg=\ngithub.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ=\ngithub.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ=\ngithub.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=\ngithub.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=\ngithub.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE=\ngithub.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=\ngithub.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=\ngithub.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=\ngithub.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=\ngithub.com/bitly/go-hostpool v0.1.0 h1:XKmsF6k5el6xHG3WPJ8U0Ku/ye7njX7W81Ng7O2ioR0=\ngithub.com/bitly/go-hostpool v0.1.0/go.mod h1:4gOCgp6+NZnVqlKyZ/iBZFTAJKembaVENUpMkpg42fw=\ngithub.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=\ngithub.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=\ngithub.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=\ngithub.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=\ngithub.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=\ngithub.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=\ngithub.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=\ngithub.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=\ngithub.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=\ngithub.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=\ngithub.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=\ngithub.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=\ngithub.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=\ngithub.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=\ngithub.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=\ngithub.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=\ngithub.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=\ngithub.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=\ngithub.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=\ngithub.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=\ngithub.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=\ngithub.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=\ngithub.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=\ngithub.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=\ngithub.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=\ngithub.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=\ngithub.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=\ngithub.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=\ngithub.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=\ngithub.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=\ngithub.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=\ngithub.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=\ngithub.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=\ngithub.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=\ngithub.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=\ngithub.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=\ngithub.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=\ngithub.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=\ngithub.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=\ngithub.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=\ngithub.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=\ngithub.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=\ngithub.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=\ngithub.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=\ngithub.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=\ngithub.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=\ngithub.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=\ngithub.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=\ngithub.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=\ngithub.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=\ngithub.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=\ngithub.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=\ngithub.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=\ngithub.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=\ngithub.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=\ngithub.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\ngithub.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=\ngithub.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=\ngithub.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=\ngithub.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=\ngithub.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=\ngithub.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=\ngithub.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=\ngithub.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=\ngithub.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=\ngithub.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=\ngithub.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=\ngithub.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=\ngithub.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=\ngithub.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w=\ngithub.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM=\ngithub.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=\ngithub.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=\ngithub.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=\ngithub.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=\ngithub.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=\ngithub.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=\ngithub.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=\ngithub.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=\ngithub.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=\ngithub.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=\ngithub.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=\ngithub.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=\ngithub.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=\ngithub.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=\ngithub.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=\ngithub.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag=\ngithub.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=\ngithub.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=\ngithub.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=\ngithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=\ngithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=\ngithub.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=\ngithub.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=\ngithub.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=\ngithub.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=\ngithub.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=\ngithub.com/jackc/pgtype v1.14.4 h1:fKuNiCumbKTAIxQwXfB/nsrnkEI6bPJrrSiMKgbJ2j8=\ngithub.com/jackc/pgtype v1.14.4/go.mod h1:aKeozOde08iifGosdJpz9MBZonJOUJxqNpPBcMJTlVA=\ngithub.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=\ngithub.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=\ngithub.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=\ngithub.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=\ngithub.com/jackc/pgx/v4 v4.18.2/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw=\ngithub.com/jackc/pgx/v4 v4.18.3 h1:dE2/TrEsGX3RBprb3qryqSV9Y60iZN1C6i8IrmW9/BA=\ngithub.com/jackc/pgx/v4 v4.18.3/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw=\ngithub.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=\ngithub.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=\ngithub.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=\ngithub.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0=\ngithub.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=\ngithub.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=\ngithub.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=\ngithub.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=\ngithub.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=\ngithub.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=\ngithub.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=\ngithub.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=\ngithub.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=\ngithub.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=\ngithub.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=\ngithub.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=\ngithub.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=\ngithub.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=\ngithub.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=\ngithub.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=\ngithub.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=\ngithub.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=\ngithub.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=\ngithub.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=\ngithub.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275 h1:IZycmTpoUtQK3PD60UYBwjaCUHUP7cML494ao9/O8+Q=\ngithub.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275/go.mod h1:zt6UU74K6Z6oMOYJbJzYpYucqdcQwSMPBEdSvGiaUMw=\ngithub.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=\ngithub.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=\ngithub.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=\ngithub.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=\ngithub.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=\ngithub.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=\ngithub.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=\ngithub.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=\ngithub.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=\ngithub.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=\ngithub.com/nyaruka/phonenumbers v1.6.3 h1:JU7Q30+UM/03/vto6Q4EiZfEuRpTVyXMqImIbI942Qw=\ngithub.com/nyaruka/phonenumbers v1.6.3/go.mod h1:7gjs+Lchqm49adhAKB5cdcng5ZXgt6x7Jgvi0ZorUtU=\ngithub.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=\ngithub.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=\ngithub.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=\ngithub.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=\ngithub.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=\ngithub.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=\ngithub.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=\ngithub.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=\ngithub.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=\ngithub.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=\ngithub.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=\ngithub.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=\ngithub.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=\ngithub.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=\ngithub.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=\ngithub.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=\ngithub.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=\ngithub.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=\ngithub.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=\ngithub.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=\ngithub.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=\ngithub.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=\ngithub.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=\ngithub.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=\ngithub.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=\ngithub.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=\ngithub.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=\ngithub.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=\ngithub.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=\ngithub.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=\ngithub.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=\ngithub.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=\ngithub.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=\ngithub.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=\ngithub.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=\ngithub.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=\ngithub.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/tinode/jsonco v1.0.0 h1:zVcpjzDvjuA1G+HLrckI5EiiRyq9jgV3x37OQl6e5FE=\ngithub.com/tinode/jsonco v1.0.0/go.mod h1:Bnavu3302Qfn2pILMNwASkelodgeew3IvDrbdzU84u8=\ngithub.com/tinode/snowflake v1.0.0 h1:YciQ9ZKn1TrnvpS8yZErt044XJaxWVtR9aMO9rOZVOE=\ngithub.com/tinode/snowflake v1.0.0/go.mod h1:5JiaCe3o7QdDeyRcAeZBGVghwRS+ygt2CF/hxmAoptQ=\ngithub.com/twilio/twilio-go v1.26.5 h1:K105kKOyoulPsW1uB6lPrjGf+j5rAEGgDh1ZXtqznWc=\ngithub.com/twilio/twilio-go v1.26.5/go.mod h1:FpgNWMoD8CFnmukpKq9RNpUSGXC0BwnbeKZj2YHlIkw=\ngithub.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=\ngithub.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=\ngithub.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=\ngithub.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=\ngithub.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=\ngithub.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=\ngithub.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=\ngithub.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=\ngithub.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=\ngithub.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngithub.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=\ngithub.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=\ngithub.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=\ngo.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw=\ngo.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=\ngo.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=\ngo.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=\ngo.opentelemetry.io/contrib/detectors/gcp v1.37.0 h1:B+WbN9RPsvobe6q4vP6KgM8/9plR/HNjgGBrfcOlweA=\ngo.opentelemetry.io/contrib/detectors/gcp v1.37.0/go.mod h1:K5zQ3TT7p2ru9Qkzk0bKtCql0RGkPj9pRjpXgZJZ+rU=\ngo.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 h1:rbRJ8BBoVMsQShESYZ0FkvcITu8X8QNwJogcLUmDNNw=\ngo.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0/go.mod h1:ru6KHrNtNHxM4nD/vd6QrLVWgKhxPYgblq4VAtNawTQ=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=\ngo.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=\ngo.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=\ngo.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=\ngo.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=\ngo.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=\ngo.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=\ngo.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=\ngo.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=\ngo.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=\ngo.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=\ngo.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=\ngo.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=\ngo.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=\ngo.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=\ngo.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=\ngo.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=\ngo.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=\ngo.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=\ngo.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=\ngo.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=\ngo.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=\ngo.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=\ngo.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=\ngo.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=\ngo.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=\ngolang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=\ngolang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=\ngolang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ=\ngolang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=\ngolang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=\ngolang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=\ngolang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=\ngolang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=\ngolang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=\ngolang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=\ngolang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=\ngolang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=\ngolang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=\ngolang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=\ngolang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=\ngolang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=\ngolang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=\ngolang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=\ngolang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=\ngolang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=\ngolang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=\ngolang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=\ngolang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=\ngolang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=\ngolang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\ngolang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=\ngolang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=\ngolang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=\ngolang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=\ngolang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=\ngolang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=\ngolang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=\ngolang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=\ngolang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=\ngolang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/api v0.241.0 h1:QKwqWQlkc6O895LchPEDUSYr22Xp3NCxpQRiWTB6avE=\ngoogle.golang.org/api v0.241.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=\ngoogle.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=\ngoogle.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=\ngoogle.golang.org/genproto v0.0.0-20250707201910-8d1bb00bc6a7 h1:FGOcxvKlJgRBVbXeugjljCfCgfKWhC42FBoYmTCWVBs=\ngoogle.golang.org/genproto v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:249YoW4b1INqFTEop2T4aJgiO7UBYJrpejsaLvjWfI8=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=\ngoogle.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=\ngoogle.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=\ngoogle.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=\ngoogle.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=\ngoogle.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=\ngoogle.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=\ngopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=\ngopkg.in/cenkalti/backoff.v2 v2.2.1 h1:eJ9UAg01/HIHG987TwxvnzK2MgxXq97YY6rYDpY9aII=\ngopkg.in/cenkalti/backoff.v2 v2.2.1/go.mod h1:S0QdOvT2AlerfSBkp0O+dk+bbIMaNbEmVk876gPCthU=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=\ngopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=\ngopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=\ngopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=\ngopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.2 h1:tczPZjdz6soV2thcuq1IFOuNLrBUGonFyUXBbIWXWis=\ngopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.2/go.mod h1:c7Wo0IjB7JL9B9Avv0UZKorYJCUhiergpj3u1WtGT1E=\ngopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=\ngopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\nhonnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=\n"
  },
  {
    "path": "keygen/README.md",
    "content": "# keygen: API key generator\n\nA command-line utility to generate an API key for [Tinode server](../server/)\n\n**Parameters:**\n\n * `sequence`: Sequential number of the API key. This value can be used to reject previously issued keys.\n * `isroot`: Currently unused. Intended to designate key of a system administrator.\n * `validate`: Key to validate: check previously issued key for validity.\n * `salt`: [HMAC](https://en.wikipedia.org/wiki/HMAC) salt, 32 random bytes base64 standard encoded; must be present for key validation; optional when generating the key: if missing, a cryptographically-strong salt will be automatically generated.\n\n\n## Usage\n\nThe API key is used to provide some protection from automatic scraping of server API and for identification of client applications.\n\n* `API key` is used on the client side.\n* `HMAC salt` is used on the server side to verify the API key.\n\nRun the generator:\n\n```sh\n./keygen\n```\n\nSample output:\n\n```text\nAPI key v1 seq1 [ordinary]: AQAAAAABAACGOIyP2vh5avSff5oVvMpk\nHMAC salt: TC0Jzr8f28kAspXrb4UYccJUJ63b7CSA16n1qMxxGpw=\n```\n\nCopy `HMAC salt` to `api_key_salt` parameter in your server [config file](https://github.com/tinode/chat/blob/master/server/tinode.conf).\nCopy `API key` to the client applications:\n\n * TinodeWeb: `API_KEY` in [config.js](https://github.com/tinode/webapp/blob/master/src/config.js)\n * Tindroid: `API_KEY` in [Cache.java](https://github.com/tinode/tindroid/blob/master/app/src/main/java/co/tinode/tindroid/Cache.java)\n * Tinodious: `kApiKey` in [SharedUtils.swift](https://github.com/tinode/ios/blob/master/TinodiosDB/SharedUtils.swift)\n\nRebuild the clients after changing the API key.\n"
  },
  {
    "path": "keygen/keygen.go",
    "content": "package main\n\nimport (\n\t\"bytes\"\n\t\"crypto/hmac\"\n\t\"crypto/md5\"\n\t\"crypto/rand\"\n\t\"encoding/base64\"\n\t\"encoding/binary\"\n\t\"flag\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n)\n\n// Generate API key\n// Composition:\n//\n//\t[1:algorithm version][4:deprecated (used to be application ID)][2:key sequence][1:isRoot][16:signature] = 24 bytes\n//\n// convertible to base64 without padding.\n// All integers are little-endian.\nfunc main() {\n\tversion := flag.Int(\"sequence\", 1, \"Sequential number of the API key\")\n\tisRoot := flag.Int(\"isroot\", 0, \"Is this a root API key?\")\n\tapikey := flag.String(\"validate\", \"\", \"API key to validate\")\n\thmacSalt := flag.String(\"salt\", \"\", \"HMAC salt, 32 random bytes base64-encoded\")\n\n\tflag.Parse()\n\n\tif *apikey != \"\" {\n\t\tif *hmacSalt == \"\" {\n\t\t\tlog.Println(\"Error: must provide HMAC salt for key validation\")\n\t\t\tos.Exit(1)\n\t\t}\n\t\tos.Exit(validate(*apikey, *hmacSalt))\n\t} else {\n\t\tos.Exit(generate(*version, *isRoot, *hmacSalt))\n\t}\n}\n\nconst (\n\t// APIKEY_VERSION is algorithm version.\n\tAPIKEY_VERSION = 1\n\t// APIKEY_APPID is deprecated.\n\tAPIKEY_APPID = 4\n\t// APIKEY_SEQUENCE key serial number.\n\tAPIKEY_SEQUENCE = 2\n\t// APIKEY_WHO is a Root user designator.\n\tAPIKEY_WHO = 1\n\t// APIKEY_SIGNATURE is cryptographic signature.\n\tAPIKEY_SIGNATURE = 16\n\t// APIKEY_LENGTH is total length of the key.\n\tAPIKEY_LENGTH = APIKEY_VERSION + APIKEY_APPID + APIKEY_SEQUENCE + APIKEY_WHO + APIKEY_SIGNATURE\n)\n\nfunc generate(sequence, isRoot int, hmacSaltB64 string) int {\n\tvar data [APIKEY_LENGTH]byte\n\tvar hmacSalt []byte\n\n\tif hmacSaltB64 == \"\" {\n\t\thmacSalt = make([]byte, 32)\n\t\t_, err := rand.Read(hmacSalt)\n\t\tif err != nil {\n\t\t\tlog.Println(\"Error: Failed to generate HMAC salt\", err)\n\n\t\t\treturn 1\n\t\t}\n\t} else {\n\t\tvar err error\n\t\thmacSalt, err = base64.URLEncoding.DecodeString(hmacSaltB64)\n\t\tif err != nil {\n\t\t\t// Try standard base64 decoding\n\t\t\thmacSalt, err = base64.StdEncoding.DecodeString(hmacSaltB64)\n\t\t}\n\t\tif err != nil {\n\t\t\tlog.Println(\"Error: Failed to decode HMAC salt\", err)\n\n\t\t\treturn 1\n\t\t}\n\t}\n\t// Make sure the salt is base64std encoded: tinode.conf requires std encoding.\n\thmacSaltB64 = base64.StdEncoding.EncodeToString(hmacSalt)\n\n\t// [1:algorithm version][4:appid][2:key sequence][1:isRoot]\n\tdata[0] = 1 // default algorithm\n\t// deprecated\n\tbinary.LittleEndian.PutUint32(data[APIKEY_VERSION:], uint32(0))\n\tbinary.LittleEndian.PutUint16(data[APIKEY_VERSION+APIKEY_APPID:], uint16(sequence))\n\tdata[APIKEY_VERSION+APIKEY_APPID+APIKEY_SEQUENCE] = uint8(isRoot)\n\n\thasher := hmac.New(md5.New, hmacSalt)\n\thasher.Write(data[:APIKEY_VERSION+APIKEY_APPID+APIKEY_SEQUENCE+APIKEY_WHO])\n\tsignature := hasher.Sum(nil)\n\n\tcopy(data[APIKEY_VERSION+APIKEY_APPID+APIKEY_SEQUENCE+APIKEY_WHO:], signature)\n\n\tvar strIsRoot string\n\tif isRoot == 1 {\n\t\tstrIsRoot = \"ROOT\"\n\t} else {\n\t\tstrIsRoot = \"ordinary\"\n\t}\n\n\tfmt.Printf(\"API key v%d seq%d [%s]: %s\\nHMAC salt: %s\\n\", 1, sequence, strIsRoot,\n\t\tbase64.URLEncoding.EncodeToString(data[:]), hmacSaltB64)\n\n\treturn 0\n}\n\nfunc validate(apikey string, hmacSaltB64 string) int {\n\tvar version uint8\n\tvar deprecated uint32\n\tvar sequence uint16\n\tvar isRoot uint8\n\n\tvar strIsRoot string\n\n\thmacSalt, err := base64.URLEncoding.DecodeString(hmacSaltB64)\n\tif err != nil {\n\t\t// Try standard base64 decoding.\n\t\thmacSalt, err = base64.StdEncoding.DecodeString(hmacSaltB64)\n\t}\n\tif err != nil {\n\t\tlog.Println(\"Error: Failed to decode HMAC salt\", err)\n\n\t\treturn 1\n\t}\n\n\tif declen := base64.URLEncoding.DecodedLen(len(apikey)); declen != APIKEY_LENGTH {\n\t\tlog.Printf(\"Error: Invalid key length %d, expecting %d\", declen, APIKEY_LENGTH)\n\n\t\treturn 1\n\t}\n\n\tdata, err := base64.URLEncoding.DecodeString(apikey)\n\tif err != nil {\n\t\tlog.Println(\"Error: Failed to decode key as base64-URL-encoded\", err)\n\n\t\treturn 1\n\t}\n\n\tbuf := bytes.NewReader(data)\n\tbinary.Read(buf, binary.LittleEndian, &version)\n\n\tif version != 1 {\n\t\tlog.Println(\"Error: Unknown signature algorithm \", version)\n\n\t\treturn 1\n\t}\n\n\thasher := hmac.New(md5.New, hmacSalt)\n\thasher.Write(data[:APIKEY_VERSION+APIKEY_APPID+APIKEY_SEQUENCE+APIKEY_WHO])\n\n\tif signature := hasher.Sum(nil); !bytes.Equal(data[APIKEY_VERSION+APIKEY_APPID+APIKEY_SEQUENCE+APIKEY_WHO:], signature) {\n\t\tlog.Println(\"Error: Invalid signature \", data, signature)\n\n\t\treturn 1\n\t}\n\t// [1:algorithm version][4:deprecated][2:key sequence][1:isRoot]\n\tbinary.Read(buf, binary.LittleEndian, &deprecated)\n\tbinary.Read(buf, binary.LittleEndian, &sequence)\n\tbinary.Read(buf, binary.LittleEndian, &isRoot)\n\n\tif isRoot == 1 {\n\t\tstrIsRoot = \"ROOT\"\n\t} else {\n\t\tstrIsRoot = \"ordinary\"\n\t}\n\n\tfmt.Printf(\"Valid v%d seq%d, [%s]\\n\", version, sequence, strIsRoot)\n\n\treturn 0\n}\n"
  },
  {
    "path": "loadtest/LICENSE",
    "content": "Code in this folder is licensed under Apache 2.0\nhttp://www.apache.org/licenses/LICENSE-2.0\n"
  },
  {
    "path": "loadtest/README.md",
    "content": "# Tinode Load Testing\n\nContent of this directory is for running rudimentary load tests of Tinode server. You need this only if you want to run your own load tests.\n\n## Tsung\n\nThe `tsung.xml` is a configuration for [Tsung](http://tsung.erlang-projects.org/). The `tinode.beam` is an erlang binary required by the test to generate base64-encoded user-password pairs. The `tinode.erl` is the source for `tinode.beam` (`erlc tinode.erl` -> `tinode.beam`).\n\n[Install Tsung](http://tsung.erlang-projects.org/user_manual/installation.html), then run the test\n```\ntsung -f ./tsung.xml start\n```\n\n## Gatling\n\nA similar loadtest scenario is also available in Gatling. The configuration file is `loadtest.scala`.\nRun it with (after [installing Gatling](https://gatling.io/docs/current/installation/)):\n```\ngatling.sh -sf . -rsf . -rd \"na\" -s tinode.Loadtest\n```\n\nCurrently, three tests are available:\n\n* `tinode.Loadtest`: after connecting to server, retrieves user's subscriptions, and publishes a few messages to them one by one.\n* `tinode.MeLoadtest`: attempts to max out `me` topic connections.\n* `tinode.SingleTopicLoadtest`: connects to and publishes messages to the specified topic (typically, a group topic).\n\nThe script supports passing params via the `JAVA_OPTS` envvar.\n\nParameter name | Default value | Description\n-------------- | ------------- | -------------\n`num_sessions` | 10000 | Total number of sessions to connect to the server\n`ramp` | 300 | Time period in seconds over which to ramp up the load (`0` to `num_sessions`).\n`publish_count` | 10 | Number of messages that a user will publish to a topic it subscribes to.\n`publish_interval` | 100 | Maximum period of time a user will wait between publishing subsequent messages to a topic.\n`accounts` | users.csv | `tinode.Loadtest` and `tinode.SingleTopicLoadtest` only: Path to CSV file containing user accounts to use in loadtest (in format `username,password[,token]` (`token` field is optional).\n`topic` | | `tinode.SingleTopicLoadtest` only: topic name to send load to.\n`username` | | `tinode.MeLoadtest` only: user to subscribe to `me` topic.\n`password` | | `tinode.MeLoadtest` only: user password.\n\nExamples:\n```shell\nJAVA_OPTS=\"-Daccounts=users.csv -Dnum_sessions=100 -Dramp=10\" gatling.sh -sf . -rsf . -rd \"na\" -s tinode.Loadtest\n```\nRamps up load to 100 sessions listed in `users.csv` file over 10 seconds.\n\n```shell\nJAVA_OPTS=\"-Dusername=user1 -Dpassword=user1123 -Dnum_sessions=10000 -Dramp=600\" gatling.sh -sf . -rsf . -rd \"na\" -s tinode.MeLoadtest\n```\nConnects 10000 sessions to `me` topic for `user1` with password `user1123` over 600 seconds.\n\n```shell\nJAVA_OPTS=\"-Dtopic=grpYOrcDwORhPg -Daccounts=users.csv -Dnum_sessions=10000 -Dramp=1000 -Dpublish_count=2 -Dpublish_interval=300\" gatling.sh -sf . -rsf . -rd \"na\" -s tinode.SingleTopicLoadtest\n```\nConnects 10000 users (specified in `users.csv` file) to `grpYOrcDwORhPg` topic over 1000 seconds. Each user will publish 2 messages with interval up to 300 seconds.\n\nThis will be eventually packaged into a docker container.\n\n### Experiments\n\nWe have tested our single-server Tinode synthetic setup with 50000 accounts on a standard `t3.xlarge` AWS box (4 vCPUs, 16GiB, 5Gbps network) with the `mysql` backend.\nAs the load increases, before starting to drop:\n\n* The server can sustain 50000 concurrently connected sessions.\n* An individual group topic was able to sustain 1500 concurrent sessions.\n"
  },
  {
    "path": "loadtest/loadtest.scala",
    "content": "package tinode\n\nimport java.util.Base64\nimport java.util.concurrent.ConcurrentHashMap\n\nimport scala.collection.JavaConverters._\nimport scala.collection._\nimport scala.concurrent.duration._\n\nimport io.gatling.core.Predef._\nimport io.gatling.http.Predef._\n\nclass Loadtest extends TinodeBase {\n  // Input file can be set with the \"accounts\" java option.\n  // E.g. JAVA_OPTS=\"-Daccounts=/tmp/z.csv\" gatling.sh -sf . -rsf . -rd \"na\" -s tinode.Loadtest\n  val feeder = csv(System.getProperty(\"accounts\", \"users.csv\")).random\n\n  val scn = scenario(\"WebSocket\")\n    .exec(ws(\"Connect WS\").connect(\"/v0/channels?apikey=AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K\"))\n    .exec(session => session.set(\"id\", \"tn-\" + session.userId))\n    .pause(1)\n    .exec(hello)\n    .pause(1)\n    .feed(feeder)\n    .doIfOrElse({session =>\n      val uname = session(\"username\").as[String]\n      var token = session(\"token\").asOption[String]\n      if (token == None) {\n        token = tokenCache.get(uname)\n      }\n      token == None\n    }) { loginBasic } { loginToken }\n    .exitHereIfFailed\n    .exec(subMe)\n    .exitHereIfFailed\n    .exec(getSubs)\n    .exitHereIfFailed\n    .doIf({session =>\n      session.attributes.contains(\"subs\")\n    }) {\n      exec { session =>\n        // Shuffle subscriptions.\n        val subs = session(\"subs\").as[Vector[String]]\n        val shuffled = scala.util.Random.shuffle(subs.toList)\n        session.set(\"subs\", shuffled)\n      }\n      .foreach(\"${subs}\", \"sub\") {\n        exec(subTopic)\n        .exitHereIfFailed\n        .pause(0, 2)\n        .doIfOrElse({session =>\n          val topic = session(\"sub\").as[String]\n          !topic.startsWith(\"chn\")\n        }) { publish } { pause(5) }\n        .exec(leaveTopic)\n        .pause(0, 3)\n      }\n    }\n    .exec(ws(\"close-ws\").close)\n\n  setUp(scn.inject(rampUsers(numSessions) during (rampPeriod.seconds))).protocols(httpProtocol)\n}\n\nclass MeLoadtest extends TinodeBase {\n  val username = System.getProperty(\"username\", \"user0\")\n  val password = System.getProperty(\"password\", \"user0123\")\n\n  val scn = scenario(\"WebSocket\")\n    .exec(ws(\"Connect WS\").connect(\"/v0/channels?apikey=AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K\"))\n    .exec(session => session.set(\"id\", \"tn-\" + session.userId))\n    .exec(session => session.set(\"username\", username))\n    .exec(session => session.set(\"password\", password))\n    .pause(1)\n    .exec(hello)\n    .pause(1)\n    .doIfOrElse({session =>\n      val uname = session(\"username\").as[String]\n      val token = tokenCache.get(username)\n      token == None\n    }) { loginBasic } { loginToken }\n    .exitHereIfFailed\n    .exec(subMe)\n    .exitHereIfFailed\n    .exec(getSubs)\n    .exitHereIfFailed\n    .pause(1000)\n    .exec(ws(\"close-ws\").close)\n\n  setUp(scn.inject(rampUsers(numSessions) during (rampPeriod.seconds))).protocols(httpProtocol)\n}\n\nclass SingleTopicLoadtest extends TinodeBase {\n  // Input file can be set with the \"accounts\" java option.\n  // E.g. JAVA_OPTS=\"-Daccounts=/tmp/z.csv\" gatling.sh -sf . -rsf . -rd \"na\" -s tinode.Loadtest\n  val feeder = csv(System.getProperty(\"accounts\", \"users.csv\")).random\n  val topic = System.getProperty(\"topic\", \"TOPIC_NAME\")\n\n  val scn = scenario(\"WebSocket\")\n    .exec(ws(\"Connect WS\").connect(\"/v0/channels?apikey=AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K\"))\n    .exec(session => session.set(\"id\", \"tn-\" + session.userId))\n    .pause(1)\n    .exec(hello)\n    .pause(1)\n    .feed(feeder)\n    .doIfOrElse({session =>\n      val uname = session(\"username\").as[String]\n      var token = session(\"token\").asOption[String]\n      if (token == None) {\n        token = tokenCache.get(uname)\n      }\n      token == None\n    }) { loginBasic } { loginToken }\n    .exitHereIfFailed\n    .exec(subMe)\n    .exitHereIfFailed\n    .exec(getSubs)\n    .exitHereIfFailed\n    .doIf({session =>\n      session.attributes.contains(\"subs\")\n    }) {\n      exec(session => session.set(\"sub\", topic))\n      .exec(subTopic)\n      .exitHereIfFailed\n      .pause(0, 10)\n      .exec(publish)\n      .pause(15)\n      .exec(leaveTopic)\n      .pause(0, 3)\n    }\n    .exec(ws(\"close-ws\").close)\n\n  setUp(scn.inject(rampUsers(numSessions) during (rampPeriod.seconds))).protocols(httpProtocol)\n}\n"
  },
  {
    "path": "loadtest/tinode.erl",
    "content": "%% Support module for Tinode load testing with Tsung.\n%% Compile using erlc then copy resulting .beam to\n%% /usr/local/lib/erlang/lib/tsung-1.7.0/ebin/\n%% Alternatively you can just leave it in the current\n%% directory.\n\n-module(tinode).\n-export([rand_user_secret/1, shuffle/1, cache_token/2, read_token/1]).\n\n%% Produces a secret for use in basic login.\nrand_user_secret({Pid, DynData}) ->\n  base64:encode_to_string(get_rand_secret()).\n\n\n%% Unexported. Picks a random user from a pre-defined list.\nget_rand_secret() ->\n  case rand:uniform(6) of\n      1 -> \"alice:alice123\";\n      2 -> \"bob:bob123\";\n      3 -> \"carol:carol123\";\n      4 -> \"dave:dave123\";\n      5 -> \"eve:eve123\";\n      6 -> \"frank:frank123\"\n  end.\n\n%% Shuffles a list randomly.\nshuffle(L) ->\n  RandomList=[{rand:uniform(), X} || X <- L],\n  [X || {_,X} <- lists:sort(RandomList)].\n\n%% Reads previously cached auth token for the specified user.\nread_token(Uid) ->\n  {ok, LogDir} = application:get_env(tsung_controller, log_dir_real),\n  case file:read_file(filename:join(LogDir, Uid)) of\n    {ok, Data} -> string:trim(Data);\n    {error, _} -> \"\"\n  end.\n\n%% Saves auth token for the specified user in the log directory.\ncache_token(Uid, Token) ->\n  {ok, LogDir} = application:get_env(tsung_controller, log_dir_real),\n  {ok, File} = file:open(filename:join(LogDir, Uid), [write]),\n  file:write(File, Token),\n  file:close(File),\n  ok.\n"
  },
  {
    "path": "loadtest/tinode.scala",
    "content": "package tinode\n\nimport java.util.Base64\nimport java.util.concurrent.ConcurrentHashMap\n\nimport scala.collection.JavaConverters._\nimport scala.collection._\nimport scala.concurrent.duration._\n\nimport io.gatling.core.Predef._\nimport io.gatling.http.Predef._\n\nclass TinodeBase extends Simulation {\n  val httpProtocol = http\n    .baseUrl(\"http://localhost:6060\")\n    .wsBaseUrl(\"ws://localhost:6060\")\n\n  // Auth tokens to share between sessions.\n  val tokenCache : concurrent.Map[String, String] = new ConcurrentHashMap() asScala\n  // Total number of messages to publish to a topic.\n  val publishCount = Integer.getInteger(\"publish_count\", 10).toInt\n  // Maximum interval between publishing messages to a topic.\n  val publishInterval = Integer.getInteger(\"publish_interval\", 100).toInt\n  // Total number of sessions.\n  val numSessions = Integer.getInteger(\"num_sessions\", 10000)\n  // Ramp up period (0 to numSessions) in seconds.\n  val rampPeriod = java.lang.Long.getLong(\"ramp\", 300L)\n\n  val hello = exitBlockOnFail {\n    exec {\n      ws(\"hi\").sendText(\n        \"\"\"{\"hi\":{\"id\":\"afabb3\",\"ver\":\"0.22.8\",\"ua\":\"Gatling-Loadtest/1.0; gatling/1.7.0\"}}\"\"\"\n      )\n      .await(15 seconds)(\n        ws.checkTextMessage(\"hi\")\n          .matching(jsonPath(\"$.ctrl\").find.exists)\n      )\n    }\n  }\n\n  val loginBasic = exitBlockOnFail {\n    exec { session =>\n      val uname = session(\"username\").as[String]\n      val password = session(\"password\").as[String]\n      val secret = new String(java.util.Base64.getEncoder.encode((uname + \":\" + password).getBytes()))\n      session.set(\"secret\", secret)\n    }\n    .exec {\n      ws(\"login\").sendText(\n        \"\"\"{\"login\":{\"id\":\"${id}-login\",\"scheme\":\"basic\",\"secret\":\"${secret}\"}}\"\"\"\n      )\n      .await(15 seconds)(\n        ws.checkTextMessage(\"login-ctrl\")\n          .matching(jsonPath(\"$.ctrl\").find.exists)\n          .check(jsonPath(\"$.ctrl.params.token\").saveAs(\"token\"))\n      )\n    }\n    .exec { session =>\n      val uname = session(\"username\").as[String]\n      val token = session(\"token\").as[String]\n      tokenCache.put(uname, token)\n      session\n    }\n  }\n\n  val loginToken = exitBlockOnFail {\n    exec { session =>\n      val uname = session(\"username\").as[String]\n      var token = session(\"token\").asOption[String]\n      if (token == None) {\n        token = tokenCache.get(uname)\n      }\n      session.set(\"token\", token.getOrElse(\"\"))\n    }\n    .exec {\n      ws(\"login-token\").sendText(\n        \"\"\"{\"login\":{\"id\":\"${id}-login2\",\"scheme\":\"token\",\"secret\":\"${token}\"}}\"\"\"\n      )\n      .await(15 seconds)(\n        ws.checkTextMessage(\"login-ctrl\")\n          .matching(jsonPath(\"$.ctrl\").find.exists)\n      )\n    }\n  }\n\n  val subMe = exitBlockOnFail {\n    exec {\n      ws(\"sub-me\").sendText(\n        \"\"\"{\"sub\":{\"id\":\"${id}-sub-me\",\"topic\":\"me\",\"get\":{\"what\":\"desc\"}}}\"\"\"\n      )\n      .await(15 seconds)(\n        ws.checkTextMessage(\"sub-me-desc\")\n          .matching(jsonPath(\"$.ctrl\").find.exists)\n          .check(jsonPath(\"$.ctrl.code\").ofType[Int].in(200 to 299))\n      )\n    }\n  }\n\n  val subTopic = exitBlockOnFail {\n    exec {\n      ws(\"sub-topic\").sendText(\n        \"\"\"{\"sub\":{\"id\":\"${id}-sub-${sub}\",\"topic\":\"${sub}\",\"get\":{\"what\":\"desc sub data del\"}}}\"\"\"\n      )\n      .await(15 seconds)(\n        ws.checkTextMessage(\"sub-topic-ctrl\")\n          .matching(jsonPath(\"$.ctrl\").find.exists)\n          .check(jsonPath(\"$.ctrl.code\").ofType[Int].in(200 to 299))\n      )\n    }\n  }\n\n  val publish = exitBlockOnFail {\n    exec {\n      repeat(publishCount, \"i\") {\n        exec {\n          ws(\"pub-topic\").sendText(\n            \"\"\"{\"pub\":{\"id\":\"${id}-pub-${sub}-${i}\",\"topic\":\"${sub}\",\"content\":\"This is a Gatling test ${i}\"}}\"\"\"\n          )\n          .await(15 seconds)(\n            ws.checkTextMessage(\"pub-topic-ctrl\")\n              .matching(jsonPath(\"$.ctrl\").find.exists)\n              .check(jsonPath(\"$.ctrl.code\").ofType[Int].in(200 to 299))\n          )\n        }\n        .pause(0, publishInterval)\n      }\n    }\n  }\n\n  val getSubs = exitBlockOnFail {\n    exec {\n      ws(\"get-subs\").sendText(\n        \"\"\"{\"get\":{\"id\":\"${id}-get-subs\",\"topic\":\"me\",\"what\":\"sub\"}}\"\"\"\n      )\n      .await(15 seconds)(\n        ws.checkTextMessage(\"save-subs\")\n          .matching(jsonPath(\"$.meta.sub\").find.exists)\n          .check(jsonPath(\"$.meta.sub[*].topic\").findAll.saveAs(\"subs\"))\n      )\n    }\n  }\n\n  val leaveTopic = exitBlockOnFail {\n    exec {\n      ws(\"leave-topic\").sendText(\n        \"\"\"{\"leave\":{\"id\":\"${id}-leave-${sub}\",\"topic\":\"${sub}\"}}\"\"\"\n      )\n      .await(15 seconds)(\n        ws.checkTextMessage(\"sub-topic-ctrl\")\n          .matching(jsonPath(\"$.ctrl\").find.exists)\n      )\n    }\n  }\n}\n"
  },
  {
    "path": "loadtest/tsung.xml",
    "content": "<?xml version=\"1.0\"?>\n<!DOCTYPE tsung SYSTEM \"/usr/local/share/tsung/tsung-1.0.dtd\" []>\n<tsung loglevel=\"notice\" version=\"1.0\" dumptraffic=\"false\">\n  <clients>\n    <client host=\"localhost\" use_controller_vm=\"true\" maxusers=\"40000\" />\n  </clients>\n\n  <servers>\n    <server host=\"127.0.0.1\" port=\"6060\" type=\"tcp\" />\n  </servers>\n\n  <load>\n\n    <arrivalphase phase=\"1\" duration=\"120\" unit=\"second\">\n      <users maxnumber=\"40000\" arrivalrate=\"50\" unit=\"second\" />\n    </arrivalphase>\n  </load>\n\n  <sessions>\n    <session name=\"websocket\" probability=\"100\" type=\"ts_websocket\">\n      <setdynvars sourcetype=\"random_string\" length=\"4\">\n        <var name=\"baseid\" />\n      </setdynvars>\n\n      <request subst=\"true\">\n        <websocket type=\"connect\" path=\"/v0/channels?apikey=AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K\">\n          <!--http_header name=\"X-Tinode-APIKey\" value=\"AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K\"/-->\n        </websocket>\n      </request>\n\n      <request subst=\"true\">\n        <websocket type=\"message\">{\"hi\":{\"id\":\"%%_baseid%%01\",\"ver\":\"0.15\",\"ua\":\"Tsung-Loadtest/1.0; tsung/1.7.0\"}}</websocket>\n      </request>\n\n      <!-- Randomly pick a user. -->\n      <setdynvars sourcetype=\"eval\"\n        code='fun({Pid, DynVars}) ->\n                    tinode:rand_user_secret({Pid, DynVars})\n                  end.'>\n        <var name=\"secret\" />\n      </setdynvars>\n\n      <!-- Read auth token from the cache. -->\n      <setdynvars sourcetype=\"eval\"\n        code='fun({Pid, DynVars}) ->\n                    {ok, Secret} = ts_dynvars:lookup(secret, DynVars),\n                    tinode:read_token(Secret)\n                  end.'>\n        <var name=\"token\" />\n      </setdynvars>\n\n      <!-- Token present. Authenticate with it. -->\n      <if var=\"token\" neq=\"\">\n        <request subst=\"true\">\n          <match do=\"abort\" when=\"nomatch\">{\"ctrl\":.*\"code\":200.*}</match>\n          <websocket type=\"message\">{\"login\":{\"id\":\"%%_baseid%%02\",\"scheme\":\"token\",\"secret\":\"%%_token%%\"}}</websocket>\n        </request>\n      </if>\n\n      <!-- else log in with user name and password. -->\n      <if var=\"token\" eq=\"\">\n        <request subst=\"true\">\n          <match do=\"abort\" when=\"nomatch\">{\"ctrl\":.*\"code\":200.*}</match>\n          <dyn_variable name=\"token\" re='^{\"ctrl\":.*\"token\":\"([-A-Za-z0-9+\\/=]+={0,3})\".*}$'/>\n          <websocket type=\"message\">{\"login\":{\"id\":\"%%_baseid%%02\",\"scheme\":\"basic\",\"secret\":\"%%_secret%%\"}}</websocket>\n        </request>\n\n        <!-- Save token in the cache. -->\n        <setdynvars sourcetype=\"eval\"\n          code='fun({_, DynVars}) ->\n                      {ok, Token} = ts_dynvars:lookup(token, DynVars),\n                      {ok, Secret} = ts_dynvars:lookup(secret, DynVars),\n                      tinode:cache_token(Secret, Token)\n                    end.'>\n          <var name=\"dummy\" />\n        </setdynvars>\n      </if>\n\n      <request subst=\"true\">\n        <websocket type=\"message\" frame=\"text\">{\"sub\":{\"id\":\"%%_baseid%%03\",\"topic\":\"me\",\"get\":{\"what\":\"desc\"}}}</websocket>\n      </request>\n\n      <thinktime value=\"3\" random=\"true\"></thinktime>\n\n      <request subst=\"true\">\n        <!--\n        Response looks like:\n\n        {\"meta\":{\"id\":\"106691\",\"topic\":\"me\",\"ts\":\"2018-07-25T18:14:24.433Z\",\"sub\":[\n          {\"updated\":\"2018-07-16T01:29:25.016Z\",\"acs\":{\"given\":47,\"want\":47,\"mode\":47},\"topic\":\"grpOZcxbbwE7Hc\",\"touched\":\"2018-07-16T17:32:12.48Z\",\"seq\":3},{\"updated\":\"2018-07-16T01:35:25.016Z\",\"acs\":{\"given\":47,\"want\":47,\"mode\":47},\"topic\":\"grpz6jDYiygVkI\",\"touched\":\"2018-07-16T17:30:32.165Z\",\"seq\":22},{\"updated\":\"2018-07-16T02:05:25.019Z\",\"acs\":{\"given\":47,\"want\":47,\"mode\":47},\"topic\":\"grp8efPpvZoUQ4\",\"touched\":\"2018-07-16T17:24:34.946Z\",\"seq\":1},{\"updated\":\"2018-07-15T17:23:25.002Z\",\"acs\":{\"given\":31,\"want\":31,\"mode\":31},\"topic\":\"usrlF7rSfaEx9c\",\"touched\":\"2018-07-16T17:28:42.07Z\",\"seq\":9},{\"updated\":\"2018-07-15T23:23:25.008Z\",\"acs\":{\"given\":16,\"want\":31,\"mode\":16},\"topic\":\"usrddg3AFqzeEQ\"},{\"updated\":\"2018-07-15T23:53:25.009Z\",\"acs\":{\"given\":31,\"want\":31,\"mode\":31},\"topic\":\"usrR6qq94MxLZ0\",\"touched\":\"2018-07-16T17:30:03.93Z\",\"seq\":11},{\"updated\":\"2018-07-15T23:56:25.01Z\",\"acs\":{\"given\":31,\"want\":31,\"mode\":31},\"topic\":\"usriUhihEisbH8\",\"touched\":\"2018-07-16T17:26:20.401Z\",\"seq\":9}]}}\n        -->\n        <dyn_variable name=\"subs\" jsonpath=\"meta.sub[*].topic\"/>\n        <websocket type=\"message\" frame=\"text\">{\"get\":{\"id\":\"%%_baseid%%04\",\"topic\":\"me\",\"what\":\"sub\"}}</websocket>\n      </request>\n      <setdynvars sourcetype=\"eval\"\n            code=\"fun({Pid,DynVars})->\n                      {ok,Subs}=ts_dynvars:lookup(subs,DynVars),\n                      tinode:shuffle(Subs) end.\">\n        <var name=\"shuffled_subs\" />\n      </setdynvars>\n\n      <!-- main loop -->\n      <for from=\"1\" to=\"100\" incr=\"1\" var=\"ctr\">\n        <!-- Iterate over subscriptions -->\n        <foreach name=\"topicx\" in=\"shuffled_subs\">\n          <thinktime value=\"1\" random=\"true\"></thinktime>\n\n          <request subst=\"true\">\n            <dyn_variable name=\"code\" jsonpath=\"ctrl.code\"/>\n            <websocket type=\"message\" frame=\"text\">{\"sub\":{\"id\":\"%%_baseid%%%%_topicx%%%%_ctr%%\",\"topic\":\"%%_topicx%%\",\"get\":{\"what\":\"desc sub data del\"}}}</websocket>\n          </request>\n\n          <if var=\"code\" eq=\"200\">\n            <for from=\"1\" to=\"50\" incr=\"1\" var=\"counter\">\n              <thinktime min=\"2\" max=\"10\" random=\"true\"></thinktime>\n\n              <request subst=\"true\">\n                <websocket type=\"message\" frame=\"text\">{\"pub\":{\"id\":\"%%_baseid%%%%_topicx%%%%_ctr%%%%_counter%%\",\"topic\":\"%%_topicx%%\",\"content\":\"This is a Tsung test %%_baseid%% %%_counter%%\"}}</websocket>\n              </request>\n            </for>\n          </if>\n\n          <request subst=\"true\">\n            <websocket type=\"message\" frame=\"text\">{\"leave\":{\"id\":\"%%_baseid%%%%_topicx%%%%_ctr%%\",\"topic\":\"%%_topicx%%\"}}</websocket>\n          </request>\n\n        </foreach>\n      </for>\n      <!-- end main loop -->\n\n      <request>\n        <websocket type=\"close\"></websocket>\n      </request>\n    </session>\n  </sessions>\n</tsung>\n"
  },
  {
    "path": "loadtest/users.csv",
    "content": "username,password\nalice,alice123\nbob,bob123\ncarol,carol123\ndave,dave123\neve,eve123\nfrank,frank123\n"
  },
  {
    "path": "monitoring/LICENSE",
    "content": "Code in this folder and nested folders is licensed under Apache 2.0\nhttp://www.apache.org/licenses/LICENSE-2.0\n"
  },
  {
    "path": "monitoring/README.md",
    "content": "# Monitoring Support\n\nThis directory contains code related to monitoring Tinode server. Supported monitoring services are\n* [Prometheus](https://prometheus.io/)\n* [InfluxDB](https://www.influxdata.com/)\n\nSee [exporter/README](./exporter/README.md) for more details.\n"
  },
  {
    "path": "monitoring/exporter/README.md",
    "content": "# Tinode Metric Exporter\n\nThis is a simple service which reads JSON monitoring data exposed by Tinode server using [expvar](https://golang.org/pkg/expvar/) and re-publishes it in other formats. Currently the supported formats are:\n\n* [InfluxDB](https://www.influxdata.com/) [exporter](https://docs.influxdata.com/influxdb/v1.7/tools/api/#write-http-endpoint) **pushes** data to its target backend. This is the default mode.\n* [Prometheus](https://prometheus.io/) [exporter](https://prometheus.io/docs/instrumenting/exporters/) which exports data in [prometheus format](https://prometheus.io/docs/concepts/data_model/). The Prometheus monitoring service is expected to **pull/scrape** data from the  exporter.\n\n## Usage\n\nExporters are intended to run next to (pair with) Tinode servers: one Exporter per one Tinode server, i.e. a single Exporter provides metrics from a single Tinode server. \n\n## Configuration\n\nThe exporters are configured by command-line flags:\n\n### Common flags\n* `serve_for` specifies which monitoring service the Exporter will gather metrics for; accepted values: `influxdb`, `prometheus`; default: `influxdb`.\n* `tinode_addr` is the address where the Tinode instance publishes `expvar` data to scrape; default: `http://localhost:6060/stats/expvar`.\n* `listen_at` is the hostname to bind to for serving the metrics; default: `:6222`.\n* `instance` is the Exporter instance name (it may be exported to the upstream backend); default: `exporter`.\n* `metric_list` is a comma-separated list of metrics to export; default: `Version,LiveTopics,TotalTopics,LiveSessions,ClusterLeader,TotalClusterNodes,LiveClusterNodes,memstats.Alloc`.\n\n### InfluxDB\n* `influx_push_addr` is the address of InfluxDB target server where the data gets sent; default: `http://localhost:9999/write`.\n* `influx_db_version` is the version of InfluxDB (only 1.7 and 2.0 are supported); default: `1.7`.\n* `influx_organization` specifies InfluxDB organization to push metrics as; default: `test`;\n* `influx_bucket` is the name of InfluxDB storage bucket to store data in (used only in InfluxDB 2.0); default: `test`.\n* `influx_auth_token` - InfluxDB authentication token; no default value.\n* `influx_push_interval` - InfluxDB push interval in seconds; default: `30`.\n\n#### Example\n\nRun InfluxDB Exporter as\n```\n./exporter \\\n    --serve_for=influxdb \\\n    --tinode_addr=http://localhost:6060/stats/expvar \\\n    --listen_at=:6222 \\\n    --instance=exp-0 \\\n    --influx_push_addr=http://my-influxdb-backend.net/write \\\n    --influx_db_version=1.7 \\\n    --influx_organization=myOrg \\\n    --influx_auth_token=myAuthToken123 \\\n    --influx_push_interval=30\n```\n\nThis exporter will push the collected metrics to the specified backend once every 30 seconds.\n\n\n### Prometheus\n* `prom_namespace` is a prefix to use for metrics names. If you are monitoring multiple tinode instances you may want to use different namespaces; default: `tinode`.\n* `prom_metrics_path` is the path under which to expose the metrics for scraping; default: `/metrics`.\n* `prom_timeout` is the Tinode connection timeout in seconds in response to Prometheus scrapes; default: `15`.\n\n#### Example\nRun Prometheus Exporter as\n```\n./exporter \\\n    --serve_for=prometheus \\\n    --tinode_addr=http://localhost:6060/stats/expvar \\\n    --listen_at=:6222 \\\n    --instance=exp-0 \\\n    --prom_namespace=tinode \\\n    --prom_metrics_path=/metrics \\\n    --prom_timeout=15\n```\n\nThis exporter will serve data at path /metrics, on port 6222.\nOnce running, configure your Prometheus monitoring installation to collect data from this exporter.\n"
  },
  {
    "path": "monitoring/exporter/build.sh",
    "content": "#!/bin/bash\n\n# This scripts build and archives binaries and supporting files.\n\n# Supported OSs: mac (darwin), windows, linux.\ngoplat=( darwin darwin windows linux )\n\n# CPUs architectures: amd64 and arm64. The same order as OSs.\ngoarc=( amd64 arm64 amd64 amd64 )\n\n# Number of platform+architectures.\nbuildCount=${#goplat[@]}\n\nfor line in $@; do\n  eval \"$line\"\ndone\n\n# Strip 'v' prefix as in v0.16.4 -> 0.16.4.\nversion=${tag#?}\n\nif [ -z \"$version\" ]; then\n  # Get last git tag as release version. Tag looks like 'v.1.2.3', so strip 'v'.\n  version=`git describe --tags`\n  version=${version#?}\nfi\n\necho \"Releasing exporter $version\"\n\nGOSRC=${GOPATH}/src/github.com/tinode\n\npushd ${GOSRC}/chat > /dev/null\n\n# Make sure earlier builds are deleted.\nrm -f ./releases/${version}/exporter*\n\nfor (( i=0; i<${buildCount}; i++ ));\ndo\n  plat=\"${goplat[$i]}\"\n  arc=\"${goarc[$i]}\"\n\n  echo \"Building ${plat}/${arc}...\"\n\n  # Remove possibly existing binaries from earlier builds.\n  rm -f ./releases/tmp/exporter*\n\n  # Environment to cros-compile for the platform.\n  env GOOS=\"${plat}\" GOARCH=\"${arc}\" go build \\\n    -ldflags \"-s -w -X main.buildstamp=`git describe --tags`\" \\\n    -o ./releases/tmp/exporter ./monitoring/exporter > /dev/null\n\n  # Build archive. All platforms but Windows use tar for archiving. Windows uses zip.\n  if [ \"$plat\" = \"windows\" ]; then\n    # Just copy the binary with .exe appended.\n    cp ./releases/tmp/exporter ./releases/${version}/exporter.\"${plat}-${arc}\".exe\n  else\n    plat2=$plat\n    # Rename 'darwin' tp 'mac'\n    if [ \"$plat\" = \"darwin\" ]; then\n      plat2=mac\n    fi\n\n    # Just copy the binary.\n    cp ./releases/tmp/exporter ./releases/${version}/exporter.\"${plat2}-${arc}\"\n  fi\n\ndone\n\npopd > /dev/null\n"
  },
  {
    "path": "monitoring/exporter/influxdb_exporter.go",
    "content": "package main\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n)\n\n// InfluxDBExporter collects metrics from a Tinode server and pushes them to InfluxDB.\ntype InfluxDBExporter struct {\n\ttargetAddress string\n\torganization  string\n\tbucket        string\n\ttokenHeader   string\n\tinstance      string\n\tscraper       *Scraper\n}\n\n// NewInfluxDBExporter returns an initialized InfluxDB exporter.\nfunc NewInfluxDBExporter(influxDBVersion, pushBaseAddress, organization,\n\tbucket, token, instance string, scraper *Scraper) *InfluxDBExporter {\n\n\ttargetAddress := formPushTargetAddress(influxDBVersion, pushBaseAddress, organization, bucket)\n\ttokenHeader := formAuthorizationHeaderValue(influxDBVersion, token)\n\treturn &InfluxDBExporter{\n\t\ttargetAddress: targetAddress,\n\t\torganization:  organization,\n\t\tbucket:        bucket,\n\t\ttokenHeader:   tokenHeader,\n\t\tinstance:      instance,\n\t\tscraper:       scraper,\n\t}\n}\n\n// Push scrapes metrics from Tinode server and pushes these metrics to InfluxDB.\nfunc (e *InfluxDBExporter) Push() error {\n\tmetrics, err := e.scraper.CollectRaw()\n\tif err != nil {\n\t\treturn err\n\t}\n\tb := new(bytes.Buffer)\n\tts := time.Now().UnixNano()\n\tfor k, v := range metrics {\n\t\tswitch val := v.(type) {\n\t\tcase float64:\n\t\t\tfmt.Fprintf(b, \"%s,instance=%s value=%f %d\\n\", k, e.instance, val, ts)\n\t\tcase *histogram:\n\t\t\tfmt.Fprintf(b, \"%s,instance=%s count=%d %d\\n\", k, e.instance, val.count, ts)\n\t\t\tfmt.Fprintf(b, \"%s,instance=%s sum=%f %d\\n\", k, e.instance, val.sum, ts)\n\t\t\tfor bucket, count := range val.buckets {\n\t\t\t\tfmt.Fprintf(b, \"%s,instance=%s le=%f,value=%d %d\\n\", k, e.instance, bucket, count, ts)\n\t\t\t}\n\t\tdefault:\n\t\t\tlog.Panicln(\"Invalid metric type: \", v)\n\t\t}\n\t}\n\treq, err := http.NewRequest(\"POST\", e.targetAddress, b)\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Add(\"Authorization\", e.tokenHeader)\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode >= 400 {\n\t\tvar body string\n\t\tif rb, err := io.ReadAll(resp.Body); err != nil {\n\t\t\tbody = err.Error()\n\t\t} else {\n\t\t\tbody = strings.TrimSpace(string(rb))\n\t\t}\n\n\t\treturn fmt.Errorf(\"HTTP %s: %s\", resp.Status, body)\n\t}\n\treturn nil\n}\n\nfunc formPushTargetAddress(influxDBVersion, baseAddr, organization, bucket string) string {\n\turl, err := url.ParseRequestURI(baseAddr)\n\tif err != nil {\n\t\tlog.Fatal(\"Invalid push_addr\", err)\n\t}\n\t// Url format\n\t// - in 2.0: /api/v2/write?org=organization&bucket=bucket\n\t// - in 1.7: /write?db=organization\n\torganizationParamName := \"org\"\n\tbucketParamName := \"bucket\"\n\tif influxDBVersion == \"1.7\" {\n\t\torganizationParamName = \"db\"\n\t\t// Concept of explicit bucket in 1.7 is absent.\n\t\tbucketParamName = \"\"\n\t}\n\tq := url.Query()\n\tq.Add(organizationParamName, organization)\n\tif bucketParamName != \"\" {\n\t\tq.Add(bucketParamName, bucket)\n\t}\n\turl.RawQuery = q.Encode()\n\treturn url.String()\n}\n\nfunc formAuthorizationHeaderValue(influxDBVersion, token string) string {\n\t// Authorization header has value\n\t// - in 2.0: Token <token>\n\t// - in 1.7: Bearer <token>\n\tif influxDBVersion == \"2.0\" {\n\t\treturn fmt.Sprintf(\"Token %s\", token)\n\t}\n\treturn fmt.Sprintf(\"Bearer %s\", token)\n}\n"
  },
  {
    "path": "monitoring/exporter/main.go",
    "content": "package main\n\nimport (\n\t\"flag\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/client_golang/prometheus/promhttp\"\n\t\"github.com/prometheus/common/version\"\n)\n\ntype monitoringService int\n\nconst (\n\tpromService   monitoringService = 1\n\tinfluxService monitoringService = 2\n)\n\nconst (\n\t// Minimum interval between InfluxDB pushes in seconds.\n\tminPushInterval = 10\n)\n\ntype promHTTPLogger struct{}\n\nfunc (l promHTTPLogger) Println(v ...interface{}) {\n\tlog.Println(v...)\n}\n\nfunc parseMetricList(list string) []string {\n\tmetrics := strings.Split(list, \",\")\n\tfor i, m := range metrics {\n\t\tmetrics[i] = strings.TrimSpace(m)\n\t}\n\treturn metrics\n}\n\n// Build version number defined by the compiler:\n//\n//\t-ldflags \"-X main.buildstamp=value_to_assign_to_buildstamp\"\n//\n// Reported to clients in response to {hi} message.\n// For instance, to define the buildstamp as a timestamp of when the server was built add a\n// flag to compiler command line:\n//\n//\t-ldflags \"-X main.buildstamp=`date -u '+%Y%m%dT%H:%M:%SZ'`\"\n//\n// or to set it to git tag:\n//\n//\t-ldflags \"-X main.buildstamp=`git describe --tags`\"\nvar buildstamp = \"undef\"\n\nfunc main() {\n\tlog.Printf(\"Tinode metrics exporter.\")\n\n\tvar (\n\t\tserveFor = flag.String(\"serve_for\", \"influxdb\",\n\t\t\t\"Monitoring service to gather metrics for. Available: influxdb, prometheus.\")\n\t\ttinodeAddr = flag.String(\"tinode_addr\", \"http://localhost:6060/stats/expvar\",\n\t\t\t\"Address of the Tinode instance to scrape.\")\n\t\tlistenAt = flag.String(\"listen_at\", \":6222\",\n\t\t\t\"Host name and port to listen for incoming requests on.\")\n\t\tmetricList = flag.String(\"metric_list\",\n\t\t\t\"Version,LiveTopics,TotalTopics,LiveSessions,ClusterLeader,TotalClusterNodes,LiveClusterNodes,memstats.Alloc\",\n\t\t\t\"Comma-separated list of numeric metrics to scrape and export.\")\n\t\thistoMetricList = flag.String(\"histo_metric_list\",\n\t\t\t\"RequestLatency,OutgoingMessageSize\",\n\t\t\t\"Comma-separated list of histogram metrics to scrape and export.\")\n\t\tinstance = flag.String(\"instance\", \"exporter\",\n\t\t\t\"Exporter instance name.\")\n\n\t\t// Prometheus-specific arguments.\n\t\tpromNamespace   = flag.String(\"prom_namespace\", \"tinode\", \"Prometheus namespace for metrics '<namespace>_...'\")\n\t\tpromMetricsPath = flag.String(\"prom_metrics_path\", \"/metrics\", \"Path under which to expose metrics for Prometheus scrapes.\")\n\t\tpromTimeout     = flag.Int(\"prom_timeout\", 15, \"Tinode connection timeout in seconds in response to Prometheus scrapes.\")\n\n\t\t// InfluxDB-specific arguments.\n\t\tinfluxPushAddr = flag.String(\"influx_push_addr\", \"http://localhost:9999/write\",\n\t\t\t\"Address of InfluxDB target server where the data gets sent.\")\n\t\tinfluxDBVersion = flag.String(\"influx_db_version\", \"1.7\",\n\t\t\t\"Version of InfluxDB (only 1.7 and 2.0 are supported).\")\n\t\tinfluxOrganization = flag.String(\"influx_organization\", \"test\",\n\t\t\t\"InfluxDB organization to push metrics as.\")\n\t\tinfluxBucket = flag.String(\"influx_bucket\", \"test\",\n\t\t\t\"InfluxDB storage bucket to store data in (used only in InfluxDB 2.0).\")\n\t\tinfluxAuthToken = flag.String(\"influx_auth_token\", \"\",\n\t\t\t\"InfluxDB authentication token.\")\n\t\tinfluxPushInterval = flag.Int(\"influx_push_interval\", 30,\n\t\t\t\"InfluxDB push interval in seconds.\")\n\t)\n\tflag.Parse()\n\n\tvar service monitoringService\n\tif *serveFor == \"prometheus\" {\n\t\tservice = promService\n\t} else if *serveFor == \"influxdb\" {\n\t\tservice = influxService\n\t} else {\n\t\tlog.Fatal(\"Invalid monitoring service:\" + *serveFor + \"; must be either \\\"prometheus\\\" or \\\"influxdb\\\"\")\n\t}\n\t// Validate flags.\n\tswitch service {\n\tcase promService:\n\t\tif *promMetricsPath == \"/\" {\n\t\t\tlog.Fatal(\"Serving metrics from / is not supported\")\n\t\t}\n\tcase influxService:\n\t\tif *influxOrganization == \"\" {\n\t\t\tlog.Fatal(\"Must specify --influx_organization\")\n\t\t}\n\t\tif *influxAuthToken == \"\" {\n\t\t\tlog.Fatal(\"Must specify --influx_auth_token\")\n\t\t}\n\t\tif *influxBucket == \"\" {\n\t\t\tlog.Fatal(\"Must specify --influx_bucket\")\n\t\t}\n\t\tif *influxDBVersion != \"1.7\" && *influxDBVersion != \"2.0\" {\n\t\t\tlog.Fatal(\"The --influx_db_version must be either 1.7 or 2.0\")\n\t\t}\n\t\tif *influxPushInterval > 0 && *influxPushInterval < minPushInterval {\n\t\t\t*influxPushInterval = minPushInterval\n\t\t\tlog.Println(\"The --influx_push_interval is too low, reset to\", minPushInterval)\n\t\t}\n\t}\n\n\t// Index page at web root.\n\thttp.HandleFunc(\"/\", func(w http.ResponseWriter, r *http.Request) {\n\t\tvar servingPath string\n\t\tswitch service {\n\t\tcase promService:\n\t\t\tservingPath = \"<p>Prometheus exporter path: <a href='\" + *promMetricsPath + \"'>Metrics</a></p>\"\n\t\tcase influxService:\n\t\t\tservingPath = \"<p>InfluxDB push path: <a href='/push'>Push</a></p>\"\n\t\t}\n\n\t\tw.Write([]byte(`<html><head><title>Tinode Exporter</title></head><body>\n<h1>Tinode Exporter</h1>\n<p>Server type` + *serveFor + `</p>` + servingPath +\n\t\t\t`<h2>Build</h2>\n<pre>` + version.Info() + ` ` + version.BuildContext() + `</pre>\n</body></html>`))\n\t})\n\n\tmetrics := parseMetricList(*metricList)\n\thistoMetrics := parseMetricList(*histoMetricList)\n\tscraper := Scraper{address: *tinodeAddr, simpleMetrics: metrics, histogramMetrics: histoMetrics}\n\tvar serverTypeString string\n\t// Create exporters.\n\tswitch service {\n\tcase promService:\n\t\tserverTypeString = *serveFor\n\t\tpromExporter := NewPromExporter(*tinodeAddr, *promNamespace, time.Duration(*promTimeout)*time.Second, &scraper)\n\t\tregistry := prometheus.NewRegistry()\n\t\tregistry.MustRegister(promExporter)\n\t\thttp.Handle(*promMetricsPath,\n\t\t\tpromhttp.InstrumentMetricHandler(\n\t\t\t\tregistry,\n\t\t\t\tpromhttp.HandlerFor(\n\t\t\t\t\tregistry,\n\t\t\t\t\tpromhttp.HandlerOpts{\n\t\t\t\t\t\tErrorLog: &promHTTPLogger{},\n\t\t\t\t\t\tTimeout:  time.Duration(*promTimeout) * time.Second,\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t),\n\t\t)\n\tcase influxService:\n\t\tserverTypeString = fmt.Sprintf(\"%s, version %s\", *serveFor, *influxDBVersion)\n\t\tinfluxDBExporter := NewInfluxDBExporter(*influxDBVersion, *influxPushAddr, *influxOrganization, *influxBucket,\n\t\t\t*influxAuthToken, *instance, &scraper)\n\t\tif *influxPushInterval > 0 {\n\t\t\tgo func() {\n\t\t\t\tinterval := time.Duration(*influxPushInterval) * time.Second\n\t\t\t\tch := time.Tick(interval)\n\t\t\t\tfor {\n\t\t\t\t\tif _, ok := <-ch; ok {\n\t\t\t\t\t\tif err := influxDBExporter.Push(); err != nil {\n\t\t\t\t\t\t\tlog.Println(\"InfluxDB push failed:\", err)\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}()\n\t\t} else {\n\t\t\tlog.Println(\"InfluxDB push interval is zero. Will not push data automatically.\")\n\t\t}\n\t\t// Forces a data push.\n\t\thttp.HandleFunc(\"/push\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\tvar msg string\n\t\t\tif err := influxDBExporter.Push(); err == nil {\n\t\t\t\tmsg = \"HTTP 200 OK\"\n\t\t\t} else {\n\t\t\t\tmsg = err.Error()\n\t\t\t}\n\n\t\t\tw.Write([]byte(`<html><head><title>Tinode Push</title></head><body>\n<h1>Tinode Push</h1>\n<pre>` + msg + `</pre>\n</body></html>`))\n\t\t})\n\t}\n\n\tlog.Println(\"Reading Tinode expvar from\", *tinodeAddr)\n\tlog.Printf(\"Exporter running at %s. Server type %s\", *listenAt, serverTypeString)\n\tlog.Fatalln(http.ListenAndServe(*listenAt, nil))\n}\n"
  },
  {
    "path": "monitoring/exporter/prom_exporter.go",
    "content": "package main\n\nimport (\n\t\"log\"\n\t\"time\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n)\n\n// PromExporter collects metrics in Prometheus format from a Tinode server.\ntype PromExporter struct {\n\taddress   string\n\ttimeout   time.Duration\n\tnamespace string\n\n\tscraper *Scraper\n\n\tup            *prometheus.Desc\n\tversion       *prometheus.Desc\n\ttopicsLive    *prometheus.Desc\n\ttopicsTotal   *prometheus.Desc\n\tsessionsLive  *prometheus.Desc\n\tsessionsTotal *prometheus.Desc\n\n\tnumGoroutines *prometheus.Desc\n\n\tincomingMessagesWebsockTotal *prometheus.Desc\n\toutgoingMessagesWebsockTotal *prometheus.Desc\n\n\tincomingMessagesLongpollTotal *prometheus.Desc\n\toutgoingMessagesLongpollTotal *prometheus.Desc\n\n\tincomingMessagesGrpcTotal *prometheus.Desc\n\toutgoingMessagesGrpcTotal *prometheus.Desc\n\n\tfileDownloadsTotal *prometheus.Desc\n\tfileUploadsTotal   *prometheus.Desc\n\n\tctrlCodesTotal2xx *prometheus.Desc\n\tctrlCodesTotal3xx *prometheus.Desc\n\tctrlCodesTotal4xx *prometheus.Desc\n\tctrlCodesTotal5xx *prometheus.Desc\n\n\tclusterLeader             *prometheus.Desc\n\tclusterSize               *prometheus.Desc\n\tclusterNodesLive          *prometheus.Desc\n\tmalloced                  *prometheus.Desc\n\trequestLatencyMsCount     *prometheus.Desc\n\toutgoingMessageBytesCount *prometheus.Desc\n}\n\n// NewPromExporter returns an initialized Prometheus exporter.\nfunc NewPromExporter(server, namespace string, timeout time.Duration, scraper *Scraper) *PromExporter {\n\treturn &PromExporter{\n\t\taddress:   server,\n\t\ttimeout:   timeout,\n\t\tnamespace: namespace,\n\t\tscraper:   scraper,\n\t\tup: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"up\"),\n\t\t\t\"If tinode instance is reachable.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\tversion: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"version\"),\n\t\t\t\"The version of this tinode instance.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\ttopicsLive: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"topics_live_count\"),\n\t\t\t\"Number of currently active topics.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\ttopicsTotal: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"topics_total\"),\n\t\t\t\"Total number of topics used during instance lifetime.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\tsessionsLive: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"sessions_live_count\"),\n\t\t\t\"Number of currently active sessions.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\tsessionsTotal: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"sessions_total\"),\n\t\t\t\"Total number of sessions since instance start.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\tnumGoroutines: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"num_goroutines\"),\n\t\t\t\"Number of currently spawned goroutines.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\tincomingMessagesWebsockTotal: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"incoming_messages_websock_total\"),\n\t\t\t\"Total number of incoming messages via websocket.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\toutgoingMessagesWebsockTotal: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"outgoing_messages_websock_total\"),\n\t\t\t\"Total number of outgoiing messages via websocket.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\tincomingMessagesLongpollTotal: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"incoming_messages_longpoll_total\"),\n\t\t\t\"Total number of incoming messages via longpoll.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\toutgoingMessagesLongpollTotal: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"outgoing_messages_longpoll_total\"),\n\t\t\t\"Total number of outgoiing messages via longpoll.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\tincomingMessagesGrpcTotal: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"incoming_messages_grpc_total\"),\n\t\t\t\"Total number of incoming messages via grpc.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\toutgoingMessagesGrpcTotal: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"outgoing_messages_grpc_total\"),\n\t\t\t\"Total number of outgoiing messages via grpc.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\tfileDownloadsTotal: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"file_downloads_total\"),\n\t\t\t\"Total number of large file downloads.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\tfileUploadsTotal: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"file_uploads_total\"),\n\t\t\t\"Total number of large file uploads.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\tctrlCodesTotal2xx: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"ctrl_codes_total_2xx\"),\n\t\t\t\"Total number of 2xx ctrl response codes.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\tctrlCodesTotal3xx: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"ctrl_codes_total_3xx\"),\n\t\t\t\"Total number of 3xx ctrl response codes.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\tctrlCodesTotal4xx: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"ctrl_codes_total_4xx\"),\n\t\t\t\"Total number of 4xx ctrl response codes.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\tctrlCodesTotal5xx: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"ctrl_codes_total_5xx\"),\n\t\t\t\"Total number of 5xx ctrl response codes.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\tclusterLeader: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"cluster_leader\"),\n\t\t\t\"If this cluster node is the cluster leader.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\tclusterSize: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"cluster_size\"),\n\t\t\t\"Configured number of cluster nodes.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\tclusterNodesLive: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"cluster_nodes_live\"),\n\t\t\t\"Number of cluster nodes believed to be live by the current node.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\tmalloced: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"malloced_bytes\"),\n\t\t\t\"Number of bytes of memory allocated and in use.\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\trequestLatencyMsCount: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"request_latency_ms_count\"),\n\t\t\t\"Request latency histogram (in ms).\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t\toutgoingMessageBytesCount: prometheus.NewDesc(\n\t\t\tprometheus.BuildFQName(namespace, \"\", \"outgoing_message_bytes\"),\n\t\t\t\"Response size histogram (in bytes).\",\n\t\t\tnil,\n\t\t\tnil,\n\t\t),\n\t}\n}\n\n// Describe describes all the metrics exported by the memcached exporter. It\n// implements prometheus.Collector.\nfunc (e *PromExporter) Describe(ch chan<- *prometheus.Desc) {\n\tch <- e.up\n\tch <- e.version\n\tch <- e.topicsLive\n\tch <- e.topicsTotal\n\tch <- e.sessionsLive\n\tch <- e.sessionsTotal\n\tch <- e.numGoroutines\n\n\tch <- e.incomingMessagesWebsockTotal\n\tch <- e.outgoingMessagesWebsockTotal\n\n\tch <- e.incomingMessagesLongpollTotal\n\tch <- e.outgoingMessagesLongpollTotal\n\n\tch <- e.incomingMessagesGrpcTotal\n\tch <- e.outgoingMessagesGrpcTotal\n\n\tch <- e.fileDownloadsTotal\n\tch <- e.fileUploadsTotal\n\n\tch <- e.ctrlCodesTotal2xx\n\tch <- e.ctrlCodesTotal3xx\n\tch <- e.ctrlCodesTotal4xx\n\tch <- e.ctrlCodesTotal5xx\n\n\tch <- e.clusterLeader\n\tch <- e.clusterSize\n\tch <- e.clusterNodesLive\n\tch <- e.malloced\n\n\tch <- e.requestLatencyMsCount\n\tch <- e.outgoingMessageBytesCount\n}\n\n// Collect fetches statistics from the configured Tinode instance, and\n// delivers them as Prometheus metrics. It implements prometheus.Collector.\nfunc (e *PromExporter) Collect(ch chan<- prometheus.Metric) {\n\tup := float64(1)\n\tif stats, err := e.scraper.Scrape(); err != nil {\n\t\tlog.Println(\"Failed to fetch or parse response\", err)\n\t\tup = 0\n\t} else {\n\t\tif err := e.parseStats(ch, stats); err != nil {\n\t\t\tup = 0\n\t\t}\n\t}\n\n\tch <- prometheus.MustNewConstMetric(e.up, prometheus.GaugeValue, up)\n}\n\nfunc (e *PromExporter) parseStats(ch chan<- prometheus.Metric, stats map[string]interface{}) error {\n\terr := firstError(\n\t\te.parseAndUpdate(ch, e.version, prometheus.GaugeValue, stats, \"Version\"),\n\t\te.parseAndUpdate(ch, e.topicsLive, prometheus.GaugeValue, stats, \"LiveTopics\"),\n\t\te.parseAndUpdate(ch, e.topicsTotal, prometheus.CounterValue, stats, \"TotalTopics\"),\n\t\te.parseAndUpdate(ch, e.sessionsLive, prometheus.GaugeValue, stats, \"LiveSessions\"),\n\t\te.parseAndUpdate(ch, e.sessionsTotal, prometheus.CounterValue, stats, \"TotalSessions\"),\n\t\te.parseAndUpdate(ch, e.numGoroutines, prometheus.GaugeValue, stats, \"NumGoroutines\"),\n\n\t\te.parseAndUpdate(ch, e.incomingMessagesWebsockTotal, prometheus.CounterValue, stats, \"IncomingMessagesWebsockTotal\"),\n\t\te.parseAndUpdate(ch, e.outgoingMessagesWebsockTotal, prometheus.CounterValue, stats, \"OutgoingMessagesWebsockTotal\"),\n\n\t\te.parseAndUpdate(ch, e.incomingMessagesLongpollTotal, prometheus.CounterValue, stats, \"IncomingMessagesLongpollTotal\"),\n\t\te.parseAndUpdate(ch, e.outgoingMessagesLongpollTotal, prometheus.CounterValue, stats, \"OutgoingMessagesLongpollTotal\"),\n\n\t\te.parseAndUpdate(ch, e.incomingMessagesGrpcTotal, prometheus.CounterValue, stats, \"IncomingMessagesGrpcTotal\"),\n\t\te.parseAndUpdate(ch, e.outgoingMessagesGrpcTotal, prometheus.CounterValue, stats, \"OutgoingMessagesGrpcTotal\"),\n\n\t\te.parseAndUpdate(ch, e.fileDownloadsTotal, prometheus.CounterValue, stats, \"FileDownloadsTotal\"),\n\t\te.parseAndUpdate(ch, e.fileUploadsTotal, prometheus.CounterValue, stats, \"FileUploadsTotal\"),\n\n\t\te.parseAndUpdate(ch, e.ctrlCodesTotal2xx, prometheus.CounterValue, stats, \"CtrlCodesTotal2xx\"),\n\t\te.parseAndUpdate(ch, e.ctrlCodesTotal3xx, prometheus.CounterValue, stats, \"CtrlCodesTotal3xx\"),\n\t\te.parseAndUpdate(ch, e.ctrlCodesTotal4xx, prometheus.CounterValue, stats, \"CtrlCodesTotal4xx\"),\n\t\te.parseAndUpdate(ch, e.ctrlCodesTotal5xx, prometheus.CounterValue, stats, \"CtrlCodesTotal5xx\"),\n\n\t\te.parseAndUpdate(ch, e.clusterLeader, prometheus.GaugeValue, stats, \"ClusterLeader\"),\n\t\te.parseAndUpdate(ch, e.clusterSize, prometheus.GaugeValue, stats, \"TotalClusterNodes\"),\n\t\te.parseAndUpdate(ch, e.clusterNodesLive, prometheus.GaugeValue, stats, \"LiveClusterNodes\"),\n\t\te.parseAndUpdate(ch, e.malloced, prometheus.GaugeValue, stats, \"memstats.Alloc\"),\n\n\t\te.parseAndUpdateHisto(ch, e.requestLatencyMsCount, stats, \"RequestLatency\"),\n\t\te.parseAndUpdateHisto(ch, e.outgoingMessageBytesCount, stats, \"OutgoingMessageSize\"),\n\t)\n\n\treturn err\n}\n\nfunc (e *PromExporter) parseAndUpdate(ch chan<- prometheus.Metric, desc *prometheus.Desc, valueType prometheus.ValueType,\n\tstats map[string]interface{}, key string) error {\n\tv, err := parseNumeric(stats, key)\n\tif err != nil {\n\t\treturn err\n\t}\n\tch <- prometheus.MustNewConstMetric(desc, valueType, v)\n\treturn nil\n}\n\nfunc (e *PromExporter) parseAndUpdateHisto(ch chan<- prometheus.Metric, desc *prometheus.Desc,\n\tstats map[string]interface{}, key string) error {\n\th, err := parseHisto(stats, key)\n\tif err != nil {\n\t\treturn err\n\t}\n\tch <- prometheus.MustNewConstHistogram(desc, h.count, h.sum, h.buckets)\n\treturn nil\n}\n\nfunc firstError(errs ...error) error {\n\tfor _, v := range errs {\n\t\tif v != nil {\n\t\t\treturn v\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "monitoring/exporter/scraper.go",
    "content": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"log\"\n\t\"net/http\"\n\t\"strings\"\n)\n\n// Scraper collects metrics from a tinode server.\ntype Scraper struct {\n\t// Target Tinode server address.\n\taddress string\n\t// List of simple numeric metrics to scrape.\n\tsimpleMetrics []string\n\t// List of histogram metrics to scrape.\n\thistogramMetrics []string\n}\n\n// Histogram struct.\ntype histogram struct {\n\tcount   uint64\n\tsum     float64\n\tbuckets map[float64]uint64\n}\n\nvar errKeyNotFound = errors.New(\"key not found\")\nvar errMalformed = errors.New(\"input malformed\")\n\n// CollectRaw gathers all metrics from the configured Tinode instance,\n// and returns them as a map.\nfunc (s *Scraper) CollectRaw() (map[string]interface{}, error) {\n\tstats, err := s.Scrape()\n\tif err != nil {\n\t\tlog.Println(\"Failed to fetch or parse response\", err)\n\t\treturn nil, err\n\t}\n\tmetrics, err := s.parseStatsRaw(stats)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tmetrics[\"up\"] = 1.0\n\treturn metrics, nil\n}\n\n// Scrape fetches the data from Tinode server using HTTP GET then decodes the response.\nfunc (s *Scraper) Scrape() (map[string]interface{}, error) {\n\tresp, err := http.Get(s.address)\n\tif err != nil {\n\t\tlog.Println(\"Failed to connect to server\", err)\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tvar stats map[string]interface{}\n\terr = json.NewDecoder(resp.Body).Decode(&stats)\n\treturn stats, err\n}\n\nfunc (s *Scraper) parseStatsRaw(stats map[string]interface{}) (map[string]interface{}, error) {\n\tmetrics := make(map[string]interface{})\n\tfor _, key := range s.simpleMetrics {\n\t\tif val, err := parseNumeric(stats, key); err == nil {\n\t\t\tmetrics[key] = val\n\t\t} else {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tfor _, key := range s.histogramMetrics {\n\t\tif val, err := parseHisto(stats, key); err == nil {\n\t\t\tmetrics[key] = val\n\t\t} else {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\treturn metrics, nil\n}\n\n// Extracts a simple histogram from `stats` and returns a cumulative histogram\n// corresponding to the simple histogram.\n// Returns: (count, sum, buckets, error) tuple.\nfunc parseHisto(stats map[string]interface{}, key string) (*histogram, error) {\n\t// Histogram is presented as a json with the predefined fields: count, sum, count_per_bucket, bounds.\n\tcount, err := parseNumeric(stats, key+\".count\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsum, err := parseNumeric(stats, key+\".sum\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tbuckets, err := parseList(stats, key+\".count_per_bucket\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tbounds, err := parseList(stats, key+\".bounds\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tn := len(buckets)\n\tif n != len(bounds)+1 {\n\t\treturn nil, errMalformed\n\t}\n\tresult := make(map[float64]uint64)\n\ts := uint64(0)\n\tfor i, v := range bounds {\n\t\ts += uint64(buckets[i])\n\t\tresult[v] = s\n\t}\n\treturn &histogram{count: uint64(count), sum: sum, buckets: result}, nil\n}\n\n// Extracts a list of numerics from `stats` for the given path.\nfunc parseList(stats map[string]interface{}, path string) ([]float64, error) {\n\tvalue, err := parseMetric(stats, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tlistval, ok := value.([]interface{})\n\tif !ok {\n\t\tlog.Println(\"Value at path is not a float64 array:\", path, value)\n\t\treturn nil, errMalformed\n\t}\n\tresult := []float64{}\n\tfor _, v := range listval {\n\t\tresult = append(result, v.(float64))\n\t}\n\treturn result, nil\n}\n\n// Extracts a numeric from `stats` for the given path.\nfunc parseNumeric(stats map[string]interface{}, path string) (float64, error) {\n\tvalue, err := parseMetric(stats, path)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfloatval, ok := value.(float64)\n\tif !ok {\n\t\tlog.Println(\"Value at path is not a float64:\", path, value)\n\t\treturn 0, errKeyNotFound\n\t}\n\treturn floatval, nil\n}\n\n// Extracts a metric from `stats` for the given path.\nfunc parseMetric(stats map[string]interface{}, path string) (interface{}, error) {\n\tparts := strings.Split(path, \".\")\n\tvar value interface{}\n\tvar found bool\n\tvalue = stats\n\tfor i := 0; i < len(parts); i++ {\n\t\tsubset, ok := value.(map[string]interface{})\n\t\tif !ok {\n\t\t\tlog.Println(\"Invalid key path:\", path)\n\t\t\treturn 0, errKeyNotFound\n\t\t}\n\t\tvalue, found = subset[parts[i]]\n\t\tif !found {\n\t\t\tlog.Println(\"Invalid key path:\", path, \"(\", parts[i], \")\")\n\t\t\treturn 0, errKeyNotFound\n\t\t}\n\t}\n\n\treturn value, nil\n}\n"
  },
  {
    "path": "pbx/README.md",
    "content": "# Protocol Buffer and gRPC definitions\n\nDefinitions for Tinode [gRPC](https://grpc.io/) client and plugins.\n\nTinode gRPC clients must implement rpc service `Node`, Tinode plugins `Plugin`.\n\nGenerated `Go` and `Python` code is included. For a sample `Python` implementation of a command line client see [tn-cli](../tn-cli/).\nFor a partial plugin implementation see [chatbot](../chatbot/).\n\nIf you want to make changes, you have to install protobuffers tool chain and gRPC:\n```\n$ python -m pip install grpcio grpcio-tools googleapis-common-protos\n```\n\nTo generate `Go` bindings add the following comment to your code and run `go generate` (your actual path to `/pbx` may be different):\n```\n//go:generate protoc --proto_path=../pbx --go_out=plugins=grpc:../pbx ../pbx/model.proto\n```\n\nTo generate `Python` bindings:\n```\npython -m grpc_tools.protoc -I../pbx --python_out=. --grpc_python_out=. ../pbx/model.proto\n```\n"
  },
  {
    "path": "pbx/go-generate.sh",
    "content": "#!/bin/bash\nprotoc --go_out=../pbx --go_opt=paths=source_relative --go-grpc_out=../pbx --go-grpc_opt=paths=source_relative model.proto\n"
  },
  {
    "path": "pbx/model.pb.go",
    "content": "// Code generated by protoc-gen-go. DO NOT EDIT.\n// versions:\n// \tprotoc-gen-go v1.27.1\n// \tprotoc        v3.21.12\n// source: model.proto\n\npackage pbx\n\nimport (\n\tprotoreflect \"google.golang.org/protobuf/reflect/protoreflect\"\n\tprotoimpl \"google.golang.org/protobuf/runtime/protoimpl\"\n\treflect \"reflect\"\n\tsync \"sync\"\n)\n\nconst (\n\t// Verify that this generated code is sufficiently up-to-date.\n\t_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)\n\t// Verify that runtime/protoimpl is sufficiently up-to-date.\n\t_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)\n)\n\n// Authentication level\ntype AuthLevel int32\n\nconst (\n\tAuthLevel_NONE AuthLevel = 0\n\tAuthLevel_ANON AuthLevel = 10\n\tAuthLevel_AUTH AuthLevel = 20\n\tAuthLevel_ROOT AuthLevel = 30\n)\n\n// Enum value maps for AuthLevel.\nvar (\n\tAuthLevel_name = map[int32]string{\n\t\t0:  \"NONE\",\n\t\t10: \"ANON\",\n\t\t20: \"AUTH\",\n\t\t30: \"ROOT\",\n\t}\n\tAuthLevel_value = map[string]int32{\n\t\t\"NONE\": 0,\n\t\t\"ANON\": 10,\n\t\t\"AUTH\": 20,\n\t\t\"ROOT\": 30,\n\t}\n)\n\nfunc (x AuthLevel) Enum() *AuthLevel {\n\tp := new(AuthLevel)\n\t*p = x\n\treturn p\n}\n\nfunc (x AuthLevel) String() string {\n\treturn protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))\n}\n\nfunc (AuthLevel) Descriptor() protoreflect.EnumDescriptor {\n\treturn file_model_proto_enumTypes[0].Descriptor()\n}\n\nfunc (AuthLevel) Type() protoreflect.EnumType {\n\treturn &file_model_proto_enumTypes[0]\n}\n\nfunc (x AuthLevel) Number() protoreflect.EnumNumber {\n\treturn protoreflect.EnumNumber(x)\n}\n\n// Deprecated: Use AuthLevel.Descriptor instead.\nfunc (AuthLevel) EnumDescriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{0}\n}\n\ntype InfoNote int32\n\nconst (\n\t// Invalid value. The name must be globally unique.\n\tInfoNote_X1   InfoNote = 0\n\tInfoNote_READ InfoNote = 1\n\tInfoNote_RECV InfoNote = 2\n\tInfoNote_KP   InfoNote = 3\n\tInfoNote_CALL InfoNote = 4\n)\n\n// Enum value maps for InfoNote.\nvar (\n\tInfoNote_name = map[int32]string{\n\t\t0: \"X1\",\n\t\t1: \"READ\",\n\t\t2: \"RECV\",\n\t\t3: \"KP\",\n\t\t4: \"CALL\",\n\t}\n\tInfoNote_value = map[string]int32{\n\t\t\"X1\":   0,\n\t\t\"READ\": 1,\n\t\t\"RECV\": 2,\n\t\t\"KP\":   3,\n\t\t\"CALL\": 4,\n\t}\n)\n\nfunc (x InfoNote) Enum() *InfoNote {\n\tp := new(InfoNote)\n\t*p = x\n\treturn p\n}\n\nfunc (x InfoNote) String() string {\n\treturn protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))\n}\n\nfunc (InfoNote) Descriptor() protoreflect.EnumDescriptor {\n\treturn file_model_proto_enumTypes[1].Descriptor()\n}\n\nfunc (InfoNote) Type() protoreflect.EnumType {\n\treturn &file_model_proto_enumTypes[1]\n}\n\nfunc (x InfoNote) Number() protoreflect.EnumNumber {\n\treturn protoreflect.EnumNumber(x)\n}\n\n// Deprecated: Use InfoNote.Descriptor instead.\nfunc (InfoNote) EnumDescriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{1}\n}\n\ntype CallEvent int32\n\nconst (\n\t// Invalid value. The name must be globally unique.\n\tCallEvent_X2            CallEvent = 0\n\tCallEvent_ACCEPT        CallEvent = 1\n\tCallEvent_ANSWER        CallEvent = 2\n\tCallEvent_HANG_UP       CallEvent = 3\n\tCallEvent_ICE_CANDIDATE CallEvent = 4\n\tCallEvent_INVITE        CallEvent = 5\n\tCallEvent_OFFER         CallEvent = 6\n\tCallEvent_RINGING       CallEvent = 7\n)\n\n// Enum value maps for CallEvent.\nvar (\n\tCallEvent_name = map[int32]string{\n\t\t0: \"X2\",\n\t\t1: \"ACCEPT\",\n\t\t2: \"ANSWER\",\n\t\t3: \"HANG_UP\",\n\t\t4: \"ICE_CANDIDATE\",\n\t\t5: \"INVITE\",\n\t\t6: \"OFFER\",\n\t\t7: \"RINGING\",\n\t}\n\tCallEvent_value = map[string]int32{\n\t\t\"X2\":            0,\n\t\t\"ACCEPT\":        1,\n\t\t\"ANSWER\":        2,\n\t\t\"HANG_UP\":       3,\n\t\t\"ICE_CANDIDATE\": 4,\n\t\t\"INVITE\":        5,\n\t\t\"OFFER\":         6,\n\t\t\"RINGING\":       7,\n\t}\n)\n\nfunc (x CallEvent) Enum() *CallEvent {\n\tp := new(CallEvent)\n\t*p = x\n\treturn p\n}\n\nfunc (x CallEvent) String() string {\n\treturn protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))\n}\n\nfunc (CallEvent) Descriptor() protoreflect.EnumDescriptor {\n\treturn file_model_proto_enumTypes[2].Descriptor()\n}\n\nfunc (CallEvent) Type() protoreflect.EnumType {\n\treturn &file_model_proto_enumTypes[2]\n}\n\nfunc (x CallEvent) Number() protoreflect.EnumNumber {\n\treturn protoreflect.EnumNumber(x)\n}\n\n// Deprecated: Use CallEvent.Descriptor instead.\nfunc (CallEvent) EnumDescriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{2}\n}\n\n// Plugin response codes\ntype RespCode int32\n\nconst (\n\t// Instruct Tinode server to continue with default processing of the client request.\n\tRespCode_CONTINUE RespCode = 0\n\t// Drop the request as if the client did not send it\n\tRespCode_DROP RespCode = 1\n\t// Send the the provided srvmsg response to the client. ServerResp must contain non-zero\n\t// srvmsg.\n\tRespCode_RESPOND RespCode = 2\n\t// Replace client's original request with the provided clmsg request then continue with\n\t// processing. ServerResp must contain non-zero clmsg.\n\tRespCode_REPLACE RespCode = 3\n)\n\n// Enum value maps for RespCode.\nvar (\n\tRespCode_name = map[int32]string{\n\t\t0: \"CONTINUE\",\n\t\t1: \"DROP\",\n\t\t2: \"RESPOND\",\n\t\t3: \"REPLACE\",\n\t}\n\tRespCode_value = map[string]int32{\n\t\t\"CONTINUE\": 0,\n\t\t\"DROP\":     1,\n\t\t\"RESPOND\":  2,\n\t\t\"REPLACE\":  3,\n\t}\n)\n\nfunc (x RespCode) Enum() *RespCode {\n\tp := new(RespCode)\n\t*p = x\n\treturn p\n}\n\nfunc (x RespCode) String() string {\n\treturn protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))\n}\n\nfunc (RespCode) Descriptor() protoreflect.EnumDescriptor {\n\treturn file_model_proto_enumTypes[3].Descriptor()\n}\n\nfunc (RespCode) Type() protoreflect.EnumType {\n\treturn &file_model_proto_enumTypes[3]\n}\n\nfunc (x RespCode) Number() protoreflect.EnumNumber {\n\treturn protoreflect.EnumNumber(x)\n}\n\n// Deprecated: Use RespCode.Descriptor instead.\nfunc (RespCode) EnumDescriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{3}\n}\n\ntype Crud int32\n\nconst (\n\tCrud_CREATE Crud = 0\n\tCrud_UPDATE Crud = 1\n\tCrud_DELETE Crud = 2\n)\n\n// Enum value maps for Crud.\nvar (\n\tCrud_name = map[int32]string{\n\t\t0: \"CREATE\",\n\t\t1: \"UPDATE\",\n\t\t2: \"DELETE\",\n\t}\n\tCrud_value = map[string]int32{\n\t\t\"CREATE\": 0,\n\t\t\"UPDATE\": 1,\n\t\t\"DELETE\": 2,\n\t}\n)\n\nfunc (x Crud) Enum() *Crud {\n\tp := new(Crud)\n\t*p = x\n\treturn p\n}\n\nfunc (x Crud) String() string {\n\treturn protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))\n}\n\nfunc (Crud) Descriptor() protoreflect.EnumDescriptor {\n\treturn file_model_proto_enumTypes[4].Descriptor()\n}\n\nfunc (Crud) Type() protoreflect.EnumType {\n\treturn &file_model_proto_enumTypes[4]\n}\n\nfunc (x Crud) Number() protoreflect.EnumNumber {\n\treturn protoreflect.EnumNumber(x)\n}\n\n// Deprecated: Use Crud.Descriptor instead.\nfunc (Crud) EnumDescriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{4}\n}\n\n// What to delete, either \"msg\" to delete messages (default) or \"topic\" to delete the topic or \"sub\"\n// to delete a subscription to topic.\ntype ClientDel_What int32\n\nconst (\n\t// Invalid value. The name must be globally unique.\n\tClientDel_X0    ClientDel_What = 0\n\tClientDel_MSG   ClientDel_What = 1\n\tClientDel_TOPIC ClientDel_What = 2\n\tClientDel_SUB   ClientDel_What = 3\n\tClientDel_USER  ClientDel_What = 4\n\tClientDel_CRED  ClientDel_What = 5\n)\n\n// Enum value maps for ClientDel_What.\nvar (\n\tClientDel_What_name = map[int32]string{\n\t\t0: \"X0\",\n\t\t1: \"MSG\",\n\t\t2: \"TOPIC\",\n\t\t3: \"SUB\",\n\t\t4: \"USER\",\n\t\t5: \"CRED\",\n\t}\n\tClientDel_What_value = map[string]int32{\n\t\t\"X0\":    0,\n\t\t\"MSG\":   1,\n\t\t\"TOPIC\": 2,\n\t\t\"SUB\":   3,\n\t\t\"USER\":  4,\n\t\t\"CRED\":  5,\n\t}\n)\n\nfunc (x ClientDel_What) Enum() *ClientDel_What {\n\tp := new(ClientDel_What)\n\t*p = x\n\treturn p\n}\n\nfunc (x ClientDel_What) String() string {\n\treturn protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))\n}\n\nfunc (ClientDel_What) Descriptor() protoreflect.EnumDescriptor {\n\treturn file_model_proto_enumTypes[5].Descriptor()\n}\n\nfunc (ClientDel_What) Type() protoreflect.EnumType {\n\treturn &file_model_proto_enumTypes[5]\n}\n\nfunc (x ClientDel_What) Number() protoreflect.EnumNumber {\n\treturn protoreflect.EnumNumber(x)\n}\n\n// Deprecated: Use ClientDel_What.Descriptor instead.\nfunc (ClientDel_What) EnumDescriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{18, 0}\n}\n\ntype ServerPres_What int32\n\nconst (\n\t// Invalid value. The name must be globally unique.\n\tServerPres_X3   ServerPres_What = 0\n\tServerPres_ON   ServerPres_What = 1\n\tServerPres_OFF  ServerPres_What = 2\n\tServerPres_UA   ServerPres_What = 3\n\tServerPres_UPD  ServerPres_What = 4\n\tServerPres_GONE ServerPres_What = 5\n\tServerPres_ACS  ServerPres_What = 6\n\tServerPres_TERM ServerPres_What = 7\n\tServerPres_MSG  ServerPres_What = 8\n\tServerPres_READ ServerPres_What = 9\n\tServerPres_RECV ServerPres_What = 10\n\tServerPres_DEL  ServerPres_What = 11\n\tServerPres_TAGS ServerPres_What = 12\n\tServerPres_AUX  ServerPres_What = 13\n)\n\n// Enum value maps for ServerPres_What.\nvar (\n\tServerPres_What_name = map[int32]string{\n\t\t0:  \"X3\",\n\t\t1:  \"ON\",\n\t\t2:  \"OFF\",\n\t\t3:  \"UA\",\n\t\t4:  \"UPD\",\n\t\t5:  \"GONE\",\n\t\t6:  \"ACS\",\n\t\t7:  \"TERM\",\n\t\t8:  \"MSG\",\n\t\t9:  \"READ\",\n\t\t10: \"RECV\",\n\t\t11: \"DEL\",\n\t\t12: \"TAGS\",\n\t\t13: \"AUX\",\n\t}\n\tServerPres_What_value = map[string]int32{\n\t\t\"X3\":   0,\n\t\t\"ON\":   1,\n\t\t\"OFF\":  2,\n\t\t\"UA\":   3,\n\t\t\"UPD\":  4,\n\t\t\"GONE\": 5,\n\t\t\"ACS\":  6,\n\t\t\"TERM\": 7,\n\t\t\"MSG\":  8,\n\t\t\"READ\": 9,\n\t\t\"RECV\": 10,\n\t\t\"DEL\":  11,\n\t\t\"TAGS\": 12,\n\t\t\"AUX\":  13,\n\t}\n)\n\nfunc (x ServerPres_What) Enum() *ServerPres_What {\n\tp := new(ServerPres_What)\n\t*p = x\n\treturn p\n}\n\nfunc (x ServerPres_What) String() string {\n\treturn protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))\n}\n\nfunc (ServerPres_What) Descriptor() protoreflect.EnumDescriptor {\n\treturn file_model_proto_enumTypes[6].Descriptor()\n}\n\nfunc (ServerPres_What) Type() protoreflect.EnumType {\n\treturn &file_model_proto_enumTypes[6]\n}\n\nfunc (x ServerPres_What) Number() protoreflect.EnumNumber {\n\treturn protoreflect.EnumNumber(x)\n}\n\n// Deprecated: Use ServerPres_What.Descriptor instead.\nfunc (ServerPres_What) EnumDescriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{28, 0}\n}\n\n// Dummy placeholder message.\ntype Unused struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n}\n\nfunc (x *Unused) Reset() {\n\t*x = Unused{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[0]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *Unused) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*Unused) ProtoMessage() {}\n\nfunc (x *Unused) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[0]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use Unused.ProtoReflect.Descriptor instead.\nfunc (*Unused) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{0}\n}\n\n// Topic default access mode\ntype DefaultAcsMode struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tAuth string `protobuf:\"bytes,1,opt,name=auth,proto3\" json:\"auth,omitempty\"`\n\tAnon string `protobuf:\"bytes,2,opt,name=anon,proto3\" json:\"anon,omitempty\"`\n}\n\nfunc (x *DefaultAcsMode) Reset() {\n\t*x = DefaultAcsMode{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[1]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *DefaultAcsMode) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*DefaultAcsMode) ProtoMessage() {}\n\nfunc (x *DefaultAcsMode) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[1]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use DefaultAcsMode.ProtoReflect.Descriptor instead.\nfunc (*DefaultAcsMode) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{1}\n}\n\nfunc (x *DefaultAcsMode) GetAuth() string {\n\tif x != nil {\n\t\treturn x.Auth\n\t}\n\treturn \"\"\n}\n\nfunc (x *DefaultAcsMode) GetAnon() string {\n\tif x != nil {\n\t\treturn x.Anon\n\t}\n\treturn \"\"\n}\n\n// Actual access mode\ntype AccessMode struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\t// Access mode requested by the user\n\tWant string `protobuf:\"bytes,1,opt,name=want,proto3\" json:\"want,omitempty\"`\n\t// Access mode granted to the user by the admin\n\tGiven string `protobuf:\"bytes,2,opt,name=given,proto3\" json:\"given,omitempty\"`\n}\n\nfunc (x *AccessMode) Reset() {\n\t*x = AccessMode{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[2]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *AccessMode) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*AccessMode) ProtoMessage() {}\n\nfunc (x *AccessMode) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[2]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use AccessMode.ProtoReflect.Descriptor instead.\nfunc (*AccessMode) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{2}\n}\n\nfunc (x *AccessMode) GetWant() string {\n\tif x != nil {\n\t\treturn x.Want\n\t}\n\treturn \"\"\n}\n\nfunc (x *AccessMode) GetGiven() string {\n\tif x != nil {\n\t\treturn x.Given\n\t}\n\treturn \"\"\n}\n\n// SetSub: payload in set.sub request to update current subscription or invite another user, {sub.what} == \"sub\"\ntype SetSub struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\t// User affected by this request. Default (empty): current user\n\tUserId string `protobuf:\"bytes,1,opt,name=user_id,json=userId,proto3\" json:\"user_id,omitempty\"`\n\t// Access mode change, either Given or Want depending on context\n\tMode string `protobuf:\"bytes,2,opt,name=mode,proto3\" json:\"mode,omitempty\"`\n}\n\nfunc (x *SetSub) Reset() {\n\t*x = SetSub{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[3]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *SetSub) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*SetSub) ProtoMessage() {}\n\nfunc (x *SetSub) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[3]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use SetSub.ProtoReflect.Descriptor instead.\nfunc (*SetSub) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{3}\n}\n\nfunc (x *SetSub) GetUserId() string {\n\tif x != nil {\n\t\treturn x.UserId\n\t}\n\treturn \"\"\n}\n\nfunc (x *SetSub) GetMode() string {\n\tif x != nil {\n\t\treturn x.Mode\n\t}\n\treturn \"\"\n}\n\n// Credentials such as email or phone number\ntype ClientCred struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\t// Credential type, i.e. `email` or `tel`.\n\tMethod string `protobuf:\"bytes,1,opt,name=method,proto3\" json:\"method,omitempty\"`\n\t// Value to verify, i.e. `user@example.com` or `+18003287448`\n\tValue string `protobuf:\"bytes,2,opt,name=value,proto3\" json:\"value,omitempty\"`\n\t// Verification response\n\tResponse string `protobuf:\"bytes,3,opt,name=response,proto3\" json:\"response,omitempty\"`\n\t// Request parameters, such as preferences or country code.\n\tParams map[string][]byte `protobuf:\"bytes,4,rep,name=params,proto3\" json:\"params,omitempty\" protobuf_key:\"bytes,1,opt,name=key,proto3\" protobuf_val:\"bytes,2,opt,name=value,proto3\"`\n}\n\nfunc (x *ClientCred) Reset() {\n\t*x = ClientCred{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[4]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *ClientCred) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ClientCred) ProtoMessage() {}\n\nfunc (x *ClientCred) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[4]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ClientCred.ProtoReflect.Descriptor instead.\nfunc (*ClientCred) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{4}\n}\n\nfunc (x *ClientCred) GetMethod() string {\n\tif x != nil {\n\t\treturn x.Method\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientCred) GetValue() string {\n\tif x != nil {\n\t\treturn x.Value\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientCred) GetResponse() string {\n\tif x != nil {\n\t\treturn x.Response\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientCred) GetParams() map[string][]byte {\n\tif x != nil {\n\t\treturn x.Params\n\t}\n\treturn nil\n}\n\n// SetDesc: C2S in set.what == \"desc\" and sub.init message\ntype SetDesc struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tDefaultAcs *DefaultAcsMode `protobuf:\"bytes,1,opt,name=default_acs,json=defaultAcs,proto3\" json:\"default_acs,omitempty\"`\n\tPublic     []byte          `protobuf:\"bytes,2,opt,name=public,proto3\" json:\"public,omitempty\"`\n\tPrivate    []byte          `protobuf:\"bytes,3,opt,name=private,proto3\" json:\"private,omitempty\"`\n\tTrusted    []byte          `protobuf:\"bytes,4,opt,name=trusted,proto3\" json:\"trusted,omitempty\"`\n}\n\nfunc (x *SetDesc) Reset() {\n\t*x = SetDesc{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[5]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *SetDesc) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*SetDesc) ProtoMessage() {}\n\nfunc (x *SetDesc) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[5]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use SetDesc.ProtoReflect.Descriptor instead.\nfunc (*SetDesc) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{5}\n}\n\nfunc (x *SetDesc) GetDefaultAcs() *DefaultAcsMode {\n\tif x != nil {\n\t\treturn x.DefaultAcs\n\t}\n\treturn nil\n}\n\nfunc (x *SetDesc) GetPublic() []byte {\n\tif x != nil {\n\t\treturn x.Public\n\t}\n\treturn nil\n}\n\nfunc (x *SetDesc) GetPrivate() []byte {\n\tif x != nil {\n\t\treturn x.Private\n\t}\n\treturn nil\n}\n\nfunc (x *SetDesc) GetTrusted() []byte {\n\tif x != nil {\n\t\treturn x.Trusted\n\t}\n\treturn nil\n}\n\ntype SeqRange struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tLow int32 `protobuf:\"varint,1,opt,name=low,proto3\" json:\"low,omitempty\"`\n\tHi  int32 `protobuf:\"varint,2,opt,name=hi,proto3\" json:\"hi,omitempty\"`\n}\n\nfunc (x *SeqRange) Reset() {\n\t*x = SeqRange{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[6]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *SeqRange) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*SeqRange) ProtoMessage() {}\n\nfunc (x *SeqRange) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[6]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use SeqRange.ProtoReflect.Descriptor instead.\nfunc (*SeqRange) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{6}\n}\n\nfunc (x *SeqRange) GetLow() int32 {\n\tif x != nil {\n\t\treturn x.Low\n\t}\n\treturn 0\n}\n\nfunc (x *SeqRange) GetHi() int32 {\n\tif x != nil {\n\t\treturn x.Hi\n\t}\n\treturn 0\n}\n\ntype GetOpts struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\t// Timestamp in milliseconds since epoch 01/01/1970\n\tIfModifiedSince int64 `protobuf:\"varint,1,opt,name=if_modified_since,json=ifModifiedSince,proto3\" json:\"if_modified_since,omitempty\"`\n\t// Limit search to this user ID\n\tUser string `protobuf:\"bytes,2,opt,name=user,proto3\" json:\"user,omitempty\"`\n\t// Limit search results to one topic;\n\tTopic string `protobuf:\"bytes,3,opt,name=topic,proto3\" json:\"topic,omitempty\"`\n\t// Load messages with seq id equal or greater than this\n\tSinceId int32 `protobuf:\"varint,4,opt,name=since_id,json=sinceId,proto3\" json:\"since_id,omitempty\"`\n\t// Load messages with seq id lower than this\n\tBeforeId int32 `protobuf:\"varint,5,opt,name=before_id,json=beforeId,proto3\" json:\"before_id,omitempty\"`\n\t// Maximum number of results to return\n\tLimit int32 `protobuf:\"varint,6,opt,name=limit,proto3\" json:\"limit,omitempty\"`\n\t// Load messages by id or ranges of ids\n\tRanges []*SeqRange `protobuf:\"bytes,7,rep,name=ranges,proto3\" json:\"ranges,omitempty\"`\n}\n\nfunc (x *GetOpts) Reset() {\n\t*x = GetOpts{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[7]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *GetOpts) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*GetOpts) ProtoMessage() {}\n\nfunc (x *GetOpts) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[7]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use GetOpts.ProtoReflect.Descriptor instead.\nfunc (*GetOpts) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{7}\n}\n\nfunc (x *GetOpts) GetIfModifiedSince() int64 {\n\tif x != nil {\n\t\treturn x.IfModifiedSince\n\t}\n\treturn 0\n}\n\nfunc (x *GetOpts) GetUser() string {\n\tif x != nil {\n\t\treturn x.User\n\t}\n\treturn \"\"\n}\n\nfunc (x *GetOpts) GetTopic() string {\n\tif x != nil {\n\t\treturn x.Topic\n\t}\n\treturn \"\"\n}\n\nfunc (x *GetOpts) GetSinceId() int32 {\n\tif x != nil {\n\t\treturn x.SinceId\n\t}\n\treturn 0\n}\n\nfunc (x *GetOpts) GetBeforeId() int32 {\n\tif x != nil {\n\t\treturn x.BeforeId\n\t}\n\treturn 0\n}\n\nfunc (x *GetOpts) GetLimit() int32 {\n\tif x != nil {\n\t\treturn x.Limit\n\t}\n\treturn 0\n}\n\nfunc (x *GetOpts) GetRanges() []*SeqRange {\n\tif x != nil {\n\t\treturn x.Ranges\n\t}\n\treturn nil\n}\n\ntype GetQuery struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tWhat string `protobuf:\"bytes,1,opt,name=what,proto3\" json:\"what,omitempty\"`\n\t// Parameters of \"desc\" request\n\tDesc *GetOpts `protobuf:\"bytes,2,opt,name=desc,proto3\" json:\"desc,omitempty\"`\n\t// Parameters of \"sub\" request\n\tSub *GetOpts `protobuf:\"bytes,3,opt,name=sub,proto3\" json:\"sub,omitempty\"`\n\t// Parameters of \"data\" request\n\tData *GetOpts `protobuf:\"bytes,4,opt,name=data,proto3\" json:\"data,omitempty\"`\n}\n\nfunc (x *GetQuery) Reset() {\n\t*x = GetQuery{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[8]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *GetQuery) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*GetQuery) ProtoMessage() {}\n\nfunc (x *GetQuery) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[8]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use GetQuery.ProtoReflect.Descriptor instead.\nfunc (*GetQuery) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{8}\n}\n\nfunc (x *GetQuery) GetWhat() string {\n\tif x != nil {\n\t\treturn x.What\n\t}\n\treturn \"\"\n}\n\nfunc (x *GetQuery) GetDesc() *GetOpts {\n\tif x != nil {\n\t\treturn x.Desc\n\t}\n\treturn nil\n}\n\nfunc (x *GetQuery) GetSub() *GetOpts {\n\tif x != nil {\n\t\treturn x.Sub\n\t}\n\treturn nil\n}\n\nfunc (x *GetQuery) GetData() *GetOpts {\n\tif x != nil {\n\t\treturn x.Data\n\t}\n\treturn nil\n}\n\ntype SetQuery struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\t// Topic metadata, new topic & new subscriptions only\n\tDesc *SetDesc `protobuf:\"bytes,1,opt,name=desc,proto3\" json:\"desc,omitempty\"`\n\t// Subscription parameters\n\tSub *SetSub `protobuf:\"bytes,2,opt,name=sub,proto3\" json:\"sub,omitempty\"`\n\t// Indexable tags\n\tTags []string `protobuf:\"bytes,3,rep,name=tags,proto3\" json:\"tags,omitempty\"`\n\t// Credential being updated.\n\tCred *ClientCred `protobuf:\"bytes,4,opt,name=cred,proto3\" json:\"cred,omitempty\"`\n\t// Auxiliary data.\n\tAux map[string][]byte `protobuf:\"bytes,5,rep,name=aux,proto3\" json:\"aux,omitempty\" protobuf_key:\"bytes,1,opt,name=key,proto3\" protobuf_val:\"bytes,2,opt,name=value,proto3\"`\n}\n\nfunc (x *SetQuery) Reset() {\n\t*x = SetQuery{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[9]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *SetQuery) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*SetQuery) ProtoMessage() {}\n\nfunc (x *SetQuery) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[9]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use SetQuery.ProtoReflect.Descriptor instead.\nfunc (*SetQuery) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{9}\n}\n\nfunc (x *SetQuery) GetDesc() *SetDesc {\n\tif x != nil {\n\t\treturn x.Desc\n\t}\n\treturn nil\n}\n\nfunc (x *SetQuery) GetSub() *SetSub {\n\tif x != nil {\n\t\treturn x.Sub\n\t}\n\treturn nil\n}\n\nfunc (x *SetQuery) GetTags() []string {\n\tif x != nil {\n\t\treturn x.Tags\n\t}\n\treturn nil\n}\n\nfunc (x *SetQuery) GetCred() *ClientCred {\n\tif x != nil {\n\t\treturn x.Cred\n\t}\n\treturn nil\n}\n\nfunc (x *SetQuery) GetAux() map[string][]byte {\n\tif x != nil {\n\t\treturn x.Aux\n\t}\n\treturn nil\n}\n\n// Client handshake\ntype ClientHi struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tId         string `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\tUserAgent  string `protobuf:\"bytes,2,opt,name=user_agent,json=userAgent,proto3\" json:\"user_agent,omitempty\"`\n\tVer        string `protobuf:\"bytes,3,opt,name=ver,proto3\" json:\"ver,omitempty\"`\n\tDeviceId   string `protobuf:\"bytes,4,opt,name=device_id,json=deviceId,proto3\" json:\"device_id,omitempty\"`\n\tLang       string `protobuf:\"bytes,5,opt,name=lang,proto3\" json:\"lang,omitempty\"`\n\tPlatform   string `protobuf:\"bytes,6,opt,name=platform,proto3\" json:\"platform,omitempty\"`\n\tBackground bool   `protobuf:\"varint,7,opt,name=background,proto3\" json:\"background,omitempty\"`\n}\n\nfunc (x *ClientHi) Reset() {\n\t*x = ClientHi{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[10]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *ClientHi) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ClientHi) ProtoMessage() {}\n\nfunc (x *ClientHi) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[10]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ClientHi.ProtoReflect.Descriptor instead.\nfunc (*ClientHi) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{10}\n}\n\nfunc (x *ClientHi) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientHi) GetUserAgent() string {\n\tif x != nil {\n\t\treturn x.UserAgent\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientHi) GetVer() string {\n\tif x != nil {\n\t\treturn x.Ver\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientHi) GetDeviceId() string {\n\tif x != nil {\n\t\treturn x.DeviceId\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientHi) GetLang() string {\n\tif x != nil {\n\t\treturn x.Lang\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientHi) GetPlatform() string {\n\tif x != nil {\n\t\treturn x.Platform\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientHi) GetBackground() bool {\n\tif x != nil {\n\t\treturn x.Background\n\t}\n\treturn false\n}\n\n// User creation message {acc}\ntype ClientAcc struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tId string `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\t// User being created or updated\n\tUserId string `protobuf:\"bytes,2,opt,name=user_id,json=userId,proto3\" json:\"user_id,omitempty\"`\n\t// The initial authentication scheme the account can use\n\tScheme string `protobuf:\"bytes,3,opt,name=scheme,proto3\" json:\"scheme,omitempty\"`\n\t// Shared secret\n\tSecret []byte `protobuf:\"bytes,4,opt,name=secret,proto3\" json:\"secret,omitempty\"`\n\t// Authenticate session with the newly created account\n\tLogin bool `protobuf:\"varint,5,opt,name=login,proto3\" json:\"login,omitempty\"`\n\t// Indexable tags for user discovery\n\tTags []string `protobuf:\"bytes,6,rep,name=tags,proto3\" json:\"tags,omitempty\"`\n\t// User initialization data when creating a new user, otherwise ignored\n\tDesc *SetDesc `protobuf:\"bytes,7,opt,name=desc,proto3\" json:\"desc,omitempty\"`\n\t// Credentials for verification.\n\tCred []*ClientCred `protobuf:\"bytes,8,rep,name=cred,proto3\" json:\"cred,omitempty\"`\n\t// Authentication token used for resetting a password.\n\tToken []byte `protobuf:\"bytes,9,opt,name=token,proto3\" json:\"token,omitempty\"`\n\t// Account state: normal (\"ok\"), suspended\n\tState string `protobuf:\"bytes,10,opt,name=state,proto3\" json:\"state,omitempty\"`\n\t// AuthLevel\n\tAuthLevel AuthLevel `protobuf:\"varint,11,opt,name=auth_level,json=authLevel,proto3,enum=pbx.AuthLevel\" json:\"auth_level,omitempty\"`\n\t// Temporary auth params for one-off actions like password reset.\n\tTmpScheme string `protobuf:\"bytes,12,opt,name=tmp_scheme,json=tmpScheme,proto3\" json:\"tmp_scheme,omitempty\"`\n\tTmpSecret []byte `protobuf:\"bytes,13,opt,name=tmp_secret,json=tmpSecret,proto3\" json:\"tmp_secret,omitempty\"`\n}\n\nfunc (x *ClientAcc) Reset() {\n\t*x = ClientAcc{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[11]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *ClientAcc) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ClientAcc) ProtoMessage() {}\n\nfunc (x *ClientAcc) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[11]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ClientAcc.ProtoReflect.Descriptor instead.\nfunc (*ClientAcc) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{11}\n}\n\nfunc (x *ClientAcc) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientAcc) GetUserId() string {\n\tif x != nil {\n\t\treturn x.UserId\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientAcc) GetScheme() string {\n\tif x != nil {\n\t\treturn x.Scheme\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientAcc) GetSecret() []byte {\n\tif x != nil {\n\t\treturn x.Secret\n\t}\n\treturn nil\n}\n\nfunc (x *ClientAcc) GetLogin() bool {\n\tif x != nil {\n\t\treturn x.Login\n\t}\n\treturn false\n}\n\nfunc (x *ClientAcc) GetTags() []string {\n\tif x != nil {\n\t\treturn x.Tags\n\t}\n\treturn nil\n}\n\nfunc (x *ClientAcc) GetDesc() *SetDesc {\n\tif x != nil {\n\t\treturn x.Desc\n\t}\n\treturn nil\n}\n\nfunc (x *ClientAcc) GetCred() []*ClientCred {\n\tif x != nil {\n\t\treturn x.Cred\n\t}\n\treturn nil\n}\n\nfunc (x *ClientAcc) GetToken() []byte {\n\tif x != nil {\n\t\treturn x.Token\n\t}\n\treturn nil\n}\n\nfunc (x *ClientAcc) GetState() string {\n\tif x != nil {\n\t\treturn x.State\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientAcc) GetAuthLevel() AuthLevel {\n\tif x != nil {\n\t\treturn x.AuthLevel\n\t}\n\treturn AuthLevel_NONE\n}\n\nfunc (x *ClientAcc) GetTmpScheme() string {\n\tif x != nil {\n\t\treturn x.TmpScheme\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientAcc) GetTmpSecret() []byte {\n\tif x != nil {\n\t\treturn x.TmpSecret\n\t}\n\treturn nil\n}\n\n// Login {login} message\ntype ClientLogin struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tId string `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\t// Authentication scheme\n\tScheme string `protobuf:\"bytes,2,opt,name=scheme,proto3\" json:\"scheme,omitempty\"`\n\t// Shared secret\n\tSecret []byte `protobuf:\"bytes,3,opt,name=secret,proto3\" json:\"secret,omitempty\"`\n\t// Credentials for verification.\n\tCred []*ClientCred `protobuf:\"bytes,4,rep,name=cred,proto3\" json:\"cred,omitempty\"`\n}\n\nfunc (x *ClientLogin) Reset() {\n\t*x = ClientLogin{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[12]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *ClientLogin) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ClientLogin) ProtoMessage() {}\n\nfunc (x *ClientLogin) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[12]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ClientLogin.ProtoReflect.Descriptor instead.\nfunc (*ClientLogin) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{12}\n}\n\nfunc (x *ClientLogin) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientLogin) GetScheme() string {\n\tif x != nil {\n\t\treturn x.Scheme\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientLogin) GetSecret() []byte {\n\tif x != nil {\n\t\treturn x.Secret\n\t}\n\treturn nil\n}\n\nfunc (x *ClientLogin) GetCred() []*ClientCred {\n\tif x != nil {\n\t\treturn x.Cred\n\t}\n\treturn nil\n}\n\n// Subscription request {sub} message\ntype ClientSub struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tId    string `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\tTopic string `protobuf:\"bytes,2,opt,name=topic,proto3\" json:\"topic,omitempty\"`\n\t// mirrors {set}\n\tSetQuery *SetQuery `protobuf:\"bytes,3,opt,name=set_query,json=setQuery,proto3\" json:\"set_query,omitempty\"`\n\t// mirrors {get}\n\tGetQuery *GetQuery `protobuf:\"bytes,4,opt,name=get_query,json=getQuery,proto3\" json:\"get_query,omitempty\"`\n}\n\nfunc (x *ClientSub) Reset() {\n\t*x = ClientSub{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[13]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *ClientSub) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ClientSub) ProtoMessage() {}\n\nfunc (x *ClientSub) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[13]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ClientSub.ProtoReflect.Descriptor instead.\nfunc (*ClientSub) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{13}\n}\n\nfunc (x *ClientSub) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientSub) GetTopic() string {\n\tif x != nil {\n\t\treturn x.Topic\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientSub) GetSetQuery() *SetQuery {\n\tif x != nil {\n\t\treturn x.SetQuery\n\t}\n\treturn nil\n}\n\nfunc (x *ClientSub) GetGetQuery() *GetQuery {\n\tif x != nil {\n\t\treturn x.GetQuery\n\t}\n\treturn nil\n}\n\n// Unsubscribe {leave} request message\ntype ClientLeave struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tId    string `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\tTopic string `protobuf:\"bytes,2,opt,name=topic,proto3\" json:\"topic,omitempty\"`\n\tUnsub bool   `protobuf:\"varint,3,opt,name=unsub,proto3\" json:\"unsub,omitempty\"`\n}\n\nfunc (x *ClientLeave) Reset() {\n\t*x = ClientLeave{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[14]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *ClientLeave) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ClientLeave) ProtoMessage() {}\n\nfunc (x *ClientLeave) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[14]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ClientLeave.ProtoReflect.Descriptor instead.\nfunc (*ClientLeave) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{14}\n}\n\nfunc (x *ClientLeave) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientLeave) GetTopic() string {\n\tif x != nil {\n\t\treturn x.Topic\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientLeave) GetUnsub() bool {\n\tif x != nil {\n\t\treturn x.Unsub\n\t}\n\treturn false\n}\n\n// ClientPub is client's request to publish data to topic subscribers {pub}\ntype ClientPub struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tId      string            `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\tTopic   string            `protobuf:\"bytes,2,opt,name=topic,proto3\" json:\"topic,omitempty\"`\n\tNoEcho  bool              `protobuf:\"varint,3,opt,name=no_echo,json=noEcho,proto3\" json:\"no_echo,omitempty\"`\n\tHead    map[string][]byte `protobuf:\"bytes,4,rep,name=head,proto3\" json:\"head,omitempty\" protobuf_key:\"bytes,1,opt,name=key,proto3\" protobuf_val:\"bytes,2,opt,name=value,proto3\"`\n\tContent []byte            `protobuf:\"bytes,5,opt,name=content,proto3\" json:\"content,omitempty\"`\n}\n\nfunc (x *ClientPub) Reset() {\n\t*x = ClientPub{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[15]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *ClientPub) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ClientPub) ProtoMessage() {}\n\nfunc (x *ClientPub) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[15]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ClientPub.ProtoReflect.Descriptor instead.\nfunc (*ClientPub) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{15}\n}\n\nfunc (x *ClientPub) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientPub) GetTopic() string {\n\tif x != nil {\n\t\treturn x.Topic\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientPub) GetNoEcho() bool {\n\tif x != nil {\n\t\treturn x.NoEcho\n\t}\n\treturn false\n}\n\nfunc (x *ClientPub) GetHead() map[string][]byte {\n\tif x != nil {\n\t\treturn x.Head\n\t}\n\treturn nil\n}\n\nfunc (x *ClientPub) GetContent() []byte {\n\tif x != nil {\n\t\treturn x.Content\n\t}\n\treturn nil\n}\n\n// Query topic state {get}\ntype ClientGet struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tId    string    `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\tTopic string    `protobuf:\"bytes,2,opt,name=topic,proto3\" json:\"topic,omitempty\"`\n\tQuery *GetQuery `protobuf:\"bytes,3,opt,name=query,proto3\" json:\"query,omitempty\"`\n}\n\nfunc (x *ClientGet) Reset() {\n\t*x = ClientGet{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[16]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *ClientGet) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ClientGet) ProtoMessage() {}\n\nfunc (x *ClientGet) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[16]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ClientGet.ProtoReflect.Descriptor instead.\nfunc (*ClientGet) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{16}\n}\n\nfunc (x *ClientGet) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientGet) GetTopic() string {\n\tif x != nil {\n\t\treturn x.Topic\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientGet) GetQuery() *GetQuery {\n\tif x != nil {\n\t\treturn x.Query\n\t}\n\treturn nil\n}\n\n// Update topic state {set}\ntype ClientSet struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tId    string    `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\tTopic string    `protobuf:\"bytes,2,opt,name=topic,proto3\" json:\"topic,omitempty\"`\n\tQuery *SetQuery `protobuf:\"bytes,3,opt,name=query,proto3\" json:\"query,omitempty\"`\n}\n\nfunc (x *ClientSet) Reset() {\n\t*x = ClientSet{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[17]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *ClientSet) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ClientSet) ProtoMessage() {}\n\nfunc (x *ClientSet) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[17]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ClientSet.ProtoReflect.Descriptor instead.\nfunc (*ClientSet) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{17}\n}\n\nfunc (x *ClientSet) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientSet) GetTopic() string {\n\tif x != nil {\n\t\treturn x.Topic\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientSet) GetQuery() *SetQuery {\n\tif x != nil {\n\t\treturn x.Query\n\t}\n\treturn nil\n}\n\n// ClientDel delete messages or topic\ntype ClientDel struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tId    string         `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\tTopic string         `protobuf:\"bytes,2,opt,name=topic,proto3\" json:\"topic,omitempty\"`\n\tWhat  ClientDel_What `protobuf:\"varint,3,opt,name=what,proto3,enum=pbx.ClientDel_What\" json:\"what,omitempty\"`\n\t// Delete messages by id or range of ids\n\tDelSeq []*SeqRange `protobuf:\"bytes,4,rep,name=del_seq,json=delSeq,proto3\" json:\"del_seq,omitempty\"`\n\t// User ID of the subscription to delete\n\tUserId string `protobuf:\"bytes,5,opt,name=user_id,json=userId,proto3\" json:\"user_id,omitempty\"`\n\t// Credential to delete.\n\tCred *ClientCred `protobuf:\"bytes,6,opt,name=cred,proto3\" json:\"cred,omitempty\"`\n\t// Request to hard-delete messages for all users, if such option is available.\n\tHard bool `protobuf:\"varint,7,opt,name=hard,proto3\" json:\"hard,omitempty\"`\n}\n\nfunc (x *ClientDel) Reset() {\n\t*x = ClientDel{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[18]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *ClientDel) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ClientDel) ProtoMessage() {}\n\nfunc (x *ClientDel) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[18]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ClientDel.ProtoReflect.Descriptor instead.\nfunc (*ClientDel) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{18}\n}\n\nfunc (x *ClientDel) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientDel) GetTopic() string {\n\tif x != nil {\n\t\treturn x.Topic\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientDel) GetWhat() ClientDel_What {\n\tif x != nil {\n\t\treturn x.What\n\t}\n\treturn ClientDel_X0\n}\n\nfunc (x *ClientDel) GetDelSeq() []*SeqRange {\n\tif x != nil {\n\t\treturn x.DelSeq\n\t}\n\treturn nil\n}\n\nfunc (x *ClientDel) GetUserId() string {\n\tif x != nil {\n\t\treturn x.UserId\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientDel) GetCred() *ClientCred {\n\tif x != nil {\n\t\treturn x.Cred\n\t}\n\treturn nil\n}\n\nfunc (x *ClientDel) GetHard() bool {\n\tif x != nil {\n\t\treturn x.Hard\n\t}\n\treturn false\n}\n\n// ClientNote is a client-generated notification for topic subscribers\ntype ClientNote struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tTopic string `protobuf:\"bytes,1,opt,name=topic,proto3\" json:\"topic,omitempty\"`\n\t// what is being reported: \"recv\" - message received, \"read\" - message read,\n\t// \"kp\" - typing notification, \"call\" - voice/video call\n\tWhat InfoNote `protobuf:\"varint,2,opt,name=what,proto3,enum=pbx.InfoNote\" json:\"what,omitempty\"`\n\t// Server-issued message ID being reported\n\tSeqId int32 `protobuf:\"varint,3,opt,name=seq_id,json=seqId,proto3\" json:\"seq_id,omitempty\"`\n\t// Client's count of unread messages to report back to the server. Used in push notifications on iOS.\n\tUnread int32 `protobuf:\"varint,4,opt,name=unread,proto3\" json:\"unread,omitempty\"`\n\t// Call event.\n\tEvent CallEvent `protobuf:\"varint,5,opt,name=event,proto3,enum=pbx.CallEvent\" json:\"event,omitempty\"`\n\t// Arbitrary json payload (used in video calls).\n\tPayload []byte `protobuf:\"bytes,6,opt,name=payload,proto3\" json:\"payload,omitempty\"`\n}\n\nfunc (x *ClientNote) Reset() {\n\t*x = ClientNote{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[19]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *ClientNote) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ClientNote) ProtoMessage() {}\n\nfunc (x *ClientNote) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[19]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ClientNote.ProtoReflect.Descriptor instead.\nfunc (*ClientNote) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{19}\n}\n\nfunc (x *ClientNote) GetTopic() string {\n\tif x != nil {\n\t\treturn x.Topic\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientNote) GetWhat() InfoNote {\n\tif x != nil {\n\t\treturn x.What\n\t}\n\treturn InfoNote_X1\n}\n\nfunc (x *ClientNote) GetSeqId() int32 {\n\tif x != nil {\n\t\treturn x.SeqId\n\t}\n\treturn 0\n}\n\nfunc (x *ClientNote) GetUnread() int32 {\n\tif x != nil {\n\t\treturn x.Unread\n\t}\n\treturn 0\n}\n\nfunc (x *ClientNote) GetEvent() CallEvent {\n\tif x != nil {\n\t\treturn x.Event\n\t}\n\treturn CallEvent_X2\n}\n\nfunc (x *ClientNote) GetPayload() []byte {\n\tif x != nil {\n\t\treturn x.Payload\n\t}\n\treturn nil\n}\n\ntype ClientExtra struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tAttachments []string `protobuf:\"bytes,1,rep,name=attachments,proto3\" json:\"attachments,omitempty\"`\n\t// Root user may send messages on behalf of other users.\n\tOnBehalfOf string    `protobuf:\"bytes,2,opt,name=on_behalf_of,json=onBehalfOf,proto3\" json:\"on_behalf_of,omitempty\"`\n\tAuthLevel  AuthLevel `protobuf:\"varint,3,opt,name=auth_level,json=authLevel,proto3,enum=pbx.AuthLevel\" json:\"auth_level,omitempty\"`\n}\n\nfunc (x *ClientExtra) Reset() {\n\t*x = ClientExtra{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[20]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *ClientExtra) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ClientExtra) ProtoMessage() {}\n\nfunc (x *ClientExtra) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[20]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ClientExtra.ProtoReflect.Descriptor instead.\nfunc (*ClientExtra) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{20}\n}\n\nfunc (x *ClientExtra) GetAttachments() []string {\n\tif x != nil {\n\t\treturn x.Attachments\n\t}\n\treturn nil\n}\n\nfunc (x *ClientExtra) GetOnBehalfOf() string {\n\tif x != nil {\n\t\treturn x.OnBehalfOf\n\t}\n\treturn \"\"\n}\n\nfunc (x *ClientExtra) GetAuthLevel() AuthLevel {\n\tif x != nil {\n\t\treturn x.AuthLevel\n\t}\n\treturn AuthLevel_NONE\n}\n\ntype ClientMsg struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\t// Types that are assignable to Message:\n\t//\t*ClientMsg_Hi\n\t//\t*ClientMsg_Acc\n\t//\t*ClientMsg_Login\n\t//\t*ClientMsg_Sub\n\t//\t*ClientMsg_Leave\n\t//\t*ClientMsg_Pub\n\t//\t*ClientMsg_Get\n\t//\t*ClientMsg_Set\n\t//\t*ClientMsg_Del\n\t//\t*ClientMsg_Note\n\tMessage isClientMsg_Message `protobuf_oneof:\"Message\"`\n\t// Additional message parameters.\n\tExtra *ClientExtra `protobuf:\"bytes,13,opt,name=extra,proto3\" json:\"extra,omitempty\"`\n}\n\nfunc (x *ClientMsg) Reset() {\n\t*x = ClientMsg{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[21]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *ClientMsg) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ClientMsg) ProtoMessage() {}\n\nfunc (x *ClientMsg) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[21]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ClientMsg.ProtoReflect.Descriptor instead.\nfunc (*ClientMsg) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{21}\n}\n\nfunc (m *ClientMsg) GetMessage() isClientMsg_Message {\n\tif m != nil {\n\t\treturn m.Message\n\t}\n\treturn nil\n}\n\nfunc (x *ClientMsg) GetHi() *ClientHi {\n\tif x, ok := x.GetMessage().(*ClientMsg_Hi); ok {\n\t\treturn x.Hi\n\t}\n\treturn nil\n}\n\nfunc (x *ClientMsg) GetAcc() *ClientAcc {\n\tif x, ok := x.GetMessage().(*ClientMsg_Acc); ok {\n\t\treturn x.Acc\n\t}\n\treturn nil\n}\n\nfunc (x *ClientMsg) GetLogin() *ClientLogin {\n\tif x, ok := x.GetMessage().(*ClientMsg_Login); ok {\n\t\treturn x.Login\n\t}\n\treturn nil\n}\n\nfunc (x *ClientMsg) GetSub() *ClientSub {\n\tif x, ok := x.GetMessage().(*ClientMsg_Sub); ok {\n\t\treturn x.Sub\n\t}\n\treturn nil\n}\n\nfunc (x *ClientMsg) GetLeave() *ClientLeave {\n\tif x, ok := x.GetMessage().(*ClientMsg_Leave); ok {\n\t\treturn x.Leave\n\t}\n\treturn nil\n}\n\nfunc (x *ClientMsg) GetPub() *ClientPub {\n\tif x, ok := x.GetMessage().(*ClientMsg_Pub); ok {\n\t\treturn x.Pub\n\t}\n\treturn nil\n}\n\nfunc (x *ClientMsg) GetGet() *ClientGet {\n\tif x, ok := x.GetMessage().(*ClientMsg_Get); ok {\n\t\treturn x.Get\n\t}\n\treturn nil\n}\n\nfunc (x *ClientMsg) GetSet() *ClientSet {\n\tif x, ok := x.GetMessage().(*ClientMsg_Set); ok {\n\t\treturn x.Set\n\t}\n\treturn nil\n}\n\nfunc (x *ClientMsg) GetDel() *ClientDel {\n\tif x, ok := x.GetMessage().(*ClientMsg_Del); ok {\n\t\treturn x.Del\n\t}\n\treturn nil\n}\n\nfunc (x *ClientMsg) GetNote() *ClientNote {\n\tif x, ok := x.GetMessage().(*ClientMsg_Note); ok {\n\t\treturn x.Note\n\t}\n\treturn nil\n}\n\nfunc (x *ClientMsg) GetExtra() *ClientExtra {\n\tif x != nil {\n\t\treturn x.Extra\n\t}\n\treturn nil\n}\n\ntype isClientMsg_Message interface {\n\tisClientMsg_Message()\n}\n\ntype ClientMsg_Hi struct {\n\tHi *ClientHi `protobuf:\"bytes,1,opt,name=hi,proto3,oneof\"`\n}\n\ntype ClientMsg_Acc struct {\n\tAcc *ClientAcc `protobuf:\"bytes,2,opt,name=acc,proto3,oneof\"`\n}\n\ntype ClientMsg_Login struct {\n\tLogin *ClientLogin `protobuf:\"bytes,3,opt,name=login,proto3,oneof\"`\n}\n\ntype ClientMsg_Sub struct {\n\tSub *ClientSub `protobuf:\"bytes,4,opt,name=sub,proto3,oneof\"`\n}\n\ntype ClientMsg_Leave struct {\n\tLeave *ClientLeave `protobuf:\"bytes,5,opt,name=leave,proto3,oneof\"`\n}\n\ntype ClientMsg_Pub struct {\n\tPub *ClientPub `protobuf:\"bytes,6,opt,name=pub,proto3,oneof\"`\n}\n\ntype ClientMsg_Get struct {\n\tGet *ClientGet `protobuf:\"bytes,7,opt,name=get,proto3,oneof\"`\n}\n\ntype ClientMsg_Set struct {\n\tSet *ClientSet `protobuf:\"bytes,8,opt,name=set,proto3,oneof\"`\n}\n\ntype ClientMsg_Del struct {\n\tDel *ClientDel `protobuf:\"bytes,9,opt,name=del,proto3,oneof\"`\n}\n\ntype ClientMsg_Note struct {\n\tNote *ClientNote `protobuf:\"bytes,10,opt,name=note,proto3,oneof\"`\n}\n\nfunc (*ClientMsg_Hi) isClientMsg_Message() {}\n\nfunc (*ClientMsg_Acc) isClientMsg_Message() {}\n\nfunc (*ClientMsg_Login) isClientMsg_Message() {}\n\nfunc (*ClientMsg_Sub) isClientMsg_Message() {}\n\nfunc (*ClientMsg_Leave) isClientMsg_Message() {}\n\nfunc (*ClientMsg_Pub) isClientMsg_Message() {}\n\nfunc (*ClientMsg_Get) isClientMsg_Message() {}\n\nfunc (*ClientMsg_Set) isClientMsg_Message() {}\n\nfunc (*ClientMsg_Del) isClientMsg_Message() {}\n\nfunc (*ClientMsg_Note) isClientMsg_Message() {}\n\n// Credentials\ntype ServerCred struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\t// Credential type, i.e. `email` or `tel`.\n\tMethod string `protobuf:\"bytes,1,opt,name=method,proto3\" json:\"method,omitempty\"`\n\t// Value to verify, i.e. `user@example.com` or `+18003287448`\n\tValue string `protobuf:\"bytes,2,opt,name=value,proto3\" json:\"value,omitempty\"`\n\t// Indicator that the credential is validated\n\tDone bool `protobuf:\"varint,3,opt,name=done,proto3\" json:\"done,omitempty\"`\n}\n\nfunc (x *ServerCred) Reset() {\n\t*x = ServerCred{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[22]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *ServerCred) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ServerCred) ProtoMessage() {}\n\nfunc (x *ServerCred) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[22]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ServerCred.ProtoReflect.Descriptor instead.\nfunc (*ServerCred) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{22}\n}\n\nfunc (x *ServerCred) GetMethod() string {\n\tif x != nil {\n\t\treturn x.Method\n\t}\n\treturn \"\"\n}\n\nfunc (x *ServerCred) GetValue() string {\n\tif x != nil {\n\t\treturn x.Value\n\t}\n\treturn \"\"\n}\n\nfunc (x *ServerCred) GetDone() bool {\n\tif x != nil {\n\t\treturn x.Done\n\t}\n\treturn false\n}\n\n// Topic description, S2C in Meta message\ntype TopicDesc struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tCreatedAt int64           `protobuf:\"varint,1,opt,name=created_at,json=createdAt,proto3\" json:\"created_at,omitempty\"`\n\tUpdatedAt int64           `protobuf:\"varint,2,opt,name=updated_at,json=updatedAt,proto3\" json:\"updated_at,omitempty\"`\n\tTouchedAt int64           `protobuf:\"varint,3,opt,name=touched_at,json=touchedAt,proto3\" json:\"touched_at,omitempty\"`\n\tDefacs    *DefaultAcsMode `protobuf:\"bytes,4,opt,name=defacs,proto3\" json:\"defacs,omitempty\"`\n\tAcs       *AccessMode     `protobuf:\"bytes,5,opt,name=acs,proto3\" json:\"acs,omitempty\"`\n\tSeqId     int32           `protobuf:\"varint,6,opt,name=seq_id,json=seqId,proto3\" json:\"seq_id,omitempty\"`\n\tReadId    int32           `protobuf:\"varint,7,opt,name=read_id,json=readId,proto3\" json:\"read_id,omitempty\"`\n\tRecvId    int32           `protobuf:\"varint,8,opt,name=recv_id,json=recvId,proto3\" json:\"recv_id,omitempty\"`\n\tDelId     int32           `protobuf:\"varint,9,opt,name=del_id,json=delId,proto3\" json:\"del_id,omitempty\"`\n\tPublic    []byte          `protobuf:\"bytes,10,opt,name=public,proto3\" json:\"public,omitempty\"`\n\tPrivate   []byte          `protobuf:\"bytes,11,opt,name=private,proto3\" json:\"private,omitempty\"`\n\tState     string          `protobuf:\"bytes,12,opt,name=state,proto3\" json:\"state,omitempty\"`\n\tStateAt   int64           `protobuf:\"varint,13,opt,name=state_at,json=stateAt,proto3\" json:\"state_at,omitempty\"`\n\tTrusted   []byte          `protobuf:\"bytes,14,opt,name=trusted,proto3\" json:\"trusted,omitempty\"`\n\tIsChan    bool            `protobuf:\"varint,17,opt,name=is_chan,json=isChan,proto3\" json:\"is_chan,omitempty\"` // 17!\n\tOnline    bool            `protobuf:\"varint,18,opt,name=online,proto3\" json:\"online,omitempty\"`\n\t// P2P only: other user's last online timestamp & user agent\n\tLastSeenTime      int64  `protobuf:\"varint,15,opt,name=last_seen_time,json=lastSeenTime,proto3\" json:\"last_seen_time,omitempty\"`\n\tLastSeenUserAgent string `protobuf:\"bytes,16,opt,name=last_seen_user_agent,json=lastSeenUserAgent,proto3\" json:\"last_seen_user_agent,omitempty\"`\n}\n\nfunc (x *TopicDesc) Reset() {\n\t*x = TopicDesc{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[23]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *TopicDesc) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*TopicDesc) ProtoMessage() {}\n\nfunc (x *TopicDesc) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[23]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use TopicDesc.ProtoReflect.Descriptor instead.\nfunc (*TopicDesc) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{23}\n}\n\nfunc (x *TopicDesc) GetCreatedAt() int64 {\n\tif x != nil {\n\t\treturn x.CreatedAt\n\t}\n\treturn 0\n}\n\nfunc (x *TopicDesc) GetUpdatedAt() int64 {\n\tif x != nil {\n\t\treturn x.UpdatedAt\n\t}\n\treturn 0\n}\n\nfunc (x *TopicDesc) GetTouchedAt() int64 {\n\tif x != nil {\n\t\treturn x.TouchedAt\n\t}\n\treturn 0\n}\n\nfunc (x *TopicDesc) GetDefacs() *DefaultAcsMode {\n\tif x != nil {\n\t\treturn x.Defacs\n\t}\n\treturn nil\n}\n\nfunc (x *TopicDesc) GetAcs() *AccessMode {\n\tif x != nil {\n\t\treturn x.Acs\n\t}\n\treturn nil\n}\n\nfunc (x *TopicDesc) GetSeqId() int32 {\n\tif x != nil {\n\t\treturn x.SeqId\n\t}\n\treturn 0\n}\n\nfunc (x *TopicDesc) GetReadId() int32 {\n\tif x != nil {\n\t\treturn x.ReadId\n\t}\n\treturn 0\n}\n\nfunc (x *TopicDesc) GetRecvId() int32 {\n\tif x != nil {\n\t\treturn x.RecvId\n\t}\n\treturn 0\n}\n\nfunc (x *TopicDesc) GetDelId() int32 {\n\tif x != nil {\n\t\treturn x.DelId\n\t}\n\treturn 0\n}\n\nfunc (x *TopicDesc) GetPublic() []byte {\n\tif x != nil {\n\t\treturn x.Public\n\t}\n\treturn nil\n}\n\nfunc (x *TopicDesc) GetPrivate() []byte {\n\tif x != nil {\n\t\treturn x.Private\n\t}\n\treturn nil\n}\n\nfunc (x *TopicDesc) GetState() string {\n\tif x != nil {\n\t\treturn x.State\n\t}\n\treturn \"\"\n}\n\nfunc (x *TopicDesc) GetStateAt() int64 {\n\tif x != nil {\n\t\treturn x.StateAt\n\t}\n\treturn 0\n}\n\nfunc (x *TopicDesc) GetTrusted() []byte {\n\tif x != nil {\n\t\treturn x.Trusted\n\t}\n\treturn nil\n}\n\nfunc (x *TopicDesc) GetIsChan() bool {\n\tif x != nil {\n\t\treturn x.IsChan\n\t}\n\treturn false\n}\n\nfunc (x *TopicDesc) GetOnline() bool {\n\tif x != nil {\n\t\treturn x.Online\n\t}\n\treturn false\n}\n\nfunc (x *TopicDesc) GetLastSeenTime() int64 {\n\tif x != nil {\n\t\treturn x.LastSeenTime\n\t}\n\treturn 0\n}\n\nfunc (x *TopicDesc) GetLastSeenUserAgent() string {\n\tif x != nil {\n\t\treturn x.LastSeenUserAgent\n\t}\n\treturn \"\"\n}\n\n// MsgTopicSub: topic subscription details, sent in Meta message\ntype TopicSub struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tUpdatedAt int64       `protobuf:\"varint,1,opt,name=updated_at,json=updatedAt,proto3\" json:\"updated_at,omitempty\"`\n\tDeletedAt int64       `protobuf:\"varint,2,opt,name=deleted_at,json=deletedAt,proto3\" json:\"deleted_at,omitempty\"`\n\tOnline    bool        `protobuf:\"varint,3,opt,name=online,proto3\" json:\"online,omitempty\"`\n\tAcs       *AccessMode `protobuf:\"bytes,4,opt,name=acs,proto3\" json:\"acs,omitempty\"`\n\tReadId    int32       `protobuf:\"varint,5,opt,name=read_id,json=readId,proto3\" json:\"read_id,omitempty\"`\n\tRecvId    int32       `protobuf:\"varint,6,opt,name=recv_id,json=recvId,proto3\" json:\"recv_id,omitempty\"`\n\tPublic    []byte      `protobuf:\"bytes,7,opt,name=public,proto3\" json:\"public,omitempty\"`\n\tTrusted   []byte      `protobuf:\"bytes,16,opt,name=trusted,proto3\" json:\"trusted,omitempty\"` // 16!\n\tPrivate   []byte      `protobuf:\"bytes,8,opt,name=private,proto3\" json:\"private,omitempty\"`\n\t// Uid of the subscribed user\n\tUserId string `protobuf:\"bytes,9,opt,name=user_id,json=userId,proto3\" json:\"user_id,omitempty\"`\n\t// Topic name of this subscription\n\tTopic     string `protobuf:\"bytes,10,opt,name=topic,proto3\" json:\"topic,omitempty\"`\n\tTouchedAt int64  `protobuf:\"varint,11,opt,name=touched_at,json=touchedAt,proto3\" json:\"touched_at,omitempty\"`\n\t// ID of the last {data} message in a topic\n\tSeqId int32 `protobuf:\"varint,12,opt,name=seq_id,json=seqId,proto3\" json:\"seq_id,omitempty\"`\n\t// Messages are deleted up to this ID\n\tDelId int32 `protobuf:\"varint,13,opt,name=del_id,json=delId,proto3\" json:\"del_id,omitempty\"`\n\t// Other user's last online timestamp & user agent\n\tLastSeenTime      int64  `protobuf:\"varint,14,opt,name=last_seen_time,json=lastSeenTime,proto3\" json:\"last_seen_time,omitempty\"`\n\tLastSeenUserAgent string `protobuf:\"bytes,15,opt,name=last_seen_user_agent,json=lastSeenUserAgent,proto3\" json:\"last_seen_user_agent,omitempty\"`\n}\n\nfunc (x *TopicSub) Reset() {\n\t*x = TopicSub{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[24]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *TopicSub) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*TopicSub) ProtoMessage() {}\n\nfunc (x *TopicSub) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[24]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use TopicSub.ProtoReflect.Descriptor instead.\nfunc (*TopicSub) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{24}\n}\n\nfunc (x *TopicSub) GetUpdatedAt() int64 {\n\tif x != nil {\n\t\treturn x.UpdatedAt\n\t}\n\treturn 0\n}\n\nfunc (x *TopicSub) GetDeletedAt() int64 {\n\tif x != nil {\n\t\treturn x.DeletedAt\n\t}\n\treturn 0\n}\n\nfunc (x *TopicSub) GetOnline() bool {\n\tif x != nil {\n\t\treturn x.Online\n\t}\n\treturn false\n}\n\nfunc (x *TopicSub) GetAcs() *AccessMode {\n\tif x != nil {\n\t\treturn x.Acs\n\t}\n\treturn nil\n}\n\nfunc (x *TopicSub) GetReadId() int32 {\n\tif x != nil {\n\t\treturn x.ReadId\n\t}\n\treturn 0\n}\n\nfunc (x *TopicSub) GetRecvId() int32 {\n\tif x != nil {\n\t\treturn x.RecvId\n\t}\n\treturn 0\n}\n\nfunc (x *TopicSub) GetPublic() []byte {\n\tif x != nil {\n\t\treturn x.Public\n\t}\n\treturn nil\n}\n\nfunc (x *TopicSub) GetTrusted() []byte {\n\tif x != nil {\n\t\treturn x.Trusted\n\t}\n\treturn nil\n}\n\nfunc (x *TopicSub) GetPrivate() []byte {\n\tif x != nil {\n\t\treturn x.Private\n\t}\n\treturn nil\n}\n\nfunc (x *TopicSub) GetUserId() string {\n\tif x != nil {\n\t\treturn x.UserId\n\t}\n\treturn \"\"\n}\n\nfunc (x *TopicSub) GetTopic() string {\n\tif x != nil {\n\t\treturn x.Topic\n\t}\n\treturn \"\"\n}\n\nfunc (x *TopicSub) GetTouchedAt() int64 {\n\tif x != nil {\n\t\treturn x.TouchedAt\n\t}\n\treturn 0\n}\n\nfunc (x *TopicSub) GetSeqId() int32 {\n\tif x != nil {\n\t\treturn x.SeqId\n\t}\n\treturn 0\n}\n\nfunc (x *TopicSub) GetDelId() int32 {\n\tif x != nil {\n\t\treturn x.DelId\n\t}\n\treturn 0\n}\n\nfunc (x *TopicSub) GetLastSeenTime() int64 {\n\tif x != nil {\n\t\treturn x.LastSeenTime\n\t}\n\treturn 0\n}\n\nfunc (x *TopicSub) GetLastSeenUserAgent() string {\n\tif x != nil {\n\t\treturn x.LastSeenUserAgent\n\t}\n\treturn \"\"\n}\n\ntype DelValues struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tDelId  int32       `protobuf:\"varint,1,opt,name=del_id,json=delId,proto3\" json:\"del_id,omitempty\"`\n\tDelSeq []*SeqRange `protobuf:\"bytes,2,rep,name=del_seq,json=delSeq,proto3\" json:\"del_seq,omitempty\"`\n}\n\nfunc (x *DelValues) Reset() {\n\t*x = DelValues{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[25]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *DelValues) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*DelValues) ProtoMessage() {}\n\nfunc (x *DelValues) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[25]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use DelValues.ProtoReflect.Descriptor instead.\nfunc (*DelValues) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{25}\n}\n\nfunc (x *DelValues) GetDelId() int32 {\n\tif x != nil {\n\t\treturn x.DelId\n\t}\n\treturn 0\n}\n\nfunc (x *DelValues) GetDelSeq() []*SeqRange {\n\tif x != nil {\n\t\treturn x.DelSeq\n\t}\n\treturn nil\n}\n\n// {ctrl} message\ntype ServerCtrl struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tId     string            `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\tTopic  string            `protobuf:\"bytes,2,opt,name=topic,proto3\" json:\"topic,omitempty\"`\n\tCode   int32             `protobuf:\"varint,3,opt,name=code,proto3\" json:\"code,omitempty\"`\n\tText   string            `protobuf:\"bytes,4,opt,name=text,proto3\" json:\"text,omitempty\"`\n\tParams map[string][]byte `protobuf:\"bytes,5,rep,name=params,proto3\" json:\"params,omitempty\" protobuf_key:\"bytes,1,opt,name=key,proto3\" protobuf_val:\"bytes,2,opt,name=value,proto3\"`\n}\n\nfunc (x *ServerCtrl) Reset() {\n\t*x = ServerCtrl{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[26]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *ServerCtrl) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ServerCtrl) ProtoMessage() {}\n\nfunc (x *ServerCtrl) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[26]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ServerCtrl.ProtoReflect.Descriptor instead.\nfunc (*ServerCtrl) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{26}\n}\n\nfunc (x *ServerCtrl) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\nfunc (x *ServerCtrl) GetTopic() string {\n\tif x != nil {\n\t\treturn x.Topic\n\t}\n\treturn \"\"\n}\n\nfunc (x *ServerCtrl) GetCode() int32 {\n\tif x != nil {\n\t\treturn x.Code\n\t}\n\treturn 0\n}\n\nfunc (x *ServerCtrl) GetText() string {\n\tif x != nil {\n\t\treturn x.Text\n\t}\n\treturn \"\"\n}\n\nfunc (x *ServerCtrl) GetParams() map[string][]byte {\n\tif x != nil {\n\t\treturn x.Params\n\t}\n\treturn nil\n}\n\n// {data} message\ntype ServerData struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tTopic string `protobuf:\"bytes,1,opt,name=topic,proto3\" json:\"topic,omitempty\"`\n\t// ID of the user who originated the message as {pub}, could be empty if sent by the system\n\tFromUserId string `protobuf:\"bytes,2,opt,name=from_user_id,json=fromUserId,proto3\" json:\"from_user_id,omitempty\"`\n\t// Timestamp when the message was sent.\n\tTimestamp int64 `protobuf:\"varint,7,opt,name=timestamp,proto3\" json:\"timestamp,omitempty\"`\n\t// Timestamp when the message was deleted or 0. Milliseconds since the epoch 01/01/1970\n\tDeletedAt int64             `protobuf:\"varint,3,opt,name=deleted_at,json=deletedAt,proto3\" json:\"deleted_at,omitempty\"`\n\tSeqId     int32             `protobuf:\"varint,4,opt,name=seq_id,json=seqId,proto3\" json:\"seq_id,omitempty\"`\n\tHead      map[string][]byte `protobuf:\"bytes,5,rep,name=head,proto3\" json:\"head,omitempty\" protobuf_key:\"bytes,1,opt,name=key,proto3\" protobuf_val:\"bytes,2,opt,name=value,proto3\"`\n\tContent   []byte            `protobuf:\"bytes,6,opt,name=content,proto3\" json:\"content,omitempty\"`\n}\n\nfunc (x *ServerData) Reset() {\n\t*x = ServerData{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[27]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *ServerData) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ServerData) ProtoMessage() {}\n\nfunc (x *ServerData) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[27]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ServerData.ProtoReflect.Descriptor instead.\nfunc (*ServerData) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{27}\n}\n\nfunc (x *ServerData) GetTopic() string {\n\tif x != nil {\n\t\treturn x.Topic\n\t}\n\treturn \"\"\n}\n\nfunc (x *ServerData) GetFromUserId() string {\n\tif x != nil {\n\t\treturn x.FromUserId\n\t}\n\treturn \"\"\n}\n\nfunc (x *ServerData) GetTimestamp() int64 {\n\tif x != nil {\n\t\treturn x.Timestamp\n\t}\n\treturn 0\n}\n\nfunc (x *ServerData) GetDeletedAt() int64 {\n\tif x != nil {\n\t\treturn x.DeletedAt\n\t}\n\treturn 0\n}\n\nfunc (x *ServerData) GetSeqId() int32 {\n\tif x != nil {\n\t\treturn x.SeqId\n\t}\n\treturn 0\n}\n\nfunc (x *ServerData) GetHead() map[string][]byte {\n\tif x != nil {\n\t\treturn x.Head\n\t}\n\treturn nil\n}\n\nfunc (x *ServerData) GetContent() []byte {\n\tif x != nil {\n\t\treturn x.Content\n\t}\n\treturn nil\n}\n\n// {pres} message\ntype ServerPres struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tTopic        string          `protobuf:\"bytes,1,opt,name=topic,proto3\" json:\"topic,omitempty\"`\n\tSrc          string          `protobuf:\"bytes,2,opt,name=src,proto3\" json:\"src,omitempty\"`\n\tWhat         ServerPres_What `protobuf:\"varint,3,opt,name=what,proto3,enum=pbx.ServerPres_What\" json:\"what,omitempty\"`\n\tUserAgent    string          `protobuf:\"bytes,4,opt,name=user_agent,json=userAgent,proto3\" json:\"user_agent,omitempty\"`\n\tSeqId        int32           `protobuf:\"varint,5,opt,name=seq_id,json=seqId,proto3\" json:\"seq_id,omitempty\"`\n\tDelId        int32           `protobuf:\"varint,6,opt,name=del_id,json=delId,proto3\" json:\"del_id,omitempty\"`\n\tDelSeq       []*SeqRange     `protobuf:\"bytes,7,rep,name=del_seq,json=delSeq,proto3\" json:\"del_seq,omitempty\"`\n\tTargetUserId string          `protobuf:\"bytes,8,opt,name=target_user_id,json=targetUserId,proto3\" json:\"target_user_id,omitempty\"`\n\tActorUserId  string          `protobuf:\"bytes,9,opt,name=actor_user_id,json=actorUserId,proto3\" json:\"actor_user_id,omitempty\"`\n\tAcs          *AccessMode     `protobuf:\"bytes,10,opt,name=acs,proto3\" json:\"acs,omitempty\"`\n}\n\nfunc (x *ServerPres) Reset() {\n\t*x = ServerPres{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[28]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *ServerPres) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ServerPres) ProtoMessage() {}\n\nfunc (x *ServerPres) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[28]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ServerPres.ProtoReflect.Descriptor instead.\nfunc (*ServerPres) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{28}\n}\n\nfunc (x *ServerPres) GetTopic() string {\n\tif x != nil {\n\t\treturn x.Topic\n\t}\n\treturn \"\"\n}\n\nfunc (x *ServerPres) GetSrc() string {\n\tif x != nil {\n\t\treturn x.Src\n\t}\n\treturn \"\"\n}\n\nfunc (x *ServerPres) GetWhat() ServerPres_What {\n\tif x != nil {\n\t\treturn x.What\n\t}\n\treturn ServerPres_X3\n}\n\nfunc (x *ServerPres) GetUserAgent() string {\n\tif x != nil {\n\t\treturn x.UserAgent\n\t}\n\treturn \"\"\n}\n\nfunc (x *ServerPres) GetSeqId() int32 {\n\tif x != nil {\n\t\treturn x.SeqId\n\t}\n\treturn 0\n}\n\nfunc (x *ServerPres) GetDelId() int32 {\n\tif x != nil {\n\t\treturn x.DelId\n\t}\n\treturn 0\n}\n\nfunc (x *ServerPres) GetDelSeq() []*SeqRange {\n\tif x != nil {\n\t\treturn x.DelSeq\n\t}\n\treturn nil\n}\n\nfunc (x *ServerPres) GetTargetUserId() string {\n\tif x != nil {\n\t\treturn x.TargetUserId\n\t}\n\treturn \"\"\n}\n\nfunc (x *ServerPres) GetActorUserId() string {\n\tif x != nil {\n\t\treturn x.ActorUserId\n\t}\n\treturn \"\"\n}\n\nfunc (x *ServerPres) GetAcs() *AccessMode {\n\tif x != nil {\n\t\treturn x.Acs\n\t}\n\treturn nil\n}\n\n// {meta} message\ntype ServerMeta struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tId    string            `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\tTopic string            `protobuf:\"bytes,2,opt,name=topic,proto3\" json:\"topic,omitempty\"`\n\tDesc  *TopicDesc        `protobuf:\"bytes,3,opt,name=desc,proto3\" json:\"desc,omitempty\"`\n\tSub   []*TopicSub       `protobuf:\"bytes,4,rep,name=sub,proto3\" json:\"sub,omitempty\"`\n\tDel   *DelValues        `protobuf:\"bytes,5,opt,name=del,proto3\" json:\"del,omitempty\"`\n\tTags  []string          `protobuf:\"bytes,6,rep,name=tags,proto3\" json:\"tags,omitempty\"`\n\tCred  []*ServerCred     `protobuf:\"bytes,7,rep,name=cred,proto3\" json:\"cred,omitempty\"`\n\tAux   map[string][]byte `protobuf:\"bytes,8,rep,name=aux,proto3\" json:\"aux,omitempty\" protobuf_key:\"bytes,1,opt,name=key,proto3\" protobuf_val:\"bytes,2,opt,name=value,proto3\"`\n}\n\nfunc (x *ServerMeta) Reset() {\n\t*x = ServerMeta{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[29]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *ServerMeta) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ServerMeta) ProtoMessage() {}\n\nfunc (x *ServerMeta) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[29]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ServerMeta.ProtoReflect.Descriptor instead.\nfunc (*ServerMeta) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{29}\n}\n\nfunc (x *ServerMeta) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\nfunc (x *ServerMeta) GetTopic() string {\n\tif x != nil {\n\t\treturn x.Topic\n\t}\n\treturn \"\"\n}\n\nfunc (x *ServerMeta) GetDesc() *TopicDesc {\n\tif x != nil {\n\t\treturn x.Desc\n\t}\n\treturn nil\n}\n\nfunc (x *ServerMeta) GetSub() []*TopicSub {\n\tif x != nil {\n\t\treturn x.Sub\n\t}\n\treturn nil\n}\n\nfunc (x *ServerMeta) GetDel() *DelValues {\n\tif x != nil {\n\t\treturn x.Del\n\t}\n\treturn nil\n}\n\nfunc (x *ServerMeta) GetTags() []string {\n\tif x != nil {\n\t\treturn x.Tags\n\t}\n\treturn nil\n}\n\nfunc (x *ServerMeta) GetCred() []*ServerCred {\n\tif x != nil {\n\t\treturn x.Cred\n\t}\n\treturn nil\n}\n\nfunc (x *ServerMeta) GetAux() map[string][]byte {\n\tif x != nil {\n\t\treturn x.Aux\n\t}\n\treturn nil\n}\n\n// {info} message: server-side copy of ClientNote with From and optional Src added.\ntype ServerInfo struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tTopic      string    `protobuf:\"bytes,1,opt,name=topic,proto3\" json:\"topic,omitempty\"`\n\tFromUserId string    `protobuf:\"bytes,2,opt,name=from_user_id,json=fromUserId,proto3\" json:\"from_user_id,omitempty\"`\n\tWhat       InfoNote  `protobuf:\"varint,3,opt,name=what,proto3,enum=pbx.InfoNote\" json:\"what,omitempty\"`\n\tSeqId      int32     `protobuf:\"varint,4,opt,name=seq_id,json=seqId,proto3\" json:\"seq_id,omitempty\"`\n\tSrc        string    `protobuf:\"bytes,5,opt,name=src,proto3\" json:\"src,omitempty\"`\n\tEvent      CallEvent `protobuf:\"varint,6,opt,name=event,proto3,enum=pbx.CallEvent\" json:\"event,omitempty\"`\n\tPayload    []byte    `protobuf:\"bytes,7,opt,name=payload,proto3\" json:\"payload,omitempty\"`\n}\n\nfunc (x *ServerInfo) Reset() {\n\t*x = ServerInfo{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[30]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *ServerInfo) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ServerInfo) ProtoMessage() {}\n\nfunc (x *ServerInfo) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[30]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ServerInfo.ProtoReflect.Descriptor instead.\nfunc (*ServerInfo) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{30}\n}\n\nfunc (x *ServerInfo) GetTopic() string {\n\tif x != nil {\n\t\treturn x.Topic\n\t}\n\treturn \"\"\n}\n\nfunc (x *ServerInfo) GetFromUserId() string {\n\tif x != nil {\n\t\treturn x.FromUserId\n\t}\n\treturn \"\"\n}\n\nfunc (x *ServerInfo) GetWhat() InfoNote {\n\tif x != nil {\n\t\treturn x.What\n\t}\n\treturn InfoNote_X1\n}\n\nfunc (x *ServerInfo) GetSeqId() int32 {\n\tif x != nil {\n\t\treturn x.SeqId\n\t}\n\treturn 0\n}\n\nfunc (x *ServerInfo) GetSrc() string {\n\tif x != nil {\n\t\treturn x.Src\n\t}\n\treturn \"\"\n}\n\nfunc (x *ServerInfo) GetEvent() CallEvent {\n\tif x != nil {\n\t\treturn x.Event\n\t}\n\treturn CallEvent_X2\n}\n\nfunc (x *ServerInfo) GetPayload() []byte {\n\tif x != nil {\n\t\treturn x.Payload\n\t}\n\treturn nil\n}\n\n// Cumulative message\ntype ServerMsg struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\t// Types that are assignable to Message:\n\t//\t*ServerMsg_Ctrl\n\t//\t*ServerMsg_Data\n\t//\t*ServerMsg_Pres\n\t//\t*ServerMsg_Meta\n\t//\t*ServerMsg_Info\n\tMessage isServerMsg_Message `protobuf_oneof:\"Message\"`\n\t// DEPRECATED. Will be removed soon.\n\t// When response is sent to Root, send internal topic name too.\n\t//\n\t// Deprecated: Do not use.\n\tTopic string `protobuf:\"bytes,6,opt,name=topic,proto3\" json:\"topic,omitempty\"`\n}\n\nfunc (x *ServerMsg) Reset() {\n\t*x = ServerMsg{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[31]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *ServerMsg) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ServerMsg) ProtoMessage() {}\n\nfunc (x *ServerMsg) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[31]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ServerMsg.ProtoReflect.Descriptor instead.\nfunc (*ServerMsg) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{31}\n}\n\nfunc (m *ServerMsg) GetMessage() isServerMsg_Message {\n\tif m != nil {\n\t\treturn m.Message\n\t}\n\treturn nil\n}\n\nfunc (x *ServerMsg) GetCtrl() *ServerCtrl {\n\tif x, ok := x.GetMessage().(*ServerMsg_Ctrl); ok {\n\t\treturn x.Ctrl\n\t}\n\treturn nil\n}\n\nfunc (x *ServerMsg) GetData() *ServerData {\n\tif x, ok := x.GetMessage().(*ServerMsg_Data); ok {\n\t\treturn x.Data\n\t}\n\treturn nil\n}\n\nfunc (x *ServerMsg) GetPres() *ServerPres {\n\tif x, ok := x.GetMessage().(*ServerMsg_Pres); ok {\n\t\treturn x.Pres\n\t}\n\treturn nil\n}\n\nfunc (x *ServerMsg) GetMeta() *ServerMeta {\n\tif x, ok := x.GetMessage().(*ServerMsg_Meta); ok {\n\t\treturn x.Meta\n\t}\n\treturn nil\n}\n\nfunc (x *ServerMsg) GetInfo() *ServerInfo {\n\tif x, ok := x.GetMessage().(*ServerMsg_Info); ok {\n\t\treturn x.Info\n\t}\n\treturn nil\n}\n\n// Deprecated: Do not use.\nfunc (x *ServerMsg) GetTopic() string {\n\tif x != nil {\n\t\treturn x.Topic\n\t}\n\treturn \"\"\n}\n\ntype isServerMsg_Message interface {\n\tisServerMsg_Message()\n}\n\ntype ServerMsg_Ctrl struct {\n\tCtrl *ServerCtrl `protobuf:\"bytes,1,opt,name=ctrl,proto3,oneof\"`\n}\n\ntype ServerMsg_Data struct {\n\tData *ServerData `protobuf:\"bytes,2,opt,name=data,proto3,oneof\"`\n}\n\ntype ServerMsg_Pres struct {\n\tPres *ServerPres `protobuf:\"bytes,3,opt,name=pres,proto3,oneof\"`\n}\n\ntype ServerMsg_Meta struct {\n\tMeta *ServerMeta `protobuf:\"bytes,4,opt,name=meta,proto3,oneof\"`\n}\n\ntype ServerMsg_Info struct {\n\tInfo *ServerInfo `protobuf:\"bytes,5,opt,name=info,proto3,oneof\"`\n}\n\nfunc (*ServerMsg_Ctrl) isServerMsg_Message() {}\n\nfunc (*ServerMsg_Data) isServerMsg_Message() {}\n\nfunc (*ServerMsg_Pres) isServerMsg_Message() {}\n\nfunc (*ServerMsg_Meta) isServerMsg_Message() {}\n\nfunc (*ServerMsg_Info) isServerMsg_Message() {}\n\ntype ServerResp struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tStatus RespCode   `protobuf:\"varint,1,opt,name=status,proto3,enum=pbx.RespCode\" json:\"status,omitempty\"`\n\tSrvmsg *ServerMsg `protobuf:\"bytes,2,opt,name=srvmsg,proto3\" json:\"srvmsg,omitempty\"`\n\tClmsg  *ClientMsg `protobuf:\"bytes,3,opt,name=clmsg,proto3\" json:\"clmsg,omitempty\"`\n}\n\nfunc (x *ServerResp) Reset() {\n\t*x = ServerResp{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[32]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *ServerResp) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ServerResp) ProtoMessage() {}\n\nfunc (x *ServerResp) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[32]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ServerResp.ProtoReflect.Descriptor instead.\nfunc (*ServerResp) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{32}\n}\n\nfunc (x *ServerResp) GetStatus() RespCode {\n\tif x != nil {\n\t\treturn x.Status\n\t}\n\treturn RespCode_CONTINUE\n}\n\nfunc (x *ServerResp) GetSrvmsg() *ServerMsg {\n\tif x != nil {\n\t\treturn x.Srvmsg\n\t}\n\treturn nil\n}\n\nfunc (x *ServerResp) GetClmsg() *ClientMsg {\n\tif x != nil {\n\t\treturn x.Clmsg\n\t}\n\treturn nil\n}\n\n// Context message\ntype Session struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tSessionId  string    `protobuf:\"bytes,1,opt,name=session_id,json=sessionId,proto3\" json:\"session_id,omitempty\"`\n\tUserId     string    `protobuf:\"bytes,2,opt,name=user_id,json=userId,proto3\" json:\"user_id,omitempty\"`\n\tAuthLevel  AuthLevel `protobuf:\"varint,3,opt,name=auth_level,json=authLevel,proto3,enum=pbx.AuthLevel\" json:\"auth_level,omitempty\"`\n\tRemoteAddr string    `protobuf:\"bytes,4,opt,name=remote_addr,json=remoteAddr,proto3\" json:\"remote_addr,omitempty\"`\n\tUserAgent  string    `protobuf:\"bytes,5,opt,name=user_agent,json=userAgent,proto3\" json:\"user_agent,omitempty\"`\n\tDeviceId   string    `protobuf:\"bytes,6,opt,name=device_id,json=deviceId,proto3\" json:\"device_id,omitempty\"`\n\tLanguage   string    `protobuf:\"bytes,7,opt,name=language,proto3\" json:\"language,omitempty\"`\n}\n\nfunc (x *Session) Reset() {\n\t*x = Session{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[33]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *Session) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*Session) ProtoMessage() {}\n\nfunc (x *Session) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[33]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use Session.ProtoReflect.Descriptor instead.\nfunc (*Session) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{33}\n}\n\nfunc (x *Session) GetSessionId() string {\n\tif x != nil {\n\t\treturn x.SessionId\n\t}\n\treturn \"\"\n}\n\nfunc (x *Session) GetUserId() string {\n\tif x != nil {\n\t\treturn x.UserId\n\t}\n\treturn \"\"\n}\n\nfunc (x *Session) GetAuthLevel() AuthLevel {\n\tif x != nil {\n\t\treturn x.AuthLevel\n\t}\n\treturn AuthLevel_NONE\n}\n\nfunc (x *Session) GetRemoteAddr() string {\n\tif x != nil {\n\t\treturn x.RemoteAddr\n\t}\n\treturn \"\"\n}\n\nfunc (x *Session) GetUserAgent() string {\n\tif x != nil {\n\t\treturn x.UserAgent\n\t}\n\treturn \"\"\n}\n\nfunc (x *Session) GetDeviceId() string {\n\tif x != nil {\n\t\treturn x.DeviceId\n\t}\n\treturn \"\"\n}\n\nfunc (x *Session) GetLanguage() string {\n\tif x != nil {\n\t\treturn x.Language\n\t}\n\treturn \"\"\n}\n\ntype ClientReq struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tMsg  *ClientMsg `protobuf:\"bytes,1,opt,name=msg,proto3\" json:\"msg,omitempty\"`\n\tSess *Session   `protobuf:\"bytes,2,opt,name=sess,proto3\" json:\"sess,omitempty\"`\n}\n\nfunc (x *ClientReq) Reset() {\n\t*x = ClientReq{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[34]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *ClientReq) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ClientReq) ProtoMessage() {}\n\nfunc (x *ClientReq) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[34]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ClientReq.ProtoReflect.Descriptor instead.\nfunc (*ClientReq) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{34}\n}\n\nfunc (x *ClientReq) GetMsg() *ClientMsg {\n\tif x != nil {\n\t\treturn x.Msg\n\t}\n\treturn nil\n}\n\nfunc (x *ClientReq) GetSess() *Session {\n\tif x != nil {\n\t\treturn x.Sess\n\t}\n\treturn nil\n}\n\ntype SearchQuery struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tUserId string `protobuf:\"bytes,1,opt,name=user_id,json=userId,proto3\" json:\"user_id,omitempty\"`\n\tQuery  string `protobuf:\"bytes,2,opt,name=query,proto3\" json:\"query,omitempty\"`\n}\n\nfunc (x *SearchQuery) Reset() {\n\t*x = SearchQuery{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[35]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *SearchQuery) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*SearchQuery) ProtoMessage() {}\n\nfunc (x *SearchQuery) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[35]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use SearchQuery.ProtoReflect.Descriptor instead.\nfunc (*SearchQuery) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{35}\n}\n\nfunc (x *SearchQuery) GetUserId() string {\n\tif x != nil {\n\t\treturn x.UserId\n\t}\n\treturn \"\"\n}\n\nfunc (x *SearchQuery) GetQuery() string {\n\tif x != nil {\n\t\treturn x.Query\n\t}\n\treturn \"\"\n}\n\ntype SearchFound struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tStatus RespCode `protobuf:\"varint,1,opt,name=status,proto3,enum=pbx.RespCode\" json:\"status,omitempty\"`\n\t// New search query If status == REPLACE, otherwise unset.\n\tQuery string `protobuf:\"bytes,2,opt,name=query,proto3\" json:\"query,omitempty\"`\n\t// Search results.\n\tResult []*TopicSub `protobuf:\"bytes,3,rep,name=result,proto3\" json:\"result,omitempty\"`\n}\n\nfunc (x *SearchFound) Reset() {\n\t*x = SearchFound{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[36]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *SearchFound) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*SearchFound) ProtoMessage() {}\n\nfunc (x *SearchFound) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[36]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use SearchFound.ProtoReflect.Descriptor instead.\nfunc (*SearchFound) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{36}\n}\n\nfunc (x *SearchFound) GetStatus() RespCode {\n\tif x != nil {\n\t\treturn x.Status\n\t}\n\treturn RespCode_CONTINUE\n}\n\nfunc (x *SearchFound) GetQuery() string {\n\tif x != nil {\n\t\treturn x.Query\n\t}\n\treturn \"\"\n}\n\nfunc (x *SearchFound) GetResult() []*TopicSub {\n\tif x != nil {\n\t\treturn x.Result\n\t}\n\treturn nil\n}\n\ntype TopicEvent struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tAction Crud       `protobuf:\"varint,1,opt,name=action,proto3,enum=pbx.Crud\" json:\"action,omitempty\"`\n\tName   string     `protobuf:\"bytes,2,opt,name=name,proto3\" json:\"name,omitempty\"`\n\tDesc   *TopicDesc `protobuf:\"bytes,3,opt,name=desc,proto3\" json:\"desc,omitempty\"`\n}\n\nfunc (x *TopicEvent) Reset() {\n\t*x = TopicEvent{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[37]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *TopicEvent) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*TopicEvent) ProtoMessage() {}\n\nfunc (x *TopicEvent) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[37]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use TopicEvent.ProtoReflect.Descriptor instead.\nfunc (*TopicEvent) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{37}\n}\n\nfunc (x *TopicEvent) GetAction() Crud {\n\tif x != nil {\n\t\treturn x.Action\n\t}\n\treturn Crud_CREATE\n}\n\nfunc (x *TopicEvent) GetName() string {\n\tif x != nil {\n\t\treturn x.Name\n\t}\n\treturn \"\"\n}\n\nfunc (x *TopicEvent) GetDesc() *TopicDesc {\n\tif x != nil {\n\t\treturn x.Desc\n\t}\n\treturn nil\n}\n\ntype AccountEvent struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tAction     Crud            `protobuf:\"varint,1,opt,name=action,proto3,enum=pbx.Crud\" json:\"action,omitempty\"`\n\tUserId     string          `protobuf:\"bytes,2,opt,name=user_id,json=userId,proto3\" json:\"user_id,omitempty\"`\n\tDefaultAcs *DefaultAcsMode `protobuf:\"bytes,3,opt,name=default_acs,json=defaultAcs,proto3\" json:\"default_acs,omitempty\"`\n\tPublic     []byte          `protobuf:\"bytes,4,opt,name=public,proto3\" json:\"public,omitempty\"`\n\t// Indexable tags for user discovery\n\tTags []string `protobuf:\"bytes,8,rep,name=tags,proto3\" json:\"tags,omitempty\"`\n}\n\nfunc (x *AccountEvent) Reset() {\n\t*x = AccountEvent{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[38]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *AccountEvent) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*AccountEvent) ProtoMessage() {}\n\nfunc (x *AccountEvent) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[38]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use AccountEvent.ProtoReflect.Descriptor instead.\nfunc (*AccountEvent) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{38}\n}\n\nfunc (x *AccountEvent) GetAction() Crud {\n\tif x != nil {\n\t\treturn x.Action\n\t}\n\treturn Crud_CREATE\n}\n\nfunc (x *AccountEvent) GetUserId() string {\n\tif x != nil {\n\t\treturn x.UserId\n\t}\n\treturn \"\"\n}\n\nfunc (x *AccountEvent) GetDefaultAcs() *DefaultAcsMode {\n\tif x != nil {\n\t\treturn x.DefaultAcs\n\t}\n\treturn nil\n}\n\nfunc (x *AccountEvent) GetPublic() []byte {\n\tif x != nil {\n\t\treturn x.Public\n\t}\n\treturn nil\n}\n\nfunc (x *AccountEvent) GetTags() []string {\n\tif x != nil {\n\t\treturn x.Tags\n\t}\n\treturn nil\n}\n\ntype SubscriptionEvent struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tAction  Crud        `protobuf:\"varint,1,opt,name=action,proto3,enum=pbx.Crud\" json:\"action,omitempty\"`\n\tTopic   string      `protobuf:\"bytes,2,opt,name=topic,proto3\" json:\"topic,omitempty\"`\n\tUserId  string      `protobuf:\"bytes,3,opt,name=user_id,json=userId,proto3\" json:\"user_id,omitempty\"`\n\tDelId   int32       `protobuf:\"varint,4,opt,name=del_id,json=delId,proto3\" json:\"del_id,omitempty\"`\n\tReadId  int32       `protobuf:\"varint,5,opt,name=read_id,json=readId,proto3\" json:\"read_id,omitempty\"`\n\tRecvId  int32       `protobuf:\"varint,6,opt,name=recv_id,json=recvId,proto3\" json:\"recv_id,omitempty\"`\n\tMode    *AccessMode `protobuf:\"bytes,7,opt,name=mode,proto3\" json:\"mode,omitempty\"`\n\tPrivate []byte      `protobuf:\"bytes,8,opt,name=private,proto3\" json:\"private,omitempty\"`\n}\n\nfunc (x *SubscriptionEvent) Reset() {\n\t*x = SubscriptionEvent{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[39]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *SubscriptionEvent) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*SubscriptionEvent) ProtoMessage() {}\n\nfunc (x *SubscriptionEvent) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[39]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use SubscriptionEvent.ProtoReflect.Descriptor instead.\nfunc (*SubscriptionEvent) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{39}\n}\n\nfunc (x *SubscriptionEvent) GetAction() Crud {\n\tif x != nil {\n\t\treturn x.Action\n\t}\n\treturn Crud_CREATE\n}\n\nfunc (x *SubscriptionEvent) GetTopic() string {\n\tif x != nil {\n\t\treturn x.Topic\n\t}\n\treturn \"\"\n}\n\nfunc (x *SubscriptionEvent) GetUserId() string {\n\tif x != nil {\n\t\treturn x.UserId\n\t}\n\treturn \"\"\n}\n\nfunc (x *SubscriptionEvent) GetDelId() int32 {\n\tif x != nil {\n\t\treturn x.DelId\n\t}\n\treturn 0\n}\n\nfunc (x *SubscriptionEvent) GetReadId() int32 {\n\tif x != nil {\n\t\treturn x.ReadId\n\t}\n\treturn 0\n}\n\nfunc (x *SubscriptionEvent) GetRecvId() int32 {\n\tif x != nil {\n\t\treturn x.RecvId\n\t}\n\treturn 0\n}\n\nfunc (x *SubscriptionEvent) GetMode() *AccessMode {\n\tif x != nil {\n\t\treturn x.Mode\n\t}\n\treturn nil\n}\n\nfunc (x *SubscriptionEvent) GetPrivate() []byte {\n\tif x != nil {\n\t\treturn x.Private\n\t}\n\treturn nil\n}\n\ntype MessageEvent struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tAction Crud        `protobuf:\"varint,1,opt,name=action,proto3,enum=pbx.Crud\" json:\"action,omitempty\"`\n\tMsg    *ServerData `protobuf:\"bytes,2,opt,name=msg,proto3\" json:\"msg,omitempty\"`\n}\n\nfunc (x *MessageEvent) Reset() {\n\t*x = MessageEvent{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[40]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *MessageEvent) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*MessageEvent) ProtoMessage() {}\n\nfunc (x *MessageEvent) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[40]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use MessageEvent.ProtoReflect.Descriptor instead.\nfunc (*MessageEvent) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{40}\n}\n\nfunc (x *MessageEvent) GetAction() Crud {\n\tif x != nil {\n\t\treturn x.Action\n\t}\n\treturn Crud_CREATE\n}\n\nfunc (x *MessageEvent) GetMsg() *ServerData {\n\tif x != nil {\n\t\treturn x.Msg\n\t}\n\treturn nil\n}\n\ntype Auth struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tScheme string `protobuf:\"bytes,1,opt,name=scheme,proto3\" json:\"scheme,omitempty\"`\n\tSecret string `protobuf:\"bytes,2,opt,name=secret,proto3\" json:\"secret,omitempty\"`\n}\n\nfunc (x *Auth) Reset() {\n\t*x = Auth{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[41]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *Auth) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*Auth) ProtoMessage() {}\n\nfunc (x *Auth) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[41]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use Auth.ProtoReflect.Descriptor instead.\nfunc (*Auth) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{41}\n}\n\nfunc (x *Auth) GetScheme() string {\n\tif x != nil {\n\t\treturn x.Scheme\n\t}\n\treturn \"\"\n}\n\nfunc (x *Auth) GetSecret() string {\n\tif x != nil {\n\t\treturn x.Secret\n\t}\n\treturn \"\"\n}\n\n// File description.\ntype FileMeta struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tName     string `protobuf:\"bytes,1,opt,name=name,proto3\" json:\"name,omitempty\"`\n\tMimeType string `protobuf:\"bytes,2,opt,name=mime_type,json=mimeType,proto3\" json:\"mime_type,omitempty\"`\n\tEtag     string `protobuf:\"bytes,3,opt,name=etag,proto3\" json:\"etag,omitempty\"`\n\tSize     int64  `protobuf:\"varint,4,opt,name=size,proto3\" json:\"size,omitempty\"`\n}\n\nfunc (x *FileMeta) Reset() {\n\t*x = FileMeta{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[42]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *FileMeta) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*FileMeta) ProtoMessage() {}\n\nfunc (x *FileMeta) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[42]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use FileMeta.ProtoReflect.Descriptor instead.\nfunc (*FileMeta) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{42}\n}\n\nfunc (x *FileMeta) GetName() string {\n\tif x != nil {\n\t\treturn x.Name\n\t}\n\treturn \"\"\n}\n\nfunc (x *FileMeta) GetMimeType() string {\n\tif x != nil {\n\t\treturn x.MimeType\n\t}\n\treturn \"\"\n}\n\nfunc (x *FileMeta) GetEtag() string {\n\tif x != nil {\n\t\treturn x.Etag\n\t}\n\treturn \"\"\n}\n\nfunc (x *FileMeta) GetSize() int64 {\n\tif x != nil {\n\t\treturn x.Size\n\t}\n\treturn 0\n}\n\n// File upload request.\ntype FileUpReq struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\t// Request ID.\n\tId string `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\t// Request authentication credentials.\n\tAuth *Auth `protobuf:\"bytes,2,opt,name=auth,proto3\" json:\"auth,omitempty\"`\n\t// The topic this upload belongs to.\n\tTopic string `protobuf:\"bytes,3,opt,name=topic,proto3\" json:\"topic,omitempty\"`\n\t// Uploaded metadata.\n\tMeta *FileMeta `protobuf:\"bytes,4,opt,name=meta,proto3\" json:\"meta,omitempty\"`\n\t// File bytes being uploaded.\n\tContent []byte `protobuf:\"bytes,5,opt,name=content,proto3\" json:\"content,omitempty\"`\n}\n\nfunc (x *FileUpReq) Reset() {\n\t*x = FileUpReq{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[43]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *FileUpReq) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*FileUpReq) ProtoMessage() {}\n\nfunc (x *FileUpReq) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[43]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use FileUpReq.ProtoReflect.Descriptor instead.\nfunc (*FileUpReq) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{43}\n}\n\nfunc (x *FileUpReq) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\nfunc (x *FileUpReq) GetAuth() *Auth {\n\tif x != nil {\n\t\treturn x.Auth\n\t}\n\treturn nil\n}\n\nfunc (x *FileUpReq) GetTopic() string {\n\tif x != nil {\n\t\treturn x.Topic\n\t}\n\treturn \"\"\n}\n\nfunc (x *FileUpReq) GetMeta() *FileMeta {\n\tif x != nil {\n\t\treturn x.Meta\n\t}\n\treturn nil\n}\n\nfunc (x *FileUpReq) GetContent() []byte {\n\tif x != nil {\n\t\treturn x.Content\n\t}\n\treturn nil\n}\n\n// Response to file upload.\ntype FileUpResp struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\t// Response ID.\n\tId string `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\t// Response code.\n\tCode int32 `protobuf:\"varint,2,opt,name=code,proto3\" json:\"code,omitempty\"`\n\t// Response text.\n\tText string    `protobuf:\"bytes,3,opt,name=text,proto3\" json:\"text,omitempty\"`\n\tMeta *FileMeta `protobuf:\"bytes,4,opt,name=meta,proto3\" json:\"meta,omitempty\"`\n\t// New upload location.\n\tRedirUrl string `protobuf:\"bytes,5,opt,name=redir_url,json=redirUrl,proto3\" json:\"redir_url,omitempty\"`\n}\n\nfunc (x *FileUpResp) Reset() {\n\t*x = FileUpResp{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[44]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *FileUpResp) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*FileUpResp) ProtoMessage() {}\n\nfunc (x *FileUpResp) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[44]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use FileUpResp.ProtoReflect.Descriptor instead.\nfunc (*FileUpResp) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{44}\n}\n\nfunc (x *FileUpResp) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\nfunc (x *FileUpResp) GetCode() int32 {\n\tif x != nil {\n\t\treturn x.Code\n\t}\n\treturn 0\n}\n\nfunc (x *FileUpResp) GetText() string {\n\tif x != nil {\n\t\treturn x.Text\n\t}\n\treturn \"\"\n}\n\nfunc (x *FileUpResp) GetMeta() *FileMeta {\n\tif x != nil {\n\t\treturn x.Meta\n\t}\n\treturn nil\n}\n\nfunc (x *FileUpResp) GetRedirUrl() string {\n\tif x != nil {\n\t\treturn x.RedirUrl\n\t}\n\treturn \"\"\n}\n\n// File download request.\ntype FileDownReq struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\t// Request ID\n\tId string `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\t// Request authentication credentials.\n\tAuth *Auth `protobuf:\"bytes,2,opt,name=auth,proto3\" json:\"auth,omitempty\"`\n\t// File URI to download.\n\tUri string `protobuf:\"bytes,3,opt,name=uri,proto3\" json:\"uri,omitempty\"`\n\t// ETag\n\tIfModified string `protobuf:\"bytes,4,opt,name=if_modified,json=ifModified,proto3\" json:\"if_modified,omitempty\"`\n}\n\nfunc (x *FileDownReq) Reset() {\n\t*x = FileDownReq{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[45]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *FileDownReq) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*FileDownReq) ProtoMessage() {}\n\nfunc (x *FileDownReq) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[45]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use FileDownReq.ProtoReflect.Descriptor instead.\nfunc (*FileDownReq) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{45}\n}\n\nfunc (x *FileDownReq) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\nfunc (x *FileDownReq) GetAuth() *Auth {\n\tif x != nil {\n\t\treturn x.Auth\n\t}\n\treturn nil\n}\n\nfunc (x *FileDownReq) GetUri() string {\n\tif x != nil {\n\t\treturn x.Uri\n\t}\n\treturn \"\"\n}\n\nfunc (x *FileDownReq) GetIfModified() string {\n\tif x != nil {\n\t\treturn x.IfModified\n\t}\n\treturn \"\"\n}\n\n// Response to file download.\ntype FileDownResp struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\t// Response ID.\n\tId string `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\t// Response code.\n\tCode int32 `protobuf:\"varint,2,opt,name=code,proto3\" json:\"code,omitempty\"`\n\t// Response text.\n\tText string    `protobuf:\"bytes,3,opt,name=text,proto3\" json:\"text,omitempty\"`\n\tMeta *FileMeta `protobuf:\"bytes,4,opt,name=meta,proto3\" json:\"meta,omitempty\"`\n\t// File location.\n\tRedirUrl string `protobuf:\"bytes,5,opt,name=redir_url,json=redirUrl,proto3\" json:\"redir_url,omitempty\"`\n\t// File bytes.\n\tContent []byte `protobuf:\"bytes,6,opt,name=content,proto3\" json:\"content,omitempty\"`\n}\n\nfunc (x *FileDownResp) Reset() {\n\t*x = FileDownResp{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_model_proto_msgTypes[46]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *FileDownResp) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*FileDownResp) ProtoMessage() {}\n\nfunc (x *FileDownResp) ProtoReflect() protoreflect.Message {\n\tmi := &file_model_proto_msgTypes[46]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use FileDownResp.ProtoReflect.Descriptor instead.\nfunc (*FileDownResp) Descriptor() ([]byte, []int) {\n\treturn file_model_proto_rawDescGZIP(), []int{46}\n}\n\nfunc (x *FileDownResp) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\nfunc (x *FileDownResp) GetCode() int32 {\n\tif x != nil {\n\t\treturn x.Code\n\t}\n\treturn 0\n}\n\nfunc (x *FileDownResp) GetText() string {\n\tif x != nil {\n\t\treturn x.Text\n\t}\n\treturn \"\"\n}\n\nfunc (x *FileDownResp) GetMeta() *FileMeta {\n\tif x != nil {\n\t\treturn x.Meta\n\t}\n\treturn nil\n}\n\nfunc (x *FileDownResp) GetRedirUrl() string {\n\tif x != nil {\n\t\treturn x.RedirUrl\n\t}\n\treturn \"\"\n}\n\nfunc (x *FileDownResp) GetContent() []byte {\n\tif x != nil {\n\t\treturn x.Content\n\t}\n\treturn nil\n}\n\nvar File_model_proto protoreflect.FileDescriptor\n\nvar file_model_proto_rawDesc = []byte{\n\t0x0a, 0x0b, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x03, 0x70,\n\t0x62, 0x78, 0x22, 0x08, 0x0a, 0x06, 0x55, 0x6e, 0x75, 0x73, 0x65, 0x64, 0x22, 0x38, 0x0a, 0x0e,\n\t0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x41, 0x63, 0x73, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x12,\n\t0x0a, 0x04, 0x61, 0x75, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x61, 0x75,\n\t0x74, 0x68, 0x12, 0x12, 0x0a, 0x04, 0x61, 0x6e, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,\n\t0x52, 0x04, 0x61, 0x6e, 0x6f, 0x6e, 0x22, 0x36, 0x0a, 0x0a, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73,\n\t0x4d, 0x6f, 0x64, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x77, 0x61, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01,\n\t0x28, 0x09, 0x52, 0x04, 0x77, 0x61, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x69, 0x76, 0x65,\n\t0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x67, 0x69, 0x76, 0x65, 0x6e, 0x22, 0x35,\n\t0x0a, 0x06, 0x53, 0x65, 0x74, 0x53, 0x75, 0x62, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72,\n\t0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49,\n\t0x64, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,\n\t0x04, 0x6d, 0x6f, 0x64, 0x65, 0x22, 0xc6, 0x01, 0x0a, 0x0a, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74,\n\t0x43, 0x72, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x18, 0x01,\n\t0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x14, 0x0a, 0x05,\n\t0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c,\n\t0x75, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x18, 0x03,\n\t0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x33,\n\t0x0a, 0x06, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b,\n\t0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x43, 0x72, 0x65, 0x64, 0x2e,\n\t0x50, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x70, 0x61, 0x72,\n\t0x61, 0x6d, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x45, 0x6e, 0x74,\n\t0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,\n\t0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20,\n\t0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x8b,\n\t0x01, 0x0a, 0x07, 0x53, 0x65, 0x74, 0x44, 0x65, 0x73, 0x63, 0x12, 0x34, 0x0a, 0x0b, 0x64, 0x65,\n\t0x66, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x61, 0x63, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,\n\t0x13, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x41, 0x63, 0x73,\n\t0x4d, 0x6f, 0x64, 0x65, 0x52, 0x0a, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x41, 0x63, 0x73,\n\t0x12, 0x16, 0x0a, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c,\n\t0x52, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x69, 0x76,\n\t0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x70, 0x72, 0x69, 0x76, 0x61,\n\t0x74, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x74, 0x72, 0x75, 0x73, 0x74, 0x65, 0x64, 0x18, 0x04, 0x20,\n\t0x01, 0x28, 0x0c, 0x52, 0x07, 0x74, 0x72, 0x75, 0x73, 0x74, 0x65, 0x64, 0x22, 0x2c, 0x0a, 0x08,\n\t0x53, 0x65, 0x71, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6c, 0x6f, 0x77, 0x18,\n\t0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x6c, 0x6f, 0x77, 0x12, 0x0e, 0x0a, 0x02, 0x68, 0x69,\n\t0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x02, 0x68, 0x69, 0x22, 0xd4, 0x01, 0x0a, 0x07, 0x47,\n\t0x65, 0x74, 0x4f, 0x70, 0x74, 0x73, 0x12, 0x2a, 0x0a, 0x11, 0x69, 0x66, 0x5f, 0x6d, 0x6f, 0x64,\n\t0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, 0x73, 0x69, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,\n\t0x03, 0x52, 0x0f, 0x69, 0x66, 0x4d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x53, 0x69, 0x6e,\n\t0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,\n\t0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x18,\n\t0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x12, 0x19, 0x0a, 0x08,\n\t0x73, 0x69, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07,\n\t0x73, 0x69, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x62, 0x65, 0x66, 0x6f, 0x72,\n\t0x65, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x62, 0x65, 0x66, 0x6f,\n\t0x72, 0x65, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x06, 0x20,\n\t0x01, 0x28, 0x05, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x25, 0x0a, 0x06, 0x72, 0x61,\n\t0x6e, 0x67, 0x65, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x70, 0x62, 0x78,\n\t0x2e, 0x53, 0x65, 0x71, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x06, 0x72, 0x61, 0x6e, 0x67, 0x65,\n\t0x73, 0x22, 0x82, 0x01, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x51, 0x75, 0x65, 0x72, 0x79, 0x12, 0x12,\n\t0x0a, 0x04, 0x77, 0x68, 0x61, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x77, 0x68,\n\t0x61, 0x74, 0x12, 0x20, 0x0a, 0x04, 0x64, 0x65, 0x73, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b,\n\t0x32, 0x0c, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x70, 0x74, 0x73, 0x52, 0x04,\n\t0x64, 0x65, 0x73, 0x63, 0x12, 0x1e, 0x0a, 0x03, 0x73, 0x75, 0x62, 0x18, 0x03, 0x20, 0x01, 0x28,\n\t0x0b, 0x32, 0x0c, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x70, 0x74, 0x73, 0x52,\n\t0x03, 0x73, 0x75, 0x62, 0x12, 0x20, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x01,\n\t0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x70, 0x74, 0x73,\n\t0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x22, 0xe6, 0x01, 0x0a, 0x08, 0x53, 0x65, 0x74, 0x51, 0x75,\n\t0x65, 0x72, 0x79, 0x12, 0x20, 0x0a, 0x04, 0x64, 0x65, 0x73, 0x63, 0x18, 0x01, 0x20, 0x01, 0x28,\n\t0x0b, 0x32, 0x0c, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x53, 0x65, 0x74, 0x44, 0x65, 0x73, 0x63, 0x52,\n\t0x04, 0x64, 0x65, 0x73, 0x63, 0x12, 0x1d, 0x0a, 0x03, 0x73, 0x75, 0x62, 0x18, 0x02, 0x20, 0x01,\n\t0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x53, 0x65, 0x74, 0x53, 0x75, 0x62, 0x52,\n\t0x03, 0x73, 0x75, 0x62, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x61, 0x67, 0x73, 0x18, 0x03, 0x20, 0x03,\n\t0x28, 0x09, 0x52, 0x04, 0x74, 0x61, 0x67, 0x73, 0x12, 0x23, 0x0a, 0x04, 0x63, 0x72, 0x65, 0x64,\n\t0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x6c, 0x69,\n\t0x65, 0x6e, 0x74, 0x43, 0x72, 0x65, 0x64, 0x52, 0x04, 0x63, 0x72, 0x65, 0x64, 0x12, 0x28, 0x0a,\n\t0x03, 0x61, 0x75, 0x78, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x62, 0x78,\n\t0x2e, 0x53, 0x65, 0x74, 0x51, 0x75, 0x65, 0x72, 0x79, 0x2e, 0x41, 0x75, 0x78, 0x45, 0x6e, 0x74,\n\t0x72, 0x79, 0x52, 0x03, 0x61, 0x75, 0x78, 0x1a, 0x36, 0x0a, 0x08, 0x41, 0x75, 0x78, 0x45, 0x6e,\n\t0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,\n\t0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02,\n\t0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22,\n\t0xb8, 0x01, 0x0a, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x48, 0x69, 0x12, 0x0e, 0x0a, 0x02,\n\t0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a,\n\t0x75, 0x73, 0x65, 0x72, 0x5f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,\n\t0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x76,\n\t0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x76, 0x65, 0x72, 0x12, 0x1b, 0x0a,\n\t0x09, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,\n\t0x52, 0x08, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6c, 0x61,\n\t0x6e, 0x67, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6c, 0x61, 0x6e, 0x67, 0x12, 0x1a,\n\t0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09,\n\t0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x12, 0x1e, 0x0a, 0x0a, 0x62, 0x61,\n\t0x63, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a,\n\t0x62, 0x61, 0x63, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x6e, 0x64, 0x22, 0xee, 0x02, 0x0a, 0x09, 0x43,\n\t0x6c, 0x69, 0x65, 0x6e, 0x74, 0x41, 0x63, 0x63, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01,\n\t0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72,\n\t0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49,\n\t0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28,\n\t0x09, 0x52, 0x06, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x63,\n\t0x72, 0x65, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x73, 0x65, 0x63, 0x72, 0x65,\n\t0x74, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08,\n\t0x52, 0x05, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x61, 0x67, 0x73, 0x18,\n\t0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x74, 0x61, 0x67, 0x73, 0x12, 0x20, 0x0a, 0x04, 0x64,\n\t0x65, 0x73, 0x63, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x70, 0x62, 0x78, 0x2e,\n\t0x53, 0x65, 0x74, 0x44, 0x65, 0x73, 0x63, 0x52, 0x04, 0x64, 0x65, 0x73, 0x63, 0x12, 0x23, 0x0a,\n\t0x04, 0x63, 0x72, 0x65, 0x64, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x62,\n\t0x78, 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x43, 0x72, 0x65, 0x64, 0x52, 0x04, 0x63, 0x72,\n\t0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28,\n\t0x0c, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74,\n\t0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x2d,\n\t0x0a, 0x0a, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x0b, 0x20, 0x01,\n\t0x28, 0x0e, 0x32, 0x0e, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x4c, 0x65, 0x76,\n\t0x65, 0x6c, 0x52, 0x09, 0x61, 0x75, 0x74, 0x68, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1d, 0x0a,\n\t0x0a, 0x74, 0x6d, 0x70, 0x5f, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x18, 0x0c, 0x20, 0x01, 0x28,\n\t0x09, 0x52, 0x09, 0x74, 0x6d, 0x70, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x12, 0x1d, 0x0a, 0x0a,\n\t0x74, 0x6d, 0x70, 0x5f, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0c,\n\t0x52, 0x09, 0x74, 0x6d, 0x70, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x22, 0x72, 0x0a, 0x0b, 0x43,\n\t0x6c, 0x69, 0x65, 0x6e, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64,\n\t0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x63,\n\t0x68, 0x65, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x63, 0x68, 0x65,\n\t0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x03, 0x20, 0x01,\n\t0x28, 0x0c, 0x52, 0x06, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x23, 0x0a, 0x04, 0x63, 0x72,\n\t0x65, 0x64, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43,\n\t0x6c, 0x69, 0x65, 0x6e, 0x74, 0x43, 0x72, 0x65, 0x64, 0x52, 0x04, 0x63, 0x72, 0x65, 0x64, 0x22,\n\t0x89, 0x01, 0x0a, 0x09, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x75, 0x62, 0x12, 0x0e, 0x0a,\n\t0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x14, 0x0a,\n\t0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f,\n\t0x70, 0x69, 0x63, 0x12, 0x2a, 0x0a, 0x09, 0x73, 0x65, 0x74, 0x5f, 0x71, 0x75, 0x65, 0x72, 0x79,\n\t0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x53, 0x65, 0x74,\n\t0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x08, 0x73, 0x65, 0x74, 0x51, 0x75, 0x65, 0x72, 0x79, 0x12,\n\t0x2a, 0x0a, 0x09, 0x67, 0x65, 0x74, 0x5f, 0x71, 0x75, 0x65, 0x72, 0x79, 0x18, 0x04, 0x20, 0x01,\n\t0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x47, 0x65, 0x74, 0x51, 0x75, 0x65, 0x72,\n\t0x79, 0x52, 0x08, 0x67, 0x65, 0x74, 0x51, 0x75, 0x65, 0x72, 0x79, 0x22, 0x49, 0x0a, 0x0b, 0x43,\n\t0x6c, 0x69, 0x65, 0x6e, 0x74, 0x4c, 0x65, 0x61, 0x76, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64,\n\t0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f,\n\t0x70, 0x69, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63,\n\t0x12, 0x14, 0x0a, 0x05, 0x75, 0x6e, 0x73, 0x75, 0x62, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52,\n\t0x05, 0x75, 0x6e, 0x73, 0x75, 0x62, 0x22, 0xcb, 0x01, 0x0a, 0x09, 0x43, 0x6c, 0x69, 0x65, 0x6e,\n\t0x74, 0x50, 0x75, 0x62, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,\n\t0x52, 0x02, 0x69, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x18, 0x02, 0x20,\n\t0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x12, 0x17, 0x0a, 0x07, 0x6e, 0x6f,\n\t0x5f, 0x65, 0x63, 0x68, 0x6f, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x6e, 0x6f, 0x45,\n\t0x63, 0x68, 0x6f, 0x12, 0x2c, 0x0a, 0x04, 0x68, 0x65, 0x61, 0x64, 0x18, 0x04, 0x20, 0x03, 0x28,\n\t0x0b, 0x32, 0x18, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x50, 0x75,\n\t0x62, 0x2e, 0x48, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x04, 0x68, 0x65, 0x61,\n\t0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01,\n\t0x28, 0x0c, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x1a, 0x37, 0x0a, 0x09, 0x48,\n\t0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18,\n\t0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61,\n\t0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,\n\t0x3a, 0x02, 0x38, 0x01, 0x22, 0x56, 0x0a, 0x09, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x47, 0x65,\n\t0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69,\n\t0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,\n\t0x52, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x12, 0x23, 0x0a, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79,\n\t0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x47, 0x65, 0x74,\n\t0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x22, 0x56, 0x0a, 0x09,\n\t0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18,\n\t0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x70,\n\t0x69, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x12,\n\t0x23, 0x0a, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d,\n\t0x2e, 0x70, 0x62, 0x78, 0x2e, 0x53, 0x65, 0x74, 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x05, 0x71,\n\t0x75, 0x65, 0x72, 0x79, 0x22, 0x95, 0x02, 0x0a, 0x09, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x44,\n\t0x65, 0x6c, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02,\n\t0x69, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28,\n\t0x09, 0x52, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x12, 0x27, 0x0a, 0x04, 0x77, 0x68, 0x61, 0x74,\n\t0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x13, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x6c, 0x69,\n\t0x65, 0x6e, 0x74, 0x44, 0x65, 0x6c, 0x2e, 0x57, 0x68, 0x61, 0x74, 0x52, 0x04, 0x77, 0x68, 0x61,\n\t0x74, 0x12, 0x26, 0x0a, 0x07, 0x64, 0x65, 0x6c, 0x5f, 0x73, 0x65, 0x71, 0x18, 0x04, 0x20, 0x03,\n\t0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x53, 0x65, 0x71, 0x52, 0x61, 0x6e, 0x67,\n\t0x65, 0x52, 0x06, 0x64, 0x65, 0x6c, 0x53, 0x65, 0x71, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65,\n\t0x72, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72,\n\t0x49, 0x64, 0x12, 0x23, 0x0a, 0x04, 0x63, 0x72, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b,\n\t0x32, 0x0f, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x43, 0x72, 0x65,\n\t0x64, 0x52, 0x04, 0x63, 0x72, 0x65, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x61, 0x72, 0x64, 0x18,\n\t0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x68, 0x61, 0x72, 0x64, 0x22, 0x3f, 0x0a, 0x04, 0x57,\n\t0x68, 0x61, 0x74, 0x12, 0x06, 0x0a, 0x02, 0x58, 0x30, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4d,\n\t0x53, 0x47, 0x10, 0x01, 0x12, 0x09, 0x0a, 0x05, 0x54, 0x4f, 0x50, 0x49, 0x43, 0x10, 0x02, 0x12,\n\t0x07, 0x0a, 0x03, 0x53, 0x55, 0x42, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x55, 0x53, 0x45, 0x52,\n\t0x10, 0x04, 0x12, 0x08, 0x0a, 0x04, 0x43, 0x52, 0x45, 0x44, 0x10, 0x05, 0x22, 0xb4, 0x01, 0x0a,\n\t0x0a, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x4e, 0x6f, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x74,\n\t0x6f, 0x70, 0x69, 0x63, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x70, 0x69,\n\t0x63, 0x12, 0x21, 0x0a, 0x04, 0x77, 0x68, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32,\n\t0x0d, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x49, 0x6e, 0x66, 0x6f, 0x4e, 0x6f, 0x74, 0x65, 0x52, 0x04,\n\t0x77, 0x68, 0x61, 0x74, 0x12, 0x15, 0x0a, 0x06, 0x73, 0x65, 0x71, 0x5f, 0x69, 0x64, 0x18, 0x03,\n\t0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x73, 0x65, 0x71, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x75,\n\t0x6e, 0x72, 0x65, 0x61, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x75, 0x6e, 0x72,\n\t0x65, 0x61, 0x64, 0x12, 0x24, 0x0a, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01,\n\t0x28, 0x0e, 0x32, 0x0e, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x61, 0x6c, 0x6c, 0x45, 0x76, 0x65,\n\t0x6e, 0x74, 0x52, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x61, 0x79,\n\t0x6c, 0x6f, 0x61, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x70, 0x61, 0x79, 0x6c,\n\t0x6f, 0x61, 0x64, 0x22, 0x80, 0x01, 0x0a, 0x0b, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x45, 0x78,\n\t0x74, 0x72, 0x61, 0x12, 0x20, 0x0a, 0x0b, 0x61, 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e,\n\t0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x74, 0x74, 0x61, 0x63, 0x68,\n\t0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x20, 0x0a, 0x0c, 0x6f, 0x6e, 0x5f, 0x62, 0x65, 0x68, 0x61,\n\t0x6c, 0x66, 0x5f, 0x6f, 0x66, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6f, 0x6e, 0x42,\n\t0x65, 0x68, 0x61, 0x6c, 0x66, 0x4f, 0x66, 0x12, 0x2d, 0x0a, 0x0a, 0x61, 0x75, 0x74, 0x68, 0x5f,\n\t0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0e, 0x2e, 0x70, 0x62,\n\t0x78, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x09, 0x61, 0x75, 0x74,\n\t0x68, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x22, 0xb2, 0x03, 0x0a, 0x09, 0x43, 0x6c, 0x69, 0x65, 0x6e,\n\t0x74, 0x4d, 0x73, 0x67, 0x12, 0x1f, 0x0a, 0x02, 0x68, 0x69, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,\n\t0x32, 0x0d, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x48, 0x69, 0x48,\n\t0x00, 0x52, 0x02, 0x68, 0x69, 0x12, 0x22, 0x0a, 0x03, 0x61, 0x63, 0x63, 0x18, 0x02, 0x20, 0x01,\n\t0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x41,\n\t0x63, 0x63, 0x48, 0x00, 0x52, 0x03, 0x61, 0x63, 0x63, 0x12, 0x28, 0x0a, 0x05, 0x6c, 0x6f, 0x67,\n\t0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43,\n\t0x6c, 0x69, 0x65, 0x6e, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x48, 0x00, 0x52, 0x05, 0x6c, 0x6f,\n\t0x67, 0x69, 0x6e, 0x12, 0x22, 0x0a, 0x03, 0x73, 0x75, 0x62, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b,\n\t0x32, 0x0e, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x75, 0x62,\n\t0x48, 0x00, 0x52, 0x03, 0x73, 0x75, 0x62, 0x12, 0x28, 0x0a, 0x05, 0x6c, 0x65, 0x61, 0x76, 0x65,\n\t0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x6c, 0x69,\n\t0x65, 0x6e, 0x74, 0x4c, 0x65, 0x61, 0x76, 0x65, 0x48, 0x00, 0x52, 0x05, 0x6c, 0x65, 0x61, 0x76,\n\t0x65, 0x12, 0x22, 0x0a, 0x03, 0x70, 0x75, 0x62, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e,\n\t0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x50, 0x75, 0x62, 0x48, 0x00,\n\t0x52, 0x03, 0x70, 0x75, 0x62, 0x12, 0x22, 0x0a, 0x03, 0x67, 0x65, 0x74, 0x18, 0x07, 0x20, 0x01,\n\t0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x47,\n\t0x65, 0x74, 0x48, 0x00, 0x52, 0x03, 0x67, 0x65, 0x74, 0x12, 0x22, 0x0a, 0x03, 0x73, 0x65, 0x74,\n\t0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x6c, 0x69,\n\t0x65, 0x6e, 0x74, 0x53, 0x65, 0x74, 0x48, 0x00, 0x52, 0x03, 0x73, 0x65, 0x74, 0x12, 0x22, 0x0a,\n\t0x03, 0x64, 0x65, 0x6c, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x70, 0x62, 0x78,\n\t0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x44, 0x65, 0x6c, 0x48, 0x00, 0x52, 0x03, 0x64, 0x65,\n\t0x6c, 0x12, 0x25, 0x0a, 0x04, 0x6e, 0x6f, 0x74, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32,\n\t0x0f, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x4e, 0x6f, 0x74, 0x65,\n\t0x48, 0x00, 0x52, 0x04, 0x6e, 0x6f, 0x74, 0x65, 0x12, 0x26, 0x0a, 0x05, 0x65, 0x78, 0x74, 0x72,\n\t0x61, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x6c,\n\t0x69, 0x65, 0x6e, 0x74, 0x45, 0x78, 0x74, 0x72, 0x61, 0x52, 0x05, 0x65, 0x78, 0x74, 0x72, 0x61,\n\t0x42, 0x09, 0x0a, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x4e, 0x0a, 0x0a, 0x53,\n\t0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x72, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74,\n\t0x68, 0x6f, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f,\n\t0x64, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,\n\t0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x6f, 0x6e, 0x65, 0x18,\n\t0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x64, 0x6f, 0x6e, 0x65, 0x22, 0x9d, 0x04, 0x0a, 0x09,\n\t0x54, 0x6f, 0x70, 0x69, 0x63, 0x44, 0x65, 0x73, 0x63, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65,\n\t0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63,\n\t0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61,\n\t0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x75, 0x70,\n\t0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x6f, 0x75, 0x63, 0x68,\n\t0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x6f, 0x75,\n\t0x63, 0x68, 0x65, 0x64, 0x41, 0x74, 0x12, 0x2b, 0x0a, 0x06, 0x64, 0x65, 0x66, 0x61, 0x63, 0x73,\n\t0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x44, 0x65, 0x66,\n\t0x61, 0x75, 0x6c, 0x74, 0x41, 0x63, 0x73, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x06, 0x64, 0x65, 0x66,\n\t0x61, 0x63, 0x73, 0x12, 0x21, 0x0a, 0x03, 0x61, 0x63, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b,\n\t0x32, 0x0f, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4d, 0x6f, 0x64,\n\t0x65, 0x52, 0x03, 0x61, 0x63, 0x73, 0x12, 0x15, 0x0a, 0x06, 0x73, 0x65, 0x71, 0x5f, 0x69, 0x64,\n\t0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x73, 0x65, 0x71, 0x49, 0x64, 0x12, 0x17, 0x0a,\n\t0x07, 0x72, 0x65, 0x61, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06,\n\t0x72, 0x65, 0x61, 0x64, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x72, 0x65, 0x63, 0x76, 0x5f, 0x69,\n\t0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x72, 0x65, 0x63, 0x76, 0x49, 0x64, 0x12,\n\t0x15, 0x0a, 0x06, 0x64, 0x65, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, 0x52,\n\t0x05, 0x64, 0x65, 0x6c, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63,\n\t0x18, 0x0a, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x12, 0x18,\n\t0x0a, 0x07, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0c, 0x52,\n\t0x07, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74,\n\t0x65, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x19,\n\t0x0a, 0x08, 0x73, 0x74, 0x61, 0x74, 0x65, 0x5f, 0x61, 0x74, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x03,\n\t0x52, 0x07, 0x73, 0x74, 0x61, 0x74, 0x65, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x74, 0x72, 0x75,\n\t0x73, 0x74, 0x65, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x74, 0x72, 0x75, 0x73,\n\t0x74, 0x65, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x73, 0x5f, 0x63, 0x68, 0x61, 0x6e, 0x18, 0x11,\n\t0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x69, 0x73, 0x43, 0x68, 0x61, 0x6e, 0x12, 0x16, 0x0a, 0x06,\n\t0x6f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x18, 0x12, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x6f, 0x6e,\n\t0x6c, 0x69, 0x6e, 0x65, 0x12, 0x24, 0x0a, 0x0e, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x73, 0x65, 0x65,\n\t0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x6c, 0x61,\n\t0x73, 0x74, 0x53, 0x65, 0x65, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x2f, 0x0a, 0x14, 0x6c, 0x61,\n\t0x73, 0x74, 0x5f, 0x73, 0x65, 0x65, 0x6e, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x61, 0x67, 0x65,\n\t0x6e, 0x74, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x6c, 0x61, 0x73, 0x74, 0x53, 0x65,\n\t0x65, 0x6e, 0x55, 0x73, 0x65, 0x72, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x22, 0xd4, 0x03, 0x0a, 0x08,\n\t0x54, 0x6f, 0x70, 0x69, 0x63, 0x53, 0x75, 0x62, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61,\n\t0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x75, 0x70,\n\t0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x64, 0x65, 0x6c, 0x65, 0x74,\n\t0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x64, 0x65, 0x6c,\n\t0x65, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x6e, 0x6c, 0x69, 0x6e, 0x65,\n\t0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x6f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x12, 0x21,\n\t0x0a, 0x03, 0x61, 0x63, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x62,\n\t0x78, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x03, 0x61, 0x63,\n\t0x73, 0x12, 0x17, 0x0a, 0x07, 0x72, 0x65, 0x61, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01,\n\t0x28, 0x05, 0x52, 0x06, 0x72, 0x65, 0x61, 0x64, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x72, 0x65,\n\t0x63, 0x76, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x72, 0x65, 0x63,\n\t0x76, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x18, 0x07, 0x20,\n\t0x01, 0x28, 0x0c, 0x52, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x12, 0x18, 0x0a, 0x07, 0x74,\n\t0x72, 0x75, 0x73, 0x74, 0x65, 0x64, 0x18, 0x10, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x74, 0x72,\n\t0x75, 0x73, 0x74, 0x65, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65,\n\t0x18, 0x08, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x12,\n\t0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09,\n\t0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x70, 0x69,\n\t0x63, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x12, 0x1d,\n\t0x0a, 0x0a, 0x74, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x0b, 0x20, 0x01,\n\t0x28, 0x03, 0x52, 0x09, 0x74, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x64, 0x41, 0x74, 0x12, 0x15, 0x0a,\n\t0x06, 0x73, 0x65, 0x71, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x73,\n\t0x65, 0x71, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x64, 0x65, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x0d,\n\t0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x6c,\n\t0x61, 0x73, 0x74, 0x5f, 0x73, 0x65, 0x65, 0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x0e, 0x20,\n\t0x01, 0x28, 0x03, 0x52, 0x0c, 0x6c, 0x61, 0x73, 0x74, 0x53, 0x65, 0x65, 0x6e, 0x54, 0x69, 0x6d,\n\t0x65, 0x12, 0x2f, 0x0a, 0x14, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x73, 0x65, 0x65, 0x6e, 0x5f, 0x75,\n\t0x73, 0x65, 0x72, 0x5f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52,\n\t0x11, 0x6c, 0x61, 0x73, 0x74, 0x53, 0x65, 0x65, 0x6e, 0x55, 0x73, 0x65, 0x72, 0x41, 0x67, 0x65,\n\t0x6e, 0x74, 0x22, 0x4a, 0x0a, 0x09, 0x44, 0x65, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12,\n\t0x15, 0x0a, 0x06, 0x64, 0x65, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52,\n\t0x05, 0x64, 0x65, 0x6c, 0x49, 0x64, 0x12, 0x26, 0x0a, 0x07, 0x64, 0x65, 0x6c, 0x5f, 0x73, 0x65,\n\t0x71, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x53, 0x65,\n\t0x71, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x06, 0x64, 0x65, 0x6c, 0x53, 0x65, 0x71, 0x22, 0xca,\n\t0x01, 0x0a, 0x0a, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x74, 0x72, 0x6c, 0x12, 0x0e, 0x0a,\n\t0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x14, 0x0a,\n\t0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f,\n\t0x70, 0x69, 0x63, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28,\n\t0x05, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74, 0x18,\n\t0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x65, 0x78, 0x74, 0x12, 0x33, 0x0a, 0x06, 0x70,\n\t0x61, 0x72, 0x61, 0x6d, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x62,\n\t0x78, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x74, 0x72, 0x6c, 0x2e, 0x50, 0x61, 0x72,\n\t0x61, 0x6d, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x73,\n\t0x1a, 0x39, 0x0a, 0x0b, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12,\n\t0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65,\n\t0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c,\n\t0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x9a, 0x02, 0x0a, 0x0a,\n\t0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x44, 0x61, 0x74, 0x61, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f,\n\t0x70, 0x69, 0x63, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63,\n\t0x12, 0x20, 0x0a, 0x0c, 0x66, 0x72, 0x6f, 0x6d, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,\n\t0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x66, 0x72, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72,\n\t0x49, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18,\n\t0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70,\n\t0x12, 0x1d, 0x0a, 0x0a, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03,\n\t0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12,\n\t0x15, 0x0a, 0x06, 0x73, 0x65, 0x71, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52,\n\t0x05, 0x73, 0x65, 0x71, 0x49, 0x64, 0x12, 0x2d, 0x0a, 0x04, 0x68, 0x65, 0x61, 0x64, 0x18, 0x05,\n\t0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65,\n\t0x72, 0x44, 0x61, 0x74, 0x61, 0x2e, 0x48, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52,\n\t0x04, 0x68, 0x65, 0x61, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74,\n\t0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x1a,\n\t0x37, 0x0a, 0x09, 0x48, 0x65, 0x61, 0x64, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03,\n\t0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14,\n\t0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76,\n\t0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xc9, 0x03, 0x0a, 0x0a, 0x53, 0x65, 0x72,\n\t0x76, 0x65, 0x72, 0x50, 0x72, 0x65, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63,\n\t0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x12, 0x10, 0x0a,\n\t0x03, 0x73, 0x72, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x73, 0x72, 0x63, 0x12,\n\t0x28, 0x0a, 0x04, 0x77, 0x68, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x14, 0x2e,\n\t0x70, 0x62, 0x78, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x50, 0x72, 0x65, 0x73, 0x2e, 0x57,\n\t0x68, 0x61, 0x74, 0x52, 0x04, 0x77, 0x68, 0x61, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65,\n\t0x72, 0x5f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75,\n\t0x73, 0x65, 0x72, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x15, 0x0a, 0x06, 0x73, 0x65, 0x71, 0x5f,\n\t0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x73, 0x65, 0x71, 0x49, 0x64, 0x12,\n\t0x15, 0x0a, 0x06, 0x64, 0x65, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52,\n\t0x05, 0x64, 0x65, 0x6c, 0x49, 0x64, 0x12, 0x26, 0x0a, 0x07, 0x64, 0x65, 0x6c, 0x5f, 0x73, 0x65,\n\t0x71, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x53, 0x65,\n\t0x71, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x06, 0x64, 0x65, 0x6c, 0x53, 0x65, 0x71, 0x12, 0x24,\n\t0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,\n\t0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73,\n\t0x65, 0x72, 0x49, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73,\n\t0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x63, 0x74,\n\t0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x03, 0x61, 0x63, 0x73, 0x18,\n\t0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x41, 0x63, 0x63, 0x65,\n\t0x73, 0x73, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x03, 0x61, 0x63, 0x73, 0x22, 0x86, 0x01, 0x0a, 0x04,\n\t0x57, 0x68, 0x61, 0x74, 0x12, 0x06, 0x0a, 0x02, 0x58, 0x33, 0x10, 0x00, 0x12, 0x06, 0x0a, 0x02,\n\t0x4f, 0x4e, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x46, 0x46, 0x10, 0x02, 0x12, 0x06, 0x0a,\n\t0x02, 0x55, 0x41, 0x10, 0x03, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x50, 0x44, 0x10, 0x04, 0x12, 0x08,\n\t0x0a, 0x04, 0x47, 0x4f, 0x4e, 0x45, 0x10, 0x05, 0x12, 0x07, 0x0a, 0x03, 0x41, 0x43, 0x53, 0x10,\n\t0x06, 0x12, 0x08, 0x0a, 0x04, 0x54, 0x45, 0x52, 0x4d, 0x10, 0x07, 0x12, 0x07, 0x0a, 0x03, 0x4d,\n\t0x53, 0x47, 0x10, 0x08, 0x12, 0x08, 0x0a, 0x04, 0x52, 0x45, 0x41, 0x44, 0x10, 0x09, 0x12, 0x08,\n\t0x0a, 0x04, 0x52, 0x45, 0x43, 0x56, 0x10, 0x0a, 0x12, 0x07, 0x0a, 0x03, 0x44, 0x45, 0x4c, 0x10,\n\t0x0b, 0x12, 0x08, 0x0a, 0x04, 0x54, 0x41, 0x47, 0x53, 0x10, 0x0c, 0x12, 0x07, 0x0a, 0x03, 0x41,\n\t0x55, 0x58, 0x10, 0x0d, 0x22, 0xb6, 0x02, 0x0a, 0x0a, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4d,\n\t0x65, 0x74, 0x61, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,\n\t0x02, 0x69, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x18, 0x02, 0x20, 0x01,\n\t0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x12, 0x22, 0x0a, 0x04, 0x64, 0x65, 0x73,\n\t0x63, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x54, 0x6f,\n\t0x70, 0x69, 0x63, 0x44, 0x65, 0x73, 0x63, 0x52, 0x04, 0x64, 0x65, 0x73, 0x63, 0x12, 0x1f, 0x0a,\n\t0x03, 0x73, 0x75, 0x62, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x70, 0x62, 0x78,\n\t0x2e, 0x54, 0x6f, 0x70, 0x69, 0x63, 0x53, 0x75, 0x62, 0x52, 0x03, 0x73, 0x75, 0x62, 0x12, 0x20,\n\t0x0a, 0x03, 0x64, 0x65, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x70, 0x62,\n\t0x78, 0x2e, 0x44, 0x65, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x52, 0x03, 0x64, 0x65, 0x6c,\n\t0x12, 0x12, 0x0a, 0x04, 0x74, 0x61, 0x67, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04,\n\t0x74, 0x61, 0x67, 0x73, 0x12, 0x23, 0x0a, 0x04, 0x63, 0x72, 0x65, 0x64, 0x18, 0x07, 0x20, 0x03,\n\t0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43,\n\t0x72, 0x65, 0x64, 0x52, 0x04, 0x63, 0x72, 0x65, 0x64, 0x12, 0x2a, 0x0a, 0x03, 0x61, 0x75, 0x78,\n\t0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x53, 0x65, 0x72,\n\t0x76, 0x65, 0x72, 0x4d, 0x65, 0x74, 0x61, 0x2e, 0x41, 0x75, 0x78, 0x45, 0x6e, 0x74, 0x72, 0x79,\n\t0x52, 0x03, 0x61, 0x75, 0x78, 0x1a, 0x36, 0x0a, 0x08, 0x41, 0x75, 0x78, 0x45, 0x6e, 0x74, 0x72,\n\t0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,\n\t0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01,\n\t0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xd0, 0x01,\n\t0x0a, 0x0a, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x14, 0x0a, 0x05,\n\t0x74, 0x6f, 0x70, 0x69, 0x63, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x70,\n\t0x69, 0x63, 0x12, 0x20, 0x0a, 0x0c, 0x66, 0x72, 0x6f, 0x6d, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f,\n\t0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x66, 0x72, 0x6f, 0x6d, 0x55, 0x73,\n\t0x65, 0x72, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x04, 0x77, 0x68, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01,\n\t0x28, 0x0e, 0x32, 0x0d, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x49, 0x6e, 0x66, 0x6f, 0x4e, 0x6f, 0x74,\n\t0x65, 0x52, 0x04, 0x77, 0x68, 0x61, 0x74, 0x12, 0x15, 0x0a, 0x06, 0x73, 0x65, 0x71, 0x5f, 0x69,\n\t0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x73, 0x65, 0x71, 0x49, 0x64, 0x12, 0x10,\n\t0x0a, 0x03, 0x73, 0x72, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x73, 0x72, 0x63,\n\t0x12, 0x24, 0x0a, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0e, 0x32,\n\t0x0e, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x61, 0x6c, 0x6c, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52,\n\t0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61,\n\t0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64,\n\t0x22, 0xf3, 0x01, 0x0a, 0x09, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4d, 0x73, 0x67, 0x12, 0x25,\n\t0x0a, 0x04, 0x63, 0x74, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70,\n\t0x62, 0x78, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x74, 0x72, 0x6c, 0x48, 0x00, 0x52,\n\t0x04, 0x63, 0x74, 0x72, 0x6c, 0x12, 0x25, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20,\n\t0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72,\n\t0x44, 0x61, 0x74, 0x61, 0x48, 0x00, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x25, 0x0a, 0x04,\n\t0x70, 0x72, 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x62, 0x78,\n\t0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x50, 0x72, 0x65, 0x73, 0x48, 0x00, 0x52, 0x04, 0x70,\n\t0x72, 0x65, 0x73, 0x12, 0x25, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28,\n\t0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4d, 0x65,\n\t0x74, 0x61, 0x48, 0x00, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x25, 0x0a, 0x04, 0x69, 0x6e,\n\t0x66, 0x6f, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x53,\n\t0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x48, 0x00, 0x52, 0x04, 0x69, 0x6e, 0x66,\n\t0x6f, 0x12, 0x18, 0x0a, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09,\n\t0x42, 0x02, 0x18, 0x01, 0x52, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x42, 0x09, 0x0a, 0x07, 0x4d,\n\t0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x81, 0x01, 0x0a, 0x0a, 0x53, 0x65, 0x72, 0x76, 0x65,\n\t0x72, 0x52, 0x65, 0x73, 0x70, 0x12, 0x25, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18,\n\t0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0d, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x52, 0x65, 0x73, 0x70,\n\t0x43, 0x6f, 0x64, 0x65, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x26, 0x0a, 0x06,\n\t0x73, 0x72, 0x76, 0x6d, 0x73, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x70,\n\t0x62, 0x78, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4d, 0x73, 0x67, 0x52, 0x06, 0x73, 0x72,\n\t0x76, 0x6d, 0x73, 0x67, 0x12, 0x24, 0x0a, 0x05, 0x63, 0x6c, 0x6d, 0x73, 0x67, 0x18, 0x03, 0x20,\n\t0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74,\n\t0x4d, 0x73, 0x67, 0x52, 0x05, 0x63, 0x6c, 0x6d, 0x73, 0x67, 0x22, 0xe9, 0x01, 0x0a, 0x07, 0x53,\n\t0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f,\n\t0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73,\n\t0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,\n\t0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x2d,\n\t0x0a, 0x0a, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01,\n\t0x28, 0x0e, 0x32, 0x0e, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x4c, 0x65, 0x76,\n\t0x65, 0x6c, 0x52, 0x09, 0x61, 0x75, 0x74, 0x68, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1f, 0x0a,\n\t0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x18, 0x04, 0x20, 0x01,\n\t0x28, 0x09, 0x52, 0x0a, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x12, 0x1d,\n\t0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01,\n\t0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x1b, 0x0a,\n\t0x09, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09,\n\t0x52, 0x08, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x6c, 0x61,\n\t0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6c, 0x61,\n\t0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x22, 0x4f, 0x0a, 0x09, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74,\n\t0x52, 0x65, 0x71, 0x12, 0x20, 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,\n\t0x32, 0x0e, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x4d, 0x73, 0x67,\n\t0x52, 0x03, 0x6d, 0x73, 0x67, 0x12, 0x20, 0x0a, 0x04, 0x73, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20,\n\t0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f,\n\t0x6e, 0x52, 0x04, 0x73, 0x65, 0x73, 0x73, 0x22, 0x3c, 0x0a, 0x0b, 0x53, 0x65, 0x61, 0x72, 0x63,\n\t0x68, 0x51, 0x75, 0x65, 0x72, 0x79, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69,\n\t0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12,\n\t0x14, 0x0a, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,\n\t0x71, 0x75, 0x65, 0x72, 0x79, 0x22, 0x71, 0x0a, 0x0b, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x46,\n\t0x6f, 0x75, 0x6e, 0x64, 0x12, 0x25, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01,\n\t0x20, 0x01, 0x28, 0x0e, 0x32, 0x0d, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x52, 0x65, 0x73, 0x70, 0x43,\n\t0x6f, 0x64, 0x65, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x71,\n\t0x75, 0x65, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x71, 0x75, 0x65, 0x72,\n\t0x79, 0x12, 0x25, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x03, 0x20, 0x03, 0x28,\n\t0x0b, 0x32, 0x0d, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x54, 0x6f, 0x70, 0x69, 0x63, 0x53, 0x75, 0x62,\n\t0x52, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x22, 0x67, 0x0a, 0x0a, 0x54, 0x6f, 0x70, 0x69,\n\t0x63, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x21, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e,\n\t0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x09, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x72, 0x75,\n\t0x64, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d,\n\t0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x22, 0x0a,\n\t0x04, 0x64, 0x65, 0x73, 0x63, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x70, 0x62,\n\t0x78, 0x2e, 0x54, 0x6f, 0x70, 0x69, 0x63, 0x44, 0x65, 0x73, 0x63, 0x52, 0x04, 0x64, 0x65, 0x73,\n\t0x63, 0x22, 0xac, 0x01, 0x0a, 0x0c, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x45, 0x76, 0x65,\n\t0x6e, 0x74, 0x12, 0x21, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01,\n\t0x28, 0x0e, 0x32, 0x09, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x72, 0x75, 0x64, 0x52, 0x06, 0x61,\n\t0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,\n\t0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x34,\n\t0x0a, 0x0b, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x61, 0x63, 0x73, 0x18, 0x03, 0x20,\n\t0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c,\n\t0x74, 0x41, 0x63, 0x73, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x0a, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c,\n\t0x74, 0x41, 0x63, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x18, 0x04,\n\t0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x12, 0x12, 0x0a, 0x04,\n\t0x74, 0x61, 0x67, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x74, 0x61, 0x67, 0x73,\n\t0x22, 0xed, 0x01, 0x0a, 0x11, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f,\n\t0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x21, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e,\n\t0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x09, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x72, 0x75,\n\t0x64, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x70,\n\t0x69, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x12,\n\t0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09,\n\t0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x64, 0x65, 0x6c, 0x5f,\n\t0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x49, 0x64, 0x12,\n\t0x17, 0x0a, 0x07, 0x72, 0x65, 0x61, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05,\n\t0x52, 0x06, 0x72, 0x65, 0x61, 0x64, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x72, 0x65, 0x63, 0x76,\n\t0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x72, 0x65, 0x63, 0x76, 0x49,\n\t0x64, 0x12, 0x23, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32,\n\t0x0f, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4d, 0x6f, 0x64, 0x65,\n\t0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74,\n\t0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65,\n\t0x22, 0x54, 0x0a, 0x0c, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x45, 0x76, 0x65, 0x6e, 0x74,\n\t0x12, 0x21, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e,\n\t0x32, 0x09, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x72, 0x75, 0x64, 0x52, 0x06, 0x61, 0x63, 0x74,\n\t0x69, 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b,\n\t0x32, 0x0f, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x44, 0x61, 0x74,\n\t0x61, 0x52, 0x03, 0x6d, 0x73, 0x67, 0x22, 0x36, 0x0a, 0x04, 0x41, 0x75, 0x74, 0x68, 0x12, 0x16,\n\t0x0a, 0x06, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06,\n\t0x73, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74,\n\t0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x22, 0x63,\n\t0x0a, 0x08, 0x46, 0x69, 0x6c, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61,\n\t0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1b,\n\t0x0a, 0x09, 0x6d, 0x69, 0x6d, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,\n\t0x09, 0x52, 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x65,\n\t0x74, 0x61, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x65, 0x74, 0x61, 0x67, 0x12,\n\t0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73,\n\t0x69, 0x7a, 0x65, 0x22, 0x8d, 0x01, 0x0a, 0x09, 0x46, 0x69, 0x6c, 0x65, 0x55, 0x70, 0x52, 0x65,\n\t0x71, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69,\n\t0x64, 0x12, 0x1d, 0x0a, 0x04, 0x61, 0x75, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,\n\t0x09, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x04, 0x61, 0x75, 0x74, 0x68,\n\t0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,\n\t0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x12, 0x21, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x04,\n\t0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x4d,\n\t0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e,\n\t0x74, 0x65, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74,\n\t0x65, 0x6e, 0x74, 0x22, 0x84, 0x01, 0x0a, 0x0a, 0x46, 0x69, 0x6c, 0x65, 0x55, 0x70, 0x52, 0x65,\n\t0x73, 0x70, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02,\n\t0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05,\n\t0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74, 0x18, 0x03,\n\t0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x65, 0x78, 0x74, 0x12, 0x21, 0x0a, 0x04, 0x6d, 0x65,\n\t0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x46,\n\t0x69, 0x6c, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x1b, 0x0a,\n\t0x09, 0x72, 0x65, 0x64, 0x69, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09,\n\t0x52, 0x08, 0x72, 0x65, 0x64, 0x69, 0x72, 0x55, 0x72, 0x6c, 0x22, 0x6f, 0x0a, 0x0b, 0x46, 0x69,\n\t0x6c, 0x65, 0x44, 0x6f, 0x77, 0x6e, 0x52, 0x65, 0x71, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18,\n\t0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x04, 0x61, 0x75, 0x74,\n\t0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x09, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x41, 0x75,\n\t0x74, 0x68, 0x52, 0x04, 0x61, 0x75, 0x74, 0x68, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x69, 0x18,\n\t0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x69, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x66,\n\t0x5f, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52,\n\t0x0a, 0x69, 0x66, 0x4d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x22, 0xa0, 0x01, 0x0a, 0x0c,\n\t0x46, 0x69, 0x6c, 0x65, 0x44, 0x6f, 0x77, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x12, 0x0e, 0x0a, 0x02,\n\t0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04,\n\t0x63, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65,\n\t0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,\n\t0x74, 0x65, 0x78, 0x74, 0x12, 0x21, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x04, 0x20, 0x01,\n\t0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x4d, 0x65, 0x74,\n\t0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65, 0x64, 0x69, 0x72,\n\t0x5f, 0x75, 0x72, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x64, 0x69,\n\t0x72, 0x55, 0x72, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18,\n\t0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x2a, 0x33,\n\t0x0a, 0x09, 0x41, 0x75, 0x74, 0x68, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x08, 0x0a, 0x04, 0x4e,\n\t0x4f, 0x4e, 0x45, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x41, 0x4e, 0x4f, 0x4e, 0x10, 0x0a, 0x12,\n\t0x08, 0x0a, 0x04, 0x41, 0x55, 0x54, 0x48, 0x10, 0x14, 0x12, 0x08, 0x0a, 0x04, 0x52, 0x4f, 0x4f,\n\t0x54, 0x10, 0x1e, 0x2a, 0x38, 0x0a, 0x08, 0x49, 0x6e, 0x66, 0x6f, 0x4e, 0x6f, 0x74, 0x65, 0x12,\n\t0x06, 0x0a, 0x02, 0x58, 0x31, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x52, 0x45, 0x41, 0x44, 0x10,\n\t0x01, 0x12, 0x08, 0x0a, 0x04, 0x52, 0x45, 0x43, 0x56, 0x10, 0x02, 0x12, 0x06, 0x0a, 0x02, 0x4b,\n\t0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x43, 0x41, 0x4c, 0x4c, 0x10, 0x04, 0x2a, 0x6f, 0x0a,\n\t0x09, 0x43, 0x61, 0x6c, 0x6c, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x06, 0x0a, 0x02, 0x58, 0x32,\n\t0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x43, 0x43, 0x45, 0x50, 0x54, 0x10, 0x01, 0x12, 0x0a,\n\t0x0a, 0x06, 0x41, 0x4e, 0x53, 0x57, 0x45, 0x52, 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x48, 0x41,\n\t0x4e, 0x47, 0x5f, 0x55, 0x50, 0x10, 0x03, 0x12, 0x11, 0x0a, 0x0d, 0x49, 0x43, 0x45, 0x5f, 0x43,\n\t0x41, 0x4e, 0x44, 0x49, 0x44, 0x41, 0x54, 0x45, 0x10, 0x04, 0x12, 0x0a, 0x0a, 0x06, 0x49, 0x4e,\n\t0x56, 0x49, 0x54, 0x45, 0x10, 0x05, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x46, 0x46, 0x45, 0x52, 0x10,\n\t0x06, 0x12, 0x0b, 0x0a, 0x07, 0x52, 0x49, 0x4e, 0x47, 0x49, 0x4e, 0x47, 0x10, 0x07, 0x2a, 0x3c,\n\t0x0a, 0x08, 0x52, 0x65, 0x73, 0x70, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x0c, 0x0a, 0x08, 0x43, 0x4f,\n\t0x4e, 0x54, 0x49, 0x4e, 0x55, 0x45, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x52, 0x4f, 0x50,\n\t0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x52, 0x45, 0x53, 0x50, 0x4f, 0x4e, 0x44, 0x10, 0x02, 0x12,\n\t0x0b, 0x0a, 0x07, 0x52, 0x45, 0x50, 0x4c, 0x41, 0x43, 0x45, 0x10, 0x03, 0x2a, 0x2a, 0x0a, 0x04,\n\t0x43, 0x72, 0x75, 0x64, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x10, 0x00,\n\t0x12, 0x0a, 0x0a, 0x06, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06,\n\t0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x10, 0x02, 0x32, 0xaf, 0x01, 0x0a, 0x04, 0x4e, 0x6f, 0x64,\n\t0x65, 0x12, 0x33, 0x0a, 0x0b, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x4c, 0x6f, 0x6f, 0x70,\n\t0x12, 0x0e, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x4d, 0x73, 0x67,\n\t0x1a, 0x0e, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4d, 0x73, 0x67,\n\t0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x12, 0x37, 0x0a, 0x10, 0x4c, 0x61, 0x72, 0x67, 0x65, 0x46,\n\t0x69, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x12, 0x0e, 0x2e, 0x70, 0x62, 0x78,\n\t0x2e, 0x46, 0x69, 0x6c, 0x65, 0x55, 0x70, 0x52, 0x65, 0x71, 0x1a, 0x0f, 0x2e, 0x70, 0x62, 0x78,\n\t0x2e, 0x46, 0x69, 0x6c, 0x65, 0x55, 0x70, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x28, 0x01, 0x12,\n\t0x39, 0x0a, 0x0e, 0x4c, 0x61, 0x72, 0x67, 0x65, 0x46, 0x69, 0x6c, 0x65, 0x53, 0x65, 0x72, 0x76,\n\t0x65, 0x12, 0x10, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x44, 0x6f, 0x77, 0x6e,\n\t0x52, 0x65, 0x71, 0x1a, 0x11, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x44, 0x6f,\n\t0x77, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x22, 0x00, 0x30, 0x01, 0x32, 0x9f, 0x02, 0x0a, 0x06, 0x50,\n\t0x6c, 0x75, 0x67, 0x69, 0x6e, 0x12, 0x2d, 0x0a, 0x08, 0x46, 0x69, 0x72, 0x65, 0x48, 0x6f, 0x73,\n\t0x65, 0x12, 0x0e, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x65,\n\t0x71, 0x1a, 0x0f, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x65,\n\t0x73, 0x70, 0x22, 0x00, 0x12, 0x2c, 0x0a, 0x04, 0x46, 0x69, 0x6e, 0x64, 0x12, 0x10, 0x2e, 0x70,\n\t0x62, 0x78, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x51, 0x75, 0x65, 0x72, 0x79, 0x1a, 0x10,\n\t0x2e, 0x70, 0x62, 0x78, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x46, 0x6f, 0x75, 0x6e, 0x64,\n\t0x22, 0x00, 0x12, 0x2b, 0x0a, 0x07, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x11, 0x2e,\n\t0x70, 0x62, 0x78, 0x2e, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74,\n\t0x1a, 0x0b, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x55, 0x6e, 0x75, 0x73, 0x65, 0x64, 0x22, 0x00, 0x12,\n\t0x27, 0x0a, 0x05, 0x54, 0x6f, 0x70, 0x69, 0x63, 0x12, 0x0f, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x54,\n\t0x6f, 0x70, 0x69, 0x63, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x1a, 0x0b, 0x2e, 0x70, 0x62, 0x78, 0x2e,\n\t0x55, 0x6e, 0x75, 0x73, 0x65, 0x64, 0x22, 0x00, 0x12, 0x35, 0x0a, 0x0c, 0x53, 0x75, 0x62, 0x73,\n\t0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x53,\n\t0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x76, 0x65, 0x6e, 0x74,\n\t0x1a, 0x0b, 0x2e, 0x70, 0x62, 0x78, 0x2e, 0x55, 0x6e, 0x75, 0x73, 0x65, 0x64, 0x22, 0x00, 0x12,\n\t0x2b, 0x0a, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x11, 0x2e, 0x70, 0x62, 0x78,\n\t0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x1a, 0x0b, 0x2e,\n\t0x70, 0x62, 0x78, 0x2e, 0x55, 0x6e, 0x75, 0x73, 0x65, 0x64, 0x22, 0x00, 0x42, 0x1c, 0x5a, 0x1a,\n\t0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x74, 0x69, 0x6e, 0x6f, 0x64,\n\t0x65, 0x2f, 0x63, 0x68, 0x61, 0x74, 0x2f, 0x70, 0x62, 0x78, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74,\n\t0x6f, 0x33,\n}\n\nvar (\n\tfile_model_proto_rawDescOnce sync.Once\n\tfile_model_proto_rawDescData = file_model_proto_rawDesc\n)\n\nfunc file_model_proto_rawDescGZIP() []byte {\n\tfile_model_proto_rawDescOnce.Do(func() {\n\t\tfile_model_proto_rawDescData = protoimpl.X.CompressGZIP(file_model_proto_rawDescData)\n\t})\n\treturn file_model_proto_rawDescData\n}\n\nvar file_model_proto_enumTypes = make([]protoimpl.EnumInfo, 7)\nvar file_model_proto_msgTypes = make([]protoimpl.MessageInfo, 53)\nvar file_model_proto_goTypes = []interface{}{\n\t(AuthLevel)(0),            // 0: pbx.AuthLevel\n\t(InfoNote)(0),             // 1: pbx.InfoNote\n\t(CallEvent)(0),            // 2: pbx.CallEvent\n\t(RespCode)(0),             // 3: pbx.RespCode\n\t(Crud)(0),                 // 4: pbx.Crud\n\t(ClientDel_What)(0),       // 5: pbx.ClientDel.What\n\t(ServerPres_What)(0),      // 6: pbx.ServerPres.What\n\t(*Unused)(nil),            // 7: pbx.Unused\n\t(*DefaultAcsMode)(nil),    // 8: pbx.DefaultAcsMode\n\t(*AccessMode)(nil),        // 9: pbx.AccessMode\n\t(*SetSub)(nil),            // 10: pbx.SetSub\n\t(*ClientCred)(nil),        // 11: pbx.ClientCred\n\t(*SetDesc)(nil),           // 12: pbx.SetDesc\n\t(*SeqRange)(nil),          // 13: pbx.SeqRange\n\t(*GetOpts)(nil),           // 14: pbx.GetOpts\n\t(*GetQuery)(nil),          // 15: pbx.GetQuery\n\t(*SetQuery)(nil),          // 16: pbx.SetQuery\n\t(*ClientHi)(nil),          // 17: pbx.ClientHi\n\t(*ClientAcc)(nil),         // 18: pbx.ClientAcc\n\t(*ClientLogin)(nil),       // 19: pbx.ClientLogin\n\t(*ClientSub)(nil),         // 20: pbx.ClientSub\n\t(*ClientLeave)(nil),       // 21: pbx.ClientLeave\n\t(*ClientPub)(nil),         // 22: pbx.ClientPub\n\t(*ClientGet)(nil),         // 23: pbx.ClientGet\n\t(*ClientSet)(nil),         // 24: pbx.ClientSet\n\t(*ClientDel)(nil),         // 25: pbx.ClientDel\n\t(*ClientNote)(nil),        // 26: pbx.ClientNote\n\t(*ClientExtra)(nil),       // 27: pbx.ClientExtra\n\t(*ClientMsg)(nil),         // 28: pbx.ClientMsg\n\t(*ServerCred)(nil),        // 29: pbx.ServerCred\n\t(*TopicDesc)(nil),         // 30: pbx.TopicDesc\n\t(*TopicSub)(nil),          // 31: pbx.TopicSub\n\t(*DelValues)(nil),         // 32: pbx.DelValues\n\t(*ServerCtrl)(nil),        // 33: pbx.ServerCtrl\n\t(*ServerData)(nil),        // 34: pbx.ServerData\n\t(*ServerPres)(nil),        // 35: pbx.ServerPres\n\t(*ServerMeta)(nil),        // 36: pbx.ServerMeta\n\t(*ServerInfo)(nil),        // 37: pbx.ServerInfo\n\t(*ServerMsg)(nil),         // 38: pbx.ServerMsg\n\t(*ServerResp)(nil),        // 39: pbx.ServerResp\n\t(*Session)(nil),           // 40: pbx.Session\n\t(*ClientReq)(nil),         // 41: pbx.ClientReq\n\t(*SearchQuery)(nil),       // 42: pbx.SearchQuery\n\t(*SearchFound)(nil),       // 43: pbx.SearchFound\n\t(*TopicEvent)(nil),        // 44: pbx.TopicEvent\n\t(*AccountEvent)(nil),      // 45: pbx.AccountEvent\n\t(*SubscriptionEvent)(nil), // 46: pbx.SubscriptionEvent\n\t(*MessageEvent)(nil),      // 47: pbx.MessageEvent\n\t(*Auth)(nil),              // 48: pbx.Auth\n\t(*FileMeta)(nil),          // 49: pbx.FileMeta\n\t(*FileUpReq)(nil),         // 50: pbx.FileUpReq\n\t(*FileUpResp)(nil),        // 51: pbx.FileUpResp\n\t(*FileDownReq)(nil),       // 52: pbx.FileDownReq\n\t(*FileDownResp)(nil),      // 53: pbx.FileDownResp\n\tnil,                       // 54: pbx.ClientCred.ParamsEntry\n\tnil,                       // 55: pbx.SetQuery.AuxEntry\n\tnil,                       // 56: pbx.ClientPub.HeadEntry\n\tnil,                       // 57: pbx.ServerCtrl.ParamsEntry\n\tnil,                       // 58: pbx.ServerData.HeadEntry\n\tnil,                       // 59: pbx.ServerMeta.AuxEntry\n}\nvar file_model_proto_depIdxs = []int32{\n\t54, // 0: pbx.ClientCred.params:type_name -> pbx.ClientCred.ParamsEntry\n\t8,  // 1: pbx.SetDesc.default_acs:type_name -> pbx.DefaultAcsMode\n\t13, // 2: pbx.GetOpts.ranges:type_name -> pbx.SeqRange\n\t14, // 3: pbx.GetQuery.desc:type_name -> pbx.GetOpts\n\t14, // 4: pbx.GetQuery.sub:type_name -> pbx.GetOpts\n\t14, // 5: pbx.GetQuery.data:type_name -> pbx.GetOpts\n\t12, // 6: pbx.SetQuery.desc:type_name -> pbx.SetDesc\n\t10, // 7: pbx.SetQuery.sub:type_name -> pbx.SetSub\n\t11, // 8: pbx.SetQuery.cred:type_name -> pbx.ClientCred\n\t55, // 9: pbx.SetQuery.aux:type_name -> pbx.SetQuery.AuxEntry\n\t12, // 10: pbx.ClientAcc.desc:type_name -> pbx.SetDesc\n\t11, // 11: pbx.ClientAcc.cred:type_name -> pbx.ClientCred\n\t0,  // 12: pbx.ClientAcc.auth_level:type_name -> pbx.AuthLevel\n\t11, // 13: pbx.ClientLogin.cred:type_name -> pbx.ClientCred\n\t16, // 14: pbx.ClientSub.set_query:type_name -> pbx.SetQuery\n\t15, // 15: pbx.ClientSub.get_query:type_name -> pbx.GetQuery\n\t56, // 16: pbx.ClientPub.head:type_name -> pbx.ClientPub.HeadEntry\n\t15, // 17: pbx.ClientGet.query:type_name -> pbx.GetQuery\n\t16, // 18: pbx.ClientSet.query:type_name -> pbx.SetQuery\n\t5,  // 19: pbx.ClientDel.what:type_name -> pbx.ClientDel.What\n\t13, // 20: pbx.ClientDel.del_seq:type_name -> pbx.SeqRange\n\t11, // 21: pbx.ClientDel.cred:type_name -> pbx.ClientCred\n\t1,  // 22: pbx.ClientNote.what:type_name -> pbx.InfoNote\n\t2,  // 23: pbx.ClientNote.event:type_name -> pbx.CallEvent\n\t0,  // 24: pbx.ClientExtra.auth_level:type_name -> pbx.AuthLevel\n\t17, // 25: pbx.ClientMsg.hi:type_name -> pbx.ClientHi\n\t18, // 26: pbx.ClientMsg.acc:type_name -> pbx.ClientAcc\n\t19, // 27: pbx.ClientMsg.login:type_name -> pbx.ClientLogin\n\t20, // 28: pbx.ClientMsg.sub:type_name -> pbx.ClientSub\n\t21, // 29: pbx.ClientMsg.leave:type_name -> pbx.ClientLeave\n\t22, // 30: pbx.ClientMsg.pub:type_name -> pbx.ClientPub\n\t23, // 31: pbx.ClientMsg.get:type_name -> pbx.ClientGet\n\t24, // 32: pbx.ClientMsg.set:type_name -> pbx.ClientSet\n\t25, // 33: pbx.ClientMsg.del:type_name -> pbx.ClientDel\n\t26, // 34: pbx.ClientMsg.note:type_name -> pbx.ClientNote\n\t27, // 35: pbx.ClientMsg.extra:type_name -> pbx.ClientExtra\n\t8,  // 36: pbx.TopicDesc.defacs:type_name -> pbx.DefaultAcsMode\n\t9,  // 37: pbx.TopicDesc.acs:type_name -> pbx.AccessMode\n\t9,  // 38: pbx.TopicSub.acs:type_name -> pbx.AccessMode\n\t13, // 39: pbx.DelValues.del_seq:type_name -> pbx.SeqRange\n\t57, // 40: pbx.ServerCtrl.params:type_name -> pbx.ServerCtrl.ParamsEntry\n\t58, // 41: pbx.ServerData.head:type_name -> pbx.ServerData.HeadEntry\n\t6,  // 42: pbx.ServerPres.what:type_name -> pbx.ServerPres.What\n\t13, // 43: pbx.ServerPres.del_seq:type_name -> pbx.SeqRange\n\t9,  // 44: pbx.ServerPres.acs:type_name -> pbx.AccessMode\n\t30, // 45: pbx.ServerMeta.desc:type_name -> pbx.TopicDesc\n\t31, // 46: pbx.ServerMeta.sub:type_name -> pbx.TopicSub\n\t32, // 47: pbx.ServerMeta.del:type_name -> pbx.DelValues\n\t29, // 48: pbx.ServerMeta.cred:type_name -> pbx.ServerCred\n\t59, // 49: pbx.ServerMeta.aux:type_name -> pbx.ServerMeta.AuxEntry\n\t1,  // 50: pbx.ServerInfo.what:type_name -> pbx.InfoNote\n\t2,  // 51: pbx.ServerInfo.event:type_name -> pbx.CallEvent\n\t33, // 52: pbx.ServerMsg.ctrl:type_name -> pbx.ServerCtrl\n\t34, // 53: pbx.ServerMsg.data:type_name -> pbx.ServerData\n\t35, // 54: pbx.ServerMsg.pres:type_name -> pbx.ServerPres\n\t36, // 55: pbx.ServerMsg.meta:type_name -> pbx.ServerMeta\n\t37, // 56: pbx.ServerMsg.info:type_name -> pbx.ServerInfo\n\t3,  // 57: pbx.ServerResp.status:type_name -> pbx.RespCode\n\t38, // 58: pbx.ServerResp.srvmsg:type_name -> pbx.ServerMsg\n\t28, // 59: pbx.ServerResp.clmsg:type_name -> pbx.ClientMsg\n\t0,  // 60: pbx.Session.auth_level:type_name -> pbx.AuthLevel\n\t28, // 61: pbx.ClientReq.msg:type_name -> pbx.ClientMsg\n\t40, // 62: pbx.ClientReq.sess:type_name -> pbx.Session\n\t3,  // 63: pbx.SearchFound.status:type_name -> pbx.RespCode\n\t31, // 64: pbx.SearchFound.result:type_name -> pbx.TopicSub\n\t4,  // 65: pbx.TopicEvent.action:type_name -> pbx.Crud\n\t30, // 66: pbx.TopicEvent.desc:type_name -> pbx.TopicDesc\n\t4,  // 67: pbx.AccountEvent.action:type_name -> pbx.Crud\n\t8,  // 68: pbx.AccountEvent.default_acs:type_name -> pbx.DefaultAcsMode\n\t4,  // 69: pbx.SubscriptionEvent.action:type_name -> pbx.Crud\n\t9,  // 70: pbx.SubscriptionEvent.mode:type_name -> pbx.AccessMode\n\t4,  // 71: pbx.MessageEvent.action:type_name -> pbx.Crud\n\t34, // 72: pbx.MessageEvent.msg:type_name -> pbx.ServerData\n\t48, // 73: pbx.FileUpReq.auth:type_name -> pbx.Auth\n\t49, // 74: pbx.FileUpReq.meta:type_name -> pbx.FileMeta\n\t49, // 75: pbx.FileUpResp.meta:type_name -> pbx.FileMeta\n\t48, // 76: pbx.FileDownReq.auth:type_name -> pbx.Auth\n\t49, // 77: pbx.FileDownResp.meta:type_name -> pbx.FileMeta\n\t28, // 78: pbx.Node.MessageLoop:input_type -> pbx.ClientMsg\n\t50, // 79: pbx.Node.LargeFileReceive:input_type -> pbx.FileUpReq\n\t52, // 80: pbx.Node.LargeFileServe:input_type -> pbx.FileDownReq\n\t41, // 81: pbx.Plugin.FireHose:input_type -> pbx.ClientReq\n\t42, // 82: pbx.Plugin.Find:input_type -> pbx.SearchQuery\n\t45, // 83: pbx.Plugin.Account:input_type -> pbx.AccountEvent\n\t44, // 84: pbx.Plugin.Topic:input_type -> pbx.TopicEvent\n\t46, // 85: pbx.Plugin.Subscription:input_type -> pbx.SubscriptionEvent\n\t47, // 86: pbx.Plugin.Message:input_type -> pbx.MessageEvent\n\t38, // 87: pbx.Node.MessageLoop:output_type -> pbx.ServerMsg\n\t51, // 88: pbx.Node.LargeFileReceive:output_type -> pbx.FileUpResp\n\t53, // 89: pbx.Node.LargeFileServe:output_type -> pbx.FileDownResp\n\t39, // 90: pbx.Plugin.FireHose:output_type -> pbx.ServerResp\n\t43, // 91: pbx.Plugin.Find:output_type -> pbx.SearchFound\n\t7,  // 92: pbx.Plugin.Account:output_type -> pbx.Unused\n\t7,  // 93: pbx.Plugin.Topic:output_type -> pbx.Unused\n\t7,  // 94: pbx.Plugin.Subscription:output_type -> pbx.Unused\n\t7,  // 95: pbx.Plugin.Message:output_type -> pbx.Unused\n\t87, // [87:96] is the sub-list for method output_type\n\t78, // [78:87] is the sub-list for method input_type\n\t78, // [78:78] is the sub-list for extension type_name\n\t78, // [78:78] is the sub-list for extension extendee\n\t0,  // [0:78] is the sub-list for field type_name\n}\n\nfunc init() { file_model_proto_init() }\nfunc file_model_proto_init() {\n\tif File_model_proto != nil {\n\t\treturn\n\t}\n\tif !protoimpl.UnsafeEnabled {\n\t\tfile_model_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*Unused); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*DefaultAcsMode); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*AccessMode); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*SetSub); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*ClientCred); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*SetDesc); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*SeqRange); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*GetOpts); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*GetQuery); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*SetQuery); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*ClientHi); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*ClientAcc); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*ClientLogin); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*ClientSub); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*ClientLeave); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*ClientPub); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*ClientGet); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*ClientSet); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*ClientDel); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*ClientNote); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*ClientExtra); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*ClientMsg); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*ServerCred); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*TopicDesc); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*TopicSub); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*DelValues); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*ServerCtrl); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*ServerData); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[28].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*ServerPres); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[29].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*ServerMeta); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[30].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*ServerInfo); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[31].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*ServerMsg); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[32].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*ServerResp); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[33].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*Session); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[34].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*ClientReq); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[35].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*SearchQuery); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[36].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*SearchFound); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[37].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*TopicEvent); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[38].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*AccountEvent); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[39].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*SubscriptionEvent); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[40].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*MessageEvent); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[41].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*Auth); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[42].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*FileMeta); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[43].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*FileUpReq); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[44].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*FileUpResp); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[45].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*FileDownReq); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_model_proto_msgTypes[46].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*FileDownResp); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t}\n\tfile_model_proto_msgTypes[21].OneofWrappers = []interface{}{\n\t\t(*ClientMsg_Hi)(nil),\n\t\t(*ClientMsg_Acc)(nil),\n\t\t(*ClientMsg_Login)(nil),\n\t\t(*ClientMsg_Sub)(nil),\n\t\t(*ClientMsg_Leave)(nil),\n\t\t(*ClientMsg_Pub)(nil),\n\t\t(*ClientMsg_Get)(nil),\n\t\t(*ClientMsg_Set)(nil),\n\t\t(*ClientMsg_Del)(nil),\n\t\t(*ClientMsg_Note)(nil),\n\t}\n\tfile_model_proto_msgTypes[31].OneofWrappers = []interface{}{\n\t\t(*ServerMsg_Ctrl)(nil),\n\t\t(*ServerMsg_Data)(nil),\n\t\t(*ServerMsg_Pres)(nil),\n\t\t(*ServerMsg_Meta)(nil),\n\t\t(*ServerMsg_Info)(nil),\n\t}\n\ttype x struct{}\n\tout := protoimpl.TypeBuilder{\n\t\tFile: protoimpl.DescBuilder{\n\t\t\tGoPackagePath: reflect.TypeOf(x{}).PkgPath(),\n\t\t\tRawDescriptor: file_model_proto_rawDesc,\n\t\t\tNumEnums:      7,\n\t\t\tNumMessages:   53,\n\t\t\tNumExtensions: 0,\n\t\t\tNumServices:   2,\n\t\t},\n\t\tGoTypes:           file_model_proto_goTypes,\n\t\tDependencyIndexes: file_model_proto_depIdxs,\n\t\tEnumInfos:         file_model_proto_enumTypes,\n\t\tMessageInfos:      file_model_proto_msgTypes,\n\t}.Build()\n\tFile_model_proto = out.File\n\tfile_model_proto_rawDesc = nil\n\tfile_model_proto_goTypes = nil\n\tfile_model_proto_depIdxs = nil\n}\n"
  },
  {
    "path": "pbx/model.proto",
    "content": "syntax = \"proto3\";\npackage pbx;\noption go_package = \"github.com/tinode/chat/pbx\";\n\n// This is the methods that needs to be implemented by a gRPC client.\nservice Node {\n\t// Client sends a stream of ClientMsg, server responds with a stream of ServerMsg\n  rpc MessageLoop(stream ClientMsg) returns (stream ServerMsg) {}\n\n\t// Large file upload: a request with a stream of chunks.\n\trpc LargeFileReceive(stream FileUpReq) returns (FileUpResp) {}\n\n\t// Large file file download: a response with a stream of chunks.\n\trpc LargeFileServe(FileDownReq) returns (stream FileDownResp) {}\n}\n\n// Plugin interface.\nservice Plugin {\n\t// This plugin method is called by Tinode server for every message received from the clients. The\n\t// method returns a ServerResp message. ServerResp.status tells Tinode server what to do next.\n\t// See possible values for ServerResp.status in RespCode below.\n\trpc FireHose(ClientReq) returns (ServerResp) {}\n\n\t// An alteranative user and topic discovery mechanism.\n\t// A search request issued on a 'fnd' topic. This method is called to generate an alternative result set.\n\trpc Find(SearchQuery) returns (SearchFound) {}\n\n\t// The following methods are for the Tinode server to report individual events.\n\n\t// Account created, updated or deleted\n\trpc Account(AccountEvent) returns (Unused) {}\n\n\t// Topic created, updated [or deleted -- not supported yet]\n\trpc Topic(TopicEvent) returns (Unused) {}\n\n\t// Subscription created, updated or deleted\n\trpc Subscription(SubscriptionEvent) returns (Unused) {}\n\n\t// Message published or deleted\n\trpc Message(MessageEvent) returns (Unused) {}\n}\n\n// Dummy placeholder message.\nmessage Unused {\n}\n\n// Common client/server messages\n\n// Authentication level\nenum AuthLevel {\n\tNONE = 0;\n\tANON = 10;\n\tAUTH = 20;\n\tROOT = 30;\n}\n\n// Client messages\n\n// Topic default access mode\nmessage DefaultAcsMode {\n\tstring auth = 1;\n\tstring anon = 2;\n}\n\n// Actual access mode\nmessage AccessMode {\n\t// Access mode requested by the user\n\tstring want = 1;\n\t// Access mode granted to the user by the admin\n\tstring given = 2;\n}\n\n// SetSub: payload in set.sub request to update current subscription or invite another user, {sub.what} == \"sub\"\nmessage SetSub {\n\t// User affected by this request. Default (empty): current user\n\tstring user_id = 1;\n\n\t// Access mode change, either Given or Want depending on context\n\tstring mode = 2;\n}\n\n// Credentials such as email or phone number\nmessage ClientCred {\n\t// Credential type, i.e. `email` or `tel`.\n\tstring method = 1;\n\t// Value to verify, i.e. `user@example.com` or `+18003287448`\n\tstring value = 2;\n\t// Verification response\n\tstring response = 3;\n\t// Request parameters, such as preferences or country code.\n\tmap<string, bytes> params = 4;\n}\n\n// SetDesc: C2S in set.what == \"desc\" and sub.init message\nmessage SetDesc {\n\tDefaultAcsMode default_acs = 1;\n\tbytes public = 2;\n\tbytes private = 3;\n\tbytes trusted = 4;\n}\n\nmessage SeqRange {\n\tint32 low = 1;\n\tint32 hi = 2;\n}\n\nmessage GetOpts {\n\t// Timestamp in milliseconds since epoch 01/01/1970\n\tint64 if_modified_since = 1;\n\t// Limit search to this user ID\n\tstring user = 2;\n\t// Limit search results to one topic;\n\tstring topic = 3;\n\t// Load messages with seq id equal or greater than this\n\tint32 since_id = 4;\n\t// Load messages with seq id lower than this\n\tint32 before_id = 5;\n\t// Maximum number of results to return\n\tint32 limit = 6;\n\t// Load messages by id or ranges of ids\n\trepeated SeqRange ranges = 7;\n}\n\nmessage GetQuery {\n\tstring what = 1;\n\n\t// Parameters of \"desc\" request\n\tGetOpts desc = 2;\n\t// Parameters of \"sub\" request\n\tGetOpts sub = 3;\n\t// Parameters of \"data\" request\n\tGetOpts data = 4;\n}\n\nmessage SetQuery {\n\t// Topic metadata, new topic & new subscriptions only\n\tSetDesc desc = 1;\n\t// Subscription parameters\n\tSetSub sub = 2;\n\t// Indexable tags\n\trepeated string tags = 3;\n\t// Credential being updated.\n\tClientCred cred = 4;\n\t// Auxiliary data.\n\tmap<string, bytes> aux = 5;\n}\n\n// Client handshake\nmessage ClientHi {\n\tstring id = 1;\n\tstring user_agent = 2;\n\tstring ver = 3;\n\tstring device_id = 4;\n\tstring lang = 5;\n\tstring platform = 6;\n\tbool background = 7;\n}\n\n// User creation message {acc}\nmessage ClientAcc {\n\tstring id = 1;\n\t// User being created or updated\n\tstring user_id = 2;\n\t// The initial authentication scheme the account can use\n\tstring scheme = 3;\n\t// Shared secret\n\tbytes secret = 4;\n\t// Authenticate session with the newly created account\n\tbool login = 5;\n\t// Indexable tags for user discovery\n\trepeated string tags = 6;\n\t// User initialization data when creating a new user, otherwise ignored\n\tSetDesc desc = 7;\n\t// Credentials for verification.\n\trepeated ClientCred cred = 8;\n\t// Authentication token used for resetting a password.\n\tbytes token = 9;\n\t// Account state: normal (\"ok\"), suspended\n\tstring state = 10;\n\t// AuthLevel\n\tAuthLevel auth_level = 11;\n\t// Temporary auth params for one-off actions like password reset.\n\tstring tmp_scheme = 12;\n\tbytes tmp_secret = 13;\n}\n\n// Login {login} message\nmessage ClientLogin  {\n\tstring id = 1;\n\t// Authentication scheme\n\tstring scheme  = 2;\n\t// Shared secret\n\tbytes secret = 3;\n\t// Credentials for verification.\n\trepeated ClientCred cred = 4;\n}\n\n// Subscription request {sub} message\nmessage ClientSub {\n\tstring id = 1;\n\tstring topic  = 2;\n\n\t// mirrors {set}\n\tSetQuery set_query = 3;\n\n\t// mirrors {get}\n\tGetQuery get_query = 4;\n}\n\n// Unsubscribe {leave} request message\nmessage ClientLeave {\n\tstring id = 1;\n\tstring topic  = 2;\n\tbool unsub = 3;\n}\n\n// ClientPub is client's request to publish data to topic subscribers {pub}\nmessage ClientPub {\n\tstring id = 1;\n\tstring topic = 2;\n\tbool no_echo = 3;\n\tmap<string, bytes> head = 4;\n\tbytes content = 5;\n}\n\n// Query topic state {get}\nmessage ClientGet {\n\tstring id = 1;\n\tstring topic = 2;\n\tGetQuery query = 3;\n}\n\n// Update topic state {set}\nmessage ClientSet {\n\tstring id = 1;\n\tstring topic = 2;\n\tSetQuery query = 3;\n}\n\n// ClientDel delete messages or topic\nmessage ClientDel {\n\tstring id = 1;\n\tstring topic = 2;\n\t// What to delete, either \"msg\" to delete messages (default) or \"topic\" to delete the topic or \"sub\"\n\t// to delete a subscription to topic.\n\tenum What {\n\t\t// Invalid value. The name must be globally unique.\n\t\tX0 = 0;\n\t\tMSG = 1;\n\t\tTOPIC = 2;\n\t\tSUB = 3;\n\t\tUSER = 4;\n\t\tCRED = 5;\n\t}\n\tWhat what = 3;\n\t// Delete messages by id or range of ids\n\trepeated SeqRange del_seq = 4;\n\t// User ID of the subscription to delete\n\tstring user_id = 5;\n\t// Credential to delete.\n\tClientCred cred = 6;\n\t// Request to hard-delete messages for all users, if such option is available.\n\tbool hard = 7;\n}\n\nenum InfoNote {\n\t// Invalid value. The name must be globally unique.\n\tX1 = 0;\n\tREAD = 1;\n\tRECV = 2;\n\tKP = 3;\n\tCALL = 4;\n}\n\nenum CallEvent {\n\t// Invalid value. The name must be globally unique.\n\tX2 = 0;\n\tACCEPT = 1;\n\tANSWER = 2;\n\tHANG_UP = 3;\n\tICE_CANDIDATE = 4;\n\tINVITE = 5;\n\tOFFER = 6;\n\tRINGING = 7;\n}\n\n// ClientNote is a client-generated notification for topic subscribers\nmessage ClientNote {\n\tstring topic = 1;\n\t// what is being reported: \"recv\" - message received, \"read\" - message read,\n\t// \"kp\" - typing notification, \"call\" - voice/video call\n\tInfoNote what = 2;\n\t// Server-issued message ID being reported\n\tint32 seq_id = 3;\n\t// Client's count of unread messages to report back to the server. Used in push notifications on iOS.\n\tint32 unread = 4;\n\t// Call event.\n\tCallEvent event = 5;\n\t// Arbitrary json payload (used in video calls).\n\tbytes payload = 6;\n}\n\nmessage ClientExtra {\n\trepeated string attachments = 1;\n\t// Root user may send messages on behalf of other users.\n\tstring on_behalf_of = 2;\n\tAuthLevel auth_level = 3;\n}\n\nmessage ClientMsg {\n\toneof Message {\n\t\tClientHi hi = 1;\n\t\tClientAcc acc = 2;\n\t\tClientLogin login = 3;\n\t\tClientSub sub = 4;\n\t\tClientLeave leave = 5;\n\t\tClientPub pub = 6;\n\t\tClientGet get = 7;\n\t\tClientSet set = 8;\n\t\tClientDel del = 9;\n\t\tClientNote note = 10;\n\t}\n\t// Additional message parameters.\n\tClientExtra extra = 13;\n}\n\n// ************************\n// Server response messages\n\n// Credentials\nmessage ServerCred {\n\t// Credential type, i.e. `email` or `tel`.\n\tstring method = 1;\n\t// Value to verify, i.e. `user@example.com` or `+18003287448`\n\tstring value = 2;\n\t// Indicator that the credential is validated\n\tbool done = 3;\n}\n\n// Topic description, S2C in Meta message\nmessage TopicDesc {\n\tint64 created_at = 1;\n\tint64 updated_at = 2;\n\tint64 touched_at = 3;\n\tDefaultAcsMode defacs = 4;\n\tAccessMode acs = 5;\n\tint32 seq_id = 6;\n\tint32 read_id = 7;\n\tint32 recv_id = 8;\n\tint32 del_id = 9;\n\tbytes public = 10;\n\tbytes private = 11;\n\tstring state = 12;\n\tint64 state_at = 13;\n\tbytes trusted = 14;\n\tbool is_chan = 17; // 17!\n\tbool online = 18;\n\n\t// P2P only: other user's last online timestamp & user agent\n\tint64 last_seen_time = 15;\n\tstring last_seen_user_agent = 16;\n}\n\n// MsgTopicSub: topic subscription details, sent in Meta message\nmessage TopicSub {\n\tint64 updated_at = 1;\n\tint64 deleted_at = 2;\n\n\tbool online = 3;\n\n\tAccessMode acs = 4;\n\tint32 read_id = 5;\n\tint32 recv_id = 6;\n\tbytes public  = 7;\n\tbytes trusted = 16; // 16!\n\tbytes private = 8;\n\n\t// Response to non-'me' topic\n\n\t// Uid of the subscribed user\n\tstring user_id = 9;\n\n\t// 'me' topic only\n\n\t// Topic name of this subscription\n\tstring topic = 10;\n\tint64 touched_at = 11;\n\t// ID of the last {data} message in a topic\n\tint32 seq_id = 12;\n\t// Messages are deleted up to this ID\n\tint32 del_id = 13;\n\n\t// P2P topics only:\n\n\t// Other user's last online timestamp & user agent\n\tint64 last_seen_time = 14;\n\tstring last_seen_user_agent = 15;\n}\n\nmessage DelValues {\n\tint32 del_id = 1;\n\trepeated SeqRange del_seq = 2;\n}\n\n// {ctrl} message\nmessage ServerCtrl {\n\tstring id = 1;\n\tstring topic = 2;\n\tint32 code = 3;\n\tstring text = 4;\n\tmap<string, bytes> params = 5;\n}\n\n// {data} message\nmessage ServerData {\n\tstring topic = 1;\n\t// ID of the user who originated the message as {pub}, could be empty if sent by the system\n\tstring from_user_id = 2;\n\t// Timestamp when the message was sent.\n\tint64 timestamp = 7;\n\t// Timestamp when the message was deleted or 0. Milliseconds since the epoch 01/01/1970\n\tint64 deleted_at = 3;\n\tint32 seq_id = 4;\n\tmap<string, bytes> head = 5;\n\tbytes content = 6;\n}\n\n// {pres} message\nmessage ServerPres {\n\tstring topic = 1;\n\tstring src = 2;\n\tenum What {\n\t\t// Invalid value. The name must be globally unique.\n\t\tX3 = 0;\n\t\tON = 1;\n\t\tOFF = 2;\n\t\tUA = 3;\n\t\tUPD = 4;\n\t\tGONE = 5;\n\t\tACS = 6;\n\t\tTERM = 7;\n\t\tMSG = 8;\n\t\tREAD = 9;\n\t\tRECV = 10;\n\t\tDEL = 11;\n\t\tTAGS = 12;\n\t\tAUX = 13;\n\t}\n\tWhat what = 3;\n\tstring user_agent = 4;\n\tint32 seq_id = 5;\n\tint32 del_id = 6;\n\trepeated SeqRange del_seq = 7;\n\tstring target_user_id = 8;\n\tstring actor_user_id = 9;\n\tAccessMode acs = 10;\n}\n\n// {meta} message\nmessage ServerMeta {\n\tstring id = 1;\n\tstring topic = 2;\n\n\tTopicDesc desc = 3;\n\trepeated TopicSub sub = 4;\n\tDelValues del = 5;\n\trepeated string tags = 6;\n\trepeated ServerCred cred = 7;\n\tmap<string, bytes> aux = 8;\n}\n\n// {info} message: server-side copy of ClientNote with From and optional Src added.\nmessage ServerInfo {\n\tstring topic = 1;\n\tstring from_user_id = 2;\n\tInfoNote what = 3;\n\tint32 seq_id = 4;\n\tstring src = 5;\n\tCallEvent event = 6;\n\tbytes payload = 7;\n}\n\n// Cumulative message\nmessage ServerMsg {\n\toneof Message {\n\t\tServerCtrl ctrl = 1;\n\t\tServerData data = 2;\n\t\tServerPres pres = 3;\n\t\tServerMeta meta = 4;\n\t\tServerInfo info = 5;\n\t}\n\t// DEPRECATED. Will be removed soon.\n\t// When response is sent to Root, send internal topic name too.\n\tstring topic = 6 [deprecated = true];\n}\n\n// Plugin response codes\nenum RespCode {\n\t// Instruct Tinode server to continue with default processing of the client request.\n\tCONTINUE = 0;\n\t// Drop the request as if the client did not send it\n\tDROP = 1;\n\t// Send the the provided srvmsg response to the client. ServerResp must contain non-zero\n\t// srvmsg.\n\tRESPOND = 2;\n\t// Replace client's original request with the provided clmsg request then continue with\n\t// processing. ServerResp must contain non-zero clmsg.\n\tREPLACE = 3;\n}\n\nmessage ServerResp {\n\tRespCode status = 1;\n\tServerMsg srvmsg = 2;\n\tClientMsg clmsg = 3;\n}\n\n// Context message\nmessage Session {\n\tstring session_id = 1;\n\tstring user_id = 2;\n\tAuthLevel auth_level = 3;\n\tstring remote_addr = 4;\n\tstring user_agent = 5;\n\tstring device_id = 6;\n\tstring language = 7;\n}\n\nmessage ClientReq {\n\tClientMsg msg = 1;\n\tSession sess = 2;\n}\n\n// Search\n\nmessage SearchQuery {\n\tstring user_id = 1;\n\tstring query = 2;\n}\n\nmessage SearchFound {\n\tRespCode status = 1;\n\t// New search query If status == REPLACE, otherwise unset.\n\tstring query = 2;\n\t// Search results.\n\trepeated TopicSub result = 3;\n}\n\n// CRUD event messages\n\nenum Crud {\n\tCREATE = 0;\n\tUPDATE = 1;\n\tDELETE = 2;\n}\n\nmessage TopicEvent {\n\tCrud action = 1;\n\tstring name = 2;\n\tTopicDesc desc = 3;\n}\n\nmessage AccountEvent {\n\tCrud action = 1;\n\tstring user_id = 2;\n\n\tDefaultAcsMode default_acs = 3;\n\tbytes public = 4;\n\n\t// Indexable tags for user discovery\n\trepeated string tags = 8;\n}\n\nmessage SubscriptionEvent {\n\tCrud action = 1;\n\tstring topic = 2;\n\tstring user_id = 3;\n\n\tint32 del_id = 4;\n\tint32 read_id = 5;\n\tint32 recv_id = 6;\n\n\tAccessMode mode = 7;\n\n\tbytes private = 8;\n}\n\nmessage MessageEvent {\n\tCrud action = 1;\n\tServerData msg = 2;\n}\n\n// Large file handling.\n\nmessage Auth {\n\tstring scheme = 1;\n\tstring secret = 2;\n}\n\n// File description.\nmessage FileMeta {\n\tstring name = 1;\n\tstring mime_type = 2;\n\tstring etag = 3;\n\tint64 size = 4;\n}\n\n// File upload request.\nmessage FileUpReq {\n\t// Request ID.\n\tstring id = 1;\n\t// Request authentication credentials.\n\tAuth auth = 2;\n\t// The topic this upload belongs to.\n\tstring topic = 3;\n\t// Uploaded metadata.\n\tFileMeta meta = 4;\n\t// File bytes being uploaded.\n\tbytes content = 5;\n}\n\n// Response to file upload.\nmessage FileUpResp {\n\t// Response ID.\n\tstring id = 1;\n\t// Response code.\n\tint32 code = 2;\n\t// Response text.\n\tstring text = 3;\n\tFileMeta meta = 4;\n\t// New upload location.\n\tstring redir_url = 5;\n}\n\n// File download request.\nmessage FileDownReq {\n\t// Request ID\n\tstring id = 1;\n\t// Request authentication credentials.\n\tAuth auth = 2;\n\t// File URI to download.\n\tstring uri = 3;\n\t// ETag\n\tstring if_modified = 4;\n}\n\n// Response to file download.\nmessage FileDownResp {\n\t// Response ID.\n\tstring id = 1;\n\t// Response code.\n\tint32 code = 2;\n\t// Response text.\n\tstring text = 3;\n\tFileMeta meta = 4;\n\t// File location.\n\tstring redir_url = 5;\n\t// File bytes.\n\tbytes content = 6;\n}\n"
  },
  {
    "path": "pbx/model_grpc.pb.go",
    "content": "// Code generated by protoc-gen-go-grpc. DO NOT EDIT.\n// versions:\n// - protoc-gen-go-grpc v1.2.0\n// - protoc             v3.21.12\n// source: model.proto\n\npackage pbx\n\nimport (\n\tcontext \"context\"\n\tgrpc \"google.golang.org/grpc\"\n\tcodes \"google.golang.org/grpc/codes\"\n\tstatus \"google.golang.org/grpc/status\"\n)\n\n// This is a compile-time assertion to ensure that this generated file\n// is compatible with the grpc package it is being compiled against.\n// Requires gRPC-Go v1.32.0 or later.\nconst _ = grpc.SupportPackageIsVersion7\n\n// NodeClient is the client API for Node service.\n//\n// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.\ntype NodeClient interface {\n\t// Client sends a stream of ClientMsg, server responds with a stream of ServerMsg\n\tMessageLoop(ctx context.Context, opts ...grpc.CallOption) (Node_MessageLoopClient, error)\n\t// Large file upload: a request with a stream of chunks.\n\tLargeFileReceive(ctx context.Context, opts ...grpc.CallOption) (Node_LargeFileReceiveClient, error)\n\t// Large file file download: a response with a stream of chunks.\n\tLargeFileServe(ctx context.Context, in *FileDownReq, opts ...grpc.CallOption) (Node_LargeFileServeClient, error)\n}\n\ntype nodeClient struct {\n\tcc grpc.ClientConnInterface\n}\n\nfunc NewNodeClient(cc grpc.ClientConnInterface) NodeClient {\n\treturn &nodeClient{cc}\n}\n\nfunc (c *nodeClient) MessageLoop(ctx context.Context, opts ...grpc.CallOption) (Node_MessageLoopClient, error) {\n\tstream, err := c.cc.NewStream(ctx, &Node_ServiceDesc.Streams[0], \"/pbx.Node/MessageLoop\", opts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tx := &nodeMessageLoopClient{stream}\n\treturn x, nil\n}\n\ntype Node_MessageLoopClient interface {\n\tSend(*ClientMsg) error\n\tRecv() (*ServerMsg, error)\n\tgrpc.ClientStream\n}\n\ntype nodeMessageLoopClient struct {\n\tgrpc.ClientStream\n}\n\nfunc (x *nodeMessageLoopClient) Send(m *ClientMsg) error {\n\treturn x.ClientStream.SendMsg(m)\n}\n\nfunc (x *nodeMessageLoopClient) Recv() (*ServerMsg, error) {\n\tm := new(ServerMsg)\n\tif err := x.ClientStream.RecvMsg(m); err != nil {\n\t\treturn nil, err\n\t}\n\treturn m, nil\n}\n\nfunc (c *nodeClient) LargeFileReceive(ctx context.Context, opts ...grpc.CallOption) (Node_LargeFileReceiveClient, error) {\n\tstream, err := c.cc.NewStream(ctx, &Node_ServiceDesc.Streams[1], \"/pbx.Node/LargeFileReceive\", opts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tx := &nodeLargeFileReceiveClient{stream}\n\treturn x, nil\n}\n\ntype Node_LargeFileReceiveClient interface {\n\tSend(*FileUpReq) error\n\tCloseAndRecv() (*FileUpResp, error)\n\tgrpc.ClientStream\n}\n\ntype nodeLargeFileReceiveClient struct {\n\tgrpc.ClientStream\n}\n\nfunc (x *nodeLargeFileReceiveClient) Send(m *FileUpReq) error {\n\treturn x.ClientStream.SendMsg(m)\n}\n\nfunc (x *nodeLargeFileReceiveClient) CloseAndRecv() (*FileUpResp, error) {\n\tif err := x.ClientStream.CloseSend(); err != nil {\n\t\treturn nil, err\n\t}\n\tm := new(FileUpResp)\n\tif err := x.ClientStream.RecvMsg(m); err != nil {\n\t\treturn nil, err\n\t}\n\treturn m, nil\n}\n\nfunc (c *nodeClient) LargeFileServe(ctx context.Context, in *FileDownReq, opts ...grpc.CallOption) (Node_LargeFileServeClient, error) {\n\tstream, err := c.cc.NewStream(ctx, &Node_ServiceDesc.Streams[2], \"/pbx.Node/LargeFileServe\", opts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tx := &nodeLargeFileServeClient{stream}\n\tif err := x.ClientStream.SendMsg(in); err != nil {\n\t\treturn nil, err\n\t}\n\tif err := x.ClientStream.CloseSend(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn x, nil\n}\n\ntype Node_LargeFileServeClient interface {\n\tRecv() (*FileDownResp, error)\n\tgrpc.ClientStream\n}\n\ntype nodeLargeFileServeClient struct {\n\tgrpc.ClientStream\n}\n\nfunc (x *nodeLargeFileServeClient) Recv() (*FileDownResp, error) {\n\tm := new(FileDownResp)\n\tif err := x.ClientStream.RecvMsg(m); err != nil {\n\t\treturn nil, err\n\t}\n\treturn m, nil\n}\n\n// NodeServer is the server API for Node service.\n// All implementations must embed UnimplementedNodeServer\n// for forward compatibility\ntype NodeServer interface {\n\t// Client sends a stream of ClientMsg, server responds with a stream of ServerMsg\n\tMessageLoop(Node_MessageLoopServer) error\n\t// Large file upload: a request with a stream of chunks.\n\tLargeFileReceive(Node_LargeFileReceiveServer) error\n\t// Large file file download: a response with a stream of chunks.\n\tLargeFileServe(*FileDownReq, Node_LargeFileServeServer) error\n\tmustEmbedUnimplementedNodeServer()\n}\n\n// UnimplementedNodeServer must be embedded to have forward compatible implementations.\ntype UnimplementedNodeServer struct {\n}\n\nfunc (UnimplementedNodeServer) MessageLoop(Node_MessageLoopServer) error {\n\treturn status.Errorf(codes.Unimplemented, \"method MessageLoop not implemented\")\n}\nfunc (UnimplementedNodeServer) LargeFileReceive(Node_LargeFileReceiveServer) error {\n\treturn status.Errorf(codes.Unimplemented, \"method LargeFileReceive not implemented\")\n}\nfunc (UnimplementedNodeServer) LargeFileServe(*FileDownReq, Node_LargeFileServeServer) error {\n\treturn status.Errorf(codes.Unimplemented, \"method LargeFileServe not implemented\")\n}\nfunc (UnimplementedNodeServer) mustEmbedUnimplementedNodeServer() {}\n\n// UnsafeNodeServer may be embedded to opt out of forward compatibility for this service.\n// Use of this interface is not recommended, as added methods to NodeServer will\n// result in compilation errors.\ntype UnsafeNodeServer interface {\n\tmustEmbedUnimplementedNodeServer()\n}\n\nfunc RegisterNodeServer(s grpc.ServiceRegistrar, srv NodeServer) {\n\ts.RegisterService(&Node_ServiceDesc, srv)\n}\n\nfunc _Node_MessageLoop_Handler(srv interface{}, stream grpc.ServerStream) error {\n\treturn srv.(NodeServer).MessageLoop(&nodeMessageLoopServer{stream})\n}\n\ntype Node_MessageLoopServer interface {\n\tSend(*ServerMsg) error\n\tRecv() (*ClientMsg, error)\n\tgrpc.ServerStream\n}\n\ntype nodeMessageLoopServer struct {\n\tgrpc.ServerStream\n}\n\nfunc (x *nodeMessageLoopServer) Send(m *ServerMsg) error {\n\treturn x.ServerStream.SendMsg(m)\n}\n\nfunc (x *nodeMessageLoopServer) Recv() (*ClientMsg, error) {\n\tm := new(ClientMsg)\n\tif err := x.ServerStream.RecvMsg(m); err != nil {\n\t\treturn nil, err\n\t}\n\treturn m, nil\n}\n\nfunc _Node_LargeFileReceive_Handler(srv interface{}, stream grpc.ServerStream) error {\n\treturn srv.(NodeServer).LargeFileReceive(&nodeLargeFileReceiveServer{stream})\n}\n\ntype Node_LargeFileReceiveServer interface {\n\tSendAndClose(*FileUpResp) error\n\tRecv() (*FileUpReq, error)\n\tgrpc.ServerStream\n}\n\ntype nodeLargeFileReceiveServer struct {\n\tgrpc.ServerStream\n}\n\nfunc (x *nodeLargeFileReceiveServer) SendAndClose(m *FileUpResp) error {\n\treturn x.ServerStream.SendMsg(m)\n}\n\nfunc (x *nodeLargeFileReceiveServer) Recv() (*FileUpReq, error) {\n\tm := new(FileUpReq)\n\tif err := x.ServerStream.RecvMsg(m); err != nil {\n\t\treturn nil, err\n\t}\n\treturn m, nil\n}\n\nfunc _Node_LargeFileServe_Handler(srv interface{}, stream grpc.ServerStream) error {\n\tm := new(FileDownReq)\n\tif err := stream.RecvMsg(m); err != nil {\n\t\treturn err\n\t}\n\treturn srv.(NodeServer).LargeFileServe(m, &nodeLargeFileServeServer{stream})\n}\n\ntype Node_LargeFileServeServer interface {\n\tSend(*FileDownResp) error\n\tgrpc.ServerStream\n}\n\ntype nodeLargeFileServeServer struct {\n\tgrpc.ServerStream\n}\n\nfunc (x *nodeLargeFileServeServer) Send(m *FileDownResp) error {\n\treturn x.ServerStream.SendMsg(m)\n}\n\n// Node_ServiceDesc is the grpc.ServiceDesc for Node service.\n// It's only intended for direct use with grpc.RegisterService,\n// and not to be introspected or modified (even as a copy)\nvar Node_ServiceDesc = grpc.ServiceDesc{\n\tServiceName: \"pbx.Node\",\n\tHandlerType: (*NodeServer)(nil),\n\tMethods:     []grpc.MethodDesc{},\n\tStreams: []grpc.StreamDesc{\n\t\t{\n\t\t\tStreamName:    \"MessageLoop\",\n\t\t\tHandler:       _Node_MessageLoop_Handler,\n\t\t\tServerStreams: true,\n\t\t\tClientStreams: true,\n\t\t},\n\t\t{\n\t\t\tStreamName:    \"LargeFileReceive\",\n\t\t\tHandler:       _Node_LargeFileReceive_Handler,\n\t\t\tClientStreams: true,\n\t\t},\n\t\t{\n\t\t\tStreamName:    \"LargeFileServe\",\n\t\t\tHandler:       _Node_LargeFileServe_Handler,\n\t\t\tServerStreams: true,\n\t\t},\n\t},\n\tMetadata: \"model.proto\",\n}\n\n// PluginClient is the client API for Plugin service.\n//\n// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.\ntype PluginClient interface {\n\t// This plugin method is called by Tinode server for every message received from the clients. The\n\t// method returns a ServerResp message. ServerResp.status tells Tinode server what to do next.\n\t// See possible values for ServerResp.status in RespCode below.\n\tFireHose(ctx context.Context, in *ClientReq, opts ...grpc.CallOption) (*ServerResp, error)\n\t// An alteranative user and topic discovery mechanism.\n\t// A search request issued on a 'fnd' topic. This method is called to generate an alternative result set.\n\tFind(ctx context.Context, in *SearchQuery, opts ...grpc.CallOption) (*SearchFound, error)\n\t// Account created, updated or deleted\n\tAccount(ctx context.Context, in *AccountEvent, opts ...grpc.CallOption) (*Unused, error)\n\t// Topic created, updated [or deleted -- not supported yet]\n\tTopic(ctx context.Context, in *TopicEvent, opts ...grpc.CallOption) (*Unused, error)\n\t// Subscription created, updated or deleted\n\tSubscription(ctx context.Context, in *SubscriptionEvent, opts ...grpc.CallOption) (*Unused, error)\n\t// Message published or deleted\n\tMessage(ctx context.Context, in *MessageEvent, opts ...grpc.CallOption) (*Unused, error)\n}\n\ntype pluginClient struct {\n\tcc grpc.ClientConnInterface\n}\n\nfunc NewPluginClient(cc grpc.ClientConnInterface) PluginClient {\n\treturn &pluginClient{cc}\n}\n\nfunc (c *pluginClient) FireHose(ctx context.Context, in *ClientReq, opts ...grpc.CallOption) (*ServerResp, error) {\n\tout := new(ServerResp)\n\terr := c.cc.Invoke(ctx, \"/pbx.Plugin/FireHose\", in, out, opts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out, nil\n}\n\nfunc (c *pluginClient) Find(ctx context.Context, in *SearchQuery, opts ...grpc.CallOption) (*SearchFound, error) {\n\tout := new(SearchFound)\n\terr := c.cc.Invoke(ctx, \"/pbx.Plugin/Find\", in, out, opts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out, nil\n}\n\nfunc (c *pluginClient) Account(ctx context.Context, in *AccountEvent, opts ...grpc.CallOption) (*Unused, error) {\n\tout := new(Unused)\n\terr := c.cc.Invoke(ctx, \"/pbx.Plugin/Account\", in, out, opts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out, nil\n}\n\nfunc (c *pluginClient) Topic(ctx context.Context, in *TopicEvent, opts ...grpc.CallOption) (*Unused, error) {\n\tout := new(Unused)\n\terr := c.cc.Invoke(ctx, \"/pbx.Plugin/Topic\", in, out, opts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out, nil\n}\n\nfunc (c *pluginClient) Subscription(ctx context.Context, in *SubscriptionEvent, opts ...grpc.CallOption) (*Unused, error) {\n\tout := new(Unused)\n\terr := c.cc.Invoke(ctx, \"/pbx.Plugin/Subscription\", in, out, opts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out, nil\n}\n\nfunc (c *pluginClient) Message(ctx context.Context, in *MessageEvent, opts ...grpc.CallOption) (*Unused, error) {\n\tout := new(Unused)\n\terr := c.cc.Invoke(ctx, \"/pbx.Plugin/Message\", in, out, opts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out, nil\n}\n\n// PluginServer is the server API for Plugin service.\n// All implementations must embed UnimplementedPluginServer\n// for forward compatibility\ntype PluginServer interface {\n\t// This plugin method is called by Tinode server for every message received from the clients. The\n\t// method returns a ServerResp message. ServerResp.status tells Tinode server what to do next.\n\t// See possible values for ServerResp.status in RespCode below.\n\tFireHose(context.Context, *ClientReq) (*ServerResp, error)\n\t// An alteranative user and topic discovery mechanism.\n\t// A search request issued on a 'fnd' topic. This method is called to generate an alternative result set.\n\tFind(context.Context, *SearchQuery) (*SearchFound, error)\n\t// Account created, updated or deleted\n\tAccount(context.Context, *AccountEvent) (*Unused, error)\n\t// Topic created, updated [or deleted -- not supported yet]\n\tTopic(context.Context, *TopicEvent) (*Unused, error)\n\t// Subscription created, updated or deleted\n\tSubscription(context.Context, *SubscriptionEvent) (*Unused, error)\n\t// Message published or deleted\n\tMessage(context.Context, *MessageEvent) (*Unused, error)\n\tmustEmbedUnimplementedPluginServer()\n}\n\n// UnimplementedPluginServer must be embedded to have forward compatible implementations.\ntype UnimplementedPluginServer struct {\n}\n\nfunc (UnimplementedPluginServer) FireHose(context.Context, *ClientReq) (*ServerResp, error) {\n\treturn nil, status.Errorf(codes.Unimplemented, \"method FireHose not implemented\")\n}\nfunc (UnimplementedPluginServer) Find(context.Context, *SearchQuery) (*SearchFound, error) {\n\treturn nil, status.Errorf(codes.Unimplemented, \"method Find not implemented\")\n}\nfunc (UnimplementedPluginServer) Account(context.Context, *AccountEvent) (*Unused, error) {\n\treturn nil, status.Errorf(codes.Unimplemented, \"method Account not implemented\")\n}\nfunc (UnimplementedPluginServer) Topic(context.Context, *TopicEvent) (*Unused, error) {\n\treturn nil, status.Errorf(codes.Unimplemented, \"method Topic not implemented\")\n}\nfunc (UnimplementedPluginServer) Subscription(context.Context, *SubscriptionEvent) (*Unused, error) {\n\treturn nil, status.Errorf(codes.Unimplemented, \"method Subscription not implemented\")\n}\nfunc (UnimplementedPluginServer) Message(context.Context, *MessageEvent) (*Unused, error) {\n\treturn nil, status.Errorf(codes.Unimplemented, \"method Message not implemented\")\n}\nfunc (UnimplementedPluginServer) mustEmbedUnimplementedPluginServer() {}\n\n// UnsafePluginServer may be embedded to opt out of forward compatibility for this service.\n// Use of this interface is not recommended, as added methods to PluginServer will\n// result in compilation errors.\ntype UnsafePluginServer interface {\n\tmustEmbedUnimplementedPluginServer()\n}\n\nfunc RegisterPluginServer(s grpc.ServiceRegistrar, srv PluginServer) {\n\ts.RegisterService(&Plugin_ServiceDesc, srv)\n}\n\nfunc _Plugin_FireHose_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {\n\tin := new(ClientReq)\n\tif err := dec(in); err != nil {\n\t\treturn nil, err\n\t}\n\tif interceptor == nil {\n\t\treturn srv.(PluginServer).FireHose(ctx, in)\n\t}\n\tinfo := &grpc.UnaryServerInfo{\n\t\tServer:     srv,\n\t\tFullMethod: \"/pbx.Plugin/FireHose\",\n\t}\n\thandler := func(ctx context.Context, req interface{}) (interface{}, error) {\n\t\treturn srv.(PluginServer).FireHose(ctx, req.(*ClientReq))\n\t}\n\treturn interceptor(ctx, in, info, handler)\n}\n\nfunc _Plugin_Find_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {\n\tin := new(SearchQuery)\n\tif err := dec(in); err != nil {\n\t\treturn nil, err\n\t}\n\tif interceptor == nil {\n\t\treturn srv.(PluginServer).Find(ctx, in)\n\t}\n\tinfo := &grpc.UnaryServerInfo{\n\t\tServer:     srv,\n\t\tFullMethod: \"/pbx.Plugin/Find\",\n\t}\n\thandler := func(ctx context.Context, req interface{}) (interface{}, error) {\n\t\treturn srv.(PluginServer).Find(ctx, req.(*SearchQuery))\n\t}\n\treturn interceptor(ctx, in, info, handler)\n}\n\nfunc _Plugin_Account_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {\n\tin := new(AccountEvent)\n\tif err := dec(in); err != nil {\n\t\treturn nil, err\n\t}\n\tif interceptor == nil {\n\t\treturn srv.(PluginServer).Account(ctx, in)\n\t}\n\tinfo := &grpc.UnaryServerInfo{\n\t\tServer:     srv,\n\t\tFullMethod: \"/pbx.Plugin/Account\",\n\t}\n\thandler := func(ctx context.Context, req interface{}) (interface{}, error) {\n\t\treturn srv.(PluginServer).Account(ctx, req.(*AccountEvent))\n\t}\n\treturn interceptor(ctx, in, info, handler)\n}\n\nfunc _Plugin_Topic_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {\n\tin := new(TopicEvent)\n\tif err := dec(in); err != nil {\n\t\treturn nil, err\n\t}\n\tif interceptor == nil {\n\t\treturn srv.(PluginServer).Topic(ctx, in)\n\t}\n\tinfo := &grpc.UnaryServerInfo{\n\t\tServer:     srv,\n\t\tFullMethod: \"/pbx.Plugin/Topic\",\n\t}\n\thandler := func(ctx context.Context, req interface{}) (interface{}, error) {\n\t\treturn srv.(PluginServer).Topic(ctx, req.(*TopicEvent))\n\t}\n\treturn interceptor(ctx, in, info, handler)\n}\n\nfunc _Plugin_Subscription_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {\n\tin := new(SubscriptionEvent)\n\tif err := dec(in); err != nil {\n\t\treturn nil, err\n\t}\n\tif interceptor == nil {\n\t\treturn srv.(PluginServer).Subscription(ctx, in)\n\t}\n\tinfo := &grpc.UnaryServerInfo{\n\t\tServer:     srv,\n\t\tFullMethod: \"/pbx.Plugin/Subscription\",\n\t}\n\thandler := func(ctx context.Context, req interface{}) (interface{}, error) {\n\t\treturn srv.(PluginServer).Subscription(ctx, req.(*SubscriptionEvent))\n\t}\n\treturn interceptor(ctx, in, info, handler)\n}\n\nfunc _Plugin_Message_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {\n\tin := new(MessageEvent)\n\tif err := dec(in); err != nil {\n\t\treturn nil, err\n\t}\n\tif interceptor == nil {\n\t\treturn srv.(PluginServer).Message(ctx, in)\n\t}\n\tinfo := &grpc.UnaryServerInfo{\n\t\tServer:     srv,\n\t\tFullMethod: \"/pbx.Plugin/Message\",\n\t}\n\thandler := func(ctx context.Context, req interface{}) (interface{}, error) {\n\t\treturn srv.(PluginServer).Message(ctx, req.(*MessageEvent))\n\t}\n\treturn interceptor(ctx, in, info, handler)\n}\n\n// Plugin_ServiceDesc is the grpc.ServiceDesc for Plugin service.\n// It's only intended for direct use with grpc.RegisterService,\n// and not to be introspected or modified (even as a copy)\nvar Plugin_ServiceDesc = grpc.ServiceDesc{\n\tServiceName: \"pbx.Plugin\",\n\tHandlerType: (*PluginServer)(nil),\n\tMethods: []grpc.MethodDesc{\n\t\t{\n\t\t\tMethodName: \"FireHose\",\n\t\t\tHandler:    _Plugin_FireHose_Handler,\n\t\t},\n\t\t{\n\t\t\tMethodName: \"Find\",\n\t\t\tHandler:    _Plugin_Find_Handler,\n\t\t},\n\t\t{\n\t\t\tMethodName: \"Account\",\n\t\t\tHandler:    _Plugin_Account_Handler,\n\t\t},\n\t\t{\n\t\t\tMethodName: \"Topic\",\n\t\t\tHandler:    _Plugin_Topic_Handler,\n\t\t},\n\t\t{\n\t\t\tMethodName: \"Subscription\",\n\t\t\tHandler:    _Plugin_Subscription_Handler,\n\t\t},\n\t\t{\n\t\t\tMethodName: \"Message\",\n\t\t\tHandler:    _Plugin_Message_Handler,\n\t\t},\n\t},\n\tStreams:  []grpc.StreamDesc{},\n\tMetadata: \"model.proto\",\n}\n"
  },
  {
    "path": "pbx/py-generate.sh",
    "content": "#!/bin/bash\n\n# Generate python gRPC bindings for Tinode. A command line parameter v=XX will use specified python version,\n# i.e. ./generate-python.sh v=3 will use python3.\n\nfor line in $@; do\n  eval \"$line\"\ndone\n\npython=\"python${v}\"\n\n# This generates python gRPC bindings for Tinode.\n$python -m grpc_tools.protoc -I../pbx --python_out=../py_grpc/tinode_grpc --grpc_python_out=../py_grpc/tinode_grpc --pyi_out=../py_grpc/tinode_grpc ../pbx/model.proto\n# Bindings are incompatible with Python packaging system. This is a fix.\n$python py_fix.py\n"
  },
  {
    "path": "pbx/py_fix.py",
    "content": "# grpc-tools generates python 2 file which does not work with\n# python3 packaging system. This is a fix.\n\nmodel_pb2_grpc = \"../py_grpc/tinode_grpc/model_pb2_grpc.py\"\n\nwith open(model_pb2_grpc, \"r\") as fh:\n    content = fh.read().replace(\"\\nimport model_pb2 as model__pb2\",\n        \"\\nfrom . import model_pb2 as model__pb2\")\n\n    with open(model_pb2_grpc,\"w\") as fh:\n        fh.write(content)\n"
  },
  {
    "path": "py_grpc/.gitignore",
    "content": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n/build/\n/dist/\n/tinode_grpc/GIT_VERSION\n*.egg-info/"
  },
  {
    "path": "py_grpc/LICENSE",
    "content": "The code in this folder is licensed under Apache 2.0\nhttp://www.apache.org/licenses/LICENSE-2.0\n"
  },
  {
    "path": "py_grpc/README.md",
    "content": "# Generated Protocol Buffer and gRPC files for [Tinode](https://github.com/tinode)\n\nGenerated Python code for [gRPC](https://grpc.io/) client and plugins.\n\ngRPC clients must implement rpc service `Node`, plugins must implement `Plugin`.\n\nFor a sample implementation of a command line client see [tn-cli](https://github.com/tinode/chat/tree/master/tn-cli/).\nFor a partial plugin implementation see [chatbot](https://github.com/tinode/chat/tree/master/chatbot).\n\n## Installing\n\nInstall the package by executing\n```\npip install tinode_grpc\n```\n\n\n## Generating files\n\nDon't modify included files directly. If you want to make changes, you have to install protobuffers tool chain and gRPC then generate the Python bindings from [`pbx/model.proto`](https://github.com/tinode/chat/tree/master/pbx/model.proto) (your path to `model.proto` may be different):\n```\npython -m grpc_tools.protoc -I../pbx --python_out=. --pyi_out=. --grpc_python_out=. ../pbx/model.proto\n```\nThe generated `model_pb2_grpc.py` imports `model_pb2.py` as a module instead of a package which is incompatible with python3 packaging system. Use `../pbx/py_fix.py` to apply a fix. This is only needed if you want to repackage the generated files.\n"
  },
  {
    "path": "py_grpc/pyproject.toml",
    "content": "[build-system]\nrequires = [\"setuptools>=45\", \"wheel\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[project]\nname = \"tinode_grpc\"\ndescription = \"Tinode gRPC bindings.\"\nauthors = [\n    {name = \"Tinode Authors\", email = \"info@tinode.co\"},\n]\nlicense = \"Apache-2.0\"\nreadme = \"README.md\"\nkeywords = [\"chat\", \"messaging\", \"messenger\", \"im\", \"tinode\"]\nclassifiers = [\n    \"Programming Language :: Python :: 2\",\n    \"Programming Language :: Python :: 2.7\",\n    \"Programming Language :: Python :: 3\",\n    \"Programming Language :: Python :: 3.5\",\n    \"Programming Language :: Python :: 3.6\",\n    \"Programming Language :: Python :: 3.7\",\n    \"Programming Language :: Python :: 3.8\",\n    \"Programming Language :: Python :: 3.9\",\n    \"Programming Language :: Python :: 3.10\",\n    \"Programming Language :: Python :: 3.11\",\n    \"Operating System :: OS Independent\",\n    \"Topic :: Communications :: Chat\",\n    \"Intended Audience :: Developers\",\n]\ndependencies = [\n    \"protobuf>=3.6.1\",\n    \"grpcio>=1.19.0\",\n]\ndynamic = [\"version\"]\n\n[project.urls]\nHomepage = \"https://github.com/tinode/chat\"\nRepository = \"https://github.com/tinode/chat\"\nIssues = \"https://github.com/tinode/chat/issues\"\n\n[tool.setuptools]\npackages = [\"tinode_grpc\"]\n\n[tool.setuptools.package-data]\n\"*\" = [\"GIT_VERSION\", \"*.pyi\"]\n\n# Alternative version handling if you want to keep reading from GIT_VERSION file\n[tool.setuptools.dynamic]\nversion = {file = \"tinode_grpc/GIT_VERSION\"}\n"
  },
  {
    "path": "py_grpc/tinode_grpc/__init__.py",
    "content": "from . import model_pb2 as pb\nfrom . import model_pb2_grpc as pbx\n"
  },
  {
    "path": "py_grpc/tinode_grpc/model_pb2.py",
    "content": "# -*- coding: utf-8 -*-\n# Generated by the protocol buffer compiler.  DO NOT EDIT!\n# source: model.proto\n\"\"\"Generated protocol buffer code.\"\"\"\nfrom google.protobuf import descriptor as _descriptor\nfrom google.protobuf import descriptor_pool as _descriptor_pool\nfrom google.protobuf import symbol_database as _symbol_database\nfrom google.protobuf.internal import builder as _builder\n# @@protoc_insertion_point(imports)\n\n_sym_db = _symbol_database.Default()\n\n\n\n\nDESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(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\\\"\\x99\\x01\\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+\\n\\x06params\\x18\\x04 \\x03(\\x0b\\x32\\x1b.pbx.ClientCred.ParamsEntry\\x1a-\\n\\x0bParamsEntry\\x12\\x0b\\n\\x03key\\x18\\x01 \\x01(\\t\\x12\\r\\n\\x05value\\x18\\x02 \\x01(\\x0c:\\x02\\x38\\x01\\\"e\\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\\x12\\x0f\\n\\x07trusted\\x18\\x04 \\x01(\\x0c\\\"#\\n\\x08SeqRange\\x12\\x0b\\n\\x03low\\x18\\x01 \\x01(\\x05\\x12\\n\\n\\x02hi\\x18\\x02 \\x01(\\x05\\\"\\x94\\x01\\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\\x12\\x1d\\n\\x06ranges\\x18\\x07 \\x03(\\x0b\\x32\\r.pbx.SeqRange\\\"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\\\"\\xbe\\x01\\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\\x12#\\n\\x03\\x61ux\\x18\\x05 \\x03(\\x0b\\x32\\x16.pbx.SetQuery.AuxEntry\\x1a*\\n\\x08\\x41uxEntry\\x12\\x0b\\n\\x03key\\x18\\x01 \\x01(\\t\\x12\\r\\n\\x05value\\x18\\x02 \\x01(\\x0c:\\x02\\x38\\x01\\\"~\\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\\x12\\x12\\n\\nbackground\\x18\\x07 \\x01(\\x08\\\"\\x8a\\x02\\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\\x12\\r\\n\\x05state\\x18\\n \\x01(\\t\\x12\\\"\\n\\nauth_level\\x18\\x0b \\x01(\\x0e\\x32\\x0e.pbx.AuthLevel\\x12\\x12\\n\\ntmp_scheme\\x18\\x0c \\x01(\\t\\x12\\x12\\n\\ntmp_secret\\x18\\r \\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\\\"\\xe8\\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\\\"?\\n\\x04What\\x12\\x06\\n\\x02X0\\x10\\x00\\x12\\x07\\n\\x03MSG\\x10\\x01\\x12\\t\\n\\x05TOPIC\\x10\\x02\\x12\\x07\\n\\x03SUB\\x10\\x03\\x12\\x08\\n\\x04USER\\x10\\x04\\x12\\x08\\n\\x04\\x43RED\\x10\\x05\\\"\\x88\\x01\\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\\x12\\x0e\\n\\x06unread\\x18\\x04 \\x01(\\x05\\x12\\x1d\\n\\x05\\x65vent\\x18\\x05 \\x01(\\x0e\\x32\\x0e.pbx.CallEvent\\x12\\x0f\\n\\x07payload\\x18\\x06 \\x01(\\x0c\\\"\\\\\\n\\x0b\\x43lientExtra\\x12\\x13\\n\\x0b\\x61ttachments\\x18\\x01 \\x03(\\t\\x12\\x14\\n\\x0con_behalf_of\\x18\\x02 \\x01(\\t\\x12\\\"\\n\\nauth_level\\x18\\x03 \\x01(\\x0e\\x32\\x0e.pbx.AuthLevel\\\"\\xf5\\x02\\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\\x1f\\n\\x05\\x65xtra\\x18\\r \\x01(\\x0b\\x32\\x10.pbx.ClientExtraB\\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\\\"\\xf6\\x02\\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\\x12\\r\\n\\x05state\\x18\\x0c \\x01(\\t\\x12\\x10\\n\\x08state_at\\x18\\r \\x01(\\x03\\x12\\x0f\\n\\x07trusted\\x18\\x0e \\x01(\\x0c\\x12\\x0f\\n\\x07is_chan\\x18\\x11 \\x01(\\x08\\x12\\x0e\\n\\x06online\\x18\\x12 \\x01(\\x08\\x12\\x16\\n\\x0elast_seen_time\\x18\\x0f \\x01(\\x03\\x12\\x1c\\n\\x14last_seen_user_agent\\x18\\x10 \\x01(\\t\\\"\\xbe\\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\\x07trusted\\x18\\x10 \\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\\\"\\xf6\\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\\\"\\x86\\x01\\n\\x04What\\x12\\x06\\n\\x02X3\\x10\\x00\\x12\\x06\\n\\x02ON\\x10\\x01\\x12\\x07\\n\\x03OFF\\x10\\x02\\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\\x12\\x07\\n\\x03\\x41UX\\x10\\r\\\"\\xfe\\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\\x12%\\n\\x03\\x61ux\\x18\\x08 \\x03(\\x0b\\x32\\x18.pbx.ServerMeta.AuxEntry\\x1a*\\n\\x08\\x41uxEntry\\x12\\x0b\\n\\x03key\\x18\\x01 \\x01(\\t\\x12\\r\\n\\x05value\\x18\\x02 \\x01(\\x0c:\\x02\\x38\\x01\\\"\\x9b\\x01\\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\\x12\\x0b\\n\\x03src\\x18\\x05 \\x01(\\t\\x12\\x1d\\n\\x05\\x65vent\\x18\\x06 \\x01(\\x0e\\x32\\x0e.pbx.CallEvent\\x12\\x0f\\n\\x07payload\\x18\\x07 \\x01(\\x0c\\\"\\xce\\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\\x11\\n\\x05topic\\x18\\x06 \\x01(\\tB\\x02\\x18\\x01\\x42\\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\\\"&\\n\\x04\\x41uth\\x12\\x0e\\n\\x06scheme\\x18\\x01 \\x01(\\t\\x12\\x0e\\n\\x06secret\\x18\\x02 \\x01(\\t\\\"G\\n\\x08\\x46ileMeta\\x12\\x0c\\n\\x04name\\x18\\x01 \\x01(\\t\\x12\\x11\\n\\tmime_type\\x18\\x02 \\x01(\\t\\x12\\x0c\\n\\x04\\x65tag\\x18\\x03 \\x01(\\t\\x12\\x0c\\n\\x04size\\x18\\x04 \\x01(\\x03\\\"m\\n\\tFileUpReq\\x12\\n\\n\\x02id\\x18\\x01 \\x01(\\t\\x12\\x17\\n\\x04\\x61uth\\x18\\x02 \\x01(\\x0b\\x32\\t.pbx.Auth\\x12\\r\\n\\x05topic\\x18\\x03 \\x01(\\t\\x12\\x1b\\n\\x04meta\\x18\\x04 \\x01(\\x0b\\x32\\r.pbx.FileMeta\\x12\\x0f\\n\\x07\\x63ontent\\x18\\x05 \\x01(\\x0c\\\"d\\n\\nFileUpResp\\x12\\n\\n\\x02id\\x18\\x01 \\x01(\\t\\x12\\x0c\\n\\x04\\x63ode\\x18\\x02 \\x01(\\x05\\x12\\x0c\\n\\x04text\\x18\\x03 \\x01(\\t\\x12\\x1b\\n\\x04meta\\x18\\x04 \\x01(\\x0b\\x32\\r.pbx.FileMeta\\x12\\x11\\n\\tredir_url\\x18\\x05 \\x01(\\t\\\"T\\n\\x0b\\x46ileDownReq\\x12\\n\\n\\x02id\\x18\\x01 \\x01(\\t\\x12\\x17\\n\\x04\\x61uth\\x18\\x02 \\x01(\\x0b\\x32\\t.pbx.Auth\\x12\\x0b\\n\\x03uri\\x18\\x03 \\x01(\\t\\x12\\x13\\n\\x0bif_modified\\x18\\x04 \\x01(\\t\\\"w\\n\\x0c\\x46ileDownResp\\x12\\n\\n\\x02id\\x18\\x01 \\x01(\\t\\x12\\x0c\\n\\x04\\x63ode\\x18\\x02 \\x01(\\x05\\x12\\x0c\\n\\x04text\\x18\\x03 \\x01(\\t\\x12\\x1b\\n\\x04meta\\x18\\x04 \\x01(\\x0b\\x32\\r.pbx.FileMeta\\x12\\x11\\n\\tredir_url\\x18\\x05 \\x01(\\t\\x12\\x0f\\n\\x07\\x63ontent\\x18\\x06 \\x01(\\x0c*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*8\\n\\x08InfoNote\\x12\\x06\\n\\x02X1\\x10\\x00\\x12\\x08\\n\\x04READ\\x10\\x01\\x12\\x08\\n\\x04RECV\\x10\\x02\\x12\\x06\\n\\x02KP\\x10\\x03\\x12\\x08\\n\\x04\\x43\\x41LL\\x10\\x04*o\\n\\tCallEvent\\x12\\x06\\n\\x02X2\\x10\\x00\\x12\\n\\n\\x06\\x41\\x43\\x43\\x45PT\\x10\\x01\\x12\\n\\n\\x06\\x41NSWER\\x10\\x02\\x12\\x0b\\n\\x07HANG_UP\\x10\\x03\\x12\\x11\\n\\rICE_CANDIDATE\\x10\\x04\\x12\\n\\n\\x06INVITE\\x10\\x05\\x12\\t\\n\\x05OFFER\\x10\\x06\\x12\\x0b\\n\\x07RINGING\\x10\\x07*<\\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\\xaf\\x01\\n\\x04Node\\x12\\x33\\n\\x0bMessageLoop\\x12\\x0e.pbx.ClientMsg\\x1a\\x0e.pbx.ServerMsg\\\"\\x00(\\x01\\x30\\x01\\x12\\x37\\n\\x10LargeFileReceive\\x12\\x0e.pbx.FileUpReq\\x1a\\x0f.pbx.FileUpResp\\\"\\x00(\\x01\\x12\\x39\\n\\x0eLargeFileServe\\x12\\x10.pbx.FileDownReq\\x1a\\x11.pbx.FileDownResp\\\"\\x00\\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\\x42\\x1cZ\\x1agithub.com/tinode/chat/pbxb\\x06proto3')\n\n_globals = globals()\n_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)\n_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'model_pb2', _globals)\nif _descriptor._USE_C_DESCRIPTORS == False:\n\n  DESCRIPTOR._options = None\n  DESCRIPTOR._serialized_options = b'Z\\032github.com/tinode/chat/pbx'\n  _CLIENTCRED_PARAMSENTRY._options = None\n  _CLIENTCRED_PARAMSENTRY._serialized_options = b'8\\001'\n  _SETQUERY_AUXENTRY._options = None\n  _SETQUERY_AUXENTRY._serialized_options = b'8\\001'\n  _CLIENTPUB_HEADENTRY._options = None\n  _CLIENTPUB_HEADENTRY._serialized_options = b'8\\001'\n  _SERVERCTRL_PARAMSENTRY._options = None\n  _SERVERCTRL_PARAMSENTRY._serialized_options = b'8\\001'\n  _SERVERDATA_HEADENTRY._options = None\n  _SERVERDATA_HEADENTRY._serialized_options = b'8\\001'\n  _SERVERMETA_AUXENTRY._options = None\n  _SERVERMETA_AUXENTRY._serialized_options = b'8\\001'\n  _SERVERMSG.fields_by_name['topic']._options = None\n  _SERVERMSG.fields_by_name['topic']._serialized_options = b'\\030\\001'\n  _globals['_AUTHLEVEL']._serialized_start=6379\n  _globals['_AUTHLEVEL']._serialized_end=6430\n  _globals['_INFONOTE']._serialized_start=6432\n  _globals['_INFONOTE']._serialized_end=6488\n  _globals['_CALLEVENT']._serialized_start=6490\n  _globals['_CALLEVENT']._serialized_end=6601\n  _globals['_RESPCODE']._serialized_start=6603\n  _globals['_RESPCODE']._serialized_end=6663\n  _globals['_CRUD']._serialized_start=6665\n  _globals['_CRUD']._serialized_end=6707\n  _globals['_UNUSED']._serialized_start=20\n  _globals['_UNUSED']._serialized_end=28\n  _globals['_DEFAULTACSMODE']._serialized_start=30\n  _globals['_DEFAULTACSMODE']._serialized_end=74\n  _globals['_ACCESSMODE']._serialized_start=76\n  _globals['_ACCESSMODE']._serialized_end=117\n  _globals['_SETSUB']._serialized_start=119\n  _globals['_SETSUB']._serialized_end=158\n  _globals['_CLIENTCRED']._serialized_start=161\n  _globals['_CLIENTCRED']._serialized_end=314\n  _globals['_CLIENTCRED_PARAMSENTRY']._serialized_start=269\n  _globals['_CLIENTCRED_PARAMSENTRY']._serialized_end=314\n  _globals['_SETDESC']._serialized_start=316\n  _globals['_SETDESC']._serialized_end=417\n  _globals['_SEQRANGE']._serialized_start=419\n  _globals['_SEQRANGE']._serialized_end=454\n  _globals['_GETOPTS']._serialized_start=457\n  _globals['_GETOPTS']._serialized_end=605\n  _globals['_GETQUERY']._serialized_start=607\n  _globals['_GETQUERY']._serialized_end=714\n  _globals['_SETQUERY']._serialized_start=717\n  _globals['_SETQUERY']._serialized_end=907\n  _globals['_SETQUERY_AUXENTRY']._serialized_start=865\n  _globals['_SETQUERY_AUXENTRY']._serialized_end=907\n  _globals['_CLIENTHI']._serialized_start=909\n  _globals['_CLIENTHI']._serialized_end=1035\n  _globals['_CLIENTACC']._serialized_start=1038\n  _globals['_CLIENTACC']._serialized_end=1304\n  _globals['_CLIENTLOGIN']._serialized_start=1306\n  _globals['_CLIENTLOGIN']._serialized_end=1394\n  _globals['_CLIENTSUB']._serialized_start=1396\n  _globals['_CLIENTSUB']._serialized_end=1502\n  _globals['_CLIENTLEAVE']._serialized_start=1504\n  _globals['_CLIENTLEAVE']._serialized_end=1559\n  _globals['_CLIENTPUB']._serialized_start=1562\n  _globals['_CLIENTPUB']._serialized_end=1719\n  _globals['_CLIENTPUB_HEADENTRY']._serialized_start=1676\n  _globals['_CLIENTPUB_HEADENTRY']._serialized_end=1719\n  _globals['_CLIENTGET']._serialized_start=1721\n  _globals['_CLIENTGET']._serialized_end=1789\n  _globals['_CLIENTSET']._serialized_start=1791\n  _globals['_CLIENTSET']._serialized_end=1859\n  _globals['_CLIENTDEL']._serialized_start=1862\n  _globals['_CLIENTDEL']._serialized_end=2094\n  _globals['_CLIENTDEL_WHAT']._serialized_start=2031\n  _globals['_CLIENTDEL_WHAT']._serialized_end=2094\n  _globals['_CLIENTNOTE']._serialized_start=2097\n  _globals['_CLIENTNOTE']._serialized_end=2233\n  _globals['_CLIENTEXTRA']._serialized_start=2235\n  _globals['_CLIENTEXTRA']._serialized_end=2327\n  _globals['_CLIENTMSG']._serialized_start=2330\n  _globals['_CLIENTMSG']._serialized_end=2703\n  _globals['_SERVERCRED']._serialized_start=2705\n  _globals['_SERVERCRED']._serialized_end=2762\n  _globals['_TOPICDESC']._serialized_start=2765\n  _globals['_TOPICDESC']._serialized_end=3139\n  _globals['_TOPICSUB']._serialized_start=3142\n  _globals['_TOPICSUB']._serialized_end=3460\n  _globals['_DELVALUES']._serialized_start=3462\n  _globals['_DELVALUES']._serialized_end=3521\n  _globals['_SERVERCTRL']._serialized_start=3524\n  _globals['_SERVERCTRL']._serialized_end=3683\n  _globals['_SERVERCTRL_PARAMSENTRY']._serialized_start=269\n  _globals['_SERVERCTRL_PARAMSENTRY']._serialized_end=314\n  _globals['_SERVERDATA']._serialized_start=3686\n  _globals['_SERVERDATA']._serialized_end=3893\n  _globals['_SERVERDATA_HEADENTRY']._serialized_start=1676\n  _globals['_SERVERDATA_HEADENTRY']._serialized_end=1719\n  _globals['_SERVERPRES']._serialized_start=3896\n  _globals['_SERVERPRES']._serialized_end=4270\n  _globals['_SERVERPRES_WHAT']._serialized_start=4136\n  _globals['_SERVERPRES_WHAT']._serialized_end=4270\n  _globals['_SERVERMETA']._serialized_start=4273\n  _globals['_SERVERMETA']._serialized_end=4527\n  _globals['_SERVERMETA_AUXENTRY']._serialized_start=865\n  _globals['_SERVERMETA_AUXENTRY']._serialized_end=907\n  _globals['_SERVERINFO']._serialized_start=4530\n  _globals['_SERVERINFO']._serialized_end=4685\n  _globals['_SERVERMSG']._serialized_start=4688\n  _globals['_SERVERMSG']._serialized_end=4894\n  _globals['_SERVERRESP']._serialized_start=4896\n  _globals['_SERVERRESP']._serialized_end=5002\n  _globals['_SESSION']._serialized_start=5005\n  _globals['_SESSION']._serialized_end=5165\n  _globals['_CLIENTREQ']._serialized_start=5167\n  _globals['_CLIENTREQ']._serialized_end=5235\n  _globals['_SEARCHQUERY']._serialized_start=5237\n  _globals['_SEARCHQUERY']._serialized_end=5282\n  _globals['_SEARCHFOUND']._serialized_start=5284\n  _globals['_SEARCHFOUND']._serialized_end=5374\n  _globals['_TOPICEVENT']._serialized_start=5376\n  _globals['_TOPICEVENT']._serialized_end=5459\n  _globals['_ACCOUNTEVENT']._serialized_start=5462\n  _globals['_ACCOUNTEVENT']._serialized_end=5592\n  _globals['_SUBSCRIPTIONEVENT']._serialized_start=5595\n  _globals['_SUBSCRIPTIONEVENT']._serialized_end=5771\n  _globals['_MESSAGEEVENT']._serialized_start=5773\n  _globals['_MESSAGEEVENT']._serialized_end=5844\n  _globals['_AUTH']._serialized_start=5846\n  _globals['_AUTH']._serialized_end=5884\n  _globals['_FILEMETA']._serialized_start=5886\n  _globals['_FILEMETA']._serialized_end=5957\n  _globals['_FILEUPREQ']._serialized_start=5959\n  _globals['_FILEUPREQ']._serialized_end=6068\n  _globals['_FILEUPRESP']._serialized_start=6070\n  _globals['_FILEUPRESP']._serialized_end=6170\n  _globals['_FILEDOWNREQ']._serialized_start=6172\n  _globals['_FILEDOWNREQ']._serialized_end=6256\n  _globals['_FILEDOWNRESP']._serialized_start=6258\n  _globals['_FILEDOWNRESP']._serialized_end=6377\n  _globals['_NODE']._serialized_start=6710\n  _globals['_NODE']._serialized_end=6885\n  _globals['_PLUGIN']._serialized_start=6888\n  _globals['_PLUGIN']._serialized_end=7175\n# @@protoc_insertion_point(module_scope)\n"
  },
  {
    "path": "py_grpc/tinode_grpc/model_pb2.pyi",
    "content": "from google.protobuf.internal import containers as _containers\nfrom google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper\nfrom google.protobuf import descriptor as _descriptor\nfrom google.protobuf import message as _message\nfrom typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union\n\nDESCRIPTOR: _descriptor.FileDescriptor\n\nclass AuthLevel(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):\n    __slots__ = []\n    NONE: _ClassVar[AuthLevel]\n    ANON: _ClassVar[AuthLevel]\n    AUTH: _ClassVar[AuthLevel]\n    ROOT: _ClassVar[AuthLevel]\n\nclass InfoNote(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):\n    __slots__ = []\n    X1: _ClassVar[InfoNote]\n    READ: _ClassVar[InfoNote]\n    RECV: _ClassVar[InfoNote]\n    KP: _ClassVar[InfoNote]\n    CALL: _ClassVar[InfoNote]\n\nclass CallEvent(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):\n    __slots__ = []\n    X2: _ClassVar[CallEvent]\n    ACCEPT: _ClassVar[CallEvent]\n    ANSWER: _ClassVar[CallEvent]\n    HANG_UP: _ClassVar[CallEvent]\n    ICE_CANDIDATE: _ClassVar[CallEvent]\n    INVITE: _ClassVar[CallEvent]\n    OFFER: _ClassVar[CallEvent]\n    RINGING: _ClassVar[CallEvent]\n\nclass RespCode(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):\n    __slots__ = []\n    CONTINUE: _ClassVar[RespCode]\n    DROP: _ClassVar[RespCode]\n    RESPOND: _ClassVar[RespCode]\n    REPLACE: _ClassVar[RespCode]\n\nclass Crud(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):\n    __slots__ = []\n    CREATE: _ClassVar[Crud]\n    UPDATE: _ClassVar[Crud]\n    DELETE: _ClassVar[Crud]\nNONE: AuthLevel\nANON: AuthLevel\nAUTH: AuthLevel\nROOT: AuthLevel\nX1: InfoNote\nREAD: InfoNote\nRECV: InfoNote\nKP: InfoNote\nCALL: InfoNote\nX2: CallEvent\nACCEPT: CallEvent\nANSWER: CallEvent\nHANG_UP: CallEvent\nICE_CANDIDATE: CallEvent\nINVITE: CallEvent\nOFFER: CallEvent\nRINGING: CallEvent\nCONTINUE: RespCode\nDROP: RespCode\nRESPOND: RespCode\nREPLACE: RespCode\nCREATE: Crud\nUPDATE: Crud\nDELETE: Crud\n\nclass Unused(_message.Message):\n    __slots__ = []\n    def __init__(self) -> None: ...\n\nclass DefaultAcsMode(_message.Message):\n    __slots__ = [\"auth\", \"anon\"]\n    AUTH_FIELD_NUMBER: _ClassVar[int]\n    ANON_FIELD_NUMBER: _ClassVar[int]\n    auth: str\n    anon: str\n    def __init__(self, auth: _Optional[str] = ..., anon: _Optional[str] = ...) -> None: ...\n\nclass AccessMode(_message.Message):\n    __slots__ = [\"want\", \"given\"]\n    WANT_FIELD_NUMBER: _ClassVar[int]\n    GIVEN_FIELD_NUMBER: _ClassVar[int]\n    want: str\n    given: str\n    def __init__(self, want: _Optional[str] = ..., given: _Optional[str] = ...) -> None: ...\n\nclass SetSub(_message.Message):\n    __slots__ = [\"user_id\", \"mode\"]\n    USER_ID_FIELD_NUMBER: _ClassVar[int]\n    MODE_FIELD_NUMBER: _ClassVar[int]\n    user_id: str\n    mode: str\n    def __init__(self, user_id: _Optional[str] = ..., mode: _Optional[str] = ...) -> None: ...\n\nclass ClientCred(_message.Message):\n    __slots__ = [\"method\", \"value\", \"response\", \"params\"]\n    class ParamsEntry(_message.Message):\n        __slots__ = [\"key\", \"value\"]\n        KEY_FIELD_NUMBER: _ClassVar[int]\n        VALUE_FIELD_NUMBER: _ClassVar[int]\n        key: str\n        value: bytes\n        def __init__(self, key: _Optional[str] = ..., value: _Optional[bytes] = ...) -> None: ...\n    METHOD_FIELD_NUMBER: _ClassVar[int]\n    VALUE_FIELD_NUMBER: _ClassVar[int]\n    RESPONSE_FIELD_NUMBER: _ClassVar[int]\n    PARAMS_FIELD_NUMBER: _ClassVar[int]\n    method: str\n    value: str\n    response: str\n    params: _containers.ScalarMap[str, bytes]\n    def __init__(self, method: _Optional[str] = ..., value: _Optional[str] = ..., response: _Optional[str] = ..., params: _Optional[_Mapping[str, bytes]] = ...) -> None: ...\n\nclass SetDesc(_message.Message):\n    __slots__ = [\"default_acs\", \"public\", \"private\", \"trusted\"]\n    DEFAULT_ACS_FIELD_NUMBER: _ClassVar[int]\n    PUBLIC_FIELD_NUMBER: _ClassVar[int]\n    PRIVATE_FIELD_NUMBER: _ClassVar[int]\n    TRUSTED_FIELD_NUMBER: _ClassVar[int]\n    default_acs: DefaultAcsMode\n    public: bytes\n    private: bytes\n    trusted: bytes\n    def __init__(self, default_acs: _Optional[_Union[DefaultAcsMode, _Mapping]] = ..., public: _Optional[bytes] = ..., private: _Optional[bytes] = ..., trusted: _Optional[bytes] = ...) -> None: ...\n\nclass SeqRange(_message.Message):\n    __slots__ = [\"low\", \"hi\"]\n    LOW_FIELD_NUMBER: _ClassVar[int]\n    HI_FIELD_NUMBER: _ClassVar[int]\n    low: int\n    hi: int\n    def __init__(self, low: _Optional[int] = ..., hi: _Optional[int] = ...) -> None: ...\n\nclass GetOpts(_message.Message):\n    __slots__ = [\"if_modified_since\", \"user\", \"topic\", \"since_id\", \"before_id\", \"limit\", \"ranges\"]\n    IF_MODIFIED_SINCE_FIELD_NUMBER: _ClassVar[int]\n    USER_FIELD_NUMBER: _ClassVar[int]\n    TOPIC_FIELD_NUMBER: _ClassVar[int]\n    SINCE_ID_FIELD_NUMBER: _ClassVar[int]\n    BEFORE_ID_FIELD_NUMBER: _ClassVar[int]\n    LIMIT_FIELD_NUMBER: _ClassVar[int]\n    RANGES_FIELD_NUMBER: _ClassVar[int]\n    if_modified_since: int\n    user: str\n    topic: str\n    since_id: int\n    before_id: int\n    limit: int\n    ranges: _containers.RepeatedCompositeFieldContainer[SeqRange]\n    def __init__(self, if_modified_since: _Optional[int] = ..., user: _Optional[str] = ..., topic: _Optional[str] = ..., since_id: _Optional[int] = ..., before_id: _Optional[int] = ..., limit: _Optional[int] = ..., ranges: _Optional[_Iterable[_Union[SeqRange, _Mapping]]] = ...) -> None: ...\n\nclass GetQuery(_message.Message):\n    __slots__ = [\"what\", \"desc\", \"sub\", \"data\"]\n    WHAT_FIELD_NUMBER: _ClassVar[int]\n    DESC_FIELD_NUMBER: _ClassVar[int]\n    SUB_FIELD_NUMBER: _ClassVar[int]\n    DATA_FIELD_NUMBER: _ClassVar[int]\n    what: str\n    desc: GetOpts\n    sub: GetOpts\n    data: GetOpts\n    def __init__(self, what: _Optional[str] = ..., desc: _Optional[_Union[GetOpts, _Mapping]] = ..., sub: _Optional[_Union[GetOpts, _Mapping]] = ..., data: _Optional[_Union[GetOpts, _Mapping]] = ...) -> None: ...\n\nclass SetQuery(_message.Message):\n    __slots__ = [\"desc\", \"sub\", \"tags\", \"cred\", \"aux\"]\n    class AuxEntry(_message.Message):\n        __slots__ = [\"key\", \"value\"]\n        KEY_FIELD_NUMBER: _ClassVar[int]\n        VALUE_FIELD_NUMBER: _ClassVar[int]\n        key: str\n        value: bytes\n        def __init__(self, key: _Optional[str] = ..., value: _Optional[bytes] = ...) -> None: ...\n    DESC_FIELD_NUMBER: _ClassVar[int]\n    SUB_FIELD_NUMBER: _ClassVar[int]\n    TAGS_FIELD_NUMBER: _ClassVar[int]\n    CRED_FIELD_NUMBER: _ClassVar[int]\n    AUX_FIELD_NUMBER: _ClassVar[int]\n    desc: SetDesc\n    sub: SetSub\n    tags: _containers.RepeatedScalarFieldContainer[str]\n    cred: ClientCred\n    aux: _containers.ScalarMap[str, bytes]\n    def __init__(self, desc: _Optional[_Union[SetDesc, _Mapping]] = ..., sub: _Optional[_Union[SetSub, _Mapping]] = ..., tags: _Optional[_Iterable[str]] = ..., cred: _Optional[_Union[ClientCred, _Mapping]] = ..., aux: _Optional[_Mapping[str, bytes]] = ...) -> None: ...\n\nclass ClientHi(_message.Message):\n    __slots__ = [\"id\", \"user_agent\", \"ver\", \"device_id\", \"lang\", \"platform\", \"background\"]\n    ID_FIELD_NUMBER: _ClassVar[int]\n    USER_AGENT_FIELD_NUMBER: _ClassVar[int]\n    VER_FIELD_NUMBER: _ClassVar[int]\n    DEVICE_ID_FIELD_NUMBER: _ClassVar[int]\n    LANG_FIELD_NUMBER: _ClassVar[int]\n    PLATFORM_FIELD_NUMBER: _ClassVar[int]\n    BACKGROUND_FIELD_NUMBER: _ClassVar[int]\n    id: str\n    user_agent: str\n    ver: str\n    device_id: str\n    lang: str\n    platform: str\n    background: bool\n    def __init__(self, id: _Optional[str] = ..., user_agent: _Optional[str] = ..., ver: _Optional[str] = ..., device_id: _Optional[str] = ..., lang: _Optional[str] = ..., platform: _Optional[str] = ..., background: bool = ...) -> None: ...\n\nclass ClientAcc(_message.Message):\n    __slots__ = [\"id\", \"user_id\", \"scheme\", \"secret\", \"login\", \"tags\", \"desc\", \"cred\", \"token\", \"state\", \"auth_level\", \"tmp_scheme\", \"tmp_secret\"]\n    ID_FIELD_NUMBER: _ClassVar[int]\n    USER_ID_FIELD_NUMBER: _ClassVar[int]\n    SCHEME_FIELD_NUMBER: _ClassVar[int]\n    SECRET_FIELD_NUMBER: _ClassVar[int]\n    LOGIN_FIELD_NUMBER: _ClassVar[int]\n    TAGS_FIELD_NUMBER: _ClassVar[int]\n    DESC_FIELD_NUMBER: _ClassVar[int]\n    CRED_FIELD_NUMBER: _ClassVar[int]\n    TOKEN_FIELD_NUMBER: _ClassVar[int]\n    STATE_FIELD_NUMBER: _ClassVar[int]\n    AUTH_LEVEL_FIELD_NUMBER: _ClassVar[int]\n    TMP_SCHEME_FIELD_NUMBER: _ClassVar[int]\n    TMP_SECRET_FIELD_NUMBER: _ClassVar[int]\n    id: str\n    user_id: str\n    scheme: str\n    secret: bytes\n    login: bool\n    tags: _containers.RepeatedScalarFieldContainer[str]\n    desc: SetDesc\n    cred: _containers.RepeatedCompositeFieldContainer[ClientCred]\n    token: bytes\n    state: str\n    auth_level: AuthLevel\n    tmp_scheme: str\n    tmp_secret: bytes\n    def __init__(self, id: _Optional[str] = ..., user_id: _Optional[str] = ..., scheme: _Optional[str] = ..., secret: _Optional[bytes] = ..., login: bool = ..., tags: _Optional[_Iterable[str]] = ..., desc: _Optional[_Union[SetDesc, _Mapping]] = ..., cred: _Optional[_Iterable[_Union[ClientCred, _Mapping]]] = ..., token: _Optional[bytes] = ..., state: _Optional[str] = ..., auth_level: _Optional[_Union[AuthLevel, str]] = ..., tmp_scheme: _Optional[str] = ..., tmp_secret: _Optional[bytes] = ...) -> None: ...\n\nclass ClientLogin(_message.Message):\n    __slots__ = [\"id\", \"scheme\", \"secret\", \"cred\"]\n    ID_FIELD_NUMBER: _ClassVar[int]\n    SCHEME_FIELD_NUMBER: _ClassVar[int]\n    SECRET_FIELD_NUMBER: _ClassVar[int]\n    CRED_FIELD_NUMBER: _ClassVar[int]\n    id: str\n    scheme: str\n    secret: bytes\n    cred: _containers.RepeatedCompositeFieldContainer[ClientCred]\n    def __init__(self, id: _Optional[str] = ..., scheme: _Optional[str] = ..., secret: _Optional[bytes] = ..., cred: _Optional[_Iterable[_Union[ClientCred, _Mapping]]] = ...) -> None: ...\n\nclass ClientSub(_message.Message):\n    __slots__ = [\"id\", \"topic\", \"set_query\", \"get_query\"]\n    ID_FIELD_NUMBER: _ClassVar[int]\n    TOPIC_FIELD_NUMBER: _ClassVar[int]\n    SET_QUERY_FIELD_NUMBER: _ClassVar[int]\n    GET_QUERY_FIELD_NUMBER: _ClassVar[int]\n    id: str\n    topic: str\n    set_query: SetQuery\n    get_query: GetQuery\n    def __init__(self, id: _Optional[str] = ..., topic: _Optional[str] = ..., set_query: _Optional[_Union[SetQuery, _Mapping]] = ..., get_query: _Optional[_Union[GetQuery, _Mapping]] = ...) -> None: ...\n\nclass ClientLeave(_message.Message):\n    __slots__ = [\"id\", \"topic\", \"unsub\"]\n    ID_FIELD_NUMBER: _ClassVar[int]\n    TOPIC_FIELD_NUMBER: _ClassVar[int]\n    UNSUB_FIELD_NUMBER: _ClassVar[int]\n    id: str\n    topic: str\n    unsub: bool\n    def __init__(self, id: _Optional[str] = ..., topic: _Optional[str] = ..., unsub: bool = ...) -> None: ...\n\nclass ClientPub(_message.Message):\n    __slots__ = [\"id\", \"topic\", \"no_echo\", \"head\", \"content\"]\n    class HeadEntry(_message.Message):\n        __slots__ = [\"key\", \"value\"]\n        KEY_FIELD_NUMBER: _ClassVar[int]\n        VALUE_FIELD_NUMBER: _ClassVar[int]\n        key: str\n        value: bytes\n        def __init__(self, key: _Optional[str] = ..., value: _Optional[bytes] = ...) -> None: ...\n    ID_FIELD_NUMBER: _ClassVar[int]\n    TOPIC_FIELD_NUMBER: _ClassVar[int]\n    NO_ECHO_FIELD_NUMBER: _ClassVar[int]\n    HEAD_FIELD_NUMBER: _ClassVar[int]\n    CONTENT_FIELD_NUMBER: _ClassVar[int]\n    id: str\n    topic: str\n    no_echo: bool\n    head: _containers.ScalarMap[str, bytes]\n    content: bytes\n    def __init__(self, id: _Optional[str] = ..., topic: _Optional[str] = ..., no_echo: bool = ..., head: _Optional[_Mapping[str, bytes]] = ..., content: _Optional[bytes] = ...) -> None: ...\n\nclass ClientGet(_message.Message):\n    __slots__ = [\"id\", \"topic\", \"query\"]\n    ID_FIELD_NUMBER: _ClassVar[int]\n    TOPIC_FIELD_NUMBER: _ClassVar[int]\n    QUERY_FIELD_NUMBER: _ClassVar[int]\n    id: str\n    topic: str\n    query: GetQuery\n    def __init__(self, id: _Optional[str] = ..., topic: _Optional[str] = ..., query: _Optional[_Union[GetQuery, _Mapping]] = ...) -> None: ...\n\nclass ClientSet(_message.Message):\n    __slots__ = [\"id\", \"topic\", \"query\"]\n    ID_FIELD_NUMBER: _ClassVar[int]\n    TOPIC_FIELD_NUMBER: _ClassVar[int]\n    QUERY_FIELD_NUMBER: _ClassVar[int]\n    id: str\n    topic: str\n    query: SetQuery\n    def __init__(self, id: _Optional[str] = ..., topic: _Optional[str] = ..., query: _Optional[_Union[SetQuery, _Mapping]] = ...) -> None: ...\n\nclass ClientDel(_message.Message):\n    __slots__ = [\"id\", \"topic\", \"what\", \"del_seq\", \"user_id\", \"cred\", \"hard\"]\n    class What(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):\n        __slots__ = []\n        X0: _ClassVar[ClientDel.What]\n        MSG: _ClassVar[ClientDel.What]\n        TOPIC: _ClassVar[ClientDel.What]\n        SUB: _ClassVar[ClientDel.What]\n        USER: _ClassVar[ClientDel.What]\n        CRED: _ClassVar[ClientDel.What]\n    X0: ClientDel.What\n    MSG: ClientDel.What\n    TOPIC: ClientDel.What\n    SUB: ClientDel.What\n    USER: ClientDel.What\n    CRED: ClientDel.What\n    ID_FIELD_NUMBER: _ClassVar[int]\n    TOPIC_FIELD_NUMBER: _ClassVar[int]\n    WHAT_FIELD_NUMBER: _ClassVar[int]\n    DEL_SEQ_FIELD_NUMBER: _ClassVar[int]\n    USER_ID_FIELD_NUMBER: _ClassVar[int]\n    CRED_FIELD_NUMBER: _ClassVar[int]\n    HARD_FIELD_NUMBER: _ClassVar[int]\n    id: str\n    topic: str\n    what: ClientDel.What\n    del_seq: _containers.RepeatedCompositeFieldContainer[SeqRange]\n    user_id: str\n    cred: ClientCred\n    hard: bool\n    def __init__(self, id: _Optional[str] = ..., topic: _Optional[str] = ..., what: _Optional[_Union[ClientDel.What, str]] = ..., del_seq: _Optional[_Iterable[_Union[SeqRange, _Mapping]]] = ..., user_id: _Optional[str] = ..., cred: _Optional[_Union[ClientCred, _Mapping]] = ..., hard: bool = ...) -> None: ...\n\nclass ClientNote(_message.Message):\n    __slots__ = [\"topic\", \"what\", \"seq_id\", \"unread\", \"event\", \"payload\"]\n    TOPIC_FIELD_NUMBER: _ClassVar[int]\n    WHAT_FIELD_NUMBER: _ClassVar[int]\n    SEQ_ID_FIELD_NUMBER: _ClassVar[int]\n    UNREAD_FIELD_NUMBER: _ClassVar[int]\n    EVENT_FIELD_NUMBER: _ClassVar[int]\n    PAYLOAD_FIELD_NUMBER: _ClassVar[int]\n    topic: str\n    what: InfoNote\n    seq_id: int\n    unread: int\n    event: CallEvent\n    payload: bytes\n    def __init__(self, topic: _Optional[str] = ..., what: _Optional[_Union[InfoNote, str]] = ..., seq_id: _Optional[int] = ..., unread: _Optional[int] = ..., event: _Optional[_Union[CallEvent, str]] = ..., payload: _Optional[bytes] = ...) -> None: ...\n\nclass ClientExtra(_message.Message):\n    __slots__ = [\"attachments\", \"on_behalf_of\", \"auth_level\"]\n    ATTACHMENTS_FIELD_NUMBER: _ClassVar[int]\n    ON_BEHALF_OF_FIELD_NUMBER: _ClassVar[int]\n    AUTH_LEVEL_FIELD_NUMBER: _ClassVar[int]\n    attachments: _containers.RepeatedScalarFieldContainer[str]\n    on_behalf_of: str\n    auth_level: AuthLevel\n    def __init__(self, attachments: _Optional[_Iterable[str]] = ..., on_behalf_of: _Optional[str] = ..., auth_level: _Optional[_Union[AuthLevel, str]] = ...) -> None: ...\n\nclass ClientMsg(_message.Message):\n    __slots__ = [\"hi\", \"acc\", \"login\", \"sub\", \"leave\", \"pub\", \"get\", \"set\", \"note\", \"extra\"]\n    HI_FIELD_NUMBER: _ClassVar[int]\n    ACC_FIELD_NUMBER: _ClassVar[int]\n    LOGIN_FIELD_NUMBER: _ClassVar[int]\n    SUB_FIELD_NUMBER: _ClassVar[int]\n    LEAVE_FIELD_NUMBER: _ClassVar[int]\n    PUB_FIELD_NUMBER: _ClassVar[int]\n    GET_FIELD_NUMBER: _ClassVar[int]\n    SET_FIELD_NUMBER: _ClassVar[int]\n    DEL_FIELD_NUMBER: _ClassVar[int]\n    NOTE_FIELD_NUMBER: _ClassVar[int]\n    EXTRA_FIELD_NUMBER: _ClassVar[int]\n    hi: ClientHi\n    acc: ClientAcc\n    login: ClientLogin\n    sub: ClientSub\n    leave: ClientLeave\n    pub: ClientPub\n    get: ClientGet\n    set: ClientSet\n    note: ClientNote\n    extra: ClientExtra\n    def __init__(self, hi: _Optional[_Union[ClientHi, _Mapping]] = ..., acc: _Optional[_Union[ClientAcc, _Mapping]] = ..., login: _Optional[_Union[ClientLogin, _Mapping]] = ..., sub: _Optional[_Union[ClientSub, _Mapping]] = ..., leave: _Optional[_Union[ClientLeave, _Mapping]] = ..., pub: _Optional[_Union[ClientPub, _Mapping]] = ..., get: _Optional[_Union[ClientGet, _Mapping]] = ..., set: _Optional[_Union[ClientSet, _Mapping]] = ..., note: _Optional[_Union[ClientNote, _Mapping]] = ..., extra: _Optional[_Union[ClientExtra, _Mapping]] = ..., **kwargs) -> None: ...\n\nclass ServerCred(_message.Message):\n    __slots__ = [\"method\", \"value\", \"done\"]\n    METHOD_FIELD_NUMBER: _ClassVar[int]\n    VALUE_FIELD_NUMBER: _ClassVar[int]\n    DONE_FIELD_NUMBER: _ClassVar[int]\n    method: str\n    value: str\n    done: bool\n    def __init__(self, method: _Optional[str] = ..., value: _Optional[str] = ..., done: bool = ...) -> None: ...\n\nclass TopicDesc(_message.Message):\n    __slots__ = [\"created_at\", \"updated_at\", \"touched_at\", \"defacs\", \"acs\", \"seq_id\", \"read_id\", \"recv_id\", \"del_id\", \"public\", \"private\", \"state\", \"state_at\", \"trusted\", \"is_chan\", \"online\", \"last_seen_time\", \"last_seen_user_agent\"]\n    CREATED_AT_FIELD_NUMBER: _ClassVar[int]\n    UPDATED_AT_FIELD_NUMBER: _ClassVar[int]\n    TOUCHED_AT_FIELD_NUMBER: _ClassVar[int]\n    DEFACS_FIELD_NUMBER: _ClassVar[int]\n    ACS_FIELD_NUMBER: _ClassVar[int]\n    SEQ_ID_FIELD_NUMBER: _ClassVar[int]\n    READ_ID_FIELD_NUMBER: _ClassVar[int]\n    RECV_ID_FIELD_NUMBER: _ClassVar[int]\n    DEL_ID_FIELD_NUMBER: _ClassVar[int]\n    PUBLIC_FIELD_NUMBER: _ClassVar[int]\n    PRIVATE_FIELD_NUMBER: _ClassVar[int]\n    STATE_FIELD_NUMBER: _ClassVar[int]\n    STATE_AT_FIELD_NUMBER: _ClassVar[int]\n    TRUSTED_FIELD_NUMBER: _ClassVar[int]\n    IS_CHAN_FIELD_NUMBER: _ClassVar[int]\n    ONLINE_FIELD_NUMBER: _ClassVar[int]\n    LAST_SEEN_TIME_FIELD_NUMBER: _ClassVar[int]\n    LAST_SEEN_USER_AGENT_FIELD_NUMBER: _ClassVar[int]\n    created_at: int\n    updated_at: int\n    touched_at: int\n    defacs: DefaultAcsMode\n    acs: AccessMode\n    seq_id: int\n    read_id: int\n    recv_id: int\n    del_id: int\n    public: bytes\n    private: bytes\n    state: str\n    state_at: int\n    trusted: bytes\n    is_chan: bool\n    online: bool\n    last_seen_time: int\n    last_seen_user_agent: str\n    def __init__(self, created_at: _Optional[int] = ..., updated_at: _Optional[int] = ..., touched_at: _Optional[int] = ..., defacs: _Optional[_Union[DefaultAcsMode, _Mapping]] = ..., acs: _Optional[_Union[AccessMode, _Mapping]] = ..., seq_id: _Optional[int] = ..., read_id: _Optional[int] = ..., recv_id: _Optional[int] = ..., del_id: _Optional[int] = ..., public: _Optional[bytes] = ..., private: _Optional[bytes] = ..., state: _Optional[str] = ..., state_at: _Optional[int] = ..., trusted: _Optional[bytes] = ..., is_chan: bool = ..., online: bool = ..., last_seen_time: _Optional[int] = ..., last_seen_user_agent: _Optional[str] = ...) -> None: ...\n\nclass TopicSub(_message.Message):\n    __slots__ = [\"updated_at\", \"deleted_at\", \"online\", \"acs\", \"read_id\", \"recv_id\", \"public\", \"trusted\", \"private\", \"user_id\", \"topic\", \"touched_at\", \"seq_id\", \"del_id\", \"last_seen_time\", \"last_seen_user_agent\"]\n    UPDATED_AT_FIELD_NUMBER: _ClassVar[int]\n    DELETED_AT_FIELD_NUMBER: _ClassVar[int]\n    ONLINE_FIELD_NUMBER: _ClassVar[int]\n    ACS_FIELD_NUMBER: _ClassVar[int]\n    READ_ID_FIELD_NUMBER: _ClassVar[int]\n    RECV_ID_FIELD_NUMBER: _ClassVar[int]\n    PUBLIC_FIELD_NUMBER: _ClassVar[int]\n    TRUSTED_FIELD_NUMBER: _ClassVar[int]\n    PRIVATE_FIELD_NUMBER: _ClassVar[int]\n    USER_ID_FIELD_NUMBER: _ClassVar[int]\n    TOPIC_FIELD_NUMBER: _ClassVar[int]\n    TOUCHED_AT_FIELD_NUMBER: _ClassVar[int]\n    SEQ_ID_FIELD_NUMBER: _ClassVar[int]\n    DEL_ID_FIELD_NUMBER: _ClassVar[int]\n    LAST_SEEN_TIME_FIELD_NUMBER: _ClassVar[int]\n    LAST_SEEN_USER_AGENT_FIELD_NUMBER: _ClassVar[int]\n    updated_at: int\n    deleted_at: int\n    online: bool\n    acs: AccessMode\n    read_id: int\n    recv_id: int\n    public: bytes\n    trusted: bytes\n    private: bytes\n    user_id: str\n    topic: str\n    touched_at: int\n    seq_id: int\n    del_id: int\n    last_seen_time: int\n    last_seen_user_agent: str\n    def __init__(self, updated_at: _Optional[int] = ..., deleted_at: _Optional[int] = ..., online: bool = ..., acs: _Optional[_Union[AccessMode, _Mapping]] = ..., read_id: _Optional[int] = ..., recv_id: _Optional[int] = ..., public: _Optional[bytes] = ..., trusted: _Optional[bytes] = ..., private: _Optional[bytes] = ..., user_id: _Optional[str] = ..., topic: _Optional[str] = ..., touched_at: _Optional[int] = ..., seq_id: _Optional[int] = ..., del_id: _Optional[int] = ..., last_seen_time: _Optional[int] = ..., last_seen_user_agent: _Optional[str] = ...) -> None: ...\n\nclass DelValues(_message.Message):\n    __slots__ = [\"del_id\", \"del_seq\"]\n    DEL_ID_FIELD_NUMBER: _ClassVar[int]\n    DEL_SEQ_FIELD_NUMBER: _ClassVar[int]\n    del_id: int\n    del_seq: _containers.RepeatedCompositeFieldContainer[SeqRange]\n    def __init__(self, del_id: _Optional[int] = ..., del_seq: _Optional[_Iterable[_Union[SeqRange, _Mapping]]] = ...) -> None: ...\n\nclass ServerCtrl(_message.Message):\n    __slots__ = [\"id\", \"topic\", \"code\", \"text\", \"params\"]\n    class ParamsEntry(_message.Message):\n        __slots__ = [\"key\", \"value\"]\n        KEY_FIELD_NUMBER: _ClassVar[int]\n        VALUE_FIELD_NUMBER: _ClassVar[int]\n        key: str\n        value: bytes\n        def __init__(self, key: _Optional[str] = ..., value: _Optional[bytes] = ...) -> None: ...\n    ID_FIELD_NUMBER: _ClassVar[int]\n    TOPIC_FIELD_NUMBER: _ClassVar[int]\n    CODE_FIELD_NUMBER: _ClassVar[int]\n    TEXT_FIELD_NUMBER: _ClassVar[int]\n    PARAMS_FIELD_NUMBER: _ClassVar[int]\n    id: str\n    topic: str\n    code: int\n    text: str\n    params: _containers.ScalarMap[str, bytes]\n    def __init__(self, id: _Optional[str] = ..., topic: _Optional[str] = ..., code: _Optional[int] = ..., text: _Optional[str] = ..., params: _Optional[_Mapping[str, bytes]] = ...) -> None: ...\n\nclass ServerData(_message.Message):\n    __slots__ = [\"topic\", \"from_user_id\", \"timestamp\", \"deleted_at\", \"seq_id\", \"head\", \"content\"]\n    class HeadEntry(_message.Message):\n        __slots__ = [\"key\", \"value\"]\n        KEY_FIELD_NUMBER: _ClassVar[int]\n        VALUE_FIELD_NUMBER: _ClassVar[int]\n        key: str\n        value: bytes\n        def __init__(self, key: _Optional[str] = ..., value: _Optional[bytes] = ...) -> None: ...\n    TOPIC_FIELD_NUMBER: _ClassVar[int]\n    FROM_USER_ID_FIELD_NUMBER: _ClassVar[int]\n    TIMESTAMP_FIELD_NUMBER: _ClassVar[int]\n    DELETED_AT_FIELD_NUMBER: _ClassVar[int]\n    SEQ_ID_FIELD_NUMBER: _ClassVar[int]\n    HEAD_FIELD_NUMBER: _ClassVar[int]\n    CONTENT_FIELD_NUMBER: _ClassVar[int]\n    topic: str\n    from_user_id: str\n    timestamp: int\n    deleted_at: int\n    seq_id: int\n    head: _containers.ScalarMap[str, bytes]\n    content: bytes\n    def __init__(self, topic: _Optional[str] = ..., from_user_id: _Optional[str] = ..., timestamp: _Optional[int] = ..., deleted_at: _Optional[int] = ..., seq_id: _Optional[int] = ..., head: _Optional[_Mapping[str, bytes]] = ..., content: _Optional[bytes] = ...) -> None: ...\n\nclass ServerPres(_message.Message):\n    __slots__ = [\"topic\", \"src\", \"what\", \"user_agent\", \"seq_id\", \"del_id\", \"del_seq\", \"target_user_id\", \"actor_user_id\", \"acs\"]\n    class What(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):\n        __slots__ = []\n        X3: _ClassVar[ServerPres.What]\n        ON: _ClassVar[ServerPres.What]\n        OFF: _ClassVar[ServerPres.What]\n        UA: _ClassVar[ServerPres.What]\n        UPD: _ClassVar[ServerPres.What]\n        GONE: _ClassVar[ServerPres.What]\n        ACS: _ClassVar[ServerPres.What]\n        TERM: _ClassVar[ServerPres.What]\n        MSG: _ClassVar[ServerPres.What]\n        READ: _ClassVar[ServerPres.What]\n        RECV: _ClassVar[ServerPres.What]\n        DEL: _ClassVar[ServerPres.What]\n        TAGS: _ClassVar[ServerPres.What]\n        AUX: _ClassVar[ServerPres.What]\n    X3: ServerPres.What\n    ON: ServerPres.What\n    OFF: ServerPres.What\n    UA: ServerPres.What\n    UPD: ServerPres.What\n    GONE: ServerPres.What\n    ACS: ServerPres.What\n    TERM: ServerPres.What\n    MSG: ServerPres.What\n    READ: ServerPres.What\n    RECV: ServerPres.What\n    DEL: ServerPres.What\n    TAGS: ServerPres.What\n    AUX: ServerPres.What\n    TOPIC_FIELD_NUMBER: _ClassVar[int]\n    SRC_FIELD_NUMBER: _ClassVar[int]\n    WHAT_FIELD_NUMBER: _ClassVar[int]\n    USER_AGENT_FIELD_NUMBER: _ClassVar[int]\n    SEQ_ID_FIELD_NUMBER: _ClassVar[int]\n    DEL_ID_FIELD_NUMBER: _ClassVar[int]\n    DEL_SEQ_FIELD_NUMBER: _ClassVar[int]\n    TARGET_USER_ID_FIELD_NUMBER: _ClassVar[int]\n    ACTOR_USER_ID_FIELD_NUMBER: _ClassVar[int]\n    ACS_FIELD_NUMBER: _ClassVar[int]\n    topic: str\n    src: str\n    what: ServerPres.What\n    user_agent: str\n    seq_id: int\n    del_id: int\n    del_seq: _containers.RepeatedCompositeFieldContainer[SeqRange]\n    target_user_id: str\n    actor_user_id: str\n    acs: AccessMode\n    def __init__(self, topic: _Optional[str] = ..., src: _Optional[str] = ..., what: _Optional[_Union[ServerPres.What, str]] = ..., user_agent: _Optional[str] = ..., seq_id: _Optional[int] = ..., del_id: _Optional[int] = ..., del_seq: _Optional[_Iterable[_Union[SeqRange, _Mapping]]] = ..., target_user_id: _Optional[str] = ..., actor_user_id: _Optional[str] = ..., acs: _Optional[_Union[AccessMode, _Mapping]] = ...) -> None: ...\n\nclass ServerMeta(_message.Message):\n    __slots__ = [\"id\", \"topic\", \"desc\", \"sub\", \"tags\", \"cred\", \"aux\"]\n    class AuxEntry(_message.Message):\n        __slots__ = [\"key\", \"value\"]\n        KEY_FIELD_NUMBER: _ClassVar[int]\n        VALUE_FIELD_NUMBER: _ClassVar[int]\n        key: str\n        value: bytes\n        def __init__(self, key: _Optional[str] = ..., value: _Optional[bytes] = ...) -> None: ...\n    ID_FIELD_NUMBER: _ClassVar[int]\n    TOPIC_FIELD_NUMBER: _ClassVar[int]\n    DESC_FIELD_NUMBER: _ClassVar[int]\n    SUB_FIELD_NUMBER: _ClassVar[int]\n    DEL_FIELD_NUMBER: _ClassVar[int]\n    TAGS_FIELD_NUMBER: _ClassVar[int]\n    CRED_FIELD_NUMBER: _ClassVar[int]\n    AUX_FIELD_NUMBER: _ClassVar[int]\n    id: str\n    topic: str\n    desc: TopicDesc\n    sub: _containers.RepeatedCompositeFieldContainer[TopicSub]\n    tags: _containers.RepeatedScalarFieldContainer[str]\n    cred: _containers.RepeatedCompositeFieldContainer[ServerCred]\n    aux: _containers.ScalarMap[str, bytes]\n    def __init__(self, id: _Optional[str] = ..., topic: _Optional[str] = ..., desc: _Optional[_Union[TopicDesc, _Mapping]] = ..., sub: _Optional[_Iterable[_Union[TopicSub, _Mapping]]] = ..., tags: _Optional[_Iterable[str]] = ..., cred: _Optional[_Iterable[_Union[ServerCred, _Mapping]]] = ..., aux: _Optional[_Mapping[str, bytes]] = ..., **kwargs) -> None: ...\n\nclass ServerInfo(_message.Message):\n    __slots__ = [\"topic\", \"from_user_id\", \"what\", \"seq_id\", \"src\", \"event\", \"payload\"]\n    TOPIC_FIELD_NUMBER: _ClassVar[int]\n    FROM_USER_ID_FIELD_NUMBER: _ClassVar[int]\n    WHAT_FIELD_NUMBER: _ClassVar[int]\n    SEQ_ID_FIELD_NUMBER: _ClassVar[int]\n    SRC_FIELD_NUMBER: _ClassVar[int]\n    EVENT_FIELD_NUMBER: _ClassVar[int]\n    PAYLOAD_FIELD_NUMBER: _ClassVar[int]\n    topic: str\n    from_user_id: str\n    what: InfoNote\n    seq_id: int\n    src: str\n    event: CallEvent\n    payload: bytes\n    def __init__(self, topic: _Optional[str] = ..., from_user_id: _Optional[str] = ..., what: _Optional[_Union[InfoNote, str]] = ..., seq_id: _Optional[int] = ..., src: _Optional[str] = ..., event: _Optional[_Union[CallEvent, str]] = ..., payload: _Optional[bytes] = ...) -> None: ...\n\nclass ServerMsg(_message.Message):\n    __slots__ = [\"ctrl\", \"data\", \"pres\", \"meta\", \"info\", \"topic\"]\n    CTRL_FIELD_NUMBER: _ClassVar[int]\n    DATA_FIELD_NUMBER: _ClassVar[int]\n    PRES_FIELD_NUMBER: _ClassVar[int]\n    META_FIELD_NUMBER: _ClassVar[int]\n    INFO_FIELD_NUMBER: _ClassVar[int]\n    TOPIC_FIELD_NUMBER: _ClassVar[int]\n    ctrl: ServerCtrl\n    data: ServerData\n    pres: ServerPres\n    meta: ServerMeta\n    info: ServerInfo\n    topic: str\n    def __init__(self, ctrl: _Optional[_Union[ServerCtrl, _Mapping]] = ..., data: _Optional[_Union[ServerData, _Mapping]] = ..., pres: _Optional[_Union[ServerPres, _Mapping]] = ..., meta: _Optional[_Union[ServerMeta, _Mapping]] = ..., info: _Optional[_Union[ServerInfo, _Mapping]] = ..., topic: _Optional[str] = ...) -> None: ...\n\nclass ServerResp(_message.Message):\n    __slots__ = [\"status\", \"srvmsg\", \"clmsg\"]\n    STATUS_FIELD_NUMBER: _ClassVar[int]\n    SRVMSG_FIELD_NUMBER: _ClassVar[int]\n    CLMSG_FIELD_NUMBER: _ClassVar[int]\n    status: RespCode\n    srvmsg: ServerMsg\n    clmsg: ClientMsg\n    def __init__(self, status: _Optional[_Union[RespCode, str]] = ..., srvmsg: _Optional[_Union[ServerMsg, _Mapping]] = ..., clmsg: _Optional[_Union[ClientMsg, _Mapping]] = ...) -> None: ...\n\nclass Session(_message.Message):\n    __slots__ = [\"session_id\", \"user_id\", \"auth_level\", \"remote_addr\", \"user_agent\", \"device_id\", \"language\"]\n    SESSION_ID_FIELD_NUMBER: _ClassVar[int]\n    USER_ID_FIELD_NUMBER: _ClassVar[int]\n    AUTH_LEVEL_FIELD_NUMBER: _ClassVar[int]\n    REMOTE_ADDR_FIELD_NUMBER: _ClassVar[int]\n    USER_AGENT_FIELD_NUMBER: _ClassVar[int]\n    DEVICE_ID_FIELD_NUMBER: _ClassVar[int]\n    LANGUAGE_FIELD_NUMBER: _ClassVar[int]\n    session_id: str\n    user_id: str\n    auth_level: AuthLevel\n    remote_addr: str\n    user_agent: str\n    device_id: str\n    language: str\n    def __init__(self, session_id: _Optional[str] = ..., user_id: _Optional[str] = ..., auth_level: _Optional[_Union[AuthLevel, str]] = ..., remote_addr: _Optional[str] = ..., user_agent: _Optional[str] = ..., device_id: _Optional[str] = ..., language: _Optional[str] = ...) -> None: ...\n\nclass ClientReq(_message.Message):\n    __slots__ = [\"msg\", \"sess\"]\n    MSG_FIELD_NUMBER: _ClassVar[int]\n    SESS_FIELD_NUMBER: _ClassVar[int]\n    msg: ClientMsg\n    sess: Session\n    def __init__(self, msg: _Optional[_Union[ClientMsg, _Mapping]] = ..., sess: _Optional[_Union[Session, _Mapping]] = ...) -> None: ...\n\nclass SearchQuery(_message.Message):\n    __slots__ = [\"user_id\", \"query\"]\n    USER_ID_FIELD_NUMBER: _ClassVar[int]\n    QUERY_FIELD_NUMBER: _ClassVar[int]\n    user_id: str\n    query: str\n    def __init__(self, user_id: _Optional[str] = ..., query: _Optional[str] = ...) -> None: ...\n\nclass SearchFound(_message.Message):\n    __slots__ = [\"status\", \"query\", \"result\"]\n    STATUS_FIELD_NUMBER: _ClassVar[int]\n    QUERY_FIELD_NUMBER: _ClassVar[int]\n    RESULT_FIELD_NUMBER: _ClassVar[int]\n    status: RespCode\n    query: str\n    result: _containers.RepeatedCompositeFieldContainer[TopicSub]\n    def __init__(self, status: _Optional[_Union[RespCode, str]] = ..., query: _Optional[str] = ..., result: _Optional[_Iterable[_Union[TopicSub, _Mapping]]] = ...) -> None: ...\n\nclass TopicEvent(_message.Message):\n    __slots__ = [\"action\", \"name\", \"desc\"]\n    ACTION_FIELD_NUMBER: _ClassVar[int]\n    NAME_FIELD_NUMBER: _ClassVar[int]\n    DESC_FIELD_NUMBER: _ClassVar[int]\n    action: Crud\n    name: str\n    desc: TopicDesc\n    def __init__(self, action: _Optional[_Union[Crud, str]] = ..., name: _Optional[str] = ..., desc: _Optional[_Union[TopicDesc, _Mapping]] = ...) -> None: ...\n\nclass AccountEvent(_message.Message):\n    __slots__ = [\"action\", \"user_id\", \"default_acs\", \"public\", \"tags\"]\n    ACTION_FIELD_NUMBER: _ClassVar[int]\n    USER_ID_FIELD_NUMBER: _ClassVar[int]\n    DEFAULT_ACS_FIELD_NUMBER: _ClassVar[int]\n    PUBLIC_FIELD_NUMBER: _ClassVar[int]\n    TAGS_FIELD_NUMBER: _ClassVar[int]\n    action: Crud\n    user_id: str\n    default_acs: DefaultAcsMode\n    public: bytes\n    tags: _containers.RepeatedScalarFieldContainer[str]\n    def __init__(self, action: _Optional[_Union[Crud, str]] = ..., user_id: _Optional[str] = ..., default_acs: _Optional[_Union[DefaultAcsMode, _Mapping]] = ..., public: _Optional[bytes] = ..., tags: _Optional[_Iterable[str]] = ...) -> None: ...\n\nclass SubscriptionEvent(_message.Message):\n    __slots__ = [\"action\", \"topic\", \"user_id\", \"del_id\", \"read_id\", \"recv_id\", \"mode\", \"private\"]\n    ACTION_FIELD_NUMBER: _ClassVar[int]\n    TOPIC_FIELD_NUMBER: _ClassVar[int]\n    USER_ID_FIELD_NUMBER: _ClassVar[int]\n    DEL_ID_FIELD_NUMBER: _ClassVar[int]\n    READ_ID_FIELD_NUMBER: _ClassVar[int]\n    RECV_ID_FIELD_NUMBER: _ClassVar[int]\n    MODE_FIELD_NUMBER: _ClassVar[int]\n    PRIVATE_FIELD_NUMBER: _ClassVar[int]\n    action: Crud\n    topic: str\n    user_id: str\n    del_id: int\n    read_id: int\n    recv_id: int\n    mode: AccessMode\n    private: bytes\n    def __init__(self, action: _Optional[_Union[Crud, str]] = ..., topic: _Optional[str] = ..., user_id: _Optional[str] = ..., del_id: _Optional[int] = ..., read_id: _Optional[int] = ..., recv_id: _Optional[int] = ..., mode: _Optional[_Union[AccessMode, _Mapping]] = ..., private: _Optional[bytes] = ...) -> None: ...\n\nclass MessageEvent(_message.Message):\n    __slots__ = [\"action\", \"msg\"]\n    ACTION_FIELD_NUMBER: _ClassVar[int]\n    MSG_FIELD_NUMBER: _ClassVar[int]\n    action: Crud\n    msg: ServerData\n    def __init__(self, action: _Optional[_Union[Crud, str]] = ..., msg: _Optional[_Union[ServerData, _Mapping]] = ...) -> None: ...\n\nclass Auth(_message.Message):\n    __slots__ = [\"scheme\", \"secret\"]\n    SCHEME_FIELD_NUMBER: _ClassVar[int]\n    SECRET_FIELD_NUMBER: _ClassVar[int]\n    scheme: str\n    secret: str\n    def __init__(self, scheme: _Optional[str] = ..., secret: _Optional[str] = ...) -> None: ...\n\nclass FileMeta(_message.Message):\n    __slots__ = [\"name\", \"mime_type\", \"etag\", \"size\"]\n    NAME_FIELD_NUMBER: _ClassVar[int]\n    MIME_TYPE_FIELD_NUMBER: _ClassVar[int]\n    ETAG_FIELD_NUMBER: _ClassVar[int]\n    SIZE_FIELD_NUMBER: _ClassVar[int]\n    name: str\n    mime_type: str\n    etag: str\n    size: int\n    def __init__(self, name: _Optional[str] = ..., mime_type: _Optional[str] = ..., etag: _Optional[str] = ..., size: _Optional[int] = ...) -> None: ...\n\nclass FileUpReq(_message.Message):\n    __slots__ = [\"id\", \"auth\", \"topic\", \"meta\", \"content\"]\n    ID_FIELD_NUMBER: _ClassVar[int]\n    AUTH_FIELD_NUMBER: _ClassVar[int]\n    TOPIC_FIELD_NUMBER: _ClassVar[int]\n    META_FIELD_NUMBER: _ClassVar[int]\n    CONTENT_FIELD_NUMBER: _ClassVar[int]\n    id: str\n    auth: Auth\n    topic: str\n    meta: FileMeta\n    content: bytes\n    def __init__(self, id: _Optional[str] = ..., auth: _Optional[_Union[Auth, _Mapping]] = ..., topic: _Optional[str] = ..., meta: _Optional[_Union[FileMeta, _Mapping]] = ..., content: _Optional[bytes] = ...) -> None: ...\n\nclass FileUpResp(_message.Message):\n    __slots__ = [\"id\", \"code\", \"text\", \"meta\", \"redir_url\"]\n    ID_FIELD_NUMBER: _ClassVar[int]\n    CODE_FIELD_NUMBER: _ClassVar[int]\n    TEXT_FIELD_NUMBER: _ClassVar[int]\n    META_FIELD_NUMBER: _ClassVar[int]\n    REDIR_URL_FIELD_NUMBER: _ClassVar[int]\n    id: str\n    code: int\n    text: str\n    meta: FileMeta\n    redir_url: str\n    def __init__(self, id: _Optional[str] = ..., code: _Optional[int] = ..., text: _Optional[str] = ..., meta: _Optional[_Union[FileMeta, _Mapping]] = ..., redir_url: _Optional[str] = ...) -> None: ...\n\nclass FileDownReq(_message.Message):\n    __slots__ = [\"id\", \"auth\", \"uri\", \"if_modified\"]\n    ID_FIELD_NUMBER: _ClassVar[int]\n    AUTH_FIELD_NUMBER: _ClassVar[int]\n    URI_FIELD_NUMBER: _ClassVar[int]\n    IF_MODIFIED_FIELD_NUMBER: _ClassVar[int]\n    id: str\n    auth: Auth\n    uri: str\n    if_modified: str\n    def __init__(self, id: _Optional[str] = ..., auth: _Optional[_Union[Auth, _Mapping]] = ..., uri: _Optional[str] = ..., if_modified: _Optional[str] = ...) -> None: ...\n\nclass FileDownResp(_message.Message):\n    __slots__ = [\"id\", \"code\", \"text\", \"meta\", \"redir_url\", \"content\"]\n    ID_FIELD_NUMBER: _ClassVar[int]\n    CODE_FIELD_NUMBER: _ClassVar[int]\n    TEXT_FIELD_NUMBER: _ClassVar[int]\n    META_FIELD_NUMBER: _ClassVar[int]\n    REDIR_URL_FIELD_NUMBER: _ClassVar[int]\n    CONTENT_FIELD_NUMBER: _ClassVar[int]\n    id: str\n    code: int\n    text: str\n    meta: FileMeta\n    redir_url: str\n    content: bytes\n    def __init__(self, id: _Optional[str] = ..., code: _Optional[int] = ..., text: _Optional[str] = ..., meta: _Optional[_Union[FileMeta, _Mapping]] = ..., redir_url: _Optional[str] = ..., content: _Optional[bytes] = ...) -> None: ...\n"
  },
  {
    "path": "py_grpc/tinode_grpc/model_pb2_grpc.py",
    "content": "# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!\n\"\"\"Client and server classes corresponding to protobuf-defined services.\"\"\"\nimport grpc\n\nfrom . import model_pb2 as model__pb2\n\n\nclass NodeStub(object):\n    \"\"\"This is the methods that needs to be implemented by a gRPC client.\n    \"\"\"\n\n    def __init__(self, channel):\n        \"\"\"Constructor.\n\n        Args:\n            channel: A grpc.Channel.\n        \"\"\"\n        self.MessageLoop = channel.stream_stream(\n                '/pbx.Node/MessageLoop',\n                request_serializer=model__pb2.ClientMsg.SerializeToString,\n                response_deserializer=model__pb2.ServerMsg.FromString,\n                )\n        self.LargeFileReceive = channel.stream_unary(\n                '/pbx.Node/LargeFileReceive',\n                request_serializer=model__pb2.FileUpReq.SerializeToString,\n                response_deserializer=model__pb2.FileUpResp.FromString,\n                )\n        self.LargeFileServe = channel.unary_stream(\n                '/pbx.Node/LargeFileServe',\n                request_serializer=model__pb2.FileDownReq.SerializeToString,\n                response_deserializer=model__pb2.FileDownResp.FromString,\n                )\n\n\nclass NodeServicer(object):\n    \"\"\"This is the methods that needs to be implemented by a gRPC client.\n    \"\"\"\n\n    def MessageLoop(self, request_iterator, context):\n        \"\"\"Client sends a stream of ClientMsg, server responds with a stream of ServerMsg\n        \"\"\"\n        context.set_code(grpc.StatusCode.UNIMPLEMENTED)\n        context.set_details('Method not implemented!')\n        raise NotImplementedError('Method not implemented!')\n\n    def LargeFileReceive(self, request_iterator, context):\n        \"\"\"Large file upload: a request with a stream of chunks.\n        \"\"\"\n        context.set_code(grpc.StatusCode.UNIMPLEMENTED)\n        context.set_details('Method not implemented!')\n        raise NotImplementedError('Method not implemented!')\n\n    def LargeFileServe(self, request, context):\n        \"\"\"Large file file download: a response with a stream of chunks.\n        \"\"\"\n        context.set_code(grpc.StatusCode.UNIMPLEMENTED)\n        context.set_details('Method not implemented!')\n        raise NotImplementedError('Method not implemented!')\n\n\ndef add_NodeServicer_to_server(servicer, server):\n    rpc_method_handlers = {\n            'MessageLoop': grpc.stream_stream_rpc_method_handler(\n                    servicer.MessageLoop,\n                    request_deserializer=model__pb2.ClientMsg.FromString,\n                    response_serializer=model__pb2.ServerMsg.SerializeToString,\n            ),\n            'LargeFileReceive': grpc.stream_unary_rpc_method_handler(\n                    servicer.LargeFileReceive,\n                    request_deserializer=model__pb2.FileUpReq.FromString,\n                    response_serializer=model__pb2.FileUpResp.SerializeToString,\n            ),\n            'LargeFileServe': grpc.unary_stream_rpc_method_handler(\n                    servicer.LargeFileServe,\n                    request_deserializer=model__pb2.FileDownReq.FromString,\n                    response_serializer=model__pb2.FileDownResp.SerializeToString,\n            ),\n    }\n    generic_handler = grpc.method_handlers_generic_handler(\n            'pbx.Node', rpc_method_handlers)\n    server.add_generic_rpc_handlers((generic_handler,))\n\n\n # This class is part of an EXPERIMENTAL API.\nclass Node(object):\n    \"\"\"This is the methods that needs to be implemented by a gRPC client.\n    \"\"\"\n\n    @staticmethod\n    def MessageLoop(request_iterator,\n            target,\n            options=(),\n            channel_credentials=None,\n            call_credentials=None,\n            insecure=False,\n            compression=None,\n            wait_for_ready=None,\n            timeout=None,\n            metadata=None):\n        return grpc.experimental.stream_stream(request_iterator, target, '/pbx.Node/MessageLoop',\n            model__pb2.ClientMsg.SerializeToString,\n            model__pb2.ServerMsg.FromString,\n            options, channel_credentials,\n            insecure, call_credentials, compression, wait_for_ready, timeout, metadata)\n\n    @staticmethod\n    def LargeFileReceive(request_iterator,\n            target,\n            options=(),\n            channel_credentials=None,\n            call_credentials=None,\n            insecure=False,\n            compression=None,\n            wait_for_ready=None,\n            timeout=None,\n            metadata=None):\n        return grpc.experimental.stream_unary(request_iterator, target, '/pbx.Node/LargeFileReceive',\n            model__pb2.FileUpReq.SerializeToString,\n            model__pb2.FileUpResp.FromString,\n            options, channel_credentials,\n            insecure, call_credentials, compression, wait_for_ready, timeout, metadata)\n\n    @staticmethod\n    def LargeFileServe(request,\n            target,\n            options=(),\n            channel_credentials=None,\n            call_credentials=None,\n            insecure=False,\n            compression=None,\n            wait_for_ready=None,\n            timeout=None,\n            metadata=None):\n        return grpc.experimental.unary_stream(request, target, '/pbx.Node/LargeFileServe',\n            model__pb2.FileDownReq.SerializeToString,\n            model__pb2.FileDownResp.FromString,\n            options, channel_credentials,\n            insecure, call_credentials, compression, wait_for_ready, timeout, metadata)\n\n\nclass PluginStub(object):\n    \"\"\"Plugin interface.\n    \"\"\"\n\n    def __init__(self, channel):\n        \"\"\"Constructor.\n\n        Args:\n            channel: A grpc.Channel.\n        \"\"\"\n        self.FireHose = channel.unary_unary(\n                '/pbx.Plugin/FireHose',\n                request_serializer=model__pb2.ClientReq.SerializeToString,\n                response_deserializer=model__pb2.ServerResp.FromString,\n                )\n        self.Find = channel.unary_unary(\n                '/pbx.Plugin/Find',\n                request_serializer=model__pb2.SearchQuery.SerializeToString,\n                response_deserializer=model__pb2.SearchFound.FromString,\n                )\n        self.Account = channel.unary_unary(\n                '/pbx.Plugin/Account',\n                request_serializer=model__pb2.AccountEvent.SerializeToString,\n                response_deserializer=model__pb2.Unused.FromString,\n                )\n        self.Topic = channel.unary_unary(\n                '/pbx.Plugin/Topic',\n                request_serializer=model__pb2.TopicEvent.SerializeToString,\n                response_deserializer=model__pb2.Unused.FromString,\n                )\n        self.Subscription = channel.unary_unary(\n                '/pbx.Plugin/Subscription',\n                request_serializer=model__pb2.SubscriptionEvent.SerializeToString,\n                response_deserializer=model__pb2.Unused.FromString,\n                )\n        self.Message = channel.unary_unary(\n                '/pbx.Plugin/Message',\n                request_serializer=model__pb2.MessageEvent.SerializeToString,\n                response_deserializer=model__pb2.Unused.FromString,\n                )\n\n\nclass PluginServicer(object):\n    \"\"\"Plugin interface.\n    \"\"\"\n\n    def FireHose(self, request, context):\n        \"\"\"This plugin method is called by Tinode server for every message received from the clients. The\n        method returns a ServerResp message. ServerResp.status tells Tinode server what to do next.\n        See possible values for ServerResp.status in RespCode below.\n        \"\"\"\n        context.set_code(grpc.StatusCode.UNIMPLEMENTED)\n        context.set_details('Method not implemented!')\n        raise NotImplementedError('Method not implemented!')\n\n    def Find(self, request, context):\n        \"\"\"An alteranative user and topic discovery mechanism.\n        A search request issued on a 'fnd' topic. This method is called to generate an alternative result set.\n        \"\"\"\n        context.set_code(grpc.StatusCode.UNIMPLEMENTED)\n        context.set_details('Method not implemented!')\n        raise NotImplementedError('Method not implemented!')\n\n    def Account(self, request, context):\n        \"\"\"The following methods are for the Tinode server to report individual events.\n\n        Account created, updated or deleted\n        \"\"\"\n        context.set_code(grpc.StatusCode.UNIMPLEMENTED)\n        context.set_details('Method not implemented!')\n        raise NotImplementedError('Method not implemented!')\n\n    def Topic(self, request, context):\n        \"\"\"Topic created, updated [or deleted -- not supported yet]\n        \"\"\"\n        context.set_code(grpc.StatusCode.UNIMPLEMENTED)\n        context.set_details('Method not implemented!')\n        raise NotImplementedError('Method not implemented!')\n\n    def Subscription(self, request, context):\n        \"\"\"Subscription created, updated or deleted\n        \"\"\"\n        context.set_code(grpc.StatusCode.UNIMPLEMENTED)\n        context.set_details('Method not implemented!')\n        raise NotImplementedError('Method not implemented!')\n\n    def Message(self, request, context):\n        \"\"\"Message published or deleted\n        \"\"\"\n        context.set_code(grpc.StatusCode.UNIMPLEMENTED)\n        context.set_details('Method not implemented!')\n        raise NotImplementedError('Method not implemented!')\n\n\ndef add_PluginServicer_to_server(servicer, server):\n    rpc_method_handlers = {\n            'FireHose': grpc.unary_unary_rpc_method_handler(\n                    servicer.FireHose,\n                    request_deserializer=model__pb2.ClientReq.FromString,\n                    response_serializer=model__pb2.ServerResp.SerializeToString,\n            ),\n            'Find': grpc.unary_unary_rpc_method_handler(\n                    servicer.Find,\n                    request_deserializer=model__pb2.SearchQuery.FromString,\n                    response_serializer=model__pb2.SearchFound.SerializeToString,\n            ),\n            'Account': grpc.unary_unary_rpc_method_handler(\n                    servicer.Account,\n                    request_deserializer=model__pb2.AccountEvent.FromString,\n                    response_serializer=model__pb2.Unused.SerializeToString,\n            ),\n            'Topic': grpc.unary_unary_rpc_method_handler(\n                    servicer.Topic,\n                    request_deserializer=model__pb2.TopicEvent.FromString,\n                    response_serializer=model__pb2.Unused.SerializeToString,\n            ),\n            'Subscription': grpc.unary_unary_rpc_method_handler(\n                    servicer.Subscription,\n                    request_deserializer=model__pb2.SubscriptionEvent.FromString,\n                    response_serializer=model__pb2.Unused.SerializeToString,\n            ),\n            'Message': grpc.unary_unary_rpc_method_handler(\n                    servicer.Message,\n                    request_deserializer=model__pb2.MessageEvent.FromString,\n                    response_serializer=model__pb2.Unused.SerializeToString,\n            ),\n    }\n    generic_handler = grpc.method_handlers_generic_handler(\n            'pbx.Plugin', rpc_method_handlers)\n    server.add_generic_rpc_handlers((generic_handler,))\n\n\n # This class is part of an EXPERIMENTAL API.\nclass Plugin(object):\n    \"\"\"Plugin interface.\n    \"\"\"\n\n    @staticmethod\n    def FireHose(request,\n            target,\n            options=(),\n            channel_credentials=None,\n            call_credentials=None,\n            insecure=False,\n            compression=None,\n            wait_for_ready=None,\n            timeout=None,\n            metadata=None):\n        return grpc.experimental.unary_unary(request, target, '/pbx.Plugin/FireHose',\n            model__pb2.ClientReq.SerializeToString,\n            model__pb2.ServerResp.FromString,\n            options, channel_credentials,\n            insecure, call_credentials, compression, wait_for_ready, timeout, metadata)\n\n    @staticmethod\n    def Find(request,\n            target,\n            options=(),\n            channel_credentials=None,\n            call_credentials=None,\n            insecure=False,\n            compression=None,\n            wait_for_ready=None,\n            timeout=None,\n            metadata=None):\n        return grpc.experimental.unary_unary(request, target, '/pbx.Plugin/Find',\n            model__pb2.SearchQuery.SerializeToString,\n            model__pb2.SearchFound.FromString,\n            options, channel_credentials,\n            insecure, call_credentials, compression, wait_for_ready, timeout, metadata)\n\n    @staticmethod\n    def Account(request,\n            target,\n            options=(),\n            channel_credentials=None,\n            call_credentials=None,\n            insecure=False,\n            compression=None,\n            wait_for_ready=None,\n            timeout=None,\n            metadata=None):\n        return grpc.experimental.unary_unary(request, target, '/pbx.Plugin/Account',\n            model__pb2.AccountEvent.SerializeToString,\n            model__pb2.Unused.FromString,\n            options, channel_credentials,\n            insecure, call_credentials, compression, wait_for_ready, timeout, metadata)\n\n    @staticmethod\n    def Topic(request,\n            target,\n            options=(),\n            channel_credentials=None,\n            call_credentials=None,\n            insecure=False,\n            compression=None,\n            wait_for_ready=None,\n            timeout=None,\n            metadata=None):\n        return grpc.experimental.unary_unary(request, target, '/pbx.Plugin/Topic',\n            model__pb2.TopicEvent.SerializeToString,\n            model__pb2.Unused.FromString,\n            options, channel_credentials,\n            insecure, call_credentials, compression, wait_for_ready, timeout, metadata)\n\n    @staticmethod\n    def Subscription(request,\n            target,\n            options=(),\n            channel_credentials=None,\n            call_credentials=None,\n            insecure=False,\n            compression=None,\n            wait_for_ready=None,\n            timeout=None,\n            metadata=None):\n        return grpc.experimental.unary_unary(request, target, '/pbx.Plugin/Subscription',\n            model__pb2.SubscriptionEvent.SerializeToString,\n            model__pb2.Unused.FromString,\n            options, channel_credentials,\n            insecure, call_credentials, compression, wait_for_ready, timeout, metadata)\n\n    @staticmethod\n    def Message(request,\n            target,\n            options=(),\n            channel_credentials=None,\n            call_credentials=None,\n            insecure=False,\n            compression=None,\n            wait_for_ready=None,\n            timeout=None,\n            metadata=None):\n        return grpc.experimental.unary_unary(request, target, '/pbx.Plugin/Message',\n            model__pb2.MessageEvent.SerializeToString,\n            model__pb2.Unused.FromString,\n            options, channel_credentials,\n            insecure, call_credentials, compression, wait_for_ready, timeout, metadata)\n"
  },
  {
    "path": "py_grpc/version.py",
    "content": "# Convert git tag like \"v0.15.5-rc5-3-g2084bd63\" to PEP 440 version like \"0.15.5rc5.post3\"\n\nfrom subprocess import check_output\n\ncommand = 'git describe --tags'\n\ndef git_version():\n    line = check_output(command.split()).decode('utf-8').strip()\n    if line.startswith(\"v\"):\n        line = line[1:]\n    if '-rc' in line:\n        line = line.replace('-rc', 'rc')\n    if '-beta' in line:\n        line = line.replace('-beta', 'b')\n    if '-alpha' in line:\n        line = line.replace('-alpha', 'a')\n    if '-' in line:\n        parts = line.split('-')\n        line = parts[0] + '.post' + parts[1]\n    return line\n\nif __name__ == '__main__':\n    with open('tinode_grpc/GIT_VERSION','w+') as fh:\n        fh.write(git_version())\n"
  },
  {
    "path": "rest-auth/README.md",
    "content": "# Example of a REST authenticator server.\n\nThis is an example of a server-side [REST authenticator](../server/auth/rest/). It's a basic Python script meant to be run as a web server. It implements the required endpoints. It responds to all requests with dummy data.\n\nThe service uses [Flask](http://flask.pocoo.org/), so make sure it's installed:\n```\npip install flask\n```\nRun the service as\n```\npython auth.py\n```\n"
  },
  {
    "path": "rest-auth/auth.py",
    "content": "#!/usr/bin/python\n\n# Sample Tinode REST/JSON-RPC authentication service.\n# See https://github.com/tinode/chat/rest-auth for details.\n\nfrom flask import Flask, jsonify, make_response, request\nimport base64\nimport json\n\ndummy_data = {}\n\napp = Flask(__name__)\n\ndef parse_secret(ecoded_secret):\n    secret = base64.b64decode(ecoded_secret)\n    return secret.split(':')\n\n@app.route('/')\ndef index():\n    return 'Sample Tinode REST/JSON-RPC authentication service. '+\\\n        'See <a href=\"https://github.com/tinode/chat/rest-auth/\">https://github.com/tinode/chat/rest-auth/</a> for details.'\n\n@app.route('/add', methods=['POST'])\ndef add():\n    return jsonify({'err': 'unsupported'})\n\n@app.route('/auth', methods=['POST'])\ndef auth():\n    if not request.json:\n        return jsonify({'err': 'malformed'})\n    uname, password = parse_secret(request.json.get('secret'))\n    if uname in dummy_data:\n        if dummy_data[uname]['password'] != password:\n            # Wrong password\n            return jsonify({'err': 'failed'})\n        if 'uid' in dummy_data[uname]:\n            # We have uname -> uid mapping\n            return jsonify({\n                'rec': {\n                    'uid': dummy_data[uname]['uid'],\n                    'authlvl': dummy_data[uname]['authlvl'],\n                    'features': dummy_data[uname]['features']\n                }\n            })\n        else:\n            # This is the first login. Tell Tinode to create a new account.\n            return jsonify({\n                'rec': {\n                    'authlvl': dummy_data[uname]['authlvl'],\n                    'tags': dummy_data[uname]['tags'],\n                    'features': dummy_data[uname]['features']\n                },\n                'newacc': {\n                    'auth': dummy_data[uname]['auth'],\n                    'anon': dummy_data[uname]['anon'],\n                    'public': dummy_data[uname]['public'],\n                    'private': dummy_data[uname]['private']\n                }\n            })\n        return jsonify({'err': 'unsupported'})\n    else:\n        return jsonify({'err': 'not found'})\n\n@app.route('/checkunique', methods=['POST'])\ndef checkunique():\n    return jsonify({'err': 'unsupported'})\n\n@app.route('/del', methods=['POST'])\ndef xdel():\n    return jsonify({'err': 'unsupported'})\n\n@app.route('/gen', methods=['POST'])\ndef gen():\n    return jsonify({'err': 'unsupported'})\n\n@app.route('/link', methods=['POST'])\ndef link():\n    if not request.json:\n        return jsonify({'err': 'malformed'})\n\n    rec = request.json.get('rec', None)\n    secret = request.json.get('secret', '')\n    if not rec or not rec['uid'] or not secret:\n        return jsonify({'err': 'malformed'})\n\n    # Save the link account <-> secret to database.\n    uname, password = parse_secret(secret)\n    if uname not in dummy_data:\n        # Unknown user name\n        return jsonify({'err': 'not found'})\n    if 'uid' in dummy_data[uname]:\n        # Already linked\n        return jsonify({'err': 'duplicate value'})\n\n    # Save updated data to file\n    dummy_data[uname]['uid'] = rec['uid']\n    with open('dummy_data.json', 'w') as outfile:\n        json.dump(dummy_data, outfile, indent=2, sort_keys=True)\n\n    # Success\n    return jsonify({})\n\n@app.route('/upd', methods=['POST'])\ndef upd():\n    return jsonify({'err': 'unsupported'})\n\n@app.route('/rtagns', methods=['POST'])\ndef rtags():\n    # Return dummy namespace \"rest\" and \"email\", let client check logins by regular expression.\n    return jsonify({'strarr': ['rest', 'email'], 'byteval': base64.b64encode('^[a-z0-9_]{3,8}$')})\n\n@app.errorhandler(404)\ndef not_found(error):\n    return make_response(jsonify({'err': 'not found'}), 404)\n\n@app.errorhandler(405)\ndef not_found(error):\n    return make_response(jsonify({'err': 'method not allowed'}), 405)\n\nif __name__ == '__main__':\n    # Load previously saved dummy data. Dummy data contains\n    # tinode user id <-> user name mapping and data for account creation.\n    with open('dummy_data.json') as infile:\n        dummy_data = json.load(infile)\n    app.run(debug=True)\n"
  },
  {
    "path": "rest-auth/dummy_data.json",
    "content": "{\n  \"alice\": {\n    \"anon\": \"N\",\n    \"auth\": \"JRWPA\",\n    \"authlvl\": \"auth\",\n    \"features\": \"V\",\n    \"password\": \"alice123\",\n    \"private\": \"email:bob@example.com,email:carol@example.com,email:dave@example.com,email:eve@example.com,email:frank@example.com\",\n    \"public\": {\n      \"fn\": \"Alice Johnson\",\n      \"photo\": {\n        \"data\": \"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAgACADASIAAhEBAxEB/8QAGQAAAwEBAQAAAAAAAAAAAAAAAQUGAwQI/8QAKRAAAgEDAgQFBQAAAAAAAAAAAQIDAAQRBTEGIUFREhMUYYEiQnGCo//EABYBAQEBAAAAAAAAAAAAAAAAAAABAv/EAB4RAAICAgMBAQAAAAAAAAAAAAECABEDIRNB4YGR/9oADAMBAAIRAxEAPwD1TQ5YoZ5ikvFNzJBpywWsvl3d3ItvEw3Utuw91UM361CaFzSKXYKO475GjSLhS9kvdKX1bq95bs1vO22XQ4Jx0zgN+CKd53oDYuHQoxU9QNvjoaj9Tj1DU+MFGny28cOlw/U00RkBmk7AMMFUH9asCcVhDDFG80kcaI0z+Nyox4jgLk9zgAfAoy3qbxZeMlgN1X75cl9GF7pXFdxbahJbyDU4/URtDEY18yMBXGCx5lSh3+01Y965praKWeCWREaSFiY2ZQSpIwSD05Ej5rUNge+9FFakzZeQhq3W/nlT/9k=\",\n        \"type\": \"jpeg\"\n      }\n    },\n    \"tags\": [\n      \"email:alice@example.com\"\n    ]\n  },\n  \"bob\": {\n    \"anon\": \"N\",\n    \"auth\": \"JRWPA\",\n    \"authlvl\": \"auth\",\n    \"features\": \"V\",\n    \"password\": \"bob123\",\n    \"private\": \"email:alice@example.com,email:carol@example.com,email:dave@example.com,email:eve@example.com,email:frank@example.com\",\n    \"public\": {\n      \"fn\": \"Bob Smith\",\n      \"photo\": {\n        \"data\": \"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAgACADASIAAhEBAxEB/8QAGgABAAEFAAAAAAAAAAAAAAAABQMAAQIECP/EACkQAAEDAgQFBAMAAAAAAAAAAAECAwQAEQUSITETIjJBgUJRUnFhofD/xAAXAQEBAQEAAAAAAAAAAAAAAAAAAgED/8QAHxEAAQUBAAIDAAAAAAAAAAAAEQABAgMSMXGhE2Hw/9oADAMBAAIRAxEAPwDqisb+4FWvv4obFC65i8RlD7rLamXVnIQLkKQBuPyaicsMQudk8RIPPacBvtVeKEw5x5M5yOp9choNhZUoDMg32JAA286U3SE9MUrntiFHbTXqIoTForMrHYKJLaFpDDx5hf1N07/CtSbAizVIMuOy8UdPEQFW+r1lsNsPCm2v5IZB536dFxG2oWLJjYblDRbUp1pJ5UG4ym3Ym6vvxT99NRUEWMxFa4cdpDSPigAD9VOe9zSuGGH5kprww9NxvC//2Q==\",\n        \"type\": \"jpeg\"\n      }\n    },\n    \"tags\": [\n      \"email:bob@example.com\"\n    ]\n  },\n  \"carol\": {\n    \"anon\": \"N\",\n    \"auth\": \"JRWPA\",\n    \"authlvl\": \"auth\",\n    \"features\": \"V\",\n    \"password\": \"carol123\",\n    \"private\": \"email:alice@example.com,email:bob@example.com,email:dave@example.com,email:eve@example.com,email:frank@example.com\",\n    \"public\": {\n      \"fn\": \"Carol Xmas\",\n      \"photo\": {\n        \"data\": \"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAgACADASIAAhEBAxEB/8QAGQAAAwEBAQAAAAAAAAAAAAAAAQUGBAMI/8QAJRAAAQQCAQMEAwAAAAAAAAAAAQIDBAUAERITIVEGMUFxFCJh/8QAFQEBAQAAAAAAAAAAAAAAAAAAAQL/xAAdEQEAAgEFAQAAAAAAAAAAAAABABECAxIhMcFB/9oADAMBAAIRAxEAPwD1RrAO31hyPkuzL71JPr4816DXVnBEhUfiHXnlpC+HIg8UhBSTrSiVDuAP2cNPdfNBywWpYYcU1Ncuv6iVWE2WlWilMlSVdP6ISD3/AKT7feNsEBobjB8ZH0zyK71vfwJJ6blk43YRSo9nQGW2VpT5KS0CR4cTlhmC0qoFqwGbKHGlsg8g2+0HEg+dH5ysMjGx6SvfIJNgcSVFOxyA2RnTFlVS1tQlxNVXxIYcIK/x2Q3y17b0O+M8lr51Gf/Z\",\n        \"type\": \"jpeg\"\n      }\n    },\n    \"tags\": [\n      \"email:carol@example.com\"\n    ]\n  },\n  \"dave\": {\n    \"anon\": \"N\",\n    \"auth\": \"JRWPA\",\n    \"authlvl\": \"auth\",\n    \"features\": \"V\",\n    \"password\": \"dave123\",\n    \"private\": \"email:alice@example.com,email:bob@example.com,email:carol@example.com,email:eve@example.com,email:frank@example.com\",\n    \"public\": {\n      \"fn\": \"Dave Goliathsson\",\n      \"photo\": {\n        \"data\": \"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAgACADASIAAhEBAxEB/8QAGQAAAwADAAAAAAAAAAAAAAAAAAQFAgMI/8QAIxAAAgIBAwQDAQAAAAAAAAAAAQIAAxEEITEFEhNBYXOxof/EABcBAQEBAQAAAAAAAAAAAAAAAAECAAP/xAAcEQACAwADAQAAAAAAAAAAAAABAgADERIxwUH/2gAMAwEAAhEDEQA/AOqMQ9wkxksu6lfX57URKkICEcktn18CcbH45g3YgSpCT6bbKtSlF9nkDglHIwduQcbShKRwwhCIVbdX1X01frx6LXaPTXv33UVWPjGXQE4kWKzYV+Hwj2Ii5ZdT1GrxN3JSGLMOMnYD9/kozBFWtQqAADgATZGusrpPZmM//9k=\",\n        \"type\": \"jpeg\"\n      }\n    },\n    \"tags\": [\n      \"email:dave@example.com\"\n    ]\n  },\n  \"eve\": {\n    \"anon\": \"N\",\n    \"auth\": \"JRWPA\",\n    \"authlvl\": \"auth\",\n    \"features\": \"V\",\n    \"password\": \"eve123\",\n    \"private\": \"email:alice@example.com,email:bob@example.com,email:carol@example.com,email:dave@example.com,email:frank@example.com\",\n    \"public\": {\n      \"fn\": \"Eve Adams\",\n      \"photo\": {\n        \"data\": \"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAgACADASIAAhEBAxEB/8QAGQAAAwEBAQAAAAAAAAAAAAAAAQUGAgQI/8QAIxAAAQQCAAcBAQAAAAAAAAAAAgEDBAUAEQYSEyExYXFBkf/EABUBAQEAAAAAAAAAAAAAAAAAAAIB/8QAGxEAAwEBAQEBAAAAAAAAAAAAAQIRABIhA3H/2gAMAwEAAhEDEQA/APVKZlfiZrymSt6EiVxJAhtzpMZgor7pIwSIpEJtIm9ov4RYkXozJE6Ms1R81h/MV1dYcIjIp8yTzJrT5oqD80iY0yEAHzRgAfDcFyUv4EWfxhVtzY7MhsYMgkF0EJEXqM9++VZeMXWVRXWhtLZQIkvp7QFfZFzl351tO3hP5iRuTcvk/DXGtqYFdzrXw2I5Hrm6QIO9eN6zvL1rF1dS1taZnXwIcUyTREwyLaqnvSYyX3kJptuLmm2/u//Z\",\n        \"type\": \"jpeg\"\n      }\n    },\n    \"tags\": [\n      \"email:eve@example.com\"\n    ]\n  },\n  \"frank\": {\n    \"anon\": \"N\",\n    \"auth\": \"JRWPA\",\n    \"authlvl\": \"auth\",\n    \"features\": \"V\",\n    \"password\": \"frank123\",\n    \"private\": \"email:alice@example.com,email:bob@example.com,email:carol@example.com,email:dave@example.com,email:eve@example.com\",\n    \"public\": {\n      \"fn\": \"Frank Singer\",\n      \"photo\": {\n        \"data\": \"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAgACADASIAAhEBAxEB/8QAGQAAAgMBAAAAAAAAAAAAAAAAAAUCAwQI/8QAJxAAAQQBAgUEAwAAAAAAAAAAAQACAwQREjEFEyFBgQYUU2FxcpH/xAAXAQADAQAAAAAAAAAAAAAAAAAAAQID/8QAHBEBAAMBAQADAAAAAAAAAAAAAQACEQMSUaHh/9oADAMBAAIRAxEAPwDqfHQoIwcdkZ3SXibZZeMVK0diaKJ0ErzyyBkh0YHb7KYazPpfxXc39joeFJYaVR9bUXWZ5tXyuBx+MBbUMdVTUyBCQ8TrR2vUNBkzGvb7aY4P7RJ605VLomGZspa3WGlodjqAdxnwP4nVx2T15nSvl+T6ZCpSgqauRG1mrfHdat0I8qV2XWpUwJ//2Q==\",\n        \"type\": \"jpeg\"\n      }\n    },\n    \"tags\": [\n      \"email:frank@example.com\"\n    ]\n  },\n  \"xena\": {\n    \"anon\": \"N\",\n    \"auth\": \"JRWPA\",\n    \"authlvl\": \"root\",\n    \"features\": \"V\",\n    \"password\": \"xena123\",\n    \"private\": \"\",\n    \"public\": {\n      \"fn\": \"Xena Peaceful Peasant\",\n      \"photo\": {\n        \"data\": \"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAgACADASIAAhEBAxEB/8QAGAABAQEBAQAAAAAAAAAAAAAABgAEBQj/xAArEAABAwMCBAQHAAAAAAAAAAABAgMEAAUREjEGISJhNEFRcRQjJTJCQ4L/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8A9U1VUeukuRar/EffcUu1TMRVg7R3s/LX7LzoPfR6mgQ1Ue4fmSbvOl3FLhTavDxG88ncHre9ieSeyc/lyQ0FRTi36y43ww2fGtlyaobtRQcHHopZ6R/RH20rrM1GYakOvtstIfeADiwkBS8bZPnjJoOBwbIcYadsM7R8baghoEJwHmP1OgbcwMEDZST5YpRWZUZgy0yiy2ZSUFsO6RqCSQSnO+MgHHatNB//2Q==\",\n        \"type\": \"jpeg\"\n      }\n    },\n    \"tags\": [\n      \"email:xena@example.com\"\n    ]\n  }\n}\n"
  },
  {
    "path": "rest-auth/requirements.txt",
    "content": "flask>=1.1.0\n"
  },
  {
    "path": "server/.golangci.yml",
    "content": "linters-settings:\n  govet:\n    check-shadowing: true\n    disable:\n      - composites\n  golint:\n    min-confidence: 0\n  gocyclo:\n    min-complexity: 10\n  maligned:\n    suggest-new: true\n  dupl:\n    threshold: 100\n  goconst:\n    min-len: 2\n    min-occurrences: 2\n  misspell:\n    locale: US\n  lll:\n    line-length: 140\n  gocritic:\n    enabled-tags:\n      - performance\n      - style\n      - experimental\n    disabled-checks:\n      - wrapperFunc\n      - commentFormatting # https://github.com/go-critic/go-critic/issues/755\n\nlinters:\n  enable-all: true\n  disable:\n    - errcheck\n    - maligned\n    - prealloc\n    - gosec\n    - gochecknoglobals\n\n# options for analysis running\nrun:\n  # list of build tags, all linters use it. Default is empty list.\n  build-tags:\n    - mysql\n    - rethinkdb\n    - mongodb\n"
  },
  {
    "path": "server/api_key.go",
    "content": "/******************************************************************************\n *\n *  Description :\n *\n *  Authentication\n *\n *****************************************************************************/\n\npackage main\n\nimport (\n\t\"bytes\"\n\t\"crypto/hmac\"\n\t\"crypto/md5\"\n\t\"encoding/base64\"\n\n\t\"github.com/tinode/chat/server/logs\"\n)\n\n// Singned AppID. Composition:\n//\n//\t[1:algorithm version][4:appid][2:key sequence][1:isRoot][16:signature] = 24 bytes\n//\n// convertible to base64 without padding. All integers are little-endian.\n// Definitions for byte lengths of key's parts.\nconst (\n\t// apikeyVersion is the version of this API scheme.\n\tapikeyVersion = 1\n\t// apikeyAppID is deprecated and will be removed in the future.\n\tapikeyAppID = 4\n\t// apikeySequence is the serial number of the key.\n\tapikeySequence = 2\n\t// apikeyWho indicates if the key grants root privileges.\n\tapikeyWho = 1\n\t// apikeySignature is key's cryptographic (HMAC) signature.\n\tapikeySignature = 16\n\t// apikeyLength is the length of the key in bytes.\n\tapikeyLength = apikeyVersion + apikeyAppID + apikeySequence + apikeyWho + apikeySignature\n)\n\n// Client signature validation\n//\n//\tkey: client's secret key\n//\n// Returns application id, key type.\nfunc checkAPIKey(apikey string) (isValid, isRoot bool) {\n\tif declen := base64.URLEncoding.DecodedLen(len(apikey)); declen != apikeyLength {\n\t\treturn\n\t}\n\n\tdata, err := base64.URLEncoding.DecodeString(apikey)\n\tif err != nil {\n\t\tlogs.Warn.Println(\"failed to decode.base64 appid \", err)\n\t\treturn\n\t}\n\tif data[0] != 1 {\n\t\tlogs.Warn.Println(\"unknown appid signature algorithm \", data[0])\n\t\treturn\n\t}\n\n\thasher := hmac.New(md5.New, globals.apiKeySalt)\n\thasher.Write(data[:apikeyVersion+apikeyAppID+apikeySequence+apikeyWho])\n\tcheck := hasher.Sum(nil)\n\tif !bytes.Equal(data[apikeyVersion+apikeyAppID+apikeySequence+apikeyWho:], check) {\n\t\tlogs.Warn.Println(\"invalid apikey signature\")\n\t\treturn\n\t}\n\n\tisRoot = (data[apikeyVersion+apikeyAppID+apikeySequence] == 1)\n\n\tisValid = true\n\n\treturn\n}\n"
  },
  {
    "path": "server/auth/anon/auth_anon.go",
    "content": "// Package anon provides authentication without credentials. Most useful for customer support.\n// Anonymous authentication is used only at the account creation time.\npackage anon\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/auth\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\n// authenticator is the singleton instance of the anonymous authorizer.\ntype authenticator struct {\n\tname string\n}\n\n// Init is a noop, always returns success.\nfunc (a *authenticator) Init(_ json.RawMessage, name string) error {\n\tif name == \"\" {\n\t\treturn errors.New(\"auth_anonymous: authenticator name cannot be blank\")\n\t}\n\n\tif a.name != \"\" {\n\t\treturn errors.New(\"auth_anonymous: already initialized as \" + a.name + \"; \" + name)\n\t}\n\n\ta.name = name\n\treturn nil\n}\n\n// IsInitialized returns true if the handler is initialized.\nfunc (a *authenticator) IsInitialized() bool {\n\treturn a.name != \"\"\n}\n\n// AddRecord checks authLevel and assigns default LevelAnon. Otherwise it\n// just reports success.\nfunc (authenticator) AddRecord(rec *auth.Rec, secret []byte, remoteAddr string) (*auth.Rec, error) {\n\tif rec.AuthLevel == auth.LevelNone {\n\t\trec.AuthLevel = auth.LevelAnon\n\t}\n\trec.State = types.StateOK\n\treturn rec, nil\n}\n\n// UpdateRecord is a noop. Just report success.\nfunc (authenticator) UpdateRecord(rec *auth.Rec, secret []byte, remoteAddr string) (*auth.Rec, error) {\n\treturn rec, nil\n}\n\n// Authenticate is not supported. This authenticator is used only at account creation time.\nfunc (authenticator) Authenticate(secret []byte, remoteAddr string) (*auth.Rec, []byte, error) {\n\treturn nil, nil, types.ErrUnsupported\n}\n\n// AsTag is not supported, will produce an empty string.\nfunc (authenticator) AsTag(token string) string {\n\treturn \"\"\n}\n\n// IsUnique for a noop. Anonymous login does not use secret, any secret is fine.\nfunc (authenticator) IsUnique(secret []byte, remoteAddr string) (bool, error) {\n\treturn true, nil\n}\n\n// GenSecret always fails.\nfunc (authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {\n\treturn nil, time.Time{}, types.ErrUnsupported\n}\n\n// DelRecords is a noop which always succeeds.\nfunc (authenticator) DelRecords(uid types.Uid) error {\n\treturn nil\n}\n\n// RestrictedTags returns tag namespaces restricted by this authenticator (none for anonymous).\nfunc (authenticator) RestrictedTags() ([]string, error) {\n\treturn nil, nil\n}\n\n// GetResetParams returns authenticator parameters passed to password reset handler\n// (none for anonymous).\nfunc (authenticator) GetResetParams(uid types.Uid) (map[string]any, error) {\n\treturn nil, nil\n}\n\nconst realName = \"anonymous\"\n\n// GetRealName returns the hardcoded name of the authenticator.\nfunc (authenticator) GetRealName() string {\n\treturn realName\n}\n\nfunc init() {\n\tstore.RegisterAuthScheme(realName, &authenticator{})\n}\n"
  },
  {
    "path": "server/auth/auth.go",
    "content": "// Package auth provides interfaces and types required for implementing an authenticaor.\npackage auth\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\n// Level is the type for authentication levels.\ntype Level int\n\n// Authentication levels\nconst (\n\t// LevelNone is undefined/not authenticated\n\tLevelNone Level = iota * 10\n\t// LevelAnon is anonymous user/light authentication\n\tLevelAnon\n\t// LevelAuth is fully authenticated user\n\tLevelAuth\n\t// LevelRoot is a superuser (currently unused)\n\tLevelRoot\n)\n\n// String implements Stringer interface: gets human-readable name for a numeric authentication level.\nfunc (a Level) String() string {\n\ts, err := a.MarshalText()\n\tif err != nil {\n\t\treturn \"unkn\"\n\t}\n\treturn string(s)\n}\n\n// ParseAuthLevel parses authentication level from a string.\nfunc ParseAuthLevel(name string) Level {\n\tswitch name {\n\tcase \"anon\", \"ANON\":\n\t\treturn LevelAnon\n\tcase \"auth\", \"AUTH\":\n\t\treturn LevelAuth\n\tcase \"root\", \"ROOT\":\n\t\treturn LevelRoot\n\tdefault:\n\t\treturn LevelNone\n\t}\n}\n\n// MarshalText converts Level to a slice of bytes with the name of the level.\nfunc (a Level) MarshalText() ([]byte, error) {\n\tswitch a {\n\tcase LevelNone:\n\t\treturn []byte(\"\"), nil\n\tcase LevelAnon:\n\t\treturn []byte(\"anon\"), nil\n\tcase LevelAuth:\n\t\treturn []byte(\"auth\"), nil\n\tcase LevelRoot:\n\t\treturn []byte(\"root\"), nil\n\tdefault:\n\t\treturn nil, errors.New(\"auth.Level: invalid level value\")\n\t}\n}\n\n// UnmarshalText parses authentication level from a string.\nfunc (a *Level) UnmarshalText(b []byte) error {\n\tswitch string(b) {\n\tcase \"\":\n\t\t*a = LevelNone\n\t\treturn nil\n\tcase \"anon\", \"ANON\":\n\t\t*a = LevelAnon\n\t\treturn nil\n\tcase \"auth\", \"AUTH\":\n\t\t*a = LevelAuth\n\t\treturn nil\n\tcase \"root\", \"ROOT\":\n\t\t*a = LevelRoot\n\t\treturn nil\n\tdefault:\n\t\treturn errors.New(\"auth.Level: unrecognized\")\n\t}\n}\n\n// MarshalJSON converts Level to a quoted string.\nfunc (a Level) MarshalJSON() ([]byte, error) {\n\tres, err := a.MarshalText()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn append(append([]byte{'\"'}, res...), '\"'), nil\n}\n\n// UnmarshalJSON reads Level from a quoted string.\nfunc (a *Level) UnmarshalJSON(b []byte) error {\n\tif b[0] != '\"' || b[len(b)-1] != '\"' {\n\t\treturn errors.New(\"syntax error\")\n\t}\n\n\treturn a.UnmarshalText(b[1 : len(b)-1])\n}\n\n// Feature is a bitmap of authenticated features, such as validated/not validated.\ntype Feature uint16\n\nconst (\n\t// FeatureValidated bit is set if user's credentials are already validated (V).\n\tFeatureValidated Feature = 1 << iota\n\t// FeatureNoLogin is set if the token should not be used to permanently authenticate a session (L).\n\tFeatureNoLogin\n)\n\n// MarshalText converts Feature to ASCII byte slice.\nfunc (f Feature) MarshalText() ([]byte, error) {\n\tres := []byte{}\n\tfor i, chr := range []byte{'V', 'L'} {\n\t\tif (f & (1 << uint(i))) != 0 {\n\t\t\tres = append(res, chr)\n\t\t}\n\t}\n\treturn res, nil\n}\n\n// UnmarshalText parses Feature string as byte slice.\nfunc (f *Feature) UnmarshalText(b []byte) error {\n\tvar f0 int\n\tvar err error\n\tif len(b) > 0 {\n\t\tif b[0] >= '0' && b[0] <= '9' {\n\t\t\tf0, err = strconv.Atoi(string(b))\n\t\t} else {\n\t\tLoop:\n\t\t\tfor i := range b {\n\t\t\t\tswitch b[i] {\n\t\t\t\tcase 'V', 'v':\n\t\t\t\t\tf0 |= int(FeatureValidated)\n\t\t\t\tcase 'L', 'l':\n\t\t\t\t\tf0 |= int(FeatureNoLogin)\n\t\t\t\tdefault:\n\t\t\t\t\terr = errors.New(\"Feature: invalid character '\" + string(b[i]) + \"'\")\n\t\t\t\t\tbreak Loop\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t*f = Feature(f0)\n\n\treturn err\n}\n\n// String Featureto a string representation.\nfunc (f Feature) String() string {\n\tres, err := f.MarshalText()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn string(res)\n}\n\n// MarshalJSON converts Feature to a quoted string.\nfunc (f Feature) MarshalJSON() ([]byte, error) {\n\tres, err := f.MarshalText()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn append(append([]byte{'\"'}, res...), '\"'), nil\n}\n\n// UnmarshalJSON reads Feature from a quoted string or an integer.\nfunc (f *Feature) UnmarshalJSON(b []byte) error {\n\tif b[0] == '\"' && b[len(b)-1] == '\"' {\n\t\treturn f.UnmarshalText(b[1 : len(b)-1])\n\t}\n\treturn f.UnmarshalText(b)\n}\n\n// Duration is identical to time.Duration except it can be sanely unmarshallend from JSON.\ntype Duration time.Duration\n\n// UnmarshalJSON handles the cases where duration is specified in JSON as a \"5000s\" string or just plain seconds.\nfunc (d *Duration) UnmarshalJSON(b []byte) error {\n\tvar v any\n\tif err := json.Unmarshal(b, &v); err != nil {\n\t\treturn err\n\t}\n\tswitch value := v.(type) {\n\tcase float64:\n\t\t*d = Duration(time.Duration(value) * time.Second)\n\t\treturn nil\n\tcase string:\n\t\td0, err := time.ParseDuration(value)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t*d = Duration(d0)\n\t\treturn nil\n\tdefault:\n\t\treturn errors.New(\"invalid duration\")\n\t}\n}\n\n// Rec is an authentication record.\ntype Rec struct {\n\t// User ID.\n\tUid types.Uid `json:\"uid,omitempty\"`\n\t// Authentication level.\n\tAuthLevel Level `json:\"authlvl,omitempty\"`\n\t// Lifetime of this record.\n\tLifetime Duration `json:\"lifetime,omitempty\"`\n\t// Bitmap of features. Currently 'validated'/'not validated' only.\n\tFeatures Feature `json:\"features,omitempty\"`\n\t// Tags generated by this authentication record.\n\tTags []string `json:\"tags,omitempty\"`\n\t// User account state received or read by the authenticator.\n\tState types.ObjState\n\t// Credential 'method:value' associated with this record.\n\tCredential string `json:\"cred,omitempty\"`\n\n\t// Authenticator may request the server to create a new account.\n\t// These are the account parameters which can be used for creating the account.\n\tDefAcs  *types.DefaultAccess `json:\"defacs,omitempty\"`\n\tPublic  any                  `json:\"public,omitempty\"`\n\tPrivate any                  `json:\"private,omitempty\"`\n}\n\n// AuthHandler is the interface which auth providers must implement.\ntype AuthHandler interface {\n\t// Init initializes the handler taking config string and logical name as parameters.\n\tInit(jsonconf json.RawMessage, name string) error\n\n\t// IsInitialized returns true if the handler is initialized.\n\tIsInitialized() bool\n\n\t// AddRecord adds persistent authentication record to the database.\n\t// Returns: updated auth record, error\n\tAddRecord(rec *Rec, secret []byte, remoteAddr string) (*Rec, error)\n\n\t// UpdateRecord updates existing record with new credentials.\n\t// Returns updated auth record, error.\n\tUpdateRecord(rec *Rec, secret []byte, remoteAddr string) (*Rec, error)\n\n\t// Authenticate: given a user-provided authentication secret (such as \"login:password\"), either\n\t// return user's record (ID, time when the secret expires, etc), or issue a challenge to\n\t// continue the authentication process to the next step, or return an error code.\n\t// The remoteAddr (i.e. the IP address of the client) can be used by custom authenticators for\n\t// additional validation. The stock authenticators don't use it.\n\t// store.Users.GetAuthRecord(\"scheme\", \"unique\")\n\t// Returns: user auth record, challenge, error.\n\tAuthenticate(secret []byte, remoteAddr string) (*Rec, []byte, error)\n\n\t// AsTag converts search token into prefixed tag or an empty string if it\n\t// cannot be represented as a prefixed tag.\n\tAsTag(token string) string\n\n\t// IsUnique verifies if the provided secret can be considered unique by the auth scheme\n\t// E.g. if login is unique. It also may check for policy compliance, i.e. not too short, etc.\n\tIsUnique(secret []byte, remoteAddr string) (bool, error)\n\n\t// GenSecret generates a new secret, if appropriate.\n\tGenSecret(rec *Rec) ([]byte, time.Time, error)\n\n\t// DelRecords deletes (or disables) all authentication records for the given user.\n\tDelRecords(uid types.Uid) error\n\n\t// RestrictedTags returns the tag namespaces (prefixes) which are restricted by this authenticator.\n\tRestrictedTags() ([]string, error)\n\n\t// GetResetParams returns authenticator parameters passed to password reset handler\n\t// for the provided user id.\n\t// Returns: map of params.\n\tGetResetParams(uid types.Uid) (map[string]any, error)\n\n\t// GetRealName returns the hardcoded name of the authenticator.\n\tGetRealName() string\n}\n"
  },
  {
    "path": "server/auth/basic/auth_basic.go",
    "content": "// Package basic is an authenticator by login-password.\npackage basic\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/auth\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/types\"\n\n\t\"golang.org/x/crypto/bcrypt\"\n)\n\n// Define default constraints on login and password\nconst (\n\tdefaultMinLoginLength = 2\n\tdefaultMaxLoginLength = 32\n\n\tdefaultMinPasswordLength = 3\n)\n\n// Token suitable as a login: starts and ends with a Unicode letter (class L) or number (class N),\n// contains Unicode letters, numbers, dot and underscore.\nvar loginPattern = regexp.MustCompile(`^[\\pL\\pN][_.\\pL\\pN]*[\\pL\\pN]+$`)\n\n// authenticator is the type to map authentication methods to.\ntype authenticator struct {\n\tname      string\n\taddToTags bool\n\n\tminPasswordLength int\n\tminLoginLength    int\n}\n\nfunc (a *authenticator) checkLoginPolicy(uname string) error {\n\trlogin := []rune(uname)\n\tif len(rlogin) < a.minLoginLength || len(rlogin) > defaultMaxLoginLength || !loginPattern.MatchString(uname) {\n\t\treturn types.ErrPolicy\n\t}\n\n\treturn nil\n}\n\nfunc (a *authenticator) checkPasswordPolicy(password string) error {\n\tif len([]rune(password)) < a.minPasswordLength {\n\t\treturn types.ErrPolicy\n\t}\n\n\treturn nil\n}\n\nfunc parseSecret(bsecret []byte) (uname, password string, err error) {\n\tsecret := string(bsecret)\n\n\tsplitAt := strings.Index(secret, \":\")\n\tif splitAt < 0 {\n\t\terr = types.ErrMalformed\n\t\treturn\n\t}\n\n\tuname = strings.ToLower(secret[:splitAt])\n\tpassword = secret[splitAt+1:]\n\treturn\n}\n\n// Init initializes the basic authenticator.\nfunc (a *authenticator) Init(jsonconf json.RawMessage, name string) error {\n\tif name == \"\" {\n\t\treturn errors.New(\"auth_basic: authenticator name cannot be blank\")\n\t}\n\n\tif a.name != \"\" {\n\t\treturn errors.New(\"auth_basic: already initialized as \" + a.name + \"; \" + name)\n\t}\n\n\ttype configType struct {\n\t\t// AddToTags indicates that the user name should be used as a searchable tag.\n\t\tAddToTags         bool `json:\"add_to_tags\"`\n\t\tMinPasswordLength int  `json:\"min_password_length\"`\n\t\tMinLoginLength    int  `json:\"min_login_length\"`\n\t}\n\n\tvar config configType\n\tif err := json.Unmarshal(jsonconf, &config); err != nil {\n\t\treturn errors.New(\"auth_basic: failed to parse config: \" + err.Error() + \"(\" + string(jsonconf) + \")\")\n\t}\n\ta.name = name\n\ta.addToTags = config.AddToTags\n\ta.minPasswordLength = config.MinPasswordLength\n\tif a.minPasswordLength <= 0 {\n\t\ta.minPasswordLength = defaultMinPasswordLength\n\t}\n\ta.minLoginLength = config.MinLoginLength\n\tif a.minLoginLength > defaultMaxLoginLength {\n\t\treturn errors.New(\"auth_basic: min_login_length exceeds the limit\")\n\t}\n\tif a.minLoginLength <= 0 {\n\t\ta.minLoginLength = defaultMinLoginLength\n\t}\n\n\treturn nil\n}\n\n// IsInitialized returns true if the handler is initialized.\nfunc (a *authenticator) IsInitialized() bool {\n\treturn a.name != \"\"\n}\n\n// AddRecord adds a basic authentication record to DB.\nfunc (a *authenticator) AddRecord(rec *auth.Rec, secret []byte, remoteAddr string) (*auth.Rec, error) {\n\tuname, password, err := parseSecret(secret)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err = a.checkLoginPolicy(uname); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err = a.checkPasswordPolicy(password); err != nil {\n\t\treturn nil, err\n\t}\n\n\tpasshash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar expires time.Time\n\tif rec.Lifetime > 0 {\n\t\texpires = time.Now().Add(time.Duration(rec.Lifetime)).UTC().Round(time.Millisecond)\n\t}\n\n\tauthLevel := rec.AuthLevel\n\tif authLevel == auth.LevelNone {\n\t\tauthLevel = auth.LevelAuth\n\t}\n\n\terr = store.Users.AddAuthRecord(rec.Uid, authLevel, a.name, uname, passhash, expires)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\trec.AuthLevel = authLevel\n\tif a.addToTags {\n\t\trec.Tags = append(rec.Tags, a.name+\":\"+uname)\n\t}\n\treturn rec, nil\n}\n\n// UpdateRecord updates password for basic authentication.\nfunc (a *authenticator) UpdateRecord(rec *auth.Rec, secret []byte, remoteAddr string) (*auth.Rec, error) {\n\tuname, password, err := parseSecret(secret)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlogin, authLevel, _, _, err := store.Users.GetAuthRecord(rec.Uid, a.name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t// User does not have a record.\n\tif login == \"\" {\n\t\treturn nil, types.ErrNotFound\n\t}\n\n\tif uname == \"\" || uname == login {\n\t\t// User is changing just the password.\n\t\tuname = login\n\t} else if err = a.checkLoginPolicy(uname); err != nil {\n\t\treturn nil, err\n\t} else if uid, _, _, _, err := store.Users.GetAuthUniqueRecord(a.name, uname); err != nil {\n\t\treturn nil, err\n\t} else if !uid.IsZero() {\n\t\t// The (new) user name already exists. Report an error.\n\t\treturn nil, types.ErrDuplicate\n\t}\n\n\tif err = a.checkPasswordPolicy(password); err != nil {\n\t\treturn nil, err\n\t}\n\n\tpasshash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)\n\tif err != nil {\n\t\treturn nil, types.ErrInternal\n\t}\n\tvar expires time.Time\n\tif rec.Lifetime > 0 {\n\t\texpires = types.TimeNow().Add(time.Duration(rec.Lifetime))\n\t}\n\terr = store.Users.UpdateAuthRecord(rec.Uid, authLevel, a.name, uname, passhash, expires)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Remove old tag from the list of tags\n\toldTag := a.name + \":\" + login\n\tfor i, tag := range rec.Tags {\n\t\tif tag == oldTag {\n\t\t\trec.Tags[i] = rec.Tags[len(rec.Tags)-1]\n\t\t\trec.Tags = rec.Tags[:len(rec.Tags)-1]\n\n\t\t\tbreak\n\t\t}\n\t}\n\t// Add new tag\n\trec.Tags = append(rec.Tags, a.name+\":\"+uname)\n\n\treturn rec, nil\n}\n\n// Authenticate checks login and password.\nfunc (a *authenticator) Authenticate(secret []byte, remoteAddr string) (*auth.Rec, []byte, error) {\n\tuname, password, err := parseSecret(secret)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tuid, authLvl, passhash, expires, err := store.Users.GetAuthUniqueRecord(a.name, uname)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tif uid.IsZero() {\n\t\t// Invalid login.\n\t\treturn nil, nil, types.ErrFailed\n\t}\n\tif !expires.IsZero() && expires.Before(time.Now()) {\n\t\t// The record has expired\n\t\treturn nil, nil, types.ErrExpired\n\t}\n\n\terr = bcrypt.CompareHashAndPassword(passhash, []byte(password))\n\tif err != nil {\n\t\t// Invalid password\n\t\treturn nil, nil, types.ErrFailed\n\t}\n\n\tvar lifetime time.Duration\n\tif !expires.IsZero() {\n\t\tlifetime = time.Until(expires)\n\t}\n\treturn &auth.Rec{\n\t\tUid:       uid,\n\t\tAuthLevel: authLvl,\n\t\tLifetime:  auth.Duration(lifetime),\n\t\tFeatures:  0,\n\t\tState:     types.StateUndefined}, nil, nil\n}\n\n// AsTag convert search token into a prefixed tag, if possible.\nfunc (a *authenticator) AsTag(token string) string {\n\tif !a.addToTags {\n\t\treturn \"\"\n\t}\n\n\tif err := a.checkLoginPolicy(token); err != nil {\n\t\treturn \"\"\n\t}\n\n\treturn a.name + \":\" + token\n}\n\n// IsUnique checks login uniqueness and policy compliance.\nfunc (a *authenticator) IsUnique(secret []byte, remoteAddr string) (bool, error) {\n\tuname, _, err := parseSecret(secret)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\tif err := a.checkLoginPolicy(uname); err != nil {\n\t\treturn false, err\n\t}\n\n\tuid, _, _, _, err := store.Users.GetAuthUniqueRecord(a.name, uname)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\tif uid.IsZero() {\n\t\treturn true, nil\n\t}\n\treturn false, types.ErrDuplicate\n}\n\n// GenSecret is not supported, generates an error.\nfunc (authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {\n\treturn nil, time.Time{}, types.ErrUnsupported\n}\n\n// DelRecords deletes saved authentication records of the given user.\nfunc (a *authenticator) DelRecords(uid types.Uid) error {\n\treturn store.Users.DelAuthRecords(uid, a.name)\n}\n\n// RestrictedTags returns tag namespaces (prefixes) restricted by this adapter.\nfunc (a *authenticator) RestrictedTags() ([]string, error) {\n\tvar prefix []string\n\tif a.addToTags {\n\t\tprefix = []string{a.name}\n\t}\n\treturn prefix, nil\n}\n\n// GetResetParams returns authenticator parameters passed to password reset handler.\nfunc (a *authenticator) GetResetParams(uid types.Uid) (map[string]any, error) {\n\tlogin, _, _, _, err := store.Users.GetAuthRecord(uid, a.name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t// User does not have a record matching the authentication scheme.\n\tif login == \"\" {\n\t\treturn nil, types.ErrNotFound\n\t}\n\n\tparams := make(map[string]any)\n\tparams[\"login\"] = login\n\treturn params, nil\n}\n\nconst realName = \"basic\"\n\n// GetRealName returns the hardcoded name of the authenticator.\nfunc (authenticator) GetRealName() string {\n\treturn realName\n}\n\nfunc init() {\n\tstore.RegisterAuthScheme(realName, &authenticator{})\n}\n"
  },
  {
    "path": "server/auth/code/auth_code.go",
    "content": "// Package code implements temporary no-login authentication by short numeric code.\npackage code\n\nimport (\n\t\"crypto/rand\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"math/big\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/auth\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\n// authenticator is a singleton instance of the authenticator.\ntype authenticator struct {\n\tname         string\n\tcodeLength   int\n\tmaxCodeValue *big.Int\n\tlifetime     time.Duration\n\tmaxRetries   int\n}\n\n// Init initializes the authenticator: parses the config and sets internal state.\nfunc (ca *authenticator) Init(jsonconf json.RawMessage, name string) error {\n\tif name == \"\" {\n\t\treturn errors.New(\"auth_code: authenticator name cannot be blank\")\n\t}\n\n\tif ca.name != \"\" {\n\t\treturn errors.New(\"auth_code: already initialized as \" + ca.name + \"; \" + name)\n\t}\n\n\ttype configType struct {\n\t\t// Length of the security code.\n\t\tCodeLength int `json:\"code_length\"`\n\t\t// Code expiration time in seconds.\n\t\tExpireIn int `json:\"expire_in\"`\n\t\t// Maximum number of verification attempts per code.\n\t\tMaxRetries int `json:\"max_retries\"`\n\t}\n\tvar config configType\n\tif err := json.Unmarshal(jsonconf, &config); err != nil {\n\t\treturn errors.New(\"auth_code: failed to parse config: \" + err.Error() + \"(\" + string(jsonconf) + \")\")\n\t}\n\n\tif config.ExpireIn <= 0 {\n\t\treturn errors.New(\"auth_code: invalid expiration period\")\n\t}\n\n\tif config.CodeLength < 4 {\n\t\treturn errors.New(\"auth_code: invalid code length\")\n\t}\n\n\tif config.MaxRetries < 1 {\n\t\treturn errors.New(\"auth_code: invalid retries count\")\n\t}\n\n\tca.name = name\n\tca.codeLength = config.CodeLength\n\tca.maxCodeValue = big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(ca.codeLength)), nil)\n\tca.lifetime = time.Duration(config.ExpireIn) * time.Second\n\tca.maxRetries = config.MaxRetries\n\n\treturn nil\n}\n\n// IsInitialized returns true if the handler is initialized.\nfunc (ca *authenticator) IsInitialized() bool {\n\treturn ca.name != \"\"\n}\n\n// AddRecord is not supported, will produce an error.\nfunc (authenticator) AddRecord(rec *auth.Rec, secret []byte, remoteAddr string) (*auth.Rec, error) {\n\treturn nil, types.ErrUnsupported\n}\n\n// UpdateRecord is not supported, will produce an error.\nfunc (authenticator) UpdateRecord(rec *auth.Rec, secret []byte, remoteAddr string) (*auth.Rec, error) {\n\treturn nil, types.ErrUnsupported\n}\n\n// Authenticate checks validity of provided short code.\n// The secret is structured as <code>:<cred_method>:<cred_value>, \"123456:email:alice@example.com\".\nfunc (ca *authenticator) Authenticate(secret []byte, remoteAddr string) (*auth.Rec, []byte, error) {\n\tparts := strings.SplitN(string(secret), \":\", 2)\n\tif len(parts) != 2 {\n\t\treturn nil, nil, types.ErrMalformed\n\t}\n\n\tcode, cred := parts[0], parts[1]\n\tkey := sanitizeKey(realName + \"_\" + cred)\n\n\tvalue, err := store.PCache.Get(key)\n\tif err != nil {\n\t\tif err == types.ErrNotFound {\n\t\t\terr = types.ErrFailed\n\t\t}\n\t\treturn nil, nil, err\n\t}\n\n\t// code:count:uid\n\tparts = strings.Split(value, \":\")\n\tif len(parts) != 3 {\n\t\treturn nil, nil, types.ErrInternal\n\t}\n\n\tcount, err := strconv.Atoi(parts[1])\n\tif err != nil {\n\t\treturn nil, nil, types.ErrInternal\n\t}\n\n\tif count >= ca.maxRetries {\n\t\treturn nil, nil, types.ErrFailed\n\t}\n\n\tif parts[0] != code {\n\t\t// Update count of attempts. If the update fails, the error is ignored.\n\t\tstore.PCache.Upsert(key, parts[0]+\":\"+strconv.Itoa(count+1)+\":\"+parts[2], false)\n\t\treturn nil, nil, types.ErrFailed\n\t}\n\n\t// Success. Remove no longer needed entry. The error is ignored here.\n\tif err = store.PCache.Delete(key); err != nil {\n\t\tlogs.Warn.Println(\"code_auth: error deleting key\", key, err)\n\t}\n\n\treturn &auth.Rec{\n\t\tUid:        types.ParseUid(parts[2]),\n\t\tAuthLevel:  auth.LevelNone,\n\t\tLifetime:   auth.Duration(ca.lifetime),\n\t\tFeatures:   auth.FeatureNoLogin,\n\t\tState:      types.StateUndefined,\n\t\tCredential: cred}, nil, nil\n}\n\n// GenSecret generates a new code.\nfunc (ca *authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {\n\t// Run garbage collection.\n\tstore.PCache.Expire(realName+\"_\", time.Now().UTC().Add(-ca.lifetime))\n\n\t// Generate random code.\n\tcode, err := rand.Int(rand.Reader, ca.maxCodeValue)\n\tif err != nil {\n\t\treturn nil, time.Time{}, types.ErrInternal\n\t}\n\n\t// Convert the code to fixed length string.\n\tresp := strconv.FormatInt(code.Int64(), 10)\n\tresp = strings.Repeat(\"0\", ca.codeLength-len(resp)) + resp\n\n\tif rec.Lifetime == 0 {\n\t\trec.Lifetime = auth.Duration(ca.lifetime)\n\t} else if rec.Lifetime < 0 {\n\t\treturn nil, time.Time{}, types.ErrExpired\n\t}\n\n\t// Save \"code:counter:uid\" to the database. The key is code_<credential>.\n\tif err = store.PCache.Upsert(sanitizeKey(realName+\"_\"+rec.Credential), resp+\":0:\"+rec.Uid.String(), true); err != nil {\n\t\treturn nil, time.Time{}, err\n\t}\n\n\texpires := time.Now().Add(time.Duration(rec.Lifetime)).UTC().Round(time.Millisecond)\n\n\treturn []byte(resp), expires, nil\n}\n\n// AsTag is not supported, will produce an empty string.\nfunc (authenticator) AsTag(token string) string {\n\treturn \"\"\n}\n\n// IsUnique is not supported, will produce an error.\nfunc (authenticator) IsUnique(secret []byte, remoteAddr string) (bool, error) {\n\treturn false, types.ErrUnsupported\n}\n\n// DelRecords adds disabled user ID to a stop list.\nfunc (authenticator) DelRecords(uid types.Uid) error {\n\treturn nil\n}\n\n// RestrictedTags returns tag namespaces restricted by this authenticator (none for short code).\nfunc (authenticator) RestrictedTags() ([]string, error) {\n\treturn nil, nil\n}\n\n// GetResetParams returns authenticator parameters passed to password reset handler\n// (none for short code).\nfunc (authenticator) GetResetParams(uid types.Uid) (map[string]any, error) {\n\treturn nil, nil\n}\n\n// Replace all occurrences of '%' with '/' to ensure SQL LIKE query works correctly.\nfunc sanitizeKey(key string) string {\n\treturn strings.ReplaceAll(key, \"%\", \"/\")\n}\n\nconst realName = \"code\"\n\n// GetRealName returns the hardcoded name of the authenticator.\nfunc (authenticator) GetRealName() string {\n\treturn realName\n}\n\nfunc init() {\n\tstore.RegisterAuthScheme(realName, &authenticator{})\n}\n"
  },
  {
    "path": "server/auth/mock_auth/mock_auth.go",
    "content": "// Code generated by MockGen. DO NOT EDIT.\n// Source: auth/auth.go\n\n// Package mock_auth is a generated GoMock package.\npackage mock_auth\n\nimport (\n\tjson \"encoding/json\"\n\treflect \"reflect\"\n\ttime \"time\"\n\n\tgomock \"github.com/golang/mock/gomock\"\n\tauth \"github.com/tinode/chat/server/auth\"\n\ttypes \"github.com/tinode/chat/server/store/types\"\n)\n\n// MockAuthHandler is a mock of AuthHandler interface.\ntype MockAuthHandler struct {\n\tctrl     *gomock.Controller\n\trecorder *MockAuthHandlerMockRecorder\n}\n\n// MockAuthHandlerMockRecorder is the mock recorder for MockAuthHandler.\ntype MockAuthHandlerMockRecorder struct {\n\tmock *MockAuthHandler\n}\n\n// NewMockAuthHandler creates a new mock instance.\nfunc NewMockAuthHandler(ctrl *gomock.Controller) *MockAuthHandler {\n\tmock := &MockAuthHandler{ctrl: ctrl}\n\tmock.recorder = &MockAuthHandlerMockRecorder{mock}\n\treturn mock\n}\n\n// EXPECT returns an object that allows the caller to indicate expected use.\nfunc (m *MockAuthHandler) EXPECT() *MockAuthHandlerMockRecorder {\n\treturn m.recorder\n}\n\n// AddRecord mocks base method.\nfunc (m *MockAuthHandler) AddRecord(rec *auth.Rec, secret []byte, remoteAddr string) (*auth.Rec, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"AddRecord\", rec, secret, remoteAddr)\n\tret0, _ := ret[0].(*auth.Rec)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// AddRecord indicates an expected call of AddRecord.\nfunc (mr *MockAuthHandlerMockRecorder) AddRecord(rec, secret, remoteAddr interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"AddRecord\", reflect.TypeOf((*MockAuthHandler)(nil).AddRecord), rec, secret, remoteAddr)\n}\n\n// AsTag mocks base method.\nfunc (m *MockAuthHandler) AsTag(token string) string {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"AsTag\", token)\n\tret0, _ := ret[0].(string)\n\treturn ret0\n}\n\n// AsTag indicates an expected call of AsTag.\nfunc (mr *MockAuthHandlerMockRecorder) AsTag(token interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"AsTag\", reflect.TypeOf((*MockAuthHandler)(nil).AsTag), token)\n}\n\n// Authenticate mocks base method.\nfunc (m *MockAuthHandler) Authenticate(secret []byte, remoteAddr string) (*auth.Rec, []byte, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Authenticate\", secret, remoteAddr)\n\tret0, _ := ret[0].(*auth.Rec)\n\tret1, _ := ret[1].([]byte)\n\tret2, _ := ret[2].(error)\n\treturn ret0, ret1, ret2\n}\n\n// Authenticate indicates an expected call of Authenticate.\nfunc (mr *MockAuthHandlerMockRecorder) Authenticate(secret, remoteAddr interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Authenticate\", reflect.TypeOf((*MockAuthHandler)(nil).Authenticate), secret, remoteAddr)\n}\n\n// DelRecords mocks base method.\nfunc (m *MockAuthHandler) DelRecords(uid types.Uid) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"DelRecords\", uid)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// DelRecords indicates an expected call of DelRecords.\nfunc (mr *MockAuthHandlerMockRecorder) DelRecords(uid interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"DelRecords\", reflect.TypeOf((*MockAuthHandler)(nil).DelRecords), uid)\n}\n\n// GenSecret mocks base method.\nfunc (m *MockAuthHandler) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GenSecret\", rec)\n\tret0, _ := ret[0].([]byte)\n\tret1, _ := ret[1].(time.Time)\n\tret2, _ := ret[2].(error)\n\treturn ret0, ret1, ret2\n}\n\n// GenSecret indicates an expected call of GenSecret.\nfunc (mr *MockAuthHandlerMockRecorder) GenSecret(rec interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GenSecret\", reflect.TypeOf((*MockAuthHandler)(nil).GenSecret), rec)\n}\n\n// GetRealName mocks base method.\nfunc (m *MockAuthHandler) GetRealName() string {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetRealName\")\n\tret0, _ := ret[0].(string)\n\treturn ret0\n}\n\n// GetRealName indicates an expected call of GetRealName.\nfunc (mr *MockAuthHandlerMockRecorder) GetRealName() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetRealName\", reflect.TypeOf((*MockAuthHandler)(nil).GetRealName))\n}\n\n// GetResetParams mocks base method.\nfunc (m *MockAuthHandler) GetResetParams(uid types.Uid) (map[string]any, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetResetParams\", uid)\n\tret0, _ := ret[0].(map[string]any)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// GetResetParams indicates an expected call of GetResetParams.\nfunc (mr *MockAuthHandlerMockRecorder) GetResetParams(uid interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetResetParams\", reflect.TypeOf((*MockAuthHandler)(nil).GetResetParams), uid)\n}\n\n// Init mocks base method.\nfunc (m *MockAuthHandler) Init(jsonconf json.RawMessage, name string) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Init\", jsonconf, name)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// Init indicates an expected call of Init.\nfunc (mr *MockAuthHandlerMockRecorder) Init(jsonconf, name interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Init\", reflect.TypeOf((*MockAuthHandler)(nil).Init), jsonconf, name)\n}\n\n// IsInitialized mocks base method.\nfunc (m *MockAuthHandler) IsInitialized() bool {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"IsInitialized\")\n\tret0, _ := ret[0].(bool)\n\treturn ret0\n}\n\n// IsInitialized indicates an expected call of IsInitialized.\nfunc (mr *MockAuthHandlerMockRecorder) IsInitialized() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"IsInitialized\", reflect.TypeOf((*MockAuthHandler)(nil).IsInitialized))\n}\n\n// IsUnique mocks base method.\nfunc (m *MockAuthHandler) IsUnique(secret []byte, remoteAddr string) (bool, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"IsUnique\", secret, remoteAddr)\n\tret0, _ := ret[0].(bool)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// IsUnique indicates an expected call of IsUnique.\nfunc (mr *MockAuthHandlerMockRecorder) IsUnique(secret, remoteAddr interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"IsUnique\", reflect.TypeOf((*MockAuthHandler)(nil).IsUnique), secret, remoteAddr)\n}\n\n// RestrictedTags mocks base method.\nfunc (m *MockAuthHandler) RestrictedTags() ([]string, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"RestrictedTags\")\n\tret0, _ := ret[0].([]string)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// RestrictedTags indicates an expected call of RestrictedTags.\nfunc (mr *MockAuthHandlerMockRecorder) RestrictedTags() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"RestrictedTags\", reflect.TypeOf((*MockAuthHandler)(nil).RestrictedTags))\n}\n\n// UpdateRecord mocks base method.\nfunc (m *MockAuthHandler) UpdateRecord(rec *auth.Rec, secret []byte, remoteAddr string) (*auth.Rec, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"UpdateRecord\", rec, secret, remoteAddr)\n\tret0, _ := ret[0].(*auth.Rec)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// UpdateRecord indicates an expected call of UpdateRecord.\nfunc (mr *MockAuthHandlerMockRecorder) UpdateRecord(rec, secret, remoteAddr interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"UpdateRecord\", reflect.TypeOf((*MockAuthHandler)(nil).UpdateRecord), rec, secret, remoteAddr)\n}\n"
  },
  {
    "path": "server/auth/rest/README.md",
    "content": "# REST or JSON-RPC authenticator\n\nThis authenticator permits authentication of Tinode users and creation of Tinode accounts using a separate process as a\nsource of truth. For instance, if accounts are managed by corporate LDAP, this service allows handling of Tinode\nauthentication using the same LDAP service.\n\nThis authenticator calls a designated authentication service over HTTP(S) POST. A skeleton implementation of a server\nis provided for reference at [rest-auth](../../../rest-auth/). The requests may be handled either by a single endpoint\nor by separate per-request endpoints.\n\nRequest and response payloads are formatted as JSON. Some of the request or response fields are context-dependent and\nmay be skipped.\n\n<!-- TOC depthFrom:1 depthTo:6 withLinks:1 updateOnSave:1 orderedList:0 -->\n\n- [REST or JSON-RPC authenticator](#rest-or-json-rpc-authenticator)\n\t- [Configuration](#configuration)\n\t- [Request](#request)\n\t- [Response](#response)\n\t- [Recognized error responses](#recognized-error-responses)\n\t- [The server must implement the following endpoints:](#the-server-must-implement-the-following-endpoints)\n\t\t- [`add` Add new authentication record](#add-add-new-authentication-record)\n\t\t\t- [Sample request](#sample-request)\n\t\t\t- [Sample response (rec values may change)](#sample-response-rec-values-may-change)\n\t\t- [`auth` Request for authentication](#auth-request-for-authentication)\n\t\t\t- [Sample request](#sample-request)\n\t\t\t- [Sample response when the account already exists (optional challenge included)](#sample-response-when-the-account-already-exists-optional-challenge-included)\n\t\t\t- [Sample response when the account needs to be created by Tinode](#sample-response-when-the-account-needs-to-be-created-by-tinode)\n\t\t- [`checkunique` Checks if provided authentication record is unique.](#checkunique-checks-if-provided-authentication-record-is-unique)\n\t\t\t- [Sample request](#sample-request)\n\t\t\t- [Sample response](#sample-response)\n\t\t- [`del` Requests to delete authentication record.](#del-requests-to-delete-authentication-record)\n\t\t\t- [Sample request](#sample-request)\n\t\t\t- [Sample response](#sample-response)\n\t\t- [`gen` Generate authentication secret.](#gen-generate-authentication-secret)\n\t\t\t- [Sample request](#sample-request)\n\t\t\t- [Sample response](#sample-response)\n\t\t- [`link` Requests server to link new account ID to authentication record.](#link-requests-server-to-link-new-account-id-to-authentication-record)\n\t\t\t- [Sample request](#sample-request)\n\t\t\t- [Sample response](#sample-response)\n\t\t- [`upd` Update authentication record.](#upd-update-authentication-record)\n\t\t\t- [Sample request](#sample-request)\n\t\t\t- [Sample response](#sample-response)\n\t\t- [`rtagns` Get a list of restricted tag namespaces.](#rtagns-get-a-list-of-restricted-tag-namespaces)\n\t\t\t- [Sample request](#sample-request)\n\t\t\t- [Sample response](#sample-response)\n\n<!-- /TOC -->\n\n## Configuration\n\nAdd the following section to the `auth_config` in [tinode.conf](../../tinode.conf):\n\n```js\n...\n\"auth_config\": {\n  ...\n  \"rest\": {\n    // ServerUrl is the URL of the authentication server to call. The URL must be absolute:\n    // it must include the scheme, such as http or https, and the host name.\n    \"server_url\": \"http://127.0.0.1:5000/\",\n    // Authentication server is allowed to create new accounts.\n    \"allow_new_accounts\": true,\n    // Use separate endpoints, i.e. add request name to serverUrl path when making requests:\n    // http://127.0.0.1:5000/add\n    \"use_separate_endpoints\": true\n  },\n  ...\n},\n```\nIf you want to use your authenticator **instead** of stock `basic` (login-password) authentication,\nadd logical renaming and disable `rest` at the original name:\n```js\n...\n\"auth_config\": {\n  \"logical_names\": [\"basic:rest\", \"rest:\"],\n  \"rest\": { ... },\n  ...\n},\n...\n```\n\n## Request\n\n```js\n{\n  \"endpoint\": \"auth\",       // string, one of the endpoints as described below, optional.\n  \"secret\": \"Ym9iOmJvYjEyMw==\", // authentication secret as provided by the client,\n                            // base64-encoded bytes, optional.\n  \"addr\": \"2001:0db8:85a3:0000:0000:8a2e:0370:7334\", // string, IPv4 or IPv6 address of\n                            // the client making the request, optional.\n  \"rec\": {    // authentication record, optional.\n    {\n      \"uid\": \"LELEQHDWbgY\", // user ID, int64 base64-encoded\n      \"authlvl\": \"auth\",    // authentication level\n      \"lifetime\": \"10000s\", // lifetime of this record in seconds or as time.Duration string;\n                            // see https://golang.org/pkg/time/#Duration for format.\n       \"features\": 1,       // bitmap of features as integer or as a string of feature characters:\n                            // \"validated\" (V) or \"no login\" (L)\".\n       \"tags\": [\"email:alice@example.com\"], // Tags associated with this authentication record.\n       \"state\": \"ok\",       // optional account state.\n    }\n  }\n}\n```\n\n## Response\n\n```js\n{\n  \"err\": \"internal\", // string, error message in case of an error.\n  \"rec\": {           // authentication record.\n    ...              // the same as `request.rec`\n  },\n  \"byteval\": \"Ym9iOmJvYjEyMw==\",    // array of bytes, optional\n  \"ts\": \"2018-12-04T15:17:02.627Z\", // time stamp, optional\n  \"boolval\": true,                  // boolean value, optional\n  \"strarr\": [\"abc\", \"def\"],         // array of strings, optoional\n  \"newacc\": {        // data to use for creating a new account.\n    // Default access mode\n    \"auth\": \"JRWPS\",\n    \"anon\": \"N\",\n    \"public\": {...}, // user's public data, see /docs/API.md#trusted-public-and-private-fields\n    \"trusted\": {...}, // user's trusted data, see /docs/API.md#trusted-public-and-private-fields\n    \"private\": {...} // user's private data, see /docs/API.md#trusted-public-and-private-fields\n  }\n}\n```\n\n## Recognized error responses\n\nThe error is returned as json:\n\n```json\n{ \"err\": \"error-message\" }\n```\n\nSee [here](../../store/types/types.go#L24) for an up to date list of supported error messages.\n\n* \"internal\": database failure or other internal catch-all failure.\n* \"malformed\": request cannot be parsed or otherwise wrong.\n* \"failed\": authentication failed (wrong login or password, etc).\n* \"duplicate value\": duplicate credential, i.e. attempt to create a record with a non-unique login.\n* \"unsupported\": the operation is not supported.\n* \"expired\": the secret has expired.\n* \"policy\": policy violation, e.g. password too weak.\n* \"credentials\": credentials like email or captcha must be validated.\n* \"not found\": the object was not found.\n* \"denied\": the operation is not permitted.\n\n## The server must implement the following endpoints:\n\n### `add` Add new authentication record\n\nThis endpoint requests server to add a new authentication record. This endpoint is generally used for account creation.\nIf accounts are managed externally, it's likely to be unused and should generally return an error `\"unsupported\"`.\n\n#### Sample request\n```json\n{\n  \"endpoint\": \"add\",\n  \"secret\": \"Ym9iOmJvYjEyMw==\",\n  \"addr\": \"111.22.33.44\",\n  \"rec\": {\n    \"uid\": \"LELEQHDWbgY\",\n    \"lifetime\": \"10000s\",\n    \"features\": 2,\n    \"tags\": [\"email:alice@example.com\"]\n  }\n}\n```\n\n#### Sample response (rec values may change)\n```json\n{\n  \"rec\": {\n    \"uid\": \"LELEQHDWbgY\",\n    \"authlvl\": \"auth\",\n    \"lifetime\": \"5000s\",\n    \"features\": 1,\n    \"tags\": [\"email:alice@example.com\", \"uname:alice\"]\n  }\n}\n```\n\n### `auth` Request for authentication\n\nRequest to authenticate a user. Client (Tinode) provides a secret, authentication server responds with a user record.\nIf this is a very first login and the server manages the accounts, the server may return `newacc` object which will\nbe used by client (Tinode) to create the account. The server may optionally return a challenge as `byteval`.\n\n#### Sample request\n```json\n{\n  \"endpoint\": \"auth\",\n  \"secret\": \"Ym9iOmJvYjEyMw==\",\n  \"addr\": \"111.22.33.44\"\n}\n```\n\n#### Sample response when the account already exists (optional challenge included)\n```json\n{\n  \"rec\": {\n    \"uid\": \"LELEQHDWbgY\",\n    \"authlvl\": \"auth\",\n    \"state\": \"ok\"\n  },\n  \"byteval\": \"9X6m3tWeBEMlDxlcFAABAAEAbVs\"\n}\n```\n\n#### Sample response when the account needs to be created by Tinode\n```js\n{\n  \"rec\": {\n    \"state\": \"suspended\", // Or \"ok\".\n    \"authlvl\": \"auth\",\n    \"lifetime\": \"5000s\",\n    \"features\": 1,\n    \"tags\": [\"email:alice@example.com\", \"uname:alice\"]\n  },\n  \"newacc\": {\n    \"auth\": \"JRWPS\",\n    \"anon\": \"N\",\n    \"public\": {/* see /docs/API.md#trusted-public-and-private-fields */},\n    \"trusted\": {/* see /docs/API.md#trusted-public-and-private-fields */},\n    \"private\": {/* see /docs/API.md#trusted-public-and-private-fields */}\n  }\n}\n```\n\n### `checkunique` Checks if provided authentication record is unique.\n\nRequest is used for account creation. If accounts are managed by the server, the server should respond with\nan error `\"unsupported\"`.\n\n#### Sample request\n```json\n{\n  \"endpoint\": \"checkunique\",\n  \"secret\": \"Ym9iOmJvYjEyMw==\",\n  \"addr\": \"111.22.33.44\"\n}\n```\n\n#### Sample response\n```json\n{\n  \"boolval\": true\n}\n```\n\n### `del` Requests to delete authentication record.\n\nIf accounts are managed by the server, the server should respond with an error `\"unsupported\"`.\n\n#### Sample request\n```json\n{\n  \"endpoint\": \"del\",\n  \"rec\": {\n    \"uid\": \"LELEQHDWbgY\",\n  }\n}\n```\n\n#### Sample response\n```json\n{}\n```\n\n\n### `gen` Generate authentication secret.\n\nIf accounts are managed by the server, the server should respond with an error `\"unsupported\"`.\n\n#### Sample request\n```json\n{\n  \"endpoint\": \"gen\",\n  \"rec\": {\n    \"uid\": \"LELEQHDWbgY\",\n    \"authlvl\": \"auth\",\n  }\n}\n```\n\n#### Sample response\n```json\n{\n  \"byteval\": \"9X6m3tWeBEMlDxlcFAABAAEAbVs\",\n  \"ts\": \"2018-12-04T15:17:02.627Z\",\n}\n```\n\n\n### `link` Requests server to link new account ID to authentication record.\n\nIf server requested Tinode to create a new account, this endpoint is used to link the new Tinode user ID with the\nserver's authentication record. If linking is successful, the server should respond with a non-empty json.\n\n#### Sample request\n```json\n{\n  \"endpoint\": \"link\",\n  \"secret\": \"Ym9iOmJvYjEyMw==\",\n  \"rec\": {\n    \"uid\": \"LELEQHDWbgY\",\n    \"authlvl\": \"auth\",\n  },\n}\n```\n\n#### Sample response\n```json\n{}\n```\n\n\n### `upd` Update authentication record.\n\nIf accounts are managed by the server, the server should respond with an error `\"unsupported\"`.\n\n#### Sample request\n```json\n{\n  \"endpoint\": \"upd\",\n  \"secret\": \"Ym9iOmJvYjEyMw==\",\n  \"addr\": \"111.22.33.44\",\n  \"rec\": {\n    \"uid\": \"LELEQHDWbgY\",\n    \"authlvl\": \"auth\",\n  }\n}\n```\n\n#### Sample response\n```json\n{}\n```\n\n### `rtagns` Get a list of restricted tag namespaces.\n\nServer may enforce certain tag namespaces (tag prefixes) to be restricted, i.e. not editable by the user.\nThese are also used in Tinode discovery mechanism (e.g. searching for users, contact sync). See\n[API docs](/docs/API.md#fnd-and-tags-finding-users-and-topics) for details.\n\nThe server may optionally provide a regular expression to validate search tokens before rewriting them as prefixed tags.\nI.e. if server allows only logins of 3-8 ASCII letters and numbers then the regexp could be `^[a-z0-9_]{3,8}$`\nwhich is base64-encoded as `XlthLXowLTlfXXszLDh9JA==`.\n\n#### Sample request\n```json\n{\n  \"endpoint\": \"rtagns\",\n}\n```\n\n#### Sample response\n```json\n{\n  \"strarr\": [\"basic\", \"email\", \"tel\"],\n  \"byteval\": \"XlthLXowLTlfXXszLDh9JA==\"\n}\n```\n"
  },
  {
    "path": "server/auth/rest/auth_rest.go",
    "content": "// Package rest provides authentication by calling a separate process over REST API (technically JSON RPC, not REST).\npackage rest\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/auth\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\n// authenticator is the type to map authentication methods to.\ntype authenticator struct {\n\t// Logical name of this authenticator\n\tname string\n\t// URL of the server\n\tserverUrl string\n\t// Authenticator may add new accounts to local database.\n\tallowNewAccounts bool\n\t// Use separate endpoints, i.e. add request name to serverUrl path when making requests.\n\tuseSeparateEndpoints bool\n\t// Cache of restricted tag prefixes (namespaces).\n\trTagNS []string\n\t// Optional regex pattern for checking tokens.\n\treToken *regexp.Regexp\n}\n\n// Request to the server.\ntype request struct {\n\tEndpoint   string    `json:\"endpoint\"`\n\tName       string    `json:\"name\"`\n\tRecord     *auth.Rec `json:\"rec,omitempty\"`\n\tSecret     []byte    `json:\"secret,omitempty\"`\n\tRemoteAddr string    `json:\"addr,omitempty\"`\n}\n\n// User initialization data when creating a new user.\ntype newAccount struct {\n\t// Default access mode\n\tAuth string `json:\"auth,omitempty\"`\n\tAnon string `json:\"anon,omitempty\"`\n\t// User's Public data\n\tPublic any `json:\"public,omitempty\"`\n\t// User's Trusted data\n\tTrusted any `json:\"trusted,omitempty\"`\n\t// Per-subscription private data\n\tPrivate any `json:\"private,omitempty\"`\n}\n\n// Response from the server.\ntype response struct {\n\t// Error message in case of an error.\n\tErr string `json:\"err,omitempty\"`\n\t// Optional auth record\n\tRecord *auth.Rec `json:\"rec,omitempty\"`\n\t// Optional byte slice\n\tByteVal []byte `json:\"byteval,omitempty\"`\n\t// Optional time value\n\tTimeVal time.Time `json:\"ts,omitempty\"`\n\t// Boolean value\n\tBoolVal bool `json:\"boolval,omitempty\"`\n\t// String slice value\n\tStrSliceVal []string `json:\"strarr,omitempty\"`\n\t// Account creation data\n\tNewAcc *newAccount `json:\"newacc,omitempty\"`\n}\n\n// Init initializes the handler.\nfunc (a *authenticator) Init(jsonconf json.RawMessage, name string) error {\n\tif name == \"\" {\n\t\treturn errors.New(\"auth_rest: authenticator name cannot be blank\")\n\t}\n\n\tif a.name != \"\" {\n\t\treturn errors.New(\"auth_rest: already initialized as \" + a.name + \"; \" + name)\n\t}\n\n\ttype configType struct {\n\t\t// ServerUrl is the URL of the server to call.\n\t\tServerUrl string `json:\"server_url\"`\n\t\t// Server may create new accounts.\n\t\tAllowNewAccounts bool `json:\"allow_new_accounts\"`\n\t\t// Use separate endpoints, i.e. add request name to serverUrl path when making requests.\n\t\tUseSeparateEndpoints bool `json:\"use_separate_endpoints\"`\n\t}\n\n\tvar config configType\n\terr := json.Unmarshal(jsonconf, &config)\n\tif err != nil {\n\t\treturn errors.New(\"auth_rest: failed to parse config: \" + err.Error() + \"(\" + string(jsonconf) + \")\")\n\t}\n\n\tserverUrl, err := url.Parse(config.ServerUrl)\n\tif err != nil || !serverUrl.IsAbs() {\n\t\treturn errors.New(\"auth_rest: invalid server_url '\" + string(jsonconf) + \"'\")\n\t}\n\n\tif !strings.HasSuffix(serverUrl.Path, \"/\") {\n\t\tserverUrl.Path += \"/\"\n\t}\n\n\ta.name = name\n\ta.serverUrl = serverUrl.String()\n\ta.allowNewAccounts = config.AllowNewAccounts\n\ta.useSeparateEndpoints = config.UseSeparateEndpoints\n\n\treturn nil\n}\n\n// IsInitialized returns true if the handler is initialized.\nfunc (a *authenticator) IsInitialized() bool {\n\treturn a.name != \"\"\n}\n\n// Execute HTTP POST to the server at the specified endpoint and with the provided payload.\nfunc (a *authenticator) callEndpoint(endpoint string, rec *auth.Rec, secret []byte, remoteAddr string) (*response, error) {\n\t// Convert payload to json.\n\treq := &request{Endpoint: endpoint, Name: a.name, Record: rec, Secret: secret, RemoteAddr: remoteAddr}\n\tcontent, err := json.Marshal(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\turlToCall := a.serverUrl\n\tif a.useSeparateEndpoints {\n\t\tepUrl, _ := url.Parse(a.serverUrl)\n\t\tepUrl.Path += endpoint\n\t\turlToCall = epUrl.String()\n\t}\n\n\t// Send payload to server using default HTTP client.\n\tpost, err := http.Post(urlToCall, \"application/json\", bytes.NewBuffer(content))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer post.Body.Close()\n\n\t// Check HTTP status response. Must be 2xx.\n\tif post.StatusCode < http.StatusOK || post.StatusCode >= http.StatusMultipleChoices {\n\t\treturn nil, errors.New(\"unexpected HTTP response \" + post.Status)\n\t}\n\n\t// Read response.\n\tbody, err := io.ReadAll(post.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Parse response.\n\tvar resp response\n\terr = json.Unmarshal(body, &resp)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif resp.Err != \"\" {\n\t\treturn nil, types.StoreError(resp.Err)\n\t}\n\n\treturn &resp, nil\n}\n\n// AddRecord adds persistent authentication record to the database.\n// Returns: updated auth record, error\nfunc (a *authenticator) AddRecord(rec *auth.Rec, secret []byte, remoteAddr string) (*auth.Rec, error) {\n\tresp, err := a.callEndpoint(\"add\", rec, secret, remoteAddr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn resp.Record, nil\n}\n\n// UpdateRecord updates existing record with new credentials.\nfunc (a *authenticator) UpdateRecord(rec *auth.Rec, secret []byte, remoteAddr string) (*auth.Rec, error) {\n\t_, err := a.callEndpoint(\"upd\", rec, secret, remoteAddr)\n\treturn rec, err\n}\n\n// Authenticate: get user record by provided secret\nfunc (a *authenticator) Authenticate(secret []byte, remoteAddr string) (*auth.Rec, []byte, error) {\n\tresp, err := a.callEndpoint(\"auth\", nil, secret, remoteAddr)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\t// Auth record not found.\n\tif resp.Record == nil {\n\t\tlogs.Warn.Println(\"rest_auth: invalid response: missing Record\")\n\t\treturn nil, nil, types.ErrInternal\n\t}\n\n\t// Check if server provided a user ID. If not, create a new account in the local database.\n\tif resp.Record.Uid.IsZero() && a.allowNewAccounts {\n\t\tif resp.NewAcc == nil {\n\t\t\treturn nil, nil, types.ErrNotFound\n\t\t}\n\n\t\t// Create account, get UID, report UID back to the server.\n\n\t\tuser := types.User{\n\t\t\tState:   resp.Record.State,\n\t\t\tPublic:  resp.NewAcc.Public,\n\t\t\tTrusted: resp.NewAcc.Trusted,\n\t\t\tTags:    resp.Record.Tags,\n\t\t}\n\t\tuser.Access.Auth.UnmarshalText([]byte(resp.NewAcc.Auth))\n\t\tuser.Access.Anon.UnmarshalText([]byte(resp.NewAcc.Anon))\n\t\t_, err = store.Users.Create(&user, resp.NewAcc.Private)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\n\t\t// Report the new UID to the server.\n\t\tresp.Record.Uid = user.Uid()\n\t\t_, err = a.callEndpoint(\"link\", resp.Record, secret, \"\")\n\t\tif err != nil {\n\t\t\tstore.Users.Delete(resp.Record.Uid, true)\n\t\t\treturn nil, nil, err\n\t\t}\n\t}\n\n\treturn resp.Record, resp.ByteVal, nil\n}\n\n// AsTag converts search token into prefixed tag or an empty string if it\n// cannot be represented as a prefixed tag.\nfunc (a *authenticator) AsTag(token string) string {\n\tif len(a.rTagNS) > 0 {\n\t\tif a.reToken != nil && !a.reToken.MatchString(token) {\n\t\t\treturn \"\"\n\t\t}\n\t\t// No validation or passed validation.\n\t\treturn a.rTagNS[0] + \":\" + token\n\t}\n\treturn \"\"\n}\n\n// IsUnique verifies if the provided secret can be considered unique by the auth\n// scheme as well as policy compliance. E.g. if login is unique and not too short/long.\nfunc (a *authenticator) IsUnique(secret []byte, remoteAddr string) (bool, error) {\n\tresp, err := a.callEndpoint(\"checkunique\", nil, secret, remoteAddr)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\treturn resp.BoolVal, err\n}\n\n// GenSecret generates a new secret, if appropriate.\nfunc (a *authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {\n\tresp, err := a.callEndpoint(\"gen\", rec, nil, \"\")\n\tif err != nil {\n\t\treturn nil, time.Time{}, err\n\t}\n\n\treturn resp.ByteVal, resp.TimeVal, err\n}\n\n// DelRecords deletes all authentication records for the given user.\nfunc (a *authenticator) DelRecords(uid types.Uid) error {\n\tlogs.Info.Println(\"DelRecords, initialized=\", a.name != \"\")\n\t_, err := a.callEndpoint(\"del\", &auth.Rec{Uid: uid}, nil, \"\")\n\treturn err\n}\n\n// RestrictedTags returns tag namespaces (prefixes, such as prefix:login) restricted by the server.\nfunc (a *authenticator) RestrictedTags() ([]string, error) {\n\tif a.rTagNS != nil {\n\t\t// Using cached prefixes.\n\t\tns := make([]string, len(a.rTagNS))\n\t\t// Returning a copy to prevent accidental modification of server-provided tags.\n\t\tcopy(ns, a.rTagNS)\n\t\treturn ns, nil\n\t}\n\n\t// First time use, fetch prefixes from the server.\n\tresp, err := a.callEndpoint(\"rtagns\", nil, nil, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Save valid result to cache.\n\ta.rTagNS = resp.StrSliceVal\n\tif len(resp.ByteVal) > 0 {\n\t\ta.reToken, err = regexp.Compile(string(resp.ByteVal))\n\t\tif err != nil {\n\t\t\tlogs.Warn.Println(\"rest_auth: invalid token regexp\", string(resp.ByteVal))\n\t\t}\n\t}\n\treturn resp.StrSliceVal, nil\n}\n\n// GetResetParams returns authenticator parameters passed to password reset handler\n// (none for rest).\nfunc (authenticator) GetResetParams(uid types.Uid) (map[string]any, error) {\n\t// TODO: route request to the server.\n\treturn nil, nil\n}\n\nconst realName = \"rest\"\n\n// GetRealName returns the hardcoded name of the authenticator.\nfunc (authenticator) GetRealName() string {\n\treturn realName\n}\n\nfunc init() {\n\tstore.RegisterAuthScheme(realName, &authenticator{})\n}\n"
  },
  {
    "path": "server/auth/token/auth_token.go",
    "content": "// Package token implements authentication by HMAC-signed security token.\npackage token\n\nimport (\n\t\"bytes\"\n\t\"crypto/hmac\"\n\t\"crypto/sha256\"\n\t\"encoding/binary\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/auth\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\n// authenticator is a singleton instance of the authenticator.\ntype authenticator struct {\n\tname         string\n\thmacSalt     []byte\n\tlifetime     time.Duration\n\tserialNumber int\n}\n\n// tokenLayout defines positioning of various bytes in token.\n// [8:UID][4:expires][2:authLevel][2:serial-number][2:feature-bits][32:signature] = 50 bytes\ntype tokenLayout struct {\n\t// User ID.\n\tUid uint64\n\t// Token expiration time.\n\tExpires uint32\n\t// User's authentication level.\n\tAuthLevel uint16\n\t// Serial number - to invalidate all tokens if needed.\n\tSerialNumber uint16\n\t// Bitmap with feature bits.\n\tFeatures uint16\n}\n\n// Init initializes the authenticator: parses the config and sets salt, serial number and lifetime.\nfunc (ta *authenticator) Init(jsonconf json.RawMessage, name string) error {\n\tif name == \"\" {\n\t\treturn errors.New(\"auth_token: authenticator name cannot be blank\")\n\t}\n\n\tif ta.name != \"\" {\n\t\treturn errors.New(\"auth_token: already initialized as \" + ta.name + \"; \" + name)\n\t}\n\n\ttype configType struct {\n\t\t// Key for signing tokens\n\t\tKey []byte `json:\"key\"`\n\t\t// Datatabase or other serial number, to invalidate all issued tokens at once.\n\t\tSerialNum int `json:\"serial_num\"`\n\t\t// Token expiration time\n\t\tExpireIn int `json:\"expire_in\"`\n\t}\n\tvar config configType\n\tif err := json.Unmarshal(jsonconf, &config); err != nil {\n\t\treturn errors.New(\"auth_token: failed to parse config: \" + err.Error() + \"(\" + string(jsonconf) + \")\")\n\t}\n\n\tif len(config.Key) < sha256.Size {\n\t\treturn errors.New(\"auth_token: the key is missing or too short\")\n\t}\n\tif config.ExpireIn <= 0 {\n\t\treturn errors.New(\"auth_token: invalid expiration value\")\n\t}\n\n\tta.name = name\n\tta.hmacSalt = config.Key\n\tta.lifetime = time.Duration(config.ExpireIn) * time.Second\n\tta.serialNumber = config.SerialNum\n\n\treturn nil\n}\n\n// IsInitialized returns true if the handler is initialized.\nfunc (ta *authenticator) IsInitialized() bool {\n\treturn ta.name != \"\"\n}\n\n// AddRecord is not supported, will produce an error.\nfunc (authenticator) AddRecord(rec *auth.Rec, secret []byte, remoteAddr string) (*auth.Rec, error) {\n\treturn nil, types.ErrUnsupported\n}\n\n// UpdateRecord is not supported, will produce an error.\nfunc (authenticator) UpdateRecord(rec *auth.Rec, secret []byte, remoteAddr string) (*auth.Rec, error) {\n\treturn nil, types.ErrUnsupported\n}\n\n// Authenticate checks validity of provided token.\nfunc (ta *authenticator) Authenticate(token []byte, remoteAddr string) (*auth.Rec, []byte, error) {\n\tvar tl tokenLayout\n\tdataSize := binary.Size(&tl)\n\tif len(token) < dataSize+sha256.Size {\n\t\t// Token is too short\n\t\treturn nil, nil, types.ErrMalformed\n\t}\n\n\tbuf := bytes.NewBuffer(token)\n\terr := binary.Read(buf, binary.LittleEndian, &tl)\n\tif err != nil {\n\t\treturn nil, nil, types.ErrMalformed\n\t}\n\n\thbuf := new(bytes.Buffer)\n\tbinary.Write(hbuf, binary.LittleEndian, &tl)\n\n\t// Check signature.\n\thasher := hmac.New(sha256.New, ta.hmacSalt)\n\thasher.Write(hbuf.Bytes())\n\tif !hmac.Equal(token[dataSize:dataSize+sha256.Size], hasher.Sum(nil)) {\n\t\treturn nil, nil, types.ErrFailed\n\t}\n\n\t// Check authentication level for validity.\n\tif auth.Level(tl.AuthLevel) > auth.LevelRoot {\n\t\treturn nil, nil, types.ErrMalformed\n\t}\n\n\t// Check serial number.\n\tif int(tl.SerialNumber) != ta.serialNumber {\n\t\treturn nil, nil, types.ErrFailed\n\t}\n\n\t// Check token expiration time.\n\texpires := time.Unix(int64(tl.Expires), 0).UTC()\n\tif expires.Before(time.Now().Add(1 * time.Second)) {\n\t\treturn nil, nil, types.ErrExpired\n\t}\n\n\treturn &auth.Rec{\n\t\tUid:       types.Uid(tl.Uid),\n\t\tAuthLevel: auth.Level(tl.AuthLevel),\n\t\tLifetime:  auth.Duration(time.Until(expires)),\n\t\tFeatures:  auth.Feature(tl.Features),\n\t\tState:     types.StateUndefined}, nil, nil\n}\n\n// GenSecret generates a new token.\nfunc (ta *authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {\n\n\tif rec.Lifetime == 0 {\n\t\trec.Lifetime = auth.Duration(ta.lifetime)\n\t} else if rec.Lifetime < 0 {\n\t\treturn nil, time.Time{}, types.ErrExpired\n\t}\n\texpires := time.Now().Add(time.Duration(rec.Lifetime)).UTC().Round(time.Millisecond)\n\n\ttl := tokenLayout{\n\t\tUid:          uint64(rec.Uid),\n\t\tExpires:      uint32(expires.Unix()),\n\t\tAuthLevel:    uint16(rec.AuthLevel),\n\t\tSerialNumber: uint16(ta.serialNumber),\n\t\tFeatures:     uint16(rec.Features),\n\t}\n\tbuf := new(bytes.Buffer)\n\tbinary.Write(buf, binary.LittleEndian, &tl)\n\thasher := hmac.New(sha256.New, ta.hmacSalt)\n\thasher.Write(buf.Bytes())\n\tbinary.Write(buf, binary.LittleEndian, hasher.Sum(nil))\n\n\treturn buf.Bytes(), expires, nil\n}\n\n// AsTag is not supported, will produce an empty string.\nfunc (authenticator) AsTag(token string) string {\n\treturn \"\"\n}\n\n// IsUnique is not supported, will produce an error.\nfunc (authenticator) IsUnique(token []byte, remoteAddr string) (bool, error) {\n\treturn false, types.ErrUnsupported\n}\n\n// DelRecords adds disabled user ID to a stop list.\nfunc (authenticator) DelRecords(uid types.Uid) error {\n\treturn nil\n}\n\n// RestrictedTags returns tag namespaces restricted by this authenticator (none for token).\nfunc (authenticator) RestrictedTags() ([]string, error) {\n\treturn nil, nil\n}\n\n// GetResetParams returns authenticator parameters passed to password reset handler\n// (none for token).\nfunc (authenticator) GetResetParams(uid types.Uid) (map[string]any, error) {\n\treturn nil, nil\n}\n\nconst realName = \"token\"\n\n// GetRealName returns the hardcoded name of the authenticator.\nfunc (authenticator) GetRealName() string {\n\treturn realName\n}\n\nfunc init() {\n\tstore.RegisterAuthScheme(realName, &authenticator{})\n}\n"
  },
  {
    "path": "server/calls.go",
    "content": "/******************************************************************************\n *\n *  Description :\n *    Video call handling (establishment, metadata exhange and termination).\n *\n *****************************************************************************/\npackage main\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store/types\"\n\tjcr \"github.com/tinode/jsonco\"\n)\n\n// Video call constants.\nconst (\n\t// Events for call between users A and B.\n\t//\n\t// B has received the call but hasn't picked it up yet.\n\tconstCallEventRinging = \"ringing\"\n\t// B has accepted the call.\n\tconstCallEventAccept = \"accept\"\n\t// WebRTC SDP & ICE data exchange events.\n\tconstCallEventOffer        = \"offer\"\n\tconstCallEventAnswer       = \"answer\"\n\tconstCallEventIceCandidate = \"ice-candidate\"\n\t// Call finished by either side or server.\n\tconstCallEventHangUp = \"hang-up\"\n\n\t// Message headers representing call states.\n\t// Call is established.\n\tconstCallMsgAccepted = \"accepted\"\n\t// Previously establied call has successfully finished.\n\tconstCallMsgFinished = \"finished\"\n\t// Call is dropped (e.g. because of an error).\n\tconstCallMsgDisconnected = \"disconnected\"\n\t// Call is missed (the callee didn't pick up the phone).\n\tconstCallMsgMissed = \"missed\"\n\t// Call is declined (the callee hung up before picking up).\n\tconstCallMsgDeclined = \"declined\"\n)\n\ntype callConfig struct {\n\t// Enable video/voice calls.\n\tEnabled bool `json:\"enabled\"`\n\t// Timeout in seconds before a call is dropped if not answered.\n\tCallEstablishmentTimeout int `json:\"call_establishment_timeout\"`\n\t// ICE servers.\n\tICEServers []iceServer `json:\"ice_servers\"`\n\t// Alternative config as an external file.\n\tICEServersFile string `json:\"ice_servers_file\"`\n}\n\n// ICE server config.\ntype iceServer struct {\n\tUsername       string   `json:\"username,omitempty\"`\n\tCredential     string   `json:\"credential,omitempty\"`\n\tCredentialType string   `json:\"credential_type,omitempty\"`\n\tUrls           []string `json:\"urls,omitempty\"`\n}\n\n// callPartyData describes a video call participant.\ntype callPartyData struct {\n\t// ID of the call participant (asUid); not necessarily the session owner.\n\tuid types.Uid\n\t// True if this session/user initiated the call.\n\tisOriginator bool\n\t// Call party session.\n\tsess *Session\n}\n\n// videoCall describes video call that's being established or in progress.\ntype videoCall struct {\n\t// Call participants (session sid -> callPartyData).\n\tparties map[string]callPartyData\n\t// Call message seq ID.\n\tseq int\n\t// Call message content.\n\tcontent any\n\t// Call message content mime type.\n\tcontentMime any\n\t// Time when the call was accepted.\n\tacceptedAt time.Time\n}\n\n// callPartySession returns a session to be stored in the call party data.\nfunc callPartySession(sess *Session) *Session {\n\tif sess.isProxy() {\n\t\t// We are on the topic host node. Make a copy of the ephemeral proxy session.\n\t\tcallSess := &Session{\n\t\t\tproto: PROXY,\n\t\t\t// Multiplexing session which actually handles the communication.\n\t\t\tmulti: sess.multi,\n\t\t\t// Local parameters specific to this session.\n\t\t\tsid:         sess.sid,\n\t\t\tuserAgent:   sess.userAgent,\n\t\t\tremoteAddr:  sess.remoteAddr,\n\t\t\tlang:        sess.lang,\n\t\t\tcountryCode: sess.countryCode,\n\t\t\tproxyReq:    ProxyReqCall,\n\t\t\tbackground:  sess.background,\n\t\t\tuid:         sess.uid,\n\t\t}\n\t\treturn callSess\n\t}\n\treturn sess\n}\n\nfunc initVideoCalls(jsconfig json.RawMessage) error {\n\tvar config callConfig\n\n\tif len(jsconfig) == 0 {\n\t\treturn nil\n\t}\n\n\tif err := json.Unmarshal([]byte(jsconfig), &config); err != nil {\n\t\treturn fmt.Errorf(\"failed to parse config: %w\", err)\n\t}\n\n\tif !config.Enabled {\n\t\tlogs.Info.Println(\"Video calls disabled\")\n\t\treturn nil\n\t}\n\n\tif len(config.ICEServers) > 0 {\n\t\tglobals.iceServers = config.ICEServers\n\t} else if config.ICEServersFile != \"\" {\n\t\tvar iceConfig []iceServer\n\t\tfile, err := os.Open(config.ICEServersFile)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to read ICE config: %w\", err)\n\t\t}\n\n\t\tjr := jcr.New(file)\n\t\tif err = json.NewDecoder(jr).Decode(&iceConfig); err != nil {\n\t\t\tswitch jerr := err.(type) {\n\t\t\tcase *json.UnmarshalTypeError:\n\t\t\t\tlnum, cnum, _ := jr.LineAndChar(jerr.Offset)\n\t\t\t\treturn fmt.Errorf(\"unmarshall error in ICE config in %s at %d:%d (offset %d bytes): %w\",\n\t\t\t\t\tjerr.Field, lnum, cnum, jerr.Offset, jerr)\n\t\t\tcase *json.SyntaxError:\n\t\t\t\tlnum, cnum, _ := jr.LineAndChar(jerr.Offset)\n\t\t\t\treturn fmt.Errorf(\"syntax error in config file at %d:%d (offset %d bytes): %w\",\n\t\t\t\t\tlnum, cnum, jerr.Offset, jerr)\n\t\t\tdefault:\n\t\t\t\treturn fmt.Errorf(\"failed to parse config file: %w\", err)\n\t\t\t}\n\t\t}\n\t\tfile.Close()\n\n\t\tglobals.iceServers = iceConfig\n\t}\n\n\tif len(globals.iceServers) == 0 {\n\t\treturn errors.New(\"no valid ICE cervers found\")\n\t}\n\n\tglobals.callEstablishmentTimeout = config.CallEstablishmentTimeout\n\tif globals.callEstablishmentTimeout <= 0 {\n\t\tglobals.callEstablishmentTimeout = defaultCallEstablishmentTimeout\n\t}\n\n\tlogs.Info.Println(\"Video calls enabled with\", len(globals.iceServers), \"ICE servers\")\n\treturn nil\n}\n\n// Add webRTC-related headers to message Head. The original Head may already contain some entries,\n// like 'sender', preserve them.\nfunc (call *videoCall) messageHead(head map[string]any, newState string, duration int) map[string]any {\n\tif head == nil {\n\t\thead = map[string]any{}\n\t}\n\n\thead[\"replace\"] = \":\" + strconv.Itoa(call.seq)\n\thead[\"webrtc\"] = newState\n\n\tif duration > 0 {\n\t\thead[\"webrtc-duration\"] = duration\n\t} else {\n\t\tdelete(head, \"webrtc-duration\")\n\t}\n\tif call.contentMime != nil {\n\t\thead[\"mime\"] = call.contentMime\n\t}\n\treturn head\n}\n\n// Generates server info message template for the video call event.\nfunc (call *videoCall) infoMessage(event string) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tInfo: &MsgServerInfo{\n\t\t\tWhat:  \"call\",\n\t\t\tEvent: event,\n\t\t\tSeqId: call.seq,\n\t\t},\n\t}\n}\n\n// Returns Uid and session of the present video call originator\n// if a call is being established or in progress.\nfunc (t *Topic) getCallOriginator() (types.Uid, *Session) {\n\tif t.currentCall == nil {\n\t\treturn types.ZeroUid, nil\n\t}\n\tfor _, p := range t.currentCall.parties {\n\t\tif p.isOriginator {\n\t\t\treturn p.uid, p.sess\n\t\t}\n\t}\n\treturn types.ZeroUid, nil\n}\n\n// Handles video call invite (initiation)\n// (in response to msg = {pub head=[mime: application/x-tinode-webrtc]}).\nfunc (t *Topic) handleCallInvite(msg *ClientComMessage, asUid types.Uid) {\n\t// Call being establshed.\n\tt.currentCall = &videoCall{\n\t\tparties:     make(map[string]callPartyData),\n\t\tseq:         t.lastID,\n\t\tcontent:     msg.Pub.Content,\n\t\tcontentMime: msg.Pub.Head[\"mime\"],\n\t}\n\tt.currentCall.parties[msg.sess.sid] = callPartyData{\n\t\tuid:          asUid,\n\t\tisOriginator: true,\n\t\tsess:         callPartySession(msg.sess),\n\t}\n\t// Wait for constCallEstablishmentTimeout for the other side to accept the call.\n\tt.callEstablishmentTimer.Reset(time.Duration(globals.callEstablishmentTimeout) * time.Second)\n}\n\n// Handles events on existing video call (acceptance, termination, metadata exchange).\n// (in response to msg = {note what=call}).\nfunc (t *Topic) handleCallEvent(msg *ClientComMessage) {\n\tif t.currentCall == nil {\n\t\t// Must initiate call first.\n\t\tlogs.Warn.Printf(\"topic[%s]: No call in progress\", t.name)\n\t\treturn\n\t}\n\tif t.isInactive() {\n\t\t// Topic is paused or being deleted.\n\t\treturn\n\t}\n\n\tcall := msg.Note\n\tif t.currentCall.seq != call.SeqId {\n\t\t// Call not found.\n\t\tlogs.Info.Printf(\"topic[%s]: invalid seq id - current call (%d) vs received (%d)\", t.name, t.currentCall.seq, call.SeqId)\n\t\treturn\n\t}\n\n\tasUid := types.ParseUserId(msg.AsUser)\n\n\tif _, userFound := t.perUser[asUid]; !userFound {\n\t\t// User not found in topic.\n\t\tlogs.Warn.Printf(\"topic[%s]: could not find user %s\", t.name, asUid.UserId())\n\t\treturn\n\t}\n\n\tswitch call.Event {\n\tcase constCallEventRinging, constCallEventAccept:\n\t\t// Invariants:\n\t\t// 1. Call has been initiated but not been established yet.\n\t\tif len(t.currentCall.parties) != 1 {\n\t\t\treturn\n\t\t}\n\t\toriginatorUid, originator := t.getCallOriginator()\n\t\tif originator == nil {\n\t\t\t// No originator session: terminating.\n\t\t\tt.terminateCallInProgress(false)\n\t\t\treturn\n\t\t}\n\t\t// 2. These events may only arrive from the callee.\n\t\tif originator.sid == msg.sess.sid || originatorUid == asUid {\n\t\t\treturn\n\t\t}\n\t\t// Prepare a {info} message to forward to the call originator.\n\t\tforwardMsg := t.currentCall.infoMessage(call.Event)\n\t\tforwardMsg.Info.From = msg.AsUser\n\t\tforwardMsg.Info.Topic = t.original(originatorUid)\n\t\tif call.Event == constCallEventAccept {\n\t\t\t// The call has been accepted.\n\t\t\t// Send a replacement {data} message to the topic.\n\t\t\tmsgCopy := *msg\n\t\t\tmsgCopy.AsUser = originatorUid.UserId()\n\t\t\treplaceWith := constCallMsgAccepted\n\t\t\tvar origHead map[string]any\n\t\t\tif msgCopy.Pub != nil {\n\t\t\t\torigHead = msgCopy.Pub.Head\n\t\t\t} // else fetch the original message from store and use its head.\n\t\t\thead := t.currentCall.messageHead(origHead, replaceWith, 0)\n\t\t\tif err := t.saveAndBroadcastMessage(&msgCopy, originatorUid, false, nil,\n\t\t\t\thead, t.currentCall.content); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// Add callee data to t.currentCall.\n\t\t\tt.currentCall.parties[msg.sess.sid] = callPartyData{\n\t\t\t\tuid:          asUid,\n\t\t\t\tisOriginator: false,\n\t\t\t\tsess:         callPartySession(msg.sess),\n\t\t\t}\n\t\t\tt.currentCall.acceptedAt = time.Now()\n\n\t\t\t// Notify other clients that the call has been accepted.\n\t\t\tt.infoCallSubsOffline(msg.AsUser, asUid, call.Event, t.currentCall.seq, call.Payload, msg.sess.sid, false)\n\t\t\tt.callEstablishmentTimer.Stop()\n\t\t}\n\t\toriginator.queueOut(forwardMsg)\n\n\tcase constCallEventOffer, constCallEventAnswer, constCallEventIceCandidate:\n\t\t// Invariants:\n\t\t// 1. Call has been estabslied (2 participants).\n\t\tif len(t.currentCall.parties) != 2 {\n\t\t\tlogs.Warn.Printf(\"topic[%s]: call participants expected 2 vs found %d\", t.name, len(t.currentCall.parties))\n\t\t\treturn\n\t\t}\n\t\t// 2. Event is coming from a call participant session.\n\t\tif _, ok := t.currentCall.parties[msg.sess.sid]; !ok {\n\t\t\tlogs.Warn.Printf(\"topic[%s]: call event from non-party session %s\", t.name, msg.sess.sid)\n\t\t\treturn\n\t\t}\n\t\t// Call metadata exchange. Either side of the call may send these events.\n\t\t// Simply forward them to the other session.\n\t\tvar otherUid types.Uid\n\t\tvar otherEnd *Session\n\t\tfor sid, p := range t.currentCall.parties {\n\t\t\tif sid != msg.sess.sid {\n\t\t\t\totherUid = p.uid\n\t\t\t\totherEnd = p.sess\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif otherEnd == nil {\n\t\t\tlogs.Warn.Printf(\"topic[%s]: could not find call peer for session %s\", t.name, msg.sess.sid)\n\t\t\treturn\n\t\t}\n\t\t// All is good. Send {info} message to the otherEnd.\n\t\tforwardMsg := t.currentCall.infoMessage(call.Event)\n\t\tforwardMsg.Info.From = msg.AsUser\n\t\tforwardMsg.Info.Topic = t.original(otherUid)\n\t\tforwardMsg.Info.Payload = call.Payload\n\t\totherEnd.queueOut(forwardMsg)\n\n\tcase constCallEventHangUp:\n\t\tswitch len(t.currentCall.parties) {\n\t\tcase 2:\n\t\t\t// If it's a call in progress, hangup may arrive only from a call participant session.\n\t\t\tif _, ok := t.currentCall.parties[msg.sess.sid]; !ok {\n\t\t\t\treturn\n\t\t\t}\n\t\tcase 1:\n\t\t\t// Call hasn't been established yet.\n\t\t\toriginatorUid, originator := t.getCallOriginator()\n\t\t\t// Hangup may come from either the originating session or\n\t\t\t// any callee user session.\n\t\t\tif asUid == originatorUid && originator.sid != msg.sess.sid {\n\t\t\t\treturn\n\t\t\t}\n\t\tdefault:\n\t\t\tbreak\n\t\t}\n\t\tt.maybeEndCallInProgress(msg.AsUser, msg, false)\n\n\tdefault:\n\t\tlogs.Warn.Printf(\"topic[%s]: video call (seq %d) received unexpected call event: %s\", t.name, t.currentCall.seq, call.Event)\n\t}\n}\n\n// Ends current call in response to a client hangup request (msg).\nfunc (t *Topic) maybeEndCallInProgress(from string, msg *ClientComMessage, callDidTimeout bool) {\n\tif t.currentCall == nil {\n\t\treturn\n\t}\n\tt.callEstablishmentTimer.Stop()\n\toriginatorUid, _ := t.getCallOriginator()\n\tvar replaceWith string\n\tvar callDuration int64\n\tif from != \"\" && len(t.currentCall.parties) == 2 {\n\t\t// This is a call in progress.\n\t\treplaceWith = constCallMsgFinished\n\t\tcallDuration = time.Since(t.currentCall.acceptedAt).Milliseconds()\n\t} else {\n\t\tif from != \"\" {\n\t\t\t// User originated hang-up.\n\t\t\tif from == originatorUid.UserId() {\n\t\t\t\t// Originator/caller requested event.\n\t\t\t\treplaceWith = constCallMsgMissed\n\t\t\t} else {\n\t\t\t\t// Callee requested event.\n\t\t\t\treplaceWith = constCallMsgDeclined\n\t\t\t}\n\t\t} else {\n\t\t\t// Server initiated disconnect.\n\t\t\t// Call hasn't been established. Just drop it.\n\t\t\tif callDidTimeout {\n\t\t\t\treplaceWith = constCallMsgMissed\n\t\t\t} else {\n\t\t\t\treplaceWith = constCallMsgDisconnected\n\t\t\t}\n\t\t}\n\t}\n\n\t// Send a message indicating the call has ended.\n\tmsgCopy := *msg\n\tmsgCopy.AsUser = originatorUid.UserId()\n\tvar origHead map[string]any\n\tif msgCopy.Pub != nil {\n\t\torigHead = msgCopy.Pub.Head\n\t} // else fetch the original message from store and use its head.\n\thead := t.currentCall.messageHead(origHead, replaceWith, int(callDuration))\n\tif err := t.saveAndBroadcastMessage(&msgCopy, originatorUid, false, nil, head, t.currentCall.content); err != nil {\n\t\tlogs.Err.Printf(\"topic[%s]: failed to write finalizing message for call seq id %d - '%s'\", t.name, t.currentCall.seq, err)\n\t}\n\n\t// Send {info} hangup event to the subscribed sessions.\n\tt.broadcastToSessions(t.currentCall.infoMessage(constCallEventHangUp))\n\n\t// Let all other sessions know the call is over.\n\tfor tgt := range t.perUser {\n\t\tt.infoCallSubsOffline(from, tgt, constCallEventHangUp, t.currentCall.seq, nil, \"\", true)\n\t}\n\tt.currentCall = nil\n}\n\n// Server initiated call termination.\nfunc (t *Topic) terminateCallInProgress(callDidTimeout bool) {\n\tif t.currentCall == nil {\n\t\treturn\n\t}\n\tuid, sess := t.getCallOriginator()\n\tif sess == nil || uid.IsZero() {\n\t\t// Just drop the call.\n\t\tlogs.Warn.Printf(\"topic[%s]: video call seq %d has no originator, terminating.\", t.name, t.currentCall.seq)\n\t\tt.currentCall = nil\n\t\treturn\n\t}\n\t// Dummy hangup request.\n\tdummy := &ClientComMessage{\n\t\tOriginal:  t.original(uid),\n\t\tRcptTo:    uid.UserId(),\n\t\tAsUser:    uid.UserId(),\n\t\tTimestamp: types.TimeNow(),\n\t\tsess:      sess,\n\t}\n\n\tlogs.Info.Printf(\"topic[%s]: terminating call seq %d, timeout: %t\", t.name, t.currentCall.seq, callDidTimeout)\n\tt.maybeEndCallInProgress(\"\", dummy, callDidTimeout)\n}\n"
  },
  {
    "path": "server/cluster.go",
    "content": "package main\n\nimport (\n\t\"encoding/gob\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"net\"\n\t\"net/rpc\"\n\t\"sort\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/auth\"\n\t\"github.com/tinode/chat/server/concurrency\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/push\"\n\trh \"github.com/tinode/chat/server/ringhash\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\nconst (\n\t// Network connection timeout.\n\tclusterNetworkTimeout = 3 * time.Second\n\t// Default timeout before attempting to reconnect to a node.\n\tclusterDefaultReconnectTime = 200 * time.Millisecond\n\t// Number of replicas in ringhash.\n\tclusterHashReplicas = 20\n\t// Buffer size for sending requests from proxy to master.\n\tclusterProxyToMasterBuffer = 64\n\t// Expand buffer size by this value for nodes over the basic 3-node setup.\n\tclusterProxyToMasterBufferPerNode = 16\n\t// Timeout for attempting to enqueue a proxy-to-master request when the buffer is full.\n\tclusterP2MTimeout = 20 * time.Millisecond\n\t// Buffer size for receiving responses from other nodes, per node.\n\tclusterRpcCompletionBuffer = 64\n)\n\n// ProxyReqType is the type of proxy requests.\ntype ProxyReqType int\n\n// Individual request types.\nconst (\n\tProxyReqNone      ProxyReqType = iota\n\tProxyReqJoin                   // {sub}.\n\tProxyReqLeave                  // {leave}\n\tProxyReqMeta                   // {meta set|get}\n\tProxyReqBroadcast              // {pub}, {note}\n\tProxyReqBgSession\n\tProxyReqMeUserAgent\n\tProxyReqCall // Used in video call proxy sessions for routing call events.\n)\n\ntype clusterNodeConfig struct {\n\tName string `json:\"name\"`\n\tAddr string `json:\"addr\"`\n}\n\ntype clusterConfig struct {\n\t// List of all members of the cluster, including this member\n\tNodes []clusterNodeConfig `json:\"nodes\"`\n\t// Name of this cluster node\n\tThisName string `json:\"self\"`\n\t// Deprecated: this field is no longer used.\n\tNumProxyEventGoRoutines int `json:\"-\"`\n\t// Failover configuration\n\tFailover *clusterFailoverConfig\n}\n\n// ClusterNode is a client's connection to another node.\ntype ClusterNode struct {\n\tlock sync.Mutex\n\n\t// RPC endpoint\n\tendpoint *rpc.Client\n\t// True if the endpoint is believed to be connected\n\tconnected bool\n\t// True if a go routine is trying to reconnect the node\n\treconnecting bool\n\t// TCP address in the form host:port\n\taddress string\n\t// Name of the node\n\tname string\n\t// Fingerprint of the node: unique value which changes when the node restarts.\n\tfingerprint int64\n\n\t// A number of times this node has failed in a row\n\tfailCount int\n\n\t// Channel for shutting down the runner; buffered, 1.\n\tdone chan bool\n\n\t// IDs of multiplexing sessions belonging to this node.\n\tmsess map[string]struct{}\n\n\t// Default channel for receiving responses to RPC calls issued by this node.\n\t// Buffered, clusterRpcCompletionBuffer * number_of_nodes.\n\trpcDone chan *rpc.Call\n\n\t// Channel for sending proxy to master requests; buffered, clusterProxyToMasterBuffer.\n\tp2mSender chan *ClusterReq\n}\n\nfunc (n *ClusterNode) asyncRpcLoop() {\n\tfor call := range n.rpcDone {\n\t\tn.handleRpcResponse(call)\n\t}\n}\n\nfunc (n *ClusterNode) p2mSenderLoop() {\n\tfor req := range n.p2mSender {\n\t\tif req == nil {\n\t\t\t// Stop\n\t\t\treturn\n\t\t}\n\n\t\tif err := n.proxyToMaster(req); err != nil {\n\t\t\tlogs.Warn.Println(\"p2mSenderLoop: call failed\", n.name, err)\n\t\t}\n\t}\n}\n\n// ClusterSess is a basic info on a remote session where the message was created.\ntype ClusterSess struct {\n\t// IP address of the client. For long polling this is the IP of the last poll\n\tRemoteAddr string\n\n\t// User agent, a string provived by an authenticated client in {login} packet\n\tUserAgent string\n\n\t// ID of the current user or 0\n\tUid types.Uid\n\n\t// User's authentication level\n\tAuthLvl auth.Level\n\n\t// Protocol version of the client: ((major & 0xff) << 8) | (minor & 0xff)\n\tVer int\n\n\t// Human language of the client\n\tLang string\n\t// Country of the client\n\tCountryCode string\n\n\t// Device ID\n\tDeviceID string\n\n\t// Device platform: \"web\", \"ios\", \"android\"\n\tPlatform string\n\n\t// Session ID\n\tSid string\n\n\t// Background session\n\tBackground bool\n}\n\n// ClusterSessUpdate represents a request to update a session.\n// User Agent change or background session comes to foreground.\ntype ClusterSessUpdate struct {\n\t// User this session represents.\n\tUid types.Uid\n\t// Session id.\n\tSid string\n\t// Session user agent.\n\tUserAgent string\n}\n\n// ClusterReq is either a Proxy to Master or Topic Proxy to Topic Master or intra-cluster routing request message.\ntype ClusterReq struct {\n\t// Name of the node sending this request\n\tNode string\n\n\t// Ring hash signature of the node sending this request\n\t// Signature must match the signature of the receiver, otherwise the\n\t// Cluster is desynchronized.\n\tSignature string\n\n\t// Fingerprint of the node sending this request.\n\t// Fingerprint changes when the node is restarted.\n\tFingerprint int64\n\n\t// Type of request.\n\tReqType ProxyReqType\n\n\t// Client message. Set for C2S requests.\n\tCliMsg *ClientComMessage\n\t// Message to be routed. Set for intra-cluster route requests.\n\tSrvMsg *ServerComMessage\n\n\t// Expanded (routable) topic name\n\tRcptTo string\n\t// Originating session\n\tSess *ClusterSess\n\t// True when the topic proxy is gone.\n\tGone bool\n}\n\n// ClusterRoute is intra-cluster routing request message.\ntype ClusterRoute struct {\n\t// Name of the node sending this request\n\tNode string\n\n\t// Ring hash signature of the node sending this request\n\t// Signature must match the signature of the receiver, otherwise the\n\t// Cluster is desynchronized.\n\tSignature string\n\n\t// Fingerprint of the node sending this request.\n\t// Fingerprint changes when the node is restarted.\n\tFingerprint int64\n\n\t// Message to be routed. Set for intra-cluster route requests.\n\tSrvMsg *ServerComMessage\n\n\t// Originating session\n\tSess *ClusterSess\n}\n\n// ClusterResp is a Master to Proxy response message.\ntype ClusterResp struct {\n\t// Server message with the response.\n\tSrvMsg *ServerComMessage\n\t// Originating session ID to forward response to, if any.\n\tOrigSid string\n\t// Expanded (routable) topic name\n\tRcptTo string\n\n\t// Parameters sent back by the topic master in response a topic proxy request.\n\n\t// Original request type.\n\tOrigReqType ProxyReqType\n}\n\n// ClusterPing is used to detect node restarts.\ntype ClusterPing struct {\n\t// Name of the node sending this request.\n\tNode string\n\n\t// Fingerprint of the node sending this request.\n\t// Fingerprint changes when the node restarts.\n\tFingerprint int64\n}\n\n// Handle outbound node communication: read messages from the channel, forward to remote nodes.\n// FIXME(gene): this will drain the outbound queue in case of a failure: all unprocessed messages will be dropped.\n// Maybe it's a good thing, maybe not.\nfunc (n *ClusterNode) reconnect() {\n\tvar reconnTicker *time.Ticker\n\n\t// Avoid parallel reconnection threads.\n\tn.lock.Lock()\n\tif n.reconnecting {\n\t\tn.lock.Unlock()\n\t\treturn\n\t}\n\tn.reconnecting = true\n\tn.lock.Unlock()\n\n\tcount := 0\n\tfor {\n\t\t// Attempt to reconnect right away\n\t\tif conn, err := net.DialTimeout(\"tcp\", n.address, clusterNetworkTimeout); err == nil {\n\t\t\tif reconnTicker != nil {\n\t\t\t\treconnTicker.Stop()\n\t\t\t}\n\t\t\tn.lock.Lock()\n\t\t\tn.endpoint = rpc.NewClient(conn)\n\t\t\tn.connected = true\n\t\t\tn.reconnecting = false\n\t\t\tn.lock.Unlock()\n\t\t\tstatsInc(\"LiveClusterNodes\", 1)\n\t\t\tlogs.Info.Println(\"cluster: connected to\", n.name)\n\t\t\t// Send this node credentials to the new node.\n\t\t\tvar unused bool\n\t\t\tn.call(\"Cluster.Ping\",\n\t\t\t\t&ClusterPing{\n\t\t\t\t\tNode:        globals.cluster.thisNodeName,\n\t\t\t\t\tFingerprint: globals.cluster.fingerprint,\n\t\t\t\t},\n\t\t\t\t&unused)\n\t\t\treturn\n\t\t} else if count == 0 {\n\t\t\treconnTicker = time.NewTicker(clusterDefaultReconnectTime)\n\t\t}\n\n\t\tcount++\n\n\t\tselect {\n\t\tcase <-reconnTicker.C:\n\t\t\t// Wait for timer to try to reconnect again. Do nothing if the timer is inactive.\n\t\tcase <-n.done:\n\t\t\t// Shutting down\n\t\t\tlogs.Info.Println(\"cluster: shutdown started at node\", n.name)\n\t\t\treconnTicker.Stop()\n\t\t\tif n.endpoint != nil {\n\t\t\t\tn.endpoint.Close()\n\t\t\t}\n\t\t\tn.lock.Lock()\n\t\t\tn.connected = false\n\t\t\tn.reconnecting = false\n\t\t\tn.lock.Unlock()\n\t\t\tlogs.Info.Println(\"cluster: shut down completed at node\", n.name)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (n *ClusterNode) call(proc string, req, resp any) error {\n\tif !n.connected {\n\t\treturn errors.New(\"cluster: node '\" + n.name + \"' not connected\")\n\t}\n\n\tif err := n.endpoint.Call(proc, req, resp); err != nil {\n\t\tlogs.Warn.Println(\"cluster: call failed\", n.name, err)\n\n\t\tn.lock.Lock()\n\t\tif n.connected {\n\t\t\tn.endpoint.Close()\n\t\t\tn.connected = false\n\t\t\tstatsInc(\"LiveClusterNodes\", -1)\n\t\t\tgo n.reconnect()\n\t\t}\n\t\tn.lock.Unlock()\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (n *ClusterNode) handleRpcResponse(call *rpc.Call) {\n\tif call.Error != nil {\n\t\tlogs.Warn.Printf(\"cluster: %s call failed: %s\", call.ServiceMethod, call.Error)\n\t\tn.lock.Lock()\n\t\tif n.connected {\n\t\t\tn.endpoint.Close()\n\t\t\tn.connected = false\n\t\t\tstatsInc(\"LiveClusterNodes\", -1)\n\t\t\tgo n.reconnect()\n\t\t}\n\t\tn.lock.Unlock()\n\t}\n}\n\nfunc (n *ClusterNode) callAsync(proc string, req, resp any, done chan *rpc.Call) *rpc.Call {\n\tif done != nil && cap(done) == 0 {\n\t\tlogs.Err.Panic(\"cluster: RPC done channel is unbuffered\")\n\t}\n\n\tif !n.connected {\n\t\tcall := &rpc.Call{\n\t\t\tServiceMethod: proc,\n\t\t\tArgs:          req,\n\t\t\tReply:         resp,\n\t\t\tError:         errors.New(\"cluster: node '\" + n.name + \"' not connected\"),\n\t\t\tDone:          done,\n\t\t}\n\t\tif done != nil {\n\t\t\tdone <- call\n\t\t}\n\t\treturn call\n\t}\n\n\tvar responseChan chan *rpc.Call\n\tif done != nil {\n\t\t// Make a separate response callback if we need to notify the caller.\n\t\tmyDone := make(chan *rpc.Call, 1)\n\t\tgo func() {\n\t\t\tcall := <-myDone\n\t\t\tn.handleRpcResponse(call)\n\t\t\tif done != nil {\n\t\t\t\tdone <- call\n\t\t\t}\n\t\t}()\n\t\tresponseChan = myDone\n\t} else {\n\t\tresponseChan = n.rpcDone\n\t}\n\n\tcall := n.endpoint.Go(proc, req, resp, responseChan)\n\n\treturn call\n}\n\n// proxyToMaster forwards request from topic proxy to topic master.\nfunc (n *ClusterNode) proxyToMaster(msg *ClusterReq) error {\n\tmsg.Node = globals.cluster.thisNodeName\n\tvar rejected bool\n\terr := n.call(\"Cluster.TopicMaster\", msg, &rejected)\n\tif err == nil && rejected {\n\t\terr = errors.New(\"cluster: topic master node out of sync\")\n\t}\n\treturn err\n}\n\n// proxyToMaster forwards request from topic proxy to topic master.\nfunc (n *ClusterNode) proxyToMasterAsync(msg *ClusterReq) error {\n\tselect {\n\tcase n.p2mSender <- msg:\n\t\treturn nil\n\tdefault:\n\t}\n\t// Buffer is full. Wait briefly before giving up.\n\ttimer := time.NewTimer(clusterP2MTimeout)\n\tdefer timer.Stop()\n\tselect {\n\tcase n.p2mSender <- msg:\n\t\treturn nil\n\tcase <-timer.C:\n\t\treturn errors.New(\"cluster: load exceeded\")\n\t}\n}\n\n// masterToProxyAsync forwards response from topic master to topic proxy\n// in a fire-and-forget manner.\nfunc (n *ClusterNode) masterToProxyAsync(msg *ClusterResp) error {\n\tvar unused bool\n\tif c := n.callAsync(\"Cluster.TopicProxy\", msg, &unused, nil); c.Error != nil {\n\t\treturn c.Error\n\t}\n\treturn nil\n}\n\n// route routes server message within the cluster.\nfunc (n *ClusterNode) route(msg *ClusterRoute) error {\n\tvar unused bool\n\treturn n.call(\"Cluster.Route\", msg, &unused)\n}\n\n// Cluster is the representation of the cluster.\ntype Cluster struct {\n\t// Cluster nodes with RPC endpoints (excluding current node).\n\tnodes map[string]*ClusterNode\n\t// Name of the local node\n\tthisNodeName string\n\t// Fingerprint of the local node\n\tfingerprint int64\n\n\t// Resolved address to listed on\n\tlistenOn string\n\n\t// Socket for inbound connections\n\tinbound *net.TCPListener\n\t// Ring hash for mapping topic names to nodes\n\tring *rh.Ring\n\n\t// Failover parameters. Could be nil if failover is not enabled\n\tfo *clusterFailover\n\n\t// Thread pool to use for running proxy session (write) event processing logic.\n\t// The number of proxy sessions grows as O(number of topics x number of cluster nodes).\n\t// In large Tinode deployments (10s of thousands of topics, tens of nodes),\n\t// running a separate event processing goroutine for each proxy session\n\t// leads to a rather large memory usage and excessive scheduling overhead.\n\tproxyEventQueue *concurrency.GoRoutinePool\n}\n\nfunc (n *ClusterNode) stopMultiplexingSession(msess *Session) {\n\tif msess == nil {\n\t\treturn\n\t}\n\tmsess.stopSession(nil)\n\tn.lock.Lock()\n\tdelete(n.msess, msess.sid)\n\tn.lock.Unlock()\n}\n\n// TopicMaster is a gRPC endpoint which receives requests sent by proxy topic to master topic.\nfunc (c *Cluster) TopicMaster(msg *ClusterReq, rejected *bool) error {\n\t*rejected = false\n\n\tnode := c.nodes[msg.Node]\n\tif node == nil {\n\t\tlogs.Warn.Println(\"cluster TopicMaster: request from an unknown node\", msg.Node)\n\t\treturn nil\n\t}\n\n\t// Master maintains one multiplexing session per proxy topic per node.\n\t// Except channel topics:\n\t// * one multiplexing session for channel subscriptions.\n\t// * one multiplexing session for group subscriptions.\n\tvar msid string\n\tif msg.CliMsg != nil && types.IsChannel(msg.CliMsg.Original) {\n\t\t// If it's a channel request, use channel name.\n\t\tmsid = msg.CliMsg.Original\n\t} else {\n\t\tmsid = msg.RcptTo\n\t}\n\t// Append node name.\n\tmsid += \"-\" + msg.Node\n\tmsess := globals.sessionStore.Get(msid)\n\n\tif msg.Gone {\n\t\t// Proxy topic is gone. Tear down the local auxiliary session.\n\t\t// If it was the last session, master topic will shut down as well.\n\t\tnode.stopMultiplexingSession(msess)\n\n\t\tif t := globals.hub.topicGet(msg.RcptTo); t != nil && t.isChan {\n\t\t\t// If it's a channel topic, also stop the \"chnX-\" local auxiliary session.\n\t\t\tmsidChn := types.GrpToChn(t.name) + \"-\" + msg.Node\n\t\t\tnode.stopMultiplexingSession(globals.sessionStore.Get(msidChn))\n\t\t}\n\n\t\treturn nil\n\t}\n\n\tif msg.Signature != c.ring.Signature() {\n\t\tlogs.Warn.Println(\"cluster TopicMaster: session signature mismatch\", msg.RcptTo)\n\t\t*rejected = true\n\t\treturn nil\n\t}\n\n\t// Create a new multiplexing session if needed.\n\tif msess == nil {\n\t\t// If the session is not found, create it.\n\t\tvar count int\n\t\tmsess, count = globals.sessionStore.NewSession(node, msid)\n\t\tnode.lock.Lock()\n\t\tnode.msess[msid] = struct{}{}\n\t\tnode.lock.Unlock()\n\n\t\tlogs.Info.Println(\"cluster: multiplexing session started\", msid, count)\n\t\tmsess.proxiedTopic = msg.RcptTo\n\t}\n\n\t// This is a local copy of a remote session.\n\tvar sess *Session\n\t// Sess is nil for user agent changes and deferred presence notification requests.\n\tif msg.Sess != nil {\n\t\t// We only need some session info. No need to copy everything.\n\t\tsess = &Session{\n\t\t\tproto: PROXY,\n\t\t\t// Multiplexing session which actually handles the communication.\n\t\t\tmulti: msess,\n\t\t\t// Local parameters specific to this session.\n\t\t\tsid:         msg.Sess.Sid,\n\t\t\tuserAgent:   msg.Sess.UserAgent,\n\t\t\tremoteAddr:  msg.Sess.RemoteAddr,\n\t\t\tlang:        msg.Sess.Lang,\n\t\t\tcountryCode: msg.Sess.CountryCode,\n\t\t\tproxyReq:    msg.ReqType,\n\t\t\tbackground:  msg.Sess.Background,\n\t\t\tuid:         msg.Sess.Uid,\n\t\t}\n\t}\n\n\tif msg.CliMsg != nil {\n\t\tmsg.CliMsg.sess = sess\n\t\tmsg.CliMsg.init = true\n\t}\n\n\tswitch msg.ReqType {\n\tcase ProxyReqJoin:\n\t\tselect {\n\t\tcase globals.hub.join <- msg.CliMsg:\n\t\tdefault:\n\t\t\t// Reply with a 500 to the user.\n\t\t\tsess.queueOut(ErrUnknownReply(msg.CliMsg, msg.CliMsg.Timestamp))\n\t\t\tlogs.Warn.Println(\"cluster: join req failed - hub.join queue full, topic \", msg.CliMsg.RcptTo,\n\t\t\t\t\"; orig sid \", sess.sid)\n\t\t}\n\n\tcase ProxyReqLeave:\n\t\tif t := globals.hub.topicGet(msg.RcptTo); t != nil {\n\t\t\tt.unreg <- msg.CliMsg\n\t\t} else {\n\t\t\tlogs.Warn.Println(\"cluster: leave request for unknown topic\", msg.RcptTo)\n\t\t}\n\n\tcase ProxyReqMeta:\n\t\tif t := globals.hub.topicGet(msg.RcptTo); t != nil {\n\t\t\tselect {\n\t\t\tcase t.meta <- msg.CliMsg:\n\t\t\tdefault:\n\t\t\t\tsess.queueOut(ErrUnknownReply(msg.CliMsg, msg.CliMsg.Timestamp))\n\t\t\t\tlogs.Warn.Println(\"cluster: meta req failed - topic.meta queue full, topic \", msg.CliMsg.RcptTo,\n\t\t\t\t\t\"; orig sid \", sess.sid)\n\t\t\t}\n\t\t} else {\n\t\t\tlogs.Warn.Println(\"cluster: meta request for unknown topic\", msg.RcptTo)\n\t\t}\n\n\tcase ProxyReqBroadcast:\n\t\tselect {\n\t\tcase globals.hub.routeCli <- msg.CliMsg:\n\t\tdefault:\n\t\t\tlogs.Err.Println(\"cluster: route req failed - hub.route queue full\")\n\t\t}\n\n\tcase ProxyReqBgSession, ProxyReqMeUserAgent:\n\t\t// sess could be nil\n\t\tif t := globals.hub.topicGet(msg.RcptTo); t != nil {\n\t\t\tif t.supd == nil {\n\t\t\t\tlogs.Err.Panicln(\"cluster: invalid topic category in session update\", t.name, msg.ReqType)\n\t\t\t}\n\t\t\tsu := &sessionUpdate{}\n\t\t\tif msg.ReqType == ProxyReqBgSession {\n\t\t\t\tsu.sess = sess\n\t\t\t} else {\n\t\t\t\tsu.userAgent = sess.userAgent\n\t\t\t}\n\t\t\tt.supd <- su\n\t\t} else {\n\t\t\tlogs.Warn.Println(\"cluster: session update for unknown topic\", msg.RcptTo, msg.ReqType)\n\t\t}\n\n\tdefault:\n\t\tlogs.Warn.Println(\"cluster: unknown request type\", msg.ReqType, msg.RcptTo)\n\t\t*rejected = true\n\t}\n\n\treturn nil\n}\n\n// TopicProxy is a gRPC endpoint at topic proxy which receives topic master responses.\nfunc (Cluster) TopicProxy(msg *ClusterResp, unused *bool) error {\n\t// This cluster member received a response from the topic master to be forwarded to the topic.\n\t// Find appropriate topic, send the message to it.\n\tif t := globals.hub.topicGet(msg.RcptTo); t != nil {\n\t\tmsg.SrvMsg.uid = types.ParseUserId(msg.SrvMsg.AsUser)\n\t\tselect {\n\t\tcase t.proxy <- msg:\n\t\tdefault:\n\t\t\tlogs.Warn.Printf(\"cluster: proxy channel full, topic %s\", msg.RcptTo)\n\t\t}\n\t} else {\n\t\tlogs.Warn.Println(\"cluster: master response for unknown topic\", msg.RcptTo)\n\t}\n\n\treturn nil\n}\n\n// Route endpoint receives intra-cluster messages destined for the nodes hosting the topic.\n// Called by Hub.route channel consumer for messages send without attaching to topic first.\nfunc (c *Cluster) Route(msg *ClusterRoute, rejected *bool) error {\n\tlogError := func(err string) {\n\t\tsid := \"\"\n\t\tif msg.Sess != nil {\n\t\t\tsid = msg.Sess.Sid\n\t\t}\n\t\tlogs.Warn.Println(err, sid)\n\t\t*rejected = true\n\t}\n\n\t*rejected = false\n\tif msg.Signature != c.ring.Signature() {\n\t\tlogError(\"cluster Route: session signature mismatch\")\n\t\treturn nil\n\t}\n\n\tif msg.SrvMsg == nil {\n\t\t// TODO: maybe panic here.\n\t\tlogError(\"cluster Route: nil server message\")\n\t\treturn nil\n\t}\n\n\tselect {\n\tcase globals.hub.routeSrv <- msg.SrvMsg:\n\tdefault:\n\t\tlogError(\"cluster Route: server busy\")\n\t}\n\treturn nil\n}\n\n// User cache & push notifications management. These are calls received by the Master from Proxy.\n// The Proxy expects no payload to be returned by the master.\n\n// UserCacheUpdate endpoint receives updates to user's cached values as well as sends push notifications.\nfunc (c *Cluster) UserCacheUpdate(msg *UserCacheReq, rejected *bool) error {\n\tif msg.Gone {\n\t\t// User is deleted. Evict all user's sessions.\n\t\tglobals.sessionStore.EvictUser(msg.UserId, \"\")\n\n\t\tif globals.cluster.isRemoteTopic(msg.UserId.UserId()) {\n\t\t\t// No need to delete user's cache if user is remote.\n\t\t\treturn nil\n\t\t}\n\t}\n\n\tusersRequestFromCluster(msg)\n\treturn nil\n}\n\n// Ping is a gRPC endpoint which receives ping requests from peer nodes.Used to detect node restarts.\nfunc (c *Cluster) Ping(ping *ClusterPing, unused *bool) error {\n\tnode := c.nodes[ping.Node]\n\tif node == nil {\n\t\tlogs.Warn.Println(\"cluster Ping from unknown node\", ping.Node)\n\t\treturn nil\n\t}\n\n\tif node.fingerprint == 0 {\n\t\t// This is the first connection to remote node.\n\t\tnode.fingerprint = ping.Fingerprint\n\t} else if node.fingerprint != ping.Fingerprint {\n\t\t// Remote node restarted.\n\t\tnode.fingerprint = ping.Fingerprint\n\t\tc.invalidateProxySubs(ping.Node)\n\t\tc.gcProxySessionsForNode(ping.Node)\n\t}\n\n\treturn nil\n}\n\n// Sends user cache update to user's Master node where the cache actually resides.\n// The request is extected to contain users who reside at remote nodes only.\nfunc (c *Cluster) routeUserReq(req *UserCacheReq) error {\n\t// Index requests by cluster node.\n\treqByNode := make(map[string]*UserCacheReq)\n\n\tif req.PushRcpt != nil {\n\t\t// Request to send push notifications. Create separate packets for each affected cluster node.\n\t\tfor uid, recipient := range req.PushRcpt.To {\n\t\t\tn := c.nodeForTopic(uid.UserId())\n\t\t\tif n == nil {\n\t\t\t\treturn errors.New(\"attempt to update user at a non-existent node (1)\")\n\t\t\t}\n\t\t\tr := reqByNode[n.name]\n\t\t\tif r == nil {\n\t\t\t\tr = &UserCacheReq{\n\t\t\t\t\tPushRcpt: &push.Receipt{\n\t\t\t\t\t\tPayload: req.PushRcpt.Payload,\n\t\t\t\t\t\tTo:      make(map[types.Uid]push.Recipient),\n\t\t\t\t\t},\n\t\t\t\t\tNode: c.thisNodeName,\n\t\t\t\t}\n\t\t\t}\n\t\t\tr.PushRcpt.To[uid] = recipient\n\t\t\treqByNode[n.name] = r\n\t\t}\n\t} else if len(req.UserIdList) > 0 {\n\t\t// Request to add/remove some users from cache.\n\t\tfor _, uid := range req.UserIdList {\n\t\t\tn := c.nodeForTopic(uid.UserId())\n\t\t\tif n == nil {\n\t\t\t\treturn errors.New(\"attempt to update user at a non-existent node (2)\")\n\t\t\t}\n\t\t\tr := reqByNode[n.name]\n\t\t\tif r == nil {\n\t\t\t\tr = &UserCacheReq{Node: c.thisNodeName, Inc: req.Inc}\n\t\t\t}\n\t\t\tr.UserIdList = append(r.UserIdList, uid)\n\t\t\treqByNode[n.name] = r\n\t\t}\n\t} else if req.Gone {\n\t\t// Message that the user is deleted is sent to all nodes.\n\t\tr := &UserCacheReq{Node: c.thisNodeName, UserIdList: req.UserIdList, Gone: true}\n\t\tfor _, n := range c.nodes {\n\t\t\treqByNode[n.name] = r\n\t\t}\n\t}\n\n\tif len(reqByNode) > 0 {\n\t\tfor nodeName, r := range reqByNode {\n\t\t\tn := c.nodes[nodeName]\n\t\t\tvar rejected bool\n\t\t\terr := n.call(\"Cluster.UserCacheUpdate\", r, &rejected)\n\t\t\tif rejected {\n\t\t\t\terr = errors.New(\"master node out of sync\")\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\n\t// Update to cached values.\n\tn := c.nodeForTopic(req.UserId.UserId())\n\tif n == nil {\n\t\treturn errors.New(\"attempt to update user at a non-existent node (3)\")\n\t}\n\treq.Node = c.thisNodeName\n\tvar rejected bool\n\terr := n.call(\"Cluster.UserCacheUpdate\", req, &rejected)\n\tif rejected {\n\t\terr = errors.New(\"master node out of sync\")\n\t}\n\treturn err\n}\n\n// Given topic name, find appropriate cluster node to route message to.\nfunc (c *Cluster) nodeForTopic(topic string) *ClusterNode {\n\tkey := c.ring.Get(topic)\n\tif key == c.thisNodeName {\n\t\tlogs.Err.Println(\"cluster: request to route to self\")\n\t\t// Do not route to self\n\t\treturn nil\n\t}\n\n\tnode := c.nodes[key]\n\tif node == nil {\n\t\tlogs.Warn.Println(\"cluster: no node for topic\", topic, key)\n\t}\n\treturn node\n}\n\n// isRemoteTopic checks if the given topic is handled by this node or a remote node.\nfunc (c *Cluster) isRemoteTopic(topic string) bool {\n\tif c == nil {\n\t\t// Cluster not initialized, all topics are local\n\t\treturn false\n\t}\n\treturn c.ring.Get(topic) != c.thisNodeName\n}\n\n// genLocalTopicName is just like genTopicName(), but the generated name belongs to the current cluster node.\nfunc (c *Cluster) genLocalTopicName() string {\n\ttopic := genTopicName()\n\tif c == nil {\n\t\t// Cluster not initialized, all topics are local\n\t\treturn topic\n\t}\n\n\t// TODO: if cluster is large it may become too inefficient.\n\tfor c.ring.Get(topic) != c.thisNodeName {\n\t\ttopic = genTopicName()\n\t}\n\treturn topic\n}\n\n// isPartitioned checks if the cluster is partitioned due to network or other failure and if the\n// current node is a part of the smaller partition.\nfunc (c *Cluster) isPartitioned() bool {\n\tif c == nil || c.fo == nil {\n\t\t// Cluster not initialized or failover disabled therefore not partitioned.\n\t\treturn false\n\t}\n\n\tc.fo.activeNodesLock.RLock()\n\tresult := (len(c.nodes)+1)/2 >= len(c.fo.activeNodes)\n\tc.fo.activeNodesLock.RUnlock()\n\n\treturn result\n}\n\nfunc (c *Cluster) makeClusterReq(reqType ProxyReqType, msg *ClientComMessage, topic string, sess *Session) *ClusterReq {\n\treq := &ClusterReq{\n\t\tNode:        c.thisNodeName,\n\t\tSignature:   c.ring.Signature(),\n\t\tFingerprint: c.fingerprint,\n\t\tReqType:     reqType,\n\t\tRcptTo:      topic,\n\t}\n\n\tvar uid types.Uid\n\n\tif msg != nil {\n\t\treq.CliMsg = msg\n\t\tuid = types.ParseUserId(req.CliMsg.AsUser)\n\t}\n\n\tif sess != nil {\n\t\tif uid.IsZero() {\n\t\t\tuid = sess.uid\n\t\t}\n\n\t\treq.Sess = &ClusterSess{\n\t\t\tUid:         uid,\n\t\t\tAuthLvl:     sess.authLvl,\n\t\t\tRemoteAddr:  sess.remoteAddr,\n\t\t\tUserAgent:   sess.userAgent,\n\t\t\tVer:         sess.ver,\n\t\t\tLang:        sess.lang,\n\t\t\tCountryCode: sess.countryCode,\n\t\t\tDeviceID:    sess.deviceID,\n\t\t\tPlatform:    sess.platf,\n\t\t\tSid:         sess.sid,\n\t\t\tBackground:  sess.background,\n\t\t}\n\t}\n\treturn req\n}\n\n// Forward client request message from the Topic Proxy to the Topic Master (cluster node which owns the topic).\nfunc (c *Cluster) routeToTopicMaster(reqType ProxyReqType, msg *ClientComMessage, topic string, sess *Session) error {\n\tif c == nil {\n\t\t// Cluster may be nil due to shutdown.\n\t\treturn nil\n\t}\n\n\tif sess != nil && reqType != ProxyReqLeave {\n\t\tif atomic.LoadInt32(&sess.terminating) > 0 {\n\t\t\t// The session is terminating.\n\t\t\t// Do not forward any requests except \"leave\" to the topic master.\n\t\t\treturn nil\n\t\t}\n\t}\n\n\treq := c.makeClusterReq(reqType, msg, topic, sess)\n\n\t// Find the cluster node which owns the topic, then forward to it.\n\tn := c.nodeForTopic(topic)\n\tif n == nil {\n\t\treturn errors.New(\"node for topic not found\")\n\t}\n\treturn n.proxyToMasterAsync(req)\n}\n\n// Forward server response message to the node that owns topic.\nfunc (c *Cluster) routeToTopicIntraCluster(topic string, msg *ServerComMessage, sess *Session) error {\n\tif c == nil {\n\t\t// Cluster may be nil due to shutdown.\n\t\treturn nil\n\t}\n\n\tn := c.nodeForTopic(topic)\n\tif n == nil {\n\t\treturn errors.New(\"node for topic not found (intra)\")\n\t}\n\n\troute := &ClusterRoute{\n\t\tNode:        c.thisNodeName,\n\t\tSignature:   c.ring.Signature(),\n\t\tFingerprint: c.fingerprint,\n\t\tSrvMsg:      msg,\n\t}\n\n\tif sess != nil {\n\t\troute.Sess = &ClusterSess{Sid: sess.sid}\n\t}\n\treturn n.route(route)\n}\n\n// Topic proxy terminated. Inform remote Master node that the proxy is gone.\nfunc (c *Cluster) topicProxyGone(topicName string) error {\n\tif c == nil {\n\t\t// Cluster may be nil due to shutdown.\n\t\treturn nil\n\t}\n\n\t// Find the cluster node which owns the topic, then forward to it.\n\tn := c.nodeForTopic(topicName)\n\tif n == nil {\n\t\treturn errors.New(\"node for topic not found\")\n\t}\n\n\treq := c.makeClusterReq(ProxyReqLeave, nil, topicName, nil)\n\treq.Gone = true\n\treturn n.proxyToMasterAsync(req)\n}\n\n// Returns snowflake worker id.\nfunc clusterInit(configString json.RawMessage, self *string) int {\n\tif globals.cluster != nil {\n\t\tlogs.Err.Fatal(\"Cluster already initialized.\")\n\t}\n\n\t// Registering variables even if it's a standalone server. Otherwise monitoring software will\n\t// complain about missing vars.\n\n\t// 1 if this node is cluster leader, 0 otherwise\n\tstatsRegisterInt(\"ClusterLeader\")\n\t// Total number of nodes configured\n\tstatsRegisterInt(\"TotalClusterNodes\")\n\t// Number of nodes currently believed to be up.\n\tstatsRegisterInt(\"LiveClusterNodes\")\n\n\t// This is a standalone server, not initializing\n\tif len(configString) == 0 {\n\t\tlogs.Info.Println(\"Cluster: running as a standalone server.\")\n\t\treturn 1\n\t}\n\n\tvar config clusterConfig\n\tif err := json.Unmarshal(configString, &config); err != nil {\n\t\tlogs.Err.Fatal(err)\n\t}\n\n\tthisName := *self\n\tif thisName == \"\" {\n\t\tthisName = config.ThisName\n\t}\n\n\t// Name of the current node is not specified: clustering disabled.\n\tif thisName == \"\" {\n\t\tlogs.Info.Println(\"Cluster: running as a standalone server.\")\n\t\treturn 1\n\t}\n\n\tgob.Register([]any{})\n\tgob.Register(map[string]any{})\n\tgob.Register(map[string]int{})\n\tgob.Register(map[string]string{})\n\tgob.Register(MsgAccessMode{})\n\n\tif config.NumProxyEventGoRoutines != 0 {\n\t\tlogs.Warn.Println(\"Cluster config: field num_proxy_event_goroutines is deprecated.\")\n\t}\n\n\tglobals.cluster = &Cluster{\n\t\tthisNodeName:    thisName,\n\t\tfingerprint:     time.Now().Unix(),\n\t\tnodes:           make(map[string]*ClusterNode),\n\t\tproxyEventQueue: concurrency.NewGoRoutinePool(len(config.Nodes) * 5),\n\t}\n\n\tvar nodeNames []string\n\tfor _, host := range config.Nodes {\n\t\tnodeNames = append(nodeNames, host.Name)\n\n\t\tif host.Name == thisName {\n\t\t\tglobals.cluster.listenOn = host.Addr\n\t\t\t// Don't create a cluster member for this local instance\n\t\t\tcontinue\n\t\t}\n\n\t\tglobals.cluster.nodes[host.Name] = &ClusterNode{\n\t\t\taddress: host.Addr,\n\t\t\tname:    host.Name,\n\t\t\tdone:    make(chan bool, 1),\n\t\t\tmsess:   make(map[string]struct{}),\n\t\t}\n\t}\n\n\tif len(globals.cluster.nodes) == 0 {\n\t\t// Cluster needs at least two nodes.\n\t\tlogs.Err.Fatal(\"Cluster: invalid cluster size: 1\")\n\t}\n\n\tif len(globals.cluster.nodes)%2 == 1 {\n\t\t// Even number of cluster nodes (self + odd number).\n\t\tlogs.Warn.Println(\"Cluster: use odd number of cluster nodes\")\n\t}\n\n\tif !globals.cluster.failoverInit(config.Failover) {\n\t\tglobals.cluster.rehash(nil)\n\t}\n\n\tsort.Strings(nodeNames)\n\tworkerId := sort.SearchStrings(nodeNames, thisName) + 1\n\n\tstatsSet(\"TotalClusterNodes\", int64(len(globals.cluster.nodes)+1))\n\n\treturn workerId\n}\n\n// Proxied session is being closed at the Master node.\nfunc (sess *Session) closeRPC() {\n\tif sess.isMultiplex() {\n\t\tlogs.Info.Println(\"cluster: session proxy closed\", sess.sid)\n\t}\n}\n\n// Start accepting connections.\nfunc (c *Cluster) start() {\n\taddr, err := net.ResolveTCPAddr(\"tcp\", c.listenOn)\n\tif err != nil {\n\t\tlogs.Err.Fatal(err)\n\t}\n\n\tc.inbound, err = net.ListenTCP(\"tcp\", addr)\n\n\tif err != nil {\n\t\tlogs.Err.Fatal(err)\n\t}\n\n\tvar bufferSize = clusterProxyToMasterBuffer\n\tif len(c.nodes) > 2 {\n\t\t// Expand the buffer for larger (>3 node) clusters.\n\t\tbufferSize += clusterProxyToMasterBufferPerNode * (len(c.nodes) - 2)\n\t}\n\tfor _, n := range c.nodes {\n\t\tgo n.reconnect()\n\t\tn.rpcDone = make(chan *rpc.Call, len(c.nodes)*clusterRpcCompletionBuffer)\n\t\tn.p2mSender = make(chan *ClusterReq, bufferSize)\n\t\tgo n.asyncRpcLoop()\n\t\tgo n.p2mSenderLoop()\n\t}\n\n\tif c.fo != nil {\n\t\tgo c.run()\n\t}\n\n\terr = rpc.Register(c)\n\tif err != nil {\n\t\tlogs.Err.Fatal(err)\n\t}\n\n\tgo rpc.Accept(c.inbound)\n\n\tlogs.Info.Printf(\"Cluster of %d nodes initialized, node '%s' is listening on [%s]\", len(globals.cluster.nodes)+1,\n\t\tglobals.cluster.thisNodeName, c.listenOn)\n}\n\nfunc (c *Cluster) shutdown() {\n\tif globals.cluster == nil {\n\t\treturn\n\t}\n\tfor _, n := range c.nodes {\n\t\tclose(n.rpcDone)\n\t\tclose(n.p2mSender)\n\t}\n\n\tglobals.cluster.proxyEventQueue.Stop()\n\tglobals.cluster = nil\n\n\tc.inbound.Close()\n\n\tif c.fo != nil {\n\t\tc.fo.done <- true\n\t}\n\n\tfor _, n := range c.nodes {\n\t\tn.done <- true\n\t}\n\n\tlogs.Info.Println(\"Cluster shut down\")\n}\n\n// Recalculate the ring hash using provided list of nodes or only nodes in a non-failed state.\n// Returns the list of nodes used for ring hash.\nfunc (c *Cluster) rehash(nodes []string) []string {\n\tring := rh.New(clusterHashReplicas, nil)\n\n\tvar ringKeys []string\n\n\tif nodes == nil {\n\t\tfor _, node := range c.nodes {\n\t\t\tringKeys = append(ringKeys, node.name)\n\t\t}\n\t\tringKeys = append(ringKeys, c.thisNodeName)\n\t} else {\n\t\tringKeys = append(ringKeys, nodes...)\n\t}\n\tring.Add(ringKeys...)\n\n\tc.ring = ring\n\n\treturn ringKeys\n}\n\n// invalidateProxySubs iterates over sessions proxied on this node and for each session\n// sends \"{pres term}\" informing that the topic subscription (attachment) was lost:\n// - Called immediately after Cluster.rehash() for all relocated topics (forNode == \"\").\n// - Called for topics hosted at a specific node when a node restart is detected.\n// TODO: consider resubscribing to topics instead of forcing sessions to resubscribe.\nfunc (c *Cluster) invalidateProxySubs(forNode string) {\n\tsessions := make(map[*Session][]string)\n\tglobals.hub.topics.Range(func(_, v any) bool {\n\t\ttopic := v.(*Topic)\n\t\tif !topic.isProxy {\n\t\t\t// Topic isn't a proxy.\n\t\t\treturn true\n\t\t}\n\t\tif forNode == \"\" {\n\t\t\tif topic.masterNode == c.ring.Get(topic.name) {\n\t\t\t\t// The topic hasn't moved. Continue.\n\t\t\t\treturn true\n\t\t\t}\n\t\t} else if topic.masterNode != forNode {\n\t\t\t// The topic is hosted at a different node than the restarted node.\n\t\t\treturn true\n\t\t}\n\n\t\tfor s, psd := range topic.sessions {\n\t\t\t// FIXME: 'me' topic must be the last one in the list for each topic.\n\t\t\tsessions[s] = append(sessions[s], topicNameForUser(topic.name, psd.uid, psd.isChanSub))\n\t\t}\n\t\treturn true\n\t})\n\n\tfor s, topicsToTerminate := range sessions {\n\t\ts.presTermDirect(topicsToTerminate)\n\t}\n}\n\n// gcProxySessions terminates orphaned proxy sessions at a master node for all lost nodes (allNodes minus activeNodes).\n// The session is orphaned when the origin node is gone.\nfunc (c *Cluster) gcProxySessions(activeNodes []string) {\n\tallNodes := []string{c.thisNodeName}\n\tfor name := range c.nodes {\n\t\tallNodes = append(allNodes, name)\n\t}\n\t_, failedNodes, _ := stringSliceDelta(allNodes, activeNodes)\n\tfor _, node := range failedNodes {\n\t\t// Iterate sessions of a failed node\n\t\tc.gcProxySessionsForNode(node)\n\t}\n}\n\n// gcProxySessionsForNode terminates orphaned proxy sessions at a master node for the given node.\n// For example, a remote node is restarted or the cluster is rehashed without the node.\nfunc (c *Cluster) gcProxySessionsForNode(node string) {\n\tn := c.nodes[node]\n\tn.lock.Lock()\n\tmsess := n.msess\n\tn.msess = make(map[string]struct{})\n\tn.lock.Unlock()\n\tfor sid := range msess {\n\t\tif sess := globals.sessionStore.Get(sid); sess != nil {\n\t\t\tsess.stop <- nil\n\t\t}\n\t}\n}\n\n// clusterWriteLoop implements write loop for multiplexing (proxy) session at a node which hosts master topic.\n// The session is a multiplexing session, i.e. it handles requests for multiple sessions at origin.\nfunc (sess *Session) clusterWriteLoop(forTopic string) {\n\tterminate := true\n\tdefer func() {\n\t\tif terminate {\n\t\t\tsess.closeRPC()\n\t\t\tglobals.sessionStore.Delete(sess)\n\t\t\tsess.inflightReqs = nil\n\t\t\tsess.unsubAll()\n\t\t}\n\t}()\n\n\tfor {\n\t\tselect {\n\t\tcase msg, ok := <-sess.send:\n\t\t\tif !ok || sess.clnode.endpoint == nil {\n\t\t\t\t// channel closed\n\t\t\t\treturn\n\t\t\t}\n\t\t\tsrvMsg := msg.(*ServerComMessage)\n\t\t\tresponse := &ClusterResp{SrvMsg: srvMsg}\n\t\t\tif srvMsg.sess == nil {\n\t\t\t\tresponse.OrigSid = \"*\"\n\t\t\t} else {\n\t\t\t\tresponse.OrigReqType = srvMsg.sess.proxyReq\n\t\t\t\tresponse.OrigSid = srvMsg.sess.sid\n\t\t\t\tsrvMsg.AsUser = srvMsg.sess.uid.UserId()\n\n\t\t\t\tswitch srvMsg.sess.proxyReq {\n\t\t\t\tcase ProxyReqJoin, ProxyReqLeave, ProxyReqMeta, ProxyReqBgSession, ProxyReqMeUserAgent, ProxyReqCall:\n\t\t\t\t// Do nothing\n\t\t\t\tcase ProxyReqBroadcast, ProxyReqNone:\n\t\t\t\t\tif srvMsg.Data != nil || srvMsg.Pres != nil || srvMsg.Info != nil {\n\t\t\t\t\t\tresponse.OrigSid = \"*\"\n\t\t\t\t\t} else if srvMsg.Ctrl == nil {\n\t\t\t\t\t\tlogs.Warn.Println(\"cluster: request type not set in clusterWriteLoop\", sess.sid,\n\t\t\t\t\t\t\tsrvMsg.describe(), \"src_sid:\", srvMsg.sess.sid)\n\t\t\t\t\t}\n\t\t\t\tdefault:\n\t\t\t\t\tlogs.Err.Panicln(\"cluster: unknown request type in clusterWriteLoop\", srvMsg.sess.proxyReq)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tsrvMsg.RcptTo = forTopic\n\t\t\tresponse.RcptTo = forTopic\n\n\t\t\tif err := sess.clnode.masterToProxyAsync(response); err != nil {\n\t\t\t\tlogs.Warn.Printf(\"cluster: response to proxy failed \\\"%s\\\": %s\", sess.sid, err.Error())\n\t\t\t\treturn\n\t\t\t}\n\t\tcase msg := <-sess.stop:\n\t\t\tif msg == nil {\n\t\t\t\t// Terminating multiplexing session.\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// There are two cases of msg != nil:\n\t\t\t//  * user is being deleted\n\t\t\t//  * node shutdown\n\t\t\t// In both cases the msg does not need to be forwarded to the proxy.\n\n\t\tcase <-sess.detach:\n\t\t\treturn\n\t\tdefault:\n\t\t\tterminate = false\n\t\t\treturn\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "server/cluster_leader.go",
    "content": "package main\n\nimport (\n\t\"math/rand\"\n\t\"net/rpc\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/logs\"\n)\n\n// Cluster methods related to leader node election. Based on ideas from Raft protocol.\n// The leader node issues heartbeats to follower nodes. If the follower node fails enough\n// times, the leader node annouces it dead and initiates rehashing: it regenerates ring hash with\n// only live nodes and communicates the new list of nodes to followers. They in turn do their\n// rehashing using the new list. When the dead node is revived, rehashing happens again.\n\n// Failover config.\ntype clusterFailover struct {\n\t// Current leader\n\tleader string\n\t// Current election term\n\tterm int\n\t// Hearbeat interval\n\theartBeat time.Duration\n\t// Vote timeout: the number of missed heartbeats before a new election is initiated.\n\tvoteTimeout int\n\n\t// The list of nodes the leader considers active\n\tactiveNodes     []string\n\tactiveNodesLock sync.RWMutex\n\t// The number of heartbeats a node can fail before being declared dead\n\tnodeFailCountLimit int\n\n\t// Channel for processing leader health checks.\n\thealthCheck chan *ClusterHealth\n\t// Channel for processing election votes.\n\telectionVote chan *ClusterVote\n\t// Channel for stopping the failover runner.\n\tdone chan bool\n}\n\ntype clusterFailoverConfig struct {\n\t// Failover is enabled\n\tEnabled bool `json:\"enabled\"`\n\t// Time in milliseconds between heartbeats\n\tHeartbeat int `json:\"heartbeat\"`\n\t// Number of failed heartbeats before a leader election is initiated.\n\tVoteAfter int `json:\"vote_after\"`\n\t// Number of failures before a node is considered dead\n\tNodeFailAfter int `json:\"node_fail_after\"`\n}\n\n// ClusterHealth is content of a leader's health check of a follower node.\ntype ClusterHealth struct {\n\t// Name of the leader node\n\tLeader string\n\t// Election term\n\tTerm int\n\t// Ring hash signature that represents the cluster\n\tSignature string\n\t// Names of nodes currently active in the cluster\n\tNodes []string\n}\n\n// ClusterVoteRequest is a request from a leader candidate to a node to vote for the candidate.\ntype ClusterVoteRequest struct {\n\t// Candidate node which issued this request\n\tNode string\n\t// Election term\n\tTerm int\n}\n\n// ClusterVoteResponse is a vote from a node.\ntype ClusterVoteResponse struct {\n\t// Actual vote\n\tResult bool\n\t// Node's term after the vote\n\tTerm int\n}\n\n// ClusterVote is a vote request and a response in leader election.\ntype ClusterVote struct {\n\treq  *ClusterVoteRequest\n\tresp chan ClusterVoteResponse\n}\n\nfunc (c *Cluster) failoverInit(config *clusterFailoverConfig) bool {\n\tif config == nil || !config.Enabled {\n\t\treturn false\n\t}\n\tif len(c.nodes) < 2 {\n\t\tlogs.Err.Printf(\"cluster: failover disabled; need at least 3 nodes, got %d\", len(c.nodes)+1)\n\t\treturn false\n\t}\n\n\t// Generate ring hash on the assumption that all nodes are alive and well.\n\t// This minimizes rehashing during normal operations.\n\tvar activeNodes []string\n\tfor _, node := range c.nodes {\n\t\tactiveNodes = append(activeNodes, node.name)\n\t}\n\tactiveNodes = append(activeNodes, c.thisNodeName)\n\tc.rehash(activeNodes)\n\n\t// Random heartbeat ticker: 0.75 * config.HeartBeat + random(0, 0.5 * config.HeartBeat).\n\t// The PRNG is initialized in main.go.\n\thb := time.Duration(config.Heartbeat) * time.Millisecond\n\thb = (hb >> 1) + (hb >> 2) + time.Duration(rand.Intn(int(hb>>1)))\n\n\tc.fo = &clusterFailover{\n\t\tactiveNodes:        activeNodes,\n\t\theartBeat:          hb,\n\t\tvoteTimeout:        config.VoteAfter,\n\t\tnodeFailCountLimit: config.NodeFailAfter,\n\t\thealthCheck:        make(chan *ClusterHealth, config.VoteAfter),\n\t\telectionVote:       make(chan *ClusterVote, len(c.nodes)),\n\t\tdone:               make(chan bool, 1),\n\t}\n\n\tlogs.Info.Println(\"cluster: failover mode enabled\")\n\n\treturn true\n}\n\n// Health is called by the leader node to assert leadership and check status\n// of the followers.\nfunc (c *Cluster) Health(health *ClusterHealth, unused *bool) error {\n\tselect {\n\tcase c.fo.healthCheck <- health:\n\tdefault:\n\t}\n\treturn nil\n}\n\n// Vote processes request for a vote from a candidate.\nfunc (c *Cluster) Vote(vreq *ClusterVoteRequest, response *ClusterVoteResponse) error {\n\trespChan := make(chan ClusterVoteResponse, 1)\n\n\tc.fo.electionVote <- &ClusterVote{\n\t\treq:  vreq,\n\t\tresp: respChan,\n\t}\n\n\t*response = <-respChan\n\n\treturn nil\n}\n\n// Cluster leader checks health of follower nodes.\nfunc (c *Cluster) sendHealthChecks() {\n\trehash := false\n\n\tfor _, node := range c.nodes {\n\t\tunused := false\n\t\terr := node.call(\"Cluster.Health\",\n\t\t\t&ClusterHealth{\n\t\t\t\tLeader:    c.thisNodeName,\n\t\t\t\tTerm:      c.fo.term,\n\t\t\t\tSignature: c.ring.Signature(),\n\t\t\t\tNodes:     c.fo.activeNodes,\n\t\t\t}, &unused)\n\n\t\tif err != nil {\n\t\t\tnode.failCount++\n\t\t\tif node.failCount == c.fo.nodeFailCountLimit {\n\t\t\t\t// Node failed too many times\n\t\t\t\trehash = true\n\t\t\t}\n\t\t} else {\n\t\t\tif node.failCount >= c.fo.nodeFailCountLimit {\n\t\t\t\t// Node has recovered\n\t\t\t\trehash = true\n\t\t\t}\n\t\t\tnode.failCount = 0\n\t\t}\n\t}\n\n\tif rehash {\n\t\tactiveNodes := []string{c.thisNodeName}\n\t\tfor _, node := range c.nodes {\n\t\t\tif node.failCount < c.fo.nodeFailCountLimit {\n\t\t\t\tactiveNodes = append(activeNodes, node.name)\n\t\t\t}\n\t\t}\n\t\tc.fo.activeNodesLock.Lock()\n\t\tc.fo.activeNodes = activeNodes\n\t\tc.fo.activeNodesLock.Unlock()\n\t\tc.rehash(activeNodes)\n\t\tc.invalidateProxySubs(\"\")\n\t\tc.gcProxySessions(activeNodes)\n\n\t\tlogs.Info.Println(\"cluster: initiating failover rehash for nodes\", activeNodes)\n\t\tglobals.hub.rehash <- true\n\t}\n}\n\nfunc (c *Cluster) electLeader() {\n\t// Increment the term (voting for myself in this term) and clear the leader\n\tc.fo.term++\n\tc.fo.leader = \"\"\n\n\t// Make sure the current node does not report itself as a leader.\n\tstatsSet(\"ClusterLeader\", 0)\n\n\tlogs.Info.Println(\"cluster: leading new election for term\", c.fo.term)\n\n\tnodeCount := len(c.nodes)\n\t// Number of votes needed to elect the leader\n\texpectVotes := (nodeCount+1)>>1 + 1\n\tdone := make(chan *rpc.Call, nodeCount)\n\n\t// Send async requests for votes to other nodes\n\tfor _, node := range c.nodes {\n\t\tresponse := ClusterVoteResponse{}\n\t\tnode.callAsync(\"Cluster.Vote\",\n\t\t\t&ClusterVoteRequest{\n\t\t\t\tNode: c.thisNodeName,\n\t\t\t\tTerm: c.fo.term,\n\t\t\t}, &response, done)\n\t}\n\n\t// Number of votes received (1 vote for self)\n\tvoteCount := 1\n\ttimeout := time.NewTimer(c.fo.heartBeat>>1 + c.fo.heartBeat)\n\t// Wait for one of the following\n\t// 1. More than half of the nodes voting in favor\n\t// 2. All nodes responded.\n\t// 3. Timeout.\n\tfor i := 0; i < nodeCount && voteCount < expectVotes; {\n\t\tselect {\n\t\tcase call := <-done:\n\t\t\tif call.Error == nil {\n\t\t\t\tif call.Reply.(*ClusterVoteResponse).Result {\n\t\t\t\t\t// Vote in my favor\n\t\t\t\t\tvoteCount++\n\t\t\t\t} else if c.fo.term < call.Reply.(*ClusterVoteResponse).Term {\n\t\t\t\t\t// Vote against me. Abandon vote: this node's term is behind the cluster\n\t\t\t\t\ti = nodeCount\n\t\t\t\t\tvoteCount = 0\n\t\t\t\t}\n\t\t\t}\n\n\t\t\ti++\n\t\tcase <-timeout.C:\n\t\t\t// break the loop\n\t\t\ti = nodeCount\n\t\t}\n\t}\n\n\tif voteCount >= expectVotes {\n\t\t// Current node elected as the leader.\n\t\tc.fo.leader = c.thisNodeName\n\t\tstatsSet(\"ClusterLeader\", 1)\n\t\tlogs.Info.Printf(\"'%s' elected self as a new leader\", c.thisNodeName)\n\t}\n}\n\n// Go routine that processes calls related to leader election and maintenance.\nfunc (c *Cluster) run() {\n\tticker := time.NewTicker(c.fo.heartBeat)\n\n\t// Count of missed health checks from the leader.\n\tmissed := 0\n\t// Don't rehash immediately on the first missed health check. If this node just came online, leader will\n\t// account it on the next check. Otherwise it will be rehashing twice.\n\trehashSkipped := false\n\n\tfor {\n\t\tselect {\n\t\tcase <-ticker.C:\n\t\t\tif c.fo.leader == c.thisNodeName {\n\t\t\t\t// I'm the leader, send the health checks to followers.\n\t\t\t\tc.sendHealthChecks()\n\t\t\t} else {\n\t\t\t\t// Increment the number of missed health checks from the leader.\n\t\t\t\t// The counter will be reset to zero when a health check is received.\n\t\t\t\tmissed++\n\t\t\t\tif missed >= c.fo.voteTimeout {\n\t\t\t\t\t// Leader is gone, initiate election of a new leader.\n\t\t\t\t\tmissed = 0\n\t\t\t\t\tc.electLeader()\n\t\t\t\t}\n\t\t\t}\n\t\tcase health := <-c.fo.healthCheck:\n\t\t\t// Health check from the leader.\n\n\t\t\tif health.Term < c.fo.term {\n\t\t\t\t// This is a health check from a stale leader. Ignore.\n\t\t\t\tlogs.Warn.Println(\"cluster: health check from a stale leader\", health.Term, c.fo.term, health.Leader, c.fo.leader)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif health.Term > c.fo.term {\n\t\t\t\tc.fo.term = health.Term\n\t\t\t\tc.fo.leader = health.Leader\n\t\t\t\tlogs.Info.Printf(\"cluster: leader '%s' elected\", c.fo.leader)\n\t\t\t} else if health.Leader != c.fo.leader {\n\t\t\t\tif c.fo.leader != \"\" {\n\t\t\t\t\t// Wrong leader. It's a bug, should never happen!\n\t\t\t\t\tlogs.Err.Printf(\"cluster: wrong leader '%s' while expecting '%s'; term %d\",\n\t\t\t\t\t\thealth.Leader, c.fo.leader, health.Term)\n\t\t\t\t} else {\n\t\t\t\t\tlogs.Info.Printf(\"cluster: leader set to '%s'\", health.Leader)\n\t\t\t\t}\n\t\t\t\tc.fo.leader = health.Leader\n\t\t\t}\n\n\t\t\t// This is a health check from a leader, consequently this node is not the leader.\n\t\t\tstatsSet(\"ClusterLeader\", 0)\n\n\t\t\tmissed = 0\n\t\t\tif health.Signature != c.ring.Signature() {\n\t\t\t\tif rehashSkipped {\n\t\t\t\t\tlogs.Info.Println(\"cluster: rehashing at a request of\",\n\t\t\t\t\t\thealth.Leader, health.Nodes, health.Signature, c.ring.Signature())\n\t\t\t\t\tc.rehash(health.Nodes)\n\t\t\t\t\tc.invalidateProxySubs(\"\")\n\t\t\t\t\tc.gcProxySessions(health.Nodes)\n\t\t\t\t\trehashSkipped = false\n\n\t\t\t\t\tglobals.hub.rehash <- true\n\t\t\t\t} else {\n\t\t\t\t\trehashSkipped = true\n\t\t\t\t}\n\t\t\t}\n\n\t\tcase vreq := <-c.fo.electionVote:\n\t\t\tif c.fo.term < vreq.req.Term {\n\t\t\t\t// This is a new election. This node has not voted yet. Vote for the requestor and\n\t\t\t\t// clear the current leader.\n\t\t\t\tlogs.Info.Printf(\"Voting YES for %s, my term %d, vote term %d\", vreq.req.Node, c.fo.term, vreq.req.Term)\n\t\t\t\tc.fo.term = vreq.req.Term\n\t\t\t\tc.fo.leader = \"\"\n\t\t\t\t// Election means these is no leader yet.\n\t\t\t\tstatsSet(\"ClusterLeader\", 0)\n\t\t\t\tvreq.resp <- ClusterVoteResponse{Result: true, Term: c.fo.term}\n\t\t\t} else {\n\t\t\t\t// This node has voted already or stale election, reject.\n\t\t\t\tlogs.Info.Printf(\"Voting NO for %s, my term %d, vote term %d\", vreq.req.Node, c.fo.term, vreq.req.Term)\n\t\t\t\tvreq.resp <- ClusterVoteResponse{Result: false, Term: c.fo.term}\n\t\t\t}\n\t\tcase <-c.fo.done:\n\t\t\treturn\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "server/concurrency/goroutinepool.go",
    "content": "// Package concurrency is a very simple implementation of a mutex with channels.\n// Provides TryLock functionality absent in Go's regular sync.Mutex.\n// See https://github.com/golang/go/issues/6123 for details.\npackage concurrency\n\n// Task represents a work task to be run on the specified thread pool.\ntype Task func()\n\n// GoRoutinePool is a pull of Go routines with associated locking mechanism.\ntype GoRoutinePool struct {\n\t// Work queue.\n\twork chan Task\n\t// Counter to control the number of already allocated/running goroutines.\n\tsem chan struct{}\n\t// Exit knob.\n\tstop chan struct{}\n}\n\n// NewGoRoutinePool allocates a new thread pool with `numWorkers` goroutines.\nfunc NewGoRoutinePool(numWorkers int) *GoRoutinePool {\n\treturn &GoRoutinePool{\n\t\twork: make(chan Task),\n\t\tsem:  make(chan struct{}, numWorkers),\n\t\tstop: make(chan struct{}, numWorkers),\n\t}\n}\n\n// Schedule enqueus a closure to run on the GoRoutinePool's goroutines.\nfunc (p *GoRoutinePool) Schedule(task Task) {\n\tselect {\n\tcase p.work <- task:\n\tcase p.sem <- struct{}{}:\n\t\tgo p.worker(task)\n\t}\n}\n\n// Stop sends a stop signal to all running goroutines.\nfunc (p *GoRoutinePool) Stop() {\n\tnumWorkers := cap(p.sem)\n\tfor range numWorkers {\n\t\tp.stop <- struct{}{}\n\t}\n}\n\n// Thread pool worker goroutine.\nfunc (p *GoRoutinePool) worker(task Task) {\n\tdefer func() { <-p.sem }()\n\tfor {\n\t\ttask()\n\t\tselect {\n\t\tcase task = <-p.work:\n\t\tcase <-p.stop:\n\t\t\treturn\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "server/concurrency/simplemutex.go",
    "content": "package concurrency\n\n// SimpleMutex is a channel used for locking.\ntype SimpleMutex chan struct{}\n\n// NewSimpleMutex creates and returns a new SimpleMutex object.\nfunc NewSimpleMutex() SimpleMutex {\n\treturn make(SimpleMutex, 1)\n}\n\n// Lock acquires a lock on the mutex.\nfunc (s SimpleMutex) Lock() {\n\ts <- struct{}{}\n}\n\n// TryLock attempts to acquire a lock on the mutex.\n// Returns true if the lock has been acquired, false otherwise.\nfunc (s SimpleMutex) TryLock() bool {\n\tselect {\n\tcase s <- struct{}{}:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// Unlock releases the mutex.\nfunc (s SimpleMutex) Unlock() {\n\t<-s\n}\n"
  },
  {
    "path": "server/datamodel.go",
    "content": "package main\n\n/******************************************************************************\n *\n *  Description :\n *\n *    Wire protocol structures\n *\n *****************************************************************************/\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\n// MsgGetOpts defines Get query parameters.\ntype MsgGetOpts struct {\n\t// Optional User ID to return result(s) for one user.\n\tUser string `json:\"user,omitempty\"`\n\t// Optional topic name to return result(s) for one topic.\n\tTopic string `json:\"topic,omitempty\"`\n\t// Return results modified since this timespamp.\n\tIfModifiedSince *time.Time `json:\"ims,omitempty\"`\n\t// Load messages/ranges with IDs equal or greater than this (inclusive or closed).\n\tSinceId int `json:\"since,omitempty\"`\n\t// Load messages/ranges with IDs lower than this (exclusive or open).\n\tBeforeId int `json:\"before,omitempty\"`\n\t// Limit the number of messages loaded.\n\tLimit int `json:\"limit,omitempty\"`\n\t// Fetch messages with IDs in these ranges.\n\tIdRanges []MsgRange `json:\"ranges,omitempty\"`\n}\n\n// MsgGetQuery is a topic metadata or data query.\ntype MsgGetQuery struct {\n\tWhat string `json:\"what\"`\n\n\t// Parameters of \"desc\" request: IfModifiedSince\n\tDesc *MsgGetOpts `json:\"desc,omitempty\"`\n\t// Parameters of \"sub\" request: User, Topic, IfModifiedSince, Limit.\n\tSub *MsgGetOpts `json:\"sub,omitempty\"`\n\t// Parameters of \"data\" request: Since, Before, Limit.\n\tData *MsgGetOpts `json:\"data,omitempty\"`\n\t// Parameters of \"del\" request: Since, Before, Limit.\n\tDel *MsgGetOpts `json:\"del,omitempty\"`\n}\n\n// MsgSetSub is a payload in set.sub request to update current subscription or invite another user, {sub.what} == \"sub\".\ntype MsgSetSub struct {\n\t// User affected by this request. Default (empty): current user.\n\tUser string `json:\"user,omitempty\"`\n\n\t// Access mode change, either Given or Want depending on context.\n\tMode string `json:\"mode,omitempty\"`\n}\n\n// MsgSetDesc is a C2S in set.what == \"desc\", acc, sub message.\ntype MsgSetDesc struct {\n\t// Default access mode.\n\tDefaultAcs *MsgDefaultAcsMode `json:\"defacs,omitempty\"`\n\t// Description of the user or topic.\n\tPublic any `json:\"public,omitempty\"`\n\t// Trusted (system-provided) user or topic data.\n\tTrusted any `json:\"trusted,omitempty\"`\n\t// Per-subscription private data.\n\tPrivate any `json:\"private,omitempty\"`\n}\n\n// MsgCredClient is an account credential such as email or phone number.\ntype MsgCredClient struct {\n\t// Credential type, i.e. `email` or `tel`.\n\tMethod string `json:\"meth,omitempty\"`\n\t// Value to verify, i.e. `user@example.com` or `+18003287448`\n\tValue string `json:\"val,omitempty\"`\n\t// Verification response\n\tResponse string `json:\"resp,omitempty\"`\n\t// Request parameters, such as preferences. Passed to valiator without interpretation.\n\tParams map[string]any `json:\"params,omitempty\"`\n}\n\n// MsgSetQuery is an update to topic or user metadata: description, subscriptions, tags, credentials.\ntype MsgSetQuery struct {\n\t// Topic/user description, new object & new subscriptions only\n\tDesc *MsgSetDesc `json:\"desc,omitempty\"`\n\t// Subscription parameters\n\tSub *MsgSetSub `json:\"sub,omitempty\"`\n\t// Indexable tags for user discovery\n\tTags []string `json:\"tags,omitempty\"`\n\t// Update to account credentials.\n\tCred *MsgCredClient `json:\"cred,omitempty\"`\n\t// Update auxiliary data\n\tAux map[string]any\n}\n\n// MsgRange is either an individual ID (HiId=0) or a randge of IDs, low end inclusive (closed),\n// high-end exclusive (open): [LowId .. HiId), e.g. 1..5 -> 1, 2, 3, 4.\ntype MsgRange struct {\n\tLowId int `json:\"low,omitempty\"`\n\tHiId  int `json:\"hi,omitempty\"`\n}\n\n/****************************************************************\n * Client to Server (C2S) messages.\n ****************************************************************/\n\n// MsgClientHi is a handshake {hi} message.\ntype MsgClientHi struct {\n\t// Message Id\n\tId string `json:\"id,omitempty\"`\n\t// User agent\n\tUserAgent string `json:\"ua,omitempty\"`\n\t// Protocol version, i.e. \"0.13\"\n\tVersion string `json:\"ver,omitempty\"`\n\t// Client's unique device ID\n\tDeviceID string `json:\"dev,omitempty\"`\n\t// ISO 639-1 human language of the connected device\n\tLang string `json:\"lang,omitempty\"`\n\t// Platform code: ios, android, web.\n\tPlatform string `json:\"platf,omitempty\"`\n\t// Session is initially in non-iteractive, i.e. issued by a service. Presence notifications are delayed.\n\tBackground bool `json:\"bkg,omitempty\"`\n}\n\n// MsgClientAcc is an {acc} message for creating or updating a user account.\ntype MsgClientAcc struct {\n\t// Message Id\n\tId string `json:\"id,omitempty\"`\n\t// \"newXYZ\" to create a new user or UserId to update a user; default: current user.\n\tUser string `json:\"user,omitempty\"`\n\t// Temporary authentication parameters for one-off actions, like password reset.\n\tTmpScheme string `json:\"tmpscheme,omitempty\"`\n\tTmpSecret []byte `json:\"tmpsecret,omitempty\"`\n\t// Account state: normal, suspended.\n\tState string `json:\"status,omitempty\"`\n\t// Authentication level of the user when UserID is set and not equal to the current user.\n\t// Either \"\", \"auth\" or \"anon\". Default: \"\"\n\tAuthLevel string `json:\"authlevel,omitempty\"`\n\t// The initial authentication scheme the account can use\n\tScheme string `json:\"scheme,omitempty\"`\n\t// Shared secret\n\tSecret []byte `json:\"secret,omitempty\"`\n\t// Authenticate session with the newly created account\n\tLogin bool `json:\"login,omitempty\"`\n\t// Indexable tags for user discovery\n\tTags []string `json:\"tags,omitempty\"`\n\t// User initialization data when creating a new user, otherwise ignored\n\tDesc *MsgSetDesc `json:\"desc,omitempty\"`\n\t// Credentials to verify (email or phone or captcha)\n\tCred []MsgCredClient `json:\"cred,omitempty\"`\n}\n\n// MsgClientLogin is a login {login} message.\ntype MsgClientLogin struct {\n\t// Message Id\n\tId string `json:\"id,omitempty\"`\n\t// Authentication scheme\n\tScheme string `json:\"scheme,omitempty\"`\n\t// Shared secret\n\tSecret []byte `json:\"secret\"`\n\t// Credntials being verified (email or phone or captcha etc.)\n\tCred []MsgCredClient `json:\"cred,omitempty\"`\n}\n\n// MsgClientSub is a subscription request {sub} message.\ntype MsgClientSub struct {\n\tId    string `json:\"id,omitempty\"`\n\tTopic string `json:\"topic\"`\n\n\t// Mirrors {set}.\n\tSet *MsgSetQuery `json:\"set,omitempty\"`\n\n\t// Mirrors {get}.\n\tGet *MsgGetQuery `json:\"get,omitempty\"`\n\n\t// Intra-cluster fields.\n\n\t// True if this subscription created a new topic.\n\t// In case of p2p topics, it's true if the other user's subscription was\n\t// created (as a part of new topic creation or just alone).\n\tCreated bool `json:\"-\"`\n\t// True if this is a new subscription.\n\tNewsub bool `json:\"-\"`\n}\n\nconst (\n\tconstMsgMetaDesc = 1 << iota\n\tconstMsgMetaSub\n\tconstMsgMetaData\n\tconstMsgMetaTags\n\tconstMsgMetaDel\n\tconstMsgMetaCred\n\tconstMsgMetaAux\n)\n\nconst (\n\tconstMsgDelTopic = iota + 1\n\tconstMsgDelMsg\n\tconstMsgDelSub\n\tconstMsgDelUser\n\tconstMsgDelCred\n)\n\nfunc parseMsgClientMeta(params string) int {\n\tvar bits int\n\tparts := strings.SplitN(params, \" \", 8)\n\tfor _, p := range parts {\n\t\tswitch p {\n\t\tcase \"desc\":\n\t\t\tbits |= constMsgMetaDesc\n\t\tcase \"sub\":\n\t\t\tbits |= constMsgMetaSub\n\t\tcase \"data\":\n\t\t\tbits |= constMsgMetaData\n\t\tcase \"tags\":\n\t\t\tbits |= constMsgMetaTags\n\t\tcase \"del\":\n\t\t\tbits |= constMsgMetaDel\n\t\tcase \"cred\":\n\t\t\tbits |= constMsgMetaCred\n\t\tcase \"aux\":\n\t\t\tbits |= constMsgMetaAux\n\t\tdefault:\n\t\t\t// ignore unknown\n\t\t}\n\t}\n\treturn bits\n}\n\nfunc parseMsgClientDel(params string) int {\n\tswitch params {\n\tcase \"\", \"msg\":\n\t\treturn constMsgDelMsg\n\tcase \"topic\":\n\t\treturn constMsgDelTopic\n\tcase \"sub\":\n\t\treturn constMsgDelSub\n\tcase \"user\":\n\t\treturn constMsgDelUser\n\tcase \"cred\":\n\t\treturn constMsgDelCred\n\tdefault:\n\t\t// ignore\n\t}\n\treturn 0\n}\n\n// MsgDefaultAcsMode is a topic default access mode.\ntype MsgDefaultAcsMode struct {\n\tAuth string `json:\"auth,omitempty\"`\n\tAnon string `json:\"anon,omitempty\"`\n}\n\n// MsgClientLeave is an unsubscribe {leave} request message.\ntype MsgClientLeave struct {\n\tId    string `json:\"id,omitempty\"`\n\tTopic string `json:\"topic\"`\n\tUnsub bool   `json:\"unsub,omitempty\"`\n}\n\n// MsgClientPub is client's request to publish data to topic subscribers {pub}.\ntype MsgClientPub struct {\n\tId      string         `json:\"id,omitempty\"`\n\tTopic   string         `json:\"topic\"`\n\tNoEcho  bool           `json:\"noecho,omitempty\"`\n\tHead    map[string]any `json:\"head,omitempty\"`\n\tContent any            `json:\"content\"`\n}\n\n// MsgClientGet is a query of topic state {get}.\ntype MsgClientGet struct {\n\tId    string `json:\"id,omitempty\"`\n\tTopic string `json:\"topic\"`\n\tMsgGetQuery\n}\n\n// MsgClientSet is an update of topic state {set}.\ntype MsgClientSet struct {\n\tId    string `json:\"id,omitempty\"`\n\tTopic string `json:\"topic\"`\n\tMsgSetQuery\n}\n\n// MsgClientDel delete messages or topic {del}.\ntype MsgClientDel struct {\n\tId    string `json:\"id,omitempty\"`\n\tTopic string `json:\"topic,omitempty\"`\n\t// What to delete:\n\t// * \"msg\" to delete messages (default)\n\t// * \"topic\" to delete the topic\n\t// * \"sub\" to delete a subscription to topic.\n\t// * \"user\" to delete or disable user.\n\t// * \"cred\" to delete credential (email or phone)\n\tWhat string `json:\"what\"`\n\t// Delete messages with these IDs (either one by one or a set of ranges)\n\tDelSeq []MsgRange `json:\"delseq,omitempty\"`\n\t// User ID of the user or subscription to delete\n\tUser string `json:\"user,omitempty\"`\n\t// Credential to delete\n\tCred *MsgCredClient `json:\"cred,omitempty\"`\n\t// Request to hard-delete objects (i.e. delete messages for all users), if such option is available.\n\tHard bool `json:\"hard,omitempty\"`\n}\n\n// MsgClientNote is a client-generated notification for topic subscribers {note}.\ntype MsgClientNote struct {\n\t// There is no Id -- server will not akn {ping} packets, they are \"fire and forget\"\n\tTopic string `json:\"topic\"`\n\t// what is being reported: \"recv\" - message received, \"read\" - message read, \"kp\" - typing notification\n\tWhat string `json:\"what\"`\n\t// Server-issued message ID being reported\n\tSeqId int `json:\"seq,omitempty\"`\n\t// Client's count of unread messages to report back to the server. Used in push notifications on iOS.\n\tUnread int `json:\"unread,omitempty\"`\n\t// Call event.\n\tEvent string `json:\"event,omitempty\"`\n\t// Arbitrary json payload (used in video calls).\n\tPayload json.RawMessage `json:\"payload,omitempty\"`\n}\n\n// MsgClientExtra is not a stand-alone message but extra data which augments the main payload.\ntype MsgClientExtra struct {\n\t// Array of out-of-band attachments which have to be exempted from GC.\n\tAttachments []string `json:\"attachments,omitempty\"`\n\t// Alternative user ID set by the root user (obo = On Behalf Of).\n\tAsUser string `json:\"obo,omitempty\"`\n\t// Altered authentication level set by the root user.\n\tAuthLevel string `json:\"authlevel,omitempty\"`\n}\n\n// ClientComMessage is a wrapper for client messages.\ntype ClientComMessage struct {\n\tHi    *MsgClientHi    `json:\"hi\"`\n\tAcc   *MsgClientAcc   `json:\"acc\"`\n\tLogin *MsgClientLogin `json:\"login\"`\n\tSub   *MsgClientSub   `json:\"sub\"`\n\tLeave *MsgClientLeave `json:\"leave\"`\n\tPub   *MsgClientPub   `json:\"pub\"`\n\tGet   *MsgClientGet   `json:\"get\"`\n\tSet   *MsgClientSet   `json:\"set\"`\n\tDel   *MsgClientDel   `json:\"del\"`\n\tNote  *MsgClientNote  `json:\"note\"`\n\t// Optional data.\n\tExtra *MsgClientExtra `json:\"extra\"`\n\n\t// Internal fields, routed only within the cluster.\n\n\t// Message ID denormalized\n\tId string `json:\"-\"`\n\t// Un-routable (original) topic name denormalized from XXX.Topic.\n\tOriginal string `json:\"-\"`\n\t// Routable (expanded) topic name.\n\tRcptTo string `json:\"-\"`\n\t// Sender's UserId as string.\n\tAsUser string `json:\"-\"`\n\t// Sender's authentication level.\n\tAuthLvl int `json:\"-\"`\n\t// Denormalized 'what' field of meta messages (set, get, del).\n\tMetaWhat int `json:\"-\"`\n\t// Timestamp when this message was received by the server.\n\tTimestamp time.Time `json:\"-\"`\n\n\t// Originating session to send an aknowledgement to.\n\tsess *Session\n\t// The message is initialized (true) as opposite to being used as a wrapper for session.\n\tinit bool\n}\n\n/****************************************************************\n * Server to client messages.\n ****************************************************************/\n\n// MsgLastSeenInfo contains info on user's appearance online - when & user agent.\ntype MsgLastSeenInfo struct {\n\t// Timestamp of user's last appearance online.\n\tWhen *time.Time `json:\"when,omitempty\"`\n\t// User agent of the device when the user was last online.\n\tUserAgent string `json:\"ua,omitempty\"`\n}\n\nfunc (src *MsgLastSeenInfo) describe() string {\n\treturn \"'\" + src.UserAgent + \"' @ \" + src.When.String()\n}\n\n// MsgCredServer is an account credential such as email or phone number.\ntype MsgCredServer struct {\n\t// Credential type, i.e. `email` or `tel`.\n\tMethod string `json:\"meth,omitempty\"`\n\t// Credential value, i.e. `user@example.com` or `+18003287448`\n\tValue string `json:\"val,omitempty\"`\n\t// Indicates that the credential is validated.\n\tDone bool `json:\"done,omitempty\"`\n}\n\n// MsgAccessMode is a definition of access mode.\ntype MsgAccessMode struct {\n\t// Access mode requested by the user\n\tWant string `json:\"want,omitempty\"`\n\t// Access mode granted to the user by the admin\n\tGiven string `json:\"given,omitempty\"`\n\t// Cumulative access mode want & given\n\tMode string `json:\"mode,omitempty\"`\n}\n\nfunc (src *MsgAccessMode) describe() string {\n\tvar s string\n\tif src.Want != \"\" {\n\t\ts = \"w=\" + src.Want\n\t}\n\tif src.Given != \"\" {\n\t\ts += \" g=\" + src.Given\n\t}\n\tif src.Mode != \"\" {\n\t\ts += \" m=\" + src.Mode\n\t}\n\treturn strings.TrimSpace(s)\n}\n\n// MsgTopicDesc is a topic description, S2C in Meta message.\ntype MsgTopicDesc struct {\n\tCreatedAt *time.Time `json:\"created,omitempty\"`\n\tUpdatedAt *time.Time `json:\"updated,omitempty\"`\n\t// Timestamp of the last message\n\tTouchedAt *time.Time `json:\"touched,omitempty\"`\n\n\t// Account state, 'me' topic only.\n\tState string `json:\"state,omitempty\"`\n\n\t// If the group topic is online.\n\tOnline bool `json:\"online,omitempty\"`\n\n\t// If the topic can be accessed as a channel\n\tIsChan bool `json:\"chan,omitempty\"`\n\n\t// P2P other user's last online timestamp & user agent\n\tLastSeen *MsgLastSeenInfo `json:\"seen,omitempty\"`\n\n\tDefaultAcs *MsgDefaultAcsMode `json:\"defacs,omitempty\"`\n\t// Actual access mode\n\tAcs *MsgAccessMode `json:\"acs,omitempty\"`\n\t// Max message ID\n\tSeqId     int `json:\"seq,omitempty\"`\n\tReadSeqId int `json:\"read,omitempty\"`\n\tRecvSeqId int `json:\"recv,omitempty\"`\n\t// Id of the last delete operation as seen by the requesting user\n\tDelId   int `json:\"clear,omitempty\"`\n\tSubCnt  int `json:\"subcnt,omitempty\"`\n\tPublic  any `json:\"public,omitempty\"`\n\tTrusted any `json:\"trusted,omitempty\"`\n\t// Per-subscription private data\n\tPrivate any `json:\"private,omitempty\"`\n}\n\nfunc (src *MsgTopicDesc) describe() string {\n\tvar s string\n\tif src.State != \"\" {\n\t\ts = \" state=\" + src.State\n\t}\n\ts += \" online=\" + strconv.FormatBool(src.Online)\n\tif src.Acs != nil {\n\t\ts += \" acs={\" + src.Acs.describe() + \"}\"\n\t}\n\tif src.SeqId != 0 {\n\t\ts += \" seq=\" + strconv.Itoa(src.SeqId)\n\t}\n\tif src.ReadSeqId != 0 {\n\t\ts += \" read=\" + strconv.Itoa(src.ReadSeqId)\n\t}\n\tif src.RecvSeqId != 0 {\n\t\ts += \" recv=\" + strconv.Itoa(src.RecvSeqId)\n\t}\n\tif src.DelId != 0 {\n\t\ts += \" clear=\" + strconv.Itoa(src.DelId)\n\t}\n\tif src.SubCnt != 0 {\n\t\ts += \" subcnt=\" + strconv.Itoa(src.SubCnt)\n\t}\n\tif src.Public != nil {\n\t\ts += \" pub='...'\"\n\t}\n\tif src.Trusted != nil {\n\t\ts += \" trst='...'\"\n\t}\n\tif src.Private != nil {\n\t\ts += \" priv='...'\"\n\t}\n\treturn s\n}\n\n// MsgTopicSub is topic subscription details, sent in Meta message.\ntype MsgTopicSub struct {\n\t// Fields common to all subscriptions\n\n\t// Timestamp when the subscription was last updated\n\tUpdatedAt *time.Time `json:\"updated,omitempty\"`\n\t// Timestamp when the subscription was deleted\n\tDeletedAt *time.Time `json:\"deleted,omitempty\"`\n\n\t// If the subscriber/topic is online\n\tOnline bool `json:\"online,omitempty\"`\n\n\t// Access mode. Topic admins receive the full info, non-admins receive just the cumulative mode\n\t// Acs.Mode = want & given. The field is not a pointer because at least one value is always assigned.\n\tAcs MsgAccessMode `json:\"acs,omitempty\"`\n\t// ID of the message reported by the given user as read\n\tReadSeqId int `json:\"read,omitempty\"`\n\t// ID of the message reported by the given user as received\n\tRecvSeqId int `json:\"recv,omitempty\"`\n\t// Topic's public data\n\tPublic any `json:\"public,omitempty\"`\n\t// Topic's trusted public data\n\tTrusted any `json:\"trusted,omitempty\"`\n\t// User's own private data per topic\n\tPrivate any `json:\"private,omitempty\"`\n\n\t// Response to non-'me' topic\n\n\t// Uid of the subscribed user\n\tUser string `json:\"user,omitempty\"`\n\n\t// The following sections makes sense only in context of getting\n\t// user's own subscriptions ('me' topic response)\n\n\t// Topic name of this subscription\n\tTopic string `json:\"topic,omitempty\"`\n\t// Timestamp of the last message in the topic.\n\tTouchedAt *time.Time `json:\"touched,omitempty\"`\n\t// ID of the last {data} message in a topic\n\tSeqId int `json:\"seq,omitempty\"`\n\t// Id of the latest Delete operation\n\tDelId int `json:\"clear,omitempty\"`\n\t// Number of subscribers, group topics only.\n\tSubCnt  int `json:\"subcnt,omitempty\"`\n\t// P2P topics in 'me' {get subs} response:\n\n\t// Other user's last online timestamp & user agent\n\tLastSeen *MsgLastSeenInfo `json:\"seen,omitempty\"`\n}\n\nfunc (src *MsgTopicSub) describe() string {\n\ts := src.Topic + \":\" + src.User + \" online=\" + strconv.FormatBool(src.Online) + \" acs=\" + src.Acs.describe()\n\n\tif src.SeqId != 0 {\n\t\ts += \" seq=\" + strconv.Itoa(src.SeqId)\n\t}\n\tif src.ReadSeqId != 0 {\n\t\ts += \" read=\" + strconv.Itoa(src.ReadSeqId)\n\t}\n\tif src.RecvSeqId != 0 {\n\t\ts += \" recv=\" + strconv.Itoa(src.RecvSeqId)\n\t}\n\tif src.DelId != 0 {\n\t\ts += \" clear=\" + strconv.Itoa(src.DelId)\n\t}\n\t\tif src.SubCnt != 0 {\n\t\ts += \" subcnt=\" + strconv.Itoa(src.SubCnt)\n\t}\n\tif src.Public != nil {\n\t\ts += \" pub='...'\"\n\t}\n\tif src.Trusted != nil {\n\t\ts += \" trst='...'\"\n\t}\n\tif src.Private != nil {\n\t\ts += \" priv='...'\"\n\t}\n\tif src.LastSeen != nil {\n\t\ts += \" seen={\" + src.LastSeen.describe() + \"}\"\n\t}\n\treturn s\n}\n\n// MsgDelValues describes request to delete messages.\ntype MsgDelValues struct {\n\tDelId  int        `json:\"clear,omitempty\"`\n\tDelSeq []MsgRange `json:\"delseq,omitempty\"`\n}\n\n// MsgServerCtrl is a server control message {ctrl}.\ntype MsgServerCtrl struct {\n\tId     string `json:\"id,omitempty\"`\n\tTopic  string `json:\"topic,omitempty\"`\n\tParams any    `json:\"params,omitempty\"`\n\n\tCode      int       `json:\"code\"`\n\tText      string    `json:\"text,omitempty\"`\n\tTimestamp time.Time `json:\"ts\"`\n}\n\n// Deep-shallow copy.\nfunc (src *MsgServerCtrl) copy() *MsgServerCtrl {\n\tif src == nil {\n\t\treturn nil\n\t}\n\tdst := *src\n\treturn &dst\n}\n\nfunc (src *MsgServerCtrl) describe() string {\n\treturn src.Topic + \" id=\" + src.Id + \" code=\" + strconv.Itoa(src.Code) + \" txt=\" + src.Text\n}\n\n// MsgServerData is a server {data} message.\ntype MsgServerData struct {\n\tTopic string `json:\"topic\"`\n\t// ID of the user who originated the message as {pub}, could be empty if sent by the system\n\tFrom      string         `json:\"from,omitempty\"`\n\tTimestamp time.Time      `json:\"ts\"`\n\tDeletedAt *time.Time     `json:\"deleted,omitempty\"`\n\tSeqId     int            `json:\"seq\"`\n\tHead      map[string]any `json:\"head,omitempty\"`\n\tContent   any            `json:\"content\"`\n}\n\n// Deep-shallow copy.\nfunc (src *MsgServerData) copy() *MsgServerData {\n\tif src == nil {\n\t\treturn nil\n\t}\n\tdst := *src\n\treturn &dst\n}\n\nfunc (src *MsgServerData) describe() string {\n\ts := src.Topic + \" from=\" + src.From + \" seq=\" + strconv.Itoa(src.SeqId)\n\tif src.DeletedAt != nil {\n\t\ts += \" deleted\"\n\t} else {\n\t\tif src.Head != nil {\n\t\t\ts += \" head=...\"\n\t\t}\n\t\ts += \" content='...'\"\n\t}\n\treturn s\n}\n\n// MsgServerPres is presence notification {pres} (authoritative update).\ntype MsgServerPres struct {\n\tTopic     string     `json:\"topic\"`\n\tSrc       string     `json:\"src,omitempty\"`\n\tWhat      string     `json:\"what\"`\n\tUserAgent string     `json:\"ua,omitempty\"`\n\tSeqId     int        `json:\"seq,omitempty\"`\n\tDelId     int        `json:\"clear,omitempty\"`\n\tDelSeq    []MsgRange `json:\"delseq,omitempty\"`\n\tAcsTarget string     `json:\"tgt,omitempty\"`\n\tAcsActor  string     `json:\"act,omitempty\"`\n\t// Acs or a delta Acs. Need to marshal it to json under a name different than 'acs'\n\t// to allow different handling on the client\n\tAcs *MsgAccessMode `json:\"dacs,omitempty\"`\n\n\t// UNroutable params. All marked with `json:\"-\"` to exclude from json marshaling.\n\t// They are still serialized for intra-cluster communication.\n\n\t// Flag to break the reply loop\n\tWantReply bool `json:\"-\"`\n\n\t// Additional access mode filters when sending to topic's online members. Both filter conditions must be true.\n\t// send only to those who have this access mode.\n\tFilterIn int `json:\"-\"`\n\t// skip those who have this access mode.\n\tFilterOut int `json:\"-\"`\n\n\t// When sending to 'me', skip sessions subscribed to this topic.\n\tSkipTopic string `json:\"-\"`\n\n\t// Send to sessions of a single user only.\n\tSingleUser string `json:\"-\"`\n\n\t// Exclude sessions of a single user.\n\tExcludeUser string `json:\"-\"`\n}\n\n// Deep-shallow copy.\nfunc (src *MsgServerPres) copy() *MsgServerPres {\n\tif src == nil {\n\t\treturn nil\n\t}\n\tdst := *src\n\treturn &dst\n}\n\nfunc (src *MsgServerPres) describe() string {\n\ts := src.Topic\n\tif src.Src != \"\" {\n\t\ts += \" src=\" + src.Src\n\t}\n\tif src.What != \"\" {\n\t\ts += \" what=\" + src.What\n\t}\n\tif src.UserAgent != \"\" {\n\t\ts += \" ua=\" + src.UserAgent\n\t}\n\tif src.SeqId != 0 {\n\t\ts += \" seq=\" + strconv.Itoa(src.SeqId)\n\t}\n\tif src.DelId != 0 {\n\t\ts += \" clear=\" + strconv.Itoa(src.DelId)\n\t}\n\tif src.DelSeq != nil {\n\t\ts += \" delseq\"\n\t}\n\tif src.AcsTarget != \"\" {\n\t\ts += \" tgt=\" + src.AcsTarget\n\t}\n\tif src.AcsActor != \"\" {\n\t\ts += \" actor=\" + src.AcsActor\n\t}\n\tif src.Acs != nil {\n\t\ts += \" dacs=\" + src.Acs.describe()\n\t}\n\n\treturn s\n}\n\n// MsgServerMeta is a topic metadata {meta} update.\ntype MsgServerMeta struct {\n\tId    string `json:\"id,omitempty\"`\n\tTopic string `json:\"topic\"`\n\n\tTimestamp *time.Time `json:\"ts,omitempty\"`\n\n\t// Topic description\n\tDesc *MsgTopicDesc `json:\"desc,omitempty\"`\n\t// Subscriptions as an array of objects\n\tSub []MsgTopicSub `json:\"sub,omitempty\"`\n\t// Delete ID and the ranges of IDs of deleted messages\n\tDel *MsgDelValues `json:\"del,omitempty\"`\n\t// User discovery tags\n\tTags []string `json:\"tags,omitempty\"`\n\t// Account credentials, 'me' only.\n\tCred []*MsgCredServer `json:\"cred,omitempty\"`\n\t// Auxiliary data\n\tAux map[string]any `json:\"aux,omitempty\"`\n}\n\n// Deep-shallow copy of meta message. Deep copy of Id and Topic fields, shallow copy of payload.\nfunc (src *MsgServerMeta) copy() *MsgServerMeta {\n\tif src == nil {\n\t\treturn nil\n\t}\n\tdst := *src\n\treturn &dst\n}\n\nfunc (src *MsgServerMeta) describe() string {\n\ts := src.Topic + \" id=\" + src.Id\n\n\tif src.Desc != nil {\n\t\ts += \" desc={\" + src.Desc.describe() + \"}\"\n\t}\n\tif src.Sub != nil {\n\t\tvar x []string\n\t\tfor _, sub := range src.Sub {\n\t\t\tx = append(x, sub.describe())\n\t\t}\n\t\ts += \" sub=[{\" + strings.Join(x, \"},{\") + \"}]\"\n\t}\n\tif src.Del != nil {\n\t\tx, _ := json.Marshal(src.Del)\n\t\ts += \" del={\" + string(x) + \"}\"\n\t}\n\tif src.Tags != nil {\n\t\ts += \" tags=[\" + strings.Join(src.Tags, \",\") + \"]\"\n\t}\n\tif src.Cred != nil {\n\t\tx, _ := json.Marshal(src.Cred)\n\t\ts += \" cred=[\" + string(x) + \"]\"\n\t}\n\tif src.Aux != nil {\n\t\tx, _ := json.Marshal(src.Aux)\n\t\ts += \" aux=[\" + string(x) + \"]\"\n\t}\n\treturn s\n}\n\n// MsgServerInfo is the server-side copy of MsgClientNote with From and optionally Src added (non-authoritative).\ntype MsgServerInfo struct {\n\t// Topic to send event to.\n\tTopic string `json:\"topic\"`\n\t// Topic where the even has occurred (set only when Topic='me').\n\tSrc string `json:\"src,omitempty\"`\n\t// ID of the user who originated the message.\n\tFrom string `json:\"from,omitempty\"`\n\t// The event being reported: \"rcpt\" - message received, \"read\" - message read, \"kp\" - typing notification, \"call\" - video call.\n\tWhat string `json:\"what\"`\n\t// Server-issued message ID being reported.\n\tSeqId int `json:\"seq,omitempty\"`\n\t// Call event.\n\tEvent string `json:\"event,omitempty\"`\n\t// Arbitrary json payload (used by video calls).\n\tPayload json.RawMessage `json:\"payload,omitempty\"`\n\n\t// UNroutable params. All marked with `json:\"-\"` to exclude from json marshaling.\n\t// They are still serialized for intra-cluster communication.\n\n\t// When sending to 'me', skip sessions subscribed to this topic.\n\tSkipTopic string `json:\"-\"`\n}\n\n// Deep copy.\nfunc (src *MsgServerInfo) copy() *MsgServerInfo {\n\tif src == nil {\n\t\treturn nil\n\t}\n\tdst := *src\n\treturn &dst\n}\n\n// Basic description.\nfunc (src *MsgServerInfo) describe() string {\n\ts := src.Topic\n\tif src.Src != \"\" {\n\t\ts += \" src=\" + src.Src\n\t}\n\ts += \" what=\" + src.What + \" from=\" + src.From\n\tif src.SeqId > 0 {\n\t\ts += \" seq=\" + strconv.Itoa(src.SeqId)\n\t}\n\tif len(src.Payload) > 0 {\n\t\ts += \" payload=<...\" + strconv.Itoa(len(src.Payload)) + \" bytes ...>\"\n\t}\n\treturn s\n}\n\n// ServerComMessage is a wrapper for server-side messages.\ntype ServerComMessage struct {\n\tCtrl *MsgServerCtrl `json:\"ctrl,omitempty\"`\n\tData *MsgServerData `json:\"data,omitempty\"`\n\tMeta *MsgServerMeta `json:\"meta,omitempty\"`\n\tPres *MsgServerPres `json:\"pres,omitempty\"`\n\tInfo *MsgServerInfo `json:\"info,omitempty\"`\n\n\t// Internal fields.\n\n\t// MsgServerData has no Id field, copying it here for use in {ctrl} aknowledgements\n\tId string `json:\"-\"`\n\t// Routable (expanded) name of the topic.\n\tRcptTo string `json:\"-\"`\n\t// User ID of the sender of the original message.\n\tAsUser string `json:\"-\"`\n\t// Timestamp for consistency of timestamps in {ctrl} messages\n\t// (corresponds to originating client message receipt timestamp).\n\tTimestamp time.Time `json:\"-\"`\n\t// Originating session to send an aknowledgement to. Could be nil.\n\tsess *Session\n\t// Session ID to skip when sendng packet to sessions. Used to skip sending to original session.\n\t// Could be either empty.\n\tSkipSid string `json:\"-\"`\n\t// User id affected by this message.\n\tuid types.Uid\n}\n\n// Deep-shallow copy of ServerComMessage. Deep copy of service fields,\n// shallow copy of session and payload.\nfunc (src *ServerComMessage) copy() *ServerComMessage {\n\tif src == nil {\n\t\treturn nil\n\t}\n\tdst := &ServerComMessage{\n\t\tId:        src.Id,\n\t\tRcptTo:    src.RcptTo,\n\t\tAsUser:    src.AsUser,\n\t\tTimestamp: src.Timestamp,\n\t\tsess:      src.sess,\n\t\tSkipSid:   src.SkipSid,\n\t\tuid:       src.uid,\n\t}\n\n\tdst.Ctrl = src.Ctrl.copy()\n\tdst.Data = src.Data.copy()\n\tdst.Meta = src.Meta.copy()\n\tdst.Pres = src.Pres.copy()\n\tdst.Info = src.Info.copy()\n\n\treturn dst\n}\n\nfunc (src *ServerComMessage) describe() string {\n\tif src == nil {\n\t\treturn \"-\"\n\t}\n\n\tswitch {\n\tcase src.Ctrl != nil:\n\t\treturn \"{ctrl \" + src.Ctrl.describe() + \"}\"\n\tcase src.Data != nil:\n\t\treturn \"{data \" + src.Data.describe() + \"}\"\n\tcase src.Meta != nil:\n\t\treturn \"{meta \" + src.Meta.describe() + \"}\"\n\tcase src.Pres != nil:\n\t\treturn \"{pres \" + src.Pres.describe() + \"}\"\n\tcase src.Info != nil:\n\t\treturn \"{info \" + src.Info.describe() + \"}\"\n\tdefault:\n\t\treturn \"{nil}\"\n\t}\n}\n\n// Generators of server-side error messages {ctrl}.\n\n// NoErr indicates successful completion (200).\nfunc NoErr(id, topic string, ts time.Time) *ServerComMessage {\n\treturn NoErrParams(id, topic, ts, nil)\n}\n\n// NoErrExplicitTs indicates successful completion with explicit server and incoming request timestamps (200).\nfunc NoErrExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn NoErrParamsExplicitTs(id, topic, serverTs, incomingReqTs, nil)\n}\n\n// NoErrReply indicates successful completion as a reply to a client message (200).\nfunc NoErrReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {\n\treturn NoErrExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)\n}\n\n// NoErrParams indicates successful completion with additional parameters (200).\nfunc NoErrParams(id, topic string, ts time.Time, params any) *ServerComMessage {\n\treturn NoErrParamsExplicitTs(id, topic, ts, ts, params)\n}\n\n// NoErrParamsExplicitTs indicates successful completion with additional parameters\n// and explicit server and incoming request timestamps (200).\nfunc NoErrParamsExplicitTs(id, topic string, serverTs, incomingReqTs time.Time, params any) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusOK, // 200\n\t\t\tText:      \"ok\",\n\t\t\tTopic:     topic,\n\t\t\tParams:    params,\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// NoErrParamsReply indicates successful completion with additional parameters\n// and explicit server and incoming request timestamps (200).\nfunc NoErrParamsReply(msg *ClientComMessage, ts time.Time, params any) *ServerComMessage {\n\treturn NoErrParamsExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp, params)\n}\n\n// NoErrCreated indicated successful creation of an object (201).\nfunc NoErrCreated(id, topic string, ts time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusCreated, // 201\n\t\t\tText:      \"created\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: ts,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: ts,\n\t}\n}\n\n// NoErrAccepted indicates request was accepted but not processed yet (202).\nfunc NoErrAccepted(id, topic string, ts time.Time) *ServerComMessage {\n\treturn NoErrAcceptedExplicitTs(id, topic, ts, ts)\n}\n\n// NoErrAcceptedExplicitTs indicates request was accepted but not processed yet\n// with explicit server and incoming request timestamps (202).\nfunc NoErrAcceptedExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusAccepted, // 202\n\t\t\tText:      \"accepted\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: serverTs,\n\t\t}, Id: id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// NoContentParams indicates request was processed but resulted in no content (204).\nfunc NoContentParams(id, topic string, serverTs, incomingReqTs time.Time, params any) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusNoContent, // 204\n\t\t\tText:      \"no content\",\n\t\t\tTopic:     topic,\n\t\t\tParams:    params,\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// NoContentParamsReply indicates request was processed but resulted in no content\n// in response to a client request (204).\nfunc NoContentParamsReply(msg *ClientComMessage, ts time.Time, params any) *ServerComMessage {\n\treturn NoContentParams(msg.Id, msg.Original, ts, msg.Timestamp, params)\n}\n\n// NoErrEvicted indicates that the user was disconnected from topic for no fault of the user (205).\nfunc NoErrEvicted(id, topic string, ts time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusResetContent, // 205\n\t\t\tText:      \"evicted\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: ts,\n\t\t}, Id: id,\n\t}\n}\n\n// NoErrShutdown means user was disconnected from topic because system shutdown is in progress (205).\nfunc NoErrShutdown(ts time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tCode:      http.StatusResetContent, // 205\n\t\t\tText:      \"server shutdown\",\n\t\t\tTimestamp: ts,\n\t\t},\n\t}\n}\n\n// NoErrDeliveredParams means requested content has been delivered (208).\nfunc NoErrDeliveredParams(id, topic string, ts time.Time, params any) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusAlreadyReported, // 208\n\t\t\tText:      \"delivered\",\n\t\t\tTopic:     topic,\n\t\t\tParams:    params,\n\t\t\tTimestamp: ts,\n\t\t},\n\t\tId: id,\n\t}\n}\n\n// 3xx\n\n// InfoValidateCredentials requires user to confirm credentials before going forward (300).\nfunc InfoValidateCredentials(id string, ts time.Time) *ServerComMessage {\n\treturn InfoValidateCredentialsExplicitTs(id, ts, ts)\n}\n\n// InfoValidateCredentialsExplicitTs requires user to confirm credentials before going forward\n// with explicit server and incoming request timestamps (300).\nfunc InfoValidateCredentialsExplicitTs(id string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusMultipleChoices, // 300\n\t\t\tText:      \"validate credentials\",\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// InfoChallenge requires user to respond to presented challenge before login can be completed (300).\nfunc InfoChallenge(id string, ts time.Time, challenge []byte) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusMultipleChoices, // 300\n\t\t\tText:      \"challenge\",\n\t\t\tParams:    map[string]any{\"challenge\": challenge},\n\t\t\tTimestamp: ts,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: ts,\n\t}\n}\n\n// InfoAuthReset is sent in response to request to reset authentication when it was completed\n// but login was not performed (301).\nfunc InfoAuthReset(id string, ts time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusMovedPermanently, // 301\n\t\t\tText:      \"auth reset\",\n\t\t\tTimestamp: ts,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: ts,\n\t}\n}\n\n// InfoUseOther is a response to a subscription request redirecting client to another topic (303).\nfunc InfoUseOther(id, topic, other string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusSeeOther, // 303\n\t\t\tText:      \"use other\",\n\t\t\tTopic:     topic,\n\t\t\tParams:    map[string]string{\"topic\": other},\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// InfoUseOtherReply is a response to a subscription request redirecting client to another topic (303).\nfunc InfoUseOtherReply(msg *ClientComMessage, other string, ts time.Time) *ServerComMessage {\n\treturn InfoUseOther(msg.Id, msg.Original, other, ts, msg.Timestamp)\n}\n\n// InfoAlreadySubscribed response means request to subscribe was ignored because user is already subscribed (304).\nfunc InfoAlreadySubscribed(id, topic string, ts time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusNotModified, // 304\n\t\t\tText:      \"already subscribed\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: ts,\n\t\t},\n\t\tId: id, Timestamp: ts,\n\t}\n}\n\n// InfoNotJoined response means request to leave was ignored because user was not subscribed (304).\nfunc InfoNotJoined(id, topic string, ts time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusNotModified, // 304\n\t\t\tText:      \"not joined\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: ts,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: ts,\n\t}\n}\n\n// InfoNoAction response means request was ignored because the object was already in the desired state\n// with explicit server and incoming request timestamps (304).\nfunc InfoNoAction(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusNotModified, // 304\n\t\t\tText:      \"no action\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// InfoNoActionReply response means request was ignored because the object was already in the desired state\n// in response to a client request (304).\nfunc InfoNoActionReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {\n\treturn InfoNoAction(msg.Id, msg.Original, ts, msg.Timestamp)\n}\n\n// InfoNotModified response means update request was a noop (304).\nfunc InfoNotModified(id, topic string, ts time.Time) *ServerComMessage {\n\treturn InfoNotModifiedExplicitTs(id, topic, ts, ts)\n}\n\n// InfoNotModifiedReply response means update request was a noop\n// in response to a client request (304).\nfunc InfoNotModifiedReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {\n\treturn InfoNotModifiedExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)\n}\n\n// InfoNotModifiedExplicitTs response means update request was a noop\n// with explicit server and incoming request timestamps (304).\nfunc InfoNotModifiedExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusNotModified, // 304\n\t\t\tText:      \"not modified\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// InfoFound redirects to a new resource (307).\nfunc InfoFound(id, topic string, ts time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusTemporaryRedirect, // 307\n\t\t\tText:      \"found\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: ts,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: ts,\n\t}\n}\n\n// 4xx Errors\n\n// ErrMalformed request malformed (400).\nfunc ErrMalformed(id, topic string, ts time.Time) *ServerComMessage {\n\treturn ErrMalformedExplicitTs(id, topic, ts, ts)\n}\n\n// ErrMalformedReply request malformed\n// in response to a client request (400).\nfunc ErrMalformedReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {\n\treturn ErrMalformedExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)\n}\n\n// ErrMalformedExplicitTs request malformed with explicit server and incoming request timestamps (400).\nfunc ErrMalformedExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusBadRequest, // 400\n\t\t\tText:      \"malformed\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// ErrAuthRequired authentication required  - user must authenticate first (401).\nfunc ErrAuthRequired(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusUnauthorized, // 401\n\t\t\tText:      \"authentication required\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// ErrAuthRequiredReply authentication required  - user must authenticate first\n// in response to a client request (401).\nfunc ErrAuthRequiredReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {\n\treturn ErrAuthRequired(msg.Id, msg.Original, ts, msg.Timestamp)\n}\n\n// ErrAuthFailed authentication failed\n// with explicit server and incoming request timestamps (401).\nfunc ErrAuthFailed(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusUnauthorized, // 401\n\t\t\tText:      \"authentication failed\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// ErrAuthUnknownScheme authentication scheme is unrecognized or invalid (401).\nfunc ErrAuthUnknownScheme(id, topic string, ts time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusUnauthorized, // 401\n\t\t\tText:      \"unknown authentication scheme\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: ts,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: ts,\n\t}\n}\n\n// ErrPermissionDenied user is authenticated but operation is not permitted (403).\nfunc ErrPermissionDenied(id, topic string, ts time.Time) *ServerComMessage {\n\treturn ErrPermissionDeniedExplicitTs(id, topic, ts, ts)\n}\n\n// ErrPermissionDeniedExplicitTs user is authenticated but operation is not permitted\n// with explicit server and incoming request timestamps (403).\nfunc ErrPermissionDeniedExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusForbidden, // 403\n\t\t\tText:      \"permission denied\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// ErrPermissionDeniedReply user is authenticated but operation is not permitted\n// with explicit server and incoming request timestamps in response to a client request (403).\nfunc ErrPermissionDeniedReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {\n\treturn ErrPermissionDeniedExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)\n}\n\n// ErrAPIKeyRequired  valid API key is required (403).\nfunc ErrAPIKeyRequired(ts time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tCode:      http.StatusForbidden,\n\t\t\tText:      \"valid API key required\",\n\t\t\tTimestamp: ts,\n\t\t},\n\t}\n}\n\n// ErrSessionNotFound  valid API key is required (403).\nfunc ErrSessionNotFound(ts time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tCode:      http.StatusForbidden,\n\t\t\tText:      \"invalid or expired session\",\n\t\t\tTimestamp: ts,\n\t\t},\n\t}\n}\n\n// ErrTopicNotFound topic is not found\n// with explicit server and incoming request timestamps (404).\nfunc ErrTopicNotFound(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusNotFound,\n\t\t\tText:      \"topic not found\", // 404\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// ErrTopicNotFoundReply topic is not found\n// with explicit server and incoming request timestamps\n// in response to a client request (404).\nfunc ErrTopicNotFoundReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {\n\treturn ErrTopicNotFound(msg.Id, msg.Original, ts, msg.Timestamp)\n}\n\n// ErrUserNotFound user is not found\n// with explicit server and incoming request timestamps (404).\nfunc ErrUserNotFound(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusNotFound, // 404\n\t\t\tText:      \"user not found\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// ErrUserNotFoundReply user is not found\n// with explicit server and incoming request timestamps in response to a client request (404).\nfunc ErrUserNotFoundReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {\n\treturn ErrUserNotFound(msg.Id, msg.Original, ts, msg.Timestamp)\n}\n\n// ErrNotFound is an error for missing objects other than user or topic (404).\nfunc ErrNotFound(id, topic string, ts time.Time) *ServerComMessage {\n\treturn ErrNotFoundExplicitTs(id, topic, ts, ts)\n}\n\n// ErrNotFoundExplicitTs is an error for missing objects other than user or topic\n// with explicit server and incoming request timestamps (404).\nfunc ErrNotFoundExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusNotFound, // 404\n\t\t\tText:      \"not found\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// ErrNotFoundReply is an error for missing objects other than user or topic\n// with explicit server and incoming request timestamps in response to a client request (404).\nfunc ErrNotFoundReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {\n\treturn ErrNotFoundExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)\n}\n\n// ErrOperationNotAllowed a valid operation is not permitted in this context (405).\nfunc ErrOperationNotAllowed(id, topic string, ts time.Time) *ServerComMessage {\n\treturn ErrOperationNotAllowedExplicitTs(id, topic, ts, ts)\n}\n\n// ErrOperationNotAllowedExplicitTs a valid operation is not permitted in this context\n// with explicit server and incoming request timestamps (405).\nfunc ErrOperationNotAllowedExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusMethodNotAllowed, // 405\n\t\t\tText:      \"operation or method not allowed\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// ErrOperationNotAllowedReply a valid operation is not permitted in this context\n// with explicit server and incoming request timestamps (405).\nfunc ErrOperationNotAllowedReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {\n\treturn ErrOperationNotAllowedExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)\n}\n\n// ErrInvalidResponse indicates that the client's response in invalid\n// with explicit server and incoming request timestamps (406).\nfunc ErrInvalidResponse(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusNotAcceptable, // 406\n\t\t\tText:      \"invalid response\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// ErrDisconnected indicates that client disconnected or failed to send data in a timely\n// manner (408).\nfunc ErrDisconnected(id, topic string, ts time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusRequestTimeout, // 408\n\t\t\tText:      \"disconnected\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: ts,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: ts,\n\t}\n}\n\n// ErrAlreadyAuthenticated invalid attempt to authenticate an already authenticated session.\n// Switching users is not supported (409).\nfunc ErrAlreadyAuthenticated(id, topic string, ts time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusConflict, // 409\n\t\t\tText:      \"already authenticated\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: ts,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: ts,\n\t}\n}\n\n// ErrDuplicateCredential attempt to create a duplicate credential\n// with explicit server and incoming request timestamps (409).\nfunc ErrDuplicateCredential(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusConflict, // 409\n\t\t\tText:      \"duplicate credential\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// ErrAttachFirst must attach to topic first in response to a client message (409).\nfunc ErrAttachFirst(msg *ClientComMessage, ts time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        msg.Id,\n\t\t\tCode:      http.StatusConflict, // 409\n\t\t\tText:      \"must attach first\",\n\t\t\tTopic:     msg.Original,\n\t\t\tTimestamp: ts,\n\t\t},\n\t\tId:        msg.Id,\n\t\tTimestamp: msg.Timestamp,\n\t}\n}\n\n// ErrAlreadyExists the object already exists (409).\nfunc ErrAlreadyExists(id, topic string, ts time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusConflict, // 409\n\t\t\tText:      \"already exists\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: ts,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: ts,\n\t}\n}\n\n// ErrCommandOutOfSequence invalid sequence of comments, i.e. attempt to {sub} before {hi} (409).\nfunc ErrCommandOutOfSequence(id, unused string, ts time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusConflict, // 409\n\t\t\tText:      \"command out of sequence\",\n\t\t\tTimestamp: ts,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: ts,\n\t}\n}\n\n// ErrGone topic deleted or user banned (410).\nfunc ErrGone(id, topic string, ts time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusGone, // 410\n\t\t\tText:      \"gone\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: ts,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: ts,\n\t}\n}\n\n// ErrTooLarge packet or request size exceeded the limit (413).\nfunc ErrTooLarge(id, topic string, ts time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusRequestEntityTooLarge, // 413\n\t\t\tText:      \"too large\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: ts,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: ts,\n\t}\n}\n\n// ErrPolicy request violates a policy (e.g. password is too weak or too many subscribers) (422).\nfunc ErrPolicy(id, topic string, ts time.Time) *ServerComMessage {\n\treturn ErrPolicyExplicitTs(id, topic, ts, ts)\n}\n\n// ErrPolicyExplicitTs request violates a policy (e.g. password is too weak or too many subscribers)\n// with explicit server and incoming request timestamps (422).\nfunc ErrPolicyExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusUnprocessableEntity, // 422\n\t\t\tText:      \"policy violation\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// ErrPolicyReply request violates a policy (e.g. password is too weak or too many subscribers)\n// with explicit server and incoming request timestamps in response to a client request (422).\nfunc ErrPolicyReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {\n\treturn ErrPolicyExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)\n}\n\n// ErrCallBusyExplicitTs indicates a \"busy\" reply to a video call request (486).\nfunc ErrCallBusyExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      486, // Busy here.\n\t\t\tText:      \"busy here\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// ErrCallBusyReply indicates a \"busy\" reply in response to a video call request (486)\nfunc ErrCallBusyReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {\n\treturn ErrCallBusyExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)\n}\n\n// ErrUnknown database or other server error (500).\nfunc ErrUnknown(id, topic string, ts time.Time) *ServerComMessage {\n\treturn ErrUnknownExplicitTs(id, topic, ts, ts)\n}\n\n// ErrUnknownExplicitTs database or other server error with explicit server and incoming request timestamps (500).\nfunc ErrUnknownExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusInternalServerError, // 500\n\t\t\tText:      \"internal error\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// ErrUnknownReply database or other server error in response to a client request (500).\nfunc ErrUnknownReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {\n\treturn ErrUnknownExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)\n}\n\n// ErrNotImplemented feature not implemented with explicit server and incoming request timestamps (501).\n// TODO: consider changing status code to 4XX.\nfunc ErrNotImplemented(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusNotImplemented, // 501\n\t\t\tText:      \"not implemented\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// ErrNotImplementedReply feature not implemented error in response to a client request (501).\nfunc ErrNotImplementedReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {\n\treturn ErrNotImplemented(msg.Id, msg.Original, ts, msg.Timestamp)\n}\n\n// ErrClusterUnreachableReply in-cluster communication has failed error as response to a client request (502).\nfunc ErrClusterUnreachableReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {\n\treturn ErrClusterUnreachableExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)\n}\n\n// ErrClusterUnreachable in-cluster communication has failed error with explicit server and\n// incoming request timestamps (502).\nfunc ErrClusterUnreachableExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusBadGateway, // 502\n\t\t\tText:      \"cluster unreachable\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// ErrServiceUnavailableReply server overloaded error in response to a client request (503).\nfunc ErrServiceUnavailableReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {\n\treturn ErrServiceUnavailableExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)\n}\n\n// ErrServiceUnavailableExplicitTs server overloaded error with explicit server and\n// incoming request timestamps (503).\nfunc ErrServiceUnavailableExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusServiceUnavailable, // 503\n\t\t\tText:      \"service unavailable\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// ErrLocked operation rejected because the topic is being deleted (503).\nfunc ErrLocked(id, topic string, ts time.Time) *ServerComMessage {\n\treturn ErrLockedExplicitTs(id, topic, ts, ts)\n}\n\n// ErrLockedReply operation rejected because the topic is being deleted in response\n// to a client request (503).\nfunc ErrLockedReply(msg *ClientComMessage, ts time.Time) *ServerComMessage {\n\treturn ErrLockedExplicitTs(msg.Id, msg.Original, ts, msg.Timestamp)\n}\n\n// ErrLockedExplicitTs operation rejected because the topic is being deleted\n// with explicit server and incoming request timestamps (503).\nfunc ErrLockedExplicitTs(id, topic string, serverTs, incomingReqTs time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusServiceUnavailable, // 503\n\t\t\tText:      \"locked\",\n\t\t\tTopic:     topic,\n\t\t\tTimestamp: serverTs,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: incomingReqTs,\n\t}\n}\n\n// ErrVersionNotSupported invalid (too low) protocol version (505).\nfunc ErrVersionNotSupported(id string, ts time.Time) *ServerComMessage {\n\treturn &ServerComMessage{\n\t\tCtrl: &MsgServerCtrl{\n\t\t\tId:        id,\n\t\t\tCode:      http.StatusHTTPVersionNotSupported, // 505\n\t\t\tText:      \"version not supported\",\n\t\t\tTimestamp: ts,\n\t\t},\n\t\tId:        id,\n\t\tTimestamp: ts,\n\t}\n}\n"
  },
  {
    "path": "server/db/adapter.go",
    "content": "// Package adapter contains the interfaces to be implemented by the database adapter\npackage adapter\n\nimport (\n\t\"encoding/json\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/auth\"\n\tt \"github.com/tinode/chat/server/store/types\"\n)\n\n// Adapter is the interface that must be implemented by a database\n// adapter. The current schema supports a single connection by database type.\ntype Adapter interface {\n\t// General\n\n\t// Open and configure the adapter\n\tOpen(config json.RawMessage) error\n\t// Close the adapter\n\tClose() error\n\t// IsOpen checks if the adapter is ready for use\n\tIsOpen() bool\n\t// GetDbVersion returns current database version.\n\tGetDbVersion() (int, error)\n\t// CheckDbVersion checks if the actual database version matches adapter version.\n\tCheckDbVersion() error\n\t// GetName returns the name of the adapter\n\tGetName() string\n\t// SetMaxResults configures how many results can be returned in a single DB call.\n\tSetMaxResults(val int) error\n\t// CreateDb creates the database optionally dropping an existing database first.\n\tCreateDb(reset bool) error\n\t// UpgradeDb upgrades database to the current adapter version.\n\tUpgradeDb() error\n\t// Version returns adapter version\n\tVersion() int\n\t// DB connection stats object.\n\tStats() any\n\n\t// User management\n\n\t// UserCreate creates user record\n\tUserCreate(user *t.User) error\n\t// UserGet returns record for a given user ID\n\tUserGet(uid t.Uid) (*t.User, error)\n\t// UserGetAll returns user records for a given list of user IDs\n\tUserGetAll(ids ...t.Uid) ([]t.User, error)\n\t// UserDelete deletes user record\n\tUserDelete(uid t.Uid, hard bool) error\n\t// UserUpdate updates user record\n\tUserUpdate(uid t.Uid, update map[string]any) error\n\t// UserUpdateTags adds, removes, or resets user's tags\n\tUserUpdateTags(uid t.Uid, add, remove, reset []string) ([]string, error)\n\t// UserGetByCred returns user ID for the given validated credential.\n\tUserGetByCred(method, value string) (t.Uid, error)\n\t// UserUnreadCount returns the total number of unread messages in all topics with\n\t// the R permission. If read fails, the counts are still returned with the original\n\t// user IDs but with the unread count undefined and non-nil error.\n\tUserUnreadCount(ids ...t.Uid) (map[t.Uid]int, error)\n\t// UserGetUnvalidated returns a list of no more than 'limit' uids who never logged in,\n\t// have no validated credentials and which haven't been updated since 'lastUpdatedBefore'.\n\tUserGetUnvalidated(lastUpdatedBefore time.Time, limit int) ([]t.Uid, error)\n\n\t// Credential management\n\n\t// CredUpsert adds or updates a credential record. Returns true if record was inserted, false if updated.\n\tCredUpsert(cred *t.Credential) (bool, error)\n\t// CredGetActive returns the currently active credential record for the given method.\n\tCredGetActive(uid t.Uid, method string) (*t.Credential, error)\n\t// CredGetAll returns credential records for the given user and method, validated only or all.\n\tCredGetAll(uid t.Uid, method string, validatedOnly bool) ([]t.Credential, error)\n\t// CredDel deletes credentials for the given method/value. If method is empty, deletes all\n\t// user's credentials.\n\tCredDel(uid t.Uid, method, value string) error\n\t// CredConfirm marks given credential as validated.\n\tCredConfirm(uid t.Uid, method string) error\n\t// CredFail increments count of failed validation attepmts for the given credentials.\n\tCredFail(uid t.Uid, method string) error\n\n\t// Authentication management for the basic authentication scheme\n\n\t// AuthGetUniqueRecord returns authentication record for a given unique value i.e. login.\n\tAuthGetUniqueRecord(unique string) (t.Uid, auth.Level, []byte, time.Time, error)\n\t// AuthGetRecord returns authentication record given user ID and method.\n\tAuthGetRecord(user t.Uid, scheme string) (string, auth.Level, []byte, time.Time, error)\n\t// AuthAddRecord creates new authentication record\n\tAuthAddRecord(user t.Uid, scheme, unique string, authLvl auth.Level, secret []byte, expires time.Time) error\n\t// AuthDelScheme deletes an existing authentication scheme for the user.\n\tAuthDelScheme(user t.Uid, scheme string) error\n\t// AuthDelAllRecords deletes all records of a given user.\n\tAuthDelAllRecords(uid t.Uid) (int, error)\n\t// AuthUpdRecord modifies an authentication record. Only non-default/non-zero values are updated.\n\tAuthUpdRecord(user t.Uid, scheme, unique string, authLvl auth.Level, secret []byte, expires time.Time) error\n\n\t// Topic management\n\n\t// TopicCreate creates a topic\n\tTopicCreate(topic *t.Topic) error\n\t// TopicCreateP2P creates a p2p topic\n\tTopicCreateP2P(initiator, invited *t.Subscription) error\n\t// TopicGet loads a single topic by name, if it exists. If the topic does not exist the call returns (nil, nil)\n\tTopicGet(topic string) (*t.Topic, error)\n\t// TopicsForUser loads subscriptions for a given user. Reads public value.\n\t// When the 'opts.IfModifiedSince' query is not nil the subscriptions with UpdatedAt > opts.IfModifiedSince\n\t// are returned, where UpdatedAt can be either a subscription, a topic, or a user update timestamp.\n\t// This is need in order to support paginagion of subscriptions: get subscriptions page by page\n\t// from the oldest updates to most recent:\n\t// 1. Client already has subscriptions with the latest update timestamp X.\n\t// 2. Client asks for N updated subscriptions since X. The server returns N with updates between X and Y.\n\t// 3. Client goes to step 1 with X := Y.\n\tTopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error)\n\t// UsersForTopic loads users' subscriptions for a given topic. Public is loaded.\n\tUsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error)\n\t// OwnTopics loads a slice of topic names where the user is the owner.\n\tOwnTopics(uid t.Uid) ([]string, error)\n\t// ChannelsForUser loads a slice of topic names where the user is a channel reader and notifications (P) are enabled.\n\tChannelsForUser(uid t.Uid) ([]string, error)\n\t// TopicShare creates topic subscriptions.\n\tTopicShare(topic string, subs []*t.Subscription) error\n\t// TopicDelete deletes topic, subscriptions, messages.\n\tTopicDelete(topic string, isChan, hard bool) error\n\t// TopicUpdateOnMessage increments Topic's or User's SeqId value and updates TouchedAt timestamp.\n\tTopicUpdateOnMessage(topic string, msg *t.Message) error\n\t// TopicUpdateSubCnt refreshes denormalized topic subscriber count.\n\tTopicUpdateSubCnt(topic string) error\n\t// TopicUpdate updates topic record.\n\tTopicUpdate(topic string, update map[string]any) error\n\t// TopicOwnerChange updates topic's owner\n\tTopicOwnerChange(topic string, newOwner t.Uid) error\n\n\t// Topic subscriptions\n\n\t// SubscriptionGet reads a subscription of a user to a topic\n\tSubscriptionGet(topic string, user t.Uid, keepDeleted bool) (*t.Subscription, error)\n\t// SubsForUser loads all subscriptions of a given user. Does NOT load Public or Private values,\n\t// does not load deleted subscriptions.\n\tSubsForUser(user t.Uid) ([]t.Subscription, error)\n\t// SubsForTopic gets a list of subscriptions to a given topic.. Does NOT load Public value.\n\tSubsForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error)\n\t// SubsUpdate updates pasrt of a subscription object. Pass nil for fields which don't need to be updated\n\tSubsUpdate(topic string, user t.Uid, update map[string]any) error\n\t// SubsDelete deletes a single subscription\n\tSubsDelete(topic string, user t.Uid) error\n\n\t// Search\n\n\t// Find searches for users or topics given a list of tags.\n\t// - caller is the user or topic who is doing the searching, it will be skipped from results.\n\t// - prefix if present will cause match rank highest in the results.\n\t// - req is a list of required tag sets. Each set is a list of tags. The search will return\n\t//   all users/topics which have at least one tag from each set.\n\t// - opt is a list of optional tags; if present the result will rank higher.\n\t// - activeOnly if true will return only active subscriptions.\n\tFind(caller, prefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error)\n\t// FindOne returns topic or user which matches the given tag.\n\tFindOne(tag string) (string, error)\n\n\t// Messages\n\n\t// MessageSave saves message to database\n\tMessageSave(msg *t.Message) error\n\t// MessageGetAll returns messages matching the query\n\tMessageGetAll(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.Message, error)\n\t// MessageDeleteList marks messages as deleted.\n\t// Soft- or Hard- is defined by forUser value: forUser.IsZero == true is hard.\n\tMessageDeleteList(topic string, toDel *t.DelMessage) error\n\t// MessageGetDeleted returns a list of deleted message Ids.\n\tMessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error)\n\n\t// Devices (for push notifications)\n\n\t// DeviceUpsert creates or updates a device record\n\tDeviceUpsert(uid t.Uid, dev *t.DeviceDef) error\n\t// DeviceGetAll returns all devices for a given set of users\n\tDeviceGetAll(uid ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error)\n\t// DeviceDelete deletes a device record\n\tDeviceDelete(uid t.Uid, deviceID string) error\n\n\t// File upload records. The files are stored outside of the database.\n\n\t// FileStartUpload initializes a file upload.\n\tFileStartUpload(fd *t.FileDef) error\n\t// FileFinishUpload marks file upload as completed, successfully or otherwise.\n\tFileFinishUpload(fd *t.FileDef, success bool, size int64) (*t.FileDef, error)\n\t// FileGet fetches a record of a specific file\n\tFileGet(fid string) (*t.FileDef, error)\n\t// FileDeleteUnused deletes records where UseCount is zero. If olderThan is non-zero, deletes\n\t// unused records with UpdatedAt before olderThan.\n\t// Returns array of FileDef.Location of deleted filerecords so actual files can be deleted too.\n\tFileDeleteUnused(olderThan time.Time, limit int) ([]string, error)\n\t// FileLinkAttachments connects given topic or message to the file record IDs from the list.\n\tFileLinkAttachments(topic string, userId, msgId t.Uid, fids []string) error\n\n\t// Persistent cache management.\n\n\t// PCacheGet reads a persistent cache entry.\n\tPCacheGet(key string) (string, error)\n\t// PCacheUpsert creates or updates a persistent cache entry.\n\tPCacheUpsert(key string, value string, failOnDuplicate bool) error\n\t// PCacheDelete deletes a single persistent cache entry.\n\tPCacheDelete(key string) error\n\t// PCacheExpire expires older entries with the specified key prefix.\n\tPCacheExpire(keyPrefix string, olderThan time.Time) error\n\n\t// Testing\n\n\t// GetTestDB returns a currently open database connection.\n\tGetTestDB() any\n}\n"
  },
  {
    "path": "server/db/common/common.go",
    "content": "// Package common contains utility methods used by all adapters.\npackage common\n\nimport (\n\t\"encoding/json\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/auth\"\n\t\"github.com/tinode/chat/server/store\"\n\tt \"github.com/tinode/chat/server/store/types\"\n)\n\ntype AuthRecord struct {\n\tUnique  string     `json:\"unique\" bson:\"_id\"`\n\tUserId  string     `json:\"userid\"`\n\tScheme  string     `json:\"scheme\"`\n\tAuthLvl auth.Level `json:\"authLvl\"`\n\tSecret  []byte     `json:\"secret\"`\n\tExpires time.Time  `json:\"expires\"`\n}\n\n// SelectEarliestUpdatedSubs selects no more than the given number of subscriptions from the\n// given slice satisfying the query. When the number of subscriptions is greater than the limit,\n// the subscriptions with the earliest timestamp are selected.\nfunc SelectEarliestUpdatedSubs(subs []t.Subscription, opts *t.QueryOpt, maxResults int) []t.Subscription {\n\tlimit := maxResults\n\tims := time.Time{}\n\tif opts != nil {\n\t\tif opts.Limit > 0 && opts.Limit < limit {\n\t\t\tlimit = opts.Limit\n\t\t}\n\t\tif opts.IfModifiedSince != nil {\n\t\t\tims = *opts.IfModifiedSince\n\t\t}\n\t}\n\n\t// No cache management and the number of results is below the limit: return all.\n\tif ims.IsZero() && len(subs) <= limit {\n\t\treturn subs\n\t}\n\n\t// Now that we fetched potentially more subscriptions than needed, we got to take those with the oldest modifications.\n\t// Sorting in ascending order by modification time.\n\tsort.Slice(subs, func(i, j int) bool {\n\t\treturn subs[i].LastModified().Before(subs[j].LastModified())\n\t})\n\n\tif !ims.IsZero() {\n\t\t// Keep only those subscriptions which are newer than ims.\n\t\tat := sort.Search(len(subs), func(i int) bool {\n\t\t\treturn subs[i].LastModified().After(ims)\n\t\t})\n\t\tsubs = subs[at:]\n\t}\n\t// Trim slice at the limit.\n\tif len(subs) > limit {\n\t\tsubs = subs[:limit]\n\t}\n\n\treturn subs\n}\n\n// SelectLatestTime picks the latest update timestamp out of the two.\nfunc SelectLatestTime(t1, t2 time.Time) time.Time {\n\tif t1.Before(t2) {\n\t\t// Subscription has not changed recently, use user's update timestamp.\n\t\treturn t2\n\t}\n\n\treturn t1\n}\n\n// RangesToSql converts a slice of ranges to SQL BETWEEN or IN() constraint and arguments.\nfunc RangesToSql(in []t.Range) (string, []any) {\n\tif len(in) > 1 || in[0].Hi == 0 {\n\t\tvar args []any\n\t\tfor _, r := range in {\n\t\t\tif r.Hi == 0 {\n\t\t\t\targs = append(args, r.Low)\n\t\t\t} else {\n\t\t\t\tfor i := r.Low; i < r.Hi; i++ {\n\t\t\t\t\targs = append(args, i)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn \"IN (?\" + strings.Repeat(\",?\", len(args)-1) + \")\", args\n\t}\n\n\t// Optimizing for a special case of single range low..hi.\n\t// SQL's BETWEEN is inclusive-inclusive thus decrement Hi by 1.\n\treturn \"BETWEEN ? AND ?\", []any{in[0].Low, in[0].Hi - 1}\n}\n\n// DisjunctionSql converts a slice of disjunctions to SQL HAVING clause and arguments.\nfunc DisjunctionSql(req [][]string, fieldName string) (string, []any) {\n\tvar args []any\n\tcounts := make([]string, 0, len(req))\n\tfor _, reqDisjunction := range req {\n\t\t// At least one of the tags must be present.\n\t\tif len(reqDisjunction) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tcounts = append(counts, \"COUNT(\"+fieldName+\" IN (?\"+strings.Repeat(\",?\", len(reqDisjunction)-1)+\") OR NULL)>=1\")\n\t\tfor _, tag := range reqDisjunction {\n\t\t\targs = append(args, tag)\n\t\t}\n\t}\n\treturn \"HAVING \" + strings.Join(counts, \" AND \") + \" \", args\n}\n\n// FilterFoundTags keeps only those tags in setTags that are present in the index.\nfunc FilterFoundTags(setTags t.StringSlice, index map[string]struct{}) []string {\n\tfoundTags := make([]string, 0, 1)\n\tfor _, tag := range setTags {\n\t\tif _, ok := index[tag]; ok {\n\t\t\tfoundTags = append(foundTags, tag)\n\t\t}\n\t}\n\treturn foundTags\n}\n\n// Convert to JSON before storing to JSON field.\nfunc ToJSON(src any) []byte {\n\tif src == nil {\n\t\treturn nil\n\t}\n\n\tjval, _ := json.Marshal(src)\n\treturn jval\n}\n\n// Deserialize JSON data from DB.\nfunc FromJSON(src any) any {\n\tif src == nil {\n\t\treturn nil\n\t}\n\tif bb, ok := src.([]byte); ok {\n\t\tvar out any\n\t\tjson.Unmarshal(bb, &out)\n\t\treturn out\n\t}\n\treturn nil\n}\n\n// Convert update to a list of columns and arguments.\nfunc UpdateByMap(update map[string]any) (cols []string, args []any) {\n\tfor col, arg := range update {\n\t\tcol = strings.ToLower(col)\n\t\tif col == \"public\" || col == \"trusted\" || col == \"private\" || col == \"aux\" {\n\t\t\targ = ToJSON(arg)\n\t\t}\n\t\tcols = append(cols, col+\"=?\")\n\t\targs = append(args, arg)\n\t}\n\treturn\n}\n\n// If Tags field is updated, get the tags so tags table cab be updated too.\nfunc ExtractTags(update map[string]any) []string {\n\tvar tags []string\n\n\tif val := update[\"Tags\"]; val != nil {\n\t\ttags, _ = val.(t.StringSlice)\n\t}\n\n\treturn []string(tags)\n}\n\n// EncodeUidString takes decoded string representation of int64, produce UID.\n// UIDs are stored as decoded int64 values.\nfunc EncodeUidString(str string) t.Uid {\n\tunum, _ := strconv.ParseInt(str, 10, 64)\n\treturn store.EncodeUid(unum)\n}\n\n// DecodeUidString takes UID as string, converts it to int64 representation.\n// UIDs are stored as decoded int64 values.\nfunc DecodeUidString(str string) int64 {\n\tuid := t.ParseUid(str)\n\treturn store.DecodeUid(uid)\n}\n"
  },
  {
    "path": "server/db/common/common_test.go",
    "content": "package common\n\nimport (\n\t\"encoding/json\"\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\nfunc genTestData() []types.Subscription {\n\tvar testData = []types.Subscription{\n\t\t{ObjHeader: types.ObjHeader{Id: \"1\", UpdatedAt: time.Date(2021, time.June, 1, 1, 11, 0, 0, time.Local)}},\n\t\t{ObjHeader: types.ObjHeader{Id: \"2\", UpdatedAt: time.Date(2021, time.June, 2, 2, 12, 0, 0, time.Local)}},\n\t\t{ObjHeader: types.ObjHeader{Id: \"3\", UpdatedAt: time.Date(2021, time.June, 3, 3, 13, 0, 0, time.Local)}},\n\t\t{ObjHeader: types.ObjHeader{Id: \"4\", UpdatedAt: time.Date(2021, time.June, 4, 4, 14, 0, 0, time.Local)}},\n\t\t{ObjHeader: types.ObjHeader{Id: \"5\", UpdatedAt: time.Date(2021, time.June, 5, 5, 15, 0, 0, time.Local)}},\n\t\t{ObjHeader: types.ObjHeader{Id: \"6\", UpdatedAt: time.Date(2021, time.June, 6, 6, 16, 0, 0, time.Local)}},\n\t\t{ObjHeader: types.ObjHeader{Id: \"7\", UpdatedAt: time.Date(2021, time.June, 7, 7, 17, 0, 0, time.Local)}},\n\t\t{ObjHeader: types.ObjHeader{Id: \"8\", UpdatedAt: time.Date(2021, time.June, 8, 8, 18, 0, 0, time.Local)}},\n\t\t{ObjHeader: types.ObjHeader{Id: \"9\", UpdatedAt: time.Date(2021, time.June, 9, 9, 19, 0, 0, time.Local)}},\n\t\t{ObjHeader: types.ObjHeader{Id: \"10\", UpdatedAt: time.Date(2021, time.June, 10, 10, 20, 0, 0, time.Local)}},\n\t}\n\n\t// TouchedAt is either greater or equal to UpdatedAt.\n\ttestData[0].SetTouchedAt(time.Date(2021, time.June, 1, 1, 11, 0, 0, time.Local))   // 1\n\ttestData[1].SetTouchedAt(time.Date(2021, time.June, 4, 4, 12, 0, 0, time.Local))   // 3\n\ttestData[2].SetTouchedAt(time.Date(2021, time.June, 4, 2, 13, 0, 0, time.Local))   // 2\n\ttestData[3].SetTouchedAt(time.Date(2021, time.June, 4, 4, 14, 0, 0, time.Local))   // 4\n\ttestData[4].SetTouchedAt(time.Date(2021, time.June, 7, 5, 15, 0, 0, time.Local))   // 6\n\ttestData[5].SetTouchedAt(time.Date(2021, time.June, 6, 6, 16, 0, 0, time.Local))   // 5\n\ttestData[6].SetTouchedAt(time.Date(2021, time.June, 7, 7, 17, 0, 0, time.Local))   // 7\n\ttestData[7].SetTouchedAt(time.Date(2021, time.June, 9, 8, 18, 0, 0, time.Local))   // 8\n\ttestData[8].SetTouchedAt(time.Date(2021, time.June, 10, 11, 19, 0, 0, time.Local)) // 10\n\ttestData[9].SetTouchedAt(time.Date(2021, time.June, 10, 10, 20, 0, 0, time.Local)) // 9\n\n\treturn testData\n}\n\nfunc TestSelectEarliestUpdatedSubs(t *testing.T) {\n\tgetOrder := func(subs []types.Subscription) string {\n\t\tvar order []string\n\t\tfor i := range subs {\n\t\t\torder = append(order, subs[i].Id)\n\t\t}\n\t\treturn strings.Join(order, \",\")\n\t}\n\n\tsubs := SelectEarliestUpdatedSubs(genTestData(), nil, 100)\n\n\t// No sorting when returning the full set.\n\texpectedOrder := \"1,2,3,4,5,6,7,8,9,10\"\n\tsortOrder := getOrder(subs)\n\tif sortOrder != expectedOrder {\n\t\tt.Error(\"Wrong results returned. Expected:\", expectedOrder, \"; Got:\", sortOrder)\n\t}\n\n\t// Sorted, oldest 9 results.\n\tsubs = SelectEarliestUpdatedSubs(genTestData(), nil, 9)\n\texpectedOrder = \"1,3,2,4,6,5,7,8,10\"\n\tsortOrder = getOrder(subs)\n\tif sortOrder != expectedOrder {\n\t\tt.Error(\"Limited query returned wrong results. Expected:\", expectedOrder, \"; Got:\", sortOrder)\n\t}\n\n\t// Sorted, oldest 9 results.\n\tsubs = SelectEarliestUpdatedSubs(genTestData(), &types.QueryOpt{Limit: 20}, 9)\n\texpectedOrder = \"1,3,2,4,6,5,7,8,10\"\n\tsortOrder = getOrder(subs)\n\tif sortOrder != expectedOrder {\n\t\tt.Error(\"Limited query (2) returned wrong results. Expected:\", expectedOrder, \"; Got:\", sortOrder)\n\t}\n\n\t// Sorted, oldest 9 results.\n\tsubs = SelectEarliestUpdatedSubs(genTestData(), &types.QueryOpt{Limit: 9}, 20)\n\texpectedOrder = \"1,3,2,4,6,5,7,8,10\"\n\tsortOrder = getOrder(subs)\n\tif sortOrder != expectedOrder {\n\t\tt.Error(\"Limited query (3) returned wrong results. Expected:\", expectedOrder, \"; Got:\", sortOrder)\n\t}\n\n\tims := time.Date(2021, time.June, 7, 8, 16, 15, 0, time.Local)\n\tsubs = SelectEarliestUpdatedSubs(genTestData(), &types.QueryOpt{Limit: 6, IfModifiedSince: &ims}, 20)\n\texpectedOrder = \"8,10,9\"\n\tsortOrder = getOrder(subs)\n\tif sortOrder != expectedOrder {\n\t\tt.Error(\"Date & count limited query returned wrong results. Expected:\", expectedOrder, \"; Got:\", sortOrder)\n\t}\n\n\tims = time.Date(2021, time.June, 4, 4, 13, 15, 0, time.Local)\n\tsubs = SelectEarliestUpdatedSubs(genTestData(), &types.QueryOpt{Limit: 3, IfModifiedSince: &ims}, 20)\n\texpectedOrder = \"4,6,5\"\n\tsortOrder = getOrder(subs)\n\tif sortOrder != expectedOrder {\n\t\tt.Error(\"Count & date limited query returned wrong results. Expected:\", expectedOrder, \"; Got:\", sortOrder)\n\t}\n}\n\nfunc TestSelectLatestTime(t *testing.T) {\n\tt1 := time.Date(2021, time.June, 1, 10, 0, 0, 0, time.UTC)\n\tt2 := time.Date(2021, time.June, 2, 10, 0, 0, 0, time.UTC)\n\n\t// t1 is before t2, should return t2\n\tresult := SelectLatestTime(t1, t2)\n\tif !result.Equal(t2) {\n\t\tt.Errorf(\"Expected %v, got %v\", t2, result)\n\t}\n\n\t// t2 is after t1, should return t2\n\tresult = SelectLatestTime(t2, t1)\n\tif !result.Equal(t2) {\n\t\tt.Errorf(\"Expected %v, got %v\", t2, result)\n\t}\n\n\t// Equal times, should return either one (in this case t1)\n\tresult = SelectLatestTime(t1, t1)\n\tif !result.Equal(t1) {\n\t\tt.Errorf(\"Expected %v, got %v\", t1, result)\n\t}\n}\n\nfunc TestRangesToSql(t *testing.T) {\n\t// Test single range with Hi = 0 (IN clause)\n\tranges := []types.Range{{Low: 5, Hi: 0}}\n\tsql, args := RangesToSql(ranges)\n\texpectedSql := \"IN (?)\"\n\texpectedArgs := []any{5}\n\tif sql != expectedSql {\n\t\tt.Errorf(\"Expected SQL '%s', got '%s'\", expectedSql, sql)\n\t}\n\tif !reflect.DeepEqual(args, expectedArgs) {\n\t\tt.Errorf(\"Expected args %v, got %v\", expectedArgs, args)\n\t}\n\n\t// Test single range with Hi > 0 (BETWEEN clause)\n\tranges = []types.Range{{Low: 5, Hi: 8}}\n\tsql, args = RangesToSql(ranges)\n\texpectedSql = \"BETWEEN ? AND ?\"\n\texpectedArgs = []any{5, 7} // Hi-1 for BETWEEN\n\tif sql != expectedSql {\n\t\tt.Errorf(\"Expected SQL '%s', got '%s'\", expectedSql, sql)\n\t}\n\tif !reflect.DeepEqual(args, expectedArgs) {\n\t\tt.Errorf(\"Expected args %v, got %v\", expectedArgs, args)\n\t}\n\n\t// Test multiple ranges (IN clause)\n\tranges = []types.Range{{Low: 1, Hi: 3}, {Low: 5, Hi: 0}, {Low: 8, Hi: 10}}\n\tsql, args = RangesToSql(ranges)\n\texpectedSql = \"IN (?,?,?,?,?)\"\n\texpectedArgs = []any{1, 2, 5, 8, 9}\n\tif sql != expectedSql {\n\t\tt.Errorf(\"Expected SQL '%s', got '%s'\", expectedSql, sql)\n\t}\n\tif !reflect.DeepEqual(args, expectedArgs) {\n\t\tt.Errorf(\"Expected args %v, got %v\", expectedArgs, args)\n\t}\n}\n\nfunc TestDisjunctionSql(t *testing.T) {\n\t// Test single disjunction\n\treq := [][]string{{\"tag1\", \"tag2\", \"tag3\"}}\n\tsql, args := DisjunctionSql(req, \"tagname\")\n\texpectedSql := \"HAVING COUNT(tagname IN (?,?,?) OR NULL)>=1 \"\n\texpectedArgs := []any{\"tag1\", \"tag2\", \"tag3\"}\n\tif sql != expectedSql {\n\t\tt.Errorf(\"Expected SQL '%s', got '%s'\", expectedSql, sql)\n\t}\n\tif !reflect.DeepEqual(args, expectedArgs) {\n\t\tt.Errorf(\"Expected args %v, got %v\", expectedArgs, args)\n\t}\n\n\t// Test multiple disjunctions\n\treq = [][]string{{\"tag1\", \"tag2\"}, {\"tag3\"}, {\"tag4\", \"tag5\"}}\n\tsql, args = DisjunctionSql(req, \"fieldname\")\n\texpectedSql = \"HAVING COUNT(fieldname IN (?,?) OR NULL)>=1 AND COUNT(fieldname IN (?) OR NULL)>=1 AND COUNT(fieldname IN (?,?) OR NULL)>=1 \"\n\texpectedArgs = []any{\"tag1\", \"tag2\", \"tag3\", \"tag4\", \"tag5\"}\n\tif sql != expectedSql {\n\t\tt.Errorf(\"Expected SQL '%s', got '%s'\", expectedSql, sql)\n\t}\n\tif !reflect.DeepEqual(args, expectedArgs) {\n\t\tt.Errorf(\"Expected args %v, got %v\", expectedArgs, args)\n\t}\n\n\t// Test empty disjunctions (should be skipped)\n\treq = [][]string{{\"tag1\"}, {}, {\"tag2\"}}\n\tsql, args = DisjunctionSql(req, \"fieldname\")\n\texpectedSql = \"HAVING COUNT(fieldname IN (?) OR NULL)>=1 AND COUNT(fieldname IN (?) OR NULL)>=1 \"\n\texpectedArgs = []any{\"tag1\", \"tag2\"}\n\tif sql != expectedSql {\n\t\tt.Errorf(\"Expected SQL '%s', got '%s'\", expectedSql, sql)\n\t}\n\tif !reflect.DeepEqual(args, expectedArgs) {\n\t\tt.Errorf(\"Expected args %v, got %v\", expectedArgs, args)\n\t}\n}\n\nfunc TestFilterFoundTags(t *testing.T) {\n\tsetTags := types.StringSlice{\"tag1\", \"tag2\", \"tag3\", \"tag4\", \"tag5\"}\n\tindex := map[string]struct{}{\n\t\t\"tag1\": {},\n\t\t\"tag3\": {},\n\t\t\"tag5\": {},\n\t\t\"tag6\": {}, // Not in setTags\n\t}\n\n\tresult := FilterFoundTags(setTags, index)\n\texpected := []string{\"tag1\", \"tag3\", \"tag5\"}\n\tif !reflect.DeepEqual(result, expected) {\n\t\tt.Errorf(\"Expected %v, got %v\", expected, result)\n\t}\n\n\t// Test with empty index\n\temptyIndex := map[string]struct{}{}\n\tresult = FilterFoundTags(setTags, emptyIndex)\n\texpected = []string{}\n\tif !reflect.DeepEqual(result, expected) {\n\t\tt.Errorf(\"Expected %v, got %v\", expected, result)\n\t}\n\n\t// Test with empty setTags\n\temptyTags := types.StringSlice{}\n\tresult = FilterFoundTags(emptyTags, index)\n\texpected = []string{}\n\tif !reflect.DeepEqual(result, expected) {\n\t\tt.Errorf(\"Expected %v, got %v\", expected, result)\n\t}\n}\n\nfunc TestToJSON(t *testing.T) {\n\t// Test with nil\n\tresult := ToJSON(nil)\n\tif result != nil {\n\t\tt.Errorf(\"Expected nil, got %v\", result)\n\t}\n\n\t// Test with string\n\tinput := \"test string\"\n\tresult = ToJSON(input)\n\texpected := []byte(`\"test string\"`)\n\tif !reflect.DeepEqual(result, expected) {\n\t\tt.Errorf(\"Expected %v, got %v\", expected, result)\n\t}\n\n\t// Test with map\n\tinput2 := map[string]any{\"key\": \"value\", \"number\": 42}\n\tresult = ToJSON(input2)\n\t// Parse back to verify\n\tvar parsed map[string]any\n\tif err := json.Unmarshal(result, &parsed); err != nil {\n\t\tt.Errorf(\"Failed to unmarshal result: %v\", err)\n\t}\n\tif parsed[\"key\"] != \"value\" || parsed[\"number\"] != float64(42) {\n\t\tt.Errorf(\"JSON conversion failed, got %v\", parsed)\n\t}\n}\n\nfunc TestFromJSON(t *testing.T) {\n\t// Test with nil\n\tresult := FromJSON(nil)\n\tif result != nil {\n\t\tt.Errorf(\"Expected nil, got %v\", result)\n\t}\n\n\t// Test with valid JSON bytes\n\tinput := []byte(`{\"key\": \"value\", \"number\": 42}`)\n\tresult = FromJSON(input)\n\tif resultMap, ok := result.(map[string]any); ok {\n\t\tif resultMap[\"key\"] != \"value\" || resultMap[\"number\"] != float64(42) {\n\t\t\tt.Errorf(\"JSON deserialization failed, got %v\", resultMap)\n\t\t}\n\t} else {\n\t\tt.Errorf(\"Expected map[string]any, got %T\", result)\n\t}\n\n\t// Test with invalid JSON bytes\n\tinvalidInput := []byte(`{invalid json}`)\n\tresult = FromJSON(invalidInput)\n\tif result != nil {\n\t\tt.Errorf(\"Expected nil for invalid JSON, got %v\", result)\n\t}\n\n\t// Test with non-byte slice\n\tstringInput := \"not bytes\"\n\tresult = FromJSON(stringInput)\n\tif result != nil {\n\t\tt.Errorf(\"Expected nil for non-byte input, got %v\", result)\n\t}\n}\n\nfunc TestUpdateByMap(t *testing.T) {\n\tupdate := map[string]any{\n\t\t\"Name\":      \"John Doe\",\n\t\t\"Age\":       30,\n\t\t\"Public\":    map[string]string{\"avatar\": \"url\"},\n\t\t\"Private\":   map[string]string{\"email\": \"john@example.com\"},\n\t\t\"Trusted\":   map[string]bool{\"verified\": true},\n\t\t\"UpdatedAt\": time.Now(),\n\t}\n\n\tcols, args := UpdateByMap(update)\n\n\t// Check that we have the right number of columns and args\n\tif len(cols) != len(args) || len(cols) != len(update) {\n\t\tt.Errorf(\"Expected %d columns and args, got %d cols and %d args\", len(update), len(cols), len(args))\n\t}\n\n\t// Verify column format\n\tfor _, col := range cols {\n\t\tif !strings.Contains(col, \"=?\") {\n\t\t\tt.Errorf(\"Column should contain '=?', got %s\", col)\n\t\t}\n\t}\n\n\t// Check that JSON fields are properly handled\n\tfoundPublic := false\n\tfoundPrivate := false\n\tfoundTrusted := false\n\tfor i, col := range cols {\n\t\tif strings.HasPrefix(col, \"public=?\") {\n\t\t\tfoundPublic = true\n\t\t\t// Should be JSON bytes\n\t\t\tif _, ok := args[i].([]byte); !ok {\n\t\t\t\tt.Errorf(\"Public field should be []byte, got %T\", args[i])\n\t\t\t}\n\t\t}\n\t\tif strings.HasPrefix(col, \"private=?\") {\n\t\t\tfoundPrivate = true\n\t\t\tif _, ok := args[i].([]byte); !ok {\n\t\t\t\tt.Errorf(\"Private field should be []byte, got %T\", args[i])\n\t\t\t}\n\t\t}\n\t\tif strings.HasPrefix(col, \"trusted=?\") {\n\t\t\tfoundTrusted = true\n\t\t\tif _, ok := args[i].([]byte); !ok {\n\t\t\t\tt.Errorf(\"Trusted field should be []byte, got %T\", args[i])\n\t\t\t}\n\t\t}\n\t}\n\n\tif !foundPublic || !foundPrivate || !foundTrusted {\n\t\tt.Error(\"Missing JSON fields in output\")\n\t}\n}\n\nfunc TestExtractTags(t *testing.T) {\n\t// Test with Tags field present\n\tupdate := map[string]any{\n\t\t\"Name\": \"John\",\n\t\t\"Tags\": types.StringSlice{\"tag1\", \"tag2\", \"tag3\"},\n\t\t\"Age\":  30,\n\t}\n\ttags := ExtractTags(update)\n\texpected := []string{\"tag1\", \"tag2\", \"tag3\"}\n\tif !reflect.DeepEqual(tags, expected) {\n\t\tt.Errorf(\"Expected %v, got %v\", expected, tags)\n\t}\n\n\t// Test with no Tags field\n\tupdate = map[string]any{\n\t\t\"Name\": \"John\",\n\t\t\"Age\":  30,\n\t}\n\ttags = ExtractTags(update)\n\texpected = nil\n\tif !reflect.DeepEqual(tags, expected) {\n\t\tt.Errorf(\"Expected %+v, got %+v\", expected, tags)\n\t}\n\n\t// Test with nil Tags field\n\tupdate = map[string]any{\n\t\t\"Name\": \"John\",\n\t\t\"Tags\": nil,\n\t\t\"Age\":  30,\n\t}\n\ttags = ExtractTags(update)\n\texpected = nil\n\tif !reflect.DeepEqual(tags, expected) {\n\t\tt.Errorf(\"Expected %+v, got %+v\", expected, tags)\n\t}\n\n\t// Test with wrong type for Tags field\n\tupdate = map[string]any{\n\t\t\"Name\": \"John\",\n\t\t\"Tags\": \"not a slice\",\n\t\t\"Age\":  30,\n\t}\n\ttags = ExtractTags(update)\n\texpected = nil\n\tif !reflect.DeepEqual(tags, expected) {\n\t\tt.Errorf(\"Expected %+v, got %+v\", expected, tags)\n\t}\n}\n"
  },
  {
    "path": "server/db/common/test_data/test_data.go",
    "content": "package test_data\n\nimport (\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/auth\"\n\t\"github.com/tinode/chat/server/db/common\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\ntype TestData struct {\n\tUGen   *types.UidGenerator\n\tUsers  []*types.User\n\tCreds  []*types.Credential\n\tRecs   []common.AuthRecord\n\tTopics []*types.Topic\n\tSubs   []*types.Subscription\n\tMsgs   []*types.Message\n\tDevs   []*types.DeviceDef\n\tFiles  []*types.FileDef\n\t// Tags: add, remove, reset\n\tTags [][]string\n\tNow  time.Time\n}\n\nfunc initUsers(now time.Time) []*types.User {\n\tusers := make([]*types.User, 0, 3)\n\tusers = append(users, &types.User{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tId: \"3ysxkod5hNM\",\n\t\t},\n\t\tUserAgent: \"SomeAgent v1.2.3\",\n\t\tTags:      []string{\"alice\"},\n\t})\n\tusers = append(users, &types.User{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tId: \"9AVDamaNCRY\",\n\t\t},\n\t\tUserAgent: \"Tinode Web v111.222.333\",\n\t\tTags:      []string{\"bob\"},\n\t})\n\tusers = append(users, &types.User{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tId: \"0QLrX3WPS2o\",\n\t\t},\n\t\tUserAgent: \"Tindroid v1.2.3\",\n\t\tTags:      []string{\"carol\"},\n\t})\n\tfor _, user := range users {\n\t\t// Initialize timestamps.\n\t\tuser.InitTimes()\n\t\t// Assign user.id from user.Id.\n\t\tuser.Uid()\n\t}\n\tdeletedAt := now.Add(10 * time.Minute)\n\tusers[2].State = types.StateDeleted\n\tusers[2].StateAt = &deletedAt\n\treturn users\n}\n\nfunc initCreds(now time.Time, users []*types.User) []*types.Credential {\n\tcreds := make([]*types.Credential, 0, 6)\n\tcreds = append(creds, &types.Credential{ // 0\n\t\tUser:   users[0].Id,\n\t\tMethod: \"email\",\n\t\tValue:  \"alice@test.example.com\",\n\t\tDone:   true,\n\t})\n\tcreds = append(creds, &types.Credential{ // 1\n\t\tUser:   users[1].Id,\n\t\tMethod: \"email\",\n\t\tValue:  \"bob@test.example.com\",\n\t\tDone:   true,\n\t})\n\tcreds = append(creds, &types.Credential{ // 2\n\t\tUser:   users[1].Id,\n\t\tMethod: \"email\",\n\t\tValue:  \"bob@test.example.com\",\n\t})\n\tcreds = append(creds, &types.Credential{ // 3\n\t\tUser:   users[2].Id,\n\t\tMethod: \"tel\",\n\t\tValue:  \"+998991112233\",\n\t})\n\tcreds = append(creds, &types.Credential{ // 4\n\t\tUser:   users[2].Id,\n\t\tMethod: \"tel\",\n\t\tValue:  \"+998993332211\",\n\t\tDone:   true,\n\t})\n\tcreds = append(creds, &types.Credential{ // 5\n\t\tUser:   users[2].Id,\n\t\tMethod: \"email\",\n\t\tValue:  \"asdf@example.com\",\n\t})\n\tfor _, cred := range creds {\n\t\tcred.InitTimes()\n\t}\n\tcreds[3].CreatedAt = now.Add(-10 * time.Minute)\n\tcreds[3].UpdatedAt = now.Add(-10 * time.Minute)\n\treturn creds\n}\n\nfunc initAuthRecords(now time.Time, users []*types.User) []common.AuthRecord {\n\trecs := make([]common.AuthRecord, 0, 2)\n\trecs = append(recs, common.AuthRecord{\n\t\tUnique:  \"basic:alice\",\n\t\tUserId:  users[0].Id,\n\t\tScheme:  \"basic\",\n\t\tAuthLvl: auth.LevelAuth,\n\t\tSecret:  []byte{'a', 'l', 'i', 'c', 'e'},\n\t\tExpires: now.Add(24 * time.Hour),\n\t})\n\trecs = append(recs, common.AuthRecord{\n\t\tUnique:  \"basic:bob\",\n\t\tUserId:  users[1].Id,\n\t\tScheme:  \"basic\",\n\t\tAuthLvl: auth.LevelAuth,\n\t\tSecret:  []byte{'b', 'o', 'b'},\n\t\tExpires: now.Add(24 * time.Hour),\n\t})\n\treturn recs\n}\n\nfunc initTopics(now time.Time, users []*types.User) []*types.Topic {\n\ttopics := make([]*types.Topic, 0, 5)\n\ttopics = append(topics, &types.Topic{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tId:        \"grpgRXf0rU4uR4\",\n\t\t\tCreatedAt: now.Add(10 * time.Minute),\n\t\t\tUpdatedAt: now,\n\t\t},\n\t\tTouchedAt: now,\n\t\tOwner:     users[0].Id,\n\t\tSeqId:     111,\n\t\tTags:      []string{\"travel\", \"zxcv\"},\n\t\tSubCnt:    2,\n\t})\n\ttopics = append(topics, &types.Topic{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tId:        \"p2p9AVDamaNCRbfKzGSh3mE0w\",\n\t\t\tCreatedAt: now,\n\t\t\tUpdatedAt: now,\n\t\t},\n\t\tTouchedAt: now,\n\t\tSeqId:     12,\n\t})\n\ttopics = append(topics, &types.Topic{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tId:        \"p2pxQLrX3WPS2rfKzGSh3mE0w\",\n\t\t\tCreatedAt: now,\n\t\t\tUpdatedAt: now,\n\t\t},\n\t\tTouchedAt: now,\n\t\tSeqId:     15,\n\t})\n\ttopics = append(topics, &types.Topic{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tId:        \"p2pE1iE7I9JN5ESv44HiLbj1A\",\n\t\t\tCreatedAt: now,\n\t\t\tUpdatedAt: now,\n\t\t},\n\t\tTouchedAt: now,\n\t\tSeqId:     555,\n\t})\n\ttopics = append(topics, &types.Topic{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tId:        \"p2pQvr1xwKU01LfKzGSh3mE0w\",\n\t\t\tCreatedAt: now,\n\t\t\tUpdatedAt: now,\n\t\t},\n\t\tTouchedAt: now,\n\t\tSeqId:     333,\n\t})\n\treturn topics\n}\n\nfunc initSubs(now time.Time, users []*types.User, topics []*types.Topic) []*types.Subscription {\n\tsubs := make([]*types.Subscription, 0, 6)\n\tsubs = append(subs, &types.Subscription{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tCreatedAt: now,\n\t\t\tUpdatedAt: now.Add(10 * time.Minute),\n\t\t},\n\t\tUser:      users[0].Id,\n\t\tTopic:     topics[0].Id,\n\t\tRecvSeqId: 5,\n\t\tReadSeqId: 1,\n\t\tModeWant:  255,\n\t\tModeGiven: 255,\n\t})\n\tsubs = append(subs, &types.Subscription{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tCreatedAt: now,\n\t\t\tUpdatedAt: now.Add(15 * time.Minute),\n\t\t},\n\t\tUser:      users[1].Id,\n\t\tTopic:     topics[0].Id,\n\t\tRecvSeqId: 6,\n\t\tReadSeqId: 3,\n\t\tModeWant:  47,\n\t\tModeGiven: 47,\n\t})\n\tsubs = append(subs, &types.Subscription{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tCreatedAt: now.Add(-10 * time.Hour),\n\t\t\tUpdatedAt: now.Add(-10 * time.Hour),\n\t\t},\n\t\tUser:      users[0].Id,\n\t\tTopic:     topics[1].Id,\n\t\tRecvSeqId: 9,\n\t\tReadSeqId: 5,\n\t\tModeWant:  47,\n\t\tModeGiven: 47,\n\t})\n\tsubs = append(subs, &types.Subscription{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tCreatedAt: now,\n\t\t\tUpdatedAt: now.Add(20 * time.Minute),\n\t\t},\n\t\tUser:      users[1].Id,\n\t\tTopic:     topics[1].Id,\n\t\tRecvSeqId: 9,\n\t\tReadSeqId: 5,\n\t\tModeWant:  47,\n\t\tModeGiven: 47,\n\t})\n\tsubs = append(subs, &types.Subscription{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tCreatedAt: now,\n\t\t\tUpdatedAt: now.Add(30 * time.Minute),\n\t\t},\n\t\tUser:      users[2].Id,\n\t\tTopic:     topics[2].Id,\n\t\tRecvSeqId: 0,\n\t\tReadSeqId: 0,\n\t\tModeWant:  47,\n\t\tModeGiven: 47,\n\t})\n\tsubs = append(subs, &types.Subscription{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tCreatedAt: now,\n\t\t\tUpdatedAt: now.Add(40 * time.Minute),\n\t\t},\n\t\tUser:      users[2].Id,\n\t\tTopic:     topics[3].Id,\n\t\tRecvSeqId: 555,\n\t\tReadSeqId: 455,\n\t\tModeWant:  47,\n\t\tModeGiven: 47,\n\t})\n\tfor _, sub := range subs {\n\t\tsub.SetTouchedAt(now)\n\t}\n\treturn subs\n}\n\nfunc initMessages(users []*types.User, topics []*types.Topic) []*types.Message {\n\tmsgs := make([]*types.Message, 0, 6)\n\tmsgs = append(msgs, &types.Message{\n\t\tSeqId:   1,\n\t\tTopic:   topics[0].Id,\n\t\tFrom:    users[0].Id,\n\t\tContent: \"msg1\",\n\t})\n\tmsgs = append(msgs, &types.Message{\n\t\tSeqId:   2,\n\t\tTopic:   topics[0].Id,\n\t\tFrom:    users[2].Id,\n\t\tContent: \"msg2\",\n\t\tDeletedFor: []types.SoftDelete{{\n\t\t\tUser:  users[0].Id,\n\t\t\tDelId: 1}},\n\t})\n\tmsgs = append(msgs, &types.Message{\n\t\tSeqId:   3,\n\t\tTopic:   topics[0].Id,\n\t\tFrom:    users[0].Id,\n\t\tContent: \"msg31\",\n\t})\n\tmsgs = append(msgs, &types.Message{\n\t\tSeqId:   1,\n\t\tTopic:   topics[1].Id,\n\t\tFrom:    users[1].Id,\n\t\tContent: \"msg1\",\n\t})\n\tmsgs = append(msgs, &types.Message{\n\t\tSeqId:   5,\n\t\tTopic:   topics[1].Id,\n\t\tFrom:    users[1].Id,\n\t\tContent: \"msg2\",\n\t})\n\tmsgs = append(msgs, &types.Message{\n\t\tSeqId:   11,\n\t\tTopic:   topics[1].Id,\n\t\tFrom:    users[0].Id,\n\t\tContent: \"msg3\",\n\t})\n\n\tfor i, msg := range msgs {\n\t\tmsg.InitTimes()\n\t\tmsg.SetUid(types.Uid(i + 1))\n\t}\n\treturn msgs\n}\n\nfunc initDevices(now time.Time) []*types.DeviceDef {\n\tdevs := make([]*types.DeviceDef, 0, 2)\n\tdevs = append(devs, &types.DeviceDef{\n\t\tDeviceId: \"2934ujfoviwj09ntf094\",\n\t\tPlatform: \"Android\",\n\t\tLastSeen: now,\n\t\tLang:     \"en_EN\",\n\t})\n\tdevs = append(devs, &types.DeviceDef{\n\t\tDeviceId: \"pogpjb023b09gfdmp\",\n\t\tPlatform: \"iOS\",\n\t\tLastSeen: now,\n\t\tLang:     \"en_EN\",\n\t})\n\treturn devs\n}\n\nfunc initFileDefs(now time.Time, users []*types.User) []*types.FileDef {\n\tfiles := make([]*types.FileDef, 0, 2)\n\tfiles = append(files, &types.FileDef{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tCreatedAt: now,\n\t\t\tUpdatedAt: now,\n\t\t},\n\t\tStatus:   types.UploadStarted,\n\t\tUser:     users[0].Id,\n\t\tMimeType: \"application/pdf\",\n\t\tLocation: \"uploads/qwerty.pdf\",\n\t\tSize:     123456,\n\t})\n\tfiles = append(files, &types.FileDef{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tCreatedAt: now.Add(60 * time.Minute),\n\t\t\tUpdatedAt: now.Add(60 * time.Minute),\n\t\t},\n\t\tStatus:   types.UploadStarted,\n\t\tUser:     users[0].Id,\n\t\tLocation: \"uploads/asdf.txt\",\n\t\tSize:     654321,\n\t})\n\tfiles[0].SetUid(types.Uid(1001))\n\tfiles[1].SetUid(types.Uid(1002))\n\treturn files\n}\n\nfunc initTags() [][]string {\n\t// Tags must be lowercase and non-repeating.\n\taddTags := []string{\"tag1\", \"alice\"}\n\tremoveTags := []string{\"alice\", \"tag1\", \"tag2\"}\n\tresetTags := []string{\"alice\", \"tag111\", \"tag333\"}\n\treturn [][]string{addTags, removeTags, resetTags}\n}\n\nfunc InitTestData() *TestData {\n\t// Use fixed timestamp to make tests more predictable\n\tvar now = time.Date(2021, time.June, 12, 11, 39, 24, 15, time.Local).UTC().Round(time.Millisecond)\n\tvar uGen = &types.UidGenerator{}\n\tif err := uGen.Init(11, []byte(\"testtesttesttest\")); err != nil {\n\t\treturn nil\n\t}\n\tvar users = initUsers(now)\n\tvar topics = initTopics(now, users)\n\treturn &TestData{\n\t\tUGen:   uGen,\n\t\tUsers:  users,\n\t\tCreds:  initCreds(now, users),\n\t\tRecs:   initAuthRecords(now, users),\n\t\tTopics: topics,\n\t\tSubs:   initSubs(now, users, topics),\n\t\tMsgs:   initMessages(users, topics),\n\t\tDevs:   initDevices(now),\n\t\tFiles:  initFileDefs(now, users),\n\t\tTags:   initTags(),\n\t\tNow:    now,\n\t}\n}\n"
  },
  {
    "path": "server/db/mongodb/adapter.go",
    "content": "//go:build mongodb\n\n// Package mongodb is a database adapter for MongoDB.\npackage mongodb\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"slices\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/auth\"\n\t\"github.com/tinode/chat/server/db/common\"\n\t\"github.com/tinode/chat/server/store\"\n\tt \"github.com/tinode/chat/server/store/types\"\n\tb \"go.mongodb.org/mongo-driver/bson\"\n\t\"go.mongodb.org/mongo-driver/bson/primitive\"\n\tmdb \"go.mongodb.org/mongo-driver/mongo\"\n\tmdbopts \"go.mongodb.org/mongo-driver/mongo/options\"\n)\n\n// adapter holds MongoDB connection data.\ntype adapter struct {\n\tconn   *mdb.Client\n\tdb     *mdb.Database\n\tdbName string\n\t// Maximum number of records to return\n\tmaxResults int\n\t// Maximum number of message records to return\n\tmaxMessageResults int\n\tversion           int\n\tctx               context.Context\n\tuseTransactions   bool\n}\n\nconst (\n\tadpVersion  = 116\n\tadapterName = \"mongodb\"\n\n\tdefaultHost     = \"localhost:27017\"\n\tdefaultDatabase = \"tinode\"\n\n\tdefaultMaxResults = 1024\n\t// This is capped by the Session's send queue limit (128).\n\tdefaultMaxMessageResults = 100\n\n\tdefaultAuthMechanism = \"SCRAM-SHA-256\"\n\tdefaultAuthSource    = \"admin\"\n)\n\n// See https://godoc.org/go.mongodb.org/mongo-driver/mongo/options#ClientOptions for explanations.\ntype configType struct {\n\t// Connection string URI https://www.mongodb.com/docs/manual/reference/connection-string/\n\tUri            string `json:\"uri,omitempty\"`\n\tAddresses      any    `json:\"addresses,omitempty\"`\n\tConnectTimeout int    `json:\"timeout,omitempty\"`\n\n\t// Options separately from ClientOptions (custom options):\n\tDatabase   string `json:\"database,omitempty\"`\n\tReplicaSet string `json:\"replica_set,omitempty\"`\n\n\tAuthMechanism string `json:\"auth_mechanism,omitempty\"`\n\tAuthSource    string `json:\"auth_source,omitempty\"`\n\tUsername      string `json:\"username,omitempty\"`\n\tPassword      string `json:\"password,omitempty\"`\n\n\tUseTLS             bool   `json:\"tls,omitempty\"`\n\tTlsCertFile        string `json:\"tls_cert_file,omitempty\"`\n\tTlsPrivateKey      string `json:\"tls_private_key,omitempty\"`\n\tInsecureSkipVerify bool   `json:\"tls_skip_verify,omitempty\"`\n\n\t// The only version supported at this time is \"1\".\n\tAPIVersion mdbopts.ServerAPIVersion `json:\"api_version,omitempty\"`\n}\n\nfunc (a *adapter) maybeStartTransaction(sess mdb.Session) error {\n\tif a.useTransactions {\n\t\treturn sess.StartTransaction()\n\t}\n\treturn nil\n}\n\nfunc (a *adapter) maybeCommitTransaction(ctx context.Context, sess mdb.Session) error {\n\tif a.useTransactions {\n\t\treturn sess.CommitTransaction(ctx)\n\t}\n\treturn nil\n}\n\n// Open initializes mongodb session\nfunc (a *adapter) Open(jsonconfig json.RawMessage) error {\n\tif a.conn != nil {\n\t\treturn errors.New(\"adapter mongodb is already connected\")\n\t}\n\n\tif len(jsonconfig) < 2 {\n\t\treturn errors.New(\"adapter mongodb missing config\")\n\t}\n\n\tvar err error\n\tvar config configType\n\tif err = json.Unmarshal(jsonconfig, &config); err != nil {\n\t\treturn errors.New(\"adapter mongodb failed to parse config: \" + err.Error())\n\t}\n\n\tvar opts mdbopts.ClientOptions\n\n\tif config.Addresses == nil {\n\t\topts.SetHosts([]string{defaultHost})\n\t} else if host, ok := config.Addresses.(string); ok {\n\t\topts.SetHosts([]string{host})\n\t} else if ihosts, ok := config.Addresses.([]any); ok && len(ihosts) > 0 {\n\t\thosts := make([]string, len(ihosts))\n\t\tfor i, ih := range ihosts {\n\t\t\th, ok := ih.(string)\n\t\t\tif !ok || h == \"\" {\n\t\t\t\treturn errors.New(\"adapter mongodb invalid config.Addresses value\")\n\t\t\t}\n\t\t\thosts[i] = h\n\t\t}\n\t\topts.SetHosts(hosts)\n\t} else {\n\t\treturn errors.New(\"adapter mongodb failed to parse config.Addresses\")\n\t}\n\n\tif config.Database == \"\" {\n\t\ta.dbName = defaultDatabase\n\t} else {\n\t\ta.dbName = config.Database\n\t}\n\n\tif config.ReplicaSet != \"\" {\n\t\topts.SetReplicaSet(config.ReplicaSet)\n\t\ta.useTransactions = true\n\t} else {\n\t\t// Retriable writes are not supported in a standalone instance.\n\t\topts.SetRetryWrites(false)\n\t}\n\n\tif config.Username != \"\" {\n\t\tif config.AuthMechanism == \"\" {\n\t\t\tconfig.AuthMechanism = defaultAuthMechanism\n\t\t}\n\t\tif config.AuthSource == \"\" {\n\t\t\tconfig.AuthSource = defaultAuthSource\n\t\t}\n\t\tvar passwordSet bool\n\t\tif config.Password != \"\" {\n\t\t\tpasswordSet = true\n\t\t}\n\t\topts.SetAuth(\n\t\t\tmdbopts.Credential{\n\t\t\t\tAuthMechanism: config.AuthMechanism,\n\t\t\t\tAuthSource:    config.AuthSource,\n\t\t\t\tUsername:      config.Username,\n\t\t\t\tPassword:      config.Password,\n\t\t\t\tPasswordSet:   passwordSet,\n\t\t\t})\n\t}\n\n\tif config.UseTLS {\n\t\ttlsConfig := tls.Config{\n\t\t\tInsecureSkipVerify: config.InsecureSkipVerify,\n\t\t}\n\n\t\tif config.TlsCertFile != \"\" {\n\t\t\tcert, err := tls.LoadX509KeyPair(config.TlsCertFile, config.TlsPrivateKey)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\ttlsConfig.Certificates = append(tlsConfig.Certificates, cert)\n\t\t}\n\n\t\topts.SetTLSConfig(&tlsConfig)\n\t}\n\n\tif a.maxResults <= 0 {\n\t\ta.maxResults = defaultMaxResults\n\t}\n\n\tif a.maxMessageResults <= 0 {\n\t\ta.maxMessageResults = defaultMaxMessageResults\n\t}\n\n\t// Connection string URI overrides any other options configured earlier.\n\tif config.Uri != \"\" {\n\t\topts.ApplyURI(config.Uri)\n\t}\n\n\tif config.APIVersion != \"\" {\n\t\topts.SetServerAPIOptions(mdbopts.ServerAPI(config.APIVersion))\n\t}\n\n\t// Make sure the options are sane.\n\tif err = opts.Validate(); err != nil {\n\t\treturn err\n\t}\n\n\ta.ctx = context.Background()\n\ta.conn, err = mdb.Connect(a.ctx, &opts)\n\ta.db = a.conn.Database(a.dbName)\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.version = -1\n\n\treturn nil\n}\n\n// Close the adapter\nfunc (a *adapter) Close() error {\n\tvar err error\n\tif a.conn != nil {\n\t\terr = a.conn.Disconnect(a.ctx)\n\t\ta.conn = nil\n\t\ta.version = -1\n\t}\n\treturn err\n}\n\n// IsOpen checks if the adapter is ready for use\nfunc (a *adapter) IsOpen() bool {\n\treturn a.conn != nil\n}\n\n// GetDbVersion returns current database version.\nfunc (a *adapter) GetDbVersion() (int, error) {\n\tif a.version > 0 {\n\t\treturn a.version, nil\n\t}\n\n\tvar result struct {\n\t\tKey   string `bson:\"_id\"`\n\t\tValue int\n\t}\n\tif err := a.db.Collection(\"kvmeta\").FindOne(a.ctx, b.M{\"_id\": \"version\"}).Decode(&result); err != nil {\n\t\tif err == mdb.ErrNoDocuments {\n\t\t\terr = errors.New(\"Database not initialized\")\n\t\t}\n\t\treturn -1, err\n\t}\n\n\ta.version = result.Value\n\treturn result.Value, nil\n}\n\nfunc (a *adapter) updateDbVersion(v int) error {\n\ta.version = -1\n\t_, err := a.db.Collection(\"kvmeta\").UpdateOne(a.ctx,\n\t\tb.M{\"_id\": \"version\"},\n\t\tb.M{\"$set\": b.M{\"value\": v}},\n\t)\n\treturn err\n}\n\n// CheckDbVersion checks if the actual database version matches adapter version.\nfunc (a *adapter) CheckDbVersion() error {\n\tversion, err := a.GetDbVersion()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif version != adpVersion {\n\t\treturn errors.New(\"Invalid database version \" + strconv.Itoa(version) +\n\t\t\t\". Expected \" + strconv.Itoa(adpVersion))\n\t}\n\n\treturn nil\n}\n\n// Version returns adapter version\nfunc (a *adapter) Version() int {\n\treturn adpVersion\n}\n\n// DB connection stats object.\nfunc (a *adapter) Stats() any {\n\tif a.db == nil {\n\t\treturn nil\n\t}\n\n\tvar result b.M\n\tif err := a.db.RunCommand(a.ctx, b.D{{\"serverStatus\", 1}}, nil).Decode(&result); err != nil {\n\t\treturn nil\n\t}\n\n\treturn result[\"connections\"]\n}\n\n// GetName returns the name of the adapter\nfunc (a *adapter) GetName() string {\n\treturn adapterName\n}\n\n// SetMaxResults configures how many results can be returned in a single DB call.\nfunc (a *adapter) SetMaxResults(val int) error {\n\tif val <= 0 {\n\t\ta.maxResults = defaultMaxResults\n\t} else {\n\t\ta.maxResults = val\n\t}\n\n\treturn nil\n}\n\n// CreateDb creates the database optionally dropping an existing database first.\nfunc (a *adapter) CreateDb(reset bool) error {\n\tif reset {\n\t\tif err := a.db.Drop(a.ctx); err != nil {\n\t\t\treturn err\n\t\t}\n\t} else if a.isDbInitialized() {\n\t\treturn errors.New(\"Database already initialized\")\n\t}\n\t// Collections (tables) do not need to be explicitly created since MongoDB creates them with first write operation\n\n\tindexes := []struct {\n\t\tCollection string\n\t\tField      string\n\t\tIndexOpts  mdb.IndexModel\n\t}{\n\t\t// Users\n\t\t// Index on 'user.state' for finding suspended and soft-deleted users.\n\t\t{\n\t\t\tCollection: \"users\",\n\t\t\tField:      \"state\",\n\t\t},\n\t\t// Index on 'user.tags' array so user can be found by tags.\n\t\t{\n\t\t\tCollection: \"users\",\n\t\t\tField:      \"tags\",\n\t\t},\n\t\t// Index for 'user.devices.deviceid' to ensure Device ID uniqueness across users.\n\t\t// Partial filter set to avoid unique constraint for null values (when user object have no devices).\n\t\t{\n\t\t\tCollection: \"users\",\n\t\t\tIndexOpts: mdb.IndexModel{\n\t\t\t\tKeys: b.M{\"devices.deviceid\": 1},\n\t\t\t\tOptions: mdbopts.Index().\n\t\t\t\t\tSetUnique(true).\n\t\t\t\t\tSetPartialFilterExpression(b.M{\"devices.deviceid\": b.M{\"$exists\": true}}),\n\t\t\t},\n\t\t},\n\t\t// Index on lastSeen and updatedat for deleting stale user accounts.\n\t\t{\n\t\t\tCollection: \"users\",\n\t\t\tIndexOpts:  mdb.IndexModel{Keys: b.D{{\"lastseen\", 1}, {\"updatedat\", 1}}},\n\t\t},\n\n\t\t// User authentication records {_id, userid, secret}\n\t\t// Should be able to access user's auth records by user id\n\t\t{\n\t\t\tCollection: \"auth\",\n\t\t\tField:      \"userid\",\n\t\t},\n\n\t\t// Subscription to a topic. The primary key is a topic:user string\n\t\t{\n\t\t\tCollection: \"subscriptions\",\n\t\t\tField:      \"user\",\n\t\t},\n\t\t{\n\t\t\tCollection: \"subscriptions\",\n\t\t\tField:      \"topic\",\n\t\t},\n\n\t\t// Topics stored in database\n\t\t// Index on 'owner' field for deleting users.\n\t\t{\n\t\t\tCollection: \"topics\",\n\t\t\tField:      \"owner\",\n\t\t},\n\t\t// Index on 'state' for finding suspended and soft-deleted topics.\n\t\t{\n\t\t\tCollection: \"topics\",\n\t\t\tField:      \"state\",\n\t\t},\n\t\t// Index on 'topic.tags' array so topics can be found by tags.\n\t\t// These tags are not unique as opposite to 'user.tags'.\n\t\t{\n\t\t\tCollection: \"topics\",\n\t\t\tField:      \"tags\",\n\t\t},\n\n\t\t// Stored message\n\t\t// Compound index of 'topic - seqid' for selecting messages in a topic.\n\t\t{\n\t\t\tCollection: \"messages\",\n\t\t\tIndexOpts:  mdb.IndexModel{Keys: b.D{{\"topic\", 1}, {\"seqid\", 1}}},\n\t\t},\n\t\t// Compound index of hard-deleted messages\n\t\t{\n\t\t\tCollection: \"messages\",\n\t\t\tIndexOpts:  mdb.IndexModel{Keys: b.D{{\"topic\", 1}, {\"delid\", 1}}},\n\t\t},\n\t\t// Compound multi-index of soft-deleted messages: each message gets multiple compound index entries like\n\t\t// \t\t [topic, user1, delid1], [topic, user2, delid2],...\n\t\t{\n\t\t\tCollection: \"messages\",\n\t\t\tIndexOpts:  mdb.IndexModel{Keys: b.D{{\"topic\", 1}, {\"deletedfor.user\", 1}, {\"deletedfor.delid\", 1}}},\n\t\t},\n\n\t\t// Log of deleted messages\n\t\t// Compound index of 'topic - delid'\n\t\t{\n\t\t\tCollection: \"dellog\",\n\t\t\tIndexOpts:  mdb.IndexModel{Keys: b.D{{\"topic\", 1}, {\"delid\", 1}}},\n\t\t},\n\n\t\t// User credentials - contact information such as \"email:jdoe@example.com\" or \"tel:+18003287448\":\n\t\t// Id: \"method:credential\" like \"email:jdoe@example.com\". See types.Credential.\n\t\t// Index on 'credentials.user' to be able to query credentials by user id.\n\t\t{\n\t\t\tCollection: \"credentials\",\n\t\t\tField:      \"user\",\n\t\t},\n\n\t\t// Records of file uploads. See types.FileDef.\n\t\t// Index on 'fileuploads.usecount' to be able to delete unused records at once.\n\t\t{\n\t\t\tCollection: \"fileuploads\",\n\t\t\tField:      \"usecount\",\n\t\t},\n\t}\n\n\tvar err error\n\tfor _, idx := range indexes {\n\t\tif idx.Field != \"\" {\n\t\t\t_, err = a.db.Collection(idx.Collection).Indexes().CreateOne(a.ctx, mdb.IndexModel{Keys: b.M{idx.Field: 1}})\n\t\t} else {\n\t\t\t_, err = a.db.Collection(idx.Collection).Indexes().CreateOne(a.ctx, idx.IndexOpts)\n\t\t}\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Collection \"kvmeta\" with metadata key-value pairs.\n\t// Key in \"_id\" field.\n\t// Record current DB version.\n\tif _, err := a.db.Collection(\"kvmeta\").InsertOne(a.ctx, map[string]any{\"_id\": \"version\", \"value\": adpVersion}); err != nil {\n\t\treturn err\n\t}\n\n\t// Create system topic 'sys'.\n\treturn createSystemTopic(a)\n}\n\n// UpgradeDb upgrades database to the current adapter version.\nfunc (a *adapter) UpgradeDb() error {\n\tbumpVersion := func(a *adapter, x int) error {\n\t\tif err := a.updateDbVersion(x); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err := a.GetDbVersion()\n\t\treturn err\n\t}\n\n\t_, err := a.GetDbVersion()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif a.version == 110 {\n\t\t// Perform database upgrade from versions 110 to version 111.\n\n\t\t// Users\n\n\t\t// Reset previously unused field State to value StateOK.\n\t\tif _, err := a.db.Collection(\"users\").UpdateMany(a.ctx,\n\t\t\tb.M{},\n\t\t\tb.M{\"$set\": b.M{\"state\": t.StateOK}}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Add StatusDeleted to all deleted users as indicated by DeletedAt not being null.\n\t\tif _, err := a.db.Collection(\"users\").UpdateMany(a.ctx,\n\t\t\tb.M{\"deletedat\": b.M{\"$ne\": nil}},\n\t\t\tb.M{\"$set\": b.M{\"state\": t.StateDeleted}}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Rename DeletedAt into StateAt. Update only those rows which have defined DeletedAt.\n\t\tif _, err := a.db.Collection(\"users\").UpdateMany(a.ctx,\n\t\t\tb.M{\"deletedat\": b.M{\"$exists\": true}},\n\t\t\tb.M{\"$rename\": b.M{\"deletedat\": \"stateat\"}}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Drop secondary index DeletedAt.\n\t\tif _, err := a.db.Collection(\"users\").Indexes().DropOne(a.ctx, \"deletedat_1\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Create secondary index on State for finding suspended and soft-deleted topics.\n\t\tif _, err = a.db.Collection(\"users\").Indexes().CreateOne(a.ctx, mdb.IndexModel{Keys: b.M{\"state\": 1}}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Topics\n\n\t\t// Add StateDeleted to all topics with DeletedAt not null.\n\t\tif _, err := a.db.Collection(\"topics\").UpdateMany(a.ctx,\n\t\t\tb.M{\"deletedat\": b.M{\"$ne\": nil}},\n\t\t\tb.M{\"$set\": b.M{\"state\": t.StateDeleted}}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Set StateOK for all other topics.\n\t\tif _, err := a.db.Collection(\"topics\").UpdateMany(a.ctx,\n\t\t\tb.M{\"state\": b.M{\"$exists\": false}},\n\t\t\tb.M{\"$set\": b.M{\"state\": t.StateOK}}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Rename DeletedAt into StateAt. Update only those rows which have defined DeletedAt.\n\t\tif _, err := a.db.Collection(\"topics\").UpdateMany(a.ctx,\n\t\t\tb.M{\"deletedat\": b.M{\"$exists\": true}},\n\t\t\tb.M{\"$rename\": b.M{\"deletedat\": \"stateat\"}}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Create secondary index on State for finding suspended and soft-deleted topics.\n\t\tif _, err = a.db.Collection(\"topics\").Indexes().CreateOne(a.ctx, mdb.IndexModel{Keys: b.M{\"state\": 1}}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := bumpVersion(a, 111); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version == 111 {\n\t\t// Just bump the version to keep in line with MySQL.\n\t\tif err := bumpVersion(a, 112); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version == 112 {\n\t\t// Create secondary index on Users(lastseen,updatedat) for deleting stale user accounts.\n\t\tif _, err = a.db.Collection(\"users\").Indexes().CreateOne(a.ctx,\n\t\t\tmdb.IndexModel{Keys: b.D{{\"lastseen\", 1}, {\"updatedat\", 1}}}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := bumpVersion(a, 113); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version < 116 {\n\t\t// Version 114: topics.aux added, fileuploads.etag added.\n\t\t// Version 115: SQL indexes added.\n\t\t// Version 116: topics.subcnt added.\n\t\tif err := bumpVersion(a, 116); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version != adpVersion {\n\t\treturn errors.New(\"Failed to perform database upgrade to version \" + strconv.Itoa(adpVersion) +\n\t\t\t\". DB is still at \" + strconv.Itoa(a.version))\n\t}\n\treturn nil\n}\n\n// Create system topic 'sys'.\nfunc createSystemTopic(a *adapter) error {\n\tnow := t.TimeNow()\n\t_, err := a.db.Collection(\"topics\").InsertOne(a.ctx, &t.Topic{\n\t\tObjHeader: t.ObjHeader{\n\t\t\tId:        \"sys\",\n\t\t\tCreatedAt: now,\n\t\t\tUpdatedAt: now},\n\t\tTouchedAt: now,\n\t\tAccess:    t.DefaultAccess{Auth: t.ModeNone, Anon: t.ModeNone},\n\t\tPublic:    map[string]any{\"fn\": \"System\"},\n\t})\n\treturn err\n}\n\n// User management\n\n// UserCreate creates user record\nfunc (a *adapter) UserCreate(usr *t.User) error {\n\tif _, err := a.db.Collection(\"users\").InsertOne(a.ctx, &usr); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// UserGet fetches a single user by user id. If user is not found it returns (nil, nil)\nfunc (a *adapter) UserGet(id t.Uid) (*t.User, error) {\n\tvar user t.User\n\n\tfilter := b.M{\"_id\": id.String(), \"state\": b.M{\"$ne\": t.StateDeleted}}\n\tif err := a.db.Collection(\"users\").FindOne(a.ctx, filter).Decode(&user); err != nil {\n\t\tif err == mdb.ErrNoDocuments { // User not found\n\t\t\treturn nil, nil\n\t\t} else {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tuser.Public = unmarshalBsonD(user.Public)\n\tuser.Trusted = unmarshalBsonD(user.Trusted)\n\treturn &user, nil\n}\n\n// UserGetAll returns user records for a given list of user IDs\nfunc (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) {\n\tuids := make([]any, len(ids))\n\tfor i, id := range ids {\n\t\tuids[i] = id.String()\n\t}\n\n\tvar users []t.User\n\tfilter := b.M{\"_id\": b.M{\"$in\": uids}, \"state\": b.M{\"$ne\": t.StateDeleted}}\n\tcur, err := a.db.Collection(\"users\").Find(a.ctx, filter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cur.Close(a.ctx)\n\n\tfor cur.Next(a.ctx) {\n\t\tvar user t.User\n\t\tif err := cur.Decode(&user); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tuser.Public = unmarshalBsonD(user.Public)\n\t\tuser.Trusted = unmarshalBsonD(user.Trusted)\n\n\t\tusers = append(users, user)\n\t}\n\n\treturn users, nil\n}\n\n// UserDelete deletes specified user: wipes completely (hard-delete) or marks as deleted.\nfunc (a *adapter) UserDelete(uid t.Uid, hard bool) error {\n\townFilter := b.M{\"owner\": uid.String()}\n\t// In case of hard delete, delete all topics, even those which were\n\t// soft-deleted previsously.\n\tif !hard {\n\t\townFilter[\"state\"] = b.M{\"$ne\": t.StateDeleted}\n\t}\n\n\tforUser := uid.String()\n\t// Select topics where the user is the owner.\n\townTopics, err := a.topicNamesForUser(\"topics\", ownFilter, \"_id\", true)\n\tif err != nil {\n\t\treturn err\n\t}\n\townTopicsFilter := b.M{\"topic\": b.M{\"$in\": ownTopics}}\n\n\tvar sess mdb.Session\n\tif sess, err = a.conn.StartSession(); err != nil {\n\t\treturn err\n\t}\n\tdefer sess.EndSession(a.ctx)\n\n\tif err = a.maybeStartTransaction(sess); err != nil {\n\t\treturn err\n\t}\n\n\tif err = mdb.WithSession(a.ctx, sess, func(sc mdb.SessionContext) error {\n\n\t\tif hard {\n\t\t\t// No need to delete user's devices: devices are stored in user's record and will be deleted with it.\n\n\t\t\t// Delete user's subscriptions in all topics and decrement subcnt in topic.\n\t\t\tif err = a.subsDelete(sc, b.M{\"user\": forUser}, true); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// Delete user's dellog entries in all topics.\n\t\t\terr = a.clearUserDellog(sc, forUser)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// Can't delete user's messages in all topics because we cannot notify topics of such deletion.\n\t\t\t// Just leave the messages there marked as sent by \"not found\" user.\n\n\t\t\t// Delete topics where the user is the owner:\n\t\t\tif len(ownTopics) > 0 {\n\n\t\t\t\t// 1. Delete dellog\n\t\t\t\t// 2. Decrement fileuploads.\n\t\t\t\t// 3. Delete all messages.\n\t\t\t\t// 4. Delete subscriptions.\n\n\t\t\t\t// Delete dellog for topics owned by the user.\n\t\t\t\t_, err = a.db.Collection(\"dellog\").DeleteMany(sc, ownTopicsFilter)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\t// Decrement fileuploads UseCounter\n\t\t\t\t// First get array of attachments IDs that were used in messages of topics from topicIds\n\t\t\t\t// Then decrement the usecount field of these file records\n\t\t\t\terr = a.decFileUseCounter(sc, \"messages\", ownTopicsFilter)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\t// Decrement use counter for topic avatars.\n\t\t\t\terr = a.decFileUseCounter(sc, \"topics\", b.M{\"_id\": b.M{\"$in\": ownTopics}})\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\t// Delete messages\n\t\t\t\t_, err = a.db.Collection(\"messages\").DeleteMany(sc, ownTopicsFilter)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\t// Delete subscriptions for all users where the user is the owner of the topic.\n\t\t\t\t_, err = a.db.Collection(\"subscriptions\").DeleteMany(sc, ownTopicsFilter)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\t// No need to delete topic tags: they are stored in topic record and will be deleted with it.\n\n\t\t\t\t// And finally delete the topics.\n\t\t\t\tif _, err = a.db.Collection(\"topics\").DeleteMany(sc, b.M{\"owner\": forUser}); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Delete user's authentication records.\n\t\t\tif _, err = a.authDelAllRecords(sc, uid); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// Delete credentials.\n\t\t\tif err = a.credDel(sc, uid, \"\", \"\"); err != nil && err != t.ErrNotFound {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// Delete avatar (decrement use counter).\n\t\t\tif err = a.decFileUseCounter(sc, \"users\", b.M{\"_id\": forUser}); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// No need to delete user's tags: they are stored in user's record and will be deleted with it.\n\n\t\t\t// And finally delete the user.\n\t\t\tif _, err = a.db.Collection(\"users\").DeleteOne(sc, b.M{\"_id\": forUser}); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t} else {\n\t\t\t// Disable user's subscriptions.\n\t\t\tif err = a.subsDelete(sc, b.M{\"user\": forUser}, false); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tnow := t.TimeNow()\n\t\t\tdisable := b.M{\"$set\": b.M{\"updatedat\": now, \"state\": t.StateDeleted, \"stateat\": now}}\n\n\t\t\tif len(ownTopics) > 0 {\n\t\t\t\t// Disable subscriptions for topics where the user is the owner.\n\t\t\t\tif _, err = a.db.Collection(\"subscriptions\").UpdateMany(sc, ownTopicsFilter, disable); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\t// Disable group topics where the user is the owner.\n\t\t\t\tif _, err = a.db.Collection(\"topics\").UpdateMany(sc, b.M{\"_id\": b.M{\"$in\": ownTopics}},\n\t\t\t\t\tb.M{\"$set\": b.M{\n\t\t\t\t\t\t\"updatedat\": now, \"touchedat\": now, \"state\": t.StateDeleted, \"stateat\": now,\n\t\t\t\t\t}}); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Disable p2p topics with the user.\n\t\t\tp2pTopics, err := a.p2pTopicsForUser(uid)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif len(p2pTopics) > 0 {\n\t\t\t\tif _, err = a.db.Collection(\"topics\").UpdateMany(sc, b.M{\"_id\": b.M{\"$in\": p2pTopics}},\n\t\t\t\t\tb.M{\"$set\": b.M{\n\t\t\t\t\t\t\"updatedat\": now, \"touchedat\": now, \"state\": t.StateDeleted, \"stateat\": now,\n\t\t\t\t\t}}); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\t// Disable subscription to user's disabled p2p topics.\n\t\t\t\tif _, err = a.db.Collection(\"subscriptions\").UpdateMany(sc,\n\t\t\t\t\tb.M{\"topic\": b.M{\"$in\": p2pTopics}}, disable); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Finally disable the user.\n\t\t\tif _, err = a.db.Collection(\"users\").UpdateMany(sc, b.M{\"_id\": forUser}, disable); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t// Finally commit all changes\n\t\treturn a.maybeCommitTransaction(sc, sess)\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn err\n}\n\n// topicStateForUser is called by UserUpdate when the update contains state change.\n// Soft-deleted topics remain soft-deleted.\nfunc (a *adapter) topicStateForUser(uid t.Uid, now time.Time, update any) error {\n\tstate, ok := update.(t.ObjState)\n\tif !ok {\n\t\treturn t.ErrMalformed\n\t}\n\n\tif now.IsZero() {\n\t\tnow = t.TimeNow()\n\t}\n\n\t// Change state of all topics where the user is the owner.\n\tif _, err := a.db.Collection(\"topics\").UpdateMany(a.ctx,\n\t\tb.M{\"owner\": uid.String(), \"state\": b.M{\"$ne\": t.StateDeleted}},\n\t\tb.M{\"$set\": b.M{\"state\": state, \"stateat\": now}}); err != nil {\n\t\treturn err\n\t}\n\n\t// Change state of p2p topics with the user (p2p topic's owner is blank)\n\t// Get list of p2p topics with the user.\n\tp2pTopics, err := a.p2pTopicsForUser(uid)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(p2pTopics) > 0 {\n\t\tif _, err := a.db.Collection(\"topics\").UpdateMany(a.ctx,\n\t\t\tb.M{\"_id\": b.M{\"$in\": p2pTopics}, \"state\": b.M{\"$ne\": t.StateDeleted}},\n\t\t\tb.M{\"$set\": b.M{\"state\": state, \"stateat\": now}}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Subscriptions don't need to be updated:\n\t// subscriptions of a disabled user are not disabled and still can be manipulated.\n\treturn nil\n}\n\n// UserUpdate updates user record\nfunc (a *adapter) UserUpdate(uid t.Uid, update map[string]any) error {\n\t// Convert field names from CamelCase to lowercase.\n\tupdate = normalizeUpdateMap(update)\n\n\t_, err := a.db.Collection(\"users\").UpdateOne(a.ctx, b.M{\"_id\": uid.String()}, b.M{\"$set\": update})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif state, ok := update[\"state\"]; ok {\n\t\tnow, _ := update[\"stateat\"].(time.Time)\n\t\terr = a.topicStateForUser(uid, now, state)\n\t}\n\n\t// Tags are stored in the same record, no need to update them separately.\n\n\treturn err\n}\n\n// UserUpdateTags adds, removes, or resets user's tags.\nfunc (a *adapter) UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]string, error) {\n\tvar newTags t.StringSlice\n\t// Compare to nil vs checking for zero length: zero length reset is valid.\n\tif reset != nil {\n\t\t// Replace tags with the new value\n\t\tnewTags = reset\n\t} else {\n\t\tvar user t.User\n\t\terr := a.db.Collection(\"users\").FindOne(a.ctx, b.M{\"_id\": uid.String()}).Decode(&user)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// Mutate the tag list.\n\t\tnewTags = user.Tags\n\t\tif len(add) > 0 {\n\t\t\tnewTags = union(newTags, add)\n\t\t}\n\t\tif len(remove) > 0 {\n\t\t\tnewTags = diff(newTags, remove)\n\t\t}\n\t}\n\n\treturn newTags, a.UserUpdate(uid, map[string]any{\"tags\": newTags})\n}\n\n// UserGetByCred returns user ID for the given validated credential.\nfunc (a *adapter) UserGetByCred(method, value string) (t.Uid, error) {\n\tvar userId map[string]string\n\terr := a.db.Collection(\"credentials\").FindOne(a.ctx,\n\t\tb.M{\"_id\": method + \":\" + value},\n\t\tmdbopts.FindOne().SetProjection(b.M{\"user\": 1, \"_id\": 0}),\n\t).Decode(&userId)\n\tif err != nil {\n\t\tif err == mdb.ErrNoDocuments {\n\t\t\treturn t.ZeroUid, nil\n\t\t}\n\t\treturn t.ZeroUid, err\n\t}\n\n\treturn t.ParseUid(userId[\"user\"]), nil\n}\n\n// UserUnreadCount returns the total number of unread messages in all topics with\n// the R permission. If read fails, the counts are still returned with the original\n// user IDs but with the unread count undefined and non-nil error.\n// Does not count unread messages in channels although it probably should.\nfunc (a *adapter) UserUnreadCount(ids ...t.Uid) (map[t.Uid]int, error) {\n\tuids := make([]string, len(ids))\n\tcounts := make(map[t.Uid]int, len(ids))\n\tfor i, id := range ids {\n\t\tuids[i] = id.String()\n\t\t// Ensure all original uids are always present.\n\t\tcounts[id] = 0\n\t}\n\t/*\n\t\tQuery:\n\t\t\tdb.subscriptions.aggregate([\n\t\t\t\t{ $match: { user: { $in: [\"KnElfSSA21U\", \"0ZcCQmwI2RI\"] } } },\n\t\t\t\t{ $lookup: { from: \"topics\", localField: \"topic\", foreignField: \"_id\", as: \"fromTopics\"} },\n\t\t\t\t{ $match: { fromTopics: { $not: {$size: 0}  }}},\n\t\t\t\t{ $replaceRoot: { newRoot: { $mergeObjects: [ {$arrayElemAt: [ \"$fromTopics\", 0 ]} , \"$$ROOT\" ] } } },\n\t\t\t\t{ $match: {\n\t\t\t\t\t\tdeletedat: { $exists: false },\n\t\t\t\t\t\tstate:     { $ne: t.StateDeleted },\n\t\t\t\t\t\tmodewant:  { $bitsAllSet: [ t.ModeRead ] },\n\t\t\t\t\t\tmodegiven: { $bitsAllSet: [ t.ModeRead ] }\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t{ $project: { _id: 0, user: 1, readseqid: 1, seqid: 1} },\n\t\t\t\t{ $group: { _id: \"$user\", unreadCount: { $sum: { $subtract: [ \"$seqid\", \"$readseqid\" ] } } } }\n\t\t\t])\n\n\t\tResult:\n\t\t\t{ \"_id\" : \"KnElfSSA21U\", \"unreadCount\" : 0 }\n\t\t\t{ \"_id\" : \"0ZcCQmwI2RI\", \"unreadCount\" : 7 }\n\t*/\n\n\tpipeline := b.A{\n\t\tb.M{\"$match\": b.M{\"user\": b.M{\"$in\": uids}}},\n\t\t// Join documents from two collection.\n\t\t// FIXME: this does not work for channels as localField[topic] is not the same as foreignField[_id].\n\t\tb.M{\"$lookup\": b.M{\n\t\t\t\"from\":         \"topics\",\n\t\t\t\"localField\":   \"topic\",\n\t\t\t\"foreignField\": \"_id\",\n\t\t\t\"as\":           \"fromTopics\"},\n\t\t},\n\t\t// Remove users with no subscriptions.\n\t\tb.M{\"$match\": b.M{\"fromTopics\": b.M{\"$not\": b.M{\"$size\": 0}}}},\n\t\t// Merge two documents into one\n\t\tb.M{\"$replaceRoot\": b.M{\"newRoot\": b.M{\"$mergeObjects\": b.A{b.M{\"$arrayElemAt\": b.A{\"$fromTopics\", 0}}, \"$$ROOT\"}}}},\n\n\t\t// Keep only those records which affect the result.\n\t\tb.M{\"$match\": b.M{\n\t\t\t\"deletedat\": b.M{\"$exists\": false},\n\t\t\t\"state\":     b.M{\"$ne\": t.StateDeleted},\n\t\t\t// Filter by access mode\n\t\t\t\"modewant\":  b.M{\"$bitsAllSet\": b.A{t.ModeRead}},\n\t\t\t\"modegiven\": b.M{\"$bitsAllSet\": b.A{t.ModeRead}}}},\n\n\t\t// Remove unused fields.\n\t\tb.M{\"$project\": b.M{\"_id\": 0, \"user\": 1, \"readseqid\": 1, \"seqid\": 1}},\n\t\t// GROUP BY user.\n\t\tb.M{\"$group\": b.M{\"_id\": \"$user\", \"unreadCount\": b.M{\"$sum\": b.M{\"$subtract\": b.A{\"$seqid\", \"$readseqid\"}}}}},\n\t}\n\tcur, err := a.db.Collection(\"subscriptions\").Aggregate(a.ctx, pipeline)\n\tif err != nil {\n\t\treturn counts, err\n\t}\n\tdefer cur.Close(a.ctx)\n\n\tfor cur.Next(a.ctx) {\n\t\tvar oneCount struct {\n\t\t\tId          string `bson:\"_id\"`\n\t\t\tUnreadCount int    `bson:\"unreadCount\"`\n\t\t}\n\t\tcur.Decode(&oneCount)\n\t\tcounts[t.ParseUid(oneCount.Id)] = oneCount.UnreadCount\n\t}\n\n\treturn counts, nil\n}\n\n// UserGetUnvalidated returns a list of uids which have never logged in, have no\n// validated credentials and haven't been updated since lastUpdatedBefore.\nfunc (a *adapter) UserGetUnvalidated(lastUpdatedBefore time.Time, limit int) ([]t.Uid, error) {\n\t/*\n\t\tQuery:\n\t\t[\n\t\t\t// .. WHERE lastseen IS NULL AND updatedat<?\n\t\t\t{$match: {\n\t\t\t\t$and: [\n\t\t\t\t\t{ lastseen: null },\n\t\t\t\t\t{ updatedat: {$lt: new ISODate(\"2022-12-09T01:26:15.819Z\")} },\n\t\t\t\t],\n\t\t\t}},\n\t\t\t// JOIN credentials ON id=user\n\t\t\t{$lookup: {\n\t\t\t\tfrom: \"credentials\",\n\t\t\t\tlocalField: \"_id\",\n\t\t\t\tforeignField: \"user\",\n\t\t\t\tas: \"fcred\",\n\t\t\t}},\n\t\t\t// {x: 1, y: [{a: 1}, {a: 2}]} -> [{x: 1, a: 1}, {x: 1, a: 2}]\n\t\t  {$unwind: {path: \"$fcred\"}},\n\t\t\t// SELECT _id, CASE WHEN done THEN 1 ELSE 0 END\n\t\t  {$project: {\n\t\t\t\t_id: 1,\n\t\t    completed: { $cond: { if: \"$fcred.done\", then: 1, else: 0 } },\n\t\t  }},\n\t\t\t// GROUP BY _id\n\t\t  {$group: { _id: \"$_id\", completed: { $sum: \"$completed\" } } },\n\t\t\t// HAVING completed=0\n\t\t  {$match: { completed: 0 }},\n\t\t\t// SELECT _id\n\t\t  {$project: { _id: \"$_id\" }},\n\t\t\t{$limit: 10}\n\t\t]\n\t*/\n\tpipeline := b.A{\n\t\tb.M{\"$match\": b.M{\n\t\t\t\"$and\": b.A{\n\t\t\t\tb.M{\"lastseen\": primitive.Null{}},\n\t\t\t\tb.M{\"updatedat\": b.M{\"$lt\": lastUpdatedBefore}},\n\t\t\t},\n\t\t}},\n\t\tb.M{\"$lookup\": b.D{\n\t\t\t{\"from\", \"credentials\"},\n\t\t\t{\"localField\", \"_id\"},\n\t\t\t{\"foreignField\", \"user\"},\n\t\t\t{\"as\", \"fcred\"}},\n\t\t},\n\t\tb.M{\"$unwind\": b.M{\"path\": \"$fcred\"}},\n\t\tb.M{\"$project\": b.D{\n\t\t\t{\"_id\", 1},\n\t\t\t{\"completed\", b.M{\n\t\t\t\t\"$cond\": b.D{{\"if\", \"$fcred.done\"}, {\"then\", 1}, {\"else\", 0}}},\n\t\t\t}}},\n\t\tb.M{\"$group\": b.D{{\"_id\", \"$_id\"}, {\"completed\", b.M{\"$sum\": \"$completed\"}}}},\n\t\tb.M{\"$match\": b.M{\"completed\": 0}},\n\t\tb.M{\"$project\": b.M{\"_id\": \"$_id\"}},\n\t\tb.M{\"$limit\": limit},\n\t}\n\n\tcur, err := a.db.Collection(\"users\").Aggregate(a.ctx, pipeline)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cur.Close(a.ctx)\n\n\tvar uids []t.Uid\n\tfor cur.Next(a.ctx) {\n\t\tvar oneUser struct {\n\t\t\tId string `bson:\"_id\"`\n\t\t}\n\t\tif err := cur.Decode(&oneUser); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tuid := t.ParseUid(oneUser.Id)\n\t\tif uid.IsZero() {\n\t\t\treturn nil, errors.New(\"failed to decode user id\")\n\t\t}\n\t\tuids = append(uids, uid)\n\t}\n\n\treturn uids, err\n}\n\n// Credential management\n\n// CredUpsert adds or updates a validation record. Returns true if inserted, false if updated.\n// 1. if credential is validated:\n// 1.1 Hard-delete unconfirmed equivalent record, if exists.\n// 1.2 Insert new. Report error if duplicate.\n// 2. if credential is not validated:\n// 2.1 Check if validated equivalent exist. If so, report an error.\n// 2.2 Soft-delete all unvalidated records of the same method.\n// 2.3 Undelete existing credential. Return if successful.\n// 2.4 Insert new credential record.\nfunc (a *adapter) CredUpsert(cred *t.Credential) (bool, error) {\n\tcredCollection := a.db.Collection(\"credentials\")\n\n\tcred.Id = cred.Method + \":\" + cred.Value\n\n\tif !cred.Done {\n\t\t// Check if the same credential is already validated.\n\t\tvar result1 t.Credential\n\t\terr := credCollection.FindOne(a.ctx, b.M{\"_id\": cred.Id}).Decode(&result1)\n\t\tif result1 != (t.Credential{}) {\n\t\t\t// Someone has already validated this credential.\n\t\t\treturn false, t.ErrDuplicate\n\t\t}\n\t\tif err != nil && err != mdb.ErrNoDocuments { // if no result -> continue\n\t\t\treturn false, err\n\t\t}\n\n\t\t// Soft-delete all unvalidated records of this user and method.\n\t\t_, err = credCollection.UpdateMany(a.ctx,\n\t\t\tb.M{\"user\": cred.User, \"method\": cred.Method, \"done\": false},\n\t\t\tb.M{\"$set\": b.M{\"deletedat\": t.TimeNow()}})\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\n\t\t// If credential is not confirmed, it should not block others\n\t\t// from attempting to validate it: make index user-unique instead of global-unique.\n\t\tcred.Id = cred.User + \":\" + cred.Id\n\n\t\t// Check if this credential has already been added by the user.\n\t\tvar result2 t.Credential\n\t\terr = credCollection.FindOne(a.ctx, b.M{\"_id\": cred.Id}).Decode(&result2)\n\t\tif result2 != (t.Credential{}) {\n\t\t\t_, err = credCollection.UpdateOne(a.ctx,\n\t\t\t\tb.M{\"_id\": cred.Id},\n\t\t\t\tb.M{\n\t\t\t\t\t\"$unset\": b.M{\"deletedat\": \"\"},\n\t\t\t\t\t\"$set\":   b.M{\"updatedat\": cred.UpdatedAt, \"resp\": cred.Resp}})\n\t\t\tif err != nil {\n\t\t\t\treturn false, err\n\t\t\t}\n\n\t\t\t// The record was updated, all is fine.\n\t\t\treturn false, nil\n\t\t}\n\t\tif err != nil && err != mdb.ErrNoDocuments {\n\t\t\treturn false, err\n\t\t}\n\t} else {\n\t\t// Hard-delete potentially present unvalidated credential.\n\t\t_, err := credCollection.DeleteOne(a.ctx, b.M{\"_id\": cred.User + \":\" + cred.Id})\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\n\t// Insert a new record.\n\t_, err := credCollection.InsertOne(a.ctx, cred)\n\tif isDuplicateErr(err) {\n\t\treturn true, t.ErrDuplicate\n\t}\n\n\treturn true, err\n}\n\n// CredGetActive returns the currently active credential record for the given method.\nfunc (a *adapter) CredGetActive(uid t.Uid, method string) (*t.Credential, error) {\n\tvar cred t.Credential\n\n\tfilter := b.M{\n\t\t\"user\":      uid.String(),\n\t\t\"deletedat\": b.M{\"$exists\": false},\n\t\t\"method\":    method,\n\t\t\"done\":      false}\n\n\tif err := a.db.Collection(\"credentials\").FindOne(a.ctx, filter).Decode(&cred); err != nil {\n\t\tif err == mdb.ErrNoDocuments { // Cred not found\n\t\t\terr = nil\n\t\t}\n\t\treturn nil, err\n\t}\n\n\treturn &cred, nil\n}\n\n// CredGetAll returns credential records for the given user and method, validated only or all.\nfunc (a *adapter) CredGetAll(uid t.Uid, method string, validatedOnly bool) ([]t.Credential, error) {\n\tfilter := b.M{\"user\": uid.String()}\n\tif method != \"\" {\n\t\tfilter[\"method\"] = method\n\t}\n\tif validatedOnly {\n\t\tfilter[\"done\"] = true\n\t} else {\n\t\tfilter[\"deletedat\"] = b.M{\"$exists\": false}\n\t}\n\n\tcur, err := a.db.Collection(\"credentials\").Find(a.ctx, filter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cur.Close(a.ctx)\n\n\tvar credentials []t.Credential\n\tif err := cur.All(a.ctx, &credentials); err != nil {\n\t\treturn nil, err\n\t}\n\treturn credentials, nil\n}\n\n// CredDel deletes credentials for the given method/value. If method is empty, deletes all\n// user's credentials.\nfunc (a *adapter) credDel(ctx context.Context, uid t.Uid, method, value string) error {\n\tcredCollection := a.db.Collection(\"credentials\")\n\tfilter := b.M{\"user\": uid.String()}\n\tif method != \"\" {\n\t\tfilter[\"method\"] = method\n\t\tif value != \"\" {\n\t\t\tfilter[\"value\"] = value\n\t\t}\n\t} else {\n\t\tres, err := credCollection.DeleteMany(ctx, filter)\n\t\tif err == nil {\n\t\t\tif res.DeletedCount == 0 {\n\t\t\t\terr = t.ErrNotFound\n\t\t\t}\n\t\t}\n\t\treturn err\n\t}\n\n\t// Hard-delete all confirmed values or values with no attempts at confirmation.\n\thardDeleteFilter := copyBsonMap(filter)\n\thardDeleteFilter[\"$or\"] = b.A{\n\t\tb.M{\"done\": true},\n\t\tb.M{\"retries\": 0}}\n\tif res, err := credCollection.DeleteMany(ctx, hardDeleteFilter); err != nil {\n\t\treturn err\n\t} else if res.DeletedCount > 0 {\n\t\treturn nil\n\t}\n\n\t// Soft-delete all other values.\n\tres, err := credCollection.UpdateMany(ctx, filter, b.M{\"$set\": b.M{\"deletedat\": t.TimeNow()}})\n\tif err == nil {\n\t\tif res.ModifiedCount == 0 {\n\t\t\terr = t.ErrNotFound\n\t\t}\n\t}\n\treturn err\n}\n\nfunc (a *adapter) CredDel(uid t.Uid, method, value string) error {\n\treturn a.credDel(a.ctx, uid, method, value)\n}\n\n// CredConfirm marks given credential as validated.\nfunc (a *adapter) CredConfirm(uid t.Uid, method string) error {\n\tcred, err := a.CredGetActive(uid, method)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tcred.Done = true\n\tcred.UpdatedAt = t.TimeNow()\n\tif _, err = a.CredUpsert(cred); err != nil {\n\t\treturn err\n\t}\n\n\t_, _ = a.db.Collection(\"credentials\").DeleteOne(a.ctx, b.M{\"_id\": uid.String() + \":\" + cred.Method + \":\" + cred.Value})\n\treturn nil\n}\n\n// CredFail increments count of failed validation attepmts for the given credentials.\nfunc (a *adapter) CredFail(uid t.Uid, method string) error {\n\tfilter := b.M{\n\t\t\"user\":      uid.String(),\n\t\t\"deletedat\": b.M{\"$exists\": false},\n\t\t\"method\":    method,\n\t\t\"done\":      false}\n\n\tupdate := b.M{\n\t\t\"$inc\": b.M{\"retries\": 1},\n\t\t\"$set\": b.M{\"updatedat\": t.TimeNow()}}\n\t_, err := a.db.Collection(\"credentials\").UpdateOne(a.ctx, filter, update)\n\treturn err\n}\n\n// Authentication management for the basic authentication scheme\n\n// AuthGetUniqueRecord returns authentication record for a given unique value i.e. login.\nfunc (a *adapter) AuthGetUniqueRecord(unique string) (t.Uid, auth.Level, []byte, time.Time, error) {\n\tvar record struct {\n\t\tUserId  string\n\t\tAuthLvl auth.Level\n\t\tSecret  []byte\n\t\tExpires time.Time\n\t}\n\n\tfilter := b.M{\"_id\": unique}\n\tfindOpts := mdbopts.FindOne().SetProjection(b.M{\n\t\t\"userid\":  1,\n\t\t\"authlvl\": 1,\n\t\t\"secret\":  1,\n\t\t\"expires\": 1,\n\t})\n\tif err := a.db.Collection(\"auth\").FindOne(a.ctx, filter, findOpts).Decode(&record); err != nil {\n\t\tif err == mdb.ErrNoDocuments {\n\t\t\treturn t.ZeroUid, 0, nil, time.Time{}, nil\n\t\t}\n\t\treturn t.ZeroUid, 0, nil, time.Time{}, err\n\t}\n\n\treturn t.ParseUid(record.UserId), record.AuthLvl, record.Secret, record.Expires, nil\n}\n\n// AuthGetRecord returns authentication record given user ID and method.\nfunc (a *adapter) AuthGetRecord(uid t.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) {\n\tvar record struct {\n\t\tId      string `bson:\"_id\"`\n\t\tAuthLvl auth.Level\n\t\tSecret  []byte\n\t\tExpires time.Time\n\t}\n\n\tfilter := b.M{\"userid\": uid.String(), \"scheme\": scheme}\n\tfindOpts := mdbopts.FindOne().SetProjection(b.M{\n\t\t\"authlvl\": 1,\n\t\t\"secret\":  1,\n\t\t\"expires\": 1,\n\t})\n\terr := a.db.Collection(\"auth\").FindOne(a.ctx, filter, findOpts).Decode(&record)\n\tif err != nil {\n\t\tif err == mdb.ErrNoDocuments {\n\t\t\terr = t.ErrNotFound\n\t\t}\n\t\treturn \"\", 0, nil, time.Time{}, err\n\t}\n\n\treturn record.Id, record.AuthLvl, record.Secret, record.Expires, nil\n}\n\n// AuthAddRecord creates new authentication record\nfunc (a *adapter) AuthAddRecord(uid t.Uid, scheme, unique string, authLvl auth.Level, secret []byte, expires time.Time) error {\n\tauthRecord := b.M{\n\t\t\"_id\":     unique,\n\t\t\"userid\":  uid.String(),\n\t\t\"scheme\":  scheme,\n\t\t\"authlvl\": authLvl,\n\t\t\"secret\":  secret,\n\t\t\"expires\": expires}\n\tif _, err := a.db.Collection(\"auth\").InsertOne(a.ctx, authRecord); err != nil {\n\t\tif isDuplicateErr(err) {\n\t\t\treturn t.ErrDuplicate\n\t\t}\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// AuthDelScheme deletes an existing authentication scheme for the user.\nfunc (a *adapter) AuthDelScheme(uid t.Uid, scheme string) error {\n\t_, err := a.db.Collection(\"auth\").DeleteOne(a.ctx,\n\t\tb.M{\n\t\t\t\"userid\": uid.String(),\n\t\t\t\"scheme\": scheme})\n\treturn err\n}\n\nfunc (a *adapter) authDelAllRecords(ctx context.Context, uid t.Uid) (int, error) {\n\tres, err := a.db.Collection(\"auth\").DeleteMany(ctx, b.M{\"userid\": uid.String()})\n\treturn int(res.DeletedCount), err\n}\n\n// AuthDelAllRecords deletes all records of a given user.\nfunc (a *adapter) AuthDelAllRecords(uid t.Uid) (int, error) {\n\treturn a.authDelAllRecords(a.ctx, uid)\n}\n\n// AuthUpdRecord modifies an authentication record.\nfunc (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string,\n\tauthLvl auth.Level, secret []byte, expires time.Time) error {\n\t// The primary key is immutable. If '_id' has changed, we have to replace the old record with a new one:\n\t// 1. Check if '_id' has changed.\n\t// 2. If not, execute update by '_id'\n\t// 3. If yes, first insert the new record (it may fail due to dublicate '_id') then delete the old one.\n\n\tvar err error\n\tvar record common.AuthRecord\n\tfindOpts := mdbopts.FindOne().SetProjection(b.M{\"_id\": 1})\n\tfilter := b.M{\"userid\": uid.String(), \"scheme\": scheme}\n\tif err = a.db.Collection(\"auth\").FindOne(a.ctx, filter, findOpts).Decode(&record); err != nil {\n\t\tif err == mdb.ErrNoDocuments {\n\t\t\terr = t.ErrNotFound\n\t\t}\n\t\treturn err\n\t}\n\n\tif record.Unique == unique {\n\t\tupd := b.M{\n\t\t\t\"authlvl\": authLvl,\n\t\t}\n\t\tif len(secret) > 0 {\n\t\t\tupd[\"secret\"] = secret\n\t\t}\n\t\tif !expires.IsZero() {\n\t\t\tupd[\"expires\"] = expires\n\t\t}\n\t\t_, err = a.db.Collection(\"auth\").UpdateOne(a.ctx,\n\t\t\tb.M{\"_id\": unique},\n\t\t\tb.M{\"$set\": upd})\n\t} else {\n\t\t// Unique has changed. Insert-Delete.\n\t\t// FIXME: use transaction.\n\t\tif len(secret) == 0 {\n\t\t\tsecret = record.Secret\n\t\t}\n\t\tif expires.IsZero() {\n\t\t\texpires = record.Expires\n\t\t}\n\t\terr = a.AuthAddRecord(uid, scheme, unique, authLvl, secret, expires)\n\t\tif err == nil {\n\t\t\t// Delete the old record. Not much can be done with the error.\n\t\t\ta.db.Collection(\"auth\").DeleteOne(a.ctx, b.M{\"_id\": record.Unique})\n\t\t}\n\t}\n\n\treturn err\n}\n\n// Topic management\n\nfunc (a *adapter) undeleteSubscription(sub *t.Subscription) error {\n\t_, err := a.db.Collection(\"subscriptions\").UpdateOne(a.ctx,\n\t\tb.M{\"_id\": sub.Id},\n\t\tb.M{\n\t\t\t\"$unset\": b.M{\"deletedat\": \"\"},\n\t\t\t\"$set\": b.M{\n\t\t\t\t\"updatedat\": sub.UpdatedAt,\n\t\t\t\t\"createdat\": sub.CreatedAt,\n\t\t\t\t\"modegiven\": sub.ModeGiven,\n\t\t\t\t\"modewant\":  sub.ModeWant,\n\t\t\t\t\"delid\":     0,\n\t\t\t\t\"readseqid\": 0,\n\t\t\t\t\"recvseqid\": 0}})\n\treturn err\n}\n\n// TopicCreate creates a topic\nfunc (a *adapter) TopicCreate(topic *t.Topic) error {\n\t_, err := a.db.Collection(\"topics\").InsertOne(a.ctx, &topic)\n\treturn err\n}\n\n// TopicCreateP2P creates a p2p topic.\nfunc (a *adapter) TopicCreateP2P(initiator, invited *t.Subscription) error {\n\tinitiator.Id = initiator.Topic + \":\" + initiator.User\n\t// Don't care if the initiator changes own subscription\n\treplOpts := mdbopts.Replace().SetUpsert(true)\n\t_, err := a.db.Collection(\"subscriptions\").ReplaceOne(a.ctx, b.M{\"_id\": initiator.Id}, initiator, replOpts)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// If the second subscription exists, don't overwrite it. Just make sure it's not deleted.\n\tinvited.Id = invited.Topic + \":\" + invited.User\n\t_, err = a.db.Collection(\"subscriptions\").InsertOne(a.ctx, invited)\n\tif err != nil {\n\t\t// Is this a duplicate subscription?\n\t\tif !isDuplicateErr(err) {\n\t\t\t// It's a genuine DB error\n\t\t\treturn err\n\t\t}\n\t\t// Undelete the second subsription if it exists: remove DeletedAt, update CreatedAt and UpdatedAt,\n\t\t// update ModeGiven.\n\t\terr = a.undeleteSubscription(invited)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\ttopic := &t.Topic{\n\t\tObjHeader: t.ObjHeader{Id: initiator.Topic},\n\t\tTouchedAt: initiator.GetTouchedAt(),\n\t}\n\ttopic.ObjHeader.MergeTimes(&initiator.ObjHeader)\n\treturn a.TopicCreate(topic)\n}\n\n// TopicGet loads a single topic by name, if it exists. If the topic does not exist the call returns (nil, nil)\nfunc (a *adapter) TopicGet(topic string) (*t.Topic, error) {\n\tvar tt = new(t.Topic)\n\tif err := a.db.Collection(\"topics\").FindOne(a.ctx, b.M{\"_id\": topic}).Decode(tt); err != nil {\n\t\tif err == mdb.ErrNoDocuments {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\n\tif t.GetTopicCat(topic) == t.TopicCatGrp {\n\t\t// Topic found, get subsription count.\n\t\tsubCnt, err := a.subscriptionCount(topic)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif int(subCnt) != tt.SubCnt {\n\t\t\t// Update the topic with the correct subscription count.\n\t\t\ttt.SubCnt = int(subCnt)\n\t\t\terr = a.topicUpdate(topic, b.M{\"subcnt\": tt.SubCnt})\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\n\ttt.Public = unmarshalBsonD(tt.Public)\n\ttt.Trusted = unmarshalBsonD(tt.Trusted)\n\n\treturn tt, nil\n}\n\n// TopicsForUser loads user's contact list: p2p and grp topics, except for 'me' & 'fnd' subscriptions.\n// Reads and denormalizes Public & Trusted values.\nfunc (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {\n\t// Fetch all user's subscriptions.\n\tfilter := b.M{\"user\": uid.String()}\n\tif !keepDeleted {\n\t\t// Filter out rows with defined deletedat\n\t\tfilter[\"deletedat\"] = b.M{\"$exists\": false}\n\t}\n\n\tlimit := 0\n\tims := time.Time{}\n\tif opts != nil {\n\t\tif opts.Topic != \"\" {\n\t\t\tfilter[\"topic\"] = opts.Topic\n\t\t}\n\n\t\t// Apply the limit only when the client does not manage the cache (or cold start).\n\t\t// Otherwise have to get all subscriptions and do a manual join with users/topics.\n\t\tif opts.IfModifiedSince == nil {\n\t\t\tif opts.Limit > 0 && opts.Limit < a.maxResults {\n\t\t\t\tlimit = opts.Limit\n\t\t\t} else {\n\t\t\t\tlimit = a.maxResults\n\t\t\t}\n\t\t} else {\n\t\t\tims = *opts.IfModifiedSince\n\t\t}\n\t} else {\n\t\tlimit = a.maxResults\n\t}\n\n\tvar findOpts *mdbopts.FindOptions\n\tif limit > 0 {\n\t\tfindOpts = mdbopts.Find().SetLimit(int64(limit))\n\t}\n\n\tcur, err := a.db.Collection(\"subscriptions\").Find(a.ctx, filter, findOpts)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t// Must close the cursor manually as we will be reusing it.\n\n\t// Fetch subscriptions. Two queries are needed: users table (me & p2p) and topics table (p2p and grp).\n\t// Prepare a list of Separate subscriptions to users vs topics\n\tjoin := make(map[string]t.Subscription) // Keeping these to make a join with table for .private and .access\n\ttopq := make([]string, 0, 16)\n\tusrq := make([]string, 0, 16)\n\tfor cur.Next(a.ctx) {\n\t\tvar sub t.Subscription\n\t\tif err = cur.Decode(&sub); err != nil {\n\t\t\tbreak\n\t\t}\n\t\ttname := sub.Topic\n\t\tsub.User = uid.String()\n\t\ttcat := t.GetTopicCat(tname)\n\n\t\tif tcat == t.TopicCatMe || tcat == t.TopicCatFnd {\n\t\t\t// Skip 'me' or 'fnd' subscription. Don't skip 'sys'.\n\t\t\tcontinue\n\t\t} else if tcat == t.TopicCatP2P {\n\t\t\t// P2P subscription, find the other user to get user.Public\n\t\t\tuid1, uid2, _ := t.ParseP2P(sub.Topic)\n\t\t\tif uid1 == uid {\n\t\t\t\tusrq = append(usrq, uid2.String())\n\t\t\t\tsub.SetWith(uid2.UserId())\n\t\t\t} else {\n\t\t\t\tusrq = append(usrq, uid1.String())\n\t\t\t\tsub.SetWith(uid1.UserId())\n\t\t\t}\n\t\t\ttopq = append(topq, tname)\n\t\t} else if tcat == t.TopicCatGrp {\n\t\t\t// Maybe convert channel name to topic name.\n\t\t\ttname = t.ChnToGrp(tname)\n\t\t}\n\t\t// No special handling needed for 'slf', 'sys' subscriptions.\n\n\t\ttopq = append(topq, tname)\n\t\tsub.Private = unmarshalBsonD(sub.Private)\n\t\tjoin[tname] = sub\n\t}\n\tcur.Close(a.ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar subs []t.Subscription\n\tif len(join) == 0 {\n\t\treturn subs, nil\n\t}\n\n\tif len(topq) > 0 {\n\t\t// Fetch grp & p2p topics\n\t\tfilter = b.M{\"_id\": b.M{\"$in\": topq}}\n\n\t\tif !keepDeleted {\n\t\t\tfilter[\"state\"] = b.M{\"$ne\": t.StateDeleted}\n\t\t}\n\n\t\tif !ims.IsZero() {\n\t\t\t// Use cache timestamp if provided: get newer entries only.\n\t\t\tfilter[\"touchedat\"] = b.M{\"$gt\": ims}\n\n\t\t\tfindOpts = nil\n\t\t\tif limit > 0 && limit < len(topq) {\n\t\t\t\t// No point in fetching more than the requested limit.\n\t\t\t\tfindOpts = mdbopts.Find().SetSort(b.D{{\"touchedat\", 1}}).SetLimit(int64(limit))\n\t\t\t}\n\t\t}\n\n\t\tcur, err = a.db.Collection(\"topics\").Find(a.ctx, filter, findOpts)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor cur.Next(a.ctx) {\n\t\t\tvar top t.Topic\n\t\t\tif err = cur.Decode(&top); err != nil {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tsub := join[top.Id]\n\t\t\t// Check if sub.UpdatedAt needs to be adjusted to earlier or later time.\n\t\t\tsub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, top.UpdatedAt)\n\t\t\tsub.SetState(top.State)\n\t\t\tsub.SetTouchedAt(top.TouchedAt)\n\t\t\tsub.SetSeqId(top.SeqId)\n\t\t\tif t.GetTopicCat(sub.Topic) == t.TopicCatGrp {\n\t\t\t\tsub.SetSubCnt(top.SubCnt)\n\t\t\t\tsub.SetPublic(unmarshalBsonD(top.Public))\n\t\t\t\tsub.SetTrusted(unmarshalBsonD(top.Trusted))\n\t\t\t}\n\t\t\t// Put back the updated value of a p2p subsription, will process further below\n\t\t\tjoin[top.Id] = sub\n\t\t}\n\t\tcur.Close(a.ctx)\n\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\t// Fetch p2p users and join to p2p tables\n\tif len(usrq) > 0 {\n\t\tfilter = b.M{\"_id\": b.M{\"$in\": usrq}}\n\t\tif !keepDeleted {\n\t\t\tfilter[\"state\"] = b.M{\"$ne\": t.StateDeleted}\n\t\t}\n\n\t\t// Ignoring ims: we need all users to get LastSeen and UserAgent.\n\n\t\tcur, err = a.db.Collection(\"users\").Find(a.ctx, filter, findOpts)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor cur.Next(a.ctx) {\n\t\t\tvar usr2 t.User\n\t\t\tif err = cur.Decode(&usr2); err != nil {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tjoinOn := uid.P2PName(t.ParseUid(usr2.Id))\n\t\t\tif sub, ok := join[joinOn]; ok {\n\t\t\t\tsub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, usr2.UpdatedAt)\n\t\t\t\tsub.SetState(usr2.State)\n\t\t\t\tsub.SetPublic(unmarshalBsonD(usr2.Public))\n\t\t\t\tsub.SetTrusted(unmarshalBsonD(usr2.Trusted))\n\t\t\t\tsub.SetDefaultAccess(usr2.Access.Auth, usr2.Access.Anon)\n\t\t\t\tsub.SetLastSeenAndUA(usr2.LastSeen, usr2.UserAgent)\n\t\t\t\tjoin[joinOn] = sub\n\t\t\t}\n\t\t}\n\t\tcur.Close(a.ctx)\n\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tsubs = make([]t.Subscription, 0, len(join))\n\tfor _, sub := range join {\n\t\tsubs = append(subs, sub)\n\t}\n\n\treturn common.SelectEarliestUpdatedSubs(subs, opts, a.maxResults), nil\n}\n\n// UsersForTopic loads users' subscriptions for a given topic (not channel readers).\n// Public & Trusted are loaded.\nfunc (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {\n\ttcat := t.GetTopicCat(topic)\n\n\t// Fetch all subscribed users. The number of users is not large.\n\tfilter := b.M{\"topic\": topic}\n\tif !keepDeleted && tcat != t.TopicCatP2P {\n\t\t// Filter out rows with DeletedAt being not null.\n\t\t// P2P topics must load all subscriptions otherwise it will be impossible\n\t\t// to swap Public values.\n\t\tfilter[\"deletedat\"] = b.M{\"$exists\": false}\n\t}\n\n\tlimit := a.maxResults\n\tvar oneUser t.Uid\n\tif opts != nil {\n\t\t// Ignore IfModifiedSince - we must return all entries\n\t\t// Those unmodified will be stripped of Public, Trusted & Private.\n\n\t\tif !opts.User.IsZero() {\n\t\t\tif tcat != t.TopicCatP2P {\n\t\t\t\tfilter[\"user\"] = opts.User.String()\n\t\t\t}\n\t\t\toneUser = opts.User\n\t\t}\n\t\tif opts.Limit > 0 && opts.Limit < limit {\n\t\t\tlimit = opts.Limit\n\t\t}\n\t}\n\n\tcur, err := a.db.Collection(\"subscriptions\").Find(a.ctx, filter, mdbopts.Find().SetLimit(int64(limit)))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Fetch subscriptions.\n\tvar subs []t.Subscription\n\tjoin := make(map[string]t.Subscription)\n\tusrq := make([]any, 0, 16)\n\tfor cur.Next(a.ctx) {\n\t\tvar sub t.Subscription\n\t\tif err = cur.Decode(&sub); err != nil {\n\t\t\tbreak\n\t\t}\n\t\tjoin[sub.User] = sub\n\t\tusrq = append(usrq, sub.User)\n\t}\n\tcur.Close(a.ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Fetch users by a list of subscriptions.\n\tif len(usrq) > 0 {\n\t\tsubs = make([]t.Subscription, 0, len(usrq))\n\t\tcur, err = a.db.Collection(\"users\").Find(a.ctx, b.M{\n\t\t\t\"_id\":   b.M{\"$in\": usrq},\n\t\t\t\"state\": b.M{\"$ne\": t.StateDeleted}})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor cur.Next(a.ctx) {\n\t\t\tvar usr2 t.User\n\t\t\tif err = cur.Decode(&usr2); err != nil {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sub, ok := join[usr2.Id]; ok {\n\t\t\t\tsub.ObjHeader.MergeTimes(&usr2.ObjHeader)\n\t\t\t\tsub.Private = unmarshalBsonD(sub.Private)\n\t\t\t\tsub.SetPublic(unmarshalBsonD(usr2.Public))\n\t\t\t\tsub.SetTrusted(unmarshalBsonD(usr2.Trusted))\n\t\t\t\tsub.SetLastSeenAndUA(usr2.LastSeen, usr2.UserAgent)\n\t\t\t\tsubs = append(subs, sub)\n\t\t\t}\n\t\t}\n\t\tcur.Close(a.ctx)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif t.GetTopicCat(topic) == t.TopicCatP2P && len(subs) > 0 {\n\t\t// Swap public values & lastSeen of P2P topics as expected.\n\t\tif len(subs) == 1 {\n\t\t\t// User is deleted. Nothing we can do.\n\t\t\tsubs[0].SetPublic(nil)\n\t\t\tsubs[0].SetTrusted(nil)\n\t\t\tsubs[0].SetLastSeenAndUA(nil, \"\")\n\t\t} else {\n\t\t\ttmp := subs[0].GetPublic()\n\t\t\tsubs[0].SetPublic(subs[1].GetPublic())\n\t\t\tsubs[1].SetPublic(tmp)\n\n\t\t\ttmp = subs[0].GetTrusted()\n\t\t\tsubs[0].SetTrusted(subs[1].GetTrusted())\n\t\t\tsubs[1].SetTrusted(tmp)\n\n\t\t\tlastSeen := subs[0].GetLastSeen()\n\t\t\tuserAgent := subs[0].GetUserAgent()\n\t\t\tsubs[0].SetLastSeenAndUA(subs[1].GetLastSeen(), subs[1].GetUserAgent())\n\t\t\tsubs[1].SetLastSeenAndUA(lastSeen, userAgent)\n\t\t}\n\n\t\t// Remove deleted and unneeded subscriptions\n\t\tif !keepDeleted || !oneUser.IsZero() {\n\t\t\tvar xsubs []t.Subscription\n\t\t\tfor i := range subs {\n\t\t\t\tif (subs[i].DeletedAt != nil && !keepDeleted) || (!oneUser.IsZero() && subs[i].Uid() != oneUser) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\txsubs = append(xsubs, subs[i])\n\t\t\t}\n\t\t\tsubs = xsubs\n\t\t}\n\t}\n\n\treturn subs, nil\n}\n\n// topicNamesForUser reads topic names from the 'field' of 'collection' using 'filter'.\n// If includeChan is true, for group topics also add the corresponding channel name.\nfunc (a *adapter) topicNamesForUser(collection string, filter b.M, field string, includeChan bool) ([]string, error) {\n\tcur, err := a.db.Collection(collection).Find(a.ctx, filter,\n\t\tmdbopts.Find().SetProjection(b.M{field: 1}))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cur.Close(a.ctx)\n\n\tvar names []string\n\tfor cur.Next(a.ctx) {\n\t\tvar res map[string]string\n\t\tif err = cur.Decode(&res); err != nil {\n\t\t\tbreak\n\t\t}\n\t\tnames = append(names, res[field])\n\t\t// If the name is a group topic, also add the channel name if requested.\n\t\tif includeChan {\n\t\t\tif channel := t.GrpToChn(res[field]); channel != \"\" {\n\t\t\t\tnames = append(names, channel)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn names, err\n}\n\nfunc (a *adapter) p2pTopicsForUser(uid t.Uid) ([]string, error) {\n\treturn a.topicNamesForUser(\"subscriptions\",\n\t\tb.M{\n\t\t\t\"user\":      uid.String(),\n\t\t\t\"deletedat\": b.M{\"$exists\": false},\n\t\t\t\"topic\":     b.M{\"$regex\": primitive.Regex{Pattern: \"^p2p\"}}},\n\t\t\"topic\", false)\n}\n\n// OwnTopics loads a slice of topic names where the user is the owner.\nfunc (a *adapter) OwnTopics(uid t.Uid) ([]string, error) {\n\treturn a.topicNamesForUser(\"topics\",\n\t\tb.M{\"owner\": uid.String(), \"state\": b.M{\"$ne\": t.StateDeleted}},\n\t\t\"_id\", false)\n}\n\n// ChannelsForUser loads a slice of topic names where the user is a channel reader and notifications (P) are enabled.\nfunc (a *adapter) ChannelsForUser(uid t.Uid) ([]string, error) {\n\treturn a.topicNamesForUser(\"subscriptions\",\n\t\tb.M{\n\t\t\t\"user\":      uid.String(),\n\t\t\t\"deletedat\": b.M{\"$exists\": false},\n\t\t\t\"topic\":     b.M{\"$regex\": primitive.Regex{Pattern: \"^chn\"}},\n\t\t\t\"modewant\":  b.M{\"$bitsAllSet\": b.A{t.ModePres}},\n\t\t\t\"modegiven\": b.M{\"$bitsAllSet\": b.A{t.ModePres}}},\n\t\t\"topic\", false)\n}\n\n// TopicShare creates topic subscriptions.\nfunc (a *adapter) TopicShare(topic string, shares []*t.Subscription) error {\n\t// Assign Ids.\n\tfor _, sub := range shares {\n\t\tsub.Id = sub.Topic + \":\" + sub.User\n\t}\n\n\t// Subscription could have been marked as deleted (DeletedAt != nil). If it's marked\n\t// as deleted, unmark by clearing the DeletedAt field of the old subscription and\n\t// updating times and ModeGiven.\n\tfor _, sub := range shares {\n\t\t_, err := a.db.Collection(\"subscriptions\").InsertOne(a.ctx, sub)\n\t\tif err != nil {\n\t\t\tif isDuplicateErr(err) {\n\t\t\t\tif err = a.undeleteSubscription(sub); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\tif topic != \"\" {\n\t\t// Update topic's subscription count.\n\t\t// The error is ignored because the subscriptions have been created already.\n\t\ta.db.Collection(\"topics\").UpdateOne(a.ctx,\n\t\t\tb.M{\"_id\": topic},\n\t\t\tb.M{\"$inc\": b.M{\"subcnt\": len(shares)}})\n\t}\n\n\treturn nil\n}\n\n// TopicDelete deletes topic, subscriptions, messages.\nfunc (a *adapter) TopicDelete(topic string, isChan, hard bool) error {\n\tfilter := b.M{}\n\tif isChan {\n\t\t// If the topic is a channel, must try to delete subscriptions under both grpXXX and chnXXX names.\n\t\tfilter[\"$or\"] = b.A{\n\t\t\tb.M{\"topic\": topic},\n\t\t\tb.M{\"topic\": t.GrpToChn(topic)},\n\t\t}\n\t} else {\n\t\tfilter[\"topic\"] = topic\n\t}\n\terr := a.subsDelete(a.ctx, filter, hard)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfilter = b.M{\"_id\": topic}\n\tif hard {\n\t\tif err = a.decFileUseCounter(a.ctx, \"topics\", filter); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err = a.MessageDeleteList(topic, nil); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = a.db.Collection(\"topics\").DeleteOne(a.ctx, filter)\n\t} else {\n\t\t_, err = a.db.Collection(\"topics\").UpdateOne(a.ctx, filter, b.M{\"$set\": b.M{\n\t\t\t\"state\":   t.StateDeleted,\n\t\t\t\"stateat\": t.TimeNow(),\n\t\t}})\n\t}\n\n\treturn err\n}\n\n// TopicUpdateOnMessage increments Topic's or User's SeqId value and updates TouchedAt timestamp.\nfunc (a *adapter) TopicUpdateOnMessage(topic string, msg *t.Message) error {\n\treturn a.topicUpdate(topic, b.M{\"seqid\": msg.SeqId, \"touchedat\": msg.CreatedAt})\n}\n\nfunc (a *adapter) subscriptionCount(topic string) (int64, error) {\n\t// Get count of non-deleted subscriptions to the topic.\n\treturn a.db.Collection(\"subscriptions\").CountDocuments(a.ctx, b.M{\n\t\t\"topic\":     b.M{\"$in\": b.A{topic, t.GrpToChn(topic)}},\n\t\t\"deletedat\": b.M{\"$exists\": false},\n\t})\n}\n\n// TopicUpdateSubCnt updates subscriber count denormalized in topic.\nfunc (a *adapter) TopicUpdateSubCnt(topic string) error {\n\t// Get count of non-deleted subscriptions to the topic.\n\t// UPDATE ... SET=(SELECT ...) is not supported in MongoDB, so we have to do it in two queries.\n\tcount, err := a.subscriptionCount(topic)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.topicUpdate(topic, b.M{\"subcnt\": count})\n}\n\n// TopicUpdate updates topic record.\nfunc (a *adapter) TopicUpdate(topic string, update map[string]any) error {\n\tif t, u := update[\"TouchedAt\"], update[\"UpdatedAt\"]; t == nil && u != nil {\n\t\tupdate[\"TouchedAt\"] = u\n\t}\n\treturn a.topicUpdate(topic, normalizeUpdateMap(update))\n}\n\n// TopicOwnerChange updates topic's owner\nfunc (a *adapter) TopicOwnerChange(topic string, newOwner t.Uid) error {\n\treturn a.topicUpdate(topic, map[string]any{\"owner\": newOwner.String()})\n}\n\nfunc (a *adapter) topicUpdate(topic string, update map[string]any) error {\n\t_, err := a.db.Collection(\"topics\").UpdateOne(a.ctx,\n\t\tb.M{\"_id\": topic},\n\t\tb.M{\"$set\": update})\n\n\treturn err\n}\n\n// Topic subscriptions\n\n// SubscriptionGet reads a subscription of a user to a topic.\nfunc (a *adapter) SubscriptionGet(topic string, user t.Uid, keepDeleted bool) (*t.Subscription, error) {\n\tsub := new(t.Subscription)\n\tfilter := b.M{\"_id\": topic + \":\" + user.String()}\n\tif !keepDeleted {\n\t\tfilter[\"deletedat\"] = b.M{\"$exists\": false}\n\t}\n\terr := a.db.Collection(\"subscriptions\").FindOne(a.ctx, filter).Decode(sub)\n\tif err != nil {\n\t\tif err == mdb.ErrNoDocuments {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\n\treturn sub, nil\n}\n\n// SubsForUser loads all subscriptions of a given user. It does NOT load Public, Trusted or Private values,\n// does not load deleted subs.\nfunc (a *adapter) SubsForUser(user t.Uid) ([]t.Subscription, error) {\n\tfilter := b.M{\"user\": user.String(), \"deletedat\": b.M{\"$exists\": false}}\n\n\tcur, err := a.db.Collection(\"subscriptions\").Find(a.ctx, filter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cur.Close(a.ctx)\n\n\tvar subs []t.Subscription\n\tfor cur.Next(a.ctx) {\n\t\tvar ss t.Subscription\n\t\tif err := cur.Decode(&ss); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tss.Private = nil\n\t\tsubs = append(subs, ss)\n\t}\n\n\treturn subs, cur.Err()\n}\n\n// SubsForTopic fetches all subsciptions for a topic. Does NOT load Public value and does not load channel readers.\n// The difference between UsersForTopic vs SubsForTopic is that the former loads user.public+trusted,\n// the latter does not.\nfunc (a *adapter) SubsForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {\n\tfilter := b.M{\"topic\": topic}\n\tif !keepDeleted {\n\t\tfilter[\"deletedat\"] = b.M{\"$exists\": false}\n\t}\n\n\tlimit := a.maxResults\n\tif opts != nil {\n\t\t// Ignore IfModifiedSince - we must return all entries\n\t\t// Those unmodified will be stripped of Public, Trusted & Private.\n\n\t\tif !opts.User.IsZero() {\n\t\t\tfilter[\"user\"] = opts.User.String()\n\t\t}\n\t\tif opts.Limit > 0 && opts.Limit < limit {\n\t\t\tlimit = opts.Limit\n\t\t}\n\t}\n\tfindOpts := new(mdbopts.FindOptions).SetLimit(int64(limit))\n\n\tcur, err := a.db.Collection(\"subscriptions\").Find(a.ctx, filter, findOpts)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cur.Close(a.ctx)\n\n\tvar subs []t.Subscription\n\tfor cur.Next(a.ctx) {\n\t\tvar ss t.Subscription\n\t\tif err := cur.Decode(&ss); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tss.Private = unmarshalBsonD(ss.Private)\n\t\tsubs = append(subs, ss)\n\t}\n\n\treturn subs, cur.Err()\n}\n\n// SubsUpdate updates part of a subscription object. Pass nil for fields which don't need to be updated\nfunc (a *adapter) SubsUpdate(topic string, user t.Uid, update map[string]any) error {\n\t// Convert CamelCase field names to lowercase.\n\tupdate = normalizeUpdateMap(update)\n\n\tfilter := b.M{}\n\tif !user.IsZero() {\n\t\t// Update one topic subscription\n\t\tfilter[\"_id\"] = topic + \":\" + user.String()\n\t} else {\n\t\t// Update all topic subscriptions\n\t\tfilter[\"topic\"] = topic\n\t}\n\t_, err := a.db.Collection(\"subscriptions\").UpdateOne(a.ctx, filter, b.M{\"$set\": update})\n\treturn err\n}\n\n// SubsDelete marks at most one subscription as deleted (soft-deleting).\nfunc (a *adapter) SubsDelete(topic string, user t.Uid) error {\n\tvar sess mdb.Session\n\tvar err error\n\n\tif sess, err = a.conn.StartSession(); err != nil {\n\t\treturn err\n\t}\n\tdefer sess.EndSession(a.ctx)\n\n\tif err = a.maybeStartTransaction(sess); err != nil {\n\t\treturn err\n\t}\n\n\tforUser := user.String()\n\n\treturn mdb.WithSession(a.ctx, sess, func(sc mdb.SessionContext) error {\n\t\tif err := a.subsDelete(sc, b.M{\"_id\": topic + \":\" + forUser}, false); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Channel readers cannot delete messages.\n\t\tif !t.IsChannel(topic) {\n\n\t\t\t// Delete user's dellog entries.\n\t\t\tif _, err := a.db.Collection(\"dellog\").DeleteMany(sc, b.M{\"topic\": topic, \"deletedfor\": forUser}); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// Delete user's markings of soft-deleted messages\n\t\t\tfilter := b.M{\"topic\": topic, \"deletedfor.user\": forUser}\n\t\t\tif _, err := a.db.Collection(\"messages\").\n\t\t\t\tUpdateMany(sc, filter, b.M{\"$pull\": b.M{\"deletedfor\": b.M{\"user\": forUser}}}); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\tif t.GetTopicCat(topic) == t.TopicCatGrp {\n\t\t\t// Decrement topic subscription count (only one subscription is\tdeleted).\n\t\t\tif err := a.topicUpdate(topic, b.M{\"subcnt\": -1}); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t// Commit changes.\n\t\treturn a.maybeCommitTransaction(sc, sess)\n\t})\n}\n\n// clearUserDellog deletes all dellog entries and deletedfor markings of a given user.\nfunc (a *adapter) clearUserDellog(sc mdb.SessionContext, forUser string) error {\n\ttopics, err := a.db.Collection(\"subscriptions\").Distinct(sc, \"topic\",\n\t\tb.M{\"user\": forUser, \"deletedat\": b.M{\"$exists\": false}})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// No need to convert channel names to group names:\n\t// channel readers cannot delete messages.\n\n\tif len(topics) > 0 {\n\t\t// Delete user's dellog entries.\n\t\tif _, err = a.db.Collection(\"dellog\").DeleteMany(sc,\n\t\t\tb.M{\"topic\": b.M{\"$in\": topics}, \"deletedfor\": forUser}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Delete user's markings of soft-deleted messages\n\t\tfilter := b.M{\"topic\": b.M{\"$in\": topics}, \"deletedfor.user\": forUser}\n\t\tif _, err = a.db.Collection(\"messages\").\n\t\t\tUpdateMany(sc, filter, b.M{\"$pull\": b.M{\"deletedfor\": b.M{\"user\": forUser}}}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// Delete/mark deleted subscriptions and decrement subcnt in topic.\nfunc (a *adapter) subsDelete(ctx context.Context, filter b.M, hard bool) error {\n\t// First, decrement subscription count in all affected topics.\n\t// Doing it in two steps because MongoDB does not support an equivalent of\n\t// 'UPDATE .. LEFT JOIN ...'.\n\tfilterWithDeletedAt := copyBsonMap(filter)\n\tfilterWithDeletedAt[\"deletedat\"] = b.M{\"$exists\": false}\n\tcur, err := a.db.Collection(\"subscriptions\").Find(ctx, filterWithDeletedAt,\n\t\tmdbopts.Find().SetProjection(b.D{{\"topic\", 1}, {\"_id\", 0}}))\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer cur.Close(ctx)\n\tvar topics []string\n\tfor cur.Next(ctx) {\n\t\tvar result struct {\n\t\t\tTopic string `bson:\"topic\"`\n\t\t}\n\t\tif err = cur.Decode(&result); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif t.IsChannel(result.Topic) {\n\t\t\t// Convert channel name to group name.\n\t\t\ttopics = append(topics, t.ChnToGrp(result.Topic))\n\t\t}\n\t\ttopics = append(topics, result.Topic)\n\t}\n\n\tif err = cur.Err(); err != nil {\n\t\treturn err\n\t}\n\n\tif len(topics) > 0 {\n\t\t// Decrement subscription count in affected topics.\n\t\ta.db.Collection(\"topics\").UpdateMany(ctx,\n\t\t\tb.M{\"_id\": b.M{\"$in\": topics}},\n\t\t\tb.M{\"$inc\": b.M{\"subcnt\": -1}})\n\t}\n\n\t// Now delete or mark deleted the subscriptions.\n\tif hard {\n\t\t_, err = a.db.Collection(\"subscriptions\").DeleteMany(ctx, filter)\n\t} else {\n\t\tnow := t.TimeNow()\n\t\t_, err = a.db.Collection(\"subscriptions\").UpdateMany(ctx, filterWithDeletedAt,\n\t\t\tb.M{\"$set\": b.M{\"updatedat\": now, \"deletedat\": now}})\n\t}\n\treturn err\n}\n\n// Find searches for contacts and topics given a list of tags.\nfunc (a *adapter) Find(caller, prefPrefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error) {\n\t/*\n\t\t// MongoDB aggregation pipeline using unionWith.\n\t\t[\n\t\t\t{ $match: { tags: { $in: [\"basic:alice\", \"travel\"] } } },\n\t\t\t{ $unionWith: {\n\t\t\t\t\tcoll: \"topics\",\n\t\t\t\t\tpipeline: [ { $match: { tags: { $in: [\"basic:alice\", \"travel\"] } } } ]\n\t\t\t\t}\n\t\t\t},\n\t\t\t{ $project: { _id: 1, access: 1, createdat: 1, updatedat: 1, usebt: 1, public: 1, trusted: 1, tags: 1, _source: 1 } },\n\t\t\t{ $addFields: { matchedCount: { $sum: { $map: {\n\t\t\t\tinput: { $setIntersection: [ \"$tags\", [ \"alias:aliassa\", \"basic:alice\", \"travel\" ] ] },\n\t\t\t\tas: \"tag\",\n\t\t\t\tin: { $cond: { if: { $regexMatch: { input: \"$$tag\", regex: \"^alias:\"} }, then: 20, else: 1 } }\n\t\t\t} }}}},\n\t\t\t{ $match: { $expr: { $ne: [ { $size: { $setIntersection: [ \"$tags\", [\"basic:alice\", \"travel\"] ] } }, 0 ] } } },\n\t\t\t{ $sort: { matchedCount: -1 } },\n\t\t\t{ $limit: 20 }\n\t\t]\n\n\t\t// Alternative approach using $facet for (supposedly) better performance:\n\t\t[ { $facet: {\n\t\t\t\t\tusers: [\n\t\t\t\t\t\t{ $match: { tags: { $in: [ \"alias:alice\", \"basic:alice\", \"travel\" ] } } },\n\t\t\t\t\t\t{ $project: { _id: 1, access: 1, createdat: 1, updatedat: 1, usebt: 1, public: 1, trusted: 1, tags: 1 } }\n\t\t\t\t\t],\n\t\t\t\t\ttopics: [\n\t\t\t\t\t\t{ $lookup: {\n\t\t\t\t\t\t\tfrom: \"topics\",\n\t\t\t\t\t\t\tpipeline: [\n\t\t\t\t\t\t\t\t{ $match: { tags: { $in: [ \"alias:alice\", \"basic:alice\", \"travel\" ] } } },\n\t\t\t\t\t\t\t\t{ $project: { _id: 1, access: 1, createdat: 1, updatedat: 1, usebt: 1, public: 1, trusted: 1, tags: 1 } } }\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\tas: \"topicDocs\"\n\t\t\t\t\t\t}},\n\t\t\t\t\t\t{ $unwind: \"$topicDocs\" },\n\t\t\t\t\t\t{ $replaceRoot: { newRoot: \"$topicDocs\" } }\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t},\n\t\t\t{ $project: { combined: { $concatArrays: [\"$users\", \"$topics\"] } } },\n\t\t\t{ $unwind: \"$combined\" },\n\t\t\t{ $replaceRoot: { newRoot: \"$combined\" } },\n\t\t\t{ $group: { _id: \"$_id\", doc: { $first: \"$$ROOT\" } } },\n\t\t\t{ $replaceRoot: { newRoot: \"$doc\" } },\n\t\t\t{ $addFields: { matchedCount:\n\t\t\t\t{ $sum: { $map: { input:\n\t\t\t\t\t{ $setIntersection: [ \"$tags\", [ \"alias:alice\", \"basic:alice\", \"travel\" ] ] },\n\t\t\t\t\tas: \"tag\",\n\t\t\t\t\tin: {\n\t\t\t\t\t$cond: {\n\t\t\t\t\t\tif: { $regexMatch: { input: \"$$tag\", regex: \"^alias:\" } }, then: 20, else: 1 }\n\t\t\t\t\t}\n\t\t\t\t} }\n\t\t\t} } },\n\t\t\t{ $match: { $expr: { $ne: [\n\t\t\t\t{ $size: { $setIntersection: [ \"$tags\", [ \"alias:alice\", \"basic:alice\", \"travel\" ] ] } },\n\t\t\t\t0\n\t\t\t] } } },\n\t\t\t{ $sort: { matchedCount: -1 } },\n\t\t\t{ $limit: 20 }\n\t\t]\n\t*/\n\n\tindex := make(map[string]struct{})\n\tallReq := t.FlattenDoubleSlice(req)\n\tvar allTags []any\n\tfor _, tag := range append(allReq, opt...) {\n\t\tallTags = append(allTags, tag)\n\t\tindex[tag] = struct{}{}\n\t}\n\n\tmatchOn := b.M{\"tags\": b.M{\"$in\": allTags}}\n\tif activeOnly {\n\t\tmatchOn[\"state\"] = b.M{\"$eq\": t.StateOK}\n\t}\n\n\tprojectFields := b.M{\"_id\": 1, \"createdat\": 1, \"updatedat\": 1, \"usebt\": 1,\n\t\t\"access\": 1, \"subcnt\": 1, \"public\": 1, \"trusted\": 1, \"tags\": 1}\n\n\tpipeline := b.A{\n\t\t// Stage 1: $facet\n\t\tb.M{\n\t\t\t\"$facet\": b.D{\n\t\t\t\t{\"users\", b.A{\n\t\t\t\t\tb.M{\"$match\": matchOn},\n\t\t\t\t\tb.M{\"$project\": projectFields},\n\t\t\t\t}},\n\t\t\t\t{\"topics\", b.A{\n\t\t\t\t\tb.M{\"$lookup\": b.D{\n\t\t\t\t\t\t{\"from\", \"topics\"},\n\t\t\t\t\t\t{\"pipeline\", b.A{\n\t\t\t\t\t\t\tb.M{\"$match\": matchOn},\n\t\t\t\t\t\t\tb.M{\"$project\": projectFields},\n\t\t\t\t\t\t}},\n\t\t\t\t\t\t{\"as\", \"topicDocs\"},\n\t\t\t\t\t}},\n\t\t\t\t\tb.M{\"$unwind\": \"$topicDocs\"},\n\t\t\t\t\tb.M{\"$replaceRoot\": b.M{\"newRoot\": \"$topicDocs\"}},\n\t\t\t\t}},\n\t\t\t},\n\t\t},\n\t\t// Stage 2: $project\n\t\tb.M{\"$project\": b.M{\"combined\": b.M{\"$concatArrays\": b.A{\"$users\", \"$topics\"}}}},\n\t\t// Stage 3: $unwind\n\t\tb.M{\"$unwind\": \"$combined\"},\n\t\t// Stage 4: $replaceRoot\n\t\tb.M{\"$replaceRoot\": b.M{\"newRoot\": \"$combined\"}},\n\t\t// Stage 5: $group\n\t\tb.M{\"$group\": b.D{{\"_id\", \"$_id\"}, {\"doc\", b.M{\"$first\": \"$$ROOT\"}}}},\n\t\t// Stage 6: $replaceRoot\n\t\tb.M{\"$replaceRoot\": b.M{\"newRoot\": \"$doc\"}},\n\t\t// Stage 7: $addFields\n\t\tb.M{\"$addFields\": b.M{\"matchedCount\": b.M{\"$sum\": b.M{\"$map\": b.D{\n\t\t\t{\"input\", b.M{\"$setIntersection\": b.A{\"$tags\", allTags}}},\n\t\t\t{\"as\", \"tag\"},\n\t\t\t{\"in\", b.D{\n\t\t\t\t{\"$cond\", b.D{\n\t\t\t\t\t{\"if\", b.M{\"$regexMatch\": b.D{\n\t\t\t\t\t\t{\"input\", \"$$tag\"},\n\t\t\t\t\t\t{\"regex\", \"^alias:\"},\n\t\t\t\t\t},\n\t\t\t\t\t}},\n\t\t\t\t\t{\"then\", 20},\n\t\t\t\t\t{\"else\", 1},\n\t\t\t\t}}}}},\n\t\t}}}},\n\t}\n\n\t// Ensure required tags are present.\n\tfor _, reqDisjunction := range req {\n\t\tif len(reqDisjunction) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tvar reqTags []any\n\t\tfor _, tag := range reqDisjunction {\n\t\t\treqTags = append(reqTags, tag)\n\t\t}\n\t\t// Filter out documents where 'tags' intersection with 'reqTags' is an empty array.\n\t\tpipeline = append(pipeline,\n\t\t\tb.M{\"$match\": b.M{\"$expr\": b.M{\"$ne\": b.A{b.M{\"$size\": b.M{\"$setIntersection\": b.A{\"$tags\", reqTags}}}, 0}}}})\n\t}\n\n\tpipeline = append(pipeline,\n\t\t// Stage 9: $sort\n\t\tb.M{\"$sort\": b.D{{\"matchedCount\", -1}, {\"subcnt\", -1}}},\n\t\t// Stage 10: $limit\n\t\tb.M{\"$limit\": a.maxResults},\n\t)\n\n\tcur, err := a.db.Collection(\"users\").Aggregate(a.ctx, pipeline)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cur.Close(a.ctx)\n\n\tvar subs []t.Subscription\n\tfor cur.Next(a.ctx) {\n\t\tvar topic t.Topic\n\t\tvar sub t.Subscription\n\t\tif err = cur.Decode(&topic); err != nil {\n\t\t\tbreak\n\t\t}\n\n\t\tif topic.UseBt {\n\t\t\t// This is a channel, convert grp to chn name: all channel-capable\n\t\t\t// topics should appear as channels in search results.\n\t\t\tsub.Topic = t.GrpToChn(topic.Id)\n\t\t} else {\n\t\t\tif uid := t.ParseUid(topic.Id); !uid.IsZero() {\n\t\t\t\ttopic.Id = uid.UserId()\n\t\t\t\tif topic.Id == caller {\n\t\t\t\t\t// Skip the caller.\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t\tsub.Topic = topic.Id\n\t\t}\n\n\t\tsub.CreatedAt = topic.CreatedAt\n\t\tsub.UpdatedAt = topic.UpdatedAt\n\t\tsub.SetSubCnt(topic.SubCnt)\n\t\tsub.SetPublic(unmarshalBsonD(topic.Public))\n\t\tsub.SetTrusted(unmarshalBsonD(topic.Trusted))\n\t\tsub.SetDefaultAccess(topic.Access.Auth, topic.Access.Anon)\n\t\t// Indicating that the mode is not set, not 'N'.\n\t\tsub.ModeGiven = t.ModeUnset\n\t\tsub.ModeWant = t.ModeUnset\n\t\tsub.Private = common.FilterFoundTags(topic.Tags, index)\n\t\tsubs = append(subs, sub)\n\t}\n\tif err == nil {\n\t\terr = cur.Err()\n\t}\n\n\treturn subs, err\n}\n\n// FindOne returns the first topic or user which matches the given tag.\nfunc (a *adapter) FindOne(tag string) (string, error) {\n\t// Part of the pipeline identical for users and topics collections.\n\tcommonPipe := b.A{b.M{\"$match\": b.M{\"tags\": tag}}, b.M{\"$project\": b.M{\"_id\": 1}}}\n\n\t// Must create a copy of commonPipe so the original commonPipe can be used unmodified in $unionWith.\n\tpipeline := append(slices.Clone(commonPipe),\n\t\tb.M{\"$unionWith\": b.M{\"coll\": \"topics\", \"pipeline\": commonPipe}},\n\t\tb.M{\"$limit\": 1})\n\tcur, err := a.db.Collection(\"users\").Aggregate(a.ctx, pipeline)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer cur.Close(a.ctx)\n\n\tvar found string\n\tif cur.Next(a.ctx) {\n\t\tentry := map[string]any{}\n\t\tif err = cur.Decode(&entry); err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\n\t\tif id, ok := entry[\"_id\"].(string); ok {\n\t\t\tif user := t.ParseUid(id); !user.IsZero() {\n\t\t\t\tfound = user.UserId()\n\t\t\t} else {\n\t\t\t\tfound = id\n\t\t\t}\n\t\t}\n\t}\n\n\treturn found, cur.Err()\n}\n\n// Messages\n\n// MessageSave saves message to database\nfunc (a *adapter) MessageSave(msg *t.Message) error {\n\t_, err := a.db.Collection(\"messages\").InsertOne(a.ctx, msg)\n\treturn err\n}\n\n// MessageGetAll returns messages matching the query.\nfunc (a *adapter) MessageGetAll(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.Message, error) {\n\tvar limit = a.maxMessageResults\n\tvar lower, upper int\n\trequester := forUser.String()\n\tif opts != nil {\n\t\tif opts.Since > 0 {\n\t\t\tlower = opts.Since\n\t\t}\n\t\tif opts.Before > 0 {\n\t\t\tupper = opts.Before\n\t\t}\n\n\t\tif opts.Limit > 0 && opts.Limit < limit {\n\t\t\tlimit = opts.Limit\n\t\t}\n\t}\n\tfilter := b.M{\n\t\t\"topic\":           topic,\n\t\t\"delid\":           b.M{\"$exists\": false},\n\t\t\"deletedfor.user\": b.M{\"$ne\": requester},\n\t}\n\tif upper == 0 {\n\t\tfilter[\"seqid\"] = b.M{\"$gte\": lower}\n\t} else {\n\t\tfilter[\"seqid\"] = b.M{\"$gte\": lower, \"$lt\": upper}\n\t}\n\tfindOpts := mdbopts.Find().SetSort(b.D{{\"topic\", -1}, {\"seqid\", -1}})\n\tfindOpts.SetLimit(int64(limit))\n\n\tcur, err := a.db.Collection(\"messages\").Find(a.ctx, filter, findOpts)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cur.Close(a.ctx)\n\n\tvar msgs []t.Message\n\tfor cur.Next(a.ctx) {\n\t\tvar msg t.Message\n\t\tif err = cur.Decode(&msg); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tmsg.Content = unmarshalBsonD(msg.Content)\n\t\tmsgs = append(msgs, msg)\n\t}\n\n\treturn msgs, nil\n}\n\nfunc (a *adapter) messagesHardDelete(topic string) error {\n\tvar err error\n\n\t// TODO: handle file uploads\n\tfilter := b.M{\"topic\": topic}\n\tif _, err = a.db.Collection(\"dellog\").DeleteMany(a.ctx, filter); err != nil {\n\t\treturn err\n\t}\n\n\tif err = a.decFileUseCounter(a.ctx, \"messages\", filter); err != nil {\n\t\treturn err\n\t}\n\n\tif _, err = a.db.Collection(\"messages\").DeleteMany(a.ctx, filter); err != nil {\n\t\treturn err\n\t}\n\n\treturn err\n}\n\n// rangeToFilter is Mongo's equivalent of common.RangeToSql.\nfunc rangeToFilter(delRanges []t.Range, filter b.M) b.M {\n\tif len(delRanges) > 1 || delRanges[0].Hi == 0 {\n\t\trangeFilter := b.A{}\n\t\tfor _, rng := range delRanges {\n\t\t\tif rng.Hi == 0 {\n\t\t\t\trangeFilter = append(rangeFilter, b.M{\"seqid\": rng.Low})\n\t\t\t} else {\n\t\t\t\trangeFilter = append(rangeFilter, b.M{\"seqid\": b.M{\"$gte\": rng.Low, \"$lt\": rng.Hi}})\n\t\t\t}\n\t\t}\n\t\tfilter[\"$or\"] = rangeFilter\n\t} else {\n\t\tfilter[\"seqid\"] = b.M{\"$gte\": delRanges[0].Low, \"$lt\": delRanges[0].Hi}\n\t}\n\treturn filter\n}\n\n// MessageDeleteList marks messages as deleted.\n// Soft- or Hard- is defined by forUser value: forUSer.IsZero == true is hard.\nfunc (a *adapter) MessageDeleteList(topic string, toDel *t.DelMessage) error {\n\tvar err error\n\n\tif toDel == nil {\n\t\t// No filter: delete all messages.\n\t\treturn a.messagesHardDelete(topic)\n\t}\n\n\t// Only some messages are being deleted\n\n\tdelRanges := toDel.SeqIdRanges\n\tfilter := b.M{\n\t\t\"topic\": topic,\n\t\t// Skip already hard-deleted messages.\n\t\t\"delid\": b.M{\"$exists\": false},\n\t}\n\t// Mongo's equivalent of common.RangeToSql\n\trangeToFilter(delRanges, filter)\n\n\tif toDel.DeletedFor == \"\" {\n\t\t// Hard-deleting messages requires updates to the messages table.\n\n\t\t// We are asked to delete messages no older than newerThan.\n\t\tif newerThan := toDel.GetNewerThan(); newerThan != nil {\n\t\t\tfilter[\"createdat\"] = b.M{\"$gt\": newerThan}\n\t\t}\n\n\t\tpipeline := b.A{\n\t\t\tb.M{\"$match\": filter},\n\t\t\tb.M{\"$project\": b.M{\"seqid\": 1}},\n\t\t}\n\n\t\t// Find the actual IDs still present in the database.\n\n\t\tcur, err := a.db.Collection(\"messages\").Aggregate(a.ctx, pipeline)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer cur.Close(a.ctx)\n\n\t\tvar seqIDs []int\n\t\tfor cur.Next(a.ctx) {\n\t\t\tvar result struct {\n\t\t\t\tSeqID int `bson:\"seqid\"`\n\t\t\t}\n\t\t\tif err = cur.Decode(&result); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tseqIDs = append(seqIDs, result.SeqID)\n\t\t}\n\n\t\tif len(seqIDs) == 0 {\n\t\t\t// Nothing to delete. No need to make a log entry. All done.\n\t\t\treturn nil\n\t\t}\n\n\t\t// Recalculate the actual ranges to delete.\n\t\tsort.Ints(seqIDs)\n\t\tdelRanges = t.SliceToRanges(seqIDs)\n\n\t\t// Compose a new query with the new ranges.\n\t\tfilter = b.M{\n\t\t\t\"topic\": topic,\n\t\t}\n\t\trangeToFilter(delRanges, filter)\n\n\t\tif err = a.decFileUseCounter(a.ctx, \"messages\", filter); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// Hard-delete individual messages. Message is not deleted but all fields with content\n\t\t// are replaced with nulls.\n\t\t_, err = a.db.Collection(\"messages\").UpdateMany(a.ctx, filter, b.M{\"$set\": b.M{\n\t\t\t\"deletedat\":   t.TimeNow(),\n\t\t\t\"delid\":       toDel.DelId,\n\t\t\t\"from\":        \"\",\n\t\t\t\"head\":        nil,\n\t\t\t\"content\":     nil,\n\t\t\t\"attachments\": nil}})\n\t} else {\n\t\t// Soft-deleting: adding DelId to DeletedFor\n\n\t\t// Skip messages already soft-deleted for the current user\n\t\tfilter[\"deletedfor.user\"] = b.M{\"$ne\": toDel.DeletedFor}\n\n\t\t_, err = a.db.Collection(\"messages\").UpdateMany(a.ctx, filter,\n\t\t\tb.M{\"$addToSet\": b.M{\n\t\t\t\t\"deletedfor\": &t.SoftDelete{\n\t\t\t\t\tUser:  toDel.DeletedFor,\n\t\t\t\t\tDelId: toDel.DelId,\n\t\t\t\t}}})\n\t}\n\n\t// Make log entries. Needed for both hard- and soft-deleting.\n\t_, err = a.db.Collection(\"dellog\").InsertOne(a.ctx, toDel)\n\treturn err\n}\n\n// MessageGetDeleted returns a list of deleted message Ids.\nfunc (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) {\n\tvar limit = a.maxResults\n\tvar lower, upper int\n\tif opts != nil {\n\t\tif opts.Since > 0 {\n\t\t\tlower = opts.Since\n\t\t}\n\t\tif opts.Before > 0 {\n\t\t\tupper = opts.Before\n\t\t}\n\t\tif opts.Limit > 0 && opts.Limit < limit {\n\t\t\tlimit = opts.Limit\n\t\t}\n\t}\n\tfilter := b.M{\n\t\t\"topic\": topic,\n\t\t\"$or\": b.A{\n\t\t\tb.M{\"deletedfor\": forUser.String()},\n\t\t\tb.M{\"deletedfor\": \"\"},\n\t\t}}\n\tif upper == 0 {\n\t\tfilter[\"delid\"] = b.M{\"$gte\": lower}\n\t} else {\n\t\tfilter[\"delid\"] = b.M{\"$gte\": lower, \"$lt\": upper}\n\t}\n\tfindOpts := mdbopts.Find().\n\t\tSetSort(b.D{{\"topic\", 1}, {\"delid\", 1}}).\n\t\tSetLimit(int64(limit))\n\n\tcur, err := a.db.Collection(\"dellog\").Find(a.ctx, filter, findOpts)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cur.Close(a.ctx)\n\n\tvar dmsgs []t.DelMessage\n\tif err = cur.All(a.ctx, &dmsgs); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn dmsgs, nil\n}\n\n// Devices (for push notifications).\n\n// DeviceUpsert creates or updates a device record.\nfunc (a *adapter) DeviceUpsert(uid t.Uid, dev *t.DeviceDef) error {\n\tuserId := uid.String()\n\tvar user t.User\n\terr := a.db.Collection(\"users\").FindOne(a.ctx, b.M{\n\t\t\"_id\":              userId,\n\t\t\"devices.deviceid\": dev.DeviceId}).Decode(&user)\n\n\tif err == nil && user.Id != \"\" { // current user owns this device\n\t\t// ArrayFilter used to avoid adding another (duplicate) device object. Update that device data\n\t\tupdOpts := mdbopts.Update().SetArrayFilters(mdbopts.ArrayFilters{\n\t\t\tFilters: []any{b.M{\"dev.deviceid\": dev.DeviceId}}})\n\t\t_, err = a.db.Collection(\"users\").UpdateOne(a.ctx,\n\t\t\tb.M{\"_id\": userId},\n\t\t\tb.M{\"$set\": b.M{\n\t\t\t\t\"devices.$[dev].platform\": dev.Platform,\n\t\t\t\t\"devices.$[dev].lastseen\": dev.LastSeen,\n\t\t\t\t\"devices.$[dev].lang\":     dev.Lang}},\n\t\t\tupdOpts)\n\t\treturn err\n\t} else if err == mdb.ErrNoDocuments { // device is free or owned by other user\n\t\terr = a.deviceInsert(userId, dev)\n\n\t\tif isDuplicateErr(err) {\n\t\t\t// Other user owns this device.\n\t\t\t// We need to delete this device from that user and then insert again\n\t\t\tif _, err = a.db.Collection(\"users\").UpdateOne(a.ctx,\n\t\t\t\tb.M{\"devices.deviceid\": dev.DeviceId},\n\t\t\t\tb.M{\"$pull\": b.M{\"devices\": b.M{\"deviceid\": dev.DeviceId}}}); err != nil {\n\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.deviceInsert(userId, dev)\n\t\t}\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t}\n\n\treturn err\n}\n\n// deviceInsert adds device object to user.devices array\nfunc (a *adapter) deviceInsert(userId string, dev *t.DeviceDef) error {\n\tfilter := b.M{\"_id\": userId}\n\t_, err := a.db.Collection(\"users\").UpdateOne(a.ctx, filter,\n\t\tb.M{\"$push\": b.M{\"devices\": dev}})\n\n\tif err != nil && strings.Contains(err.Error(), \"must be an array\") {\n\t\t// field 'devices' is not array. Make it array with 'dev' as its first element\n\t\t_, err = a.db.Collection(\"users\").UpdateOne(a.ctx, filter,\n\t\t\tb.M{\"$set\": b.M{\"devices\": []any{dev}}})\n\t}\n\n\treturn err\n}\n\n// DeviceGetAll returns all devices for a given set of users.\nfunc (a *adapter) DeviceGetAll(uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {\n\tids := make([]any, len(uids))\n\tfor i, id := range uids {\n\t\tids[i] = id.String()\n\t}\n\n\tfilter := b.M{\"_id\": b.M{\"$in\": ids}}\n\tfindOpts := mdbopts.Find().SetProjection(b.M{\"_id\": 1, \"devices\": 1})\n\tcur, err := a.db.Collection(\"users\").Find(a.ctx, filter, findOpts)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\tdefer cur.Close(a.ctx)\n\n\tresult := make(map[t.Uid][]t.DeviceDef)\n\tcount := 0\n\tvar uid t.Uid\n\tfor cur.Next(a.ctx) {\n\t\tvar row struct {\n\t\t\tId      string `bson:\"_id\"`\n\t\t\tDevices []t.DeviceDef\n\t\t}\n\t\tif err = cur.Decode(&row); err != nil {\n\t\t\treturn nil, 0, err\n\t\t}\n\t\tif len(row.Devices) > 0 {\n\t\t\tif err := uid.UnmarshalText([]byte(row.Id)); err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tresult[uid] = row.Devices\n\t\t\tcount++\n\t\t}\n\t}\n\treturn result, count, cur.Err()\n}\n\n// DeviceDelete deletes a device record (push token).\nfunc (a *adapter) DeviceDelete(uid t.Uid, deviceID string) error {\n\tvar err error\n\tfilter := b.M{\"_id\": uid.String()}\n\tupdate := b.M{}\n\tif deviceID == \"\" {\n\t\tupdate[\"$set\"] = b.M{\"devices\": []any{}}\n\t} else {\n\t\tupdate[\"$pull\"] = b.M{\"devices\": b.M{\"deviceid\": deviceID}}\n\t}\n\t_, err = a.db.Collection(\"users\").UpdateOne(a.ctx, filter, update)\n\treturn err\n}\n\n// File upload records. The files are stored outside of the database.\n\n// FileStartUpload initializes a file upload\nfunc (a *adapter) FileStartUpload(fd *t.FileDef) error {\n\t_, err := a.db.Collection(\"fileuploads\").InsertOne(a.ctx, fd)\n\treturn err\n}\n\n// FileFinishUpload marks file upload as completed, successfully or otherwise.\nfunc (a *adapter) FileFinishUpload(fd *t.FileDef, success bool, size int64) (*t.FileDef, error) {\n\tnow := t.TimeNow()\n\tif success {\n\t\t// Mark upload as completed.\n\t\tif _, err := a.db.Collection(\"fileuploads\").UpdateOne(a.ctx,\n\t\t\tb.M{\"_id\": fd.Id},\n\t\t\tb.M{\"$set\": b.M{\n\t\t\t\t\"updatedat\": now,\n\t\t\t\t\"status\":    t.UploadCompleted,\n\t\t\t\t\"size\":      size,\n\t\t\t\t\"etag\":      fd.ETag,\n\t\t\t\t\"location\":  fd.Location,\n\t\t\t}}); err != nil {\n\n\t\t\treturn nil, err\n\t\t}\n\t\tfd.Status = t.UploadCompleted\n\t\tfd.Size = size\n\t} else {\n\t\t// Remove record: it's now useless.\n\t\tif _, err := a.db.Collection(\"fileuploads\").DeleteOne(a.ctx, b.M{\"_id\": fd.Id}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tfd.Status = t.UploadFailed\n\t\tfd.Size = 0\n\t}\n\n\tfd.UpdatedAt = now\n\n\treturn fd, nil\n}\n\n// FileGet fetches a record of a specific file\nfunc (a *adapter) FileGet(fid string) (*t.FileDef, error) {\n\tvar fd t.FileDef\n\terr := a.db.Collection(\"fileuploads\").FindOne(a.ctx, b.M{\"_id\": fid}).Decode(&fd)\n\tif err != nil {\n\t\tif err == mdb.ErrNoDocuments {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\n\treturn &fd, nil\n}\n\n// FileDeleteUnused deletes records where UseCount is zero. If olderThan is non-zero, deletes\n// unused records with UpdatedAt before olderThan.\n// Returns array of FileDef.Location of deleted filerecords so actual files can be deleted too.\nfunc (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, error) {\n\tfindOpts := mdbopts.Find()\n\tfilter := b.M{\"$or\": b.A{\n\t\tb.M{\"usecount\": 0},\n\t\tb.M{\"usecount\": b.M{\"$exists\": false}}}}\n\tif !olderThan.IsZero() {\n\t\tfilter[\"updatedat\"] = b.M{\"$lt\": olderThan}\n\t}\n\tif limit > 0 {\n\t\tfindOpts.SetLimit(int64(limit))\n\t}\n\n\tfindOpts.SetProjection(b.M{\"location\": 1, \"_id\": 0})\n\tcur, err := a.db.Collection(\"fileuploads\").Find(a.ctx, filter, findOpts)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cur.Close(a.ctx)\n\n\tvar locations []string\n\tfor cur.Next(a.ctx) {\n\t\tvar result map[string]string\n\t\tif err := cur.Decode(&result); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tlocations = append(locations, result[\"location\"])\n\t}\n\n\t_, err = a.db.Collection(\"fileuploads\").DeleteMany(a.ctx, filter)\n\treturn locations, err\n}\n\n// Given a filter query against 'messages' collection, decrement corresponding use counter in 'fileuploads' table.\nfunc (a *adapter) decFileUseCounter(ctx context.Context, collection string, msgFilter b.M) error {\n\t// Copy msgFilter\n\tfilter := b.M{}\n\tfor k, v := range msgFilter {\n\t\tfilter[k] = v\n\t}\n\tfilter[\"attachments\"] = b.M{\"$exists\": true}\n\tfileIds, err := a.db.Collection(collection).Distinct(ctx, \"attachments\", filter)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif len(fileIds) > 0 {\n\t\t_, err = a.db.Collection(\"fileuploads\").UpdateMany(ctx,\n\t\t\tb.M{\"_id\": b.M{\"$in\": fileIds}},\n\t\t\tb.M{\"$inc\": b.M{\"usecount\": -1}})\n\t}\n\n\treturn err\n}\n\n// FileLinkAttachments connects given topic or message to the file record IDs from the list.\nfunc (a *adapter) FileLinkAttachments(topic string, userId, msgId t.Uid, fids []string) error {\n\tif len(fids) == 0 || (topic == \"\" && userId.IsZero() && msgId.IsZero()) {\n\t\treturn t.ErrMalformed\n\t}\n\n\tnow := t.TimeNow()\n\tvar err error\n\n\tif msgId.IsZero() {\n\t\t// Only one link per user or topic is permitted.\n\t\tfids = fids[0:1]\n\n\t\t// Topics and users and mutable. Must unlink the previous attachments first.\n\t\tvar table string\n\t\tvar linkId string\n\t\tif topic != \"\" {\n\t\t\ttable = \"topics\"\n\t\t\tlinkId = topic\n\t\t} else {\n\t\t\ttable = \"users\"\n\t\t\tlinkId = userId.String()\n\t\t}\n\n\t\t// Find the old attachment.\n\t\tvar attachments map[string][]string\n\t\tfindOpts := mdbopts.FindOne().SetProjection(b.M{\"attachments\": 1, \"_id\": 0})\n\t\terr = a.db.Collection(table).FindOne(a.ctx, b.M{\"_id\": linkId}, findOpts).Decode(&attachments)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(attachments[\"attachments\"]) > 0 {\n\t\t\t// Decrement the use count of old attachment.\n\t\t\tif _, err = a.db.Collection(\"fileuploads\").UpdateOne(a.ctx,\n\t\t\t\tb.M{\"_id\": attachments[\"attachments\"][0]},\n\t\t\t\tb.M{\n\t\t\t\t\t\"$set\": b.M{\"updatedat\": now},\n\t\t\t\t\t\"$inc\": b.M{\"usecount\": -1},\n\t\t\t\t},\n\t\t\t); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t_, err = a.db.Collection(table).UpdateOne(a.ctx,\n\t\t\tb.M{\"_id\": linkId},\n\t\t\tb.M{\"$set\": b.M{\"updatedat\": now, \"attachments\": fids}})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\t_, err = a.db.Collection(\"messages\").UpdateOne(a.ctx,\n\t\t\tb.M{\"_id\": msgId.String()},\n\t\t\tb.M{\"$set\": b.M{\"updatedat\": now, \"attachments\": fids}})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tids := make([]any, len(fids))\n\tfor i, id := range fids {\n\t\tids[i] = id\n\t}\n\t_, err = a.db.Collection(\"fileuploads\").UpdateMany(a.ctx,\n\t\tb.M{\"_id\": b.M{\"$in\": ids}},\n\t\tb.M{\n\t\t\t\"$set\": b.M{\"updatedat\": now},\n\t\t\t\"$inc\": b.M{\"usecount\": 1},\n\t\t},\n\t)\n\n\treturn err\n}\n\n// PCacheGet reads a persistet cache entry.\nfunc (a *adapter) PCacheGet(key string) (string, error) {\n\tvar value map[string]string\n\tfindOpts := mdbopts.FindOneOptions{Projection: b.M{\"value\": 1, \"_id\": 0}}\n\tif err := a.db.Collection(\"kvmeta\").FindOne(a.ctx, b.M{\"_id\": key}, &findOpts).Decode(&value); err != nil {\n\t\tif err == mdb.ErrNoDocuments {\n\t\t\terr = t.ErrNotFound\n\t\t}\n\t\treturn \"\", err\n\t}\n\treturn value[\"value\"], nil\n}\n\n// PCacheUpsert creates or updates a persistent cache entry.\nfunc (a *adapter) PCacheUpsert(key string, value string, failOnDuplicate bool) error {\n\tif strings.Contains(key, \"^\") {\n\t\t// Do not allow ^ in keys: it interferes with $match query.\n\t\treturn t.ErrMalformed\n\t}\n\n\tcollection := a.db.Collection(\"kvmeta\")\n\tdoc := b.M{\n\t\t\"value\": value,\n\t}\n\n\tif failOnDuplicate {\n\t\tdoc[\"_id\"] = key\n\t\tdoc[\"createdat\"] = t.TimeNow()\n\t\t_, err := collection.InsertOne(a.ctx, doc)\n\t\tif mdb.IsDuplicateKeyError(err) {\n\t\t\terr = t.ErrDuplicate\n\t\t}\n\t\treturn err\n\t}\n\n\tres := collection.FindOneAndUpdate(a.ctx, b.M{\"_id\": key}, b.M{\"$set\": doc},\n\t\tmdbopts.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(mdbopts.After))\n\treturn res.Err()\n}\n\n// PCacheDelete deletes one persistent cache entry.\nfunc (a *adapter) PCacheDelete(key string) error {\n\t_, err := a.db.Collection(\"kvmeta\").DeleteOne(a.ctx, b.M{\"_id\": key})\n\treturn err\n}\n\n// PCacheExpire expires old entries with the given key prefix.\nfunc (a *adapter) PCacheExpire(keyPrefix string, olderThan time.Time) error {\n\tif keyPrefix == \"\" {\n\t\treturn t.ErrMalformed\n\t}\n\n\t_, err := a.db.Collection(\"kvmeta\").DeleteMany(a.ctx, b.M{\"createdat\": b.M{\"$lt\": olderThan},\n\t\t\"_id\": primitive.Regex{Pattern: \"^\" + keyPrefix}})\n\treturn err\n}\n\n// GetTestDB returns a currently open database connection.\nfunc (a *adapter) GetTestDB() any {\n\treturn a.db\n}\n\nfunc (a *adapter) isDbInitialized() bool {\n\tvar result map[string]int\n\n\tfindOpts := mdbopts.FindOneOptions{Projection: b.M{\"value\": 1, \"_id\": 0}}\n\tif err := a.db.Collection(\"kvmeta\").FindOne(a.ctx, b.M{\"_id\": \"version\"}, &findOpts).Decode(&result); err != nil {\n\t\treturn false\n\t}\n\treturn true\n}\n\n// GetTestAdapter returns an adapter object. Useful for running tests.\nfunc GetTestAdapter() *adapter {\n\treturn &adapter{}\n}\n\nfunc init() {\n\tstore.RegisterAdapter(&adapter{})\n}\n\nfunc contains(s []string, e string) bool {\n\tfor _, a := range s {\n\t\tif a == e {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc union(userTags, addTags []string) []string {\n\tfor _, tag := range addTags {\n\t\tif !contains(userTags, tag) {\n\t\t\tuserTags = append(userTags, tag)\n\t\t}\n\t}\n\treturn userTags\n}\n\nfunc diff(userTags, removeTags []string) []string {\n\tvar result []string\n\tfor _, tag := range userTags {\n\t\tif !contains(removeTags, tag) {\n\t\t\tresult = append(result, tag)\n\t\t}\n\t}\n\treturn result\n}\n\n// normalizeUpdateMap turns keys that hardcoded as CamelCase into lowercase (MongoDB uses lowercase by default)\nfunc normalizeUpdateMap(update map[string]any) map[string]any {\n\tresult := make(map[string]any, len(update))\n\tfor key, value := range update {\n\t\tresult[strings.ToLower(key)] = value\n\t}\n\n\treturn result\n}\n\n// Recursive unmarshalling of bson.D type.\n// Mongo drivers unmarshalling into 'any' creates bson.D object for maps and bson.A object for slices.\n// We need to manually unmarshal them into correct types: map[string]any and []any respectively.\nfunc unmarshalBsonD(bsonObj any) any {\n\tif obj, ok := bsonObj.(b.D); ok && len(obj) != 0 {\n\t\tresult := make(map[string]any)\n\t\tfor key, val := range obj.Map() {\n\t\t\tresult[key] = unmarshalBsonD(val)\n\t\t}\n\t\treturn result\n\t} else if obj, ok := bsonObj.(primitive.Binary); ok {\n\t\t// primitive.Binary is a struct type with Subtype and Data fields. We need only Data ([]byte)\n\t\treturn obj.Data\n\t} else if obj, ok := bsonObj.(b.A); ok {\n\t\t// in case of array of bson.D objects\n\t\tvar result []any\n\t\tfor _, elem := range obj {\n\t\t\tresult = append(result, unmarshalBsonD(elem))\n\t\t}\n\t\treturn result\n\t}\n\t// Just return value as is\n\treturn bsonObj\n}\n\nfunc copyBsonMap(mp b.M) b.M {\n\tresult := b.M{}\n\tfor k, v := range mp {\n\t\tresult[k] = v\n\t}\n\treturn result\n}\nfunc isDuplicateErr(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\n\tmsg := err.Error()\n\treturn strings.Contains(msg, \"duplicate key error\")\n}\n"
  },
  {
    "path": "server/db/mongodb/blank.go",
    "content": "//go:build !mongodb\n// +build !mongodb\n\n// This file is needed for conditional compilation. It's used when\n// the build tag 'mongodb' is not defined. Otherwise the adapter.go\n// is compiled.\npackage mongodb\n"
  },
  {
    "path": "server/db/mongodb/schema.md",
    "content": "# MongoDB Database Schema\n\n## Database `tinode`\n\n### Table `users`\nStores user accounts\n\nFields:\n* `_id` user id, primary key\n* `createdat` timestamp when the user was created\n* `updatedat` timestamp when user metadata was updated\n* `access` user's default access level for peer-to-peer topics\n    * `auth`, `anon` default permissions for authenticated and anonymous users\n* `public` application-defined data\n* `state` account state: normal (ok), suspended, soft-deleted\n* `stateat` timestamp when the state was last updated or NULL\n* `lastseen` timestamp when the user was last online\n* `useragent` client User-Agent used when last online\n* `tags` unique strings for user discovery\n* `devices` client devices for push notifications\n    * `deviceid` device registration ID\n    * `platform` device platform string (iOS, Android, Web)\n    * `lastseen` last logged in\n    * `lang` device language, ISO code\n\nIndexes:\n * `_id` primary key\n * `tags` multikey-index (indexed array)\n * `deletedat` index\n * `deviceids` multikey-index of push notification tokens\n\nSample:\n```json\n{\n  \"access\": {\n    \"anon\": 0 ,\n    \"auth\": 47\n  } ,\n  \"createdat\": \"2019-10-11T12:13:14.522Z\" , \n  \"state\": 0,\n  \"stateat\": null ,\n  \"devices\": null ,\n  \"_id\": \"7yUCHniegrM\" ,\n  \"lastseen\": \"2019-10-11T12:13:14.522Z\" ,\n  \"public\": {\n    \"fn\": \"Alice Johnson\" ,\n    \"photo\": {\n      \"data\": Binary('/9j/4AAQSkZJRgAB...'),\n      \"type\": \"jpg\"\n    }\n  } ,\n  \"state\": 1 ,\n  \"tags\": [\n    \"email:alice@example.com\" ,\n    \"tel:17025550001\"\n  ] ,\n  \"updatedat\": \"2019-10-11T12:13:14.522Z\",\n  \"useragent\": \"TinodeWeb/0.13 (MacIntel) tinodejs/0.13\"\n}\n```\n\n### Table `auth`\nStores authentication secrets\n\nFields:\n* `_id` unique string which identifies this record, primary key; defined as \"_authentication scheme_':'_some unique value per scheme_\"\n* `userid` ID of the user who owns the record\n* `secret` shared secret, for instance bcrypt of password\n* `authLvl` authentication level\n* `expires` timestamp when the records expires\n\n\nIndexes:\n * `_id` primary key\n * `userid` index\n\nSample:\n```json\n{\n   \"_id\": \"basic:alice\" ,\n   \"authLvl\": 20 ,\n   \"expires\": \"2019-10-11T12:13:14.522Z\" ,\n   \"secret\": Binary('/9j/RgAB...'),\n   \"userid\": \"7yUCHniegrM\"\n}\n```\n\n### Table `topics`\nThe table stores topics.\n\nFields:\n * `_id` name of the topic, primary key\n * `createdat` topic creation time\n * `updatedat` timestamp of the last change to topic metadata\n * `access` stores topic's default access permissions\n    * `auth`, `anon` permissions for authenticated and anonymous users respectively\n * `owner` ID of the user who owns the topic\n * `public` application-defined data\n * `state` topic state: normal (ok), suspended, soft-deleted\n * `stateat` timestamp when the state was last updated or NULL\n * `seqid` sequential ID of the last message\n * `delid` topic-sequential ID of the deletion operation\n * `usebt` currently unused\n\nIndexes:\n* `_id` primary key\n* `owner` index\n* `tags` multikey index\n\nSample:\n```json\n{\n \"access\": {\n  \"anon\": 64 ,\n  \"auth\": 64\n } ,\n \"delid\": 0,\n \"createdat\": \"2019-10-11T12:13:14.522Z\",\n \"lastmessageat\": \"2019-10-11T12:13:14.522Z\" ,\n \"id\":  \"p2pavVGHLCBbKrvJQIeeJ6Csw\" ,\n \"owner\": \"v2JyG4OLSoA\" ,\n \"public\": {\n   \"fn\":  \"Travel, travel, travel\" ,\n   \"photo\": {\n     \"data\": Binary('/9j/RgAB...') ,\n     \"type\":  \"jpg\"\n   }\n } ,\n \"seqid\": 14,\n \"state\": 0,\n \"stateat\": null,\n \"updatedat\": \"2019-10-11T12:13:14.522Z\" ,\n \"usebt\": false\n}\n```\n\n### Table `subscriptions`\nThe table stores relationships between users and topics.\n\nFields:\n * `_id` used for object retrieval\n * `createdat` timestamp when the user was created\n * `updatedat` timestamp when user metadata was updated\n * `deletedat` currently unused\n * `readseqid` id of the message last read by the user\n * `recvseqid` id of the message last received by user device\n * `delid` topic-sequential ID of the soft-deletion operation\n * `topic` name of the topic subscribed to\n * `user` subscriber's user ID\n * `modewant` access mode that user wants when accessing the topic\n * `modegiven` access mode granted to user by the topic\n * `private` application-defined data, accessible by the user only\n\nIndexes:\n * `_id` primary key composed as \"_topic name_':'_user ID_\"\n * `user` index\n * `topic` index\n\nSample:\n```json\n{\n  \"_id\": \"grpjajVKrHn0PU:v2JyG4OLSoA\" ,\n  \"createdat\": \"2019-10-11T12:13:14.522Z\" ,\n  \"updatedat\": \"2019-10-11T12:13:14.522Z\" ,\n  \"deletedat\": null ,\n  \"user\": \"v2JyG4OLSoA\",\n  \"topic\": \"grpjajVKrHn0PU\" ,\n  \"recvseqid\": 0 ,\n  \"readseqid\": 0 ,\n  \"modewant\": 47 ,\n  \"modegiven\": 47 ,\n  \"private\": \"Kirgudu\" ,\n  \"state\": 0 \n}\n```\n\n### Table `messages`\nThe table stores `{data}` messages\n\nFields:\n* `_id` currently unused, primary key\n* `createdat` timestamp when the message was created\n* `updatedat` initially equal to CreatedAt, for deleted messages equal to DeletedAt\n* `deletedfor` array of user IDs which soft-deleted the message\n    * `delid` topic-sequential ID of the soft-deletion operation\n    * `user` ID of the user who soft-deleted the message\n* `from` ID of the user who generated this message\n* `topic` which received this message\n* `seqid` messages ID - sequential number of the message in the topic\n* `head` message headers\n* `attachments` denormalized IDs of files attached to the message\n* `content` application-defined message payload\n\nIndexes:\n * `_id` primary key\n\nSample:\n```json\n{\n  \"_id\":  \"LLXKEe9W4Bs\" ,\n  \"createdat\": \"2019-10-11T12:13:14.522Z\" ,\n  \"updatedat\": \"2019-10-11T12:13:14.522Z\",\n  \"deletedfor\": [\n    {\n      \"delid\": 1 ,\n      \"user\":  \"wTI0jO9rEqY\"\n    }\n  ] ,\n  \"seqid\": 3 ,\n  \"topic\":  \"p2pJhbJnya8z5PBMjSM72sSpg\",\n  \"from\":  \"wTI0jO9rEqY\" ,\n  \"head\": {\n    \"mime\":  \"text/x-drafty\"\n  } ,\n  \"content\": {\n    \"fmt\": [\n      {\n        \"len\": 6 ,\n        \"tp\":  \"ST\"\n      }\n    ] ,\n    \"txt\":  \"Hello!\"\n  }\n}\n```\n\n### Table `dellog`\nThe table stores records of message deletions\n\nFields:\n* `_id` currently unused, primary key\n* `createdat` timestamp when the record was created\n* `updatedat` timestamp equal to CreatedAt\n* `delid` topic-sequential ID of the deletion operation.\n* `deletedfor` ID of the user for soft-deletions, blank string for hard-deletions\n* `topic` affected topic\n* `seqidranges` array of ranges of deleted message IDs (see `messages.seqid`)\n\nIndexes:\n * `_id` primary key\n* `topic_delid` compound index `[\"Topic\", \"DelId\"]`\n\nSample:\n```json\n{\n  \"_id\": \"9LfrjW349Rc\",\n  \"createdat\": \"2019-10-11T12:13:14.522Z\",\n  \"updatedat\": \"2019-10-11T12:13:14.522Z\",\n  \"topic\":  \"grpGx7fpjQwVC0\",\n  \"delid\": 18,\n  \"deletedfor\": \"xY-YHx09-WI\",\n  \"seqidranges\": [\n    {\n      \"low\": 20,\n      \"hi\": 25\n    }\n  ]\n}\n```\n\n### Table `credentials`\nThe tables stores user credentials used for validation.\n\n* `_id` credential, primary key\n* `createdat` timestamp when the record was created\n* `updatedat` timestamp when the last validation attempt was performed (successful or not).\n* `method` validation method\n* `done` indicator if the credential is validated\n* `resp` expected validation response\n* `retries` number of failed attempts at validation\n* `user` id of the user who owns this credential\n* `value` value of the credential\n\nIndexes:\n* `_id` Primary key composed either as `user`:`method`:`value` for unconfirmed credentials or as `method`:`value` for confirmed.\n* `user` Index\n\nSample:\n```json\n{\n  \"Id\": \"tel:+17025550001\",\n  \"CreatedAt\": \"2019-10-11T12:13:14.522Z\",\n  \"UpdatedAt\": \"2019-10-11T12:13:14.522Z\",\n  \"Method\":  \"tel\" ,\n  \"Done\": true ,\n  \"Resp\":  \"123456\" ,\n  \"Retries\": 0 ,\n  \"User\":  \"k3srBRk9RYw\" ,\n  \"Value\":  \"+17025550001\"\n}\n```\n\n### Table `fileuploads`\nThe table stores records of uploaded files. The files themselves are stored outside of the database.\n* `_id` unique user-visible file name, primary key\n* `createdat` timestamp when the record was created\n* `updatedat` timestamp of when th upload has cmpleted or failed\n* `user` id of the user who uploaded this file.\n* `location` actual location of the file on the server.\n* `mimetype` file content type as a [Mime](https://en.wikipedia.org/wiki/MIME) string.\n* `size` size of the file in bytes. Could be 0 if upload has not completed yet.\n* `usecount` count of messages referencing this file.\n* `status` upload status: 0 pending, 1 completed, -1 failed.\n\nIndexes:\n * `_id` file name, primary key\n * `user` index\n * `usecount` index\n\nSample:\n```json\n{\n  \"_id\":  \"sFmjlQ_kA6A\" ,\n  \"createdat\": \"2019-10-11T12:13:14.522Z\" ,\n  \"updatedat\": \"2019-10-11T12:13:14.522Z\" ,\n  \"location\":  \"uploads/sFmjlQ_kA6A\" ,\n  \"mimetype\":  \"image/jpeg\" ,\n  \"size\": 54961090 ,\n  \"usecount\": 3,\n  \"status\": 1 ,\n  \"user\":  \"7j-RR1V7O3Y\"\n}\n```"
  },
  {
    "path": "server/db/mongodb/tests/mongo_test.go",
    "content": "// To test another db backend:\n// 1) Create GetAdapter function inside your db backend adapter package (like one inside mongodb adapter)\n// 2) Uncomment your db backend package ('backend' named package)\n// 3) Write own initConnectionToDb and 'db' variable\n// 4) Replace mongodb specific db queries inside test to your own queries.\n// 5) Run.\n\npackage tests\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"flag\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"reflect\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/google/go-cmp/cmp\"\n\t\"github.com/google/go-cmp/cmp/cmpopts\"\n\tadapter \"github.com/tinode/chat/server/db\"\n\tjcr \"github.com/tinode/jsonco\"\n\tb \"go.mongodb.org/mongo-driver/bson\"\n\tmdb \"go.mongodb.org/mongo-driver/mongo\"\n\tmdbopts \"go.mongodb.org/mongo-driver/mongo/options\"\n\n\t\"github.com/tinode/chat/server/db/common\"\n\t\"github.com/tinode/chat/server/db/common/test_data\"\n\tbackend \"github.com/tinode/chat/server/db/mongodb\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\ntype configType struct {\n\t// If Reset=true test will recreate database every time it runs\n\tReset bool `json:\"reset_db_data\"`\n\t// Configurations for individual adapters.\n\tAdapters map[string]json.RawMessage `json:\"adapters\"`\n}\n\nvar config configType\nvar adp adapter.Adapter\nvar db *mdb.Database\nvar ctx context.Context\nvar testData *test_data.TestData\n\nfunc TestCreateDb(t *testing.T) {\n\tif err := adp.CreateDb(config.Reset); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\n// ================== Create tests ================================\nfunc TestUserCreate(t *testing.T) {\n\tfor _, user := range testData.Users {\n\t\tif err := adp.UserCreate(user); err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n\tcount, err := db.Collection(\"users\").CountDocuments(ctx, b.M{})\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif count == 0 {\n\t\tt.Error(\"No users created!\")\n\t}\n}\n\nfunc TestCredUpsert(t *testing.T) {\n\t// Test just inserts:\n\tfor i := 0; i < 2; i++ {\n\t\tinserted, err := adp.CredUpsert(testData.Creds[i])\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif !inserted {\n\t\t\tt.Error(\"Should be inserted, but updated\")\n\t\t}\n\t}\n\n\t// Test duplicate:\n\t_, err := adp.CredUpsert(testData.Creds[1])\n\tif err != types.ErrDuplicate {\n\t\tt.Error(\"Should return duplicate error but got\", err)\n\t}\n\t_, err = adp.CredUpsert(testData.Creds[2])\n\tif err != types.ErrDuplicate {\n\t\tt.Error(\"Should return duplicate error but got\", err)\n\t}\n\n\t// Test add new unvalidated credentials\n\tinserted, err := adp.CredUpsert(testData.Creds[3])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !inserted {\n\t\tt.Error(\"Should be inserted, but updated\")\n\t}\n\tinserted, err = adp.CredUpsert(testData.Creds[3])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif inserted {\n\t\tt.Error(\"Should be updated, but inserted\")\n\t}\n\n\t// Just insert other creds (used in other tests)\n\tfor _, cred := range testData.Creds[4:] {\n\t\t_, err = adp.CredUpsert(cred)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n}\n\nfunc TestAuthAddRecord(t *testing.T) {\n\tfor _, rec := range testData.Recs {\n\t\terr := adp.AuthAddRecord(types.ParseUserId(\"usr\"+rec.UserId), rec.Scheme, rec.Unique,\n\t\t\trec.AuthLvl, rec.Secret, rec.Expires)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\t//Test duplicate\n\terr := adp.AuthAddRecord(types.ParseUserId(\"usr\"+testData.Users[0].Id), testData.Recs[0].Scheme,\n\t\ttestData.Recs[0].Unique, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)\n\tif err != types.ErrDuplicate {\n\t\tt.Fatal(\"Should be duplicate error but got\", err)\n\t}\n}\n\nfunc TestTopicCreate(t *testing.T) {\n\terr := adp.TopicCreate(testData.Topics[0])\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tfor _, tpc := range testData.Topics[3:] {\n\t\terr = adp.TopicCreate(tpc)\n\t\tif err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n}\n\nfunc TestTopicCreateP2P(t *testing.T) {\n\terr := adp.TopicCreateP2P(testData.Subs[2], testData.Subs[3])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\toldModeGiven := testData.Subs[2].ModeGiven\n\ttestData.Subs[2].ModeGiven = 255\n\terr = adp.TopicCreateP2P(testData.Subs[4], testData.Subs[2])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got types.Subscription\n\terr = db.Collection(\"subscriptions\").FindOne(ctx, b.M{\"_id\": testData.Subs[2].Id}).Decode(&got)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.ModeGiven == oldModeGiven {\n\t\tt.Error(\"ModeGiven update failed\")\n\t}\n}\n\nfunc TestTopicShare(t *testing.T) {\n\tif err := adp.TopicShare(testData.Subs[0].Topic, testData.Subs); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestMessageSave(t *testing.T) {\n\tfor _, msg := range testData.Msgs {\n\t\terr := adp.MessageSave(msg)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\n\t// Some messages are soft deleted, but it's ignored by adp.MessageSave\n\tfor _, msg := range testData.Msgs {\n\t\tif len(msg.DeletedFor) > 0 {\n\t\t\tfor _, del := range msg.DeletedFor {\n\t\t\t\ttoDel := types.DelMessage{\n\t\t\t\t\tTopic:       msg.Topic,\n\t\t\t\t\tDeletedFor:  del.User,\n\t\t\t\t\tDelId:       del.DelId,\n\t\t\t\t\tSeqIdRanges: []types.Range{{Low: msg.SeqId}},\n\t\t\t\t}\n\t\t\t\tadp.MessageDeleteList(msg.Topic, &toDel)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestFileStartUpload(t *testing.T) {\n\tfor _, f := range testData.Files {\n\t\terr := adp.FileStartUpload(f)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n}\n\n// ================== Read tests ==================================\nfunc TestUserGet(t *testing.T) {\n\t// Test not found\n\tgot, err := adp.UserGet(types.ParseUserId(\"dummyuserid\"))\n\tif err == nil && got != nil {\n\t\tt.Error(\"user should be nil.\")\n\t}\n\n\tgot, err = adp.UserGet(types.ParseUserId(\"usr\" + testData.Users[0].Id))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !reflect.DeepEqual(got, testData.Users[0]) {\n\t\tt.Error(mismatchErrorString(\"User\", got, testData.Users[0]))\n\t}\n}\n\nfunc TestUserGetAll(t *testing.T) {\n\t// Test not found\n\tgot, err := adp.UserGetAll(types.ParseUserId(\"dummyuserid\"), types.ParseUserId(\"otherdummyid\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got != nil {\n\t\tt.Error(\"result users should be nil.\")\n\t}\n\n\tgot, err = adp.UserGetAll(types.ParseUserId(\"usr\"+testData.Users[0].Id), types.ParseUserId(\"usr\"+testData.Users[1].Id))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(got) != 2 {\n\t\tt.Fatal(mismatchErrorString(\"resultUsers length\", len(got), 2))\n\t}\n\tfor i, usr := range got {\n\t\tif !reflect.DeepEqual(&usr, testData.Users[i]) {\n\t\t\tt.Error(mismatchErrorString(\"User\", &usr, testData.Users[i]))\n\t\t}\n\t}\n}\n\nfunc TestUserGetByCred(t *testing.T) {\n\t// Test not found\n\tgot, err := adp.UserGetByCred(\"foo\", \"bar\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got != types.ZeroUid {\n\t\tt.Error(\"result uid should be ZeroUid\")\n\t}\n\n\tgot, _ = adp.UserGetByCred(testData.Creds[0].Method, testData.Creds[0].Value)\n\tif got != types.ParseUserId(\"usr\"+testData.Creds[0].User) {\n\t\tt.Error(mismatchErrorString(\"Uid\", got, types.ParseUserId(\"usr\"+testData.Creds[0].User)))\n\t}\n}\n\nfunc TestCredGetActive(t *testing.T) {\n\tgot, err := adp.CredGetActive(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"tel\")\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif !reflect.DeepEqual(got, testData.Creds[3]) {\n\t\tt.Error(mismatchErrorString(\"Credential\", got, testData.Creds[3]))\n\t}\n\n\t// Test not found\n\tgot, err = adp.CredGetActive(types.ParseUserId(\"dummyusrid\"), \"\")\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif got != nil {\n\t\tt.Error(\"result should be nil, got\", got)\n\t}\n}\n\nfunc TestCredGetAll(t *testing.T) {\n\tgot, err := adp.CredGetAll(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"\", false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(got) != 3 {\n\t\tt.Error(mismatchErrorString(\"Credentials length\", len(got), 3))\n\t}\n\n\tgot, _ = adp.CredGetAll(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"tel\", false)\n\tif len(got) != 2 {\n\t\tt.Error(mismatchErrorString(\"Credentials length\", len(got), 2))\n\t}\n\n\tgot, _ = adp.CredGetAll(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"\", true)\n\tif len(got) != 1 {\n\t\tt.Error(mismatchErrorString(\"Credentials length\", len(got), 1))\n\t}\n\n\tgot, _ = adp.CredGetAll(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"tel\", true)\n\tif len(got) != 1 {\n\t\tt.Error(mismatchErrorString(\"Credentials length\", len(got), 1))\n\t}\n}\n\nfunc TestAuthGetUniqueRecord(t *testing.T) {\n\tuid, authLvl, secret, expires, err := adp.AuthGetUniqueRecord(\"basic:alice\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif uid != types.ParseUserId(\"usr\"+testData.Recs[0].UserId) ||\n\t\tauthLvl != testData.Recs[0].AuthLvl ||\n\t\t!bytes.Equal(secret, testData.Recs[0].Secret) ||\n\t\texpires != testData.Recs[0].Expires {\n\n\t\tgot := fmt.Sprintf(\"%v %v %v %v\", uid, authLvl, secret, expires)\n\t\twant := fmt.Sprintf(\"%v %v %v %v\", testData.Recs[0].UserId, testData.Recs[0].AuthLvl,\n\t\t\ttestData.Recs[0].Secret, testData.Recs[0].Expires)\n\t\tt.Error(mismatchErrorString(\"Auth record\", got, want))\n\t}\n\n\t// Test not found\n\tuid, _, _, _, err = adp.AuthGetUniqueRecord(\"qwert:asdfg\")\n\tif err == nil && !uid.IsZero() {\n\t\tt.Error(\"Auth record found but shouldn't. Uid:\", uid.String())\n\t}\n}\n\nfunc TestAuthGetRecord(t *testing.T) {\n\trecId, authLvl, secret, expires, err := adp.AuthGetRecord(types.ParseUserId(\"usr\"+testData.Recs[0].UserId), \"basic\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif recId != testData.Recs[0].Unique ||\n\t\tauthLvl != testData.Recs[0].AuthLvl ||\n\t\t!bytes.Equal(secret, testData.Recs[0].Secret) ||\n\t\texpires != testData.Recs[0].Expires {\n\n\t\tgot := fmt.Sprintf(\"%v %v %v %v\", recId, authLvl, secret, expires)\n\t\twant := fmt.Sprintf(\"%v %v %v %v\", testData.Recs[0].Unique, testData.Recs[0].AuthLvl,\n\t\t\ttestData.Recs[0].Secret, testData.Recs[0].Expires)\n\t\tt.Error(mismatchErrorString(\"Auth record\", got, want))\n\t}\n\n\t// Test not found\n\trecId, _, _, _, err = adp.AuthGetRecord(types.ParseUserId(\"dummyuserid\"), \"scheme\")\n\tif err != types.ErrNotFound {\n\t\tt.Error(\"Auth record found but shouldn't. recId:\", recId)\n\t}\n}\n\nfunc TestTopicGet(t *testing.T) {\n\tgot, err := adp.TopicGet(testData.Topics[0].Id)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !reflect.DeepEqual(got, testData.Topics[0]) {\n\t\tt.Error(mismatchErrorString(\"Topic\", got, testData.Topics[0]))\n\t}\n\t// Test not found\n\tgot, err = adp.TopicGet(\"asdfasdfasdf\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got != nil {\n\t\tt.Error(\"Topic should be nil but got:\", got)\n\t}\n}\n\nfunc TestTopicsForUser(t *testing.T) {\n\tqOpts := types.QueryOpt{\n\t\tTopic: testData.Topics[1].Id,\n\t\tLimit: 999,\n\t}\n\tgotSubs, err := adp.TopicsForUser(types.ParseUserId(\"usr\"+testData.Users[0].Id), false, &qOpts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 1 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 1))\n\t}\n\n\tgotSubs, err = adp.TopicsForUser(types.ParseUserId(\"usr\"+testData.Users[1].Id), true, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 2 {\n\t\tt.Error(mismatchErrorString(\"Subs length (2)\", len(gotSubs), 2))\n\t}\n\n\tqOpts.Topic = \"\"\n\tims := testData.Now.Add(15 * time.Minute)\n\tqOpts.IfModifiedSince = &ims\n\tgotSubs, err = adp.TopicsForUser(types.ParseUserId(\"usr\"+testData.Users[0].Id), false, &qOpts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 1 {\n\t\tt.Error(mismatchErrorString(\"Subs length (IMS)\", len(gotSubs), 1))\n\t}\n\n\t// time.Now() is correct (as opposite to testData.Now)\n\t// Topic is modified using time.Now().\n\tims = time.Now().Add(15 * time.Minute)\n\tgotSubs, err = adp.TopicsForUser(types.ParseUserId(\"usr\"+testData.Users[0].Id), false, &qOpts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 0 {\n\t\tt.Error(mismatchErrorString(\"Subs length (IMS 2)\", len(gotSubs), 0))\n\t}\n}\n\nfunc TestUsersForTopic(t *testing.T) {\n\tqOpts := types.QueryOpt{\n\t\tUser:  types.ParseUserId(\"usr\" + testData.Users[0].Id),\n\t\tLimit: 999,\n\t}\n\tgotSubs, err := adp.UsersForTopic(testData.Topics[0].Id, false, &qOpts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 1 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 1))\n\t}\n\n\tgotSubs, err = adp.UsersForTopic(testData.Topics[0].Id, true, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 2 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 2))\n\t}\n\n\tgotSubs, err = adp.UsersForTopic(testData.Topics[1].Id, false, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 2 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 2))\n\t}\n}\n\nfunc TestOwnTopics(t *testing.T) {\n\tgotSubs, err := adp.OwnTopics(types.ParseUserId(\"usr\" + testData.Users[0].Id))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 1 {\n\t\tt.Fatalf(\"Got topic length %v instead of %v\", len(gotSubs), 1)\n\t}\n\tif gotSubs[0] != testData.Topics[0].Id {\n\t\tt.Errorf(\"Got topic %v instead of %v\", gotSubs[0], testData.Topics[0].Id)\n\t}\n}\n\nfunc TestSubscriptionGet(t *testing.T) {\n\tgot, err := adp.SubscriptionGet(testData.Topics[0].Id, types.ParseUserId(\"usr\"+testData.Users[0].Id), false)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\topts := cmpopts.IgnoreUnexported(types.Subscription{}, types.ObjHeader{})\n\tif !cmp.Equal(got, testData.Subs[0], opts) {\n\t\tt.Error(mismatchErrorString(\"Subs\", got, testData.Subs[0]))\n\t}\n\t// Test not found\n\tgot, err = adp.SubscriptionGet(\"dummytopic\", types.ParseUserId(\"dummyuserid\"), false)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif got != nil {\n\t\tt.Error(\"result sub should be nil.\")\n\t}\n}\n\nfunc TestSubsForUser(t *testing.T) {\n\tgotSubs, err := adp.SubsForUser(types.ParseUserId(\"usr\" + testData.Users[0].Id))\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif len(gotSubs) != 2 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 1))\n\t}\n\n\t// Test not found\n\tgotSubs, err = adp.SubsForUser(types.ParseUserId(\"usr12345678\"))\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif len(gotSubs) != 0 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 0))\n\t}\n}\n\nfunc TestSubsForTopic(t *testing.T) {\n\tqOpts := types.QueryOpt{\n\t\tUser:  types.ParseUserId(\"usr\" + testData.Users[0].Id),\n\t\tLimit: 999,\n\t}\n\tgotSubs, err := adp.SubsForTopic(testData.Topics[0].Id, false, &qOpts)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif len(gotSubs) != 1 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 1))\n\t}\n\t// Test not found\n\tgotSubs, err = adp.SubsForTopic(\"dummytopicid\", false, nil)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif len(gotSubs) != 0 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 0))\n\t}\n}\n\nfunc TestFind(t *testing.T) {\n\treqTags := [][]string{{\"alice\", \"bob\", \"carol\", \"travel\", \"qwer\", \"asdf\", \"zxcv\"}}\n\tgotSubs, err := adp.Find(\"usr\"+testData.Users[2].Id, \"\", reqTags, nil, true)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif len(gotSubs) != 3 {\n\t\tt.Error(mismatchErrorString(\"result length\", len(gotSubs), 3))\n\t}\n}\n\nfunc TestMessageGetAll(t *testing.T) {\n\topts := types.QueryOpt{\n\t\tSince:  1,\n\t\tBefore: 2,\n\t\tLimit:  999,\n\t}\n\tgotMsgs, err := adp.MessageGetAll(testData.Topics[0].Id,\n\t\ttypes.ParseUserId(\"usr\"+testData.Users[0].Id), &opts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotMsgs) != 1 {\n\t\tt.Error(mismatchErrorString(\"Messages length opts\", len(gotMsgs), 1))\n\t}\n\tgotMsgs, _ = adp.MessageGetAll(testData.Topics[0].Id, types.ParseUserId(\"usr\"+testData.Users[0].Id), nil)\n\tif len(gotMsgs) != 2 {\n\t\tt.Error(mismatchErrorString(\"Messages length no opts\", len(gotMsgs), 2))\n\t}\n\tgotMsgs, _ = adp.MessageGetAll(testData.Topics[0].Id, types.ZeroUid, nil)\n\tif len(gotMsgs) != 3 {\n\t\tt.Error(mismatchErrorString(\"Messages length zero uid\", len(gotMsgs), 3))\n\t}\n}\n\nfunc TestFileGet(t *testing.T) {\n\t// General test done during TestFileFinishUpload().\n\n\t// Test not found\n\tgot, err := adp.FileGet(\"dummyfileid\")\n\tif err != nil {\n\t\tif got != nil {\n\t\t\tt.Error(\"File found but shouldn't:\", got)\n\t\t}\n\t}\n}\n\n// ================== Update tests ================================\nfunc TestUserUpdate(t *testing.T) {\n\tupdate := map[string]any{\n\t\t\"UserAgent\": \"Test Agent v0.11\",\n\t\t\"UpdatedAt\": testData.Now.Add(30 * time.Minute),\n\t}\n\terr := adp.UserUpdate(types.ParseUserId(\"usr\"+testData.Users[0].Id), update)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tvar got types.User\n\terr = db.Collection(\"users\").FindOne(ctx, b.M{\"_id\": testData.Users[0].Id}).Decode(&got)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.UserAgent != \"Test Agent v0.11\" {\n\t\tt.Error(mismatchErrorString(\"UserAgent\", got.UserAgent, \"Test Agent v0.11\"))\n\t}\n\tif got.UpdatedAt.Equal(got.CreatedAt) {\n\t\tt.Error(\"UpdatedAt field not updated\")\n\t}\n}\n\nfunc TestUserUpdateTags(t *testing.T) {\n\taddTags := testData.Tags[0]\n\tremoveTags := testData.Tags[1]\n\tresetTags := testData.Tags[2]\n\tgot, err := adp.UserUpdateTags(types.ParseUserId(\"usr\"+testData.Users[0].Id), addTags, nil, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"alice\", \"tag1\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Error(mismatchErrorString(\"Tags\", got, want))\n\n\t}\n\tgot, _ = adp.UserUpdateTags(types.ParseUserId(\"usr\"+testData.Users[0].Id), nil, removeTags, nil)\n\twant = nil\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Error(mismatchErrorString(\"Tags\", got, want))\n\n\t}\n\tgot, _ = adp.UserUpdateTags(types.ParseUserId(\"usr\"+testData.Users[0].Id), nil, nil, resetTags)\n\twant = []string{\"alice\", \"tag111\", \"tag333\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Error(mismatchErrorString(\"Tags\", got, want))\n\n\t}\n\tgot, _ = adp.UserUpdateTags(types.ParseUserId(\"usr\"+testData.Users[0].Id), addTags, removeTags, nil)\n\twant = []string{\"tag111\", \"tag333\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Error(mismatchErrorString(\"Tags\", got, want))\n\n\t}\n\tgot, _ = adp.UserUpdateTags(types.ParseUserId(\"usr\"+testData.Users[0].Id), addTags, removeTags, nil)\n\twant = []string{\"tag111\", \"tag333\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Error(mismatchErrorString(\"Tags\", got, want))\n\t}\n}\n\nfunc TestCredFail(t *testing.T) {\n\terr := adp.CredFail(types.ParseUserId(\"usr\"+testData.Creds[3].User), \"tel\")\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\n\t// Check if fields updated\n\tvar got types.Credential\n\t_ = db.Collection(\"credentials\").FindOne(ctx, b.M{\n\t\t\"user\":   testData.Creds[3].User,\n\t\t\"method\": \"tel\",\n\t\t\"value\":  testData.Creds[3].Value}).Decode(&got)\n\tif got.Retries != 1 {\n\t\tt.Error(mismatchErrorString(\"Retries count\", got.Retries, 1))\n\t}\n\tif got.UpdatedAt.Equal(got.CreatedAt) {\n\t\tt.Error(\"UpdatedAt field not updated\")\n\t}\n}\n\nfunc TestCredConfirm(t *testing.T) {\n\terr := adp.CredConfirm(types.ParseUserId(\"usr\"+testData.Creds[3].User), \"tel\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test fields are updated\n\tvar got types.Credential\n\terr = db.Collection(\"credentials\").FindOne(ctx, b.M{\n\t\t\"user\":   testData.Creds[3].User,\n\t\t\"method\": \"tel\",\n\t\t\"value\":  testData.Creds[3].Value}).Decode(&got)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.UpdatedAt.Equal(got.CreatedAt) {\n\t\tt.Error(\"Credential not updated correctly\")\n\t}\n\t// and uncomfirmed credential deleted\n\terr = db.Collection(\"credentials\").FindOne(ctx, b.M{\"_id\": testData.Creds[3].User + \":\" + got.Method + \":\" + got.Value}).Decode(&got)\n\tif err != mdb.ErrNoDocuments {\n\t\tt.Error(\"Uncomfirmed credential not deleted\")\n\t}\n}\n\nfunc TestAuthUpdRecord(t *testing.T) {\n\trec := testData.Recs[1]\n\tnewSecret := []byte{'s', 'e', 'c', 'r', 'e', 't'}\n\terr := adp.AuthUpdRecord(types.ParseUserId(\"usr\"+rec.UserId), rec.Scheme, rec.Unique,\n\t\trec.AuthLvl, newSecret, rec.Expires)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got common.AuthRecord\n\terr = db.Collection(\"auth\").FindOne(ctx, b.M{\"_id\": rec.Unique}).Decode(&got)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif bytes.Equal(got.Secret, rec.Secret) {\n\t\tt.Error(mismatchErrorString(\"Secret\", got.Secret, rec.Secret))\n\t}\n\n\t// Test with auth ID (unique) change\n\tnewId := \"basic:bob12345\"\n\terr = adp.AuthUpdRecord(types.ParseUserId(\"usr\"+rec.UserId), rec.Scheme, newId,\n\t\trec.AuthLvl, newSecret, rec.Expires)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Test if old ID deleted\n\terr = db.Collection(\"auth\").FindOne(ctx, b.M{\"_id\": rec.Unique}).Decode(&got)\n\tif err == nil || err != mdb.ErrNoDocuments {\n\t\tt.Errorf(\"Unique not changed. Got error: %v; ID: %v\", err, got.Unique)\n\t}\n\tif bytes.Equal(got.Secret, rec.Secret) {\n\t\tt.Error(mismatchErrorString(\"Secret\", got.Secret, rec.Secret))\n\t}\n\tif bytes.Equal(got.Secret, rec.Secret) {\n\t\tt.Error(mismatchErrorString(\"Secret\", got.Secret, rec.Secret))\n\t}\n}\n\nfunc TestTopicUpdateOnMessage(t *testing.T) {\n\tmsg := types.Message{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tCreatedAt: testData.Now.Add(33 * time.Minute),\n\t\t},\n\t\tSeqId: 66,\n\t}\n\terr := adp.TopicUpdateOnMessage(testData.Topics[2].Id, &msg)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got types.Topic\n\terr = db.Collection(\"topics\").FindOne(ctx, b.M{\"_id\": testData.Topics[2].Id}).Decode(&got)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.TouchedAt != msg.CreatedAt || got.SeqId != msg.SeqId {\n\t\tt.Error(mismatchErrorString(\"TouchedAt\", got.TouchedAt, msg.CreatedAt))\n\t\tt.Error(mismatchErrorString(\"SeqId\", got.SeqId, msg.SeqId))\n\t}\n}\n\nfunc TestTopicUpdate(t *testing.T) {\n\tupdate := map[string]any{\n\t\t\"UpdatedAt\": testData.Now.Add(55 * time.Minute),\n\t}\n\terr := adp.TopicUpdate(testData.Topics[0].Id, update)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got types.Topic\n\t_ = db.Collection(\"topics\").FindOne(ctx, b.M{\"_id\": testData.Topics[0].Id}).Decode(&got)\n\tif got.UpdatedAt != update[\"UpdatedAt\"] {\n\t\tt.Error(mismatchErrorString(\"UpdatedAt\", got.UpdatedAt, update[\"UpdatedAt\"]))\n\t}\n}\n\nfunc TestTopicOwnerChange(t *testing.T) {\n\terr := adp.TopicOwnerChange(testData.Topics[0].Id, types.ParseUserId(\"usr\"+testData.Users[1].Id))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got types.Topic\n\t_ = db.Collection(\"topics\").FindOne(ctx, b.M{\"_id\": testData.Topics[0].Id}).Decode(&got)\n\tif got.Owner != testData.Users[1].Id {\n\t\tt.Error(mismatchErrorString(\"Owner\", got.Owner, testData.Users[1].Id))\n\t}\n}\n\nfunc TestSubsUpdate(t *testing.T) {\n\tupdate := map[string]any{\n\t\t\"UpdatedAt\": testData.Now.Add(22 * time.Minute),\n\t}\n\terr := adp.SubsUpdate(testData.Topics[0].Id, types.ParseUserId(\"usr\"+testData.Users[0].Id), update)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got types.Subscription\n\t_ = db.Collection(\"subscriptions\").FindOne(ctx, b.M{\"_id\": testData.Topics[0].Id + \":\" + testData.Users[0].Id}).Decode(&got)\n\tif got.UpdatedAt != update[\"UpdatedAt\"] {\n\t\tt.Error(mismatchErrorString(\"UpdatedAt\", got.UpdatedAt, update[\"UpdatedAt\"]))\n\t}\n\n\terr = adp.SubsUpdate(testData.Topics[1].Id, types.ZeroUid, update)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\t_ = db.Collection(\"subscriptions\").FindOne(ctx, b.M{\"topic\": testData.Topics[1].Id}).Decode(&got)\n\tif got.UpdatedAt != update[\"UpdatedAt\"] {\n\t\tt.Error(mismatchErrorString(\"UpdatedAt\", got.UpdatedAt, update[\"UpdatedAt\"]))\n\t}\n}\n\nfunc TestSubsDelete(t *testing.T) {\n\terr := adp.SubsDelete(testData.Topics[1].Id, types.ParseUserId(\"usr\"+testData.Users[0].Id))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got types.Subscription\n\t_ = db.Collection(\"subscriptions\").FindOne(ctx, b.M{\"_id\": testData.Topics[1].Id + \":\" + testData.Users[0].Id}).Decode(&got)\n\tif got.DeletedAt == nil {\n\t\tt.Error(mismatchErrorString(\"DeletedAt\", got.DeletedAt, nil))\n\t}\n}\n\nfunc TestDeviceUpsert(t *testing.T) {\n\terr := adp.DeviceUpsert(types.ParseUserId(\"usr\"+testData.Users[0].Id), testData.Devs[0])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got types.User\n\terr = db.Collection(\"users\").FindOne(ctx, b.M{\"_id\": testData.Users[0].Id}).Decode(&got)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif !reflect.DeepEqual(got.DeviceArray[0], testData.Devs[0]) {\n\t\tt.Error(mismatchErrorString(\"Device\", got.DeviceArray[0], testData.Devs[0]))\n\t}\n\t// Test update\n\ttestData.Devs[0].Platform = \"Web\"\n\terr = adp.DeviceUpsert(types.ParseUserId(\"usr\"+testData.Users[0].Id), testData.Devs[0])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = db.Collection(\"users\").FindOne(ctx, b.M{\"_id\": testData.Users[0].Id}).Decode(&got)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif got.DeviceArray[0].Platform != \"Web\" {\n\t\tt.Error(\"Device not updated.\", got.DeviceArray[0])\n\t}\n\t// Test add same device to another user\n\terr = adp.DeviceUpsert(types.ParseUserId(\"usr\"+testData.Users[1].Id), testData.Devs[0])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = db.Collection(\"users\").FindOne(ctx, b.M{\"_id\": testData.Users[1].Id}).Decode(&got)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif got.DeviceArray[0].Platform != \"Web\" {\n\t\tt.Error(\"Device not updated.\", got.DeviceArray[0])\n\t}\n\n\terr = adp.DeviceUpsert(types.ParseUserId(\"usr\"+testData.Users[2].Id), testData.Devs[1])\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestMessageAttachments(t *testing.T) {\n\tfids := []string{testData.Files[0].Id, testData.Files[1].Id}\n\terr := adp.FileLinkAttachments(\"\", types.ZeroUid, types.ParseUid(testData.Msgs[1].Id), fids)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got map[string][]string\n\tfindOpts := mdbopts.FindOne().SetProjection(b.M{\"attachments\": 1, \"_id\": 0})\n\terr = db.Collection(\"messages\").FindOne(ctx, b.M{\"_id\": testData.Msgs[1].Id}, findOpts).Decode(&got)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !reflect.DeepEqual(got[\"attachments\"], fids) {\n\t\tt.Error(mismatchErrorString(\"Attachments\", got[\"attachments\"], fids))\n\t}\n\tvar got2 map[string]int\n\tfindOpts = mdbopts.FindOne().SetProjection(b.M{\"usecount\": 1, \"_id\": 0})\n\terr = db.Collection(\"fileuploads\").FindOne(ctx, b.M{\"_id\": testData.Files[0].Id}, findOpts).Decode(&got2)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got2[\"usecount\"] != 1 {\n\t\tt.Error(mismatchErrorString(\"UseCount\", got2[\"usecount\"], 1))\n\t}\n}\n\nfunc TestFileFinishUpload(t *testing.T) {\n\tgot, err := adp.FileFinishUpload(testData.Files[0], true, 22222)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.Status != types.UploadCompleted {\n\t\tt.Error(mismatchErrorString(\"Status\", got.Status, types.UploadCompleted))\n\t}\n\tif got.Size != 22222 {\n\t\tt.Error(mismatchErrorString(\"Size\", got.Size, 22222))\n\t}\n}\n\n// ================== Other tests =================================\nfunc TestDeviceGetAll(t *testing.T) {\n\tuid0 := types.ParseUserId(\"usr\" + testData.Users[0].Id)\n\tuid1 := types.ParseUserId(\"usr\" + testData.Users[1].Id)\n\tuid2 := types.ParseUserId(\"usr\" + testData.Users[2].Id)\n\tgotDevs, count, err := adp.DeviceGetAll(uid0, uid1, uid2)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count != 2 {\n\t\tt.Fatal(mismatchErrorString(\"count\", count, 2))\n\t}\n\tif !reflect.DeepEqual(gotDevs[uid1][0], *testData.Devs[0]) {\n\t\tt.Error(mismatchErrorString(\"Device\", gotDevs[uid1][0], *testData.Devs[0]))\n\t}\n\tif !reflect.DeepEqual(gotDevs[uid2][0], *testData.Devs[1]) {\n\t\tt.Error(mismatchErrorString(\"Device\", gotDevs[uid2][0], *testData.Devs[1]))\n\t}\n}\n\nfunc TestDeviceDelete(t *testing.T) {\n\terr := adp.DeviceDelete(types.ParseUserId(\"usr\"+testData.Users[1].Id), testData.Devs[0].DeviceId)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got types.User\n\terr = db.Collection(\"users\").FindOne(ctx, b.M{\"_id\": testData.Users[1].Id}).Decode(&got)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(got.DeviceArray) != 0 {\n\t\tt.Error(\"Device not deleted:\", got.DeviceArray)\n\t}\n\n\terr = adp.DeviceDelete(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = db.Collection(\"users\").FindOne(ctx, b.M{\"_id\": testData.Users[2].Id}).Decode(&got)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(got.DeviceArray) != 0 {\n\t\tt.Error(\"Device not deleted:\", got.DeviceArray)\n\t}\n}\n\n// ================== Persistent Cache tests ======================\nfunc TestPCacheUpsert(t *testing.T) {\n\terr := adp.PCacheUpsert(\"test_key\", \"test_value\", false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test duplicate with failOnDuplicate = true\n\terr = adp.PCacheUpsert(\"test_key2\", \"test_value2\", true)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terr = adp.PCacheUpsert(\"test_key2\", \"new_value\", true)\n\tif err != types.ErrDuplicate {\n\t\tt.Error(\"Expected duplicate error\")\n\t}\n}\n\nfunc TestPCacheGet(t *testing.T) {\n\tvalue, err := adp.PCacheGet(\"test_key\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif value != \"test_value\" {\n\t\tt.Error(mismatchErrorString(\"Cache value\", value, \"test_value\"))\n\t}\n\n\t// Test not found\n\t_, err = adp.PCacheGet(\"nonexistent\")\n\tif err != types.ErrNotFound {\n\t\tt.Error(\"Expected not found error\")\n\t}\n}\n\nfunc TestPCacheDelete(t *testing.T) {\n\terr := adp.PCacheDelete(\"test_key\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify deleted\n\t_, err = adp.PCacheGet(\"test_key\")\n\tif err != types.ErrNotFound {\n\t\tt.Error(\"Key should be deleted\")\n\t}\n}\n\nfunc TestPCacheExpire(t *testing.T) {\n\t// Insert some test keys with prefix\n\tadp.PCacheUpsert(\"prefix_key1\", \"value1\", false)\n\tadp.PCacheUpsert(\"prefix_key2\", \"value2\", false)\n\n\t// Expire keys older than now (should delete all test keys)\n\terr := adp.PCacheExpire(\"prefix_\", time.Now().Add(1*time.Minute))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\n// ================== Delete tests ================================\nfunc TestCredDel(t *testing.T) {\n\terr := adp.CredDel(types.ParseUserId(\"usr\"+testData.Users[0].Id), \"email\", \"alice@test.example.com\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got []map[string]any\n\tcur, err := db.Collection(\"credentials\").Find(ctx, b.M{\"method\": \"email\", \"value\": \"alice@test.example.com\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err = cur.All(ctx, &got); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(got) != 0 {\n\t\tt.Error(\"Got result but shouldn't\", got)\n\t}\n\n\terr = adp.CredDel(types.ParseUserId(\"usr\"+testData.Users[1].Id), \"\", \"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcur, err = db.Collection(\"credentials\").Find(ctx, b.M{\"user\": testData.Users[1].Id})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err = cur.All(ctx, &got); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(got) != 0 {\n\t\tt.Error(\"Got result but shouldn't\", got)\n\t}\n}\n\nfunc TestAuthDelScheme(t *testing.T) {\n\t// tested during TestAuthUpdRecord\n}\n\nfunc TestAuthDelAllRecords(t *testing.T) {\n\tdelCount, err := adp.AuthDelAllRecords(types.ParseUserId(\"usr\" + testData.Recs[0].UserId))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif delCount != 1 {\n\t\tt.Error(mismatchErrorString(\"delCount\", delCount, 1))\n\t}\n\n\t// With dummy user\n\tdelCount, _ = adp.AuthDelAllRecords(types.ParseUserId(\"dummyuserid\"))\n\tif delCount != 0 {\n\t\tt.Error(mismatchErrorString(\"delCount\", delCount, 0))\n\t}\n}\n\nfunc TestSubsDelForUser(t *testing.T) {\n\t// Tested during TestUserDelete (both hard and soft deletions)\n}\n\nfunc TestMessageDeleteList(t *testing.T) {\n\ttoDel := types.DelMessage{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tId:        testData.UGen.GetStr(),\n\t\t\tCreatedAt: testData.Now,\n\t\t\tUpdatedAt: testData.Now,\n\t\t},\n\t\tTopic:       testData.Topics[1].Id,\n\t\tDeletedFor:  testData.Users[2].Id,\n\t\tDelId:       1,\n\t\tSeqIdRanges: []types.Range{{Low: 9}, {Low: 3, Hi: 7}},\n\t}\n\terr := adp.MessageDeleteList(toDel.Topic, &toDel)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tvar got []types.Message\n\tcur, err := db.Collection(\"messages\").Find(ctx, b.M{\"topic\": toDel.Topic})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err = cur.All(ctx, &got); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, msg := range got {\n\t\tif msg.SeqId == 1 && msg.DeletedFor != nil {\n\t\t\tt.Error(\"Message with SeqID=1 should not be deleted\")\n\t\t}\n\t\tif msg.SeqId == 5 && msg.DeletedFor == nil {\n\t\t\tt.Error(\"Message with SeqID=5 should be deleted\")\n\t\t}\n\t\tif msg.SeqId == 11 && msg.DeletedFor != nil {\n\t\t\tt.Error(\"Message with SeqID=11 should not be deleted\")\n\t\t}\n\t}\n\t//\n\ttoDel = types.DelMessage{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tId:        testData.UGen.GetStr(),\n\t\t\tCreatedAt: testData.Now,\n\t\t\tUpdatedAt: testData.Now,\n\t\t},\n\t\tTopic:       testData.Topics[0].Id,\n\t\tDelId:       3,\n\t\tSeqIdRanges: []types.Range{{Low: 1, Hi: 3}},\n\t}\n\terr = adp.MessageDeleteList(toDel.Topic, &toDel)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcur, err = db.Collection(\"messages\").Find(ctx, b.M{\"topic\": toDel.Topic})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err = cur.All(ctx, &got); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, msg := range got {\n\t\tif msg.Content != nil && msg.SeqId != 3 {\n\t\t\tt.Error(\"Message not deleted:\", msg.SeqId)\n\t\t}\n\t}\n\n\terr = adp.MessageDeleteList(testData.Topics[0].Id, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcur, err = db.Collection(\"messages\").Find(ctx, b.M{\"topic\": testData.Topics[0].Id})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err = cur.All(ctx, &got); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(got) != 0 {\n\t\tt.Error(\"Result should be empty:\", got)\n\t}\n}\n\nfunc TestTopicDelete(t *testing.T) {\n\terr := adp.TopicDelete(testData.Topics[1].Id, false, false)\n\tif err != nil {\n\t\tt.Fatal()\n\t}\n\tvar got types.Topic\n\tcur, err := db.Collection(\"topics\").Find(ctx, b.M{\"topic\": testData.Topics[1].Id})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor cur.Next(ctx) {\n\t\tif err = cur.Decode(&got); err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t\tif got.State != types.StateDeleted {\n\t\t\tt.Error(\"Soft delete failed:\", got)\n\t\t}\n\t}\n\n\terr = adp.TopicDelete(testData.Topics[0].Id, true, true)\n\tif err != nil {\n\t\tt.Fatal()\n\t}\n\n\tvar got2 []types.Topic\n\tcur, err = db.Collection(\"topics\").Find(ctx, b.M{\"topic\": testData.Topics[0].Id})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err = cur.All(ctx, &got2); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(got2) != 0 {\n\t\tt.Error(\"Hard delete failed:\", got2)\n\t}\n}\n\nfunc TestFileDeleteUnused(t *testing.T) {\n\t// time.Now() is correct (as opposite to testData.Now):\n\t// the FileFinishUpload uses time.Now() as a timestamp.\n\tlocs, err := adp.FileDeleteUnused(time.Now().Add(1*time.Minute), 999)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(locs) != 2 {\n\t\tt.Error(mismatchErrorString(\"Locations length\", len(locs), 2))\n\t}\n}\n\nfunc TestUserDelete(t *testing.T) {\n\terr := adp.UserDelete(types.ParseUserId(\"usr\"+testData.Users[0].Id), false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got types.User\n\terr = db.Collection(\"users\").FindOne(ctx, b.M{\"_id\": testData.Users[0].Id}).Decode(&got)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.State != types.StateDeleted {\n\t\tt.Error(\"User soft delete failed\", got)\n\t}\n\n\terr = adp.UserDelete(types.ParseUserId(\"usr\"+testData.Users[1].Id), true)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = db.Collection(\"users\").FindOne(ctx, b.M{\"_id\": testData.Users[1].Id}).Decode(&got)\n\tif err != mdb.ErrNoDocuments {\n\t\tt.Error(\"User hard delete failed\", err)\n\t}\n}\n\nfunc TestUserUnreadCount(t *testing.T) {\n\tuids := []types.Uid{\n\t\ttypes.ParseUserId(\"usr\" + testData.Users[1].Id),\n\t\ttypes.ParseUserId(\"usr\" + testData.Users[2].Id),\n\t}\n\texpected := map[types.Uid]int{uids[0]: 0, uids[1]: 166}\n\tcounts, err := adp.UserUnreadCount(uids...)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(counts) != 2 {\n\t\tt.Error(mismatchErrorString(\"UnreadCount length\", len(counts), 2))\n\t}\n\n\tfor uid, unread := range counts {\n\t\tif expected[uid] != unread {\n\t\t\tt.Error(mismatchErrorString(\"UnreadCount\", unread, expected[uid]))\n\t\t}\n\t}\n\n\t// Test not found (even if the account is not found, the call must return one record).\n\tuid := types.ParseUserId(\"dummyuserid\")\n\tcounts, err = adp.UserUnreadCount(uid)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(counts) != 1 {\n\t\tt.Error(mismatchErrorString(\"UnreadCount length (dummy)\", len(counts), 1))\n\t}\n\tif counts[uid] != 0 {\n\t\tt.Error(mismatchErrorString(\"Non-zero UnreadCount (dummy)\", counts[uid], 0))\n\t}\n}\n\n// ================== Other tests =================================\nfunc TestMessageGetDeleted(t *testing.T) {\n\tqOpts := types.QueryOpt{\n\t\tSince:  1,\n\t\tBefore: 10,\n\t\tLimit:  999,\n\t}\n\tgot, err := adp.MessageGetDeleted(testData.Topics[1].Id, types.ParseUserId(\"usr\"+testData.Users[2].Id), &qOpts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(got) != 1 {\n\t\tt.Error(mismatchErrorString(\"result length\", len(got), 1))\n\t}\n}\n\n// ================================================================\nfunc mismatchErrorString(key string, got, want any) string {\n\treturn fmt.Sprintf(\"%s mismatch:\\nGot  = %+v\\nWant = %+v\", key, got, want)\n}\n\nfunc init() {\n\tlogs.Init(os.Stderr, \"stdFlags\")\n\tadp = backend.GetTestAdapter()\n\tconffile := flag.String(\"config\", \"./test.conf\", \"config of the database connection\")\n\n\tif file, err := os.Open(*conffile); err != nil {\n\t\tlog.Fatal(\"Failed to read config file:\", err)\n\t} else if err = json.NewDecoder(jcr.New(file)).Decode(&config); err != nil {\n\t\tlog.Fatal(\"Failed to parse config file:\", err)\n\t}\n\n\tif adp == nil {\n\t\tlog.Fatal(\"Database adapter is missing\")\n\t}\n\tif adp.IsOpen() {\n\t\tlog.Print(\"Connection is already opened\")\n\t}\n\n\terr := adp.Open(config.Adapters[adp.GetName()])\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\tdb = adp.GetTestDB().(*mdb.Database)\n\ttestData = test_data.InitTestData()\n\tif testData == nil {\n\t\tlog.Fatal(\"Failed to initialize test data\")\n\t}\n}\n"
  },
  {
    "path": "server/db/mongodb/tests/test.conf",
    "content": "{\n  \"reset_db_data\": true,\n  \"adapters\": {\n    \"mongodb\": {\n      \"database\": \"tinode_test\",\n      //\"replica_set\": \"rs0\",\n      \"addresses\": \"localhost:27017\",\n      //\"username\": \"tinode_test\",\n      //\"password\": \"tinode_test\",\n    }\n  }\n}\n"
  },
  {
    "path": "server/db/mysql/adapter.go",
    "content": "//go:build mysql\n// +build mysql\n\n// Package mysql is a database adapter for MySQL.\npackage mysql\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"hash/fnv\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\tms \"github.com/go-sql-driver/mysql\"\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/tinode/chat/server/auth\"\n\t\"github.com/tinode/chat/server/db/common\"\n\t\"github.com/tinode/chat/server/store\"\n\tt \"github.com/tinode/chat/server/store/types\"\n)\n\n// adapter holds MySQL connection data.\ntype adapter struct {\n\tdb     *sqlx.DB\n\tdsn    string\n\tdbName string\n\t// Maximum number of records to return\n\tmaxResults int\n\t// Maximum number of message records to return\n\tmaxMessageResults int\n\tversion           int\n\n\t// Single query timeout.\n\tsqlTimeout time.Duration\n\t// DB transaction timeout.\n\ttxTimeout time.Duration\n}\n\nconst (\n\tadpVersion  = 116\n\tadapterName = \"mysql\"\n\n\tdefaultDSN      = \"root:@tcp(localhost:3306)/tinode?parseTime=true\"\n\tdefaultDatabase = \"tinode\"\n\n\tdefaultMaxResults = 1024\n\t// This is capped by the Session's send queue limit (128).\n\tdefaultMaxMessageResults = 100\n\n\t// If DB request timeout is specified,\n\t// we allocate txTimeoutMultiplier times more time for transactions.\n\ttxTimeoutMultiplier = 1.5\n)\n\ntype configType struct {\n\t// DB connection settings.\n\t// See https://pkg.go.dev/github.com/go-sql-driver/mysql#Config\n\t// for the full list of fields.\n\tms.Config\n\t// Deprecated.\n\tDSN string `json:\"dsn,omitempty\"`\n\n\t// Connection pool settings.\n\t//\n\t// Maximum number of open connections to the database.\n\tMaxOpenConns int `json:\"max_open_conns,omitempty\"`\n\t// Maximum number of connections in the idle connection pool.\n\tMaxIdleConns int `json:\"max_idle_conns,omitempty\"`\n\t// Maximum amount of time a connection may be reused (in seconds).\n\tConnMaxLifetime int `json:\"conn_max_lifetime,omitempty\"`\n\n\t// DB request timeout (in seconds).\n\t// If 0 (or negative), no timeout is applied.\n\tSqlTimeout int `json:\"sql_timeout,omitempty\"`\n}\n\nfunc (a *adapter) getContext() (context.Context, context.CancelFunc) {\n\tif a.sqlTimeout > 0 {\n\t\treturn context.WithTimeout(context.Background(), a.sqlTimeout)\n\t}\n\treturn context.Background(), nil\n}\n\nfunc (a *adapter) getContextForTx() (context.Context, context.CancelFunc) {\n\tif a.txTimeout > 0 {\n\t\treturn context.WithTimeout(context.Background(), a.txTimeout)\n\t}\n\treturn context.Background(), nil\n}\n\n// Open initializes database session\nfunc (a *adapter) Open(jsonconfig json.RawMessage) error {\n\tif a.db != nil {\n\t\treturn errors.New(\"mysql adapter is already connected\")\n\t}\n\n\tif len(jsonconfig) < 2 {\n\t\treturn errors.New(\"adapter mysql missing config\")\n\t}\n\n\tvar err error\n\tdefaultCfg := ms.NewConfig()\n\tconfig := configType{Config: *defaultCfg}\n\tif err = json.Unmarshal(jsonconfig, &config); err != nil {\n\t\treturn errors.New(\"mysql adapter failed to parse config: \" + err.Error())\n\t}\n\n\tif dsn := config.FormatDSN(); dsn != defaultCfg.FormatDSN() {\n\t\t// MySql config is specified. Use it.\n\t\ta.dbName = config.DBName\n\t\ta.dsn = dsn\n\t\tif config.DSN != \"\" {\n\t\t\treturn errors.New(\"mysql config: conflicting config and DSN are provided\")\n\t\t}\n\t} else {\n\t\t// Otherwise, use DSN to configure database connection.\n\t\t// Note: this method is deprecated.\n\t\tif config.DSN != \"\" {\n\t\t\t// Remove optional schema.\n\t\t\ta.dsn = strings.TrimPrefix(config.DSN, \"mysql://\")\n\t\t} else {\n\t\t\ta.dsn = defaultDSN\n\t\t}\n\n\t\t// Parse out the database name from the DSN.\n\t\tif cfg, err := ms.ParseDSN(a.dsn); err == nil {\n\t\t\ta.dbName = cfg.DBName\n\t\t} else {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.dbName == \"\" {\n\t\ta.dbName = defaultDatabase\n\t}\n\n\tif a.maxResults <= 0 {\n\t\ta.maxResults = defaultMaxResults\n\t}\n\n\tif a.maxMessageResults <= 0 {\n\t\ta.maxMessageResults = defaultMaxMessageResults\n\t}\n\n\t// This just initializes the driver but does not open the network connection.\n\ta.db, err = sqlx.Open(\"mysql\", a.dsn)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Actually opening the network connection.\n\terr = a.db.Ping()\n\tif isMissingDb(err) {\n\t\t// Ignore missing database here. If we are initializing the database\n\t\t// missing DB is OK.\n\t\terr = nil\n\t}\n\tif err == nil {\n\t\tif config.MaxOpenConns > 0 {\n\t\t\ta.db.SetMaxOpenConns(config.MaxOpenConns)\n\t\t}\n\t\tif config.MaxIdleConns > 0 {\n\t\t\ta.db.SetMaxIdleConns(config.MaxIdleConns)\n\t\t}\n\t\tif config.ConnMaxLifetime > 0 {\n\t\t\ta.db.SetConnMaxLifetime(time.Duration(config.ConnMaxLifetime) * time.Second)\n\t\t}\n\t\tif config.SqlTimeout > 0 {\n\t\t\ta.sqlTimeout = time.Duration(config.SqlTimeout) * time.Second\n\t\t\t// We allocate txTimeoutMultiplier times sqlTimeout for transactions.\n\t\t\ta.txTimeout = time.Duration(float64(config.SqlTimeout)*txTimeoutMultiplier) * time.Second\n\t\t}\n\t}\n\treturn err\n}\n\n// Close closes the underlying database connection\nfunc (a *adapter) Close() error {\n\tvar err error\n\tif a.db != nil {\n\t\terr = a.db.Close()\n\t\ta.db = nil\n\t\ta.version = -1\n\t}\n\treturn err\n}\n\n// IsOpen returns true if connection to database has been established. It does not check if\n// connection is actually live.\nfunc (a *adapter) IsOpen() bool {\n\treturn a.db != nil\n}\n\n// GetDbVersion returns current database version.\nfunc (a *adapter) GetDbVersion() (int, error) {\n\tif a.version > 0 {\n\t\treturn a.version, nil\n\t}\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tvar vers int\n\terr := a.db.GetContext(ctx, &vers, \"SELECT `value` FROM kvmeta WHERE `key`='version'\")\n\tif err != nil {\n\t\tif isMissingDb(err) || isMissingTable(err) || err == sql.ErrNoRows {\n\t\t\terr = errors.New(\"Database not initialized\")\n\t\t}\n\t\treturn -1, err\n\t}\n\n\ta.version = vers\n\n\treturn vers, nil\n}\n\nfunc (a *adapter) updateDbVersion(v int) error {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ta.version = -1\n\tif _, err := a.db.ExecContext(ctx, \"UPDATE kvmeta SET `value`=? WHERE `key`='version'\", v); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// CheckDbVersion checks whether the actual DB version matches the expected version of this adapter.\nfunc (a *adapter) CheckDbVersion() error {\n\tversion, err := a.GetDbVersion()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif version != adpVersion {\n\t\treturn errors.New(\"Invalid database version \" + strconv.Itoa(version) +\n\t\t\t\". Expected \" + strconv.Itoa(adpVersion))\n\t}\n\n\treturn nil\n}\n\n// Version returns adapter version.\nfunc (adapter) Version() int {\n\treturn adpVersion\n}\n\n// DB connection stats object.\nfunc (a *adapter) Stats() any {\n\tif a.db == nil {\n\t\treturn nil\n\t}\n\treturn a.db.Stats()\n}\n\n// GetName returns string that adapter uses to register itself with store.\nfunc (a *adapter) GetName() string {\n\treturn adapterName\n}\n\n// SetMaxResults configures how many results can be returned in a single DB call.\nfunc (a *adapter) SetMaxResults(val int) error {\n\tif val <= 0 {\n\t\ta.maxResults = defaultMaxResults\n\t} else {\n\t\ta.maxResults = val\n\t}\n\n\treturn nil\n}\n\n// CreateDb initializes the storage.\nfunc (a *adapter) CreateDb(reset bool) error {\n\tvar err error\n\tvar tx *sql.Tx\n\n\t// Can't use an existing connection because it's configured with a database name which may not exist.\n\t// Don't care if it does not close cleanly.\n\ta.db.Close()\n\n\t// This DSN has been parsed before and produced no error, not checking for errors here.\n\tcfg, _ := ms.ParseDSN(a.dsn)\n\t// Clear database name\n\tcfg.DBName = \"\"\n\n\ta.db, err = sqlx.Open(\"mysql\", cfg.FormatDSN())\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif tx, err = a.db.Begin(); err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\t// FIXME: This is useless: MySQL auto-commits on every CREATE TABLE.\n\t\t\t// Maybe DROP DATABASE instead.\n\t\t\ttx.Rollback()\n\t\t}\n\t}()\n\n\tif reset {\n\t\tif _, err = tx.Exec(\"DROP DATABASE IF EXISTS \" + a.dbName); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif _, err = tx.Exec(\"CREATE DATABASE \" + a.dbName + \" CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci\"); err != nil {\n\t\treturn err\n\t}\n\n\tif _, err = tx.Exec(\"USE \" + a.dbName); err != nil {\n\t\treturn err\n\t}\n\n\tif _, err = tx.Exec(\n\t\t`CREATE TABLE users(\n\t\t\tid        BIGINT NOT NULL,\n\t\t\tcreatedat DATETIME(3) NOT NULL,\n\t\t\tupdatedat DATETIME(3) NOT NULL,\n\t\t\tstate     SMALLINT NOT NULL DEFAULT 0,\n\t\t\tstateat   DATETIME(3),\n\t\t\taccess    JSON,\n\t\t\tlastseen  DATETIME,\n\t\t\tuseragent VARCHAR(255) DEFAULT '',\n\t\t\tpublic    JSON,\n\t\t\ttrusted   JSON,\n\t\t\ttags      JSON,\n\t\t\tPRIMARY KEY(id),\n\t\t\tINDEX users_state_stateat(state, stateat),\n\t\t\tINDEX users_lastseen_updatedat(lastseen, updatedat)\n\t\t)`); err != nil {\n\t\treturn err\n\t}\n\n\t// Indexed user tags.\n\tif _, err = tx.Exec(\n\t\t`CREATE TABLE usertags(\n\t\t\tid     INT NOT NULL AUTO_INCREMENT,\n\t\t\tuserid BIGINT NOT NULL,\n\t\t\ttag    VARCHAR(96) NOT NULL,\n\t\t\tPRIMARY KEY(id),\n\t\t\tFOREIGN KEY(userid) REFERENCES users(id),\n\t\t\tINDEX usertags_tag(tag),\n\t\t\tUNIQUE INDEX usertags_userid_tag(userid, tag)\n\t\t)`); err != nil {\n\t\treturn err\n\t}\n\n\t// Indexed devices. Normalized into a separate table.\n\tif _, err = tx.Exec(\n\t\t`CREATE TABLE devices(\n\t\t\tid       INT NOT NULL AUTO_INCREMENT,\n\t\t\tuserid   BIGINT NOT NULL,\n\t\t\thash     CHAR(16) NOT NULL,\n\t\t\tdeviceid TEXT NOT NULL,\n\t\t\tplatform VARCHAR(32),\n\t\t\tlastseen DATETIME NOT NULL,\n\t\t\tlang     VARCHAR(8),\n\t\t\tPRIMARY KEY(id),\n\t\t\tFOREIGN KEY(userid) REFERENCES users(id),\n\t\t\tUNIQUE INDEX devices_hash(hash)\n\t\t)`); err != nil {\n\t\treturn err\n\t}\n\n\t// Authentication records for the basic authentication scheme.\n\tif _, err = tx.Exec(\n\t\t`CREATE TABLE auth(\n\t\t\tid      INT NOT NULL AUTO_INCREMENT,\n\t\t\tuname   VARCHAR(32) NOT NULL,\n\t\t\tuserid  BIGINT NOT NULL,\n\t\t\tscheme  VARCHAR(16) NOT NULL,\n\t\t\tauthlvl INT NOT NULL,\n\t\t\tsecret  VARCHAR(255) NOT NULL,\n\t\t\texpires DATETIME,\n\t\t\tPRIMARY KEY(id),\n\t\t\tFOREIGN KEY(userid) REFERENCES users(id),\n\t\t\tUNIQUE INDEX auth_userid_scheme(userid, scheme),\n\t\t\tUNIQUE INDEX auth_uname(uname)\n\t\t)`); err != nil {\n\t\treturn err\n\t}\n\n\t// Topics\n\tif _, err = tx.Exec(\n\t\t`CREATE TABLE topics(\n\t\t\tid        INT NOT NULL AUTO_INCREMENT,\n\t\t\tcreatedat DATETIME(3) NOT NULL,\n\t\t\tupdatedat DATETIME(3) NOT NULL,\n\t\t\tstate     SMALLINT NOT NULL DEFAULT 0,\n\t\t\tstateat   DATETIME(3),\n\t\t\ttouchedat DATETIME(3),\n\t\t\tname      CHAR(25) NOT NULL,\n\t\t\tusebt     TINYINT DEFAULT 0,\n\t\t\towner     BIGINT NOT NULL DEFAULT 0,\n\t\t\taccess    JSON,\n\t\t\tseqid     INT NOT NULL DEFAULT 0,\n\t\t\tdelid     INT DEFAULT 0,\n\t\t\tsubcnt\t\tINT DEFAULT 0,\n\t\t\tpublic    JSON,\n\t\t\ttrusted   JSON,\n\t\t\ttags      JSON,\n\t\t\taux       JSON,\n\t\t\tPRIMARY KEY(id),\n\t\t\tUNIQUE INDEX topics_name(name),\n\t\t\tINDEX topics_owner(owner),\n\t\t\tINDEX topics_state_stateat(state, stateat),\n\t\t\tINDEX topics_name_state_seqid(name, state, seqid)\n\t\t)`); err != nil {\n\t\treturn err\n\t}\n\n\t// Create system topic 'sys'.\n\tif err = createSystemTopic(tx); err != nil {\n\t\treturn err\n\t}\n\n\t// Indexed topic tags.\n\tif _, err = tx.Exec(\n\t\t`CREATE TABLE topictags(\n\t\t\tid    INT NOT NULL AUTO_INCREMENT,\n\t\t\ttopic CHAR(25) NOT NULL,\n\t\t\ttag   VARCHAR(96) NOT NULL,\n\t\t\tPRIMARY KEY(id),\n\t\t\tFOREIGN KEY(topic) REFERENCES topics(name),\n\t\t\tINDEX topictags_tag(tag),\n\t\t\tUNIQUE INDEX topictags_topic_tag(topic, tag)\n\t\t)`); err != nil {\n\t\treturn err\n\t}\n\n\t// Subscriptions\n\tif _, err = tx.Exec(\n\t\t`CREATE TABLE subscriptions(\n\t\t\tid        INT NOT NULL AUTO_INCREMENT,\n\t\t\tcreatedat DATETIME(3) NOT NULL,\n\t\t\tupdatedat DATETIME(3) NOT NULL,\n\t\t\tdeletedat DATETIME(3),\n\t\t\tuserid    BIGINT NOT NULL,\n\t\t\ttopic     CHAR(25) NOT NULL,\n\t\t\tdelid     INT DEFAULT 0,\n\t\t\trecvseqid INT DEFAULT 0,\n\t\t\treadseqid INT DEFAULT 0,\n\t\t\tmodewant  CHAR(8),\n\t\t\tmodegiven CHAR(8),\n\t\t\tprivate   JSON,\n\t\t\tPRIMARY KEY(id),\n\t\t\tFOREIGN KEY(userid) REFERENCES users(id),\n\t\t\tUNIQUE INDEX subscriptions_topic_userid(topic, userid),\n\t\t\tINDEX subscriptions_topic(topic),\n\t\t\tINDEX subscriptions_deletedat(deletedat),\n\t\t\tINDEX subscriptions_userid_topic_deletedat(userid, topic, deletedat)\n\t\t)`); err != nil {\n\t\treturn err\n\t}\n\n\t// Messages\n\tif _, err = tx.Exec(\n\t\t`CREATE TABLE messages(\n\t\t\tid        INT NOT NULL AUTO_INCREMENT,\n\t\t\tcreatedat DATETIME(3) NOT NULL,\n\t\t\tupdatedat DATETIME(3) NOT NULL,\n\t\t\tdeletedat DATETIME(3),\n\t\t\tdelid     INT DEFAULT 0,\n\t\t\tseqid     INT NOT NULL,\n\t\t\ttopic     CHAR(25) NOT NULL,` +\n\t\t\t\"`from`   BIGINT NOT NULL,\" +\n\t\t\t`head     JSON,\n\t\t\tcontent   JSON,\n\t\t\tPRIMARY KEY(id),\n\t\t\tFOREIGN KEY(topic) REFERENCES topics(name),\n\t\t\tUNIQUE INDEX messages_topic_seqid(topic, seqid)\n\t\t)`); err != nil {\n\t\treturn err\n\t}\n\n\t// Deletion log\n\tif _, err = tx.Exec(\n\t\t`CREATE TABLE dellog(\n\t\t\tid         INT NOT NULL AUTO_INCREMENT,\n\t\t\ttopic      CHAR(25) NOT NULL,\n\t\t\tdeletedfor BIGINT NOT NULL DEFAULT 0,\n\t\t\tdelid      INT NOT NULL,\n\t\t\tlow        INT NOT NULL,\n\t\t\thi         INT NOT NULL,\n\t\t\tPRIMARY KEY(id),\n\t\t\tFOREIGN KEY(topic) REFERENCES topics(name),\n\t\t\tINDEX dellog_topic_delid_deletedfor(topic,delid,deletedfor),\n\t\t\tINDEX dellog_topic_deletedfor_low_hi(topic,deletedfor,low,hi),\n\t\t\tINDEX dellog_deletedfor(deletedfor)\n\t\t)`); err != nil {\n\t\treturn err\n\t}\n\n\t// User credentials\n\tif _, err = tx.Exec(\n\t\t`CREATE TABLE credentials(\n\t\t\tid        INT NOT NULL AUTO_INCREMENT,\n\t\t\tcreatedat DATETIME(3) NOT NULL,\n\t\t\tupdatedat DATETIME(3) NOT NULL,\n\t\t\tdeletedat DATETIME(3),\n\t\t\tmethod    VARCHAR(16) NOT NULL,\n\t\t\tvalue     VARCHAR(128) NOT NULL,\n\t\t\tsynthetic VARCHAR(192) NOT NULL,\n\t\t\tuserid    BIGINT NOT NULL,\n\t\t\tresp      VARCHAR(255),\n\t\t\tdone      TINYINT NOT NULL DEFAULT 0,\n\t\t\tretries   INT NOT NULL DEFAULT 0,\n\t\t\tPRIMARY KEY(id),\n\t\t\tFOREIGN KEY(userid) REFERENCES users(id),\n\t\t\tUNIQUE credentials_uniqueness(synthetic)\n\t\t)`); err != nil {\n\t\treturn err\n\t}\n\n\t// Records of uploaded files.\n\t// Don't add FOREIGN KEY on userid. It's not needed and it will break user deletion.\n\tif _, err = tx.Exec(\n\t\t`CREATE TABLE fileuploads(\n\t\t\tid        BIGINT NOT NULL,\n\t\t\tcreatedat DATETIME(3) NOT NULL,\n\t\t\tupdatedat DATETIME(3) NOT NULL,\n\t\t\tuserid    BIGINT,\n\t\t\tstatus    INT NOT NULL,\n\t\t\tmimetype  VARCHAR(255) NOT NULL,\n\t\t\tsize      BIGINT NOT NULL,\n\t\t\tetag      VARCHAR(128),\n\t\t\tlocation  VARCHAR(2048) NOT NULL,\n\t\t\tPRIMARY KEY(id),\n\t\t\tINDEX fileuploads_status(status)\n\t\t)`); err != nil {\n\t\treturn err\n\t}\n\n\t// Links between uploaded files and the topics, users or messages they are attached to.\n\tif _, err = tx.Exec(\n\t\t`CREATE TABLE filemsglinks(\n\t\t\tid        INT NOT NULL AUTO_INCREMENT,\n\t\t\tcreatedat DATETIME(3) NOT NULL,\n\t\t\tfileid    BIGINT NOT NULL,\n\t\t\tmsgid     INT,\n\t\t\ttopic     CHAR(25),\n\t\t\tuserid    BIGINT,\n\t\t\tPRIMARY KEY(id),\n\t\t\tFOREIGN KEY(fileid) REFERENCES fileuploads(id) ON DELETE CASCADE,\n\t\t\tFOREIGN KEY(msgid) REFERENCES messages(id) ON DELETE CASCADE,\n\t\t\tFOREIGN KEY(topic) REFERENCES topics(name) ON DELETE CASCADE,\n\t\t\tFOREIGN KEY(userid) REFERENCES users(id) ON DELETE CASCADE\n\t\t)`); err != nil {\n\t\treturn err\n\t}\n\n\tif _, err = tx.Exec(\n\t\t`CREATE TABLE kvmeta(` +\n\t\t\t\"`key`       VARCHAR(64) NOT NULL,\" +\n\t\t\t\"createdat   DATETIME(3),\" +\n\t\t\t\"`value`     TEXT,\" +\n\t\t\t\"PRIMARY KEY(`key`),\" +\n\t\t\t\"INDEX kvmeta_createdat_key(createdat, `key`)\" +\n\t\t\t`)`); err != nil {\n\t\treturn err\n\t}\n\tif _, err = tx.Exec(\"INSERT INTO kvmeta(`key`, `value`) VALUES('version',?)\", adpVersion); err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit()\n}\n\n// UpgradeDb upgrades the database, if necessary.\nfunc (a *adapter) UpgradeDb() error {\n\tbumpVersion := func(a *adapter, x int) error {\n\t\tif err := a.updateDbVersion(x); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err := a.GetDbVersion()\n\t\treturn err\n\t}\n\n\tif _, err := a.GetDbVersion(); err != nil {\n\t\treturn err\n\t}\n\n\tif a.version == 106 {\n\t\t// Perform database upgrade from version 106 to version 107.\n\n\t\tif _, err := a.db.Exec(\"CREATE UNIQUE INDEX usertags_userid_tag ON usertags(userid, tag)\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err := a.db.Exec(\"CREATE UNIQUE INDEX topictags_topic_tag ON topictags(topic, tag)\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err := a.db.Exec(\"ALTER TABLE credentials ADD deletedat DATETIME(3) AFTER updatedat\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := bumpVersion(a, 107); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version == 107 {\n\t\t// Perform database upgrade from version 107 to version 108.\n\n\t\t// Replace default user access JRWPA with JRWPAS.\n\t\tif _, err := a.db.Exec(`UPDATE users SET access=JSON_REPLACE(access, '$.Auth', 'JRWPAS')\n\t\t\tWHERE CAST(JSON_EXTRACT(access, '$.Auth') AS CHAR) LIKE '\"JRWPA\"'`); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := bumpVersion(a, 108); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version == 108 {\n\t\t// Perform database upgrade from version 108 to version 109.\n\n\t\ttx, err := a.db.Begin()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err = createSystemTopic(tx); err != nil {\n\t\t\ttx.Rollback()\n\t\t\treturn err\n\t\t}\n\t\tif err = tx.Commit(); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err = bumpVersion(a, 109); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version == 109 {\n\t\t// Perform database upgrade from version 109 to version 110.\n\t\tif _, err := a.db.Exec(\"UPDATE topics SET touchedat=updatedat WHERE touchedat IS NULL\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := bumpVersion(a, 110); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version == 110 {\n\t\t// Users\n\t\tif _, err := a.db.Exec(\"ALTER TABLE users MODIFY state SMALLINT NOT NULL DEFAULT 0 AFTER updatedat\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err := a.db.Exec(\"ALTER TABLE users CHANGE deletedat stateat DATETIME(3)\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err := a.db.Exec(\"ALTER TABLE users DROP INDEX users_deletedat\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Add status to formerly soft-deleted users.\n\t\tif _, err := a.db.Exec(\"UPDATE users SET state=? WHERE stateat IS NOT NULL\", t.StateDeleted); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err := a.db.Exec(\"ALTER TABLE users ADD INDEX users_state(state)\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Topics\n\t\tif _, err := a.db.Exec(\"ALTER TABLE topics ADD state SMALLINT NOT NULL DEFAULT 0 AFTER updatedat\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err := a.db.Exec(\"ALTER TABLE topics CHANGE deletedat stateat DATETIME(3)\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Add status to formerly soft-deleted topics.\n\t\tif _, err := a.db.Exec(\"UPDATE topics SET state=? WHERE stateat IS NOT NULL\", t.StateDeleted); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err := a.db.Exec(\"ALTER TABLE topics ADD INDEX topics_state(state)\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Subscriptions\n\t\tif _, err := a.db.Exec(\"ALTER TABLE subscriptions ADD INDEX topics_deletedat(deletedat)\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := bumpVersion(a, 111); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version == 111 {\n\t\t// Perform database upgrade from version 111 to version 112.\n\t\tif _, err := a.db.Exec(\"ALTER TABLE users ADD trusted JSON AFTER public\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err := a.db.Exec(\"ALTER TABLE topics ADD trusted JSON AFTER public\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Remove NOT NULL constraint, so an avatar upload can be done at registration.\n\t\tif _, err := a.db.Exec(\"ALTER TABLE fileuploads MODIFY userid BIGINT\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err := a.db.Exec(\"ALTER TABLE fileuploads ADD INDEX fileuploads_status(status)\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Remove NOT NULL constraint to enable links to users and topics.\n\t\tif _, err := a.db.Exec(\"ALTER TABLE filemsglinks MODIFY msgid INT\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err := a.db.Exec(\"ALTER TABLE filemsglinks ADD topic CHAR(25)\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err := a.db.Exec(\"ALTER TABLE filemsglinks ADD userid BIGINT\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err := a.db.Exec(\"ALTER TABLE filemsglinks ADD FOREIGN KEY(topic) REFERENCES topics(name) ON DELETE CASCADE\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err := a.db.Exec(\"ALTER TABLE filemsglinks ADD FOREIGN KEY(userid) REFERENCES users(id) ON DELETE CASCADE\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := bumpVersion(a, 112); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version == 112 {\n\t\t// Perform database upgrade from version 112 to version 113.\n\n\t\t// Index for deleting unvalidated accounts.\n\t\tif _, err := a.db.Exec(\"ALTER TABLE users ADD INDEX users_lastseen_updatedat(lastseen,updatedat)\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Add timestamp to kvmeta.\n\t\tif _, err := a.db.Exec(\"ALTER TABLE kvmeta MODIFY `key` VARCHAR(64) NOT NULL\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Add timestamp to kvmeta.\n\t\tif _, err := a.db.Exec(\"ALTER TABLE kvmeta ADD createdat DATETIME(3) AFTER `key`\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Add compound index on the new field and key (could be searched by key prefix).\n\t\tif _, err := a.db.Exec(\"ALTER TABLE kvmeta ADD INDEX kvmeta_createdat_key(createdat, `key`)\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := bumpVersion(a, 113); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version == 113 {\n\t\t// Perform database upgrade from version 113 to version 114.\n\n\t\tif _, err := a.db.Exec(\"ALTER TABLE topics ADD aux JSON\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err := a.db.Exec(\"ALTER TABLE fileuploads ADD etag VARCHAR(128) AFTER size\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := bumpVersion(a, 114); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version == 114 {\n\t\t// Perform database upgrade from version 114 to version 115.\n\n\t\t// Find relevant subscriptions for given users efficiently, and use the join key too.\n\t\tif _, err := a.db.Exec(\"CREATE INDEX idx_subs_user_topic_del ON subscriptions(userid, topic, deletedat)\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Optimizes join; state filters; seqid supports the SUM operation.\n\t\tif _, err := a.db.Exec(\"CREATE INDEX idx_topics_name_state_seqid ON topics(name, state, seqid)\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := bumpVersion(a, 115); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version == 115 {\n\t\t// Perform database upgrade from version 115 to version 116.\n\n\t\t// Add subscriber count column to the topics table.\n\t\tif _, err := a.db.Exec(\"ALTER TABLE topics ADD COLUMN subcnt INT DEFAULT 0 AFTER delid\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := bumpVersion(a, 116); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version != adpVersion {\n\t\treturn errors.New(\"Failed to perform database upgrade to version \" + strconv.Itoa(adpVersion) +\n\t\t\t\". DB is still at \" + strconv.Itoa(a.version))\n\t}\n\treturn nil\n}\n\n// Create system topic 'sys'.\nfunc createSystemTopic(tx *sql.Tx) error {\n\tnow := t.TimeNow()\n\tquery := `INSERT INTO topics(createdat,updatedat,state,touchedat,name,access,public)\n\t\t\t\tVALUES(?,?,?,?,'sys','{\"Auth\": \"N\",\"Anon\": \"N\"}','{\"fn\": \"System\"}')`\n\t_, err := tx.Exec(query, now, now, t.StateOK, now)\n\treturn err\n}\n\nfunc addTags(tx *sqlx.Tx, table, keyName string, keyVal any, tags []string, ignoreDups bool) error {\n\tif len(tags) == 0 {\n\t\treturn nil\n\t}\n\n\tinsert, err := tx.Prepare(\"INSERT INTO \" + table + \"(\" + keyName + \",tag) VALUES(?,?)\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, tag := range tags {\n\t\tif _, err = insert.Exec(keyVal, tag); err != nil {\n\t\t\tif isDupe(err) {\n\t\t\t\tif ignoreDups {\n\t\t\t\t\terr = nil\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\treturn t.ErrDuplicate\n\t\t\t}\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc removeTags(tx *sqlx.Tx, table, keyName string, keyVal any, tags []string) error {\n\tif len(tags) == 0 {\n\t\treturn nil\n\t}\n\n\tvar args []any\n\tfor _, tag := range tags {\n\t\targs = append(args, tag)\n\t}\n\n\tquery, args, _ := sqlx.In(\"DELETE FROM \"+table+\" WHERE \"+keyName+\"=? AND tag IN (?)\", keyVal, args)\n\t_, err := tx.Exec(tx.Rebind(query), args...)\n\n\treturn err\n}\n\n// UserCreate creates a new user. Returns error and true if error is due to duplicate user name,\n// false for any other error\nfunc (a *adapter) UserCreate(user *t.User) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback()\n\t\t}\n\t}()\n\n\tdecoded_uid := store.DecodeUid(user.Uid())\n\tif _, err = tx.Exec(\"INSERT INTO users(id,createdat,updatedat,state,access,public,trusted,tags) VALUES(?,?,?,?,?,?,?,?)\",\n\t\tdecoded_uid,\n\t\tuser.CreatedAt,\n\t\tuser.UpdatedAt,\n\t\tuser.State,\n\t\tuser.Access,\n\t\tcommon.ToJSON(user.Public),\n\t\tcommon.ToJSON(user.Trusted),\n\t\tuser.Tags); err != nil {\n\t\treturn err\n\t}\n\n\t// Save user's tags to a separate table to make user findable.\n\tif err = addTags(tx, \"usertags\", \"userid\", decoded_uid, user.Tags, false); err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit()\n}\n\n// Add user's authentication record\nfunc (a *adapter) AuthAddRecord(uid t.Uid, scheme, unique string, authLvl auth.Level,\n\tsecret []byte, expires time.Time) error {\n\n\tvar exp *time.Time\n\tif !expires.IsZero() {\n\t\texp = &expires\n\t}\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\tif _, err := a.db.ExecContext(ctx, \"INSERT INTO auth(uname,userid,scheme,authLvl,secret,expires) VALUES(?,?,?,?,?,?)\",\n\t\tunique, store.DecodeUid(uid), scheme, authLvl, secret, exp); err != nil {\n\t\tif isDupe(err) {\n\t\t\treturn t.ErrDuplicate\n\t\t}\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// AuthDelScheme deletes an existing authentication scheme for the user.\nfunc (a *adapter) AuthDelScheme(user t.Uid, scheme string) error {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\t_, err := a.db.ExecContext(ctx, \"DELETE FROM auth WHERE userid=? AND scheme=?\", store.DecodeUid(user), scheme)\n\treturn err\n}\n\n// AuthDelAllRecords deletes all authentication records for the user.\nfunc (a *adapter) AuthDelAllRecords(user t.Uid) (int, error) {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tres, err := a.db.ExecContext(ctx, \"DELETE FROM auth WHERE userid=?\", store.DecodeUid(user))\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tcount, _ := res.RowsAffected()\n\n\treturn int(count), nil\n}\n\n// Update user's authentication unique, secret, auth level.\nfunc (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string, authLvl auth.Level,\n\tsecret []byte, expires time.Time) error {\n\n\tparams := []string{\"authLvl=?\"}\n\targs := []any{authLvl}\n\n\tif unique != \"\" {\n\t\tparams = append(params, \"uname=?\")\n\t\targs = append(args, unique)\n\t}\n\tif len(secret) > 0 {\n\t\tparams = append(params, \"secret=?\")\n\t\targs = append(args, secret)\n\t}\n\tif !expires.IsZero() {\n\t\tparams = append(params, \"expires=?\")\n\t\targs = append(args, expires)\n\t}\n\targs = append(args, store.DecodeUid(uid), scheme)\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tsql := \"UPDATE auth SET \" + strings.Join(params, \",\") + \" WHERE userid=? AND scheme=?\"\n\tresp, err := a.db.ExecContext(ctx, sql, args...)\n\tif isDupe(err) {\n\t\treturn t.ErrDuplicate\n\t}\n\n\tif count, _ := resp.RowsAffected(); count <= 0 {\n\t\treturn t.ErrNotFound\n\t}\n\n\treturn err\n}\n\n// Retrieve user's authentication record\nfunc (a *adapter) AuthGetRecord(uid t.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) {\n\tvar expires time.Time\n\n\tvar record struct {\n\t\tUname   string\n\t\tAuthlvl auth.Level\n\t\tSecret  []byte\n\t\tExpires *time.Time\n\t}\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tif err := a.db.GetContext(ctx, &record, \"SELECT uname,secret,expires,authlvl FROM auth WHERE userid=? AND scheme=?\",\n\t\tstore.DecodeUid(uid), scheme); err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\t// Nothing found - use standard error.\n\t\t\terr = t.ErrNotFound\n\t\t}\n\t\treturn \"\", 0, nil, expires, err\n\t}\n\n\tif record.Expires != nil {\n\t\texpires = *record.Expires\n\t}\n\n\treturn record.Uname, record.Authlvl, record.Secret, expires, nil\n}\n\n// Retrieve user's authentication record\nfunc (a *adapter) AuthGetUniqueRecord(unique string) (t.Uid, auth.Level, []byte, time.Time, error) {\n\tvar expires time.Time\n\n\tvar record struct {\n\t\tUserid  int64\n\t\tAuthlvl auth.Level\n\t\tSecret  []byte\n\t\tExpires *time.Time\n\t}\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tif err := a.db.GetContext(ctx, &record, \"SELECT userid,secret,expires,authlvl FROM auth WHERE uname=?\", unique); err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\t// Nothing found - clear the error\n\t\t\terr = nil\n\t\t}\n\t\treturn t.ZeroUid, 0, nil, expires, err\n\t}\n\n\tif record.Expires != nil {\n\t\texpires = *record.Expires\n\t}\n\n\treturn store.EncodeUid(record.Userid), record.Authlvl, record.Secret, expires, nil\n}\n\n// UserGet fetches a single user by user id. If user is not found it returns (nil, nil)\nfunc (a *adapter) UserGet(uid t.Uid) (*t.User, error) {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tvar user t.User\n\terr := a.db.GetContext(ctx, &user, \"SELECT * FROM users WHERE id=? AND state!=?\", store.DecodeUid(uid), t.StateDeleted)\n\tif err == nil {\n\t\tuser.SetUid(uid)\n\t\tuser.Public = common.FromJSON(user.Public)\n\t\tuser.Trusted = common.FromJSON(user.Trusted)\n\t\treturn &user, nil\n\t}\n\n\tif err == sql.ErrNoRows {\n\t\t// Clear the error if user does not exist or marked as soft-deleted.\n\t\treturn nil, nil\n\t}\n\n\treturn nil, err\n}\n\nfunc (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) {\n\tuids := make([]any, len(ids))\n\tfor i, id := range ids {\n\t\tif id.IsZero() {\n\t\t\tcontinue\n\t\t}\n\t\tuids[i] = store.DecodeUid(id)\n\t}\n\n\tusers := []t.User{}\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tq, uids, _ := sqlx.In(\"SELECT * FROM users WHERE id IN (?) AND state!=?\", uids, t.StateDeleted)\n\trows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), uids...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tvar user t.User\n\t\tif err = rows.StructScan(&user); err != nil {\n\t\t\tusers = nil\n\t\t\tbreak\n\t\t}\n\t\tuser.SetUid(common.EncodeUidString(user.Id))\n\t\tuser.Public = common.FromJSON(user.Public)\n\t\tuser.Trusted = common.FromJSON(user.Trusted)\n\n\t\tusers = append(users, user)\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\treturn users, err\n}\n\n// UserDelete deletes specified user: wipes completely (hard-delete) or marks as deleted.\n// TODO: report when the user is not found.\nfunc (a *adapter) UserDelete(uid t.Uid, hard bool) error {\n\tquery := \"SELECT name FROM topics WHERE owner=?\"\n\targs := []any{store.DecodeUid(uid)}\n\t// In case of hard delete, delete all topics, even those which were\n\t// soft-deleted previsously.\n\tif !hard {\n\t\tquery += \" AND state!=?\"\n\t\targs = append(args, t.StateDeleted)\n\t}\n\t// Get a list of topic names owned by the user (as 'grp' and 'chn').\n\townTopics, err := a.topicNamesForUser(query, true, args...)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback()\n\t\t}\n\t}()\n\n\tnow := t.TimeNow()\n\tdecoded_uid := store.DecodeUid(uid)\n\n\tif hard {\n\t\t// Delete user's devices\n\t\t// t.ErrNotFound = user has no devices.\n\t\tif err = deviceDelete(tx, uid, \"\"); err != nil && err != t.ErrNotFound {\n\t\t\treturn err\n\t\t}\n\n\t\t// Delete user's subscriptions in all topics.\n\t\tif err = subsDelForUser(tx, decoded_uid, true); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Delete records of messages soft-deleted for the user in all topics.\n\t\tif _, err = tx.Exec(\"DELETE FROM dellog WHERE deletedfor=?\", decoded_uid); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Can't delete user's messages in all topics because we cannot notify topics of such deletion.\n\t\t// Just leave the messages there marked as sent by \"not found\" user.\n\n\t\t// Delete topics where the user is the owner.\n\t\tif len(ownTopics) > 0 {\n\t\t\t// First delete all messages in those topics.\n\t\t\tif _, err = tx.Exec(\"DELETE dellog FROM dellog JOIN topics ON topics.name=dellog.topic WHERE topics.owner=?\",\n\t\t\t\tdecoded_uid); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// Deletion of messages will cascade to filemsglinks and so to fileuploads.\n\t\t\tif _, err = tx.Exec(\"DELETE messages FROM messages JOIN topics ON topics.name=messages.topic WHERE topics.owner=?\",\n\t\t\t\tdecoded_uid); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// Delete subscriptions for all users where the user is the owner of the topic.\n\t\t\tsql, args, _ := sqlx.In(\"DELETE FROM subscriptions AS s WHERE topic IN (?)\", ownTopics)\n\t\t\tif _, err = tx.Exec(tx.Rebind(sql), args); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// Delete topic tags.\n\t\t\tif _, err = tx.Exec(\"DELETE tt FROM topictags AS tt JOIN topics AS t ON t.name=tt.topic WHERE t.owner=?\",\n\t\t\t\tdecoded_uid); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// And finally delete the topics.\n\t\t\tif _, err = tx.Exec(\"DELETE FROM topics WHERE owner=?\", decoded_uid); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t// Delete user's authentication records.\n\t\tif _, err = tx.Exec(\"DELETE FROM auth WHERE userid=?\", decoded_uid); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Delete all credentials.\n\t\tif err = credDel(tx, uid, \"\", \"\"); err != nil && err != t.ErrNotFound {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err = tx.Exec(\"DELETE FROM usertags WHERE userid=?\", decoded_uid); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err = tx.Exec(\"DELETE FROM users WHERE id=?\", decoded_uid); err != nil {\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\t// Disable all user's subscriptions. That includes p2p subscriptions. No need to delete them.\n\t\tif err = subsDelForUser(tx, decoded_uid, false); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(ownTopics) > 0 {\n\t\t\t// Disable all subscriptions to topics where the user is the owner.\n\t\t\tsql, args, _ := sqlx.In(\"UPDATE subscriptions SET updatedat=?,deletedat=? WHERE topic IN (?)\", now, now, ownTopics)\n\t\t\tif _, err = tx.Exec(tx.Rebind(sql), args...); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t// Disable group topics where the user is the owner.\n\t\tif _, err = tx.Exec(\"UPDATE topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE owner=?\",\n\t\t\tnow, now, t.StateDeleted, now, decoded_uid); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Disable p2p topics with the user (p2p topic's owner is 0).\n\t\tif _, err = tx.Exec(\"UPDATE topics AS t JOIN subscriptions AS s ON t.name=s.topic \"+\n\t\t\t\"SET t.updatedat=?,t.touchedat=?,t.state=?,t.stateat=? \"+\n\t\t\t\"WHERE t.owner=0 AND s.userid=? AND t.name LIKE 'p2p%'\",\n\t\t\tnow, now, t.StateDeleted, now, decoded_uid); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Disable the other user's subscription to a disabled p2p topic.\n\t\tif _, err = tx.Exec(\"UPDATE subscriptions AS s_one JOIN subscriptions AS s_two \"+\n\t\t\t\"ON s_one.topic=s_two.topic \"+\n\t\t\t\"SET s_two.updatedat=?, s_two.deletedat=? WHERE s_one.userid=? AND s_one.topic LIKE 'p2p%'\",\n\t\t\tnow, now, decoded_uid); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Finally disable user.\n\t\tif _, err = tx.Exec(\"UPDATE users SET updatedat=?,state=?,stateat=? WHERE id=?\",\n\t\t\tnow, t.StateDeleted, now, decoded_uid); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn tx.Commit()\n}\n\n// topicStateForUser is called by UserUpdate when the update contains state change.\n// Soft-deleted topics remain soft-deleted.\nfunc (a *adapter) topicStateForUser(tx *sqlx.Tx, decoded_uid int64, now time.Time, update any) error {\n\tvar err error\n\n\tstate, ok := update.(t.ObjState)\n\tif !ok {\n\t\treturn t.ErrMalformed\n\t}\n\n\tif now.IsZero() {\n\t\tnow = t.TimeNow()\n\t}\n\n\t// Change state of all topics where the user is the owner.\n\tif _, err = tx.Exec(\"UPDATE topics SET state=?, stateat=? WHERE owner=? AND state!=?\",\n\t\tstate, now, decoded_uid, t.StateDeleted); err != nil {\n\t\treturn err\n\t}\n\n\t// Change state of p2p topics with the user (p2p topic's owner is 0)\n\tif _, err = tx.Exec(\"UPDATE topics JOIN subscriptions ON topics.name=subscriptions.topic \"+\n\t\t\"SET topics.state=?, topics.stateat=? WHERE topics.owner=0 AND subscriptions.userid=? AND topics.state!=?\",\n\t\tstate, now, decoded_uid, t.StateDeleted); err != nil {\n\t\treturn err\n\t}\n\n\t// Subscriptions don't need to be updated:\n\t// subscriptions of a disabled user are not disabled and still can be manipulated.\n\treturn nil\n}\n\n// UserUpdate updates user object.\nfunc (a *adapter) UserUpdate(uid t.Uid, update map[string]any) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback()\n\t\t}\n\t}()\n\n\tcols, args := common.UpdateByMap(update)\n\tdecoded_uid := store.DecodeUid(uid)\n\targs = append(args, decoded_uid)\n\t_, err = tx.Exec(\"UPDATE users SET \"+strings.Join(cols, \",\")+\" WHERE id=?\", args...)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif state, ok := update[\"State\"]; ok {\n\t\tnow, _ := update[\"StateAt\"].(time.Time)\n\t\terr = a.topicStateForUser(tx, decoded_uid, now, state)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Tags are also stored in a separate table\n\tif tags := common.ExtractTags(update); tags != nil {\n\t\t// First delete all user tags\n\t\t_, err = tx.Exec(\"DELETE FROM usertags WHERE userid=?\", decoded_uid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// Now insert new tags\n\t\terr = addTags(tx, \"usertags\", \"userid\", decoded_uid, tags, false)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn tx.Commit()\n}\n\n// UserUpdateTags adds, removes, or resets user's tags.\nfunc (a *adapter) UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]string, error) {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback()\n\t\t}\n\t}()\n\n\tdecoded_uid := store.DecodeUid(uid)\n\n\tif reset != nil {\n\t\t// Delete all tags first if resetting.\n\t\t_, err = tx.Exec(\"DELETE FROM usertags WHERE userid=?\", decoded_uid)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tadd = reset\n\t\tremove = nil\n\t}\n\n\t// Now insert new tags. Ignore duplicates if resetting.\n\terr = addTags(tx, \"usertags\", \"userid\", decoded_uid, add, reset == nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Delete tags.\n\terr = removeTags(tx, \"usertags\", \"userid\", decoded_uid, remove)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar allTags []string\n\terr = tx.Select(&allTags, \"SELECT tag FROM usertags WHERE userid=?\", decoded_uid)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t_, err = tx.Exec(\"UPDATE users SET tags=? WHERE id=?\", t.StringSlice(allTags), decoded_uid)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn allTags, tx.Commit()\n}\n\n// UserGetByCred returns user ID for the given validated credential.\nfunc (a *adapter) UserGetByCred(method, value string) (t.Uid, error) {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tvar decoded_uid int64\n\terr := a.db.GetContext(ctx, &decoded_uid, \"SELECT userid FROM credentials WHERE synthetic=?\", method+\":\"+value)\n\tif err == nil {\n\t\treturn store.EncodeUid(decoded_uid), nil\n\t}\n\n\tif err == sql.ErrNoRows {\n\t\t// Clear the error if user does not exist\n\t\treturn t.ZeroUid, nil\n\t}\n\treturn t.ZeroUid, err\n}\n\n// UserUnreadCount returns the total number of unread messages in all topics with\n// the R permission. If read fails, the counts are still returned with the original\n// user IDs but with the unread count undefined and non-nil error.\n// UserUnreadCount does not count unread messages in channels although it should.\nfunc (a *adapter) UserUnreadCount(ids ...t.Uid) (map[t.Uid]int, error) {\n\tuids := make([]any, len(ids))\n\tcounts := make(map[t.Uid]int, len(ids))\n\tfor i, id := range ids {\n\t\tuids[i] = store.DecodeUid(id)\n\t\t// Ensure all original uids are always present.\n\t\tcounts[id] = 0\n\t}\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\t// FIXME: support channels (for channels subscriptions.topic != topics.name).\n\tq, args, _ := sqlx.In(\"SELECT s.userid, SUM(t.seqid)-SUM(s.readseqid) AS unreadcount FROM topics AS t, subscriptions AS s \"+\n\t\t\"WHERE s.userid IN (?) AND t.name=s.topic AND s.deletedat IS NULL AND t.state!=? AND \"+\n\t\t\"INSTR(s.modewant, 'R')>0 AND INSTR(s.modegiven, 'R')>0 GROUP BY s.userid\", uids, int(t.StateDeleted))\n\trows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), args...)\n\tif err != nil {\n\t\treturn counts, err\n\t}\n\tdefer rows.Close()\n\n\tvar userId int64\n\tvar unreadCount int\n\tfor rows.Next() {\n\t\tif err = rows.Scan(&userId, &unreadCount); err != nil {\n\t\t\tbreak\n\t\t}\n\t\tcounts[store.EncodeUid(userId)] = unreadCount\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\treturn counts, err\n}\n\n// UserGetUnvalidated returns a list of uids which have never logged in, have no\n// validated credentials and haven't been updated since lastUpdatedBefore.\nfunc (a *adapter) UserGetUnvalidated(lastUpdatedBefore time.Time, limit int) ([]t.Uid, error) {\n\tvar uids []t.Uid\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\trows, err := a.db.QueryxContext(ctx,\n\t\t\"SELECT u.id, IFNULL(SUM(c.done),0) AS total FROM users AS u \"+\n\t\t\t\"LEFT JOIN credentials AS c ON u.id=c.userid WHERE u.lastseen IS NULL AND u.updatedat<? \"+\n\t\t\t\"GROUP BY u.id, u.updatedat HAVING total=0 ORDER BY u.updatedat ASC LIMIT ?\", lastUpdatedBefore, limit)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tvar userId int64\n\t\tvar unused int\n\t\tif err = rows.Scan(&userId, &unused); err != nil {\n\t\t\tbreak\n\t\t}\n\t\tuids = append(uids, store.EncodeUid(userId))\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\treturn uids, err\n}\n\nfunc (a *adapter) topicCreate(tx *sqlx.Tx, topic *t.Topic) error {\n\t_, err := tx.Exec(\"INSERT INTO topics(createdat,updatedat,touchedat,state,name,usebt,owner,access,public,trusted,tags,aux) \"+\n\t\t\"VALUES(?,?,?,?,?,?,?,?,?,?,?,?)\",\n\t\ttopic.CreatedAt, topic.UpdatedAt, topic.TouchedAt, topic.State, topic.Id, topic.UseBt,\n\t\tstore.DecodeUid(t.ParseUid(topic.Owner)), topic.Access, common.ToJSON(topic.Public), common.ToJSON(topic.Trusted),\n\t\ttopic.Tags, common.ToJSON(topic.Aux))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Save topic's tags to a separate table to make topic findable.\n\treturn addTags(tx, \"topictags\", \"topic\", topic.Id, topic.Tags, false)\n}\n\n// TopicCreate saves topic object to database.\nfunc (a *adapter) TopicCreate(topic *t.Topic) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback()\n\t\t}\n\t}()\n\n\terr = a.topicCreate(tx, topic)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn tx.Commit()\n}\n\n// If undelete = true - update subscription on duplicate key, otherwise ignore the duplicate.\nfunc createSubscription(tx *sqlx.Tx, sub *t.Subscription, undelete bool) error {\n\n\tisOwner := (sub.ModeGiven & sub.ModeWant).IsOwner()\n\n\tjpriv := common.ToJSON(sub.Private)\n\tdecoded_uid := store.DecodeUid(t.ParseUid(sub.User))\n\t_, err := tx.Exec(\n\t\t\"INSERT INTO subscriptions(createdat,updatedat,deletedat,userid,topic,modeWant,modeGiven,private) \"+\n\t\t\t\"VALUES(?,?,NULL,?,?,?,?,?)\",\n\t\tsub.CreatedAt, sub.UpdatedAt, decoded_uid, sub.Topic, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv)\n\n\tif err != nil && isDupe(err) {\n\t\tif undelete {\n\t\t\t_, err = tx.Exec(\"UPDATE subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,\"+\n\t\t\t\t\"delid=0,recvseqid=0,readseqid=0 WHERE topic=? AND userid=?\",\n\t\t\t\tsub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), sub.Topic, decoded_uid)\n\t\t} else {\n\t\t\t_, err = tx.Exec(\"UPDATE subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,\"+\n\t\t\t\t\"delid=0,recvseqid=0,readseqid=0,private=? WHERE topic=? AND userid=?\",\n\t\t\t\tsub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv,\n\t\t\t\tsub.Topic, decoded_uid)\n\t\t}\n\t}\n\n\tif err == nil && isOwner {\n\t\t// Update topic owner if the subscription is with owner rights.\n\t\t// Don't increment subscriber count here - it's done in TopicShare in bulk.\n\t\t_, err = tx.Exec(\"UPDATE topics SET owner=? WHERE name=?\", decoded_uid, sub.Topic)\n\t}\n\treturn err\n}\n\n// TopicCreateP2P given two users creates a p2p topic.\nfunc (a *adapter) TopicCreateP2P(initiator, invited *t.Subscription) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback()\n\t\t}\n\t}()\n\n\terr = createSubscription(tx, initiator, false)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// If the second subscription exists, don't overwrite it. Just make sure it's not deleted.\n\terr = createSubscription(tx, invited, true)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttopic := &t.Topic{ObjHeader: t.ObjHeader{Id: initiator.Topic}}\n\ttopic.ObjHeader.MergeTimes(&initiator.ObjHeader)\n\ttopic.TouchedAt = initiator.GetTouchedAt()\n\terr = a.topicCreate(tx, topic)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit()\n}\n\n// TopicGet loads a single topic by name, if it exists. If the topic does not exist the call returns (nil, nil)\nfunc (a *adapter) TopicGet(topic string) (*t.Topic, error) {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\t// Fetch topic by name\n\tvar tt = new(t.Topic)\n\tif err := a.db.GetContext(ctx, tt,\n\t\t\"SELECT createdat,updatedat,state,stateat,touchedat,name AS id,usebt,access,owner,seqid,delid,subcnt,public,trusted,tags,aux \"+\n\t\t\t\"FROM topics WHERE name=?\", topic); err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\t// Nothing found - clear the error\n\t\t\terr = nil\n\t\t}\n\t\treturn nil, err\n\t}\n\n\tif t.GetTopicCat(topic) == t.TopicCatGrp {\n\t\t// Topic found, get subsription count (ignoring the value set in topics.subcnt). Try both topic and channel names.\n\t\tvar subCnt int\n\t\tif err := a.db.GetContext(ctx, &subCnt,\n\t\t\t\"SELECT COUNT(*) FROM subscriptions WHERE topic IN (?,?) AND deletedat IS NULL\", topic, t.GrpToChn(topic)); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif subCnt != tt.SubCnt {\n\t\t\t// Update the topic with the correct subscription count.\n\t\t\ttt.SubCnt = subCnt\n\t\t\tif _, err := a.db.ExecContext(ctx, \"UPDATE topics SET subcnt=? WHERE name=?\", subCnt, topic); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\n\ttt.Owner = common.EncodeUidString(tt.Owner).String()\n\ttt.Public = common.FromJSON(tt.Public)\n\ttt.Trusted = common.FromJSON(tt.Trusted)\n\n\treturn tt, nil\n}\n\n// TopicsForUser loads user's contact list: p2p and grp topics, except for 'me' & 'fnd' subscriptions.\n// Reads and denormalizes Public & Trusted values.\nfunc (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {\n\t// Fetch ALL user's subscriptions, even those which has not been modified recently.\n\t// We are going to use these subscriptions to fetch topics and users which may have been modified recently.\n\tq := `SELECT createdat,updatedat,deletedat,topic,delid,recvseqid,\n\t\treadseqid,modewant,modegiven,private FROM subscriptions WHERE userid=?`\n\targs := []any{store.DecodeUid(uid)}\n\tif !keepDeleted {\n\t\t// Filter out deleted rows.\n\t\tq += \" AND deletedat IS NULL\"\n\t}\n\n\tlimit := 0\n\tims := time.Time{}\n\tif opts != nil {\n\t\tif opts.Topic != \"\" {\n\t\t\tq += \" AND topic=?\"\n\t\t\targs = append(args, opts.Topic)\n\t\t}\n\n\t\t// Apply the limit only when the client does not manage the cache (or cold start).\n\t\t// Otherwise have to get all subscriptions and do a manual join with users/topics.\n\t\tif opts.IfModifiedSince == nil {\n\t\t\tif opts.Limit > 0 && opts.Limit < a.maxResults {\n\t\t\t\tlimit = opts.Limit\n\t\t\t} else {\n\t\t\t\tlimit = a.maxResults\n\t\t\t}\n\t\t} else {\n\t\t\tims = *opts.IfModifiedSince\n\t\t}\n\t} else {\n\t\tlimit = a.maxResults\n\t}\n\n\tif limit > 0 {\n\t\tq += \" LIMIT ?\"\n\t\targs = append(args, limit)\n\t}\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\trows, err := a.db.QueryxContext(ctx, q, args...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t// Must close rows manually as we will be reusing it.\n\n\t// Fetch subscriptions. Two queries are needed: users table (p2p) and topics table (grp).\n\t// Prepare a list of separate subscriptions to users vs topics\n\tjoin := make(map[string]t.Subscription) // Keeping these to make a join with table for .private and .access\n\ttopq := make([]any, 0, 16)\n\tusrq := make([]any, 0, 16)\n\tfor rows.Next() {\n\t\tvar sub t.Subscription\n\t\tif err = rows.StructScan(&sub); err != nil {\n\t\t\tbreak\n\t\t}\n\t\ttname := sub.Topic\n\t\tsub.User = uid.String()\n\t\ttcat := t.GetTopicCat(tname)\n\n\t\tif tcat == t.TopicCatMe || tcat == t.TopicCatFnd {\n\t\t\t// One of 'me', 'fnd' subscriptions, skip.\n\t\t\t// Don't skip 'sys' subscription.\n\t\t\tcontinue\n\t\t} else if tcat == t.TopicCatP2P {\n\t\t\t// P2P subscription, find the other user to get user.Public and user.Trusted.\n\t\t\tuid1, uid2, _ := t.ParseP2P(tname)\n\t\t\tif uid1 == uid {\n\t\t\t\tusrq = append(usrq, store.DecodeUid(uid2))\n\t\t\t\tsub.SetWith(uid2.UserId())\n\t\t\t} else {\n\t\t\t\tusrq = append(usrq, store.DecodeUid(uid1))\n\t\t\t\tsub.SetWith(uid1.UserId())\n\t\t\t}\n\t\t} else if tcat == t.TopicCatGrp {\n\t\t\t// Maybe convert channel name to group topic name.\n\t\t\ttname = t.ChnToGrp(tname)\n\t\t}\n\t\t// No special handling needed for 'slf', 'sys' subscriptions.\n\n\t\ttopq = append(topq, tname)\n\t\tsub.Private = common.FromJSON(sub.Private)\n\t\tjoin[tname] = sub\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\trows.Close()\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar subs []t.Subscription\n\tif len(join) == 0 {\n\t\treturn subs, nil\n\t}\n\n\t// Fetch grp topics and join to subscriptions.\n\tif len(topq) > 0 {\n\t\tq = \"SELECT updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,public,trusted \" +\n\t\t\t\"FROM topics WHERE name IN (?)\"\n\t\tq, args, _ = sqlx.In(q, topq)\n\n\t\tif !keepDeleted {\n\t\t\t// Optionally skip deleted topics.\n\t\t\tq += \" AND state!=?\"\n\t\t\targs = append(args, t.StateDeleted)\n\t\t}\n\n\t\tif !ims.IsZero() {\n\t\t\t// Use cache timestamp if provided: get newer entries only.\n\t\t\tq += \" AND touchedat>?\"\n\t\t\targs = append(args, ims)\n\n\t\t\tif limit > 0 && limit < len(topq) {\n\t\t\t\t// No point in fetching more than the requested limit.\n\t\t\t\tq += \" ORDER BY touchedat LIMIT ?\"\n\t\t\t\targs = append(args, limit)\n\t\t\t}\n\t\t}\n\n\t\tctx2, cancel2 := a.getContext()\n\t\tif cancel2 != nil {\n\t\t\tdefer cancel2()\n\t\t}\n\t\trows, err = a.db.QueryxContext(ctx2, a.db.Rebind(q), args...)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tvar top t.Topic\n\t\tfor rows.Next() {\n\t\t\tif err = rows.StructScan(&top); err != nil {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tsub := join[top.Id]\n\t\t\t// Check if sub.UpdatedAt needs to be adjusted to earlier or later time.\n\t\t\tsub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, top.UpdatedAt)\n\t\t\tsub.SetState(top.State)\n\t\t\tsub.SetTouchedAt(top.TouchedAt)\n\t\t\tsub.SetSeqId(top.SeqId)\n\t\t\tif t.GetTopicCat(sub.Topic) == t.TopicCatGrp {\n\t\t\t\tsub.SetSubCnt(top.SubCnt)\n\t\t\t\tsub.SetPublic(common.FromJSON(top.Public))\n\t\t\t\tsub.SetTrusted(common.FromJSON(top.Trusted))\n\t\t\t}\n\t\t\t// Put back the updated value of a subsription, will process further below\n\t\t\tjoin[top.Id] = sub\n\t\t}\n\t\tif err == nil {\n\t\t\terr = rows.Err()\n\t\t}\n\t\trows.Close()\n\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\t// Fetch p2p users and join to p2p subscriptions.\n\tif len(usrq) > 0 {\n\t\tq = \"SELECT id,updatedat,state,access,lastseen,useragent,public,trusted \" +\n\t\t\t\"FROM users WHERE id IN (?)\"\n\t\tq, args, _ = sqlx.In(q, usrq)\n\t\tif !keepDeleted {\n\t\t\t// Optionally skip deleted users.\n\t\t\tq += \" AND state!=?\"\n\t\t\targs = append(args, t.StateDeleted)\n\t\t}\n\n\t\t// Ignoring ims: we need all users to get LastSeen and UserAgent.\n\n\t\tctx3, cancel3 := a.getContext()\n\t\tif cancel3 != nil {\n\t\t\tdefer cancel3()\n\t\t}\n\t\trows, err = a.db.QueryxContext(ctx3, a.db.Rebind(q), args...)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor rows.Next() {\n\t\t\tvar usr2 t.User\n\t\t\tif err = rows.StructScan(&usr2); err != nil {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tjoinOn := uid.P2PName(common.EncodeUidString(usr2.Id))\n\t\t\tif sub, ok := join[joinOn]; ok {\n\t\t\t\tsub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, usr2.UpdatedAt)\n\t\t\t\tsub.SetState(usr2.State)\n\t\t\t\tsub.SetPublic(common.FromJSON(usr2.Public))\n\t\t\t\tsub.SetTrusted(common.FromJSON(usr2.Trusted))\n\t\t\t\tsub.SetDefaultAccess(usr2.Access.Auth, usr2.Access.Anon)\n\t\t\t\tsub.SetLastSeenAndUA(usr2.LastSeen, usr2.UserAgent)\n\t\t\t\tjoin[joinOn] = sub\n\t\t\t}\n\t\t}\n\t\tif err == nil {\n\t\t\terr = rows.Err()\n\t\t}\n\t\trows.Close()\n\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tsubs = make([]t.Subscription, 0, len(join))\n\tfor _, sub := range join {\n\t\tsubs = append(subs, sub)\n\t}\n\n\treturn common.SelectEarliestUpdatedSubs(subs, opts, a.maxResults), nil\n}\n\n// UsersForTopic loads users subscribed to the given topic (not channel readers).\n// The difference between UsersForTopic vs SubsForTopic is that the former loads user.Public,\n// the latter does not.\nfunc (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {\n\ttcat := t.GetTopicCat(topic)\n\n\t// Fetch all subscribed users. The number of users is not large.\n\tq := `SELECT s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid,\n\t\ts.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private\n\t\tFROM subscriptions AS s JOIN users AS u ON s.userid=u.id\n\t\tWHERE s.topic=?`\n\targs := []any{topic}\n\tif !keepDeleted {\n\t\t// Filter out rows with users deleted\n\t\tq += \" AND u.state!=?\"\n\t\targs = append(args, t.StateDeleted)\n\n\t\t// For p2p topics we must load all subscriptions including deleted.\n\t\t// Otherwise it will be impossible to swipe Public values.\n\t\tif tcat != t.TopicCatP2P {\n\t\t\t// Filter out deleted subscriptions.\n\t\t\tq += \" AND s.deletedat IS NULL\"\n\t\t}\n\t}\n\n\tlimit := a.maxResults\n\tvar oneUser t.Uid\n\tif opts != nil {\n\t\t// Ignore IfModifiedSince: loading all entries because a topic cannot have too many subscribers.\n\t\t// Those unmodified will be stripped of Public & Private.\n\n\t\tif !opts.User.IsZero() {\n\t\t\t// For p2p topics we have to fetch both users otherwise public cannot be swapped.\n\t\t\tif tcat != t.TopicCatP2P {\n\t\t\t\tq += \" AND s.userid=?\"\n\t\t\t\targs = append(args, store.DecodeUid(opts.User))\n\t\t\t}\n\t\t\toneUser = opts.User\n\t\t}\n\t\tif opts.Limit > 0 && opts.Limit < limit {\n\t\t\tlimit = opts.Limit\n\t\t}\n\t}\n\tq += \" LIMIT ?\"\n\targs = append(args, limit)\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\trows, err := a.db.QueryxContext(ctx, q, args...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\t// Fetch subscriptions.\n\tvar sub t.Subscription\n\tvar subs []t.Subscription\n\tvar lastSeen sql.NullTime\n\tvar userAgent string\n\tvar public, trusted any\n\tfor rows.Next() {\n\t\tif err = rows.Scan(\n\t\t\t&sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt,\n\t\t\t&sub.User, &sub.Topic, &sub.DelId, &sub.RecvSeqId,\n\t\t\t&sub.ReadSeqId, &sub.ModeWant, &sub.ModeGiven,\n\t\t\t&public, &trusted, &lastSeen, &userAgent, &sub.Private); err != nil {\n\t\t\tbreak\n\t\t}\n\n\t\tsub.User = common.EncodeUidString(sub.User).String()\n\t\tsub.Private = common.FromJSON(sub.Private)\n\t\tsub.SetPublic(common.FromJSON(public))\n\t\tsub.SetTrusted(common.FromJSON(trusted))\n\t\tsub.SetLastSeenAndUA(&lastSeen.Time, userAgent)\n\t\tsubs = append(subs, sub)\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\tif err == nil && tcat == t.TopicCatP2P && len(subs) > 0 {\n\t\t// Swap public & lastSeen values of P2P topics as expected.\n\t\tif len(subs) == 1 {\n\t\t\t// The other user is deleted, nothing we can do.\n\t\t\tsubs[0].SetPublic(nil)\n\t\t\tsubs[0].SetTrusted(nil)\n\t\t\tsubs[0].SetLastSeenAndUA(nil, \"\")\n\t\t} else {\n\t\t\ttmp := subs[0].GetPublic()\n\t\t\tsubs[0].SetPublic(subs[1].GetPublic())\n\t\t\tsubs[1].SetPublic(tmp)\n\n\t\t\ttmp = subs[0].GetTrusted()\n\t\t\tsubs[0].SetTrusted(subs[1].GetTrusted())\n\t\t\tsubs[1].SetTrusted(tmp)\n\n\t\t\tlastSeen := subs[0].GetLastSeen()\n\t\t\tuserAgent = subs[0].GetUserAgent()\n\t\t\tsubs[0].SetLastSeenAndUA(subs[1].GetLastSeen(), subs[1].GetUserAgent())\n\t\t\tsubs[1].SetLastSeenAndUA(lastSeen, userAgent)\n\t\t}\n\n\t\t// Remove deleted and unneeded subscriptions\n\t\tif !keepDeleted || !oneUser.IsZero() {\n\t\t\tvar xsubs []t.Subscription\n\t\t\tfor i := range subs {\n\t\t\t\tif (subs[i].DeletedAt != nil && !keepDeleted) || (!oneUser.IsZero() && subs[i].Uid() != oneUser) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\txsubs = append(xsubs, subs[i])\n\t\t\t}\n\t\t\tsubs = xsubs\n\t\t}\n\t}\n\n\treturn subs, err\n}\n\n// topicNamesForUser reads a slice of strings using provided query.\n// if includeChan is true, the query is expected to add channel names as well as group topic names.\nfunc (a *adapter) topicNamesForUser(sqlQuery string, includeChan bool, args ...any) ([]string, error) {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\trows, err := a.db.QueryxContext(ctx, sqlQuery, args...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tvar names []string\n\tfor rows.Next() {\n\t\tvar name string\n\t\tif err = rows.Scan(&name); err != nil {\n\t\t\tbreak\n\t\t}\n\t\tnames = append(names, name)\n\t\t// If the name is a group topic, also add the channel name if requested.\n\t\tif includeChan {\n\t\t\tif channel := t.GrpToChn(name); channel != \"\" {\n\t\t\t\tnames = append(names, channel)\n\t\t\t}\n\t\t}\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\treturn names, err\n}\n\n// OwnTopics loads a slice of topic names where the user is the owner.\nfunc (a *adapter) OwnTopics(uid t.Uid) ([]string, error) {\n\treturn a.topicNamesForUser(\"SELECT name FROM topics WHERE owner=? AND state!=?\",\n\t\tfalse, store.DecodeUid(uid), t.StateDeleted)\n}\n\n// ChannelsForUser loads a slice of topic names where the user is a channel reader and notifications (P) are enabled.\nfunc (a *adapter) ChannelsForUser(uid t.Uid) ([]string, error) {\n\treturn a.topicNamesForUser(\"SELECT topic FROM subscriptions WHERE userid=? AND topic LIKE 'chn%' \"+\n\t\t\"AND INSTR(modewant,'P')>0 AND INSTR(modegiven,'P')>0 AND deletedat IS NULL\",\n\t\tfalse, store.DecodeUid(uid))\n}\n\n// TopicShare adds subscriptions to a topic and increments the topic's subcnt.\nfunc (a *adapter) TopicShare(topic string, shares []*t.Subscription) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback()\n\t\t}\n\t}()\n\n\tfor _, sub := range shares {\n\t\terr = createSubscription(tx, sub, true)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif topic != \"\" {\n\t\t// Update topic's subscription count.\n\t\tif _, err = tx.Exec(\"UPDATE topics SET subcnt=subcnt+? WHERE name=?\", len(shares), topic); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn tx.Commit()\n}\n\n// TopicDelete deletes topic, subscriptions, messages.\nfunc (a *adapter) TopicDelete(topic string, isChan, hard bool) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback()\n\t\t}\n\t}()\n\n\t// If the topic is a channel, must try to delete subscriptions under both grpXXX and chnXXX names.\n\targs := []any{topic}\n\tif isChan {\n\t\targs = append(args, t.GrpToChn(topic))\n\t}\n\n\tif hard {\n\t\t// Delete subscriptions. If this is a channel, delete both group subscriptions and channel subscriptions.\n\t\tq, args, _ := sqlx.In(\"DELETE FROM subscriptions WHERE topic IN (?)\", args)\n\t\tif _, err = tx.Exec(tx.Rebind(q), args...); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err = messageDeleteList(tx, topic, nil); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err = tx.Exec(\"DELETE FROM topictags WHERE topic=?\", topic); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err = tx.Exec(\"DELETE FROM topics WHERE name=?\", topic); err != nil {\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\tnow := t.TimeNow()\n\n\t\tq, args, _ := sqlx.In(\"UPDATE subscriptions SET updatedat=?,deletedat=? WHERE topic IN (?)\", now, now, args)\n\t\tif _, err = tx.Exec(tx.Rebind(q), args...); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err = tx.Exec(\"UPDATE topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE name=?\",\n\t\t\tnow, now, t.StateDeleted, now, topic); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn tx.Commit()\n}\n\n// TopicUpdateOnMessage updates topic's seqid and touchedat when a new message is posted.\nfunc (a *adapter) TopicUpdateOnMessage(topic string, msg *t.Message) error {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\t_, err := a.db.ExecContext(ctx, \"UPDATE topics SET seqid=?,touchedat=? WHERE name=?\", msg.SeqId, msg.CreatedAt, topic)\n\n\treturn err\n}\n\n// TopicUpdateSubCnt updates subscriber count denormalized in topic.\nfunc (a *adapter) TopicUpdateSubCnt(topic string) error {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\t_, err := a.db.ExecContext(ctx,\n\t\t\"UPDATE topics SET subcnt=(SELECT COUNT(*) FROM subscriptions WHERE topic IN (?,?) AND deletedat IS NULL) WHERE name=?\",\n\t\ttopic, t.GrpToChn(topic), topic)\n\treturn err\n}\n\n// TopicUpdate updates topic's fields given in the update map.\n// If update contains UpdatedAt but not TouchedAt, TouchedAt is set to Updated\nfunc (a *adapter) TopicUpdate(topic string, update map[string]any) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback()\n\t\t}\n\t}()\n\n\tif t, u := update[\"TouchedAt\"], update[\"UpdatedAt\"]; t == nil && u != nil {\n\t\tupdate[\"TouchedAt\"] = u\n\t}\n\tcols, args := common.UpdateByMap(update)\n\targs = append(args, topic)\n\t_, err = tx.Exec(\"UPDATE topics SET \"+strings.Join(cols, \",\")+\" WHERE name=?\", args...)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Tags are also stored in a separate table\n\tif tags := common.ExtractTags(update); tags != nil {\n\t\t// First delete all user tags\n\t\t_, err = tx.Exec(\"DELETE FROM topictags WHERE topic=?\", topic)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// Now insert new tags\n\t\terr = addTags(tx, \"topictags\", \"topic\", topic, tags, false)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn tx.Commit()\n}\n\nfunc (a *adapter) TopicOwnerChange(topic string, newOwner t.Uid) error {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\t_, err := a.db.ExecContext(ctx, \"UPDATE topics SET owner=? WHERE name=?\", store.DecodeUid(newOwner), topic)\n\treturn err\n}\n\n// Get a subscription of a user to a topic.\nfunc (a *adapter) SubscriptionGet(topic string, user t.Uid, keepDeleted bool) (*t.Subscription, error) {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tquery := `SELECT createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,\n\t\treadseqid,modewant,modegiven,private FROM subscriptions WHERE topic=? AND userid=?`\n\tif !keepDeleted {\n\t\tquery += \" AND deletedat IS NULL\"\n\t}\n\tvar sub t.Subscription\n\terr := a.db.GetContext(ctx, &sub, query, topic, store.DecodeUid(user))\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\t// Nothing found - clear the error\n\t\t\terr = nil\n\t\t}\n\t\treturn nil, err\n\t}\n\n\tsub.User = user.String()\n\tsub.Private = common.FromJSON(sub.Private)\n\n\treturn &sub, nil\n}\n\n// SubsForUser loads all user's subscriptions. Does NOT load Public or Private values and does\n// not load deleted subscriptions.\nfunc (a *adapter) SubsForUser(forUser t.Uid) ([]t.Subscription, error) {\n\tq := `SELECT createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,\n\t\treadseqid,modewant,modegiven FROM subscriptions WHERE userid=? AND deletedat IS NULL`\n\targs := []any{store.DecodeUid(forUser)}\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\trows, err := a.db.QueryxContext(ctx, q, args...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tvar subs []t.Subscription\n\tvar sub t.Subscription\n\tfor rows.Next() {\n\t\tif err = rows.StructScan(&sub); err != nil {\n\t\t\tbreak\n\t\t}\n\t\tsub.User = forUser.String()\n\t\tsubs = append(subs, sub)\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\treturn subs, err\n}\n\n// SubsForTopic fetches all subsciptions for a topic. Does NOT load Public value and does not load channel readers.\n// The difference between UsersForTopic vs SubsForTopic is that the former loads user.public+trusted,\n// the latter does not.\nfunc (a *adapter) SubsForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {\n\tq := `SELECT createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,\n\t\treadseqid,modewant,modegiven,private FROM subscriptions WHERE topic=?`\n\n\targs := []any{topic}\n\tif !keepDeleted {\n\t\t// Filter out deleted rows.\n\t\tq += \" AND deletedat IS NULL\"\n\t}\n\tlimit := a.maxResults\n\tif opts != nil {\n\t\t// Ignore IfModifiedSince - we must return all entries\n\t\t// Those unmodified will be stripped of Public & Private.\n\n\t\tif !opts.User.IsZero() {\n\t\t\tq += \" AND userid=?\"\n\t\t\targs = append(args, store.DecodeUid(opts.User))\n\t\t}\n\t\tif opts.Limit > 0 && opts.Limit < limit {\n\t\t\tlimit = opts.Limit\n\t\t}\n\t}\n\n\tq += \" LIMIT ?\"\n\targs = append(args, limit)\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\trows, err := a.db.QueryxContext(ctx, q, args...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tvar subs []t.Subscription\n\tvar sub t.Subscription\n\tfor rows.Next() {\n\t\tif err = rows.StructScan(&sub); err != nil {\n\t\t\tbreak\n\t\t}\n\n\t\tsub.User = common.EncodeUidString(sub.User).String()\n\t\tsub.Private = common.FromJSON(sub.Private)\n\t\tsubs = append(subs, sub)\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\treturn subs, err\n}\n\n// SubsUpdate updates one or multiple subscriptions to a topic.\nfunc (a *adapter) SubsUpdate(topic string, user t.Uid, update map[string]any) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback()\n\t\t}\n\t}()\n\n\tcols, args := common.UpdateByMap(update)\n\tq := \"UPDATE subscriptions SET \" + strings.Join(cols, \",\") + \" WHERE topic=?\"\n\targs = append(args, topic)\n\tif !user.IsZero() {\n\t\t// Update just one topic subscription\n\t\tq += \" AND userid=?\"\n\t\targs = append(args, store.DecodeUid(user))\n\t}\n\n\tif _, err = tx.Exec(q, args...); err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit()\n}\n\n// SubsDelete marks at most one subscription as deleted (soft-deleting).\nfunc (a *adapter) SubsDelete(topic string, user t.Uid) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\ttx, err := a.db.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback()\n\t\t}\n\t}()\n\n\tdecoded_id := store.DecodeUid(user)\n\tnow := t.TimeNow()\n\n\t// Mark subscription as deleted.\n\tres, err := tx.ExecContext(ctx,\n\t\t\"UPDATE subscriptions SET updatedat=?,deletedat=? WHERE topic=? AND userid=? AND deletedat IS NULL\",\n\t\tnow, now, topic, decoded_id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\taffected, err := res.RowsAffected()\n\tif err == nil && affected == 0 {\n\t\t// ensure tx.Rollback() above is ran\n\t\terr = t.ErrNotFound\n\t\treturn err\n\t}\n\n\t// Channel readers cannot delete messages.\n\tif !t.IsChannel(topic) {\n\t\t// Remove records of messages soft-deleted by this user.\n\t\t_, err = tx.Exec(\"DELETE FROM dellog WHERE topic=? AND deletedfor=?\", topic, decoded_id)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif t.GetTopicCat(topic) == t.TopicCatGrp {\n\t\t// Decrement topic subscription count (only one subscription is\tdeleted).\n\t\t_, err = tx.Exec(\"UPDATE topics SET subcnt=subcnt-1 WHERE name=?\", t.ChnToGrp(topic))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn tx.Commit()\n}\n\n// subsDelForUser marks user's subscriptions as deleted.\nfunc subsDelForUser(tx *sqlx.Tx, decoded_uid int64, hard bool) error {\n\t// Decrement subscription count for all topics the user is subscribed to.\n\trows, err := tx.Query(\"SELECT topic FROM subscriptions WHERE userid=? AND deletedat IS NULL\", decoded_uid)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar topics []any\n\tfor rows.Next() {\n\t\tvar name string\n\t\tif err = rows.Scan(&name); err != nil {\n\t\t\tbreak\n\t\t}\n\t\tif t.IsChannel(name) {\n\t\t\t// Convert channel name to group name.\n\t\t\tname = t.ChnToGrp(name)\n\t\t}\n\t\ttopics = append(topics, name)\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\trows.Close()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(topics) > 0 {\n\t\tsql, args, err := sqlx.In(\"UPDATE topics SET subcnt=subcnt-1 WHERE name IN (?)\", topics)\n\t\t_, err = tx.Exec(tx.Rebind(sql), args...)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif hard {\n\t\t_, err = tx.Exec(\"DELETE FROM subscriptions WHERE userid=?\", decoded_uid)\n\t} else {\n\t\tnow := t.TimeNow()\n\t\t_, err = tx.Exec(\"UPDATE subscriptions SET updatedat=?,deletedat=? WHERE userid=? AND deletedat IS NULL\",\n\t\t\tnow, now, decoded_uid)\n\t}\n\treturn err\n}\n\n// SubsDelForUser marks user's subscriptions as deleted.\nfunc (a *adapter) SubsDelForUser(user t.Uid, hard bool) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\ttx, err := a.db.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback()\n\t\t}\n\t}()\n\n\tif err = subsDelForUser(tx, store.DecodeUid(user), hard); err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit()\n}\n\n// Find returns a list of users or group topics who match given tags, such as \"email:jdoe@example.com\" or \"tel:+18003287448\".\nfunc (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error) {\n\tvar args []any\n\tstateConstraint := \"\"\n\tif activeOnly {\n\t\targs = append(args, t.StateOK)\n\t\tstateConstraint = \"u.state=? AND \"\n\t}\n\tindex := make(map[string]struct{})\n\tallReq := t.FlattenDoubleSlice(req)\n\tfor _, tag := range append(allReq, opt...) {\n\t\targs = append(args, tag)\n\t\tindex[tag] = struct{}{}\n\t}\n\n\tvar matcher string\n\tif promoPrefix != \"\" {\n\t\t// The max number of tags is 16. Using 20 to make sure one prefix match is greater than all non-prefix matches.\n\t\tmatcher = \"SUM(CASE WHEN LOCATE('\" + promoPrefix + \"', tg.tag)=1 THEN 20 ELSE 1 END)\"\n\t} else {\n\t\tmatcher = \"COUNT(*)\"\n\t}\n\n\tquery := \"SELECT u.id,u.createdat,u.updatedat,0,u.access,0 AS subcnt,u.public,u.trusted,u.tags,\" + matcher + \" AS matches \" +\n\t\t\"FROM users AS u JOIN usertags AS tg ON tg.userid=u.id \" +\n\t\t\"WHERE \" + stateConstraint + \"tg.tag IN (?\" + strings.Repeat(\",?\", len(allReq)+len(opt)-1) + \") \" +\n\t\t\"GROUP BY u.id,u.createdat,u.updatedat,u.access,u.public,u.trusted,u.tags \"\n\tif len(allReq) > 0 {\n\t\tq, a := common.DisjunctionSql(req, \"tg.tag\")\n\t\tquery += q\n\t\targs = append(args, a...)\n\t}\n\n\tquery += \"UNION ALL \"\n\n\tif activeOnly {\n\t\targs = append(args, t.StateOK)\n\t\tstateConstraint = \"t.state=? AND \"\n\t}\n\tfor _, tag := range append(allReq, opt...) {\n\t\targs = append(args, tag)\n\t}\n\n\tquery += \"SELECT t.name AS topic,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags,\" + matcher + \" AS matches \" +\n\t\t\"FROM topics AS t JOIN topictags AS tg ON t.name=tg.topic \" +\n\t\t\"WHERE \" + stateConstraint + \"tg.tag IN (?\" + strings.Repeat(\",?\", len(allReq)+len(opt)-1) + \") \" +\n\t\t\"GROUP BY t.name,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags \"\n\tif len(allReq) > 0 {\n\t\tq, a := common.DisjunctionSql(req, \"tg.tag\")\n\t\tquery += q\n\t\targs = append(args, a...)\n\t}\n\tquery += \"ORDER BY matches DESC, subcnt DESC LIMIT ?\"\n\targs = append(args, a.maxResults)\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\t// Get users matched by tags, sort by number of matches from high to low.\n\trows, err := a.db.QueryxContext(ctx, query, args...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\t// Read results as subscriptions.\n\tvar public, trusted any\n\tvar access t.DefaultAccess\n\tvar subcnt int\n\tvar setTags t.StringSlice\n\tvar ignored int\n\tvar isChan bool\n\tvar sub t.Subscription\n\tvar subs []t.Subscription\n\tfor rows.Next() {\n\t\tif err = rows.Scan(&sub.Topic, &sub.CreatedAt, &sub.UpdatedAt, &isChan, &access, &subcnt,\n\t\t\t&public, &trusted, &setTags, &ignored); err != nil {\n\t\t\tsubs = nil\n\t\t\tbreak\n\t\t}\n\n\t\tif id, err := strconv.ParseInt(sub.Topic, 10, 64); err == nil {\n\t\t\tsub.Topic = store.EncodeUid(id).UserId()\n\t\t\tif sub.Topic == caller {\n\t\t\t\t// Skip the caller.\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\tif isChan {\n\t\t\t// This is a channel, convert grp to chn name: all channel-capable\n\t\t\t// topics should appear as channels in search results.\n\t\t\tsub.Topic = t.GrpToChn(sub.Topic)\n\t\t}\n\n\t\tsub.SetSubCnt(subcnt)\n\t\tsub.SetPublic(common.FromJSON(public))\n\t\tsub.SetTrusted(common.FromJSON(trusted))\n\t\tsub.SetDefaultAccess(access.Auth, access.Anon)\n\t\t// Indicating that the mode is not set, not 'N'.\n\t\tsub.ModeGiven = t.ModeUnset\n\t\tsub.ModeWant = t.ModeUnset\n\t\tsub.Private = common.FilterFoundTags(setTags, index)\n\t\tsubs = append(subs, sub)\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\treturn subs, err\n}\n\n// FindOne returns the first topic or user which matches the given tag.\nfunc (a *adapter) FindOne(tag string) (string, error) {\n\tvar args []any\n\n\tquery := \"SELECT t.name AS topic FROM topics AS t LEFT JOIN topictags AS tt ON t.name=tt.topic \" +\n\t\t\"WHERE tt.tag=?\"\n\targs = append(args, tag)\n\n\tquery += \" UNION ALL \"\n\n\tquery += \"SELECT u.id AS topic FROM users AS u LEFT JOIN usertags AS ut ON ut.userid=u.id \" +\n\t\t\"WHERE ut.tag=?\"\n\targs = append(args, tag)\n\n\t// LIMIT is applied to all resultant rows.\n\tquery += \" LIMIT 1\"\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\trows, err := a.db.QueryxContext(ctx, query, args...)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer rows.Close()\n\n\tvar found string\n\tif rows.Next() {\n\t\tif err = rows.Scan(&found); err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\n\t\t// Check if the found value is a topic name or a user ID.\n\t\t// User IDs are returned as decoded decimal strings.\n\t\tif id, err := strconv.ParseInt(found, 10, 64); err == nil {\n\t\t\tfound = store.EncodeUid(id).UserId()\n\t\t}\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\treturn found, err\n}\n\n// Messages\nfunc (a *adapter) MessageSave(msg *t.Message) error {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\t// store assignes message ID, but we don't use it. Message IDs are not used anywhere.\n\t// Using a sequential ID provided by the database.\n\tres, err := a.db.ExecContext(ctx,\n\t\t\"INSERT INTO messages(createdAt,updatedAt,seqid,topic,`from`,head,content) VALUES(?,?,?,?,?,?,?)\",\n\t\tmsg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic,\n\t\tstore.DecodeUid(t.ParseUid(msg.From)), msg.Head, common.ToJSON(msg.Content))\n\tif err == nil {\n\t\tid, _ := res.LastInsertId()\n\t\t// Replacing ID given by store by ID given by the DB.\n\t\tmsg.SetUid(t.Uid(id))\n\t}\n\treturn err\n}\n\n// MessageGetAll returns messages matching the query.\nfunc (a *adapter) MessageGetAll(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.Message, error) {\n\tvar limit = a.maxMessageResults\n\n\targs := []any{store.DecodeUid(forUser), topic}\n\tseqIdConstraint := \"\"\n\tif opts != nil {\n\t\tseqIdConstraint = \"AND m.seqid \"\n\t\tif len(opts.IdRanges) > 0 {\n\t\t\tconstr, newargs := common.RangesToSql(opts.IdRanges)\n\t\t\tseqIdConstraint += constr\n\t\t\targs = append(args, newargs...)\n\t\t} else {\n\t\t\tseqIdConstraint += \"BETWEEN ? AND ?\"\n\t\t\tif opts.Since > 0 {\n\t\t\t\targs = append(args, opts.Since)\n\t\t\t} else {\n\t\t\t\targs = append(args, 0)\n\t\t\t}\n\t\t\tif opts.Before > 1 {\n\t\t\t\t// MySQL BETWEEN is inclusive-inclusive, Tinode API requires inclusive-exclusive, thus -1\n\t\t\t\targs = append(args, opts.Before-1)\n\t\t\t} else {\n\t\t\t\targs = append(args, 1<<31-1)\n\t\t\t}\n\t\t}\n\n\t\tif opts.Limit > 0 && opts.Limit < limit {\n\t\t\tlimit = opts.Limit\n\t\t}\n\t}\n\n\targs = append(args, limit)\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\trows, err := a.db.QueryxContext(\n\t\tctx,\n\t\t\"SELECT m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,m.head,m.content\"+\n\t\t\t\" FROM messages AS m LEFT JOIN dellog AS d\"+\n\t\t\t\" ON d.topic=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=?\"+\n\t\t\t\" WHERE m.delid=0 AND m.topic=? \"+seqIdConstraint+\" AND d.deletedfor IS NULL\"+\n\t\t\t\" ORDER BY m.seqid DESC LIMIT ?\",\n\t\targs...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tmsgs := make([]t.Message, 0, limit)\n\tfor rows.Next() {\n\t\tvar msg t.Message\n\t\tif err = rows.StructScan(&msg); err != nil {\n\t\t\tbreak\n\t\t}\n\t\tmsg.From = common.EncodeUidString(msg.From).String()\n\t\tmsg.Content = common.FromJSON(msg.Content)\n\t\tmsgs = append(msgs, msg)\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\treturn msgs, err\n}\n\n// Get ranges of deleted messages\nfunc (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) {\n\tvar limit = a.maxResults\n\tvar lower = 0\n\tvar upper = 1<<31 - 1\n\n\tif opts != nil {\n\t\tif opts.Since > 0 {\n\t\t\tlower = opts.Since\n\t\t}\n\t\tif opts.Before > 1 {\n\t\t\t// DelRange is inclusive-exclusive, while BETWEEN is inclusive-inclisive.\n\t\t\tupper = opts.Before - 1\n\t\t}\n\n\t\tif opts.Limit > 0 && opts.Limit < limit {\n\t\t\tlimit = opts.Limit\n\t\t}\n\t}\n\n\t// Fetch log of deletions\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\trows, err := a.db.QueryxContext(ctx, \"SELECT topic,deletedfor,delid,low,hi FROM dellog WHERE topic=? AND delid BETWEEN ? AND ?\"+\n\t\t\" AND (deletedFor=0 OR deletedFor=?)\"+\n\t\t\" ORDER BY delid LIMIT ?\", topic, lower, upper, store.DecodeUid(forUser), limit)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tvar dellog struct {\n\t\tTopic      string\n\t\tDeletedfor int64\n\t\tDelid      int\n\t\tLow        int\n\t\tHi         int\n\t}\n\tvar dmsgs []t.DelMessage\n\tvar dmsg t.DelMessage\n\tfor rows.Next() {\n\t\tif err = rows.StructScan(&dellog); err != nil {\n\t\t\tdmsgs = nil\n\t\t\tbreak\n\t\t}\n\n\t\tif dellog.Delid != dmsg.DelId {\n\t\t\tif dmsg.DelId > 0 {\n\t\t\t\tdmsgs = append(dmsgs, dmsg)\n\t\t\t}\n\t\t\tdmsg.DelId = dellog.Delid\n\t\t\tdmsg.Topic = dellog.Topic\n\t\t\tif dellog.Deletedfor > 0 {\n\t\t\t\tdmsg.DeletedFor = store.EncodeUid(dellog.Deletedfor).String()\n\t\t\t} else {\n\t\t\t\tdmsg.DeletedFor = \"\"\n\t\t\t}\n\t\t\tdmsg.SeqIdRanges = nil\n\t\t}\n\t\tif dellog.Hi <= dellog.Low+1 {\n\t\t\tdellog.Hi = 0\n\t\t}\n\t\tdmsg.SeqIdRanges = append(dmsg.SeqIdRanges, t.Range{Low: dellog.Low, Hi: dellog.Hi})\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\tif err == nil {\n\t\tif dmsg.DelId > 0 {\n\t\t\tdmsgs = append(dmsgs, dmsg)\n\t\t}\n\t}\n\n\treturn dmsgs, err\n}\n\nfunc messageDeleteList(tx *sqlx.Tx, topic string, toDel *t.DelMessage) error {\n\tvar err error\n\n\tif toDel == nil {\n\t\t// Whole topic is being deleted, thus also deleting all messages.\n\t\t_, err = tx.Exec(\"DELETE FROM dellog WHERE topic=?\", topic)\n\t\tif err == nil {\n\t\t\t_, err = tx.Exec(\"DELETE FROM messages WHERE topic=?\", topic)\n\t\t}\n\t\t// filemsglinks will be deleted because of ON DELETE CASCADE\n\t\treturn err\n\t}\n\n\t// Only some messages are being deleted.\n\n\tdelRanges := toDel.SeqIdRanges\n\n\tif toDel.DeletedFor == \"\" {\n\t\t// Hard-deleting messages requires updates to the messages table.\n\t\twhere := \"m.topic=?\"\n\t\targs := []any{topic}\n\n\t\tif len(delRanges) > 0 {\n\t\t\trSql, rArgs := common.RangesToSql(delRanges)\n\t\t\twhere += \" AND m.seqid \" + rSql\n\t\t\targs = append(args, rArgs...)\n\t\t}\n\n\t\twhere += \" AND m.deletedat IS NULL\"\n\n\t\t// We are asked to delete messages no older than newerThan.\n\t\tif newerThan := toDel.GetNewerThan(); newerThan != nil {\n\t\t\twhere += \" AND m.createdat>?\"\n\t\t\targs = append(args, newerThan)\n\t\t}\n\n\t\t// Find the actual IDs still present in the database.\n\t\tvar seqIDs []int\n\t\terr = tx.Select(&seqIDs, \"SELECT seqid FROM messages AS m WHERE \"+where, args...)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(seqIDs) == 0 {\n\t\t\t// Nothing to delete. No need to make a log entry. All done.\n\t\t\treturn nil\n\t\t}\n\n\t\t// Recalculate the actual ranges to delete.\n\t\tsort.Ints(seqIDs)\n\t\tdelRanges = t.SliceToRanges(seqIDs)\n\n\t\t// Compose a new query with the new ranges.\n\t\twhere = \"m.topic=?\"\n\t\targs = []any{topic}\n\t\trSql, rArgs := common.RangesToSql(delRanges)\n\t\twhere += \" AND m.seqid \" + rSql\n\t\targs = append(args, rArgs...)\n\n\t\t// No need to add anything else: deletedat etc is already accounted for.\n\n\t\t_, err = tx.Exec(\"DELETE fml.* FROM filemsglinks AS fml INNER JOIN messages AS m ON m.id=fml.msgid WHERE \"+\n\t\t\twhere, args...)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Instead of deleting messages, clear all content.\n\t\t_, err = tx.Exec(\"UPDATE messages AS m SET m.deletedat=?,m.delId=?,m.`from`=0,m.head=NULL,m.content=NULL WHERE \"+\n\t\t\twhere, append([]any{t.TimeNow(), toDel.DelId}, args...)...)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Now make log entries. Needed for both hard- and soft-deleting.\n\tvar insert *sql.Stmt\n\tif insert, err = tx.Prepare(\n\t\t\"INSERT INTO dellog(topic,deletedfor,delid,low,hi) VALUES(?,?,?,?,?)\"); err != nil {\n\t\treturn err\n\t}\n\n\tforUser := common.DecodeUidString(toDel.DeletedFor)\n\tfor _, rng := range delRanges {\n\t\tif rng.Hi == 0 {\n\t\t\t// Dellog must contain valid Low and *Hi*.\n\t\t\trng.Hi = rng.Low + 1\n\t\t}\n\t\t// A log entry for each range.\n\t\tif _, err = insert.Exec(topic, forUser, toDel.DelId, rng.Low, rng.Hi); err != nil {\n\t\t\tbreak\n\t\t}\n\t}\n\n\treturn err\n}\n\n// MessageDeleteList deletes messages in the given topic with seqIds from the list.\nfunc (a *adapter) MessageDeleteList(topic string, toDel *t.DelMessage) (err error) {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback()\n\t\t}\n\t}()\n\n\tif err = messageDeleteList(tx, topic, toDel); err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit()\n}\n\nfunc deviceHasher(deviceID string) string {\n\t// Generate custom key as [64-bit hash of device id] to ensure predictable\n\t// length of the key\n\thasher := fnv.New64()\n\thasher.Write([]byte(deviceID))\n\treturn strconv.FormatUint(uint64(hasher.Sum64()), 16)\n}\n\n// Device management for push notifications.\n\n// DeviceUpsert creates or updates a device record.\nfunc (a *adapter) DeviceUpsert(uid t.Uid, def *t.DeviceDef) error {\n\thash := deviceHasher(def.DeviceId)\n\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback()\n\t\t}\n\t}()\n\n\t// Ensure uniqueness of the device ID: delete all records of the device ID\n\t_, err = tx.Exec(\"DELETE FROM devices WHERE hash=?\", hash)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Actually add/update DeviceId for the new user\n\t_, err = tx.Exec(\"INSERT INTO devices(userid, hash, deviceId, platform, lastseen, lang) VALUES(?,?,?,?,?,?)\",\n\t\tstore.DecodeUid(uid), hash, def.DeviceId, def.Platform, def.LastSeen, def.Lang)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit()\n}\n\n// DeviceGetAll returns all devices for a given set of users.\nfunc (a *adapter) DeviceGetAll(uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {\n\tvar unums []any\n\tfor _, uid := range uids {\n\t\tunums = append(unums, store.DecodeUid(uid))\n\t}\n\n\tq, unums, _ := sqlx.In(\"SELECT userid,deviceid,platform,lastseen,lang FROM devices WHERE userid IN (?)\", unums)\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\trows, err := a.db.QueryxContext(ctx, q, unums...)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\tdefer rows.Close()\n\n\tvar device struct {\n\t\tUserid   int64\n\t\tDeviceid string\n\t\tPlatform string\n\t\tLastseen time.Time\n\t\tLang     string\n\t}\n\n\tresult := make(map[t.Uid][]t.DeviceDef)\n\tcount := 0\n\tfor rows.Next() {\n\t\tif err = rows.StructScan(&device); err != nil {\n\t\t\tbreak\n\t\t}\n\t\tuid := store.EncodeUid(device.Userid)\n\t\tudev := result[uid]\n\t\tudev = append(udev, t.DeviceDef{\n\t\t\tDeviceId: device.Deviceid,\n\t\t\tPlatform: device.Platform,\n\t\t\tLastSeen: device.Lastseen,\n\t\t\tLang:     device.Lang,\n\t\t})\n\t\tresult[uid] = udev\n\t\tcount++\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\treturn result, count, err\n}\n\nfunc deviceDelete(tx *sqlx.Tx, uid t.Uid, deviceID string) error {\n\tvar err error\n\tvar res sql.Result\n\tif deviceID == \"\" {\n\t\tres, err = tx.Exec(\"DELETE FROM devices WHERE userid=?\", store.DecodeUid(uid))\n\t} else {\n\t\tres, err = tx.Exec(\"DELETE FROM devices WHERE userid=? AND hash=?\", store.DecodeUid(uid), deviceHasher(deviceID))\n\t}\n\n\tif err == nil {\n\t\tif count, _ := res.RowsAffected(); count == 0 {\n\t\t\terr = t.ErrNotFound\n\t\t}\n\t}\n\n\treturn err\n}\n\n// DeviceDelete deletes a device record (push token).\nfunc (a *adapter) DeviceDelete(uid t.Uid, deviceID string) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback()\n\t\t}\n\t}()\n\n\terr = deviceDelete(tx, uid, deviceID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit()\n}\n\n// Credential management\n\n// CredUpsert adds or updates a validation record. Returns true if inserted, false if updated.\n// 1. if credential is validated:\n// 1.1 Hard-delete unconfirmed equivalent record, if exists.\n// 1.2 Insert new. Report error if duplicate.\n// 2. if credential is not validated:\n// 2.1 Check if validated equivalent exist. If so, report an error.\n// 2.2 Soft-delete all unvalidated records of the same method.\n// 2.3 Undelete existing credential. Return if successful.\n// 2.4 Insert new credential record.\nfunc (a *adapter) CredUpsert(cred *t.Credential) (bool, error) {\n\tvar err error\n\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback()\n\t\t}\n\t}()\n\n\tnow := t.TimeNow()\n\tuserId := common.DecodeUidString(cred.User)\n\n\t// Enforce uniqueness: if credential is confirmed, \"method:value\" must be unique.\n\t// if credential is not yet confirmed, \"userid:method:value\" is unique.\n\tsynth := cred.Method + \":\" + cred.Value\n\n\tif !cred.Done {\n\t\t// Check if this credential is already validated.\n\t\tvar done bool\n\t\terr = tx.Get(&done, \"SELECT done FROM credentials WHERE synthetic=?\", synth)\n\t\tif err == nil {\n\t\t\t// Assign err to ensure closing of a transaction.\n\t\t\terr = t.ErrDuplicate\n\t\t\treturn false, err\n\t\t}\n\t\tif err != sql.ErrNoRows {\n\t\t\treturn false, err\n\t\t}\n\t\t// We are going to insert new record.\n\t\tsynth = cred.User + \":\" + synth\n\n\t\t// Adding new unvalidated credential. Deactivate all unvalidated records of this user and method.\n\t\t_, err = tx.Exec(\"UPDATE credentials SET deletedat=? WHERE userid=? AND method=? AND done=FALSE\",\n\t\t\tnow, userId, cred.Method)\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\t\t// Assume that the record exists and try to update it: undelete, update timestamp and response value.\n\t\tres, err := tx.Exec(\"UPDATE credentials SET updatedat=?,deletedat=NULL,resp=?,done=FALSE WHERE synthetic=?\",\n\t\t\tcred.UpdatedAt, cred.Resp, synth)\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\t\t// If record was updated, then all is fine.\n\t\tif numrows, _ := res.RowsAffected(); numrows > 0 {\n\t\t\treturn false, tx.Commit()\n\t\t}\n\t} else {\n\t\t// Hard-deleting unconformed record if it exists.\n\t\t_, err = tx.Exec(\"DELETE FROM credentials WHERE synthetic=?\", cred.User+\":\"+synth)\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\t// Add new record.\n\t_, err = tx.Exec(\"INSERT INTO credentials(createdat,updatedat,method,value,synthetic,userid,resp,done) \"+\n\t\t\"VALUES(?,?,?,?,?,?,?,?)\",\n\t\tcred.CreatedAt, cred.UpdatedAt, cred.Method, cred.Value, synth, userId, cred.Resp, cred.Done)\n\tif err != nil {\n\t\tif isDupe(err) {\n\t\t\treturn true, t.ErrDuplicate\n\t\t}\n\t\treturn true, err\n\t}\n\treturn true, tx.Commit()\n}\n\n// credDel deletes given validation method or all methods of the given user.\n// 1. If user is being deleted, hard-delete all records (method == \"\")\n// 2. If one value is being deleted:\n// 2.1 Delete it if it's valiated or if there were no attempts at validation\n// (otherwise it could be used to circumvent the limit on validation attempts).\n// 2.2 In that case mark it as soft-deleted.\nfunc credDel(tx *sqlx.Tx, uid t.Uid, method, value string) error {\n\tconstraints := \" WHERE userid=?\"\n\targs := []any{store.DecodeUid(uid)}\n\n\tif method != \"\" {\n\t\tconstraints += \" AND method=?\"\n\t\targs = append(args, method)\n\n\t\tif value != \"\" {\n\t\t\tconstraints += \" AND value=?\"\n\t\t\targs = append(args, value)\n\t\t}\n\t}\n\n\tvar err error\n\tvar res sql.Result\n\tif method == \"\" {\n\t\t// Case 1\n\t\tres, err = tx.Exec(\"DELETE FROM credentials\"+constraints, args...)\n\t\tif err == nil {\n\t\t\tif count, _ := res.RowsAffected(); count == 0 {\n\t\t\t\terr = t.ErrNotFound\n\t\t\t}\n\t\t}\n\t\treturn err\n\t}\n\n\t// Case 2.1\n\tres, err = tx.Exec(\"DELETE FROM credentials\"+constraints+\" AND (done=TRUE OR retries=0)\", args...)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif count, _ := res.RowsAffected(); count > 0 {\n\t\treturn nil\n\t}\n\n\t// Case 2.2\n\targs = append([]any{t.TimeNow()}, args...)\n\tres, err = tx.Exec(\"UPDATE credentials SET deletedat=?\"+constraints, args...)\n\tif err == nil {\n\t\tif count, _ := res.RowsAffected(); count >= 0 {\n\t\t\terr = t.ErrNotFound\n\t\t}\n\t}\n\n\treturn err\n}\n\n// CredDel deletes either credentials of the given user. If method is blank all\n// credentials are removed. If value is blank all credentials of the given the\n// method are removed.\nfunc (a *adapter) CredDel(uid t.Uid, method, value string) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback()\n\t\t}\n\t}()\n\n\terr = credDel(tx, uid, method, value)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit()\n}\n\n// CredConfirm marks given credential method as confirmed.\nfunc (a *adapter) CredConfirm(uid t.Uid, method string) error {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tres, err := a.db.ExecContext(\n\t\tctx,\n\t\t\"UPDATE credentials SET updatedat=?,done=TRUE,synthetic=CONCAT(method,':',value) \"+\n\t\t\t\"WHERE userid=? AND method=? AND deletedat IS NULL AND done=FALSE\",\n\t\tt.TimeNow(), store.DecodeUid(uid), method)\n\tif err != nil {\n\t\tif isDupe(err) {\n\t\t\treturn t.ErrDuplicate\n\t\t}\n\t\treturn err\n\t}\n\tif numrows, _ := res.RowsAffected(); numrows < 1 {\n\t\treturn t.ErrNotFound\n\t}\n\treturn nil\n}\n\n// CredFail increments failure count of the given validation method.\nfunc (a *adapter) CredFail(uid t.Uid, method string) error {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\t_, err := a.db.ExecContext(ctx, \"UPDATE credentials SET updatedat=?,retries=retries+1 WHERE userid=? AND method=? AND done=FALSE\",\n\t\tt.TimeNow(), store.DecodeUid(uid), method)\n\treturn err\n}\n\n// CredGetActive returns currently active unvalidated credential of the given user and method.\nfunc (a *adapter) CredGetActive(uid t.Uid, method string) (*t.Credential, error) {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tvar cred t.Credential\n\terr := a.db.GetContext(ctx, &cred, \"SELECT createdat,updatedat,method,value,resp,done,retries \"+\n\t\t\"FROM credentials WHERE userid=? AND deletedat IS NULL AND method=? AND done=FALSE\",\n\t\tstore.DecodeUid(uid), method)\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\terr = nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tcred.User = uid.String()\n\n\treturn &cred, nil\n}\n\n// CredGetAll returns credential records for the given user and method, all or validated only.\nfunc (a *adapter) CredGetAll(uid t.Uid, method string, validatedOnly bool) ([]t.Credential, error) {\n\tquery := \"SELECT createdat,updatedat,method,value,resp,done,retries FROM credentials WHERE userid=? AND deletedat IS NULL\"\n\targs := []any{store.DecodeUid(uid)}\n\tif method != \"\" {\n\t\tquery += \" AND method=?\"\n\t\targs = append(args, method)\n\t}\n\tif validatedOnly {\n\t\tquery += \" AND done=TRUE\"\n\t}\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tvar credentials []t.Credential\n\terr := a.db.SelectContext(ctx, &credentials, query, args...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tuser := uid.String()\n\tfor i := range credentials {\n\t\tcredentials[i].User = user\n\t}\n\n\treturn credentials, err\n}\n\n// FileUploads\n\n// FileStartUpload initializes a file upload\nfunc (a *adapter) FileStartUpload(fd *t.FileDef) error {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tvar user any\n\tif fd.User != \"\" {\n\t\tuser = store.DecodeUid(t.ParseUid(fd.User))\n\t} else {\n\t\tuser = 0\n\t}\n\t_, err := a.db.ExecContext(ctx,\n\t\t\"INSERT INTO fileuploads(id,createdat,updatedat,userid,status,mimetype,size,etag,location) \"+\n\t\t\t\"VALUES(?,?,?,?,?,?,?,?,?)\",\n\t\tstore.DecodeUid(fd.Uid()), fd.CreatedAt, fd.UpdatedAt, user,\n\t\tfd.Status, fd.MimeType, fd.Size, fd.ETag, fd.Location)\n\treturn err\n}\n\n// FileFinishUpload marks file upload as completed, successfully or otherwise\nfunc (a *adapter) FileFinishUpload(fd *t.FileDef, success bool, size int64) (*t.FileDef, error) {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback()\n\t\t}\n\t}()\n\n\tnow := t.TimeNow()\n\tif success {\n\t\t_, err = tx.ExecContext(ctx, \"UPDATE fileuploads SET updatedat=?,status=?,size=?,etag=?,location=? WHERE id=?\",\n\t\t\tnow, t.UploadCompleted, size, fd.ETag, fd.Location, store.DecodeUid(fd.Uid()))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfd.Status = t.UploadCompleted\n\t\tfd.Size = size\n\t} else {\n\t\t// Deleting the record: there is no value in keeping it in the DB.\n\t\t_, err = tx.ExecContext(ctx, \"DELETE FROM fileuploads WHERE id=?\", store.DecodeUid(fd.Uid()))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfd.Status = t.UploadFailed\n\t\tfd.Size = 0\n\t}\n\tfd.UpdatedAt = now\n\n\treturn fd, tx.Commit()\n}\n\n// FileGet fetches a record of a specific file\nfunc (a *adapter) FileGet(fid string) (*t.FileDef, error) {\n\tid := t.ParseUid(fid)\n\tif id.IsZero() {\n\t\treturn nil, t.ErrMalformed\n\t}\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tvar fd t.FileDef\n\terr := a.db.GetContext(ctx, &fd, \"SELECT id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location \"+\n\t\t\"FROM fileuploads WHERE id=?\", store.DecodeUid(id))\n\tif err == sql.ErrNoRows {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfd.Id = common.EncodeUidString(fd.Id).String()\n\tfd.User = common.EncodeUidString(fd.User).String()\n\n\treturn &fd, nil\n}\n\n// FileDeleteUnused deletes records where UseCount is zero. If olderThan is non-zero, deletes\n// unused records with UpdatedAt before olderThan.\n// Returns array of FileDef.Location of deleted filerecords so actual files can be deleted too.\nfunc (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, error) {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback()\n\t\t}\n\t}()\n\n\t// Garbage collecting entries which as either marked as deleted, or lack message references, or have no user assigned.\n\tquery := \"SELECT fu.id,fu.location FROM fileuploads AS fu LEFT JOIN filemsglinks AS fml ON fml.fileid=fu.id \" +\n\t\t\"WHERE fml.id IS NULL\"\n\tvar args []any\n\tif !olderThan.IsZero() {\n\t\tquery += \" AND fu.updatedat<?\"\n\t\targs = append(args, olderThan)\n\t}\n\tif limit > 0 {\n\t\tquery += \" LIMIT ?\"\n\t\targs = append(args, limit)\n\t}\n\n\trows, err := tx.Query(query, args...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tvar locations []string\n\tvar ids []any\n\tfor rows.Next() {\n\t\tvar id int\n\t\tvar loc string\n\t\tif err = rows.Scan(&id, &loc); err != nil {\n\t\t\tbreak\n\t\t}\n\t\tif loc != \"\" {\n\t\t\tlocations = append(locations, loc)\n\t\t}\n\t\tids = append(ids, id)\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(ids) > 0 {\n\t\tquery, ids, _ = sqlx.In(\"DELETE FROM fileuploads WHERE id IN (?)\", ids)\n\t\t_, err = tx.Exec(query, ids...)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn locations, tx.Commit()\n}\n\n// FileLinkAttachments connects given topic or message to the file record IDs from the list.\nfunc (a *adapter) FileLinkAttachments(topic string, userId, msgId t.Uid, fids []string) error {\n\tif len(fids) == 0 || (topic == \"\" && msgId.IsZero() && userId.IsZero()) {\n\t\treturn t.ErrMalformed\n\t}\n\tnow := t.TimeNow()\n\n\tvar args []any\n\tvar linkId any\n\tvar linkBy string\n\tif !msgId.IsZero() {\n\t\tlinkBy = \"msgid\"\n\t\tlinkId = int64(msgId)\n\t} else if topic != \"\" {\n\t\tlinkBy = \"topic\"\n\t\tlinkId = topic\n\t\t// Only one attachment per topic is permitted at this time.\n\t\tfids = fids[0:1]\n\t} else {\n\t\tlinkBy = \"userid\"\n\t\tlinkId = store.DecodeUid(userId)\n\t\t// Only one attachment per user is permitted at this time.\n\t\tfids = fids[0:1]\n\t}\n\n\t// Decoded ids\n\tvar dids []any\n\tfor _, fid := range fids {\n\t\tid := t.ParseUid(fid)\n\t\tif id.IsZero() {\n\t\t\treturn t.ErrMalformed\n\t\t}\n\t\tdids = append(dids, store.DecodeUid(id))\n\t}\n\n\tfor _, id := range dids {\n\t\t// createdat,fileid,[msgid|topic|userid]\n\t\targs = append(args, now, id, linkId)\n\t}\n\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback()\n\t\t}\n\t}()\n\n\t// Unlink earlier uploads on the same topic or user allowing them to be garbage-collected.\n\tif msgId.IsZero() {\n\t\tsql := \"DELETE FROM filemsglinks WHERE \" + linkBy + \"=?\"\n\t\t_, err = tx.Exec(sql, linkId)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tsql := \"INSERT INTO filemsglinks(createdat,fileid,\" + linkBy + \") VALUES (?,?,?)\"\n\t_, err = tx.Exec(sql+strings.Repeat(\",(?,?,?)\", len(dids)-1), args...)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit()\n}\n\n// PCacheGet reads a persistet cache entry.\nfunc (a *adapter) PCacheGet(key string) (string, error) {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\tvar value string\n\tif err := a.db.GetContext(ctx, &value, \"SELECT `value` FROM kvmeta WHERE `key`=? LIMIT 1\", key); err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn \"\", t.ErrNotFound\n\t\t}\n\t\treturn \"\", err\n\t}\n\treturn value, nil\n}\n\n// PCacheUpsert creates or updates a persistent cache entry.\nfunc (a *adapter) PCacheUpsert(key string, value string, failOnDuplicate bool) error {\n\tif strings.Contains(key, \"%\") {\n\t\t// Do not allow % in keys: it interferes with LIKE query.\n\t\treturn t.ErrMalformed\n\t}\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\tvar action string\n\tif failOnDuplicate {\n\t\taction = \"INSERT\"\n\t} else {\n\t\taction = \"REPLACE\"\n\t}\n\n\t_, err := a.db.ExecContext(ctx, action+\" INTO kvmeta(`key`,createdat,`value`) VALUES(?,?,?)\", key, t.TimeNow(), value)\n\tif isDupe(err) {\n\t\treturn t.ErrDuplicate\n\t}\n\treturn err\n}\n\n// PCacheDelete deletes one persistent cache entry.\nfunc (a *adapter) PCacheDelete(key string) error {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\t_, err := a.db.ExecContext(ctx, \"DELETE FROM kvmeta WHERE `key`=?\", key)\n\treturn err\n}\n\n// PCacheExpire expires old entries with the given key prefix.\nfunc (a *adapter) PCacheExpire(keyPrefix string, olderThan time.Time) error {\n\tif keyPrefix == \"\" {\n\t\treturn t.ErrMalformed\n\t}\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\t_, err := a.db.ExecContext(ctx, \"DELETE FROM kvmeta WHERE `key` LIKE ? AND createdat<?\", keyPrefix+\"%\", olderThan)\n\treturn err\n}\n\n// GetTestDB returns a currently open database connection.\nfunc (a *adapter) GetTestDB() any {\n\treturn a.db\n}\n\n// Helper functions\n\n// Check if MySQL error is a Error Code: 1062. Duplicate entry ... for key ...\nfunc isDupe(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\n\tmyerr, ok := err.(*ms.MySQLError)\n\treturn ok && myerr.Number == 1062\n}\n\nfunc isMissingTable(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\n\tmyerr, ok := err.(*ms.MySQLError)\n\treturn ok && myerr.Number == 1146\n}\n\nfunc isMissingDb(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\n\tmyerr, ok := err.(*ms.MySQLError)\n\treturn ok && myerr.Number == 1049\n}\n\n// GetTestAdapter returns an adapter object. Useful for running tests.\nfunc GetTestAdapter() *adapter {\n\treturn &adapter{}\n}\n\nfunc init() {\n\tstore.RegisterAdapter(&adapter{})\n}\n"
  },
  {
    "path": "server/db/mysql/blank.go",
    "content": "//go:build !mysql\n// +build !mysql\n\n// This file is needed for conditional compilation. It's used when\n// the build tag 'mysql' is not defined. Otherwise the adapter.go\n// is compiled.\n\npackage mysql\n"
  },
  {
    "path": "server/db/mysql/schema.sql",
    "content": "# THIS SCHEMA FILE IS FOR REFERENCE/DOCUMENTATION ONLY!\n# DO NOT USE IT TO INITIALIZE THE DATABASE.\n# Read installation instructions first.\n\n# The following line will produce an intentional error.\n\n'READ INSTALLATION INSTRUCTIONS!';\n\n# The actual schema is below.\n\nDROP DATABASE IF EXISTS tinode;\n\nCREATE DATABASE tinode CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;\n\nUSE tinode;\n\n\nCREATE TABLE kvmeta(\n\t`key` VARCHAR(64),\n\tcreatedat DATETIME(3),\n\t`value` TEXT,\n\tPRIMARY KEY(`key`),\n\tINDEX kvmeta_createdat_key(createdat, `key`)\n);\n\nINSERT INTO kvmeta(`key`, `value`) VALUES(\"version\", \"100\");\n\nCREATE TABLE users(\n\tid \t\t\tBIGINT NOT NULL,\n\tcreatedat \tDATETIME(3) NOT NULL,\n\tupdatedat \tDATETIME(3) NOT NULL,\n\tstate \t\tSMALLINT NOT NULL DEFAULT 0,\n\tstateat \tDATETIME(3),\n\taccess \t\tJSON,\n\tlastseen \tDATETIME,\n\tuseragent \tVARCHAR(255) DEFAULT '',\n\tpublic \t\tJSON,\n\ttags\t\tJSON, -- Denormalized array of tags\n\n\tPRIMARY KEY(id),\n\tINDEX users_state_stateat(state, stateat),\n\tINDEX users_lastseen_updatedat(lastseen, updatedat)\n);\n\n# Indexed user tags.\nCREATE TABLE usertags(\n\tid \t\tINT NOT NULL AUTO_INCREMENT,\n\tuserid \tBIGINT NOT NULL,\n\ttag \tVARCHAR(96) NOT NULL,\n\n\tPRIMARY KEY(id),\n\tFOREIGN KEY(userid) REFERENCES users(id),\n\tINDEX usertags_tag(tag),\n\tUNIQUE INDEX usertags_userid_tag(userid, tag)\n);\n\n# Indexed devices. Normalized into a separate table.\nCREATE TABLE devices(\n\tid \t\t\tINT NOT NULL AUTO_INCREMENT,\n\tuserid \t\tBIGINT NOT NULL,\n\thash \t\tCHAR(16) NOT NULL,\n\tdeviceid \tTEXT NOT NULL,\n\tplatform\tVARCHAR(32),\n\tlastseen \tDATETIME NOT NULL,\n\tlang \t\tVARCHAR(8),\n\n\tPRIMARY KEY(id),\n\tFOREIGN KEY(userid) REFERENCES users(id),\n\tUNIQUE INDEX devices_hash(hash)\n);\n\n# Authentication records for the basic authentication scheme.\nCREATE TABLE auth(\n\tid \t\tINT NOT NULL AUTO_INCREMENT,\n\tuname\tVARCHAR(32) NOT NULL,\n\tuserid \tBIGINT NOT NULL,\n\tscheme\tVARCHAR(16) NOT NULL,\n\tauthlvl\tSMALLINT NOT NULL,\n\tsecret \tVARCHAR(255) NOT NULL,\n\texpires DATETIME,\n\n\tPRIMARY KEY(id),\n\tFOREIGN KEY(userid) REFERENCES users(id),\n\tUNIQUE INDEX auth_userid_scheme(userid, scheme),\n\tUNIQUE INDEX auth_uname (uname)\n);\n\n\n# Topics\nCREATE TABLE topics(\n\tid\t\t\tINT NOT NULL AUTO_INCREMENT,\n\tcreatedat \tDATETIME(3) NOT NULL,\n\tupdatedat \tDATETIME(3) NOT NULL,\n\ttouchedat \tDATETIME(3),\n\tstate\t\tSMALLINT NOT NULL DEFAULT 0,\n\tstateat\t\tDATETIME(3),\n\tname\t\tCHAR(25) NOT NULL,\n\tusebt\t\tTINYINT DEFAULT 0,\n\towner\t\tBIGINT NOT NULL DEFAULT 0,\n\taccess\t\tJSON,\n\tseqid\t\tINT NOT NULL DEFAULT 0,\n\tdelid\t\tINT DEFAULT 0,\n\tsubcnt  INT DEFAULT 0,\n\tpublic\tJSON,\n\ttrusted\tJSON,\n\ttags\t\tJSON, -- Denormalized array of tags\n\taux\t\t\tJSON,\n\n\tPRIMARY KEY(id),\n\tUNIQUE INDEX topics_name (name),\n\tINDEX topics_owner(owner),\n\tINDEX topics_state_stateat(state, stateat),\n\tINDEX topics_name_state_seqid ON topics(name, state, seqid)\n);\n\n# Indexed topic tags.\nCREATE TABLE topictags(\n\tid \t\tINT NOT NULL AUTO_INCREMENT,\n\ttopic \tCHAR(25) NOT NULL,\n\ttag \tVARCHAR(96) NOT NULL,\n\n\tPRIMARY KEY(id),\n\tFOREIGN KEY(topic) REFERENCES topics(name),\n\tINDEX topictags_tag (tag),\n\tUNIQUE INDEX topictags_topic_tag(topic, tag)\n);\n\n# Subscriptions\nCREATE TABLE subscriptions(\n\tid\t\t\tINT NOT NULL AUTO_INCREMENT,\n\tcreatedat\tDATETIME(3) NOT NULL,\n\tupdatedat\tDATETIME(3) NOT NULL,\n\tdeletedat\tDATETIME(3),\n\tuserid\t\tBIGINT NOT NULL,\n\ttopic\t\tCHAR(25) NOT NULL,\n\tdelid\t\tINT DEFAULT 0,\n\trecvseqid\tINT DEFAULT 0,\n\treadseqid\tINT DEFAULT 0,\n\tmodewant\tCHAR(8),\n\tmodegiven\tCHAR(8),\n\tprivate\t\tJSON,\n\n\tPRIMARY KEY(id)\t,\n\tFOREIGN KEY(userid) REFERENCES users(id),\n\tUNIQUE INDEX subscriptions_topic_userid(topic, userid),\n\tINDEX subscriptions_topic(topic),\n\tINDEX subscriptions_deletedat(deletedat),\n\tINDEX subscriptions_user_topic_deletedat ON subscriptions(userid, topic, deletedat)\n);\n\n# Messages\nCREATE TABLE messages(\n\tid \t\t\tINT NOT NULL AUTO_INCREMENT,\n\tcreatedat \tDATETIME(3) NOT NULL,\n\tupdatedat \tDATETIME(3) NOT NULL,\n\tdeletedat \tDATETIME(3),\n\tdelid \t\tINT DEFAULT 0,\n\tseqid \t\tINT NOT NULL,\n\ttopic \t\tCHAR(25) NOT NULL,\n\t`from` \t\tBIGINT NOT NULL,\n\thead \t\tJSON,\n\tcontent \tJSON,\n\n\tPRIMARY KEY(id),\n\tFOREIGN KEY(topic) REFERENCES topics(name),\n\tUNIQUE INDEX messages_topic_seqid (topic, seqid)\n);\n\n# Deletion log\nCREATE TABLE dellog(\n\tid\t\t\tINT NOT NULL AUTO_INCREMENT,\n\ttopic\t\tCHAR(25) NOT NULL,\n\tdeletedfor\tBIGINT NOT NULL DEFAULT 0,\n\tdelid\t\tINT NOT NULL,\n\tlow\t\t\tINT NOT NULL,\n\thi\t\t\tINT NOT NULL,\n\n\tPRIMARY KEY(id),\n\tFOREIGN KEY(topic) REFERENCES topics(name),\n\t# For getting the list of deleted message ranges\n\tINDEX dellog_topic_delid_deletedfor(topic,delid,deletedfor),\n\t# Used when getting not-yet-deleted messages(messages LEFT JOIN dellog)\n\tINDEX dellog_topic_deletedfor_low_hi(topic,deletedfor,low,hi),\n\t# Used when deleting a user\n\tINDEX dellog_deletedfor(deletedfor)\n);\n\n# User credentials\nCREATE TABLE credentials(\n\tid\t\t\tINT NOT NULL AUTO_INCREMENT,\n\tcreatedat\tDATETIME(3) NOT NULL,\n\tupdatedat\tDATETIME(3) NOT NULL,\n\tdeletedat\tDATETIME(3),\n\tmethod \t\tVARCHAR(16) NOT NULL,\n\tvalue\t\tVARCHAR(128) NOT NULL,\n\tsynthetic\tVARCHAR(192) NOT NULL,\n\tuserid \t\tBIGINT NOT NULL,\n\tresp\t\tVARCHAR(255) NOT NULL,\n\tdone\t\tTINYINT NOT NULL DEFAULT 0,\n\tretries\t\tINT NOT NULL DEFAULT 0,\n\n\tPRIMARY KEY(id),\n\tUNIQUE credentials_uniqueness(synthetic),\n\tFOREIGN KEY(userid) REFERENCES users(id),\n);\n\n# Records of uploaded files. Files themselves are stored elsewhere.\nCREATE TABLE fileuploads(\n\tid\t\t\t\tBIGINT NOT NULL,\n\tcreatedat\tDATETIME(3) NOT NULL,\n\tupdatedat\tDATETIME(3) NOT NULL,\n\tuserid\t\tBIGINT,\n\tstatus\t\tINT NOT NULL,\n\tmimetype\tVARCHAR(255) NOT NULL,\n\tsize\t\t\tBIGINT NOT NULL,\n\tlocation\tVARCHAR(2048) NOT NULL,\n\tetag\t\t\tVARCHAR(128),\n\n\tPRIMARY KEY(id),\n\tINDEX fileuploads_status(status)\n);\n\n# Links between uploaded files and messages or topics.\nCREATE TABLE filemsglinks(\n\tid\t\t\tINT NOT NULL AUTO_INCREMENT,\n\tcreatedat\tDATETIME(3) NOT NULL,\n\tfileid\t\tBIGINT NOT NULL,\n\tmsgid\t\tINT,\n\ttopic\t\tCHAR(25),\n\tuserid\t\tBIGINT,\n\n\tPRIMARY KEY(id),\n\tFOREIGN KEY(fileid) REFERENCES fileuploads(id) ON DELETE CASCADE,\n\tFOREIGN KEY(msgid) REFERENCES messages(id) ON DELETE CASCADE,\n\tFOREIGN KEY(topicid) REFERENCES topics(id) ON DELETE CASCADE,\n\tFOREIGN KEY(userid) REFERENCES users(id) ON DELETE CASCADE\n);\n"
  },
  {
    "path": "server/db/mysql/tests/mysql_test.go",
    "content": "// To test another db backend:\n// 1) Create GetAdapter function inside your db backend adapter package (like one inside mysql adapter)\n// 2) Uncomment your db backend package ('backend' named package)\n// 3) Write own initConnectionToDb and 'db' variable\n// 4) Replace mysql specific db queries inside test to your own queries.\n// 5) Run.\n\npackage tests\n\nimport (\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"flag\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"reflect\"\n\t\"testing\"\n\t\"time\"\n\n\t_ \"github.com/go-sql-driver/mysql\"\n\t\"github.com/google/go-cmp/cmp\"\n\t\"github.com/google/go-cmp/cmp/cmpopts\"\n\t\"github.com/jmoiron/sqlx\"\n\tadapter \"github.com/tinode/chat/server/db\"\n\t\"github.com/tinode/chat/server/store\"\n\tjcr \"github.com/tinode/jsonco\"\n\n\t\"github.com/tinode/chat/server/db/common/test_data\"\n\tbackend \"github.com/tinode/chat/server/db/mysql\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\ntype configType struct {\n\t// If Reset=true test will recreate database every time it runs\n\tReset bool `json:\"reset_db_data\"`\n\t// Configurations for individual adapters.\n\tAdapters map[string]json.RawMessage `json:\"adapters\"`\n}\n\nvar config configType\nvar adp adapter.Adapter\nvar db *sqlx.DB\nvar testData *test_data.TestData\n\nvar dummyUid1 = types.Uid(12345)\nvar dummyUid2 = types.Uid(54321)\n\nfunc TestCreateDb(t *testing.T) {\n\tif err := adp.CreateDb(config.Reset); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Saved db is closed, get a fresh one.\n\tdb = adp.GetTestDB().(*sqlx.DB)\n}\n\n// ================== Create tests ================================\nfunc TestUserCreate(t *testing.T) {\n\tfor _, user := range testData.Users {\n\t\tif err := adp.UserCreate(user); err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n\tvar count int\n\n\tif err := db.Ping(); err != nil {\n\t\tlogs.Err.Println(\"Database ping failed:\", err)\n\t}\n\terr := db.QueryRow(\"SELECT COUNT(*) FROM users\").Scan(&count)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif count == 0 {\n\t\tt.Error(\"No users created!\")\n\t}\n}\n\nfunc TestCredUpsert(t *testing.T) {\n\t// Test just inserts:\n\tfor i := 0; i < 2; i++ {\n\t\tinserted, err := adp.CredUpsert(testData.Creds[i])\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif !inserted {\n\t\t\tt.Error(\"Should be inserted, but updated\")\n\t\t}\n\t}\n\n\t// Test duplicate:\n\t_, err := adp.CredUpsert(testData.Creds[1])\n\tif err != types.ErrDuplicate {\n\t\tt.Error(\"Should return duplicate error but got\", err)\n\t}\n\t_, err = adp.CredUpsert(testData.Creds[2])\n\tif err != types.ErrDuplicate {\n\t\tt.Error(\"Should return duplicate error but got\", err)\n\t}\n\n\t// Test add new unvalidated credentials\n\tinserted, err := adp.CredUpsert(testData.Creds[3])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !inserted {\n\t\tt.Error(\"Should be inserted, but updated\")\n\t}\n\tinserted, err = adp.CredUpsert(testData.Creds[3])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif inserted {\n\t\tt.Error(\"Should be updated, but inserted\")\n\t}\n\n\t// Just insert other creds (used in other tests)\n\tfor _, cred := range testData.Creds[4:] {\n\t\t_, err = adp.CredUpsert(cred)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n}\n\nfunc TestAuthAddRecord(t *testing.T) {\n\tfor _, rec := range testData.Recs {\n\t\terr := adp.AuthAddRecord(types.ParseUserId(\"usr\"+rec.UserId), rec.Scheme, rec.Unique,\n\t\t\trec.AuthLvl, rec.Secret, rec.Expires)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\t//Test duplicate\n\terr := adp.AuthAddRecord(types.ParseUserId(\"usr\"+testData.Users[0].Id), testData.Recs[0].Scheme,\n\t\ttestData.Recs[0].Unique, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)\n\tif err != types.ErrDuplicate {\n\t\tt.Fatal(\"Should be duplicate error but got\", err)\n\t}\n}\n\nfunc TestTopicCreate(t *testing.T) {\n\terr := adp.TopicCreate(testData.Topics[0])\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tfor _, tpc := range testData.Topics[3:] {\n\t\terr = adp.TopicCreate(tpc)\n\t\tif err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n}\n\nfunc decodeUid(u string) int64 {\n\treturn store.DecodeUid(types.ParseUid(u))\n}\n\nfunc TestTopicCreateP2P(t *testing.T) {\n\terr := adp.TopicCreateP2P(testData.Subs[2], testData.Subs[3])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\toldModeGiven := testData.Subs[2].ModeGiven\n\ttestData.Subs[2].ModeGiven = 255\n\terr = adp.TopicCreateP2P(testData.Subs[4], testData.Subs[2])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tvar got types.Subscription\n\terr = db.QueryRow(\"SELECT createdat,updatedat,deletedat,userid,topic,delid,recvseqid,readseqid,modewant,modegiven,private FROM subscriptions WHERE topic=? AND userid=?\",\n\t\ttestData.Subs[2].Topic, decodeUid(testData.Subs[2].User)).Scan(&got.CreatedAt,\n\t\t&got.UpdatedAt, &got.DeletedAt, &got.User, &got.Topic, &got.DelId, &got.RecvSeqId, &got.ReadSeqId, &got.ModeWant, &got.ModeGiven, &got.Private)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.ModeGiven == oldModeGiven {\n\t\tt.Error(\"ModeGiven update failed\")\n\t}\n}\n\nfunc TestTopicShare(t *testing.T) {\n\tif err := adp.TopicShare(testData.Subs[0].Topic, testData.Subs); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Must save recvseqid and readseqid separately because TopicShare\n\t// ignores them.\n\tfor _, sub := range testData.Subs {\n\t\tadp.SubsUpdate(sub.Topic, types.ParseUid(sub.User), map[string]any{\n\t\t\t\"delid\":     sub.DelId,\n\t\t\t\"recvseqid\": sub.RecvSeqId,\n\t\t\t\"readseqid\": sub.ReadSeqId,\n\t\t})\n\t}\n\n\t// Update topic SeqId because it's not saved at creation time but used by the tests.\n\tfor _, tpc := range testData.Topics {\n\t\terr := adp.TopicUpdate(tpc.Id, map[string]any{\n\t\t\t\"seqid\": tpc.SeqId,\n\t\t\t\"delid\": tpc.DelId,\n\t\t})\n\t\tif err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n}\n\nfunc TestMessageSave(t *testing.T) {\n\tfor _, msg := range testData.Msgs {\n\t\terr := adp.MessageSave(msg)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\n\t// Some messages are soft deleted, but it's ignored by adp.MessageSave\n\tfor _, msg := range testData.Msgs {\n\t\tif len(msg.DeletedFor) > 0 {\n\t\t\tfor _, del := range msg.DeletedFor {\n\t\t\t\ttoDel := types.DelMessage{\n\t\t\t\t\tTopic:       msg.Topic,\n\t\t\t\t\tDeletedFor:  del.User,\n\t\t\t\t\tDelId:       del.DelId,\n\t\t\t\t\tSeqIdRanges: []types.Range{{Low: msg.SeqId}},\n\t\t\t\t}\n\t\t\t\tadp.MessageDeleteList(msg.Topic, &toDel)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestFileStartUpload(t *testing.T) {\n\tfor _, f := range testData.Files {\n\t\terr := adp.FileStartUpload(f)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n}\n\n// ================== Read tests ==================================\nfunc TestUserGet(t *testing.T) {\n\t// Test not found\n\tgot, err := adp.UserGet(dummyUid1)\n\tif err == nil && got != nil {\n\t\tt.Error(\"user should be nil.\")\n\t}\n\n\tgot, err = adp.UserGet(types.ParseUserId(\"usr\" + testData.Users[0].Id))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// User agent is not stored when creating a user. Make sure it's the same.\n\tgot.UserAgent = testData.Users[0].UserAgent\n\tgot.CreatedAt = testData.Users[0].CreatedAt\n\tgot.UpdatedAt = testData.Users[0].UpdatedAt\n\n\tif !reflect.DeepEqual(got, testData.Users[0]) {\n\t\tt.Error(mismatchErrorString(\"User\", got, testData.Users[0]))\n\t}\n}\n\nfunc TestUserGetAll(t *testing.T) {\n\t// Test not found (dummy UIDs).\n\tgot, err := adp.UserGetAll(dummyUid1, dummyUid2)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(got) > 0 {\n\t\tt.Error(\"result users should be zero length, got\", len(got))\n\t}\n\n\tgot, err = adp.UserGetAll(types.ParseUserId(\"usr\"+testData.Users[0].Id), types.ParseUserId(\"usr\"+testData.Users[1].Id))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(got) != 2 {\n\t\tt.Fatal(mismatchErrorString(\"resultUsers length\", len(got), 2))\n\t}\n\tfor i, usr := range got {\n\t\t// User agent is not compared.\n\t\tusr.UserAgent = testData.Users[i].UserAgent\n\t\tusr.CreatedAt = testData.Users[i].CreatedAt\n\t\tusr.UpdatedAt = testData.Users[i].UpdatedAt\n\t\tif !reflect.DeepEqual(&usr, testData.Users[i]) {\n\t\t\tt.Error(mismatchErrorString(\"User\", &usr, testData.Users[i]))\n\t\t}\n\t}\n}\n\nfunc TestUserGetByCred(t *testing.T) {\n\t// Test not found\n\tgot, err := adp.UserGetByCred(\"foo\", \"bar\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got != types.ZeroUid {\n\t\tt.Error(\"result uid should be ZeroUid\")\n\t}\n\n\tgot, _ = adp.UserGetByCred(testData.Creds[0].Method, testData.Creds[0].Value)\n\tif got != types.ParseUserId(\"usr\"+testData.Creds[0].User) {\n\t\tt.Error(mismatchErrorString(\"Uid\", got, types.ParseUserId(\"usr\"+testData.Creds[0].User)))\n\t}\n}\n\nfunc TestCredGetActive(t *testing.T) {\n\tgot, err := adp.CredGetActive(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"tel\")\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif !reflect.DeepEqual(got, testData.Creds[3]) {\n\t\tt.Error(mismatchErrorString(\"Credential\", got, testData.Creds[3]))\n\t}\n\n\t// Test not found\n\tgot, err = adp.CredGetActive(dummyUid1, \"\")\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif got != nil {\n\t\tt.Error(\"result should be nil, but got\", got)\n\t}\n}\n\nfunc TestCredGetAll(t *testing.T) {\n\tgot, err := adp.CredGetAll(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"\", false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(got) != 3 {\n\t\tt.Error(mismatchErrorString(\"Credentials length\", len(got), 3))\n\t}\n\n\tgot, _ = adp.CredGetAll(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"tel\", false)\n\tif len(got) != 2 {\n\t\tt.Error(mismatchErrorString(\"Credentials length\", len(got), 2))\n\t}\n\n\tgot, _ = adp.CredGetAll(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"\", true)\n\tif len(got) != 1 {\n\t\tt.Error(mismatchErrorString(\"Credentials length\", len(got), 1))\n\t}\n\n\tgot, _ = adp.CredGetAll(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"tel\", true)\n\tif len(got) != 1 {\n\t\tt.Error(mismatchErrorString(\"Credentials length\", len(got), 1))\n\t}\n}\n\nfunc TestAuthGetUniqueRecord(t *testing.T) {\n\tuid, authLvl, secret, expires, err := adp.AuthGetUniqueRecord(\"basic:alice\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif uid != types.ParseUserId(\"usr\"+testData.Recs[0].UserId) ||\n\t\tauthLvl != testData.Recs[0].AuthLvl ||\n\t\t!reflect.DeepEqual(secret, testData.Recs[0].Secret) ||\n\t\texpires != testData.Recs[0].Expires {\n\n\t\tgot := fmt.Sprintf(\"%v %v %v %v\", uid, authLvl, secret, expires)\n\t\twant := fmt.Sprintf(\"%v %v %v %v\", testData.Recs[0].UserId, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)\n\t\tt.Error(mismatchErrorString(\"Auth record\", got, want))\n\t}\n\n\t// Test not found\n\tuid, _, _, _, err = adp.AuthGetUniqueRecord(\"qwert:asdfg\")\n\tif err == nil && !uid.IsZero() {\n\t\tt.Error(\"Auth record found but shouldn't. Uid:\", uid.String())\n\t}\n}\n\nfunc TestAuthGetRecord(t *testing.T) {\n\trecId, authLvl, secret, expires, err := adp.AuthGetRecord(types.ParseUserId(\"usr\"+testData.Recs[0].UserId), \"basic\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif recId != testData.Recs[0].Unique ||\n\t\tauthLvl != testData.Recs[0].AuthLvl ||\n\t\t!reflect.DeepEqual(secret, testData.Recs[0].Secret) ||\n\t\texpires != testData.Recs[0].Expires {\n\n\t\tgot := fmt.Sprintf(\"%v %v %v %v\", recId, authLvl, secret, expires)\n\t\twant := fmt.Sprintf(\"%v %v %v %v\", testData.Recs[0].Unique, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)\n\t\tt.Error(mismatchErrorString(\"Auth record\", got, want))\n\t}\n\n\t// Test not found\n\trecId, _, _, _, err = adp.AuthGetRecord(types.Uid(123), \"scheme\")\n\tif err != types.ErrNotFound {\n\t\tt.Error(\"Auth record found but shouldn't. recId:\", recId)\n\t}\n}\n\nfunc TestTopicGet(t *testing.T) {\n\tgot, err := adp.TopicGet(testData.Topics[0].Id)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !reflect.DeepEqual(got, testData.Topics[0]) {\n\t\tt.Error(mismatchErrorString(\"Topic\", got, testData.Topics[0]))\n\t}\n\t// Test not found\n\tgot, err = adp.TopicGet(\"asdfasdfasdf\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got != nil {\n\t\tt.Error(\"Topic should be nil but got:\", got)\n\t}\n}\n\nfunc TestTopicsForUser(t *testing.T) {\n\tqOpts := types.QueryOpt{\n\t\tTopic: \"p2p9AVDamaNCRbfKzGSh3mE0w\",\n\t\tLimit: 999,\n\t}\n\tgotSubs, err := adp.TopicsForUser(types.ParseUserId(\"usr\"+testData.Users[0].Id), false, &qOpts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 1 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 1))\n\t}\n\n\tgotSubs, err = adp.TopicsForUser(types.ParseUserId(\"usr\"+testData.Users[1].Id), true, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 2 {\n\t\tt.Error(mismatchErrorString(\"Subs length (2)\", len(gotSubs), 2))\n\t}\n\n\tqOpts.Topic = \"\"\n\tims := testData.Now.Add(15 * time.Minute)\n\tqOpts.IfModifiedSince = &ims\n\tgotSubs, err = adp.TopicsForUser(types.ParseUserId(\"usr\"+testData.Users[0].Id), false, &qOpts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 1 {\n\t\tt.Error(mismatchErrorString(\"Subs length (IMS)\", len(gotSubs), 1))\n\t}\n\n\tims = time.Now().Add(15 * time.Minute)\n\tgotSubs, err = adp.TopicsForUser(types.ParseUserId(\"usr\"+testData.Users[0].Id), false, &qOpts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 0 {\n\t\tt.Error(mismatchErrorString(\"Subs length (IMS 2)\", len(gotSubs), 0))\n\t}\n}\n\nfunc TestUsersForTopic(t *testing.T) {\n\tqOpts := types.QueryOpt{\n\t\tUser:  types.ParseUserId(\"usr\" + testData.Users[0].Id),\n\t\tLimit: 999,\n\t}\n\tgotSubs, err := adp.UsersForTopic(\"grpgRXf0rU4uR4\", false, &qOpts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 1 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 1))\n\t}\n\n\tgotSubs, err = adp.UsersForTopic(\"grpgRXf0rU4uR4\", true, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 2 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 2))\n\t}\n\n\tgotSubs, err = adp.UsersForTopic(\"p2p9AVDamaNCRbfKzGSh3mE0w\", false, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 2 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 2))\n\t}\n}\n\nfunc TestOwnTopics(t *testing.T) {\n\tgotSubs, err := adp.OwnTopics(types.ParseUserId(\"usr\" + testData.Users[0].Id))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 1 {\n\t\tt.Fatalf(\"Got topic length %v instead of %v\", len(gotSubs), 1)\n\t}\n\tif gotSubs[0] != testData.Topics[0].Id {\n\t\tt.Errorf(\"Got topic %v instead of %v\", gotSubs[0], testData.Topics[0].Id)\n\t}\n}\n\nfunc TestSubscriptionGet(t *testing.T) {\n\tgot, err := adp.SubscriptionGet(testData.Topics[0].Id, types.ParseUserId(\"usr\"+testData.Users[0].Id), false)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\n\tif diff := cmp.Diff(got, testData.Subs[0],\n\t\tcmpopts.IgnoreUnexported(types.Subscription{}, types.ObjHeader{})); diff != \"\" {\n\t\tt.Error(mismatchErrorString(\"Subs\", diff, \"\"))\n\t}\n\t// Test not found\n\tgot, err = adp.SubscriptionGet(\"dummytopic\", dummyUid1, false)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif got != nil {\n\t\tt.Error(\"result sub should be nil.\")\n\t}\n}\n\nfunc TestSubsForUser(t *testing.T) {\n\tgotSubs, err := adp.SubsForUser(types.ParseUserId(\"usr\" + testData.Users[0].Id))\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif len(gotSubs) != 2 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 1))\n\t}\n\n\t// Test not found\n\tgotSubs, err = adp.SubsForUser(types.ParseUserId(\"usr12345678\"))\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif len(gotSubs) != 0 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 0))\n\t}\n}\n\nfunc TestSubsForTopic(t *testing.T) {\n\tqOpts := types.QueryOpt{\n\t\tUser:  types.ParseUserId(\"usr\" + testData.Users[0].Id),\n\t\tLimit: 999,\n\t}\n\tgotSubs, err := adp.SubsForTopic(testData.Topics[0].Id, false, &qOpts)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif len(gotSubs) != 1 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 1))\n\t}\n\t// Test not found\n\tgotSubs, err = adp.SubsForTopic(\"dummytopicid\", false, nil)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif len(gotSubs) != 0 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 0))\n\t}\n}\n\nfunc TestFind(t *testing.T) {\n\treqTags := [][]string{{\"alice\", \"bob\", \"carol\", \"travel\", \"qwer\", \"asdf\", \"zxcv\"}}\n\tgot, err := adp.Find(\"usr\"+testData.Users[2].Id, \"\", reqTags, nil, true)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif len(got) != 3 {\n\t\tt.Error(mismatchErrorString(\"result length\", len(got), 3))\n\t}\n}\n\nfunc TestMessageGetAll(t *testing.T) {\n\topts := types.QueryOpt{\n\t\tSince:  1,\n\t\tBefore: 2,\n\t\tLimit:  999,\n\t}\n\tgotMsgs, err := adp.MessageGetAll(testData.Topics[0].Id, types.ParseUserId(\"usr\"+testData.Users[0].Id), &opts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotMsgs) != 1 {\n\t\tt.Error(mismatchErrorString(\"Messages length opts\", len(gotMsgs), 1))\n\t}\n\tgotMsgs, _ = adp.MessageGetAll(testData.Topics[0].Id, types.ParseUserId(\"usr\"+testData.Users[0].Id), nil)\n\tif len(gotMsgs) != 2 {\n\t\tt.Fatalf(\"%+v\", gotMsgs)\n\t\tt.Error(mismatchErrorString(\"Messages length no opts\", len(gotMsgs), 2))\n\t}\n\tgotMsgs, _ = adp.MessageGetAll(testData.Topics[0].Id, types.ZeroUid, nil)\n\tif len(gotMsgs) != 3 {\n\t\tt.Error(mismatchErrorString(\"Messages length zero uid\", len(gotMsgs), 3))\n\t}\n}\n\nfunc TestFileGet(t *testing.T) {\n\t// General test done during TestFileFinishUpload().\n\n\t// Test not found\n\tgot, err := adp.FileGet(\"dummyfileid\")\n\tif err != nil {\n\t\tif got != nil {\n\t\t\tt.Error(\"File found but shouldn't:\", got)\n\t\t}\n\t}\n}\n\n// ================== Update tests ================================\nfunc TestUserUpdate(t *testing.T) {\n\tupdate := map[string]any{\n\t\t\"UserAgent\": \"Test Agent v0.11\",\n\t\t\"UpdatedAt\": testData.Now.Add(30 * time.Minute),\n\t}\n\terr := adp.UserUpdate(types.ParseUserId(\"usr\"+testData.Users[0].Id), update)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tvar got struct {\n\t\tUserAgent string\n\t\tUpdatedAt time.Time\n\t\tCreatedAt time.Time\n\t}\n\terr = db.QueryRow(\"SELECT useragent, updatedat, createdat FROM users WHERE id=?\", decodeUid(testData.Users[0].Id)).\n\t\tScan(&got.UserAgent, &got.UpdatedAt, &got.CreatedAt)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.UserAgent != \"Test Agent v0.11\" {\n\t\tt.Error(mismatchErrorString(\"UserAgent\", got.UserAgent, \"Test Agent v0.11\"))\n\t}\n\tif got.UpdatedAt.Equal(got.CreatedAt) {\n\t\tt.Error(\"UpdatedAt field not updated\")\n\t}\n}\n\nfunc TestUserUpdateTags(t *testing.T) {\n\taddTags := testData.Tags[0]\n\tremoveTags := testData.Tags[1]\n\tresetTags := testData.Tags[2]\n\tuid := types.ParseUserId(\"usr\" + testData.Users[0].Id)\n\n\tgot, err := adp.UserUpdateTags(uid, addTags, nil, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"alice\", \"tag1\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Error(mismatchErrorString(\"Tags\", got, want))\n\n\t}\n\tgot, err = adp.UserUpdateTags(uid, nil, removeTags, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant = nil\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Error(mismatchErrorString(\"Tags\", got, want))\n\n\t}\n\tgot, err = adp.UserUpdateTags(uid, nil, nil, resetTags)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant = []string{\"alice\", \"tag111\", \"tag333\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Error(mismatchErrorString(\"Tags\", got, want))\n\n\t}\n\tgot, err = adp.UserUpdateTags(uid, addTags, removeTags, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant = []string{\"tag111\", \"tag333\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Error(mismatchErrorString(\"Tags\", got, want))\n\n\t}\n\tgot, err = adp.UserUpdateTags(uid, addTags, removeTags, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant = []string{\"tag111\", \"tag333\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Error(mismatchErrorString(\"Tags\", got, want))\n\t}\n}\n\nfunc TestCredFail(t *testing.T) {\n\terr := adp.CredFail(types.ParseUserId(\"usr\"+testData.Creds[3].User), \"tel\")\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\n\t// Check if fields updated\n\tvar got struct {\n\t\tRetries   int\n\t\tUpdatedAt time.Time\n\t\tCreatedAt time.Time\n\t}\n\terr = db.QueryRow(\"SELECT retries, updatedat, createdat FROM credentials WHERE userid=? AND method=? AND value=?\",\n\t\tdecodeUid(testData.Creds[3].User), \"tel\", testData.Creds[3].Value).Scan(&got.Retries, &got.UpdatedAt, &got.CreatedAt)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.Retries != 1 {\n\t\tt.Error(mismatchErrorString(\"Retries count\", got.Retries, 1))\n\t}\n\tif got.UpdatedAt.Equal(got.CreatedAt) {\n\t\tt.Error(\"UpdatedAt field not updated\")\n\t}\n}\n\nfunc TestCredConfirm(t *testing.T) {\n\terr := adp.CredConfirm(types.ParseUserId(\"usr\"+testData.Creds[3].User), \"tel\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test fields are updated\n\tvar got struct {\n\t\tUpdatedAt time.Time\n\t\tCreatedAt time.Time\n\t\tDone      bool\n\t}\n\terr = db.QueryRow(\"SELECT updatedat, createdat, done FROM credentials WHERE userid=? AND method=? AND value=?\",\n\t\tdecodeUid(testData.Creds[3].User), \"tel\", testData.Creds[3].Value).Scan(&got.UpdatedAt, &got.CreatedAt, &got.Done)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.UpdatedAt.Equal(got.CreatedAt) {\n\t\tt.Error(\"Credential not updated correctly\")\n\t}\n\tif !got.Done {\n\t\tt.Error(\"Credential should be marked as done\")\n\t}\n}\n\nfunc TestAuthUpdRecord(t *testing.T) {\n\trec := testData.Recs[1]\n\tnewSecret := []byte{'s', 'e', 'c', 'r', 'e', 't'}\n\terr := adp.AuthUpdRecord(types.ParseUserId(\"usr\"+rec.UserId), rec.Scheme, rec.Unique,\n\t\trec.AuthLvl, newSecret, rec.Expires)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got []byte\n\terr = db.QueryRow(\"SELECT secret FROM auth WHERE uname=?\", rec.Unique).Scan(&got)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif reflect.DeepEqual(got, rec.Secret) {\n\t\tt.Error(mismatchErrorString(\"Secret\", got, rec.Secret))\n\t}\n\n\t// Test with auth ID (unique) change\n\tnewId := \"basic:bob12345\"\n\terr = adp.AuthUpdRecord(types.ParseUserId(\"usr\"+rec.UserId), rec.Scheme, newId,\n\t\trec.AuthLvl, newSecret, rec.Expires)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Test if old ID deleted\n\tvar count int\n\terr = db.QueryRow(\"SELECT COUNT(*) FROM auth WHERE uname=?\", rec.Unique).Scan(&count)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count != 0 {\n\t\tt.Error(\"Old auth record not deleted\")\n\t}\n}\n\nfunc TestTopicUpdateOnMessage(t *testing.T) {\n\tmsg := types.Message{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tCreatedAt: testData.Now.Add(33 * time.Minute),\n\t\t},\n\t\tSeqId: 66,\n\t}\n\terr := adp.TopicUpdateOnMessage(testData.Topics[2].Id, &msg)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got struct {\n\t\tTouchedAt time.Time\n\t\tSeqId     int\n\t}\n\terr = db.QueryRow(\"SELECT touchedat, seqid FROM topics WHERE name=?\", testData.Topics[2].Id).\n\t\tScan(&got.TouchedAt, &got.SeqId)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.TouchedAt != msg.CreatedAt || got.SeqId != msg.SeqId {\n\t\tt.Error(mismatchErrorString(\"TouchedAt\", got.TouchedAt, msg.CreatedAt))\n\t\tt.Error(mismatchErrorString(\"SeqId\", got.SeqId, msg.SeqId))\n\t}\n}\n\nfunc TestTopicUpdate(t *testing.T) {\n\tupdate := map[string]any{\n\t\t\"UpdatedAt\": testData.Now.Add(55 * time.Minute),\n\t}\n\terr := adp.TopicUpdate(testData.Topics[0].Id, update)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got time.Time\n\terr = db.QueryRow(\"SELECT updatedat FROM topics WHERE name=?\", testData.Topics[0].Id).Scan(&got)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got != update[\"UpdatedAt\"] {\n\t\tt.Error(mismatchErrorString(\"UpdatedAt\", got, update[\"UpdatedAt\"]))\n\t}\n}\n\nfunc TestTopicOwnerChange(t *testing.T) {\n\terr := adp.TopicOwnerChange(testData.Topics[0].Id, types.ParseUserId(\"usr\"+testData.Users[1].Id))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got int64\n\terr = db.QueryRow(\"SELECT owner FROM topics WHERE name=?\", testData.Topics[0].Id).Scan(&got)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\texpectedOwner := decodeUid(testData.Users[1].Id) // Assuming user ID conversion\n\tif got != expectedOwner {\n\t\tt.Error(mismatchErrorString(\"Owner\", got, expectedOwner))\n\t}\n}\n\nfunc TestSubsUpdate(t *testing.T) {\n\tupdate := map[string]any{\n\t\t\"UpdatedAt\": testData.Now.Add(22 * time.Minute),\n\t}\n\terr := adp.SubsUpdate(testData.Topics[0].Id, types.ParseUserId(\"usr\"+testData.Users[0].Id), update)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got time.Time\n\terr = db.QueryRow(\"SELECT updatedat FROM subscriptions WHERE topic=? AND userid=?\",\n\t\ttestData.Topics[0].Id, decodeUid(testData.Users[0].Id)).Scan(&got)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got != update[\"UpdatedAt\"] {\n\t\tt.Error(mismatchErrorString(\"UpdatedAt\", got, update[\"UpdatedAt\"]))\n\t}\n\n\terr = adp.SubsUpdate(testData.Topics[1].Id, types.ZeroUid, update)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = db.QueryRow(\"SELECT updatedat FROM subscriptions WHERE topic=? LIMIT 1\",\n\t\ttestData.Topics[1].Id).Scan(&got)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got != update[\"UpdatedAt\"] {\n\t\tt.Error(mismatchErrorString(\"UpdatedAt\", got, update[\"UpdatedAt\"]))\n\t}\n}\n\nfunc TestSubsDelete(t *testing.T) {\n\terr := adp.SubsDelete(testData.Topics[1].Id, types.ParseUserId(\"usr\"+testData.Users[0].Id))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar deletedat sql.NullTime\n\terr = db.QueryRow(\"SELECT deletedat FROM subscriptions WHERE topic=? AND userid=?\",\n\t\ttestData.Topics[1].Id, decodeUid(testData.Users[0].Id)).Scan(&deletedat)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !deletedat.Valid {\n\t\tt.Error(\"DeletedAt should not be null\")\n\t}\n}\n\nfunc TestDeviceUpsert(t *testing.T) {\n\terr := adp.DeviceUpsert(types.ParseUserId(\"usr\"+testData.Users[0].Id), testData.Devs[0])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got struct {\n\t\tDeviceId string\n\t\tPlatform string\n\t}\n\terr = db.QueryRow(\"SELECT deviceid, platform FROM devices WHERE userid=? LIMIT 1\",\n\t\tdecodeUid(testData.Users[0].Id)).Scan(&got.DeviceId, &got.Platform)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.DeviceId != testData.Devs[0].DeviceId || got.Platform != testData.Devs[0].Platform {\n\t\tt.Error(mismatchErrorString(\"Device\", got, testData.Devs[0]))\n\t}\n\n\t// Test update\n\ttestData.Devs[0].Platform = \"Web\"\n\terr = adp.DeviceUpsert(types.ParseUserId(\"usr\"+testData.Users[0].Id), testData.Devs[0])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = db.QueryRow(\"SELECT platform FROM devices WHERE userid=? AND deviceid=?\",\n\t\tdecodeUid(testData.Users[0].Id), testData.Devs[0].DeviceId).Scan(&got.Platform)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.Platform != \"Web\" {\n\t\tt.Error(\"Device not updated.\", got.Platform)\n\t}\n\n\t// Test add same device to another user\n\terr = adp.DeviceUpsert(types.ParseUserId(\"usr\"+testData.Users[1].Id), testData.Devs[0])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = db.QueryRow(\"SELECT platform FROM devices WHERE userid=? AND deviceid=?\",\n\t\tdecodeUid(testData.Users[1].Id), testData.Devs[0].DeviceId).Scan(&got.Platform)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.Platform != \"Web\" {\n\t\tt.Error(\"Device not updated.\", got.Platform)\n\t}\n\n\terr = adp.DeviceUpsert(types.ParseUserId(\"usr\"+testData.Users[2].Id), testData.Devs[1])\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestFileFinishUpload(t *testing.T) {\n\tgot, err := adp.FileFinishUpload(testData.Files[0], true, 22222)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.Status != types.UploadCompleted {\n\t\tt.Error(mismatchErrorString(\"Status\", got.Status, types.UploadCompleted))\n\t}\n\tif got.Size != 22222 {\n\t\tt.Error(mismatchErrorString(\"Size\", got.Size, 22222))\n\t}\n}\n\nfunc TestMessageAttachments(t *testing.T) {\n\tfids := []string{testData.Files[0].Id, testData.Files[1].Id}\n\terr := adp.FileLinkAttachments(\"\", types.ZeroUid, types.ParseUid(testData.Msgs[1].Id), fids)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Check if attachments were linked (this would require checking filemsglinks table)\n\tvar count int\n\tif err = db.QueryRow(\"SELECT COUNT(*) FROM filemsglinks WHERE msgid=?\",\n\t\ttypes.ParseUid(testData.Msgs[1].Id)).Scan(&count); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif count != len(fids) {\n\t\tt.Error(mismatchErrorString(\"Attachments count\", count, len(fids)))\n\t}\n}\n\n// ================== Other tests =================================\nfunc TestDeviceGetAll(t *testing.T) {\n\tuid0 := types.ParseUserId(\"usr\" + testData.Users[0].Id)\n\tuid1 := types.ParseUserId(\"usr\" + testData.Users[1].Id)\n\tuid2 := types.ParseUserId(\"usr\" + testData.Users[2].Id)\n\tgotDevs, count, err := adp.DeviceGetAll(uid0, uid1, uid2)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count != 2 {\n\t\tt.Fatal(mismatchErrorString(\"count\", count, 2))\n\t}\n\tif !reflect.DeepEqual(gotDevs[uid1][0], *testData.Devs[0]) {\n\t\tt.Error(mismatchErrorString(\"Device\", gotDevs[uid1][0], *testData.Devs[0]))\n\t}\n\tif !reflect.DeepEqual(gotDevs[uid2][0], *testData.Devs[1]) {\n\t\tt.Error(mismatchErrorString(\"Device\", gotDevs[uid2][0], *testData.Devs[1]))\n\t}\n}\n\nfunc TestDeviceDelete(t *testing.T) {\n\terr := adp.DeviceDelete(types.ParseUserId(\"usr\"+testData.Users[1].Id), testData.Devs[0].DeviceId)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar count int\n\terr = db.QueryRow(\"SELECT COUNT(*) FROM devices WHERE userid=?\", testData.Users[1].Id).Scan(&count)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count != 0 {\n\t\tt.Error(\"Device not deleted:\", count)\n\t}\n\n\terr = adp.DeviceDelete(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = db.QueryRow(\"SELECT COUNT(*) FROM devices WHERE userid=?\", testData.Users[2].Id).Scan(&count)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count != 0 {\n\t\tt.Error(\"Device not deleted:\", count)\n\t}\n}\n\n// ================== Persistent Cache tests ======================\nfunc TestPCacheUpsert(t *testing.T) {\n\terr := adp.PCacheUpsert(\"test_key\", \"test_value\", false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test duplicate with failOnDuplicate = true\n\terr = adp.PCacheUpsert(\"test_key2\", \"test_value2\", true)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terr = adp.PCacheUpsert(\"test_key2\", \"new_value\", true)\n\tif err != types.ErrDuplicate {\n\t\tt.Error(\"Expected duplicate error\")\n\t}\n}\n\nfunc TestPCacheGet(t *testing.T) {\n\tvalue, err := adp.PCacheGet(\"test_key\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif value != \"test_value\" {\n\t\tt.Error(mismatchErrorString(\"Cache value\", value, \"test_value\"))\n\t}\n\n\t// Test not found\n\t_, err = adp.PCacheGet(\"nonexistent\")\n\tif err != types.ErrNotFound {\n\t\tt.Error(\"Expected not found error\")\n\t}\n}\n\nfunc TestPCacheDelete(t *testing.T) {\n\terr := adp.PCacheDelete(\"test_key\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify deleted\n\t_, err = adp.PCacheGet(\"test_key\")\n\tif err != types.ErrNotFound {\n\t\tt.Error(\"Key should be deleted\")\n\t}\n}\n\nfunc TestPCacheExpire(t *testing.T) {\n\t// Insert some test keys with prefix\n\tadp.PCacheUpsert(\"prefix_key1\", \"value1\", false)\n\tadp.PCacheUpsert(\"prefix_key2\", \"value2\", false)\n\n\t// Expire keys older than now (should delete all test keys)\n\terr := adp.PCacheExpire(\"prefix_\", time.Now().Add(1*time.Minute))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\n// ================== Delete tests ================================\nfunc TestCredDel(t *testing.T) {\n\terr := adp.CredDel(types.ParseUserId(\"usr\"+testData.Users[0].Id), \"email\", \"alice@test.example.com\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar count int\n\terr = db.QueryRow(\"SELECT COUNT(*) FROM credentials WHERE method='email' AND value='alice@test.example.com'\").Scan(&count)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count != 0 {\n\t\tt.Error(\"Got result but shouldn't\", count)\n\t}\n\n\terr = adp.CredDel(types.ParseUserId(\"usr\"+testData.Users[1].Id), \"\", \"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = db.QueryRow(\"SELECT COUNT(*) FROM credentials WHERE userid=?\", testData.Users[1].Id).Scan(&count)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count != 0 {\n\t\tt.Error(\"Got result but shouldn't\", count)\n\t}\n}\n\nfunc TestAuthDelScheme(t *testing.T) {\n\t// tested during TestAuthUpdRecord\n}\n\nfunc TestAuthDelAllRecords(t *testing.T) {\n\tdelCount, err := adp.AuthDelAllRecords(types.ParseUserId(\"usr\" + testData.Recs[0].UserId))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif delCount != 1 {\n\t\tt.Error(mismatchErrorString(\"delCount\", delCount, 1))\n\t}\n\n\t// With dummy user\n\tdelCount, _ = adp.AuthDelAllRecords(dummyUid1)\n\tif delCount != 0 {\n\t\tt.Error(mismatchErrorString(\"delCount\", delCount, 0))\n\t}\n}\n\nfunc TestSubsDelForUser(t *testing.T) {\n\t// Tested during TestUserDelete (both hard and soft deletions)\n}\n\nfunc TestMessageDeleteList(t *testing.T) {\n\ttoDel := types.DelMessage{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tId:        testData.UGen.GetStr(),\n\t\t\tCreatedAt: testData.Now,\n\t\t\tUpdatedAt: testData.Now,\n\t\t},\n\t\tTopic:       testData.Topics[1].Id,\n\t\tDeletedFor:  testData.Users[2].Id,\n\t\tDelId:       1,\n\t\tSeqIdRanges: []types.Range{{Low: 9}, {Low: 3, Hi: 7}},\n\t}\n\terr := adp.MessageDeleteList(toDel.Topic, &toDel)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Check messages in dellog\n\tvar count int\n\terr = db.QueryRow(\"SELECT COUNT(*) FROM dellog WHERE topic=? AND deletedfor=?\",\n\t\ttoDel.Topic, decodeUid(toDel.DeletedFor)).Scan(&count)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count == 0 {\n\t\tt.Error(\"No dellog entries created\")\n\t}\n\n\t// Hard delete test\n\ttoDel = types.DelMessage{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tId:        testData.UGen.GetStr(),\n\t\t\tCreatedAt: testData.Now,\n\t\t\tUpdatedAt: testData.Now,\n\t\t},\n\t\tTopic:       testData.Topics[0].Id,\n\t\tDelId:       3,\n\t\tSeqIdRanges: []types.Range{{Low: 1, Hi: 3}},\n\t}\n\terr = adp.MessageDeleteList(toDel.Topic, &toDel)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Check if messages content was cleared\n\terr = db.QueryRow(\"SELECT COUNT(*) FROM messages WHERE topic=? AND content IS NOT NULL\",\n\t\ttoDel.Topic).Scan(&count)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count > 1 {\n\t\tt.Errorf(\"Messages not properly deleted %d, %s\", count, toDel.Topic)\n\t}\n\n\terr = adp.MessageDeleteList(testData.Topics[0].Id, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = db.QueryRow(\"SELECT COUNT(*) FROM messages WHERE topic=?\", testData.Topics[0].Id).Scan(&count)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count != 0 {\n\t\tt.Error(\"Result should be empty:\", count)\n\t}\n}\n\nfunc TestTopicDelete(t *testing.T) {\n\terr := adp.TopicDelete(testData.Topics[1].Id, false, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar state int\n\terr = db.QueryRow(\"SELECT state FROM topics WHERE name=?\", testData.Topics[1].Id).Scan(&state)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif state != int(types.StateDeleted) {\n\t\tt.Error(\"Soft delete failed:\", state)\n\t}\n\n\terr = adp.TopicDelete(testData.Topics[0].Id, false, true)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tvar count int\n\terr = db.QueryRow(\"SELECT COUNT(*) FROM topics WHERE name=?\", testData.Topics[0].Id).Scan(&count)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count != 0 {\n\t\tt.Error(\"Hard delete failed:\", count)\n\t}\n}\n\nfunc TestFileDeleteUnused(t *testing.T) {\n\tlocs, err := adp.FileDeleteUnused(time.Now().Add(1*time.Minute), 999)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(locs) != 2 {\n\t\tt.Error(mismatchErrorString(\"Locations length\", len(locs), 2))\n\t}\n}\n\nfunc TestUserDelete(t *testing.T) {\n\terr := adp.UserDelete(types.ParseUserId(\"usr\"+testData.Users[0].Id), false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar state int\n\terr = db.QueryRow(\"SELECT state FROM users WHERE id=?\",\n\t\tdecodeUid(testData.Users[0].Id)).Scan(&state)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif state != int(types.StateDeleted) {\n\t\tt.Error(\"User soft delete failed\", state)\n\t}\n\n\terr = adp.UserDelete(types.ParseUserId(\"usr\"+testData.Users[1].Id), true)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar count int\n\terr = db.QueryRow(\"SELECT COUNT(*) FROM users WHERE id=?\", testData.Users[1].Id).Scan(&count)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count != 0 {\n\t\tt.Error(\"User hard delete failed\")\n\t}\n}\n\n// ================== Other tests =================================\n\nfunc TestUserUnreadCount(t *testing.T) {\n\tuids := []types.Uid{\n\t\ttypes.ParseUserId(\"usr\" + testData.Users[1].Id),\n\t\ttypes.ParseUserId(\"usr\" + testData.Users[2].Id),\n\t}\n\texpected := map[types.Uid]int{uids[0]: 0, uids[1]: 166}\n\tcounts, err := adp.UserUnreadCount(uids...)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(counts) != 2 {\n\t\tt.Error(mismatchErrorString(\"UnreadCount length\", len(counts), 2))\n\t}\n\n\tfor uid, unread := range counts {\n\t\tif expected[uid] != unread {\n\t\t\tt.Error(mismatchErrorString(\"UnreadCount\", unread, expected[uid]))\n\t\t}\n\t}\n\n\t// Test not found (even if the account is not found, the call must return one record).\n\tcounts, err = adp.UserUnreadCount(dummyUid1)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(counts) != 1 {\n\t\tt.Error(mismatchErrorString(\"UnreadCount length (dummy)\", len(counts), 1))\n\t}\n\tif counts[dummyUid1] != 0 {\n\t\tt.Error(mismatchErrorString(\"Non-zero UnreadCount (dummy)\", counts[dummyUid1], 0))\n\t}\n}\n\nfunc TestMessageGetDeleted(t *testing.T) {\n\tqOpts := types.QueryOpt{\n\t\tSince:  1,\n\t\tBefore: 10,\n\t\tLimit:  999,\n\t}\n\tgot, err := adp.MessageGetDeleted(testData.Topics[1].Id, types.ParseUserId(\"usr\"+testData.Users[2].Id), &qOpts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(got) != 1 {\n\t\tt.Error(mismatchErrorString(\"result length\", len(got), 1))\n\t}\n}\n\n// ================================================================\nfunc mismatchErrorString(key string, got, want any) string {\n\treturn fmt.Sprintf(\"%s mismatch:\\nGot  = %+v\\nWant = %+v\", key, got, want)\n}\n\nfunc init() {\n\tlogs.Init(os.Stderr, \"stdFlags\")\n\tadp = backend.GetTestAdapter()\n\tconffile := flag.String(\"config\", \"./test.conf\", \"config of the database connection\")\n\n\tif file, err := os.Open(*conffile); err != nil {\n\t\tlog.Fatal(\"Failed to read config file:\", err)\n\t} else if err = json.NewDecoder(jcr.New(file)).Decode(&config); err != nil {\n\t\tlog.Fatal(\"Failed to parse config file:\", err)\n\t}\n\n\tif adp == nil {\n\t\tlog.Fatal(\"Database adapter is missing\")\n\t}\n\tif adp.IsOpen() {\n\t\tlog.Print(\"Connection is already opened\")\n\t}\n\n\terr := adp.Open(config.Adapters[adp.GetName()])\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\tdb = adp.GetTestDB().(*sqlx.DB)\n\ttestData = test_data.InitTestData()\n\tif testData == nil {\n\t\tlog.Fatal(\"Failed to initialize test data\")\n\t}\n\tstore.SetTestUidGenerator(*testData.UGen)\n}\n"
  },
  {
    "path": "server/db/mysql/tests/test.conf",
    "content": "{\n  \"reset_db_data\": true,\n  \"adapters\": {\n    \"mysql\": {\n\t\t\t\t\"dsn\": \"root@tcp(localhost:3306)/tinode_test?parseTime=true&collation=utf8mb4_unicode_ci\",\n\t\t\t\t// Name of the main database.\n\t\t\t\t\"database\": \"tinode_test\"\n    }\n  }\n}\n"
  },
  {
    "path": "server/db/postgres/adapter.go",
    "content": "//go:build postgres\n// +build postgres\n\n// Package postgres is a database adapter for PostgreSQL.\npackage postgres\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"hash/fnv\"\n\t\"log\"\n\t\"net/url\"\n\t\"reflect\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/jackc/pgconn\"\n\t\"github.com/jackc/pgx/v4\"\n\t\"github.com/jackc/pgx/v4/pgxpool\"\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/tinode/chat/server/auth\"\n\t\"github.com/tinode/chat/server/db/common\"\n\t\"github.com/tinode/chat/server/store\"\n\tt \"github.com/tinode/chat/server/store/types\"\n)\n\n// adapter holds MySQL connection data.\ntype adapter struct {\n\tdb         *pgxpool.Pool\n\tpoolConfig *pgxpool.Config\n\tdsn        string\n\tdbName     string\n\t// Maximum number of records to return\n\tmaxResults int\n\t// Maximum number of message records to return\n\tmaxMessageResults int\n\tversion           int\n\n\t// Single query timeout.\n\tsqlTimeout time.Duration\n\t// DB transaction timeout.\n\ttxTimeout time.Duration\n}\n\nconst (\n\tadpVersion  = 116\n\tadapterName = \"postgres\"\n\n\tdefaultMaxResults = 1024\n\t// This is capped by the Session's send queue limit (128).\n\tdefaultMaxMessageResults = 100\n\n\t// If DB request timeout is specified,\n\t// we allocate txTimeoutMultiplier times more time for transactions.\n\ttxTimeoutMultiplier = 1.5\n)\n\ntype configType struct {\n\t// DB connection settings:\n\t// Using fields\n\tUser   string `json:\"user,omitempty\"`\n\tPasswd string `json:\"passwd,omitempty\"`\n\tHost   string `json:\"host,omitempty\"`\n\tPort   string `json:\"port,omitempty\"`\n\tDBName string `json:\"dbname,omitempty\"`\n\t// Deprecated.\n\tDSN string `json:\"dsn,omitempty\"`\n\n\t// Connection pool settings.\n\t//\n\t// Maximum number of open connections to the database.\n\tMaxOpenConns int `json:\"max_open_conns,omitempty\"`\n\t// Maximum number of connections in the idle connection pool.\n\tMaxIdleConns int `json:\"max_idle_conns,omitempty\"`\n\t// Maximum amount of time a connection may be reused (in seconds).\n\tConnMaxLifetime int `json:\"conn_max_lifetime,omitempty\"`\n\n\t// SSL mode determines how SSL connections are handled.\n\t// Supported values:\n\t//   - \"disable\": No SSL connection (default)\n\t//   - \"require\": Require SSL connection but don't verify server certificate\n\t//   - \"verify-ca\": Require SSL and verify that the server certificate is issued by a trusted CA\n\t//   - \"verify-full\": Require SSL and verify that the server certificate matches the server hostname\n\t//   - \"prefer\": Try SSL first, fallback to non-SSL if SSL fails\n\t//   - \"allow\": Try non-SSL first, fallback to SSL if non-SSL fails\n\tSSLMode string `json:\"ssl_mode,omitempty\"`\n\n\t// DB request timeout (in seconds).\n\t// If 0 (or negative), no timeout is applied.\n\tSqlTimeout int `json:\"sql_timeout,omitempty\"`\n}\n\nfunc (a *adapter) getContext() (context.Context, context.CancelFunc) {\n\tif a.sqlTimeout > 0 {\n\t\treturn context.WithTimeout(context.Background(), a.sqlTimeout)\n\t}\n\treturn context.Background(), nil\n}\n\nfunc (a *adapter) getContextForTx() (context.Context, context.CancelFunc) {\n\tif a.txTimeout > 0 {\n\t\treturn context.WithTimeout(context.Background(), a.txTimeout)\n\t}\n\treturn context.Background(), nil\n}\n\n// Open initializes database session\nfunc (a *adapter) Open(jsonconfig json.RawMessage) error {\n\tif a.db != nil {\n\t\treturn errors.New(\"postgres adapter is already connected\")\n\t}\n\n\tif len(jsonconfig) < 2 {\n\t\treturn errors.New(\"postgres adapter missing config\")\n\t}\n\n\tvar err error\n\tvar config configType\n\tctx := context.Background()\n\tif err = json.Unmarshal(jsonconfig, &config); err != nil {\n\t\treturn errors.New(\"postgres adapter failed to parse config: \" + err.Error())\n\t}\n\n\tif config.DSN != \"\" {\n\t\ta.dsn = config.DSN\n\t\tif uri, err := url.Parse(a.dsn); err == nil {\n\t\t\ta.dbName = strings.TrimPrefix(uri.Path, \"/\")\n\t\t} else {\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\tif a.dsn, err = setConnStr(config); err != nil {\n\t\t\treturn err\n\t\t}\n\t\ta.dbName = config.DBName\n\t}\n\n\tif a.maxResults <= 0 {\n\t\ta.maxResults = defaultMaxResults\n\t}\n\n\tif a.maxMessageResults <= 0 {\n\t\ta.maxMessageResults = defaultMaxMessageResults\n\t}\n\n\tif a.poolConfig, err = pgxpool.ParseConfig(a.dsn); err != nil {\n\t\treturn errors.New(\"postgres adapter failed to parse DSN: \" + err.Error())\n\t}\n\n\t// ConnectConfig creates a new Pool and immediately establishes one connection.\n\ta.db, err = pgxpool.ConnectConfig(ctx, a.poolConfig)\n\tif isMissingDb(err) {\n\t\t// Missing DB is OK if we are initializing the database.\n\t\t// Since tinode DB does not exist, connect without specifying the DB name.\n\t\ta.poolConfig.ConnConfig.Database = \"\"\n\t\ta.db, err = pgxpool.ConnectConfig(ctx, a.poolConfig)\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Actually opening the network connection if one was not opened earlier.\n\tif a.poolConfig.LazyConnect {\n\t\terr = a.db.Ping(ctx)\n\t}\n\n\tif err == nil {\n\t\tif config.MaxOpenConns > 0 {\n\t\t\ta.poolConfig.MaxConns = int32(config.MaxOpenConns)\n\t\t}\n\t\tif config.MaxIdleConns > 0 {\n\t\t\ta.poolConfig.MinConns = int32(config.MaxIdleConns)\n\t\t}\n\t\tif config.ConnMaxLifetime > 0 {\n\t\t\ta.poolConfig.MaxConnLifetime = time.Duration(config.ConnMaxLifetime) * time.Second\n\t\t}\n\t\tif config.SqlTimeout > 0 {\n\t\t\ta.sqlTimeout = time.Duration(config.SqlTimeout) * time.Second\n\t\t\t// We allocate txTimeoutMultiplier times sqlTimeout for transactions.\n\t\t\ta.txTimeout = time.Duration(float64(config.SqlTimeout)*txTimeoutMultiplier) * time.Second\n\t\t}\n\t}\n\treturn err\n}\n\n// Close closes the underlying database connection\nfunc (a *adapter) Close() error {\n\tif a.db != nil {\n\t\ta.db.Close()\n\t\ta.db = nil\n\t\ta.version = -1\n\t}\n\treturn nil\n}\n\n// IsOpen returns true if connection to database has been established. It does not check if\n// connection is actually live.\nfunc (a *adapter) IsOpen() bool {\n\treturn a.db != nil\n}\n\n// GetDbVersion returns current database version.\nfunc (a *adapter) GetDbVersion() (int, error) {\n\tif a.version > 0 {\n\t\treturn a.version, nil\n\t}\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tvar vers string\n\terr := a.db.QueryRow(ctx, \"SELECT value FROM kvmeta WHERE key='version'\").Scan(&vers)\n\tif err != nil {\n\t\tif isMissingDb(err) || isMissingTable(err) || err == pgx.ErrNoRows {\n\t\t\terr = errors.New(\"Database not initialized\")\n\t\t}\n\t\treturn -1, err\n\t}\n\n\ta.version, _ = strconv.Atoi(vers)\n\n\treturn a.version, nil\n}\n\nfunc (a *adapter) updateDbVersion(v int) error {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ta.version = -1\n\tif _, err := a.db.Exec(ctx, `UPDATE kvmeta SET \"value\"=$1 WHERE \"key\"='version'`, strconv.Itoa(v)); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// CheckDbVersion checks whether the actual DB version matches the expected version of this adapter.\nfunc (a *adapter) CheckDbVersion() error {\n\tversion, err := a.GetDbVersion()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif version != adpVersion {\n\t\treturn errors.New(\"Invalid database version \" + strconv.Itoa(version) +\n\t\t\t\". Expected \" + strconv.Itoa(adpVersion))\n\t}\n\n\treturn nil\n}\n\n// Version returns adapter version.\nfunc (adapter) Version() int {\n\treturn adpVersion\n}\n\n// DB connection stats object.\nfunc (a *adapter) Stats() any {\n\tif a.db == nil {\n\t\treturn nil\n\t}\n\treturn a.db.Stat()\n}\n\n// GetName returns string that adapter uses to register itself with store.\nfunc (a *adapter) GetName() string {\n\treturn adapterName\n}\n\n// SetMaxResults configures how many results can be returned in a single DB call.\nfunc (a *adapter) SetMaxResults(val int) error {\n\tif val <= 0 {\n\t\ta.maxResults = defaultMaxResults\n\t} else {\n\t\ta.maxResults = val\n\t}\n\n\treturn nil\n}\n\n// CreateDb initializes the storage.\nfunc (a *adapter) CreateDb(reset bool) error {\n\tvar err error\n\tvar tx pgx.Tx\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\t// Can't use an existing connection because it's configured with a database name which may not exist.\n\t// Don't care if it does not close cleanly.\n\tif a.db != nil {\n\t\ta.db.Close()\n\t}\n\n\t// Create default database name\n\ta.poolConfig.ConnConfig.Database = \"postgres\"\n\n\ta.db, err = pgxpool.ConnectConfig(ctx, a.poolConfig)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif reset {\n\t\tif _, err = a.db.Exec(ctx, fmt.Sprintf(\"DROP DATABASE IF EXISTS %s;\", a.dbName)); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif _, err = a.db.Exec(ctx, fmt.Sprintf(\"CREATE DATABASE %s WITH ENCODING utf8;\", a.dbName)); err != nil {\n\t\treturn err\n\t}\n\n\ta.poolConfig.ConnConfig.Database = a.dbName\n\ta.db, err = pgxpool.ConnectConfig(ctx, a.poolConfig)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif tx, err = a.db.Begin(ctx); err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\t// FIXME: This is useless: MySQL auto-commits on every CREATE TABLE.\n\t\t\t// Maybe DROP DATABASE instead.\n\t\t\ttx.Rollback(ctx)\n\t\t}\n\t}()\n\n\t// Indexed users.\n\tif _, err := tx.Exec(ctx,\n\t\t`CREATE TABLE users(\n\t\t\tid        BIGINT NOT NULL,\n\t\t\tcreatedat TIMESTAMP(3) NOT NULL,\n\t\t\tupdatedat TIMESTAMP(3) NOT NULL,\n\t\t\tstate     SMALLINT NOT NULL DEFAULT 0,\n\t\t\tstateat   TIMESTAMP(3),\n\t\t\taccess    JSON,\n\t\t\tlastseen  TIMESTAMP,\n\t\t\tuseragent VARCHAR(255) DEFAULT '',\n\t\t\tpublic    JSON,\n\t\t\ttrusted   JSON,\n\t\t\ttags      JSON,\n\t\t\tPRIMARY KEY(id)\n\t\t);\n\t\tCREATE INDEX users_state_stateat ON users(state, stateat);\n\t\tCREATE INDEX users_lastseen_updatedat ON users(lastseen, updatedat);`); err != nil {\n\t\treturn err\n\t}\n\n\t// Indexed user tags.\n\tif _, err = tx.Exec(ctx,\n\t\t`CREATE TABLE usertags(\n\t\t\tid     SERIAL NOT NULL,\n\t\t\tuserid BIGINT NOT NULL,\n\t\t\ttag    VARCHAR(96) NOT NULL,\n\t\t\tPRIMARY KEY(id),\n\t\t\tFOREIGN KEY(userid) REFERENCES users(id)\n\t\t);\n\t\tCREATE INDEX usertags_tag ON usertags(tag);\n\t\tCREATE UNIQUE INDEX usertags_userid_tag ON usertags(userid, tag);`); err != nil {\n\t\treturn err\n\t}\n\n\t// Indexed devices. Normalized into a separate table.\n\tif _, err = tx.Exec(ctx,\n\t\t`CREATE TABLE devices(\n\t\t\tid       SERIAL NOT NULL,\n\t\t\tuserid   BIGINT NOT NULL,\n\t\t\thash     CHAR(16) NOT NULL,\n\t\t\tdeviceid TEXT NOT NULL,\n\t\t\tplatform VARCHAR(32),\n\t\t\tlastseen TIMESTAMP NOT NULL,\n\t\t\tlang     VARCHAR(8),\n\t\t\tPRIMARY KEY(id),\n\t\t\tFOREIGN KEY(userid) REFERENCES users(id)\n\t\t);\n\t\tCREATE UNIQUE INDEX devices_hash ON devices(hash);`); err != nil {\n\t\treturn err\n\t}\n\n\t// Authentication records for the basic authentication scheme.\n\tif _, err = tx.Exec(ctx,\n\t\t`CREATE TABLE auth(\n\t\t\tid      SERIAL NOT NULL,\n\t\t\tuname   VARCHAR(32) NOT NULL,\n\t\t\tuserid  BIGINT NOT NULL,\n\t\t\tscheme  VARCHAR(16) NOT NULL,\n\t\t\tauthlvl INT NOT NULL,\n\t\t\tsecret  VARCHAR(255) NOT NULL,\n\t\t\texpires TIMESTAMP,\n\t\t\tPRIMARY KEY(id),\n\t\t\tFOREIGN KEY(userid) REFERENCES users(id)\n\t\t);\n\t\tCREATE UNIQUE INDEX auth_userid_scheme ON auth(userid, scheme);\n\t\tCREATE UNIQUE INDEX auth_uname ON auth(uname);`); err != nil {\n\t\treturn err\n\t}\n\n\t// Topics\n\tif _, err = tx.Exec(ctx,\n\t\t`CREATE TABLE topics(\n\t\t\tid        SERIAL NOT NULL,\n\t\t\tcreatedat TIMESTAMP(3) NOT NULL,\n\t\t\tupdatedat TIMESTAMP(3) NOT NULL,\n\t\t\tstate     SMALLINT NOT NULL DEFAULT 0,\n\t\t\tstateat   TIMESTAMP(3),\n\t\t\ttouchedat TIMESTAMP(3),\n\t\t\tname      VARCHAR(25) NOT NULL,\n\t\t\tusebt     BOOLEAN DEFAULT FALSE,\n\t\t\towner     BIGINT NOT NULL DEFAULT 0,\n\t\t\taccess    JSON,\n\t\t\tseqid     INT NOT NULL DEFAULT 0,\n\t\t\tdelid     INT DEFAULT 0,\n\t\t\tsubcnt    INT DEFAULT 0,\n\t\t\tpublic    JSON,\n\t\t\ttrusted   JSON,\n\t\t\ttags      JSON,\n\t\t\taux\t\t\t\tJSON,\n\t\t\tPRIMARY KEY(id)\n\t\t);\n\t\tCREATE UNIQUE INDEX topics_name ON topics(name);\n\t\tCREATE INDEX topics_owner ON topics(owner);\n\t\tCREATE INDEX topics_state_stateat ON topics(state, stateat);\n\t\tCREATE INDEX topics_name_state_seqid ON topics(name, state, seqid);`); err != nil {\n\t\treturn err\n\t}\n\n\t// Create system topic 'sys'.\n\tif err = createSystemTopic(tx); err != nil {\n\t\treturn err\n\t}\n\n\t// Indexed topic tags.\n\tif _, err = tx.Exec(ctx,\n\t\t`CREATE TABLE topictags(\n\t\t\tid    SERIAL NOT NULL,\n\t\t\ttopic VARCHAR(25) NOT NULL,\n\t\t\ttag   VARCHAR(96) NOT NULL,\n\t\t\tPRIMARY KEY(id),\n\t\t\tFOREIGN KEY(topic) REFERENCES topics(name)\n\t\t);\n\t\tCREATE INDEX topictags_tag ON topictags(tag);\n\t\tCREATE UNIQUE INDEX topictags_topic_tag ON topictags(topic, tag);`); err != nil {\n\t\treturn err\n\t}\n\n\t// Subscriptions\n\tif _, err = tx.Exec(ctx,\n\t\t`CREATE TABLE subscriptions(\n\t\t\tid        SERIAL NOT NULL,\n\t\t\tcreatedat TIMESTAMP(3) NOT NULL,\n\t\t\tupdatedat TIMESTAMP(3) NOT NULL,\n\t\t\tdeletedat TIMESTAMP(3),\n\t\t\tuserid    BIGINT NOT NULL,\n\t\t\ttopic     VARCHAR(25) NOT NULL,\n\t\t\tdelid     INT DEFAULT 0,\n\t\t\trecvseqid INT DEFAULT 0,\n\t\t\treadseqid INT DEFAULT 0,\n\t\t\tmodewant  VARCHAR(8),\n\t\t\tmodegiven VARCHAR(8),\n\t\t\tprivate   JSON,\n\t\t\tPRIMARY KEY(id),\n\t\t\tFOREIGN KEY(userid) REFERENCES users(id)\n\t\t);\n\t\tCREATE UNIQUE INDEX subscriptions_topic_userid ON subscriptions(topic, userid);\n\t\tCREATE INDEX subscriptions_topic ON subscriptions(topic);\n\t\tCREATE INDEX subscriptions_deletedat ON subscriptions(deletedat);\n\t\tCREATE INDEX subscriptions_userid_topic_deletedat ON subscriptions(userid, topic, deletedat);`); err != nil {\n\t\treturn err\n\t}\n\n\t// Messages\n\tif _, err = tx.Exec(ctx,\n\t\t`CREATE TABLE messages(\n\t\t\tid        SERIAL NOT NULL,\n\t\t\tcreatedat TIMESTAMP(3) NOT NULL,\n\t\t\tupdatedat TIMESTAMP(3) NOT NULL,\n\t\t\tdeletedat TIMESTAMP(3),\n\t\t\tdelid     INT DEFAULT 0,\n\t\t\tseqid     INT NOT NULL,\n\t\t\ttopic     VARCHAR(25) NOT NULL,\n\t\t\t\"from\"    BIGINT NOT NULL,\n\t\t\thead      JSON,\n\t\t\tcontent   JSON,\n\t\t\tPRIMARY KEY(id),\n\t\t\tFOREIGN KEY(topic) REFERENCES topics(name)\n\t\t);\n\t\tCREATE UNIQUE INDEX messages_topic_seqid ON messages(topic, seqid);`); err != nil {\n\t\treturn err\n\t}\n\n\t// Deletion log\n\tif _, err = tx.Exec(ctx,\n\t\t`CREATE TABLE dellog(\n\t\t\tid         SERIAL NOT NULL,\n\t\t\ttopic      VARCHAR(25) NOT NULL,\n\t\t\tdeletedfor BIGINT NOT NULL DEFAULT 0,\n\t\t\tdelid      INT NOT NULL,\n\t\t\tlow        INT NOT NULL,\n\t\t\thi         INT NOT NULL,\n\t\t\tPRIMARY KEY(id),\n\t\t\tFOREIGN KEY(topic) REFERENCES topics(name)\n\t\t);\n\t\tCREATE INDEX dellog_topic_delid_deletedfor ON dellog(topic,delid,deletedfor);\n\t\tCREATE INDEX dellog_topic_deletedfor_low_hi ON dellog(topic,deletedfor,low,hi);\n\t\tCREATE INDEX dellog_deletedfor ON dellog(deletedfor);`); err != nil {\n\t\treturn err\n\t}\n\n\t// User credentials\n\tif _, err = tx.Exec(ctx,\n\t\t`CREATE TABLE credentials(\n\t\t\tid        SERIAL NOT NULL,\n\t\t\tcreatedat TIMESTAMP(3) NOT NULL,\n\t\t\tupdatedat TIMESTAMP(3) NOT NULL,\n\t\t\tdeletedat TIMESTAMP(3),\n\t\t\tmethod    VARCHAR(16) NOT NULL,\n\t\t\tvalue     VARCHAR(128) NOT NULL,\n\t\t\tsynthetic VARCHAR(192) NOT NULL,\n\t\t\tuserid    BIGINT NOT NULL,\n\t\t\tresp      VARCHAR(255),\n\t\t\tdone      BOOLEAN NOT NULL DEFAULT FALSE,\n\t\t\tretries   INT NOT NULL DEFAULT 0,\n\t\t\tPRIMARY KEY(id),\n\t\t\tFOREIGN KEY(userid) REFERENCES users(id)\n\t\t);\n\t\tCREATE UNIQUE INDEX credentials_uniqueness ON credentials(synthetic);`); err != nil {\n\t\treturn err\n\t}\n\n\t// Records of uploaded files.\n\t// Don't add FOREIGN KEY on userid. It's not needed and it will break user deletion.\n\tif _, err = tx.Exec(ctx,\n\t\t`CREATE TABLE fileuploads(\n\t\t\tid        BIGINT NOT NULL,\n\t\t\tcreatedat TIMESTAMP(3) NOT NULL,\n\t\t\tupdatedat TIMESTAMP(3) NOT NULL,\n\t\t\tuserid    BIGINT,\n\t\t\tstatus    INT NOT NULL,\n\t\t\tmimetype  VARCHAR(255) NOT NULL,\n\t\t\tsize      BIGINT NOT NULL,\n\t\t\tetag      VARCHAR(128),\n\t\t\tlocation  VARCHAR(2048) NOT NULL,\n\t\t\tPRIMARY KEY(id)\n\t\t);\n\t\tCREATE INDEX fileuploads_status ON fileuploads(status);`); err != nil {\n\t\treturn err\n\t}\n\n\t// Links between uploaded files and the topics, users or messages they are attached to.\n\tif _, err = tx.Exec(ctx,\n\t\t`CREATE TABLE filemsglinks(\n\t\t\tid        SERIAL NOT NULL,\n\t\t\tcreatedat TIMESTAMP(3) NOT NULL,\n\t\t\tfileid    BIGINT NOT NULL,\n\t\t\tmsgid     INT,\n\t\t\ttopic     VARCHAR(25),\n\t\t\tuserid    BIGINT,\n\t\t\tPRIMARY KEY(id),\n\t\t\tFOREIGN KEY(fileid) REFERENCES fileuploads(id) ON DELETE CASCADE,\n\t\t\tFOREIGN KEY(msgid) REFERENCES messages(id) ON DELETE CASCADE,\n\t\t\tFOREIGN KEY(topic) REFERENCES topics(name) ON DELETE CASCADE,\n\t\t\tFOREIGN KEY(userid) REFERENCES users(id) ON DELETE CASCADE\n\t\t);`); err != nil {\n\t\treturn err\n\t}\n\n\tif _, err = tx.Exec(ctx,\n\t\t`CREATE TABLE kvmeta(\n\t\t\t\"key\"     VARCHAR(64) NOT NULL,\n\t\t\tcreatedat TIMESTAMP(3),\n\t\t\t\"value\"   TEXT,\n\t\t\tPRIMARY KEY(\"key\")\n\t\t);\n\t\tCREATE INDEX kvmeta_createdat_key ON kvmeta(createdat, \"key\");`); err != nil {\n\t\treturn err\n\t}\n\tif _, err = tx.Exec(ctx, `INSERT INTO kvmeta(\"key\", \"value\") VALUES($1, $2)`, \"version\", strconv.Itoa(adpVersion)); err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit(ctx)\n}\n\n// UpgradeDb upgrades the database, if necessary.\nfunc (a *adapter) UpgradeDb() error {\n\tbumpVersion := func(a *adapter, x int) error {\n\t\tif err := a.updateDbVersion(x); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err := a.GetDbVersion()\n\t\treturn err\n\t}\n\n\tif _, err := a.GetDbVersion(); err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\tif a.version == 112 {\n\t\t// Perform database upgrade from version 112 to version 113.\n\n\t\t// Index for deleting unvalidated accounts.\n\t\tif _, err := a.db.Exec(ctx, \"CREATE INDEX users_lastseen_updatedat ON users(lastseen,updatedat)\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Allow lnger kvmeta keys.\n\t\tif _, err := a.db.Exec(ctx, `ALTER TABLE kvmeta ALTER COLUMN \"key\" TYPE VARCHAR(64)`); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err := a.db.Exec(ctx, `ALTER TABLE kvmeta ALTER COLUMN \"key\" SET NOT NULL`); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Add timestamp to kvmeta.\n\t\tif _, err := a.db.Exec(ctx, `ALTER TABLE kvmeta ADD COLUMN createdat TIMESTAMP(3)`); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Add compound index on the new field and key (could be searched by key prefix).\n\t\tif _, err := a.db.Exec(ctx, `CREATE INDEX kvmeta_createdat_key ON kvmeta(createdat, \"key\")`); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := bumpVersion(a, 113); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version == 113 {\n\t\t// Perform database upgrade from version 113 to version 114.\n\n\t\tif _, err := a.db.Exec(ctx, \"ALTER TABLE topics ADD COLUMN aux JSON\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err := a.db.Exec(ctx, \"ALTER TABLE fileuploads ADD COLUMN etag VARCHAR(128)\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := bumpVersion(a, 114); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version == 114 {\n\t\t// Perform database upgrade from version 114 to version 115.\n\n\t\t// Find relevant subscriptions for given users efficiently, and use the join key too.\n\t\tif _, err := a.db.Exec(ctx, \"CREATE INDEX idx_subs_user_topic_del ON subscriptions(userid, topic, deletedat)\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Optimizes join; state filters; seqid supports the SUM operation.\n\t\tif _, err := a.db.Exec(ctx, \"CREATE INDEX idx_topics_name_state_seqid ON topics(name, state, seqid)\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := bumpVersion(a, 115); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version == 115 {\n\t\t// Perform database upgrade from version 115 to version 116.\n\n\t\t// Add subscriber count column to the topics table.\n\t\tif _, err := a.db.Exec(ctx, \"ALTER TABLE topics ADD subcnt INT DEFAULT 0\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := bumpVersion(a, 116); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version != adpVersion {\n\t\treturn errors.New(\"Failed to perform database upgrade to version \" + strconv.Itoa(adpVersion) +\n\t\t\t\". DB is still at \" + strconv.Itoa(a.version))\n\t}\n\treturn nil\n}\n\nfunc createSystemTopic(tx pgx.Tx) error {\n\tnow := t.TimeNow()\n\tquery := `INSERT INTO topics(createdat,updatedat,state,touchedat,name,access,public)\n\t\t\t\tVALUES($1,$2,$3,$4,'sys','{\"Auth\": \"N\",\"Anon\": \"N\"}','{\"fn\": \"System\"}')`\n\t_, err := tx.Exec(context.Background(), query, now, now, t.StateOK, now)\n\treturn err\n}\n\nfunc addTags(ctx context.Context, tx pgx.Tx, table, keyName string, keyVal any, tags []string, ignoreDups bool) error {\n\tif len(tags) == 0 {\n\t\treturn nil\n\t}\n\n\t//addTags(ctx, tx, \"usertags\", \"userid\", decoded_uid, add, reset == nil)\n\tsql := \"INSERT INTO \" + table + \" (\" + keyName + \",tag) VALUES($1,$2)\"\n\tif ignoreDups {\n\t\tsql += \" ON CONFLICT DO NOTHING\"\n\t}\n\tfor _, tag := range tags {\n\t\tif _, err := tx.Exec(ctx, sql, keyVal, tag); err != nil {\n\t\t\tif isDupe(err) {\n\t\t\t\treturn t.ErrDuplicate\n\t\t\t}\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc removeTags(ctx context.Context, tx pgx.Tx, table, keyName string, keyVal any, tags []string) error {\n\tif len(tags) == 0 {\n\t\treturn nil\n\t}\n\n\tsql, args := expandQuery(\"DELETE FROM \"+table+\" WHERE \"+keyName+\"=? AND tag IN (?)\", keyVal, tags)\n\t_, err := tx.Exec(ctx, sql, args...)\n\n\treturn err\n}\n\n// UserCreate creates a new user. Returns error and true if error is due to duplicate user name,\n// false for any other error\nfunc (a *adapter) UserCreate(user *t.User) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTx(ctx, pgx.TxOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback(ctx)\n\t\t}\n\t}()\n\n\tdecoded_uid := store.DecodeUid(user.Uid())\n\tif _, err = tx.Exec(ctx,\n\t\t\"INSERT INTO users(id,createdat,updatedat,state,access,public,trusted,tags) VALUES($1,$2,$3,$4,$5,$6,$7,$8);\",\n\t\tdecoded_uid,\n\t\tuser.CreatedAt,\n\t\tuser.UpdatedAt,\n\t\tuser.State,\n\t\tuser.Access,\n\t\tcommon.ToJSON(user.Public),\n\t\tcommon.ToJSON(user.Trusted),\n\t\tuser.Tags); err != nil {\n\t\treturn err\n\t}\n\n\t// Save user's tags to a separate table to make user findable.\n\tif err = addTags(ctx, tx, \"usertags\", \"userid\", decoded_uid, user.Tags, false); err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit(ctx)\n}\n\n// Add user's authentication record\nfunc (a *adapter) AuthAddRecord(uid t.Uid, scheme, unique string, authLvl auth.Level,\n\tsecret []byte, expires time.Time) error {\n\n\tvar exp *time.Time\n\tif !expires.IsZero() {\n\t\texp = &expires\n\t}\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\tif _, err := a.db.Exec(ctx, \"INSERT INTO auth(uname,userid,scheme,authLvl,secret,expires) VALUES($1,$2,$3,$4,$5,$6)\",\n\t\tunique, store.DecodeUid(uid), scheme, authLvl, secret, exp); err != nil {\n\t\tif isDupe(err) {\n\t\t\treturn t.ErrDuplicate\n\t\t}\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// AuthDelScheme deletes an existing authentication scheme for the user.\nfunc (a *adapter) AuthDelScheme(user t.Uid, scheme string) error {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\t_, err := a.db.Exec(ctx, \"DELETE FROM auth WHERE userid=$1 AND scheme=$2\", store.DecodeUid(user), scheme)\n\treturn err\n}\n\n// AuthDelAllRecords deletes all authentication records for the user.\nfunc (a *adapter) AuthDelAllRecords(user t.Uid) (int, error) {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\tres, err := a.db.Exec(ctx, \"DELETE FROM auth WHERE userid=$1\", store.DecodeUid(user))\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tcount := res.RowsAffected()\n\n\treturn int(count), nil\n}\n\n// Update user's authentication unique, secret, auth level.\nfunc (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string, authLvl auth.Level,\n\tsecret []byte, expires time.Time) error {\n\n\tparapg := []string{\"authLvl=?\"}\n\targs := []any{authLvl}\n\tif unique != \"\" {\n\t\tparapg = append(parapg, \"uname=?\")\n\t\targs = append(args, unique)\n\t}\n\tif len(secret) > 0 {\n\t\tparapg = append(parapg, \"secret=?\")\n\t\targs = append(args, secret)\n\t}\n\tif !expires.IsZero() {\n\t\tparapg = append(parapg, \"expires=?\")\n\t\targs = append(args, expires)\n\t}\n\targs = append(args, store.DecodeUid(uid), scheme)\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tsql, args := expandQuery(\"UPDATE auth SET \"+strings.Join(parapg, \",\")+\" WHERE userid=? AND scheme=?\", args...)\n\tresp, err := a.db.Exec(ctx, sql, args...)\n\tif isDupe(err) {\n\t\treturn t.ErrDuplicate\n\t}\n\n\tif count := resp.RowsAffected(); count <= 0 {\n\t\treturn t.ErrNotFound\n\t}\n\n\treturn err\n}\n\n// Retrieve user's authentication record\nfunc (a *adapter) AuthGetRecord(uid t.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) {\n\tvar expires time.Time\n\n\tvar record struct {\n\t\tUname   string\n\t\tAuthlvl auth.Level\n\t\tSecret  []byte\n\t\tExpires *time.Time\n\t}\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tif err := a.db.QueryRow(ctx, \"SELECT uname,secret,expires,authlvl FROM auth WHERE userid=$1 AND scheme=$2\",\n\t\tstore.DecodeUid(uid), scheme).Scan(\n\t\t&record.Uname, &record.Secret, &record.Expires, &record.Authlvl); err != nil {\n\t\tif err == pgx.ErrNoRows {\n\t\t\t// Nothing found - use standard error.\n\t\t\terr = t.ErrNotFound\n\t\t}\n\t\treturn \"\", 0, nil, expires, err\n\t}\n\n\tif record.Expires != nil {\n\t\texpires = *record.Expires\n\t}\n\n\treturn record.Uname, record.Authlvl, record.Secret, expires, nil\n}\n\n// Retrieve user's authentication record\nfunc (a *adapter) AuthGetUniqueRecord(unique string) (t.Uid, auth.Level, []byte, time.Time, error) {\n\tvar expires time.Time\n\n\tvar record struct {\n\t\tUserid  int64\n\t\tAuthlvl auth.Level\n\t\tSecret  []byte\n\t\tExpires *time.Time\n\t}\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tif err := a.db.QueryRow(ctx, \"SELECT userid,secret,expires,authlvl FROM auth WHERE uname=$1\", unique).Scan(\n\t\t&record.Userid, &record.Secret, &record.Expires, &record.Authlvl); err != nil {\n\t\tif err == pgx.ErrNoRows {\n\t\t\t// Nothing found - clear the error\n\t\t\terr = nil\n\t\t}\n\t\treturn t.ZeroUid, 0, nil, expires, err\n\t}\n\n\tif record.Expires != nil {\n\t\texpires = *record.Expires\n\t}\n\n\treturn store.EncodeUid(record.Userid), record.Authlvl, record.Secret, expires, nil\n}\n\n// UserGet fetches a single user by user id. If user is not found it returns (nil, nil)\nfunc (a *adapter) UserGet(uid t.Uid) (*t.User, error) {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\tvar user t.User\n\tvar id int64\n\trow, err := a.db.Query(ctx, \"SELECT * FROM users WHERE id=$1 AND state!=$2\", store.DecodeUid(uid), t.StateDeleted)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer row.Close()\n\n\tif !row.Next() {\n\t\t// Nothing found: user does not exist or marked as soft-deleted\n\t\treturn nil, nil\n\t}\n\n\terr = row.Scan(&id, &user.CreatedAt, &user.UpdatedAt, &user.State, &user.StateAt, &user.Access, &user.LastSeen, &user.UserAgent, &user.Public, &user.Trusted, &user.Tags)\n\tif err == nil {\n\t\tuser.SetUid(uid)\n\t\treturn &user, nil\n\t}\n\n\treturn nil, err\n}\n\nfunc (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) {\n\tuids := make([]any, len(ids))\n\tfor i, id := range ids {\n\t\tuids[i] = store.DecodeUid(id)\n\t}\n\n\tusers := []t.User{}\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\trows, err := a.db.Query(ctx, \"SELECT * FROM users WHERE id = ANY ($1) AND state!=$2\", uids, t.StateDeleted)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tvar user t.User\n\t\tvar id int64\n\t\tif err = rows.Scan(&id, &user.CreatedAt, &user.UpdatedAt, &user.State, &user.StateAt, &user.Access, &user.LastSeen, &user.UserAgent, &user.Public, &user.Trusted, &user.Tags); err != nil {\n\t\t\tusers = nil\n\t\t\tbreak\n\t\t}\n\t\tuser.SetUid(store.EncodeUid(id))\n\n\t\tusers = append(users, user)\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\treturn users, err\n}\n\n// UserDelete deletes specified user: wipes completely (hard-delete) or marks as deleted.\n// TODO: report when the user is not found.\nfunc (a *adapter) UserDelete(uid t.Uid, hard bool) error {\n\tquery := \"SELECT name FROM topics WHERE owner=$1\"\n\targs := []any{store.DecodeUid(uid)}\n\t// In case of hard delete, delete all topics, even those which were\n\t// soft-deleted previsously.\n\tif !hard {\n\t\tquery += \" AND state!=$2\"\n\t\targs = append(args, t.StateDeleted)\n\t}\n\t// Get a list of topic names owned by the user (as 'grp' and 'chn').\n\townTopics, err := a.topicNamesForUser(query, false, args...)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTx(ctx, pgx.TxOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback(ctx)\n\t\t}\n\t}()\n\n\tnow := t.TimeNow()\n\tdecoded_uid := store.DecodeUid(uid)\n\n\tif hard {\n\t\t// Delete user's devices\n\t\t// t.ErrNotFound = user has no devices.\n\t\tif err = deviceDelete(ctx, tx, uid, \"\"); err != nil && err != t.ErrNotFound {\n\t\t\treturn err\n\t\t}\n\n\t\t// Delete user's subscriptions in all topics.\n\t\tif err = subsDelForUser(ctx, tx, decoded_uid, true); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Delete records of messages soft-deleted for the user.\n\t\tif _, err = tx.Exec(ctx, \"DELETE FROM dellog WHERE deletedfor=$1\", decoded_uid); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Can't delete user's messages in all topics because we cannot notify topics of such deletion.\n\t\t// Just leave the messages there marked as sent by \"not found\" user.\n\n\t\t// Delete topics where the user is the owner.\n\n\t\tif len(ownTopics) > 0 {\n\t\t\t// First delete all messages in those topics.\n\t\t\tif _, err = tx.Exec(ctx, \"DELETE FROM dellog USING topics WHERE topics.name=dellog.topic AND topics.owner=$1\",\n\t\t\t\tdecoded_uid); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// Deletion of messages will cascade to filemsglinks and so to fileuploads.\n\t\t\tif _, err = tx.Exec(ctx, \"DELETE FROM messages USING topics WHERE topics.name=messages.topic AND topics.owner=$1\",\n\t\t\t\tdecoded_uid); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\t// Delete subscriptions for all users where the user is the owner of the topic.\n\t\t\tsql, args, _ := sqlx.In(\"DELETE FROM subscriptions AS s WHERE topic IN (?)\", ownTopics)\n\t\t\tif _, err = tx.Exec(ctx, sqlx.Rebind(sqlx.DOLLAR, sql), args...); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// Delete topic tags.\n\t\t\tif _, err = tx.Exec(ctx, \"DELETE FROM topictags USING topics WHERE topics.name=topictags.topic AND topics.owner=$1\",\n\t\t\t\tdecoded_uid); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// And finally delete the topics.\n\t\t\tif _, err = tx.Exec(ctx, \"DELETE FROM topics WHERE owner=$1\", decoded_uid); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t// Delete user's authentication records.\n\t\tif _, err = tx.Exec(ctx, \"DELETE FROM auth WHERE userid=$1\", decoded_uid); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Delete all credentials.\n\t\tif err = credDel(ctx, tx, uid, \"\", \"\"); err != nil && err != t.ErrNotFound {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err = tx.Exec(ctx, \"DELETE FROM usertags WHERE userid=$1\", decoded_uid); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err = tx.Exec(ctx, \"DELETE FROM users WHERE id=$1\", decoded_uid); err != nil {\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\t// Disable all user's subscriptions. That includes p2p subscriptions. No need to delete them.\n\t\tif err = subsDelForUser(ctx, tx, decoded_uid, false); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(ownTopics) > 0 {\n\t\t\t// Disable all subscriptions to topics where the user is the owner.\n\t\t\tsql, args, _ := sqlx.In(\"UPDATE subscriptions SET updatedat=?,deletedat=? WHERE topic IN (?)\", now, now, ownTopics)\n\t\t\tif _, err = tx.Exec(ctx, sqlx.Rebind(sqlx.DOLLAR, sql), args...); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// Disable group topics where the user is the owner.\n\t\t\tif _, err = tx.Exec(ctx, \"UPDATE topics SET updatedat=$1,touchedat=$1,state=$2,stateat=$1 WHERE owner=$3\",\n\t\t\t\tnow, t.StateDeleted, decoded_uid); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t// Disable p2p topics with the user (p2p topic's owner is 0).\n\t\tif _, err = tx.Exec(ctx, \"UPDATE topics SET updatedat=$1,touchedat=$1,state=$2,stateat=$1 \"+\n\t\t\t\"FROM subscriptions WHERE topics.name=subscriptions.topic \"+\n\t\t\t\"AND topics.owner=0 AND subscriptions.userid=$3\",\n\t\t\tnow, t.StateDeleted, decoded_uid); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Disable the other user's subscription to a disabled p2p topic.\n\t\tif _, err = tx.Exec(ctx, \"UPDATE subscriptions AS s_one SET updatedat=$1,deletedat=$1 \"+\n\t\t\t\"FROM subscriptions AS s_two WHERE s_one.topic=s_two.topic \"+\n\t\t\t\"AND s_two.userid=$2 AND s_two.topic LIKE 'p2p%'\",\n\t\t\tnow, decoded_uid); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Disable user.\n\t\tif _, err = tx.Exec(ctx, \"UPDATE users SET updatedat=$1,state=$2,stateat=$1 WHERE id=$3\",\n\t\t\tnow, t.StateDeleted, decoded_uid); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn tx.Commit(ctx)\n}\n\n// topicStateForUser is called by UserUpdate when the update contains state change.\n// Soft-deleted topics remain soft-deleted.\nfunc (a *adapter) topicStateForUser(ctx context.Context, tx pgx.Tx, decoded_uid int64, now time.Time, update any) error {\n\tvar err error\n\n\tstate, ok := update.(t.ObjState)\n\tif !ok {\n\t\treturn t.ErrMalformed\n\t}\n\n\tif now.IsZero() {\n\t\tnow = t.TimeNow()\n\t}\n\n\t// Change state of all topics where the user is the owner.\n\tif _, err = tx.Exec(ctx, \"UPDATE topics SET state=$1, stateat=$2 WHERE owner=$3 AND state!=$4\",\n\t\tstate, now, decoded_uid, t.StateDeleted); err != nil {\n\t\treturn err\n\t}\n\n\t// Change state of p2p topics with the user (p2p topic's owner is 0)\n\tif _, err = tx.Exec(ctx, \"UPDATE topics SET state=$1, stateat=$2 \"+\n\t\t\"FROM subscriptions WHERE topics.name=subscriptions.topic AND \"+\n\t\t\"topics.owner=0 AND subscriptions.userid=$3 AND topics.state!=$4\",\n\t\tstate, now, decoded_uid, t.StateDeleted); err != nil {\n\t\treturn err\n\t}\n\n\t// Subscriptions don't need to be updated:\n\t// subscriptions of a disabled user are not disabled and still can be manipulated.\n\n\treturn nil\n}\n\n// UserUpdate updates user object.\nfunc (a *adapter) UserUpdate(uid t.Uid, update map[string]any) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTx(ctx, pgx.TxOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback(ctx)\n\t\t}\n\t}()\n\n\tcols, args := common.UpdateByMap(update)\n\tdecoded_uid := store.DecodeUid(uid)\n\targs = append(args, decoded_uid)\n\tsql, args := expandQuery(\"UPDATE users SET \"+strings.Join(cols, \",\")+\" WHERE id=?\", args...)\n\t_, err = tx.Exec(ctx, sql, args...)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif state, ok := update[\"State\"]; ok {\n\t\tnow, _ := update[\"StateAt\"].(time.Time)\n\t\terr = a.topicStateForUser(ctx, tx, decoded_uid, now, state)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Tags are also stored in a separate table\n\tif tags := common.ExtractTags(update); tags != nil {\n\t\t// First delete all user tags\n\t\t_, err = tx.Exec(ctx, \"DELETE FROM usertags WHERE userid=$1\", decoded_uid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// Now insert new tags\n\t\terr = addTags(ctx, tx, \"usertags\", \"userid\", decoded_uid, tags, false)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn tx.Commit(ctx)\n}\n\nfunc tempFetchTags(ctx context.Context, tx pgx.Tx, decoded_uid int64) ([]string, error) {\n\tvar allTags []string\n\trows, err := tx.Query(ctx, \"SELECT tag FROM usertags WHERE userid=$1\", decoded_uid)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tvar tag string\n\t\trows.Scan(&tag)\n\t\tallTags = append(allTags, tag)\n\t}\n\treturn allTags, nil\n}\n\n// UserUpdateTags adds or resets user's tags\nfunc (a *adapter) UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]string, error) {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTx(ctx, pgx.TxOptions{})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback(ctx)\n\t\t}\n\t}()\n\n\tdecoded_uid := store.DecodeUid(uid)\n\n\tif reset != nil {\n\t\t// Delete all tags first if resetting.\n\t\t_, err = tx.Exec(ctx, \"DELETE FROM usertags WHERE userid=$1\", decoded_uid)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tadd = reset\n\t\tremove = nil\n\t}\n\n\t// Now insert new tags. Ignore duplicates if resetting.\n\terr = addTags(ctx, tx, \"usertags\", \"userid\", decoded_uid, add, reset == nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Delete tags.\n\terr = removeTags(ctx, tx, \"usertags\", \"userid\", decoded_uid, remove)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar allTags []string\n\trows, err := tx.Query(ctx, \"SELECT tag FROM usertags WHERE userid=$1\", decoded_uid)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tvar tag string\n\t\trows.Scan(&tag)\n\t\tallTags = append(allTags, tag)\n\t}\n\n\t_, err = tx.Exec(ctx, \"UPDATE users SET tags=$1 WHERE id=$2\", t.StringSlice(allTags), decoded_uid)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn allTags, tx.Commit(ctx)\n}\n\n// UserGetByCred returns user ID for the given validated credential.\nfunc (a *adapter) UserGetByCred(method, value string) (t.Uid, error) {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tvar decoded_uid int64\n\terr := a.db.QueryRow(ctx, \"SELECT userid FROM credentials WHERE synthetic=$1\", method+\":\"+value).Scan(&decoded_uid)\n\tif err == nil {\n\t\treturn store.EncodeUid(decoded_uid), nil\n\t}\n\n\tif err == pgx.ErrNoRows {\n\t\t// Clear the error if user does not exist\n\t\treturn t.ZeroUid, nil\n\t}\n\treturn t.ZeroUid, err\n}\n\n// UserUnreadCount returns the total number of unread messages in all topics with\n// the R permission. If read fails, the counts are still returned with the original\n// user IDs but with the unread count undefined and non-nil error.\n// UserUnreadCount does not count unread messages in channels although it should.\nfunc (a *adapter) UserUnreadCount(ids ...t.Uid) (map[t.Uid]int, error) {\n\tuids := make([]any, len(ids))\n\tcounts := make(map[t.Uid]int, len(ids))\n\tfor i, id := range ids {\n\t\tuids[i] = store.DecodeUid(id)\n\t\t// Ensure all original uids are always present.\n\t\tcounts[id] = 0\n\t}\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\t// FIXME: support channels.\n\tquery, uids := expandQuery(\"SELECT s.userid, SUM(t.seqid)-SUM(s.readseqid) AS unreadcount FROM topics AS t, subscriptions AS s \"+\n\t\t\"WHERE s.userid IN (?) AND t.name=s.topic AND s.deletedat IS NULL AND t.state!=? AND \"+\n\t\t\"POSITION('R' IN s.modewant)>0 AND POSITION('R' IN s.modegiven)>0 GROUP BY s.userid\", uids, t.StateDeleted)\n\trows, err := a.db.Query(ctx, query, uids...)\n\tif err != nil {\n\t\treturn counts, err\n\t}\n\tdefer rows.Close()\n\n\tvar userId int64\n\tvar unreadCount int\n\tfor rows.Next() {\n\t\tif err = rows.Scan(&userId, &unreadCount); err != nil {\n\t\t\tbreak\n\t\t}\n\t\tcounts[store.EncodeUid(userId)] = unreadCount\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\treturn counts, err\n}\n\n// UserGetUnvalidated returns a list of uids which have never logged in, have no\n// validated credentials and haven't been updated since lastUpdatedBefore.\nfunc (a *adapter) UserGetUnvalidated(lastUpdatedBefore time.Time, limit int) ([]t.Uid, error) {\n\tvar uids []t.Uid\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\trows, err := a.db.Query(ctx,\n\t\t\"SELECT u.id, COALESCE(SUM(CASE WHEN c.done THEN 1 ELSE 0 END), 0) AS total \"+\n\t\t\t\"FROM users u LEFT JOIN credentials c ON u.id = c.userid \"+\n\t\t\t\"WHERE u.lastseen IS NULL AND u.updatedat < $1 GROUP BY u.id, u.updatedat \"+\n\t\t\t\"HAVING COALESCE(SUM(CASE WHEN c.done THEN 1 ELSE 0 END), 0) = 0 ORDER BY u.updatedat ASC LIMIT $2\",\n\t\tlastUpdatedBefore, limit)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tvar userId int64\n\t\tvar unused int\n\t\tif err = rows.Scan(&userId, &unused); err != nil {\n\t\t\tbreak\n\t\t}\n\t\tuids = append(uids, store.EncodeUid(userId))\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\treturn uids, err\n}\n\n// *****************************\n\nfunc (a *adapter) topicCreate(ctx context.Context, tx pgx.Tx, topic *t.Topic) error {\n\t_, err := tx.Exec(ctx, \"INSERT INTO topics(createdat,updatedat,touchedat,state,name,usebt,owner,access,public,trusted,tags,aux) \"+\n\t\t\"VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)\",\n\t\ttopic.CreatedAt, topic.UpdatedAt, topic.TouchedAt, topic.State, topic.Id, topic.UseBt,\n\t\tstore.DecodeUid(t.ParseUid(topic.Owner)), topic.Access, common.ToJSON(topic.Public), common.ToJSON(topic.Trusted),\n\t\ttopic.Tags, common.ToJSON(topic.Aux))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Save topic's tags to a separate table to make topic findable.\n\treturn addTags(ctx, tx, \"topictags\", \"topic\", topic.Id, topic.Tags, false)\n}\n\n// TopicCreate saves topic object to database.\nfunc (a *adapter) TopicCreate(topic *t.Topic) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTx(ctx, pgx.TxOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback(ctx)\n\t\t}\n\t}()\n\n\terr = a.topicCreate(ctx, tx, topic)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn tx.Commit(ctx)\n}\n\n// If undelete = true - update subscription on duplicate key, otherwise ignore the duplicate.\nfunc createSubscription(ctx context.Context, tx pgx.Tx, sub *t.Subscription, undelete bool) error {\n\n\tisOwner := (sub.ModeGiven & sub.ModeWant).IsOwner()\n\n\tjpriv := common.ToJSON(sub.Private)\n\tdecoded_uid := store.DecodeUid(t.ParseUid(sub.User))\n\t_, err2 := tx.Exec(ctx, \"SAVEPOINT createSub\")\n\tif err2 != nil {\n\t\tlog.Println(\"Error: Failed to create savepoint: \", err2.Error())\n\t}\n\t_, err := tx.Exec(ctx,\n\t\t\"INSERT INTO subscriptions(createdat,updatedat,deletedat,userid,topic,modeWant,modeGiven,private) \"+\n\t\t\t\"VALUES($1,$2,NULL,$3,$4,$5,$6,$7)\",\n\t\tsub.CreatedAt, sub.UpdatedAt, decoded_uid, sub.Topic, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv)\n\n\tif err != nil && isDupe(err) {\n\t\t_, err2 = tx.Exec(ctx, \"ROLLBACK TO SAVEPOINT createSub\")\n\t\tif err2 != nil {\n\t\t\tlog.Println(\"Error: Failed to rollback savepoint: \", err2.Error())\n\t\t}\n\t\tif undelete {\n\t\t\t_, err = tx.Exec(ctx, \"UPDATE subscriptions SET createdat=$1,updatedat=$2,deletedat=NULL,modeWant=$3,modeGiven=$4,\"+\n\t\t\t\t\"delid=0,recvseqid=0,readseqid=0 WHERE topic=$5 AND userid=$6\",\n\t\t\t\tsub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), sub.Topic, decoded_uid)\n\t\t} else {\n\t\t\t_, err = tx.Exec(ctx, \"UPDATE subscriptions SET createdat=$1,updatedat=$2,deletedat=NULL,modeWant=$3,modeGiven=$4,\"+\n\t\t\t\t\"delid=0,recvseqid=0,readseqid=0,private=$5 WHERE topic=$6 AND userid=$7\",\n\t\t\t\tsub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv,\n\t\t\t\tsub.Topic, decoded_uid)\n\t\t}\n\t} else {\n\t\t_, err2 = tx.Exec(ctx, \"RELEASE SAVEPOINT createSub\")\n\t\tif err2 != nil {\n\t\t\tlog.Println(\"Error: Failed to release savepoint: \", err2.Error())\n\t\t}\n\t}\n\tif err == nil && isOwner {\n\t\t_, err = tx.Exec(ctx, \"UPDATE topics SET owner=$1 WHERE name=$2\", decoded_uid, sub.Topic)\n\t}\n\treturn err\n}\n\n// TopicCreateP2P given two users creates a p2p topic\nfunc (a *adapter) TopicCreateP2P(initiator, invited *t.Subscription) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTx(ctx, pgx.TxOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback(ctx)\n\t\t}\n\t}()\n\n\terr = createSubscription(ctx, tx, initiator, false)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = createSubscription(ctx, tx, invited, true)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttopic := &t.Topic{ObjHeader: t.ObjHeader{Id: initiator.Topic}}\n\ttopic.ObjHeader.MergeTimes(&initiator.ObjHeader)\n\ttopic.TouchedAt = initiator.GetTouchedAt()\n\terr = a.topicCreate(ctx, tx, topic)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit(ctx)\n}\n\n// TopicGet loads a single topic by name, if it exists. If the topic does not exist the call returns (nil, nil)\nfunc (a *adapter) TopicGet(topic string) (*t.Topic, error) {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\t// Fetch topic by name\n\tvar tt = new(t.Topic)\n\tvar owner int64\n\terr := a.db.QueryRow(ctx,\n\t\t\"SELECT createdat,updatedat,state,stateat,touchedat,name AS id,usebt,access,owner,seqid,delid,subcnt,public,trusted,tags,aux \"+\n\t\t\t\"FROM topics WHERE name=$1\",\n\t\ttopic).Scan(&tt.CreatedAt, &tt.UpdatedAt, &tt.State, &tt.StateAt, &tt.TouchedAt, &tt.Id,\n\t\t&tt.UseBt, &tt.Access, &owner, &tt.SeqId, &tt.DelId, &tt.SubCnt, &tt.Public, &tt.Trusted, &tt.Tags, &tt.Aux)\n\tif err != nil {\n\t\tif err == pgx.ErrNoRows {\n\t\t\t// Nothing found - clear the error\n\t\t\terr = nil\n\t\t}\n\t\treturn nil, err\n\t}\n\n\tif t.GetTopicCat(topic) == t.TopicCatGrp {\n\t\t// Topic found, get subsription count. Try both topic and channel names.\n\t\tvar subCnt int\n\t\tif err = a.db.QueryRow(ctx,\n\t\t\t\"SELECT COUNT(*) FROM subscriptions WHERE topic IN ($1,$2) AND deletedat IS NULL\", topic, t.GrpToChn(topic)).\n\t\t\tScan(&subCnt); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif subCnt != tt.SubCnt {\n\t\t\t// Update the topic with the correct subscription count.\n\t\t\ttt.SubCnt = subCnt\n\t\t\tif _, err = a.db.Exec(ctx, \"UPDATE topics SET subcnt=$1 WHERE name=$2\", subCnt, topic); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\n\ttt.Owner = store.EncodeUid(owner).String()\n\n\treturn tt, err\n}\n\n// TopicsForUser loads user's contact list: p2p and grp topics, except for 'me' & 'fnd' subscriptions.\n// Reads and denormalizes Public value.\nfunc (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {\n\t// Fetch ALL user's subscriptions, even those which has not been modified recently.\n\t// We are going to use these subscriptions to fetch topics and users which may have been modified recently.\n\tq := `SELECT createdat,updatedat,deletedat,topic,delid,recvseqid,\n\t\treadseqid,modewant,modegiven,private FROM subscriptions WHERE userid=?`\n\targs := []any{store.DecodeUid(uid)}\n\tif !keepDeleted {\n\t\t// Filter out deleted rows.\n\t\tq += \" AND deletedat IS NULL\"\n\t}\n\n\tlimit := 0\n\tims := time.Time{}\n\tif opts != nil {\n\t\tif opts.Topic != \"\" {\n\t\t\tq += \" AND topic=?\"\n\t\t\targs = append(args, opts.Topic)\n\t\t}\n\n\t\t// Apply the limit only when the client does not manage the cache (or cold start).\n\t\t// Otherwise have to get all subscriptions and do a manual join with users/topics.\n\t\tif opts.IfModifiedSince == nil {\n\t\t\tif opts.Limit > 0 && opts.Limit < a.maxResults {\n\t\t\t\tlimit = opts.Limit\n\t\t\t} else {\n\t\t\t\tlimit = a.maxResults\n\t\t\t}\n\t\t} else {\n\t\t\tims = *opts.IfModifiedSince\n\t\t}\n\t} else {\n\t\tlimit = a.maxResults\n\t}\n\n\tif limit > 0 {\n\t\tq += \" LIMIT ?\"\n\t\targs = append(args, limit)\n\t}\n\n\tq, args = expandQuery(q, args...)\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\trows, err := a.db.Query(ctx, q, args...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t// Must close rows manually as we will be reusing it.\n\n\t// Fetch subscriptions. Two queries are needed: users table (p2p) and topics table (grp).\n\t// Prepare a list of separate subscriptions to users vs topics\n\tjoin := make(map[string]t.Subscription) // Keeping these to make a join with table for .private and .access\n\ttopq := make([]any, 0, 16)\n\tusrq := make([]any, 0, 16)\n\tfor rows.Next() {\n\t\tvar sub t.Subscription\n\t\tvar modeWant, modeGiven []byte\n\t\tif err = rows.Scan(&sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt, &sub.Topic, &sub.DelId,\n\t\t\t&sub.RecvSeqId, &sub.ReadSeqId, &modeWant, &modeGiven, &sub.Private); err != nil {\n\t\t\tbreak\n\t\t}\n\t\tsub.ModeWant.Scan(modeWant)\n\t\tsub.ModeGiven.Scan(modeGiven)\n\t\ttname := sub.Topic\n\t\tsub.User = uid.String()\n\t\ttcat := t.GetTopicCat(tname)\n\n\t\tif tcat == t.TopicCatMe || tcat == t.TopicCatFnd {\n\t\t\t// One of 'me', 'fnd' subscriptions, skip.\n\t\t\t// Don't skip 'sys' subscription.\n\t\t\tcontinue\n\t\t} else if tcat == t.TopicCatP2P {\n\t\t\t// P2P subscription, find the other user to get user.Public and user.Trusted.\n\t\t\tuid1, uid2, _ := t.ParseP2P(tname)\n\t\t\tif uid1 == uid {\n\t\t\t\tusrq = append(usrq, store.DecodeUid(uid2))\n\t\t\t\tsub.SetWith(uid2.UserId())\n\t\t\t} else {\n\t\t\t\tusrq = append(usrq, store.DecodeUid(uid1))\n\t\t\t\tsub.SetWith(uid1.UserId())\n\t\t\t}\n\t\t} else if tcat == t.TopicCatGrp {\n\t\t\t// Maybe convert channel name to topic name.\n\t\t\ttname = t.ChnToGrp(tname)\n\t\t}\n\t\t// No special handling needed for 'slf', 'sys' subscriptions.\n\n\t\ttopq = append(topq, tname)\n\t\tsub.Private = common.FromJSON(sub.Private)\n\t\tjoin[tname] = sub\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\trows.Close()\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar subs []t.Subscription\n\tif len(join) == 0 {\n\t\treturn subs, nil\n\t}\n\n\t// Fetch grp topics and join to subscriptions.\n\tif len(topq) > 0 {\n\t\tq = \"SELECT updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,public,trusted \" +\n\t\t\t\"FROM topics WHERE name IN (?)\"\n\t\tnewargs := []any{topq}\n\n\t\tif !keepDeleted {\n\t\t\t// Optionally skip deleted topics.\n\t\t\tq += \" AND state!=?\"\n\t\t\tnewargs = append(newargs, t.StateDeleted)\n\t\t}\n\n\t\tif !ims.IsZero() {\n\t\t\t// Use cache timestamp if provided: get newer entries only.\n\t\t\tq += \" AND touchedat>?\"\n\t\t\tnewargs = append(newargs, ims)\n\n\t\t\tif limit > 0 && limit < len(topq) {\n\t\t\t\t// No point in fetching more than the requested limit.\n\t\t\t\tq += \" ORDER BY touchedat LIMIT ?\"\n\t\t\t\tnewargs = append(newargs, limit)\n\t\t\t}\n\t\t}\n\t\tq, newargs = expandQuery(q, newargs...)\n\n\t\tctx2, cancel2 := a.getContext()\n\t\tif cancel2 != nil {\n\t\t\tdefer cancel2()\n\t\t}\n\t\trows, err = a.db.Query(ctx2, q, newargs...)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tvar top t.Topic\n\t\tfor rows.Next() {\n\t\t\tif err = rows.Scan(&top.UpdatedAt, &top.State, &top.TouchedAt, &top.Id, &top.UseBt,\n\t\t\t\t&top.Access, &top.SeqId, &top.DelId, &top.SubCnt, &top.Public, &top.Trusted); err != nil {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tsub := join[top.Id]\n\t\t\t// Check if sub.UpdatedAt needs to be adjusted to earlier or later time.\n\t\t\tsub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, top.UpdatedAt)\n\t\t\tsub.SetState(top.State)\n\t\t\tsub.SetTouchedAt(top.TouchedAt)\n\t\t\tsub.SetSeqId(top.SeqId)\n\t\t\tif t.GetTopicCat(sub.Topic) == t.TopicCatGrp {\n\t\t\t\tsub.SetSubCnt(top.SubCnt)\n\t\t\t\tsub.SetPublic(top.Public)\n\t\t\t\tsub.SetTrusted(top.Trusted)\n\t\t\t}\n\t\t\t// Put back the updated value of a subsription, will process further below\n\t\t\tjoin[top.Id] = sub\n\t\t}\n\t\tif err == nil {\n\t\t\terr = rows.Err()\n\t\t}\n\t\trows.Close()\n\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\t// Fetch p2p users and join to p2p subscriptions.\n\tif len(usrq) > 0 {\n\t\tq = \"SELECT id,updatedat,state,access,lastseen,useragent,public,trusted \" +\n\t\t\t\"FROM users WHERE id IN (?)\"\n\t\tnewargs := []any{usrq}\n\t\tif !keepDeleted {\n\t\t\t// Optionally skip deleted users.\n\t\t\tq += \" AND state!=?\"\n\t\t\tnewargs = append(newargs, t.StateDeleted)\n\t\t}\n\n\t\t// Ignoring ipg: we need all users to get LastSeen and UserAgent.\n\n\t\tq, newargs = expandQuery(q, newargs...)\n\n\t\tctx3, cancel3 := a.getContext()\n\t\tif cancel3 != nil {\n\t\t\tdefer cancel3()\n\t\t}\n\t\trows, err = a.db.Query(ctx3, q, newargs...)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor rows.Next() {\n\t\t\tvar usr2 t.User\n\t\t\tvar id int64\n\t\t\tif err = rows.Scan(&id, &usr2.UpdatedAt, &usr2.State, &usr2.Access, &usr2.LastSeen, &usr2.UserAgent,\n\t\t\t\t&usr2.Public, &usr2.Trusted); err != nil {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tusr2.Id = store.EncodeUid(id).String()\n\t\t\tjoinOn := uid.P2PName(t.ParseUid(usr2.Id))\n\t\t\tif sub, ok := join[joinOn]; ok {\n\t\t\t\tsub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, usr2.UpdatedAt)\n\t\t\t\tsub.SetState(usr2.State)\n\t\t\t\tsub.SetPublic(usr2.Public)\n\t\t\t\tsub.SetTrusted(usr2.Trusted)\n\t\t\t\tsub.SetDefaultAccess(usr2.Access.Auth, usr2.Access.Anon)\n\t\t\t\tsub.SetLastSeenAndUA(usr2.LastSeen, usr2.UserAgent)\n\t\t\t\tjoin[joinOn] = sub\n\t\t\t}\n\t\t}\n\t\tif err == nil {\n\t\t\terr = rows.Err()\n\t\t}\n\t\trows.Close()\n\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tsubs = make([]t.Subscription, 0, len(join))\n\tfor _, sub := range join {\n\t\tsubs = append(subs, sub)\n\t}\n\n\treturn common.SelectEarliestUpdatedSubs(subs, opts, a.maxResults), nil\n}\n\n// UsersForTopic loads users subscribed to the given topic.\n// The difference between UsersForTopic vs SubsForTopic is that the former loads user.Public,\n// the latter does not.\nfunc (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {\n\ttcat := t.GetTopicCat(topic)\n\n\t// Fetch all subscribed users. The number of users is not large\n\tq := `SELECT s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid,\n\t\ts.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private\n\t\tFROM subscriptions AS s JOIN users AS u ON s.userid=u.id\n\t\tWHERE s.topic=?`\n\targs := []any{topic}\n\tif !keepDeleted {\n\t\t// Filter out rows with users deleted\n\t\tq += \" AND u.state!=?\"\n\t\targs = append(args, t.StateDeleted)\n\n\t\t// For p2p topics we must load all subscriptions including deleted.\n\t\t// Otherwise it will be impossible to swipe Public values.\n\t\tif tcat != t.TopicCatP2P {\n\t\t\t// Filter out deleted subscriptions.\n\t\t\tq += \" AND s.deletedat IS NULL\"\n\t\t}\n\t}\n\n\tlimit := a.maxResults\n\tvar oneUser t.Uid\n\tif opts != nil {\n\t\t// Ignore IfModifiedSince: loading all entries because a topic cannot have too many subscribers.\n\t\t// Those unmodified will be stripped of Public & Private.\n\n\t\tif !opts.User.IsZero() {\n\t\t\t// For p2p topics we have to fetch both users otherwise public cannot be swapped.\n\t\t\tif tcat != t.TopicCatP2P {\n\t\t\t\tq += \" AND s.userid=?\"\n\t\t\t\targs = append(args, store.DecodeUid(opts.User))\n\t\t\t}\n\t\t\toneUser = opts.User\n\t\t}\n\t\tif opts.Limit > 0 && opts.Limit < limit {\n\t\t\tlimit = opts.Limit\n\t\t}\n\t}\n\tq += \" LIMIT ?\"\n\targs = append(args, limit)\n\tq, args = expandQuery(q, args...)\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\trows, err := a.db.Query(ctx, q, args...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\t// Fetch subscriptions\n\tvar sub t.Subscription\n\tvar subs []t.Subscription\n\tvar userId int64\n\tvar modeWant, modeGiven []byte\n\tvar lastSeen *time.Time = nil\n\tvar userAgent string\n\tvar public, trusted any\n\tfor rows.Next() {\n\t\tif err = rows.Scan(\n\t\t\t&sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt,\n\t\t\t&userId, &sub.Topic, &sub.DelId, &sub.RecvSeqId,\n\t\t\t&sub.ReadSeqId, &modeWant, &modeGiven,\n\t\t\t&public, &trusted, &lastSeen, &userAgent, &sub.Private); err != nil {\n\t\t\tbreak\n\t\t}\n\n\t\tsub.User = store.EncodeUid(userId).String()\n\t\tsub.SetPublic(public)\n\t\tsub.SetTrusted(trusted)\n\t\tsub.SetLastSeenAndUA(lastSeen, userAgent)\n\t\tsub.ModeWant.Scan(modeWant)\n\t\tsub.ModeGiven.Scan(modeGiven)\n\t\tsubs = append(subs, sub)\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\tif err == nil && tcat == t.TopicCatP2P && len(subs) > 0 {\n\t\t// Swap public & lastSeen values of P2P topics as expected.\n\t\tif len(subs) == 1 {\n\t\t\t// The other user is deleted, nothing we can do.\n\t\t\tsubs[0].SetPublic(nil)\n\t\t\tsubs[0].SetTrusted(nil)\n\t\t\tsubs[0].SetLastSeenAndUA(nil, \"\")\n\t\t} else {\n\t\t\ttmp := subs[0].GetPublic()\n\t\t\tsubs[0].SetPublic(subs[1].GetPublic())\n\t\t\tsubs[1].SetPublic(tmp)\n\n\t\t\ttmp = subs[0].GetTrusted()\n\t\t\tsubs[0].SetTrusted(subs[1].GetTrusted())\n\t\t\tsubs[1].SetTrusted(tmp)\n\n\t\t\tlastSeen := subs[0].GetLastSeen()\n\t\t\tuserAgent = subs[0].GetUserAgent()\n\t\t\tsubs[0].SetLastSeenAndUA(subs[1].GetLastSeen(), subs[1].GetUserAgent())\n\t\t\tsubs[1].SetLastSeenAndUA(lastSeen, userAgent)\n\t\t}\n\n\t\t// Remove deleted and unneeded subscriptions\n\t\tif !keepDeleted || !oneUser.IsZero() {\n\t\t\tvar xsubs []t.Subscription\n\t\t\tfor i := range subs {\n\t\t\t\tif (subs[i].DeletedAt != nil && !keepDeleted) || (!oneUser.IsZero() && subs[i].Uid() != oneUser) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\txsubs = append(xsubs, subs[i])\n\t\t\t}\n\t\t\tsubs = xsubs\n\t\t}\n\t}\n\n\treturn subs, err\n}\n\n// topicNamesForUser reads a slice of strings using provided query.\nfunc (a *adapter) topicNamesForUser(sqlQuery string, includeChan bool, args ...any) ([]string, error) {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\trows, err := a.db.Query(ctx, sqlQuery, args...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tvar names []string\n\tfor rows.Next() {\n\t\tvar name string\n\t\tif err = rows.Scan(&name); err != nil {\n\t\t\tbreak\n\t\t}\n\t\tnames = append(names, name)\n\t\t// If the name is a group topic, also add the channel name if requested.\n\t\tif includeChan {\n\t\t\tif channel := t.GrpToChn(name); channel != \"\" {\n\t\t\t\tnames = append(names, channel)\n\t\t\t}\n\t\t}\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\treturn names, err\n}\n\n// OwnTopics loads a slice of topic names where the user is the owner.\nfunc (a *adapter) OwnTopics(uid t.Uid) ([]string, error) {\n\treturn a.topicNamesForUser(\"SELECT name FROM topics WHERE owner=$1 AND state!=$2\",\n\t\tfalse, store.DecodeUid(uid), t.StateDeleted)\n}\n\n// ChannelsForUser loads a slice of topic names where the user is a channel reader and notifications (P) are enabled.\nfunc (a *adapter) ChannelsForUser(uid t.Uid) ([]string, error) {\n\treturn a.topicNamesForUser(\"SELECT topic FROM subscriptions WHERE userid=$1 AND topic LIKE 'chn%' \"+\n\t\t\"AND POSITION('P' IN modewant)>0 AND POSITION('P' IN modegiven)>0 AND deletedat IS NULL\",\n\t\tfalse, store.DecodeUid(uid))\n}\n\n// TopicShare creates topic subscriptions and increments the topic's subcnt.\nfunc (a *adapter) TopicShare(topic string, shares []*t.Subscription) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTx(ctx, pgx.TxOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback(ctx)\n\t\t}\n\t}()\n\n\tfor _, sub := range shares {\n\t\terr = createSubscription(ctx, tx, sub, true)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif topic != \"\" {\n\t\tif _, err = tx.Exec(ctx, \"UPDATE topics SET subcnt=subcnt+$1 WHERE name=$2\", len(shares), topic); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn tx.Commit(ctx)\n}\n\n// TopicDelete deletes topic, subscriptions, messages.\nfunc (a *adapter) TopicDelete(topic string, isChan, hard bool) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTx(ctx, pgx.TxOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback(ctx)\n\t\t}\n\t}()\n\n\t// If the topic is a channel, must try to delete subscriptions under both grpXXX and chnXXX names.\n\targs := []any{topic}\n\tif isChan {\n\t\targs = append(args, t.GrpToChn(topic))\n\t}\n\n\tif hard {\n\t\t// Delete subscriptions. If this is a channel, delete both group subscriptions and channel subscriptions.\n\t\tq, args := expandQuery(\"DELETE FROM subscriptions WHERE topic IN (?)\", args)\n\t\tif _, err = tx.Exec(ctx, q, args...); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err = messageDeleteList(ctx, tx, topic, nil); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err = tx.Exec(ctx, \"DELETE FROM topictags WHERE topic=$1\", topic); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err = tx.Exec(ctx, \"DELETE FROM topics WHERE name=$1\", topic); err != nil {\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\tnow := t.TimeNow()\n\n\t\tq, args := expandQuery(\"UPDATE subscriptions SET updatedat=?,deletedat=? WHERE topic IN (?)\", now, now, args)\n\t\tif _, err = tx.Exec(ctx, q, args...); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err = tx.Exec(ctx, \"UPDATE topics SET updatedat=$1,touchedat=$1,state=$2,stateat=$1 WHERE name=$3\",\n\t\t\tnow, t.StateDeleted, topic); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn tx.Commit(ctx)\n}\n\nfunc (a *adapter) TopicUpdateOnMessage(topic string, msg *t.Message) error {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\t_, err := a.db.Exec(ctx, \"UPDATE topics SET seqid=$1,touchedat=$2 WHERE name=$3\", msg.SeqId, msg.CreatedAt, topic)\n\n\treturn err\n}\n\n// TopicUpdateSubCnt updates subscriber count denormalized in topic.\nfunc (a *adapter) TopicUpdateSubCnt(topic string) error {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\t_, err := a.db.Exec(ctx,\n\t\t\"UPDATE topics SET subcnt=(SELECT COUNT(*) FROM subscriptions WHERE topic IN ($1,$2) AND deletedat IS NULL) WHERE name=$1\",\n\t\ttopic, t.GrpToChn(topic))\n\treturn err\n}\n\nfunc (a *adapter) TopicUpdate(topic string, update map[string]any) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTx(ctx, pgx.TxOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback(ctx)\n\t\t}\n\t}()\n\n\tif t, u := update[\"TouchedAt\"], update[\"UpdatedAt\"]; t == nil && u != nil {\n\t\tupdate[\"TouchedAt\"] = u\n\t}\n\tcols, args := common.UpdateByMap(update)\n\tq, args := expandQuery(\"UPDATE topics SET \"+strings.Join(cols, \",\")+\" WHERE name=?\", args, topic)\n\t_, err = tx.Exec(ctx, q, args...)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Tags are also stored in a separate table\n\tif tags := common.ExtractTags(update); tags != nil {\n\t\t// First delete all user tags\n\t\t_, err = tx.Exec(ctx, \"DELETE FROM topictags WHERE topic=$1\", topic)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// Now insert new tags\n\t\terr = addTags(ctx, tx, \"topictags\", \"topic\", topic, tags, false)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn tx.Commit(ctx)\n}\n\nfunc (a *adapter) TopicOwnerChange(topic string, newOwner t.Uid) error {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\t_, err := a.db.Exec(ctx, \"UPDATE topics SET owner=$1 WHERE name=$2\", store.DecodeUid(newOwner), topic)\n\treturn err\n}\n\n// Get a subscription of a user to a topic.\nfunc (a *adapter) SubscriptionGet(topic string, user t.Uid, keepDeleted bool) (*t.Subscription, error) {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tquery := `SELECT createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,\n\t\treadseqid,modewant,modegiven,private FROM subscriptions WHERE topic=$1 AND userid=$2`\n\tif !keepDeleted {\n\t\tquery += \" AND deletedat IS NULL\"\n\t}\n\tvar sub t.Subscription\n\tvar userId int64\n\tvar modeWant, modeGiven []byte\n\terr := a.db.QueryRow(ctx, query, topic, store.DecodeUid(user)).Scan(&sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt, &userId,\n\t\t&sub.Topic, &sub.DelId, &sub.RecvSeqId, &sub.ReadSeqId, &modeWant, &modeGiven, &sub.Private)\n\n\tif err != nil {\n\t\tif err == pgx.ErrNoRows {\n\t\t\t// Nothing found - clear the error\n\t\t\terr = nil\n\t\t}\n\t\treturn nil, err\n\t}\n\n\tsub.User = store.EncodeUid(userId).String()\n\tsub.ModeWant.Scan(modeWant)\n\tsub.ModeGiven.Scan(modeGiven)\n\n\treturn &sub, nil\n}\n\n// SubsForUser loads all user's subscriptions. Does NOT load Public or Private values and does\n// not load deleted subscriptions.\nfunc (a *adapter) SubsForUser(forUser t.Uid) ([]t.Subscription, error) {\n\tq := `SELECT createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,\n\t\treadseqid,modewant,modegiven FROM subscriptions WHERE userid=$1 AND deletedat IS NULL`\n\targs := []any{store.DecodeUid(forUser)}\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\trows, err := a.db.Query(ctx, q, args...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tvar subs []t.Subscription\n\tvar sub t.Subscription\n\tvar userId int64\n\tvar modeWant, modeGiven []byte\n\tfor rows.Next() {\n\t\tif err = rows.Scan(&sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt, &userId, &sub.Topic, &sub.DelId,\n\t\t\t&sub.RecvSeqId, &sub.ReadSeqId, &modeWant, &modeGiven); err != nil {\n\t\t\tbreak\n\t\t}\n\n\t\tsub.User = store.EncodeUid(userId).String()\n\t\tsub.ModeWant.Scan(modeWant)\n\t\tsub.ModeGiven.Scan(modeGiven)\n\t\tsubs = append(subs, sub)\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\treturn subs, err\n}\n\n// SubsForTopic fetches all subsciptions for a topic. Does NOT load Public value.\n// The difference between UsersForTopic vs SubsForTopic is that the former loads user.public+trusted,\n// the latter does not.\nfunc (a *adapter) SubsForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {\n\tq := `SELECT createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,\n\t\treadseqid,modewant,modegiven,private FROM subscriptions WHERE topic=?`\n\n\targs := []any{topic}\n\tif !keepDeleted {\n\t\t// Filter out deleted rows.\n\t\tq += \" AND deletedat IS NULL\"\n\t}\n\tlimit := a.maxResults\n\tif opts != nil {\n\t\t// Ignore IfModifiedSince - we must return all entries\n\t\t// Those unmodified will be stripped of Public & Private.\n\n\t\tif !opts.User.IsZero() {\n\t\t\tq += \" AND userid=?\"\n\t\t\targs = append(args, store.DecodeUid(opts.User))\n\t\t}\n\t\tif opts.Limit > 0 && opts.Limit < limit {\n\t\t\tlimit = opts.Limit\n\t\t}\n\t}\n\n\tq += \" LIMIT ?\"\n\targs = append(args, limit)\n\tq, args = expandQuery(q, args...)\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\trows, err := a.db.Query(ctx, q, args...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tvar subs []t.Subscription\n\tvar sub t.Subscription\n\tvar userId int64\n\tvar modeWant, modeGiven []byte\n\tfor rows.Next() {\n\t\tif err = rows.Scan(&sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt, &userId, &sub.Topic, &sub.DelId,\n\t\t\t&sub.RecvSeqId, &sub.ReadSeqId, &modeWant, &modeGiven, &sub.Private); err != nil {\n\t\t\tbreak\n\t\t}\n\n\t\tsub.User = store.EncodeUid(userId).String()\n\t\tsub.ModeWant.Scan(modeWant)\n\t\tsub.ModeGiven.Scan(modeGiven)\n\t\tsubs = append(subs, sub)\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\treturn subs, err\n}\n\n// SubsUpdate updates one or multiple subscriptions to a topic.\nfunc (a *adapter) SubsUpdate(topic string, user t.Uid, update map[string]any) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTx(ctx, pgx.TxOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback(ctx)\n\t\t}\n\t}()\n\n\tcols, args := common.UpdateByMap(update)\n\tq := \"UPDATE subscriptions SET \" + strings.Join(cols, \",\") + \" WHERE topic=?\"\n\targs = append(args, topic)\n\tif !user.IsZero() {\n\t\t// Update just one topic subscription\n\t\tq += \" AND userid=?\"\n\t\targs = append(args, store.DecodeUid(user))\n\t}\n\tq, args = expandQuery(q, args...)\n\n\tif _, err = tx.Exec(ctx, q, args...); err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit(ctx)\n}\n\n// SubsDelete marks at most one subscription as deleted.\nfunc (a *adapter) SubsDelete(topic string, user t.Uid) error {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\ttx, err := a.db.Begin(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback(ctx)\n\t\t}\n\t}()\n\n\tdecoded_id := store.DecodeUid(user)\n\tnow := t.TimeNow()\n\tres, err := tx.Exec(ctx,\n\t\t\"UPDATE subscriptions SET updatedat=$1,deletedat=$2 WHERE topic=$3 AND userid=$4 AND deletedat IS NULL\",\n\t\tnow, now, topic, decoded_id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\taffected := res.RowsAffected()\n\tif affected == 0 {\n\t\t// ensure tx.Rollback() above is ran\n\t\terr = t.ErrNotFound\n\t\treturn err\n\t}\n\n\t// Channel readers cannot delete messages.\n\tif !t.IsChannel(topic) {\n\t\t// Remove records of messages soft-deleted by this user.\n\t\t_, err = tx.Exec(ctx, \"DELETE FROM dellog WHERE topic=$1 AND deletedfor=$2\", topic, decoded_id)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif t.GetTopicCat(topic) == t.TopicCatGrp {\n\t\t// Decrement topic subscription count (only one subscription is\tdeleted).\n\t\t_, err = tx.Exec(ctx, \"UPDATE topics SET subcnt=subcnt-1 WHERE name=$1\", topic)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn tx.Commit(ctx)\n}\n\n// subsDelForUser marks user's subscriptions as deleted.\nfunc subsDelForUser(ctx context.Context, tx pgx.Tx, decoded_uid int64, hard bool) error {\n\t// Decrement subscription count for all topics the user is subscribed to.\n\trows, err := tx.Query(ctx, \"SELECT topic FROM subscriptions WHERE userid=$1 AND deletedat IS NULL\", decoded_uid)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar topics []any\n\tfor rows.Next() {\n\t\tvar name string\n\t\tif err = rows.Scan(&name); err != nil {\n\t\t\tbreak\n\t\t}\n\t\tif t.IsChannel(name) {\n\t\t\t// Convert channel name to group name.\n\t\t\tname = t.ChnToGrp(name)\n\t\t}\n\t\ttopics = append(topics, name)\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\trows.Close()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(topics) > 0 {\n\t\tsql, args, _ := sqlx.In(\"UPDATE topics SET subcnt=subcnt-1 WHERE name IN (?)\", topics)\n\t\t_, err = tx.Exec(ctx, sqlx.Rebind(sqlx.DOLLAR, sql), args...)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif hard {\n\t\t// Hard delete: remove all subscriptions for the user.\n\t\t_, err = tx.Exec(ctx, \"DELETE FROM subscriptions WHERE userid=$1\", decoded_uid)\n\t} else {\n\t\tnow := t.TimeNow()\n\t\t_, err = tx.Exec(ctx, \"UPDATE subscriptions SET updatedat=$1,deletedat=$2 WHERE userid=$3 AND deletedat IS NULL;\",\n\t\t\tnow, now, decoded_uid)\n\t}\n\treturn err\n}\n\n// SubsDelForUser marks user's subscriptions as deleted.\nfunc (a *adapter) SubsDelForUser(user t.Uid, hard bool) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\ttx, err := a.db.BeginTx(ctx, pgx.TxOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback(ctx)\n\t\t}\n\t}()\n\n\tif err = subsDelForUser(ctx, tx, store.DecodeUid(user), hard); err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit(ctx)\n\n}\n\n// Find returns a list of users and group topics which match the given tags, such as \"email:jdoe@example.com\" or \"tel:+18003287448\".\nfunc (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error) {\n\tindex := make(map[string]struct{})\n\tvar args []any\n\tconstraint := \"\"\n\tallReq := t.FlattenDoubleSlice(req)\n\tfor _, tag := range append(allReq, opt...) {\n\t\targs = append(args, tag)\n\t\tindex[tag] = struct{}{}\n\t}\n\tif len(args) == 0 {\n\t\t// Nothing to search for.\n\t\treturn nil, nil\n\t}\n\tconstraint += \"tg.tag IN (?) \"\n\tconstraint, args, err := sqlx.In(constraint, args)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif activeOnly {\n\t\targs = append(args, t.StateOK)\n\t\tconstraint += \"AND state=? \"\n\t}\n\tconstraint = sqlx.Rebind(sqlx.DOLLAR, constraint)\n\n\tvar matcher string\n\tif promoPrefix != \"\" {\n\t\t// The max number of tags is 16. Using 20 to make sure one prefix match is greater than all non-prefix matches.\n\t\tmatcher = \"SUM(CASE WHEN POSITION('\" + promoPrefix + \"' IN tg.tag)=1 THEN 20 ELSE 1 END)\"\n\t} else {\n\t\tmatcher = \"COUNT(*)\"\n\t}\n\n\tquery := \"SELECT CAST(u.id AS VARCHAR) AS topic,u.createdat,u.updatedat,FALSE,u.access::jsonb,0 AS subcnt,u.public::jsonb,u.trusted::jsonb,u.tags::jsonb,\" +\n\t\tmatcher + \" AS matches \" +\n\t\t\"FROM users AS u JOIN usertags AS tg ON tg.userid=u.id \" +\n\t\t\"WHERE \" + constraint +\n\t\t\"GROUP BY u.id,u.createdat,u.updatedat,u.access::jsonb,u.public::jsonb,u.trusted::jsonb,u.tags::jsonb \"\n\n\thaving := \"\"\n\tif len(allReq) > 0 {\n\t\tvar a []any\n\t\thaving, a = common.DisjunctionSql(req, \"tg.tag\")\n\t\thaving = rebindWithStart(having, len(args)+1)\n\t\tquery += having\n\t\targs = append(args, a...)\n\t}\n\n\tquery += \"UNION ALL \"\n\n\tquery += \"SELECT t.name AS topic,t.createdat,t.updatedat,t.usebt,t.access::jsonb,t.subcnt,t.public::jsonb,t.trusted::jsonb,t.tags::jsonb,\" +\n\t\tmatcher + \" AS matches \" +\n\t\t\"FROM topics AS t JOIN topictags AS tg ON t.name=tg.topic \" +\n\t\t\"WHERE \" + constraint +\n\t\t\"GROUP BY t.name,t.createdat,t.updatedat,t.usebt,t.access::jsonb,t.subcnt,t.public::jsonb,t.trusted::jsonb,t.tags::jsonb \"\n\tif having != \"\" {\n\t\tquery += having\n\t}\n\targs = append(args, a.maxResults)\n\tquery += \"ORDER BY matches DESC, subcnt DESC LIMIT $\" + strconv.Itoa(len(args))\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\t// Get users matched by tags, sort by number of matches from high to low.\n\trows, err := a.db.Query(ctx, query, args...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\t// Fetch subscriptions\n\tvar public, trusted any\n\tvar access t.DefaultAccess\n\tvar subcnt int\n\tvar setTags t.StringSlice\n\tvar ignored int\n\tvar isChan bool\n\tvar sub t.Subscription\n\tvar subs []t.Subscription\n\tfor rows.Next() {\n\t\tif err = rows.Scan(&sub.Topic, &sub.CreatedAt, &sub.UpdatedAt, &isChan, &access, &subcnt,\n\t\t\t&public, &trusted, &setTags, &ignored); err != nil {\n\t\t\tsubs = nil\n\t\t\tbreak\n\t\t}\n\n\t\tif id, err := strconv.ParseInt(sub.Topic, 10, 64); err == nil {\n\t\t\tsub.Topic = store.EncodeUid(id).UserId()\n\t\t\tif sub.Topic == caller {\n\t\t\t\t// Skip the caller.\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\tif isChan {\n\t\t\t// This is a channel, convert grp to chn name.\n\t\t\tsub.Topic = t.GrpToChn(sub.Topic)\n\t\t}\n\n\t\tsub.SetSubCnt(subcnt)\n\t\tsub.SetPublic(public)\n\t\tsub.SetTrusted(trusted)\n\t\tsub.SetDefaultAccess(access.Auth, access.Anon)\n\t\t// Indicating that the mode is not set, not 'N'.\n\t\tsub.ModeGiven = t.ModeUnset\n\t\tsub.ModeWant = t.ModeUnset\n\t\tsub.Private = common.FilterFoundTags(setTags, index)\n\t\tsubs = append(subs, sub)\n\t}\n\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\treturn subs, err\n\n}\n\n// FindOne returns topic or user which matches the given tag.\nfunc (a *adapter) FindOne(tag string) (string, error) {\n\tvar args []any\n\tquery := \"SELECT t.name AS topic FROM topics AS t LEFT JOIN topictags AS tt ON t.name=tt.topic \" +\n\t\t\"WHERE tt.tag=?\"\n\targs = append(args, tag)\n\n\tquery += \" UNION ALL \"\n\n\tquery += \"SELECT CAST(u.id AS VARCHAR) AS topic FROM users AS u LEFT JOIN usertags AS ut ON ut.userid=u.id \" +\n\t\t\"WHERE ut.tag=?\"\n\targs = append(args, tag)\n\n\t// LIMIT is applied to all resultant rows.\n\tquery += \" LIMIT 1\"\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\tquery, args = expandQuery(query, args)\n\trows, err := a.db.Query(ctx, query, args...)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer rows.Close()\n\n\tvar found string\n\tif rows.Next() {\n\t\tif err = rows.Scan(&found); err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\n\t\t// Check if the found value is a topic name or a user ID.\n\t\t// User IDs are returned as decoded decimal strings.\n\t\tif id, err := strconv.ParseInt(found, 10, 64); err == nil {\n\t\t\tfound = store.EncodeUid(id).UserId()\n\t\t}\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\treturn found, err\n}\n\n// Messages\nfunc (a *adapter) MessageSave(msg *t.Message) error {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\t// store assignes message ID, but we don't use it. Message IDs are not used anywhere.\n\t// Using a sequential ID provided by the database.\n\tvar id int\n\terr := a.db.QueryRow(ctx,\n\t\t`INSERT INTO messages(createdAt,updatedAt,seqid,topic,\"from\",head,content) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING id`,\n\t\tmsg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic,\n\t\tstore.DecodeUid(t.ParseUid(msg.From)), msg.Head, common.ToJSON(msg.Content)).Scan(&id)\n\tif err == nil {\n\t\t// Replacing ID given by store by ID given by the DB.\n\t\tmsg.SetUid(t.Uid(id))\n\t}\n\treturn err\n}\n\nfunc (a *adapter) MessageGetAll(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.Message, error) {\n\tvar limit = a.maxMessageResults\n\n\targs := []any{store.DecodeUid(forUser), topic}\n\tseqIdConstraint := \"\"\n\tif opts != nil {\n\t\tseqIdConstraint = \"AND m.seqid \"\n\t\tif len(opts.IdRanges) > 0 {\n\t\t\tconstr, newargs := common.RangesToSql(opts.IdRanges)\n\t\t\tseqIdConstraint += constr\n\t\t\targs = append(args, newargs...)\n\t\t} else {\n\t\t\tseqIdConstraint += \"BETWEEN ? AND ?\"\n\t\t\tif opts.Since > 0 {\n\t\t\t\targs = append(args, opts.Since)\n\t\t\t} else {\n\t\t\t\targs = append(args, 0)\n\t\t\t}\n\t\t\tif opts.Before > 0 {\n\t\t\t\t// BETWEEN is inclusive-inclusive, Tinode API requires inclusive-exclusive, thus -1\n\t\t\t\targs = append(args, opts.Before-1)\n\t\t\t} else {\n\t\t\t\targs = append(args, 1<<31-1)\n\t\t\t}\n\t\t}\n\n\t\tif opts.Limit > 0 && opts.Limit < limit {\n\t\t\tlimit = opts.Limit\n\t\t}\n\t}\n\n\targs = append(args, limit)\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\tquery, args := expandQuery(`SELECT m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.\"from\",m.head,m.content`+\n\t\t\" FROM messages AS m LEFT JOIN dellog AS d\"+\n\t\t\" ON d.topic=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=?\"+\n\t\t\" WHERE m.delid=0 AND m.topic=? \"+seqIdConstraint+\" AND d.deletedfor IS NULL\"+\n\t\t\" ORDER BY m.seqid DESC LIMIT ?\", args...)\n\trows, err := a.db.Query(ctx, query, args...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tmsgs := make([]t.Message, 0, limit)\n\tfor rows.Next() {\n\t\tvar msg t.Message\n\t\tvar from int64\n\t\tif err = rows.Scan(&msg.CreatedAt, &msg.UpdatedAt, &msg.DeletedAt, &msg.DelId, &msg.SeqId,\n\t\t\t&msg.Topic, &from, &msg.Head, &msg.Content); err != nil {\n\t\t\tbreak\n\t\t}\n\t\tmsg.From = store.EncodeUid(from).String()\n\t\tmsgs = append(msgs, msg)\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\treturn msgs, err\n}\n\n// Get ranges of deleted messages\nfunc (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) {\n\tvar limit = a.maxResults\n\tvar lower = 0\n\tvar upper = 1<<31 - 1\n\n\tif opts != nil {\n\t\tif opts.Since > 0 {\n\t\t\tlower = opts.Since\n\t\t}\n\t\tif opts.Before > 1 {\n\t\t\t// DelRange is inclusive-exclusive, while BETWEEN is inclusive-inclisive.\n\t\t\tupper = opts.Before - 1\n\t\t}\n\n\t\tif opts.Limit > 0 && opts.Limit < limit {\n\t\t\tlimit = opts.Limit\n\t\t}\n\t}\n\n\t// Fetch log of deletions\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\trows, err := a.db.Query(ctx, \"SELECT topic,deletedfor,delid,low,hi FROM dellog WHERE topic=$1 AND delid BETWEEN $2 AND $3\"+\n\t\t\" AND (deletedFor=0 OR deletedFor=$4) ORDER BY delid LIMIT $5\",\n\t\ttopic, lower, upper, store.DecodeUid(forUser), limit)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tvar dellog struct {\n\t\tTopic      string\n\t\tDeletedfor int64\n\t\tDelid      int\n\t\tLow        int\n\t\tHi         int\n\t}\n\tvar dmsgs []t.DelMessage\n\tvar dmsg t.DelMessage\n\tfor rows.Next() {\n\t\tif err = rows.Scan(&dellog.Topic, &dellog.Deletedfor, &dellog.Delid, &dellog.Low, &dellog.Hi); err != nil {\n\t\t\tdmsgs = nil\n\t\t\tbreak\n\t\t}\n\n\t\tif dellog.Delid != dmsg.DelId {\n\t\t\tif dmsg.DelId > 0 {\n\t\t\t\tdmsgs = append(dmsgs, dmsg)\n\t\t\t}\n\t\t\tdmsg.DelId = dellog.Delid\n\t\t\tdmsg.Topic = dellog.Topic\n\t\t\tif dellog.Deletedfor > 0 {\n\t\t\t\tdmsg.DeletedFor = store.EncodeUid(dellog.Deletedfor).String()\n\t\t\t} else {\n\t\t\t\tdmsg.DeletedFor = \"\"\n\t\t\t}\n\t\t\tdmsg.SeqIdRanges = nil\n\t\t}\n\t\tif dellog.Hi <= dellog.Low+1 {\n\t\t\tdellog.Hi = 0\n\t\t}\n\t\tdmsg.SeqIdRanges = append(dmsg.SeqIdRanges, t.Range{Low: dellog.Low, Hi: dellog.Hi})\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\tif err == nil {\n\t\tif dmsg.DelId > 0 {\n\t\t\tdmsgs = append(dmsgs, dmsg)\n\t\t}\n\t}\n\n\treturn dmsgs, err\n}\n\nfunc messageDeleteList(ctx context.Context, tx pgx.Tx, topic string, toDel *t.DelMessage) error {\n\tvar err error\n\n\tif toDel == nil {\n\t\t// Whole topic is being deleted, thus also deleting all messages.\n\t\t_, err = tx.Exec(ctx, \"DELETE FROM dellog WHERE topic=$1\", topic)\n\t\tif err == nil {\n\t\t\t_, err = tx.Exec(ctx, \"DELETE FROM messages WHERE topic=$1\", topic)\n\t\t}\n\t\t// filemsglinks will be deleted because of ON DELETE CASCADE\n\t\treturn err\n\t}\n\n\t// Only some messages are being deleted\n\n\tdelRanges := toDel.SeqIdRanges\n\n\tif toDel.DeletedFor == \"\" {\n\t\t// Hard-deleting messages requires updates to the messages table.\n\t\twhere := \"m.topic=? \"\n\t\targs := []any{topic}\n\n\t\tif len(delRanges) > 0 {\n\t\t\trSql, rArgs := common.RangesToSql(delRanges)\n\t\t\twhere += \" AND m.seqid \" + rSql\n\t\t\targs = append(args, rArgs...)\n\t\t}\n\n\t\twhere += \" AND m.deletedat IS NULL\"\n\n\t\t// We are asked to delete messages no older than newerThan.\n\t\tif newerThan := toDel.GetNewerThan(); newerThan != nil {\n\t\t\twhere += \" AND m.createdat>?\"\n\t\t\targs = append(args, newerThan)\n\t\t}\n\n\t\t// Find the actual IDs still present in the database.\n\t\tvar seqIDs []int\n\t\tquery, newargs := expandQuery(\"SELECT seqid FROM messages AS m WHERE \"+where, args)\n\t\trows, err := tx.Query(ctx, query, newargs...)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer rows.Close()\n\n\t\tfor rows.Next() {\n\t\t\tvar seqID int\n\t\t\tif err := rows.Scan(&seqID); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tseqIDs = append(seqIDs, seqID)\n\t\t}\n\t\tif err = rows.Err(); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(seqIDs) == 0 {\n\t\t\t// Nothing to delete. No need to make a log entry. All done.\n\t\t\treturn nil\n\t\t}\n\n\t\t// Recalculate the actual ranges to delete.\n\t\tsort.Ints(seqIDs)\n\t\tdelRanges = t.SliceToRanges(seqIDs)\n\n\t\t// Compose a new query with the new ranges.\n\t\twhere = \"m.topic=?\"\n\t\targs = []any{topic}\n\t\trSql, rArgs := common.RangesToSql(delRanges)\n\t\twhere += \" AND m.seqid \" + rSql\n\t\targs = append(args, rArgs...)\n\n\t\t// No need to add anything else: deletedat etc is already accounted for.\n\n\t\tquery, newargs = expandQuery(\"DELETE FROM filemsglinks AS fml USING messages AS m WHERE m.id=fml.msgid AND \"+\n\t\t\twhere, args...)\n\t\t_, err = tx.Exec(ctx, query, newargs...)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tquery, newargs = expandQuery(`UPDATE messages AS m SET deletedat=?,delid=?,\"from\"=0,head=NULL,content=NULL WHERE `+\n\t\t\twhere, t.TimeNow(), toDel.DelId, args)\n\t\t_, err = tx.Exec(ctx, query, newargs...)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Now make log entries. Needed for both hard- and soft-deleting.\n\n\t// Prepare statement is not needed because the driver prepares the statement on first use then caches it.\n\tforUser := common.DecodeUidString(toDel.DeletedFor)\n\tfor _, rng := range toDel.SeqIdRanges {\n\t\tif rng.Hi == 0 {\n\t\t\t// Dellog must contain valid Low and *Hi*.\n\t\t\trng.Hi = rng.Low + 1\n\t\t}\n\n\t\tif _, err = tx.Exec(ctx, \"INSERT INTO dellog(topic,deletedfor,delid,low,hi) VALUES($1,$2,$3,$4,$5)\",\n\t\t\ttopic, forUser, toDel.DelId, rng.Low, rng.Hi); err != nil {\n\t\t\tbreak\n\t\t}\n\t}\n\n\treturn err\n}\n\n// MessageDeleteList deletes messages in the given topic with seqIds from the list.\nfunc (a *adapter) MessageDeleteList(topic string, toDel *t.DelMessage) (err error) {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTx(ctx, pgx.TxOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback(ctx)\n\t\t}\n\t}()\n\n\tif err = messageDeleteList(ctx, tx, topic, toDel); err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit(ctx)\n}\n\nfunc deviceHasher(deviceID string) string {\n\t// Generate custom key as [64-bit hash of device id] to ensure predictable\n\t// length of the key\n\thasher := fnv.New64()\n\thasher.Write([]byte(deviceID))\n\treturn strconv.FormatUint(uint64(hasher.Sum64()), 16)\n}\n\n// Device management for push notifications\nfunc (a *adapter) DeviceUpsert(uid t.Uid, def *t.DeviceDef) error {\n\thash := deviceHasher(def.DeviceId)\n\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTx(ctx, pgx.TxOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback(ctx)\n\t\t}\n\t}()\n\n\t// Ensure uniqueness of the device ID: delete all records of the device ID\n\t_, err = tx.Exec(ctx, \"DELETE FROM devices WHERE hash=$1\", hash)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Actually add/update DeviceId for the new user\n\t_, err = tx.Exec(ctx, \"INSERT INTO devices(userid, hash, deviceId, platform, lastseen, lang) VALUES($1,$2,$3,$4,$5,$6)\",\n\t\tstore.DecodeUid(uid), hash, def.DeviceId, def.Platform, def.LastSeen, def.Lang)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit(ctx)\n}\n\nfunc (a *adapter) DeviceGetAll(uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {\n\tvar unums []any\n\tfor _, uid := range uids {\n\t\tunums = append(unums, store.DecodeUid(uid))\n\t}\n\n\tquery, unums := expandQuery(\"SELECT userid,deviceid,platform,lastseen,lang FROM devices WHERE userid IN (?)\", unums)\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\trows, err := a.db.Query(ctx, query, unums...)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\tdefer rows.Close()\n\n\tvar device struct {\n\t\tUserid   int64\n\t\tDeviceid string\n\t\tPlatform string\n\t\tLastseen time.Time\n\t\tLang     string\n\t}\n\n\tresult := make(map[t.Uid][]t.DeviceDef)\n\tcount := 0\n\tfor rows.Next() {\n\t\tif err = rows.Scan(&device.Userid, &device.Deviceid, &device.Platform, &device.Lastseen, &device.Lang); err != nil {\n\t\t\tbreak\n\t\t}\n\t\tuid := store.EncodeUid(device.Userid)\n\t\tudev := result[uid]\n\t\tudev = append(udev, t.DeviceDef{\n\t\t\tDeviceId: device.Deviceid,\n\t\t\tPlatform: device.Platform,\n\t\t\tLastSeen: device.Lastseen,\n\t\t\tLang:     device.Lang,\n\t\t})\n\t\tresult[uid] = udev\n\t\tcount++\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\treturn result, count, err\n}\n\nfunc deviceDelete(ctx context.Context, tx pgx.Tx, uid t.Uid, deviceID string) error {\n\tvar err error\n\tvar res pgconn.CommandTag\n\tif deviceID == \"\" {\n\t\tres, err = tx.Exec(ctx, \"DELETE FROM devices WHERE userid=$1\", store.DecodeUid(uid))\n\t} else {\n\t\tres, err = tx.Exec(ctx, \"DELETE FROM devices WHERE userid=$1 AND hash=$2\", store.DecodeUid(uid), deviceHasher(deviceID))\n\t}\n\n\tif err == nil {\n\t\tif count := res.RowsAffected(); count == 0 {\n\t\t\terr = t.ErrNotFound\n\t\t}\n\t}\n\n\treturn err\n}\n\nfunc (a *adapter) DeviceDelete(uid t.Uid, deviceID string) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTx(ctx, pgx.TxOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback(ctx)\n\t\t}\n\t}()\n\n\terr = deviceDelete(ctx, tx, uid, deviceID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit(ctx)\n}\n\n// Credential management\n\n// CredUpsert adds or updates a validation record. Returns true if inserted, false if updated.\n// 1. if credential is validated:\n// 1.1 Hard-delete unconfirmed equivalent record, if exists.\n// 1.2 Insert new. Report error if duplicate.\n// 2. if credential is not validated:\n// 2.1 Check if validated equivalent exist. If so, report an error.\n// 2.2 Soft-delete all unvalidated records of the same method.\n// 2.3 Undelete existing credential. Return if successful.\n// 2.4 Insert new credential record.\nfunc (a *adapter) CredUpsert(cred *t.Credential) (bool, error) {\n\tvar err error\n\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTx(ctx, pgx.TxOptions{})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback(ctx)\n\t\t}\n\t}()\n\n\tnow := t.TimeNow()\n\tuserId := common.DecodeUidString(cred.User)\n\n\t// Enforce uniqueness: if credential is confirmed, \"method:value\" must be unique.\n\t// if credential is not yet confirmed, \"userid:method:value\" is unique.\n\tsynth := cred.Method + \":\" + cred.Value\n\n\tif !cred.Done {\n\t\t// Check if this credential is already validated.\n\t\tvar done bool\n\t\terr = tx.QueryRow(ctx, \"SELECT done FROM credentials WHERE synthetic=$1\", synth).Scan(&done)\n\t\tif err == nil {\n\t\t\t// Assign err to ensure closing of a transaction.\n\t\t\terr = t.ErrDuplicate\n\t\t\treturn false, err\n\t\t}\n\t\tif err != pgx.ErrNoRows {\n\t\t\treturn false, err\n\t\t}\n\t\t// We are going to insert new record.\n\t\tsynth = cred.User + \":\" + synth\n\n\t\t// Adding new unvalidated credential. Deactivate all unvalidated records of this user and method.\n\t\t_, err = tx.Exec(ctx, \"UPDATE credentials SET deletedat=$1 WHERE userid=$2 AND method=$3 AND done=FALSE\",\n\t\t\tnow, userId, cred.Method)\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\t\t// Assume that the record exists and try to update it: undelete, update timestamp and response value.\n\t\tres, err := tx.Exec(ctx, \"UPDATE credentials SET updatedat=$1,deletedat=NULL,resp=$2,done=FALSE WHERE synthetic=$3\",\n\t\t\tcred.UpdatedAt, cred.Resp, synth)\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\t\t// If record was updated, then all is fine.\n\t\tif numrows := res.RowsAffected(); numrows > 0 {\n\t\t\treturn false, tx.Commit(ctx)\n\t\t}\n\t} else {\n\t\t// Hard-deleting unconformed record if it exists.\n\t\t_, err = tx.Exec(ctx, \"DELETE FROM credentials WHERE synthetic=$1\", cred.User+\":\"+synth)\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\n\t_, err = tx.Exec(ctx, \"INSERT INTO credentials(createdat,updatedat,method,value,synthetic,userid,resp,done) \"+\n\t\t\"VALUES($1,$2,$3,$4,$5,$6,$7,$8)\",\n\t\tcred.CreatedAt, cred.UpdatedAt, cred.Method, cred.Value, synth, userId, cred.Resp, cred.Done)\n\tif err != nil {\n\t\tif isDupe(err) {\n\t\t\treturn true, t.ErrDuplicate\n\t\t}\n\t\treturn true, err\n\t}\n\treturn true, tx.Commit(ctx)\n}\n\n// credDel deletes given validation method or all methods of the given user.\n// 1. If user is being deleted, hard-delete all records (method == \"\")\n// 2. If one value is being deleted:\n// 2.1 Delete it if it's valiated or if there were no attempts at validation\n// (otherwise it could be used to circumvent the limit on validation attempts).\n// 2.2 In that case mark it as soft-deleted.\nfunc credDel(ctx context.Context, tx pgx.Tx, uid t.Uid, method, value string) error {\n\tconstraints := \" WHERE userid=?\"\n\targs := []any{store.DecodeUid(uid)}\n\n\tif method != \"\" {\n\t\tconstraints += \" AND method=?\"\n\t\targs = append(args, method)\n\n\t\tif value != \"\" {\n\t\t\tconstraints += \" AND value=?\"\n\t\t\targs = append(args, value)\n\t\t}\n\t}\n\twhere, _ := expandQuery(constraints, args...)\n\n\tvar err error\n\tvar res pgconn.CommandTag\n\tif method == \"\" {\n\t\t// Case 1\n\t\tres, err = tx.Exec(ctx, \"DELETE FROM credentials\"+where, args...)\n\t\tif err == nil {\n\t\t\tif count := res.RowsAffected(); count == 0 {\n\t\t\t\terr = t.ErrNotFound\n\t\t\t}\n\t\t}\n\t\treturn err\n\t}\n\n\t// Case 2.1\n\tres, err = tx.Exec(ctx, \"DELETE FROM credentials\"+where+\" AND (done=TRUE OR retries=0)\", args...)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif count := res.RowsAffected(); count > 0 {\n\t\treturn nil\n\t}\n\n\t// Case 2.2\n\tquery, args := expandQuery(\"UPDATE credentials SET deletedat=?\"+constraints, t.TimeNow(), args)\n\tres, err = tx.Exec(ctx, query, args...)\n\tif err == nil {\n\t\tif count := res.RowsAffected(); count >= 0 {\n\t\t\terr = t.ErrNotFound\n\t\t}\n\t}\n\n\treturn err\n}\n\n// CredDel deletes either credentials of the given user. If method is blank all\n// credentials are removed. If value is blank all credentials of the given the\n// method are removed.\nfunc (a *adapter) CredDel(uid t.Uid, method, value string) error {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTx(ctx, pgx.TxOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback(ctx)\n\t\t}\n\t}()\n\n\terr = credDel(ctx, tx, uid, method, value)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit(ctx)\n}\n\n// CredConfirm marks given credential method as confirmed.\nfunc (a *adapter) CredConfirm(uid t.Uid, method string) error {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tres, err := a.db.Exec(\n\t\tctx,\n\t\t\"UPDATE credentials SET updatedat=$1,done=TRUE,synthetic=CONCAT(method,':',value) \"+\n\t\t\t\"WHERE userid=$2 AND method=$3 AND deletedat IS NULL AND done=FALSE\",\n\t\tt.TimeNow(), store.DecodeUid(uid), method)\n\tif err != nil {\n\t\tif isDupe(err) {\n\t\t\treturn t.ErrDuplicate\n\t\t}\n\t\treturn err\n\t}\n\tif numrows := res.RowsAffected(); numrows < 1 {\n\t\treturn t.ErrNotFound\n\t}\n\treturn nil\n}\n\n// CredFail increments failure count of the given validation method.\nfunc (a *adapter) CredFail(uid t.Uid, method string) error {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\t_, err := a.db.Exec(ctx, \"UPDATE credentials SET updatedat=$1,retries=retries+1 WHERE userid=$2 AND method=$3 AND done=FALSE\",\n\t\tt.TimeNow(), store.DecodeUid(uid), method)\n\treturn err\n}\n\n// CredGetActive returns currently active unvalidated credential of the given user and method.\nfunc (a *adapter) CredGetActive(uid t.Uid, method string) (*t.Credential, error) {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tvar cred t.Credential\n\n\terr := a.db.QueryRow(ctx, \"SELECT createdat,updatedat,method,value,resp,done,retries \"+\n\t\t\"FROM credentials WHERE userid=$1 AND deletedat IS NULL AND method=$2 AND done=FALSE\",\n\t\tstore.DecodeUid(uid), method).Scan(&cred.CreatedAt, &cred.UpdatedAt, &cred.Method, &cred.Value, &cred.Resp, &cred.Done, &cred.Retries)\n\tif err != nil {\n\t\tif err == pgx.ErrNoRows {\n\t\t\terr = nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tcred.User = uid.String()\n\n\treturn &cred, nil\n}\n\n// CredGetAll returns credential records for the given user and method, all or validated only.\nfunc (a *adapter) CredGetAll(uid t.Uid, method string, validatedOnly bool) ([]t.Credential, error) {\n\tquery := \"SELECT createdat,updatedat,method,value,resp,done,retries FROM credentials WHERE userid=$1 AND deletedat IS NULL\"\n\targs := []any{store.DecodeUid(uid)}\n\tif method != \"\" {\n\t\tquery += \" AND method=$2\"\n\t\targs = append(args, method)\n\t}\n\tif validatedOnly {\n\t\tquery += \" AND done=TRUE\"\n\t}\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tvar credentials []t.Credential\n\trows, err := a.db.Query(ctx, query, args...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tvar cred t.Credential\n\t\tif err = rows.Scan(&cred.CreatedAt, &cred.UpdatedAt, &cred.Method, &cred.Value, &cred.Resp, &cred.Done, &cred.Retries); err != nil {\n\t\t\tcredentials = nil\n\t\t\tbreak\n\t\t}\n\n\t\tcredentials = append(credentials, cred)\n\t}\n\n\tuser := uid.String()\n\tfor i := range credentials {\n\t\tcredentials[i].User = user\n\t}\n\n\treturn credentials, err\n}\n\n// FileUploads\n\n// FileStartUpload initializes a file upload\nfunc (a *adapter) FileStartUpload(fd *t.FileDef) error {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tvar user any\n\tif fd.User != \"\" {\n\t\tuser = store.DecodeUid(t.ParseUid(fd.User))\n\t}\n\t_, err := a.db.Exec(ctx,\n\t\t\"INSERT INTO fileuploads(id,createdat,updatedat,userid,status,mimetype,size,etag,location) \"+\n\t\t\t\"VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)\",\n\t\tstore.DecodeUid(fd.Uid()), fd.CreatedAt, fd.UpdatedAt, user,\n\t\tfd.Status, fd.MimeType, fd.Size, fd.ETag, fd.Location)\n\treturn err\n}\n\n// FileFinishUpload marks file upload as completed, successfully or otherwise\nfunc (a *adapter) FileFinishUpload(fd *t.FileDef, success bool, size int64) (*t.FileDef, error) {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTx(ctx, pgx.TxOptions{})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback(ctx)\n\t\t}\n\t}()\n\n\tnow := t.TimeNow()\n\tif success {\n\t\t_, err = tx.Exec(ctx, \"UPDATE fileuploads SET updatedat=$1,status=$2,size=$3,etag=$4,location=$5 WHERE id=$6\",\n\t\t\tnow, t.UploadCompleted, size, fd.ETag, fd.Location, store.DecodeUid(fd.Uid()))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfd.Status = t.UploadCompleted\n\t\tfd.Size = size\n\t} else {\n\t\t// Deleting the record: there is no value in keeping it in the DB.\n\t\t_, err = tx.Exec(ctx, \"DELETE FROM fileuploads WHERE id=$1\", store.DecodeUid(fd.Uid()))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfd.Status = t.UploadFailed\n\t\tfd.Size = 0\n\t}\n\tfd.UpdatedAt = now\n\n\treturn fd, tx.Commit(ctx)\n}\n\n// FileGet fetches a record of a specific file\nfunc (a *adapter) FileGet(fid string) (*t.FileDef, error) {\n\tid := t.ParseUid(fid)\n\tif id.IsZero() {\n\t\treturn nil, t.ErrMalformed\n\t}\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\tvar fd t.FileDef\n\tvar ID int64\n\tvar userId int64\n\terr := a.db.QueryRow(ctx, \"SELECT id,createdat,updatedat,userid AS user,status,mimetype,size,etag,location \"+\n\t\t\"FROM fileuploads WHERE id=$1\", store.DecodeUid(id)).Scan(&ID, &fd.CreatedAt, &fd.UpdatedAt, &userId, &fd.Status,\n\t\t&fd.MimeType, &fd.Size, &fd.ETag, &fd.Location)\n\tif err == pgx.ErrNoRows {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfd.Id = common.EncodeUidString(fd.Id).String()\n\tfd.User = store.EncodeUid(userId).String()\n\n\treturn &fd, nil\n}\n\n// FileDeleteUnused deletes file upload records.\nfunc (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, error) {\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTx(ctx, pgx.TxOptions{})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback(ctx)\n\t\t}\n\t}()\n\n\t// Garbage collecting entries which as either marked as deleted, or lack message references, or have no user assigned.\n\tquery := \"SELECT fu.id,fu.location FROM fileuploads AS fu LEFT JOIN filemsglinks AS fml ON fml.fileid=fu.id \" +\n\t\t\"WHERE fml.id IS NULL\"\n\tvar args []any\n\tif !olderThan.IsZero() {\n\t\tquery += \" AND fu.updatedat<?\"\n\t\targs = append(args, olderThan)\n\t}\n\tif limit > 0 {\n\t\tquery += \" LIMIT ?\"\n\t\targs = append(args, limit)\n\t}\n\tquery, _ = expandQuery(query, args...)\n\n\trows, err := tx.Query(ctx, query, args...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tvar locations []string\n\tvar ids []any\n\tfor rows.Next() {\n\t\tvar id int\n\t\tvar loc string\n\t\tif err = rows.Scan(&id, &loc); err != nil {\n\t\t\tbreak\n\t\t}\n\t\tif loc != \"\" {\n\t\t\tlocations = append(locations, loc)\n\t\t}\n\t\tids = append(ids, id)\n\t}\n\tif err == nil {\n\t\terr = rows.Err()\n\t}\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(ids) > 0 {\n\t\tquery, ids = expandQuery(\"DELETE FROM fileuploads WHERE id IN (?)\", ids)\n\t\t_, err = tx.Exec(ctx, query, ids...)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn locations, tx.Commit(ctx)\n}\n\n// FileLinkAttachments connects given topic or message to the file record IDs from the list.\nfunc (a *adapter) FileLinkAttachments(topic string, userId, msgId t.Uid, fids []string) error {\n\tif len(fids) == 0 || (topic == \"\" && msgId.IsZero() && userId.IsZero()) {\n\t\treturn t.ErrMalformed\n\t}\n\tnow := t.TimeNow()\n\n\tvar args []any\n\tvar linkId any\n\tvar linkBy string\n\tif !msgId.IsZero() {\n\t\tlinkBy = \"msgid\"\n\t\tlinkId = int64(msgId)\n\t} else if topic != \"\" {\n\t\tlinkBy = \"topic\"\n\t\tlinkId = topic\n\t\t// Only one attachment per topic is permitted at this time.\n\t\tfids = fids[0:1]\n\t} else {\n\t\tlinkBy = \"userid\"\n\t\tlinkId = store.DecodeUid(userId)\n\t\t// Only one attachment per user is permitted at this time.\n\t\tfids = fids[0:1]\n\t}\n\n\t// Decoded ids\n\tvar dids []any\n\tfor _, fid := range fids {\n\t\tid := t.ParseUid(fid)\n\t\tif id.IsZero() {\n\t\t\treturn t.ErrMalformed\n\t\t}\n\t\tdids = append(dids, store.DecodeUid(id))\n\t}\n\n\tfor _, id := range dids {\n\t\t// createdat,fileid,[msgid|topic|userid]\n\t\targs = append(args, now, id, linkId)\n\t}\n\n\tctx, cancel := a.getContextForTx()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\ttx, err := a.db.BeginTx(ctx, pgx.TxOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() {\n\t\tif err != nil {\n\t\t\ttx.Rollback(ctx)\n\t\t}\n\t}()\n\n\t// Unlink earlier uploads on the same topic or user allowing them to be garbage-collected.\n\tif msgId.IsZero() {\n\t\tsql := \"DELETE FROM filemsglinks WHERE \" + linkBy + \"=$1\"\n\t\t_, err = tx.Exec(ctx, sql, linkId)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tquery, args := expandQuery(\"INSERT INTO filemsglinks(createdat,fileid,\"+linkBy+\") VALUES (?,?,?)\"+\n\t\tstrings.Repeat(\",(?,?,?)\", len(dids)-1), args...)\n\t_, err = tx.Exec(ctx, query, args...)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit(ctx)\n}\n\n// PCacheGet reads a persistet cache entry.\nfunc (a *adapter) PCacheGet(key string) (string, error) {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\tvar value string\n\tif err := a.db.QueryRow(ctx, `SELECT \"value\" FROM kvmeta WHERE \"key\"=$1 LIMIT 1`, key).Scan(&value); err != nil {\n\t\tif err == pgx.ErrNoRows {\n\t\t\treturn \"\", t.ErrNotFound\n\t\t}\n\t\treturn \"\", err\n\t}\n\treturn value, nil\n}\n\n// PCacheUpsert creates or updates a persistent cache entry.\nfunc (a *adapter) PCacheUpsert(key string, value string, failOnDuplicate bool) error {\n\tif strings.Contains(key, \"%\") {\n\t\t// Do not allow % in keys: it interferes with LIKE query.\n\t\treturn t.ErrMalformed\n\t}\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\tvar action string\n\tif !failOnDuplicate {\n\t\taction = ` ON CONFLICT (\"key\") DO UPDATE SET createdat=$2,\"value\"=$3`\n\t}\n\n\t_, err := a.db.Exec(ctx, `INSERT INTO kvmeta(\"key\",createdat,\"value\") VALUES($1,$2,$3)`+action,\n\t\tkey, t.TimeNow(), value)\n\tif isDupe(err) {\n\t\treturn t.ErrDuplicate\n\t}\n\treturn err\n}\n\n// PCacheDelete deletes one persistent cache entry.\nfunc (a *adapter) PCacheDelete(key string) error {\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\t_, err := a.db.Exec(ctx, `DELETE FROM kvmeta WHERE \"key\"=$1`, key)\n\treturn err\n}\n\n// PCacheExpire expires old entries with the given key prefix.\nfunc (a *adapter) PCacheExpire(keyPrefix string, olderThan time.Time) error {\n\tif keyPrefix == \"\" {\n\t\treturn t.ErrMalformed\n\t}\n\n\tctx, cancel := a.getContext()\n\tif cancel != nil {\n\t\tdefer cancel()\n\t}\n\n\t_, err := a.db.Exec(ctx, `DELETE FROM kvmeta WHERE \"key\" LIKE $1 AND createdat<$2`, keyPrefix+\"%\", olderThan)\n\treturn err\n}\n\n// GetTestDB returns a currently open database connection.\nfunc (a *adapter) GetTestDB() any {\n\treturn a.db\n}\n\n// Helper functions\n\n// Check if MySQL error is a Error Code: 1062. Duplicate entry ... for key ...\nfunc isDupe(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\n\tmsg := err.Error()\n\treturn strings.Contains(msg, \"SQLSTATE 23505\")\n}\n\nfunc isMissingTable(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\n\tmsg := err.Error()\n\treturn strings.Contains(msg, \"SQLSTATE 42P01\")\n}\n\nfunc isMissingDb(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\n\tmsg := err.Error()\n\treturn strings.Contains(msg, \"SQLSTATE 3D000\")\n}\n\n// setConnStr converts a config structure to a DSN connection string.\nfunc setConnStr(c configType) (string, error) {\n\t// Default to disable SSL mode.\n\tsslMode := \"disable\"\n\tif c.SSLMode != \"\" {\n\t\tsslMode = c.SSLMode\n\t}\n\n\tif c.User == \"\" || c.Passwd == \"\" || c.Host == \"\" || c.Port == \"\" || c.DBName == \"\" {\n\t\treturn \"\", errors.New(\"adapter postgres invalid config value\")\n\t}\n\tconnStr := fmt.Sprintf(\"postgres://%s:%s@%s:%s/%s?sslmode=%s&connect_timeout=%d\",\n\t\tc.User,\n\t\tc.Passwd,\n\t\tc.Host,\n\t\tc.Port,\n\t\tc.DBName,\n\t\tsslMode,\n\t\tc.SqlTimeout)\n\n\treturn connStr, nil\n}\n\n// expandQuery replaces the placeholders in the query with the actual values and returns\n// the expanded query and the arguments to be used in the query.\nfunc expandQuery(query string, args ...any) (string, []any) {\n\tvar expandedArgs []any\n\tvar expandedQuery string\n\n\tif len(args) != strings.Count(query, \"?\") {\n\t\targs = flattenSlice(args)\n\t}\n\texpandedQuery, expandedArgs, _ = sqlx.In(query, args...)\n\treturn sqlx.Rebind(sqlx.DOLLAR, expandedQuery), expandedArgs\n}\n\n// flatMap converts a slice of mixed values/slices into a flat slice.\nfunc flattenSlice(slice []any) []any {\n\tvar result []any\n\tfor _, v := range slice {\n\t\tswitch reflect.TypeOf(v).Kind() {\n\t\tcase reflect.Slice:\n\t\t\ts := reflect.ValueOf(v)\n\t\t\tfor i := 0; i < s.Len(); i++ {\n\t\t\t\tresult = append(result, s.Index(i).Interface())\n\t\t\t}\n\t\tdefault:\n\t\t\tresult = append(result, v)\n\t\t}\n\t}\n\treturn result\n}\n\n// Rebind a query from ? to the target $ with custom initial value.\nfunc rebindWithStart(query string, startAt int) string {\n\t// Add space enough for 10 params before we have to allocate\n\trqb := make([]byte, 0, len(query)+10)\n\n\tvar i, j = 0, startAt\n\n\tfor i = strings.Index(query, \"?\"); i != -1; i = strings.Index(query, \"?\") {\n\t\trqb = append(rqb, query[:i]...)\n\t\trqb = append(rqb, '$')\n\n\t\trqb = strconv.AppendInt(rqb, int64(j), 10)\n\t\tj++\n\n\t\tquery = query[i+1:]\n\t}\n\n\treturn string(append(rqb, query...))\n}\n\n// GetTestAdapter returns an adapter object. Useful for running tests.\nfunc GetTestAdapter() *adapter {\n\treturn &adapter{}\n}\n\nfunc init() {\n\tstore.RegisterAdapter(&adapter{})\n}\n"
  },
  {
    "path": "server/db/postgres/blank.go",
    "content": "//go:build !postgres\n// +build !postgres\n\n// This file is needed for conditional compilation. It's used when\n// the build tag 'postgres' is not defined. Otherwise the adapter.go\n// is compiled.\n\npackage postgres\n"
  },
  {
    "path": "server/db/postgres/schema.sql",
    "content": "# The MySQL and PostrgreSQL schemas are identical save for differences in SQL flavors.\n# SEE ../mysql/schema.sql.\n"
  },
  {
    "path": "server/db/postgres/tests/postgres_test.go",
    "content": "// To test another db backend:\n// 1) Create GetAdapter function inside your db backend adapter package (like one inside postgres adapter)\n// 2) Uncomment your db backend package ('backend' named package)\n// 3) Write own initConnectionToDb and 'db' variable\n// 4) Replace postgres specific db queries inside test to your own queries.\n// 5) Run.\n\npackage tests\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"flag\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/google/go-cmp/cmp\"\n\t\"github.com/google/go-cmp/cmp/cmpopts\"\n\t\"github.com/jackc/pgx/v4/pgxpool\"\n\tadapter \"github.com/tinode/chat/server/db\"\n\t\"github.com/tinode/chat/server/store\"\n\tjcr \"github.com/tinode/jsonco\"\n\n\t\"github.com/tinode/chat/server/db/common/test_data\"\n\tbackend \"github.com/tinode/chat/server/db/postgres\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\ntype configType struct {\n\t// If Reset=true test will recreate database every time it runs\n\tReset bool `json:\"reset_db_data\"`\n\t// Configurations for individual adapters.\n\tAdapters map[string]json.RawMessage `json:\"adapters\"`\n}\n\nvar config configType\nvar adp adapter.Adapter\nvar db *pgxpool.Pool\nvar testData *test_data.TestData\nvar ctx context.Context\n\nvar dummyUid1 = types.Uid(12345)\nvar dummyUid2 = types.Uid(54321)\n\nfunc TestCreateDb(t *testing.T) {\n\tif err := adp.CreateDb(config.Reset); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Saved db is closed, get a fresh one.\n\tdb = adp.GetTestDB().(*pgxpool.Pool)\n}\n\n// ================== Create tests ================================\nfunc TestUserCreate(t *testing.T) {\n\tfor _, user := range testData.Users {\n\t\tif err := adp.UserCreate(user); err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n\tvar count int\n\n\terr := db.QueryRow(ctx, \"SELECT COUNT(*) FROM users\").Scan(&count)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif count == 0 {\n\t\tt.Error(\"No users created!\")\n\t}\n}\n\nfunc TestCredUpsert(t *testing.T) {\n\t// Test just inserts:\n\tfor i := 0; i < 2; i++ {\n\t\tinserted, err := adp.CredUpsert(testData.Creds[i])\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif !inserted {\n\t\t\tt.Error(\"Should be inserted, but updated\")\n\t\t}\n\t}\n\n\t// Test duplicate:\n\t_, err := adp.CredUpsert(testData.Creds[1])\n\tif err != types.ErrDuplicate {\n\t\tt.Error(\"Should return duplicate error but got\", err)\n\t}\n\t_, err = adp.CredUpsert(testData.Creds[2])\n\tif err != types.ErrDuplicate {\n\t\tt.Error(\"Should return duplicate error but got\", err)\n\t}\n\n\t// Test add new unvalidated credentials\n\tinserted, err := adp.CredUpsert(testData.Creds[3])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !inserted {\n\t\tt.Error(\"Should be inserted, but updated\")\n\t}\n\tinserted, err = adp.CredUpsert(testData.Creds[3])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif inserted {\n\t\tt.Error(\"Should be updated, but inserted\")\n\t}\n\n\t// Just insert other creds (used in other tests)\n\tfor _, cred := range testData.Creds[4:] {\n\t\t_, err = adp.CredUpsert(cred)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n}\n\nfunc TestAuthAddRecord(t *testing.T) {\n\tfor _, rec := range testData.Recs {\n\t\terr := adp.AuthAddRecord(types.ParseUserId(\"usr\"+rec.UserId), rec.Scheme, rec.Unique,\n\t\t\trec.AuthLvl, rec.Secret, rec.Expires)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\t//Test duplicate\n\terr := adp.AuthAddRecord(types.ParseUserId(\"usr\"+testData.Users[0].Id), testData.Recs[0].Scheme,\n\t\ttestData.Recs[0].Unique, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)\n\tif err != types.ErrDuplicate {\n\t\tt.Fatal(\"Should be duplicate error but got\", err)\n\t}\n}\n\nfunc TestTopicCreate(t *testing.T) {\n\terr := adp.TopicCreate(testData.Topics[0])\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\t// Update topic SeqId because it's not saved at creation time but used by the tests.\n\terr = adp.TopicUpdate(testData.Topics[0].Id, map[string]interface{}{\n\t\t\"seqid\": testData.Topics[0].SeqId,\n\t})\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tfor _, tpc := range testData.Topics[3:] {\n\t\terr = adp.TopicCreate(tpc)\n\t\tif err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n}\n\nfunc decodeUid(u string) int64 {\n\treturn store.DecodeUid(types.ParseUid(u))\n}\n\nfunc encodeUid(u string) types.Uid {\n\tid, _ := strconv.ParseInt(u, 10, 64)\n\treturn store.EncodeUid(int64(id))\n}\n\nfunc TestTopicCreateP2P(t *testing.T) {\n\terr := adp.TopicCreateP2P(testData.Subs[2], testData.Subs[3])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\toldModeGiven := testData.Subs[2].ModeGiven\n\ttestData.Subs[2].ModeGiven = 255\n\terr = adp.TopicCreateP2P(testData.Subs[4], testData.Subs[2])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tvar got types.Subscription\n\tvar userId int64\n\tvar modeWant, modeGiven []byte\n\terr = db.QueryRow(ctx, \"SELECT createdat,updatedat,deletedat,userid,topic,delid,recvseqid,readseqid,modewant,modegiven,private FROM subscriptions WHERE topic=$1 AND userid=$2\",\n\t\ttestData.Subs[2].Topic, decodeUid(testData.Subs[2].User)).Scan(&got.CreatedAt,\n\t\t&got.UpdatedAt, &got.DeletedAt, &userId, &got.Topic, &got.DelId, &got.RecvSeqId, &got.ReadSeqId,\n\t\t&modeWant, &modeGiven, &got.Private)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgot.ModeGiven.Scan(modeGiven)\n\tif got.ModeGiven == oldModeGiven {\n\t\tt.Error(\"ModeGiven update failed\")\n\t}\n}\n\nfunc TestTopicShare(t *testing.T) {\n\tif err := adp.TopicShare(testData.Subs[0].Topic, testData.Subs); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Must save recvseqid and readseqid separately because TopicShare\n\t// ignores them.\n\tfor _, sub := range testData.Subs {\n\t\tadp.SubsUpdate(sub.Topic, types.ParseUid(sub.User), map[string]any{\n\t\t\t\"delid\":     sub.DelId,\n\t\t\t\"recvseqid\": sub.RecvSeqId,\n\t\t\t\"readseqid\": sub.ReadSeqId,\n\t\t})\n\t}\n\n\t// Update topic SeqId because it's not saved at creation time but used by the tests.\n\tfor _, tpc := range testData.Topics {\n\t\terr := adp.TopicUpdate(tpc.Id, map[string]any{\n\t\t\t\"seqid\": tpc.SeqId,\n\t\t\t\"delid\": tpc.DelId,\n\t\t})\n\t\tif err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n}\n\nfunc TestMessageSave(t *testing.T) {\n\tfor _, msg := range testData.Msgs {\n\t\terr := adp.MessageSave(msg)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\n\t// Some messages are soft deleted, but it's ignored by adp.MessageSave\n\tfor _, msg := range testData.Msgs {\n\t\tif len(msg.DeletedFor) > 0 {\n\t\t\tfor _, del := range msg.DeletedFor {\n\t\t\t\ttoDel := types.DelMessage{\n\t\t\t\t\tTopic:       msg.Topic,\n\t\t\t\t\tDeletedFor:  del.User,\n\t\t\t\t\tDelId:       del.DelId,\n\t\t\t\t\tSeqIdRanges: []types.Range{{Low: msg.SeqId}},\n\t\t\t\t}\n\t\t\t\tadp.MessageDeleteList(msg.Topic, &toDel)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestFileStartUpload(t *testing.T) {\n\tfor _, f := range testData.Files {\n\t\terr := adp.FileStartUpload(f)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n}\n\n// ================== Read tests ==================================\nfunc TestUserGet(t *testing.T) {\n\t// Test not found\n\tgot, err := adp.UserGet(dummyUid1)\n\tif err == nil && got != nil {\n\t\tt.Error(\"user should be nil.\")\n\t}\n\n\tgot, err = adp.UserGet(types.ParseUserId(\"usr\" + testData.Users[0].Id))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// User agent is not stored when creating a user. Make sure it's the same.\n\tgot.UserAgent = testData.Users[0].UserAgent\n\n\tif !reflect.DeepEqual(got, testData.Users[0]) {\n\t\tt.Error(mismatchErrorString(\"User\", got, testData.Users[0]))\n\t}\n}\n\nfunc TestUserGetAll(t *testing.T) {\n\t// Test not found (dummy UIDs).\n\tgot, err := adp.UserGetAll(dummyUid1, dummyUid2)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(got) > 0 {\n\t\tt.Error(\"result users should be zero length, got\", len(got))\n\t}\n\n\tgot, err = adp.UserGetAll(types.ParseUserId(\"usr\"+testData.Users[0].Id), types.ParseUserId(\"usr\"+testData.Users[1].Id))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(got) != 2 {\n\t\tt.Fatal(mismatchErrorString(\"resultUsers length\", len(got), 2))\n\t}\n\tfor i, usr := range got {\n\t\t// User agent is not compared.\n\t\tusr.UserAgent = testData.Users[i].UserAgent\n\t\tif !reflect.DeepEqual(&usr, testData.Users[i]) {\n\t\t\tt.Error(mismatchErrorString(\"User\", &usr, testData.Users[i]))\n\t\t}\n\t}\n}\n\nfunc TestUserGetByCred(t *testing.T) {\n\t// Test not found\n\tgot, err := adp.UserGetByCred(\"foo\", \"bar\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got != types.ZeroUid {\n\t\tt.Error(\"result uid should be ZeroUid\")\n\t}\n\n\tgot, _ = adp.UserGetByCred(testData.Creds[0].Method, testData.Creds[0].Value)\n\tif got != types.ParseUserId(\"usr\"+testData.Creds[0].User) {\n\t\tt.Error(mismatchErrorString(\"Uid\", got, types.ParseUserId(\"usr\"+testData.Creds[0].User)))\n\t}\n}\n\nfunc TestCredGetActive(t *testing.T) {\n\tgot, err := adp.CredGetActive(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"tel\")\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif !reflect.DeepEqual(got, testData.Creds[3]) {\n\t\tt.Error(mismatchErrorString(\"Credential\", got, testData.Creds[3]))\n\t}\n\n\t// Test not found\n\tgot, err = adp.CredGetActive(dummyUid1, \"\")\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif got != nil {\n\t\tt.Error(\"result should be nil, but got\", got)\n\t}\n}\n\nfunc TestCredGetAll(t *testing.T) {\n\tgot, err := adp.CredGetAll(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"\", false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(got) != 3 {\n\t\tt.Error(mismatchErrorString(\"Credentials length\", len(got), 3))\n\t}\n\n\tgot, _ = adp.CredGetAll(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"tel\", false)\n\tif len(got) != 2 {\n\t\tt.Error(mismatchErrorString(\"Credentials length\", len(got), 2))\n\t}\n\n\tgot, _ = adp.CredGetAll(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"\", true)\n\tif len(got) != 1 {\n\t\tt.Error(mismatchErrorString(\"Credentials length\", len(got), 1))\n\t}\n\n\tgot, _ = adp.CredGetAll(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"tel\", true)\n\tif len(got) != 1 {\n\t\tt.Error(mismatchErrorString(\"Credentials length\", len(got), 1))\n\t}\n}\n\nfunc TestAuthGetUniqueRecord(t *testing.T) {\n\tuid, authLvl, secret, expires, err := adp.AuthGetUniqueRecord(\"basic:alice\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif uid != types.ParseUserId(\"usr\"+testData.Recs[0].UserId) ||\n\t\tauthLvl != testData.Recs[0].AuthLvl ||\n\t\t!reflect.DeepEqual(secret, testData.Recs[0].Secret) ||\n\t\texpires != testData.Recs[0].Expires {\n\n\t\tgot := fmt.Sprintf(\"%v %v %v %v\", uid, authLvl, secret, expires)\n\t\twant := fmt.Sprintf(\"%v %v %v %v\", testData.Recs[0].UserId, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)\n\t\tt.Error(mismatchErrorString(\"Auth record\", got, want))\n\t}\n\n\t// Test not found\n\tuid, _, _, _, err = adp.AuthGetUniqueRecord(\"qwert:asdfg\")\n\tif err == nil && !uid.IsZero() {\n\t\tt.Error(\"Auth record found but shouldn't. Uid:\", uid.String())\n\t}\n}\n\nfunc TestAuthGetRecord(t *testing.T) {\n\trecId, authLvl, secret, expires, err := adp.AuthGetRecord(types.ParseUserId(\"usr\"+testData.Recs[0].UserId), \"basic\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif recId != testData.Recs[0].Unique ||\n\t\tauthLvl != testData.Recs[0].AuthLvl ||\n\t\t!reflect.DeepEqual(secret, testData.Recs[0].Secret) ||\n\t\texpires != testData.Recs[0].Expires {\n\n\t\tgot := fmt.Sprintf(\"%v %v %v %v\", recId, authLvl, secret, expires)\n\t\twant := fmt.Sprintf(\"%v %v %v %v\", testData.Recs[0].Unique, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)\n\t\tt.Error(mismatchErrorString(\"Auth record\", got, want))\n\t}\n\n\t// Test not found\n\trecId, _, _, _, err = adp.AuthGetRecord(types.Uid(123), \"scheme\")\n\tif err != types.ErrNotFound {\n\t\tt.Error(\"Auth record found but shouldn't. recId:\", recId)\n\t}\n}\n\nfunc TestTopicGet(t *testing.T) {\n\tgot, err := adp.TopicGet(testData.Topics[0].Id)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !reflect.DeepEqual(got, testData.Topics[0]) {\n\t\tt.Error(mismatchErrorString(\"Topic\", got, testData.Topics[0]))\n\t}\n\t// Test not found\n\tgot, err = adp.TopicGet(\"asdfasdfasdf\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got != nil {\n\t\tt.Error(\"Topic should be nil but got:\", got)\n\t}\n}\n\nfunc TestTopicsForUser(t *testing.T) {\n\tqOpts := types.QueryOpt{\n\t\tTopic: \"p2p9AVDamaNCRbfKzGSh3mE0w\",\n\t\tLimit: 999,\n\t}\n\tgotSubs, err := adp.TopicsForUser(types.ParseUserId(\"usr\"+testData.Users[0].Id), false, &qOpts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 1 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 1))\n\t}\n\n\tgotSubs, err = adp.TopicsForUser(types.ParseUserId(\"usr\"+testData.Users[1].Id), true, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 2 {\n\t\tt.Error(mismatchErrorString(\"Subs length (2)\", len(gotSubs), 2))\n\t}\n\n\tqOpts.Topic = \"\"\n\tims := testData.Now.Add(15 * time.Minute)\n\tqOpts.IfModifiedSince = &ims\n\tgotSubs, err = adp.TopicsForUser(types.ParseUserId(\"usr\"+testData.Users[0].Id), false, &qOpts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 1 {\n\t\tt.Error(mismatchErrorString(\"Subs length (IMS)\", len(gotSubs), 1))\n\t}\n\n\tims = time.Now().Add(15 * time.Minute)\n\tgotSubs, err = adp.TopicsForUser(types.ParseUserId(\"usr\"+testData.Users[0].Id), false, &qOpts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 0 {\n\t\tt.Error(mismatchErrorString(\"Subs length (IMS 2)\", len(gotSubs), 0))\n\t}\n}\n\nfunc TestUsersForTopic(t *testing.T) {\n\tqOpts := types.QueryOpt{\n\t\tUser:  types.ParseUserId(\"usr\" + testData.Users[0].Id),\n\t\tLimit: 999,\n\t}\n\tgotSubs, err := adp.UsersForTopic(\"grpgRXf0rU4uR4\", false, &qOpts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 1 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 1))\n\t}\n\n\tgotSubs, err = adp.UsersForTopic(\"grpgRXf0rU4uR4\", true, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 2 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 2))\n\t}\n\n\tgotSubs, err = adp.UsersForTopic(\"p2p9AVDamaNCRbfKzGSh3mE0w\", false, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 2 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 2))\n\t}\n}\n\nfunc TestOwnTopics(t *testing.T) {\n\tgotSubs, err := adp.OwnTopics(types.ParseUserId(\"usr\" + testData.Users[0].Id))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 1 {\n\t\tt.Fatalf(\"Got topic length %v instead of %v\", len(gotSubs), 1)\n\t}\n\tif gotSubs[0] != testData.Topics[0].Id {\n\t\tt.Errorf(\"Got topic %v instead of %v\", gotSubs[0], testData.Topics[0].Id)\n\t}\n}\n\nfunc TestChannelsForUser(t *testing.T) {\n\t// Test channels for user (PostgreSQL specific test)\n\tchannels, err := adp.ChannelsForUser(types.ParseUserId(\"usr\" + testData.Users[0].Id))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Should return empty slice since we don't have channel subscriptions in test data\n\tif len(channels) != 0 {\n\t\tt.Error(mismatchErrorString(\"Channels length\", len(channels), 0))\n\t}\n}\n\nfunc TestSubscriptionGet(t *testing.T) {\n\tgot, err := adp.SubscriptionGet(testData.Topics[0].Id, types.ParseUserId(\"usr\"+testData.Users[0].Id), false)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\n\tif diff := cmp.Diff(got, testData.Subs[0],\n\t\tcmpopts.IgnoreUnexported(types.Subscription{}, types.ObjHeader{})); diff != \"\" {\n\t\tt.Error(mismatchErrorString(\"Subs\", diff, \"\"))\n\t}\n\t// Test not found\n\tgot, err = adp.SubscriptionGet(\"dummytopic\", dummyUid1, false)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif got != nil {\n\t\tt.Error(\"result sub should be nil.\")\n\t}\n}\n\nfunc TestSubsForUser(t *testing.T) {\n\tgotSubs, err := adp.SubsForUser(types.ParseUserId(\"usr\" + testData.Users[0].Id))\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif len(gotSubs) != 2 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 2))\n\t}\n\n\t// Test not found\n\tgotSubs, err = adp.SubsForUser(types.ParseUserId(\"usr12345678\"))\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif len(gotSubs) != 0 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 0))\n\t}\n}\n\nfunc TestSubsForTopic(t *testing.T) {\n\tqOpts := types.QueryOpt{\n\t\tUser:  types.ParseUserId(\"usr\" + testData.Users[0].Id),\n\t\tLimit: 999,\n\t}\n\tgotSubs, err := adp.SubsForTopic(testData.Topics[0].Id, false, &qOpts)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif len(gotSubs) != 1 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 1))\n\t}\n\t// Test not found\n\tgotSubs, err = adp.SubsForTopic(\"dummytopicid\", false, nil)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif len(gotSubs) != 0 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 0))\n\t}\n}\n\nfunc TestFind(t *testing.T) {\n\treqTags := [][]string{{\"alice\", \"bob\", \"carol\", \"travel\", \"qwer\", \"asdf\", \"zxcv\"}}\n\tgot, err := adp.Find(\"usr\"+testData.Users[2].Id, \"\", reqTags, nil, true)\n\tif err != nil {\n\t\tt.Error(err)\n\t} else if len(got) != 3 {\n\t\tt.Error(mismatchErrorString(\"result length\", len(got), 3))\n\t}\n}\n\nfunc TestFindOne(t *testing.T) {\n\t// Test PostgreSQL specific FindOne method\n\tfound, err := adp.FindOne(\"alice\")\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\t// Should find the user with alice tag\n\tif found == \"\" {\n\t\tt.Error(\"Expected to find user with alice tag\")\n\t}\n\n\t// Test not found\n\tfound, err = adp.FindOne(\"nonexistent\")\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif found != \"\" {\n\t\tt.Error(\"Should not find nonexistent tag\")\n\t}\n}\n\nfunc TestMessageGetAll(t *testing.T) {\n\topts := types.QueryOpt{\n\t\tSince:  1,\n\t\tBefore: 2,\n\t\tLimit:  999,\n\t}\n\tgotMsgs, err := adp.MessageGetAll(testData.Topics[0].Id, types.ParseUserId(\"usr\"+testData.Users[0].Id), &opts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotMsgs) != 1 {\n\t\tt.Error(mismatchErrorString(\"Messages length opts\", len(gotMsgs), 1))\n\t}\n\tgotMsgs, _ = adp.MessageGetAll(testData.Topics[0].Id, types.ParseUserId(\"usr\"+testData.Users[0].Id), nil)\n\tif len(gotMsgs) != 2 {\n\t\tt.Error(mismatchErrorString(\"Messages length no opts\", len(gotMsgs), 2))\n\t}\n\tgotMsgs, _ = adp.MessageGetAll(testData.Topics[0].Id, types.ZeroUid, nil)\n\tif len(gotMsgs) != 3 {\n\t\tt.Error(mismatchErrorString(\"Messages length zero uid\", len(gotMsgs), 3))\n\t}\n}\n\nfunc TestFileGet(t *testing.T) {\n\t// General test done during TestFileFinishUpload().\n\n\t// Test not found\n\tgot, err := adp.FileGet(\"dummyfileid\")\n\tif err != nil && got != nil {\n\t\tt.Error(\"File found but shouldn't:\", got)\n\t}\n}\n\n// ================== Update tests ================================\nfunc TestUserUpdate(t *testing.T) {\n\tupdate := map[string]any{\n\t\t\"UserAgent\": \"Test Agent v0.11\",\n\t\t\"UpdatedAt\": testData.Now.Add(30 * time.Minute),\n\t}\n\terr := adp.UserUpdate(types.ParseUserId(\"usr\"+testData.Users[0].Id), update)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tvar got struct {\n\t\tUserAgent string\n\t\tUpdatedAt time.Time\n\t\tCreatedAt time.Time\n\t}\n\terr = db.QueryRow(ctx, \"SELECT useragent, updatedat, createdat FROM users WHERE id=$1\",\n\t\tdecodeUid(testData.Users[0].Id)).Scan(&got.UserAgent, &got.UpdatedAt, &got.CreatedAt)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.UserAgent != \"Test Agent v0.11\" {\n\t\tt.Error(mismatchErrorString(\"UserAgent\", got.UserAgent, \"Test Agent v0.11\"))\n\t}\n\tif got.UpdatedAt == got.CreatedAt {\n\t\tt.Error(\"UpdatedAt field not updated\")\n\t}\n}\n\nfunc TestUserUpdateTags(t *testing.T) {\n\taddTags := testData.Tags[0]\n\tremoveTags := testData.Tags[1]\n\tresetTags := testData.Tags[2]\n\tuid := types.ParseUserId(\"usr\" + testData.Users[0].Id)\n\n\tgot, err := adp.UserUpdateTags(uid, addTags, nil, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"alice\", \"tag1\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Error(mismatchErrorString(\"Tags\", got, want))\n\t}\n\n\tgot, err = adp.UserUpdateTags(uid, nil, removeTags, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant = nil\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Error(mismatchErrorString(\"Tags\", got, want))\n\t}\n\n\tgot, err = adp.UserUpdateTags(uid, nil, nil, resetTags)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant = []string{\"alice\", \"tag111\", \"tag333\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Error(mismatchErrorString(\"Tags\", got, want))\n\t}\n\n\tgot, err = adp.UserUpdateTags(uid, addTags, removeTags, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant = []string{\"tag111\", \"tag333\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Error(mismatchErrorString(\"Tags\", got, want))\n\t}\n}\n\nfunc TestUserGetUnvalidated(t *testing.T) {\n\t// Test PostgreSQL specific method\n\tcutoff := time.Now().Add(-24 * time.Hour)\n\tuids, err := adp.UserGetUnvalidated(cutoff, 10)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\t// Should return empty slice since all test users are considered validated\n\tif len(uids) > 0 {\n\t\tt.Error(\"Expected no unvalidated users in test data\")\n\t}\n}\n\nfunc TestCredFail(t *testing.T) {\n\terr := adp.CredFail(types.ParseUserId(\"usr\"+testData.Creds[3].User), \"tel\")\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\n\t// Check if fields updated\n\tvar got struct {\n\t\tRetries   int\n\t\tUpdatedAt time.Time\n\t\tCreatedAt time.Time\n\t}\n\terr = db.QueryRow(ctx, \"SELECT retries, updatedat, createdat FROM credentials WHERE userid=$1 AND method=$2 AND value=$3\",\n\t\tdecodeUid(testData.Creds[3].User), \"tel\", testData.Creds[3].Value).Scan(&got.Retries, &got.UpdatedAt, &got.CreatedAt)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.Retries != 1 {\n\t\tt.Error(mismatchErrorString(\"Retries count\", got.Retries, 1))\n\t}\n\tif got.UpdatedAt == got.CreatedAt {\n\t\tt.Error(\"UpdatedAt field not updated\")\n\t}\n}\n\nfunc TestCredConfirm(t *testing.T) {\n\terr := adp.CredConfirm(types.ParseUserId(\"usr\"+testData.Creds[3].User), \"tel\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test fields are updated\n\tvar got struct {\n\t\tUpdatedAt time.Time\n\t\tCreatedAt time.Time\n\t\tDone      bool\n\t}\n\terr = db.QueryRow(ctx, \"SELECT updatedat, createdat, done FROM credentials WHERE userid=$1 AND method=$2 AND value=$3\",\n\t\tdecodeUid(testData.Creds[3].User), \"tel\", testData.Creds[3].Value).Scan(&got.UpdatedAt, &got.CreatedAt, &got.Done)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.UpdatedAt == got.CreatedAt {\n\t\tt.Error(\"Credential not updated correctly\")\n\t}\n\tif !got.Done {\n\t\tt.Error(\"Credential should be marked as done\")\n\t}\n}\n\nfunc TestAuthUpdRecord(t *testing.T) {\n\trec := testData.Recs[1]\n\tnewSecret := []byte{'s', 'e', 'c', 'r', 'e', 't'}\n\terr := adp.AuthUpdRecord(types.ParseUserId(\"usr\"+rec.UserId), rec.Scheme, rec.Unique,\n\t\trec.AuthLvl, newSecret, rec.Expires)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got []byte\n\terr = db.QueryRow(ctx, \"SELECT secret FROM auth WHERE uname=$1\", rec.Unique).Scan(&got)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif reflect.DeepEqual(got, rec.Secret) {\n\t\tt.Error(mismatchErrorString(\"Secret\", got, rec.Secret))\n\t}\n\n\t// Test with auth ID (unique) change\n\tnewId := \"basic:bob12345\"\n\terr = adp.AuthUpdRecord(types.ParseUserId(\"usr\"+rec.UserId), rec.Scheme, newId,\n\t\trec.AuthLvl, newSecret, rec.Expires)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Test if old ID deleted\n\tvar count int\n\terr = db.QueryRow(ctx, \"SELECT COUNT(*) FROM auth WHERE uname=$1\", rec.Unique).Scan(&count)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count != 0 {\n\t\tt.Error(\"Old auth record not deleted\")\n\t}\n}\n\nfunc TestTopicUpdateOnMessage(t *testing.T) {\n\tmsg := types.Message{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tCreatedAt: testData.Now.Add(33 * time.Minute),\n\t\t},\n\t\tSeqId: 66,\n\t}\n\terr := adp.TopicUpdateOnMessage(testData.Topics[2].Id, &msg)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got struct {\n\t\tTouchedAt time.Time\n\t\tSeqId     int\n\t}\n\terr = db.QueryRow(ctx, \"SELECT touchedat, seqid FROM topics WHERE name=$1\", testData.Topics[2].Id).\n\t\tScan(&got.TouchedAt, &got.SeqId)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.TouchedAt != msg.CreatedAt || got.SeqId != msg.SeqId {\n\t\tt.Error(mismatchErrorString(\"TouchedAt\", got.TouchedAt, msg.CreatedAt))\n\t\tt.Error(mismatchErrorString(\"SeqId\", got.SeqId, msg.SeqId))\n\t}\n}\n\nfunc TestTopicUpdate(t *testing.T) {\n\tupdate := map[string]any{\n\t\t\"UpdatedAt\": testData.Now.Add(55 * time.Minute),\n\t}\n\terr := adp.TopicUpdate(testData.Topics[0].Id, update)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got time.Time\n\terr = db.QueryRow(ctx, \"SELECT updatedat FROM topics WHERE name=$1\", testData.Topics[0].Id).Scan(&got)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got != update[\"UpdatedAt\"] {\n\t\tt.Error(mismatchErrorString(\"UpdatedAt\", got, update[\"UpdatedAt\"]))\n\t}\n}\n\nfunc TestTopicUpdateSubCnt(t *testing.T) {\n\t// Test PostgreSQL specific method\n\terr := adp.TopicUpdateSubCnt(testData.Topics[0].Id)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify the subscription count was updated correctly\n\tvar subcnt int\n\terr = db.QueryRow(ctx, \"SELECT subcnt FROM topics WHERE name=$1\", testData.Topics[0].Id).Scan(&subcnt)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Should match the number of active subscriptions\n\tif subcnt < 0 {\n\t\tt.Error(\"Subscription count should be non-negative\")\n\t}\n}\n\nfunc TestTopicOwnerChange(t *testing.T) {\n\terr := adp.TopicOwnerChange(testData.Topics[0].Id, types.ParseUserId(\"usr\"+testData.Users[1].Id))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got int64\n\terr = db.QueryRow(ctx, \"SELECT owner FROM topics WHERE name=$1\", testData.Topics[0].Id).Scan(&got)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\texpectedOwner := decodeUid(testData.Users[1].Id)\n\tif got != expectedOwner {\n\t\tt.Error(mismatchErrorString(\"Owner\", got, expectedOwner))\n\t}\n}\n\nfunc TestSubsUpdate(t *testing.T) {\n\tupdate := map[string]any{\n\t\t\"UpdatedAt\": testData.Now.Add(22 * time.Minute),\n\t}\n\terr := adp.SubsUpdate(testData.Topics[0].Id, types.ParseUserId(\"usr\"+testData.Users[0].Id), update)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got time.Time\n\terr = db.QueryRow(ctx, \"SELECT updatedat FROM subscriptions WHERE topic=$1 AND userid=$2\",\n\t\ttestData.Topics[0].Id, decodeUid(testData.Users[0].Id)).Scan(&got)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got != update[\"UpdatedAt\"] {\n\t\tt.Error(mismatchErrorString(\"UpdatedAt\", got, update[\"UpdatedAt\"]))\n\t}\n\n\terr = adp.SubsUpdate(testData.Topics[1].Id, types.ZeroUid, update)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = db.QueryRow(ctx, \"SELECT updatedat FROM subscriptions WHERE topic=$1 LIMIT 1\",\n\t\ttestData.Topics[1].Id).Scan(&got)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got != update[\"UpdatedAt\"] {\n\t\tt.Error(mismatchErrorString(\"UpdatedAt\", got, update[\"UpdatedAt\"]))\n\t}\n}\n\nfunc TestSubsDelete(t *testing.T) {\n\terr := adp.SubsDelete(testData.Topics[1].Id, types.ParseUserId(\"usr\"+testData.Users[0].Id))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar deletedat sql.NullTime\n\terr = db.QueryRow(ctx, \"SELECT deletedat FROM subscriptions WHERE topic=$1 AND userid=$2\",\n\t\ttestData.Topics[1].Id, decodeUid(testData.Users[0].Id)).Scan(&deletedat)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !deletedat.Valid {\n\t\tt.Error(\"DeletedAt should not be null\")\n\t}\n}\n\nfunc TestSubsDelForUser(t *testing.T) {\n\t// Tested during TestUserDelete (both hard and soft deletions)\n}\n\nfunc TestDeviceUpsert(t *testing.T) {\n\terr := adp.DeviceUpsert(types.ParseUserId(\"usr\"+testData.Users[0].Id), testData.Devs[0])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar got struct {\n\t\tDeviceId string\n\t\tPlatform string\n\t}\n\terr = db.QueryRow(ctx, \"SELECT deviceid, platform FROM devices WHERE userid=$1 LIMIT 1\",\n\t\tdecodeUid(testData.Users[0].Id)).Scan(&got.DeviceId, &got.Platform)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.DeviceId != testData.Devs[0].DeviceId || got.Platform != testData.Devs[0].Platform {\n\t\tt.Error(mismatchErrorString(\"Device\", got, testData.Devs[0]))\n\t}\n\n\t// Test update\n\ttestData.Devs[0].Platform = \"Web\"\n\terr = adp.DeviceUpsert(types.ParseUserId(\"usr\"+testData.Users[0].Id), testData.Devs[0])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = db.QueryRow(ctx, \"SELECT platform FROM devices WHERE userid=$1 AND deviceid=$2\",\n\t\tdecodeUid(testData.Users[0].Id), testData.Devs[0].DeviceId).Scan(&got.Platform)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.Platform != \"Web\" {\n\t\tt.Error(\"Device not updated.\", got.Platform)\n\t}\n\n\t// Test add same device to another user\n\terr = adp.DeviceUpsert(types.ParseUserId(\"usr\"+testData.Users[1].Id), testData.Devs[0])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terr = adp.DeviceUpsert(types.ParseUserId(\"usr\"+testData.Users[2].Id), testData.Devs[1])\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestMessageAttachments(t *testing.T) {\n\tfids := []string{testData.Files[0].Id, testData.Files[1].Id}\n\terr := adp.FileLinkAttachments(\"\", types.ZeroUid, types.ParseUid(testData.Msgs[1].Id), fids)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Check if attachments were linked\n\tvar count int\n\terr = db.QueryRow(ctx, \"SELECT COUNT(*) FROM filemsglinks WHERE msgid=$1\",\n\t\tint64(types.ParseUid(testData.Msgs[1].Id))).Scan(&count)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count != len(fids) {\n\t\tt.Error(mismatchErrorString(\"Attachments count\", count, len(fids)))\n\t}\n}\n\nfunc TestFileFinishUpload(t *testing.T) {\n\tgot, err := adp.FileFinishUpload(testData.Files[0], true, 22222)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.Status != types.UploadCompleted {\n\t\tt.Error(mismatchErrorString(\"Status\", got.Status, types.UploadCompleted))\n\t}\n\tif got.Size != 22222 {\n\t\tt.Error(mismatchErrorString(\"Size\", got.Size, 22222))\n\t}\n}\n\n// ================== Other tests =================================\nfunc TestDeviceGetAll(t *testing.T) {\n\tuid0 := types.ParseUserId(\"usr\" + testData.Users[0].Id)\n\tuid1 := types.ParseUserId(\"usr\" + testData.Users[1].Id)\n\tuid2 := types.ParseUserId(\"usr\" + testData.Users[2].Id)\n\tgotDevs, count, err := adp.DeviceGetAll(uid0, uid1, uid2)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count < 1 {\n\t\tt.Fatal(mismatchErrorString(\"count\", count, \">=1\"))\n\t}\n\t// Test that devices exist for the users\n\tif len(gotDevs) == 0 {\n\t\tt.Error(\"Expected devices for users\")\n\t}\n}\n\nfunc TestDeviceDelete(t *testing.T) {\n\terr := adp.DeviceDelete(types.ParseUserId(\"usr\"+testData.Users[1].Id), testData.Devs[0].DeviceId)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar count int\n\terr = db.QueryRow(ctx, \"SELECT COUNT(*) FROM devices WHERE userid=$1 AND deviceid=$2\",\n\t\tdecodeUid(testData.Users[1].Id), testData.Devs[0].DeviceId).Scan(&count)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count != 0 {\n\t\tt.Error(\"Device not deleted:\", count)\n\t}\n\n\terr = adp.DeviceDelete(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = db.QueryRow(ctx, \"SELECT COUNT(*) FROM devices WHERE userid=$1\",\n\t\tdecodeUid(testData.Users[2].Id)).Scan(&count)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count != 0 {\n\t\tt.Error(\"All devices not deleted:\", count)\n\t}\n}\n\n// ================== Persistent Cache tests ======================\nfunc TestPCacheUpsert(t *testing.T) {\n\terr := adp.PCacheUpsert(\"test_key\", \"test_value\", false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test duplicate with failOnDuplicate = true\n\terr = adp.PCacheUpsert(\"test_key2\", \"test_value2\", true)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terr = adp.PCacheUpsert(\"test_key2\", \"new_value\", true)\n\tif err != types.ErrDuplicate {\n\t\tt.Error(\"Expected duplicate error\")\n\t}\n}\n\nfunc TestPCacheGet(t *testing.T) {\n\tvalue, err := adp.PCacheGet(\"test_key\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif value != \"test_value\" {\n\t\tt.Error(mismatchErrorString(\"Cache value\", value, \"test_value\"))\n\t}\n\n\t// Test not found\n\t_, err = adp.PCacheGet(\"nonexistent\")\n\tif err != types.ErrNotFound {\n\t\tt.Error(\"Expected not found error\")\n\t}\n}\n\nfunc TestPCacheDelete(t *testing.T) {\n\terr := adp.PCacheDelete(\"test_key\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify deleted\n\t_, err = adp.PCacheGet(\"test_key\")\n\tif err != types.ErrNotFound {\n\t\tt.Error(\"Key should be deleted\")\n\t}\n}\n\nfunc TestPCacheExpire(t *testing.T) {\n\t// Insert some test keys with prefix\n\tadp.PCacheUpsert(\"prefix_key1\", \"value1\", false)\n\tadp.PCacheUpsert(\"prefix_key2\", \"value2\", false)\n\n\t// Expire keys older than now (should delete all test keys)\n\terr := adp.PCacheExpire(\"prefix_\", time.Now().Add(1*time.Minute))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\n// ================== Delete tests ================================\nfunc TestCredDel(t *testing.T) {\n\terr := adp.CredDel(types.ParseUserId(\"usr\"+testData.Users[0].Id), \"email\", \"alice@test.example.com\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar count int\n\terr = db.QueryRow(ctx, \"SELECT COUNT(*) FROM credentials WHERE method='email' AND value='alice@test.example.com'\").Scan(&count)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count != 0 {\n\t\tt.Error(\"Got result but shouldn't\", count)\n\t}\n\n\terr = adp.CredDel(types.ParseUserId(\"usr\"+testData.Users[1].Id), \"\", \"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = db.QueryRow(ctx, \"SELECT COUNT(*) FROM credentials WHERE userid=$1\",\n\t\tdecodeUid(testData.Users[1].Id)).Scan(&count)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count != 0 {\n\t\tt.Error(\"Got result but shouldn't\", count)\n\t}\n}\n\nfunc TestAuthDelScheme(t *testing.T) {\n\t// Test deleting auth scheme\n\terr := adp.AuthDelScheme(types.ParseUserId(\"usr\"+testData.Recs[1].UserId), testData.Recs[1].Scheme)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify deleted\n\t_, _, _, _, err = adp.AuthGetRecord(types.ParseUserId(\"usr\"+testData.Recs[1].UserId), testData.Recs[1].Scheme)\n\tif err != types.ErrNotFound {\n\t\tt.Error(\"Auth record should be deleted\")\n\t}\n}\n\nfunc TestAuthDelAllRecords(t *testing.T) {\n\tdelCount, err := adp.AuthDelAllRecords(types.ParseUserId(\"usr\" + testData.Recs[0].UserId))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif delCount != 1 {\n\t\tt.Error(mismatchErrorString(\"delCount\", delCount, 1))\n\t}\n\n\t// With dummy user\n\tdelCount, _ = adp.AuthDelAllRecords(dummyUid1)\n\tif delCount != 0 {\n\t\tt.Error(mismatchErrorString(\"delCount\", delCount, 0))\n\t}\n}\n\nfunc TestMessageDeleteList(t *testing.T) {\n\ttoDel := types.DelMessage{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tId:        testData.UGen.GetStr(),\n\t\t\tCreatedAt: testData.Now,\n\t\t\tUpdatedAt: testData.Now,\n\t\t},\n\t\tTopic:       testData.Topics[1].Id,\n\t\tDeletedFor:  testData.Users[2].Id,\n\t\tDelId:       1,\n\t\tSeqIdRanges: []types.Range{{Low: 9}, {Low: 3, Hi: 7}},\n\t}\n\terr := adp.MessageDeleteList(toDel.Topic, &toDel)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Check messages in dellog\n\tvar count int\n\terr = db.QueryRow(ctx, \"SELECT COUNT(*) FROM dellog WHERE topic=$1 AND deletedfor=$2\",\n\t\ttoDel.Topic, decodeUid(toDel.DeletedFor)).Scan(&count)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count == 0 {\n\t\tt.Error(\"No dellog entries created\")\n\t}\n\n\t// Hard delete test\n\ttoDel = types.DelMessage{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tId:        testData.UGen.GetStr(),\n\t\t\tCreatedAt: testData.Now,\n\t\t\tUpdatedAt: testData.Now,\n\t\t},\n\t\tTopic:       testData.Topics[0].Id,\n\t\tDelId:       3,\n\t\tSeqIdRanges: []types.Range{{Low: 1, Hi: 3}},\n\t}\n\terr = adp.MessageDeleteList(toDel.Topic, &toDel)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Check if messages content was cleared\n\terr = db.QueryRow(ctx, \"SELECT COUNT(*) FROM messages WHERE topic=$1 AND content IS NOT NULL\",\n\t\ttoDel.Topic).Scan(&count)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count > 1 {\n\t\tt.Errorf(\"Messages not properly deleted %d, %s\", count, toDel.Topic)\n\t}\n\n\terr = adp.MessageDeleteList(testData.Topics[0].Id, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = db.QueryRow(ctx, \"SELECT COUNT(*) FROM messages WHERE topic=$1\", testData.Topics[0].Id).Scan(&count)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count != 0 {\n\t\tt.Error(\"Result should be empty:\", count)\n\t}\n}\n\nfunc TestTopicDelete(t *testing.T) {\n\terr := adp.TopicDelete(testData.Topics[1].Id, false, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar state int\n\terr = db.QueryRow(ctx, \"SELECT state FROM topics WHERE name=$1\", testData.Topics[1].Id).Scan(&state)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif state != int(types.StateDeleted) {\n\t\tt.Error(\"Soft delete failed:\", state)\n\t}\n\n\terr = adp.TopicDelete(testData.Topics[0].Id, false, true)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tvar count int\n\terr = db.QueryRow(ctx, \"SELECT COUNT(*) FROM topics WHERE name=$1\", testData.Topics[0].Id).Scan(&count)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count != 0 {\n\t\tt.Error(\"Hard delete failed:\", count)\n\t}\n}\n\nfunc TestFileDeleteUnused(t *testing.T) {\n\tlocs, err := adp.FileDeleteUnused(time.Now().Add(1*time.Minute), 999)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(locs) < 1 {\n\t\tt.Log(\"No unused files to delete - this is expected in test environment\")\n\t}\n}\n\nfunc TestUserDelete(t *testing.T) {\n\terr := adp.UserDelete(types.ParseUserId(\"usr\"+testData.Users[0].Id), false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar state int\n\terr = db.QueryRow(ctx, \"SELECT state FROM users WHERE id=$1\",\n\t\tdecodeUid(testData.Users[0].Id)).Scan(&state)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif state != int(types.StateDeleted) {\n\t\tt.Error(\"User soft delete failed\", state)\n\t}\n\n\terr = adp.UserDelete(types.ParseUserId(\"usr\"+testData.Users[1].Id), true)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar count int\n\terr = db.QueryRow(ctx, \"SELECT COUNT(*) FROM users WHERE id=$1\",\n\t\tdecodeUid(testData.Users[1].Id)).Scan(&count)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count != 0 {\n\t\tt.Error(\"User hard delete failed\")\n\t}\n}\n\nfunc TestUserUnreadCount(t *testing.T) {\n\tuids := []types.Uid{\n\t\ttypes.ParseUserId(\"usr\" + testData.Users[1].Id),\n\t\ttypes.ParseUserId(\"usr\" + testData.Users[2].Id),\n\t}\n\texpected := map[types.Uid]int{uids[0]: 0, uids[1]: 166}\n\tcounts, err := adp.UserUnreadCount(uids...)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(counts) != 2 {\n\t\tt.Error(mismatchErrorString(\"UnreadCount length\", len(counts), 2))\n\t}\n\n\tfor uid, unread := range counts {\n\t\tif expected[uid] != unread {\n\t\t\tt.Error(mismatchErrorString(\"UnreadCount\", unread, expected[uid]))\n\t\t}\n\t}\n\n\t// Test not found (even if the account is not found, the call must return one record).\n\tcounts, err = adp.UserUnreadCount(dummyUid1)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(counts) != 1 {\n\t\tt.Error(mismatchErrorString(\"UnreadCount length (dummy)\", len(counts), 1))\n\t}\n\tif counts[dummyUid1] != 0 {\n\t\tt.Error(mismatchErrorString(\"Non-zero UnreadCount (dummy)\", counts[dummyUid1], 0))\n\t}\n}\n\nfunc TestMessageGetDeleted(t *testing.T) {\n\tqOpts := types.QueryOpt{\n\t\tSince:  1,\n\t\tBefore: 10,\n\t\tLimit:  999,\n\t}\n\tgot, err := adp.MessageGetDeleted(testData.Topics[1].Id, types.ParseUserId(\"usr\"+testData.Users[2].Id), &qOpts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(got) != 1 {\n\t\tt.Error(mismatchErrorString(\"result length\", len(got), 1))\n\t}\n}\n\n// ================================================================\nfunc mismatchErrorString(key string, got, want any) string {\n\treturn fmt.Sprintf(\"%s mismatch:\\nGot  = %+v\\nWant = %+v\", key, got, want)\n}\n\nfunc init() {\n\tctx = context.Background()\n\tlogs.Init(os.Stderr, \"stdFlags\")\n\tadp = backend.GetTestAdapter()\n\tconffile := flag.String(\"config\", \"./test.conf\", \"config of the database connection\")\n\n\tif file, err := os.Open(*conffile); err != nil {\n\t\tlog.Fatal(\"Failed to read config file:\", err)\n\t} else if err = json.NewDecoder(jcr.New(file)).Decode(&config); err != nil {\n\t\tlog.Fatal(\"Failed to parse config file:\", err)\n\t}\n\n\tif adp == nil {\n\t\tlog.Fatal(\"Database adapter is missing\")\n\t}\n\tif adp.IsOpen() {\n\t\tlog.Print(\"Connection is already opened\")\n\t}\n\n\terr := adp.Open(config.Adapters[adp.GetName()])\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\tdb = adp.GetTestDB().(*pgxpool.Pool)\n\ttestData = test_data.InitTestData()\n\tif testData == nil {\n\t\tlog.Fatal(\"Failed to initialize test data\")\n\t}\n\tstore.SetTestUidGenerator(*testData.UGen)\n}\n"
  },
  {
    "path": "server/db/postgres/tests/test.conf",
    "content": "{\n  \"reset_db_data\": true,\n  \"adapters\": {\n    \"postgres\": {\n\t\t\t\t\"User\": \"postgres\",\n\t\t\t\t\"Passwd\": \"postgres\",\n\t\t\t\t\"Host\": \"localhost\",\n\t\t\t\t\"Port\": \"5432\",\n\t\t\t\t\"DBName\": \"tinode_test\"\n    }\n  }\n}\n"
  },
  {
    "path": "server/db/rethinkdb/adapter.go",
    "content": "//go:build rethinkdb\n\n// Package rethinkdb is a database adapter for RethinkDB.\npackage rethinkdb\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"hash/fnv\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/auth\"\n\t\"github.com/tinode/chat/server/db/common\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store\"\n\tt \"github.com/tinode/chat/server/store/types\"\n\trdb \"gopkg.in/rethinkdb/rethinkdb-go.v6\"\n)\n\n// adapter holds RethinkDb connection data.\ntype adapter struct {\n\tconn   *rdb.Session\n\tdbName string\n\t// Maximum number of records to return\n\tmaxResults int\n\t// Maximum number of message records to return\n\tmaxMessageResults int\n\tversion           int\n}\n\nconst (\n\tadpVersion  = 116\n\tadapterName = \"rethinkdb\"\n\n\tdefaultHost     = \"localhost:28015\"\n\tdefaultDatabase = \"tinode\"\n\n\tdefaultMaxResults = 1024\n\t// This is capped by the Session's send queue limit (128).\n\tdefaultMaxMessageResults = 100\n)\n\n// See https://godoc.org/github.com/rethinkdb/rethinkdb-go#ConnectOpts for explanations.\ntype configType struct {\n\tDatabase          string `json:\"database,omitempty\"`\n\tAddresses         any    `json:\"addresses,omitempty\"`\n\tUsername          string `json:\"username,omitempty\"`\n\tPassword          string `json:\"password,omitempty\"`\n\tAuthKey           string `json:\"authkey,omitempty\"`\n\tTimeout           int    `json:\"timeout,omitempty\"`\n\tWriteTimeout      int    `json:\"write_timeout,omitempty\"`\n\tReadTimeout       int    `json:\"read_timeout,omitempty\"`\n\tKeepAlivePeriod   int    `json:\"keep_alive_timeout,omitempty\"`\n\tUseJSONNumber     bool   `json:\"use_json_number,omitempty\"`\n\tNumRetries        int    `json:\"num_retries,omitempty\"`\n\tInitialCap        int    `json:\"initial_cap,omitempty\"`\n\tMaxOpen           int    `json:\"max_open,omitempty\"`\n\tDiscoverHosts     bool   `json:\"discover_hosts,omitempty\"`\n\tHostDecayDuration int    `json:\"host_decay_duration,omitempty\"`\n}\n\n// Open initializes rethinkdb session\nfunc (a *adapter) Open(jsonconfig json.RawMessage) error {\n\tif a.conn != nil {\n\t\treturn errors.New(\"adapter rethinkdb is already connected\")\n\t}\n\n\tif len(jsonconfig) < 2 {\n\t\treturn errors.New(\"adapter rethinkdb missing config\")\n\t}\n\n\tvar err error\n\tvar config configType\n\tif err = json.Unmarshal(jsonconfig, &config); err != nil {\n\t\treturn errors.New(\"adapter rethinkdb failed to parse config: \" + err.Error())\n\t}\n\n\tvar opts rdb.ConnectOpts\n\n\tif config.Addresses == nil {\n\t\topts.Address = defaultHost\n\t} else if host, ok := config.Addresses.(string); ok {\n\t\topts.Address = host\n\t} else if ihosts, ok := config.Addresses.([]any); ok && len(ihosts) > 0 {\n\t\thosts := make([]string, len(ihosts))\n\t\tfor i, ih := range ihosts {\n\t\t\th, ok := ih.(string)\n\t\t\tif !ok || h == \"\" {\n\t\t\t\treturn errors.New(\"adapter rethinkdb invalid config.Addresses value\")\n\t\t\t}\n\t\t\thosts[i] = h\n\t\t}\n\t\topts.Addresses = hosts\n\t} else {\n\t\treturn errors.New(\"adapter rethinkdb failed to parse config.Addresses\")\n\t}\n\n\tif config.Database == \"\" {\n\t\ta.dbName = defaultDatabase\n\t} else {\n\t\ta.dbName = config.Database\n\t}\n\n\tif a.maxResults <= 0 {\n\t\ta.maxResults = defaultMaxResults\n\t}\n\n\tif a.maxMessageResults <= 0 {\n\t\ta.maxMessageResults = defaultMaxMessageResults\n\t}\n\n\topts.Database = a.dbName\n\topts.Username = config.Username\n\topts.Password = config.Password\n\topts.AuthKey = config.AuthKey\n\topts.Timeout = time.Duration(config.Timeout) * time.Second\n\topts.WriteTimeout = time.Duration(config.WriteTimeout) * time.Second\n\topts.ReadTimeout = time.Duration(config.ReadTimeout) * time.Second\n\topts.KeepAlivePeriod = time.Duration(config.KeepAlivePeriod) * time.Second\n\topts.UseJSONNumber = config.UseJSONNumber\n\topts.NumRetries = config.NumRetries\n\topts.InitialCap = config.InitialCap\n\topts.MaxOpen = config.MaxOpen\n\topts.DiscoverHosts = config.DiscoverHosts\n\topts.HostDecayDuration = time.Duration(config.HostDecayDuration) * time.Second\n\n\ta.conn, err = rdb.Connect(opts)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trdb.SetTags(\"json\")\n\ta.version = -1\n\n\treturn nil\n}\n\n// Close closes the underlying database connection\nfunc (a *adapter) Close() error {\n\tvar err error\n\tif a.conn != nil {\n\t\t// Close will wait for all outstanding requests to finish\n\t\terr = a.conn.Close()\n\t\ta.conn = nil\n\t\ta.version = -1\n\t}\n\treturn err\n}\n\n// IsOpen returns true if connection to database has been established. It does not check if\n// connection is actually live.\nfunc (a *adapter) IsOpen() bool {\n\treturn a.conn != nil\n}\n\n// GetDbVersion returns current database version.\nfunc (a *adapter) GetDbVersion() (int, error) {\n\tif a.version > 0 {\n\t\treturn a.version, nil\n\t}\n\n\tcursor, err := rdb.DB(a.dbName).Table(\"kvmeta\").Get(\"version\").Field(\"value\").Run(a.conn)\n\tif err != nil {\n\t\tif isMissingDb(err) {\n\t\t\terr = errors.New(\"Database not initialized\")\n\t\t}\n\t\treturn -1, err\n\t}\n\tdefer cursor.Close()\n\n\tif cursor.IsNil() {\n\t\treturn -1, errors.New(\"Database not initialized\")\n\t}\n\n\tvar vers int\n\tif err = cursor.One(&vers); err != nil {\n\t\treturn -1, err\n\t}\n\n\ta.version = vers\n\n\treturn vers, nil\n}\n\nfunc (a *adapter) updateDbVersion(v int) error {\n\ta.version = -1\n\tif _, err := rdb.DB(a.dbName).Table(\"kvmeta\").Get(\"version\").\n\t\tUpdate(map[string]any{\"value\": v}).RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// CheckDbVersion checks whether the actual DB version matches the expected version of this adapter.\nfunc (a *adapter) CheckDbVersion() error {\n\tversion, err := a.GetDbVersion()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif version != adpVersion {\n\t\treturn errors.New(\"Invalid database version \" + strconv.Itoa(version) +\n\t\t\t\". Expected \" + strconv.Itoa(adpVersion))\n\t}\n\n\treturn nil\n}\n\n// Version returns adapter version.\nfunc (adapter) Version() int {\n\treturn adpVersion\n}\n\n// Stats returns DB connection stats object.\nfunc (a *adapter) Stats() any {\n\tif a.conn == nil {\n\t\treturn nil\n\t}\n\n\tcursor, err := rdb.DB(\"rethinkdb\").Table(\"stats\").Get([]string{\"cluster\"}).Field(\"query_engine\").Run(a.conn)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tdefer cursor.Close()\n\n\tvar stats []any\n\tif err = cursor.All(&stats); err != nil || len(stats) < 1 {\n\t\treturn nil\n\t}\n\n\treturn stats[0]\n}\n\n// GetName returns string that adapter uses to register itself with store.\nfunc (a *adapter) GetName() string {\n\treturn adapterName\n}\n\n// SetMaxResults configures how many results can be returned in a single DB call.\nfunc (a *adapter) SetMaxResults(val int) error {\n\tif val <= 0 {\n\t\ta.maxResults = defaultMaxResults\n\t} else {\n\t\ta.maxResults = val\n\t}\n\n\treturn nil\n}\n\n// CreateDb initializes the storage. If reset is true, the database is first deleted losing all the data.\nfunc (a *adapter) CreateDb(reset bool) error {\n\n\t// Drop database if exists, ignore error if it does not.\n\tif reset {\n\t\trdb.DBDrop(a.dbName).RunWrite(a.conn)\n\t}\n\n\tif _, err := rdb.DBCreate(a.dbName).RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\n\t// Table with metadata key-value pairs.\n\tif _, err := rdb.DB(a.dbName).TableCreate(\"kvmeta\", rdb.TableCreateOpts{PrimaryKey: \"key\"}).RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\n\t// Users\n\tif _, err := rdb.DB(a.dbName).TableCreate(\"users\", rdb.TableCreateOpts{PrimaryKey: \"Id\"}).RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\t// Create secondary index on State for finding suspended and soft-deleted users.\n\tif _, err := rdb.DB(a.dbName).Table(\"users\").IndexCreate(\"State\").RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\t// Create secondary index on User.Tags array so user can be found by tags.\n\tif _, err := rdb.DB(a.dbName).Table(\"users\").IndexCreate(\"Tags\", rdb.IndexCreateOpts{Multi: true}).RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\t// Create secondary index for User.Devices.<hash>.DeviceId to ensure ID uniqueness across users\n\tif _, err := rdb.DB(a.dbName).Table(\"users\").IndexCreateFunc(\"DeviceIds\",\n\t\tfunc(row rdb.Term) any {\n\t\t\tdevices := row.Field(\"Devices\")\n\t\t\treturn devices.Keys().Map(func(key rdb.Term) any {\n\t\t\t\treturn devices.Field(key).Field(\"DeviceId\")\n\t\t\t})\n\t\t}, rdb.IndexCreateOpts{Multi: true}).RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\n\t// User authentication records {unique, userid, secret}\n\tif _, err := rdb.DB(a.dbName).TableCreate(\"auth\", rdb.TableCreateOpts{PrimaryKey: \"unique\"}).RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\t// Should be able to access user's auth records by user id\n\tif _, err := rdb.DB(a.dbName).Table(\"auth\").IndexCreate(\"userid\").RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\n\t// Subscription to a topic. The primary key is a Topic:User string\n\tif _, err := rdb.DB(a.dbName).TableCreate(\"subscriptions\", rdb.TableCreateOpts{PrimaryKey: \"Id\"}).RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\tif _, err := rdb.DB(a.dbName).Table(\"subscriptions\").IndexCreate(\"User\").RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\tif _, err := rdb.DB(a.dbName).Table(\"subscriptions\").IndexCreate(\"Topic\").RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\n\t// Topics stored in database\n\tif _, err := rdb.DB(a.dbName).TableCreate(\"topics\", rdb.TableCreateOpts{PrimaryKey: \"Id\"}).RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\t// Secondary index on Owner field for deleting users.\n\tif _, err := rdb.DB(a.dbName).Table(\"topics\").IndexCreate(\"Owner\").RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\t// Create secondary index on State for finding suspended and soft-deleted topics.\n\tif _, err := rdb.DB(a.dbName).Table(\"topics\").IndexCreate(\"State\").RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\t// Secondary index on Topic.Tags array so topics can be found by tags.\n\t// These tags are not unique as opposite to User.Tags.\n\tif _, err := rdb.DB(a.dbName).Table(\"topics\").IndexCreate(\"Tags\", rdb.IndexCreateOpts{Multi: true}).RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\t// Create system topic 'sys'.\n\tif err := createSystemTopic(a); err != nil {\n\t\treturn err\n\t}\n\n\t// Stored message\n\tif _, err := rdb.DB(a.dbName).TableCreate(\"messages\", rdb.TableCreateOpts{PrimaryKey: \"Id\"}).RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\t// Compound index of topic - seqID for selecting messages in a topic.\n\tif _, err := rdb.DB(a.dbName).Table(\"messages\").IndexCreateFunc(\"Topic_SeqId\",\n\t\tfunc(row rdb.Term) any {\n\t\t\treturn []any{row.Field(\"Topic\"), row.Field(\"SeqId\")}\n\t\t}).RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\t// Compound index of hard-deleted messages\n\tif _, err := rdb.DB(a.dbName).Table(\"messages\").IndexCreateFunc(\"Topic_DelId\",\n\t\tfunc(row rdb.Term) any {\n\t\t\treturn []any{row.Field(\"Topic\"), row.Field(\"DelId\")}\n\t\t}).RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\t// Compound multi-index of soft-deleted messages: each message gets multiple compound index entries like\n\t// [Topic, User1, DelId1], [Topic, User2, DelId2],...\n\tif _, err := rdb.DB(a.dbName).Table(\"messages\").IndexCreateFunc(\"Topic_DeletedFor\",\n\t\tfunc(row rdb.Term) any {\n\t\t\treturn row.Field(\"DeletedFor\").Map(func(df rdb.Term) any {\n\t\t\t\treturn []any{row.Field(\"Topic\"), df.Field(\"User\"), df.Field(\"DelId\")}\n\t\t\t})\n\t\t}, rdb.IndexCreateOpts{Multi: true}).RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\n\t// Log of deleted messages\n\tif _, err := rdb.DB(a.dbName).TableCreate(\"dellog\", rdb.TableCreateOpts{PrimaryKey: \"Id\"}).RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\tif _, err := rdb.DB(a.dbName).Table(\"dellog\").IndexCreateFunc(\"Topic_DelId\",\n\t\tfunc(row rdb.Term) any {\n\t\t\treturn []any{row.Field(\"Topic\"), row.Field(\"DelId\")}\n\t\t}).RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\n\t// User credentials - contact information such as \"email:jdoe@example.com\" or \"tel:+18003287448\":\n\t// Id: \"method:credential\" like \"email:jdoe@example.com\". See types.Credential.\n\tif _, err := rdb.DB(a.dbName).TableCreate(\"credentials\", rdb.TableCreateOpts{PrimaryKey: \"Id\"}).RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\t// Create secondary index on credentials.User to be able to query credentials by user id.\n\tif _, err := rdb.DB(a.dbName).Table(\"credentials\").IndexCreate(\"User\").RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\n\t// Records of file uploads. See types.FileDef.\n\tif _, err := rdb.DB(a.dbName).TableCreate(\"fileuploads\", rdb.TableCreateOpts{PrimaryKey: \"Id\"}).RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\t// A secondary index on fileuploads.UseCount to be able to delete unused records at once.\n\tif _, err := rdb.DB(a.dbName).Table(\"fileuploads\").IndexCreate(\"UseCount\").RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\n\t// Record current DB version.\n\tif _, err := rdb.DB(a.dbName).Table(\"kvmeta\").Insert(\n\t\tmap[string]any{\"key\": \"version\", \"value\": adpVersion}).RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// UpgradeDb upgrades the database to the latest version.\nfunc (a *adapter) UpgradeDb() error {\n\tbumpVersion := func(a *adapter, x int) error {\n\t\tif err := a.updateDbVersion(x); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err := a.GetDbVersion()\n\t\treturn err\n\t}\n\n\t_, err := a.GetDbVersion()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif a.version == 106 || a.version == 107 {\n\t\t// Perform database upgrade from versions 106 or 107 to version 108.\n\n\t\t// Replace default 'Auth' access mode JRWPA with JRWPAS\n\t\tfilter := map[string]any{\"Access\": map[string]any{\"Auth\": t.ModeCP2P}}\n\t\tupdate := map[string]any{\"Access\": map[string]any{\"Auth\": t.ModeCAuth}}\n\t\tif _, err := rdb.DB(a.dbName).Table(\"users\").Filter(filter).Update(update).RunWrite(a.conn); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := bumpVersion(a, 108); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version == 108 {\n\t\t// Perform database upgrade from versions 108 to version 109.\n\n\t\tif err := createSystemTopic(a); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := bumpVersion(a, 109); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version == 109 {\n\t\t// Perform database upgrade from versions 109 to version 110.\n\n\t\t// TouchedAt is a required field now, but it's OK if it's missing.\n\t\t// Bumping version to keep RDB in sync with MySQL versions.\n\n\t\tif err := bumpVersion(a, 110); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version == 110 {\n\t\t// Perform database upgrade from versions 110 to version 111.\n\n\t\t// Users\n\n\t\t// Reset previously unused field State to value StateOK.\n\t\tif _, err := rdb.DB(a.dbName).Table(\"users\").\n\t\t\tUpdate(map[string]any{\"State\": t.StateOK}).\n\t\t\tRunWrite(a.conn); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Add StatusDeleted to all deleted users as indicated by DeletedAt not being null.\n\t\tif _, err := rdb.DB(a.dbName).Table(\"users\").\n\t\t\tBetween(rdb.MinVal, rdb.MaxVal, rdb.BetweenOpts{Index: \"DeletedAt\"}).\n\t\t\tUpdate(map[string]any{\"State\": t.StateDeleted}).\n\t\t\tRunWrite(a.conn); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Rename DeletedAt into StateAt. Update only those rows which have defined DeletedAt.\n\t\tif _, err := rdb.DB(a.dbName).Table(\"users\").\n\t\t\tBetween(rdb.MinVal, rdb.MaxVal, rdb.BetweenOpts{Index: \"DeletedAt\"}).\n\t\t\tReplace(func(row rdb.Term) rdb.Term {\n\t\t\t\treturn row.Without(\"DeletedAt\").\n\t\t\t\t\tMerge(map[string]any{\"StateAt\": row.Field(\"DeletedAt\")})\n\t\t\t}).\n\t\t\tRunWrite(a.conn); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Drop secondary index DeletedAt.\n\t\tif _, err := rdb.DB(a.dbName).Table(\"users\").IndexDrop(\"DeletedAt\").RunWrite(a.conn); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Create secondary index on State for finding suspended and soft-deleted topics.\n\t\tif _, err := rdb.DB(a.dbName).Table(\"users\").IndexCreate(\"State\").RunWrite(a.conn); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Topics\n\n\t\t// Add StateDeleted to all topics with DeletedAt not null.\n\t\tif _, err := rdb.DB(a.dbName).Table(\"topics\").\n\t\t\tFilter(rdb.Row.HasFields(\"DeletedAt\")).\n\t\t\tUpdate(map[string]any{\"State\": t.StateDeleted}).\n\t\t\tRunWrite(a.conn); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Set StateOK for all other topics.\n\t\tif _, err := rdb.DB(a.dbName).Table(\"topics\").\n\t\t\tFilter(rdb.Row.HasFields(\"State\").Not()).\n\t\t\tUpdate(map[string]any{\"State\": t.StateOK}).\n\t\t\tRunWrite(a.conn); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Rename DeletedAt into StateAt. Update only those rows which have defined DeletedAt.\n\t\tif _, err := rdb.DB(a.dbName).Table(\"topics\").\n\t\t\tFilter(rdb.Row.HasFields(\"DeletedAt\")).\n\t\t\tReplace(func(row rdb.Term) rdb.Term {\n\t\t\t\treturn row.Without(\"DeletedAt\").\n\t\t\t\t\tMerge(map[string]any{\"StateAt\": row.Field(\"DeletedAt\")})\n\t\t\t}).\n\t\t\tRunWrite(a.conn); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Create secondary index on State for finding suspended and soft-deleted topics.\n\t\tif _, err := rdb.DB(a.dbName).Table(\"topics\").IndexCreate(\"State\").RunWrite(a.conn); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := bumpVersion(a, 111); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version == 111 {\n\t\t// Just bump the version to keep up with MySQL.\n\t\tif err := bumpVersion(a, 112); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version == 112 {\n\t\t// Secondary indexes cannot store NULLs, consequently no useful indexes can be created.\n\t\t// Just bump the version.\n\t\tif err := bumpVersion(a, 113); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version < 116 {\n\t\t// Version 114: topics.aux added, fileuploads.etag added.\n\t\t// Version 115: SQL indexes added.\n\t\t// Version 116: topics.subcnt added.\n\n\t\t// Just bump the version.\n\t\tif err := bumpVersion(a, 116); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif a.version != adpVersion {\n\t\treturn errors.New(\"Failed to perform database upgrade to version \" + strconv.Itoa(adpVersion) +\n\t\t\t\". DB is still at \" + strconv.Itoa(a.version))\n\t}\n\treturn nil\n}\n\n// Create system topic 'sys'.\nfunc createSystemTopic(a *adapter) error {\n\tnow := t.TimeNow()\n\t_, err := rdb.DB(a.dbName).Table(\"topics\").Insert(&t.Topic{\n\t\tObjHeader: t.ObjHeader{Id: \"sys\",\n\t\t\tCreatedAt: now,\n\t\t\tUpdatedAt: now},\n\t\tTouchedAt: now,\n\t\tAccess:    t.DefaultAccess{Auth: t.ModeNone, Anon: t.ModeNone},\n\t\tPublic:    map[string]any{\"fn\": \"System\"},\n\t}).RunWrite(a.conn)\n\treturn err\n}\n\n// UserCreate creates a new user. Returns error and true if error is due to duplicate user name,\n// false for any other error\nfunc (a *adapter) UserCreate(user *t.User) error {\n\t_, err := rdb.DB(a.dbName).Table(\"users\").Insert(&user).RunWrite(a.conn)\n\treturn err\n}\n\n// AuthAddRecord adds user's authentication record\nfunc (a *adapter) AuthAddRecord(uid t.Uid, scheme, unique string, authLvl auth.Level,\n\tsecret []byte, expires time.Time) error {\n\n\t_, err := rdb.DB(a.dbName).Table(\"auth\").Insert(\n\t\t&common.AuthRecord{\n\t\t\tUnique:  unique,\n\t\t\tUserId:  uid.String(),\n\t\t\tScheme:  scheme,\n\t\t\tAuthLvl: authLvl,\n\t\t\tSecret:  secret,\n\t\t\tExpires: expires}).RunWrite(a.conn)\n\tif err != nil {\n\t\tif rdb.IsConflictErr(err) {\n\t\t\treturn t.ErrDuplicate\n\t\t}\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// AuthDelScheme deletes an existing authentication scheme for the user.\nfunc (a *adapter) AuthDelScheme(uid t.Uid, scheme string) error {\n\t_, err := rdb.DB(a.dbName).Table(\"auth\").\n\t\tGetAllByIndex(\"userid\", uid.String()).\n\t\tFilter(map[string]any{\"scheme\": scheme}).\n\t\tDelete().RunWrite(a.conn)\n\treturn err\n}\n\n// AuthDelAllRecords deletes user's all authentication records\nfunc (a *adapter) AuthDelAllRecords(uid t.Uid) (int, error) {\n\tres, err := rdb.DB(a.dbName).Table(\"auth\").GetAllByIndex(\"userid\", uid.String()).Delete().RunWrite(a.conn)\n\treturn res.Deleted, err\n}\n\n// AuthUpdRecord updates user's authentication secret.\nfunc (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string, authLvl auth.Level,\n\tsecret []byte, expires time.Time) error {\n\t// The 'unique' is used as a primary key (no other way to ensure uniqueness in RethinkDB).\n\t// The primary key is immutable. If 'unique' has changed, we have to replace the old record with a new one:\n\t// 1. Check if 'unique' has changed.\n\t// 2. If not, execute update by 'unique'\n\t// 3. If yes, first insert the new record (it may fail due to dublicate 'unique') then delete the old one.\n\n\t// Get the old 'unique'\n\tcursor, err := rdb.DB(a.dbName).Table(\"auth\").GetAllByIndex(\"userid\", uid.String()).\n\t\tFilter(map[string]any{\"scheme\": scheme}).\n\t\tPluck(\"unique\").Default(nil).Run(a.conn)\n\tif err != nil {\n\t\tif isNoResults(err) {\n\t\t\treturn t.ErrNotFound\n\t\t}\n\t\treturn err\n\t}\n\tdefer cursor.Close()\n\n\tif cursor.IsNil() {\n\t\t// If the record is not found, don't update it\n\t\treturn t.ErrNotFound\n\t}\n\n\tvar record common.AuthRecord\n\tif err = cursor.One(&record); err != nil {\n\t\treturn err\n\t}\n\tif record.Unique == unique {\n\t\t// Unique has not changed\n\t\tupd := map[string]any{\n\t\t\t\"authLvl\": authLvl,\n\t\t}\n\t\tif len(secret) > 0 {\n\t\t\tupd[\"secret\"] = secret\n\t\t}\n\t\tif !expires.IsZero() {\n\t\t\tupd[\"expires\"] = expires\n\t\t}\n\t\t_, err = rdb.DB(a.dbName).Table(\"auth\").Get(unique).Update(upd).RunWrite(a.conn)\n\t} else {\n\t\t// Unique has changed. Insert-Delete.\n\t\t//  No support for transactions :(\n\t\tif len(secret) == 0 {\n\t\t\tsecret = record.Secret\n\t\t}\n\t\tif expires.IsZero() {\n\t\t\texpires = record.Expires\n\t\t}\n\t\terr = a.AuthAddRecord(uid, scheme, unique, authLvl, secret, expires)\n\t\tif err == nil {\n\t\t\t// We can't do much with the error here.\n\t\t\trdb.DB(a.dbName).Table(\"auth\").Get(record.Unique).Delete().RunWrite(a.conn)\n\t\t}\n\t}\n\treturn err\n}\n\n// AuthGetRecord retrieves user's authentication record by user ID and scheme.\nfunc (a *adapter) AuthGetRecord(uid t.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) {\n\t// Default() is needed to prevent Pluck from returning an error\n\tcursor, err := rdb.DB(a.dbName).Table(\"auth\").GetAllByIndex(\"userid\", uid.String()).\n\t\tFilter(map[string]any{\"scheme\": scheme}).\n\t\tPluck(\"unique\", \"secret\", \"expires\", \"authLvl\").Default(nil).Run(a.conn)\n\tif err != nil {\n\t\treturn \"\", 0, nil, time.Time{}, err\n\t}\n\tdefer cursor.Close()\n\n\tif cursor.IsNil() {\n\t\treturn \"\", 0, nil, time.Time{}, t.ErrNotFound\n\t}\n\n\tvar record struct {\n\t\tUnique  string     `json:\"unique\"`\n\t\tAuthLvl auth.Level `json:\"authLvl\"`\n\t\tSecret  []byte     `json:\"secret\"`\n\t\tExpires time.Time  `json:\"expires\"`\n\t}\n\n\tif err = cursor.One(&record); err != nil {\n\t\treturn \"\", 0, nil, time.Time{}, err\n\t}\n\t// Convert to UTC (bug? in gorethink).\n\trecord.Expires = record.Expires.UTC()\n\treturn record.Unique, record.AuthLvl, record.Secret, record.Expires, nil\n}\n\n// AuthGetUniqueRecord retrieve user's authentication record by unique value (e.g. by login).\nfunc (a *adapter) AuthGetUniqueRecord(unique string) (t.Uid, auth.Level, []byte, time.Time, error) {\n\t// Default() is needed to prevent Pluck from returning an error\n\tcursor, err := rdb.DB(a.dbName).Table(\"auth\").Get(unique).Pluck(\n\t\t\"userid\", \"secret\", \"expires\", \"authLvl\").Default(nil).Run(a.conn)\n\tif err != nil {\n\t\treturn t.ZeroUid, 0, nil, time.Time{}, err\n\t}\n\tdefer cursor.Close()\n\n\tif cursor.IsNil() {\n\t\treturn t.ZeroUid, 0, nil, time.Time{}, nil\n\t}\n\n\tvar record struct {\n\t\tUserid  string     `json:\"userid\"`\n\t\tAuthLvl auth.Level `json:\"authLvl\"`\n\t\tSecret  []byte     `json:\"secret\"`\n\t\tExpires time.Time  `json:\"expires\"`\n\t}\n\n\tif err = cursor.One(&record); err != nil {\n\t\treturn t.ZeroUid, 0, nil, time.Time{}, err\n\t}\n\n\treturn t.ParseUid(record.Userid), record.AuthLvl, record.Secret, record.Expires.UTC(), nil\n}\n\n// UserGet fetches a single user by user id. If user is not found it returns (nil, nil)\nfunc (a *adapter) UserGet(uid t.Uid) (*t.User, error) {\n\tcursor, err := rdb.DB(a.dbName).Table(\"users\").GetAll(uid.String()).\n\t\tFilter(rdb.Row.Field(\"State\").Eq(t.StateDeleted).Not()).Run(a.conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cursor.Close()\n\n\tif cursor.IsNil() {\n\t\treturn nil, nil\n\t}\n\n\tvar user t.User\n\tif err = cursor.One(&user); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &user, nil\n}\n\n// UserGetAll fetches multiple user records by UIDs.\nfunc (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) {\n\tuids := make([]any, len(ids))\n\tfor i, id := range ids {\n\t\tuids[i] = id.String()\n\t}\n\n\tusers := []t.User{}\n\tcursor, err := rdb.DB(a.dbName).Table(\"users\").GetAll(uids...).\n\t\tFilter(rdb.Row.Field(\"State\").Eq(t.StateDeleted).Not()).Run(a.conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cursor.Close()\n\n\tvar user t.User\n\tfor cursor.Next(&user) {\n\t\t// Convert timestamps to UTC (gorethink returns them as +0000)\n\t\tuser.CreatedAt = user.CreatedAt.UTC()\n\t\tuser.UpdatedAt = user.UpdatedAt.UTC()\n\t\tif user.StateAt != nil {\n\t\t\tstateAt := user.StateAt.UTC()\n\t\t\tuser.StateAt = &stateAt\n\t\t}\n\t\tusers = append(users, user)\n\t}\n\n\treturn users, cursor.Err()\n}\n\n// UserDelete deletes user record.\nfunc (a *adapter) UserDelete(uid t.Uid, hard bool) error {\n\t// Get a list of topic names owned by the user (as 'grp' and 'chn').\n\townTopics, err := a.topicNamesForUser(rdb.DB(a.dbName).Table(\"topics\").\n\t\tGetAllByIndex(\"Owner\", uid.String()).Filter(rdb.Row.Field(\"State\").Eq(t.StateDeleted).Not()).\n\t\tField(\"Id\"), true)\n\tif err != nil {\n\t\tlogs.Err.Println(\"UserDelete: cannot get user's own topics:\", err)\n\t\treturn err\n\t}\n\n\tif hard {\n\t\t// User's devices are store in user record, no separate table.\n\n\t\t// Delete user's subscriptions in all topics.\n\t\tif err = a.subsDelForUser(uid, true); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Delete records of messages soft-deleted for the user in all topics\n\t\t// and dellog entries.\n\t\tif err = a.clearUserDellog(uid, nil); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Can't delete user's messages in all topics because we cannot notify topics of such deletion.\n\t\t// Just leave the messages marked as sent by \"not found\" user.\n\n\t\t// Delete topics where the user is the owner:\n\n\t\tif len(ownTopics) > 0 {\n\t\t\t// 1. Delete dellog\n\t\t\t// 2. Decrement use counter of fileuploads: topic itself and messages.\n\t\t\t// 3. Delete all messages.\n\t\t\t// 4. Delete subscriptions.\n\t\t\tif _, err = rdb.DB(a.dbName).Table(\"topics\").GetAll(ownTopics...).ForEach(\n\t\t\t\tfunc(topic rdb.Term) rdb.Term {\n\t\t\t\t\treturn rdb.Expr([]any{\n\t\t\t\t\t\t// Delete dellog\n\t\t\t\t\t\trdb.DB(a.dbName).Table(\"dellog\").Between(\n\t\t\t\t\t\t\t[]any{topic.Field(\"Id\"), rdb.MinVal},\n\t\t\t\t\t\t\t[]any{topic.Field(\"Id\"), rdb.MaxVal},\n\t\t\t\t\t\t\trdb.BetweenOpts{Index: \"Topic_DelId\"}).Delete(),\n\t\t\t\t\t\t// Decrement topic attachment UseCounter\n\t\t\t\t\t\trdb.DB(a.dbName).Table(\"fileuploads\").GetAll(topic.Field(\"Attachments\")).\n\t\t\t\t\t\t\tUpdate(func(fu rdb.Term) any {\n\t\t\t\t\t\t\t\treturn map[string]any{\"UseCount\": fu.Field(\"UseCount\").Default(1).Sub(1)}\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t// Decrement message attachments UseCounter\n\t\t\t\t\t\trdb.DB(a.dbName).Table(\"fileuploads\").GetAll(\n\t\t\t\t\t\t\trdb.Args(\n\t\t\t\t\t\t\t\trdb.DB(a.dbName).Table(\"messages\").Between(\n\t\t\t\t\t\t\t\t\t[]any{topic.Field(\"Id\"), rdb.MinVal},\n\t\t\t\t\t\t\t\t\t[]any{topic.Field(\"Id\"), rdb.MaxVal},\n\t\t\t\t\t\t\t\t\trdb.BetweenOpts{Index: \"Topic_SeqId\"}).\n\t\t\t\t\t\t\t\t\t// Fetch messages with attachments only\n\t\t\t\t\t\t\t\t\tFilter(func(msg rdb.Term) rdb.Term {\n\t\t\t\t\t\t\t\t\t\treturn msg.HasFields(\"Attachments\")\n\t\t\t\t\t\t\t\t\t}).\n\t\t\t\t\t\t\t\t\t// Flatten arrays\n\t\t\t\t\t\t\t\t\tConcatMap(func(row rdb.Term) any { return row.Field(\"Attachments\") }).\n\t\t\t\t\t\t\t\t\tCoerceTo(\"array\"))).\n\t\t\t\t\t\t\tUpdate(func(fu rdb.Term) any {\n\t\t\t\t\t\t\t\treturn map[string]any{\"UseCount\": fu.Field(\"UseCount\").Default(1).Sub(1)}\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t// Delete messages\n\t\t\t\t\t\trdb.DB(a.dbName).Table(\"messages\").Between(\n\t\t\t\t\t\t\t[]any{topic.Field(\"Id\"), rdb.MinVal},\n\t\t\t\t\t\t\t[]any{topic.Field(\"Id\"), rdb.MaxVal},\n\t\t\t\t\t\t\trdb.BetweenOpts{Index: \"Topic_SeqId\"}).Delete(),\n\t\t\t\t\t\t// Delete subscriptions\n\t\t\t\t\t\trdb.DB(a.dbName).Table(\"subscriptions\").\n\t\t\t\t\t\t\tGetAllByIndex(\"Topic\", topic.Field(\"Id\")).Delete(),\n\t\t\t\t\t})\n\t\t\t\t}).RunWrite(a.conn); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// And finally delete the topics.\n\t\t\tif _, err = rdb.DB(a.dbName).Table(\"topics\").GetAllByIndex(\"Owner\", uid.String()).\n\t\t\t\tDelete().RunWrite(a.conn); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t// Delete user's authentication records.\n\t\tif _, err = a.AuthDelAllRecords(uid); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Delete credentials.\n\t\tif err = a.CredDel(uid, \"\", \"\"); err != nil && err != t.ErrNotFound {\n\t\t\treturn err\n\t\t}\n\n\t\t// Must use GetAll to produce array result expected by decFileUseCounter.\n\t\tq := rdb.DB(a.dbName).Table(\"users\").GetAll(uid.String())\n\n\t\t// Unlink user's attachment.\n\t\tif err = a.decFileUseCounter(q); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// And finally delete the user.\n\t\t_, err = q.Delete().RunWrite(a.conn)\n\t} else {\n\t\t// Disable user's subscriptions.\n\t\tif err = a.subsDelForUser(uid, false); err != nil {\n\t\t\tlogs.Err.Println(\"UserDelete: subsDelForUser:\", err)\n\t\t\treturn err\n\t\t}\n\n\t\tnow := t.TimeNow()\n\t\tdisable := map[string]any{\n\t\t\t\"UpdatedAt\": now,\n\t\t\t\"State\":     t.StateDeleted,\n\t\t\t\"StateAt\":   now,\n\t\t}\n\t\tdisableSub := map[string]any{\n\t\t\t\"UpdatedAt\": now,\n\t\t\t\"DeletedAt\": now,\n\t\t}\n\t\tif len(ownTopics) > 0 {\n\t\t\t// Disable all subscriptions in topics where the user is the owner.\n\t\t\tif _, err = rdb.DB(a.dbName).Table(\"subscriptions\").\n\t\t\t\tGetAllByIndex(\"Topic\", ownTopics...).\n\t\t\t\tUpdate(disableSub).\n\t\t\t\tRunWrite(a.conn); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// Disable topics where the user is the owner.\n\t\t\tif _, err = rdb.DB(a.dbName).Table(\"topics\").\n\t\t\t\tGetAll(ownTopics...).\n\t\t\t\tUpdate(disable).\n\t\t\t\tRunWrite(a.conn); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t// Disable p2p topics with the user.\n\t\tp2pTopics, err := a.p2pTopicsForUser(uid)\n\t\tif err != nil {\n\t\t\tlogs.Err.Println(\"UserDelete: p2pTopics:\", err)\n\t\t\treturn err\n\t\t}\n\t\tif len(p2pTopics) > 0 {\n\t\t\t// Disable all subscriptions in p2p topics with the user.\n\t\t\tif _, err = rdb.DB(a.dbName).Table(\"subscriptions\").\n\t\t\t\tGetAllByIndex(\"Topic\", p2pTopics...).\n\t\t\t\tUpdate(disableSub).\n\t\t\t\tRunWrite(a.conn); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\t// Disable p2p topics with the user.\n\t\t\tif _, err = rdb.DB(a.dbName).Table(\"topics\").\n\t\t\t\tGetAll(p2pTopics...).\n\t\t\t\tUpdate(disable).\n\t\t\t\tRunWrite(a.conn); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t// Disable the user (same fields as topic).\n\t\t_, err = rdb.DB(a.dbName).Table(\"users\").Get(uid.String()).\n\t\t\tUpdate(disable).RunWrite(a.conn)\n\t}\n\treturn err\n}\n\n// Delete records of messages soft-deleted for the user in all topics.\nfunc (a *adapter) clearUserDellog(uid t.Uid, topics []any) error {\n\tvar err error\n\tforUser := uid.String()\n\tif topics == nil {\n\t\t// Get a list of all topics where the user has subscriptions.\n\t\ttopics, err = a.topicNamesForUser(rdb.DB(a.dbName).\n\t\t\tTable(\"subscriptions\").\n\t\t\tGetAllByIndex(\"User\", forUser).\n\t\t\tField(\"Topic\"), false)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// No need to convert channel names to group names:\n\t// channel readers cannot delete messages.\n\n\t// Remove current user from the messages' soft-deletion lists\n\t// in all topics where the user has subscriptions.\n\t_, err = rdb.DB(a.dbName).Table(\"topics\").GetAll(topics...).\n\t\tForEach(func(topic rdb.Term) rdb.Term {\n\t\t\treturn rdb.DB(a.dbName).Table(\"messages\").Between(\n\t\t\t\t[]any{topic.Field(\"Id\"), forUser, rdb.MinVal},\n\t\t\t\t[]any{topic.Field(\"Id\"), forUser, rdb.MaxVal},\n\t\t\t\trdb.BetweenOpts{Index: \"Topic_DeletedFor\"}).\n\t\t\t\tUpdate(map[string]any{\n\t\t\t\t\t// Take the DeletedFor array, subtract all values which contain current user ID in 'User' field.\n\t\t\t\t\t\"DeletedFor\": func(msg rdb.Term) rdb.Term {\n\t\t\t\t\t\treturn msg.Field(\"DeletedFor\").\n\t\t\t\t\t\t\tSetDifference(msg.Field(\"DeletedFor\").Filter(map[string]any{\"User\": forUser}))\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t}).RunWrite(a.conn)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Delete entries in dellog for this user in all topics where the user\n\t// has subscriptions.\n\t_, err = rdb.DB(a.dbName).Table(\"topics\").GetAll(topics...).\n\t\tForEach(func(topic rdb.Term) rdb.Term {\n\t\t\treturn rdb.DB(a.dbName).Table(\"dellog\").\n\t\t\t\t// Select all log entries for the given table.\n\t\t\t\tBetween(\n\t\t\t\t\t[]any{topic.Field(\"Id\"), rdb.MinVal},\n\t\t\t\t\t[]any{topic.Field(\"Id\"), rdb.MaxVal},\n\t\t\t\t\trdb.BetweenOpts{Index: \"Topic_DelId\"}).\n\t\t\t\t// Keep for deletion entries soft-deleted for the current user only.\n\t\t\t\tFilter(func(dle rdb.Term) rdb.Term { return dle.Field(\"DeletedFor\").Eq(forUser) }).\n\t\t\t\t// Delete them.\n\t\t\t\tDelete()\n\t\t}).RunWrite(a.conn)\n\n\treturn err\n}\n\n// topicNamesForUser returns a list of topic names by query.\nfunc (a *adapter) topicNamesForUser(query rdb.Term, includeChan bool) ([]any, error) {\n\tcursor, err := query.Run(a.conn)\n\tif err != nil {\n\t\tif isNoResults(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tdefer cursor.Close()\n\n\tvar result []string\n\tif err = cursor.All(&result); err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar args []any\n\tfor _, name := range result {\n\t\targs = append(args, name)\n\t\tif includeChan {\n\t\t\t// Append 'chn' topic names for each 'grp' name.\n\t\t\tif channel := t.GrpToChn(name); channel != \"\" {\n\t\t\t\targs = append(args, channel)\n\t\t\t}\n\t\t}\n\t}\n\treturn args, nil\n}\n\nfunc (a *adapter) p2pTopicsForUser(uid t.Uid) ([]any, error) {\n\treturn a.topicNamesForUser(rdb.DB(a.dbName).Table(\"subscriptions\").\n\t\tGetAllByIndex(\"User\", uid.String()).\n\t\tField(\"Topic\").\n\t\tFilter(rdb.Row.Field(\"Topic\").Match(\"^p2p\")), false)\n}\n\n// topicStateForUser is called by UserUpdate when the update contains state change.\nfunc (a *adapter) topicStateForUser(uid t.Uid, now time.Time, update any) error {\n\tstate, ok := update.(t.ObjState)\n\tif !ok {\n\t\treturn t.ErrMalformed\n\t}\n\n\tif now.IsZero() {\n\t\tnow = t.TimeNow()\n\t}\n\n\t// Change state of all topics where the user is the owner.\n\tif _, err := rdb.DB(a.dbName).Table(\"topics\").\n\t\tGetAllByIndex(\"Owner\", uid.String()).\n\t\tFilter(rdb.Row.Field(\"State\").Eq(t.StateDeleted).Not()).\n\t\tUpdate(map[string]any{\n\t\t\t\"State\":   state,\n\t\t\t\"StateAt\": now,\n\t\t}).RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\n\t// Change state of p2p topics with the user (p2p topic's owner is blank)\n\t/*\n\t\tr.db('tinode').table('topics').getAll(\n\t\t\tr.args(\n\t\t\t\tr.db(\"tinode\").table(\"subscriptions\").getAll('S8VFqRpXw5M', {index: 'User'})('Topic').coerceTo('array')\n\t\t\t)\n\t\t).update(...)\n\t*/\n\tif _, err := rdb.DB(a.dbName).Table(\"topics\").\n\t\tGetAll(rdb.Args(\n\t\t\trdb.DB(a.dbName).Table(\"subscriptions\").GetAllByIndex(\"User\", uid.String()).\n\t\t\t\tField(\"Topic\").CoerceTo(\"array\"))).\n\t\tFilter(rdb.Row.Field(\"Owner\").Eq(\"\").And(rdb.Row.Field(\"State\").Eq(t.StateDeleted).Not())).\n\t\tUpdate(map[string]any{\n\t\t\t\"State\":   state,\n\t\t\t\"StateAt\": now,\n\t\t}).RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\n\t// Subscriptions don't need to be updated:\n\t// subscriptions of a disabled user are not disabled and still can be manipulated.\n\n\treturn nil\n}\n\n// UserUpdate updates user object.\nfunc (a *adapter) UserUpdate(uid t.Uid, update map[string]any) error {\n\t_, err := rdb.DB(a.dbName).Table(\"users\").Get(uid.String()).Update(update).RunWrite(a.conn)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif state, ok := update[\"State\"]; ok {\n\t\tnow, _ := update[\"StateAt\"].(time.Time)\n\t\terr = a.topicStateForUser(uid, now, state)\n\t}\n\n\treturn err\n}\n\n// UserUpdateTags append or resets user's tags\nfunc (a *adapter) UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]string, error) {\n\t// Compare to nil vs checking for zero length: zero length reset is valid.\n\tif reset != nil {\n\t\t// Replace Tags with the new value\n\t\treturn reset, a.UserUpdate(uid, map[string]any{\"Tags\": reset})\n\t}\n\n\t// Mutate the tag list.\n\n\tnewTags := rdb.Row.Field(\"Tags\")\n\tif len(add) > 0 {\n\t\tnewTags = newTags.SetUnion(add)\n\t}\n\tif len(remove) > 0 {\n\t\tnewTags = newTags.SetDifference(remove)\n\t}\n\n\tq := rdb.DB(a.dbName).Table(\"users\").Get(uid.String())\n\t_, err := q.Update(map[string]any{\"Tags\": newTags}).RunWrite(a.conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Get the new tags.\n\t// Using Pluck instead of Field because of https://github.com/rethinkdb/rethinkdb-go/issues/486\n\tcursor, err := q.Pluck(\"Tags\").Run(a.conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cursor.Close()\n\n\tvar tagsField struct{ Tags []string }\n\terr = cursor.One(&tagsField)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(tagsField.Tags) == 0 {\n\t\ttagsField.Tags = nil\n\t}\n\treturn tagsField.Tags, nil\n}\n\n// UserGetByCred returns user ID for the given validated credential.\nfunc (a *adapter) UserGetByCred(method, value string) (t.Uid, error) {\n\tcursor, err := rdb.DB(a.dbName).Table(\"credentials\").Get(method + \":\" + value).Field(\"User\").Default(nil).Run(a.conn)\n\tif err != nil {\n\t\treturn t.ZeroUid, err\n\t}\n\tdefer cursor.Close()\n\n\tif cursor.IsNil() {\n\t\treturn t.ZeroUid, nil\n\t}\n\n\tvar userId string\n\tif err = cursor.One(&userId); err != nil {\n\t\treturn t.ZeroUid, err\n\t}\n\n\treturn t.ParseUid(userId), nil\n}\n\n// UserUnreadCount returns the total number of unread messages in all topics with\n// the R permission. If read fails, the counts are still returned with the original\n// user IDs but with the unread count undefined and non-nil error.\n// UserUnreadCount does not count unread messages in channels although it should.\nfunc (a *adapter) UserUnreadCount(ids ...t.Uid) (map[t.Uid]int, error) {\n\t// The call expects user IDs to be plain strings like \"356zaYaumiU\".\n\tuids := make([]any, len(ids))\n\tcounts := make(map[t.Uid]int, len(ids))\n\tfor i, id := range ids {\n\t\tuids[i] = id.String()\n\t\t// Ensure all original uids are always present.\n\t\tcounts[id] = 0\n\t}\n\n\t/*\n\t\tQuery:\n\t\t\tr.db(\"tinode\").table(\"subscriptions\").getAll(\"356zaYaumiU\", \"k4cvfaq8zCQ\", {index: \"User\"})\n\t\t\t  .eqJoin(\"Topic\", r.db(\"tinode\").table(\"topics\"), {index: \"Id\"})\n\t\t\t  .filter(\n\t\t\t    r.not(r.row.hasFields({\"left\": \"DeletedAt\"}).or(r.row(\"right\")(\"State\").eq(20)))\n\t\t\t  )\n\t\t\t  .zip()\n\t\t\t  .pluck(\"User\", \"ReadSeqId\", \"ModeWant\", \"ModeGiven\", \"SeqId\")\n\t\t\t  .filter(r.js('(function(row) {return row.ModeWant&row.ModeGiven&1 > 0;})'))\n\t\t\t  .group(\"User\")\n\t\t\t  .sum(function(x) {return x.getField(\"SeqId\").sub(x.getField(\"ReadSeqId\"));})\n\n\t\tResult:\n\t\t\t\t[{group: \"356zaYaumiU\", reduction: 1}, {group: \"k4cvfaq8zCQ\", reduction: 0}]\n\t*/\n\tcursor, err := rdb.DB(a.dbName).Table(\"subscriptions\").GetAllByIndex(\"User\", uids...).\n\t\tEqJoin(\"Topic\", rdb.DB(a.dbName).Table(\"topics\"), rdb.EqJoinOpts{Index: \"Id\"}).\n\t\t// left: subscription; right: topic.\n\t\tFilter(\n\t\t\trdb.Not(rdb.Row.HasFields(map[string]any{\"left\": \"DeletedAt\"}).\n\t\t\t\tOr(rdb.Row.Field(\"right\").Field(\"State\").Eq(t.StateDeleted)))).\n\t\tZip().\n\t\tPluck(\"User\", \"ReadSeqId\", \"ModeWant\", \"ModeGiven\", \"SeqId\").\n\t\tFilter(rdb.JS(\"(function(row) {return (row.ModeWant & row.ModeGiven & \" + strconv.Itoa(int(t.ModeRead)) + \") > 0;})\")).\n\t\tGroup(\"User\").\n\t\tSum(func(row rdb.Term) rdb.Term { return row.Field(\"SeqId\").Sub(row.Field(\"ReadSeqId\")) }).\n\t\tRun(a.conn)\n\tif err != nil {\n\t\treturn counts, err\n\t}\n\tdefer cursor.Close()\n\n\tvar oneCount struct {\n\t\tGroup     string\n\t\tReduction int\n\t}\n\tfor cursor.Next(&oneCount) {\n\t\tcounts[t.ParseUid(oneCount.Group)] = oneCount.Reduction\n\t}\n\terr = cursor.Err()\n\n\treturn counts, err\n}\n\n// UserGetUnvalidated returns a list of uids which have never logged in, have no\n// validated credentials and haven't been updated since lastUpdatedBefore.\nfunc (a *adapter) UserGetUnvalidated(lastUpdatedBefore time.Time, limit int) ([]t.Uid, error) {\n\t/*\n\t\tQuery:\n\t\t\tr.db('tinode').table('users')\n\t\t\t\t.filter(r.row('LastSeen').eq(null).and(r.row('UpdatedAt').lt('Mar 31 2022 01:03:38')))\n\t\t\t\t.eqJoin('Id', r.db('tinode').table('credentials'), {index: 'User'}).zip()\n\t\t\t\t.pluck('User', 'Done')\n\t\t\t\t.group('User')\n\t\t\t\t.sum(function(row) {return r.branch(row('Done'), 1, 0)})\n\t\t\t\t.ungroup()\n\t\t\t\t.filter({reduction: 0})\n\t\t\t\t.pluck('group').limit(10)\n\n\t\tResult: [{\"group\": \"3W1hPuHjobg\"}, {\"group\": \"Fh_skXNRhVg\"}, {\"group\": \"NqMZzq0ajWk\"}]\n\t*/\n\tcursor, err := rdb.DB(a.dbName).Table(\"users\").\n\t\tFilter(rdb.Row.Field(\"LastSeen\").Eq(nil).And(rdb.Row.Field(\"UpdatedAt\").Lt(lastUpdatedBefore))).\n\t\tEqJoin(\"Id\", rdb.DB(a.dbName).Table(\"credentials\"), rdb.EqJoinOpts{Index: \"User\"}).Zip().\n\t\tPluck(\"User\", \"Done\").\n\t\tGroup(\"User\").\n\t\tSum(func(row rdb.Term) rdb.Term { return rdb.Branch(row.Field(\"Done\"), 1, 0) }).\n\t\tUngroup().\n\t\tFilter(rdb.Row.Field(\"reduction\").Eq(0)).\n\t\tPluck(\"group\").\n\t\tLimit(limit).\n\t\tRun(a.conn)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cursor.Close()\n\n\tvar rec struct {\n\t\tGroup string\n\t}\n\n\tvar uids []t.Uid\n\tfor cursor.Next(&rec) {\n\t\tuid := t.ParseUid(rec.Group)\n\t\tif !uid.IsZero() {\n\t\t\tuids = append(uids, uid)\n\t\t} else {\n\t\t\treturn nil, errors.New(\"bad uid field\")\n\t\t}\n\t}\n\n\terr = cursor.Err()\n\n\treturn uids, err\n}\n\n// TopicCreate creates a topic from template\nfunc (a *adapter) TopicCreate(topic *t.Topic) error {\n\t_, err := rdb.DB(a.dbName).Table(\"topics\").Insert(&topic).RunWrite(a.conn)\n\treturn err\n}\n\n// TopicCreateP2P given two users creates a p2p topic\nfunc (a *adapter) TopicCreateP2P(initiator, invited *t.Subscription) error {\n\tinitiator.Id = initiator.Topic + \":\" + initiator.User\n\t// Don't care if the initiator changes own subscription\n\t_, err := rdb.DB(a.dbName).Table(\"subscriptions\").Insert(initiator, rdb.InsertOpts{Conflict: \"replace\"}).\n\t\tRunWrite(a.conn)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// If the second subscription exists, don't overwrite it. Just make sure it's not deleted.\n\tinvited.Id = invited.Topic + \":\" + invited.User\n\t_, err = rdb.DB(a.dbName).Table(\"subscriptions\").Insert(invited, rdb.InsertOpts{Conflict: \"error\"}).\n\t\tRunWrite(a.conn)\n\tif err != nil {\n\t\t// Is this a duplicate subscription?\n\t\tif !rdb.IsConflictErr(err) {\n\t\t\t// It's a genuine DB error\n\t\t\treturn err\n\t\t}\n\t\t// Undelete the second subsription if it exists: remove DeletedAt, update CreatedAt and UpdatedAt,\n\t\t// update ModeGiven.\n\t\t_, err = rdb.DB(a.dbName).Table(\"subscriptions\").\n\t\t\tGet(invited.Id).Replace(\n\t\t\trdb.Row.Without(\"DeletedAt\").\n\t\t\t\tMerge(map[string]any{\n\t\t\t\t\t\"CreatedAt\": invited.CreatedAt,\n\t\t\t\t\t\"UpdatedAt\": invited.UpdatedAt,\n\t\t\t\t\t\"ModeGiven\": invited.ModeGiven})).\n\t\t\tRunWrite(a.conn)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\ttopic := &t.Topic{ObjHeader: t.ObjHeader{Id: initiator.Topic}}\n\ttopic.ObjHeader.MergeTimes(&initiator.ObjHeader)\n\ttopic.TouchedAt = initiator.GetTouchedAt()\n\treturn a.TopicCreate(topic)\n}\n\n// TopicGet loads a single topic by name, if it exists. If the topic does not exist the call returns (nil, nil)\nfunc (a *adapter) TopicGet(topic string) (*t.Topic, error) {\n\t// Fetch topic by name\n\tcursor, err := rdb.DB(a.dbName).Table(\"topics\").Get(topic).Run(a.conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar tt = new(t.Topic)\n\tif err = cursor.One(tt); err != nil {\n\t\tif err == rdb.ErrEmptyResult {\n\t\t\terr = nil // No error if topic is not found.\n\t\t}\n\t\treturn nil, err\n\t}\n\n\t// The cursor is automatically closed by executing cursor.One.\n\n\tif t.GetTopicCat(topic) == t.TopicCatGrp {\n\t\t// Topic found, get subsription count. Try both topic and channel names.\n\t\tif cursor, err = rdb.DB(a.dbName).Table(\"subscriptions\").\n\t\t\tGetAllByIndex(\"Topic\", topic, t.GrpToChn(topic)).\n\t\t\tFilter(rdb.Row.HasFields(\"DeletedAt\").Not()).\n\t\t\tCount().Run(a.conn); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tsubCnt := 0\n\t\tif err = cursor.One(&subCnt); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\t// No need to close the cursor.\n\n\t\tif subCnt != tt.SubCnt {\n\t\t\t// Update the topic with the correct subscription count.\n\t\t\ttt.SubCnt = subCnt\n\t\t\tif _, err = rdb.DB(a.dbName).Table(\"topics\").Get(topic).\n\t\t\t\tUpdate(map[string]any{\"SubCnt\": subCnt}).RunWrite(a.conn); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\t// RethinkDB go driver incorrectly converts UTC timezone to +0000\n\ttt.CreatedAt = tt.CreatedAt.UTC()\n\ttt.UpdatedAt = tt.UpdatedAt.UTC()\n\ttt.TouchedAt = tt.TouchedAt.UTC()\n\tif tt.StateAt != nil {\n\t\tstateAt := tt.StateAt.UTC()\n\t\ttt.StateAt = &stateAt\n\t}\n\n\treturn tt, nil\n}\n\n// TopicsForUser loads user's contact list: p2p and grp topics, except for 'me' & 'fnd' subscriptions.\n// Reads and denormalizes Public value.\nfunc (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {\n\t// Fetch ALL user's subscriptions, even those which has not been modified recently.\n\t// We are going to use these subscriptions to fetch topics and users which may have been modified recently.\n\tq := rdb.DB(a.dbName).Table(\"subscriptions\").GetAllByIndex(\"User\", uid.String())\n\tif !keepDeleted {\n\t\t// Filter out rows with defined DeletedAt\n\t\tq = q.Filter(rdb.Row.HasFields(\"DeletedAt\").Not())\n\t}\n\n\tlimit := 0\n\tims := time.Time{}\n\tif opts != nil {\n\t\tif opts.Topic != \"\" {\n\t\t\tq = q.Filter(rdb.Row.Field(\"Topic\").Eq(opts.Topic))\n\t\t}\n\n\t\t// Apply the limit only when the client does not manage the cache (or cold start).\n\t\t// Otherwise have to get all subscriptions and do a manual join with users/topics.\n\t\tif opts.IfModifiedSince == nil {\n\t\t\tif opts.Limit > 0 && opts.Limit < a.maxResults {\n\t\t\t\tlimit = opts.Limit\n\t\t\t} else {\n\t\t\t\tlimit = a.maxResults\n\t\t\t}\n\t\t} else {\n\t\t\tims = *opts.IfModifiedSince\n\t\t}\n\t} else {\n\t\tlimit = a.maxResults\n\t}\n\n\tif limit > 0 {\n\t\tq = q.Limit(limit)\n\t}\n\n\tcursor, err := q.Run(a.conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Fetch subscriptions. Two queries are needed: users table (me & p2p) and topics table (p2p and grp).\n\t// Prepare a list of Separate subscriptions to users vs topics\n\tvar sub t.Subscription\n\tjoin := make(map[string]t.Subscription) // Keeping these to make a join with table for .private and .access\n\ttopq := make([]any, 0, 16)\n\tusrq := make([]any, 0, 16)\n\tfor cursor.Next(&sub) {\n\t\ttname := sub.Topic\n\t\tsub.User = uid.String()\n\t\ttcat := t.GetTopicCat(tname)\n\n\t\tif tcat == t.TopicCatMe || tcat == t.TopicCatFnd {\n\t\t\t// 'me' or 'fnd' subscription, skip. Don't skip 'sys'.\n\t\t\tcontinue\n\t\t} else if tcat == t.TopicCatP2P {\n\t\t\t// P2P subscription, find the other user to get user.Public\n\t\t\tuid1, uid2, _ := t.ParseP2P(sub.Topic)\n\t\t\tif uid1 == uid {\n\t\t\t\tusrq = append(usrq, uid2.String())\n\t\t\t\tsub.SetWith(uid2.UserId())\n\t\t\t} else {\n\t\t\t\tusrq = append(usrq, uid1.String())\n\t\t\t\tsub.SetWith(uid1.UserId())\n\t\t\t}\n\t\t} else if tcat == t.TopicCatGrp {\n\t\t\t// Maybe convert channel name to topic name.\n\t\t\ttname = t.ChnToGrp(tname)\n\t\t}\n\t\t// No special handling needed for 'slf', 'sys' subscriptions.\n\n\t\ttopq = append(topq, tname)\n\t\tjoin[tname] = sub\n\t}\n\terr = cursor.Err()\n\tcursor.Close()\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar subs []t.Subscription\n\tif len(join) == 0 {\n\t\treturn subs, nil\n\t}\n\n\tif len(topq) > 0 {\n\t\t// Fetch grp & p2p topics\n\t\tq = rdb.DB(a.dbName).Table(\"topics\").GetAll(topq...)\n\t\tif !keepDeleted {\n\t\t\tq = q.Filter(rdb.Row.Field(\"State\").Eq(t.StateDeleted).Not())\n\t\t}\n\n\t\tif !ims.IsZero() {\n\t\t\t// Use cache timestamp if provided: get newer entries only.\n\t\t\tq = q.Filter(rdb.Row.Field(\"TouchedAt\").Gt(ims))\n\n\t\t\tif limit > 0 && limit < len(topq) {\n\t\t\t\t// No point in fetching more than the requested limit.\n\t\t\t\tq = q.OrderBy(\"TouchedAt\").Limit(limit)\n\t\t\t}\n\t\t}\n\n\t\tcursor, err = q.Run(a.conn)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tvar top t.Topic\n\t\tfor cursor.Next(&top) {\n\t\t\tsub = join[top.Id]\n\t\t\t// Check if sub.UpdatedAt needs to be adjusted to earlier or later time.\n\t\t\t// top.UpdatedAt is guaranteed to be after IMS if IMS is non-zero.\n\t\t\tsub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, top.UpdatedAt)\n\t\t\tsub.SetState(top.State)\n\t\t\tsub.SetTouchedAt(top.TouchedAt)\n\t\t\tsub.SetSeqId(top.SeqId)\n\t\t\tif t.GetTopicCat(sub.Topic) == t.TopicCatGrp {\n\t\t\t\tsub.SetSubCnt(top.SubCnt)\n\t\t\t\tsub.SetPublic(top.Public)\n\t\t\t\tsub.SetTrusted(top.Trusted)\n\t\t\t}\n\t\t\t// Put back the updated value of a subsription, will process further below.\n\t\t\tjoin[top.Id] = sub\n\t\t}\n\t\terr = cursor.Err()\n\t\tcursor.Close()\n\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\t// Fetch p2p users and join to p2p subscriptions.\n\tif len(usrq) > 0 {\n\t\tq = rdb.DB(a.dbName).Table(\"users\").GetAll(usrq...)\n\t\tif !keepDeleted {\n\t\t\t// Optionally skip deleted users.\n\t\t\tq = q.Filter(rdb.Row.Field(\"State\").Eq(t.StateDeleted).Not())\n\t\t}\n\n\t\t// Ignoring ims: we need all users to get LastSeen and UserAgent.\n\n\t\tcursor, err = q.Run(a.conn)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tvar usr2 t.User\n\t\tfor cursor.Next(&usr2) {\n\t\t\tjoinOn := uid.P2PName(t.ParseUid(usr2.Id))\n\t\t\tif sub, ok := join[joinOn]; ok {\n\t\t\t\tsub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, usr2.UpdatedAt)\n\t\t\t\tsub.SetState(usr2.State)\n\t\t\t\tsub.SetPublic(usr2.Public)\n\t\t\t\tsub.SetTrusted(usr2.Trusted)\n\t\t\t\tsub.SetDefaultAccess(usr2.Access.Auth, usr2.Access.Anon)\n\t\t\t\tsub.SetLastSeenAndUA(usr2.LastSeen, usr2.UserAgent)\n\t\t\t\tjoin[joinOn] = sub\n\t\t\t}\n\t\t}\n\t\terr = cursor.Err()\n\t\tcursor.Close()\n\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tsubs = make([]t.Subscription, 0, len(join))\n\tfor _, sub := range join {\n\t\tsubs = append(subs, sub)\n\t}\n\n\treturn common.SelectEarliestUpdatedSubs(subs, opts, a.maxResults), nil\n}\n\n// UsersForTopic loads users subscribed to the given topic (not channel readers).\n// The difference between UsersForTopic vs SubsForTopic is that the former loads user.Public,\n// the latter does not.\nfunc (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {\n\ttcat := t.GetTopicCat(topic)\n\n\t// Fetch topic subscribers\n\t// Fetch all subscribed users. The number of users is not large\n\tq := rdb.DB(a.dbName).Table(\"subscriptions\").GetAllByIndex(\"Topic\", topic)\n\tif !keepDeleted && tcat != t.TopicCatP2P {\n\t\t// Filter out rows with DeletedAt being not null.\n\t\t// P2P topics must load all subscriptions otherwise it will be impossible\n\t\t// to swap Public values.\n\t\tq = q.Filter(rdb.Row.HasFields(\"DeletedAt\").Not())\n\t}\n\n\tlimit := a.maxResults\n\tvar oneUser t.Uid\n\tif opts != nil {\n\t\t// Ignore IfModifiedSince - we must return all entries\n\t\t// Those unmodified will be stripped of Public & Private.\n\n\t\tif !opts.User.IsZero() {\n\t\t\tif tcat != t.TopicCatP2P {\n\t\t\t\tq = q.Filter(rdb.Row.Field(\"User\").Eq(opts.User.String()))\n\t\t\t}\n\t\t\toneUser = opts.User\n\t\t}\n\t\tif opts.Limit > 0 && opts.Limit < limit {\n\t\t\tlimit = opts.Limit\n\t\t}\n\t}\n\tq = q.Limit(limit)\n\n\tcursor, err := q.Run(a.conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Fetch subscriptions\n\tvar sub t.Subscription\n\tvar subs []t.Subscription\n\tjoin := make(map[string]t.Subscription)\n\tusrq := make([]any, 0, 16)\n\tfor cursor.Next(&sub) {\n\t\tjoin[sub.User] = sub\n\t\tusrq = append(usrq, sub.User)\n\t}\n\tcursor.Close()\n\n\tif len(usrq) > 0 {\n\t\tsubs = make([]t.Subscription, 0, len(usrq))\n\n\t\t// Fetch users by a list of subscriptions\n\t\tcursor, err = rdb.DB(a.dbName).Table(\"users\").GetAll(usrq...).\n\t\t\tFilter(rdb.Row.Field(\"State\").Eq(t.StateDeleted).Not()).Run(a.conn)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tvar usr t.User\n\t\tfor cursor.Next(&usr) {\n\t\t\tif sub, ok := join[usr.Id]; ok {\n\t\t\t\tsub.ObjHeader.MergeTimes(&usr.ObjHeader)\n\t\t\t\tsub.SetPublic(usr.Public)\n\t\t\t\tsub.SetTrusted(usr.Trusted)\n\t\t\t\tsub.SetLastSeenAndUA(usr.LastSeen, usr.UserAgent)\n\t\t\t\tsubs = append(subs, sub)\n\t\t\t}\n\t\t}\n\t\tcursor.Close()\n\t}\n\n\tif t.GetTopicCat(topic) == t.TopicCatP2P && len(subs) > 0 {\n\t\t// Swap public values & lastSeen of P2P topics as expected.\n\t\tif len(subs) == 1 {\n\t\t\t// User is deleted. Nothing we can do.\n\t\t\tsubs[0].SetPublic(nil)\n\t\t\tsubs[0].SetTrusted(nil)\n\t\t\tsubs[0].SetLastSeenAndUA(nil, \"\")\n\t\t} else {\n\t\t\ttmp := subs[0].GetPublic()\n\t\t\tsubs[0].SetPublic(subs[1].GetPublic())\n\t\t\tsubs[1].SetPublic(tmp)\n\n\t\t\ttmp = subs[0].GetTrusted()\n\t\t\tsubs[0].SetTrusted(subs[1].GetTrusted())\n\t\t\tsubs[1].SetTrusted(tmp)\n\n\t\t\tlastSeen := subs[0].GetLastSeen()\n\t\t\tuserAgent := subs[0].GetUserAgent()\n\t\t\tsubs[0].SetLastSeenAndUA(subs[1].GetLastSeen(), subs[1].GetUserAgent())\n\t\t\tsubs[1].SetLastSeenAndUA(lastSeen, userAgent)\n\t\t}\n\n\t\t// Remove deleted and unneeded subscriptions\n\t\tif !keepDeleted || !oneUser.IsZero() {\n\t\t\tvar xsubs []t.Subscription\n\t\t\tfor i := range subs {\n\t\t\t\tif (subs[i].DeletedAt != nil && !keepDeleted) || (!oneUser.IsZero() && subs[i].Uid() != oneUser) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\txsubs = append(xsubs, subs[i])\n\t\t\t}\n\t\t\tsubs = xsubs\n\t\t}\n\t}\n\n\treturn subs, nil\n}\n\n// OwnTopics loads a slice of topic names where the user is the owner.\nfunc (a *adapter) OwnTopics(uid t.Uid) ([]string, error) {\n\tcursor, err := rdb.DB(a.dbName).Table(\"topics\").GetAllByIndex(\"Owner\", uid.String()).\n\t\tFilter(rdb.Row.Field(\"State\").Eq(t.StateDeleted).Not()).Field(\"Id\").Run(a.conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar names []string\n\tvar name string\n\tfor cursor.Next(&name) {\n\t\tnames = append(names, name)\n\t}\n\tcursor.Close()\n\treturn names, nil\n}\n\n// ChannelsForUser loads a slice of topic names where the user is a channel reader and notifications (P) are enabled.\nfunc (a *adapter) ChannelsForUser(uid t.Uid) ([]string, error) {\n\tcursor, err := rdb.DB(a.dbName).Table(\"subscriptions\").\n\t\tGetAllByIndex(\"User\", uid.String()).\n\t\tFilter(rdb.Row.HasFields(\"DeletedAt\").Not()).\n\t\tFilter(rdb.Row.Field(\"Topic\").Match(\"^chn\")).\n\t\tFilter(rdb.JS(\"(function(row) {return (row.ModeWant & row.ModeGiven & \" + strconv.Itoa(int(t.ModePres)) + \") > 0;})\")).\n\t\tField(\"Topic\").Run(a.conn)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar names []string\n\tvar name string\n\tfor cursor.Next(&name) {\n\t\tnames = append(names, name)\n\t}\n\tcursor.Close()\n\treturn names, nil\n}\n\n// TopicShare adds subscriptions to a topic and increments the topic's subcnt.\nfunc (a *adapter) TopicShare(topic string, shares []*t.Subscription) error {\n\t// Assign Ids.\n\tfor _, sub := range shares {\n\t\tsub.Id = sub.Topic + \":\" + sub.User\n\t}\n\n\t// Subscription could have been marked as deleted (DeletedAt != nil). If it's marked\n\t// as deleted, unmark by clearing the DeletedAt field of the old subscription and\n\t// updating times and ModeGiven.\n\t_, err := rdb.DB(a.dbName).Table(\"subscriptions\").\n\t\tInsert(shares, rdb.InsertOpts{Conflict: func(id, oldsub, newsub rdb.Term) any {\n\t\t\treturn oldsub.Without(\"DeletedAt\").Merge(map[string]any{\n\t\t\t\t\"CreatedAt\": newsub.Field(\"CreatedAt\"),\n\t\t\t\t\"UpdatedAt\": newsub.Field(\"UpdatedAt\"),\n\t\t\t\t\"ModeGiven\": newsub.Field(\"ModeGiven\"),\n\t\t\t\t\"ModeWant\":  newsub.Field(\"ModeWant\"),\n\t\t\t\t\"DelId\":     0,\n\t\t\t\t\"ReadSeqId\": 0,\n\t\t\t\t\"RecvSeqId\": 0})\n\t\t}}).RunWrite(a.conn)\n\n\tif err == nil && topic != \"\" {\n\t\t_, err = rdb.DB(a.dbName).Table(\"topics\").\n\t\t\tGet(topic).\n\t\t\tUpdate(map[string]any{\"SubCnt\": rdb.Row.Field(\"SubCnt\").Default(0).Add(len(shares))}).\n\t\t\tRunWrite(a.conn)\n\t}\n\treturn err\n}\n\n// TopicDelete deletes topic, subscriptions, messages.\nfunc (a *adapter) TopicDelete(topic string, isChan, hard bool) error {\n\tvar err error\n\tif err = a.subsDelForTopic(topic, isChan, hard); err != nil {\n\t\treturn err\n\t}\n\n\tif hard {\n\t\tif err = a.MessageDeleteList(topic, nil); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Must use GetAll to produce array result expected by decFileUseCounter.\n\tq := rdb.DB(a.dbName).Table(\"topics\").GetAll(topic)\n\tif hard {\n\t\tif err = a.decFileUseCounter(q); err == nil {\n\t\t\t_, err = q.Delete().RunWrite(a.conn)\n\t\t}\n\t} else {\n\t\tnow := t.TimeNow()\n\t\t_, err = q.Update(map[string]any{\n\t\t\t\"UpdatedAt\": now,\n\t\t\t\"TouchedAt\": now,\n\t\t\t\"State\":     t.StateDeleted,\n\t\t\t\"StatedAt\":  now,\n\t\t}).RunWrite(a.conn)\n\t}\n\treturn err\n}\n\n// TopicUpdateOnMessage deserializes message-related values into topic.\nfunc (a *adapter) TopicUpdateOnMessage(topic string, msg *t.Message) error {\n\tupdate := struct {\n\t\tSeqId     int\n\t\tTouchedAt time.Time\n\t}{msg.SeqId, msg.CreatedAt}\n\n\t_, err := rdb.DB(a.dbName).Table(\"topics\").Get(topic).\n\t\tUpdate(update, rdb.UpdateOpts{Durability: \"soft\"}).RunWrite(a.conn)\n\n\treturn err\n}\n\n// TopicUpdateSubCnt updates subscriber count denormalized in topic.\nfunc (a *adapter) TopicUpdateSubCnt(topic string) error {\n\tcursor, err := rdb.DB(a.dbName).Table(\"subscriptions\").\n\t\tGetAllByIndex(\"Topic\", topic, t.GrpToChn(topic)).\n\t\tFilter(rdb.Row.HasFields(\"DeletedAt\").Not()).\n\t\tCount().Run(a.conn)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer cursor.Close()\n\n\tsubCnt := 0\n\tif !cursor.IsNil() {\n\t\tif err = cursor.One(&subCnt); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\t_, err = rdb.DB(a.dbName).Table(\"topics\").\n\t\tGet(topic).\n\t\tUpdate(map[string]any{\n\t\t\t\"SubCnt\": subCnt,\n\t\t}).RunWrite(a.conn)\n\treturn err\n}\n\n// TopicUpdate performs a generic topic update.\nfunc (a *adapter) TopicUpdate(topic string, update map[string]any) error {\n\tif t, u := update[\"TouchedAt\"], update[\"UpdatedAt\"]; t == nil && u != nil {\n\t\tupdate[\"TouchedAt\"] = u\n\t}\n\t_, err := rdb.DB(a.dbName).Table(\"topics\").Get(topic).Update(update).RunWrite(a.conn)\n\treturn err\n}\n\n// TopicOwnerChange changes topic's owner.\nfunc (a *adapter) TopicOwnerChange(topic string, newOwner t.Uid) error {\n\t_, err := rdb.DB(a.dbName).Table(\"topics\").Get(topic).\n\t\tUpdate(map[string]any{\"Owner\": newOwner.String()}).RunWrite(a.conn)\n\treturn err\n}\n\n// SubscriptionGet returns a subscription of a user to a topic\nfunc (a *adapter) SubscriptionGet(topic string, user t.Uid, keepDeleted bool) (*t.Subscription, error) {\n\n\tcursor, err := rdb.DB(a.dbName).Table(\"subscriptions\").Get(topic + \":\" + user.String()).Run(a.conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cursor.Close()\n\n\tif cursor.IsNil() {\n\t\treturn nil, nil\n\t}\n\n\tvar sub t.Subscription\n\tif err = cursor.One(&sub); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif !keepDeleted && sub.DeletedAt != nil {\n\t\treturn nil, nil\n\t}\n\n\treturn &sub, nil\n}\n\n// SubsForUser loads all user's subscriptions. Does NOT load Public or Private values and does\n// not load deleted subscriptions.\nfunc (a *adapter) SubsForUser(forUser t.Uid) ([]t.Subscription, error) {\n\tq := rdb.DB(a.dbName).\n\t\tTable(\"subscriptions\").\n\t\tGetAllByIndex(\"User\", forUser.String()).\n\t\tFilter(rdb.Row.HasFields(\"DeletedAt\").Not()).\n\t\tWithout(\"Private\")\n\n\tcursor, err := q.Run(a.conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cursor.Close()\n\n\tvar subs []t.Subscription\n\tvar ss t.Subscription\n\tfor cursor.Next(&ss) {\n\t\tsubs = append(subs, ss)\n\t}\n\n\treturn subs, cursor.Err()\n}\n\n// SubsForTopic fetches all subsciptions for a topic. Does NOT load Public value.\nfunc (a *adapter) SubsForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {\n\n\tq := rdb.DB(a.dbName).Table(\"subscriptions\").GetAllByIndex(\"Topic\", topic)\n\tif !keepDeleted {\n\t\t// Filter out rows where DeletedAt is defined\n\t\tq = q.Filter(rdb.Row.HasFields(\"DeletedAt\").Not())\n\t}\n\n\tlimit := a.maxResults\n\tif opts != nil {\n\t\t// Ignore IfModifiedSince - we must return all entries\n\t\t// Those unmodified will be stripped of Public & Private.\n\n\t\tif !opts.User.IsZero() {\n\t\t\tq = q.Filter(rdb.Row.Field(\"User\").Eq(opts.User.String()))\n\t\t}\n\t\tif opts.Limit > 0 && opts.Limit < limit {\n\t\t\tlimit = opts.Limit\n\t\t}\n\t}\n\tq = q.Limit(limit)\n\n\tcursor, err := q.Run(a.conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cursor.Close()\n\n\tvar subs []t.Subscription\n\tvar ss t.Subscription\n\tfor cursor.Next(&ss) {\n\t\tsubs = append(subs, ss)\n\t}\n\n\treturn subs, cursor.Err()\n}\n\n// SubsUpdate updates a single subscription.\nfunc (a *adapter) SubsUpdate(topic string, user t.Uid, update map[string]any) error {\n\tq := rdb.DB(a.dbName).Table(\"subscriptions\")\n\tif !user.IsZero() {\n\t\t// Update one topic subscription\n\t\tq = q.Get(topic + \":\" + user.String())\n\t} else {\n\t\t// Update all topic subscriptions\n\t\tq = q.GetAllByIndex(\"Topic\", topic)\n\t}\n\t_, err := q.Update(update).RunWrite(a.conn)\n\treturn err\n}\n\n// SubsDelete marks at most one subscription as deleted.\nfunc (a *adapter) SubsDelete(topic string, user t.Uid) error {\n\tnow := t.TimeNow()\n\tforUser := user.String()\n\n\t// Mark subscription as deleted.\n\tres, err := rdb.DB(a.dbName).Table(\"subscriptions\").\n\t\tGet(topic + \":\" + forUser).Update(map[string]any{\n\t\t\"UpdatedAt\": now,\n\t\t\"DeletedAt\": now,\n\t}).RunWrite(a.conn)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif res.Replaced == 0 {\n\t\t// Nothing was updated, nothing more to do.\n\t\treturn t.ErrNotFound\n\t}\n\n\t// Decrement topic's SubCnt.\n\t_, err = rdb.DB(a.dbName).Table(\"topics\").Get(topic).\n\t\tUpdate(map[string]any{\"SubCnt\": rdb.Row.Field(\"SubCnt\").Default(1).Sub(1)}).\n\t\tRunWrite(a.conn)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif t.IsChannel(topic) {\n\t\t// Channel readers cannot delete messages, all done.\n\t\treturn nil\n\t}\n\n\t// Remove records of deleted messages.\n\n\t// Delete dellog entries of the current user.\n\tresp, err := rdb.DB(a.dbName).Table(\"dellog\").\n\t\t// Select all log entries for the given table.\n\t\tBetween([]any{topic, rdb.MinVal}, []any{topic, rdb.MaxVal},\n\t\t\trdb.BetweenOpts{Index: \"Topic_DelId\"}).\n\t\t// Keep entries soft-deleted for the current user only.\n\t\tFilter(rdb.Row.Field(\"DeletedFor\").Eq(forUser)).\n\t\t// Delete them.\n\t\tDelete().\n\t\tRunWrite(a.conn)\n\n\tif err != nil || resp.Deleted == 0 {\n\t\t// Either an error or nothing was deleted. Not much we can do with the error.\n\t\t// Returning nil even on failure.\n\t\treturn nil\n\t}\n\n\t// Remove current user from the messages' soft-deletion lists.\n\t// The possible error here is ignored.\n\trdb.DB(a.dbName).Table(\"messages\").\n\t\t// Select all messages in the given topic.\n\t\tBetween(\n\t\t\t[]any{topic, forUser, rdb.MinVal},\n\t\t\t[]any{topic, forUser, rdb.MaxVal},\n\t\t\trdb.BetweenOpts{Index: \"Topic_DeletedFor\"}).\n\t\t// Update the field DeletedFor:\n\t\tUpdate(map[string]any{\n\t\t\t// Take the DeletedFor array, subtract all values which contain current user ID in 'User' field.\n\t\t\t\"DeletedFor\": rdb.Row.Field(\"DeletedFor\").\n\t\t\t\tSetDifference(\n\t\t\t\t\trdb.Row.Field(\"DeletedFor\").\n\t\t\t\t\t\tFilter(map[string]any{\"User\": forUser}))}).\n\t\tRunWrite(a.conn)\n\n\treturn nil\n}\n\n// subsDelForTopic marks all subscriptions to the given topic as deleted.\nfunc (a *adapter) subsDelForTopic(topic string, isChan, hard bool) error {\n\tvar err error\n\n\tq := rdb.DB(a.dbName).Table(\"subscriptions\")\n\tif isChan {\n\t\t// If the topic is a channel, must try to delete subscriptions under both grpXXX and chnXXX names.\n\t\tq = q.GetAllByIndex(\"Topic\", topic, t.GrpToChn(topic))\n\t} else {\n\t\tq = q.GetAllByIndex(\"Topic\", topic)\n\t}\n\tif hard {\n\t\t_, err = q.Delete().RunWrite(a.conn)\n\t} else {\n\t\tnow := t.TimeNow()\n\t\t_, err = q.Update(map[string]any{\n\t\t\t\"UpdatedAt\": now,\n\t\t\t\"DeletedAt\": now,\n\t\t}).RunWrite(a.conn)\n\t}\n\treturn err\n}\n\n// subsDelForUser marks all subscriptions of a given user as deleted.\nfunc (a *adapter) subsDelForUser(user t.Uid, hard bool) error {\n\tvar err error\n\n\tforUser := user.String()\n\n\t// Get all topics the user is subscribed to. Channels are left as channels.\n\ttopics, err := a.topicNamesForUser(rdb.DB(a.dbName).Table(\"subscriptions\").\n\t\tGetAllByIndex(\"User\", forUser).Field(\"Topic\"), false)\n\tif err != nil {\n\t\tlogs.Err.Println(\"subsDelForUser: topicNamesForUser:\", err)\n\t\treturn err\n\t}\n\n\t// 1. Decrement SubCnt in topic.\n\tif _, err = rdb.DB(a.dbName).Table(\"topics\").Get(topics...).\n\t\tUpdate(map[string]any{\"SubCnt\": rdb.Row.Field(\"SubCnt\").\n\t\t\tDefault(1).Sub(1)}).\n\t\tRunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\n\terr = a.clearUserDellog(user, topics)\n\tif err != nil {\n\t\tlogs.Err.Println(\"subsDelForUser: clearUserDellog:\", err)\n\t\treturn err\n\t}\n\n\tif hard {\n\t\t_, err = rdb.DB(a.dbName).Table(\"subscriptions\").GetAllByIndex(\"User\", user.String()).\n\t\t\tDelete().RunWrite(a.conn)\n\t} else {\n\t\tnow := t.TimeNow()\n\t\tupdate := map[string]any{\n\t\t\t\"UpdatedAt\": now,\n\t\t\t\"DeletedAt\": now,\n\t\t}\n\t\t_, err = rdb.DB(a.dbName).Table(\"subscriptions\").GetAllByIndex(\"User\", user.String()).\n\t\t\tUpdate(update).RunWrite(a.conn)\n\t}\n\n\treturn err\n}\n\n// Find returns a list of users and topics who match the given tags, such as \"email:jdoe@example.com\" or \"tel:+18003287448\".\nfunc (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error) {\n\tindex := make(map[string]struct{})\n\tallReq := t.FlattenDoubleSlice(req)\n\tvar allTags []any\n\tfor _, tag := range append(allReq, opt...) {\n\t\tallTags = append(allTags, tag)\n\t\tindex[tag] = struct{}{}\n\t}\n\t// Query for selecting matches where every group includes at least one required match (restricting search to\n\t// group members).\n\t/*\n\t\tr.db('tinode').\n\t\t\ttable('users').\n\t\t\tgetAll('basic:alice', 'travel', {index: \"Tags\"}).\n\t\t\tunion(r.db('tinode').table('topics').getAll('basic:alice', 'travel', {index: \"Tags\"})).\n\t\t\tpluck('Id', 'Access', 'CreatedAt', 'UpdatedAt', 'UseBt', 'Public', 'Trusted', 'Tags').\n\t\t\tgroup('Id').\n\t\t\tungroup().\n\t\t\tmap(row => row.getField('reduction').nth(0).merge(\n\t\t\t\t{matchedCount: row.getField('reduction').\n\t\t\t\t\tgetField('Tags').\n\t\t\t\t\tnth(0).\n\t\t\t\t\tsetIntersection(['alias:aliassa', 'basic:alice', 'travel']).\n\t\t\t\t\tmap(tag => r.branch(tag.match('^alias:'), 20, 1)).\n\t\t\t\t\tsum()\n\t\t\t\t})).\n\t\t\tfilter(row => row.getField('Tags').setIntersection(['basic:alice', 'travel']).count().ne(0)).\n\t\t\torderBy(r.desc('matchedCount')).\n\t\t\tlimit(20)\n\t*/\n\n\t// Get users and topics matched by tags, sort by number of matches from high to low.\n\tquery := rdb.DB(a.dbName).\n\t\tTable(\"users\").\n\t\tGetAllByIndex(\"Tags\", allTags...).\n\t\tUnion(rdb.DB(a.dbName).Table(\"topics\").\n\t\t\tGetAllByIndex(\"Tags\", allTags...))\n\tif activeOnly {\n\t\tquery = query.Filter(rdb.Row.Field(\"State\").Eq(t.StateOK))\n\t}\n\tquery = query.Pluck(\"Id\", \"Access\", \"CreatedAt\", \"UpdatedAt\", \"UseBt\", \"SubCnt\", \"Public\", \"Trusted\", \"Tags\").\n\t\tGroup(\"Id\").\n\t\tUngroup().\n\t\tMap(func(row rdb.Term) rdb.Term {\n\t\t\treturn row.Field(\"reduction\").\n\t\t\t\tNth(0).\n\t\t\t\tMerge(map[string]any{\"MatchedTagsCount\": row.Field(\"reduction\").\n\t\t\t\t\tField(\"Tags\").\n\t\t\t\t\tNth(0).\n\t\t\t\t\tSetIntersection(allTags).\n\t\t\t\t\tMap(func(tag rdb.Term) any {\n\t\t\t\t\t\treturn rdb.Branch(\n\t\t\t\t\t\t\ttag.Match(\"^\"+promoPrefix),\n\t\t\t\t\t\t\t20, // If the tag matches the promo prefix, count it as 20.\n\t\t\t\t\t\t\t1)  // Otherwise count it as 1.\n\t\t\t\t\t}).\n\t\t\t\t\tSum()})\n\t\t})\n\n\tfor _, reqDisjunction := range req {\n\t\tif len(reqDisjunction) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tvar reqTags []any\n\t\tfor _, tag := range reqDisjunction {\n\t\t\treqTags = append(reqTags, tag)\n\t\t}\n\t\t// Filter out objects which do not match at least one of the required tags.\n\t\tquery = query.Filter(func(row rdb.Term) rdb.Term {\n\t\t\treturn row.Field(\"Tags\").SetIntersection(reqTags).Count().Ne(0)\n\t\t})\n\t}\n\tcursor, err := query.OrderBy(rdb.Desc(\"MatchedTagsCount\")).Limit(a.maxResults).Run(a.conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cursor.Close()\n\n\tvar topic t.Topic\n\tvar sub t.Subscription\n\tvar subs []t.Subscription\n\tfor cursor.Next(&topic) {\n\t\tif uid := t.ParseUid(topic.Id); !uid.IsZero() {\n\t\t\ttopic.Id = uid.UserId()\n\t\t\tif topic.Id == caller {\n\t\t\t\t// Skip the caller\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\tif topic.UseBt {\n\t\t\tsub.Topic = t.GrpToChn(topic.Id)\n\t\t} else {\n\t\t\tsub.Topic = topic.Id\n\t\t}\n\n\t\tsub.CreatedAt = topic.CreatedAt\n\t\tsub.UpdatedAt = topic.UpdatedAt\n\t\tsub.SetSubCnt(topic.SubCnt)\n\t\tsub.SetPublic(topic.Public)\n\t\tsub.SetTrusted(topic.Trusted)\n\t\tsub.SetDefaultAccess(topic.Access.Auth, topic.Access.Anon)\n\t\t// Indicating that the mode is not set, not 'N'.\n\t\tsub.ModeGiven = t.ModeUnset\n\t\tsub.ModeWant = t.ModeUnset\n\t\tsub.Private = common.FilterFoundTags(topic.Tags, index)\n\t\tsubs = append(subs, sub)\n\t}\n\n\treturn subs, cursor.Err()\n}\n\n// FindOne returns topic or user which matches the given tag.\nfunc (a *adapter) FindOne(tag string) (string, error) {\n\tquery := rdb.DB(a.dbName).\n\t\tTable(\"users\").GetAllByIndex(\"Tags\", tag).\n\t\tUnion(rdb.DB(a.dbName).Table(\"topics\").GetAllByIndex(\"Tags\", tag)).\n\t\tField(\"Id\").\n\t\tLimit(1)\n\tcursor, err := query.Run(a.conn)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer cursor.Close()\n\n\tvar found string\n\tif err = cursor.One(&found); err != nil {\n\t\tif err == rdb.ErrEmptyResult {\n\t\t\treturn \"\", nil\n\t\t}\n\t\treturn \"\", err\n\t}\n\n\tif user := t.ParseUid(found); !user.IsZero() {\n\t\tfound = user.UserId()\n\t}\n\n\treturn found, nil\n}\n\n// Messages\n\n// MessageSave saves message to DB.\nfunc (a *adapter) MessageSave(msg *t.Message) error {\n\t_, err := rdb.DB(a.dbName).Table(\"messages\").Insert(msg).RunWrite(a.conn)\n\treturn err\n}\n\n// MessageGetAll retrieves all messages available to the given user.\nfunc (a *adapter) MessageGetAll(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.Message, error) {\n\n\tvar limit = a.maxMessageResults\n\tvar lower, upper any\n\n\tupper = rdb.MaxVal\n\tlower = rdb.MinVal\n\n\tif opts != nil {\n\t\tif opts.Since > 0 {\n\t\t\tlower = opts.Since\n\t\t}\n\t\tif opts.Before > 0 {\n\t\t\tupper = opts.Before\n\t\t}\n\n\t\tif opts.Limit > 0 && opts.Limit < limit {\n\t\t\tlimit = opts.Limit\n\t\t}\n\t}\n\n\tlower = []any{topic, lower}\n\tupper = []any{topic, upper}\n\n\trequester := forUser.String()\n\tcursor, err := rdb.DB(a.dbName).Table(\"messages\").\n\t\tBetween(lower, upper, rdb.BetweenOpts{Index: \"Topic_SeqId\"}).\n\t\t// Ordering by index must come before filtering\n\t\tOrderBy(rdb.OrderByOpts{Index: rdb.Desc(\"Topic_SeqId\")}).\n\t\t// Skip hard-deleted messages\n\t\tFilter(rdb.Row.HasFields(\"DelId\").Not()).\n\t\t// Skip messages soft-deleted for the current user\n\t\tFilter(func(row rdb.Term) any {\n\t\t\treturn rdb.Not(row.Field(\"DeletedFor\").Default([]any{}).Contains(\n\t\t\t\tfunc(df rdb.Term) any {\n\t\t\t\t\treturn df.Field(\"User\").Eq(requester)\n\t\t\t\t}))\n\t\t}).Limit(limit).Run(a.conn)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cursor.Close()\n\n\tvar msgs []t.Message\n\tif err = cursor.All(&msgs); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn msgs, nil\n}\n\n// MessageGetDeleted returns ranges of deleted messages.\nfunc (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) {\n\t/*\n\t\tr.db('tinode_test')\n\t\t\t.table('dellog')\n\t\t\t.between(\n\t\t\t\t['p2p9AVDamaNCRbfKzGSh3mE0w', 1],\n\t\t\t\t['p2p9AVDamaNCRbfKzGSh3mE0w', 10],\n\t\t\t\t{index: 'Topic_DelId'}\n\t\t\t)\n\t\t\t.orderBy('Topic_DelId')\n\t\t\t.filter(\n\t\t\t\trow => row.getField('DeletedFor').eq('0QLrX3WPS2o').or(row.getField('DeletedFor').eq(''))\n\t\t\t)\n\t*/\n\tvar limit = a.maxResults\n\tvar lower, upper any\n\n\tupper = rdb.MaxVal\n\tlower = rdb.MinVal\n\n\tif opts != nil {\n\t\tif opts.Since > 0 {\n\t\t\tlower = opts.Since\n\t\t}\n\t\tif opts.Before > 0 {\n\t\t\tupper = opts.Before\n\t\t}\n\n\t\tif opts.Limit > 0 && opts.Limit < limit {\n\t\t\tlimit = opts.Limit\n\t\t}\n\t}\n\n\t// Fetch log of deletions\n\tcursor, err := rdb.DB(a.dbName).Table(\"dellog\").\n\t\t// Select log entries for the given table and DelId values between two limits.\n\t\t// By default, leftBound is closed and rightBound is open.\n\t\tBetween([]any{topic, lower}, []any{topic, upper},\n\t\t\trdb.BetweenOpts{Index: \"Topic_DelId\"}).\n\t\t// Sort from low DelIds to high\n\t\tOrderBy(rdb.OrderByOpts{Index: \"Topic_DelId\"}).\n\t\t// Keep entries soft-deleted for the current user and all hard-deleted entries.\n\t\tFilter(func(row rdb.Term) any {\n\t\t\treturn row.Field(\"DeletedFor\").Eq(forUser.String()).Or(row.Field(\"DeletedFor\").Eq(\"\"))\n\t\t}).\n\t\tLimit(limit).Run(a.conn)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cursor.Close()\n\n\tvar dmsgs []t.DelMessage\n\tif err = cursor.All(&dmsgs); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn dmsgs, nil\n}\n\n// messagesHardDelete deletes all messages in the topic.\nfunc (a *adapter) messagesHardDelete(topic string) error {\n\tvar err error\n\n\t// TODO: handle file uploads\n\n\tif _, err = rdb.DB(a.dbName).Table(\"dellog\").Between(\n\t\t[]any{topic, rdb.MinVal},\n\t\t[]any{topic, rdb.MaxVal},\n\t\trdb.BetweenOpts{Index: \"Topic_DelId\"}).Delete().RunWrite(a.conn); err != nil {\n\t\treturn err\n\t}\n\n\tq := rdb.DB(a.dbName).Table(\"messages\").Between(\n\t\t[]any{topic, rdb.MinVal},\n\t\t[]any{topic, rdb.MaxVal},\n\t\trdb.BetweenOpts{Index: \"Topic_SeqId\"})\n\n\tif err = a.decFileUseCounter(q); err != nil {\n\t\treturn err\n\t}\n\n\t_, err = q.Delete().RunWrite(a.conn)\n\n\treturn err\n}\n\nfunc rangeToQuery(delRanges []t.Range, topic string, query rdb.Term) rdb.Term {\n\tif len(delRanges) > 1 || delRanges[0].Hi <= delRanges[0].Low {\n\t\tvar indexVals []any\n\t\tfor _, rng := range delRanges {\n\t\t\tif rng.Hi == 0 {\n\t\t\t\tindexVals = append(indexVals, []any{topic, rng.Low})\n\t\t\t} else {\n\t\t\t\tfor i := rng.Low; i <= rng.Hi; i++ {\n\t\t\t\t\tindexVals = append(indexVals, []any{topic, i})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tquery = query.GetAllByIndex(\"Topic_SeqId\", indexVals...)\n\t} else {\n\t\t// Optimizing for a special case of single range low..hi\n\t\tquery = query.Between(\n\t\t\t[]any{topic, delRanges[0].Low},\n\t\t\t[]any{topic, delRanges[0].Hi},\n\t\t\trdb.BetweenOpts{Index: \"Topic_SeqId\", RightBound: \"closed\"})\n\t}\n\treturn query\n}\n\n// MessageDeleteList deletes messages in the given topic with seqIds from the list.\nfunc (a *adapter) MessageDeleteList(topic string, toDel *t.DelMessage) error {\n\tvar err error\n\n\tif toDel == nil {\n\t\t// Delete all messages.\n\t\treturn a.messagesHardDelete(topic)\n\t}\n\n\t// Only some messages are being deleted\n\n\tdelRanges := toDel.SeqIdRanges\n\tquery := rangeToQuery(delRanges, topic, rdb.DB(a.dbName).Table(\"messages\"))\n\t// Skip already hard-deleted messages.\n\tquery = query.Filter(rdb.Row.HasFields(\"DelId\").Not())\n\tif toDel.DeletedFor == \"\" {\n\t\t// Hard-deleting messages requires updates to the messages table.\n\n\t\t// We are asked to delete messages no older than newerThan.\n\t\tif newerThan := toDel.GetNewerThan(); newerThan != nil {\n\t\t\tquery = query.Filter(rdb.Row.Field(\"CreatedAt\").Gt(newerThan))\n\t\t}\n\n\t\tquery = query.Field(\"SeqId\")\n\n\t\t// Find the actual IDs still present in the database.\n\t\tcursor, err := query.Run(a.conn)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer cursor.Close()\n\n\t\tvar seqIDs []int\n\t\tif err = cursor.All(&seqIDs); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(seqIDs) == 0 {\n\t\t\t// Nothing to delete. No need to make a log entry. All done.\n\t\t\treturn nil\n\t\t}\n\n\t\t// Recalculate the actual ranges to delete.\n\t\tsort.Ints(seqIDs)\n\t\tdelRanges = t.SliceToRanges(seqIDs)\n\n\t\t// Compose a new query with the new ranges.\n\t\tquery = rangeToQuery(delRanges, topic, rdb.DB(a.dbName).Table(\"messages\"))\n\n\t\t// First decrement use counter for attachments.\n\t\tif err = a.decFileUseCounter(query); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Hard-delete individual messages. The messages are not deleted but all fields with personal content\n\t\t// are removed.\n\t\tif _, err = query.Replace(rdb.Row.Without(\"Head\", \"From\", \"Content\", \"Attachments\").Merge(\n\t\t\tmap[string]any{\n\t\t\t\t\"DeletedAt\": t.TimeNow(), \"DelId\": toDel.DelId})).\n\t\t\tRunWrite(a.conn); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t} else {\n\t\t// Soft-deleting: adding DelId to DeletedFor.\n\t\t_, err = query.\n\t\t\t// Skip messages already soft-deleted for the current user\n\t\t\tFilter(func(row rdb.Term) any {\n\t\t\t\treturn rdb.Not(row.Field(\"DeletedFor\").Default([]any{}).Contains(\n\t\t\t\t\tfunc(df rdb.Term) any {\n\t\t\t\t\t\treturn df.Field(\"User\").Eq(toDel.DeletedFor)\n\t\t\t\t\t}))\n\t\t\t}).\n\t\t\tUpdate(map[string]any{\"DeletedFor\": rdb.Row.Field(\"DeletedFor\").\n\t\t\t\tDefault([]any{}).Append(\n\t\t\t\t&t.SoftDelete{\n\t\t\t\t\tUser:  toDel.DeletedFor,\n\t\t\t\t\tDelId: toDel.DelId})}).RunWrite(a.conn)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Make log entries. Needed for both hard- and soft-deleting.\n\t_, err = rdb.DB(a.dbName).Table(\"dellog\").Insert(toDel).RunWrite(a.conn)\n\treturn err\n}\n\nfunc deviceHasher(deviceID string) string {\n\t// Generate custom key as [64-bit hash of device id] to ensure predictable\n\t// length of the key\n\thasher := fnv.New64()\n\thasher.Write([]byte(deviceID))\n\treturn strconv.FormatUint(uint64(hasher.Sum64()), 16)\n}\n\n// Device management for push notifications\n\n// DeviceUpsert adds or updates a user's device FCM push token.\nfunc (a *adapter) DeviceUpsert(uid t.Uid, def *t.DeviceDef) error {\n\thash := deviceHasher(def.DeviceId)\n\tuser := uid.String()\n\n\t// Ensure uniqueness of the device ID\n\t// Find users who already use this device ID, ignore current user.\n\tcursor, err := rdb.DB(a.dbName).Table(\"users\").GetAllByIndex(\"DeviceIds\", def.DeviceId).\n\t\t// We only care about user Ids\n\t\tPluck(\"Id\").\n\t\t// Make sure we filter out the current user who may legitimately use this device ID\n\t\tFilter(rdb.Not(rdb.Row.Field(\"Id\").Eq(user))).\n\t\t// Convert slice of objects to a slice of strings\n\t\tConcatMap(func(row rdb.Term) any { return []any{row.Field(\"Id\")} }).\n\t\t// Execute\n\t\tRun(a.conn)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer cursor.Close()\n\n\tvar others []any\n\tif err = cursor.All(&others); err != nil {\n\t\treturn err\n\t}\n\n\tif len(others) > 0 {\n\t\t// Delete device ID for the other users.\n\t\t_, err = rdb.DB(a.dbName).Table(\"users\").GetAll(others...).Replace(rdb.Row.Without(\n\t\t\tmap[string]string{\"Devices\": hash})).RunWrite(a.conn)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Actually add/update DeviceId for the new user\n\t_, err = rdb.DB(a.dbName).Table(\"users\").Get(user).\n\t\tUpdate(map[string]any{\n\t\t\t\"Devices\": map[string]*t.DeviceDef{\n\t\t\t\thash: def,\n\t\t\t}}).RunWrite(a.conn)\n\treturn err\n}\n\n// DeviceGetAll retrives a list of user's devices (push tokens).\nfunc (a *adapter) DeviceGetAll(uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {\n\tids := make([]any, len(uids))\n\tfor i, id := range uids {\n\t\tids[i] = id.String()\n\t}\n\n\t// {Id: \"userid\", Devices: {\"hash1\": {..def1..}, \"hash2\": {..def2..}}\n\tcursor, err := rdb.DB(a.dbName).Table(\"users\").GetAll(ids...).Pluck(\"Id\", \"Devices\").\n\t\tDefault(nil).Limit(a.maxResults).Run(a.conn)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\tdefer cursor.Close()\n\n\tvar row struct {\n\t\tId      string\n\t\tDevices map[string]*t.DeviceDef\n\t}\n\n\tresult := make(map[t.Uid][]t.DeviceDef)\n\tcount := 0\n\tvar uid t.Uid\n\tfor cursor.Next(&row) {\n\t\tif len(row.Devices) > 0 {\n\t\t\tif err := uid.UnmarshalText([]byte(row.Id)); err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tresult[uid] = make([]t.DeviceDef, len(row.Devices))\n\t\t\ti := 0\n\t\t\tfor _, def := range row.Devices {\n\t\t\t\tif def != nil {\n\t\t\t\t\tresult[uid][i] = *def\n\t\t\t\t\ti++\n\t\t\t\t\tcount++\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn result, count, cursor.Err()\n}\n\n// DeviceDelete removes user's device (push token).\nfunc (a *adapter) DeviceDelete(uid t.Uid, deviceID string) error {\n\tvar err error\n\tq := rdb.DB(a.dbName).Table(\"users\").Get(uid.String())\n\tif deviceID == \"\" {\n\t\tq = q.Update(map[string]any{\"Devices\": nil})\n\t} else {\n\t\tq = q.Replace(rdb.Row.Without(map[string]string{\"Devices\": deviceHasher(deviceID)}))\n\t}\n\t_, err = q.RunWrite(a.conn)\n\treturn err\n}\n\n// Credential management\n\n// CredUpsert adds or updates a validation record. Returns true if inserted, false if updated.\n// 1. if credential is validated:\n// 1.1 Hard-delete unconfirmed equivalent record, if exists.\n// 1.2 Insert new. Report error if duplicate.\n// 2. if credential is not validated:\n// 2.1 Check if validated equivalent exist. If so, report an error.\n// 2.2 Soft-delete all unvalidated records of the same method.\n// 2.3 Undelete existing credential. Return if successful.\n// 2.4 Insert new credential record.\nfunc (a *adapter) CredUpsert(cred *t.Credential) (bool, error) {\n\tvar err error\n\ttableCredentials := rdb.DB(a.dbName).Table(\"credentials\")\n\n\tcred.Id = cred.Method + \":\" + cred.Value\n\n\tif !cred.Done {\n\t\t// Check if the same credential is already validated.\n\t\tcursor, err := tableCredentials.Get(cred.Id).Run(a.conn)\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\t\tdefer cursor.Close()\n\t\tif !cursor.IsNil() {\n\t\t\t// Someone has already validated this credential.\n\t\t\treturn false, t.ErrDuplicate\n\t\t}\n\n\t\t// Deactivate all unvalidated records of this user and method.\n\t\t_, err = tableCredentials.GetAllByIndex(\"User\", cred.User).\n\t\t\tFilter(map[string]any{\"Method\": cred.Method, \"Done\": false}).Update(\n\t\t\tmap[string]any{\"DeletedAt\": t.TimeNow()}).RunWrite(a.conn)\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\n\t\t// If credential is not confirmed, it should not block others\n\t\t// from attempting to validate it: make index user-unique instead of global-unique.\n\t\tcred.Id = cred.User + \":\" + cred.Id\n\n\t\t// Check if this credential has already been added by the user.\n\t\tcursor2, err := tableCredentials.Get(cred.Id).Run(a.conn)\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\t\tdefer cursor2.Close()\n\t\tif !cursor2.IsNil() {\n\t\t\t_, err = tableCredentials.Get(cred.Id).\n\t\t\t\tReplace(rdb.Row.Without(\"DeletedAt\").\n\t\t\t\t\tMerge(map[string]any{\n\t\t\t\t\t\t\"UpdatedAt\": cred.UpdatedAt,\n\t\t\t\t\t\t\"Resp\":      cred.Resp})).RunWrite(a.conn)\n\t\t\tif err != nil {\n\t\t\t\treturn false, err\n\t\t\t}\n\t\t\t// The record was updated, all is fine.\n\t\t\treturn false, nil\n\t\t}\n\n\t} else {\n\t\t// Hard-delete potentially present unvalidated credential.\n\t\t_, err = tableCredentials.Get(cred.User + \":\" + cred.Id).Delete().RunWrite(a.conn)\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\n\t// Insert a new record.\n\t_, err = tableCredentials.Insert(cred).RunWrite(a.conn)\n\tif rdb.IsConflictErr(err) {\n\t\treturn true, t.ErrDuplicate\n\t}\n\n\treturn true, err\n}\n\n// CredDel deletes credentials for the given method. If method is empty, deletes all user's credentials.\nfunc (a *adapter) CredDel(uid t.Uid, method, value string) error {\n\tq := rdb.DB(a.dbName).Table(\"credentials\").\n\t\tGetAllByIndex(\"User\", uid.String())\n\tif method != \"\" {\n\t\tq = q.Filter(map[string]any{\"Method\": method})\n\t\tif value != \"\" {\n\t\t\tq = q.Filter(map[string]any{\"Value\": value})\n\t\t}\n\t}\n\n\tif method == \"\" {\n\t\tres, err := q.Delete().RunWrite(a.conn)\n\t\tif err == nil {\n\t\t\tif res.Deleted == 0 {\n\t\t\t\terr = t.ErrNotFound\n\t\t\t}\n\t\t}\n\t\treturn err\n\t}\n\n\t// Hard-delete all confirmed values or values with no attempts at confirmation.\n\tres, err := q.Filter(rdb.Or(rdb.Row.Field(\"Done\").Eq(true), rdb.Row.Field(\"Retries\").Eq(0))).Delete().RunWrite(a.conn)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif res.Deleted > 0 {\n\t\treturn nil\n\t}\n\n\t// Soft-delete all other values.\n\tres, err = q.Update(map[string]any{\"DeletedAt\": t.TimeNow()}).RunWrite(a.conn)\n\tif err == nil {\n\t\tif res.Deleted == 0 {\n\t\t\terr = t.ErrNotFound\n\t\t}\n\t}\n\treturn err\n}\n\n// credGetActive reads the currently active unvalidated credential\nfunc (a *adapter) credGetActive(uid t.Uid, method string) (*t.Credential, error) {\n\t// Get the active unconfirmed credential:\n\tcursor, err := rdb.DB(a.dbName).Table(\"credentials\").GetAllByIndex(\"User\", uid.String()).\n\t\tFilter(rdb.Row.HasFields(\"DeletedAt\").Not()).\n\t\tFilter(map[string]any{\"Method\": method, \"Done\": false}).Run(a.conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cursor.Close()\n\n\tif cursor.IsNil() {\n\t\treturn nil, nil\n\t}\n\n\tvar cred t.Credential\n\tif err = cursor.One(&cred); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &cred, nil\n}\n\n// CredConfirm marks given credential as validated.\nfunc (a *adapter) CredConfirm(uid t.Uid, method string) error {\n\n\tcred, err := a.credGetActive(uid, method)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// RethinkDb does not allow primary key to be changed (userid:method:value -> method:value)\n\t// We have to delete and re-insert with a different primary key.\n\n\tcred.Done = true\n\tcred.UpdatedAt = t.TimeNow()\n\tif _, err = a.CredUpsert(cred); err != nil {\n\t\treturn err\n\t}\n\n\trdb.DB(a.dbName).\n\t\tTable(\"credentials\").\n\t\tGet(uid.String() + \":\" + cred.Method + \":\" + cred.Value).\n\t\tDelete(rdb.DeleteOpts{Durability: \"soft\", ReturnChanges: false}).\n\t\tRunWrite(a.conn)\n\n\treturn nil\n}\n\n// CredFail increments count of failed validation attepmts for the given credentials.\nfunc (a *adapter) CredFail(uid t.Uid, method string) error {\n\t_, err := rdb.DB(a.dbName).Table(\"credentials\").\n\t\tGetAllByIndex(\"User\", uid.String()).\n\t\tFilter(map[string]any{\"Method\": method, \"Done\": false}).\n\t\tFilter(rdb.Row.HasFields(\"DeletedAt\").Not()).\n\t\tUpdate(map[string]any{\n\t\t\t\"Retries\":   rdb.Row.Field(\"Retries\").Default(0).Add(1),\n\t\t\t\"UpdatedAt\": t.TimeNow(),\n\t\t}).RunWrite(a.conn)\n\treturn err\n}\n\n// CredGetActive returns currently active credential record for the given method.\nfunc (a *adapter) CredGetActive(uid t.Uid, method string) (*t.Credential, error) {\n\treturn a.credGetActive(uid, method)\n}\n\n// CredGetAll returns user's credential records of the given method, validated only or all.\nfunc (a *adapter) CredGetAll(uid t.Uid, method string, validatedOnly bool) ([]t.Credential, error) {\n\tq := rdb.DB(a.dbName).Table(\"credentials\").GetAllByIndex(\"User\", uid.String())\n\tif method != \"\" {\n\t\tq = q.Filter(map[string]any{\"Method\": method})\n\t}\n\tif validatedOnly {\n\t\tq = q.Filter(map[string]any{\"Done\": true})\n\t} else {\n\t\tq = q.Filter(rdb.Row.HasFields(\"DeletedAt\").Not())\n\t}\n\n\tcursor, err := q.Run(a.conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cursor.Close()\n\n\tif cursor.IsNil() {\n\t\treturn nil, nil\n\t}\n\n\tvar credentials []t.Credential\n\terr = cursor.All(&credentials)\n\treturn credentials, err\n}\n\n// FileUploads\n\n// FileStartUpload initializes a file upload\nfunc (a *adapter) FileStartUpload(fd *t.FileDef) error {\n\t_, err := rdb.DB(a.dbName).Table(\"fileuploads\").Insert(fd).RunWrite(a.conn)\n\treturn err\n}\n\n// FileFinishUpload marks file upload as completed, successfully or otherwise\nfunc (a *adapter) FileFinishUpload(fd *t.FileDef, success bool, size int64) (*t.FileDef, error) {\n\tnow := t.TimeNow()\n\tif success {\n\t\tif _, err := rdb.DB(a.dbName).Table(\"fileuploads\").Get(fd.Uid()).\n\t\t\tUpdate(map[string]any{\n\t\t\t\t\"UpdatedAt\": now,\n\t\t\t\t\"Status\":    t.UploadCompleted,\n\t\t\t\t\"Size\":      size,\n\t\t\t\t\"ETag\":      fd.ETag,\n\t\t\t\t\"Location\":  fd.Location,\n\t\t\t}).RunWrite(a.conn); err != nil {\n\n\t\t\treturn nil, err\n\t\t}\n\t\tfd.Status = t.UploadCompleted\n\t\tfd.Size = size\n\t} else {\n\t\tif _, err := rdb.DB(a.dbName).Table(\"fileuploads\").Get(fd.Uid()).Delete().RunWrite(a.conn); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tfd.Status = t.UploadFailed\n\t\tfd.Size = 0\n\t}\n\tfd.UpdatedAt = now\n\n\treturn fd, nil\n}\n\n// FileGet fetches a record of a specific file\nfunc (a *adapter) FileGet(fid string) (*t.FileDef, error) {\n\tcursor, err := rdb.DB(a.dbName).Table(\"fileuploads\").Get(fid).Run(a.conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cursor.Close()\n\n\tif cursor.IsNil() {\n\t\treturn nil, nil\n\t}\n\n\tvar fd t.FileDef\n\tif err = cursor.One(&fd); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &fd, nil\n\n}\n\n// FileLinkAttachments connects given topic or message to the file record IDs from the list.\nfunc (a *adapter) FileLinkAttachments(topic string, userId, msgId t.Uid, fids []string) error {\n\tif len(fids) == 0 || (topic == \"\" && userId.IsZero() && msgId.IsZero()) {\n\t\treturn t.ErrMalformed\n\t}\n\n\tnow := t.TimeNow()\n\tvar err error\n\n\tif msgId.IsZero() {\n\t\t// Only one link per user or topic is permitted.\n\t\tfids = fids[0:1]\n\n\t\t// Topics and users and mutable. Must unlink the previous attachments first.\n\t\tvar table string\n\t\tvar linkId string\n\t\tif topic != \"\" {\n\t\t\ttable = \"topics\"\n\t\t\tlinkId = topic\n\t\t} else {\n\t\t\ttable = \"users\"\n\t\t\tlinkId = userId.String()\n\t\t}\n\n\t\t// Find the old attachment.\n\t\tvar cursor *rdb.Cursor\n\t\tcursor, err = rdb.DB(a.dbName).Table(table).Get(linkId).\n\t\t\tField(\"Attachments\").Default([]string{}).Run(a.conn)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer cursor.Close()\n\n\t\tif !cursor.IsNil() {\n\t\t\tvar attachments []string\n\t\t\tif err = cursor.One(&attachments); err != nil {\n\t\t\t\tif err != rdb.ErrEmptyResult {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\terr = nil\n\t\t\t}\n\n\t\t\tif len(attachments) > 0 {\n\t\t\t\t// Decrement the use count of old attachment.\n\t\t\t\tif _, err = rdb.DB(a.dbName).Table(\"fileuploads\").Get(attachments[0]).\n\t\t\t\t\tUpdate(map[string]any{\n\t\t\t\t\t\t\"UpdatedAt\": now,\n\t\t\t\t\t\t\"UseCount\":  rdb.Row.Field(\"UseCount\").Default(1).Sub(1),\n\t\t\t\t\t}).RunWrite(a.conn); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t_, err = rdb.DB(a.dbName).Table(table).Get(linkId).\n\t\t\tUpdate(map[string]any{\n\t\t\t\t\"UpdatedAt\":   now,\n\t\t\t\t\"Attachments\": fids,\n\t\t\t}).RunWrite(a.conn)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\t// Messages are immutable. Just save the IDs.\n\t\t_, err := rdb.DB(a.dbName).Table(\"messages\").Get(msgId.String()).\n\t\t\tUpdate(map[string]any{\n\t\t\t\t\"UpdatedAt\":   now,\n\t\t\t\t\"Attachments\": fids,\n\t\t\t}).RunWrite(a.conn)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tids := make([]any, len(fids))\n\tfor i, id := range fids {\n\t\tids[i] = id\n\t}\n\n\t_, err = rdb.DB(a.dbName).Table(\"fileuploads\").GetAll(ids...).\n\t\tUpdate(map[string]any{\n\t\t\t\"UpdatedAt\": now,\n\t\t\t\"UseCount\":  rdb.Row.Field(\"UseCount\").Default(0).Add(1),\n\t\t}).RunWrite(a.conn)\n\n\treturn err\n}\n\n// FileDeleteUnused deletes orphaned file uploads.\nfunc (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, error) {\n\tq := rdb.DB(a.dbName).Table(\"fileuploads\").GetAllByIndex(\"UseCount\", 0)\n\tif !olderThan.IsZero() {\n\t\tq = q.Filter(rdb.Row.Field(\"UpdatedAt\").Lt(olderThan))\n\t}\n\tif limit > 0 {\n\t\tq = q.Limit(limit)\n\t}\n\n\tcursor, err := q.Field(\"Location\").Run(a.conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer cursor.Close()\n\n\tvar locations []string\n\tvar loc string\n\tfor cursor.Next(&loc) {\n\t\tlocations = append(locations, loc)\n\t}\n\n\tif err = cursor.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\n\t_, err = q.Delete().RunWrite(a.conn)\n\n\treturn locations, err\n}\n\n// Given a select query, decrement corresponding use counter in 'fileuploads' table.\n// The 'query' must return an array, i.e. GetAll, not Get.\nfunc (a *adapter) decFileUseCounter(query rdb.Term) error {\n\t/*\n\t\tr.db(\"test\").table(\"one\")\n\t\t\t.getAll(\n\t\t\t\tr.args(r.db(\"test\").table(\"zero\")\n\t\t\t\t\t.getAll(\n\t\t\t\t\t\t\"07e2c6fe-ac91-49cb-9834-ff34bf50aad1\",\n\t\t\t  \t\t\t\"0098a829-6da5-4f7b-8432-32b40de9ab3b\",\n\t\t\t\t\t\t\"0926e7dd-321a-49cb-adb1-7a705d9d9a78\",\n\t\t\t\t\t\t\"8e195450-babd-4954-a8fb-0cc414b43156\")\n\t\t\t\t\t.filter(r.row.hasFields(\"att\"))\n\t\t\t\t\t.concatMap(function(row) { return row.getField(\"att\"); })\n\t\t\t\t\t.coerceTo(\"array\"))\n\t\t\t\t)\n\t\t\t.update({useCount: r.row.getField(\"useCount\").default(0).add(1)})\n\t*/\n\t_, err := rdb.DB(a.dbName).Table(\"fileuploads\").GetAll(\n\t\trdb.Args(\n\t\t\tquery.\n\t\t\t\t// Fetch messages with attachments only\n\t\t\t\tFilter(rdb.Row.HasFields(\"Attachments\")).\n\t\t\t\t// Flatten arrays\n\t\t\t\tConcatMap(func(row rdb.Term) any { return row.Field(\"Attachments\") }).\n\t\t\t\tCoerceTo(\"array\"))).\n\t\t// Decrement UseCount.\n\t\tUpdate(map[string]any{\"UseCount\": rdb.Row.Field(\"UseCount\").Default(1).Sub(1)}).\n\t\tRunWrite(a.conn)\n\treturn err\n}\n\n// PCacheGet reads a persistet cache entry.\nfunc (a *adapter) PCacheGet(key string) (string, error) {\n\tcursor, err := rdb.DB(a.dbName).Table(\"kvmeta\").Get(key).Run(a.conn)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer cursor.Close()\n\n\tif cursor.IsNil() {\n\t\treturn \"\", t.ErrNotFound\n\t}\n\n\tvar result map[string]string\n\tif err = cursor.One(&result); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn result[\"value\"], nil\n}\n\n// PCacheUpsert creates or updates a persistent cache entry.\nfunc (a *adapter) PCacheUpsert(key string, value string, failOnDuplicate bool) error {\n\tif strings.Contains(key, \"^\") {\n\t\t// Do not allow ^ in keys: it interferes with Match() query.\n\t\treturn t.ErrMalformed\n\t}\n\n\tdoc := map[string]any{\n\t\t\"key\":   key,\n\t\t\"value\": value,\n\t}\n\n\tvar action string\n\tif failOnDuplicate {\n\t\taction = \"error\"\n\t\tdoc[\"CreatedAt\"] = t.TimeNow()\n\t} else {\n\t\taction = \"update\"\n\t}\n\n\t_, err := rdb.DB(a.dbName).Table(\"kvmeta\").Insert(doc, rdb.InsertOpts{Conflict: action}).RunWrite(a.conn)\n\tif rdb.IsConflictErr(err) {\n\t\treturn t.ErrDuplicate\n\t}\n\n\treturn err\n}\n\n// PCacheDelete deletes one persistent cache entry.\nfunc (a *adapter) PCacheDelete(key string) error {\n\t_, err := rdb.DB(a.dbName).Table(\"kvmeta\").Get(key).Delete().RunWrite(a.conn)\n\treturn err\n}\n\n// PCacheExpire expires old entries with the given key prefix.\nfunc (a *adapter) PCacheExpire(keyPrefix string, olderThan time.Time) error {\n\tif keyPrefix == \"\" {\n\t\treturn t.ErrMalformed\n\t}\n\n\t_, err := rdb.DB(a.dbName).Table(\"kvmeta\").\n\t\tFilter(rdb.Row.Field(\"CreatedAt\").Lt(olderThan).And(rdb.Row.Field(\"key\").Match(\"^\" + keyPrefix))).\n\t\tDelete().\n\t\tRunWrite(a.conn)\n\n\treturn err\n}\n\n// GetTestDB returns a currently open database connection.\nfunc (a *adapter) GetTestDB() any {\n\treturn a.conn\n}\n\n// Check if error is due to no results.\n// The case covered is calling Field('name') on a non-object value.\nfunc isNoResults(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\treturn strings.Contains(err.Error(), \"perform get_field on a non-object non-sequence\")\n}\n\n// Checks if the given error is 'Database not found'.\nfunc isMissingDb(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\n\tmsg := err.Error()\n\t// \"Database `db_name` does not exist\"\n\treturn strings.Contains(msg, \"Database `\") && strings.Contains(msg, \"` does not exist\")\n}\n\n// GetTestAdapter returns an adapter object. Useful for running tests.\nfunc GetTestAdapter() *adapter {\n\treturn &adapter{}\n}\n\nfunc init() {\n\tstore.RegisterAdapter(&adapter{})\n}\n"
  },
  {
    "path": "server/db/rethinkdb/blank.go",
    "content": "//go:build !rethinkdb\n// +build !rethinkdb\n\n// This file is needed for conditional compilation. It's used when\n// the build tag 'rethinkdb' is not defined. Otherwise the adapter.go\n// is compiled.\n\npackage rethinkdb\n"
  },
  {
    "path": "server/db/rethinkdb/schema.md",
    "content": "# RethinkDB Database Schema\n\n## Database `tinode`\n\n### Table `users`\nStores user accounts\n\nFields:\n* `Id` user id, primary key\n* `CreatedAt` timestamp when the user was created\n* `UpdatedAt` timestamp when user metadata was updated\n* `State` account state: normal (ok), suspended, soft-deleted\n* `StateAt` timestamp when the state was last updated or NULL\n* `Access` user's default access level for peer-to-peer topics\n * `Auth`, `Anon` default permissions for authenticated and anonymous users\n* `Public` application-defined data\n* `State` state of the user: normal, disabled, deleted\n* `StateAt` timestamp when the state was last updated or NULL\n* `LastSeen` timestamp when the user was last online\n* `UserAgent` client User-Agent used when last online\n* `Tags` unique strings for user discovery\n* `Devices` client devices for push notifications\n * `DeviceId` device registration ID\n * `Platform` device platform string (iOS, Android, Web)\n * `LastSeen` last logged in\n * `Lang` device language, ISO code\n\nIndexes:\n * `Id` primary key\n * `Tags` multi-index (indexed array)\n * `DeletedAt` index\n * `DeviceIds` multi-index of push notification tokens\n\nSample:\n```js\n{\n  \"Access\": {\n    \"Anon\": 0 ,\n    \"Auth\": 47\n  } ,\n  \"CreatedAt\": Mon Jul 24 2017 11:16:38 GMT+00:00 ,\n  \"State\": 0,\n  \"StateAt\": null ,\n  \"Devices\": null ,\n  \"Id\": \"7yUCHniegrM\" ,\n  \"LastSeen\": Mon Jan 01 1 00:00:00 GMT+00:00 ,\n  \"Public\": {\n    \"fn\": \"Alice Johnson\" ,\n    \"photo\": {\n      \"data\": <binary, 6.5KB, \"ff d8 ff e0 00 10...\"> ,\n      \"type\": \"jpg\"\n    }\n  } ,\n  \"State\": 1 ,\n  \"Tags\": [\n    \"email:alice@example.com\" ,\n    \"tel:17025550001\"\n  ] ,\n  \"UpdatedAt\": Mon Jul 24 2017 11:16:38 GMT+00:00 ,\n  \"UserAgent\": \"TinodeWeb/0.13 (MacIntel) tinodejs/0.13\"\n}\n```\n\n### Table `auth`\nStores authentication secrets\n\nFields:\n* `userid` ID of the user who owns the record\n* `unique` unique string which identifies this record, primary key; defined as \"_authentication scheme_':'_some unique value per scheme_\"\n* `secret` shared secret, for instance bcrypt of password\n* `authLvl` authentication level\n* `expires` timestamp when the records expires\n\n\nIndexes:\n * `unique` primary key\n * `userid` index\n\nSample:\n```js\n{\n   \"authLvl\": 20 ,\n   \"expires\": Mon Jan 01 1 00:00:00 GMT+00:00 ,\n   \"secret\": <binary, 60 bytes, \"24 32 61 24 31 30...\"> ,\n   \"unique\": \"basic:alice\" ,\n   \"userid\": \"7yUCHniegrM\"\n}\n```\n\n### Table `topics`\nThe table stores topics.\n\nFields:\n * `Id` name of the topic, primary key\n * `CreatedAt` topic creation time\n * `UpdatedAt` timestamp of the last change to topic metadata\n * `State` topic state: normal (ok), suspended, soft-deleted\n * `StateAt` timestamp when the state was last updated or NULL\n * `Access` stores topic's default access permissions\n  * `Auth`, `Anon` permissions for authenticated and anonymous users respectively\n * `Owner` ID of the user who owns the topic\n * `Public` application-defined data\n * `State` state of the topic: normal, disabled, deleted\n * `SeqId` sequential ID of the last message\n * `DelId` topic-sequential ID of the deletion operation\n * `UseBt` indicator that channel functionality is enabled in the topic\n\nIndexes:\n* `Id` primary key\n* `Owner` index\n\nSample:\n```js\n{\n \"Access\": {\n  \"Anon\": 64 ,\n  \"Auth\": 64\n } ,\n \"DelId\": 0,\n \"CreatedAt\": Thu Oct 15 2015 04:06:51 GMT+00:00 ,\n \"State\": 0 ,\n \"StateAt\": null ,\n \"LastMessageAt\": Sat Oct 17 2015 13:51:56 GMT+00:00 ,\n \"Id\":  \"p2pavVGHLCBbKrvJQIeeJ6Csw\" ,\n \"Owner\": \"v2JyG4OLSoA\" ,\n \"Public\": {\n   \"fn\":  \"Travel, travel, travel\" ,\n   \"photo\": {\n     \"data\": <binary, 6.2KB, \"ff d8 ff e0 00 10...\"> ,\n     \"type\":  \"jpg\"\n   }\n } ,\n \"SeqId\": 14,\n \"State\": 0 ,\n \"UpdatedAt\": Thu Oct 15 2015 04:06:51 GMT+00:00 ,\n \"UseBt\": false\n}\n```\n\n### Table `subscriptions`\nThe table stores relationships between users and topics.\n\nFields:\n * `Id` used for object retrieval\n * `CreatedAt` timestamp when the subscription was created\n * `UpdatedAt` timestamp when the subscription was updated\n * `DeletedAt` timestamp when the subscription was deleted\n * `ReadSeqId` id of the message last read by the user\n * `RecvSeqId` id of the message last received by any user device\n * `DelId` topic-sequential ID of the soft-deletion operation\n * `Topic` name of the topic subscribed to\n * `User` subscriber's user ID\n * `ModeWant` access mode that user wants when accessing the topic\n * `ModeGiven` access mode granted to user by the topic\n * `Private` application-defined data, accessible by the user only\n\nIndexes:\n * `Id` primary key composed as \"_topic name_':'_user ID_\"\n * `User` index\n * `Topic` index\n\nSample:\n```js\n{\n  \"ClearId\": 0 ,\n  \"CreatedAt\": Tue Jul 25 2017 15:34:39 GMT+00:00 ,\n  \"DeletedAt\": null ,\n  \"Id\": \"grpjajVKrHn0PU:v2JyG4OLSoA\" ,\n  \"ModeGiven\": 47 ,\n  \"ModeWant\": 47 ,\n  \"Private\": \"Kirgudu\" ,\n  \"ReadSeqId\": 0 ,\n  \"RecvSeqId\": 0 ,\n  \"State\": 0 ,\n  \"Topic\": \"grpjajVKrHn0PU\" ,\n  \"UpdatedAt\": Tue Jul 25 2017 15:34:39 GMT+00:00 ,\n  \"User\": \"v2JyG4OLSoA\"\n}\n```\n\n### Table `messages`\nThe table stores `{data}` messages\n\nFields:\n* `Id` currently unused, primary key\n* `CreatedAt` timestamp when the message was created\n* `UpdatedAt` initially equal to CreatedAt, for deleted messages equal to DeletedAt\n* `DeletedFor` array of user IDs which soft-deleted the message\n * `DelId` topic-sequential ID of the soft-deletion operation\n * `User` ID of the user who soft-deleted the message\n* `From` ID of the user who generated this message\n* `Topic` which received this message\n* `SeqId` messages ID - sequential number of the message in the topic\n* `Head` message headers\n* `Attachments` denormalized IDs of files attached to the message\n* `Content` application-defined message payload\n\nIndexes:\n * `Id` primary key\n * `Topic_SeqId` compound index `[\"Topic\", \"SeqId\"]`\n * `Topic_DelId` compound index `[\"Topic\", \"DelId\"]`\n * `Topic_DeletedFor` compound multi-index `[\"Topic\", \"DeletedFor\"(\"User\"), \"DeletedFor\"(\"DelId\")]`\n\nSample:\n```js\n{\n  \"Content\": {\n    \"fmt\": [\n      {\n        \"len\": 6 ,\n        \"tp\":  \"ST\"\n      }\n    ] ,\n    \"txt\":  \"Hello!\"\n  } ,\n  \"CreatedAt\": Sun Dec 24 2017 05:16:23 GMT+00:00 ,\n  \"From\":  \"wTI0jO9rEqY\" ,\n  \"Head\": {\n    \"mime\":  \"text/x-drafty\"\n  } ,\n  \"DeletedFor\": [\n    {\n      \"DelId\": 1 ,\n      \"User\":  \"wTI0jO9rEqY\"\n    }\n  ] ,\n  \"Id\":  \"LLXKEe9W4Bs\" ,\n  \"SeqId\": 3 ,\n  \"Topic\":  \"p2pJhbJnya8z5PBMjSM72sSpg\" ,\n  \"UpdatedAt\": Sun Dec 24 2017 05:16:23 GMT+00:00\n}\n```\n\n### Table `dellog`\nThe table stores records of message deletions\n\nFields:\n* `Id` currently unused, primary key\n* `CreatedAt` timestamp when the record was created\n* `UpdatedAt` timestamp equal to CreatedAt\n* `DelId` topic-sequential ID of the deletion operation.\n* `DeletedFor` ID of the user for soft-deletions, blank string for hard-deletions\n* `Topic` affected topic\n* `SeqIdRanges` array of ranges of deleted message IDs (see `messages.SeqId`)\n\nIndexes:\n * `Id` primary key\n* `Topic_DelId` compound index `[\"Topic\", \"DelId\"]`\n\nSample:\n```js\n{\n  \"Id\":  \"9LfrjW349Rc\",\n  \"CreatedAt\": Tue Dec 05 2017 01:51:38 GMT+00:00,\n  \"DelId\": 18,\n  \"DeletedFor\": \"xY-YHx09-WI\" ,\n\n  \"SeqIdRanges\": [\n    {\n      \"Low\": 20,\n      \"Hi\": 25,\n    }\n  ] ,\n  \"Topic\":  \"grpGx7fpjQwVC0\" ,\n  \"UpdatedAt\": Tue Dec 05 2017 01:51:38 GMT+00:00\n}\n```\n\n### Table `credentials`\nThe tables stores user credentials used for validation.\n\n* `Id` credential, primary key\n* `CreatedAt` timestamp when the record was created\n* `UpdatedAt` timestamp when the last validation attempt was performed (successful or not).\n* `Method` validation method\n* `Done` indicator if the credential is validated\n* `Resp` expected validation response\n* `Retries` number of failed attempts at validation\n* `User` id of the user who owns this credential\n* `Value` value of the credential\n* `Closed` unvalidated credential is no longer being validated. Only one credential is not Closed for each user/method.\n\nIndexes:\n* `Id` Primary key composed either as `User`:`Method`:`Value` for unconfirmed credentials or as `Method`:`Value` for confirmed.\n* `User` Index\n\nSample:\n```js\n{\n  \"Id\": \"tel:+17025550001\",\n  \"CreatedAt\": Sun Jun 10 2018 16:37:27 GMT+00:00 ,\n  \"UpdatedAt\": Sun Jun 10 2018 16:37:28 GMT+00:00 ,\n  \"Method\":  \"tel\" ,\n  \"Done\": true ,\n  \"Resp\":  \"123456\" ,\n  \"Retries\": 0 ,\n  \"User\":  \"k3srBRk9RYw\" ,\n  \"Value\":  \"+17025550001\"\n}\n```\n\n### Table `fileuploads`\nThe table stores records of uploaded files. The files themselves are stored outside of the database.\n* `Id` unique user-visible file name, primary key\n* `CreatedAt` timestamp when the record was created\n* `UpdatedAt` timestamp of when th upload has cmpleted or failed\n* `User` id of the user who uploaded this file.\n* `Location` actual location of the file on the server.\n* `MimeType` file content type as a [Mime](https://en.wikipedia.org/wiki/MIME) string.\n* `Size` size of the file in bytes. Could be 0 if upload has not completed yet.\n* `UseCount` count of messages referencing this file.\n* `Status` upload status: 0 pending, 1 completed, -1 failed.\n\nIndexes:\n * `Id` primary key\n * `UseCount` index\n\nSample:\n```js\n{\n  \"CreatedAt\": Sun Jun 10 2018 16:38:45 GMT+00:00 ,\n  \"Id\": \"sFmjlQ_kA6A\" ,\n  \"Location\": \"uploads/sFmjlQ_kA6A\" ,\n  \"MimeType\": \"image/jpeg\" ,\n  \"Size\": 54961090 ,\n  \"UseCount\": 3,\n  \"Status\": 1,\n  \"UpdatedAt\": Sun Jun 10 2018 16:38:45 GMT+00:00 ,\n  \"User\": \"7j-RR1V7O3Y\"\n}\n```\n"
  },
  {
    "path": "server/db/rethinkdb/tests/rethink_test.go",
    "content": "package tests\n\n// To test another db backend:\n// 1) Create GetAdapter function inside your db backend adapter package (like one inside rethinkdb adapter)\n// 2) Uncomment your db backend package ('backend' named package)\n// 3) Write own initConnectionToDb and 'db' variable\n// 4) Replace rethinkdb specific db queries inside test to your own queries.\n// 5) Run.\n\nimport (\n\t\"encoding/json\"\n\t\"flag\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"reflect\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/google/go-cmp/cmp\"\n\t\"github.com/google/go-cmp/cmp/cmpopts\"\n\tadapter \"github.com/tinode/chat/server/db\"\n\tjcr \"github.com/tinode/jsonco\"\n\trdb \"gopkg.in/rethinkdb/rethinkdb-go.v6\"\n\n\t\"github.com/tinode/chat/server/db/common/test_data\"\n\tbackend \"github.com/tinode/chat/server/db/rethinkdb\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\ntype configType struct {\n\t// If Reset=true test will recreate database every time it runs\n\tReset bool `json:\"reset_db_data\"`\n\t// Configurations for individual adapters.\n\tAdapters map[string]json.RawMessage `json:\"adapters\"`\n}\n\nvar config configType\nvar adp adapter.Adapter\nvar conn *rdb.Session\nvar testData *test_data.TestData\n\nvar dummyUid1 = types.Uid(12345)\nvar dummyUid2 = types.Uid(54321)\n\nfunc TestCreateDb(t *testing.T) {\n\tif err := adp.CreateDb(config.Reset); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Saved db is closed, get a fresh one.\n\tconn = adp.GetTestDB().(*rdb.Session)\n}\n\n// ================== Create tests ================================\nfunc TestUserCreate(t *testing.T) {\n\tfor _, user := range testData.Users {\n\t\tif err := adp.UserCreate(user); err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n\n\tcursor, err := rdb.Table(\"users\").Count().Run(conn)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tdefer cursor.Close()\n\n\tvar count int\n\tif err = cursor.One(&count); err != nil {\n\t\tt.Error(err)\n\t}\n\tif count == 0 {\n\t\tt.Error(\"No users created!\")\n\t}\n}\n\nfunc TestCredUpsert(t *testing.T) {\n\t// Test just inserts:\n\tfor i := 0; i < 2; i++ {\n\t\tinserted, err := adp.CredUpsert(testData.Creds[i])\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif !inserted {\n\t\t\tt.Error(\"Should be inserted, but updated\")\n\t\t}\n\t}\n\n\t// Test duplicate:\n\t_, err := adp.CredUpsert(testData.Creds[1])\n\tif err != types.ErrDuplicate {\n\t\tt.Error(\"Should return duplicate error but got\", err)\n\t}\n\t_, err = adp.CredUpsert(testData.Creds[2])\n\tif err != types.ErrDuplicate {\n\t\tt.Error(\"Should return duplicate error but got\", err)\n\t}\n\n\t// Test add new unvalidated credentials\n\tinserted, err := adp.CredUpsert(testData.Creds[3])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !inserted {\n\t\tt.Error(\"Should be inserted, but updated\")\n\t}\n\tinserted, err = adp.CredUpsert(testData.Creds[3])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif inserted {\n\t\tt.Error(\"Should be updated, but inserted\")\n\t}\n\n\t// Just insert other creds (used in other tests)\n\tfor _, cred := range testData.Creds[4:] {\n\t\t_, err = adp.CredUpsert(cred)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n}\n\nfunc TestAuthAddRecord(t *testing.T) {\n\tfor _, rec := range testData.Recs {\n\t\terr := adp.AuthAddRecord(types.ParseUserId(\"usr\"+rec.UserId), rec.Scheme, rec.Unique,\n\t\t\trec.AuthLvl, rec.Secret, rec.Expires)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\t//Test duplicate\n\terr := adp.AuthAddRecord(types.ParseUserId(\"usr\"+testData.Users[0].Id), testData.Recs[0].Scheme,\n\t\ttestData.Recs[0].Unique, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)\n\tif err != types.ErrDuplicate {\n\t\tt.Fatal(\"Should be duplicate error but got\", err)\n\t}\n}\n\nfunc TestTopicCreate(t *testing.T) {\n\terr := adp.TopicCreate(testData.Topics[0])\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\t// Update topic SeqId because it's not saved at creation time but used by the tests.\n\terr = adp.TopicUpdate(testData.Topics[0].Id, map[string]interface{}{\n\t\t\"SeqId\": testData.Topics[0].SeqId,\n\t})\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\n\tfor _, tpc := range testData.Topics[3:] {\n\t\terr = adp.TopicCreate(tpc)\n\t\tif err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t}\n}\n\nfunc TestTopicCreateP2P(t *testing.T) {\n\terr := adp.TopicCreateP2P(testData.Subs[2], testData.Subs[3])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\toldModeGiven := testData.Subs[2].ModeGiven\n\ttestData.Subs[2].ModeGiven = 255\n\terr = adp.TopicCreateP2P(testData.Subs[4], testData.Subs[2])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcursor, err := rdb.Table(\"subscriptions\").Get(testData.Subs[2].Id).Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor.Close()\n\n\tvar got types.Subscription\n\tif err = cursor.One(&got); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.ModeGiven == oldModeGiven {\n\t\tt.Error(\"ModeGiven update failed\")\n\t}\n}\n\nfunc TestTopicShare(t *testing.T) {\n\tif err := adp.TopicShare(testData.Subs[0].Topic, testData.Subs); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Must save recvseqid and readseqid separately because TopicShare\n\t// ignores them.\n\tfor _, sub := range testData.Subs {\n\t\tadp.SubsUpdate(sub.Topic, types.ParseUid(sub.User), map[string]any{\n\t\t\t\"RecvSeqId\": sub.RecvSeqId,\n\t\t\t\"ReadSeqId\": sub.ReadSeqId,\n\t\t})\n\t}\n}\n\nfunc TestMessageSave(t *testing.T) {\n\tfor _, msg := range testData.Msgs {\n\t\terr := adp.MessageSave(msg)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\n\t// Some messages are soft deleted, but it's ignored by adp.MessageSave\n\tfor _, msg := range testData.Msgs {\n\t\tif len(msg.DeletedFor) > 0 {\n\t\t\tfor _, del := range msg.DeletedFor {\n\t\t\t\ttoDel := types.DelMessage{\n\t\t\t\t\tTopic:       msg.Topic,\n\t\t\t\t\tDeletedFor:  del.User,\n\t\t\t\t\tDelId:       del.DelId,\n\t\t\t\t\tSeqIdRanges: []types.Range{{Low: msg.SeqId}},\n\t\t\t\t}\n\t\t\t\tadp.MessageDeleteList(msg.Topic, &toDel)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestFileStartUpload(t *testing.T) {\n\tfor _, f := range testData.Files {\n\t\terr := adp.FileStartUpload(f)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n}\n\n// ================== Read tests ==================================\nfunc TestUserGet(t *testing.T) {\n\t// Test not found\n\tgot, err := adp.UserGet(dummyUid1)\n\tif err == nil && got != nil {\n\t\tt.Error(\"user should be nil.\")\n\t}\n\n\tgot, err = adp.UserGet(types.ParseUserId(\"usr\" + testData.Users[0].Id))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// User agent is not stored when creating a user. Make sure it's the same.\n\tgot.UserAgent = testData.Users[0].UserAgent\n\n\tif !cmp.Equal(got, testData.Users[0], cmpopts.IgnoreUnexported(types.User{}, types.ObjHeader{})) {\n\t\tt.Error(mismatchErrorString(\"User\", got, testData.Users[0]))\n\t}\n}\n\nfunc TestUserGetAll(t *testing.T) {\n\t// Test not found (dummy UIDs).\n\tgot, err := adp.UserGetAll(dummyUid1, dummyUid2)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(got) > 0 {\n\t\tt.Error(\"result users should be zero length, got\", len(got))\n\t}\n\n\tgot, err = adp.UserGetAll(types.ParseUserId(\"usr\"+testData.Users[0].Id), types.ParseUserId(\"usr\"+testData.Users[1].Id))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(got) != 2 {\n\t\tt.Fatal(mismatchErrorString(\"resultUsers length\", len(got), 2))\n\t}\n\tfor i, usr := range got {\n\t\t// User agent is not compared.\n\t\tusr.UserAgent = testData.Users[i].UserAgent\n\t\tif !reflect.DeepEqual(&usr, testData.Users[i]) {\n\t\t\tt.Error(mismatchErrorString(\"User\", &usr, testData.Users[i]))\n\t\t}\n\t}\n}\n\nfunc TestUserGetByCred(t *testing.T) {\n\t// Test not found\n\tgot, err := adp.UserGetByCred(\"foo\", \"bar\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got != types.ZeroUid {\n\t\tt.Error(\"result uid should be ZeroUid\")\n\t}\n\n\tgot, _ = adp.UserGetByCred(testData.Creds[0].Method, testData.Creds[0].Value)\n\tif got != types.ParseUserId(\"usr\"+testData.Creds[0].User) {\n\t\tt.Error(mismatchErrorString(\"Uid\", got, types.ParseUserId(\"usr\"+testData.Creds[0].User)))\n\t}\n}\n\nfunc TestCredGetActive(t *testing.T) {\n\tgot, err := adp.CredGetActive(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"tel\")\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif !cmp.Equal(got, testData.Creds[3], cmpopts.IgnoreUnexported(types.ObjHeader{}, types.Credential{})) {\n\t\tt.Error(mismatchErrorString(\"Credential\", got, testData.Creds[3]))\n\t}\n\n\t// Test not found\n\tgot, err = adp.CredGetActive(dummyUid1, \"\")\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif got != nil {\n\t\tt.Error(\"result should be nil, but got\", got)\n\t}\n}\n\nfunc TestCredGetAll(t *testing.T) {\n\tgot, err := adp.CredGetAll(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"\", false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(got) != 3 {\n\t\tt.Error(mismatchErrorString(\"Credentials length\", len(got), 3))\n\t}\n\n\tgot, _ = adp.CredGetAll(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"tel\", false)\n\tif len(got) != 2 {\n\t\tt.Error(mismatchErrorString(\"Credentials length\", len(got), 2))\n\t}\n\n\tgot, _ = adp.CredGetAll(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"\", true)\n\tif len(got) != 1 {\n\t\tt.Error(mismatchErrorString(\"Credentials length\", len(got), 1))\n\t}\n\n\tgot, _ = adp.CredGetAll(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"tel\", true)\n\tif len(got) != 1 {\n\t\tt.Error(mismatchErrorString(\"Credentials length\", len(got), 1))\n\t}\n}\n\nfunc TestUserGetUnvalidated(t *testing.T) {\n\t// Test RethinkDB specific method\n\tcutoff := time.Now().Add(-24 * time.Hour)\n\tuids, err := adp.UserGetUnvalidated(cutoff, 10)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\t// Should return empty slice since all test users are considered validated\n\tif len(uids) > 0 {\n\t\tt.Error(\"Expected no unvalidated users in test data\")\n\t}\n}\n\nfunc TestAuthGetUniqueRecord(t *testing.T) {\n\tuid, authLvl, secret, expires, err := adp.AuthGetUniqueRecord(\"basic:alice\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif uid != types.ParseUserId(\"usr\"+testData.Recs[0].UserId) ||\n\t\tauthLvl != testData.Recs[0].AuthLvl ||\n\t\t!reflect.DeepEqual(secret, testData.Recs[0].Secret) ||\n\t\texpires != testData.Recs[0].Expires {\n\n\t\tgot := fmt.Sprintf(\"%v %v %v %v\", uid, authLvl, secret, expires)\n\t\twant := fmt.Sprintf(\"%v %v %v %v\", testData.Recs[0].UserId, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)\n\t\tt.Error(mismatchErrorString(\"Auth record\", got, want))\n\t}\n\n\t// Test not found\n\tuid, _, _, _, err = adp.AuthGetUniqueRecord(\"qwert:asdfg\")\n\tif err == nil && !uid.IsZero() {\n\t\tt.Error(\"Auth record found but shouldn't. Uid:\", uid.String())\n\t}\n}\n\nfunc TestAuthGetRecord(t *testing.T) {\n\trecId, authLvl, secret, expires, err := adp.AuthGetRecord(types.ParseUserId(\"usr\"+testData.Recs[0].UserId), \"basic\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif recId != testData.Recs[0].Unique ||\n\t\tauthLvl != testData.Recs[0].AuthLvl ||\n\t\t!reflect.DeepEqual(secret, testData.Recs[0].Secret) ||\n\t\texpires != testData.Recs[0].Expires {\n\n\t\tgot := fmt.Sprintf(\"%v %v %v %v\", recId, authLvl, secret, expires)\n\t\twant := fmt.Sprintf(\"%v %v %v %v\", testData.Recs[0].Unique, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)\n\t\tt.Error(mismatchErrorString(\"Auth record\", got, want))\n\t}\n\n\t// Test not found\n\trecId, _, _, _, err = adp.AuthGetRecord(types.Uid(123), \"scheme\")\n\tif err != types.ErrNotFound {\n\t\tt.Error(\"Auth record found but shouldn't. recId:\", recId)\n\t}\n}\n\nfunc TestTopicGet(t *testing.T) {\n\tgot, err := adp.TopicGet(testData.Topics[0].Id)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !reflect.DeepEqual(got, testData.Topics[0]) {\n\t\tt.Error(mismatchErrorString(\"Topic\", got, testData.Topics[0]))\n\t}\n\t// Test not found\n\tgot, err = adp.TopicGet(\"asdfasdfasdf\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got != nil {\n\t\tt.Error(\"Topic should be nil but got:\", got)\n\t}\n}\n\nfunc TestTopicsForUser(t *testing.T) {\n\tqOpts := types.QueryOpt{\n\t\tTopic: testData.Topics[1].Id,\n\t\tLimit: 999,\n\t}\n\tgotSubs, err := adp.TopicsForUser(types.ParseUserId(\"usr\"+testData.Users[0].Id), false, &qOpts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 1 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 1))\n\t}\n\n\tgotSubs, err = adp.TopicsForUser(types.ParseUserId(\"usr\"+testData.Users[1].Id), true, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 2 {\n\t\tt.Error(mismatchErrorString(\"Subs length (2)\", len(gotSubs), 2))\n\t}\n\n\tqOpts.Topic = \"\"\n\tims := testData.Now.Add(15 * time.Minute)\n\tqOpts.IfModifiedSince = &ims\n\tgotSubs, err = adp.TopicsForUser(types.ParseUserId(\"usr\"+testData.Users[0].Id), false, &qOpts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 1 {\n\t\tt.Error(mismatchErrorString(\"Subs length (IMS)\", len(gotSubs), 1))\n\t}\n\n\tims = time.Now().Add(15 * time.Minute)\n\tgotSubs, err = adp.TopicsForUser(types.ParseUserId(\"usr\"+testData.Users[0].Id), false, &qOpts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 0 {\n\t\tt.Error(mismatchErrorString(\"Subs length (IMS 2)\", len(gotSubs), 0))\n\t}\n}\n\nfunc TestUsersForTopic(t *testing.T) {\n\tqOpts := types.QueryOpt{\n\t\tUser:  types.ParseUserId(\"usr\" + testData.Users[0].Id),\n\t\tLimit: 999,\n\t}\n\tgotSubs, err := adp.UsersForTopic(testData.Topics[0].Id, false, &qOpts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 1 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 1))\n\t}\n\n\tgotSubs, err = adp.UsersForTopic(testData.Topics[0].Id, true, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 2 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 2))\n\t}\n\n\tgotSubs, err = adp.UsersForTopic(testData.Topics[1].Id, false, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 2 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 2))\n\t}\n}\n\nfunc TestOwnTopics(t *testing.T) {\n\tgotSubs, err := adp.OwnTopics(types.ParseUserId(\"usr\" + testData.Users[0].Id))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotSubs) != 1 {\n\t\tt.Fatalf(\"Got topic length %v instead of %v\", len(gotSubs), 1)\n\t}\n\tif gotSubs[0] != testData.Topics[0].Id {\n\t\tt.Errorf(\"Got topic %v instead of %v\", gotSubs[0], testData.Topics[0].Id)\n\t}\n}\n\nfunc TestChannelsForUser(t *testing.T) {\n\t// Test RethinkDB specific method\n\tchannels, err := adp.ChannelsForUser(types.ParseUserId(\"usr\" + testData.Users[0].Id))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Should return empty slice since we don't have channel subscriptions in test data\n\tif len(channels) != 0 {\n\t\tt.Error(mismatchErrorString(\"Channels length\", len(channels), 0))\n\t}\n}\n\nfunc TestSubscriptionGet(t *testing.T) {\n\tgot, err := adp.SubscriptionGet(testData.Topics[0].Id, types.ParseUserId(\"usr\"+testData.Users[0].Id), false)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\n\topts := cmpopts.IgnoreUnexported(types.Subscription{}, types.ObjHeader{})\n\tif !cmp.Equal(got, testData.Subs[0], opts) {\n\t\tt.Error(mismatchErrorString(\"Subs\", got, testData.Subs[0]))\n\t}\n\t// Test not found\n\tgot, err = adp.SubscriptionGet(\"dummytopic\", dummyUid1, false)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif got != nil {\n\t\tt.Error(\"result sub should be nil.\")\n\t}\n}\n\nfunc TestSubsForUser(t *testing.T) {\n\tgotSubs, err := adp.SubsForUser(types.ParseUserId(\"usr\" + testData.Users[0].Id))\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif len(gotSubs) != 2 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 2))\n\t}\n\n\t// Test not found\n\tgotSubs, err = adp.SubsForUser(types.ParseUserId(\"usr12345678\"))\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif len(gotSubs) != 0 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 0))\n\t}\n}\n\nfunc TestSubsForTopic(t *testing.T) {\n\tqOpts := types.QueryOpt{\n\t\tUser:  types.ParseUserId(\"usr\" + testData.Users[0].Id),\n\t\tLimit: 999,\n\t}\n\tgotSubs, err := adp.SubsForTopic(testData.Topics[0].Id, false, &qOpts)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif len(gotSubs) != 1 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 1))\n\t}\n\t// Test not found\n\tgotSubs, err = adp.SubsForTopic(\"dummytopicid\", false, nil)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif len(gotSubs) != 0 {\n\t\tt.Error(mismatchErrorString(\"Subs length\", len(gotSubs), 0))\n\t}\n}\n\nfunc TestFind(t *testing.T) {\n\treqTags := [][]string{{\"alice\", \"bob\", \"carol\", \"travel\", \"qwer\", \"asdf\", \"zxcv\"}}\n\tgot, err := adp.Find(\"usr\"+testData.Users[2].Id, \"\", reqTags, nil, true)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif len(got) != 3 {\n\t\tt.Error(mismatchErrorString(\"result length\", len(got), 3))\n\t}\n}\n\nfunc TestFindOne(t *testing.T) {\n\t// Test RethinkDB specific FindOne method\n\tfound, err := adp.FindOne(\"alice\")\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\t// Should find the user with alice tag\n\tif found == \"\" {\n\t\tt.Error(\"Expected to find user with alice tag\")\n\t}\n\n\t// Test not found\n\tfound, err = adp.FindOne(\"nonexistent\")\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tif found != \"\" {\n\t\tt.Error(\"Should not find nonexistent tag\")\n\t}\n}\n\nfunc TestMessageGetAll(t *testing.T) {\n\topts := types.QueryOpt{\n\t\tSince:  1,\n\t\tBefore: 2,\n\t\tLimit:  999,\n\t}\n\tgotMsgs, err := adp.MessageGetAll(testData.Topics[0].Id, types.ParseUserId(\"usr\"+testData.Users[0].Id), &opts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(gotMsgs) != 1 {\n\t\tt.Error(mismatchErrorString(\"Messages length opts\", len(gotMsgs), 1))\n\t}\n\tgotMsgs, _ = adp.MessageGetAll(testData.Topics[0].Id, types.ParseUserId(\"usr\"+testData.Users[0].Id), nil)\n\tif len(gotMsgs) != 2 {\n\t\tt.Error(mismatchErrorString(\"Messages length no opts\", len(gotMsgs), 2))\n\t}\n\tgotMsgs, _ = adp.MessageGetAll(testData.Topics[0].Id, types.ZeroUid, nil)\n\tif len(gotMsgs) != 3 {\n\t\tt.Error(mismatchErrorString(\"Messages length zero uid\", len(gotMsgs), 3))\n\t}\n}\n\nfunc TestFileGet(t *testing.T) {\n\t// General test done during TestFileFinishUpload().\n\n\t// Test not found\n\tgot, err := adp.FileGet(\"dummyfileid\")\n\tif err != nil {\n\t\tif got != nil {\n\t\t\tt.Error(\"File found but shouldn't:\", got)\n\t\t}\n\t}\n}\n\n// ================== Update tests ================================\nfunc TestUserUpdate(t *testing.T) {\n\tupdate := map[string]any{\n\t\t\"UserAgent\": \"Test Agent v0.11\",\n\t\t\"UpdatedAt\": testData.Now.Add(30 * time.Minute),\n\t}\n\terr := adp.UserUpdate(types.ParseUserId(\"usr\"+testData.Users[0].Id), update)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcursor, err := rdb.Table(\"users\").Get(testData.Users[0].Id).Pluck(\"UserAgent\", \"UpdatedAt\", \"CreatedAt\").Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor.Close()\n\n\tvar got struct {\n\t\tUserAgent string\n\t\tUpdatedAt time.Time\n\t\tCreatedAt time.Time\n\t}\n\tif err = cursor.One(&got); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.UserAgent != \"Test Agent v0.11\" {\n\t\tt.Error(mismatchErrorString(\"UserAgent\", got.UserAgent, \"Test Agent v0.11\"))\n\t}\n\tif got.UpdatedAt.Equal(got.CreatedAt) {\n\t\tt.Error(\"UpdatedAt field not updated\")\n\t}\n}\n\nfunc TestUserUpdateTags(t *testing.T) {\n\taddTags := testData.Tags[0]\n\tremoveTags := testData.Tags[1]\n\tresetTags := testData.Tags[2]\n\tuid := types.ParseUserId(\"usr\" + testData.Users[0].Id)\n\n\tgot, err := adp.UserUpdateTags(uid, addTags, nil, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"alice\", \"tag1\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Error(mismatchErrorString(\"Tags\", got, want))\n\t}\n\n\tgot, _ = adp.UserUpdateTags(uid, nil, removeTags, nil)\n\twant = nil\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Error(mismatchErrorString(\"Tags\", got, want))\n\t}\n\n\tgot, _ = adp.UserUpdateTags(uid, nil, nil, resetTags)\n\twant = []string{\"alice\", \"tag111\", \"tag333\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Error(mismatchErrorString(\"Tags\", got, want))\n\t}\n\n\tgot, _ = adp.UserUpdateTags(uid, addTags, removeTags, nil)\n\twant = []string{\"tag111\", \"tag333\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Error(mismatchErrorString(\"Tags\", got, want))\n\t}\n}\n\nfunc TestCredFail(t *testing.T) {\n\terr := adp.CredFail(types.ParseUserId(\"usr\"+testData.Creds[3].User), \"tel\")\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\n\t// Check if fields updated\n\tcursor, err := rdb.Table(\"credentials\").GetAllByIndex(\"User\", testData.Creds[3].User).\n\t\tFilter(map[string]any{\"Method\": \"tel\", \"Value\": testData.Creds[3].Value}).\n\t\tPluck(\"Retries\", \"UpdatedAt\", \"CreatedAt\").Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor.Close()\n\n\tvar got struct {\n\t\tRetries   int\n\t\tUpdatedAt time.Time\n\t\tCreatedAt time.Time\n\t}\n\tif err = cursor.One(&got); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.Retries != 1 {\n\t\tt.Error(mismatchErrorString(\"Retries count\", got.Retries, 1))\n\t}\n\tif got.UpdatedAt.Equal(got.CreatedAt) {\n\t\tt.Error(\"UpdatedAt field not updated\")\n\t}\n}\n\nfunc TestCredConfirm(t *testing.T) {\n\terr := adp.CredConfirm(types.ParseUserId(\"usr\"+testData.Creds[3].User), \"tel\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test fields are updated - the confirmed credential should have a new ID (method:value)\n\tcursor, err := rdb.Table(\"credentials\").Get(testData.Creds[3].Method+\":\"+testData.Creds[3].Value).\n\t\tPluck(\"UpdatedAt\", \"CreatedAt\", \"Done\").Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor.Close()\n\n\tvar got struct {\n\t\tUpdatedAt time.Time\n\t\tCreatedAt time.Time\n\t\tDone      bool\n\t}\n\tif err = cursor.One(&got); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.UpdatedAt.Equal(got.CreatedAt) {\n\t\tt.Error(\"Credential not updated correctly\")\n\t}\n\tif !got.Done {\n\t\tt.Error(\"Credential should be marked as done\")\n\t}\n\n\t// And unconfirmed credential should be deleted\n\tcursor2, err := rdb.Table(\"credentials\").Get(testData.Creds[3].User + \":\" + testData.Creds[3].Method + \":\" + testData.Creds[3].Value).Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor2.Close()\n\tif !cursor2.IsNil() {\n\t\tt.Error(\"Unconfirmed credential should be deleted\")\n\t}\n}\n\nfunc TestAuthUpdRecord(t *testing.T) {\n\trec := testData.Recs[1]\n\tnewSecret := []byte{'s', 'e', 'c', 'r', 'e', 't'}\n\terr := adp.AuthUpdRecord(types.ParseUserId(\"usr\"+rec.UserId), rec.Scheme, rec.Unique,\n\t\trec.AuthLvl, newSecret, rec.Expires)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcursor, err := rdb.Table(\"auth\").Get(rec.Unique).Field(\"secret\").Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor.Close()\n\n\tvar got []byte\n\tif err = cursor.One(&got); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif reflect.DeepEqual(got, rec.Secret) {\n\t\tt.Error(mismatchErrorString(\"secret\", got, rec.Secret))\n\t}\n\n\t// Test with auth ID (unique) change\n\tnewId := \"basic:bob12345\"\n\terr = adp.AuthUpdRecord(types.ParseUserId(\"usr\"+rec.UserId), rec.Scheme, newId,\n\t\trec.AuthLvl, newSecret, rec.Expires)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test if old ID deleted\n\tcursor2, err := rdb.Table(\"auth\").Get(rec.Unique).Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor2.Close()\n\tif !cursor2.IsNil() {\n\t\tt.Error(\"Old auth record should be deleted\")\n\t}\n}\n\nfunc TestTopicUpdateOnMessage(t *testing.T) {\n\tmsg := types.Message{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tCreatedAt: testData.Now.Add(33 * time.Minute),\n\t\t},\n\t\tSeqId: 66,\n\t}\n\terr := adp.TopicUpdateOnMessage(testData.Topics[2].Id, &msg)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcursor, err := rdb.Table(\"topics\").Get(testData.Topics[2].Id).\n\t\tPluck(\"TouchedAt\", \"SeqId\").Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor.Close()\n\n\tvar got struct {\n\t\tTouchedAt time.Time\n\t\tSeqId     int\n\t}\n\tif err = cursor.One(&got); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !got.TouchedAt.Equal(msg.CreatedAt) || got.SeqId != msg.SeqId {\n\t\tt.Error(mismatchErrorString(\"TouchedAt\", got.TouchedAt, msg.CreatedAt))\n\t\tt.Error(mismatchErrorString(\"SeqId\", got.SeqId, msg.SeqId))\n\t}\n}\n\nfunc TestTopicUpdate(t *testing.T) {\n\tupdate := map[string]any{\n\t\t\"UpdatedAt\": testData.Now.Add(55 * time.Minute),\n\t}\n\terr := adp.TopicUpdate(testData.Topics[0].Id, update)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcursor, err := rdb.Table(\"topics\").Get(testData.Topics[0].Id).Field(\"UpdatedAt\").Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor.Close()\n\n\tvar got time.Time\n\tif err = cursor.One(&got); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !got.Equal(update[\"UpdatedAt\"].(time.Time)) {\n\t\tt.Error(mismatchErrorString(\"UpdatedAt\", got, update[\"UpdatedAt\"]))\n\t}\n}\n\nfunc TestTopicUpdateSubCnt(t *testing.T) {\n\t// Test RethinkDB specific method\n\terr := adp.TopicUpdateSubCnt(testData.Topics[0].Id)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify the subscription count was updated correctly\n\tcursor, err := rdb.Table(\"topics\").Get(testData.Topics[0].Id).Field(\"SubCnt\").Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor.Close()\n\n\tvar subcnt int\n\tif err = cursor.One(&subcnt); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Should match the number of active subscriptions\n\tif subcnt < 0 {\n\t\tt.Error(\"Subscription count should be non-negative\")\n\t}\n}\n\nfunc TestTopicOwnerChange(t *testing.T) {\n\terr := adp.TopicOwnerChange(testData.Topics[0].Id, types.ParseUserId(\"usr\"+testData.Users[1].Id))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcursor, err := rdb.Table(\"topics\").Get(testData.Topics[0].Id).Field(\"Owner\").Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor.Close()\n\n\tvar got string\n\tif err = cursor.One(&got); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif got != testData.Users[1].Id {\n\t\tt.Error(mismatchErrorString(\"Owner\", got, testData.Users[1].Id))\n\t}\n}\n\nfunc TestSubsUpdate(t *testing.T) {\n\tupdate := map[string]any{\n\t\t\"UpdatedAt\": testData.Now.Add(22 * time.Minute),\n\t}\n\terr := adp.SubsUpdate(testData.Topics[0].Id, types.ParseUserId(\"usr\"+testData.Users[0].Id), update)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcursor, err := rdb.Table(\"subscriptions\").Get(testData.Topics[0].Id + \":\" + testData.Users[0].Id).\n\t\tField(\"UpdatedAt\").Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor.Close()\n\n\tvar got time.Time\n\tif err = cursor.One(&got); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !got.Equal(update[\"UpdatedAt\"].(time.Time)) {\n\t\tt.Error(mismatchErrorString(\"UpdatedAt\", got, update[\"UpdatedAt\"]))\n\t}\n\n\terr = adp.SubsUpdate(testData.Topics[1].Id, types.ZeroUid, update)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcursor2, err := rdb.Table(\"subscriptions\").GetAllByIndex(\"Topic\", testData.Topics[1].Id).\n\t\tField(\"UpdatedAt\").Limit(1).Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor2.Close()\n\n\tif err = cursor2.One(&got); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !got.Equal(update[\"UpdatedAt\"].(time.Time)) {\n\t\tt.Error(mismatchErrorString(\"UpdatedAt\", got, update[\"UpdatedAt\"]))\n\t}\n}\n\nfunc TestSubsDelete(t *testing.T) {\n\terr := adp.SubsDelete(testData.Topics[1].Id, types.ParseUserId(\"usr\"+testData.Users[0].Id))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcursor, err := rdb.Table(\"subscriptions\").Get(testData.Topics[1].Id + \":\" + testData.Users[0].Id).\n\t\tField(\"DeletedAt\").Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor.Close()\n\n\tvar deletedat time.Time\n\tif err = cursor.One(&deletedat); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif deletedat.IsZero() {\n\t\tt.Error(\"DeletedAt should not be null\")\n\t}\n}\n\nfunc TestDeviceUpsert(t *testing.T) {\n\terr := adp.DeviceUpsert(types.ParseUserId(\"usr\"+testData.Users[0].Id), testData.Devs[0])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcursor, err := rdb.Table(\"users\").Get(testData.Users[0].Id).Field(\"Devices\").Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor.Close()\n\n\tvar got map[string]*types.DeviceDef\n\tif err = cursor.One(&got); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Find the device (the key is hashed)\n\tvar foundDev *types.DeviceDef\n\tfor _, dev := range got {\n\t\tif dev != nil && dev.DeviceId == testData.Devs[0].DeviceId {\n\t\t\tfoundDev = dev\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDev == nil {\n\t\tt.Error(\"Device not found after upsert\")\n\t} else {\n\t\tfoundDev.LastSeen = testData.Devs[0].LastSeen // Ignore LastSeen in comparison (workaranod for timezone issues)\n\t\tif !reflect.DeepEqual(*foundDev, *testData.Devs[0]) {\n\t\t\tt.Error(mismatchErrorString(\"Device\", foundDev, testData.Devs[0]))\n\t\t}\n\t}\n\n\t// Test update\n\ttestData.Devs[0].Platform = \"Web\"\n\terr = adp.DeviceUpsert(types.ParseUserId(\"usr\"+testData.Users[0].Id), testData.Devs[0])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcursor2, err := rdb.Table(\"users\").Get(testData.Users[0].Id).Field(\"Devices\").Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor2.Close()\n\n\tif err = cursor2.One(&got); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Find the updated device\n\tfoundDev = nil\n\tfor _, dev := range got {\n\t\tif dev != nil && dev.DeviceId == testData.Devs[0].DeviceId {\n\t\t\tfoundDev = dev\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDev == nil || foundDev.Platform != \"Web\" {\n\t\tt.Error(\"Device not updated.\", foundDev)\n\t}\n\n\t// Test add same device to another user\n\terr = adp.DeviceUpsert(types.ParseUserId(\"usr\"+testData.Users[1].Id), testData.Devs[0])\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terr = adp.DeviceUpsert(types.ParseUserId(\"usr\"+testData.Users[2].Id), testData.Devs[1])\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n}\n\nfunc TestMessageAttachments(t *testing.T) {\n\tfids := []string{testData.Files[0].Id, testData.Files[1].Id}\n\terr := adp.FileLinkAttachments(\"\", types.ZeroUid, types.ParseUid(testData.Msgs[1].Id), fids)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Check if attachments were linked to message\n\tcursor, err := rdb.Table(\"messages\").Get(testData.Msgs[1].Id).Field(\"Attachments\").Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor.Close()\n\n\tvar attachments []string\n\tif err = cursor.All(&attachments); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !reflect.DeepEqual(attachments, fids) {\n\t\tt.Error(mismatchErrorString(\"Attachments\", attachments, fids))\n\t}\n\n\t// Check if use count was incremented in fileuploads\n\tcursor2, err := rdb.Table(\"fileuploads\").Get(testData.Files[0].Id).Field(\"UseCount\").Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor2.Close()\n\n\tvar usecount int\n\tif err = cursor2.One(&usecount); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif usecount != 1 {\n\t\tt.Error(mismatchErrorString(\"UseCount\", usecount, 1))\n\t}\n}\n\nfunc TestFileFinishUpload(t *testing.T) {\n\tgot, err := adp.FileFinishUpload(testData.Files[0], true, 22222)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got.Status != types.UploadCompleted {\n\t\tt.Error(mismatchErrorString(\"Status\", got.Status, types.UploadCompleted))\n\t}\n\tif got.Size != 22222 {\n\t\tt.Error(mismatchErrorString(\"Size\", got.Size, 22222))\n\t}\n}\n\n// ================== Other tests =================================\nfunc TestDeviceGetAll(t *testing.T) {\n\tuid0 := types.ParseUserId(\"usr\" + testData.Users[0].Id)\n\tuid1 := types.ParseUserId(\"usr\" + testData.Users[1].Id)\n\tuid2 := types.ParseUserId(\"usr\" + testData.Users[2].Id)\n\tgotDevs, count, err := adp.DeviceGetAll(uid0, uid1, uid2)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif count < 1 {\n\t\tt.Fatal(mismatchErrorString(\"count\", count, \">=1\"))\n\t}\n\t// Test that devices exist for the users\n\tif len(gotDevs) == 0 {\n\t\tt.Error(\"Expected devices for users\")\n\t}\n}\n\nfunc TestDeviceDelete(t *testing.T) {\n\terr := adp.DeviceDelete(types.ParseUserId(\"usr\"+testData.Users[1].Id), testData.Devs[0].DeviceId)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcursor, err := rdb.Table(\"users\").Get(testData.Users[1].Id).Field(\"Devices\").Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor.Close()\n\n\tvar devices map[string]*types.DeviceDef\n\tif err = cursor.One(&devices); err != nil && err != rdb.ErrEmptyResult {\n\t\tt.Fatal(err)\n\t}\n\n\t// Check that the specific device is deleted\n\tfor _, dev := range devices {\n\t\tif dev != nil && dev.DeviceId == testData.Devs[0].DeviceId {\n\t\t\tt.Error(\"Device not deleted:\", dev)\n\t\t}\n\t}\n\n\terr = adp.DeviceDelete(types.ParseUserId(\"usr\"+testData.Users[2].Id), \"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcursor2, err := rdb.Table(\"users\").Get(testData.Users[2].Id).Field(\"Devices\").Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor2.Close()\n\n\tvar allDevices map[string]*types.DeviceDef\n\tif err = cursor2.One(&allDevices); err != nil && err != rdb.ErrEmptyResult {\n\t\tt.Fatal(err)\n\t}\n\tif allDevices != nil && len(allDevices) > 0 {\n\t\tt.Error(\"All devices not deleted:\", allDevices)\n\t}\n}\n\n// ================== Persistent Cache tests ======================\nfunc TestPCacheUpsert(t *testing.T) {\n\terr := adp.PCacheUpsert(\"test_key\", \"test_value\", false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test duplicate with failOnDuplicate = true\n\terr = adp.PCacheUpsert(\"test_key2\", \"test_value2\", true)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terr = adp.PCacheUpsert(\"test_key2\", \"new_value\", true)\n\tif err != types.ErrDuplicate {\n\t\tt.Error(\"Expected duplicate error\")\n\t}\n}\n\nfunc TestPCacheGet(t *testing.T) {\n\tvalue, err := adp.PCacheGet(\"test_key\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif value != \"test_value\" {\n\t\tt.Error(mismatchErrorString(\"Cache value\", value, \"test_value\"))\n\t}\n\n\t// Test not found\n\tvalue, err = adp.PCacheGet(\"nonexistent\")\n\tif err != types.ErrNotFound {\n\t\tt.Errorf(\"Expected not found error but got '%s', %s\", value, err)\n\t}\n}\n\nfunc TestPCacheDelete(t *testing.T) {\n\terr := adp.PCacheDelete(\"test_key\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify deleted\n\t_, err = adp.PCacheGet(\"test_key\")\n\tif err != types.ErrNotFound {\n\t\tt.Error(\"Key should be deleted\")\n\t}\n}\n\nfunc TestPCacheExpire(t *testing.T) {\n\t// Insert some test keys with prefix and CreatedAt\n\tadp.PCacheUpsert(\"prefix_key1\", \"value1\", true)\n\tadp.PCacheUpsert(\"prefix_key2\", \"value2\", true)\n\n\t// Expire keys older than now (should delete all test keys)\n\terr := adp.PCacheExpire(\"prefix_\", time.Now().Add(1*time.Minute))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\n// ================== Delete tests ================================\nfunc TestCredDel(t *testing.T) {\n\terr := adp.CredDel(types.ParseUserId(\"usr\"+testData.Users[0].Id), \"email\", \"alice@test.example.com\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcursor, err := rdb.Table(\"credentials\").Filter(map[string]any{\"Method\": \"email\", \"Value\": \"alice@test.example.com\"}).Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor.Close()\n\n\tvar results []any\n\tif err = cursor.All(&results); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(results) != 0 {\n\t\tt.Error(\"Got result but shouldn't\", results)\n\t}\n\n\terr = adp.CredDel(types.ParseUserId(\"usr\"+testData.Users[1].Id), \"\", \"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcursor2, err := rdb.Table(\"credentials\").GetAllByIndex(\"User\", testData.Users[1].Id).Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor2.Close()\n\n\tif err = cursor2.All(&results); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(results) != 0 {\n\t\tt.Error(\"Got result but shouldn't\", results)\n\t}\n}\n\nfunc TestAuthDelScheme(t *testing.T) {\n\t// Test deleting auth scheme\n\terr := adp.AuthDelScheme(types.ParseUserId(\"usr\"+testData.Recs[1].UserId), testData.Recs[1].Scheme)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify deleted\n\t_, _, _, _, err = adp.AuthGetRecord(types.ParseUserId(\"usr\"+testData.Recs[1].UserId), testData.Recs[1].Scheme)\n\tif err != types.ErrNotFound {\n\t\tt.Error(\"Auth record should be deleted\")\n\t}\n}\n\nfunc TestAuthDelAllRecords(t *testing.T) {\n\tdelCount, err := adp.AuthDelAllRecords(types.ParseUserId(\"usr\" + testData.Recs[0].UserId))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif delCount != 1 {\n\t\tt.Error(mismatchErrorString(\"delCount\", delCount, 1))\n\t}\n\n\t// With dummy user\n\tdelCount, _ = adp.AuthDelAllRecords(dummyUid1)\n\tif delCount != 0 {\n\t\tt.Error(mismatchErrorString(\"delCount\", delCount, 0))\n\t}\n}\n\nfunc TestMessageDeleteList(t *testing.T) {\n\ttoDel := types.DelMessage{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tId:        testData.UGen.GetStr(),\n\t\t\tCreatedAt: testData.Now,\n\t\t\tUpdatedAt: testData.Now,\n\t\t},\n\t\tTopic:       testData.Topics[1].Id,\n\t\tDeletedFor:  testData.Users[2].Id,\n\t\tDelId:       1,\n\t\tSeqIdRanges: []types.Range{{Low: 9}, {Low: 3, Hi: 7}},\n\t}\n\terr := adp.MessageDeleteList(toDel.Topic, &toDel)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Check messages in topic - some should be soft deleted\n\tcursor, err := rdb.Table(\"messages\").Filter(map[string]any{\"Topic\": toDel.Topic}).Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor.Close()\n\n\tvar messages []types.Message\n\tif err = cursor.All(&messages); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify soft deletion worked\n\tfoundSoftDeleted := false\n\tfor _, msg := range messages {\n\t\tif msg.SeqId >= 3 && msg.SeqId <= 7 || msg.SeqId == 9 {\n\t\t\tif msg.DeletedFor != nil {\n\t\t\t\tfor _, del := range msg.DeletedFor {\n\t\t\t\t\tif del.User == toDel.DeletedFor {\n\t\t\t\t\t\tfoundSoftDeleted = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif !foundSoftDeleted {\n\t\tt.Error(\"Expected to find soft-deleted messages\")\n\t}\n\n\t// Hard delete test\n\ttoDel = types.DelMessage{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tId:        testData.UGen.GetStr(),\n\t\t\tCreatedAt: testData.Now,\n\t\t\tUpdatedAt: testData.Now,\n\t\t},\n\t\tTopic:       testData.Topics[0].Id,\n\t\tDelId:       3,\n\t\tSeqIdRanges: []types.Range{{Low: 1, Hi: 3}},\n\t}\n\terr = adp.MessageDeleteList(toDel.Topic, &toDel)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Check if messages content was cleared (hard delete)\n\tcursor2, err := rdb.Table(\"messages\").Filter(map[string]any{\"Topic\": toDel.Topic}).Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor2.Close()\n\n\tif err = cursor2.All(&messages); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, msg := range messages {\n\t\tif msg.SeqId >= 1 && msg.SeqId <= 3 {\n\t\t\tif msg.Content != nil && msg.DelId == 0 {\n\t\t\t\tt.Error(\"Message not properly hard deleted:\", msg.SeqId)\n\t\t\t}\n\t\t}\n\t}\n\n\terr = adp.MessageDeleteList(testData.Topics[0].Id, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcursor3, err := rdb.Table(\"messages\").Filter(map[string]any{\"Topic\": testData.Topics[0].Id}).Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor3.Close()\n\n\tif err = cursor3.All(&messages); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(messages) != 0 {\n\t\tt.Error(\"Result should be empty:\", messages)\n\t}\n}\n\nfunc TestTopicDelete(t *testing.T) {\n\terr := adp.TopicDelete(testData.Topics[1].Id, false, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcursor, err := rdb.Table(\"topics\").Get(testData.Topics[1].Id).Field(\"State\").Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor.Close()\n\n\tvar state types.ObjState\n\tif err = cursor.One(&state); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif state != types.StateDeleted {\n\t\tt.Error(\"Soft delete failed:\", state)\n\t}\n\n\terr = adp.TopicDelete(testData.Topics[0].Id, false, true)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcursor2, err := rdb.Table(\"topics\").Get(testData.Topics[0].Id).Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor2.Close()\n\n\tif !cursor2.IsNil() {\n\t\tt.Error(\"Hard delete failed - topic still exists\")\n\t}\n}\n\nfunc TestFileDeleteUnused(t *testing.T) {\n\t// time.Now() is correct (as opposite to testData.Now):\n\t// the FileFinishUpload uses time.Now() as a timestamp.\n\tlocs, err := adp.FileDeleteUnused(time.Now().Add(1*time.Minute), 999)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(locs) < 1 {\n\t\tt.Log(\"No unused files to delete - this is expected in test environment\")\n\t}\n}\n\nfunc TestUserDelete(t *testing.T) {\n\terr := adp.UserDelete(types.ParseUserId(\"usr\"+testData.Users[0].Id), false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcursor, err := rdb.Table(\"users\").Get(testData.Users[0].Id).Field(\"State\").Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor.Close()\n\n\tvar state types.ObjState\n\tif err = cursor.One(&state); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif state != types.StateDeleted {\n\t\tt.Error(\"User soft delete failed\", state)\n\t}\n\n\terr = adp.UserDelete(types.ParseUserId(\"usr\"+testData.Users[1].Id), true)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcursor2, err := rdb.Table(\n\t\t\"users\").Get(testData.Users[1].Id).Run(conn)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer cursor2.Close()\n\n\tif !cursor2.IsNil() {\n\t\tt.Error(\"User hard delete failed - user still exists\")\n\t}\n}\n\nfunc TestMessageGetDeleted(t *testing.T) {\n\tqOpts := types.QueryOpt{\n\t\tSince:  1,\n\t\tBefore: 10,\n\t\tLimit:  999,\n\t}\n\tgot, err := adp.MessageGetDeleted(testData.Topics[1].Id, types.ParseUserId(\"usr\"+testData.Users[2].Id), &qOpts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(got) != 1 {\n\t\tt.Error(mismatchErrorString(\"result length\", len(got), 1))\n\t}\n}\n\nfunc TestUserUnreadCount(t *testing.T) {\n\tuids := []types.Uid{\n\t\ttypes.ParseUserId(\"usr\" + testData.Users[1].Id),\n\t\ttypes.ParseUserId(\"usr\" + testData.Users[2].Id),\n\t}\n\texpected := map[types.Uid]int{uids[0]: 0, uids[1]: 166}\n\tcounts, err := adp.UserUnreadCount(uids...)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(counts) != 2 {\n\t\tt.Error(mismatchErrorString(\"UnreadCount length\", len(counts), 2))\n\t}\n\n\tfor uid, unread := range counts {\n\t\tif expected[uid] != unread {\n\t\t\tt.Error(mismatchErrorString(\"UnreadCount\", unread, expected[uid]))\n\t\t}\n\t}\n\n\t// Test not found (even if the account is not found, the call must return one record).\n\tcounts, err = adp.UserUnreadCount(dummyUid1)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(counts) != 1 {\n\t\tt.Error(mismatchErrorString(\"UnreadCount length (dummy)\", len(counts), 1))\n\t}\n\tif counts[dummyUid1] != 0 {\n\t\tt.Error(mismatchErrorString(\"Non-zero UnreadCount (dummy)\", counts[dummyUid1], 0))\n\t}\n}\n\n// ================================================================\nfunc mismatchErrorString(key string, got, want any) string {\n\treturn fmt.Sprintf(\"%s mismatch:\\nGot  = %+v\\nWant = %+v\", key, got, want)\n}\n\nfunc init() {\n\tlogs.Init(os.Stderr, \"stdFlags\")\n\tadp = backend.GetTestAdapter()\n\tconffile := flag.String(\"config\", \"./test.conf\", \"config of the database connection\")\n\n\tif file, err := os.Open(*conffile); err != nil {\n\t\tlog.Fatal(\"Failed to read config file:\", err)\n\t} else if err = json.NewDecoder(jcr.New(file)).Decode(&config); err != nil {\n\t\tlog.Fatal(\"Failed to parse config file:\", err)\n\t}\n\n\tif adp == nil {\n\t\tlog.Fatal(\"Database adapter is missing\")\n\t}\n\tif adp.IsOpen() {\n\t\tlog.Print(\"Connection is already opened\")\n\t}\n\n\terr := adp.Open(config.Adapters[adp.GetName()])\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\tconn = adp.GetTestDB().(*rdb.Session)\n\ttestData = test_data.InitTestData()\n\tif testData == nil {\n\t\tlog.Fatal(\"Failed to initialize test data\")\n\t}\n\tstore.SetTestUidGenerator(*testData.UGen)\n}\n"
  },
  {
    "path": "server/db/rethinkdb/tests/test.conf",
    "content": "{\n  \"reset_db_data\": true,\n  \"adapters\": {\n    \"rethinkdb\": {\n\t\t\t\t// Address(es) of RethinkDB node(s): either a string or an array of strings.\n\t\t\t\t\"addresses\": \"localhost:28015\",\n\t\t\t\t// Name of the main database.\n\t\t\t\t\"database\": \"tinode_test\"\n    }\n  }\n}\n"
  },
  {
    "path": "server/drafty/drafty.go",
    "content": "// Package drafty contains utilities for conversion from Drafty to plain text.\npackage drafty\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"sort\"\n\t\"strings\"\n)\n\nconst (\n\t// Maximum size of style payload in preview, in bytes.\n\tmaxDataSize = 128\n\t// Maximum count of payload fields in preview.\n\tmaxDataCount = 8\n)\n\nvar (\n\terrUnrecognizedContent = errors.New(\"content unrecognized\")\n\terrInvalidContent      = errors.New(\"invalid format\")\n)\n\ntype style struct {\n\tTp     string `json:\"tp,omitempty\"`\n\tAt     int    `json:\"at,omitempty\"`\n\tLength int    `json:\"len,omitempty\"`\n\tKey    int    `json:\"key,omitempty\"`\n}\n\ntype entity struct {\n\tTp   string         `json:\"tp,omitempty\"`\n\tData map[string]any `json:\"data,omitempty\"`\n}\n\ntype document struct {\n\tTxt string   `json:\"txt,omitempty\"`\n\tFmt []style  `json:\"fmt,omitempty\"`\n\tEnt []entity `json:\"ent,omitempty\"`\n\n\t// Parsed out grapheme clusters.\n\tgc *graphemes\n}\n\ntype span struct {\n\ttp   string\n\tat   int\n\tend  int\n\tkey  int\n\tdata map[string]any\n}\n\ntype node struct {\n\tgc       *graphemes\n\tsp       *span\n\tchildren []*node\n}\n\ntype previewState struct {\n\tdrafty    *document\n\tmaxLength int\n\tkeymap    map[int]int\n}\n\n// Preview shortens Drafty to the specified length (in graphemes), removes quoted text, leading line breaks,\n// and large content from entities making them suitable for a one-line preview,\n// for example for showing in push notifications.\n// The return value is a Drafty document encoded as JSON string.\nfunc Preview(content any, length int) (string, error) {\n\tdoc, err := decodeAsDrafty(content)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif doc == nil {\n\t\treturn \"\", nil\n\t}\n\n\ttree, err := toTree(doc)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif tree == nil {\n\t\treturn \"\", nil\n\t}\n\n\tstate := previewState{\n\t\tdrafty: &document{\n\t\t\tFmt: make([]style, 0, len(doc.Fmt)),\n\t\t\tEnt: make([]entity, 0, len(doc.Ent)),\n\t\t},\n\t\tmaxLength: length,\n\t\tkeymap:    make(map[int]int),\n\t}\n\n\tif err = previewFormatter(tree, &state); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tstate.drafty.Txt = state.drafty.gc.string()\n\tdata, err := json.Marshal(state.drafty)\n\treturn string(data), err\n}\n\ntype plainTextState struct {\n\ttxt string\n}\n\n// PlainText converts drafty document to plain text with some basic markdown-like formatting.\n// Deprecated: use Preview for new development.\nfunc PlainText(content any) (string, error) {\n\tdoc, err := decodeAsDrafty(content)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif doc == nil {\n\t\treturn \"\", nil\n\t}\n\n\ttree, err := toTree(doc)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tstate := plainTextState{}\n\n\terr = plainTextFormatter(tree, &state)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn strings.TrimSpace(string(state.txt)), nil\n}\n\n// styleToSpan converts Drafty style to internal representation.\nfunc (s *span) styleToSpan(in *style) error {\n\ts.tp = in.Tp\n\ts.at = in.At\n\ts.end = in.Length\n\tif s.end < 0 {\n\t\treturn errInvalidContent\n\t}\n\ts.end += s.at\n\n\tif s.tp == \"\" {\n\t\ts.key = in.Key\n\t\tif s.key < 0 {\n\t\t\treturn errInvalidContent\n\t\t}\n\t}\n\n\treturn nil\n}\n\ntype spanfmt struct {\n\tdec    string\n\tisVoid bool\n}\n\n// Plain text formatting of the Drafty tags. Only non-blank tags need to be listed.\nvar tags = map[string]spanfmt{\n\t\"BR\": {\"\\n\", true},\n\t\"CO\": {\"`\", false},\n\t\"DL\": {\"~\", false},\n\t\"EM\": {\"_\", false},\n\t\"EX\": {\"\", true},\n\t\"ST\": {\"*\", false},\n}\n\n// Type of the formatter to apply to tree nodes.\ntype formatter func(n *node, state any) error\n\n// toTree converts a drafty document into a tree of formatted spans.\n// Each node of the tree is uniformly formatted.\nfunc toTree(drafty *document) (*node, error) {\n\tif len(drafty.Fmt) == 0 {\n\t\treturn &node{gc: drafty.gc}, nil\n\t}\n\n\ttextLen := drafty.gc.length()\n\n\tvar spans []*span\n\tfor i := range drafty.Fmt {\n\t\ts := span{}\n\t\tif err := s.styleToSpan(&drafty.Fmt[i]); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif s.at < -1 || s.end > textLen {\n\t\t\treturn nil, errInvalidContent\n\t\t}\n\n\t\t// Denormalize entities into spans.\n\t\tif s.tp == \"\" && len(drafty.Ent) > 0 {\n\t\t\tif s.key < 0 || s.key >= len(drafty.Ent) {\n\t\t\t\treturn nil, errInvalidContent\n\t\t\t}\n\n\t\t\ts.data = drafty.Ent[s.key].Data\n\t\t\ts.tp = drafty.Ent[s.key].Tp\n\t\t}\n\t\tif s.tp == \"\" && s.at == 0 && s.end == 0 && s.key == 0 {\n\t\t\treturn nil, errUnrecognizedContent\n\t\t}\n\t\tspans = append(spans, &s)\n\t}\n\n\t// Sort spans first by start index (asc) then by length (desc).\n\tsort.Slice(spans, func(i, j int) bool {\n\t\tif spans[i].at == spans[j].at {\n\t\t\t// longer one comes first\n\t\t\treturn spans[i].end > spans[j].end\n\t\t}\n\t\treturn spans[i].at < spans[j].at\n\t})\n\n\t// Drop the second format when spans overlap like '_first *second_ third*'.\n\tvar filtered []*span\n\tend := -2\n\tfor _, span := range spans {\n\t\tif span.at < end && span.end > end {\n\t\t\tcontinue\n\t\t}\n\t\tfiltered = append(filtered, span)\n\t\tif span.end > end {\n\t\t\tend = span.end\n\t\t}\n\t}\n\n\t// Iterate over an array of spans.\n\tchildren, err := forEach(drafty.gc, 0, textLen, filtered)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &node{children: children}, nil\n}\n\n// forEach recursively iterates nested spans to form a tree.\nfunc forEach(g *graphemes, start, end int, spans []*span) ([]*node, error) {\n\tvar result []*node\n\n\t// Process ranges calling iterator for each range.\n\tfor i := 0; i < len(spans); i++ {\n\t\tsp := spans[i]\n\n\t\tif sp.at < 0 {\n\t\t\t// Attachment\n\t\t\tresult = append(result, &node{sp: sp})\n\t\t\tcontinue\n\t\t}\n\t\t// Add un-styled range before the styled span starts.\n\t\tif start < sp.at {\n\t\t\tresult = append(result, &node{gc: g.slice(start, sp.at)})\n\t\t\tstart = sp.at\n\t\t}\n\n\t\t// Get all spans which are within current span.\n\t\tvar subspans []*span\n\t\tfor si := i + 1; si < len(spans) && spans[si].at < sp.end; si++ {\n\t\t\tsubspans = append(subspans, spans[si])\n\t\t\ti = si\n\t\t}\n\n\t\tif tags[sp.tp].isVoid {\n\t\t\tresult = append(result, &node{sp: sp})\n\t\t} else {\n\t\t\tchildren, err := forEach(g, start, sp.end, subspans)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tresult = append(result, &node{children: children, sp: sp})\n\t\t}\n\t\tstart = sp.end\n\t}\n\n\t// Add the remaining unformatted range.\n\tif start < end {\n\t\tresult = append(result, &node{gc: g.slice(start, end)})\n\t}\n\n\treturn result, nil\n}\n\n// plainTextFormatter converts a tree of formatted spans into plan text.\nfunc plainTextFormatter(n *node, ctx any) error {\n\tif n.sp != nil && n.sp.tp == \"QQ\" {\n\t\treturn nil\n\t}\n\n\tvar text string\n\tif len(n.children) > 0 {\n\t\tstate := &plainTextState{}\n\t\tfor _, c := range n.children {\n\t\t\tif err := plainTextFormatter(c, state); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\ttext = string(state.txt)\n\t} else {\n\t\ttext = n.gc.string()\n\t}\n\n\tstate := ctx.(*plainTextState)\n\n\tif n.sp == nil {\n\t\tstate.txt += text\n\t\treturn nil\n\t}\n\n\tswitch n.sp.tp {\n\tcase \"ST\", \"EM\", \"DL\", \"CO\":\n\t\tstate.txt += tags[n.sp.tp].dec + text + tags[n.sp.tp].dec\n\n\tcase \"LN\":\n\t\tif url, ok := nullableMapGet(n.sp.data, \"url\"); ok && url != text {\n\t\t\tstate.txt += \"[\" + text + \"](\" + url + \")\"\n\t\t} else {\n\t\t\tstate.txt += text\n\t\t}\n\n\tcase \"MN\", \"HT\":\n\t\tstate.txt += text\n\tcase \"BR\":\n\t\tstate.txt += \"\\n\"\n\tcase \"AU\", \"EX\", \"IM\", \"VD\":\n\t\tname, ok := nullableMapGet(n.sp.data, \"name\")\n\t\tif !ok || name == \"\" {\n\t\t\tname = \"?\"\n\t\t}\n\t\texpand := map[string]string{\"AU\": \"AUDIO\", \"EX\": \"FILE\", \"IM\": \"IMAGE\", \"VD\": \"VIDEO\"}\n\t\tstate.txt += \"[\" + expand[n.sp.tp] + \" '\" + name + \"']\"\n\tcase \"VC\":\n\t\tstate.txt += \"[CALL]\"\n\tdefault:\n\t\tstate.txt += text\n\t}\n\treturn nil\n}\n\n// previewFormatter converts a tree of formatted spans into a shortened drafty document.\nfunc previewFormatter(n *node, ctx any) error {\n\n\tstate := ctx.(*previewState)\n\tat := state.drafty.gc.length()\n\tif at >= state.maxLength {\n\t\t// Maximum doc length reached.\n\t\treturn nil\n\t}\n\n\tif n.sp != nil {\n\t\tif n.sp.tp == \"QQ\" {\n\t\t\t// Skip quoted text\n\t\t\treturn nil\n\t\t}\n\t\tif n.sp.tp == \"BR\" && at == 0 {\n\t\t\t// Skip leading new lines.\n\t\t\treturn nil\n\t\t}\n\t}\n\n\tif len(n.children) > 0 {\n\t\tfor _, c := range n.children {\n\t\t\tif err := previewFormatter(c, ctx); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t} else {\n\t\tincrement := n.gc.length()\n\t\tif increment > 0 {\n\t\t\tif at+increment > state.maxLength {\n\t\t\t\tincrement = state.maxLength - at\n\t\t\t}\n\t\t\tif state.drafty.gc == nil {\n\t\t\t\tstate.drafty.gc = prepareGraphemes(\"\")\n\t\t\t}\n\t\t\tstate.drafty.gc = state.drafty.gc.append(n.gc.slice(0, increment))\n\t\t}\n\t}\n\n\tend := state.drafty.gc.length()\n\n\tif n.sp != nil {\n\t\tfmt := style{}\n\t\tif n.sp.at < 0 {\n\t\t\tfmt.At = -1\n\t\t} else if at < end || tags[n.sp.tp].isVoid {\n\t\t\tfmt.At = at\n\t\t\tfmt.Length = end - at\n\t\t} else {\n\t\t\treturn nil\n\t\t}\n\n\t\tif n.sp.data != nil {\n\t\t\t// Check if we have already seen this payload.\n\t\t\tkey, ok := state.keymap[n.sp.key]\n\t\t\tif !ok {\n\t\t\t\t// Payload not found, add it.\n\t\t\t\tent := entity{Tp: n.sp.tp, Data: copyLight(n.sp.data)}\n\t\t\t\tkey = len(state.drafty.Ent)\n\t\t\t\tstate.keymap[n.sp.key] = key\n\t\t\t\tstate.drafty.Ent = append(state.drafty.Ent, ent)\n\t\t\t}\n\t\t\tfmt.Key = key\n\t\t} else {\n\t\t\tfmt.Tp = n.sp.tp\n\t\t}\n\n\t\tstate.drafty.Fmt = append(state.drafty.Fmt, fmt)\n\t}\n\treturn nil\n}\n\n// nullableMapGet is a helper method to get a possibly missing string from a possibly nil map.\nfunc nullableMapGet(data map[string]any, key string) (string, bool) {\n\tif data == nil {\n\t\treturn \"\", false\n\t}\n\tstr, ok := data[key].(string)\n\treturn str, ok\n}\n\n// decodeAsDrafty converts a string or a map to a Drafty document.\nfunc decodeAsDrafty(content any) (*document, error) {\n\tif content == nil {\n\t\treturn nil, nil\n\t}\n\n\tvar drafty *document\n\n\tswitch tmp := content.(type) {\n\tcase string:\n\t\tdrafty = &document{gc: prepareGraphemes(tmp)}\n\tcase map[string]any:\n\t\tdrafty = &document{}\n\t\tcorrect := 0\n\t\tif txt, ok := tmp[\"txt\"].(string); ok {\n\t\t\tdrafty.Txt = txt\n\t\t\tdrafty.gc = prepareGraphemes(txt)\n\t\t\tcorrect++\n\t\t}\n\t\tif ifmt, ok := tmp[\"fmt\"].([]any); ok {\n\t\t\tfor i := range ifmt {\n\t\t\t\tst, err := decodeAsStyle(ifmt[i])\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t\tif st != nil {\n\t\t\t\t\tdrafty.Fmt = append(drafty.Fmt, *st)\n\t\t\t\t}\n\t\t\t\tcorrect++\n\t\t\t}\n\t\t}\n\t\tif ient, ok := tmp[\"ent\"].([]any); ok {\n\t\t\tfor i := range ient {\n\t\t\t\tent, err := decodeAsEntity(ient[i])\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t\tif ent != nil {\n\t\t\t\t\tdrafty.Ent = append(drafty.Ent, *ent)\n\t\t\t\t}\n\t\t\t\tcorrect++\n\t\t\t}\n\t\t}\n\t\t// At least one drafty element must be present.\n\t\tif correct == 0 {\n\t\t\treturn nil, errUnrecognizedContent\n\t\t}\n\tdefault:\n\t\treturn nil, errUnrecognizedContent\n\t}\n\n\treturn drafty, nil\n}\n\n// decodeAsStyle converts a map to a style.\nfunc decodeAsStyle(content any) (*style, error) {\n\tif content == nil {\n\t\treturn nil, nil\n\t}\n\n\ttmp, ok := content.(map[string]any)\n\tif !ok {\n\t\treturn nil, errUnrecognizedContent\n\t}\n\n\tvar err error\n\tst := &style{}\n\tst.Tp, _ = tmp[\"tp\"].(string)\n\n\tst.At, err = intFromNumeric(tmp[\"at\"])\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tst.Length, err = intFromNumeric(tmp[\"len\"])\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif st.Tp == \"\" {\n\t\tst.Key, err = intFromNumeric(tmp[\"key\"])\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif st.Key < 0 {\n\t\t\treturn nil, errInvalidContent\n\t\t}\n\t}\n\n\treturn st, nil\n}\n\n// decodeAsEntity converts a map to a entity.\nfunc decodeAsEntity(content any) (*entity, error) {\n\tif content == nil {\n\t\treturn nil, nil\n\t}\n\n\ttmp, ok := content.(map[string]any)\n\tif !ok {\n\t\treturn nil, errUnrecognizedContent\n\t}\n\n\tent := &entity{}\n\n\tent.Tp, _ = tmp[\"tp\"].(string)\n\tif ent.Tp == \"\" {\n\t\treturn nil, errInvalidContent\n\t}\n\n\tent.Data, _ = tmp[\"data\"].(map[string]any)\n\n\treturn ent, nil\n}\n\n// A whitelist of entity fields to copy.\nvar lightFields = []string{\"mime\", \"name\", \"width\", \"height\", \"size\", \"url\", \"ref\"}\n\n// copyLight makes a copy of an entity retaining keys from the white list.\n// It also ensures the copied values are either basic types of fixed length or a\n// sufficiently short string/byte slice, and the count of entries is not too great.\nfunc copyLight(in any) map[string]any {\n\tdata, ok := in.(map[string]any)\n\tif !ok {\n\t\treturn nil\n\t}\n\n\tresult := map[string]any{}\n\tif len(data) > 0 {\n\t\tfor _, key := range lightFields {\n\t\t\tif val, ok := data[key]; ok {\n\t\t\t\tif isFixedLengthType(val) {\n\t\t\t\t\tresult[key] = val\n\t\t\t\t} else if l := getVariableTypeSize(val); l >= 0 && l < maxDataSize {\n\t\t\t\t\tresult[key] = val\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif len(result) > maxDataCount {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif len(result) == 0 {\n\t\t\tresult = nil\n\t\t}\n\t}\n\treturn result\n}\n\n// intFromNumeric is a helper methjod to get an integer from a value of any numeric type.\nfunc intFromNumeric(num any) (int, error) {\n\tif num == nil {\n\t\treturn 0, nil\n\t}\n\tswitch i := num.(type) {\n\tcase int:\n\t\treturn i, nil\n\tcase int16:\n\t\treturn int(i), nil\n\tcase int32:\n\t\treturn int(i), nil\n\tcase int64:\n\t\treturn int(i), nil\n\tcase float32:\n\t\treturn int(i), nil\n\tcase float64:\n\t\treturn int(i), nil\n\tdefault:\n\t\treturn 0, errInvalidContent\n\t}\n}\n\n// getVariableTypeSize checks that the given field is a string or a byte slice and gets its size in bytes.\nfunc getVariableTypeSize(x any) int {\n\tswitch val := x.(type) {\n\tcase string:\n\t\treturn len(val)\n\tcase []byte:\n\t\treturn len(val)\n\tdefault:\n\t\treturn -1\n\t}\n}\n\n// isFixedLengthType checks if the given value is a type of a fixed size.\nfunc isFixedLengthType(x any) bool {\n\tswitch x.(type) {\n\tcase nil, bool, int, int8, int16, int32, int64, uint, uint8, uint16,\n\t\tuint32, uint64, float32, float64, complex64, complex128:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n"
  },
  {
    "path": "server/drafty/drafty_test.go",
    "content": "package drafty\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n)\n\nvar validInputs = []string{\n\t`\"This is a plain text string.\"`,\n\t`{\n\t\t\"txt\":\"This is a string with a line break.\",\n\t\t\"fmt\":[{\"at\":9,\"tp\":\"BR\"}]\n\t}`,\n\t`{\n\t\t\"ent\":[{\"data\":{\"mime\":\"image/jpeg\",\"name\":\"hello.jpg\",\"val\":\"<38992, bytes: ...>\",\"width\":100, \"height\":80},\"tp\":\"EX\"}],\n\t\t\"fmt\":[{\"at\":-1, \"key\":0}]\n\t}`,\n\t`{\n\t\t\"ent\":[{\"data\":{\"url\":\"https://www.youtube.com/watch?v=dQw4w9WgXcQ\"},\"tp\":\"LN\"}],\n\t\t\"fmt\":[{\"len\":22}],\n\t\t\"txt\":\"https://api.tinode.co/\"\n\t}`,\n\t`{\n\t\t\"ent\":[{\"data\":{\"url\":\"https://api.tinode.co/\"},\"tp\":\"LN\"}],\n\t\t\"fmt\":[{\"len\":22}],\n\t\t\"txt\":\"https://api.tinode.co/\"\n\t}`,\n\t`{\n\t\t\"ent\":[{\"data\":{\"url\":\"http://tinode.co\"},\"tp\":\"LN\"}],\n\t\t\"fmt\":[{\"at\":9,\"len\":3}, {\"at\":4,\"len\":3}],\n\t\t\"txt\":\"Url one, two\"\n\t}`,\n\t`{\n\t\t\"ent\":[{\"data\":{\"height\":213,\"mime\":\"image/jpeg\",\"name\":\"roses.jpg\",\"val\":\"<38992, bytes: ...>\",\"width\":638},\"tp\":\"IM\"}],\n\t\t\"fmt\":[{\"len\":1}],\n\t\t\"txt\":\" \"\n\t}`,\n\t`{\n\t\t\"txt\":\"This text has staggered formats\",\n\t\t\"fmt\":[{\"at\":5,\"len\":8,\"tp\":\"EM\"},{\"at\":10,\"len\":13,\"tp\":\"ST\"}]\n\t}`,\n\t`{\n\t\t\"txt\":\"This text is formatted and deleted too\",\n\t\t\"fmt\":[{\"at\":5,\"len\":4,\"tp\":\"ST\"},{\"at\":13,\"len\":9,\"tp\":\"EM\"},{\"at\":35,\"len\":3,\"tp\":\"ST\"},{\"at\":27,\"len\":11,\"tp\":\"DL\"}]\n\t}`,\n\t`{\n\t\t\"txt\":\"мультибайтовый юникод\",\n\t\t\"fmt\":[{\"len\":14,\"tp\":\"ST\"},{\"at\":15,\"len\":6,\"tp\":\"EM\"}]\n\t}`,\n\t`{\n\t\t\"txt\":\"Alice Johnson    This is a test\",\n\t\t\"fmt\":[{\"at\":13,\"len\":1,\"tp\":\"BR\"},{\"at\":15,\"len\":1},{\"len\":13,\"key\":1},{\"len\":16,\"tp\":\"QQ\"},{\"at\":16,\"len\":1,\"tp\":\"BR\"}],\n\t\t\"ent\":[{\"tp\":\"IM\",\"data\":{\"mime\":\"image/jpeg\",\"val\":\"<1292, bytes: /9j/4AAQSkZJ...rehH5o6D/9k=>\",\"width\":25,\"height\":14,\"size\":968}},{\"tp\":\"MN\",\"data\":{\"color\":2}}]\n\t}`,\n\t`{\n\t\t\"txt\": \"Hello 😀, o😀k https://google.com\",\n\t\t\"fmt\":[{\"at\":9,\"len\":3,\"tp\":\"ST\"},{\"at\":13,\"len\":18}],\n\t\t\"ent\":[{\"tp\":\"LN\",\"data\":{\"url\":\"https://google.com\"}}]\n\t}`,\n\t`{\n\t\t\"txt\": \"Hi 🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿 🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿 🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿\",\n\t\t\"fmt\":[{\"at\":3,\"len\":4,\"tp\":\"ST\"},{\"at\":8,\"len\":4,\"tp\":\"ST\"}]\n\t}`,\n}\n\nvar invalidInputs = []string{\n\t`{\n\t\t\"txt\":\"This should fail\",\n\t\t\"fmt\":[{\"at\":50,\"len\":-45,\"tp\":\"ST\"}]\n\t}`,\n\t`{\n\t\t\"txt\":\"This should fail\",\n\t\t\"fmt\":[{\"at\":0,\"len\":50,\"tp\":\"ST\"}]\n\t}`,\n\t`{\n\t\t\"ent\":[],\n\t\t\"fmt\":[{\"at\":0,\"len\":1,\"tp\":\"ST\",\"key\":1}]\n\t}`,\n\t`{\n\t\t\"ent\":[{\"xy\": true, \"tp\": \"XY\"}],\n\t\t\"fmt\":[{\"len\":1,\"key\":-2}],\n\t\t\"txt\":\" \"\n\t}`,\n\t`{\n\t\t\"ent\":[{\"data\": true, \"tp\": \"ST\"}],\n\t\t\"fmt\":[{\"len\":1,\"key\":42, \"at\":\"33\"}],\n\t\t\"txt\":\"123\"\n\t}`,\n\t`{\n\t\t\"txt\":true\n\t}`,\n\t`{\n\t\t\"invalid\":[{\"data\": true, \"tp\": \"ST\"}],\n\t\t\"content\":[{\"len\":1, \"key\":42}]\n\t}`,\n}\n\nfunc TestPlainText(t *testing.T) {\n\texpect := []string{\n\t\t\"This is a plain text string.\",\n\t\t\"This is a\\n string with a line break.\",\n\t\t\"[FILE 'hello.jpg']\",\n\t\t\"[https://api.tinode.co/](https://www.youtube.com/watch?v=dQw4w9WgXcQ)\",\n\t\t\"https://api.tinode.co/\",\n\t\t\"Url [one](http://tinode.co), [two](http://tinode.co)\",\n\t\t\"[IMAGE 'roses.jpg']\",\n\t\t\"This _text has_ staggered formats\",\n\t\t\"This *text* is _formatted_ and ~deleted *too*~\",\n\t\t\"*мультибайтовый* _юникод_\",\n\t\t\"This is a test\",\n\t\t\"Hello 😀, *o😀k* https://google.com\",\n\t\t\"Hi *🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿* *🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿* 🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿\",\n\t}\n\n\tfor i := range validInputs {\n\t\tvar val any\n\t\tif err := json.Unmarshal([]byte(validInputs[i]), &val); err != nil {\n\t\t\tt.Errorf(\"Failed to parse input %d '%s': %s\", i, validInputs[i], err)\n\t\t}\n\t\tres, err := PlainText(val)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"%d failed with error: %s\", i, err)\n\t\t} else if res != expect[i] {\n\t\t\tt.Errorf(\"%d output '%s' does not match '%s'\", i, res, expect[i])\n\t\t}\n\t}\n\n\tfor i := range invalidInputs {\n\t\tvar val any\n\t\tif err := json.Unmarshal([]byte(invalidInputs[i]), &val); err != nil {\n\t\t\t// Don't make it an error: we are not testing validity of json.Unmarshal.\n\t\t\tt.Logf(\"Failed to parse input %d '%s': %s\", i, invalidInputs[i], err)\n\t\t}\n\t\tres, err := PlainText(val)\n\t\tif err == nil {\n\t\t\tt.Errorf(\"invalid input %d '%s' did not cause an error '%s'\", i, invalidInputs[i], res)\n\t\t}\n\t}\n}\n\nfunc TestPreview(t *testing.T) {\n\texpect := []string{\n\t\t`{\"txt\":\"This is a plain\"}`,\n\t\t`{\"txt\":\"This is a strin\",\"fmt\":[{\"tp\":\"BR\",\"at\":9}]}`,\n\t\t`{\"fmt\":[{\"at\":-1}],\"ent\":[{\"tp\":\"EX\",\"data\":{\"height\":80,\"mime\":\"image/jpeg\",\"name\":\"hello.jpg\",\"width\":100}}]}`,\n\t\t`{\"txt\":\"https://api.tin\",\"fmt\":[{\"len\":15}],\"ent\":[{\"tp\":\"LN\",\"data\":{\"url\":\"https://www.youtube.com/watch?v=dQw4w9WgXcQ\"}}]}`,\n\t\t`{\"txt\":\"https://api.tin\",\"fmt\":[{\"len\":15}],\"ent\":[{\"tp\":\"LN\",\"data\":{\"url\":\"https://api.tinode.co/\"}}]}`,\n\t\t`{\"txt\":\"Url one, two\",\"fmt\":[{\"at\":4,\"len\":3},{\"at\":9,\"len\":3}],\"ent\":[{\"tp\":\"LN\",\"data\":{\"url\":\"http://tinode.co\"}}]}`,\n\t\t`{\"txt\":\" \",\"fmt\":[{\"len\":1}],\"ent\":[{\"tp\":\"IM\",\"data\":{\"height\":213,\"mime\":\"image/jpeg\",\"name\":\"roses.jpg\",\"width\":638}}]}`,\n\t\t`{\"txt\":\"This text has s\",\"fmt\":[{\"tp\":\"EM\",\"at\":5,\"len\":8}]}`,\n\t\t`{\"txt\":\"This text is fo\",\"fmt\":[{\"tp\":\"ST\",\"at\":5,\"len\":4},{\"tp\":\"EM\",\"at\":13,\"len\":2}]}`,\n\t\t`{\"txt\":\"мультибайтовый \",\"fmt\":[{\"tp\":\"ST\",\"len\":14}]}`,\n\t\t`{\"txt\":\"This is a test\"}`,\n\t\t`{\"txt\":\"Hello 😀, o😀k ht\",\"fmt\":[{\"tp\":\"ST\",\"at\":9,\"len\":3},{\"at\":13,\"len\":2}],\"ent\":[{\"tp\":\"LN\",\"data\":{\"url\":\"https://google.com\"}}]}`,\n\t\t`{\"txt\":\"Hi 🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿 🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿 🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿\",\"fmt\":[{\"tp\":\"ST\",\"at\":3,\"len\":4},{\"tp\":\"ST\",\"at\":8,\"len\":4}]}`,\n\t}\n\tfor i := range validInputs {\n\t\tvar val any\n\t\tif err := json.Unmarshal([]byte(validInputs[i]), &val); err != nil {\n\t\t\tt.Errorf(\"Failed to parse input %d '%s': %s\", i, validInputs[i], err)\n\t\t}\n\t\tres, err := Preview(val, 15)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"%d failed with error: %s\", i, err)\n\t\t} else if res != expect[i] {\n\t\t\tt.Errorf(\"%d output '%s' does not match '%s'\", i, res, expect[i])\n\t\t}\n\t}\n\n\t// Only some invalid input should fail these tests.\n\ttestsToFail := []int{3, 4, 5, 6}\n\tfor _, i := range testsToFail {\n\t\tvar val any\n\t\tif err := json.Unmarshal([]byte(invalidInputs[i]), &val); err != nil {\n\t\t\t// Don't make it an error: we are not testing validity of json.Unmarshal.\n\t\t\tt.Logf(\"Failed to parse input %d '%s': %s\", i, invalidInputs[i], err)\n\t\t}\n\t\tres, err := Preview(val, 15)\n\t\tif err == nil {\n\t\t\tt.Errorf(\"invalid input %d did not cause an error '%s'\", i, res)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "server/drafty/grapheme.go",
    "content": "package drafty\n\nimport (\n\t\"github.com/rivo/uniseg\"\n)\n\n// graphemes is a container holding lengths of grapheme clusters in a string.\ntype graphemes struct {\n\t// The original string.\n\toriginal string\n\n\t// Sizes of grapheme clusters within the original string.\n\tsizes []byte\n}\n\n// prepareGraphemes returns a parsed grapheme cluster container by splitting the string into grapheme clusters\n// and saving their lengths.\nfunc prepareGraphemes(str string) *graphemes {\n\t// Split the string into grapheme clusters and save the size of each cluster.\n\tsizes := make([]byte, 0, len(str))\n\tfor state, remaining, cluster := -1, str, \"\"; len(remaining) > 0; {\n\t\tcluster, remaining, _, state = uniseg.StepString(remaining, state)\n\t\tsizes = append(sizes, byte(len(cluster)))\n\t}\n\n\treturn &graphemes{\n\t\toriginal: str,\n\t\tsizes:    sizes,\n\t}\n}\n\n// length returns the number of grapheme clusters in the original string.\nfunc (g *graphemes) length() int {\n\tif g == nil {\n\t\treturn 0\n\t}\n\treturn len(g.sizes)\n}\n\n// string returns the original string from which the grapheme cluster container was created.\nfunc (g *graphemes) string() string {\n\tif g == nil {\n\t\treturn \"\"\n\t}\n\treturn g.original\n}\n\n// slice returns a new grapheme cluster container with grapheme clusters from 'start' to 'end'.\nfunc (g *graphemes) slice(start, end int) *graphemes {\n\n\t// Convert grapheme offsets to string offsets.\n\ts := 0\n\tfor i := range start {\n\t\ts += int(g.sizes[i])\n\t}\n\te := s\n\tfor i := start; i < end; i++ {\n\t\te += int(g.sizes[i])\n\t}\n\n\treturn &graphemes{\n\t\toriginal: g.original[s:e],\n\t\tsizes:    g.sizes[start:end],\n\t}\n}\n\n// append appends 'other' grapheme cluster container to 'g' container and returns g.\n// If g is nil, the 'other' is returned.\nfunc (g *graphemes) append(other *graphemes) *graphemes {\n\tif g == nil {\n\t\treturn other\n\t}\n\n\tg.original += other.original\n\tg.sizes = append(g.sizes, other.sizes...)\n\treturn g\n}\n"
  },
  {
    "path": "server/hdl_files.go",
    "content": "/******************************************************************************\n *\n *  Description :\n *\n *    Handler of large file uploads/downloads. Validates request first then calls\n *    a handler.\n *\n *****************************************************************************/\n\npackage main\n\nimport (\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"math/rand\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/pbx\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/types\"\n\t\"google.golang.org/grpc/peer\"\n)\n\n// Allowed mime types for user-provided Content-type field. Must be alphabetically sorted.\n// Types not in the list are converted to \"application/octet-stream\".\n// See https://www.iana.org/assignments/media-types/media-types.xhtml\nvar allowedMimeTypes = []string{\"application/\", \"audio/\", \"font/\", \"image/\", \"text/\", \"video/\"}\n\nfunc largeFileServeHTTP(wrt http.ResponseWriter, req *http.Request) {\n\tnow := types.TimeNow()\n\tenc := json.NewEncoder(wrt)\n\tmh := store.Store.GetMediaHandler()\n\tstatsInc(\"FileDownloadsTotal\", 1)\n\n\twriteHttpResponse := func(msg *ServerComMessage, err error) {\n\t\t// Gorilla CompressHandler requires Content-Type to be set.\n\t\twrt.Header().Set(\"Content-Type\", \"application/json; charset=utf-8\")\n\t\twrt.WriteHeader(msg.Ctrl.Code)\n\t\tenc.Encode(msg)\n\t\tif err != nil {\n\t\t\tlogs.Warn.Println(\"media serve:\", req.URL.String(), err)\n\t\t}\n\t}\n\n\t// Preflight request: process before any security checks.\n\tif req.Method == http.MethodOptions {\n\t\theaders, statusCode, err := mh.Headers(req.Method, req.URL, req.Header, true)\n\t\tif err != nil {\n\t\t\twriteHttpResponse(decodeStoreError(err, \"\", now, nil), err)\n\t\t\treturn\n\t\t}\n\t\tfor name, values := range headers {\n\t\t\tfor _, value := range values {\n\t\t\t\twrt.Header().Add(name, value)\n\t\t\t}\n\t\t}\n\t\tif statusCode <= 0 {\n\t\t\tstatusCode = http.StatusNoContent\n\t\t}\n\t\twrt.WriteHeader(statusCode)\n\t\tlogs.Info.Println(\"media serve: preflight completed\")\n\t\treturn\n\t}\n\n\t// Check if this is a GET/HEAD request.\n\tif req.Method != http.MethodGet && req.Method != http.MethodHead {\n\t\twriteHttpResponse(ErrOperationNotAllowed(\"\", \"\", now), errors.New(\"method '\"+req.Method+\"' not allowed\"))\n\t\treturn\n\t}\n\n\t// Check for API key presence\n\tif isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {\n\t\twriteHttpResponse(ErrAPIKeyRequired(now), errors.New(\"invalid or missing API key\"))\n\t\treturn\n\t}\n\n\t// Check authorization: either auth information or SID must be present\n\tauthMethod, secret := getHttpAuth(req)\n\tuid, challenge, err := authFileRequest(authMethod, secret, req.FormValue(\"sid\"), getRemoteAddr(req))\n\tif err != nil {\n\t\twriteHttpResponse(decodeStoreError(err, \"\", now, nil), err)\n\t\treturn\n\t}\n\n\tif challenge != nil {\n\t\twriteHttpResponse(InfoChallenge(\"\", now, challenge), nil)\n\t\treturn\n\t}\n\n\tif uid.IsZero() {\n\t\t// Not authenticated\n\t\twriteHttpResponse(ErrAuthRequired(\"\", \"\", now, now), errors.New(\"user not authenticated\"))\n\t\treturn\n\t}\n\n\t// Check if media handler redirects or adds headers.\n\theaders, statusCode, err := mh.Headers(req.Method, req.URL, req.Header, true)\n\tif err != nil {\n\t\twriteHttpResponse(decodeStoreError(err, \"\", now, nil), err)\n\t\treturn\n\t}\n\n\tfor name, values := range headers {\n\t\tfor _, value := range values {\n\t\t\twrt.Header().Add(name, value)\n\t\t}\n\t}\n\n\tif statusCode != 0 {\n\t\t// The handler requested to terminate further processing.\n\t\twrt.WriteHeader(statusCode)\n\t\tif req.Method == http.MethodGet {\n\t\t\tenc.Encode(&ServerComMessage{\n\t\t\t\tCtrl: &MsgServerCtrl{\n\t\t\t\t\tCode:      statusCode,\n\t\t\t\t\tText:      http.StatusText(statusCode),\n\t\t\t\t\tTimestamp: now,\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\t\tlogs.Info.Println(\"media serve: completed with status\", statusCode, \"uid=\", uid)\n\t\treturn\n\t}\n\n\tif req.Method == http.MethodHead {\n\t\twrt.WriteHeader(http.StatusOK)\n\t\tlogs.Info.Println(\"media serve: completed\", req.Method, \"uid=\", uid)\n\t\treturn\n\t}\n\n\tfd, rsc, err := mh.Download(req.URL.String())\n\tif err != nil {\n\t\twriteHttpResponse(decodeStoreError(err, \"\", now, nil), err)\n\t\treturn\n\t}\n\n\tdefer rsc.Close()\n\n\twrt.Header().Set(\"Content-Type\", fd.MimeType)\n\tasAttachment, _ := strconv.ParseBool(req.URL.Query().Get(\"asatt\"))\n\t// Force download for html files as a security measure.\n\tasAttachment = asAttachment ||\n\t\tstrings.Contains(fd.MimeType, \"html\") ||\n\t\tstrings.Contains(fd.MimeType, \"xml\") ||\n\t\tstrings.HasPrefix(fd.MimeType, \"application/\") ||\n\t\t// The 'message', 'model', and 'multipart' cannot currently appear, but checked anyway in case\n\t\t// DetectContentType changes its logic.\n\t\tstrings.HasPrefix(fd.MimeType, \"message/\") ||\n\t\tstrings.HasPrefix(fd.MimeType, \"model/\") ||\n\t\tstrings.HasPrefix(fd.MimeType, \"multipart/\") ||\n\t\tstrings.HasPrefix(fd.MimeType, \"text/\")\n\tif asAttachment {\n\t\twrt.Header().Set(\"Content-Disposition\", \"attachment\")\n\t}\n\n\thttp.ServeContent(wrt, req, \"\", fd.UpdatedAt, rsc)\n\n\tlogs.Info.Println(\"media serve: OK, uid=\", uid)\n}\n\n// largeFileReceiveHTTP receives files from client over HTTP(S) and passes them to the configured media handler.\nfunc largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {\n\tnow := types.TimeNow()\n\tenc := json.NewEncoder(wrt)\n\tmh := store.Store.GetMediaHandler()\n\tstatsInc(\"FileUploadsTotal\", 1)\n\n\twriteHttpResponse := func(msg *ServerComMessage, err error) {\n\t\t// Gorilla CompressHandler requires Content-Type to be set.\n\t\twrt.Header().Set(\"Content-Type\", \"application/json; charset=utf-8\")\n\t\twrt.WriteHeader(msg.Ctrl.Code)\n\t\tenc.Encode(msg)\n\n\t\tif err != nil {\n\t\t\tlogs.Info.Println(\"media upload:\", msg.Ctrl.Code, msg.Ctrl.Text, \"/\", err)\n\t\t}\n\t}\n\n\t// Preflight request: process before any security checks.\n\tif req.Method == http.MethodOptions {\n\t\theaders, statusCode, err := mh.Headers(req.Method, req.URL, req.Header, true)\n\t\tif err != nil {\n\t\t\twriteHttpResponse(decodeStoreError(err, \"\", now, nil), err)\n\t\t\treturn\n\t\t}\n\t\tfor name, values := range headers {\n\t\t\tfor _, value := range values {\n\t\t\t\twrt.Header().Add(name, value)\n\t\t\t}\n\t\t}\n\t\tif statusCode <= 0 {\n\t\t\tstatusCode = http.StatusNoContent\n\t\t}\n\t\twrt.WriteHeader(statusCode)\n\t\tlogs.Info.Println(\"media upload: preflight completed\")\n\t\treturn\n\t}\n\n\t// Check if this is a POST/PUT/HEAD request.\n\tif req.Method != http.MethodPost && req.Method != http.MethodPut && req.Method != http.MethodHead {\n\t\twriteHttpResponse(ErrOperationNotAllowed(\"\", \"\", now), errors.New(\"method '\"+req.Method+\"' not allowed\"))\n\t\treturn\n\t}\n\n\tif globals.maxFileUploadSize > 0 {\n\t\t// Enforce maximum upload size.\n\t\treq.Body = http.MaxBytesReader(wrt, req.Body, globals.maxFileUploadSize)\n\t}\n\n\t// Check for API key presence\n\tif isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {\n\t\twriteHttpResponse(ErrAPIKeyRequired(now), nil)\n\t\treturn\n\t}\n\n\tmsgID := req.FormValue(\"id\")\n\t// Check authorization: either auth information or SID must be present\n\tauthMethod, secret := getHttpAuth(req)\n\tuid, challenge, err := authFileRequest(authMethod, secret, req.FormValue(\"sid\"), getRemoteAddr(req))\n\tif err != nil {\n\t\twriteHttpResponse(decodeStoreError(err, msgID, now, nil), err)\n\t\treturn\n\t}\n\tif challenge != nil {\n\t\twriteHttpResponse(InfoChallenge(msgID, now, challenge), nil)\n\t\treturn\n\t}\n\tif uid.IsZero() && req.FormValue(\"topic\") != \"newacc\" {\n\t\t// Not authenticated and not signup.\n\t\twriteHttpResponse(ErrAuthRequired(msgID, \"\", now, now), nil)\n\t\treturn\n\t}\n\n\t// Check if uploads are handled elsewhere.\n\theaders, statusCode, err := mh.Headers(req.Method, req.URL, req.Header, true)\n\tif err != nil {\n\t\tlogs.Info.Println(\"media upload: headers check failed\", err)\n\t\twriteHttpResponse(decodeStoreError(err, \"\", now, nil), err)\n\t\treturn\n\t}\n\n\tfor name, values := range headers {\n\t\tfor _, value := range values {\n\t\t\twrt.Header().Add(name, value)\n\t\t}\n\t}\n\n\tif statusCode != 0 {\n\t\t// The handler requested to terminate further processing.\n\t\twrt.WriteHeader(statusCode)\n\t\tif req.Method == http.MethodPost || req.Method == http.MethodPut {\n\t\t\tenc.Encode(&ServerComMessage{\n\t\t\t\tCtrl: &MsgServerCtrl{\n\t\t\t\t\tCode:      statusCode,\n\t\t\t\t\tText:      http.StatusText(statusCode),\n\t\t\t\t\tTimestamp: now,\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\t\tlogs.Info.Println(\"media upload: completed with status\", statusCode)\n\t\treturn\n\t}\n\n\tif req.Method == http.MethodHead || req.Method == http.MethodOptions {\n\t\twrt.WriteHeader(http.StatusOK)\n\t\tlogs.Info.Println(\"media upload: completed\", req.Method)\n\t\treturn\n\t}\n\n\tfile, header, err := req.FormFile(\"file\")\n\tif err != nil {\n\t\tlogs.Info.Println(\"media upload: invalid multipart form\", err)\n\t\tif strings.Contains(err.Error(), \"request body too large\") {\n\t\t\twriteHttpResponse(ErrTooLarge(msgID, \"\", now), err)\n\t\t} else {\n\t\t\twriteHttpResponse(ErrMalformed(msgID, \"\", now), err)\n\t\t}\n\t\treturn\n\t}\n\n\tbuff := make([]byte, 512)\n\tif _, err = file.Read(buff); err != nil {\n\t\twriteHttpResponse(ErrUnknown(msgID, \"\", now), err)\n\t\treturn\n\t}\n\n\tmimeType := http.DetectContentType(buff)\n\t// If DetectContentType fails, see if client-provided content type can be used.\n\tif mimeType == \"application/octet-stream\" {\n\t\tif userContentType, params, err := mime.ParseMediaType(header.Header.Get(\"Content-Type\")); err == nil {\n\t\t\t// Make sure the content-type is legit.\n\t\t\tfor _, allowed := range allowedMimeTypes {\n\t\t\t\tif strings.HasPrefix(userContentType, allowed) {\n\t\t\t\t\tif userContentType = mime.FormatMediaType(userContentType, params); userContentType != \"\" {\n\t\t\t\t\t\tmimeType = userContentType\n\t\t\t\t\t}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tfdef := &types.FileDef{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tId: store.Store.GetUidString(),\n\t\t},\n\t\tUser:     uid.String(),\n\t\tMimeType: mimeType,\n\t}\n\tfdef.InitTimes()\n\n\tif _, err = file.Seek(0, io.SeekStart); err != nil {\n\t\twriteHttpResponse(ErrUnknown(msgID, \"\", now), err)\n\t\treturn\n\t}\n\n\turl, size, err := mh.Upload(fdef, file)\n\tif err != nil {\n\t\tlogs.Info.Println(\"media upload: failed\", file, \"key\", fdef.Location, err)\n\t\tstore.Files.FinishUpload(fdef, false, 0)\n\t\twriteHttpResponse(decodeStoreError(err, msgID, now, nil), err)\n\t\treturn\n\t}\n\n\tfdef, err = store.Files.FinishUpload(fdef, true, size)\n\tif err != nil {\n\t\tlogs.Info.Println(\"media upload: failed to finalize\", file, \"key\", fdef.Location, err)\n\t\t// Best effort cleanup.\n\t\tmh.Delete([]string{fdef.Location})\n\t\twriteHttpResponse(decodeStoreError(err, msgID, now, nil), err)\n\t\treturn\n\t}\n\n\tparams := map[string]string{\"url\": url}\n\tif globals.mediaGcPeriod > 0 {\n\t\t// How long this file is guaranteed to exist without being attached to a message or a topic.\n\t\tparams[\"expires\"] = now.Add(globals.mediaGcPeriod).Format(types.TimeFormatRFC3339)\n\t}\n\n\twriteHttpResponse(NoErrParams(msgID, \"\", now, params), nil)\n\tlogs.Info.Println(\"media upload: ok\", fdef.Id, fdef.Location)\n}\n\n// LargeFileServe is the gRPC equivalent of largeFileServeHTTP.\nfunc (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_LargeFileServeServer) error {\n\tnow := types.TimeNow()\n\n\twriteResponse := func(msg *ServerComMessage, err error) {\n\t\tstream.Send(&pbx.FileDownResp{Id: msg.Ctrl.Id, Code: int32(msg.Ctrl.Code), Text: msg.Ctrl.Text})\n\t\tif err != nil {\n\t\t\tlogs.Info.Println(\"media serve:\", msg.Ctrl.Code, msg.Ctrl.Text, \"/\", err)\n\t\t}\n\t}\n\n\tmsgID := req.GetId()\n\n\t// Check authorization: auth information must be present (SID is not used for gRPC).\n\tauthMethod, secret := req.Auth.Scheme, req.Auth.Secret\n\tvar remoteAddr string\n\tif p, ok := peer.FromContext(stream.Context()); ok {\n\t\tremoteAddr = p.Addr.String()\n\t}\n\tuid, challenge, err := authFileRequest(authMethod, secret, \"\", remoteAddr)\n\tif err != nil {\n\t\twriteResponse(decodeStoreError(err, msgID, now, nil), err)\n\t\treturn nil\n\t}\n\n\tif challenge != nil {\n\t\twriteResponse(InfoChallenge(msgID, now, challenge), nil)\n\t\treturn nil\n\t}\n\n\tif uid.IsZero() {\n\t\t// Not authenticated\n\t\twriteResponse(ErrAuthRequired(msgID, \"\", now, now), errors.New(\"user not authenticated\"))\n\t\treturn nil\n\t}\n\n\t// Check if media handler redirects or adds headers.\n\tmh := store.Store.GetMediaHandler()\n\turl, _ := url.Parse(req.Uri)\n\theaders, statusCode, err := mh.Headers(http.MethodGet, url, http.Header{}, true)\n\tif err != nil {\n\t\twriteResponse(decodeStoreError(err, \"\", now, nil), err)\n\t\treturn nil\n\t}\n\n\tresp := pbx.FileDownResp{Meta: &pbx.FileMeta{}}\n\tif statusCode != 0 {\n\t\t// The handler requested to terminate further processing.\n\t\tresp.Code = int32(statusCode)\n\t\tresp.Text = http.StatusText(statusCode)\n\t\tresp.RedirUrl = headers.Get(\"Location\")\n\t\tstream.Send(&resp)\n\t\tlogs.Info.Println(\"media serve: completed with status\", statusCode, \"uid=\", uid)\n\t\treturn nil\n\t}\n\n\tfd, rsc, err := mh.Download(req.GetUri())\n\tif err != nil {\n\t\twriteResponse(decodeStoreError(err, msgID, now, nil), err)\n\t\treturn nil\n\t}\n\n\tdefer rsc.Close()\n\n\tresp.Code = http.StatusOK\n\tresp.Text = http.StatusText(http.StatusOK)\n\tresp.Meta.Name = fd.Location\n\tresp.Meta.MimeType = fd.MimeType\n\tresp.Meta.Size = fd.Size\n\n\tresp.Content = make([]byte, 1024*1024*2)\n\tvar n int\n\tresult := \"OK\"\n\tfor {\n\t\tn, err = rsc.Read(resp.Content)\n\t\tif err == nil {\n\t\t\tresp.Content = resp.Content[:n]\n\t\t\tif err = stream.Send(&resp); err != nil {\n\t\t\t\tlogs.Info.Println(\"media serve: failed, uid=\", uid, err)\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif err == io.EOF {\n\t\t\terr = nil\n\t\t} else {\n\t\t\tresult = err.Error()\n\t\t}\n\t\tbreak\n\t}\n\tlogs.Info.Println(\"media serve: \", result, \", uid=\", uid)\n\treturn err\n}\n\n// LargeFileReceive is the gRPC equivalent of LargeFileReceiveHTTP.\nfunc (*grpcNodeServer) LargeFileReceive(stream pbx.Node_LargeFileReceiveServer) error {\n\tnow := types.TimeNow()\n\tmh := store.Store.GetMediaHandler()\n\n\twriteResponse := func(msg *ServerComMessage, err error) {\n\t\tstream.SendAndClose(&pbx.FileUpResp{Id: msg.Ctrl.Id, Code: int32(msg.Ctrl.Code), Text: msg.Ctrl.Text})\n\t\tif err != nil {\n\t\t\tlogs.Info.Println(\"media receive:\", msg.Ctrl.Code, msg.Ctrl.Text, \"/\", err)\n\t\t}\n\t}\n\n\treq, err := stream.Recv()\n\tif err != nil {\n\t\tif errors.Is(err, io.EOF) {\n\t\t\twriteResponse(ErrDisconnected(\"\", \"\", now), err)\n\t\t} else {\n\t\t\twriteResponse(decodeStoreError(err, \"\", now, nil), err)\n\t\t}\n\t\treturn nil\n\t}\n\n\tmsgID := req.GetId()\n\t// Check authorization: auth information must be present (SID is not used for gRPC).\n\tauthMethod, secret := req.Auth.Scheme, req.Auth.Secret\n\tvar remoteAddr string\n\tif p, ok := peer.FromContext(stream.Context()); ok {\n\t\tremoteAddr = p.Addr.String()\n\t}\n\tuid, challenge, err := authFileRequest(authMethod, secret, \"\", remoteAddr)\n\tif err != nil {\n\t\twriteResponse(decodeStoreError(err, msgID, now, nil), err)\n\t\treturn nil\n\t}\n\n\tif challenge != nil {\n\t\twriteResponse(InfoChallenge(msgID, now, challenge), nil)\n\t\treturn nil\n\t}\n\n\tif uid.IsZero() {\n\t\t// Not authenticated\n\t\twriteResponse(ErrAuthRequired(msgID, \"\", now, now), errors.New(\"user not authenticated\"))\n\t\treturn nil\n\t}\n\n\t// Check if uploads are handled elsewhere.\n\theaders, statusCode, err := mh.Headers(http.MethodPost, nil, http.Header{}, false)\n\tif err != nil {\n\t\tlogs.Info.Println(\"media upload: headers check failed\", err)\n\t\twriteResponse(decodeStoreError(err, \"\", now, nil), nil)\n\t\treturn nil\n\t}\n\n\tif statusCode != 0 {\n\t\t// The handler requested to terminate further processing.\n\t\terr = stream.SendAndClose(&pbx.FileUpResp{\n\t\t\tId:       msgID,\n\t\t\tCode:     int32(statusCode),\n\t\t\tText:     http.StatusText(statusCode),\n\t\t\tRedirUrl: headers.Get(\"Location\"),\n\t\t})\n\t\tlogs.Info.Println(\"media upload: completed with status\", statusCode, \"uid=\", uid, err)\n\t\treturn err\n\t}\n\n\tmimeType := http.DetectContentType(req.Content)\n\t// If DetectContentType fails, use client-provided content type.\n\tif mimeType == \"application/octet-stream\" {\n\t\tif contentType := req.Meta.GetMimeType(); contentType != \"\" {\n\t\t\tmimeType = contentType\n\t\t}\n\t}\n\n\tfdef := &types.FileDef{\n\t\tObjHeader: types.ObjHeader{\n\t\t\tId: store.Store.GetUidString(),\n\t\t},\n\t\tUser:     uid.String(),\n\t\tMimeType: mimeType,\n\t}\n\tfdef.InitTimes()\n\n\treader, writer := io.Pipe()\n\t// Create a non-blocking channel to collect errors from the inbound IO process.\n\tdone := make(chan error, 1)\n\tgo func() {\n\t\tdefer writer.Close()\n\t\tfor {\n\t\t\tif req, err := stream.Recv(); err == nil {\n\t\t\t\tchunk := req.GetContent()\n\t\t\t\tif _, err := writer.Write(chunk); err != nil {\n\t\t\t\t\tdone <- err\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err == io.EOF {\n\t\t\t\t\terr = nil\n\t\t\t\t}\n\t\t\t\tdone <- err\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}()\n\n\turl, size, err := mh.Upload(fdef, reader)\n\tif err == nil {\n\t\t// No outbound IO error. Maybe we have an inbound one?\n\t\terr = <-done\n\t}\n\tif err != nil {\n\t\tlogs.Info.Println(\"media upload: failed\", req.Meta.Name, \"key\", fdef.Location, err)\n\t\tstore.Files.FinishUpload(fdef, false, 0)\n\t\twriteResponse(decodeStoreError(err, msgID, now, nil), nil)\n\t\treturn nil\n\t}\n\n\terr = stream.SendAndClose(&pbx.FileUpResp{\n\t\tId:   msgID,\n\t\tCode: http.StatusOK,\n\t\tText: http.StatusText(http.StatusOK),\n\t\tMeta: &pbx.FileMeta{\n\t\t\tName:     url,\n\t\t\tMimeType: mimeType,\n\t\t\tEtag:     fdef.ETag,\n\t\t\tSize:     size,\n\t\t},\n\t})\n\tlogs.Info.Println(\"media upload: ok\", fdef.Id, fdef.Location, err)\n\treturn err\n}\n\n// largeFileRunGarbageCollection runs every 'period' and deletes up to 'blockSize' unused files.\n// Returns channel which can be used to stop the process.\nfunc largeFileRunGarbageCollection(period time.Duration, blockSize int) chan<- bool {\n\t// Unbuffered stop channel. Whomever stops the gc must wait for the process to finish.\n\tstop := make(chan bool)\n\tgo func() {\n\t\t// Add some randomness to the tick period to desynchronize runs on cluster nodes:\n\t\t// 0.75 * period + rand(0, 0.5) * period.\n\t\tperiod = (period >> 1) + (period >> 2) + time.Duration(rand.Intn(int(period>>1)))\n\t\tgcTicker := time.Tick(period)\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-gcTicker:\n\t\t\t\tif err := store.Files.DeleteUnused(time.Now().Add(-time.Hour), blockSize); err != nil {\n\t\t\t\t\tlogs.Warn.Println(\"media gc:\", err)\n\t\t\t\t}\n\t\t\tcase <-stop:\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn stop\n}\n\n// Authenticate non-websocket HTTP request\nfunc authFileRequest(authMethod, secret, sid, remoteAddr string) (types.Uid, []byte, error) {\n\tvar uid types.Uid\n\tif authMethod != \"\" {\n\t\tdecodedSecret := make([]byte, base64.StdEncoding.DecodedLen(len(secret)))\n\t\tn, err := base64.StdEncoding.Decode(decodedSecret, []byte(secret))\n\t\tif err != nil {\n\t\t\tlogs.Info.Println(\"media: invalid auth secret\", authMethod, \"'\"+secret+\"'\")\n\t\t\treturn uid, nil, types.ErrMalformed\n\t\t}\n\n\t\tif authhdl := store.Store.GetLogicalAuthHandler(authMethod); authhdl != nil {\n\t\t\trec, challenge, err := authhdl.Authenticate(decodedSecret[:n], remoteAddr)\n\t\t\tif err != nil {\n\t\t\t\treturn uid, nil, err\n\t\t\t}\n\t\t\tif challenge != nil {\n\t\t\t\treturn uid, challenge, nil\n\t\t\t}\n\t\t\tuid = rec.Uid\n\t\t} else {\n\t\t\tlogs.Info.Println(\"media: unknown auth method\", authMethod)\n\t\t\treturn uid, nil, types.ErrMalformed\n\t\t}\n\t} else {\n\t\t// Find the session, make sure it's appropriately authenticated.\n\t\tsess := globals.sessionStore.Get(sid)\n\t\tif sess != nil {\n\t\t\tuid = sess.uid\n\t\t}\n\t}\n\treturn uid, nil, nil\n}\n"
  },
  {
    "path": "server/hdl_grpc.go",
    "content": "/******************************************************************************\n *\n *  Description :\n *\n *    Handler of gRPC connections. See also hdl_websock.go for websockets and\n *    hdl_longpoll.go for long polling.\n *\n *****************************************************************************/\n\npackage main\n\nimport (\n\t\"crypto/tls\"\n\t\"io\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/pbx\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"google.golang.org/grpc\"\n\t\"google.golang.org/grpc/credentials\"\n\t\"google.golang.org/grpc/keepalive\"\n\t\"google.golang.org/grpc/peer\"\n)\n\ntype grpcNodeServer struct {\n\tpbx.UnimplementedNodeServer\n}\n\nfunc (sess *Session) closeGrpc() {\n\tif sess.proto == GRPC {\n\t\tsess.lock.Lock()\n\t\tsess.grpcnode = nil\n\t\tsess.lock.Unlock()\n\t}\n}\n\n// Equivalent of starting a new session and a read loop in one.\nfunc (*grpcNodeServer) MessageLoop(stream pbx.Node_MessageLoopServer) error {\n\tsess, count := globals.sessionStore.NewSession(stream, \"\")\n\tif p, ok := peer.FromContext(stream.Context()); ok {\n\t\tsess.remoteAddr = p.Addr.String()\n\t}\n\tlogs.Info.Println(\"grpc: session started\", sess.sid, sess.remoteAddr, count)\n\n\tdefer func() {\n\t\tsess.closeGrpc()\n\t\tsess.cleanUp(false)\n\t}()\n\n\tgo sess.writeGrpcLoop()\n\n\tfor {\n\t\tin, err := stream.Recv()\n\t\tif err == io.EOF {\n\t\t\treturn nil\n\t\t}\n\t\tif err != nil {\n\t\t\tlogs.Err.Println(\"grpc: recv\", sess.sid, err)\n\t\t\treturn err\n\t\t}\n\t\tlogs.Info.Println(\"grpc in:\", truncateStringIfTooLong(in.String()), sess.sid)\n\t\tstatsInc(\"IncomingMessagesGrpcTotal\", 1)\n\t\tsess.dispatch(pbCliDeserialize(in))\n\n\t\tsess.lock.Lock()\n\t\tif sess.grpcnode == nil {\n\t\t\tsess.lock.Unlock()\n\t\t\tbreak\n\t\t}\n\t\tsess.lock.Unlock()\n\t}\n\n\treturn nil\n}\n\nfunc (sess *Session) sendMessageGrpc(msg any) bool {\n\tif len(sess.send) > sendQueueLimit {\n\t\tlogs.Err.Println(\"grpc: outbound queue limit exceeded\", sess.sid)\n\t\treturn false\n\t}\n\tstatsInc(\"OutgoingMessagesGrpcTotal\", 1)\n\tif err := grpcWrite(sess, msg); err != nil {\n\t\tlogs.Err.Println(\"grpc: write\", sess.sid, err)\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (sess *Session) writeGrpcLoop() {\n\tdefer func() {\n\t\tsess.closeGrpc() // exit MessageLoop\n\t}()\n\n\tfor {\n\t\tselect {\n\t\tcase msg, ok := <-sess.send:\n\t\t\tif !ok {\n\t\t\t\t// channel closed\n\t\t\t\treturn\n\t\t\t}\n\t\t\tswitch v := msg.(type) {\n\t\t\tcase []*ServerComMessage: // batch of unserialized messages\n\t\t\t\tfor _, msg := range v {\n\t\t\t\t\tw := sess.serializeAndUpdateStats(msg)\n\t\t\t\t\tif !sess.sendMessageGrpc(w) {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\tcase *ServerComMessage: // single unserialized message\n\t\t\t\tw := sess.serializeAndUpdateStats(v)\n\t\t\t\tif !sess.sendMessageGrpc(w) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\tdefault: // serialized message\n\t\t\t\tif !sess.sendMessageGrpc(v) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\tcase <-sess.bkgTimer.C:\n\t\t\tif sess.background {\n\t\t\t\tsess.background = false\n\t\t\t\tsess.onBackgroundTimer()\n\t\t\t}\n\n\t\tcase msg := <-sess.stop:\n\t\t\t// Shutdown requested, don't care if the message is delivered\n\t\t\tif msg != nil {\n\t\t\t\tgrpcWrite(sess, msg)\n\t\t\t}\n\t\t\treturn\n\n\t\tcase topic := <-sess.detach:\n\t\t\tsess.delSub(topic)\n\t\t}\n\t}\n}\n\nfunc grpcWrite(sess *Session, msg any) error {\n\tif out := sess.grpcnode; out != nil {\n\t\t// Will panic if msg is not of *pbx.ServerMsg type. This is an intentional panic.\n\t\treturn out.Send(msg.(*pbx.ServerMsg))\n\t}\n\treturn nil\n}\n\nfunc serveGrpc(addr string, kaEnabled bool, tlsConf *tls.Config) (*grpc.Server, error) {\n\tif addr == \"\" {\n\t\treturn nil, nil\n\t}\n\n\tlis, err := netListener(addr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsecure := \"\"\n\tvar opts []grpc.ServerOption\n\topts = append(opts, grpc.MaxRecvMsgSize(int(globals.maxMessageSize)))\n\tif tlsConf != nil {\n\t\topts = append(opts, grpc.Creds(credentials.NewTLS(tlsConf)))\n\t\tsecure = \" secure\"\n\t}\n\n\tif kaEnabled {\n\t\tkepConfig := keepalive.EnforcementPolicy{\n\t\t\tMinTime:             1 * time.Second, // If a client pings more than once every second, terminate the connection\n\t\t\tPermitWithoutStream: true,            // Allow pings even when there are no active streams\n\t\t}\n\t\topts = append(opts, grpc.KeepaliveEnforcementPolicy(kepConfig))\n\n\t\tkpConfig := keepalive.ServerParameters{\n\t\t\tTime:    60 * time.Second, // Ping the client if it is idle for 60 seconds to ensure the connection is still active\n\t\t\tTimeout: 20 * time.Second, // Wait 20 second for the ping ack before assuming the connection is dead\n\t\t}\n\t\topts = append(opts, grpc.KeepaliveParams(kpConfig))\n\t}\n\n\tsrv := grpc.NewServer(opts...)\n\tpbx.RegisterNodeServer(srv, &grpcNodeServer{})\n\tlogs.Info.Printf(\"gRPC/%s%s server is registered at [%s]\", grpc.Version, secure, addr)\n\n\tgo func() {\n\t\tif err := srv.Serve(lis); err != nil {\n\t\t\tlogs.Err.Println(\"gRPC server failed:\", err)\n\t\t}\n\t}()\n\n\treturn srv, nil\n}\n"
  },
  {
    "path": "server/hdl_longpoll.go",
    "content": "/******************************************************************************\n *\n *  Description :\n *\n *    Handler of long polling clients. See also hdl_websock.go for web sockets and\n *    hdl_grpc.go for gRPC\n *\n *****************************************************************************/\n\npackage main\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/logs\"\n)\n\nfunc (sess *Session) sendMessageLp(wrt http.ResponseWriter, msg any) bool {\n\tif len(sess.send) > sendQueueLimit {\n\t\tlogs.Err.Println(\"longPoll: outbound queue limit exceeded\", sess.sid)\n\t\treturn false\n\t}\n\n\tstatsInc(\"OutgoingMessagesLongpollTotal\", 1)\n\tif err := lpWrite(wrt, msg); err != nil {\n\t\tlogs.Err.Println(\"longPoll: writeOnce failed\", sess.sid, err)\n\t\treturn false\n\t}\n\n\treturn true\n}\n\nfunc (sess *Session) writeOnce(wrt http.ResponseWriter, req *http.Request) {\n\tfor {\n\t\tselect {\n\t\tcase msg, ok := <-sess.send:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tswitch v := msg.(type) {\n\t\t\tcase *ServerComMessage: // single unserialized message\n\t\t\t\tw := sess.serializeAndUpdateStats(v)\n\t\t\t\tif !sess.sendMessageLp(wrt, w) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\tdefault: // serialized message\n\t\t\t\tif !sess.sendMessageLp(wrt, v) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn\n\n\t\tcase <-sess.bkgTimer.C:\n\t\t\tif sess.background {\n\t\t\t\tsess.background = false\n\t\t\t\tsess.onBackgroundTimer()\n\t\t\t}\n\n\t\tcase msg := <-sess.stop:\n\t\t\t// Request to close the session. Make it unavailable.\n\t\t\tglobals.sessionStore.Delete(sess)\n\t\t\t// Don't care if lpWrite fails.\n\t\t\tif msg != nil {\n\t\t\t\tlpWrite(wrt, msg)\n\t\t\t}\n\t\t\treturn\n\n\t\tcase topic := <-sess.detach:\n\t\t\t// Request to detach the session from a topic.\n\t\t\tsess.delSub(topic)\n\t\t\t// No 'return' statement here: continue waiting\n\n\t\tcase <-time.After(pingPeriod):\n\t\t\t// just write an empty packet on timeout\n\t\t\tif _, err := wrt.Write([]byte{}); err != nil {\n\t\t\t\tlogs.Err.Println(\"longPoll: writeOnce: timout\", sess.sid, err)\n\t\t\t}\n\t\t\treturn\n\n\t\tcase <-req.Context().Done():\n\t\t\t// HTTP request canceled or connection lost.\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc lpWrite(wrt http.ResponseWriter, msg any) error {\n\t// This will panic if msg is not []byte. This is intentional.\n\twrt.Write(msg.([]byte))\n\treturn nil\n}\n\nfunc (sess *Session) readOnce(wrt http.ResponseWriter, req *http.Request) (int, error) {\n\tif req.ContentLength > globals.maxMessageSize {\n\t\treturn http.StatusExpectationFailed, errors.New(\"request too large\")\n\t}\n\n\treq.Body = http.MaxBytesReader(wrt, req.Body, globals.maxMessageSize)\n\traw, err := io.ReadAll(req.Body)\n\tif err == nil {\n\t\t// Locking-unlocking is needed because the client may issue multiple requests in parallel.\n\t\t// Should not affect performance\n\t\tsess.lock.Lock()\n\t\tstatsInc(\"IncomingMessagesLongpollTotal\", 1)\n\t\tsess.dispatchRaw(raw)\n\t\tsess.lock.Unlock()\n\t\treturn 0, nil\n\t}\n\n\treturn 0, err\n}\n\n// serveLongPoll handles long poll connections when WebSocket is not available\n// Connection could be without sid or with sid:\n//   - if sid is empty, create session, expect a login in the same request, respond and close\n//   - if sid is not empty and there is an initialized session, payload is optional\n//   - if no payload, perform long poll\n//   - if payload exists, process it and close\n//   - if sid is not empty but there is no session, report an error\nfunc serveLongPoll(wrt http.ResponseWriter, req *http.Request) {\n\tnow := time.Now().UTC().Round(time.Millisecond)\n\n\t// Use the lowest common denominator - this is a legacy handler after all (otherwise would use application/json)\n\twrt.Header().Set(\"Content-Type\", \"text/plain\")\n\tif globals.tlsStrictMaxAge != \"\" {\n\t\twrt.Header().Set(\"Strict-Transport-Security\", \"max-age\"+globals.tlsStrictMaxAge)\n\t}\n\n\tenc := json.NewEncoder(wrt)\n\n\tif isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {\n\t\twrt.WriteHeader(http.StatusForbidden)\n\t\tenc.Encode(ErrAPIKeyRequired(now))\n\t\treturn\n\t}\n\n\t// TODO(gene): should it be configurable?\n\t// Currently any domain is allowed to get data from the chat server\n\twrt.Header().Set(\"Access-Control-Allow-Origin\", \"*\")\n\n\t// Ensure the response is not cached\n\tif req.ProtoAtLeast(1, 1) {\n\t\twrt.Header().Set(\"Cache-Control\", \"no-cache, no-store, must-revalidate\") // HTTP 1.1\n\t} else {\n\t\twrt.Header().Set(\"Pragma\", \"no-cache\") // HTTP 1.0\n\t}\n\twrt.Header().Set(\"Expires\", \"0\") // Proxies\n\n\t// TODO(gene): respond differently to valious HTTP methods\n\n\t// Get session id\n\tsid := req.FormValue(\"sid\")\n\tvar sess *Session\n\tif sid == \"\" {\n\t\t// New session\n\t\tvar count int\n\t\tsess, count = globals.sessionStore.NewSession(wrt, \"\")\n\t\tsess.remoteAddr = getRemoteAddr(req)\n\t\tlogs.Info.Println(\"longPoll: session started\", sess.sid, sess.remoteAddr, count)\n\n\t\twrt.WriteHeader(http.StatusCreated)\n\t\tpkt := NoErrCreated(req.FormValue(\"id\"), \"\", now)\n\t\tpkt.Ctrl.Params = map[string]string{\n\t\t\t\"sid\": sess.sid,\n\t\t}\n\t\tenc.Encode(pkt)\n\n\t\treturn\n\t}\n\n\t// Existing session\n\tsess = globals.sessionStore.Get(sid)\n\tif sess == nil {\n\t\tlogs.Warn.Println(\"longPoll: invalid or expired session id\", sid)\n\t\twrt.WriteHeader(http.StatusForbidden)\n\t\tenc.Encode(ErrSessionNotFound(now))\n\t\treturn\n\t}\n\n\tif addr := getRemoteAddr(req); sess.remoteAddr != addr {\n\t\tsess.remoteAddr = addr\n\t\tlogs.Warn.Println(\"longPoll: remote address changed\", sid, addr)\n\t}\n\n\tif req.ContentLength != 0 {\n\t\t// Read payload and send it for processing.\n\t\tif code, err := sess.readOnce(wrt, req); err != nil {\n\t\t\tlogs.Warn.Println(\"longPoll: readOnce failed\", sess.sid, err)\n\t\t\t// Failed to read request, report an error, if possible\n\t\t\tif code != 0 {\n\t\t\t\twrt.WriteHeader(code)\n\t\t\t} else {\n\t\t\t\twrt.WriteHeader(http.StatusBadRequest)\n\t\t\t}\n\t\t\tenc.Encode(ErrMalformed(req.FormValue(\"id\"), \"\", now))\n\t\t}\n\t\treturn\n\t}\n\n\tsess.writeOnce(wrt, req)\n}\n"
  },
  {
    "path": "server/hdl_websock.go",
    "content": "/******************************************************************************\n *\n *  Description :\n *\n *    Handler of websocket connections. See also hdl_longpoll.go for long polling\n *    and hdl_grpc.go for gRPC.\n *\n *****************************************************************************/\n\npackage main\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/gorilla/websocket\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\nconst (\n\t// Time allowed to write a message to the peer.\n\twriteWait = 10 * time.Second\n\n\t// Time allowed to read the next pong message from the peer.\n\tpongWait = idleSessionTimeout\n\n\t// Send pings to peer with this period. Must be less than pongWait.\n\tpingPeriod = (pongWait * 9) / 10\n)\n\nfunc (sess *Session) closeWS() {\n\tif sess.proto == WEBSOCK {\n\t\tsess.ws.Close()\n\t}\n}\n\nfunc (sess *Session) readLoop() {\n\tdefer func() {\n\t\tsess.closeWS()\n\t\tsess.cleanUp(false)\n\t}()\n\n\tsess.ws.SetReadLimit(globals.maxMessageSize)\n\tsess.ws.SetReadDeadline(time.Now().Add(pongWait))\n\tsess.ws.SetPongHandler(func(string) error {\n\t\tsess.ws.SetReadDeadline(time.Now().Add(pongWait))\n\t\treturn nil\n\t})\n\n\tfor {\n\t\t// Read a ClientComMessage\n\t\t_, raw, err := sess.ws.ReadMessage()\n\t\tif err != nil {\n\t\t\tif websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure,\n\t\t\t\twebsocket.CloseNormalClosure) {\n\t\t\t\tlogs.Err.Println(\"ws: readLoop\", sess.sid, err)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\tstatsInc(\"IncomingMessagesWebsockTotal\", 1)\n\t\tsess.dispatchRaw(raw)\n\t}\n}\n\nfunc (sess *Session) sendMessage(msg any) bool {\n\tif len(sess.send) > sendQueueLimit {\n\t\tlogs.Err.Println(\"ws: outbound queue limit exceeded\", sess.sid)\n\t\treturn false\n\t}\n\n\tstatsInc(\"OutgoingMessagesWebsockTotal\", 1)\n\tif err := wsWrite(sess.ws, websocket.TextMessage, msg); err != nil {\n\t\tif websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure,\n\t\t\twebsocket.CloseNormalClosure) {\n\t\t\tlogs.Err.Println(\"ws: writeLoop\", sess.sid, err)\n\t\t}\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (sess *Session) writeLoop() {\n\tticker := time.NewTicker(pingPeriod)\n\n\tdefer func() {\n\t\tticker.Stop()\n\t\t// Break readLoop.\n\t\tsess.closeWS()\n\t}()\n\n\tfor {\n\t\tselect {\n\t\tcase msg, ok := <-sess.send:\n\t\t\tif !ok {\n\t\t\t\t// Channel closed.\n\t\t\t\treturn\n\t\t\t}\n\t\t\tswitch v := msg.(type) {\n\t\t\tcase []*ServerComMessage: // batch of unserialized messages\n\t\t\t\tfor _, msg := range v {\n\t\t\t\t\tw := sess.serializeAndUpdateStats(msg)\n\t\t\t\t\tif !sess.sendMessage(w) {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\tcase *ServerComMessage: // single unserialized message\n\t\t\t\tw := sess.serializeAndUpdateStats(v)\n\t\t\t\tif !sess.sendMessage(w) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\tdefault: // serialized message\n\t\t\t\tif !sess.sendMessage(v) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\tcase <-sess.bkgTimer.C:\n\t\t\tif sess.background {\n\t\t\t\tsess.background = false\n\t\t\t\tsess.onBackgroundTimer()\n\t\t\t}\n\n\t\tcase msg := <-sess.stop:\n\t\t\t// Shutdown requested, don't care if the message is delivered\n\t\t\tif msg != nil {\n\t\t\t\twsWrite(sess.ws, websocket.TextMessage, msg)\n\t\t\t}\n\t\t\treturn\n\n\t\tcase topic := <-sess.detach:\n\t\t\tsess.delSub(topic)\n\n\t\tcase <-ticker.C:\n\t\t\tif err := wsWrite(sess.ws, websocket.PingMessage, nil); err != nil {\n\t\t\t\tif websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure,\n\t\t\t\t\twebsocket.CloseNormalClosure) {\n\t\t\t\t\tlogs.Err.Println(\"ws: writeLoop ping\", sess.sid, err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n}\n\n// Writes a message with the given message type (mt) and payload.\nfunc wsWrite(ws *websocket.Conn, mt int, msg any) error {\n\tvar bits []byte\n\tif msg != nil {\n\t\tbits = msg.([]byte)\n\t} else {\n\t\tbits = []byte{}\n\t}\n\tws.SetWriteDeadline(time.Now().Add(writeWait))\n\treturn ws.WriteMessage(mt, bits)\n}\n\n// Handles websocket requests from peers.\nvar upgrader = websocket.Upgrader{\n\tReadBufferSize:    1024,\n\tWriteBufferSize:   1024,\n\tEnableCompression: globals.wsCompression,\n\t// Allow connections from any Origin\n\tCheckOrigin: func(r *http.Request) bool { return true },\n}\n\nfunc serveWebSocket(wrt http.ResponseWriter, req *http.Request) {\n\tnow := types.TimeNow()\n\n\tif isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {\n\t\twrt.WriteHeader(http.StatusForbidden)\n\t\tjson.NewEncoder(wrt).Encode(ErrAPIKeyRequired(now))\n\t\tlogs.Err.Println(\"ws: Missing, invalid or expired API key\")\n\t\treturn\n\t}\n\n\tif req.Method != http.MethodGet {\n\t\twrt.WriteHeader(http.StatusMethodNotAllowed)\n\t\tjson.NewEncoder(wrt).Encode(ErrOperationNotAllowed(\"\", \"\", now))\n\t\tlogs.Err.Println(\"ws: Invalid HTTP method\", req.Method)\n\t\treturn\n\t}\n\n\tws, err := upgrader.Upgrade(wrt, req, nil)\n\tif _, ok := err.(websocket.HandshakeError); ok {\n\t\tlogs.Err.Println(\"ws: Not a websocket handshake\")\n\t\treturn\n\t} else if err != nil {\n\t\tlogs.Err.Println(\"ws: failed to Upgrade \", err)\n\t\treturn\n\t}\n\n\tsess, count := globals.sessionStore.NewSession(ws, \"\")\n\tif globals.useXForwardedFor {\n\t\tsess.remoteAddr = req.Header.Get(\"X-Forwarded-For\")\n\t\tif !isRoutableIP(sess.remoteAddr) {\n\t\t\tsess.remoteAddr = \"\"\n\t\t}\n\t}\n\tif sess.remoteAddr == \"\" {\n\t\tsess.remoteAddr = req.RemoteAddr\n\t}\n\n\tlogs.Info.Println(\"ws: session started\", sess.sid, sess.remoteAddr, count)\n\n\t// Do work in goroutines to return from serveWebSocket() to release file pointers.\n\t// Otherwise \"too many open files\" will happen.\n\tgo sess.writeLoop()\n\tgo sess.readLoop()\n}\n"
  },
  {
    "path": "server/http.go",
    "content": "/******************************************************************************\n *\n *  Description :\n *\n *  Web server initialization and shutdown.\n *\n *****************************************************************************/\n\npackage main\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/signal\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\nfunc listenAndServe(addr string, mux *http.ServeMux, tlfConf *tls.Config, stop <-chan bool) error {\n\tglobals.shuttingDown = false\n\n\thttpdone := make(chan bool)\n\n\tserver := &http.Server{\n\t\tHandler:           mux,\n\t\tReadHeaderTimeout: 10 * time.Second,\n\t\tIdleTimeout:       30 * time.Second,\n\t\tWriteTimeout:      90 * time.Second,\n\t\tMaxHeaderBytes:    1 << 14,\n\t}\n\n\tserver.TLSConfig = tlfConf\n\n\tgo func() {\n\t\tvar err error\n\t\tif server.TLSConfig != nil {\n\t\t\t// If port is not specified, use default https port (443),\n\t\t\t// otherwise it will default to 80\n\t\t\tif addr == \"\" {\n\t\t\t\taddr = \":https\"\n\t\t\t}\n\n\t\t\tif globals.tlsRedirectHTTP != \"\" {\n\t\t\t\t// Serving redirects from a unix socket or to a unix socket makes no sense.\n\t\t\t\tif isUnixAddr(globals.tlsRedirectHTTP) || isUnixAddr(addr) {\n\t\t\t\t\terr = errors.New(\"HTTP to HTTPS redirect: unix sockets not supported\")\n\t\t\t\t} else {\n\t\t\t\t\tlogs.Info.Printf(\"Redirecting connections from HTTP at [%s] to HTTPS at [%s]\",\n\t\t\t\t\t\tglobals.tlsRedirectHTTP, addr)\n\n\t\t\t\t\t// This is a second HTTP server listenning on a different port.\n\t\t\t\t\tgo func() {\n\t\t\t\t\t\tif err := http.ListenAndServe(globals.tlsRedirectHTTP, tlsRedirect(addr)); err != nil && err != http.ErrServerClosed {\n\t\t\t\t\t\t\tlogs.Info.Println(\"HTTP redirect failed:\", err)\n\t\t\t\t\t\t}\n\t\t\t\t\t}()\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif err == nil {\n\t\t\t\tlogs.Info.Printf(\"Listening for client HTTPS connections on [%s]\", addr)\n\t\t\t\tvar lis net.Listener\n\t\t\t\tlis, err = netListener(addr)\n\t\t\t\tif err == nil {\n\t\t\t\t\terr = server.ServeTLS(lis, \"\", \"\")\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tlogs.Info.Printf(\"Listening for client HTTP connections on [%s]\", addr)\n\t\t\tvar lis net.Listener\n\t\t\tlis, err = netListener(addr)\n\t\t\tif err == nil {\n\t\t\t\terr = server.Serve(lis)\n\t\t\t}\n\t\t}\n\n\t\tif err != nil {\n\t\t\tif globals.shuttingDown {\n\t\t\t\tlogs.Info.Println(\"HTTP server: stopped\")\n\t\t\t} else {\n\t\t\t\tlogs.Err.Println(\"HTTP server: failed\", err)\n\t\t\t}\n\t\t}\n\t\thttpdone <- true\n\t}()\n\n\t// Wait for either a termination signal or an error\nLoop:\n\tfor {\n\t\tselect {\n\t\tcase <-stop:\n\t\t\t// Flip the flag that we are terminating and close the Accept-ing socket, so no new connections are possible.\n\t\t\tglobals.shuttingDown = true\n\t\t\t// Give server 2 seconds to shut down.\n\t\t\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\t\t\tif err := server.Shutdown(ctx); err != nil {\n\t\t\t\t// failure/timeout shutting down the server gracefully\n\t\t\t\tlogs.Err.Println(\"HTTP server failed to terminate gracefully\", err)\n\t\t\t}\n\n\t\t\t// While the server shuts down, termianate all sessions.\n\t\t\tglobals.sessionStore.Shutdown()\n\n\t\t\t// Wait for http server to stop Accept()-ing connections.\n\t\t\t<-httpdone\n\t\t\tcancel()\n\n\t\t\t// Shutdown local cluster node, if it's a part of a cluster.\n\t\t\tglobals.cluster.shutdown()\n\n\t\t\t// Terminate plugin connections.\n\t\t\tpluginsShutdown()\n\n\t\t\t// Shutdown gRPC server, if one is configured.\n\t\t\tif globals.grpcServer != nil {\n\t\t\t\t// GracefulStop does not terminate ServerStream. Must use Stop().\n\t\t\t\tglobals.grpcServer.Stop()\n\t\t\t}\n\n\t\t\t// Stop publishing statistics.\n\t\t\tstatsShutdown()\n\n\t\t\t// Shutdown the hub. The hub will shutdown topics.\n\t\t\thubdone := make(chan bool)\n\t\t\tglobals.hub.shutdown <- hubdone\n\n\t\t\t// Wait for the hub to finish.\n\t\t\t<-hubdone\n\n\t\t\t// Stop updating users cache\n\t\t\tusersShutdown()\n\n\t\t\tbreak Loop\n\n\t\tcase <-httpdone:\n\t\t\tbreak Loop\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc signalHandler() <-chan bool {\n\tstop := make(chan bool)\n\n\tsignchan := make(chan os.Signal, 1)\n\tsignal.Notify(signchan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)\n\n\tgo func() {\n\t\t// Wait for a signal. Don't care which signal it is\n\t\tsig := <-signchan\n\t\tlogs.Info.Printf(\"Signal received: '%s', shutting down\", sig)\n\t\tstop <- true\n\t}()\n\n\treturn stop\n}\n\n// The following code is used to intercept HTTP errors so they can be wrapped into json.\n\n// Wrapper around http.ResponseWriter which detects status set to 400+ and replaces\n// default error message with a custom one.\ntype errorResponseWriter struct {\n\tstatus int\n\thttp.ResponseWriter\n}\n\nfunc (w *errorResponseWriter) WriteHeader(status int) {\n\tif status >= http.StatusBadRequest {\n\t\t// charset=utf-8 is the default. No need to write it explicitly\n\t\t// Must set all the headers before calling super.WriteHeader()\n\t\tw.ResponseWriter.Header().Set(\"Content-Type\", \"application/json\")\n\t}\n\tw.status = status\n\tw.ResponseWriter.WriteHeader(status)\n}\n\nfunc (w *errorResponseWriter) Write(p []byte) (n int, err error) {\n\tif w.status >= http.StatusBadRequest {\n\t\tp, _ = json.Marshal(\n\t\t\t&ServerComMessage{\n\t\t\t\tCtrl: &MsgServerCtrl{\n\t\t\t\t\tTimestamp: time.Now().UTC().Round(time.Millisecond),\n\t\t\t\t\tCode:      w.status,\n\t\t\t\t\tText:      http.StatusText(w.status),\n\t\t\t\t},\n\t\t\t})\n\t}\n\treturn w.ResponseWriter.Write(p)\n}\n\n// httpErrorHandler to respond with JSON_formatted error message for static content.\nfunc httpErrorHandler(h http.Handler) http.Handler {\n\treturn http.HandlerFunc(\n\t\tfunc(w http.ResponseWriter, r *http.Request) {\n\t\t\th.ServeHTTP(&errorResponseWriter{0, w}, r)\n\t\t})\n}\n\n// Custom 404 response.\nfunc serve404(wrt http.ResponseWriter, req *http.Request) {\n\twrt.Header().Set(\"Content-Type\", \"application/json; charset=utf-8\")\n\twrt.WriteHeader(http.StatusNotFound)\n\tjson.NewEncoder(wrt).Encode(\n\t\t&ServerComMessage{\n\t\t\tCtrl: &MsgServerCtrl{\n\t\t\t\tTimestamp: time.Now().UTC().Round(time.Millisecond),\n\t\t\t\tCode:      http.StatusNotFound,\n\t\t\t\tText:      \"not found\",\n\t\t\t},\n\t\t})\n}\n\n// Redirect HTTP requests to HTTPS\nfunc tlsRedirect(toPort string) http.HandlerFunc {\n\tif toPort == \":443\" || toPort == \":https\" {\n\t\ttoPort = \"\"\n\t} else if toPort != \"\" && toPort[:1] == \":\" {\n\t\t// Strip leading colon. JoinHostPort will add it back.\n\t\ttoPort = toPort[1:]\n\t}\n\n\treturn func(wrt http.ResponseWriter, req *http.Request) {\n\t\thost, _, err := net.SplitHostPort(req.Host)\n\t\tif err != nil {\n\t\t\t// If SplitHostPort has failed assume it's because :port part is missing.\n\t\t\thost = req.Host\n\t\t}\n\n\t\ttarget, _ := url.ParseRequestURI(req.RequestURI)\n\t\ttarget.Scheme = \"https\"\n\n\t\t// Ensure valid redirect target.\n\t\tif toPort != \"\" {\n\t\t\t// Replace the port number.\n\t\t\ttarget.Host = net.JoinHostPort(host, toPort)\n\t\t} else {\n\t\t\ttarget.Host = host\n\t\t}\n\n\t\tif target.Path == \"\" {\n\t\t\ttarget.Path = \"/\"\n\t\t}\n\n\t\thttp.Redirect(wrt, req, target.String(), http.StatusTemporaryRedirect)\n\t}\n}\n\n// Wrapper for adding optional HTTP headers:\n//   - Strict-Transport-Security\n//   - X-Frame-Options\n//   - Referrer-Policy\nfunc optionalHttpHeaders(handler http.Handler) http.Handler {\n\th1 := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Referrer-Policy\", \"origin\")\n\t\thandler.ServeHTTP(w, r)\n\t})\n\n\th2 := h1\n\tif globals.tlsStrictMaxAge != \"\" {\n\t\th2 = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.Header().Set(\"Strict-Transport-Security\", \"max-age=\"+globals.tlsStrictMaxAge)\n\t\t\th1.ServeHTTP(w, r)\n\t\t})\n\t}\n\n\th3 := h2\n\tif globals.xFrameOptions != \"-\" {\n\t\th3 = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.Header().Set(\"X-Frame-Options\", globals.xFrameOptions)\n\t\t\th2.ServeHTTP(w, r)\n\t\t})\n\t}\n\treturn h3\n}\n\n// Wrapper for http.Handler which optionally adds a Cache-Control header to the response\nfunc cacheControlHandler(maxAge int, handler http.Handler) http.Handler {\n\tif maxAge > 0 {\n\t\tstrMaxAge := strconv.Itoa(maxAge)\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tw.Header().Set(\"Cache-Control\", \"must-revalidate, public, max-age=\"+strMaxAge)\n\t\t\thandler.ServeHTTP(w, r)\n\t\t})\n\t}\n\treturn handler\n}\n\n// Get API key from an HTTP request.\nfunc getAPIKey(req *http.Request) string {\n\t// Check header.\n\tapikey := req.Header.Get(\"X-Tinode-APIKey\")\n\tif apikey != \"\" {\n\t\treturn apikey\n\t}\n\n\t// Check URL query parameters.\n\tapikey = req.URL.Query().Get(\"apikey\")\n\tif apikey != \"\" {\n\t\treturn apikey\n\t}\n\n\t// Check form values.\n\tapikey = req.FormValue(\"apikey\")\n\tif apikey != \"\" {\n\t\treturn apikey\n\t}\n\n\t// Check cookies.\n\tif c, err := req.Cookie(\"apikey\"); err == nil {\n\t\tapikey = c.Value\n\t}\n\n\treturn apikey\n}\n\n// Extracts authorization credentials from an HTTP request.\n// Returns authentication method and secret.\nfunc getHttpAuth(req *http.Request) (method, secret string) {\n\t// Check X-Tinode-Auth header.\n\tif parts := strings.Split(req.Header.Get(\"X-Tinode-Auth\"), \" \"); len(parts) == 2 {\n\t\tmethod, secret = parts[0], parts[1]\n\t\treturn\n\t}\n\n\t// Check canonical Authorization header.\n\tif parts := strings.Split(req.Header.Get(\"Authorization\"), \" \"); len(parts) == 2 {\n\t\tmethod, secret = parts[0], parts[1]\n\t\treturn\n\t}\n\n\t// Check URL query parameters.\n\tif method = req.URL.Query().Get(\"auth\"); method != \"\" {\n\t\t// Get the auth secret.\n\t\tsecret = req.URL.Query().Get(\"secret\")\n\t\t// Convert base64 URL-encoding to standard encoding.\n\t\tsecret = strings.NewReplacer(\"-\", \"+\", \"_\", \"/\").Replace(secret)\n\t\treturn\n\t}\n\n\t// Check form values.\n\tif method = req.FormValue(\"auth\"); method != \"\" {\n\t\treturn method, req.FormValue(\"secret\")\n\t}\n\n\t// Check cookies as the last resort.\n\tif mcookie, err := req.Cookie(\"auth\"); err == nil {\n\t\tif scookie, err := req.Cookie(\"secret\"); err == nil {\n\t\t\tmethod, secret = mcookie.Value, scookie.Value\n\t\t}\n\t}\n\n\treturn\n}\n\n// Obtain IP address of the client.\nfunc getRemoteAddr(req *http.Request) string {\n\tvar addr string\n\tif globals.useXForwardedFor {\n\t\taddr = req.Header.Get(\"X-Forwarded-For\")\n\t\tif !isRoutableIP(addr) {\n\t\t\taddr = \"\"\n\t\t}\n\t}\n\tif addr != \"\" {\n\t\treturn addr\n\t}\n\treturn req.RemoteAddr\n}\n\n// debugSession is session debug info.\ntype debugSession struct {\n\tRemoteAddr string   `json:\"remote_addr,omitempty\"`\n\tUa         string   `json:\"ua,omitempty\"`\n\tUid        string   `json:\"uid,omitempty\"`\n\tSid        string   `json:\"sid,omitempty\"`\n\tClnode     string   `json:\"clnode,omitempty\"`\n\tSubs       []string `json:\"subs,omitempty\"`\n}\n\n// debugTopic is a topic debug info.\ntype debugTopic struct {\n\tTopic    string   `json:\"topic,omitempty\"`\n\tXorig    string   `json:\"xorig,omitempty\"`\n\tIsProxy  bool     `json:\"is_proxy,omitempty\"`\n\tPerUser  []string `json:\"per_user,omitempty\"`\n\tPerSubs  []string `json:\"per_subs,omitempty\"`\n\tSessions []string `json:\"sessions,omitempty\"`\n}\n\n// debugCachedUser is a user cache entry debug info.\ntype debugCachedUser struct {\n\tUid    string `json:\"uid,omitempty\"`\n\tUnread int    `json:\"unread,omitempty\"`\n\tTopics int    `json:\"topics,omitempty\"`\n}\n\n// debugDump is server internal state dump for debugging.\ntype debugDump struct {\n\tVersion   string            `json:\"server_version,omitempty\"`\n\tBuild     string            `json:\"build_id,omitempty\"`\n\tTimestamp time.Time         `json:\"ts,omitempty\"`\n\tSessions  []debugSession    `json:\"sessions,omitempty\"`\n\tTopics    []debugTopic      `json:\"topics,omitempty\"`\n\tUserCache []debugCachedUser `json:\"user_cache,omitempty\"`\n}\n\nfunc serveStatus(wrt http.ResponseWriter, req *http.Request) {\n\twrt.Header().Set(\"Content-Type\", \"application/json\")\n\n\tresult := &debugDump{\n\t\tVersion:   currentVersion,\n\t\tBuild:     buildstamp,\n\t\tTimestamp: types.TimeNow(),\n\t\tSessions:  make([]debugSession, 0, len(globals.sessionStore.sessCache)),\n\t\tTopics:    make([]debugTopic, 0, 10),\n\t\tUserCache: make([]debugCachedUser, 0, 10),\n\t}\n\t// Sessions.\n\tglobals.sessionStore.Range(func(sid string, s *Session) bool {\n\t\tkeys := make([]string, 0, len(s.subs))\n\t\tfor tn := range s.subs {\n\t\t\tkeys = append(keys, tn)\n\t\t}\n\t\tsort.Strings(keys)\n\t\tvar clnode string\n\t\tif s.clnode != nil {\n\t\t\tclnode = s.clnode.name\n\t\t}\n\t\tresult.Sessions = append(result.Sessions, debugSession{\n\t\t\tRemoteAddr: s.remoteAddr,\n\t\t\tUa:         s.userAgent,\n\t\t\tUid:        s.uid.String(),\n\t\t\tSid:        sid,\n\t\t\tClnode:     clnode,\n\t\t\tSubs:       keys,\n\t\t})\n\t\treturn true\n\t})\n\t// Topics.\n\tglobals.hub.topics.Range(func(_, t any) bool {\n\t\ttopic := t.(*Topic)\n\t\tpsd := make([]string, 0, len(topic.sessions))\n\t\tfor s := range topic.sessions {\n\t\t\tpsd = append(psd, s.sid)\n\t\t}\n\t\tpud := make([]string, 0, len(topic.perUser))\n\t\tfor uid := range topic.perUser {\n\t\t\tpud = append(pud, uid.String())\n\t\t}\n\t\tps := make([]string, 0, len(topic.perSubs))\n\t\tfor key := range topic.perSubs {\n\t\t\tps = append(ps, key)\n\t\t}\n\t\tresult.Topics = append(result.Topics, debugTopic{\n\t\t\tTopic:    topic.name,\n\t\t\tXorig:    topic.xoriginal,\n\t\t\tIsProxy:  topic.isProxy,\n\t\t\tPerUser:  pud,\n\t\t\tPerSubs:  ps,\n\t\t\tSessions: psd,\n\t\t})\n\t\treturn true\n\t})\n\tfor k, v := range usersCache {\n\t\tresult.UserCache = append(result.UserCache, debugCachedUser{\n\t\t\tUid:    k.UserId(),\n\t\t\tUnread: v.unread,\n\t\t\tTopics: v.topics,\n\t\t})\n\t}\n\n\tjson.NewEncoder(wrt).Encode(result)\n}\n"
  },
  {
    "path": "server/http_pprof.go",
    "content": "// Debug tooling. Dumps named profile in response to HTTP request at\n// \t\thttp(s)://<host-name>/<configured-path>/<profile-name>\n// See godoc for the list of possible profile names: https://golang.org/pkg/runtime/pprof/#Profile\n\npackage main\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"path\"\n\t\"runtime/pprof\"\n\t\"strings\"\n\n\t\"github.com/tinode/chat/server/logs\"\n)\n\nvar pprofHttpRoot string\n\n// Expose debug profiling at the given URL path.\nfunc servePprof(mux *http.ServeMux, serveAt string) {\n\tif serveAt == \"\" || serveAt == \"-\" {\n\t\treturn\n\t}\n\n\tpprofHttpRoot = path.Clean(\"/\"+serveAt) + \"/\"\n\tmux.HandleFunc(pprofHttpRoot, profileHandler)\n\n\tlogs.Info.Printf(\"pprof: profiling info exposed at '%s'\", pprofHttpRoot)\n}\n\nfunc profileHandler(wrt http.ResponseWriter, req *http.Request) {\n\twrt.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\twrt.Header().Set(\"Content-Type\", \"text/plain; charset=utf-8\")\n\n\tprofileName := strings.TrimPrefix(req.URL.Path, pprofHttpRoot)\n\n\tprofile := pprof.Lookup(profileName)\n\tif profile == nil {\n\t\tservePprofError(wrt, http.StatusNotFound, \"Unknown profile '\"+profileName+\"'\")\n\t\treturn\n\t}\n\n\t// Respond with the requested profile.\n\tprofile.WriteTo(wrt, 2)\n}\n\nfunc servePprofError(wrt http.ResponseWriter, status int, txt string) {\n\twrt.Header().Set(\"Content-Type\", \"text/plain; charset=utf-8\")\n\twrt.Header().Set(\"X-Go-Pprof\", \"1\")\n\twrt.Header().Del(\"Content-Disposition\")\n\twrt.WriteHeader(status)\n\tfmt.Fprintln(wrt, txt)\n}\n"
  },
  {
    "path": "server/hub.go",
    "content": "/******************************************************************************\n *\n *  Description :\n *\n *    Main hub for processing events such as creating/tearing down topics,\n *    routing messages between topics.\n *\n *****************************************************************************/\n\npackage main\n\nimport (\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/auth\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\n// RequestLatencyDistribution is an array of request latency distribution bounds (in milliseconds).\n// \"var\" because Go does not support array constants.\nvar requestLatencyDistribution = []float64{1, 2, 3, 4, 5, 6, 8, 10, 13, 16, 20, 25, 30, 40, 50, 65, 80, 100, 130,\n\t160, 200, 250, 300, 400, 500, 650, 800, 1000, 2000, 5000, 10000, 20000, 50000, 100000}\n\n// OutgoingMessageSizeDistribution is an array of outgoing message size distribution bounds (in bytes).\nvar outgoingMessageSizeDistribution = []float64{1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 16384,\n\t65536, 262144, 1048576, 4194304, 16777216, 67108864, 268435456, 1073741824, 4294967296}\n\n// Request to hub to remove the topic\ntype topicUnreg struct {\n\t// Original request, could be nil.\n\tpkt *ClientComMessage\n\t// Session making the request, could be nil.\n\tsess *Session\n\t// Routable name of the topic to drop. Duplicated here because pkt could be nil.\n\trcptTo string\n\t// UID of the user being deleted. Duplicated here because pkt could be nil.\n\tforUser types.Uid\n\t// Unregister then delete the topic.\n\tdel bool\n\t// Channel for reporting operation completion when deleting topics for a user.\n\tdone chan<- bool\n}\n\ntype userStatusReq struct {\n\t// UID of the user being affected.\n\tforUser types.Uid\n\t// New topic state value. Only types.StateSuspended is supported at this time.\n\tstate types.ObjState\n}\n\n// Hub is the core structure which holds topics.\ntype Hub struct {\n\n\t// Topics must be indexed by name\n\ttopics *sync.Map\n\n\t// Current number of loaded topics\n\tnumTopics int\n\n\t// Channel for routing client-side messages, buffered at 4096\n\trouteCli chan *ClientComMessage\n\n\t// Process get.info requests for topic not subscribed to, buffered 128.\n\tmeta chan *ClientComMessage\n\n\t// Channel for routing server-generated messages, buffered at 4096\n\trouteSrv chan *ServerComMessage\n\n\t// subscribe session to topic, possibly creating a new topic, buffered at 256\n\tjoin chan *ClientComMessage\n\n\t// Remove topic from hub, possibly deleting it afterwards, buffered at 32\n\tunreg chan *topicUnreg\n\n\t// Channel for suspending/resuming users, buffered 128.\n\tuserStatus chan *userStatusReq\n\n\t// Cluster request to rehash topics, unbuffered\n\trehash chan bool\n\n\t// Request to shutdown, unbuffered\n\tshutdown chan chan<- bool\n}\n\nfunc (h *Hub) topicGet(name string) *Topic {\n\tif t, ok := h.topics.Load(name); ok {\n\t\treturn t.(*Topic)\n\t}\n\treturn nil\n}\n\nfunc (h *Hub) topicPut(name string, t *Topic) {\n\th.numTopics++\n\th.topics.Store(name, t)\n}\n\nfunc (h *Hub) topicDel(name string) {\n\th.numTopics--\n\th.topics.Delete(name)\n}\n\nfunc newHub() *Hub {\n\th := &Hub{\n\t\ttopics: &sync.Map{},\n\t\t// TODO: verify if these channels have to be buffered.\n\t\trouteCli:   make(chan *ClientComMessage, 4096),\n\t\trouteSrv:   make(chan *ServerComMessage, 4096),\n\t\tjoin:       make(chan *ClientComMessage, 256),\n\t\tunreg:      make(chan *topicUnreg, 256),\n\t\trehash:     make(chan bool),\n\t\tmeta:       make(chan *ClientComMessage, 128),\n\t\tuserStatus: make(chan *userStatusReq, 128),\n\t\tshutdown:   make(chan chan<- bool),\n\t}\n\n\tstatsRegisterInt(\"LiveTopics\")\n\tstatsRegisterInt(\"TotalTopics\")\n\n\tstatsRegisterInt(\"IncomingMessagesWebsockTotal\")\n\tstatsRegisterInt(\"OutgoingMessagesWebsockTotal\")\n\n\tstatsRegisterInt(\"IncomingMessagesLongpollTotal\")\n\tstatsRegisterInt(\"OutgoingMessagesLongpollTotal\")\n\n\tstatsRegisterInt(\"IncomingMessagesGrpcTotal\")\n\tstatsRegisterInt(\"OutgoingMessagesGrpcTotal\")\n\n\tstatsRegisterInt(\"FileDownloadsTotal\")\n\tstatsRegisterInt(\"FileUploadsTotal\")\n\n\tstatsRegisterInt(\"CtrlCodesTotal2xx\")\n\tstatsRegisterInt(\"CtrlCodesTotal3xx\")\n\tstatsRegisterInt(\"CtrlCodesTotal4xx\")\n\tstatsRegisterInt(\"CtrlCodesTotal5xx\")\n\n\tstatsRegisterHistogram(\"RequestLatency\", requestLatencyDistribution)\n\tstatsRegisterHistogram(\"OutgoingMessageSize\", outgoingMessageSizeDistribution)\n\n\tgo h.run()\n\n\t// Initialize 'sys' topic. It will be initialized either as master or proxy.\n\th.join <- &ClientComMessage{RcptTo: \"sys\", Original: \"sys\"}\n\n\treturn h\n}\n\nfunc (h *Hub) run() {\n\tfor {\n\t\tselect {\n\t\tcase join := <-h.join:\n\t\t\t// Handle a subscription request:\n\t\t\t// 1. Init topic\n\t\t\t// 1.1 If a new topic is requested, create it\n\t\t\t// 1.2 If a new subscription to an existing topic is requested:\n\t\t\t// 1.2.1 check if topic is already loaded\n\t\t\t// 1.2.2 if not, load it\n\t\t\t// 1.2.3 if it cannot be loaded (not found), fail\n\t\t\t// 2. Check access rights and reject, if appropriate\n\t\t\t// 3. Attach session to the topic\n\t\t\t// Is the topic already loaded?\n\t\t\tt := h.topicGet(join.RcptTo)\n\t\t\tif t == nil {\n\t\t\t\t// Topic does not exist or not loaded.\n\t\t\t\tt = &Topic{\n\t\t\t\t\tname:      join.RcptTo,\n\t\t\t\t\txoriginal: join.Original,\n\t\t\t\t\t// Indicates a proxy topic.\n\t\t\t\t\tisProxy:   globals.cluster.isRemoteTopic(join.RcptTo),\n\t\t\t\t\tsessions:  make(map[*Session]perSessionData),\n\t\t\t\t\tclientMsg: make(chan *ClientComMessage, 192),\n\t\t\t\t\tserverMsg: make(chan *ServerComMessage, 64),\n\t\t\t\t\treg:       make(chan *ClientComMessage, 256),\n\t\t\t\t\tunreg:     make(chan *ClientComMessage, 256),\n\t\t\t\t\tmeta:      make(chan *ClientComMessage, 64),\n\t\t\t\t\tperUser:   make(map[types.Uid]perUserData),\n\t\t\t\t\texit:      make(chan *shutDown, 1),\n\t\t\t\t}\n\t\t\t\tif globals.cluster != nil {\n\t\t\t\t\tif t.isProxy {\n\t\t\t\t\t\tt.proxy = make(chan *ClusterResp, 128)\n\t\t\t\t\t\tt.masterNode = globals.cluster.ring.Get(t.name)\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// It's a master topic. Make a channel for handling\n\t\t\t\t\t\t// direct messages from the proxy.\n\t\t\t\t\t\tt.master = make(chan *ClusterSessUpdate, 8)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// Topic is created in suspended state because it's not yet configured.\n\t\t\t\tt.markPaused(true)\n\t\t\t\t// Save topic now to prevent race condition.\n\t\t\t\th.topicPut(join.RcptTo, t)\n\n\t\t\t\t// Configure the topic.\n\t\t\t\tgo topicInit(t, join, h)\n\t\t\t} else {\n\t\t\t\t// Topic found.\n\t\t\t\tif t.isInactive() {\n\t\t\t\t\t// Topic is either not ready or being deleted.\n\t\t\t\t\tif join.sess.inflightReqs != nil {\n\t\t\t\t\t\tjoin.sess.inflightReqs.Done()\n\t\t\t\t\t}\n\t\t\t\t\tjoin.sess.queueOut(ErrLockedReply(join, join.Timestamp))\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\t// Topic will check access rights and send appropriate {ctrl}\n\t\t\t\tselect {\n\t\t\t\tcase t.reg <- join:\n\t\t\t\tdefault:\n\t\t\t\t\tif join.sess.inflightReqs != nil {\n\t\t\t\t\t\tjoin.sess.inflightReqs.Done()\n\t\t\t\t\t}\n\t\t\t\t\tjoin.sess.queueOut(ErrServiceUnavailableReply(join, join.Timestamp))\n\t\t\t\t\tlogs.Err.Println(\"hub.join loop: topic's reg queue full\", join.RcptTo, join.sess.sid,\n\t\t\t\t\t\t\" - total queue len:\", len(t.reg))\n\t\t\t\t}\n\t\t\t}\n\n\t\tcase msg := <-h.routeCli:\n\t\t\t// This is a message from a session not subscribed to topic\n\t\t\t// Route incoming message to topic if topic permits such routing.\n\t\t\tif dst := h.topicGet(msg.RcptTo); dst != nil {\n\t\t\t\t// Everything is OK, sending packet to known topic\n\t\t\t\tif dst.clientMsg != nil {\n\t\t\t\t\tselect {\n\t\t\t\t\tcase dst.clientMsg <- msg:\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tlogs.Err.Println(\"hub: topic's broadcast queue is full\", dst.name)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tlogs.Warn.Println(\"hub: invalid topic category for broadcast\", dst.name)\n\t\t\t\t}\n\t\t\t} else if msg.Note == nil {\n\t\t\t\t// Topic is unknown or offline.\n\t\t\t\t// Note is silently ignored, all other messages are reported as accepted to prevent\n\t\t\t\t// clients from guessing valid topic names.\n\n\t\t\t\t// TODO(gene): validate topic name, discarding invalid topics.\n\n\t\t\t\tlogs.Info.Printf(\"Hub. Topic[%s] is unknown or offline\", msg.RcptTo)\n\n\t\t\t\tmsg.sess.queueOut(NoErrAcceptedExplicitTs(msg.Id, msg.RcptTo, types.TimeNow(), msg.Timestamp))\n\t\t\t}\n\t\tcase msg := <-h.routeSrv:\n\t\t\t// This is a server message from a connection not subscribed to topic\n\t\t\t// Route incoming message to topic if topic permits such routing.\n\t\t\tif dst := h.topicGet(msg.RcptTo); dst != nil {\n\t\t\t\t// Everything is OK, sending packet to known topic\n\t\t\t\tselect {\n\t\t\t\tcase dst.serverMsg <- msg:\n\t\t\t\tdefault:\n\t\t\t\t\tlogs.Err.Println(\"hub: topic's broadcast queue is full\", dst.name)\n\t\t\t\t}\n\t\t\t} else if (strings.HasPrefix(msg.RcptTo, \"usr\") || strings.HasPrefix(msg.RcptTo, \"grp\")) &&\n\t\t\t\tglobals.cluster.isRemoteTopic(msg.RcptTo) {\n\t\t\t\t// It is a remote topic.\n\t\t\t\tif err := globals.cluster.routeToTopicIntraCluster(msg.RcptTo, msg, msg.sess); err != nil {\n\t\t\t\t\tlogs.Warn.Printf(\"hub: routing to '%s' failed\", msg.RcptTo)\n\t\t\t\t}\n\t\t\t}\n\t\tcase msg := <-h.meta:\n\t\t\t// Metadata read or update from a user who is not attached to the topic.\n\t\t\tif msg.Get != nil {\n\t\t\t\tif msg.MetaWhat == constMsgMetaDesc {\n\t\t\t\t\tgo replyOfflineTopicGetDesc(msg.sess, msg)\n\t\t\t\t} else {\n\t\t\t\t\tgo replyOfflineTopicGetSub(msg.sess, msg)\n\t\t\t\t}\n\t\t\t} else if msg.Set != nil {\n\t\t\t\tgo replyOfflineTopicSetSub(msg.sess, msg)\n\t\t\t}\n\n\t\tcase status := <-h.userStatus:\n\t\t\t// Suspend/activate user's topics.\n\t\t\tgo h.topicsStateForUser(status.forUser, status.state == types.StateSuspended)\n\n\t\tcase unreg := <-h.unreg:\n\t\t\treason := StopNone\n\t\t\tif unreg.del {\n\t\t\t\treason = StopDeleted\n\t\t\t}\n\t\t\tif unreg.forUser.IsZero() {\n\t\t\t\t// The topic is being garbage collected or deleted.\n\t\t\t\tif err := h.topicUnreg(unreg.sess, unreg.rcptTo, unreg.pkt, reason); err != nil {\n\t\t\t\t\tlogs.Err.Println(\"hub.topicUnreg failed:\", err)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// User is being deleted.\n\t\t\t\tgo h.stopTopicsForUser(unreg.forUser, reason, unreg.done)\n\t\t\t}\n\n\t\tcase <-h.rehash:\n\t\t\t// Cluster rehashing. Some previously local topics became remote,\n\t\t\t// and the other way round.\n\t\t\t// Such topics must be shut down at this node.\n\t\t\th.topics.Range(func(_, t any) bool {\n\t\t\t\ttopic := t.(*Topic)\n\t\t\t\t// Handle two cases:\n\t\t\t\t// 1. Master topic has moved out to another node.\n\t\t\t\t// 2. Proxy topic is running on a new master node\n\t\t\t\t//    (i.e. the master topic has moved to this node).\n\t\t\t\tif topic.isProxy != globals.cluster.isRemoteTopic(topic.name) {\n\t\t\t\t\th.topicUnreg(nil, topic.name, nil, StopRehashing)\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\n\t\t\t// Check if 'sys' topic has migrated to this node.\n\t\t\tif h.topicGet(\"sys\") == nil && !globals.cluster.isRemoteTopic(\"sys\") {\n\t\t\t\t// Yes, 'sys' has migrated here. Initialize it.\n\t\t\t\t// The h.join is unbuffered. We must call from another goroutine. Otherwise deadlock.\n\t\t\t\tgo func() {\n\t\t\t\t\th.join <- &ClientComMessage{RcptTo: \"sys\", Original: \"sys\"}\n\t\t\t\t}()\n\t\t\t}\n\n\t\tcase hubdone := <-h.shutdown:\n\t\t\t// start cleanup process\n\t\t\ttopicsdone := make(chan bool)\n\t\t\ttopicCount := 0\n\t\t\th.topics.Range(func(_, topic any) bool {\n\t\t\t\ttopic.(*Topic).exit <- &shutDown{done: topicsdone}\n\t\t\t\ttopicCount++\n\t\t\t\treturn true\n\t\t\t})\n\n\t\t\tfor range topicCount {\n\t\t\t\t<-topicsdone\n\t\t\t}\n\n\t\t\tlogs.Info.Printf(\"Hub shutdown completed with %d topics\", topicCount)\n\n\t\t\t// let the main goroutine know we are done with the cleanup\n\t\t\thubdone <- true\n\n\t\t\treturn\n\n\t\tcase <-time.After(idleSessionTimeout):\n\t\t}\n\t}\n}\n\n// Update state of all topics associated with the given user:\n// * all p2p topics with the given user\n// * group topics where the given user is the owner.\n// 'me' and fnd' are ignored here because they are direcly tied to the user object.\nfunc (h *Hub) topicsStateForUser(uid types.Uid, suspended bool) {\n\th.topics.Range(func(name any, t any) bool {\n\t\ttopic := t.(*Topic)\n\t\tif topic.cat == types.TopicCatMe || topic.cat == types.TopicCatFnd {\n\t\t\treturn true\n\t\t}\n\n\t\tif _, isMember := topic.perUser[uid]; (topic.cat == types.TopicCatP2P && isMember) || topic.owner == uid {\n\t\t\ttopic.markReadOnly(suspended)\n\n\t\t\t// Don't send \"off\" notification on suspension. They will be sent when the user is evicted.\n\t\t}\n\t\treturn true\n\t})\n}\n\n// topicUnreg deletes or unregisters the topic:\n//\n// Cases:\n// 1. Topic being deleted\n// 1.1 Topic is online\n// 1.1.1 If the requester is the owner or if it's the last sub in a p2p topic (p2p may be sent internally when the last user unsubscribes):\n// 1.1.1.1 Tell topic to stop accepting requests.\n// 1.1.1.2 Hub deletes the topic from database\n// 1.1.1.3 Hub unregisters the topic\n// 1.1.1.4 Hub informs the origin of success or failure\n// 1.1.1.5 Hub forwards request to topic\n// 1.1.1.6 Topic evicts all sessions\n// 1.1.1.7 Topic exits the run() loop\n// 1.1.2 If the requester is not the owner\n// 1.1.2.1 Send it to topic to be treated like {leave unsub=true}\n//\n// 1.2 Topic is offline\n// 1.2.1 If requester is the owner\n// 1.2.1.1 Hub deletes topic from database\n// 1.2.2 If not the owner\n// 1.2.2.1 Delete subscription from DB\n// 1.2.3 Hub informs the origin of success or failure\n// 1.2.4 Send notification to subscribers that the topic was deleted\n\n// 2. Topic is just being unregistered (topic is going offline)\n// 2.1 Unregister it with no further action\nfunc (h *Hub) topicUnreg(sess *Session, topic string, msg *ClientComMessage, reason int) error {\n\tnow := types.TimeNow()\n\n\t// TODO: when channel is deleted unsubscribe all devices from channel's FCM topic.\n\n\tif reason == StopDeleted {\n\t\tvar asUid types.Uid\n\t\tif msg != nil {\n\t\t\tasUid = types.ParseUserId(msg.AsUser)\n\t\t}\n\t\t// Case 1 (unregister and delete)\n\t\tif t := h.topicGet(topic); t != nil {\n\t\t\t// Case 1.1: topic is online\n\t\t\tif (!asUid.IsZero() && t.owner == asUid) || (t.cat == types.TopicCatP2P && t.subsCount() < 2) {\n\t\t\t\t// Case 1.1.1: requester is the owner or last sub in a p2p topic\n\t\t\t\tt.markPaused(true)\n\t\t\t\thard := true\n\t\t\t\tif msg != nil && msg.Del != nil {\n\t\t\t\t\t// Soft-deleting does not make sense for p2p topics.\n\t\t\t\t\thard = msg.Del.Hard || t.cat == types.TopicCatP2P\n\t\t\t\t}\n\t\t\t\tif err := store.Topics.Delete(topic, t.isChan, hard); err != nil {\n\t\t\t\t\tt.markPaused(false)\n\t\t\t\t\tif sess != nil {\n\t\t\t\t\t\tsess.queueOut(ErrUnknownReply(msg, now))\n\t\t\t\t\t}\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tif sess != nil {\n\t\t\t\t\tsess.queueOut(NoErrReply(msg, now))\n\t\t\t\t}\n\n\t\t\t\tif t.isChan {\n\t\t\t\t\t// Notify channel subscribers that the channel is deleted.\n\t\t\t\t\tsendPush(pushForChanDelete(t.name, now))\n\t\t\t\t}\n\n\t\t\t\th.topicDel(topic)\n\t\t\t\tt.markDeleted()\n\t\t\t\tt.exit <- &shutDown{reason: StopDeleted}\n\t\t\t\tstatsInc(\"LiveTopics\", -1)\n\t\t\t} else {\n\t\t\t\t// Case 1.1.2: requester is NOT the owner or not empty P2P.\n\t\t\t\tmsg.MetaWhat = constMsgDelTopic\n\t\t\t\tmsg.sess = sess\n\t\t\t\tt.meta <- msg\n\t\t\t}\n\t\t} else {\n\t\t\t// Case 1.2: topic is offline.\n\n\t\t\t// Is user a channel subscriber? Use chnABC instead of grpABC and get only this user's subscription.\n\t\t\tvar opts *types.QueryOpt\n\t\t\tif types.IsChannel(msg.Original) {\n\t\t\t\ttopic = msg.Original\n\t\t\t\topts = &types.QueryOpt{User: asUid}\n\t\t\t}\n\n\t\t\t// Get all subscribers of non-channel topics: we need to know how many are left and notify them.\n\t\t\t// Get only one subscription for channel users.\n\t\t\tsubs, err := store.Topics.GetSubs(topic, opts)\n\t\t\tif err != nil {\n\t\t\t\tsess.queueOut(ErrUnknownReply(msg, now))\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\ttcat := topicCat(topic)\n\t\t\tif len(subs) == 0 {\n\t\t\t\tif tcat == types.TopicCatP2P {\n\t\t\t\t\t// No subscribers: delete.\n\t\t\t\t\tstore.Topics.Delete(topic, false, true)\n\t\t\t\t}\n\t\t\t\tsess.queueOut(InfoNoActionReply(msg, now))\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\t// Find subscription of the current user.\n\t\t\tvar sub *types.Subscription\n\t\t\tuser := asUid.String()\n\t\t\tfor i := range subs {\n\t\t\t\tif subs[i].User == user {\n\t\t\t\t\tsub = &subs[i]\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif sub == nil {\n\t\t\t\t// If user has no subscription, tell him all is fine\n\t\t\t\tsess.queueOut(InfoNoActionReply(msg, now))\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tif !(sub.ModeGiven & sub.ModeWant).IsOwner() {\n\t\t\t\t// Case 1.2.2.1 Not the owner, but possibly last subscription in a P2P topic.\n\n\t\t\t\tif tcat == types.TopicCatP2P && len(subs) < 2 {\n\t\t\t\t\t// This is a P2P topic and fewer than 2 subscriptions, delete the entire topic\n\t\t\t\t\tif err := store.Topics.Delete(topic, false, msg.Del.Hard); err != nil {\n\t\t\t\t\t\tsess.queueOut(ErrUnknownReply(msg, now))\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t// Inform plugin that the topic was deleted.\n\t\t\t\t\tpluginTopic(&Topic{name: topic}, plgActDel)\n\t\t\t\t} else if err := store.Subs.Delete(topic, asUid); err != nil {\n\t\t\t\t\t// Not P2P or more than 1 subscription left.\n\t\t\t\t\t// Delete user's own subscription only\n\t\t\t\t\tif err == types.ErrNotFound {\n\t\t\t\t\t\tsess.queueOut(InfoNoActionReply(msg, now))\n\t\t\t\t\t\terr = nil\n\t\t\t\t\t} else {\n\t\t\t\t\t\tsess.queueOut(ErrUnknownReply(msg, now))\n\t\t\t\t\t}\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\t// Notify user's other sessions that the subscription is gone\n\t\t\t\tpresSingleUserOfflineOffline(asUid, msg.Original, \"gone\", nilPresParams, sess.sid)\n\t\t\t\tif tcat == types.TopicCatP2P && len(subs) == 2 {\n\t\t\t\t\tuname1 := asUid.UserId()\n\t\t\t\t\tuid2 := types.ParseUserId(msg.Original)\n\t\t\t\t\t// Tell user1 to stop sending updates to user2 without passing change to user1's sessions.\n\t\t\t\t\tpresSingleUserOfflineOffline(asUid, uid2.UserId(), \"?none+rem\", nilPresParams, \"\")\n\t\t\t\t\t// Don't change the online status of user1, just ask user2 to stop notification exchange.\n\t\t\t\t\t// Tell user2 that user1 is offline but let him keep sending updates in case user1 resubscribes.\n\t\t\t\t\tpresSingleUserOfflineOffline(uid2, uname1, \"off\", nilPresParams, \"\")\n\t\t\t\t}\n\n\t\t\t\t// Inform plugin that the subscription was deleted.\n\t\t\t\tpluginSubscription(sub, plgActDel)\n\t\t\t} else {\n\t\t\t\t// Case 1.2.1.1: owner, delete the group topic from db. Only group topics have owners.\n\t\t\t\t// We don't know if the group topic is a channel, but cleaning it as a channel does no harm\n\t\t\t\t// other than a small performance penalty.\n\t\t\t\tif err := store.Topics.Delete(topic, true, msg.Del.Hard); err != nil {\n\t\t\t\t\tsess.queueOut(ErrUnknownReply(msg, now))\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\t// Notify subscribers that the group topic is gone.\n\t\t\t\tpresSubsOfflineOffline(topic, tcat, subs, \"gone\", &presParams{}, sess.sid)\n\n\t\t\t\t// Notify channel subscribers that the channel is deleted.\n\t\t\t\t// The push will not be delivered to anybody if the topic is not a channel.\n\t\t\t\tsendPush(pushForChanDelete(topic, now))\n\n\t\t\t\t// Inform plugin that the topic was deleted.\n\t\t\t\tpluginTopic(&Topic{name: topic}, plgActDel)\n\t\t\t}\n\n\t\t\tsess.queueOut(NoErrReply(msg, now))\n\t\t}\n\t} else {\n\t\t// Case 2: just unregister.\n\t\t// If t is nil, it's not registered, no action is needed\n\t\tif t := h.topicGet(topic); t != nil {\n\t\t\tt.markDeleted()\n\t\t\th.topicDel(topic)\n\n\t\t\tt.exit <- &shutDown{reason: reason}\n\n\t\t\tstatsInc(\"LiveTopics\", -1)\n\t\t}\n\n\t\t// sess && msg could be nil if the topic is being killed by timer or due to rehashing.\n\t\tif sess != nil && msg != nil {\n\t\t\tsess.queueOut(NoErrReply(msg, now))\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// Terminate all topics associated with the given user:\n// * all p2p topics with the given user\n// * group topics where the given user is the owner.\n// * user's 'me', 'fnd', 'slf' topics.\nfunc (h *Hub) stopTopicsForUser(uid types.Uid, reason int, alldone chan<- bool) {\n\tvar done chan bool\n\tif alldone != nil {\n\t\tdone = make(chan bool, 128)\n\t}\n\n\tcount := 0\n\th.topics.Range(func(name any, t any) bool {\n\t\ttopic := t.(*Topic)\n\t\tif _, isMember := topic.perUser[uid]; (topic.cat != types.TopicCatGrp && isMember) ||\n\t\t\ttopic.owner == uid {\n\t\t\ttopic.markDeleted()\n\t\t\th.topics.Delete(name)\n\n\t\t\t// This call is non-blocking unless some other routine tries to stop it at the same time.\n\t\t\ttopic.exit <- &shutDown{reason: reason, done: done}\n\n\t\t\t// Just send to p2p topics here.\n\t\t\tif topic.cat == types.TopicCatP2P && len(topic.perUser) == 2 {\n\t\t\t\tpresSingleUserOfflineOffline(topic.p2pOtherUser(uid), uid.UserId(), \"gone\", nilPresParams, \"\")\n\t\t\t}\n\t\t\tcount++\n\t\t}\n\t\treturn true\n\t})\n\n\tstatsInc(\"LiveTopics\", -count)\n\n\tif alldone != nil {\n\t\tfor range count {\n\t\t\t<-done\n\t\t}\n\t\talldone <- true\n\t}\n}\n\n// replyOfflineTopicGetDesc reads a minimal topic Desc from the database.\n// The requester may or maynot be subscribed to the topic.\nfunc replyOfflineTopicGetDesc(sess *Session, msg *ClientComMessage) {\n\tnow := types.TimeNow()\n\tdesc := &MsgTopicDesc{}\n\tasUid := types.ParseUserId(msg.AsUser)\n\ttopic := msg.RcptTo\n\n\tif strings.HasPrefix(topic, \"grp\") || topic == \"sys\" {\n\t\tstopic, err := store.Topics.Get(topic)\n\t\tif err != nil {\n\t\t\tlogs.Info.Println(\"replyOfflineTopicGetDesc\", err)\n\t\t\tsess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil))\n\t\t\treturn\n\t\t}\n\t\tif stopic == nil {\n\t\t\tsess.queueOut(ErrTopicNotFoundReply(msg, now))\n\t\t\treturn\n\t\t}\n\n\t\tdesc.CreatedAt = &stopic.CreatedAt\n\t\tdesc.UpdatedAt = &stopic.UpdatedAt\n\t\tdesc.Public = stopic.Public\n\t\tdesc.Trusted = stopic.Trusted\n\t\tdesc.IsChan = stopic.UseBt\n\t\tdesc.SubCnt = stopic.SubCnt\n\t\tif stopic.Owner == msg.AsUser {\n\t\t\tdesc.DefaultAcs = &MsgDefaultAcsMode{\n\t\t\t\tAuth: stopic.Access.Auth.String(),\n\t\t\t\tAnon: stopic.Access.Anon.String(),\n\t\t\t}\n\t\t}\n\t\t// Report appropriate access level. Could be overridden below if subscription exists.\n\t\tdesc.Acs = &MsgAccessMode{}\n\t\tswitch sess.authLvl {\n\t\tcase auth.LevelAuth, auth.LevelRoot:\n\t\t\tdesc.Acs.Mode = stopic.Access.Auth.String()\n\t\tcase auth.LevelAnon:\n\t\t\tdesc.Acs.Mode = stopic.Access.Anon.String()\n\t\t}\n\t} else {\n\t\t// 'me' and p2p topics\n\t\tuid := types.ZeroUid\n\t\tif strings.HasPrefix(topic, \"usr\") {\n\t\t\t// User specified as usrXXX\n\t\t\tuid = types.ParseUserId(topic)\n\t\t\ttopic = asUid.P2PName(uid)\n\t\t} else if strings.HasPrefix(topic, \"p2p\") {\n\t\t\t// User specified as p2pXXXYYY\n\t\t\tuid1, uid2, _ := types.ParseP2P(topic)\n\t\t\tif uid1 == asUid {\n\t\t\t\tuid = uid2\n\t\t\t} else if uid2 == asUid {\n\t\t\t\tuid = uid1\n\t\t\t}\n\t\t}\n\n\t\tif uid.IsZero() {\n\t\t\tlogs.Warn.Println(\"replyOfflineTopicGetDesc: malformed p2p topic name\")\n\t\t\tsess.queueOut(ErrMalformedReply(msg, now))\n\t\t\treturn\n\t\t}\n\n\t\tsuser, err := store.Users.Get(uid)\n\t\tif err != nil {\n\t\t\tsess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil))\n\t\t\treturn\n\t\t}\n\t\tif suser == nil {\n\t\t\tsess.queueOut(ErrUserNotFoundReply(msg, now))\n\t\t\treturn\n\t\t}\n\t\tdesc.CreatedAt = &suser.CreatedAt\n\t\tdesc.UpdatedAt = &suser.UpdatedAt\n\t\tdesc.Public = suser.Public\n\t\tdesc.Trusted = suser.Trusted\n\t\tif sess.authLvl == auth.LevelRoot {\n\t\t\tdesc.State = suser.State.String()\n\t\t}\n\n\t\t// Report appropriate access level. Could be overridden below if subscription exists.\n\t\tdesc.Acs = &MsgAccessMode{}\n\t\tswitch sess.authLvl {\n\t\tcase auth.LevelAuth, auth.LevelRoot:\n\t\t\tdesc.Acs.Mode = suser.Access.Auth.String()\n\t\tcase auth.LevelAnon:\n\t\t\tdesc.Acs.Mode = suser.Access.Anon.String()\n\t\t}\n\t}\n\n\tsub, err := store.Subs.Get(topic, asUid, false)\n\tif err != nil {\n\t\tlogs.Warn.Println(\"replyOfflineTopicGetDesc:\", err)\n\t\tsess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil))\n\t\treturn\n\t}\n\n\tif sub != nil {\n\t\tdesc.Private = sub.Private\n\t\t// FIXME: suspended topics should get no AW access.\n\t\tdesc.Acs = &MsgAccessMode{\n\t\t\tWant:  sub.ModeWant.String(),\n\t\t\tGiven: sub.ModeGiven.String(),\n\t\t\tMode:  (sub.ModeGiven & sub.ModeWant).String(),\n\t\t}\n\t}\n\n\tsess.queueOut(&ServerComMessage{\n\t\tMeta: &MsgServerMeta{\n\t\t\tId: msg.Id, Topic: msg.Original, Timestamp: &now, Desc: desc,\n\t\t},\n\t})\n}\n\n// replyOfflineTopicGetSub reads user's subscription from the database.\n// Only own subscription is available.\n// The requester must be subscribed but need not be attached.\nfunc replyOfflineTopicGetSub(sess *Session, msg *ClientComMessage) {\n\tnow := types.TimeNow()\n\n\tif msg.Get.Sub != nil && msg.Get.Sub.User != \"\" && msg.Get.Sub.User != msg.AsUser {\n\t\tsess.queueOut(ErrPermissionDeniedReply(msg, now))\n\t\treturn\n\t}\n\n\ttopicName := msg.RcptTo\n\tif types.IsChannel(msg.Original) {\n\t\ttopicName = msg.Original\n\t}\n\n\tssub, err := store.Subs.Get(topicName, types.ParseUserId(msg.AsUser), true)\n\tif err != nil {\n\t\tlogs.Warn.Println(\"replyOfflineTopicGetSub:\", err)\n\t\tsess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil))\n\t\treturn\n\t}\n\n\tif ssub == nil {\n\t\tsess.queueOut(ErrNotFoundExplicitTs(msg.Id, msg.Original, now, msg.Timestamp))\n\t\treturn\n\t}\n\n\tsub := MsgTopicSub{}\n\tif ssub.DeletedAt == nil {\n\t\tsub.UpdatedAt = &ssub.UpdatedAt\n\t\tsub.Acs = MsgAccessMode{\n\t\t\tWant:  ssub.ModeWant.String(),\n\t\t\tGiven: ssub.ModeGiven.String(),\n\t\t\tMode:  (ssub.ModeGiven & ssub.ModeWant).String(),\n\t\t}\n\t\t// Fnd is asymmetric: desc.private is a string, but sub.private is a []string.\n\t\tif types.GetTopicCat(msg.RcptTo) != types.TopicCatFnd {\n\t\t\tsub.Private = ssub.Private\n\t\t}\n\t\tsub.User = types.ParseUid(ssub.User).UserId()\n\n\t\tif (ssub.ModeGiven & ssub.ModeWant).IsReader() && (ssub.ModeWant & ssub.ModeGiven).IsJoiner() {\n\t\t\tsub.DelId = ssub.DelId\n\t\t\tsub.ReadSeqId = ssub.ReadSeqId\n\t\t\tsub.RecvSeqId = ssub.RecvSeqId\n\t\t}\n\t} else {\n\t\tsub.DeletedAt = ssub.DeletedAt\n\t}\n\n\tsess.queueOut(&ServerComMessage{\n\t\tMeta: &MsgServerMeta{\n\t\t\tId: msg.Id, Topic: msg.Original, Timestamp: &now, Sub: []MsgTopicSub{sub},\n\t\t},\n\t})\n}\n\n// replyOfflineTopicSetSub updates Desc.Private and Sub.Mode when the topic is not loaded in memory.\n// Only Private and Mode are updated and only for the requester. The requester must be subscribed to the\n// topic but does not need to be attached.\nfunc replyOfflineTopicSetSub(sess *Session, msg *ClientComMessage) {\n\tnow := types.TimeNow()\n\n\tif (msg.Set.Desc == nil || msg.Set.Desc.Private == nil) && (msg.Set.Sub == nil || msg.Set.Sub.Mode == \"\") {\n\t\tsess.queueOut(InfoNotModifiedReply(msg, now))\n\t\treturn\n\t}\n\n\tif msg.Set.Sub != nil && msg.Set.Sub.User != \"\" && msg.Set.Sub.User != msg.AsUser {\n\t\tsess.queueOut(ErrPermissionDeniedReply(msg, now))\n\t\treturn\n\t}\n\n\tasUid := types.ParseUserId(msg.AsUser)\n\n\ttopicName := msg.RcptTo\n\tif types.IsChannel(msg.Original) {\n\t\ttopicName = msg.Original\n\t}\n\n\tsub, err := store.Subs.Get(topicName, asUid, false)\n\tif err != nil {\n\t\tlogs.Warn.Println(\"replyOfflineTopicSetSub get sub:\", err)\n\t\tsess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil))\n\t\treturn\n\t}\n\n\tif sub == nil {\n\t\tsess.queueOut(ErrNotFoundExplicitTs(msg.Id, msg.Original, now, msg.Timestamp))\n\t\treturn\n\t}\n\n\tupdate := make(map[string]any)\n\tif msg.Set.Desc != nil && msg.Set.Desc.Private != nil {\n\t\tprivate, ok := msg.Set.Desc.Private.(map[string]any)\n\t\tif !ok {\n\t\t\tupdate = map[string]any{\"Private\": msg.Set.Desc.Private}\n\t\t} else if private, changed := mergeInterfaces(sub.Private, private); changed {\n\t\t\tupdate = map[string]any{\"Private\": private}\n\t\t}\n\t}\n\n\tif msg.Set.Sub != nil && msg.Set.Sub.Mode != \"\" {\n\t\tvar modeWant types.AccessMode\n\t\tif err = modeWant.UnmarshalText([]byte(msg.Set.Sub.Mode)); err != nil {\n\t\t\tlogs.Warn.Println(\"replyOfflineTopicSetSub mode:\", err)\n\t\t\tsess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil))\n\t\t\treturn\n\t\t}\n\n\t\tif modeWant.IsOwner() != sub.ModeWant.IsOwner() {\n\t\t\t// No ownership changes here.\n\t\t\tsess.queueOut(ErrPermissionDeniedReply(msg, now))\n\t\t\treturn\n\t\t}\n\n\t\tif types.GetTopicCat(msg.RcptTo) == types.TopicCatP2P {\n\t\t\t// For P2P topics ignore requests exceeding typesModeCP2P and do not allow\n\t\t\t// removal of 'A' permission.\n\t\t\tmodeWant = modeWant&globals.typesModeCP2P | types.ModeApprove\n\t\t}\n\n\t\tif modeWant != sub.ModeWant {\n\t\t\tupdate[\"ModeWant\"] = modeWant\n\t\t\t// Cache it for later use\n\t\t\tsub.ModeWant = modeWant\n\t\t}\n\t}\n\n\tif len(update) > 0 {\n\t\terr = store.Subs.Update(topicName, asUid, update)\n\t\tif err != nil {\n\t\t\tlogs.Warn.Println(\"replyOfflineTopicSetSub update:\", err)\n\t\t\tsess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil))\n\t\t} else {\n\t\t\tvar params any\n\t\t\tif update[\"ModeWant\"] != nil {\n\t\t\t\tparams = map[string]any{\n\t\t\t\t\t\"acs\": MsgAccessMode{\n\t\t\t\t\t\tGiven: sub.ModeGiven.String(),\n\t\t\t\t\t\tWant:  sub.ModeWant.String(),\n\t\t\t\t\t\tMode:  (sub.ModeGiven & sub.ModeWant).String(),\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t}\n\t\t\tsess.queueOut(NoErrParamsReply(msg, now, params))\n\t\t}\n\t} else {\n\t\tsess.queueOut(InfoNotModifiedReply(msg, now))\n\t}\n}\n"
  },
  {
    "path": "server/init_topic.go",
    "content": "/******************************************************************************\n *\n *  Description :\n *\n *    Topic initilization routines.\n *\n *****************************************************************************/\n\npackage main\n\nimport (\n\t\"strings\"\n\n\t\"github.com/tinode/chat/server/auth\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\n// topicInit reads an existing topic from database or creates a new topic\nfunc topicInit(t *Topic, join *ClientComMessage, h *Hub) {\n\tvar subscribeReqIssued bool\n\tdefer func() {\n\t\tif !subscribeReqIssued && join.Sub != nil && join.sess.inflightReqs != nil {\n\t\t\t// If it was a client initiated subscribe request and we failed it.\n\t\t\tjoin.sess.inflightReqs.Done()\n\t\t}\n\t}()\n\n\ttimestamp := types.TimeNow()\n\n\tvar err error\n\tswitch {\n\tcase t.xoriginal == \"me\":\n\t\t// Request to load a 'me' topic. The topic always exists, the subscription is never new.\n\t\terr = initTopicMe(t, join)\n\tcase t.xoriginal == \"fnd\":\n\t\t// Request to load a 'find' topic. The topic always exists, the subscription is never new.\n\t\terr = initTopicFnd(t, join)\n\tcase strings.HasPrefix(t.xoriginal, \"usr\") || strings.HasPrefix(t.xoriginal, \"p2p\"):\n\t\t// Request to load an existing or create a new p2p topic, then attach to it.\n\t\terr = initTopicP2P(t, join)\n\tcase strings.HasPrefix(t.xoriginal, \"new\"):\n\t\t// Processing request to create a new group topic.\n\t\terr = initTopicNewGrp(t, join, false)\n\tcase strings.HasPrefix(t.xoriginal, \"nch\"):\n\t\t// Processing request to create a new channel.\n\t\terr = initTopicNewGrp(t, join, true)\n\tcase strings.HasPrefix(t.xoriginal, \"grp\") || strings.HasPrefix(t.xoriginal, \"chn\"):\n\t\t// Load existing group topic (or channel).\n\t\terr = initTopicGrp(t)\n\tcase t.xoriginal == \"sys\":\n\t\t// Initialize system topic.\n\t\terr = initTopicSys(t)\n\tcase t.xoriginal == \"slf\":\n\t\t// Initialize self (notes and saved messages) topic.\n\t\terr = initTopicSlf(t, join)\n\tdefault:\n\t\t// Unrecognized topic name\n\t\terr = types.ErrTopicNotFound\n\t}\n\n\t// Failed to create or load the topic.\n\tif err != nil {\n\t\t// Remove topic from cache to prevent hub from forwarding more messages to it.\n\t\th.topicDel(join.RcptTo)\n\n\t\tlogs.Err.Println(\"init_topic: failed to load or create topic:\", join.RcptTo, err)\n\t\tjoin.sess.queueOut(decodeStoreErrorExplicitTs(err, join.Id, t.xoriginal, timestamp, join.Timestamp, nil))\n\n\t\t// Re-queue pending requests to join the topic.\n\t\tfor len(t.reg) > 0 {\n\t\t\th.join <- (<-t.reg)\n\t\t}\n\n\t\t// Reject all other pending requests\n\t\tfor len(t.clientMsg) > 0 {\n\t\t\tmsg := <-t.clientMsg\n\t\t\tif msg.init {\n\t\t\t\tmsg.sess.queueOut(ErrLockedExplicitTs(msg.Id, t.xoriginal, timestamp, join.Timestamp))\n\t\t\t}\n\t\t}\n\t\tfor len(t.unreg) > 0 {\n\t\t\tmsg := <-t.unreg\n\t\t\tif msg.sess != nil && msg.sess.inflightReqs != nil {\n\t\t\t\tmsg.sess.inflightReqs.Done()\n\t\t\t}\n\t\t\tif msg.init {\n\t\t\t\tmsg.sess.queueOut(ErrLockedReply(msg, timestamp))\n\t\t\t}\n\t\t}\n\t\tfor len(t.meta) > 0 {\n\t\t\tmsg := <-t.meta\n\t\t\tif msg.init {\n\t\t\t\tmsg.sess.queueOut(ErrLockedReply(msg, timestamp))\n\t\t\t}\n\t\t}\n\t\tif len(t.exit) > 0 {\n\t\t\tmsg := <-t.exit\n\t\t\tmsg.done <- true\n\t\t}\n\n\t\treturn\n\t}\n\n\tt.computePerUserAcsUnion()\n\n\t// prevent newly initialized topics to go live while shutdown in progress\n\tif globals.shuttingDown {\n\t\th.topicDel(join.RcptTo)\n\t\treturn\n\t}\n\n\tif t.isDeleted() {\n\t\t// Someone deleted the topic while we were trying to create it.\n\t\treturn\n\t}\n\n\tstatsInc(\"LiveTopics\", 1)\n\tstatsInc(\"TotalTopics\", 1)\n\tusersRegisterTopic(t, true)\n\n\t// Topic will check access rights, send invite to p2p user, send {ctrl} message to the initiator session\n\tif join.Sub != nil {\n\t\tsubscribeReqIssued = true\n\t\tt.reg <- join\n\t}\n\n\tt.markPaused(false)\n\tif t.cat == types.TopicCatFnd || t.cat == types.TopicCatSys {\n\t\tt.markLoaded()\n\t}\n\n\tgo t.run(h)\n}\n\n// Initialize 'me' topic.\nfunc initTopicMe(t *Topic, sreg *ClientComMessage) error {\n\tt.cat = types.TopicCatMe\n\n\tuser, err := store.Users.Get(types.ParseUserId(t.name))\n\tif err != nil {\n\t\t// Log out the session\n\t\tsreg.sess.uid = types.ZeroUid\n\t\treturn err\n\t} else if user == nil {\n\t\t// Log out the session\n\t\tsreg.sess.uid = types.ZeroUid\n\t\treturn types.ErrUserNotFound\n\t}\n\n\t// User's default access for p2p topics\n\tt.accessAuth = user.Access.Auth\n\tt.accessAnon = user.Access.Anon\n\n\t// Assign tags\n\tt.tags = user.Tags\n\n\tif err = t.loadSubscribers(); err != nil {\n\t\treturn err\n\t}\n\n\tt.public = user.Public\n\tt.trusted = user.Trusted\n\n\tt.created = user.CreatedAt\n\tt.updated = user.UpdatedAt\n\n\t// The following values are exlicitly not set for 'me'.\n\t// t.touched, t.lastId, t.delId\n\n\t// 'me' has no owner, t.owner = nil\n\n\t// Initiate User Agent with the UA of the creating session to report it later\n\tt.userAgent = sreg.sess.userAgent\n\t// Initialize channel for receiving user agent and session online updates.\n\tt.supd = make(chan *sessionUpdate, 32)\n\n\tif !t.isProxy {\n\t\t// Allocate storage for contacts.\n\t\tt.perSubs = make(map[string]perSubsData)\n\t}\n\n\treturn nil\n}\n\n// Initialize 'fnd' topic\nfunc initTopicFnd(t *Topic, sreg *ClientComMessage) error {\n\tt.cat = types.TopicCatFnd\n\n\tuid := types.ParseUserId(sreg.AsUser)\n\tif uid.IsZero() {\n\t\treturn types.ErrNotFound\n\t}\n\n\tuser, err := store.Users.Get(uid)\n\tif err != nil {\n\t\treturn err\n\t} else if user == nil {\n\t\tif !sreg.sess.isMultiplex() {\n\t\t\tsreg.sess.uid = types.ZeroUid\n\t\t}\n\t\treturn types.ErrNotFound\n\t}\n\n\t// Make sure no one can join the topic.\n\tt.accessAuth = getDefaultAccess(t.cat, true, false)\n\tt.accessAnon = getDefaultAccess(t.cat, false, false)\n\n\tif err = t.loadSubscribers(); err != nil {\n\t\treturn err\n\t}\n\n\tt.created = user.CreatedAt\n\tt.updated = user.UpdatedAt\n\n\t// 'fnd' has no owner, t.owner = nil\n\n\t// Publishing to fnd is not supported\n\t// t.lastId = 0, t.delId = 0, t.touched = nil\n\n\treturn nil\n}\n\n// Load or create a P2P topic.\n// There is a reace condition when two users try to create a p2p topic at the same time.\nfunc initTopicP2P(t *Topic, sreg *ClientComMessage) error {\n\tpktsub := sreg.Sub\n\n\t// Handle the following cases:\n\t// 1. Neither topic nor subscriptions exist: create a new p2p topic & subscriptions.\n\t// 2. Topic exists, one of the subscriptions is missing:\n\t// 2.1 Requester's subscription is missing, recreate it.\n\t// 2.2 Other user's subscription is missing, treat like a new request for user 2.\n\t// 3. Topic exists, both subscriptions are missing: should not happen, fail.\n\t// 4. Topic and both subscriptions exist: attach to topic\n\n\tt.cat = types.TopicCatP2P\n\n\t// Check if the topic already exists\n\tstopic, err := store.Topics.Get(t.name)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// If topic exists, load subscriptions\n\tvar subs []types.Subscription\n\tif stopic != nil {\n\t\t// Subs already have Public swapped\n\t\tif subs, err = store.Topics.GetUsers(t.name, nil); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Case 3, fail\n\t\tif len(subs) == 0 {\n\t\t\tlogs.Err.Println(\"hub: missing both subscriptions for '\" + t.name + \"' (SHOULD NEVER HAPPEN!)\")\n\t\t\treturn types.ErrInternal\n\t\t}\n\n\t\tt.created = stopic.CreatedAt\n\t\tt.updated = stopic.UpdatedAt\n\t\tif !stopic.TouchedAt.IsZero() {\n\t\t\tt.touched = stopic.TouchedAt\n\t\t}\n\t\tt.aux = stopic.Aux\n\t\tt.lastID = stopic.SeqId\n\t\tt.delID = stopic.DelId\n\t}\n\n\t// t.owner is blank for p2p topics\n\n\t// Default user access to P2P topics is not set because it's unused.\n\t// Other users cannot join the topic because of how topic name is constructed.\n\t// The two participants set each other's access instead.\n\t// t.accessAuth = getDefaultAccess(t.cat, true)\n\t// t.accessAnon = getDefaultAccess(t.cat, false)\n\n\t// t.public and t.trusted are not used for p2p topics since each user get a different public/trusted.\n\n\tif stopic != nil && len(subs) == 2 {\n\t\t// Case 4.\n\t\tfor i := range 2 {\n\t\t\tuid := types.ParseUid(subs[i].User)\n\t\t\tt.perUser[uid] = perUserData{\n\t\t\t\t// Adapter has already swapped the state, public, defaultAccess, lastSeen values.\n\t\t\t\tpublic:    subs[i].GetPublic(),\n\t\t\t\tlastSeen:  subs[i].GetLastSeen(),\n\t\t\t\tlastUA:    subs[i].GetUserAgent(),\n\t\t\t\ttopicName: types.ParseUid(subs[(i+1)%2].User).UserId(),\n\n\t\t\t\tprivate:   subs[i].Private,\n\t\t\t\tmodeWant:  subs[i].ModeWant,\n\t\t\t\tmodeGiven: subs[i].ModeGiven,\n\t\t\t\tdelID:     subs[i].DelId,\n\t\t\t\trecvID:    subs[i].RecvSeqId,\n\t\t\t\treadID:    subs[i].ReadSeqId,\n\t\t\t}\n\t\t}\n\t} else {\n\t\t// Cases 1 (new topic), 2 (one of the two subscriptions is missing: either it's a new request\n\t\t// or the subscription was deleted)\n\t\tvar userData perUserData\n\n\t\t// Fetching records for both users.\n\t\t// Requester.\n\t\tuserID1 := types.ParseUserId(sreg.AsUser)\n\t\t// The other user.\n\t\tuserID2 := types.ParseUserId(t.xoriginal)\n\n\t\t// User index: u1 - requester, u2 - responder, the other user\n\t\tvar u1, u2 int\n\t\tusers, err := store.Users.GetAll(userID1, userID2)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif len(users) != 2 {\n\t\t\t// Invited user does not exist\n\t\t\treturn types.ErrUserNotFound\n\t\t}\n\n\t\t// User records are unsorted, make sure we know who is who.\n\t\tif users[0].Uid() == userID1 {\n\t\t\tu1, u2 = 0, 1\n\t\t} else {\n\t\t\tu1, u2 = 1, 0\n\t\t}\n\n\t\t// Figure out which subscriptions are missing: User1's, User2's or both.\n\t\tvar sub1, sub2 *types.Subscription\n\t\t// Set to true if only requester's subscription has to be created.\n\t\tvar user1only bool\n\t\tif len(subs) == 1 {\n\t\t\tif subs[0].User == userID1.String() {\n\t\t\t\t// User2's subscription is missing, user1's exists\n\t\t\t\tsub1 = &subs[0]\n\t\t\t} else {\n\t\t\t\t// User1's is missing, user2's exists\n\t\t\t\tsub2 = &subs[0]\n\t\t\t\tuser1only = true\n\t\t\t}\n\t\t}\n\n\t\t// Other user's (responder's) subscription is missing\n\t\tif sub2 == nil {\n\t\t\tsub2 = &types.Subscription{\n\t\t\t\tUser:    userID2.String(),\n\t\t\t\tTopic:   t.name,\n\t\t\t\tPrivate: nil,\n\t\t\t}\n\n\t\t\t// Assign user2's ModeGiven based on what user1 has provided.\n\t\t\t// We don't know access mode for user2, assume it's Auth.\n\t\t\tif pktsub.Set != nil && pktsub.Set.Desc != nil && pktsub.Set.Desc.DefaultAcs != nil {\n\t\t\t\t// Use provided DefaultAcs as non-default modeGiven for the other user.\n\t\t\t\t// The other user is assumed to have auth level \"Auth\".\n\t\t\t\tsub2.ModeGiven = users[u1].Access.Auth\n\t\t\t\tif err := sub2.ModeGiven.UnmarshalText([]byte(pktsub.Set.Desc.DefaultAcs.Auth)); err != nil {\n\t\t\t\t\tlogs.Err.Println(\"hub: invalid access mode\", t.xoriginal, pktsub.Set.Desc.DefaultAcs.Auth)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Use user1.Auth as modeGiven for the other user\n\t\t\t\tsub2.ModeGiven = users[u1].Access.Auth\n\t\t\t}\n\t\t\t// Sanity check\n\t\t\tsub2.ModeGiven = sub2.ModeGiven&globals.typesModeCP2P | types.ModeApprove\n\n\t\t\t// Swap Public+Trusted to match swapped Public+Trusted in subs returned from store.Topics.GetSubs\n\t\t\tsub2.SetPublic(users[u1].Public)\n\t\t\tsub2.SetTrusted(users[u1].Trusted)\n\n\t\t\t// Mark the entire topic as new.\n\t\t\tpktsub.Created = true\n\t\t}\n\n\t\t// Requester's subscription is missing:\n\t\t// a. requester is starting a new topic\n\t\t// b. requester's subscription is missing: deleted or creation failed\n\t\tif sub1 == nil {\n\t\t\t// Set user1's ModeGiven from user2's default values\n\t\t\tuserData.modeGiven = selectAccessMode(auth.Level(sreg.AuthLvl),\n\t\t\t\tusers[u2].Access.Anon,\n\t\t\t\tusers[u2].Access.Auth,\n\t\t\t\tglobals.typesModeCP2P)\n\n\t\t\t// By default assign the same mode that user1 gave to user2 (could be changed below)\n\t\t\tuserData.modeWant = sub2.ModeGiven\n\n\t\t\tif pktsub.Set != nil {\n\t\t\t\tif pktsub.Set.Sub != nil {\n\t\t\t\t\tuid := userID1\n\t\t\t\t\tif pktsub.Set.Sub.User != \"\" {\n\t\t\t\t\t\tuid = types.ParseUserId(pktsub.Set.Sub.User)\n\t\t\t\t\t}\n\n\t\t\t\t\tif uid != userID1 {\n\t\t\t\t\t\t// Report the error and ignore the value\n\t\t\t\t\t\tlogs.Err.Println(\"hub: setting mode for another user is not supported '\" + t.name + \"'\")\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// user1 is setting non-default modeWant\n\t\t\t\t\t\tif err := userData.modeWant.UnmarshalText([]byte(pktsub.Set.Sub.Mode)); err != nil {\n\t\t\t\t\t\t\tlogs.Err.Println(\"hub: invalid access mode\", t.xoriginal, pktsub.Set.Sub.Mode)\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// Ensure sanity\n\t\t\t\t\t\tuserData.modeWant = userData.modeWant&globals.typesModeCP2P | types.ModeApprove\n\t\t\t\t\t}\n\n\t\t\t\t\t// Since user1 issued a {sub} request, make sure the user can join\n\t\t\t\t\tuserData.modeWant |= types.ModeJoin\n\t\t\t\t}\n\n\t\t\t\t// user1 sets non-default Private\n\t\t\t\tif pktsub.Set.Desc != nil {\n\t\t\t\t\tif !isNullValue(pktsub.Set.Desc.Private) {\n\t\t\t\t\t\tuserData.private = pktsub.Set.Desc.Private\n\t\t\t\t\t}\n\t\t\t\t\t// Public, if present, is ignored\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tsub1 = &types.Subscription{\n\t\t\t\tUser:      userID1.String(),\n\t\t\t\tTopic:     t.name,\n\t\t\t\tModeWant:  userData.modeWant,\n\t\t\t\tModeGiven: userData.modeGiven,\n\t\t\t\tPrivate:   userData.private,\n\t\t\t}\n\t\t\t// Swap Public+Trsuted to match swapped Public+Trusted in subs returned from store.Topics.GetSubs\n\t\t\tsub1.SetPublic(users[u2].Public)\n\t\t\tsub1.SetTrusted(users[u2].Trusted)\n\n\t\t\t// Mark this subscription as new\n\t\t\tpktsub.Newsub = true\n\t\t}\n\n\t\tif !user1only {\n\t\t\t// sub2 is being created, assign sub2.modeWant to what user2 gave to user1 (sub1.modeGiven)\n\t\t\tsub2.ModeWant = selectAccessMode(auth.Level(sreg.AuthLvl),\n\t\t\t\tusers[u2].Access.Anon,\n\t\t\t\tusers[u2].Access.Auth,\n\t\t\t\tglobals.typesModeCP2P)\n\t\t\t// Ensure sanity\n\t\t\tsub2.ModeWant = sub2.ModeWant&globals.typesModeCP2P | types.ModeApprove\n\t\t}\n\n\t\t// Create everything\n\t\tif stopic == nil {\n\t\t\tif err = store.Topics.CreateP2P(sub1, sub2); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tt.created = sub1.CreatedAt\n\t\t\tt.updated = sub1.UpdatedAt\n\t\t\tt.touched = t.updated\n\n\t\t\t// t.lastId is not set (default 0) for new topics\n\n\t\t} else {\n\t\t\t// TODO possibly update subscription, if changed\n\n\t\t\t// Recreate one of the subscriptions\n\t\t\tvar subToMake *types.Subscription\n\t\t\tif user1only {\n\t\t\t\tsubToMake = sub1\n\t\t\t} else {\n\t\t\t\tsubToMake = sub2\n\t\t\t}\n\t\t\tif err = store.Subs.Create(subToMake); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t// Public and Trusted are already swapped.\n\t\tuserData.public = sub1.GetPublic()\n\t\tuserData.trusted = sub1.GetTrusted()\n\t\tuserData.topicName = userID2.UserId()\n\t\tuserData.modeWant = sub1.ModeWant\n\t\tuserData.modeGiven = sub1.ModeGiven\n\t\tuserData.delID = sub1.DelId\n\t\tuserData.readID = sub1.ReadSeqId\n\t\tuserData.recvID = sub1.RecvSeqId\n\t\tt.perUser[userID1] = userData\n\n\t\tt.perUser[userID2] = perUserData{\n\t\t\tpublic:    sub2.GetPublic(),\n\t\t\ttrusted:   sub2.GetTrusted(),\n\t\t\ttopicName: userID1.UserId(),\n\t\t\tmodeWant:  sub2.ModeWant,\n\t\t\tmodeGiven: sub2.ModeGiven,\n\t\t\tdelID:     sub2.DelId,\n\t\t\treadID:    sub2.ReadSeqId,\n\t\t\trecvID:    sub2.RecvSeqId,\n\t\t}\n\t}\n\n\t// Clear original topic name.\n\tt.xoriginal = \"\"\n\n\treturn nil\n}\n\n// Create a new group topic\nfunc initTopicNewGrp(t *Topic, sreg *ClientComMessage, isChan bool) error {\n\ttimestamp := types.TimeNow()\n\tpktsub := sreg.Sub\n\n\tt.cat = types.TopicCatGrp\n\tt.isChan = isChan\n\n\t// Generic topics have parameters stored in the topic object\n\tt.owner = types.ParseUserId(sreg.AsUser)\n\tauthLevel := auth.Level(sreg.AuthLvl)\n\n\tt.accessAuth = getDefaultAccess(t.cat, true, isChan)\n\tt.accessAnon = getDefaultAccess(t.cat, false, isChan)\n\n\t// Owner/creator gets full access to the topic. Owner may change the default modeWant through 'set'.\n\tuserData := perUserData{\n\t\tmodeGiven: types.ModeCFull,\n\t\tmodeWant:  types.ModeCFull,\n\t}\n\n\tif pktsub.Set != nil {\n\t\t// User sent initialization parameters\n\t\tif pktsub.Set.Desc != nil {\n\t\t\tif pktsub.Set.Desc.Trusted != nil && authLevel != auth.LevelRoot {\n\t\t\t\tlogs.Err.Println(\"hub: attempt to assign Trusted by non-ROOT\", t.name)\n\t\t\t\treturn types.ErrPermissionDenied\n\t\t\t}\n\n\t\t\tif !isNullValue(pktsub.Set.Desc.Public) {\n\t\t\t\tt.public = pktsub.Set.Desc.Public\n\t\t\t}\n\t\t\tif !isNullValue(pktsub.Set.Desc.Trusted) {\n\t\t\t\tt.trusted = pktsub.Set.Desc.Trusted\n\t\t\t}\n\t\t\tif !isNullValue(pktsub.Set.Desc.Private) {\n\t\t\t\tuserData.private = pktsub.Set.Desc.Private\n\t\t\t}\n\n\t\t\t// set default access\n\t\t\tif pktsub.Set.Desc.DefaultAcs != nil {\n\t\t\t\tif authMode, anonMode, err := parseTopicAccess(pktsub.Set.Desc.DefaultAcs,\n\t\t\t\t\tt.accessAuth, t.accessAnon); err != nil {\n\n\t\t\t\t\t// Invalid access for one or both. Make it explicitly None\n\t\t\t\t\tif authMode.IsInvalid() {\n\t\t\t\t\t\tt.accessAuth = types.ModeNone\n\t\t\t\t\t} else {\n\t\t\t\t\t\tt.accessAuth = authMode\n\t\t\t\t\t}\n\t\t\t\t\tif anonMode.IsInvalid() {\n\t\t\t\t\t\tt.accessAnon = types.ModeNone\n\t\t\t\t\t} else {\n\t\t\t\t\t\tt.accessAnon = anonMode\n\t\t\t\t\t}\n\t\t\t\t\tlogs.Err.Println(\"hub: invalid access mode for topic '\" + t.name + \"': '\" + err.Error() + \"'\")\n\t\t\t\t} else if authMode.IsOwner() || anonMode.IsOwner() {\n\t\t\t\t\tlogs.Err.Println(\"hub: OWNER default access in topic\", t.name)\n\t\t\t\t\tt.accessAuth, t.accessAnon = authMode & ^types.ModeOwner, anonMode & ^types.ModeOwner\n\t\t\t\t} else {\n\t\t\t\t\tt.accessAuth, t.accessAnon = authMode, anonMode\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Owner/creator may restrict own access to topic\n\t\tif pktsub.Set.Sub != nil && pktsub.Set.Sub.Mode != \"\" {\n\t\t\tuserData.modeWant = types.ModeCFull\n\t\t\tif err := userData.modeWant.UnmarshalText([]byte(pktsub.Set.Sub.Mode)); err != nil {\n\t\t\t\tlogs.Err.Println(\"hub: invalid access mode\", t.xoriginal, pktsub.Set.Sub.Mode)\n\t\t\t}\n\t\t\t// User must not unset ModeJoin or the owner flags\n\t\t\tuserData.modeWant |= types.ModeJoin | types.ModeOwner\n\t\t}\n\n\t\tif tags := normalizeTags(pktsub.Set.Tags, globals.maxTagCount); len(tags) > 0 {\n\t\t\tif !restrictedTagsEqual(tags, nil, globals.immutableTagNS) {\n\t\t\t\treturn types.ErrPermissionDenied\n\t\t\t}\n\t\t\t// Assign tags\n\t\t\tt.tags = tags\n\t\t}\n\t}\n\n\tt.perUser[t.owner] = userData\n\n\tt.created = timestamp\n\tt.updated = timestamp\n\tt.touched = timestamp\n\n\t// t.lastId & t.delId are not set for new topics\n\n\tstopic := &types.Topic{\n\t\tObjHeader: types.ObjHeader{Id: sreg.RcptTo, CreatedAt: timestamp},\n\t\tAccess:    types.DefaultAccess{Auth: t.accessAuth, Anon: t.accessAnon},\n\t\tTags:      t.tags,\n\t\tUseBt:     isChan,\n\t\tPublic:    t.public,\n\t\tTrusted:   t.trusted,\n\t}\n\n\t// store.Topics.Create will add a subscription record for the topic creator\n\tstopic.GiveAccess(t.owner, userData.modeWant, userData.modeGiven)\n\terr := store.Topics.Create(stopic, t.owner, t.perUser[t.owner].private)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Link uploaded avatar to topic.\n\tif sreg.Extra != nil && len(sreg.Extra.Attachments) > 0 {\n\t\tif err := store.Files.LinkAttachments(t.name, types.ZeroUid, sreg.Extra.Attachments); err != nil {\n\t\t\tlogs.Warn.Printf(\"topic[%s] failed to link avatar attachment: %v\", t.name, err)\n\t\t\t// This is not a critical error, continue execution.\n\t\t}\n\t}\n\n\tt.xoriginal = t.name // keeping 'new' or 'nch' as original has no value to the client\n\tt.subCnt = 1         // One subscription, the owner.\n\n\tpktsub.Created = true\n\tpktsub.Newsub = true\n\n\treturn nil\n}\n\n// Initialize existing group topic. There is a race condition when two users attempt to load\n// the same topic at the same time. It's prevented at hub level.\nfunc initTopicGrp(t *Topic) error {\n\tt.cat = types.TopicCatGrp\n\n\t// TODO(gene): check and validate topic name\n\tstopic, err := store.Topics.Get(t.name)\n\tif err != nil {\n\t\treturn err\n\t} else if stopic == nil {\n\t\treturn types.ErrTopicNotFound\n\t}\n\n\tif err = t.loadSubscribers(); err != nil {\n\t\treturn err\n\t}\n\n\tt.isChan = stopic.UseBt\n\n\t// t.owner is set by loadSubscriptions\n\n\tt.accessAuth = stopic.Access.Auth\n\tt.accessAnon = stopic.Access.Anon\n\n\t// Assign tags & auxiliary data.\n\tt.tags = stopic.Tags\n\tt.aux = stopic.Aux\n\n\tt.public = stopic.Public\n\tt.trusted = stopic.Trusted\n\n\tt.created = stopic.CreatedAt\n\tt.updated = stopic.UpdatedAt\n\tif !stopic.TouchedAt.IsZero() {\n\t\tt.touched = stopic.TouchedAt\n\t}\n\tt.lastID = stopic.SeqId\n\tt.delID = stopic.DelId\n\tt.subCnt = stopic.SubCnt\n\n\t// Initialize channel for receiving session online updates.\n\tt.supd = make(chan *sessionUpdate, 32)\n\n\tt.xoriginal = t.name // topic may have been loaded by a channel reader; make sure it's grpXXX, not chnXXX.\n\n\treturn nil\n}\n\n// Initialize system topic. System topic is a singleton, always in memory.\nfunc initTopicSys(t *Topic) error {\n\tt.cat = types.TopicCatSys\n\n\tstopic, err := store.Topics.Get(t.name)\n\tif err != nil {\n\t\treturn err\n\t} else if stopic == nil {\n\t\treturn types.ErrTopicNotFound\n\t}\n\n\tif err = t.loadSubscribers(); err != nil {\n\t\treturn err\n\t}\n\n\t// There is no t.owner\n\n\t// Default permissions are 'W'\n\tt.accessAuth = types.ModeWrite\n\tt.accessAnon = types.ModeWrite\n\n\tt.public = stopic.Public\n\tt.trusted = stopic.Trusted\n\n\tt.created = stopic.CreatedAt\n\tt.updated = stopic.UpdatedAt\n\tif !stopic.TouchedAt.IsZero() {\n\t\tt.touched = stopic.TouchedAt\n\t}\n\tt.lastID = stopic.SeqId\n\n\treturn nil\n}\n\n// Initialize or load a self-topic 'slf'.\nfunc initTopicSlf(t *Topic, sreg *ClientComMessage) error {\n\tt.cat = types.TopicCatSlf\n\n\tstopic, err := store.Topics.Get(t.name)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// If topic exists, load subscriptions\n\tif stopic != nil {\n\t\tif err = t.loadSubscribers(); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// t.owner is set by loadSubscriptions\n\n\t\t// Topic exists but subscription is missing. Fail.\n\t\tif len(t.perUser) == 0 {\n\t\t\tlogs.Err.Println(\"hub: missing subscription for '\" + t.name + \"' (SHOULD NEVER HAPPEN!)\")\n\t\t\treturn types.ErrInternal\n\t\t}\n\n\t\tt.created = stopic.CreatedAt\n\t\tt.updated = stopic.UpdatedAt\n\t\tif !stopic.TouchedAt.IsZero() {\n\t\t\tt.touched = stopic.TouchedAt\n\t\t}\n\t\tt.aux = stopic.Aux\n\t\tt.lastID = stopic.SeqId\n\t\tt.delID = stopic.DelId\n\n\t} else {\n\t\t// Get topic owner.\n\t\tuserID := types.ParseUserId(sreg.AsUser)\n\t\tuser, err := store.Users.Get(userID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif user == nil {\n\t\t\t// User not found. Really should not happen.\n\t\t\treturn types.ErrUserNotFound\n\t\t}\n\n\t\tt.owner = userID\n\n\t\tt.accessAuth = getDefaultAccess(t.cat, true, false)\n\t\tt.accessAnon = getDefaultAccess(t.cat, false, false)\n\n\t\t// Default access for the self-owner.\n\t\tuserData := perUserData{\n\t\t\tmodeGiven: t.accessAuth,\n\t\t\tmodeWant:  t.accessAuth,\n\t\t}\n\n\t\t// Mark the topic as new.\n\t\tsreg.Sub.Created = true\n\n\t\tif sreg.Sub.Set != nil {\n\t\t\t// User sets non-default Private\n\t\t\tif sreg.Sub.Set.Desc != nil {\n\t\t\t\tif !isNullValue(sreg.Sub.Set.Desc.Private) {\n\t\t\t\t\tuserData.private = sreg.Sub.Set.Desc.Private\n\t\t\t\t}\n\t\t\t\t// Public, trusted are ignored.\n\t\t\t}\n\n\t\t\tif tags := normalizeTags(sreg.Sub.Set.Tags, globals.maxTagCount); len(tags) > 0 {\n\t\t\t\tif !restrictedTagsEqual(tags, nil, globals.immutableTagNS) {\n\t\t\t\t\treturn types.ErrPermissionDenied\n\t\t\t\t}\n\n\t\t\t\t// Assign tags\n\t\t\t\tt.tags = tags\n\t\t\t}\n\t\t}\n\n\t\t// Mark this subscription as new\n\t\tsreg.Sub.Newsub = true\n\n\t\tt.perUser[t.owner] = userData\n\n\t\ttimestamp := types.TimeNow()\n\n\t\tt.created = timestamp\n\t\tt.updated = timestamp\n\t\tt.touched = timestamp\n\n\t\tstopic = &types.Topic{\n\t\t\tObjHeader: types.ObjHeader{Id: sreg.RcptTo, CreatedAt: timestamp},\n\t\t\tAccess:    types.DefaultAccess{Auth: t.accessAuth, Anon: t.accessAnon},\n\t\t\tTags:      t.tags,\n\t\t}\n\n\t\t// store.Topics.Create will add a subscription record for the topic creator\n\t\tstopic.GiveAccess(t.owner, userData.modeWant, userData.modeGiven)\n\t\terr = store.Topics.Create(stopic, t.owner, t.perUser[t.owner].private)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tsreg.Sub.Created = true\n\t\tsreg.Sub.Newsub = true\n\t}\n\n\treturn nil\n}\n\n// loadSubscribers loads topic subscribers, sets topic owner.\nfunc (t *Topic) loadSubscribers() error {\n\tsubs, err := store.Topics.GetSubs(t.name, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif subs == nil {\n\t\treturn nil\n\t}\n\n\tfor i := range subs {\n\t\tsub := &subs[i]\n\t\tuid := types.ParseUid(sub.User)\n\t\tt.perUser[uid] = perUserData{\n\t\t\tdelID:     sub.DelId,\n\t\t\treadID:    sub.ReadSeqId,\n\t\t\trecvID:    sub.RecvSeqId,\n\t\t\tprivate:   sub.Private,\n\t\t\tmodeWant:  sub.ModeWant,\n\t\t\tmodeGiven: sub.ModeGiven,\n\t\t}\n\n\t\tif (sub.ModeGiven & sub.ModeWant).IsOwner() {\n\t\t\tt.owner = uid\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "server/logs/logs.go",
    "content": "// Package logs exposes info, warning and error loggers.\npackage logs\n\nimport (\n\t\"io\"\n\t\"log\"\n\t\"strings\"\n)\n\nvar (\n\t// Info is a logger at the 'info' logging level.\n\tInfo *log.Logger\n\t// Warn is a logger at the 'warning' logging level.\n\tWarn *log.Logger\n\t// Err is a logger at the 'error' logging level.\n\tErr *log.Logger\n)\n\nfunc parseFlags(logFlags string) int {\n\tflags := 0\n\tfor _, v := range strings.Split(logFlags, \",\") {\n\t\tswitch {\n\t\tcase v == \"date\":\n\t\t\tflags |= log.Ldate\n\t\tcase v == \"time\":\n\t\t\tflags |= log.Ltime\n\t\tcase v == \"microseconds\":\n\t\t\tflags |= log.Lmicroseconds\n\t\tcase v == \"longfile\":\n\t\t\tflags |= log.Llongfile\n\t\tcase v == \"shortfile\":\n\t\t\tflags |= log.Lshortfile\n\t\tcase v == \"UTC\":\n\t\t\tflags |= log.LUTC\n\t\tcase v == \"msgprefix\":\n\t\t\tflags |= log.Lmsgprefix\n\t\tcase v == \"stdFlags\":\n\t\t\tflags |= log.LstdFlags\n\t\tdefault:\n\t\t\tlog.Fatalln(\"Invalid log flags string: \", logFlags)\n\t\t}\n\t}\n\tif flags == 0 {\n\t\tflags = log.LstdFlags\n\t}\n\treturn flags\n}\n\n// Init initializes info, warning and error loggers given the flags and the output.\nfunc Init(output io.Writer, logFlags string) {\n\tflags := parseFlags(logFlags)\n\tInfo = log.New(output, \"I\", flags)\n\tWarn = log.New(output, \"W\", flags)\n\tErr = log.New(output, \"E\", flags)\n}\n"
  },
  {
    "path": "server/main.go",
    "content": "/******************************************************************************\n *\n *  Description :\n *\n *  Setup & initialization.\n *\n *****************************************************************************/\n\npackage main\n\n//go:generate protoc --go_out=../pbx --go_opt=paths=source_relative --go-grpc_out=../pbx --go-grpc_opt=paths=source_relative ../pbx/model.proto\n\nimport (\n\t\"encoding/json\"\n\t\"flag\"\n\t\"net/http\"\n\t\"os\"\n\t\"runtime\"\n\t\"runtime/pprof\"\n\t\"strings\"\n\t\"time\"\n\n\tgh \"github.com/gorilla/handlers\"\n\n\t// For stripping comments from JSON config\n\tjcr \"github.com/tinode/jsonco\"\n\n\t// Authenticators\n\t\"github.com/tinode/chat/server/auth\"\n\t_ \"github.com/tinode/chat/server/auth/anon\"\n\t_ \"github.com/tinode/chat/server/auth/basic\"\n\t_ \"github.com/tinode/chat/server/auth/code\"\n\t_ \"github.com/tinode/chat/server/auth/rest\"\n\t_ \"github.com/tinode/chat/server/auth/token\"\n\t\"github.com/tinode/chat/server/store/types\"\n\n\t// Database backends\n\t_ \"github.com/tinode/chat/server/db/mongodb\"\n\t_ \"github.com/tinode/chat/server/db/mysql\"\n\t_ \"github.com/tinode/chat/server/db/postgres\"\n\t_ \"github.com/tinode/chat/server/db/rethinkdb\"\n\n\t\"github.com/tinode/chat/server/logs\"\n\n\t// Push notifications\n\t\"github.com/tinode/chat/server/push\"\n\t_ \"github.com/tinode/chat/server/push/fcm\"\n\t_ \"github.com/tinode/chat/server/push/stdout\"\n\t_ \"github.com/tinode/chat/server/push/tnpg\"\n\n\t\"github.com/tinode/chat/server/store\"\n\n\t// Credential validators\n\t_ \"github.com/tinode/chat/server/validate/email\"\n\t_ \"github.com/tinode/chat/server/validate/tel\"\n\t\"google.golang.org/grpc\"\n\n\t// File upload handlers\n\t_ \"github.com/tinode/chat/server/media/fs\"\n\t_ \"github.com/tinode/chat/server/media/s3\"\n)\n\nconst (\n\t// currentVersion is the current API/protocol version\n\tcurrentVersion = \"0.25\"\n\t// minSupportedVersion is the minimum supported API version\n\tminSupportedVersion = \"0.20\"\n\n\t// idleSessionTimeout defines duration of being idle before terminating a session.\n\tidleSessionTimeout = time.Second * 55\n\t// idleMasterTopicTimeout defines now long to keep master topic alive after the last session detached.\n\tidleMasterTopicTimeout = time.Second * 4\n\t// Same as above but shut down the proxy topic sooner. Otherwise master topic would be kept alive for too long.\n\tidleProxyTopicTimeout = time.Second * 2\n\n\t// defaultMaxMessageSize is the default maximum message size\n\tdefaultMaxMessageSize = 1 << 19 // 512K\n\n\t// defaultMaxSubscriberCount is the default maximum number of group topic subscribers.\n\t// Also set in adapter.\n\tdefaultMaxSubscriberCount = 256\n\n\t// defaultMaxTagCount is the default maximum number of indexable tags\n\tdefaultMaxTagCount = 16\n\n\t// minTagLength is the shortest acceptable length of a tag in runes. Shorter tags are discarded.\n\tminTagLength = 2\n\t// maxTagLength is the maximum length of a tag in runes. Longer tags are trimmed.\n\tmaxTagLength = 96\n\n\t// Delay before updating a User Agent\n\tuaTimerDelay = time.Second * 5\n\n\t// maxDeleteCount is the maximum allowed number of messages to delete in one call.\n\tdefaultMaxDeleteCount = 1024\n\n\t// Base URL path for serving the streaming API.\n\tdefaultApiPath = \"/\"\n\n\t// Mount point where static content is served, http://host-name<defaultStaticMount>\n\tdefaultStaticMount = \"/\"\n\n\t// Local path to static content\n\tdefaultStaticPath = \"static\"\n\n\t// Default country code to fall back to if the \"default_country_code\" field\n\t// isn't specified in the config.\n\tdefaultCountryCode = \"US\"\n\n\t// Default timeout to drop an unanswered call, seconds.\n\tdefaultCallEstablishmentTimeout = 30\n)\n\n// Build version number defined by the compiler:\n//\n//\t-ldflags \"-X main.buildstamp=value_to_assign_to_buildstamp\"\n//\n// Reported to clients in response to {hi} message.\n// For instance, to define the buildstamp as a timestamp of when the server was built add a\n// flag to compiler command line:\n//\n//\t-ldflags \"-X main.buildstamp=`date -u '+%Y%m%dT%H:%M:%SZ'`\"\n//\n// or to set it to git tag:\n//\n//\t-ldflags \"-X main.buildstamp=`git describe --tags`\"\nvar buildstamp = \"undef\"\n\n// CredValidator holds additional config params for a credential validator.\ntype credValidator struct {\n\t// AuthLevel(s) which require this validator.\n\trequiredAuthLvl []auth.Level\n\taddToTags       bool\n}\n\nvar globals struct {\n\t// Topics cache and processing.\n\thub *Hub\n\t// Indicator that shutdown is in progress\n\tshuttingDown bool\n\t// Sessions cache.\n\tsessionStore *SessionStore\n\t// Cluster data.\n\tcluster *Cluster\n\t// gRPC server.\n\tgrpcServer *grpc.Server\n\t// Plugins.\n\tplugins []Plugin\n\t// Runtime statistics communication channel.\n\tstatsUpdate chan *varUpdate\n\t// Users cache communication channel.\n\tusersUpdate chan *UserCacheReq\n\n\t// Credential validators.\n\tvalidators map[string]credValidator\n\t// Credential validator config to pass to clients.\n\tvalidatorClientConfig map[string][]string\n\t// Validators required for each auth level.\n\tauthValidators map[auth.Level][]string\n\n\t// Salt used for signing API key.\n\tapiKeySalt []byte\n\t// Tag namespaces (prefixes) which are immutable to the client.\n\timmutableTagNS map[string]bool\n\t// Tag namespaces which are immutable on User and partially mutable on Topic:\n\t// user can only mutate tags he owns.\n\tmaskedTagNS map[string]bool\n\t// Na,espace used for unique user and topic aliases.\n\taliasTagNS string\n\n\t// Add Strict-Transport-Security to headers, the value signifies age.\n\t// Empty string \"\" turns it off\n\ttlsStrictMaxAge string\n\t// Listen for connections on this address:port and redirect them to HTTPS port.\n\ttlsRedirectHTTP string\n\t// Maximum message size allowed from peer.\n\tmaxMessageSize int64\n\t// Maximum number of group topic subscribers.\n\tmaxSubscriberCount int\n\t// Maximum number of indexable tags.\n\tmaxTagCount int\n\t// If true, ordinary users cannot delete their accounts.\n\tpermanentAccounts bool\n\n\t// Maximum allowed upload size.\n\tmaxFileUploadSize int64\n\t// Periodicity of a garbage collector for abandoned media uploads.\n\tmediaGcPeriod time.Duration\n\n\t// Prioritize X-Forwarded-For header as the source of IP address of the client.\n\tuseXForwardedFor bool\n\n\t// Add X-Frame-Options header to HTTP response.\n\txFrameOptions string\n\n\t// Country code to assign to sessions by default.\n\tdefaultCountryCode string\n\n\t// Time before the call is dropped if not answered.\n\tcallEstablishmentTimeout int\n\n\t// ICE servers config (video calling)\n\ticeServers []iceServer\n\n\t// Websocket per-message compression negotiation is enabled.\n\twsCompression bool\n\n\t// URL of the main endpoint.\n\t// DEPRECTATED: use file-serving gRPC API instead. This feature will be removed.\n\tservingAt string\n\n\t// P2P auth access mode. With or without the D permission depending on P2PDeleteAge.\n\ttypesModeCP2P types.AccessMode\n\n\t// Maximum age of messages which can be deleted with 'D' permission.\n\tmsgDeleteAge time.Duration\n}\n\n// Credential validator config.\ntype validatorConfig struct {\n\t// TRUE or FALSE to set\n\tAddToTags bool `json:\"add_to_tags\"`\n\t//  Authentication level which triggers this validator: \"auth\", \"anon\"... or \"\"\n\tRequired []string `json:\"required\"`\n\t// Validator params passed to validator unchanged.\n\tConfig json.RawMessage `json:\"config\"`\n}\n\n// Stale unvalidated user account GC config.\ntype accountGcConfig struct {\n\tEnabled bool `json:\"enabled\"`\n\t// How often to run GC (seconds).\n\tGcPeriod int `json:\"gc_period\"`\n\t// Number of accounts to delete in one pass.\n\tGcBlockSize int `json:\"gc_block_size\"`\n\t// Minimum hours since account was last modified.\n\tGcMinAccountAge int `json:\"gc_min_account_age\"`\n}\n\n// Large file handler config.\ntype mediaConfig struct {\n\t// The name of the handler to use for file uploads.\n\tUseHandler string `json:\"use_handler\"`\n\t// Maximum allowed size of an uploaded file\n\tMaxFileUploadSize int64 `json:\"max_size\"`\n\t// Garbage collection timeout\n\tGcPeriod int `json:\"gc_period\"`\n\t// Number of entries to delete in one pass\n\tGcBlockSize int `json:\"gc_block_size\"`\n\t// Individual handler config params to pass to handlers unchanged.\n\tHandlers map[string]json.RawMessage `json:\"handlers\"`\n}\n\n// Contentx of the configuration file\ntype configType struct {\n\t// HTTP(S) address:port to listen on for websocket and long polling clients. Either a\n\t// numeric or a canonical name, e.g. \":80\" or \":https\". Could include a host name, e.g.\n\t// \"localhost:80\".\n\t// Could be blank: if TLS is not configured, will use \":80\", otherwise \":443\".\n\t// Can be overridden from the command line, see option --listen.\n\tListen string `json:\"listen\"`\n\t// Base URL path where the streaming and large file API calls are served, default is '/'.\n\t// Can be overridden from the command line, see option --api_path.\n\tApiPath string `json:\"api_path\"`\n\t// Cache-Control value for static content.\n\tCacheControl int `json:\"cache_control\"`\n\t// If true, do not attempt to negotiate websocket per message compression (RFC 7692.4).\n\t// It should be disabled (set to true) if you are using MSFT IIS as a reverse proxy.\n\tWSCompressionDisabled bool `json:\"ws_compression_disabled\"`\n\t// Address:port to listen for gRPC clients. If blank gRPC support will not be initialized.\n\t// Could be overridden from the command line with --grpc_listen.\n\tGrpcListen string `json:\"grpc_listen\"`\n\t// Enable handling of gRPC keepalives https://github.com/grpc/grpc/blob/master/doc/keepalive.md\n\t// This sets server's GRPC_ARG_KEEPALIVE_TIME_MS to 60 seconds instead of the default 2 hours.\n\tGrpcKeepalive bool `json:\"grpc_keepalive_enabled\"`\n\t// URL path for mounting the directory with static files (usually TinodeWeb).\n\tStaticMount string `json:\"static_mount\"`\n\t// Local path to static files. All files in this path are made accessible by HTTP.\n\tStaticData string `json:\"static_data\"`\n\t// Salt used in signing API keys\n\tAPIKeySalt []byte `json:\"api_key_salt\"`\n\t// Maximum message size allowed from client. Intended to prevent malicious client from sending\n\t// very large files inband (does not affect out of band uploads).\n\tMaxMessageSize int `json:\"max_message_size\"`\n\t// Maximum number of group topic subscribers.\n\tMaxSubscriberCount int `json:\"max_subscriber_count\"`\n\t// Masked tags namespaces: tags immutable on User (mask), mutable on Topic only within the mask.\n\tMaskedTagNamespaces []string `json:\"masked_tags\"`\n\t// Tag namespace used for unique user and topic aliases.\n\tAliasTagNamespace string `json:\"alias_tag\"`\n\t// Maximum number of indexable tags.\n\tMaxTagCount int `json:\"max_tag_count\"`\n\t// If true, ordinary users cannot delete their accounts.\n\tPermanentAccounts bool `json:\"permanent_accounts\"`\n\t// URL path for exposing runtime stats. Disabled if the path is blank.\n\tExpvarPath string `json:\"expvar\"`\n\t// URL path for internal server status. Disabled if the path is blank.\n\tServerStatusPath string `json:\"server_status\"`\n\t// Take IP address of the client from HTTP header 'X-Forwarded-For'.\n\t// Useful when tinode is behind a proxy. If missing, fallback to default RemoteAddr.\n\tUseXForwardedFor bool `json:\"use_x_forwarded_for\"`\n\t// Add X-Frame-Options to HTTP response headers. It should be one of \"DENY\", \"SAMEORIGIN\",\n\t// \"-\" (disabled). The default is SAMEORIGIN.\n\tXFrameOptions string `json:\"x_frame_options\"`\n\t// 2-letter country code (ISO 3166-1 alpha-2) to assign to sessions by default\n\t// when the country isn't specified by the client explicitly and\n\t// it's impossible to infer it.\n\tDefaultCountryCode string `json:\"default_country_code\"`\n\t// Permit hard-deleting messages in p2p topics for both participants.\n\t// If it's set to 'false' then the message is only deleted for the peer who issued the command.\n\t// If it's 'true' then the message is deleted completely by either participant.\n\t// Changing the value affects the ability to hard-delete (the added or removed the D permission)\n\t// only for new topics going forward.\n\tP2PDeleteEnabled bool `json:\"p2p_delete_enabled\"`\n\t// The maximum age of a message in seconds when it can be deleted by users with the 'D' permission.\n\t// E.g. 600 means messages up to 10 minutes old can be deleted, older than that cannot be deleted.\n\t// Missing or 0 means no age limit.\n\t// Does not affect topic owners: owners can delete any message.\n\tMsgDeleteAge int `json:\"msg_delete_age\"`\n\n\t// Configs for subsystems\n\tCluster   json.RawMessage             `json:\"cluster_config\"`\n\tPlugin    json.RawMessage             `json:\"plugins\"`\n\tStore     json.RawMessage             `json:\"store_config\"`\n\tPush      json.RawMessage             `json:\"push\"`\n\tTLS       json.RawMessage             `json:\"tls\"`\n\tAuth      map[string]json.RawMessage  `json:\"auth_config\"`\n\tValidator map[string]*validatorConfig `json:\"acc_validation\"`\n\tAccountGC *accountGcConfig            `json:\"acc_gc_config\"`\n\tMedia     *mediaConfig                `json:\"media\"`\n\tWebRTC    json.RawMessage             `json:\"webrtc\"`\n}\n\nfunc main() {\n\texecutable, _ := os.Executable()\n\n\tlogFlags := flag.String(\"log_flags\", \"stdFlags\",\n\t\t\"Comma-separated list of log flags (as defined in https://golang.org/pkg/log/#pkg-constants without the L prefix)\")\n\tconfigfile := flag.String(\"config\", \"tinode.conf\", \"Path to config file.\")\n\t// Path to static content.\n\tstaticPath := flag.String(\"static_data\", defaultStaticPath, \"File path to directory with static files to be served.\")\n\tlistenOn := flag.String(\"listen\", \"\", \"Override address and port to listen on for HTTP(S) clients.\")\n\tapiPath := flag.String(\"api_path\", \"\", \"Override the base URL path where API is served.\")\n\tlistenGrpc := flag.String(\"grpc_listen\", \"\", \"Override address and port to listen on for gRPC clients.\")\n\ttlsEnabled := flag.Bool(\"tls_enabled\", false, \"Override config value for enabling TLS.\")\n\tclusterSelf := flag.String(\"cluster_self\", \"\", \"Override the name of the current cluster node.\")\n\texpvarPath := flag.String(\"expvar\", \"\", \"Override the URL path where runtime stats are exposed. Use '-' to disable.\")\n\tserverStatusPath := flag.String(\"server_status\", \"\",\n\t\t\"Override the URL path where the server's internal status is displayed. Use '-' to disable.\")\n\tpprofFile := flag.String(\"pprof\", \"\", \"File name to save profiling info to. Disabled if not set.\")\n\tpprofUrl := flag.String(\"pprof_url\", \"\", \"Debugging only! URL path for exposing profiling info. Disabled if not set.\")\n\tflag.Parse()\n\n\tlogs.Init(os.Stderr, *logFlags)\n\n\tcurwd, err := os.Getwd()\n\tif err != nil {\n\t\tlogs.Err.Fatal(\"Couldn't get current working directory: \", err)\n\t}\n\n\tlogs.Info.Printf(\"Server v%s:%s:%s; pid %d; %d process(es)\",\n\t\tcurrentVersion, executable, buildstamp,\n\t\tos.Getpid(), runtime.GOMAXPROCS(runtime.NumCPU()))\n\n\t*configfile = toAbsolutePath(curwd, *configfile)\n\tlogs.Info.Printf(\"Using config from '%s'\", *configfile)\n\n\tvar config configType\n\tif file, err := os.Open(*configfile); err != nil {\n\t\tlogs.Err.Fatal(\"Failed to read config file: \", err)\n\t} else {\n\t\tjr := jcr.New(file)\n\t\tif err = json.NewDecoder(jr).Decode(&config); err != nil {\n\t\t\tswitch jerr := err.(type) {\n\t\t\tcase *json.UnmarshalTypeError:\n\t\t\t\tlnum, cnum, _ := jr.LineAndChar(jerr.Offset)\n\t\t\t\tlogs.Err.Fatalf(\"Unmarshall error in config file in %s at %d:%d (offset %d bytes): %s\",\n\t\t\t\t\tjerr.Field, lnum, cnum, jerr.Offset, jerr.Error())\n\t\t\tcase *json.SyntaxError:\n\t\t\t\tlnum, cnum, _ := jr.LineAndChar(jerr.Offset)\n\t\t\t\tlogs.Err.Fatalf(\"Syntax error in config file at %d:%d (offset %d bytes): %s\",\n\t\t\t\t\tlnum, cnum, jerr.Offset, jerr.Error())\n\t\t\tdefault:\n\t\t\t\tlogs.Err.Fatal(\"Failed to parse config file: \", err)\n\t\t\t}\n\t\t}\n\t\tfile.Close()\n\t}\n\n\tif *listenOn != \"\" {\n\t\tconfig.Listen = *listenOn\n\t}\n\n\t// Set up HTTP server. Must use non-default mux because of expvar.\n\tmux := http.NewServeMux()\n\n\t// Exposing values for statistics and monitoring.\n\tevpath := *expvarPath\n\tif evpath == \"\" {\n\t\tevpath = config.ExpvarPath\n\t}\n\tstatsInit(mux, evpath)\n\tstatsRegisterInt(\"Version\")\n\tdecVersion := base10Version(parseVersion(buildstamp))\n\tif decVersion <= 0 {\n\t\tdecVersion = base10Version(parseVersion(currentVersion))\n\t}\n\tstatsSet(\"Version\", decVersion)\n\n\t// Initialize serving debug profiles (optional).\n\tservePprof(mux, *pprofUrl)\n\n\t// Initialize cluster and receive calculated workerId.\n\t// Cluster won't be started here yet.\n\tworkerId := clusterInit(config.Cluster, clusterSelf)\n\n\tif *pprofFile != \"\" {\n\t\t*pprofFile = toAbsolutePath(curwd, *pprofFile)\n\n\t\tcpuf, err := os.Create(*pprofFile + \".cpu\")\n\t\tif err != nil {\n\t\t\tlogs.Err.Fatal(\"Failed to create CPU pprof file: \", err)\n\t\t}\n\t\tdefer cpuf.Close()\n\n\t\tmemf, err := os.Create(*pprofFile + \".mem\")\n\t\tif err != nil {\n\t\t\tlogs.Err.Fatal(\"Failed to create Mem pprof file: \", err)\n\t\t}\n\t\tdefer memf.Close()\n\n\t\tpprof.StartCPUProfile(cpuf)\n\t\tdefer pprof.StopCPUProfile()\n\t\tdefer pprof.WriteHeapProfile(memf)\n\n\t\tlogs.Info.Printf(\"Profiling info saved to '%s.(cpu|mem)'\", *pprofFile)\n\t}\n\n\terr = store.Store.Open(workerId, config.Store)\n\tlogs.Info.Println(\"DB adapter\", store.Store.GetAdapterName(), store.Store.GetAdapterVersion())\n\tif err != nil {\n\t\tlogs.Err.Fatal(\"Failed to connect to DB: \", err)\n\t}\n\tdefer func() {\n\t\tstore.Store.Close()\n\t\tlogs.Info.Println(\"Closed database connection(s)\")\n\t\tlogs.Info.Println(\"All done, good bye\")\n\t}()\n\tstatsRegisterDbStats()\n\n\t// API key signing secret\n\tglobals.apiKeySalt = config.APIKeySalt\n\n\terr = store.InitAuthLogicalNames(config.Auth[\"logical_names\"])\n\tif err != nil {\n\t\tlogs.Err.Fatal(err)\n\t}\n\n\t// List of tag namespaces for user discovery which cannot be changed directly\n\t// by the client, e.g. 'email' or 'tel'.\n\tglobals.immutableTagNS = make(map[string]bool)\n\n\tauthNames := store.Store.GetAuthNames()\n\tfor _, name := range authNames {\n\t\tif authhdl := store.Store.GetLogicalAuthHandler(name); authhdl == nil {\n\t\t\tlogs.Err.Fatalln(\"Unknown authenticator\", name)\n\t\t} else if jsconf := config.Auth[authhdl.GetRealName()]; jsconf != nil {\n\t\t\tif err := authhdl.Init(jsconf, name); err != nil {\n\t\t\t\tlogs.Err.Fatalln(\"Failed to init auth scheme\", name+\":\", err)\n\t\t\t}\n\t\t\ttags, err := authhdl.RestrictedTags()\n\t\t\tif err != nil {\n\t\t\t\tlogs.Err.Fatalln(\"Failed get restricted tag namespaces (prefixes)\", name+\":\", err)\n\t\t\t}\n\t\t\tfor _, tag := range tags {\n\t\t\t\tif strings.Contains(tag, \":\") {\n\t\t\t\t\tlogs.Err.Fatalln(\"tags restricted by auth handler should not contain character ':'\", tag)\n\t\t\t\t}\n\t\t\t\tglobals.immutableTagNS[tag] = true\n\t\t\t}\n\t\t}\n\t}\n\n\t// Process validators.\n\tfor name, vconf := range config.Validator {\n\t\t// Check if validator is restrictive. If so, add validator name to the list of restricted tags.\n\t\t// The namespace can be restricted even if the validator is disabled.\n\t\tif vconf.AddToTags {\n\t\t\tif strings.Contains(name, \":\") {\n\t\t\t\tlogs.Err.Fatalln(\"acc_validation names should not contain character ':'\", name)\n\t\t\t}\n\t\t\tglobals.immutableTagNS[name] = true\n\t\t}\n\n\t\tif len(vconf.Required) == 0 {\n\t\t\t// Skip disabled validator.\n\t\t\tcontinue\n\t\t}\n\n\t\tvar reqLevels []auth.Level\n\t\tfor _, req := range vconf.Required {\n\t\t\tlvl := auth.ParseAuthLevel(req)\n\t\t\tif lvl == auth.LevelNone {\n\t\t\t\tlogs.Err.Fatalf(\"Invalid required AuthLevel '%s' in validator '%s'\", req, name)\n\t\t\t}\n\t\t\treqLevels = append(reqLevels, lvl)\n\t\t\tif globals.authValidators == nil {\n\t\t\t\tglobals.authValidators = make(map[auth.Level][]string)\n\t\t\t}\n\t\t\tglobals.authValidators[lvl] = append(globals.authValidators[lvl], name)\n\t\t}\n\n\t\tif val := store.Store.GetValidator(name); val == nil {\n\t\t\tlogs.Err.Fatal(\"Config provided for an unknown validator '\" + name + \"'\")\n\t\t} else if err = val.Init(string(vconf.Config)); err != nil {\n\t\t\tlogs.Err.Fatal(\"Failed to init validator '\"+name+\"': \", err)\n\t\t}\n\t\tif globals.validators == nil {\n\t\t\tglobals.validators = make(map[string]credValidator)\n\t\t}\n\t\tglobals.validators[name] = credValidator{\n\t\t\trequiredAuthLvl: reqLevels,\n\t\t\taddToTags:       vconf.AddToTags,\n\t\t}\n\t}\n\n\t// Create credential validator config for clients.\n\tif len(globals.authValidators) > 0 {\n\t\tglobals.validatorClientConfig = make(map[string][]string)\n\t\tfor key, val := range globals.authValidators {\n\t\t\tglobals.validatorClientConfig[key.String()] = val\n\t\t}\n\t}\n\n\t// Partially restricted tag namespaces.\n\tglobals.maskedTagNS = make(map[string]bool, len(config.MaskedTagNamespaces))\n\tfor _, tag := range config.MaskedTagNamespaces {\n\t\tif strings.Contains(tag, \":\") {\n\t\t\tlogs.Err.Fatal(\"masked_tags namespaces should not contain character ':'\", tag)\n\t\t}\n\t\tglobals.maskedTagNS[tag] = true\n\t}\n\n\t// Alias namespace.\n\tconfig.AliasTagNamespace = strings.TrimSpace(config.AliasTagNamespace)\n\tif config.AliasTagNamespace != \"\" {\n\t\tif prefix, _ := validateTag(config.AliasTagNamespace + \":testing\"); prefix == \"\" {\n\t\t\tlogs.Err.Fatal(\"alias_tag namespace should contain only alphanumeric characters and '_'\",\n\t\t\t\tconfig.AliasTagNamespace)\n\t\t}\n\t\tglobals.aliasTagNS = config.AliasTagNamespace\n\t}\n\n\tvar tags []string\n\tfor tag := range globals.immutableTagNS {\n\t\ttags = append(tags, \"'\"+tag+\"'\")\n\t}\n\tif len(tags) > 0 {\n\t\tlogs.Info.Println(\"Restricted tags:\", tags)\n\t}\n\ttags = nil\n\tfor tag := range globals.maskedTagNS {\n\t\ttags = append(tags, \"'\"+tag+\"'\")\n\t}\n\tif len(tags) > 0 {\n\t\tlogs.Info.Println(\"Masked tags:\", tags)\n\t}\n\tif len(globals.aliasTagNS) > 0 {\n\t\tlogs.Info.Println(\"Alias tag:\", globals.aliasTagNS)\n\t}\n\n\t// Maximum message size\n\tglobals.maxMessageSize = int64(config.MaxMessageSize)\n\tif globals.maxMessageSize <= 0 {\n\t\tglobals.maxMessageSize = defaultMaxMessageSize\n\t}\n\t// Maximum number of group topic subscribers\n\tglobals.maxSubscriberCount = config.MaxSubscriberCount\n\tif globals.maxSubscriberCount <= 1 {\n\t\tglobals.maxSubscriberCount = defaultMaxSubscriberCount\n\t}\n\t// Maximum number of indexable tags per user or topics\n\tglobals.maxTagCount = config.MaxTagCount\n\tif globals.maxTagCount <= 0 {\n\t\tglobals.maxTagCount = defaultMaxTagCount\n\t}\n\t// If account deletion is disabled.\n\tglobals.permanentAccounts = config.PermanentAccounts\n\n\tglobals.useXForwardedFor = config.UseXForwardedFor\n\tglobals.defaultCountryCode = config.DefaultCountryCode\n\tif globals.defaultCountryCode == \"\" {\n\t\tglobals.defaultCountryCode = defaultCountryCode\n\t}\n\n\t// Default access mode for P2P: with/without the D permission.\n\tglobals.typesModeCP2P = types.ModeCP2P\n\tif config.P2PDeleteEnabled {\n\t\tglobals.typesModeCP2P = types.ModeCP2PD\n\t}\n\n\tif config.MsgDeleteAge > 0 {\n\t\tglobals.msgDeleteAge = time.Duration(config.MsgDeleteAge) * time.Second\n\t}\n\n\t// Configuration of X-Frame-Options header.\n\tglobals.xFrameOptions = config.XFrameOptions\n\tif globals.xFrameOptions == \"\" {\n\t\tglobals.xFrameOptions = \"SAMEORIGIN\"\n\t}\n\tif globals.xFrameOptions != \"SAMEORIGIN\" && globals.xFrameOptions != \"DENY\" && globals.xFrameOptions != \"-\" {\n\t\tlogs.Warn.Println(\"Ignored invalid x_frame_options\", config.XFrameOptions)\n\t\tglobals.xFrameOptions = \"SAMEORIGIN\"\n\t}\n\n\t// Websocket compression.\n\tglobals.wsCompression = !config.WSCompressionDisabled\n\n\tif config.Media != nil {\n\t\tif config.Media.UseHandler == \"\" {\n\t\t\tconfig.Media = nil\n\t\t} else {\n\t\t\tglobals.maxFileUploadSize = config.Media.MaxFileUploadSize\n\t\t\tif config.Media.Handlers != nil {\n\t\t\t\tvar conf string\n\t\t\t\tif params := config.Media.Handlers[config.Media.UseHandler]; params != nil {\n\t\t\t\t\tconf = string(params)\n\t\t\t\t}\n\t\t\t\tif err = store.Store.UseMediaHandler(config.Media.UseHandler, conf); err != nil {\n\t\t\t\t\tlogs.Err.Fatalf(\"Failed to init media handler '%s': %s\", config.Media.UseHandler, err)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif config.Media.GcPeriod > 0 && config.Media.GcBlockSize > 0 {\n\t\t\t\tglobals.mediaGcPeriod = time.Second * time.Duration(config.Media.GcPeriod)\n\t\t\t\tstopFilesGc := largeFileRunGarbageCollection(globals.mediaGcPeriod, config.Media.GcBlockSize)\n\t\t\t\tdefer func() {\n\t\t\t\t\tstopFilesGc <- true\n\t\t\t\t\tlogs.Info.Println(\"Stopped files garbage collector\")\n\t\t\t\t}()\n\t\t\t}\n\t\t}\n\t}\n\n\t// Stale unvalidated user account garbage collection.\n\tif config.AccountGC != nil && config.AccountGC.Enabled {\n\t\tif config.AccountGC.GcPeriod <= 0 || config.AccountGC.GcBlockSize <= 0 ||\n\t\t\tconfig.AccountGC.GcMinAccountAge <= 0 {\n\t\t\tlogs.Err.Fatalln(\"Invalid account GC config\")\n\t\t}\n\t\tgcPeriod := time.Second * time.Duration(config.AccountGC.GcPeriod)\n\t\tstopAccountGc := garbageCollectUsers(gcPeriod, config.AccountGC.GcBlockSize, config.AccountGC.GcMinAccountAge)\n\n\t\tdefer func() {\n\t\t\tstopAccountGc <- true\n\t\t\tlogs.Info.Println(\"Stopped account garbage collector\")\n\t\t}()\n\t}\n\n\tpushHandlers, err := push.Init(config.Push)\n\tif err != nil {\n\t\tlogs.Err.Fatal(\"Failed to initialize push notifications:\", err)\n\t}\n\tdefer func() {\n\t\tpush.Stop()\n\t\tlogs.Info.Println(\"Stopped push notifications\")\n\t}()\n\tlogs.Info.Println(\"Push handlers configured:\", pushHandlers)\n\n\tif err = initVideoCalls(config.WebRTC); err != nil {\n\t\tlogs.Err.Fatal(\"Failed to init video calls: %w\", err)\n\t}\n\n\t// Keep inactive LP sessions for 15 seconds\n\tglobals.sessionStore = NewSessionStore(idleSessionTimeout + 15*time.Second)\n\t// The hub (the main message router)\n\tglobals.hub = newHub()\n\n\t// Start accepting cluster traffic.\n\tif globals.cluster != nil {\n\t\tglobals.cluster.start()\n\t}\n\n\ttlsConfig, err := parseTLSConfig(*tlsEnabled, config.TLS)\n\tif err != nil {\n\t\tlogs.Err.Fatalln(err)\n\t}\n\n\t// Initialize plugins.\n\tpluginsInit(config.Plugin)\n\n\t// Initialize users cache\n\tusersInit()\n\n\t// Set up gRPC server, if one is configured\n\tif *listenGrpc == \"\" {\n\t\t*listenGrpc = config.GrpcListen\n\t}\n\tif globals.grpcServer, err = serveGrpc(*listenGrpc, config.GrpcKeepalive, tlsConfig); err != nil {\n\t\tlogs.Err.Fatal(err)\n\t}\n\n\t// Serve static content from the directory in -static_data flag if that's\n\t// available, otherwise assume '<current-dir>/static'. The content is served at\n\t// the path pointed by 'static_mount' in the config. If that is missing then it's\n\t// served at root '/'.\n\tvar staticMountPoint string\n\tif *staticPath != \"\" && *staticPath != \"-\" {\n\t\t// Resolve path to static content.\n\t\t*staticPath = toAbsolutePath(curwd, *staticPath)\n\t\tif _, err = os.Stat(*staticPath); os.IsNotExist(err) {\n\t\t\tlogs.Err.Fatal(\"Static content directory is not found\", *staticPath)\n\t\t}\n\n\t\tstaticMountPoint = config.StaticMount\n\t\tif staticMountPoint == \"\" {\n\t\t\tstaticMountPoint = defaultStaticMount\n\t\t} else {\n\t\t\tif !strings.HasPrefix(staticMountPoint, \"/\") {\n\t\t\t\tstaticMountPoint = \"/\" + staticMountPoint\n\t\t\t}\n\t\t\tif !strings.HasSuffix(staticMountPoint, \"/\") {\n\t\t\t\tstaticMountPoint += \"/\"\n\t\t\t}\n\t\t}\n\t\tmux.Handle(staticMountPoint,\n\t\t\t// Add optional Cache-Control header.\n\t\t\tcacheControlHandler(config.CacheControl,\n\t\t\t\t// Optionally add Strict-Transport-Security and X-Frame-Options to the response.\n\t\t\t\toptionalHttpHeaders(\n\t\t\t\t\t// Add gzip compression.\n\t\t\t\t\tgh.CompressHandler(\n\t\t\t\t\t\t// And add custom formatter of errors.\n\t\t\t\t\t\thttpErrorHandler(\n\t\t\t\t\t\t\t// Remove mount point prefix.\n\t\t\t\t\t\t\thttp.StripPrefix(staticMountPoint,\n\t\t\t\t\t\t\t\thttp.FileServer(http.Dir(*staticPath))))))))\n\t\tlogs.Info.Printf(\"Serving static content from '%s' at '%s'\", *staticPath, staticMountPoint)\n\t} else {\n\t\tlogs.Info.Println(\"Static content is disabled\")\n\t}\n\n\t// Configure root path for serving API calls.\n\tif *apiPath != \"\" {\n\t\tconfig.ApiPath = *apiPath\n\t}\n\tif config.ApiPath == \"\" {\n\t\tconfig.ApiPath = defaultApiPath\n\t} else {\n\t\tif !strings.HasPrefix(config.ApiPath, \"/\") {\n\t\t\tconfig.ApiPath = \"/\" + config.ApiPath\n\t\t}\n\t\tif !strings.HasSuffix(config.ApiPath, \"/\") {\n\t\t\tconfig.ApiPath += \"/\"\n\t\t}\n\t}\n\tlogs.Info.Printf(\"API served from root URL path '%s'\", config.ApiPath)\n\n\t// Best guess location of the main endpoint.\n\t// TODO: provide fix for the case when the serving is over unix sockets.\n\t// TODO: implement serving large files over gRPC, then remove globals.servingAt.\n\tglobals.servingAt = config.Listen + config.ApiPath\n\tif tlsConfig != nil {\n\t\tglobals.servingAt = \"https://\" + globals.servingAt\n\t} else {\n\t\tglobals.servingAt = \"http://\" + globals.servingAt\n\t}\n\n\tsspath := *serverStatusPath\n\tif sspath == \"\" {\n\t\tsspath = config.ServerStatusPath\n\t}\n\tif sspath != \"\" && sspath != \"-\" {\n\t\tlogs.Info.Printf(\"Server status is available at '%s'\", sspath)\n\t\tmux.HandleFunc(sspath, serveStatus)\n\t}\n\n\t// Handle websocket clients.\n\tmux.HandleFunc(config.ApiPath+\"v0/channels\", serveWebSocket)\n\t// Handle long polling clients. Enable compression.\n\tmux.Handle(config.ApiPath+\"v0/channels/lp\", gh.CompressHandler(http.HandlerFunc(serveLongPoll)))\n\tif config.Media != nil {\n\t\t// Handle uploads of large files.\n\t\tmux.Handle(config.ApiPath+\"v0/file/u/\", gh.CompressHandler(http.HandlerFunc(largeFileReceiveHTTP)))\n\t\t// Serve large files.\n\t\tmux.Handle(config.ApiPath+\"v0/file/s/\", gh.CompressHandler(http.HandlerFunc(largeFileServeHTTP)))\n\t\tlogs.Info.Println(\"Large media handling enabled\", config.Media.UseHandler)\n\t}\n\n\tif staticMountPoint != \"/\" {\n\t\t// Serve json-formatted 404 for all other URLs\n\t\tmux.HandleFunc(\"/\", serve404)\n\t}\n\n\tif err = listenAndServe(config.Listen, mux, tlsConfig, signalHandler()); err != nil {\n\t\tlogs.Err.Fatal(err)\n\t}\n}\n"
  },
  {
    "path": "server/media/fs/filesys.go",
    "content": "// Package fs implements github.com/tinode/chat/server/media interface by storing media objects in a single\n// directory in the file system.\n// This module won't perform well with tens of thousand of files because it stores all files in a single directory.\npackage fs\n\nimport (\n\t\"encoding/base32\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"hash/fnv\"\n\t\"io\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/media\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\nconst (\n\tdefaultServeURL     = \"/v0/file/s/\"\n\tdefaultCacheControl = \"max-age=86400\"\n\n\thandlerName = \"fs\"\n)\n\ntype fileConfig struct {\n\t// FileUploadDirectory: In case of a cluster fileUploadLocation must be accessible to all cluster members.\n\tFileUploadDirectory string   `json:\"upload_dir\"`\n\tServeURL            string   `json:\"serve_url\"`\n\tCorsOrigins         []string `json:\"cors_origins\"`\n\tCacheControl        string   `json:\"cache_control\"`\n}\n\ntype fshandler struct {\n\tfileConfig\n\t// corsOrigins parsed allowed origins.\n\tcorsOrigins []media.AllowedOrigin\n}\n\nfunc (fh *fshandler) Init(jsconf string) error {\n\tvar err error\n\n\tif err = json.Unmarshal([]byte(jsconf), &fh.fileConfig); err != nil {\n\t\treturn errors.New(\"failed to parse config: \" + err.Error())\n\t}\n\n\tif fh.FileUploadDirectory == \"\" {\n\t\treturn errors.New(\"missing upload location\")\n\t}\n\n\tif fh.ServeURL == \"\" {\n\t\tfh.ServeURL = defaultServeURL\n\t}\n\n\tif fh.CacheControl == \"\" {\n\t\tfh.CacheControl = defaultCacheControl\n\t}\n\n\tfh.corsOrigins, err = media.ParseCORSAllow(fh.CorsOrigins)\n\tif err != nil {\n\t\treturn errors.New(\"failed to parse CORS allowed origins: \" + err.Error())\n\t}\n\t// Make sure the upload directory exists.\n\treturn os.MkdirAll(fh.FileUploadDirectory, 0777)\n}\n\n// Headers is used for cache management and serving CORS headers.\nfunc (fh *fshandler) Headers(method string, url *url.URL, headers http.Header, serve bool) (http.Header, int, error) {\n\tif method == http.MethodGet {\n\n\t\tfid := fh.GetIdFromUrl(url.String())\n\t\tif fid.IsZero() {\n\t\t\treturn nil, 0, types.ErrNotFound\n\t\t}\n\n\t\tfdef, err := fh.getFileRecord(fid)\n\t\tif err != nil {\n\t\t\treturn nil, 0, err\n\t\t}\n\n\t\tif etag := strings.Trim(headers.Get(\"If-None-Match\"), \"\\\"\"); etag != \"\" && etag == fdef.ETag {\n\t\t\treturn http.Header{\n\t\t\t\t\t\"Last-Modified\": {fdef.UpdatedAt.Format(http.TimeFormat)},\n\t\t\t\t\t\"ETag\":          {`\"` + fdef.ETag + `\"`},\n\t\t\t\t\t\"Cache-Control\": {fh.CacheControl},\n\t\t\t\t},\n\t\t\t\thttp.StatusNotModified, nil\n\t\t}\n\n\t\treturn http.Header{\n\t\t\t\"Content-Type\":  {fdef.MimeType},\n\t\t\t\"Cache-Control\": {fh.CacheControl},\n\t\t\t\"ETag\":          {`\"` + fdef.ETag + `\"`},\n\t\t}, 0, nil\n\t}\n\n\tif method != http.MethodOptions {\n\t\t// Not an OPTIONS request. No special handling for all other requests.\n\t\treturn nil, 0, nil\n\t}\n\theader, status := media.CORSHandler(method, headers, fh.corsOrigins, serve)\n\treturn header, status, nil\n}\n\n// Upload processes request for file upload. The file is given as io.Reader.\nfunc (fh *fshandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64, error) {\n\t// FIXME: create two-three levels of nested directories. Serving from a single directory\n\t// with tens of thousands of files in it will not perform well.\n\n\t// Generate a unique file name and attach it to path. Using base32 instead of base64 to avoid possible\n\t// file name collisions on Windows due to case-insensitive file names there.\n\tlocation := filepath.Join(fh.FileUploadDirectory, fdef.Uid().String32())\n\n\toutfile, err := os.Create(location)\n\tif err != nil {\n\t\tlogs.Warn.Println(\"Upload: failed to create file\", fdef.Location, err)\n\t\treturn \"\", 0, err\n\t}\n\n\tif err = store.Files.StartUpload(fdef); err != nil {\n\t\toutfile.Close()\n\t\tos.Remove(location)\n\t\tlogs.Warn.Println(\"failed to create file record\", fdef.Id, err)\n\t\treturn \"\", 0, err\n\t}\n\n\tsize, err := io.Copy(outfile, file)\n\toutfile.Close()\n\tif err != nil {\n\t\tos.Remove(location)\n\t\treturn \"\", 0, err\n\t}\n\n\tfname := fdef.Id\n\text, _ := mime.ExtensionsByType(fdef.MimeType)\n\tif len(ext) > 0 {\n\t\tfname += ext[0]\n\t}\n\n\tfdef.Location = location\n\t// Use file path to create ETag. File paths are unique so will be the ETag.\n\tfdef.ETag = etagFromPath(fdef.Location)\n\n\treturn fh.ServeURL + fname, size, nil\n}\n\n// Download processes request for file download.\n// The returned ReadSeekCloser must be closed after use.\nfunc (fh *fshandler) Download(url string) (*types.FileDef, media.ReadSeekCloser, error) {\n\tfid := fh.GetIdFromUrl(url)\n\tif fid.IsZero() {\n\t\treturn nil, nil, types.ErrNotFound\n\t}\n\n\tfd, err := fh.getFileRecord(fid)\n\tif err != nil {\n\t\tlogs.Warn.Println(\"Download: file not found\", fid)\n\t\treturn nil, nil, err\n\t}\n\n\tfile, err := os.Open(fd.Location)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\t// If the file is not found, send 404 instead of the default 500\n\t\t\terr = types.ErrNotFound\n\t\t}\n\t\treturn nil, nil, err\n\t}\n\n\treturn fd, file, nil\n}\n\n// Delete deletes files from storage by provided slice of locations.\nfunc (fh *fshandler) Delete(locations []string) error {\n\tfor _, loc := range locations {\n\t\tif err, _ := os.Remove(loc).(*os.PathError); err != nil {\n\t\t\tif err != os.ErrNotExist {\n\t\t\t\tlogs.Warn.Println(\"fs: error deleting file\", loc, err)\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\n// GetIdFromUrl converts an attahment URL to a file UID.\nfunc (fh *fshandler) GetIdFromUrl(url string) types.Uid {\n\treturn media.GetIdFromUrl(url, fh.ServeURL)\n}\n\n// getFileRecord given file ID reads file record from the database.\nfunc (fh *fshandler) getFileRecord(fid types.Uid) (*types.FileDef, error) {\n\tfd, err := store.Files.Get(fid.String())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif fd == nil {\n\t\treturn nil, types.ErrNotFound\n\t}\n\treturn fd, nil\n}\n\nfunc etagFromPath(path string) string {\n\thasher := fnv.New128()\n\thasher.Write([]byte(path))\n\treturn strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).\n\t\tEncodeToString(hasher.Sum(make([]byte, 0, hasher.Size()))))\n}\n\nfunc init() {\n\tstore.RegisterMediaHandler(handlerName, &fshandler{})\n}\n"
  },
  {
    "path": "server/media/media.go",
    "content": "// Package media defines an interface which must be implemented by media upload/download handlers.\npackage media\n\nimport (\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"path\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"slices\"\n\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\n// ReadSeekCloser must be implemented by the media being downloaded.\ntype ReadSeekCloser interface {\n\tio.Reader\n\tio.Seeker\n\tio.Closer\n}\n\n// Handler is an interface which must be implemented by media handlers (uploaders-downloaders).\ntype Handler interface {\n\t// Init initializes the media upload handler.\n\tInit(jsconf string) error\n\n\t// Headers checks if the handler wants to provide additional HTTP headers for the request.\n\t// It could be CORS headers, redirect to serve files from another URL, cache-control headers.\n\t// It returns headers as a map, HTTP status code to stop processing or 0 to continue, error.\n\tHeaders(method string, url *url.URL, headers http.Header, serve bool) (http.Header, int, error)\n\n\t// Upload processes request for file upload. Returns file URL, file size, error.\n\tUpload(fdef *types.FileDef, file io.Reader) (string, int64, error)\n\n\t// Download processes request for file download.\n\tDownload(url string) (*types.FileDef, ReadSeekCloser, error)\n\n\t// Delete deletes file from storage.\n\tDelete(locations []string) error\n\n\t// GetIdFromUrl extracts file ID from download URL.\n\tGetIdFromUrl(url string) types.Uid\n}\n\ntype AllowedOrigin struct {\n\tOrigin      string\n\tURL         url.URL\n\tHostParts   []string\n\tHasWildcard bool\n}\n\nvar fileNamePattern = regexp.MustCompile(`^[-_A-Za-z0-9]+`)\n\n// GetIdFromUrl is a helper method for extracting file ID from a URL.\nfunc GetIdFromUrl(url, serveUrl string) types.Uid {\n\tdir, fname := path.Split(path.Clean(url))\n\n\tif dir != \"\" && dir != serveUrl {\n\t\treturn types.ZeroUid\n\t}\n\n\treturn types.ParseUid(fileNamePattern.FindString(fname))\n}\n\n// ParseCORSAllow pre-parses allowed origins from the configuration.\nfunc ParseCORSAllow(allowed []string) ([]AllowedOrigin, error) {\n\tif len(allowed) == 0 {\n\t\treturn nil, nil\n\t}\n\n\tresult := make([]AllowedOrigin, 0, len(allowed))\n\tfor _, val := range allowed {\n\t\tparsed := AllowedOrigin{Origin: val}\n\t\tswitch val {\n\t\tcase \"*\":\n\t\t\tif len(allowed) > 1 {\n\t\t\t\treturn nil, errors.New(\"wildcard origin '*' must be the only entry\")\n\t\t\t}\n\t\t\tparsed.HasWildcard = true\n\t\tcase \"\":\n\t\t\tif len(allowed) > 1 {\n\t\t\t\treturn nil, errors.New(\"empty allowed origin '' must be the only entry\")\n\t\t\t}\n\t\t\t// Empty string means no origin allowed - no URL parsing needed\n\t\t\tparsed.HasWildcard = false\n\t\tdefault:\n\t\t\tu, err := url.ParseRequestURI(val)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tparsed.HostParts = strings.Split(u.Hostname(), \".\")\n\t\t\tparsed.URL = *u\n\t\t\tparsed.HasWildcard = strings.Contains(u.Hostname(), \"*\")\n\t\t}\n\t\tresult = append(result, parsed)\n\t}\n\treturn result, nil\n}\n\n// matchCORSOrigin compares origin from the HTTP request to a list of allowed origins.\nfunc matchCORSOrigin(allowed []AllowedOrigin, origin string) string {\n\tif len(allowed) == 0 {\n\t\t// Not configured\n\t\treturn \"\"\n\t}\n\n\tif origin == \"\" && allowed[0].Origin != \"*\" {\n\t\t// Request has no Origin header and \"*\" (any origin) not allowed.\n\t\treturn \"\"\n\t}\n\n\tif allowed[0].Origin == \"*\" {\n\t\tif origin == \"\" {\n\t\t\treturn \"*\"\n\t\t}\n\t\treturn origin\n\t}\n\n\t// Check for empty string in allowed origins - this means no origin is allowed.\n\tif allowed[0].Origin == \"\" {\n\t\treturn \"\"\n\t}\n\n\toriginUrl, err := url.ParseRequestURI(origin)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\toriginParts := strings.Split(originUrl.Hostname(), \".\")\n\n\tfor _, val := range allowed {\n\t\tif val.Origin == origin {\n\t\t\treturn origin\n\t\t}\n\n\t\tif !val.HasWildcard ||\n\t\t\toriginUrl.Scheme != val.URL.Scheme ||\n\t\t\toriginUrl.Port() != val.URL.Port() ||\n\t\t\tlen(originParts) != len(val.HostParts) {\n\t\t\tcontinue\n\t\t}\n\n\t\tmatched := true\n\t\tfor i, part := range val.HostParts {\n\t\t\tif part == \"*\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif part != originParts[i] {\n\t\t\t\tmatched = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif matched {\n\t\t\treturn origin\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\n// allowMethods must be in UPPERCASE.\nfunc matchCORSMethod(allowMethods []string, method string) bool {\n\tif method == \"\" {\n\t\t// Request has no Method header.\n\t\treturn false\n\t}\n\n\treturn slices.Contains(allowMethods, strings.ToUpper(method))\n}\n\n// CORSHandler is the default CORS processor for use by media handlers. It adds CORS headers to\n// preflight OPTIONS requests, Vary & Access-Control-Allow-Origin headers to all responses.\nfunc CORSHandler(method string, reqHeader http.Header, allowedOrigins []AllowedOrigin, serve bool) (http.Header, int) {\n\trespHeader := map[string][]string{\n\t\t// Always add Vary because of possible intermediate caches.\n\t\t\"Vary\": {\"Origin\", \"Access-Control-Request-Method, Access-Control-Request-Headers\"},\n\t}\n\n\torigin := reqHeader.Get(\"Origin\")\n\n\tallowedOrigin := matchCORSOrigin(allowedOrigins, origin)\n\tif acMethod := reqHeader.Get(\"Access-Control-Request-Method\"); method == http.MethodOptions && acMethod != \"\" {\n\t\t// Preflight request.\n\n\t\tif allowedOrigin == \"\" {\n\t\t\treturn respHeader, http.StatusNoContent\n\t\t}\n\n\t\tvar allowMethods []string\n\t\tif serve {\n\t\t\tallowMethods = []string{http.MethodGet, http.MethodHead, http.MethodOptions}\n\t\t} else {\n\t\t\tallowMethods = []string{http.MethodPost, http.MethodPut, http.MethodHead, http.MethodOptions}\n\t\t}\n\n\t\tif !matchCORSMethod(allowMethods, acMethod) {\n\t\t\t// CORS policy does not allow this method.\n\t\t\treturn respHeader, http.StatusNoContent\n\t\t}\n\n\t\trespHeader[\"Access-Control-Allow-Headers\"] = []string{\"*\"}\n\t\trespHeader[\"Access-Control-Allow-Credentials\"] = []string{\"true\"}\n\t\trespHeader[\"Access-Control-Allow-Methods\"] = []string{strings.Join(allowMethods, \", \")}\n\t\trespHeader[\"Access-Control-Max-Age\"] = []string{\"86400\"}\n\t\trespHeader[\"Access-Control-Allow-Origin\"] = []string{allowedOrigin}\n\n\t\treturn respHeader, http.StatusNoContent\n\t}\n\n\t// Regular request, not a preflight.\n\n\tif allowedOrigin != \"\" {\n\t\t// Returning Origin from the actual request instead of '*', otherwise there could be an issue with Credentials.\n\t\trespHeader[\"Access-Control-Allow-Origin\"] = []string{origin}\n\t}\n\n\treturn respHeader, 0\n}\n"
  },
  {
    "path": "server/media/media_test.go",
    "content": "package media\n\nimport (\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestMatchCORSOrigin(t *testing.T) {\n\tcases := []struct {\n\t\tallowed      []string\n\t\torigin       string\n\t\texpected     string\n\t\texpectError  bool\n\t\terrorMessage string\n\t}{\n\t\t{\n\t\t\tallowed:  []string{\"https://example.com\"},\n\t\t\torigin:   \"https://example.com\",\n\t\t\texpected: \"https://example.com\",\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"https://example2.com\", \"https://example.com\"},\n\t\t\torigin:   \"https://example.com\",\n\t\t\texpected: \"https://example.com\",\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"*\"},\n\t\t\torigin:   \"https://example.com\",\n\t\t\texpected: \"https://example.com\",\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"\"},\n\t\t\torigin:   \"https://example.com\",\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{},\n\t\t\torigin:   \"\",\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"https://example.com\"},\n\t\t\torigin:   \"\",\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{},\n\t\t\torigin:   \"https://example.com\",\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"http://example.com\"},\n\t\t\torigin:   \"https://example.com\",\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"https://example.com\"},\n\t\t\torigin:   \"http://example.com\",\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"http://example.com:8000\"},\n\t\t\torigin:   \"http://example.com:8000\",\n\t\t\texpected: \"http://example.com:8000\",\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"http://localhost:8090\"},\n\t\t\torigin:   \"http://localhost:8090\",\n\t\t\texpected: \"http://localhost:8090\",\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"http://localhost:8090\"},\n\t\t\torigin:   \"http://localhost:8080\",\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"https://example.com\"},\n\t\t\torigin:   \"https://sub.example.com\",\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"https://*.example.com\"},\n\t\t\torigin:   \"https://sub.example.com\",\n\t\t\texpected: \"https://sub.example.com\",\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"https://*.example.com\"},\n\t\t\torigin:   \"https://pre.sub.example.com\",\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"https://*.example.com\", \"https://*.sub.example.com\"},\n\t\t\torigin:   \"https://pre.sub.example.com\",\n\t\t\texpected: \"https://pre.sub.example.com\",\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"https://*.*.example.com\"},\n\t\t\torigin:   \"https://pre.sub.example.com\",\n\t\t\texpected: \"https://pre.sub.example.com\",\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"https://*.sub.example.com\"},\n\t\t\torigin:   \"https://pre.asd.example.com\",\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"https://pre.*.example.com\"},\n\t\t\torigin:   \"https://pre.sub.example.com\",\n\t\t\texpected: \"https://pre.sub.example.com\",\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"https://*.*.*.example.com\"},\n\t\t\torigin:   \"https://www.pre.sub.example.com\",\n\t\t\texpected: \"https://www.pre.sub.example.com\",\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"*\"},\n\t\t\torigin:   \"\",\n\t\t\texpected: \"*\", // Should allow any origin, including empty\n\t\t},\n\t\t// Error cases - these should fail during ParseCORSAllow\n\t\t{\n\t\t\tallowed:      []string{\"*\", \"https://example.com\"},\n\t\t\torigin:       \"https://example.com\",\n\t\t\texpectError:  true,\n\t\t\terrorMessage: \"wildcard origin '*' must be the only entry\",\n\t\t},\n\t\t{\n\t\t\tallowed:      []string{\"not-a-valid-url\"},\n\t\t\torigin:       \"https://example.com\",\n\t\t\texpectError:  true,\n\t\t\terrorMessage: \"invalid URI for request\",\n\t\t},\n\t\t{\n\t\t\tallowed:      []string{\"://invalid-url\"},\n\t\t\torigin:       \"https://example.com\",\n\t\t\texpectError:  true,\n\t\t\terrorMessage: \"missing protocol scheme\",\n\t\t},\n\t\t{\n\t\t\tallowed:      []string{\"https://\", \"example.com\"},\n\t\t\torigin:       \"https://example.com\",\n\t\t\texpectError:  true,\n\t\t\terrorMessage: \"invalid URI for request\",\n\t\t},\n\t\t// Valid cases that should not error\n\t\t{\n\t\t\tallowed:      []string{\"\", \"https://example.com\"},\n\t\t\torigin:       \"https://example.com\",\n\t\t\texpectError:  true,\n\t\t\terrorMessage: \"empty allowed origin '' must be the only entry\", // Empty string should make all origins disallowed\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"https://Example.com\"},\n\t\t\torigin:   \"https://example.com\",\n\t\t\texpected: \"\", // Should not match due to case sensitivity\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"https://example.com/\"},\n\t\t\torigin:   \"https://example.com\",\n\t\t\texpected: \"\", // Should not match due to trailing slash\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"https://example.com\"},\n\t\t\torigin:   \"not-a-valid-url\",\n\t\t\texpected: \"\", // Should handle malformed origin gracefully\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"https://example.*.com\"},\n\t\t\torigin:   \"https://example.sub.com\",\n\t\t\texpected: \"https://example.sub.com\",\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"https://*.com\"},\n\t\t\torigin:   \"https://example.com\",\n\t\t\texpected: \"https://example.com\",\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"http://*.example.com\"},\n\t\t\torigin:   \"https://sub.example.com\",\n\t\t\texpected: \"\", // Should not match due to protocol difference\n\t\t},\n\t\t{\n\t\t\tallowed:  []string{\"https://example.com\", \"https://example.com\"},\n\t\t\torigin:   \"https://example.com\",\n\t\t\texpected: \"https://example.com\", // Should still work with duplicates\n\t\t},\n\t}\n\n\tfor i, tc := range cases {\n\t\tallowedOrigins, err := ParseCORSAllow(tc.allowed)\n\n\t\tif tc.expectError {\n\t\t\tif err == nil {\n\t\t\t\tt.Errorf(\"Test case %d: Expected error but got none. Allowed: %v\", i, tc.allowed)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif tc.errorMessage != \"\" && !containsSubstring(err.Error(), tc.errorMessage) {\n\t\t\t\tt.Errorf(\"Test case %d: Expected error containing '%s', got '%s'\", i, tc.errorMessage, err.Error())\n\t\t\t}\n\t\t\t// For error cases, we don't test the matching logic\n\t\t\tcontinue\n\t\t}\n\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Test case %d: Unexpected error parsing allowed origins: %v. Allowed: %v\", i, err, tc.allowed)\n\t\t\tcontinue\n\t\t}\n\n\t\tresult := matchCORSOrigin(allowedOrigins, tc.origin)\n\t\tif result != tc.expected {\n\t\t\tt.Errorf(\"Test case %d: Match CORS origin got wrong result. Expected '%s', got '%s'. Allowed: %v, Origin: '%s'\",\n\t\t\t\ti, tc.expected, result, tc.allowed, tc.origin)\n\t\t}\n\t}\n}\n\n// Helper function to check if a string contains a substring (case-insensitive)\nfunc containsSubstring(str, substr string) bool {\n\treturn strings.Contains(strings.ToLower(str), strings.ToLower(substr))\n}\n"
  },
  {
    "path": "server/media/s3/s3.go",
    "content": "// Package s3 implements media interface by storing media objects in Amazon S3 bucket.\npackage s3\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/aws/aws-sdk-go/aws\"\n\t\"github.com/aws/aws-sdk-go/aws/awserr\"\n\t\"github.com/aws/aws-sdk-go/aws/credentials\"\n\t\"github.com/aws/aws-sdk-go/aws/request\"\n\t\"github.com/aws/aws-sdk-go/aws/session\"\n\t\"github.com/aws/aws-sdk-go/service/s3\"\n\t\"github.com/aws/aws-sdk-go/service/s3/s3manager\"\n\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/media\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\nconst (\n\tdefaultServeURL     = \"/v0/file/s/\"\n\tdefaultCacheControl = \"no-cache, must-revalidate\"\n\n\thandlerName = \"s3\"\n\t// Presign GET URLs for this number of seconds.\n\tdefaultPresignDuration = 120\n)\n\ntype awsconfig struct {\n\tAccessKeyId     string   `json:\"access_key_id\"`\n\tSecretAccessKey string   `json:\"secret_access_key\"`\n\tRegion          string   `json:\"region\"`\n\tDisableSSL      bool     `json:\"disable_ssl\"`\n\tForcePathStyle  bool     `json:\"force_path_style\"`\n\tEndpoint        string   `json:\"endpoint\"`\n\tBucketName      string   `json:\"bucket\"`\n\tCorsOrigins     []string `json:\"cors_origins\"`\n\tServeURL        string   `json:\"serve_url\"`\n\tPresignTTL      int      `json:\"presign_ttl\"`\n\tCacheControl    string   `json:\"cache_control\"`\n}\n\ntype awshandler struct {\n\tsvc         *s3.S3\n\tconf        awsconfig\n\tcorsOrigins []media.AllowedOrigin\n}\n\n// readerCounter is a byte counter for bytes read through the io.Reader\ntype readerCounter struct {\n\tio.Reader\n\tcount  int64\n\treader io.Reader\n}\n\n// Read reads the bytes and records the number of read bytes.\nfunc (rc *readerCounter) Read(buf []byte) (int, error) {\n\tn, err := rc.reader.Read(buf)\n\tatomic.AddInt64(&rc.count, int64(n))\n\treturn n, err\n}\n\n// Init initializes the media handler.\nfunc (ah *awshandler) Init(jsconf string) error {\n\tvar err error\n\tif err = json.Unmarshal([]byte(jsconf), &ah.conf); err != nil {\n\t\treturn errors.New(\"failed to parse config: \" + err.Error())\n\t}\n\n\tif ah.conf.AccessKeyId == \"\" {\n\t\treturn errors.New(\"missing Access Key ID\")\n\t}\n\tif ah.conf.SecretAccessKey == \"\" {\n\t\treturn errors.New(\"missing Secret Access Key\")\n\t}\n\tif ah.conf.Region == \"\" {\n\t\treturn errors.New(\"missing Region\")\n\t}\n\tif ah.conf.BucketName == \"\" {\n\t\treturn errors.New(\"missing Bucket\")\n\t}\n\tif ah.conf.PresignTTL <= 0 {\n\t\tah.conf.PresignTTL = defaultPresignDuration\n\t}\n\tif ah.conf.CacheControl == \"\" {\n\t\tah.conf.CacheControl = defaultCacheControl\n\t}\n\tif ah.conf.ServeURL == \"\" {\n\t\tah.conf.ServeURL = defaultServeURL\n\t}\n\tah.corsOrigins, err = media.ParseCORSAllow(ah.conf.CorsOrigins)\n\tif err != nil {\n\t\treturn errors.New(\"failed to parse CORS allowed origins: \" + err.Error())\n\t}\n\n\tvar sess *session.Session\n\tif sess, err = session.NewSession(&aws.Config{\n\t\tRegion:           aws.String(ah.conf.Region),\n\t\tDisableSSL:       aws.Bool(ah.conf.DisableSSL),\n\t\tS3ForcePathStyle: aws.Bool(ah.conf.ForcePathStyle),\n\t\tEndpoint:         aws.String(ah.conf.Endpoint),\n\t\tCredentials:      credentials.NewStaticCredentials(ah.conf.AccessKeyId, ah.conf.SecretAccessKey, \"\"),\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\t// Create S3 service client\n\tah.svc = s3.New(sess)\n\n\t// Check if bucket already exists.\n\t_, err = ah.svc.HeadBucket(&s3.HeadBucketInput{Bucket: aws.String(ah.conf.BucketName)})\n\tif err == nil {\n\t\t// Bucket exists\n\t\treturn nil\n\t}\n\n\tif aerr, ok := err.(awserr.Error); !ok || aerr.Code() != s3.ErrCodeNoSuchBucket {\n\t\t// Hard error.\n\t\treturn err\n\t}\n\n\t// Bucket does not exist. Create one.\n\t_, err = ah.svc.CreateBucket(&s3.CreateBucketInput{Bucket: aws.String(ah.conf.BucketName)})\n\tif err != nil {\n\t\t// Check if someone has already created a bucket (possible in a cluster).\n\t\tif aerr, ok := err.(awserr.Error); ok {\n\t\t\tif aerr.Code() == s3.ErrCodeBucketAlreadyExists ||\n\t\t\t\taerr.Code() == s3.ErrCodeBucketAlreadyOwnedByYou ||\n\t\t\t\t// Someone is already creating this bucket:\n\t\t\t\t// OperationAborted: A conflicting conditional operation is currently in progress against this resource.\n\t\t\t\taerr.Code() == \"OperationAborted\" {\n\t\t\t\t// Clear benign error\n\t\t\t\terr = nil\n\t\t\t}\n\t\t}\n\t} else {\n\t\t// This is a new bucket.\n\n\t\t// The following serves two purposes:\n\t\t// 1. Setup CORS policy to be able to serve media directly from S3.\n\t\t// 2. Verify that the bucket is accessible to the current user.\n\t\torigins := ah.conf.CorsOrigins\n\t\tif len(origins) == 0 {\n\t\t\torigins = append(origins, \"*\")\n\t\t}\n\t\t_, err = ah.svc.PutBucketCors(&s3.PutBucketCorsInput{\n\t\t\tBucket: aws.String(ah.conf.BucketName),\n\t\t\tCORSConfiguration: &s3.CORSConfiguration{\n\t\t\t\tCORSRules: []*s3.CORSRule{{\n\t\t\t\t\tAllowedMethods: aws.StringSlice([]string{http.MethodGet, http.MethodHead}),\n\t\t\t\t\tAllowedOrigins: aws.StringSlice(origins),\n\t\t\t\t\tAllowedHeaders: aws.StringSlice([]string{\"*\"}),\n\t\t\t\t}},\n\t\t\t},\n\t\t})\n\t}\n\treturn err\n}\n\n// Headers adds CORS headers and redirects GET and HEAD requests to the AWS server.\nfunc (ah *awshandler) Headers(method string, url *url.URL, headers http.Header, serve bool) (http.Header, int, error) {\n\t// Add CORS headers, if necessary.\n\theaders, status := media.CORSHandler(method, headers, ah.corsOrigins, serve)\n\tif status != 0 || method == http.MethodPost || method == http.MethodPut {\n\t\treturn headers, status, nil\n\t}\n\n\tfid := ah.GetIdFromUrl(url.String())\n\tif fid.IsZero() {\n\t\treturn nil, 0, types.ErrNotFound\n\t}\n\n\tfdef, err := ah.getFileRecord(fid)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tif fdef.ETag != \"\" && headers.Get(\"If-None-Match\") == `\"`+fdef.ETag+`\"` {\n\t\treturn http.Header{\n\t\t\t\t\"ETag\":          {`\"` + fdef.ETag + `\"`},\n\t\t\t\t\"Cache-Control\": {ah.conf.CacheControl},\n\t\t\t},\n\t\t\thttp.StatusNotModified, nil\n\t}\n\n\tvar awsReq *request.Request\n\tswitch method {\n\tcase http.MethodGet:\n\t\t// If the query parameter \"asatt\" is set to a true, set Content-Disposition to attachment.\n\t\t// This will cause browsers to download the file rather than attempt to display it.\n\t\t// This closes an XSS vulnerability when users upload HTML files.\n\t\tvar contentDisposition *string\n\t\tif isAttachment, _ := strconv.ParseBool(url.Query().Get(\"asatt\")); isAttachment {\n\t\t\tcontentDisposition = aws.String(\"attachment\")\n\t\t}\n\t\tawsReq, _ = ah.svc.GetObjectRequest(&s3.GetObjectInput{\n\t\t\tBucket:                     aws.String(ah.conf.BucketName),\n\t\t\tKey:                        aws.String(fid.String32()),\n\t\t\tResponseCacheControl:       aws.String(ah.conf.CacheControl),\n\t\t\tResponseContentType:        aws.String(fdef.MimeType),\n\t\t\tResponseContentDisposition: contentDisposition,\n\t\t})\n\tcase http.MethodHead:\n\t\tawsReq, _ = ah.svc.HeadObjectRequest(&s3.HeadObjectInput{\n\t\t\tBucket: aws.String(ah.conf.BucketName),\n\t\t\tKey:    aws.String(fid.String32()),\n\t\t})\n\t}\n\n\tif awsReq != nil {\n\t\t// Return presigned URL with 308 Permanent redirect. Let the client cache the response.\n\t\t// The original URL will stop working after a short period of time to prevent use of Tinode\n\t\t// as a free file server.\n\t\turl, err := awsReq.Presign(time.Second * time.Duration(ah.conf.PresignTTL))\n\t\treturn http.Header{\n\t\t\t\t\"Location\":      {url},\n\t\t\t\t\"ETag\":          {`\"` + fdef.ETag + `\"`},\n\t\t\t\t\"Content-Type\":  {\"application/json; charset=utf-8\"},\n\t\t\t\t\"Cache-Control\": {ah.conf.CacheControl},\n\t\t\t},\n\t\t\thttp.StatusPermanentRedirect, err\n\t}\n\treturn nil, 0, nil\n}\n\n// Upload processes request for a file upload. The file is given as io.Reader.\nfunc (ah *awshandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64, error) {\n\tvar err error\n\n\t// Using String32 just for consistency with the file handler.\n\tkey := fdef.Uid().String32()\n\n\tuploader := s3manager.NewUploaderWithClient(ah.svc)\n\n\tif err = store.Files.StartUpload(fdef); err != nil {\n\t\tlogs.Warn.Println(\"failed to create file record\", fdef.Id, err)\n\t\treturn \"\", 0, err\n\t}\n\n\trc := readerCounter{reader: file}\n\tresult, err := uploader.Upload(&s3manager.UploadInput{\n\t\tCacheControl: aws.String(ah.conf.CacheControl),\n\t\tBucket:       aws.String(ah.conf.BucketName),\n\t\tKey:          aws.String(key),\n\t\tBody:         &rc,\n\t})\n\n\tif err != nil {\n\t\treturn \"\", 0, err\n\t}\n\n\tfname := fdef.Id\n\text, _ := mime.ExtensionsByType(fdef.MimeType)\n\tif len(ext) > 0 {\n\t\tfname += ext[0]\n\t}\n\n\tfdef.Location = key\n\tif result.ETag != nil {\n\t\tfdef.ETag = strings.Trim(*result.ETag, \"\\\"\")\n\t}\n\treturn ah.conf.ServeURL + fname, rc.count, nil\n}\n\n// Download processes request for file download.\n// The returned ReadSeekCloser must be closed after use.\nfunc (ah *awshandler) Download(url string) (*types.FileDef, media.ReadSeekCloser, error) {\n\treturn nil, nil, types.ErrUnsupported\n}\n\n// Delete deletes files from aws by provided slice of locations.\nfunc (ah *awshandler) Delete(locations []string) error {\n\ttoDelete := make([]s3manager.BatchDeleteObject, len(locations))\n\tfor i, key := range locations {\n\t\ttoDelete[i] = s3manager.BatchDeleteObject{\n\t\t\tObject: &s3.DeleteObjectInput{\n\t\t\t\tKey:    aws.String(key),\n\t\t\t\tBucket: aws.String(ah.conf.BucketName),\n\t\t\t}}\n\t}\n\tbatcher := s3manager.NewBatchDeleteWithClient(ah.svc)\n\treturn batcher.Delete(aws.BackgroundContext(), &s3manager.DeleteObjectsIterator{\n\t\tObjects: toDelete,\n\t})\n}\n\n// GetIdFromUrl converts an attahment URL to a file UID.\nfunc (ah *awshandler) GetIdFromUrl(url string) types.Uid {\n\treturn media.GetIdFromUrl(url, ah.conf.ServeURL)\n}\n\n// getFileRecord given file ID reads file record from the database.\nfunc (ah *awshandler) getFileRecord(fid types.Uid) (*types.FileDef, error) {\n\tfd, err := store.Files.Get(fid.String())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif fd == nil {\n\t\treturn nil, types.ErrNotFound\n\t}\n\treturn fd, nil\n}\n\nfunc init() {\n\tstore.RegisterMediaHandler(handlerName, &awshandler{})\n}\n"
  },
  {
    "path": "server/pbconverter.go",
    "content": "// Converts between protobuf structs and Go representation of packets\n\npackage main\n\nimport (\n\t\"encoding/json\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/pbx\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\nfunc pbServCtrlSerializeBasic(ctrl *MsgServerCtrl) *pbx.ServerCtrl {\n\tvar params map[string][]byte\n\tif ctrl.Params != nil {\n\t\tif in, ok := ctrl.Params.(map[string]any); ok {\n\t\t\tparams = interfaceMapToByteMap(in)\n\t\t}\n\t}\n\n\treturn &pbx.ServerCtrl{\n\t\tId:     ctrl.Id,\n\t\tTopic:  ctrl.Topic,\n\t\tCode:   int32(ctrl.Code),\n\t\tText:   ctrl.Text,\n\t\tParams: params,\n\t}\n}\n\nfunc pbServCtrlSerialize(ctrl *MsgServerCtrl) *pbx.ServerMsg_Ctrl {\n\treturn &pbx.ServerMsg_Ctrl{\n\t\tCtrl: pbServCtrlSerializeBasic(ctrl),\n\t}\n}\n\nfunc pbServDataSerialize(data *MsgServerData) *pbx.ServerMsg_Data {\n\treturn &pbx.ServerMsg_Data{\n\t\tData: &pbx.ServerData{\n\t\t\tTopic:      data.Topic,\n\t\t\tFromUserId: data.From,\n\t\t\tTimestamp:  timeToInt64(&data.Timestamp),\n\t\t\tDeletedAt:  timeToInt64(data.DeletedAt),\n\t\t\tSeqId:      int32(data.SeqId),\n\t\t\tHead:       interfaceMapToByteMap(data.Head),\n\t\t\tContent:    interfaceToBytes(data.Content),\n\t\t},\n\t}\n}\n\nfunc pbServPresSerialize(pres *MsgServerPres) *pbx.ServerMsg_Pres {\n\tvar what pbx.ServerPres_What\n\tswitch pres.What {\n\tcase \"on\":\n\t\twhat = pbx.ServerPres_ON\n\tcase \"off\":\n\t\twhat = pbx.ServerPres_OFF\n\tcase \"ua\":\n\t\twhat = pbx.ServerPres_UA\n\tcase \"upd\":\n\t\twhat = pbx.ServerPres_UPD\n\tcase \"gone\":\n\t\twhat = pbx.ServerPres_GONE\n\tcase \"acs\":\n\t\twhat = pbx.ServerPres_ACS\n\tcase \"term\":\n\t\twhat = pbx.ServerPres_TERM\n\tcase \"msg\":\n\t\twhat = pbx.ServerPres_MSG\n\tcase \"read\":\n\t\twhat = pbx.ServerPres_READ\n\tcase \"recv\":\n\t\twhat = pbx.ServerPres_RECV\n\tcase \"del\":\n\t\twhat = pbx.ServerPres_DEL\n\tcase \"tags\":\n\t\twhat = pbx.ServerPres_TAGS\n\tcase \"aux\":\n\t\twhat = pbx.ServerPres_AUX\n\tdefault:\n\t\tlogs.Info.Println(\"Unknown pres.what value\", pres.What)\n\t}\n\treturn &pbx.ServerMsg_Pres{\n\t\tPres: &pbx.ServerPres{\n\t\t\tTopic:        pres.Topic,\n\t\t\tSrc:          pres.Src,\n\t\t\tWhat:         what,\n\t\t\tUserAgent:    pres.UserAgent,\n\t\t\tSeqId:        int32(pres.SeqId),\n\t\t\tDelId:        int32(pres.DelId),\n\t\t\tDelSeq:       pbDelQuerySerialize(pres.DelSeq),\n\t\t\tTargetUserId: pres.AcsTarget,\n\t\t\tActorUserId:  pres.AcsActor,\n\t\t\tAcs:          pbAccessModeSerialize(pres.Acs),\n\t\t},\n\t}\n}\n\nfunc pbServInfoSerialize(info *MsgServerInfo) *pbx.ServerMsg_Info {\n\treturn &pbx.ServerMsg_Info{\n\t\tInfo: &pbx.ServerInfo{\n\t\t\tTopic:      info.Topic,\n\t\t\tFromUserId: info.From,\n\t\t\tSrc:        info.Src,\n\t\t\tWhat:       pbInfoNoteWhatSerialize(info.What),\n\t\t\tSeqId:      int32(info.SeqId),\n\t\t\tEvent:      pbCallEventSerialize(info.Event),\n\t\t\tPayload:    info.Payload,\n\t\t},\n\t}\n}\n\nfunc pbServMetaSerialize(meta *MsgServerMeta) *pbx.ServerMsg_Meta {\n\treturn &pbx.ServerMsg_Meta{\n\t\tMeta: &pbx.ServerMeta{\n\t\t\tId:    meta.Id,\n\t\t\tTopic: meta.Topic,\n\t\t\tDesc:  pbTopicDescSerialize(meta.Desc),\n\t\t\tSub:   pbTopicSubSliceSerialize(meta.Sub),\n\t\t\tDel:   pbDelValuesSerialize(meta.Del),\n\t\t\tTags:  meta.Tags,\n\t\t\tCred:  pbServerCredsSerialize(meta.Cred),\n\t\t\tAux:   interfaceMapToByteMap(meta.Aux),\n\t\t},\n\t}\n}\n\n// Convert ServerComMessage to pbx.ServerMsg\nfunc pbServSerialize(msg *ServerComMessage) *pbx.ServerMsg {\n\tvar pkt pbx.ServerMsg\n\n\tswitch {\n\tcase msg.Ctrl != nil:\n\t\tpkt.Message = pbServCtrlSerialize(msg.Ctrl)\n\tcase msg.Data != nil:\n\t\tpkt.Message = pbServDataSerialize(msg.Data)\n\tcase msg.Pres != nil:\n\t\tpkt.Message = pbServPresSerialize(msg.Pres)\n\tcase msg.Info != nil:\n\t\tpkt.Message = pbServInfoSerialize(msg.Info)\n\tcase msg.Meta != nil:\n\t\tpkt.Message = pbServMetaSerialize(msg.Meta)\n\t}\n\n\treturn &pkt\n}\n\nfunc pbServDeserialize(pkt *pbx.ServerMsg) *ServerComMessage {\n\tvar msg ServerComMessage\n\tif ctrl := pkt.GetCtrl(); ctrl != nil {\n\t\tmsg.Ctrl = &MsgServerCtrl{\n\t\t\tId:     ctrl.GetId(),\n\t\t\tTopic:  ctrl.GetTopic(),\n\t\t\tCode:   int(ctrl.GetCode()),\n\t\t\tText:   ctrl.GetText(),\n\t\t\tParams: byteMapToInterfaceMap(ctrl.GetParams()),\n\t\t}\n\t} else if data := pkt.GetData(); data != nil {\n\t\ttsptr := int64ToTime(data.GetTimestamp())\n\t\tif tsptr == nil {\n\t\t\ttsptr = &time.Time{}\n\t\t}\n\t\tmsg.Data = &MsgServerData{\n\t\t\tTopic:     data.GetTopic(),\n\t\t\tFrom:      data.GetFromUserId(),\n\t\t\tTimestamp: *tsptr,\n\t\t\tDeletedAt: int64ToTime(data.GetDeletedAt()),\n\t\t\tSeqId:     int(data.GetSeqId()),\n\t\t\tHead:      byteMapToInterfaceMap(data.GetHead()),\n\t\t\tContent:   data.GetContent(),\n\t\t}\n\t} else if pres := pkt.GetPres(); pres != nil {\n\t\tvar what string\n\t\tswitch pres.GetWhat() {\n\t\tcase pbx.ServerPres_ON:\n\t\t\twhat = \"on\"\n\t\tcase pbx.ServerPres_OFF:\n\t\t\twhat = \"off\"\n\t\tcase pbx.ServerPres_UA:\n\t\t\twhat = \"ua\"\n\t\tcase pbx.ServerPres_UPD:\n\t\t\twhat = \"upd\"\n\t\tcase pbx.ServerPres_GONE:\n\t\t\twhat = \"gone\"\n\t\tcase pbx.ServerPres_ACS:\n\t\t\twhat = \"acs\"\n\t\tcase pbx.ServerPres_TERM:\n\t\t\twhat = \"term\"\n\t\tcase pbx.ServerPres_MSG:\n\t\t\twhat = \"msg\"\n\t\tcase pbx.ServerPres_READ:\n\t\t\twhat = \"read\"\n\t\tcase pbx.ServerPres_RECV:\n\t\t\twhat = \"recv\"\n\t\tcase pbx.ServerPres_DEL:\n\t\t\twhat = \"del\"\n\t\tcase pbx.ServerPres_TAGS:\n\t\t\twhat = \"tags\"\n\t\tcase pbx.ServerPres_AUX:\n\t\t\twhat = \"aux\"\n\t\t}\n\t\tmsg.Pres = &MsgServerPres{\n\t\t\tTopic:     pres.GetTopic(),\n\t\t\tSrc:       pres.GetSrc(),\n\t\t\tWhat:      what,\n\t\t\tUserAgent: pres.GetUserAgent(),\n\t\t\tSeqId:     int(pres.GetSeqId()),\n\t\t\tDelId:     int(pres.GetDelId()),\n\t\t\tDelSeq:    pbDelQueryDeserialize(pres.GetDelSeq()),\n\t\t\tAcsTarget: pres.GetTargetUserId(),\n\t\t\tAcsActor:  pres.GetActorUserId(),\n\t\t\tAcs:       pbAccessModeDeserialize(pres.GetAcs()),\n\t\t}\n\t} else if info := pkt.GetInfo(); info != nil {\n\t\tmsg.Info = &MsgServerInfo{\n\t\t\tTopic:   info.GetTopic(),\n\t\t\tSrc:     info.GetSrc(),\n\t\t\tFrom:    info.GetFromUserId(),\n\t\t\tWhat:    pbInfoNoteWhatDeserialize(info.GetWhat()),\n\t\t\tSeqId:   int(info.GetSeqId()),\n\t\t\tEvent:   pbCallEventDeserialize(info.GetEvent()),\n\t\t\tPayload: info.GetPayload(),\n\t\t}\n\t} else if meta := pkt.GetMeta(); meta != nil {\n\t\tmsg.Meta = &MsgServerMeta{\n\t\t\tId:    meta.GetId(),\n\t\t\tTopic: meta.GetTopic(),\n\t\t\tDesc:  pbTopicDescDeserialize(meta.GetDesc()),\n\t\t\tSub:   pbTopicSubSliceDeserialize(meta.GetSub()),\n\t\t\tDel:   pbDelValuesDeserialize(meta.GetDel()),\n\t\t\tTags:  meta.GetTags(),\n\t\t\tCred:  pbServerCredsDeserialize(meta.GetCred()),\n\t\t\tAux:   byteMapToInterfaceMap(meta.GetAux()),\n\t\t}\n\t}\n\treturn &msg\n}\n\n// Convert ClientComMessage to pbx.ClientMsg\nfunc pbCliSerialize(msg *ClientComMessage) *pbx.ClientMsg {\n\tvar pkt pbx.ClientMsg\n\n\tswitch {\n\tcase msg.Hi != nil:\n\t\tpkt.Message = &pbx.ClientMsg_Hi{\n\t\t\tHi: &pbx.ClientHi{\n\t\t\t\tId:         msg.Hi.Id,\n\t\t\t\tUserAgent:  msg.Hi.UserAgent,\n\t\t\t\tVer:        msg.Hi.Version,\n\t\t\t\tDeviceId:   msg.Hi.DeviceID,\n\t\t\t\tPlatform:   msg.Hi.Platform,\n\t\t\t\tLang:       msg.Hi.Lang,\n\t\t\t\tBackground: msg.Hi.Background,\n\t\t\t},\n\t\t}\n\tcase msg.Acc != nil:\n\t\tvar authLevel pbx.AuthLevel\n\t\tswitch msg.Acc.AuthLevel {\n\t\tcase \"NONE\", \"none\", \"\":\n\t\t\tauthLevel = pbx.AuthLevel_NONE\n\t\tcase \"ANON\", \"anon\":\n\t\t\tauthLevel = pbx.AuthLevel_ANON\n\t\tcase \"AUTH\", \"auth\":\n\t\t\tauthLevel = pbx.AuthLevel_AUTH\n\t\tcase \"ROOT\", \"root\":\n\t\t\t// No support for ROOT here.\n\t\t\tauthLevel = pbx.AuthLevel_NONE\n\t\t}\n\t\tpkt.Message = &pbx.ClientMsg_Acc{\n\t\t\tAcc: &pbx.ClientAcc{\n\t\t\t\tId:        msg.Acc.Id,\n\t\t\t\tUserId:    msg.Acc.User,\n\t\t\t\tState:     msg.Acc.State,\n\t\t\t\tTmpScheme: msg.Acc.TmpScheme,\n\t\t\t\tTmpSecret: msg.Acc.TmpSecret,\n\t\t\t\tAuthLevel: authLevel,\n\t\t\t\tScheme:    msg.Acc.Scheme,\n\t\t\t\tSecret:    msg.Acc.Secret,\n\t\t\t\tLogin:     msg.Acc.Login,\n\t\t\t\tTags:      msg.Acc.Tags,\n\t\t\t\tCred:      pbClientCredsSerialize(msg.Acc.Cred),\n\t\t\t\tDesc:      pbSetDescSerialize(msg.Acc.Desc),\n\t\t\t},\n\t\t}\n\tcase msg.Login != nil:\n\t\tpkt.Message = &pbx.ClientMsg_Login{\n\t\t\tLogin: &pbx.ClientLogin{\n\t\t\t\tId:     msg.Login.Id,\n\t\t\t\tScheme: msg.Login.Scheme,\n\t\t\t\tSecret: msg.Login.Secret,\n\t\t\t\tCred:   pbClientCredsSerialize(msg.Login.Cred),\n\t\t\t},\n\t\t}\n\tcase msg.Sub != nil:\n\t\tpkt.Message = &pbx.ClientMsg_Sub{\n\t\t\tSub: &pbx.ClientSub{\n\t\t\t\tId:       msg.Sub.Id,\n\t\t\t\tTopic:    msg.Sub.Topic,\n\t\t\t\tSetQuery: pbSetQuerySerialize(msg.Sub.Set),\n\t\t\t\tGetQuery: pbGetQuerySerialize(msg.Sub.Get),\n\t\t\t},\n\t\t}\n\tcase msg.Leave != nil:\n\t\tpkt.Message = &pbx.ClientMsg_Leave{\n\t\t\tLeave: &pbx.ClientLeave{\n\t\t\t\tId:    msg.Leave.Id,\n\t\t\t\tTopic: msg.Leave.Topic,\n\t\t\t\tUnsub: msg.Leave.Unsub,\n\t\t\t},\n\t\t}\n\tcase msg.Pub != nil:\n\t\tpkt.Message = &pbx.ClientMsg_Pub{\n\t\t\tPub: &pbx.ClientPub{\n\t\t\t\tId:      msg.Pub.Id,\n\t\t\t\tTopic:   msg.Pub.Topic,\n\t\t\t\tNoEcho:  msg.Pub.NoEcho,\n\t\t\t\tHead:    interfaceMapToByteMap(msg.Pub.Head),\n\t\t\t\tContent: interfaceToBytes(msg.Pub.Content),\n\t\t\t},\n\t\t}\n\tcase msg.Get != nil:\n\t\tpkt.Message = &pbx.ClientMsg_Get{\n\t\t\tGet: &pbx.ClientGet{\n\t\t\t\tId:    msg.Get.Id,\n\t\t\t\tTopic: msg.Get.Topic,\n\t\t\t\tQuery: pbGetQuerySerialize(&msg.Get.MsgGetQuery),\n\t\t\t},\n\t\t}\n\tcase msg.Set != nil:\n\t\tpkt.Message = &pbx.ClientMsg_Set{\n\t\t\tSet: &pbx.ClientSet{\n\t\t\t\tId:    msg.Set.Id,\n\t\t\t\tTopic: msg.Set.Topic,\n\t\t\t\tQuery: pbSetQuerySerialize(&msg.Set.MsgSetQuery),\n\t\t\t},\n\t\t}\n\tcase msg.Del != nil:\n\t\tvar what pbx.ClientDel_What\n\t\tswitch msg.Del.What {\n\t\tcase \"msg\":\n\t\t\twhat = pbx.ClientDel_MSG\n\t\tcase \"topic\":\n\t\t\twhat = pbx.ClientDel_TOPIC\n\t\tcase \"sub\":\n\t\t\twhat = pbx.ClientDel_SUB\n\t\tcase \"user\":\n\t\t\twhat = pbx.ClientDel_USER\n\t\tcase \"cred\":\n\t\t\twhat = pbx.ClientDel_CRED\n\t\t}\n\t\tpkt.Message = &pbx.ClientMsg_Del{\n\t\t\tDel: &pbx.ClientDel{\n\t\t\t\tId:     msg.Del.Id,\n\t\t\t\tTopic:  msg.Del.Topic,\n\t\t\t\tWhat:   what,\n\t\t\t\tDelSeq: pbDelQuerySerialize(msg.Del.DelSeq),\n\t\t\t\tUserId: msg.Del.User,\n\t\t\t\tCred:   pbClientCredSerialize(msg.Del.Cred),\n\t\t\t\tHard:   msg.Del.Hard,\n\t\t\t},\n\t\t}\n\tcase msg.Note != nil:\n\t\tpkt.Message = &pbx.ClientMsg_Note{\n\t\t\tNote: &pbx.ClientNote{\n\t\t\t\tTopic:   msg.Note.Topic,\n\t\t\t\tWhat:    pbInfoNoteWhatSerialize(msg.Note.What),\n\t\t\t\tSeqId:   int32(msg.Note.SeqId),\n\t\t\t\tUnread:  int32(msg.Note.Unread),\n\t\t\t\tEvent:   pbCallEventSerialize(msg.Note.Event),\n\t\t\t\tPayload: msg.Note.Payload,\n\t\t\t},\n\t\t}\n\t}\n\n\tif pkt.Message == nil {\n\t\treturn nil\n\t}\n\n\tif msg.Extra != nil {\n\t\tpkt.Extra = &pbx.ClientExtra{\n\t\t\tAttachments: msg.Extra.Attachments,\n\t\t\tOnBehalfOf:  msg.Extra.AsUser,\n\t\t\tAuthLevel:   pbx.AuthLevel(msg.AuthLvl),\n\t\t}\n\t}\n\n\treturn &pkt\n}\n\n// Convert pbx.ClientMsg to ClientComMessage\nfunc pbCliDeserialize(pkt *pbx.ClientMsg) *ClientComMessage {\n\tvar msg ClientComMessage\n\tif hi := pkt.GetHi(); hi != nil {\n\t\tmsg.Hi = &MsgClientHi{\n\t\t\tId:         hi.GetId(),\n\t\t\tUserAgent:  hi.GetUserAgent(),\n\t\t\tVersion:    hi.GetVer(),\n\t\t\tDeviceID:   hi.GetDeviceId(),\n\t\t\tPlatform:   hi.GetPlatform(),\n\t\t\tLang:       hi.GetLang(),\n\t\t\tBackground: hi.GetBackground(),\n\t\t}\n\t} else if acc := pkt.GetAcc(); acc != nil {\n\t\tmsg.Acc = &MsgClientAcc{\n\t\t\tId:        acc.GetId(),\n\t\t\tUser:      acc.GetUserId(),\n\t\t\tState:     acc.GetState(),\n\t\t\tTmpScheme: acc.GetTmpScheme(),\n\t\t\tTmpSecret: acc.GetTmpSecret(),\n\t\t\tAuthLevel: acc.GetAuthLevel().String(),\n\t\t\tScheme:    acc.GetScheme(),\n\t\t\tSecret:    acc.GetSecret(),\n\t\t\tLogin:     acc.GetLogin(),\n\t\t\tTags:      acc.GetTags(),\n\t\t\tDesc:      pbSetDescDeserialize(acc.GetDesc()),\n\t\t\tCred:      pbClientCredsDeserialize(acc.GetCred()),\n\t\t}\n\t} else if login := pkt.GetLogin(); login != nil {\n\t\tmsg.Login = &MsgClientLogin{\n\t\t\tId:     login.GetId(),\n\t\t\tScheme: login.GetScheme(),\n\t\t\tSecret: login.GetSecret(),\n\t\t\tCred:   pbClientCredsDeserialize(login.GetCred()),\n\t\t}\n\t} else if sub := pkt.GetSub(); sub != nil {\n\t\tmsg.Sub = &MsgClientSub{\n\t\t\tId:    sub.GetId(),\n\t\t\tTopic: sub.GetTopic(),\n\t\t\tGet:   pbGetQueryDeserialize(sub.GetGetQuery()),\n\t\t\tSet:   pbSetQueryDeserialize(sub.GetSetQuery()),\n\t\t}\n\t} else if leave := pkt.GetLeave(); leave != nil {\n\t\tmsg.Leave = &MsgClientLeave{\n\t\t\tId:    leave.GetId(),\n\t\t\tTopic: leave.GetTopic(),\n\t\t\tUnsub: leave.GetUnsub(),\n\t\t}\n\t} else if pub := pkt.GetPub(); pub != nil {\n\t\tmsg.Pub = &MsgClientPub{\n\t\t\tId:      pub.GetId(),\n\t\t\tTopic:   pub.GetTopic(),\n\t\t\tNoEcho:  pub.GetNoEcho(),\n\t\t\tHead:    byteMapToInterfaceMap(pub.GetHead()),\n\t\t\tContent: bytesToInterface(pub.GetContent()),\n\t\t}\n\t} else if get := pkt.GetGet(); get != nil {\n\t\tmsg.Get = &MsgClientGet{\n\t\t\tId:    get.GetId(),\n\t\t\tTopic: get.GetTopic(),\n\t\t}\n\t\tif gq := get.GetQuery(); gq != nil {\n\t\t\tmsg.Get.MsgGetQuery = *pbGetQueryDeserialize(gq)\n\t\t}\n\t} else if set := pkt.GetSet(); set != nil {\n\t\tmsg.Set = &MsgClientSet{\n\t\t\tId:    set.GetId(),\n\t\t\tTopic: set.GetTopic(),\n\t\t}\n\t\tif sq := set.GetQuery(); sq != nil {\n\t\t\tmsg.Set.MsgSetQuery = *pbSetQueryDeserialize(sq)\n\t\t}\n\t} else if del := pkt.GetDel(); del != nil {\n\t\tmsg.Del = &MsgClientDel{\n\t\t\tId:     del.GetId(),\n\t\t\tTopic:  del.GetTopic(),\n\t\t\tDelSeq: pbDelQueryDeserialize(del.GetDelSeq()),\n\t\t\tUser:   del.GetUserId(),\n\t\t\tCred:   pbClientCredDeserialize(del.GetCred()),\n\t\t\tHard:   del.GetHard(),\n\t\t}\n\t\tswitch del.GetWhat() {\n\t\tcase pbx.ClientDel_MSG:\n\t\t\tmsg.Del.What = \"msg\"\n\t\tcase pbx.ClientDel_TOPIC:\n\t\t\tmsg.Del.What = \"topic\"\n\t\tcase pbx.ClientDel_SUB:\n\t\t\tmsg.Del.What = \"sub\"\n\t\tcase pbx.ClientDel_USER:\n\t\t\tmsg.Del.What = \"user\"\n\t\tcase pbx.ClientDel_CRED:\n\t\t\tmsg.Del.What = \"cred\"\n\t\t}\n\t} else if note := pkt.GetNote(); note != nil {\n\t\tmsg.Note = &MsgClientNote{\n\t\t\tTopic:   note.GetTopic(),\n\t\t\tSeqId:   int(note.GetSeqId()),\n\t\t\tWhat:    pbInfoNoteWhatDeserialize(note.GetWhat()),\n\t\t\tUnread:  int(note.GetUnread()),\n\t\t\tEvent:   pbCallEventDeserialize(note.GetEvent()),\n\t\t\tPayload: note.GetPayload(),\n\t\t}\n\t}\n\n\tif extra := pkt.GetExtra(); extra != nil {\n\t\tmsg.Extra = &MsgClientExtra{\n\t\t\tAttachments: extra.GetAttachments(),\n\t\t\tAsUser:      extra.GetOnBehalfOf(),\n\t\t\tAuthLevel:   extra.GetAuthLevel().String(),\n\t\t}\n\t}\n\n\treturn &msg\n}\n\nfunc interfaceMapToByteMap(in map[string]any) map[string][]byte {\n\tout := make(map[string][]byte, len(in))\n\tfor key, val := range in {\n\t\tif val != nil {\n\t\t\tout[key], _ = json.Marshal(val)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc byteMapToInterfaceMap(in map[string][]byte) map[string]any {\n\tout := make(map[string]any, len(in))\n\tfor key, raw := range in {\n\t\tif val := bytesToInterface(raw); val != nil {\n\t\t\tout[key] = val\n\t\t}\n\t}\n\treturn out\n}\n\nfunc interfaceToBytes(in any) []byte {\n\tif in != nil {\n\t\tout, _ := json.Marshal(in)\n\t\treturn out\n\t}\n\treturn nil\n}\n\nfunc bytesToInterface(in []byte) any {\n\tvar out any\n\tif len(in) > 0 {\n\t\terr := json.Unmarshal(in, &out)\n\t\tif err != nil {\n\t\t\tlogs.Warn.Println(\"pbx: failed to parse bytes\", string(in), err)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc timeToInt64(ts *time.Time) int64 {\n\tif ts != nil {\n\t\treturn ts.UnixNano() / int64(time.Millisecond)\n\t}\n\treturn 0\n}\n\nfunc int64ToTime(ts int64) *time.Time {\n\tif ts > 0 {\n\t\tres := time.Unix(ts/1000, ts%1000).UTC()\n\t\treturn &res\n\t}\n\treturn nil\n}\n\nfunc pbGetQuerySerialize(in *MsgGetQuery) *pbx.GetQuery {\n\tif in == nil {\n\t\treturn nil\n\t}\n\n\tout := &pbx.GetQuery{\n\t\tWhat: in.What,\n\t}\n\n\tif in.Desc != nil {\n\t\tout.Desc = &pbx.GetOpts{\n\t\t\tIfModifiedSince: timeToInt64(in.Desc.IfModifiedSince),\n\t\t\tUser:            in.Desc.User,\n\t\t\tTopic:           in.Desc.Topic,\n\t\t\tLimit:           int32(in.Desc.Limit),\n\t\t}\n\t}\n\tif in.Sub != nil {\n\t\tout.Sub = &pbx.GetOpts{\n\t\t\tIfModifiedSince: timeToInt64(in.Sub.IfModifiedSince),\n\t\t\tUser:            in.Sub.User,\n\t\t\tTopic:           in.Sub.Topic,\n\t\t\tLimit:           int32(in.Sub.Limit),\n\t\t}\n\t}\n\tif in.Data != nil {\n\t\tout.Data = &pbx.GetOpts{\n\t\t\tBeforeId: int32(in.Data.BeforeId),\n\t\t\tSinceId:  int32(in.Data.SinceId),\n\t\t\tLimit:    int32(in.Data.Limit),\n\t\t}\n\n\t\tif len(in.Data.IdRanges) > 0 {\n\t\t\tout.Data.Ranges = make([]*pbx.SeqRange, len(in.Data.IdRanges))\n\t\t\tfor i, dq := range in.Data.IdRanges {\n\t\t\t\tout.Data.Ranges[i] = &pbx.SeqRange{Low: int32(dq.LowId), Hi: int32(dq.HiId)}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc pbGetQueryDeserialize(in *pbx.GetQuery) *MsgGetQuery {\n\tif in == nil {\n\t\treturn nil\n\t}\n\n\tmsg := MsgGetQuery{\n\t\tWhat: in.GetWhat(),\n\t}\n\n\tif desc := in.GetDesc(); desc != nil {\n\t\tmsg.Desc = &MsgGetOpts{\n\t\t\tIfModifiedSince: int64ToTime(desc.GetIfModifiedSince()),\n\t\t\tLimit:           int(desc.GetLimit()),\n\t\t}\n\t}\n\tif sub := in.GetSub(); sub != nil {\n\t\tmsg.Sub = &MsgGetOpts{\n\t\t\tIfModifiedSince: int64ToTime(sub.GetIfModifiedSince()),\n\t\t\tLimit:           int(sub.GetLimit()),\n\t\t}\n\t}\n\tif data := in.GetData(); data != nil {\n\t\tmsg.Data = &MsgGetOpts{\n\t\t\tBeforeId: int(data.GetBeforeId()),\n\t\t\tSinceId:  int(data.GetSinceId()),\n\t\t\tLimit:    int(data.GetLimit()),\n\t\t}\n\n\t\tif ranges := data.GetRanges(); len(ranges) > 0 {\n\t\t\tmsg.Data.IdRanges = make([]MsgRange, len(ranges))\n\t\t\tfor i, sr := range ranges {\n\t\t\t\tmsg.Data.IdRanges[i].LowId = int(sr.GetLow())\n\t\t\t\tmsg.Data.IdRanges[i].HiId = int(sr.GetHi())\n\t\t\t}\n\t\t}\n\t}\n\n\treturn &msg\n}\n\nfunc pbSetDescSerialize(in *MsgSetDesc) *pbx.SetDesc {\n\tif in == nil {\n\t\treturn nil\n\t}\n\n\tif in.DefaultAcs != nil || in.Public != nil || in.Trusted != nil || in.Private != nil {\n\t\treturn &pbx.SetDesc{\n\t\t\tDefaultAcs: pbDefaultAcsSerialize(in.DefaultAcs),\n\t\t\tPublic:     interfaceToBytes(in.Public),\n\t\t\tTrusted:    interfaceToBytes(in.Trusted),\n\t\t\tPrivate:    interfaceToBytes(in.Private),\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc pbSetDescDeserialize(in *pbx.SetDesc) *MsgSetDesc {\n\tif in == nil {\n\t\treturn nil\n\t}\n\n\tdefacs := pbDefaultAcsDeserialize(in.GetDefaultAcs())\n\tpublic := in.GetPublic()\n\ttrusted := in.GetTrusted()\n\tprivate := in.GetPrivate()\n\n\tif defacs != nil || public != nil || private != nil || trusted != nil {\n\t\treturn &MsgSetDesc{\n\t\t\tDefaultAcs: defacs,\n\t\t\tPublic:     bytesToInterface(public),\n\t\t\tTrusted:    bytesToInterface(trusted),\n\t\t\tPrivate:    bytesToInterface(private),\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc pbSetQuerySerialize(in *MsgSetQuery) *pbx.SetQuery {\n\tif in == nil {\n\t\treturn nil\n\t}\n\n\tout := &pbx.SetQuery{\n\t\tDesc: pbSetDescSerialize(in.Desc),\n\t}\n\n\tif in.Sub != nil {\n\t\tout.Sub = &pbx.SetSub{\n\t\t\tUserId: in.Sub.User,\n\t\t\tMode:   in.Sub.Mode,\n\t\t}\n\t}\n\n\tout.Tags = in.Tags\n\n\tout.Cred = pbClientCredSerialize(in.Cred)\n\n\treturn out\n}\n\nfunc pbSetQueryDeserialize(in *pbx.SetQuery) *MsgSetQuery {\n\tif in == nil {\n\t\treturn nil\n\t}\n\n\tvar msg *MsgSetQuery\n\n\tif desc := in.GetDesc(); desc != nil {\n\t\tmsg = &MsgSetQuery{}\n\t\tmsg.Desc = pbSetDescDeserialize(desc)\n\t}\n\n\tif sub := in.GetSub(); sub != nil {\n\t\tuser := sub.GetUserId()\n\t\tmode := sub.GetMode()\n\n\t\tif user != \"\" || mode != \"\" {\n\t\t\tif msg == nil {\n\t\t\t\tmsg = &MsgSetQuery{}\n\t\t\t}\n\n\t\t\tmsg.Sub = &MsgSetSub{\n\t\t\t\tUser: sub.GetUserId(),\n\t\t\t\tMode: sub.GetMode(),\n\t\t\t}\n\t\t}\n\t}\n\n\tif tags := in.GetTags(); tags != nil {\n\t\tif msg == nil {\n\t\t\tmsg = &MsgSetQuery{}\n\t\t}\n\t\tmsg.Tags = tags\n\t}\n\n\tif cred := in.GetCred(); cred != nil {\n\t\tif msg == nil {\n\t\t\tmsg = &MsgSetQuery{}\n\t\t}\n\t\tmsg.Cred = pbClientCredDeserialize(cred)\n\t}\n\n\treturn msg\n}\n\nfunc pbInfoNoteWhatSerialize(what string) pbx.InfoNote {\n\tvar out pbx.InfoNote\n\tswitch what {\n\tcase \"kp\":\n\t\tout = pbx.InfoNote_KP\n\tcase \"read\":\n\t\tout = pbx.InfoNote_READ\n\tcase \"recv\":\n\t\tout = pbx.InfoNote_RECV\n\tcase \"call\":\n\t\tout = pbx.InfoNote_CALL\n\tdefault:\n\t\tlogs.Info.Println(\"unknown info-note.what\", what)\n\t}\n\treturn out\n}\n\nfunc pbInfoNoteWhatDeserialize(what pbx.InfoNote) string {\n\tvar out string\n\tswitch what {\n\tcase pbx.InfoNote_KP:\n\t\tout = \"kp\"\n\tcase pbx.InfoNote_READ:\n\t\tout = \"read\"\n\tcase pbx.InfoNote_RECV:\n\t\tout = \"recv\"\n\tcase pbx.InfoNote_CALL:\n\t\tout = \"call\"\n\tdefault:\n\t}\n\treturn out\n}\n\nfunc pbCallEventSerialize(event string) pbx.CallEvent {\n\tvar out pbx.CallEvent\n\tswitch event {\n\tcase \"accept\":\n\t\tout = pbx.CallEvent_ACCEPT\n\tcase \"answer\":\n\t\tout = pbx.CallEvent_ANSWER\n\tcase \"hang-up\":\n\t\tout = pbx.CallEvent_HANG_UP\n\tcase \"ice-candidate\":\n\t\tout = pbx.CallEvent_ICE_CANDIDATE\n\tcase \"invite\":\n\t\tout = pbx.CallEvent_INVITE\n\tcase \"offer\":\n\t\tout = pbx.CallEvent_OFFER\n\tcase \"ringing\":\n\t\tout = pbx.CallEvent_RINGING\n\tcase \"\":\n\t\tout = pbx.CallEvent_X2\n\tdefault:\n\t\tlogs.Info.Println(\"unknown call event\", event)\n\t}\n\treturn out\n}\n\nfunc pbCallEventDeserialize(event pbx.CallEvent) string {\n\tvar out string\n\tswitch event {\n\tcase pbx.CallEvent_ACCEPT:\n\t\tout = \"accept\"\n\tcase pbx.CallEvent_ANSWER:\n\t\tout = \"answer\"\n\tcase pbx.CallEvent_HANG_UP:\n\t\tout = \"hang-up\"\n\tcase pbx.CallEvent_ICE_CANDIDATE:\n\t\tout = \"ice-candidate\"\n\tcase pbx.CallEvent_INVITE:\n\t\tout = \"invite\"\n\tcase pbx.CallEvent_OFFER:\n\t\tout = \"offer\"\n\tcase pbx.CallEvent_RINGING:\n\t\tout = \"ringing\"\n\tdefault:\n\t}\n\treturn out\n}\n\nfunc pbAccessModeSerialize(acs *MsgAccessMode) *pbx.AccessMode {\n\tif acs == nil {\n\t\treturn nil\n\t}\n\n\treturn &pbx.AccessMode{\n\t\tWant:  acs.Want,\n\t\tGiven: acs.Given,\n\t}\n}\n\nfunc pbAccessModeDeserialize(acs *pbx.AccessMode) *MsgAccessMode {\n\tif acs == nil {\n\t\treturn nil\n\t}\n\n\treturn &MsgAccessMode{\n\t\tWant:  acs.Want,\n\t\tGiven: acs.Given,\n\t}\n}\n\nfunc pbDefaultAcsSerialize(defacs *MsgDefaultAcsMode) *pbx.DefaultAcsMode {\n\tif defacs == nil {\n\t\treturn nil\n\t}\n\n\treturn &pbx.DefaultAcsMode{\n\t\tAuth: defacs.Auth,\n\t\tAnon: defacs.Anon,\n\t}\n}\n\nfunc pbDefaultAcsDeserialize(defacs *pbx.DefaultAcsMode) *MsgDefaultAcsMode {\n\tif defacs == nil {\n\t\treturn nil\n\t}\n\n\tauth := defacs.GetAuth()\n\tanon := defacs.GetAnon()\n\n\tif auth != \"\" || anon != \"\" {\n\t\treturn &MsgDefaultAcsMode{\n\t\t\tAuth: auth,\n\t\t\tAnon: anon,\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc pbTopicDescSerialize(desc *MsgTopicDesc) *pbx.TopicDesc {\n\tif desc == nil {\n\t\treturn nil\n\t}\n\tout := &pbx.TopicDesc{\n\t\tCreatedAt: timeToInt64(desc.CreatedAt),\n\t\tUpdatedAt: timeToInt64(desc.UpdatedAt),\n\t\tTouchedAt: timeToInt64(desc.TouchedAt),\n\t\tState:     desc.State,\n\t\tOnline:    desc.Online,\n\t\tIsChan:    desc.IsChan,\n\t\tDefacs:    pbDefaultAcsSerialize(desc.DefaultAcs),\n\t\tAcs:       pbAccessModeSerialize(desc.Acs),\n\t\tSeqId:     int32(desc.SeqId),\n\t\tReadId:    int32(desc.ReadSeqId),\n\t\tRecvId:    int32(desc.RecvSeqId),\n\t\tDelId:     int32(desc.DelId),\n\t\tPublic:    interfaceToBytes(desc.Public),\n\t\tTrusted:   interfaceToBytes(desc.Trusted),\n\t\tPrivate:   interfaceToBytes(desc.Private),\n\t}\n\tif desc.LastSeen != nil {\n\t\tout.LastSeenTime = timeToInt64(desc.LastSeen.When)\n\t\tout.LastSeenUserAgent = desc.LastSeen.UserAgent\n\t}\n\treturn out\n}\n\nfunc pbTopicDescDeserialize(desc *pbx.TopicDesc) *MsgTopicDesc {\n\tif desc == nil {\n\t\treturn nil\n\t}\n\tout := &MsgTopicDesc{\n\t\tCreatedAt:  int64ToTime(desc.GetCreatedAt()),\n\t\tUpdatedAt:  int64ToTime(desc.GetUpdatedAt()),\n\t\tTouchedAt:  int64ToTime(desc.GetTouchedAt()),\n\t\tState:      desc.GetState(),\n\t\tOnline:     desc.GetOnline(),\n\t\tIsChan:     desc.GetIsChan(),\n\t\tDefaultAcs: pbDefaultAcsDeserialize(desc.GetDefacs()),\n\t\tAcs:        pbAccessModeDeserialize(desc.GetAcs()),\n\t\tSeqId:      int(desc.SeqId),\n\t\tReadSeqId:  int(desc.ReadId),\n\t\tRecvSeqId:  int(desc.RecvId),\n\t\tDelId:      int(desc.DelId),\n\t\tPublic:     bytesToInterface(desc.Public),\n\t\tTrusted:    bytesToInterface(desc.Trusted),\n\t\tPrivate:    bytesToInterface(desc.Private),\n\t}\n\n\tif desc.GetLastSeenTime() > 0 {\n\t\tout.LastSeen = &MsgLastSeenInfo{\n\t\t\tWhen:      int64ToTime(desc.GetLastSeenTime()),\n\t\t\tUserAgent: desc.GetLastSeenUserAgent(),\n\t\t}\n\t}\n\treturn out\n}\n\nfunc pbTopicSerializeToDesc(topic *Topic) *pbx.TopicDesc {\n\tif topic == nil {\n\t\treturn nil\n\t}\n\treturn &pbx.TopicDesc{\n\t\tCreatedAt: timeToInt64(&topic.created),\n\t\tUpdatedAt: timeToInt64(&topic.updated),\n\t\tDefacs: &pbx.DefaultAcsMode{\n\t\t\tAuth: topic.accessAuth.String(),\n\t\t\tAnon: topic.accessAnon.String(),\n\t\t},\n\t\tSeqId:   int32(topic.lastID),\n\t\tDelId:   int32(topic.delID),\n\t\tPublic:  interfaceToBytes(topic.public),\n\t\tTrusted: interfaceToBytes(topic.trusted),\n\t}\n}\n\nfunc pbTopicSubSliceSerialize(subs []MsgTopicSub) []*pbx.TopicSub {\n\tif len(subs) == 0 {\n\t\treturn nil\n\t}\n\n\tout := make([]*pbx.TopicSub, len(subs))\n\tfor i := range subs {\n\t\tout[i] = pbTopicSubSerialize(&subs[i])\n\t}\n\treturn out\n}\n\nfunc pbTopicSubSerialize(sub *MsgTopicSub) *pbx.TopicSub {\n\tout := &pbx.TopicSub{\n\t\tUpdatedAt: timeToInt64(sub.UpdatedAt),\n\t\tDeletedAt: timeToInt64(sub.DeletedAt),\n\t\tOnline:    sub.Online,\n\t\tAcs:       pbAccessModeSerialize(&sub.Acs),\n\t\tReadId:    int32(sub.ReadSeqId),\n\t\tRecvId:    int32(sub.RecvSeqId),\n\t\tPublic:    interfaceToBytes(sub.Public),\n\t\tTrusted:   interfaceToBytes(sub.Trusted),\n\t\tPrivate:   interfaceToBytes(sub.Private),\n\t\tUserId:    sub.User,\n\t\tTopic:     sub.Topic,\n\t\tTouchedAt: timeToInt64(sub.TouchedAt),\n\t\tSeqId:     int32(sub.SeqId),\n\t\tDelId:     int32(sub.DelId),\n\t}\n\tif sub.LastSeen != nil {\n\t\tout.LastSeenTime = timeToInt64(sub.LastSeen.When)\n\t\tout.LastSeenUserAgent = sub.LastSeen.UserAgent\n\t}\n\treturn out\n}\n\nfunc pbTopicSubSliceDeserialize(subs []*pbx.TopicSub) []MsgTopicSub {\n\tif len(subs) == 0 {\n\t\treturn nil\n\t}\n\n\tout := make([]MsgTopicSub, len(subs))\n\tfor i := range subs {\n\t\tout[i] = MsgTopicSub{\n\t\t\tUpdatedAt: int64ToTime(subs[i].GetUpdatedAt()),\n\t\t\tDeletedAt: int64ToTime(subs[i].GetDeletedAt()),\n\t\t\tOnline:    subs[i].GetOnline(),\n\t\t\tReadSeqId: int(subs[i].GetReadId()),\n\t\t\tRecvSeqId: int(subs[i].GetRecvId()),\n\t\t\tPublic:    bytesToInterface(subs[i].GetPublic()),\n\t\t\tTrusted:   bytesToInterface(subs[i].GetTrusted()),\n\t\t\tPrivate:   bytesToInterface(subs[i].GetPrivate()),\n\t\t\tUser:      subs[i].GetUserId(),\n\t\t\tTopic:     subs[i].GetTopic(),\n\t\t\tTouchedAt: int64ToTime(subs[i].GetTouchedAt()),\n\t\t\tSeqId:     int(subs[i].GetSeqId()),\n\t\t\tDelId:     int(subs[i].GetDelId()),\n\t\t}\n\t\tif acs := subs[i].GetAcs(); acs != nil {\n\t\t\tout[i].Acs = *pbAccessModeDeserialize(acs)\n\t\t}\n\t\tif subs[i].GetLastSeenTime() > 0 {\n\t\t\tout[i].LastSeen = &MsgLastSeenInfo{\n\t\t\t\tWhen:      int64ToTime(subs[i].GetLastSeenTime()),\n\t\t\t\tUserAgent: subs[i].GetLastSeenUserAgent(),\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc pbSubSliceDeserialize(subs []*pbx.TopicSub) []types.Subscription {\n\tif len(subs) == 0 {\n\t\treturn nil\n\t}\n\n\tout := make([]types.Subscription, len(subs))\n\tfor i := range subs {\n\t\tout[i] = types.Subscription{\n\t\t\tObjHeader: types.ObjHeader{\n\t\t\t\tUpdatedAt: *int64ToTime(subs[i].GetUpdatedAt()),\n\t\t\t},\n\t\t\tDeletedAt: int64ToTime(subs[i].GetDeletedAt()),\n\t\t\tUser:      subs[i].GetUserId(),\n\t\t\tTopic:     subs[i].GetTopic(),\n\t\t\tDelId:     int(subs[i].GetDelId()),\n\t\t\tPrivate:   bytesToInterface(subs[i].GetPrivate()),\n\t\t}\n\t\tout[i].SetPublic(bytesToInterface(subs[i].GetPublic()))\n\t\tout[i].SetTrusted(bytesToInterface(subs[i].GetTrusted()))\n\t\tif acs := subs[i].GetAcs(); acs != nil {\n\t\t\tout[i].ModeGiven.UnmarshalText([]byte(acs.GetGiven()))\n\t\t\tout[i].ModeWant.UnmarshalText([]byte(acs.GetWant()))\n\t\t}\n\t\tif subs[i].GetLastSeenTime() > 0 {\n\t\t\tout[i].SetLastSeenAndUA(int64ToTime(subs[i].GetLastSeenTime()),\n\t\t\t\tsubs[i].GetLastSeenUserAgent())\n\t\t}\n\t}\n\treturn out\n}\n\nfunc pbDelQuerySerialize(in []MsgRange) []*pbx.SeqRange {\n\tif in == nil {\n\t\treturn nil\n\t}\n\n\tout := make([]*pbx.SeqRange, len(in))\n\tfor i, dq := range in {\n\t\tout[i] = &pbx.SeqRange{Low: int32(dq.LowId), Hi: int32(dq.HiId)}\n\t}\n\n\treturn out\n}\n\nfunc pbDelQueryDeserialize(in []*pbx.SeqRange) []MsgRange {\n\tif in == nil {\n\t\treturn nil\n\t}\n\n\tout := make([]MsgRange, len(in))\n\tfor i, sr := range in {\n\t\tout[i].LowId = int(sr.GetLow())\n\t\tout[i].HiId = int(sr.GetHi())\n\t}\n\n\treturn out\n}\n\nfunc pbDelValuesSerialize(in *MsgDelValues) *pbx.DelValues {\n\tif in == nil {\n\t\treturn nil\n\t}\n\n\treturn &pbx.DelValues{\n\t\tDelId:  int32(in.DelId),\n\t\tDelSeq: pbDelQuerySerialize(in.DelSeq),\n\t}\n}\n\nfunc pbDelValuesDeserialize(in *pbx.DelValues) *MsgDelValues {\n\tif in == nil {\n\t\treturn nil\n\t}\n\n\treturn &MsgDelValues{\n\t\tDelId:  int(in.GetDelId()),\n\t\tDelSeq: pbDelQueryDeserialize(in.GetDelSeq()),\n\t}\n}\n\nfunc pbClientCredSerialize(in *MsgCredClient) *pbx.ClientCred {\n\tif in == nil {\n\t\treturn nil\n\t}\n\n\treturn &pbx.ClientCred{\n\t\tMethod:   in.Method,\n\t\tValue:    in.Value,\n\t\tResponse: in.Response,\n\t\tParams:   interfaceMapToByteMap(in.Params),\n\t}\n}\n\nfunc pbClientCredsSerialize(in []MsgCredClient) []*pbx.ClientCred {\n\tif in == nil {\n\t\treturn nil\n\t}\n\n\tout := make([]*pbx.ClientCred, len(in))\n\tfor i := range in {\n\t\tout[i] = pbClientCredSerialize(&in[i])\n\t}\n\n\treturn out\n}\n\nfunc pbClientCredDeserialize(in *pbx.ClientCred) *MsgCredClient {\n\tif in == nil {\n\t\treturn nil\n\t}\n\n\treturn &MsgCredClient{\n\t\tMethod:   in.GetMethod(),\n\t\tValue:    in.GetValue(),\n\t\tResponse: in.GetResponse(),\n\t\tParams:   byteMapToInterfaceMap(in.GetParams()),\n\t}\n}\n\nfunc pbClientCredsDeserialize(in []*pbx.ClientCred) []MsgCredClient {\n\tif in == nil {\n\t\treturn nil\n\t}\n\n\tout := make([]MsgCredClient, len(in))\n\tfor i, cr := range in {\n\t\tout[i] = *pbClientCredDeserialize(cr)\n\t}\n\n\treturn out\n}\n\nfunc pbServerCredsSerialize(in []*MsgCredServer) []*pbx.ServerCred {\n\tif in == nil {\n\t\treturn nil\n\t}\n\n\tout := make([]*pbx.ServerCred, len(in))\n\tfor i, cr := range in {\n\t\tout[i] = &pbx.ServerCred{\n\t\t\tMethod: cr.Method,\n\t\t\tValue:  cr.Value,\n\t\t}\n\t}\n\n\treturn out\n}\n\nfunc pbServerCredsDeserialize(in []*pbx.ServerCred) []*MsgCredServer {\n\tif in == nil {\n\t\treturn nil\n\t}\n\n\tout := make([]*MsgCredServer, len(in))\n\tfor i, cr := range in {\n\t\tout[i] = &MsgCredServer{\n\t\t\tMethod: cr.GetMethod(),\n\t\t\tValue:  cr.GetValue(),\n\t\t\tDone:   cr.GetDone(),\n\t\t}\n\t}\n\n\treturn out\n}\n"
  },
  {
    "path": "server/plugins.go",
    "content": "// External services contacted through RPC\npackage main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/pbx\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store/types\"\n\t\"google.golang.org/grpc\"\n)\n\nconst (\n\tplgHi = 1 << iota\n\tplgAcc\n\tplgLogin\n\tplgSub\n\tplgLeave\n\tplgPub\n\tplgGet\n\tplgSet\n\tplgDel\n\tplgNote\n\tplgData\n\tplgMeta\n\tplgPres\n\tplgInfo\n\n\tplgClientMask = plgHi | plgAcc | plgLogin | plgSub | plgLeave | plgPub | plgGet | plgSet | plgDel | plgNote\n\tplgServerMask = plgData | plgMeta | plgPres | plgInfo\n)\n\nconst (\n\tplgActCreate = 1 << iota\n\tplgActUpd\n\tplgActDel\n\n\tplgActMask = plgActCreate | plgActUpd | plgActDel\n)\n\nconst (\n\tplgTopicMe = 1 << iota\n\tplgTopicFnd\n\tplgTopicP2P\n\tplgTopicGrp\n\tplgTopicSys\n\tplgTopicSlf\n\tplgTopicNew\n\tplgTopicNch\n\n\tplgTopicCatMask = plgTopicMe | plgTopicFnd | plgTopicP2P | plgTopicGrp | plgTopicSys | plgTopicSlf\n)\n\nconst (\n\tplgFilterByTopicType = 1 << iota\n\tplgFilterByPacket\n\tplgFilterByAction\n)\n\nvar (\n\tplgPacketNames = []string{\n\t\t\"hi\", \"acc\", \"login\", \"sub\", \"leave\", \"pub\", \"get\", \"set\", \"del\", \"note\",\n\t\t\"data\", \"meta\", \"pres\", \"info\",\n\t}\n\n\tplgTopicCatNames = []string{\"me\", \"fnd\", \"p2p\", \"grp\", \"sys\", \"slf\", \"new\", \"nch\"}\n)\n\n// PluginFilter is a enum which defines filtering types.\ntype PluginFilter struct {\n\tbyPacket    int\n\tbyTopicType int\n\tbyAction    int\n}\n\n// ParsePluginFilter parses filter config string.\nfunc ParsePluginFilter(s *string, filterBy int) (*PluginFilter, error) {\n\tif s == nil {\n\t\treturn nil, nil\n\t}\n\n\tparseByName := func(parts []string, options []string, def int) (int, error) {\n\t\tvar result int\n\n\t\t// Iterate over filter parts\n\t\tfor _, inp := range parts {\n\t\t\tif inp != \"\" {\n\t\t\t\tinp = strings.ToLower(inp)\n\t\t\t\t// Split string like \"hi,login,pres\" or \"me,p2p,fnd\"\n\t\t\t\tvalues := strings.Split(inp, \",\")\n\t\t\t\t// For each value in the input string, try to find it in the options set\n\t\t\t\tfor _, val := range values {\n\t\t\t\t\ti := 0\n\t\t\t\t\t// Iterate over the options, i.e find \"hi\" in the slice of packet names\n\t\t\t\t\tfor i = range options {\n\t\t\t\t\t\tif options[i] == val {\n\t\t\t\t\t\t\tresult |= 1 << uint(i)\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tif result != 0 && i == len(options) {\n\t\t\t\t\t\t// Mix of known and unknown options in the input\n\t\t\t\t\t\treturn 0, errors.New(\"plugin: unknown value in filter \" + val)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif result != 0 {\n\t\t\t\t\t// Found and parsed the right part\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// If the filter value is not defined, use default.\n\t\tif result == 0 {\n\t\t\tresult = def\n\t\t}\n\n\t\treturn result, nil\n\t}\n\n\tparseAction := func(parts []string) int {\n\t\tvar result int\n\t\tfor _, inp := range parts {\n\t\tLoop:\n\t\t\tfor _, char := range inp {\n\t\t\t\tswitch char {\n\t\t\t\tcase 'c', 'C':\n\t\t\t\t\tresult |= plgActCreate\n\t\t\t\tcase 'u', 'U':\n\t\t\t\t\tresult |= plgActUpd\n\t\t\t\tcase 'd', 'D':\n\t\t\t\t\tresult |= plgActDel\n\t\t\t\tdefault:\n\t\t\t\t\t// Unknown symbol means this is not an action string.\n\t\t\t\t\tresult = 0\n\t\t\t\t\tbreak Loop\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif result != 0 {\n\t\t\t\t// Found and parsed actions.\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif result == 0 {\n\t\t\tresult = plgActMask\n\t\t}\n\t\treturn result\n\t}\n\n\tfilter := PluginFilter{}\n\tparts := strings.Split(*s, \";\")\n\tvar err error\n\n\tif filterBy&plgFilterByPacket != 0 {\n\t\tif filter.byPacket, err = parseByName(parts, plgPacketNames, plgClientMask); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif filterBy&plgFilterByTopicType != 0 {\n\t\tif filter.byTopicType, err = parseByName(parts, plgTopicCatNames, plgTopicCatMask); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tif filterBy&plgFilterByAction != 0 {\n\t\tfilter.byAction = parseAction(parts)\n\t}\n\n\treturn &filter, nil\n}\n\n// PluginRPCFilterConfig filters for an individual RPC call. Filter strings are formatted as follows:\n// <comma separated list of packet names> ; <comma separated list of topics or topic types> ; <actions (combination of C U D)>\n// For instance:\n// \"acc,login;;CU\" - grab packets {acc} or {login}; no filtering by topic, Create or Update action\n// \"pub,pres;me,p2p;\"\ntype pluginRPCFilterConfig struct {\n\t// Filter by packet name, topic type [or exact name - not supported yet]. 2D: \"pub,pres;p2p,me\"\n\tFireHose *string `json:\"fire_hose\"`\n\n\t// Filter by CUD, [exact user name - not supported yet]. 1D: \"C\"\n\tAccount *string `json:\"account\"`\n\t// Filter by CUD, topic type[, exact name]: \"p2p;CU\"\n\tTopic *string `json:\"topic\"`\n\t// Filter by CUD, topic type[, exact topic name, exact user name]: \"CU\"\n\tSubscription *string `json:\"subscription\"`\n\t// Filter by C.D, topic type[, exact topic name, exact user name]: \"grp;CD\"\n\tMessage *string `json:\"message\"`\n\n\t// Call Find service, true or false\n\tFind bool\n}\n\ntype pluginConfig struct {\n\tEnabled bool `json:\"enabled\"`\n\t// Unique service name\n\tName string `json:\"name\"`\n\t// Microseconds to wait before timeout\n\tTimeout int64 `json:\"timeout\"`\n\t// Filters for RPC calls: when to call vs when to skip the call\n\tFilters pluginRPCFilterConfig `json:\"filters\"`\n\t// What should the server do if plugin failed: HTTP error code\n\tFailureCode int `json:\"failure_code\"`\n\t// HTTP Error message to go with the code\n\tFailureMessage string `json:\"failure_text\"`\n\t// Address of plugin server of the form \"tcp://localhost:123\" or \"unix://path_to_socket_file\"\n\tServiceAddr string `json:\"service_addr\"`\n}\n\n// Plugin defines client-side parameters of a gRPC plugin.\ntype Plugin struct {\n\tname    string\n\ttimeout time.Duration\n\t// Filters for individual methods\n\tfilterFireHose     *PluginFilter\n\tfilterAccount      *PluginFilter\n\tfilterTopic        *PluginFilter\n\tfilterSubscription *PluginFilter\n\tfilterMessage      *PluginFilter\n\tfilterFind         bool\n\tfailureCode        int\n\tfailureText        string\n\tnetwork            string\n\taddr               string\n\n\tconn   *grpc.ClientConn\n\tclient pbx.PluginClient\n}\n\nfunc pluginsInit(configString json.RawMessage) {\n\t// Check if any plugins are defined\n\tif len(configString) == 0 {\n\t\treturn\n\t}\n\n\tvar config []pluginConfig\n\tif err := json.Unmarshal(configString, &config); err != nil {\n\t\tlogs.Err.Fatal(err)\n\t}\n\n\tnameIndex := make(map[string]bool)\n\tglobals.plugins = make([]Plugin, len(config))\n\tcount := 0\n\tfor i := range config {\n\t\tconf := &config[i]\n\t\tif !conf.Enabled {\n\t\t\tcontinue\n\t\t}\n\n\t\tif nameIndex[conf.Name] {\n\t\t\tlogs.Err.Fatalf(\"plugins: duplicate name '%s'\", conf.Name)\n\t\t}\n\n\t\tglobals.plugins[count] = Plugin{\n\t\t\tname:        conf.Name,\n\t\t\ttimeout:     time.Duration(conf.Timeout) * time.Microsecond,\n\t\t\tfailureCode: conf.FailureCode,\n\t\t\tfailureText: conf.FailureMessage,\n\t\t}\n\t\tvar err error\n\t\tif globals.plugins[count].filterFireHose, err =\n\t\t\tParsePluginFilter(conf.Filters.FireHose, plgFilterByTopicType|plgFilterByPacket); err != nil {\n\t\t\tlogs.Err.Fatal(\"plugins: bad FireHose filter\", err)\n\t\t}\n\t\tif globals.plugins[count].filterAccount, err =\n\t\t\tParsePluginFilter(conf.Filters.Account, plgFilterByAction); err != nil {\n\t\t\tlogs.Err.Fatal(\"plugins: bad Account filter\", err)\n\t\t}\n\t\tif globals.plugins[count].filterTopic, err =\n\t\t\tParsePluginFilter(conf.Filters.Topic, plgFilterByTopicType|plgFilterByAction); err != nil {\n\t\t\tlogs.Err.Fatal(\"plugins: bad Topic filter\", err)\n\t\t}\n\t\tif globals.plugins[count].filterSubscription, err =\n\t\t\tParsePluginFilter(conf.Filters.Subscription, plgFilterByTopicType|plgFilterByAction); err != nil {\n\t\t\tlogs.Err.Fatal(\"plugins: bad Subscription filter\", err)\n\t\t}\n\t\tif globals.plugins[count].filterMessage, err =\n\t\t\tParsePluginFilter(conf.Filters.Message, plgFilterByTopicType|plgFilterByAction); err != nil {\n\t\t\tlogs.Err.Fatal(\"plugins: bad Message filter\", err)\n\t\t}\n\n\t\tglobals.plugins[count].filterFind = conf.Filters.Find\n\n\t\tif parts := strings.SplitN(conf.ServiceAddr, \"://\", 2); len(parts) < 2 {\n\t\t\tlogs.Err.Fatal(\"plugins: invalid server address format\", conf.ServiceAddr)\n\t\t} else {\n\t\t\tglobals.plugins[count].network = parts[0]\n\t\t\tglobals.plugins[count].addr = parts[1]\n\t\t}\n\n\t\tglobals.plugins[count].conn, err = grpc.Dial(globals.plugins[count].addr, grpc.WithInsecure())\n\t\tif err != nil {\n\t\t\tlogs.Err.Fatalf(\"plugins: connection failure %v\", err)\n\t\t}\n\n\t\tglobals.plugins[count].client = pbx.NewPluginClient(globals.plugins[count].conn)\n\n\t\tnameIndex[conf.Name] = true\n\t\tcount++\n\t}\n\n\tglobals.plugins = globals.plugins[:count]\n\tif len(globals.plugins) == 0 {\n\t\tlogs.Info.Println(\"plugins: no active plugins found\")\n\t\tglobals.plugins = nil\n\t} else {\n\t\tvar names []string\n\t\tfor i := range globals.plugins {\n\t\t\tnames = append(names, globals.plugins[i].name+\"(\"+globals.plugins[i].addr+\")\")\n\t\t}\n\n\t\tlogs.Info.Println(\"plugins: active\", \"'\"+strings.Join(names, \"', '\")+\"'\")\n\t}\n}\n\nfunc pluginsShutdown() {\n\tif globals.plugins == nil {\n\t\treturn\n\t}\n\n\tfor i := range globals.plugins {\n\t\tglobals.plugins[i].conn.Close()\n\t}\n}\n\nfunc pluginGenerateClientReq(sess *Session, msg *ClientComMessage) *pbx.ClientReq {\n\tcmsg := pbCliSerialize(msg)\n\tif cmsg == nil {\n\t\treturn nil\n\t}\n\treturn &pbx.ClientReq{\n\t\tMsg: cmsg,\n\t\tSess: &pbx.Session{\n\t\t\tSessionId:  sess.sid,\n\t\t\tUserId:     sess.uid.UserId(),\n\t\t\tAuthLevel:  pbx.AuthLevel(sess.authLvl),\n\t\t\tUserAgent:  sess.userAgent,\n\t\t\tRemoteAddr: sess.remoteAddr,\n\t\t\tDeviceId:   sess.deviceID,\n\t\t\tLanguage:   sess.lang,\n\t\t},\n\t}\n}\n\nfunc pluginFireHose(sess *Session, msg *ClientComMessage) (*ClientComMessage, *ServerComMessage) {\n\tif globals.plugins == nil {\n\t\t// Return the original message to continue processing without changes\n\t\treturn msg, nil\n\t}\n\n\tvar req *pbx.ClientReq\n\n\tid, topic := pluginIDAndTopic(msg)\n\tts := time.Now().UTC().Round(time.Millisecond)\n\tfor i := range globals.plugins {\n\t\tp := &globals.plugins[i]\n\t\tif !pluginDoFiltering(p.filterFireHose, msg) {\n\t\t\t// Plugin is not interested in FireHose\n\t\t\tcontinue\n\t\t}\n\n\t\tif req == nil {\n\t\t\t// Generate request only if needed\n\t\t\treq = pluginGenerateClientReq(sess, msg)\n\t\t\tif req == nil {\n\t\t\t\t// Failed to serialize message. Most likely the message is invalid.\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tvar ctx context.Context\n\t\tvar cancel context.CancelFunc\n\t\tif p.timeout > 0 {\n\t\t\tctx, cancel = context.WithTimeout(context.Background(), p.timeout)\n\t\t\tdefer cancel()\n\t\t} else {\n\t\t\tctx = context.Background()\n\t\t}\n\t\tif resp, err := p.client.FireHose(ctx, req); err == nil {\n\t\t\trespStatus := resp.GetStatus()\n\t\t\t// CONTINUE means default processing\n\t\t\tif respStatus == pbx.RespCode_CONTINUE {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// DROP means stop processing of the message\n\t\t\tif respStatus == pbx.RespCode_DROP {\n\t\t\t\treturn nil, nil\n\t\t\t}\n\t\t\t// REPLACE: ClientMsg was updated by the plugin. Use the new one for further processing.\n\t\t\tif respStatus == pbx.RespCode_REPLACE {\n\t\t\t\treturn pbCliDeserialize(resp.GetClmsg()), nil\n\t\t\t}\n\n\t\t\t// RESPOND: Plugin provided an alternative response message. Use it\n\t\t\treturn nil, pbServDeserialize(resp.GetSrvmsg())\n\n\t\t} else if p.failureCode != 0 {\n\t\t\t// Plugin failed and it's configured to stop further processing.\n\t\t\tlogs.Err.Println(\"plugin: failed,\", p.name, err)\n\t\t\treturn nil, &ServerComMessage{\n\t\t\t\tCtrl: &MsgServerCtrl{\n\t\t\t\t\tId:        id,\n\t\t\t\t\tCode:      p.failureCode,\n\t\t\t\t\tText:      p.failureText,\n\t\t\t\t\tTopic:     topic,\n\t\t\t\t\tTimestamp: ts,\n\t\t\t\t},\n\t\t\t}\n\t\t} else {\n\t\t\t// Plugin failed but configured to ignore failure.\n\t\t\tlogs.Warn.Println(\"plugin: failure ignored,\", p.name, err)\n\t\t}\n\t}\n\n\treturn msg, nil\n}\n\n// Ask plugin to perform search.\nfunc pluginFind(user types.Uid, query string) (string, []types.Subscription, error) {\n\tif globals.plugins == nil {\n\t\treturn query, nil, nil\n\t}\n\n\tfind := &pbx.SearchQuery{\n\t\tUserId: user.UserId(),\n\t\tQuery:  query,\n\t}\n\tfor i := range globals.plugins {\n\t\tp := &globals.plugins[i]\n\t\tif !p.filterFind {\n\t\t\t// Plugin cannot service Find requests\n\t\t\tcontinue\n\t\t}\n\n\t\tvar ctx context.Context\n\t\tvar cancel context.CancelFunc\n\t\tif p.timeout > 0 {\n\t\t\tctx, cancel = context.WithTimeout(context.Background(), p.timeout)\n\t\t\tdefer cancel()\n\t\t} else {\n\t\t\tctx = context.Background()\n\t\t}\n\t\tresp, err := p.client.Find(ctx, find)\n\t\tif err != nil {\n\t\t\tlogs.Warn.Println(\"plugins: Find call failed\", p.name, err)\n\t\t\treturn \"\", nil, err\n\t\t}\n\t\trespStatus := resp.GetStatus()\n\t\t// CONTINUE means default processing\n\t\tif respStatus == pbx.RespCode_CONTINUE {\n\t\t\tcontinue\n\t\t}\n\t\t// DROP means stop processing the request\n\t\tif respStatus == pbx.RespCode_DROP {\n\t\t\treturn \"\", nil, nil\n\t\t}\n\t\t// REPLACE: query string was changed. Use the new one for further processing.\n\t\tif respStatus == pbx.RespCode_REPLACE {\n\t\t\treturn resp.GetQuery(), nil, nil\n\t\t}\n\t\t// RESPOND: Plugin provided a specific response. Use it\n\t\treturn \"\", pbSubSliceDeserialize(resp.GetResult()), nil\n\t}\n\n\treturn query, nil, nil\n}\n\nfunc pluginAccount(user *types.User, action int) {\n\tif globals.plugins == nil {\n\t\treturn\n\t}\n\n\tvar event *pbx.AccountEvent\n\tfor i := range globals.plugins {\n\t\tp := &globals.plugins[i]\n\t\tif p.filterAccount == nil || p.filterAccount.byAction&action == 0 {\n\t\t\t// Plugin is not interested in Account actions\n\t\t\tcontinue\n\t\t}\n\n\t\tif event == nil {\n\t\t\tevent = &pbx.AccountEvent{\n\t\t\t\tAction: pluginActionToCrud(action),\n\t\t\t\tUserId: user.Uid().UserId(),\n\t\t\t\tDefaultAcs: pbDefaultAcsSerialize(&MsgDefaultAcsMode{\n\t\t\t\t\tAuth: user.Access.Auth.String(),\n\t\t\t\t\tAnon: user.Access.Anon.String(),\n\t\t\t\t}),\n\t\t\t\tPublic: interfaceToBytes(user.Public),\n\t\t\t\tTags:   user.Tags,\n\t\t\t}\n\t\t}\n\n\t\tvar ctx context.Context\n\t\tvar cancel context.CancelFunc\n\t\tif p.timeout > 0 {\n\t\t\tctx, cancel = context.WithTimeout(context.Background(), p.timeout)\n\t\t\tdefer cancel()\n\t\t} else {\n\t\t\tctx = context.Background()\n\t\t}\n\t\tif _, err := p.client.Account(ctx, event); err != nil {\n\t\t\tlogs.Warn.Println(\"plugins: Account call failed\", p.name, err)\n\t\t}\n\t}\n}\n\nfunc pluginTopic(topic *Topic, action int) {\n\tif globals.plugins == nil {\n\t\treturn\n\t}\n\n\tvar event *pbx.TopicEvent\n\tfor i := range globals.plugins {\n\t\tp := &globals.plugins[i]\n\t\tif p.filterTopic == nil || p.filterTopic.byAction&action == 0 {\n\t\t\t// Plugin is not interested in Message actions\n\t\t\tcontinue\n\t\t}\n\n\t\tif event == nil {\n\t\t\tevent = &pbx.TopicEvent{\n\t\t\t\tAction: pluginActionToCrud(action),\n\t\t\t\tName:   topic.name,\n\t\t\t\tDesc:   pbTopicSerializeToDesc(topic),\n\t\t\t}\n\t\t}\n\n\t\tvar ctx context.Context\n\t\tvar cancel context.CancelFunc\n\t\tif p.timeout > 0 {\n\t\t\tctx, cancel = context.WithTimeout(context.Background(), p.timeout)\n\t\t\tdefer cancel()\n\t\t} else {\n\t\t\tctx = context.Background()\n\t\t}\n\t\tif _, err := p.client.Topic(ctx, event); err != nil {\n\t\t\tlogs.Warn.Println(\"plugins: Topic call failed\", p.name, err)\n\t\t}\n\t}\n}\n\nfunc pluginSubscription(sub *types.Subscription, action int) {\n\tif globals.plugins == nil {\n\t\treturn\n\t}\n\n\tvar event *pbx.SubscriptionEvent\n\tfor i := range globals.plugins {\n\t\tp := &globals.plugins[i]\n\t\tif p.filterSubscription == nil || p.filterSubscription.byAction&action == 0 {\n\t\t\t// Plugin is not interested in Message actions\n\t\t\tcontinue\n\t\t}\n\n\t\tif event == nil {\n\t\t\tevent = &pbx.SubscriptionEvent{\n\t\t\t\tAction: pluginActionToCrud(action),\n\t\t\t\tTopic:  sub.Topic,\n\t\t\t\tUserId: sub.User,\n\n\t\t\t\tDelId:  int32(sub.DelId),\n\t\t\t\tReadId: int32(sub.ReadSeqId),\n\t\t\t\tRecvId: int32(sub.RecvSeqId),\n\n\t\t\t\tMode: &pbx.AccessMode{\n\t\t\t\t\tWant:  sub.ModeWant.String(),\n\t\t\t\t\tGiven: sub.ModeGiven.String(),\n\t\t\t\t},\n\n\t\t\t\tPrivate: interfaceToBytes(sub.Private),\n\t\t\t}\n\t\t}\n\n\t\tvar ctx context.Context\n\t\tvar cancel context.CancelFunc\n\t\tif p.timeout > 0 {\n\t\t\tctx, cancel = context.WithTimeout(context.Background(), p.timeout)\n\t\t\tdefer cancel()\n\t\t} else {\n\t\t\tctx = context.Background()\n\t\t}\n\t\tif _, err := p.client.Subscription(ctx, event); err != nil {\n\t\t\tlogs.Warn.Println(\"plugins: Subscription call failed\", p.name, err)\n\t\t}\n\t}\n}\n\n// Message accepted for delivery\nfunc pluginMessage(data *MsgServerData, action int) {\n\tif globals.plugins == nil || action != plgActCreate {\n\t\treturn\n\t}\n\n\tvar event *pbx.MessageEvent\n\tfor i := range globals.plugins {\n\t\tp := &globals.plugins[i]\n\t\tif p.filterMessage == nil || p.filterMessage.byAction&action == 0 {\n\t\t\t// Plugin is not interested in Message actions\n\t\t\tcontinue\n\t\t}\n\n\t\tif event == nil {\n\t\t\tevent = &pbx.MessageEvent{\n\t\t\t\tAction: pluginActionToCrud(action),\n\t\t\t\tMsg:    pbServDataSerialize(data).Data,\n\t\t\t}\n\t\t}\n\n\t\tvar ctx context.Context\n\t\tvar cancel context.CancelFunc\n\t\tif p.timeout > 0 {\n\t\t\tctx, cancel = context.WithTimeout(context.Background(), p.timeout)\n\t\t\tdefer cancel()\n\t\t} else {\n\t\t\tctx = context.Background()\n\t\t}\n\t\tif _, err := p.client.Message(ctx, event); err != nil {\n\t\t\tlogs.Warn.Println(\"plugins: Message call failed\", p.name, err)\n\t\t}\n\t}\n}\n\n// Returns false to skip, true to process\nfunc pluginDoFiltering(filter *PluginFilter, msg *ClientComMessage) bool {\n\tfilterByTopic := func(topic string, flt int) bool {\n\t\tif topic == \"\" || flt == plgTopicCatMask {\n\t\t\treturn true\n\t\t}\n\n\t\ttt := topic\n\t\tif len(tt) > 3 {\n\t\t\ttt = topic[:3]\n\t\t}\n\t\tswitch tt {\n\t\tcase \"me\":\n\t\t\treturn flt&plgTopicMe != 0\n\t\tcase \"fnd\":\n\t\t\treturn flt&plgTopicFnd != 0\n\t\tcase \"usr\":\n\t\t\treturn flt&plgTopicP2P != 0\n\t\tcase \"grp\":\n\t\t\treturn flt&plgTopicGrp != 0\n\t\tcase \"sys\":\n\t\t\treturn flt&plgTopicSys != 0\n\t\tcase \"slf\":\n\t\t\treturn flt&plgTopicSlf != 0\n\t\tcase \"new\":\n\t\t\treturn flt&plgTopicNew != 0\n\t\tcase \"nch\":\n\t\t\treturn flt&plgTopicNch != 0\n\t\t}\n\t\treturn false\n\t}\n\n\t// Check if plugin has any filters for this call\n\tif filter == nil || filter.byPacket == 0 {\n\t\treturn false\n\t}\n\t// Check if plugin wants all the messages\n\tif filter.byPacket == plgClientMask && filter.byTopicType == plgTopicCatMask {\n\t\treturn true\n\t}\n\t// Check individual bits\n\tif msg.Hi != nil {\n\t\treturn filter.byPacket&plgHi != 0\n\t}\n\tif msg.Acc != nil {\n\t\treturn filter.byPacket&plgAcc != 0\n\t}\n\tif msg.Login != nil {\n\t\treturn filter.byPacket&plgLogin != 0\n\t}\n\tif msg.Sub != nil {\n\t\treturn filter.byPacket&plgSub != 0 && filterByTopic(msg.Sub.Topic, filter.byTopicType)\n\t}\n\tif msg.Leave != nil {\n\t\treturn filter.byPacket&plgLeave != 0 && filterByTopic(msg.Leave.Topic, filter.byTopicType)\n\t}\n\tif msg.Pub != nil {\n\t\treturn filter.byPacket&plgPub != 0 && filterByTopic(msg.Pub.Topic, filter.byTopicType)\n\t}\n\tif msg.Get != nil {\n\t\treturn filter.byPacket&plgGet != 0 && filterByTopic(msg.Get.Topic, filter.byTopicType)\n\t}\n\tif msg.Set != nil {\n\t\treturn filter.byPacket&plgSet != 0 && filterByTopic(msg.Set.Topic, filter.byTopicType)\n\t}\n\tif msg.Del != nil {\n\t\treturn filter.byPacket&plgDel != 0 && filterByTopic(msg.Del.Topic, filter.byTopicType)\n\t}\n\tif msg.Note != nil {\n\t\treturn filter.byPacket&plgNote != 0 && filterByTopic(msg.Note.Topic, filter.byTopicType)\n\t}\n\treturn false\n}\n\nfunc pluginActionToCrud(action int) pbx.Crud {\n\tswitch action {\n\tcase plgActCreate:\n\t\treturn pbx.Crud_CREATE\n\tcase plgActUpd:\n\t\treturn pbx.Crud_UPDATE\n\tcase plgActDel:\n\t\treturn pbx.Crud_DELETE\n\t}\n\tpanic(\"plugin: unknown action\")\n}\n\n// pluginIDAndTopic extracts message ID and topic name.\nfunc pluginIDAndTopic(msg *ClientComMessage) (string, string) {\n\tif msg.Hi != nil {\n\t\treturn msg.Hi.Id, \"\"\n\t}\n\tif msg.Acc != nil {\n\t\treturn msg.Acc.Id, \"\"\n\t}\n\tif msg.Login != nil {\n\t\treturn msg.Login.Id, \"\"\n\t}\n\tif msg.Sub != nil {\n\t\treturn msg.Sub.Id, msg.Sub.Topic\n\t}\n\tif msg.Leave != nil {\n\t\treturn msg.Leave.Id, msg.Leave.Topic\n\t}\n\tif msg.Pub != nil {\n\t\treturn msg.Pub.Id, msg.Pub.Topic\n\t}\n\tif msg.Get != nil {\n\t\treturn msg.Get.Id, msg.Get.Topic\n\t}\n\tif msg.Set != nil {\n\t\treturn msg.Set.Id, msg.Set.Topic\n\t}\n\tif msg.Del != nil {\n\t\treturn msg.Del.Id, msg.Del.Topic\n\t}\n\tif msg.Note != nil {\n\t\treturn \"\", msg.Note.Topic\n\t}\n\treturn \"\", \"\"\n}\n"
  },
  {
    "path": "server/pres.go",
    "content": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"strings\"\n\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\n// presParams defines parameters for creating a presence notification.\ntype presParams struct {\n\tuserAgent string\n\tseqID     int\n\tdelID     int\n\tdelSeq    []MsgRange\n\n\t// Uid who performed the action\n\tactor string\n\t// Subject of the action\n\ttarget string\n\tdWant  string\n\tdGiven string\n}\n\ntype presFilters struct {\n\t// Send messages only to users with this access mode being non-zero.\n\tfilterIn types.AccessMode\n\t// Exclude users with this access mode being non-zero.\n\tfilterOut types.AccessMode\n\t// Send messages to the sessions of this single user defined by ID as a string 'usrABC'.\n\tsingleUser string\n\t// Do not send messages to sessions of this user defined by ID as a string 'usrABC'.\n\texcludeUser string\n}\n\nfunc (p *presParams) packAcs() *MsgAccessMode {\n\tif p.dWant != \"\" || p.dGiven != \"\" {\n\t\treturn &MsgAccessMode{Want: p.dWant, Given: p.dGiven}\n\t}\n\treturn nil\n}\n\n// Presence: Add another user to the list of contacts to notify of presence and other changes\nfunc (t *Topic) addToPerSubs(topic string, online, enabled bool) {\n\tif topic == t.name {\n\t\t// No need to push updates to self\n\t\treturn\n\t}\n\n\t// TODO: maybe skip loading channel subscriptions. They can's send or receive these notifications anyway.\n\n\tif uid1, uid2, err := types.ParseP2P(topic); err == nil {\n\t\t// If this is a P2P topic, index it by second user's ID\n\t\tif uid1.UserId() == t.name {\n\t\t\ttopic = uid2.UserId()\n\t\t} else {\n\t\t\ttopic = uid1.UserId()\n\t\t}\n\t}\n\n\tt.perSubs[topic] = perSubsData{online: online, enabled: enabled}\n}\n\n// loadContacts loads topic.perSubs to support presence notifications.\n// perSubs contains (a) topics that the user wants to notify of his presence and\n// (b) those which want to receive notifications from this user.\nfunc (t *Topic) loadContacts(uid types.Uid) error {\n\tsubs, err := store.Users.GetSubs(uid)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor i := range subs {\n\t\tt.addToPerSubs(subs[i].Topic, false, (subs[i].ModeGiven & subs[i].ModeWant).IsPresencer())\n\t}\n\treturn nil\n}\n\n// This topic got a request from a 'me' topic to start/stop sending presence updates.\n// The originating topic reports its own status in 'what' as \"on\", \"off\", \"gone\" or \"?unkn\".\n//\n//\t\t\"on\" - requester came online\n//\t\t\"off\" - requester is offline now\n//\t \"?none\" - anchor for \"+\" command: requester status is unknown, won't generate a response\n//\t\t\t\tand isn't forwarded to clients.\n//\t \"gone\" - topic deleted or otherwise gone - equivalent of \"off+remove\"\n//\t\t\"?unkn\" - requester wants to initiate online status exchange but it's own status is unknown yet. This\n//\t notifications is not forwarded to users.\n//\n// \"+\" commands:\n// \"+en\": enable subscription, i.e. start accepting incoming notifications from the user2;\n// \"+rem\": terminate and remove the subscription (subscription deleted)\n// \"+dis\" disable subscription withot removing it, the opposite of \"en\".\n// The \"+en/rem/dis\" command itself is stripped from the notification.\nfunc (t *Topic) procPresReq(fromUserID, what string, wantReply bool) string {\n\tif t.isInactive() {\n\t\treturn \"\"\n\t}\n\n\tif t.isProxy {\n\t\t// Passthrough on proxy: there is no point in maintaining peer status\n\t\t// at the proxy, it's an exact replica of the master.\n\t\treturn what\n\t}\n\n\tvar reqReply, onlineUpdate bool\n\n\tonline := &onlineUpdate\n\treplyAs := \"on\"\n\n\tparts := strings.Split(what, \"+\")\n\twhat = parts[0]\n\tcmd := \"\"\n\tif len(parts) > 1 {\n\t\tcmd = parts[1]\n\t}\n\n\tswitch what {\n\tcase \"on\":\n\t\t// online\n\t\t*online = true\n\tcase \"off\":\n\t\t// offline\n\tcase \"?none\":\n\t\t// no change to online status\n\t\tonline = nil\n\t\twhat = \"\"\n\tcase \"gone\":\n\t\t// offline: off+rem\n\t\tcmd = \"rem\"\n\tcase \"?unkn\":\n\t\t// no change in online status\n\t\tonline = nil\n\t\treqReply = true\n\t\twhat = \"\"\n\tdefault:\n\t\t// All other notifications are not processed here\n\t\treturn what\n\t}\n\n\tif t.cat == types.TopicCatMe {\n\t\t// Find if the contact is listed.\n\t\tif psd, ok := t.perSubs[fromUserID]; ok {\n\t\t\tif cmd == \"rem\" {\n\t\t\t\treplyAs = \"off+rem\"\n\t\t\t\tif !psd.enabled && what == \"off\" {\n\t\t\t\t\t// If it was disabled before, don't send a redundant update.\n\t\t\t\t\twhat = \"\"\n\t\t\t\t}\n\t\t\t\tdelete(t.perSubs, fromUserID)\n\t\t\t} else {\n\t\t\t\tswitch cmd {\n\t\t\t\tcase \"\":\n\t\t\t\t\t// No change in being enabled or disabled and not being added or removed.\n\t\t\t\t\tif !psd.enabled || online == nil || psd.online == *online {\n\t\t\t\t\t\t// Not enabled or no change in online status - remove unnecessary notification.\n\t\t\t\t\t\twhat = \"\"\n\t\t\t\t\t}\n\t\t\t\tcase \"en\":\n\t\t\t\t\tif !psd.enabled {\n\t\t\t\t\t\tpsd.enabled = true\n\t\t\t\t\t} else if online == nil || psd.online == *online {\n\t\t\t\t\t\t// Was active and no change or online before: skip unnecessary update.\n\t\t\t\t\t\twhat = \"\"\n\t\t\t\t\t}\n\t\t\t\tcase \"dis\":\n\t\t\t\t\tif psd.enabled {\n\t\t\t\t\t\tpsd.enabled = false\n\t\t\t\t\t\tif !psd.online {\n\t\t\t\t\t\t\twhat = \"\"\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Was disabled and consequently offline before, still offline - skip the update.\n\t\t\t\t\t\twhat = \"\"\n\t\t\t\t\t}\n\t\t\t\tdefault:\n\t\t\t\t\tpanic(\"presProcReq: unknown command '\" + cmd + \"'\")\n\t\t\t\t}\n\n\t\t\t\tif !psd.enabled {\n\t\t\t\t\t// If we don't care about updates, keep the other user off\n\t\t\t\t\tpsd.online = false\n\t\t\t\t} else if online != nil {\n\t\t\t\t\tpsd.online = *online\n\t\t\t\t}\n\n\t\t\t\tt.perSubs[fromUserID] = psd\n\t\t\t}\n\t\t} else if cmd != \"rem\" {\n\t\t\t// Got request from a new topic. This must be a new subscription. Record it.\n\t\t\t// If it's unknown, recording it as offline.\n\t\t\tt.addToPerSubs(fromUserID, onlineUpdate, cmd == \"en\")\n\n\t\t\tif cmd != \"en\" {\n\t\t\t\t// If the connection is not enabled, ignore the update.\n\t\t\t\twhat = \"\"\n\t\t\t}\n\t\t} else {\n\t\t\t// Not in list and asked to be removed from the list - ignore\n\t\t\twhat = \"\"\n\t\t}\n\t}\n\n\t// If requester's online status has not changed, do not reply, otherwise an endless loop will happen.\n\t// wantReply is needed to ensure unnecessary {pres} is not sent:\n\t// A[online, B:off] to B[online, A:off]: {pres A on}\n\t// B[online, A:on] to A[online, B:off]: {pres B on}\n\t// A[online, B:on] to B[online, A:on]: {pres A on} <<-- unnecessary, that's why wantReply is needed\n\tif (onlineUpdate || reqReply) && wantReply {\n\t\tglobals.hub.routeSrv <- &ServerComMessage{\n\t\t\t// Topic is 'me' even for group topics; group topics will use 'me' as a signal to drop the message\n\t\t\t// without forwarding to sessions\n\t\t\tPres: &MsgServerPres{\n\t\t\t\tTopic:     \"me\",\n\t\t\t\tWhat:      replyAs,\n\t\t\t\tSrc:       t.name,\n\t\t\t\tWantReply: reqReply,\n\t\t\t},\n\t\t\tRcptTo: fromUserID,\n\t\t}\n\t}\n\n\treturn what\n}\n\n// Get user-specific topic name for notifying users of interest, or skip the notification.\nfunc notifyOnOrSkip(topic, what string, online bool) string {\n\t// Don't send notifications on channels\n\tif types.IsChannel(topic) {\n\t\treturn \"\"\n\t}\n\n\t// P2P contacts are notified on 'me', group topics are notified on proper topic name.\n\tnotifyOn := \"me\"\n\tif what == \"upd\" || what == \"ua\" {\n\t\tif !online {\n\t\t\t// Skip \"upd\" and \"ua\" notifications if the contact is offline.\n\t\t\treturn \"\"\n\t\t}\n\t\tif types.GetTopicCat(topic) == types.TopicCatGrp {\n\t\t\tnotifyOn = topic\n\t\t}\n\t}\n\treturn notifyOn\n}\n\n// Publish user's update to his/her subscriptions: p2p on their 'me' topic, group topics on the topic.\n// Case A: user came online, \"on\", ua\n// Case B: user went offline, \"off\", ua\n// Case C: user agent change, \"ua\", ua\n// Case D: User updated 'public', \"upd\"\nfunc (t *Topic) presUsersOfInterest(what, ua string) {\n\tparts := strings.Split(what, \"+\")\n\twantReply := parts[0] == \"on\"\n\tgoOffline := len(parts) > 1 && parts[1] == \"dis\"\n\n\t// Push update to subscriptions\n\tfor topic, psd := range t.perSubs {\n\t\tnotifyOn := notifyOnOrSkip(topic, what, psd.online)\n\t\tif notifyOn == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tglobals.hub.routeSrv <- &ServerComMessage{\n\t\t\tPres: &MsgServerPres{\n\t\t\t\tTopic:     notifyOn,\n\t\t\t\tWhat:      what,\n\t\t\t\tSrc:       t.name,\n\t\t\t\tUserAgent: ua,\n\t\t\t\tWantReply: wantReply,\n\t\t\t},\n\t\t\tRcptTo: topic,\n\t\t}\n\n\t\tif psd.online && goOffline {\n\t\t\tpsd.online = false\n\t\t\tt.perSubs[topic] = psd\n\t\t}\n\t}\n}\n\n// Publish user's update to his/her users of interest on their 'me' topic while user's 'me' topic is offline\n// Case A: user is being deleted, \"gone\".\nfunc presUsersOfInterestOffline(uid types.Uid, subs []types.Subscription, what string) {\n\t// Push update to subscriptions\n\tfor i := range subs {\n\t\tnotifyOn := notifyOnOrSkip(subs[i].Topic, what, true)\n\t\tif notifyOn == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tglobals.hub.routeSrv <- &ServerComMessage{\n\t\t\tPres: &MsgServerPres{\n\t\t\t\tTopic:     notifyOn,\n\t\t\t\tWhat:      what,\n\t\t\t\tSrc:       uid.UserId(),\n\t\t\t\tWantReply: false,\n\t\t\t},\n\t\t\tRcptTo: subs[i].Topic,\n\t\t}\n\t}\n}\n\n// Report change to topic subscribers online, group or p2p\n//\n// Case I: User joined the topic, \"on\"\n// Case J: User left topic, \"off\"\n// Case K.2: User altered WANT (and maybe got default Given), \"acs\"\n// Case L.1: Admin altered GIVEN, \"acs\" to affected user\n// Case L.3: Admin altered GIVEN (and maybe got assigned default WANT), \"acs\" to admins\n// Case M: Topic unaccessible (cluster failure), \"left\" to everyone currently online\n// Case V.2: Messages soft deleted, \"del\" to one user only\n// Case W.2: Messages hard-deleted, \"del\"\nfunc (t *Topic) presSubsOnline(what, src string, params *presParams, filter *presFilters, skipSid string) {\n\t// If affected user is the same as the user making the change, clear 'who'\n\tactor := params.actor\n\ttarget := params.target\n\tif actor == src {\n\t\tactor = \"\"\n\t}\n\n\tif target == src {\n\t\ttarget = \"\"\n\t}\n\n\tglobals.hub.routeSrv <- &ServerComMessage{\n\t\tPres: &MsgServerPres{\n\t\t\tTopic:       t.xoriginal,\n\t\t\tWhat:        what,\n\t\t\tSrc:         src,\n\t\t\tAcs:         params.packAcs(),\n\t\t\tAcsActor:    actor,\n\t\t\tAcsTarget:   target,\n\t\t\tSeqId:       params.seqID,\n\t\t\tDelId:       params.delID,\n\t\t\tDelSeq:      params.delSeq,\n\t\t\tFilterIn:    int(filter.filterIn),\n\t\t\tFilterOut:   int(filter.filterOut),\n\t\t\tSingleUser:  filter.singleUser,\n\t\t\tExcludeUser: filter.excludeUser,\n\t\t},\n\t\tRcptTo: t.name, SkipSid: skipSid,\n\t}\n}\n\n// userIsPresencer returns true if the user (specified by `uid`) may receive presence notifications.\nfunc (t *Topic) userIsPresencer(uid types.Uid) bool {\n\tvar want, given types.AccessMode\n\tif uid.IsZero() {\n\t\t// For zero uids (typically for proxy sessions), return the union of all permissions.\n\t\twant = t.modeWantUnion\n\t\tgiven = t.modeGivenUnion\n\t} else {\n\t\tpud := t.perUser[uid]\n\t\tif pud.deleted {\n\t\t\treturn false\n\t\t}\n\t\twant = pud.modeWant\n\t\tgiven = pud.modeGiven\n\t}\n\treturn (want & given).IsPresencer()\n}\n\n// Send notification to attached sessions directly, without routing though topic.\n// This is needed because the session(s) may be already disconnected by the time it's routed through topic.\nfunc (t *Topic) presSubsOnlineDirect(what string, params *presParams, filter *presFilters, skipSid string) {\n\tmsg := &ServerComMessage{\n\t\tPres: &MsgServerPres{\n\t\t\tTopic:  t.xoriginal,\n\t\t\tWhat:   what,\n\t\t\tAcs:    params.packAcs(),\n\t\t\tSeqId:  params.seqID,\n\t\t\tDelId:  params.delID,\n\t\t\tDelSeq: params.delSeq,\n\t\t},\n\t}\n\n\tfor s, pssd := range t.sessions {\n\t\tif !s.isMultiplex() {\n\t\t\tif skipSid == s.sid {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tpud := t.perUser[pssd.uid]\n\t\t\t// Check presence filters\n\t\t\tif pud.deleted || !presOfflineFilter(pud.modeGiven&pud.modeWant, what, filter) {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif filter != nil {\n\t\t\t\tif filter.singleUser != \"\" && filter.singleUser != pssd.uid.UserId() {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif filter.excludeUser != \"\" && filter.excludeUser == pssd.uid.UserId() {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// For p2p topics topic name is dependent on receiver.\n\t\t\t// It's OK to change the pointer here because the message will be serialized in queueOut\n\t\t\t// before being placed into the channel.\n\t\t\tt.prepareBroadcastableMessage(msg, pssd.uid, pssd.isChanSub)\n\t\t}\n\t\ts.queueOut(msg)\n\t}\n}\n\n// Communicates \"topic unaccessible (cluster rehashing or node connection lost)\" event\n// to a list of topics promting the client to resubscribe to the topics.\nfunc (s *Session) presTermDirect(subs []string) {\n\tmsg := &ServerComMessage{\n\t\tPres: &MsgServerPres{Topic: \"me\", What: \"term\"},\n\t}\n\tfor _, topic := range subs {\n\t\tmsg.Pres.Src = topic\n\t\ts.queueOut(msg)\n\t}\n}\n\n// Publish to topic subscribers's sessions currently offline in the topic, on their 'me'\n// Group and P2P.\n// Case E: topic came online, \"on\"\n// Case F: topic went offline, \"off\"\n// Case G: topic updated 'public', \"upd\", who\n// Case H: topic deleted, \"gone\"\n// Case K.3: user altered WANT, \"acs\" to admins\n// Case L.4: Admin altered GIVEN, \"acs\" to admins\n// Case T: message sent, \"msg\" to all with 'R'\n// Case W.1: messages hard-deleted, \"del\" to all with 'R'\nfunc (t *Topic) presSubsOffline(what string, params *presParams,\n\tfilterSource *presFilters, filterTarget *presFilters, skipSid string, offlineOnly bool) {\n\tvar skipTopic string\n\tif offlineOnly {\n\t\tskipTopic = t.name\n\t}\n\n\tfor uid, pud := range t.perUser {\n\t\tif pud.deleted || !presOfflineFilter(pud.modeGiven&pud.modeWant, what, filterSource) {\n\t\t\tcontinue\n\t\t}\n\n\t\tuser := uid.UserId()\n\t\tactor := params.actor\n\t\ttarget := params.target\n\t\tif actor == user {\n\t\t\tactor = \"\"\n\t\t}\n\n\t\tif target == user {\n\t\t\ttarget = \"\"\n\t\t}\n\n\t\tglobals.hub.routeSrv <- &ServerComMessage{\n\t\t\tPres: &MsgServerPres{\n\t\t\t\tTopic:       \"me\",\n\t\t\t\tWhat:        what,\n\t\t\t\tSrc:         t.original(uid),\n\t\t\t\tAcs:         params.packAcs(),\n\t\t\t\tAcsActor:    actor,\n\t\t\t\tAcsTarget:   target,\n\t\t\t\tSeqId:       params.seqID,\n\t\t\t\tDelId:       params.delID,\n\t\t\t\tFilterIn:    int(filterTarget.filterIn),\n\t\t\t\tFilterOut:   int(filterTarget.filterOut),\n\t\t\t\tSingleUser:  filterTarget.singleUser,\n\t\t\t\tExcludeUser: filterTarget.excludeUser,\n\t\t\t\tSkipTopic:   skipTopic,\n\t\t\t},\n\t\t\tRcptTo:  user,\n\t\t\tSkipSid: skipSid,\n\t\t}\n\t}\n}\n\n// Publish {info what=read|recv|kp} to topic subscribers's sessions currently offline in the topic,\n// on subscriber's 'me'. Group and P2P.\nfunc (t *Topic) infoSubsOffline(from types.Uid, what string, seq int, skipSid string) {\n\tuser := from.UserId()\n\n\tfor uid, pud := range t.perUser {\n\t\tmode := pud.modeGiven & pud.modeWant\n\t\tif pud.deleted || !mode.IsPresencer() || !mode.IsReader() {\n\t\t\tcontinue\n\t\t}\n\n\t\tglobals.hub.routeSrv <- &ServerComMessage{\n\t\t\tInfo: &MsgServerInfo{\n\t\t\t\tTopic:     \"me\",\n\t\t\t\tSrc:       t.original(uid),\n\t\t\t\tFrom:      user,\n\t\t\t\tWhat:      what,\n\t\t\t\tSeqId:     seq,\n\t\t\t\tSkipTopic: t.name,\n\t\t\t},\n\t\t\tRcptTo:  uid.UserId(),\n\t\t\tSkipSid: skipSid,\n\t\t}\n\t}\n}\n\n// Publish {info what=call} to topic subscribers's sessions on subscriber's 'me'.\nfunc (t *Topic) infoCallSubsOffline(from string, target types.Uid, event string, seq int,\n\tsdp json.RawMessage, skipSid string, offlineOnly bool) {\n\tif target.IsZero() {\n\t\tlogs.Err.Printf(\"callSubs could not find target: topic %s - from %s\", t.name, from)\n\t\treturn\n\t}\n\tpud := t.perUser[target]\n\tmode := pud.modeGiven & pud.modeWant\n\tif pud.deleted || !mode.IsPresencer() || !mode.IsReader() {\n\t\treturn\n\t}\n\tmsg := &ServerComMessage{\n\t\tInfo: &MsgServerInfo{\n\t\t\tTopic:   \"me\",\n\t\t\tSrc:     t.original(target),\n\t\t\tFrom:    from,\n\t\t\tWhat:    \"call\",\n\t\t\tEvent:   event,\n\t\t\tSeqId:   seq,\n\t\t\tPayload: sdp,\n\t\t},\n\t\tRcptTo:  target.UserId(),\n\t\tSkipSid: skipSid,\n\t}\n\tif offlineOnly {\n\t\tmsg.Info.SkipTopic = t.name\n\t}\n\tglobals.hub.routeSrv <- msg\n}\n\n// Same as presSubsOffline, but the topic has not been loaded/initialized first: offline topic, offline subscribers\nfunc presSubsOfflineOffline(topic string, cat types.TopicCat, subs []types.Subscription, what string,\n\tparams *presParams, skipSid string) {\n\n\tcount := 0\n\toriginal := topic\n\tfor i := range subs {\n\t\tsub := &subs[i]\n\t\t// Let \"acs\" and \"gone\" through regardless of 'P'. Don't check for deleted subscriptions:\n\t\t// they are not passed here.\n\t\tif !presOfflineFilter(sub.ModeWant&sub.ModeGiven, what, nil) {\n\t\t\tcontinue\n\t\t}\n\n\t\tif cat == types.TopicCatP2P {\n\t\t\toriginal = types.ParseUid(subs[(count+1)%2].User).UserId()\n\t\t\tcount++\n\t\t}\n\n\t\tuser := types.ParseUid(sub.User).UserId()\n\t\tactor := params.actor\n\t\ttarget := params.target\n\t\tif actor == user {\n\t\t\tactor = \"\"\n\t\t}\n\n\t\tif target == user {\n\t\t\ttarget = \"\"\n\t\t}\n\n\t\tglobals.hub.routeSrv <- &ServerComMessage{\n\t\t\tPres: &MsgServerPres{\n\t\t\t\tTopic:     \"me\",\n\t\t\t\tWhat:      what,\n\t\t\t\tSrc:       original,\n\t\t\t\tAcs:       params.packAcs(),\n\t\t\t\tAcsActor:  actor,\n\t\t\t\tAcsTarget: target,\n\t\t\t\tSeqId:     params.seqID,\n\t\t\t\tDelId:     params.delID,\n\t\t\t},\n\t\t\tRcptTo:  user,\n\t\t\tSkipSid: skipSid,\n\t\t}\n\t}\n}\n\n// Announce to a single user on 'me' topic\n//\n// Case K.1: User altered WANT (includes new subscription, deleted subscription)\n// Case L.2: Sharer altered GIVEN (inludes invite, eviction)\n// Case U: read/recv notification\n// Case V.1: messages soft-deleted\nfunc (t *Topic) presSingleUserOffline(uid types.Uid, mode types.AccessMode,\n\twhat string, params *presParams, skipSid string,\n\tofflineOnly bool) {\n\n\tvar skipTopic string\n\tif offlineOnly {\n\t\tskipTopic = t.name\n\t}\n\n\t// ModeInvalid means the user is deleted (pud.deleted == true)\n\tif mode != types.ModeInvalid && presOfflineFilter(mode, what, nil) {\n\n\t\tuser := uid.UserId()\n\t\tactor := params.actor\n\t\ttarget := params.target\n\t\tif actor == user {\n\t\t\tactor = \"\"\n\t\t}\n\n\t\tif target == user {\n\t\t\ttarget = \"\"\n\t\t}\n\n\t\tglobals.hub.routeSrv <- &ServerComMessage{\n\t\t\tPres: &MsgServerPres{\n\t\t\t\tTopic:     \"me\",\n\t\t\t\tWhat:      what,\n\t\t\t\tSrc:       t.original(uid),\n\t\t\t\tSeqId:     params.seqID,\n\t\t\t\tDelId:     params.delID,\n\t\t\t\tAcs:       params.packAcs(),\n\t\t\t\tAcsActor:  actor,\n\t\t\t\tAcsTarget: target,\n\t\t\t\tUserAgent: params.userAgent,\n\t\t\t\tWantReply: strings.HasPrefix(what, \"?unkn\"),\n\t\t\t\tSkipTopic: skipTopic,\n\t\t\t},\n\t\t\tRcptTo:  user,\n\t\t\tSkipSid: skipSid,\n\t\t}\n\t}\n}\n\n// Announce to a single user on 'me' topic. The originating topic is not used (not loaded or user\n// already unsubscribed).\nfunc presSingleUserOfflineOffline(uid types.Uid, original, what string, params *presParams, skipSid string) {\n\tuser := uid.UserId()\n\tactor := params.actor\n\ttarget := params.target\n\tif actor == user {\n\t\tactor = \"\"\n\t}\n\n\tif target == user {\n\t\ttarget = \"\"\n\t}\n\n\tglobals.hub.routeSrv <- &ServerComMessage{\n\t\tPres: &MsgServerPres{\n\t\t\tTopic:     \"me\",\n\t\t\tWhat:      what,\n\t\t\tSrc:       original,\n\t\t\tSeqId:     params.seqID,\n\t\t\tDelId:     params.delID,\n\t\t\tAcs:       params.packAcs(),\n\t\t\tAcsActor:  actor,\n\t\t\tAcsTarget: target,\n\t\t},\n\t\tRcptTo:  uid.UserId(),\n\t\tSkipSid: skipSid,\n\t}\n}\n\n// Let other sessions of a given user know what messages are now received/read.\n// If both 'read' and 'recv' != 0 then 'read' takes precedence over 'recv'.\n// Cases U\nfunc (t *Topic) presPubMessageCount(uid types.Uid, mode types.AccessMode, read, recv int, skip string) {\n\tvar what string\n\tvar seq int\n\tif read > 0 {\n\t\twhat = \"read\"\n\t\tseq = read\n\t} else if recv > 0 {\n\t\twhat = \"recv\"\n\t\tseq = recv\n\t}\n\n\tif what != \"\" {\n\t\t// Announce to user's other sessions on 'me' only if they are not attached to this topic.\n\t\t// Attached topics will receive an {info}\n\n\t\tt.presSingleUserOffline(uid, mode, what, &presParams{seqID: seq}, skip, true)\n\t}\n}\n\n// Let other sessions of a given user know that messages are now deleted\n// Cases V.1, V.2\nfunc (t *Topic) presPubMessageDelete(uid types.Uid, mode types.AccessMode, delID int, list []MsgRange, skip string) {\n\tif len(list) == 0 && delID <= 0 {\n\t\tlogs.Warn.Printf(\"Case V.1, V.2: topic[%s] invalid request - missing payload\", t.name)\n\t\treturn\n\t}\n\n\t// This check is only needed for V.1, but it does not hurt V.2. Let's do it here for both.\n\tif !t.userIsPresencer(uid) {\n\t\treturn\n\t}\n\n\tparams := &presParams{delID: delID, delSeq: list}\n\n\t// Case V.2\n\tuser := uid.UserId()\n\tt.presSubsOnline(\"del\", user, params, &presFilters{singleUser: user}, skip)\n\n\t// Case V.1\n\tt.presSingleUserOffline(uid, mode, \"del\", params, skip, true)\n}\n\n// Filter by permissions and notification type: check for exceptions,\n// then check if mode.IsPresencer() AND mode has at least some\n// bits specified in 'filter' (or filter is ModeNone).\nfunc presOfflineFilter(mode types.AccessMode, what string, pf *presFilters) bool {\n\tif what == \"acs\" || what == \"gone\" {\n\t\treturn true\n\t}\n\tif what == \"upd\" && mode.IsJoiner() {\n\t\treturn true\n\t}\n\treturn mode.IsPresencer() &&\n\t\t(pf == nil ||\n\t\t\t((pf.filterIn == types.ModeNone || mode&pf.filterIn != 0) &&\n\t\t\t\t(pf.filterOut == types.ModeNone || mode&pf.filterOut == 0)))\n}\n"
  },
  {
    "path": "server/push/common/typedef.go",
    "content": "package common\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"reflect\"\n\t\"strings\"\n\n\t\"github.com/tinode/chat/server/push\"\n\t\"google.golang.org/api/googleapi\"\n)\n\n// Payload to be sent for a specific notification type.\ntype Payload struct {\n\t// Common for APNS and Android\n\tBody         string   `json:\"body,omitempty\"`\n\tTitle        string   `json:\"title,omitempty\"`\n\tTitleLocKey  string   `json:\"title_loc_key,omitempty\"`\n\tTitleLocArgs []string `json:\"title_loc_args,omitempty\"`\n\n\t// Android\n\tBodyLocKey  string   `json:\"body_loc_key,omitempty\"`\n\tBodyLocArgs []string `json:\"body_loc_args,omitempty\"`\n\tIcon        string   `json:\"icon,omitempty\"`\n\tColor       string   `json:\"color,omitempty\"`\n\tClickAction string   `json:\"click_action,omitempty\"`\n\tSound       string   `json:\"sound,omitempty\"`\n\tImage       string   `json:\"image,omitempty\"`\n\n\t// APNS\n\tAction          string   `json:\"action,omitempty\"`\n\tActionLocKey    string   `json:\"action_loc_key,omitempty\"`\n\tLaunchImage     string   `json:\"launch_image,omitempty\"`\n\tLocArgs         []string `json:\"loc_args,omitempty\"`\n\tLocKey          string   `json:\"loc_key,omitempty\"`\n\tSubtitle        string   `json:\"subtitle,omitempty\"`\n\tSummaryArg      string   `json:\"summary_arg,omitempty\"`\n\tSummaryArgCount int      `json:\"summary_arg_count,omitempty\"`\n}\n\n// Config is the configuration of a Notification payload.\ntype Config struct {\n\tEnabled bool `json:\"enabled,omitempty\"`\n\t// Common defaults for all push types.\n\tPayload\n\t// Configs for specific push types.\n\tMsg Payload `json:\"msg,omitempty\"`\n\tSub Payload `json:\"sub,omitempty\"`\n}\n\nfunc (cp Payload) getStringAttr(field string) string {\n\tval := reflect.ValueOf(cp).FieldByName(field)\n\tif !val.IsValid() {\n\t\treturn \"\"\n\t}\n\tif val.Kind() == reflect.String {\n\t\treturn val.String()\n\t}\n\treturn \"\"\n}\n\nfunc (cp Payload) getIntAttr(field string) int {\n\tval := reflect.ValueOf(cp).FieldByName(field)\n\tif !val.IsValid() {\n\t\treturn 0\n\t}\n\tif val.Kind() == reflect.Int {\n\t\treturn int(val.Int())\n\t}\n\treturn 0\n}\n\nfunc (cc *Config) GetStringField(what, field string) string {\n\tvar val string\n\tif what == push.ActMsg {\n\t\tval = cc.Msg.getStringAttr(field)\n\t} else if what == push.ActSub {\n\t\tval = cc.Sub.getStringAttr(field)\n\t}\n\tif val == \"\" {\n\t\tval = cc.Payload.getStringAttr(field)\n\t}\n\treturn val\n}\n\nfunc (cc *Config) GetIntField(what, field string) int {\n\tvar val int\n\tif what == push.ActMsg {\n\t\tval = cc.Msg.getIntAttr(field)\n\t} else if what == push.ActSub {\n\t\tval = cc.Sub.getIntAttr(field)\n\t}\n\tif val == 0 {\n\t\tval = cc.Payload.getIntAttr(field)\n\t}\n\treturn val\n}\n\n// AndroidVisibilityType defines notification visibility constants\n// https://developer.android.com/reference/android/app/Notification.html#visibility\ntype AndroidVisibilityType string\n\nconst (\n\t// AndroidVisibilityUnspecified if unspecified, default to `Visibility.PRIVATE`.\n\tAndroidVisibilityUnspecified AndroidVisibilityType = \"VISIBILITY_UNSPECIFIED\"\n\n\t// AndroidVisibilityPrivate show this notification on all lockscreens, but conceals\n\t// sensitive or private information on secure lockscreens.\n\tAndroidVisibilityPrivate AndroidVisibilityType = \"PRIVATE\"\n\n\t// AndroidVisibilityPublic show this notification in its entirety on all lockscreens.\n\tAndroidVisibilityPublic AndroidVisibilityType = \"PUBLIC\"\n\n\t// AndroidVisibilitySecret do not reveal any part of this notification on a secure lockscreen.\n\tAndroidVisibilitySecret AndroidVisibilityType = \"SECRET\"\n)\n\n// AndroidNotificationPriorityType defines notification priority consumeed by the client\n// after it receives the notification. Does not affect FCM sending.\ntype AndroidNotificationPriorityType string\n\nconst (\n\t// If priority is unspecified, notification priority is set to `PRIORITY_DEFAULT`.\n\tAndroidNotificationPriorityUnspecified AndroidNotificationPriorityType = \"PRIORITY_UNSPECIFIED\"\n\n\t// Lowest notification priority. Notifications with this `PRIORITY_MIN` might not be\n\t// shown to the user except under special circumstances, such as detailed notification logs.\n\tAndroidNotificationPriorityMin AndroidNotificationPriorityType = \"PRIORITY_MIN\"\n\n\t// Lower notification priority. The UI may choose to show the notifications smaller,\n\t// or at a different position in the list, compared with notifications with `PRIORITY_DEFAULT`.\n\tAndroidNotificationPriorityLow AndroidNotificationPriorityType = \"PRIORITY_LOW\"\n\n\t// Default notification priority. If the application does not prioritize its own notifications,\n\t// use this value for all notifications.\n\tAndroidNotificationPriorityDefault AndroidNotificationPriorityType = \"PRIORITY_DEFAULT\"\n\n\t// Higher notification priority. Use this for more important notifications or alerts.\n\t// The UI may choose to show these notifications larger, or at a different position in the notification\n\t// lists, compared with notifications with `PRIORITY_DEFAULT`.\n\tAndroidNotificationPriorityHigh AndroidNotificationPriorityType = \"PRIORITY_HIGH\"\n\n\t// Highest notification priority. Use this for the application's most important items that\n\t// require the user's prompt attention or input.\n\tAndroidNotificationPriorityMax AndroidNotificationPriorityType = \"PRIORITY_MAX\"\n)\n\n// AndroidPriorityType defines the server-side priorities https://goo.gl/GjONJv. It affects how soon\n// FCM sends the push.\ntype AndroidPriorityType string\n\nconst (\n\t// Default priority for data messages. Normal priority messages won't open network\n\t// connections on a sleeping device, and their delivery may be delayed to conserve\n\t// the battery. For less time-sensitive messages, such as notifications of new email\n\t// or other data to sync, choose normal delivery priority.\n\tAndroidPriorityNormal AndroidPriorityType = \"NORMAL\"\n\n\t// Default priority for notification messages. FCM attempts to deliver high priority\n\t// messages immediately, allowing the FCM service to wake a sleeping device when possible\n\t// and open a network connection to your app server. Apps with instant messaging, chat,\n\t// or voice call alerts, for example, generally need to open a network connection and make\n\t// sure FCM delivers the message to the device without delay. Set high priority if the message\n\t// is time-critical and requires the user's immediate interaction, but beware that setting\n\t// your messages to high priority contributes more to battery drain compared with normal priority messages.\n\tAndroidPriorityHigh AndroidPriorityType = \"HIGH\"\n)\n\n// InterruptionLevelType defines the values for the APNS payload.aps.InterruptionLevel.\ntype InterruptionLevelType string\n\nconst (\n\t// InterruptionLevelPassive is used to indicate that notification be delivered in a passive manner.\n\tInterruptionLevelPassive InterruptionLevelType = \"passive\"\n\n\t// InterruptionLevelActive is used to indicate the importance and delivery timing of a notification.\n\tInterruptionLevelActive InterruptionLevelType = \"active\"\n\n\t// InterruptionLevelTimeSensitive is used to indicate the importance and delivery timing of a notification.\n\tInterruptionLevelTimeSensitive InterruptionLevelType = \"time-sensitive\"\n\n\t// InterruptionLevelCritical is used to indicate the importance and delivery timing of a notification.\n\t// This interruption level requires an approved entitlement from Apple.\n\t// See: https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel/\n\tInterruptionLevelCritical InterruptionLevelType = \"critical\"\n)\n\nconst (\n\t// A canonical UUID that identifies the notification. If there is an error sending the notification,\n\t// APNs uses this value to identify the notification to your server.\n\t// The canonical form is 32 lowercase hexadecimal digits, displayed in five groups separated by hyphens\n\t// in the form 8-4-4-4-12. An example UUID is as follows: 123e4567-e89b-12d3-a456-42665544000\n\t// If you omit this header, a new UUID is created by APNs and returned in the response.\n\tHeaderApnsID = \"apns-id\"\n\n\t// A UNIX epoch date expressed in seconds (UTC). This header identifies the date when the notification\n\t// is no longer valid and can be discarded.\n\t// If this value is nonzero, APNs stores the notification and tries to deliver it at least once, repeating\n\t// the attempt as needed if it is unable to deliver the notification the first time. If the value is 0,\n\t// APNs treats the notification as if it expires immediately and does not store the notification or attempt\n\t// to redeliver it.\n\tHeaderApnsExpiration = \"apns-expiration\"\n\n\t// The priority of the notification. Specify one of the following values:\n\t// 10–Send the push message immediately. Notifications with this priority must trigger an alert, sound,\n\t// or badge on the target device. It is an error to use this priority for a push notification that\n\t// contains only the content-available key.\n\t// 5—Send the push message at a time that takes into account power considerations for the device.\n\t// Notifications with this priority might be grouped and delivered in bursts. They are throttled,\n\t// and in some cases are not delivered.\n\t// If you omit this header, the APNs server sets the priority to 10.\n\tHeaderApnsPriority = \"apns-priority\"\n\n\t// The topic of the remote notification, which is typically the bundle ID for your app.\n\t// The certificate you create in your developer account must include the capability for this topic.\n\t// If your certificate includes multiple topics, you must specify a value for this header.\n\t// If you omit this request header and your APNs certificate does not specify multiple topics,\n\t// the APNs server uses the certificate’s Subject as the default topic.\n\t// If you are using a provider token instead of a certificate, you must specify a value for this\n\t// request header. The topic you provide should be provisioned for the your team named in your developer account.\n\tHeaderApnsTopic = \"apns-topic\"\n\n\t// Multiple notifications with the same collapse identifier are displayed to the user as a single notification.\n\t// The value of this key must not exceed 64 bytes. For more information, see Quality of Service,\n\t// Store-and-Forward, and Coalesced Notifications.\n\tHeaderApnsCollapseID = \"apns-collapse-id\"\n\n\t// The value of this header must accurately reflect the contents of your notification’s payload.\n\t// If there’s a mismatch, or if the header is missing on required systems, APNs may return an error,\n\t// delay the delivery of the notification, or drop it altogether.\n\tHeaderApnsPushType = \"apns-push-type\"\n)\n\ntype ApnsPushTypeType string\n\nconst (\n\t// Use the alert push type for notifications that trigger a user interaction—for example, an alert, badge, or sound.\n\t// If you set this push type, the apns-topic header field must use your app’s bundle ID as the topic.\n\t// For more information, see Generating a remote notification.\n\t// If the notification requires immediate action from the user, set notification priority to 10; otherwise use 5.\n\tApnsPushTypeAlert ApnsPushTypeType = \"alert\"\n\n\t// Use the background push type for notifications that deliver content in the background, and don’t trigger any user interactions.\n\t// If you set this push type, the apns-topic header field must use your app’s bundle ID as the topic. Always use priority 5.\n\t// Using priority 10 is an error. For more information, see Pushing Background Updates to Your App.\n\tApnsPushTypeBackground ApnsPushTypeType = \"background\"\n\n\t// Use the location push type for notifications that request a user’s location. If you set this push type,\n\t// the apns-topic header field must use your app’s bundle ID with .location-query appended to the end.\n\t// If the location query requires an immediate response from the Location Push Service Extension, set notification\n\t// apns-priority to 10; otherwise, use 5. The location push type supports only token-based authentication.\n\tApnsPushTypeLocation ApnsPushTypeType = \"location\"\n\n\t// Use the voip push type for notifications that provide information about an incoming Voice-over-IP (VoIP) call.\n\t// For more information, see Responding to VoIP Notifications from PushKit.\n\t// If you set this push type, the apns-topic header field must use your app’s bundle ID with .voip appended to the end.\n\t// If you’re using certificate-based authentication, you must also register the certificate for VoIP services.\n\t// The topic is then part of the 1.2.840.113635.100.6.3.4 or 1.2.840.113635.100.6.3.6 extension.\n\tApnsPushTypeVoip ApnsPushTypeType = \"voip\"\n\n\t// Use the fileprovider push type to signal changes to a File Provider extension. If you set this push type,\n\t// the apns-topic header field must use your app’s bundle ID with .pushkit.fileprovider appended to the end.\n\t// For more information, see Using Push Notifications to Signal Changes.\n\tApnsPushTypeFileprovider ApnsPushTypeType = \"fileprovider\"\n)\n\n// Aps is the APNS payload. See explanation here:\n// https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/generating_a_remote_notification#2943363\ntype Aps struct {\n\tAlert             *ApsAlert             `json:\"alert,omitempty\"`\n\tBadge             int                   `json:\"badge,omitempty\"`\n\tCategory          string                `json:\"category,omitempty\"`\n\tContentAvailable  int                   `json:\"content-available,omitempty\"`\n\tInterruptionLevel InterruptionLevelType `json:\"interruption-level,omitempty\"`\n\tMutableContent    int                   `json:\"mutable-content,omitempty\"`\n\tRelevanceScore    any                   `json:\"relevance-score,omitempty\"`\n\tSound             any                   `json:\"sound,omitempty\"`\n\tThreadID          string                `json:\"thread-id,omitempty\"`\n\tURLArgs           []string              `json:\"url-args,omitempty\"`\n}\n\n// ApsAlert is the content of the aps.Alert field.\ntype ApsAlert struct {\n\tAction          string   `json:\"action,omitempty\"`\n\tActionLocKey    string   `json:\"action-loc-key,omitempty\"`\n\tBody            string   `json:\"body,omitempty\"`\n\tLaunchImage     string   `json:\"launch-image,omitempty\"`\n\tLocArgs         []string `json:\"loc-args,omitempty\"`\n\tLocKey          string   `json:\"loc-key,omitempty\"`\n\tTitle           string   `json:\"title,omitempty\"`\n\tSubtitle        string   `json:\"subtitle,omitempty\"`\n\tTitleLocArgs    []string `json:\"title-loc-args,omitempty\"`\n\tTitleLocKey     string   `json:\"title-loc-key,omitempty\"`\n\tSummaryArg      string   `json:\"summary-arg,omitempty\"`\n\tSummaryArgCount int      `json:\"summary-arg-count,omitempty\"`\n}\n\n// FCM error codes\nconst (\n\t// No more information is available about this error.\n\tErrorUnspecified = \"UNSPECIFIED_ERROR\"\n\n\t// Request parameters were invalid (HTTP error code = 400). An extension of type google.rpc.BadRequest is returned\n\t// to specify which field was invalid.\n\t// Potential causes:\n\t// - Invalid registration: Check the format of the registration token you pass to the server. Make sure it matches\n\t//   the registration token the client app receives from registering with Firebase Notifications.\n\t//   Do not truncate or add additional characters.\n\t// - Invalid package name: Make sure the message was addressed to a registration token whose package name matches\n\t//   the value passed in the request.\n\t// - Message too big: Check that the total size of the payload data included in a message does not exceed FCM limits:\n\t//   4096 bytes for most messages, or 2048 bytes in the case of messages to topics. This includes both the keys and the values.\n\t// - Invalid data key: Check that the payload data does not contain a key (such as from, or gcm, or any value prefixed\n\t//   by google) that is used internally by FCM. Note that some words (such as collapse_key) are also used by FCM but are\n\t//   allowed in the payload, in which case the payload value will be overridden by the FCM value.\n\t// - Invalid TTL: Check that the value used in ttl is an integer representing a duration in seconds between 0 and\n\t//   2,419,200 (4 weeks).\n\t// - Invalid parameters: Check that the provided parameters have the right name and type.\n\tErrorInvalidArgument = \"INVALID_ARGUMENT\"\n\n\t// App instance was unregistered from FCM (HTTP error code = 404). This usually means that the token used is no\n\t// longer valid and a new one must be used.\n\t// This error can be caused by missing registration tokens, or unregistered tokens.\n\t// - Missing Registration: If the message's target is a token value, check that the request contains a registration token.\n\t// - Not registered: An existing registration token may cease to be valid in a number of scenarios, including:\n\t//   - If the client app unregisters with FCM.\n\t//   - If the client app is automatically unregistered, which can happen if the user uninstalls the application.\n\t// \t\t For example, on iOS, if the APNS Feedback Service reported the APNS token as invalid.\n\t//   - If the registration token expires (for example, Google might decide to refresh registration tokens,\n\t//\t   or the APNS token has expired for iOS devices).\n\t//   - If the client app is updated but the new version is not configured to receive messages.\n\t// For all these cases, remove this registration token from the app server and stop using it to send messages.\n\tErrorUnregistered = \"UNREGISTERED\"\n\n\t// The authenticated sender ID is different from the sender ID for the registration token (HTTP error code = 403).\n\t// A registration token is tied to a certain group of senders. When a client app registers for FCM, it must specify\n\t// which senders are allowed to send messages. You should use one of those sender IDs when sending messages to\n\t// the client app. If you switch to a different sender, the existing registration tokens won't work.\n\tErrorSenderIDMismatch = \"SENDER_ID_MISMATCH\"\n\n\t// Sending limit exceeded for the message target (HTTP error code = 429). An extension of type google.rpc.QuotaFailure\n\t// is returned to specify which quota got exceeded.\tThis error can be caused by exceeded message rate quota,\n\t// exceeded device message rate quota, or exceeded topic message rate quota.\n\t// - Message rate exceeded: The sending rate of messages is too high. Reduce the number of messages sent and use\n\t//   exponential backoff to retry sending.\n\t// - Device message rate exceeded: The rate of messages to a particular device is too high. If an iOS app sends\n\t//   messages at a rate exceeding APNs limits, it may receive this error message. Reduce the number of messages\n\t//   sent to this device and use exponential backoff to retry sending.\n\t// - Topic message rate exceeded: The rate of messages to subscribers to a particular topic is too high.\n\t//   Reduce the number of messages sent for this topic and use exponential backoff to retry sending.\n\tErrorQuotaExceeded = \"QUOTA_EXCEEDED\"\n\n\t// The server is overloaded (HTTP error code = 503). The server couldn't process the request in time. Retry the\n\t// same request, but you must:\n\t// - Honor the Retry-After header if it is included in the response from the FCM Connection Server.\n\t// - Implement exponential back-off in your retry mechanism. (e.g. if you waited one second before the first retry,\n\t//   wait at least two second before the next one, then 4 seconds and so on). If you're sending multiple messages,\n\t//   delay each one independently by an additional random amount to avoid issuing a new request for all messages\n\t//   at the same time. Senders that cause problems risk being denylisted.\n\tErrorUnavailable = \"UNAVAILABLE\"\n\n\t// An unknown internal error occurred (HTTP error code = 500). The server encountered an error while trying to process\n\t// the request. You could retry the same request following the requirements listed in \"Timeout\" (see row above).\n\t// If the error persists, please contact Firebase support.\n\tErrorInternal = \"INTERNAL\"\n\n\t// APNs certificate or web push auth key was invalid or missing (HTTP error code = 401). A message targeted to an\n\t// iOS device or a web push registration could not be sent. Check the validity of your development and production\n\t// credentials.\n\tErrorThirdPartyAuth = \"THIRD_PARTY_AUTH_ERROR\"\n)\n\n// APNS error messages\nconst (\n\t// The collapse identifier exceeds the maximum allowed size (HTTP error code = 400).\n\tErrorApnsBadCollapseId = \"BadCollapseId\"\n\n\t// The specified device token was bad. Verify that the request contains a valid token and that the\n\t// token matches the environment (HTTP error code = 400).\n\tErrorApnsBadDeviceToken = \"BadDeviceToken\"\n\n\t// The apns-expiration value is bad (HTTP error code = 400).\n\tErrorApnsBadExpirationDate = \"BadExpirationDate\"\n\n\t// The apns-id value is bad (HTTP error code = 400).\n\tErrorApnsBadMessageId = \"BadMessageId\"\n\n\t// The apns-priority value is bad (HTTP error code = 400).\n\tErrorApnsBadPriority = \"BadPriority\"\n\n\t// The apns-topic was invalid (HTTP error code = 400).\n\tErrorApnsBadTopic = \"BadTopic\"\n\n\t// The device token does not match the specified topic (HTTP error code = 400).\n\tErrorApnsDeviceTokenNotForTopic = \"DeviceTokenNotForTopic\"\n\n\t// One or more headers were repeated (HTTP error code = 400).\n\tErrorApnsDuplicateHeaders = \"DuplicateHeaders\"\n\n\t// Idle time out (HTTP error code = 400).\n\tErrorApnsIdleTimeout = \"IdleTimeout\"\n\n\t// The device token is not specified in the request :path. Verify that the :path header\n\t// contains the device token (HTTP error code = 400).\n\tErrorApnsMissingDeviceToken = \"MissingDeviceToken\"\n\n\t// The apns-topic header of the request was not specified and was required.\n\t// The apns-topic header is mandatory when the client is connected using a certificate\n\t// that supports multiple topics (HTTP error code = 400).\n\tErrorApnsMissingTopic = \"MissingTopic\"\n\n\t// The message payload was empty (HTTP error code = 400).\n\tErrorApnsPayloadEmpty = \"PayloadEmpty\"\n\n\t// Pushing to this topic is not allowed (HTTP error code = 400).\n\tErrorApnsTopicDisallowed = \"TopicDisallowed\"\n\n\t// The certificate was bad (HTTP error code = 403).\n\tErrorApnsBadCertificate = \"BadCertificate\"\n\n\t// The client certificate was for the wrong environment (HTTP error code = 403).\n\tErrorApnsBadCertificateEnvironment = \"BadCertificateEnvironment\"\n\n\t// The provider token is stale and a new token should be generated (HTTP error code = 403).\n\tErrorApnsExpiredProviderToken = \"ExpiredProviderToken\"\n\n\t// The specified action is not allowed (HTTP error code = 403).\n\tErrorApnsForbidden = \"Forbidden\"\n\n\t// The provider token is not valid or the token signature could not be verified (HTTP error code = 403).\n\tErrorApnsInvalidProviderToken = \"InvalidProviderToken\"\n\n\t// No provider certificate was used to connect to APNs and Authorization header was missing\n\t// or no provider token was specified (HTTP error code = 403).\n\tErrorApnsMissingProviderToken = \"MissingProviderToken\"\n\n\t// The request contained a bad :path value (HTTP error code = 404).\n\tErrorApnsBadPath = \"BadPath\"\n\n\t// The specified :method was not POST (HTTP error code = 405).\n\tErrorApnsMethodNotAllowed = \"MethodNotAllowed\"\n\n\t// The device token is inactive for the specified topic (HTTP error code = 410).\n\tErrorApnsUnregistered = \"Unregistered\"\n\n\t// The message payload was too large. See Creating the Remote Notification Payload\n\t// for details on maximum payload size (HTTP error code = 413).\n\tErrorApnsPayloadTooLarge = \"PayloadTooLarge\"\n\n\t// The provider token is being updated too often (HTTP error code = 429).\n\tErrorApnsTooManyProviderTokenUpdates = \"TooManyProviderTokenUpdates\"\n\n\t// Too many requests were made consecutively to the same device token (HTTP error code = 429).\n\tErrorApnsTooManyRequests = \"TooManyRequests\"\n\n\t// An internal server error occurred (HTTP error code = 500).\n\tErrorApnsInternalServerError = \"InternalServerError\"\n\n\t// The service is unavailable (HTTP error code = 503).\n\tErrorApnsServiceUnavailable = \"ServiceUnavailable\"\n\n\t// The server is shutting down (HTTP error code = 503).\n\tErrorApnsShutdown = \"Shutdown\"\n)\n\n// GApiError stores a simplified representation of an error returned by a call to Google API.\ntype GApiError struct {\n\tHttpCode int\n\t// This one is not informative, but can be logged for user consideration.\n\tErrMessage string\n\t// FCM error code, informative but may be missing.\n\tFcmErrCode string\n\t// Extended error info dependent on the fcmErrCode.\n\tExtendedInfo string\n}\n\n// DecodeGoogleApiError converts very complex googleapi.Error to a bit more manageable structure.\nfunc DecodeGoogleApiError(err error) (decoded *GApiError, errs []error) {\n\tdecoded = &GApiError{}\n\tif gerr, ok := err.(*googleapi.Error); ok {\n\t\t// HTTP status code.\n\t\tdecoded.HttpCode = gerr.Code\n\t\tdecoded.ErrMessage = gerr.Message\n\t\tfor _, errInfo := range gerr.Errors {\n\t\t\tdecoded.ErrMessage += \"; \" + errInfo.Reason + \"/\" + errInfo.Message\n\t\t}\n\n\t\t// Decode the FCM error.\n\t\tfor _, iface := range gerr.Details {\n\t\t\tdetails, ok := iface.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\terrs = append(errs, fmt.Errorf(\"error.Details unrecognized format %T\", iface))\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch details[\"@type\"] {\n\t\t\tcase \"type.googleapis.com/google.firebase.fcm.v1.FcmError\":\n\t\t\t\tif errCode, ok := details[\"errorCode\"].(string); ok {\n\t\t\t\t\tif decoded.FcmErrCode != \"\" {\n\t\t\t\t\t\t// This has not been observed but FCM is uncler if it can happen.\n\t\t\t\t\t\terrs = append(errs, fmt.Errorf(\"multiple FcmError codes '%s', '%s'\", errCode, decoded.FcmErrCode))\n\t\t\t\t\t} else {\n\t\t\t\t\t\tdecoded.FcmErrCode = errCode\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\terrs = append(errs, fmt.Errorf(\"error.Details errorCode is not a string: %T\", details[\"errorCode\"]))\n\t\t\t\t}\n\t\t\tcase \"type.googleapis.com/google.rpc.BadRequest\":\n\t\t\t\t// dst.fcmErrCode == INVALID_ARGUMENT\n\t\t\t\tif fieldViolations, ok := details[\"fieldViolations\"].([]any); !ok {\n\t\t\t\t\terrs = append(errs, fmt.Errorf(\"wrong type of error.Details 'fieldViolations': %T\", details[\"fieldViolations\"]))\n\t\t\t\t} else {\n\t\t\t\t\tvar fields []string\n\t\t\t\t\tfor _, violationIface := range fieldViolations {\n\t\t\t\t\t\tif violation, ok := violationIface.(map[string]any); !ok {\n\t\t\t\t\t\t\terrs = append(errs, fmt.Errorf(\"wrong type of error.Details.fieldViolations item: %T\", iface))\n\t\t\t\t\t\t} else if field, ok := violation[\"field\"].(string); ok && field != \"\" {\n\t\t\t\t\t\t\tfields = append(fields, field)\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\terrs = append(errs, fmt.Errorf(\"error.Details 'fieldViolation' has no 'field': %T, %s\", violation[\"field\"], violation[\"description\"]))\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tdecoded.ExtendedInfo = strings.Join(fields, \",\")\n\t\t\t\t}\n\t\t\tcase \"type.googleapis.com/google.rpc.QuotaFailure\":\n\t\t\t\t// dst.fcmErrCode == QUOTA_EXCEEDED\n\t\t\t\t// TODO: this error has not been observed, don't know how to handle it.\n\t\t\t\terrs = append(errs, fmt.Errorf(\"quota exceeded %v\", details))\n\t\t\tdefault:\n\t\t\t\terrs = append(errs, fmt.Errorf(\"unknown error '@type': %v\", details))\n\t\t\t}\n\t\t}\n\t} else {\n\t\tdecoded.HttpCode = http.StatusBadRequest\n\t\tdecoded.ErrMessage = err.Error()\n\t\terrs = append(errs, fmt.Errorf(\"not googleapi.Error %w\", err))\n\t}\n\n\tif decoded.FcmErrCode == \"\" {\n\t\tdecoded.FcmErrCode = string(ErrorUnspecified)\n\t}\n\n\treturn\n}\n"
  },
  {
    "path": "server/push/fcm/README.md",
    "content": "# FCM push adapter\n\nThis adapter sends push notifications to mobile clients and web browsers using [Google FCM](https://firebase.google.com/docs/cloud-messaging/). As of the time of this writing it supports Android with [Play Services](https://developers.google.com/android/guides/overview), iOS devices, and all major web browsers [excluding Safari](https://caniuse.com/#feat=push-api).\n\nThis adapter requires you to obtain your own credentials from Goole Firebase. If you want to use iOS and Android mobile apps with your service, they must be recompiled with your credentials obtained from Google. If you do not want to recompile mobile clients, consider using TNPG adapter instead.\n\n\n## Configuring FCM adapter\n\n### Server and TinodeWeb\n\n1. Create a project at https://firebase.google.com/ if you have not done so already.\n2. Follow instructions at https://cloud.google.com/iam/docs/creating-managing-service-account-keys to download the credentials file.\n3. Update the server config [`tinode.conf`](../server/tinode.conf#L255), section `\"push\"` -> `\"name\": \"fcm\"`. Do _ONE_ of the following:\n  * _Either_ enter the path to the downloaded credentials file into `\"credentials_file\"`.\n  * _OR_ copy the file contents to `\"credentials\"`.<br/><br/>\n    Remove the other entry. I.e. if you have updated `\"credentials_file\"`, remove `\"credentials\"` and vice versa.\n4. 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\n\n### iOS and Android\n\n1. If you are using an Android client, add `google-services.json` to [Tindroid](/tinode/tindroid/) by following instructions at https://developers.google.com/android/guides/google-services-plugin and recompile the client. You may also optionally submit it to Google Play Store.\nSee more info at https://github.com/tinode/tindroid/#push_notifications\n2. If you are using an iOS client, add `GoogleService-Info.plist` to [Tinodios](/tinode/ios/) by following instructions at https://firebase.google.com/docs/cloud-messaging/ios/client) and recompile the client. You may optionally submit the app to Apple AppStore.\nSee more info at https://github.com/tinode/ios/#push_notifications\n"
  },
  {
    "path": "server/push/fcm/payload.go",
    "content": "package fcm\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"strconv\"\n\t\"time\"\n\n\tfcmv1 \"google.golang.org/api/fcm/v1\"\n\n\t\"github.com/tinode/chat/server/drafty\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/push\"\n\t\"github.com/tinode/chat/server/push/common\"\n\t\"github.com/tinode/chat/server/store\"\n\tt \"github.com/tinode/chat/server/store/types\"\n\t\"maps\"\n)\n\nconst (\n\t// TTL of a VOIP push notification in seconds.\n\tvoipTimeToLive = 10\n\t// TTL of a regular push notification in seconds.\n\tdefaultTimeToLive = 3600\n)\n\nfunc payloadToData(pl *push.Payload) (map[string]string, error) {\n\tif pl == nil {\n\t\treturn nil, errors.New(\"empty push payload\")\n\t}\n\tdata := make(map[string]string)\n\tvar err error\n\tdata[\"what\"] = pl.What\n\tif pl.Silent {\n\t\tdata[\"silent\"] = \"true\"\n\t}\n\tdata[\"topic\"] = pl.Topic\n\tdata[\"ts\"] = pl.Timestamp.Format(time.RFC3339Nano)\n\t// Must use \"xfrom\" because \"from\" is a reserved word. Google did not bother to document it anywhere.\n\tdata[\"xfrom\"] = pl.From\n\tif pl.What == push.ActMsg {\n\t\tdata[\"seq\"] = strconv.Itoa(pl.SeqId)\n\t\tif pl.ContentType != \"\" {\n\t\t\tdata[\"mime\"] = pl.ContentType\n\t\t}\n\n\t\t// Convert Drafty content to plain text (clients 0.16 and below).\n\t\tdata[\"content\"], err = drafty.PlainText(pl.Content)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\t// Trim long strings to 128 runes.\n\t\t// Check byte length first and don't waste time converting short strings.\n\t\tif len(data[\"content\"]) > push.MaxPayloadLength {\n\t\t\trunes := []rune(data[\"content\"])\n\t\t\tif len(runes) > push.MaxPayloadLength {\n\t\t\t\tdata[\"content\"] = string(runes[:push.MaxPayloadLength]) + \"…\"\n\t\t\t}\n\t\t}\n\n\t\t// Rich content for clients version 0.17 and above.\n\t\tdata[\"rc\"], err = drafty.Preview(pl.Content, push.MaxPayloadLength)\n\n\t\tif pl.Webrtc != \"\" {\n\t\t\tdata[\"webrtc\"] = pl.Webrtc\n\t\t\tif pl.AudioOnly {\n\t\t\t\tdata[\"aonly\"] = \"true\"\n\t\t\t}\n\t\t\t// Video call push notifications are silent.\n\t\t\tdata[\"silent\"] = \"true\"\n\t\t}\n\t\tif pl.Replace != \"\" {\n\t\t\t// Notification of a message edit should be silent too.\n\t\t\tdata[\"silent\"] = \"true\"\n\t\t\tdata[\"replace\"] = pl.Replace\n\t\t}\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t} else if pl.What == push.ActSub {\n\t\tdata[\"modeWant\"] = pl.ModeWant.String()\n\t\tdata[\"modeGiven\"] = pl.ModeGiven.String()\n\t} else if pl.What == push.ActRead {\n\t\tdata[\"seq\"] = strconv.Itoa(pl.SeqId)\n\t\tdata[\"silent\"] = \"true\"\n\t} else {\n\t\treturn nil, errors.New(\"unknown push type\")\n\t}\n\treturn data, nil\n}\n\nfunc clonePayload(src map[string]string) map[string]string {\n\tdst := make(map[string]string, len(src))\n\tmaps.Copy(dst, src)\n\treturn dst\n}\n\n// PrepareV1Notifications creates notification payloads ready to be posted\n// to push notification server for the provided receipt.\nfunc PrepareV1Notifications(rcpt *push.Receipt, config *configType) ([]*fcmv1.Message, []t.Uid) {\n\tdata, err := payloadToData(&rcpt.Payload)\n\tif err != nil {\n\t\tlogs.Warn.Println(\"fcm push: could not parse payload:\", err)\n\t\treturn nil, nil\n\t}\n\n\t// Device IDs to send pushes to.\n\tvar devices map[t.Uid][]t.DeviceDef\n\t// Count of device IDs to push to.\n\tvar count int\n\t// Devices which were online in the topic when the message was sent.\n\tskipDevices := make(map[string]struct{})\n\tif len(rcpt.To) > 0 {\n\t\t// List of UIDs for querying the database\n\n\t\tuids := make([]t.Uid, len(rcpt.To))\n\t\ti := 0\n\t\tfor uid, to := range rcpt.To {\n\t\t\tuids[i] = uid\n\t\t\ti++\n\t\t\t// Some devices were online and received the message. Skip them.\n\t\t\tfor _, deviceID := range to.Devices {\n\t\t\t\tskipDevices[deviceID] = struct{}{}\n\t\t\t}\n\t\t}\n\t\tdevices, count, err = store.Devices.GetAll(uids...)\n\t\tif err != nil {\n\t\t\tlogs.Warn.Println(\"fcm push: db error\", err)\n\t\t\treturn nil, nil\n\t\t}\n\t}\n\tif count == 0 && rcpt.Channel == \"\" {\n\t\treturn nil, nil\n\t}\n\n\tif config == nil {\n\t\t// config is nil when called from tnpg adapter; provide a blank one for simplicity.\n\t\tconfig = &configType{}\n\t}\n\n\tvar messages []*fcmv1.Message\n\tvar uids []t.Uid\n\tfor uid, devList := range devices {\n\t\ttopic := rcpt.Payload.Topic\n\t\tuserData := data\n\t\ttcat := t.GetTopicCat(topic)\n\t\tif rcpt.To[uid].Delivered > 0 || tcat == t.TopicCatP2P {\n\t\t\tuserData = clonePayload(data)\n\t\t\t// Fix topic name for P2P pushes.\n\t\t\tif tcat == t.TopicCatP2P {\n\t\t\t\ttopic, _ = t.P2PNameForUser(uid, topic)\n\t\t\t\tuserData[\"topic\"] = topic\n\t\t\t}\n\t\t\t// Silence the push for user who have received the data interactively.\n\t\t\tif rcpt.To[uid].Delivered > 0 {\n\t\t\t\tuserData[\"silent\"] = \"true\"\n\t\t\t}\n\t\t}\n\n\t\tfor i := range devList {\n\t\t\td := &devList[i]\n\t\t\tif _, ok := skipDevices[d.DeviceId]; !ok && d.DeviceId != \"\" {\n\t\t\t\tmsg := fcmv1.Message{\n\t\t\t\t\tToken: d.DeviceId,\n\t\t\t\t\tData:  userData,\n\t\t\t\t}\n\n\t\t\t\tswitch d.Platform {\n\t\t\t\tcase \"android\":\n\t\t\t\t\tmsg.Android = androidNotificationConfig(rcpt.Payload.What, topic, userData, config)\n\t\t\t\tcase \"ios\":\n\t\t\t\t\tmsg.Apns = apnsNotificationConfig(rcpt.Payload.What, topic, userData, rcpt.To[uid].Unread, config)\n\t\t\t\tcase \"web\":\n\t\t\t\t\tif config != nil && config.Webpush != nil && config.Webpush.Enabled {\n\t\t\t\t\t\tmsg.Webpush = &fcmv1.WebpushConfig{}\n\t\t\t\t\t}\n\t\t\t\tcase \"\":\n\t\t\t\t\t// ignore\n\t\t\t\tdefault:\n\t\t\t\t\tlogs.Warn.Println(\"fcm: unknown device platform\", d.Platform)\n\t\t\t\t}\n\n\t\t\t\tuids = append(uids, uid)\n\t\t\t\tmessages = append(messages, &msg)\n\t\t\t}\n\t\t}\n\t}\n\n\tif rcpt.Channel != \"\" {\n\t\ttopic := rcpt.Channel\n\t\tuserData := clonePayload(data)\n\t\tuserData[\"topic\"] = topic\n\t\t// Channel receiver should not know the ID of the message sender.\n\t\tdelete(userData, \"xfrom\")\n\t\tmsg := fcmv1.Message{\n\t\t\tTopic: topic,\n\t\t\tData:  userData,\n\t\t}\n\n\t\t// We don't know the platform of the receiver, must provide payload for all platforms.\n\t\tmsg.Android = androidNotificationConfig(rcpt.Payload.What, topic, userData, config)\n\t\tmsg.Apns = apnsNotificationConfig(rcpt.Payload.What, topic, userData, 0, config)\n\t\t// TODO: add webpush payload.\n\t\tmessages = append(messages, &msg)\n\t\t// UID is not used in handling Topic pushes, but should keep the same count as messages.\n\t\tuids = append(uids, t.ZeroUid)\n\t}\n\n\treturn messages, uids\n}\n\n// DevicesForUser loads device IDs of the given user.\nfunc DevicesForUser(uid t.Uid) []string {\n\tddef, count, err := store.Devices.GetAll(uid)\n\tif err != nil {\n\t\tlogs.Warn.Println(\"fcm devices for user: db error\", err)\n\t\treturn nil\n\t}\n\n\tif count == 0 {\n\t\treturn nil\n\t}\n\n\tdevices := make([]string, count)\n\tfor i, dd := range ddef[uid] {\n\t\tdevices[i] = dd.DeviceId\n\t}\n\treturn devices\n}\n\n// ChannelsForUser loads user's channel subscriptions with P permission.\nfunc ChannelsForUser(uid t.Uid) []string {\n\tchannels, err := store.Users.GetChannels(uid)\n\tif err != nil {\n\t\tlogs.Warn.Println(\"fcm channels for user: db error\", err)\n\t\treturn nil\n\t}\n\treturn channels\n}\n\nfunc androidNotificationConfig(what, topic string, data map[string]string, config *configType) *fcmv1.AndroidConfig {\n\ttimeToLive := strconv.Itoa(defaultTimeToLive) + \"s\"\n\tif config != nil && config.TimeToLive > 0 {\n\t\ttimeToLive = strconv.Itoa(config.TimeToLive) + \"s\"\n\t}\n\n\tif what == push.ActRead {\n\t\treturn &fcmv1.AndroidConfig{\n\t\t\tPriority:     string(common.AndroidPriorityNormal),\n\t\t\tNotification: nil,\n\t\t\tTtl:          timeToLive,\n\t\t}\n\t}\n\n\t_, videoCall := data[\"webrtc\"]\n\tif videoCall {\n\t\ttimeToLive = \"0s\"\n\t}\n\n\t// Sending priority.\n\tpriority := string(common.AndroidPriorityHigh)\n\tac := &fcmv1.AndroidConfig{\n\t\tPriority: priority,\n\t\tTtl:      timeToLive,\n\t}\n\n\t// When this notification type is included and the app is not in the foreground\n\t// Android won't wake up the app and won't call FirebaseMessagingService:onMessageReceived.\n\t// See dicussion: https://github.com/firebase/quickstart-js/issues/71\n\tif config.Android == nil || !config.Android.Enabled {\n\t\treturn ac\n\t}\n\n\tbody := config.Android.GetStringField(what, \"Body\")\n\tif body == \"$content\" {\n\t\tbody = data[\"content\"]\n\t}\n\n\t// Client-side display priority.\n\tpriority = string(common.AndroidNotificationPriorityHigh)\n\tif videoCall {\n\t\tpriority = string(common.AndroidNotificationPriorityMax)\n\t}\n\n\tac.Notification = &fcmv1.AndroidNotification{\n\t\t// Android uses Tag value to group notifications together:\n\t\t// show just one notification per topic.\n\t\tTag:                  topic,\n\t\tNotificationPriority: priority,\n\t\tVisibility:           string(common.AndroidVisibilityPrivate),\n\t\tTitleLocKey:          config.Android.GetStringField(what, \"TitleLocKey\"),\n\t\tTitle:                config.Android.GetStringField(what, \"Title\"),\n\t\tBodyLocKey:           config.Android.GetStringField(what, \"BodyLocKey\"),\n\t\tBody:                 body,\n\t\tIcon:                 config.Android.GetStringField(what, \"Icon\"),\n\t\tColor:                config.Android.GetStringField(what, \"Color\"),\n\t\tClickAction:          config.Android.GetStringField(what, \"ClickAction\"),\n\t}\n\n\treturn ac\n}\n\nfunc apnsShouldPresentAlert(what, callStatus, isSilent string, config *configType) bool {\n\treturn config.Apns != nil && config.Apns.Enabled && what != push.ActRead && callStatus == \"\" && isSilent == \"\"\n}\n\nfunc apnsNotificationConfig(what, topic string, data map[string]string, unread int, config *configType) *fcmv1.ApnsConfig {\n\tcallStatus := data[\"webrtc\"]\n\texpires := time.Now().UTC().Add(time.Duration(defaultTimeToLive) * time.Second)\n\tif config.TimeToLive > 0 {\n\t\texpires = time.Now().UTC().Add(time.Duration(config.TimeToLive) * time.Second)\n\t}\n\tbundleId := config.ApnsBundleID\n\tpushType := common.ApnsPushTypeAlert\n\tpriority := 10\n\tinterruptionLevel := common.InterruptionLevelTimeSensitive\n\tif callStatus == \"started\" {\n\t\t// Send VOIP push only when a new call is started, otherwise send normal alert.\n\t\tinterruptionLevel = common.InterruptionLevelCritical\n\t\t// FIXME: PushKit notifications do not work with the current FCM adapter.\n\t\t// Using normal pushes as a poor-man's replacement for VOIP pushes.\n\t\t// Uncomment the following two lines when FCM fixes its problem or when we switch to\n\t\t// a different adapter.\n\t\t// pushType = common.ApnsPushTypeVoip\n\t\t// bundleId += \".voip\"\n\t\texpires = time.Now().UTC().Add(time.Duration(voipTimeToLive) * time.Second)\n\t} else if what == push.ActRead {\n\t\tpriority = 5\n\t\tinterruptionLevel = common.InterruptionLevelPassive\n\t\tpushType = common.ApnsPushTypeBackground\n\t}\n\n\tapsPayload := common.Aps{\n\t\tBadge:             unread,\n\t\tContentAvailable:  1,\n\t\tMutableContent:    1,\n\t\tInterruptionLevel: interruptionLevel,\n\t\tSound:             \"default\",\n\t\tThreadID:          topic,\n\t}\n\n\t// Do not present alert for read notifications and video calls.\n\tif apnsShouldPresentAlert(what, callStatus, data[\"silent\"], config) {\n\t\tbody := config.Apns.GetStringField(what, \"Body\")\n\t\tif body == \"$content\" {\n\t\t\tbody = data[\"content\"]\n\t\t}\n\n\t\tapsPayload.Alert = &common.ApsAlert{\n\t\t\tAction:          config.Apns.GetStringField(what, \"Action\"),\n\t\t\tActionLocKey:    config.Apns.GetStringField(what, \"ActionLocKey\"),\n\t\t\tBody:            body,\n\t\t\tLaunchImage:     config.Apns.GetStringField(what, \"LaunchImage\"),\n\t\t\tLocKey:          config.Apns.GetStringField(what, \"LocKey\"),\n\t\t\tTitle:           config.Apns.GetStringField(what, \"Title\"),\n\t\t\tSubtitle:        config.Apns.GetStringField(what, \"Subtitle\"),\n\t\t\tTitleLocKey:     config.Apns.GetStringField(what, \"TitleLocKey\"),\n\t\t\tSummaryArg:      config.Apns.GetStringField(what, \"SummaryArg\"),\n\t\t\tSummaryArgCount: config.Apns.GetIntField(what, \"SummaryArgCount\"),\n\t\t}\n\t}\n\n\tpayload, err := json.Marshal(map[string]any{\"aps\": apsPayload})\n\tif err != nil {\n\t\treturn nil\n\t}\n\theaders := map[string]string{\n\t\tcommon.HeaderApnsExpiration: strconv.FormatInt(expires.Unix(), 10),\n\t\tcommon.HeaderApnsPriority:   strconv.Itoa(priority),\n\t\tcommon.HeaderApnsTopic:      bundleId,\n\t\tcommon.HeaderApnsCollapseID: topic,\n\t\tcommon.HeaderApnsPushType:   string(pushType),\n\t}\n\n\tac := &fcmv1.ApnsConfig{\n\t\tHeaders: headers,\n\t\tPayload: payload,\n\t}\n\n\treturn ac\n}\n"
  },
  {
    "path": "server/push/fcm/push_fcm.go",
    "content": "// Package fcm implements push notification plugin for Google FCM backend.\n// Push notifications for Android, iOS and web clients are sent through Google's Firebase Cloud Messaging service.\n// Package fcm is push notification plugin using Google FCM.\n// https://firebase.google.com/docs/cloud-messaging\npackage fcm\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"os\"\n\t\"strings\"\n\n\tfbase \"firebase.google.com/go\"\n\tlegacy \"firebase.google.com/go/messaging\"\n\tfcmv1 \"google.golang.org/api/fcm/v1\"\n\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/push\"\n\t\"github.com/tinode/chat/server/push/common\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/types\"\n\n\t\"golang.org/x/oauth2/google\"\n\t\"google.golang.org/api/option\"\n)\n\nvar handler Handler\n\nconst (\n\t// Size of the input channel buffer.\n\tbufferSize = 1024\n\n\t// The number of push messages sent in one batch. FCM constant.\n\tpushBatchSize = 100\n\n\t// The number of sub/unsub requests sent in one batch. FCM constant.\n\tsubBatchSize = 1000\n)\n\n// Handler represents the push handler; implements push.PushHandler interface.\ntype Handler struct {\n\tinput     chan *push.Receipt\n\tchannel   chan *push.ChannelReq\n\tstop      chan bool\n\tprojectID string\n\n\tclient *legacy.Client\n\tv1     *fcmv1.Service\n}\n\ntype configType struct {\n\tEnabled         bool            `json:\"enabled\"`\n\tDryRun          bool            `json:\"dry_run\"`\n\tCredentials     json.RawMessage `json:\"credentials\"`\n\tCredentialsFile string          `json:\"credentials_file\"`\n\tTimeToLive      int             `json:\"time_to_live,omitempty\"`\n\tApnsBundleID    string          `json:\"apns_bundle_id,omitempty\"`\n\tAndroid         *common.Config  `json:\"android,omitempty\"`\n\tApns            *common.Config  `json:\"apns,omitempty\"`\n\tWebpush         *common.Config  `json:\"webpush,omitempty\"`\n}\n\n// Init initializes the push handler\nfunc (Handler) Init(jsonconf json.RawMessage) (bool, error) {\n\n\tvar config configType\n\terr := json.Unmarshal([]byte(jsonconf), &config)\n\tif err != nil {\n\t\treturn false, errors.New(\"failed to parse config: \" + err.Error())\n\t}\n\n\tif !config.Enabled {\n\t\treturn false, nil\n\t}\n\n\tif config.Credentials == nil && config.CredentialsFile != \"\" {\n\t\tconfig.Credentials, err = os.ReadFile(config.CredentialsFile)\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\n\tif config.Credentials == nil {\n\t\treturn false, errors.New(\"missing credentials\")\n\t}\n\n\tctx := context.Background()\n\tcredentials, err := google.CredentialsFromJSON(ctx, config.Credentials, \"https://www.googleapis.com/auth/firebase.messaging\")\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif credentials.ProjectID == \"\" {\n\t\treturn false, errors.New(\"missing project ID\")\n\t}\n\n\tapp, err := fbase.NewApp(ctx, &fbase.Config{}, option.WithCredentials(credentials))\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\thandler.client, err = app.Messaging(ctx)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\thandler.v1, err = fcmv1.NewService(ctx, option.WithCredentials(credentials), option.WithScopes(fcmv1.FirebaseMessagingScope))\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\thandler.input = make(chan *push.Receipt, bufferSize)\n\thandler.channel = make(chan *push.ChannelReq, bufferSize)\n\thandler.stop = make(chan bool, 1)\n\thandler.projectID = credentials.ProjectID\n\n\tgo func() {\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase rcpt := <-handler.input:\n\t\t\t\tgo sendFcmV1(rcpt, &config)\n\t\t\tcase sub := <-handler.channel:\n\t\t\t\tgo processSubscription(sub)\n\t\t\tcase <-handler.stop:\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn true, nil\n}\n\nfunc sendFcmV1(rcpt *push.Receipt, config *configType) {\n\tmessages, uids := PrepareV1Notifications(rcpt, config)\n\tfor i := range messages {\n\t\treq := &fcmv1.SendMessageRequest{\n\t\t\tMessage:      messages[i],\n\t\t\tValidateOnly: config.DryRun,\n\t\t}\n\t\t_, err := handler.v1.Projects.Messages.Send(\"projects/\"+handler.projectID, req).Do()\n\t\tif err != nil {\n\t\t\tgerr, decodingErrs := common.DecodeGoogleApiError(err)\n\t\t\tfor _, err := range decodingErrs {\n\t\t\t\tlogs.Info.Println(\"fcm googleapi.Error decoding:\", err)\n\t\t\t}\n\t\t\tswitch strings.ToUpper(gerr.FcmErrCode) {\n\t\t\tcase \"\": // no error\n\t\t\tcase common.ErrorQuotaExceeded, common.ErrorUnavailable, common.ErrorInternal, common.ErrorUnspecified:\n\t\t\t\t// Transient errors. Stop sending this batch.\n\t\t\t\tlogs.Warn.Println(\"fcm transient failure:\", gerr.FcmErrCode, gerr.ErrMessage)\n\t\t\t\treturn\n\t\t\tcase common.ErrorSenderIDMismatch, common.ErrorInvalidArgument, common.ErrorThirdPartyAuth:\n\t\t\t\t// Config errors. Stop.\n\t\t\t\tlogs.Warn.Println(\"fcm invalid config:\", gerr.FcmErrCode, gerr.ErrMessage)\n\t\t\t\treturn\n\t\t\tcase common.ErrorUnregistered:\n\t\t\t\t// Token is no longer valid. Delete token from DB and continue sending.\n\t\t\t\tlogs.Warn.Println(\"fcm invalid token:\", gerr.FcmErrCode, gerr.ErrMessage)\n\t\t\t\tif err := store.Devices.Delete(uids[i], messages[i].Token); err != nil {\n\t\t\t\t\tlogs.Warn.Println(\"tnpg failed to delete invalid token:\", err)\n\t\t\t\t}\n\t\t\tdefault:\n\t\t\t\t// Unknown error. Stop sending just in case.\n\t\t\t\tlogs.Warn.Println(\"tnpg unrecognized error:\", gerr.FcmErrCode, gerr.ErrMessage)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc processSubscription(req *push.ChannelReq) {\n\tvar channel string\n\tvar devices []string\n\tvar device string\n\tvar channels []string\n\n\tif req.Channel != \"\" {\n\t\tdevices = DevicesForUser(req.Uid)\n\t\tchannel = req.Channel\n\t} else if req.DeviceID != \"\" {\n\t\tchannels = ChannelsForUser(req.Uid)\n\t\tdevice = req.DeviceID\n\t}\n\n\tif (len(devices) == 0 && device == \"\") || (len(channels) == 0 && channel == \"\") {\n\t\t// No channels or devces to subscribe or unsubscribe.\n\t\treturn\n\t}\n\n\tif len(devices) > subBatchSize {\n\t\t// It's extremely unlikely for a single user to have this many devices.\n\t\tdevices = devices[0:subBatchSize]\n\t\tlogs.Warn.Println(\"fcm: user\", req.Uid.UserId(), \"has more than\", subBatchSize, \"devices\")\n\t}\n\n\tvar err error\n\tvar resp *legacy.TopicManagementResponse\n\tif channel != \"\" && len(devices) > 0 {\n\t\tif req.Unsub {\n\t\t\tresp, err = handler.client.UnsubscribeFromTopic(context.Background(), devices, channel)\n\t\t} else {\n\t\t\tresp, err = handler.client.SubscribeToTopic(context.Background(), devices, channel)\n\t\t}\n\t\tif err != nil {\n\t\t\t// Complete failure.\n\t\t\tlogs.Warn.Println(\"fcm: sub or upsub failed\", req.Unsub, err)\n\t\t} else {\n\t\t\t// Check for partial failure.\n\t\t\thandleSubErrors(resp, req.Uid, devices)\n\t\t}\n\t\treturn\n\t}\n\n\tif device != \"\" && len(channels) > 0 {\n\t\tdevices := []string{device}\n\t\tfor _, channel := range channels {\n\t\t\tif req.Unsub {\n\t\t\t\tresp, err = handler.client.UnsubscribeFromTopic(context.Background(), devices, channel)\n\t\t\t} else {\n\t\t\t\tresp, err = handler.client.SubscribeToTopic(context.Background(), devices, channel)\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\t// Complete failure.\n\t\t\t\tlogs.Warn.Println(\"fcm: sub or upsub failed\", req.Unsub, err)\n\t\t\t\tbreak\n\t\t\t}\n\t\t\t// Check for partial failure.\n\t\t\thandleSubErrors(resp, req.Uid, devices)\n\t\t}\n\t\treturn\n\t}\n\n\t// Invalid request: either multiple channels & multiple devices (not supported) or no channels and no devices.\n\tlogs.Err.Println(\"fcm: user\", req.Uid.UserId(), \"invalid combination of sub/unsub channels/devices\",\n\t\tlen(devices), len(channels))\n}\n\nfunc handleSubErrors(response *legacy.TopicManagementResponse, uid types.Uid, devices []string) {\n\tif response.FailureCount <= 0 {\n\t\treturn\n\t}\n\n\tfor _, errinfo := range response.Errors {\n\t\t// FCM documentation sucks. There is no list of possible errors so no action can be taken but logging.\n\t\tlogs.Warn.Println(\"fcm sub/unsub error\", errinfo.Reason, uid, devices[errinfo.Index])\n\t}\n}\n\n// IsReady checks if the push handler has been initialized.\nfunc (Handler) IsReady() bool {\n\treturn handler.input != nil\n}\n\n// Push returns a channel that the server will use to send messages to.\n// If the adapter blocks, the message will be dropped.\nfunc (Handler) Push() chan<- *push.Receipt {\n\treturn handler.input\n}\n\n// Channel returns a channel for subscribing/unsubscribing devices to FCM topics.\nfunc (Handler) Channel() chan<- *push.ChannelReq {\n\treturn handler.channel\n}\n\n// Stop shuts down the handler\nfunc (Handler) Stop() {\n\thandler.stop <- true\n}\n\nfunc init() {\n\tpush.Register(\"fcm\", &handler)\n}\n"
  },
  {
    "path": "server/push/push.go",
    "content": "// Package push contains interfaces to be implemented by push notification plugins.\npackage push\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"time\"\n\n\tt \"github.com/tinode/chat/server/store/types\"\n)\n\n// Push actions\nconst (\n\t// New message.\n\tActMsg = \"msg\"\n\t// New subscription.\n\tActSub = \"sub\"\n\t// Messages read: clear unread count.\n\tActRead = \"read\"\n)\n\n// MaxPayloadLength is the maximum length of push payload in multibyte characters.\nconst MaxPayloadLength = 128\n\n// Recipient is a user targeted by the push.\ntype Recipient struct {\n\t// Count of user's connections that were live when the packet was dispatched from the server\n\tDelivered int `json:\"delivered\"`\n\t// List of user's devices that the packet was delivered to (if known). Len(Devices) >= Delivered\n\tDevices []string `json:\"devices,omitempty\"`\n\t// Unread count to include in the push\n\tUnread int `json:\"unread\"`\n\t// Indicates whether unread counter in the cache should be incremented before sending the push.\n\tShouldIncrementUnreadCountInCache bool `json:\"-\"`\n}\n\n// Receipt is the push payload with a list of recipients.\ntype Receipt struct {\n\t// List of individual recipients, including those who did not receive the message.\n\tTo map[t.Uid]Recipient `json:\"to\"`\n\t// Push topic for group notifications.\n\tChannel string `json:\"channel\"`\n\t// Actual content to be delivered to the client.\n\tPayload Payload `json:\"payload\"`\n}\n\n// ChannelReq is a request to subscribe/unsubscribe device ID(s) to channel(s) (FCM topic).\n// - If DeviceID is provided, it's subscribed/unsubscribed to all user's channels.\n// - If Channel is provided, then all user's devices are subscribed/unsubscribed from the channel.\ntype ChannelReq struct {\n\t// Uid is the ID of the user making request.\n\tUid t.Uid\n\t// DeviceID is the device-provided token in case a single device is being subscribed to all channels.\n\tDeviceID string\n\t// Channel to subscribe to or unsubscribe from.\n\tChannel string\n\t// Unsub is set to true to unsubscribe devices, otherwise subscribe them.\n\tUnsub bool\n}\n\n// Payload is content of the push.\ntype Payload struct {\n\t// Action type of the push: new message (msg), new subscription (sub), etc.\n\tWhat string `json:\"what\"`\n\t// If this is a silent push: perform action but do not show a notification to the user.\n\tSilent bool `json:\"silent\"`\n\t// Topic which was affected by the action.\n\tTopic string `json:\"topic\"`\n\t// Timestamp of the action.\n\tTimestamp time.Time `json:\"ts\"`\n\n\t// {data} notification.\n\n\t// Message sender 'usrXXX'\n\tFrom string `json:\"from\"`\n\t// Sequential ID of the message.\n\tSeqId int `json:\"seq\"`\n\t// MIME-Type of the message content, text/x-drafty or text/plain\n\tContentType string `json:\"mime\"`\n\t// Actual Data.Content of the message, if requested\n\tContent any `json:\"content,omitempty\"`\n\t// State of the video call (available in video call messages only).\n\tWebrtc string `json:\"webrtc,omitempty\"`\n\t// If call is audio-only (available only if Webrtc is present).\n\tAudioOnly bool `json:\"aonly,omitempty\"`\n\t// Seq id the message is supposed to replace.\n\tReplace string `json:\"replace,omitempty\"`\n\n\t// Subscription change notification.\n\n\t// New access mode when notifying of a subscription change.\n\t// ModeNone for both means the subscription is removed.\n\tModeWant  t.AccessMode `json:\"want,omitempty\"`\n\tModeGiven t.AccessMode `json:\"given,omitempty\"`\n}\n\n// Handler is an interface which must be implemented by handlers.\ntype Handler interface {\n\t// Init initializes the handler.\n\tInit(jsonconf json.RawMessage) (bool, error)\n\n\t// IsReady сhecks if the handler is initialized.\n\tIsReady() bool\n\n\t// Push returns a channel that the server will use to send messages to.\n\t// The message will be dropped if the channel blocks.\n\tPush() chan<- *Receipt\n\n\t// Subscribe/unsubscribe device from FCM topic (channel).\n\tChannel() chan<- *ChannelReq\n\n\t// Stop terminates the handler's worker and stops sending pushes.\n\tStop()\n}\n\ntype configType struct {\n\tName   string          `json:\"name\"`\n\tConfig json.RawMessage `json:\"config\"`\n}\n\nvar handlers map[string]Handler\n\n// Register a push handler\nfunc Register(name string, hnd Handler) {\n\tif handlers == nil {\n\t\thandlers = make(map[string]Handler)\n\t}\n\n\tif hnd == nil {\n\t\tpanic(\"Register: push handler is nil\")\n\t}\n\tif _, dup := handlers[name]; dup {\n\t\tpanic(\"Register: called twice for handler \" + name)\n\t}\n\thandlers[name] = hnd\n}\n\n// Init initializes registered handlers.\nfunc Init(jsconfig json.RawMessage) ([]string, error) {\n\tvar config []configType\n\n\tif err := json.Unmarshal(jsconfig, &config); err != nil {\n\t\treturn nil, errors.New(\"failed to parse config: \" + err.Error())\n\t}\n\n\tvar enabled []string\n\tfor _, cc := range config {\n\t\tif hnd := handlers[cc.Name]; hnd != nil {\n\t\t\tif ok, err := hnd.Init(cc.Config); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t} else if ok {\n\t\t\t\tenabled = append(enabled, cc.Name)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn enabled, nil\n}\n\n// Push a single message to devices.\nfunc Push(msg *Receipt) {\n\tif handlers == nil {\n\t\treturn\n\t}\n\n\tfor _, hnd := range handlers {\n\t\tif !hnd.IsReady() {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Push without delay or skip\n\t\tselect {\n\t\tcase hnd.Push() <- msg:\n\t\tdefault:\n\t\t}\n\t}\n}\n\n// ChannelSub handles a channel (FCM topic) subscription/unsubscription request.\nfunc ChannelSub(msg *ChannelReq) {\n\tif handlers == nil {\n\t\treturn\n\t}\n\n\tfor _, hnd := range handlers {\n\t\tif !hnd.IsReady() {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Send without delay or skip.\n\t\tselect {\n\t\tcase hnd.Channel() <- msg:\n\t\tdefault:\n\t\t}\n\t}\n}\n\n// Stop all pushes\nfunc Stop() {\n\tif handlers == nil {\n\t\treturn\n\t}\n\n\tfor _, hnd := range handlers {\n\t\tif hnd.IsReady() {\n\t\t\t// Will potentially block\n\t\t\thnd.Stop()\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "server/push/stdout/README.md",
    "content": "# `stdout` push adapter\n\nThis is an adapter which logs push notifications to `STDOUT` where they can be redirected to file or processed by some other service.\nThis adapter is primarily intended for debugging and logging.\n"
  },
  {
    "path": "server/push/stdout/push_stdout.go",
    "content": "// Package stdout is a sample implementation of a push plugin.\n// If enabled, it writes every notification to stdout.\npackage stdout\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/tinode/chat/server/push\"\n)\n\nvar handler stdoutPush\n\n// How much to buffer the input channel.\nconst defaultBuffer = 32\n\ntype stdoutPush struct {\n\tinitialized bool\n\tinput       chan *push.Receipt\n\tchannel     chan *push.ChannelReq\n\tstop        chan bool\n}\n\ntype configType struct {\n\tEnabled bool `json:\"enabled\"`\n\tBuffer  int  `json:\"buffer\"`\n}\n\n// Init initializes the handler\nfunc (stdoutPush) Init(jsonconf json.RawMessage) (bool, error) {\n\n\t// Check if the handler is already initialized\n\tif handler.initialized {\n\t\treturn false, errors.New(\"already initialized\")\n\t}\n\n\tvar config configType\n\tif err := json.Unmarshal([]byte(jsonconf), &config); err != nil {\n\t\treturn false, errors.New(\"failed to parse config: \" + err.Error())\n\t}\n\n\thandler.initialized = true\n\n\tif !config.Enabled {\n\t\treturn false, nil\n\t}\n\n\tif config.Buffer <= 0 {\n\t\tconfig.Buffer = defaultBuffer\n\t}\n\n\thandler.input = make(chan *push.Receipt, config.Buffer)\n\thandler.channel = make(chan *push.ChannelReq, config.Buffer)\n\thandler.stop = make(chan bool, 1)\n\n\tgo func() {\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase msg := <-handler.input:\n\t\t\t\tfmt.Fprintln(os.Stdout, msg)\n\t\t\tcase msg := <-handler.channel:\n\t\t\t\tfmt.Fprintln(os.Stdout, msg)\n\t\t\tcase <-handler.stop:\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn true, nil\n}\n\n// IsReady checks if the handler is initialized.\nfunc (stdoutPush) IsReady() bool {\n\treturn handler.input != nil\n}\n\n// Push returns a channel that the server will use to send messages to.\n// If the adapter blocks, the message will be dropped.\nfunc (stdoutPush) Push() chan<- *push.Receipt {\n\treturn handler.input\n}\n\n// Channel returns a channel that caller can use to subscribe/unsubscribe devices to channels (FCM topics).\n// If the adapter blocks, the message will be dropped.\nfunc (stdoutPush) Channel() chan<- *push.ChannelReq {\n\treturn handler.channel\n}\n\n// Stop terminates the handler's worker and stops sending pushes.\nfunc (stdoutPush) Stop() {\n\thandler.stop <- true\n}\n\nfunc init() {\n\tpush.Register(\"stdout\", &handler)\n}\n"
  },
  {
    "path": "server/push/tnpg/README.md",
    "content": "# TNPG: Push Gateway\n\nThis is a push notifications adapter which communicates with Tinode Push Gateway (TNPG).\n\nTNPG is a proprietary service intended to simplify deployment of on-premise installations.\nDeploying a Tinode server without TNPG requires [configuring Google FCM](../fcm/) with your own credentials, recompiling Android and iOS clients, releasing them to PlayStore and AppStore under your own accounts. It's usually time consuming and relatively complex.\n\nTNPG solves this problem by letting Tinode LLC (the company behind Tinode) to send push notifications on your behalf: you hand a notification over to TNPG, TNPG sends it to the clients using its own credentials and certificates. Internally it uses [Google FCM](https://firebase.google.com/docs/cloud-messaging/) and as such supports the same platforms as FCM. The main advantage of using TNPG over FCM is simplicity of configuration: you can use stock mobile clients with your custom Tinode server, all is needed is a configuration update on the server.\n\n## Configuring TNPG adapter\n\n### Obtain TNPG token\n\n1. Register at https://console.tinode.co and create an organization.\n2. Get the TPNG token from the _Self hosting_ &rarr; _Push Gateway_ section by following the instructions there.\n\n### Configure the server\nUpdate the server config [`tinode.conf`](../../tinode.conf#L413), section `\"push\"` -> `\"name\": \"tnpg\"`:\n```js\n{\n  \"enabled\": true,\n  \"org\": \"myorg\", // Short name (URL) of the organization you registered at console.tinode.co\n  \"token\": \"SoMe_LonG.RaNDoM-StRiNg.12345\" // authentication token obtained from console.tinode.co\n}\n```\nMake sure the `fcm` section is disabled `\"enabled\": false` or removed altogether.\n"
  },
  {
    "path": "server/push/tnpg/push_tnpg.go",
    "content": "// Package tnpg implements push notification plugin for Tinode Push Gateway.\npackage tnpg\n\nimport (\n\t\"bytes\"\n\t\"compress/gzip\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/push\"\n\t\"github.com/tinode/chat/server/push/common\"\n\t\"github.com/tinode/chat/server/push/fcm\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/types\"\n\n\tfcmv1 \"google.golang.org/api/fcm/v1\"\n)\n\nconst (\n\tbaseTargetAddress = \"https://pushgw.tinode.co/\"\n\tpushPath          = \"pushv1\"\n\tsubsPath          = \"sub\"\n\tpushBatchSize     = 100\n\tsubBatchSize      = 1000\n\tbufferSize        = 1024\n)\n\nvar handler Handler\n\nconst maxPooledPostBodyCap = 1 << 16\n\nvar postBodyPool = sync.Pool{\n\tNew: func() any {\n\t\treturn new(bytes.Buffer)\n\t},\n}\n\nvar gzipWriterPool = sync.Pool{\n\tNew: func() any {\n\t\treturn gzip.NewWriter(nil)\n\t},\n}\n\n// Handler represents state of TNPG push client.\ntype Handler struct {\n\tinput   chan *push.Receipt\n\tchannel chan *push.ChannelReq\n\tstop    chan bool\n\tpushUrl string\n\tsubUrl  string\n}\n\ntype configType struct {\n\tEnabled         bool   `json:\"enabled\"`\n\tOrgID           string `json:\"org\"`\n\tAuthToken       string `json:\"token\"`\n\tDebugPushGWHost string `json:\"debug_server\"`\n}\n\n// subUnsubReq is a request to subscribe/unsubscribe device ID(s) to channel(s) (FCM topic).\n// One device to multiple channels or multiple devices to one channel.\ntype subUnsubReq struct {\n\tChannel  string   `json:\"channel,omitempty\"`\n\tChannels []string `json:\"channels,omitempty\"`\n\tDevice   string   `json:\"device,omitempty\"`\n\tDevices  []string `json:\"devices,omitempty\"`\n\tUnsub    bool     `json:\"unsub\"`\n}\n\ntype tnpgResponse struct {\n\t// Push message response only.\n\tMessageID string `json:\"msg_id,omitempty\"`\n\t// Server response HTTP code.\n\tCode int `json:\"code,omitempty\"`\n\t// FCM response code. Both push and sub/unsub response.\n\tErrorCode     string `json:\"errcode,omitempty\"`\n\tExtendedError string `json:\"exerr,omitempty\"`\n\tErrorMessage  string `json:\"errmsg,omitempty\"`\n\t// Channel sub/unsub response only.\n\tIndex int `json:\"index,omitempty\"`\n}\n\ntype batchResponse struct {\n\t// Number of successfully sent messages.\n\tSuccessCount int `json:\"sent_count\"`\n\t// Number of failures.\n\tFailureCount int `json:\"fail_count\"`\n\t// Error code and message if the entire batch failed.\n\tFatalCode    string `json:\"errcode,omitempty\"`\n\tFatalMessage string `json:\"errmsg,omitempty\"`\n\t// Individual reponses in the same order as messages. Could be nil if the entire batch failed.\n\tResponses []*tnpgResponse `json:\"resp,omitempty\"`\n\n\t// Local values\n\thttpCode   int\n\thttpStatus string\n}\n\n// Error codes copied from https://github.com/firebase/firebase-admin-go/blob/master/messaging/messaging.go\nconst (\n\tinternalError                  = \"internal-error\"\n\tinvalidAPNSCredentials         = \"invalid-apns-credentials\"\n\tinvalidArgument                = \"invalid-argument\"\n\tmessageRateExceeded            = \"message-rate-exceeded\"\n\tmismatchedCredential           = \"mismatched-credential\"\n\tregistrationTokenNotRegistered = \"registration-token-not-registered\"\n\tserverUnavailable              = \"server-unavailable\"\n\ttooManyTopics                  = \"too-many-topics\"\n\tunknownError                   = \"unknown-error\"\n)\n\n// Init initializes the handler\nfunc (Handler) Init(jsonconf json.RawMessage) (bool, error) {\n\tvar config configType\n\tif err := json.Unmarshal(jsonconf, &config); err != nil {\n\t\treturn false, errors.New(\"failed to parse config: \" + err.Error())\n\t}\n\n\tif !config.Enabled {\n\t\treturn false, nil\n\t}\n\n\tconfig.OrgID = strings.TrimSpace(config.OrgID)\n\tif config.OrgID == \"\" {\n\t\treturn false, errors.New(\"organization name is missing\")\n\t}\n\n\t// Convert to lower case to avoid confusion.\n\tconfig.OrgID = strings.ToLower(config.OrgID)\n\n\t// Construct server URLs.\n\tserverAddr := baseTargetAddress\n\tif config.DebugPushGWHost != \"\" {\n\t\tserverAddr = config.DebugPushGWHost\n\t}\n\tserverUrl, err := url.Parse(serverAddr)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tserverUrl.Path += pushPath + \"/\" + config.OrgID\n\thandler.pushUrl = serverUrl.String()\n\tserverUrl, _ = url.Parse(serverAddr)\n\tserverUrl.Path += subsPath + \"/\" + config.OrgID\n\thandler.subUrl = serverUrl.String()\n\n\thandler.input = make(chan *push.Receipt, bufferSize)\n\thandler.channel = make(chan *push.ChannelReq, bufferSize)\n\thandler.stop = make(chan bool, 1)\n\n\tgo func() {\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase rcpt := <-handler.input:\n\t\t\t\tgo sendPushes(rcpt, &config)\n\t\t\tcase sub := <-handler.channel:\n\t\t\t\tgo processSubscription(sub, &config)\n\t\t\tcase <-handler.stop:\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn true, nil\n}\n\nfunc postMessage(endpoint string, body any, config *configType) (*batchResponse, error) {\n\tbuf := postBodyPool.Get().(*bytes.Buffer)\n\tdefer func() {\n\t\tbuf.Reset()\n\t\tif cap(buf.Bytes()) > maxPooledPostBodyCap {\n\t\t\treturn\n\t\t}\n\t\tpostBodyPool.Put(buf)\n\t}()\n\n\tgzw := gzipWriterPool.Get().(*gzip.Writer)\n\tdefer func() {\n\t\tgzw.Reset(nil)\n\t\tgzipWriterPool.Put(gzw)\n\t}()\n\tgzw.Reset(buf)\n\terr := json.NewEncoder(gzw).Encode(body)\n\tif closeErr := gzw.Close(); err == nil {\n\t\terr = closeErr\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treq, err := http.NewRequest(http.MethodPost, endpoint, buf)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Add(\"Authorization\", \"Bearer \"+config.AuthToken)\n\treq.Header.Set(\"Content-Type\", \"application/json; charset=utf-8\")\n\treq.Header.Add(\"Content-Encoding\", \"gzip\")\n\treq.Header.Add(\"Accept-Encoding\", \"gzip\")\n\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar batch batchResponse\n\tvar reader io.ReadCloser\n\tif strings.Contains(resp.Header.Get(\"Content-Encoding\"), \"gzip\") {\n\t\treader, err = gzip.NewReader(resp.Body)\n\t\tif err == nil {\n\t\t\tdefer reader.Close()\n\t\t}\n\t} else {\n\t\treader = resp.Body\n\t}\n\n\tif err == nil {\n\t\terr = json.NewDecoder(reader).Decode(&batch)\n\t}\n\tresp.Body.Close()\n\n\tif err != nil {\n\t\t// Just log the error, but don't report it to caller. The push succeeded.\n\t\tlogs.Warn.Println(\"tnpg failed to decode response:\", err)\n\t}\n\n\tbatch.httpCode = resp.StatusCode\n\tbatch.httpStatus = resp.Status\n\n\treturn &batch, nil\n}\n\nfunc sendPushes(rcpt *push.Receipt, config *configType) {\n\tmessages, uids := fcm.PrepareV1Notifications(rcpt, nil)\n\n\tn := len(messages)\n\tfor i := 0; i < n; i += pushBatchSize {\n\t\tupper := min(i+pushBatchSize, n)\n\t\tvar payloads []any\n\t\tfor j := i; j < upper; j++ {\n\t\t\tpayloads = append(payloads, messages[j])\n\t\t}\n\t\tresp, err := postMessage(handler.pushUrl, payloads, config)\n\t\tif err != nil {\n\t\t\tlogs.Warn.Println(\"tnpg push request failed:\", err)\n\t\t\tbreak\n\t\t}\n\t\tif resp.httpCode >= 300 {\n\t\t\tlogs.Warn.Println(\"tnpg push rejected:\", resp.httpStatus)\n\t\t\tbreak\n\t\t}\n\t\tif resp.FatalCode != \"\" {\n\t\t\tlogs.Err.Println(\"tnpg push failed:\", resp.FatalMessage)\n\t\t\tbreak\n\t\t}\n\t\t// Check for expired tokens and other errors.\n\t\thandlePushResponse(resp, messages[i:upper], uids[i:upper])\n\t}\n}\n\nfunc processSubscription(req *push.ChannelReq, config *configType) {\n\tsu := subUnsubReq{\n\t\tUnsub: req.Unsub,\n\t}\n\n\tif req.Channel != \"\" {\n\t\tsu.Devices = fcm.DevicesForUser(req.Uid)\n\t\tsu.Channel = req.Channel\n\t} else if req.DeviceID != \"\" {\n\t\tsu.Channels = fcm.ChannelsForUser(req.Uid)\n\t\tsu.Device = req.DeviceID\n\t}\n\n\tif (len(su.Devices) == 0 && su.Device == \"\") || (len(su.Channels) == 0 && su.Channel == \"\") {\n\t\treturn\n\t}\n\n\tif len(su.Devices) > subBatchSize {\n\t\t// It's extremely unlikely for a single user to have this many devices.\n\t\tsu.Devices = su.Devices[0:subBatchSize]\n\t\tlogs.Warn.Println(\"tnpg: user\", req.Uid.UserId(), \"has more than\", subBatchSize, \"devices\")\n\t}\n\n\tresp, err := postMessage(handler.subUrl, &su, config)\n\tif err != nil {\n\t\tlogs.Warn.Println(\"tnpg channel sub request failed:\", err)\n\t\treturn\n\t}\n\tif resp.httpCode >= 300 {\n\t\tlogs.Warn.Println(\"tnpg channel sub rejected:\", resp.httpStatus)\n\t\treturn\n\t}\n\tif resp.FatalCode != \"\" {\n\t\tlogs.Err.Println(\"tnpg channel sub failed:\", resp.FatalMessage)\n\t\treturn\n\t}\n\t// Check for expired tokens and other errors.\n\thandleSubResponse(resp, req, su.Devices, su.Channels)\n}\n\nfunc handlePushResponse(batch *batchResponse, messages []*fcmv1.Message, uids []types.Uid) {\n\tif batch.FailureCount <= 0 {\n\t\treturn\n\t}\n\n\tfor i, resp := range batch.Responses {\n\t\tswitch resp.ErrorCode {\n\t\tcase \"\": // no error\n\t\tcase common.ErrorQuotaExceeded, common.ErrorUnavailable, common.ErrorInternal, common.ErrorUnspecified:\n\t\t\t// Transient errors. Stop sending this batch.\n\t\t\tlogs.Warn.Println(\"tnpg transient failure:\", resp.ErrorMessage)\n\t\t\treturn\n\t\tcase common.ErrorInvalidArgument:\n\t\t\t// Usually an invalid token.\n\t\t\tlogs.Warn.Println(\"tnpg invalid argument:\", resp.ExtendedError, resp.ErrorMessage)\n\t\t\tif strings.Contains(resp.ExtendedError, \"message.token\") {\n\t\t\t\tif err := store.Devices.Delete(uids[i], messages[i].Token); err != nil {\n\t\t\t\t\tlogs.Warn.Println(\"tnpg failed to delete invalid token:\", err)\n\t\t\t\t}\n\t\t\t}\n\t\tcase common.ErrorSenderIDMismatch, common.ErrorThirdPartyAuth:\n\t\t\t// Config errors\n\t\t\tlogs.Warn.Println(\"tnpg invalid config:\", resp.ExtendedError, resp.ErrorMessage)\n\t\t\treturn\n\t\tcase common.ErrorUnregistered:\n\t\t\t// Token is no longer valid.\n\t\t\tlogs.Info.Println(\"tnpg invalid token:\", resp.ErrorMessage, resp.ExtendedError, resp.MessageID)\n\t\t\tif err := store.Devices.Delete(uids[i], messages[i].Token); err != nil {\n\t\t\t\tlogs.Warn.Println(\"tnpg failed to delete invalid token:\", err)\n\t\t\t}\n\t\tdefault:\n\t\t\tlogs.Warn.Println(\"tnpg unrecognized error:\", resp.ErrorCode, resp.ErrorMessage, resp.ExtendedError, resp.Code)\n\t\t}\n\t}\n}\n\nfunc handleSubResponse(batch *batchResponse, req *push.ChannelReq, devices, channels []string) {\n\tif batch.FailureCount <= 0 {\n\t\treturn\n\t}\n\n\tvar src string\n\tfor _, resp := range batch.Responses {\n\t\tif len(devices) > 0 {\n\t\t\tsrc = devices[resp.Index]\n\t\t} else {\n\t\t\tsrc = channels[resp.Index]\n\t\t}\n\t\t// FCM documentation sucks. There is no list of possible errors so no action can be taken but logging.\n\t\tlogs.Warn.Println(\"fcm sub/unsub error\", resp.ErrorCode, req.Uid, src)\n\t}\n}\n\n// IsReady checks if the handler is initialized.\nfunc (Handler) IsReady() bool {\n\treturn handler.input != nil\n}\n\n// Push returns a channel that the server will use to send messages to.\n// If the adapter blocks, the message will be dropped.\nfunc (Handler) Push() chan<- *push.Receipt {\n\treturn handler.input\n}\n\n// Channel returns a channel that the server will use to send group requests to.\n// If the adapter blocks, the message will be dropped.\nfunc (Handler) Channel() chan<- *push.ChannelReq {\n\treturn handler.channel\n}\n\n// Stop terminates the handler's worker and stops sending pushes.\nfunc (Handler) Stop() {\n\thandler.stop <- true\n}\n\nfunc init() {\n\tpush.Register(\"tnpg\", &handler)\n}\n"
  },
  {
    "path": "server/push.go",
    "content": "/******************************************************************************\n *\n *  Description:\n *    Push notifications handling.\n *\n *****************************************************************************/\n\npackage main\n\nimport (\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/push\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\n// Subscribe or unsubscribe user to/from FCM topic (channel).\nfunc (t *Topic) channelSubUnsub(uid types.Uid, sub bool) {\n\tpush.ChannelSub(&push.ChannelReq{\n\t\tUid:     uid,\n\t\tChannel: types.GrpToChn(t.name),\n\t\tUnsub:   !sub,\n\t})\n}\n\n// Prepares a payload to be delivered to a mobile device as a push notification in response to a {data} message.\nfunc (t *Topic) pushForData(fromUid types.Uid, data *MsgServerData, msgMarkedAsReadBySender bool) *push.Receipt {\n\t// Passing `Topic` as `t.name` for group topics and P2P topics. The p2p topic name is later rewritten for\n\t// each recipient then the payload is created: p2p recipient sees the topic as the ID of the other user.\n\n\t// Initialize the push receipt.\n\tcontentType, _ := data.Head[\"mime\"].(string)\n\treceipt := push.Receipt{\n\t\tTo: make(map[types.Uid]push.Recipient, t.subsCount()),\n\t\tPayload: push.Payload{\n\t\t\tWhat:        push.ActMsg,\n\t\t\tSilent:      false,\n\t\t\tTopic:       t.name,\n\t\t\tFrom:        data.From,\n\t\t\tTimestamp:   data.Timestamp,\n\t\t\tSeqId:       data.SeqId,\n\t\t\tContentType: contentType,\n\t\t\tContent:     data.Content,\n\t\t},\n\t}\n\tif webrtc, found := data.Head[\"webrtc\"].(string); found {\n\t\treceipt.Payload.Webrtc = webrtc\n\t\taudioOnly, _ := data.Head[\"aonly\"].(bool)\n\t\treceipt.Payload.AudioOnly = audioOnly\n\t}\n\tif replace, found := data.Head[\"replace\"].(string); found {\n\t\treceipt.Payload.Replace = replace\n\t}\n\n\tif t.isChan {\n\t\t// Channel readers should get a push on a channel name (as an FCM topic push).\n\t\treceipt.Channel = types.GrpToChn(t.name)\n\t}\n\n\tfor uid, pud := range t.perUser {\n\t\tonline := pud.online\n\t\tif uid == fromUid && online == 0 {\n\t\t\t// Make sure the sender's devices receive a silent push.\n\t\t\tonline = 1\n\t\t}\n\n\t\t// Send only to those who have notifications enabled.\n\t\tmode := pud.modeWant & pud.modeGiven\n\t\tif mode.IsPresencer() && mode.IsReader() && !pud.deleted && !pud.isChan {\n\t\t\treceipt.To[uid] = push.Recipient{\n\t\t\t\t// Number of attached sessions the data message will be delivered to.\n\t\t\t\t// Push notifications sent to users with non-zero online sessions will be marked silent.\n\t\t\t\tDelivered: online,\n\t\t\t\t// Unread counts are incremented for all recipients,\n\t\t\t\t// and for sender only if the message wasnt't marked 'read' by the sender\n\t\t\t\tShouldIncrementUnreadCountInCache: uid != fromUid || !msgMarkedAsReadBySender,\n\t\t\t}\n\t\t}\n\t}\n\tif len(receipt.To) > 0 || receipt.Channel != \"\" {\n\t\treturn &receipt\n\t}\n\t// If there are no recipient there is no need to send the push notification.\n\treturn nil\n}\n\nfunc (t *Topic) preparePushForSubReceipt(fromUid types.Uid, now time.Time) *push.Receipt {\n\t// The `Topic` in the push receipt is `t.xoriginal` for group topics, `fromUid` for p2p topics,\n\t// not the t.original(fromUid) because it's the topic name as seen by the recipient, not by the sender.\n\ttopic := t.xoriginal\n\tif t.cat == types.TopicCatP2P {\n\t\ttopic = fromUid.UserId()\n\t}\n\n\t// Initialize the push receipt.\n\treceipt := &push.Receipt{\n\t\tTo: make(map[types.Uid]push.Recipient, t.subsCount()),\n\t\tPayload: push.Payload{\n\t\t\tWhat:      push.ActSub,\n\t\t\tSilent:    false,\n\t\t\tTopic:     topic,\n\t\t\tFrom:      fromUid.UserId(),\n\t\t\tTimestamp: now,\n\t\t\tSeqId:     t.lastID,\n\t\t},\n\t}\n\treturn receipt\n}\n\n// Prepares payload to be delivered to a mobile device as a push notification in response to a new subscription in a p2p topic.\nfunc (t *Topic) pushForP2PSub(fromUid, toUid types.Uid, want, given types.AccessMode, now time.Time) *push.Receipt {\n\treceipt := t.preparePushForSubReceipt(fromUid, now)\n\treceipt.Payload.ModeWant = want\n\treceipt.Payload.ModeGiven = given\n\n\treceipt.To[toUid] = push.Recipient{}\n\n\treturn receipt\n}\n\n// Prepares payload to be delivered to a mobile device as a push notification in response to a new subscription in a group topic.\nfunc (t *Topic) pushForGroupSub(fromUid types.Uid, now time.Time) *push.Receipt {\n\treceipt := t.preparePushForSubReceipt(fromUid, now)\n\tif pud, ok := t.perUser[fromUid]; ok {\n\t\treceipt.Payload.ModeWant = pud.modeWant\n\t\treceipt.Payload.ModeGiven = pud.modeGiven\n\t} else {\n\t\t// Sender is not a subscriber (BUG?)\n\t\treturn nil\n\t}\n\n\tfor uid, pud := range t.perUser {\n\t\t// Send only to those who have notifications enabled.\n\t\tmode := pud.modeWant & pud.modeGiven\n\t\tif mode.IsPresencer() && mode.IsReader() && !pud.deleted && !pud.isChan {\n\t\t\treceipt.To[uid] = push.Recipient{}\n\t\t}\n\t}\n\tif len(receipt.To) > 0 || receipt.Channel != \"\" {\n\t\treturn receipt\n\t}\n\treturn nil\n}\n\n// Prepares payload to be delivered to a mobile device as a push notification in response to owner deleting a channel.\nfunc pushForChanDelete(topicName string, now time.Time) *push.Receipt {\n\ttopicName = types.GrpToChn(topicName)\n\t// Initialize the push receipt.\n\treturn &push.Receipt{\n\t\tPayload: push.Payload{\n\t\t\tWhat:      push.ActSub,\n\t\t\tSilent:    true,\n\t\t\tTopic:     topicName,\n\t\t\tTimestamp: now,\n\t\t\tModeWant:  types.ModeNone,\n\t\t\tModeGiven: types.ModeNone,\n\t\t},\n\t\tChannel: topicName,\n\t}\n}\n\n// Prepares payload to be delivered to a mobile device as a push notification in response to receiving \"read\" notification.\nfunc (t *Topic) pushForReadRcpt(uid types.Uid, seq int, now time.Time) *push.Receipt {\n\t// The `Topic` in the push receipt is `t.xoriginal` for group topics, `fromUid` for p2p topics,\n\t// not the t.original(fromUid) because it's the topic name as seen by the recipient, not by the sender.\n\ttopic := t.xoriginal\n\tif t.cat == types.TopicCatP2P {\n\t\ttopic = uid.UserId()\n\t}\n\n\t// Initialize the push receipt.\n\treceipt := &push.Receipt{\n\t\tTo: make(map[types.Uid]push.Recipient, 1),\n\t\tPayload: push.Payload{\n\t\t\tWhat:      push.ActRead,\n\t\t\tSilent:    true,\n\t\t\tTopic:     topic,\n\t\t\tFrom:      uid.UserId(),\n\t\t\tTimestamp: now,\n\t\t\tSeqId:     seq,\n\t\t},\n\t}\n\treceipt.To[uid] = push.Recipient{}\n\treturn receipt\n}\n\n// Process push notification.\nfunc sendPush(rcpt *push.Receipt) {\n\tif rcpt == nil || globals.usersUpdate == nil {\n\t\treturn\n\t}\n\n\tvar local *UserCacheReq\n\n\t// In case of a cluster pushes will be initiated at the nodes which own the users.\n\t// Sort users into local and remote.\n\tif globals.cluster != nil {\n\t\tlocal = &UserCacheReq{PushRcpt: &push.Receipt{\n\t\t\tPayload: rcpt.Payload,\n\t\t\tChannel: rcpt.Channel,\n\t\t\tTo:      make(map[types.Uid]push.Recipient),\n\t\t}}\n\t\tremote := &UserCacheReq{PushRcpt: &push.Receipt{\n\t\t\tPayload: rcpt.Payload,\n\t\t\tChannel: rcpt.Channel,\n\t\t\tTo:      make(map[types.Uid]push.Recipient),\n\t\t}}\n\n\t\tfor uid, recipient := range rcpt.To {\n\t\t\tif globals.cluster.isRemoteTopic(uid.UserId()) {\n\t\t\t\tremote.PushRcpt.To[uid] = recipient\n\t\t\t} else {\n\t\t\t\tlocal.PushRcpt.To[uid] = recipient\n\t\t\t}\n\t\t}\n\n\t\tif len(remote.PushRcpt.To) > 0 || remote.PushRcpt.Channel != \"\" {\n\t\t\tglobals.cluster.routeUserReq(remote)\n\t\t}\n\t} else {\n\t\tlocal = &UserCacheReq{PushRcpt: rcpt}\n\t}\n\n\tif len(local.PushRcpt.To) > 0 || local.PushRcpt.Channel != \"\" {\n\t\tselect {\n\t\tcase globals.usersUpdate <- local:\n\t\tdefault:\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "server/ringhash/ringhash.go",
    "content": "// Package ringhash implementats a consistent ring hash:\n// https://en.wikipedia.org/wiki/Consistent_hashing\npackage ringhash\n\nimport (\n\t\"encoding/ascii85\"\n\t\"hash/crc32\"\n\t\"hash/fnv\"\n\t\"sort\"\n\t\"strconv\"\n\n\t\"github.com/tinode/chat/server/logs\"\n)\n\n// Hash is a signature of a hash function used by the package.\ntype Hash func(data []byte) uint32\n\ntype elem struct {\n\tkey  string\n\thash uint32\n}\n\ntype sortable []elem\n\nfunc (k sortable) Len() int      { return len(k) }\nfunc (k sortable) Swap(i, j int) { k[i], k[j] = k[j], k[i] }\nfunc (k sortable) Less(i, j int) bool {\n\t// Weak hash function may cause collisions.\n\tif k[i].hash < k[j].hash {\n\t\treturn true\n\t}\n\tif k[i].hash == k[j].hash {\n\t\treturn k[i].key < k[j].key\n\t}\n\treturn false\n}\n\n// Ring is the definition of the ringhash.\ntype Ring struct {\n\tkeys []elem // Sorted list of keys.\n\n\tsignature string\n\treplicas  int\n\thashfunc  Hash\n}\n\n// New initializes an empty ringhash with the given number of replicas and a hash function.\n// If the hash function is nil, crc32.NewIEEE() is used.\nfunc New(replicas int, fn Hash) *Ring {\n\tring := &Ring{\n\t\treplicas: replicas,\n\t\thashfunc: fn,\n\t}\n\tif ring.hashfunc == nil {\n\t\tring.hashfunc = func(data []byte) uint32 {\n\t\t\thash := crc32.NewIEEE()\n\t\t\thash.Write(data)\n\t\t\treturn hash.Sum32()\n\t\t}\n\t}\n\treturn ring\n}\n\n// Len returns the number of keys in the ring.\nfunc (ring *Ring) Len() int {\n\treturn len(ring.keys)\n}\n\n// Add adds keys to the ring.\nfunc (ring *Ring) Add(keys ...string) {\n\tfor _, key := range keys {\n\t\tfor i := range ring.replicas {\n\t\t\tring.keys = append(ring.keys, elem{\n\t\t\t\thash: ring.hashfunc([]byte(strconv.Itoa(i) + key)),\n\t\t\t\tkey:  key})\n\t\t}\n\t}\n\tsort.Sort(sortable(ring.keys))\n\n\t// Calculate signature\n\thash := fnv.New128a()\n\tb := make([]byte, 4)\n\tfor _, key := range ring.keys {\n\t\tb[0] = byte(key.hash)\n\t\tb[1] = byte(key.hash >> 8)\n\t\tb[2] = byte(key.hash >> 16)\n\t\tb[3] = byte(key.hash >> 24)\n\t\thash.Write(b)\n\t\thash.Write([]byte(key.key))\n\t}\n\n\tb = []byte{}\n\tb = hash.Sum(b)\n\tdst := make([]byte, ascii85.MaxEncodedLen(len(b)))\n\tascii85.Encode(dst, b)\n\tring.signature = string(dst)\n}\n\n// Get returns the closest item in the ring to the provided key.\nfunc (ring *Ring) Get(key string) string {\n\n\tif ring.Len() == 0 {\n\t\treturn \"\"\n\t}\n\n\thash := ring.hashfunc([]byte(key))\n\n\t// Binary search for appropriate replica.\n\tidx := sort.Search(len(ring.keys), func(i int) bool {\n\t\tel := ring.keys[i]\n\t\treturn (el.hash > hash) || (el.hash == hash && el.key >= key)\n\t})\n\n\t// Means we have cycled back to the first replica.\n\tif idx == len(ring.keys) {\n\t\tidx = 0\n\t}\n\n\treturn ring.keys[idx].key\n}\n\n// Signature returns the ring's hash signature. Two identical ringhashes\n// will have the same signature. Two hashes with different\n// number of keys or replicas or hash functions will have different\n// signatures.\nfunc (ring *Ring) Signature() string {\n\treturn ring.signature\n}\n\nfunc (ring *Ring) dump() {\n\tfor _, e := range ring.keys {\n\t\tlogs.Info.Printf(\"key: '%s', hash=%d\", e.key, e.hash)\n\t}\n}\n"
  },
  {
    "path": "server/ringhash/ringhash_test.go",
    "content": "package ringhash_test\n\nimport (\n\t\"fmt\"\n\t\"hash/crc32\"\n\t\"hash/fnv\"\n\t\"testing\"\n\n\t\"github.com/tinode/chat/server/ringhash\"\n)\n\nfunc TestHashing(t *testing.T) {\n\n\t// Ring with 3 elements hashed by crc32.ChecksumIEEE\n\tring := ringhash.New(3, crc32.ChecksumIEEE)\n\tring.Add(\"A\", \"B\", \"C\")\n\n\t// The ring contains:\n\t// B0 =  105710768 -> B\n\t// B1 =  525743601 -> B\n\t// B2 =  880502322 -> B\n\t// C2 = 1132222116 -> C\n\t// C1 = 1750140263 -> C\n\t// C0 = 1900688422 -> C\n\t// A1 = 2254398539 -> A\n\t// A0 = 2672055562 -> A\n\t// A2 = 2909943688 -> A\n\n\t// Key=A, Hash=3554254475 -> B0\n\t// Key=B, Hash=1255198513 -> C1\n\t// Key=C, Hash=1037565863 -> C2\n\t// Key=D, Hash=2746444292 -> A2\n\t// Key=E, Hash=3568589458 -> B0\n\t// Key=F, Hash=1304234792 -> C1\n\n\ttestHashes := map[string]string{\n\t\t\"A\": \"B\",\n\t\t\"B\": \"C\",\n\t\t\"C\": \"C\",\n\t\t\"D\": \"A\",\n\t\t\"E\": \"B\",\n\t\t\"F\": \"C\",\n\t}\n\n\tfor k, v := range testHashes {\n\t\tn := ring.Get(k)\n\t\tif n != v {\n\t\t\tt.Errorf(\"Key '%s', expecting '%s', got '%s'\", k, v, n)\n\t\t}\n\t}\n\n\tring.Add(\"X\")\n\t// Adding\n\t// X0 = 4214226378\n\t// X1 = 3795111051\n\t// X2 = 3373899592\n\n\t// Changes to mapping:\n\ttestHashes[\"A\"] = \"X\"\n\ttestHashes[\"E\"] = \"X\"\n\n\tfor k, v := range testHashes {\n\t\tn := ring.Get(k)\n\t\tif n != v {\n\t\t\tt.Errorf(\"Key '%s, expecting %s, got %s\", k, v, n)\n\t\t}\n\t}\n}\n\nfunc TestConsistency(t *testing.T) {\n\tring1 := ringhash.New(3, nil)\n\tring2 := ringhash.New(3, nil)\n\n\tring1.Add(\"owl\", \"crow\", \"sparrow\")\n\tring2.Add(\"sparrow\", \"owl\", \"crow\")\n\n\tif ring1.Get(\"duck\") != ring2.Get(\"duck\") {\n\t\tt.Error(\"'duck' should map to 'sparrow' in both cases\")\n\t}\n\n\t// Collision test: these strings generate CRC32 collisions\n\t// Google's implementation fails this test.\n\t// 0VXGD 0BGABAA\n\t// 0VXGG 0BGABAB\n\t// 0VXGF 0BGABAC\n\t// 0VXGA 0BGABAD\n\t// 0VXGC 0BGABAF\n\t// 0VXGB 0BGABAG\n\t// 0VXGM 0BGABAH\n\t// 0VXGL 0BGABAI\n\t// 0VXGO 0BGABAJ\n\t// 0VXGN 0BGABAK\n\t// 0VXGI 0BGABAL\n\n\tring1 = ringhash.New(1, crc32.ChecksumIEEE)\n\tring2 = ringhash.New(1, crc32.ChecksumIEEE)\n\n\tring1.Add(\"VXGD\", \"BGABAA\", \"VXGG\", \"BGABAB\", \"VXGF\", \"BGABAC\")\n\tring2.Add(\"BGABAA\", \"VXGD\", \"BGABAB\", \"VXGG\", \"BGABAC\", \"VXGF\")\n\n\tstr := []string{\n\t\t\"datsam\", \"kGmVht\", \"dSPmEr\", \"RloWQr\", \"WFkAkG\", \"gLBNPX\", \"twEwll\", \"RnRdaf\",\n\t\t\"ruEMuJ\", \"ZvXJsJ\", \"xjQzKD\", \"CKfSFg\", \"BMKMvM\", \"PSzYdC\", \"CsxqTR\", \"IbzdXz\",\n\t\t\"xdnZGj\", \"VdHcVp\", \"iVgIvH\", \"bZsTIX\", \"CyRBUO\", \"ylgEGS\", \"vOTwJD\", \"JZbyFU\",\n\t\t\"Hayrly\", \"jQQkOV\", \"NEVjlJ\", \"SkJfie\", \"HrdJuL\", \"ASwkXH\", \"UwJOmo\", \"nfbrxA\",\n\t}\n\tfor _, key := range str {\n\t\tif ring1.Get(key) != ring2.Get(key) {\n\t\t\tt.Errorf(\"'%s' should map to the same bin in both cases\", key)\n\t\t}\n\t}\n}\n\nfunc TestSignature(t *testing.T) {\n\tring1 := ringhash.New(4, nil)\n\tring2 := ringhash.New(4, nil)\n\n\tring1.Add(\"owl\", \"crow\", \"sparrow\")\n\tring2.Add(\"sparrow\", \"owl\", \"crow\")\n\n\tif ring1.Signature() != ring2.Signature() {\n\t\tt.Error(\"Signatures must be identical\")\n\t}\n\n\tring1 = ringhash.New(4, nil)\n\tring2 = ringhash.New(5, nil)\n\n\tring1.Add(\"owl\", \"crow\", \"sparrow\")\n\tring2.Add(\"owl\", \"crow\", \"sparrow\")\n\n\tif ring1.Signature() == ring2.Signature() {\n\t\tt.Error(\"Signatures must be different - different count of replicas\")\n\t}\n\n\tring1 = ringhash.New(4, nil)\n\tring2 = ringhash.New(4, nil)\n\n\tring1.Add(\"owl\", \"crow\", \"sparrow\")\n\tring2.Add(\"owl\", \"crow\", \"sparrow\", \"crane\")\n\n\tif ring1.Signature() == ring2.Signature() {\n\t\tt.Error(\"Signatures must be different - different keys\")\n\t}\n\n\tfnvHashfunc := func(data []byte) uint32 {\n\t\thash := fnv.New32a()\n\t\thash.Write(data)\n\t\treturn hash.Sum32()\n\t}\n\n\tring1 = ringhash.New(4, nil)\n\tring2 = ringhash.New(4, fnvHashfunc)\n\n\tring1.Add(\"owl\", \"crow\", \"sparrow\")\n\tring2.Add(\"owl\", \"crow\", \"sparrow\")\n\n\tif ring1.Signature() == ring2.Signature() {\n\t\tt.Error(\"Signatures must be different - different hash functions\")\n\t}\n}\n\nfunc BenchmarkGet8(b *testing.B)   { benchmarkGet(b, 8) }\nfunc BenchmarkGet32(b *testing.B)  { benchmarkGet(b, 32) }\nfunc BenchmarkGet128(b *testing.B) { benchmarkGet(b, 128) }\nfunc BenchmarkGet512(b *testing.B) { benchmarkGet(b, 512) }\n\nfunc benchmarkGet(b *testing.B, keycount int) {\n\n\tring := ringhash.New(53, nil)\n\n\tvar ids []string\n\tfor i := range keycount {\n\t\tids = append(ids, fmt.Sprintf(\"id=%d\", i))\n\t}\n\n\tring.Add(ids...)\n\n\tb.ResetTimer()\n\n\tfor i := range b.N {\n\t\tring.Get(ids[i&(keycount-1)])\n\t}\n}\n"
  },
  {
    "path": "server/run-cluster.sh",
    "content": "#!/bin/bash\n\n# Start/stop test cluster on localhost. This is NOT a production script. Use it for reference only.\n\n# Names of cluster nodes\nALL_NODE_NAMES=( one two three )\n# Port where the first node will listen for client connections over http\nHTTP_BASE_PORT=6060\n# Port where the first node will listen for gRPC intra-cluster connections.\nGRPC_BASE_PORT=16060\n\nUSAGE=\"Usage: $0 [ --config <path_to_tinode.conf> ] {start|stop}\"\n\n# Your server binary may have a different name and location.\nSERVER='./server'\n\nif [ \"$#\" -lt \"1\" ]; then\n  echo $USAGE\n  exit 1\nfi\n\nwhile [[ $# -gt 0 ]]; do\n  key=\"$1\"\n  shift\n  echo \"$key\"\n  case \"$key\" in\n    -c|--config)\n      config=$1\n      shift # value\n      ;;\n    -s|--static_data)\n      static_data=$1\n      shift # value\n      ;;\n    start)\n      if [ ! -z \"$config\" ] ; then\n        TINODE_CONF=$config\n      else\n        TINODE_CONF=\"tinode.conf\"\n      fi\n      if [ ! -z \"${static_data+x}\" ] ; then\n        STATIC_DATA_DIR=$static_data\n      else\n        STATIC_DATA_DIR=\"static\"\n      fi\n\n      echo \"HTTP ports 6060-6062, gRPC ports 16060-16062, config ${config}\"\n\n      HTTP_PORT=$HTTP_BASE_PORT\n      GRPC_PORT=$GRPC_BASE_PORT\n      for NODE_NAME in \"${ALL_NODE_NAMES[@]}\"\n      do\n        # Start the node\n        $SERVER -config=${TINODE_CONF} -cluster_self=${NODE_NAME} -listen=:${HTTP_PORT} -grpc_listen=:${GRPC_PORT} -static_data=${STATIC_DATA_DIR} -log_flags=stdFlags,shortfile &\n        # Save PID of the node to a temp file.\n        # /var/tmp/ does not requre root access.\n        echo $!> \"/var/tmp/tinode-${NODE_NAME}.pid\"\n        # Increment ports for the next node.\n        HTTP_PORT=$((HTTP_PORT+1))\n        GRPC_PORT=$((GRPC_PORT+1))\n      done\n      exit 0\n      ;;\n    stop)\n      echo 'Stopping cluster'\n\n      for NODE_NAME in \"${ALL_NODE_NAMES[@]}\"\n      do\n        # Read PIDs of running nodes from temp files and kill them.\n        kill `cat /var/tmp/tinode-${NODE_NAME}.pid`\n        # Clean up: delete temp files.\n        rm \"/var/tmp/tinode-${NODE_NAME}.pid\"\n      done\n      exit 0\n      ;;\n    *)\n      echo $USAGE\n      exit 1\n  esac\ndone\n"
  },
  {
    "path": "server/sanity-test.sh",
    "content": "#!/bin/bash\n\nBINARY_PATH=$GOPATH/bin\nTINODE_BINARY=$BINARY_PATH/server\n\n# Kills and removes any running containers.\ncleanup() {\n  ./run-cluster.sh stop\n  if [ -f \"./server\" ]; then\n    rm ./server\n  fi\n  docker stop mysql && docker rm mysql\n}\n\n# Reports failure.\nfail() {\n  cleanup\n  echo \"**************************************************\"\n  printf \"Tests Failed: ${@}\\n\"\n  echo \"**************************************************\"\n  exit 1\n}\n\n# Reports success.\npass() {\n  cleanup\n  echo \"**************************************************\"\n  echo \"*                       OK                       *\"\n  echo \"**************************************************\"\n  exit 0\n}\n\n# Brings up a mysql docker container.\nsetup() {\n  docker info 1>/dev/null 2>&1 || (echo \"docker not running\" && return 1)\n  docker run -p 3306:3306 --name mysql --env MYSQL_ALLOW_EMPTY_PASSWORD=yes -d mysql:5.7 || return 1\n  # This fails to detect when the mysql is actually ready.\n  # TODO: figure out why.\n  #until nc -z -v -w30 localhost 3306; do\n  #  echo \"Waiting for database connection...\"\n  #  sleep 1\n  #done\n  echo -n \"Waiting for mysql to come up.\"\n  while ! mysqladmin ping -u root -h 127.0.0.1 --silent; do\n    echo -n \".\"\n    sleep 1\n  done\n  # Make sure there's no Tinode server binary in the current directory.\n  if [ -f \"./server\" ]; then\n    rm ./server\n  fi\n}\n\n# Compiles Tinode binaries.\nbuild() {\n  go install -tags mysql -ldflags \"-X main.buildstamp=`date -u '+%Y%m%dT%H:%M:%SZ'`\" \\\n    github.com/tinode/chat/tinode-db \\\n    github.com/tinode/chat/server && \\\n  ln -s $TINODE_BINARY\n}\n\n# Initializes Tinode database.\ninit-db() {\n  $GOPATH/bin/tinode-db -config=./tinode.conf -data=../tinode-db/data.json\n}\n\nwait-for() {\n  local port=$1\n  while ! nc -z localhost $port; do\n    sleep 1\n  done\n}\n\n# Brings up a three-node Tinode cluster.\nrun-server() {\n  ./run-cluster.sh -s \"\" start && wait-for 16060\n}\n\nsend-requests() {\n  local expect=12\n  local port=$1\n  local id=$2\n  local outfile=$(mktemp /tmp/tinode-${id}.txt)\n  pushd .\n  cd ../tn-cli\n  python3 tn-cli.py --host=localhost:${port} --no-login < sample-script.txt > $outfile || fail \"Test script failed (instance port ${port})\"\n  popd\n  num_positive_responses=`grep -c '<= 20[0-9]' $outfile`\n  if [ $num_positive_responses -ne expect ]\n  then\n    fail \"Instance ${port}: unexpected number of 20X responses: ${num_positive_responses} (expected ${expected}). Log file ${outfile}\"\n  fi\n  rm $outfile\n}\n\n# Catch unexpected failures, do cleanup and output an error message\ntrap 'cleanup ; fail \"For Unexpected Reasons\"'\\\n  HUP INT QUIT PIPE TERM\n\n# Normal script termination.\n#trap 'cleanup'\\\n#  EXIT\n\nrun_id=`date +%s`\necho \"+----------------------------------------------------+\"\necho \"|                 Tinode sanity test.                |\"\necho \"+----------------------------------------------------+\"\necho \"Timestamp = ${run_id}\"\n\nsetup || fail \"Test setup failed.\"\nbuild || fail \"Could not build Tinode binaries\"\ninit-db || fail \"Could not initialize Tinode database\"\nrun-server || fail \"Could not start tinode\"\n\n# Test requests.\nsend-requests 16060 $run_id\nsend-requests 16061 $run_id\nsend-requests 16062 $run_id\n\npass\n"
  },
  {
    "path": "server/session.go",
    "content": "/******************************************************************************\n *\n *  Description :\n *\n *  Handling of user sessions/connections. One user may have multiple sesions.\n *  Each session may handle multiple topics\n *\n *****************************************************************************/\n\npackage main\n\nimport (\n\t\"container/list\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/gorilla/websocket\"\n\t\"github.com/tinode/chat/pbx\"\n\t\"github.com/tinode/chat/server/auth\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/types\"\n\n\t\"golang.org/x/text/language\"\n)\n\n// Maximum number of queued messages before session is considered stale and dropped.\nconst sendQueueLimit = 128\n\n// Time given to a background session to terminate to avoid tiggering presence notifications.\n// If session terminates (or unsubscribes from topic) in this time frame notifications are not sent at all.\nconst deferredNotificationsTimeout = time.Second * 5\n\nvar minSupportedVersionValue = parseVersion(minSupportedVersion)\n\n// SessionProto is the type of the wire transport.\ntype SessionProto int\n\n// Constants defining individual types of wire transports.\nconst (\n\t// NONE is undefined/not set.\n\tNONE SessionProto = iota\n\t// WEBSOCK represents websocket connection.\n\tWEBSOCK\n\t// LPOLL represents a long polling connection.\n\tLPOLL\n\t// GRPC is a gRPC connection\n\tGRPC\n\t// PROXY is temporary session used as a proxy at master node.\n\tPROXY\n\t// MULTIPLEX is a multiplexing session reprsenting a connection from proxy topic to master.\n\tMULTIPLEX\n)\n\n// Session represents a single WS connection or a long polling session. A user may have multiple\n// sessions.\ntype Session struct {\n\t// protocol - NONE (unset), WEBSOCK, LPOLL, GRPC, PROXY, MULTIPLEX\n\tproto SessionProto\n\n\t// Session ID\n\tsid string\n\n\t// Websocket. Set only for websocket sessions.\n\tws *websocket.Conn\n\n\t// Pointer to session's record in sessionStore. Set only for Long Poll sessions.\n\tlpTracker *list.Element\n\n\t// gRPC handle. Set only for gRPC clients.\n\tgrpcnode pbx.Node_MessageLoopServer\n\n\t// Reference to the cluster node where the session has originated. Set only for cluster RPC sessions.\n\tclnode *ClusterNode\n\n\t// Reference to multiplexing session. Set only for proxy sessions.\n\tmulti        *Session\n\tproxiedTopic string\n\n\t// IP address of the client. For long polling this is the IP of the last poll.\n\tremoteAddr string\n\n\t// User agent, a string provived by an authenticated client in {login} packet.\n\tuserAgent string\n\n\t// Protocol version of the client: ((major & 0xff) << 8) | (minor & 0xff).\n\tver int\n\n\t// Device ID of the client\n\tdeviceID string\n\t// Platform: web, ios, android\n\tplatf string\n\t// Human language of the client\n\tlang string\n\t// Country code of the client\n\tcountryCode string\n\n\t// ID of the current user. Could be zero if session is not authenticated\n\t// or for multiplexing sessions.\n\tuid types.Uid\n\n\t// Authentication level - NONE (unset), ANON, AUTH, ROOT.\n\tauthLvl auth.Level\n\n\t// Time when the long polling session was last refreshed\n\tlastTouched time.Time\n\n\t// Time when the session received any packer from client\n\tlastAction int64\n\n\t// Timer which triggers after some seconds to mark background session as foreground.\n\tbkgTimer *time.Timer\n\n\t// Number of subscribe/unsubscribe requests in flight.\n\tinflightReqs *boundedWaitGroup\n\t// Synchronizes access to session store in cluster mode:\n\t// subscribe/unsubscribe replies are asynchronous.\n\tsessionStoreLock sync.Mutex\n\t// Indicates that the session is terminating.\n\t// After this flag's been flipped to true, there must not be any more writes\n\t// into the session's send channel.\n\t// Read/written atomically.\n\t// 0 = false\n\t// 1 = true\n\tterminating int32\n\n\t// Background session: subscription presence notifications and online status are delayed.\n\tbackground bool\n\n\t// Outbound mesages, buffered.\n\t// The content must be serialized in format suitable for the session.\n\tsend chan any\n\n\t// Channel for shutting down the session, buffer 1.\n\t// Content in the same format as for 'send'\n\tstop chan any\n\n\t// detach - channel for detaching session from topic, buffered.\n\t// Content is topic name to detach from.\n\tdetach chan string\n\n\t// Map of topic subscriptions, indexed by topic name.\n\t// Don't access directly. Use getters/setters.\n\tsubs map[string]*Subscription\n\t// Mutex for subs access: both topic go routines and network go routines access\n\t// subs concurrently.\n\tsubsLock sync.RWMutex\n\n\t// Needed for long polling and grpc.\n\tlock sync.Mutex\n\n\t// Field used only in cluster mode by topic master node.\n\n\t// Type of proxy to master request being handled.\n\tproxyReq ProxyReqType\n}\n\n// Subscription is a mapper of sessions to topics.\ntype Subscription struct {\n\t// Channel to communicate with the topic, copy of Topic.clientMsg\n\tbroadcast chan<- *ClientComMessage\n\n\t// Session sends a signal to Topic when this session is unsubscribed\n\t// This is a copy of Topic.unreg\n\tdone chan<- *ClientComMessage\n\n\t// Channel to send {meta} requests, copy of Topic.meta\n\tmeta chan<- *ClientComMessage\n\n\t// Channel to ping topic with session's updates, copy of Topic.supd\n\tsupd chan<- *sessionUpdate\n}\n\nfunc (s *Session) addSub(topic string, sub *Subscription) {\n\tif s.multi != nil {\n\t\ts.multi.addSub(topic, sub)\n\t\treturn\n\t}\n\ts.subsLock.Lock()\n\n\t// Sessions that serve as an interface between proxy topics and their masters (proxy sessions)\n\t// may have only one subscription, that is, to its master topic.\n\t// Normal sessions may be subscribed to multiple topics.\n\n\tif !s.isMultiplex() || s.countSub() == 0 {\n\t\ts.subs[topic] = sub\n\t}\n\ts.subsLock.Unlock()\n}\n\nfunc (s *Session) getSub(topic string) *Subscription {\n\t// Don't check s.multi here. Let it panic if called for proxy session.\n\n\ts.subsLock.RLock()\n\tdefer s.subsLock.RUnlock()\n\n\treturn s.subs[topic]\n}\n\nfunc (s *Session) delSub(topic string) {\n\tif s.multi != nil {\n\t\ts.multi.delSub(topic)\n\t\treturn\n\t}\n\ts.subsLock.Lock()\n\tdelete(s.subs, topic)\n\ts.subsLock.Unlock()\n}\n\nfunc (s *Session) countSub() int {\n\tif s.multi != nil {\n\t\treturn s.multi.countSub()\n\t}\n\treturn len(s.subs)\n}\n\n// Inform topics that the session is being terminated.\n// No need to check for s.multi because it's not called for PROXY sessions.\nfunc (s *Session) unsubAll() {\n\ts.subsLock.RLock()\n\tdefer s.subsLock.RUnlock()\n\n\tfor _, sub := range s.subs {\n\t\t// sub.done is the same as topic.unreg\n\t\t// The whole session is being dropped; ClientComMessage is a wrapper for session, ClientComMessage.init is false.\n\t\t// keep redundant init: false so it can be searched for.\n\t\tsub.done <- &ClientComMessage{sess: s, init: false}\n\t}\n}\n\n// Indicates whether this session is a local interface for a remote proxy topic.\n// It multiplexes multiple sessions.\nfunc (s *Session) isMultiplex() bool {\n\treturn s.proto == MULTIPLEX\n}\n\n// Indicates whether this session is a short-lived proxy for a remote session.\nfunc (s *Session) isProxy() bool {\n\treturn s.proto == PROXY\n}\n\n// Cluster session: either a proxy or a multiplexing session.\nfunc (s *Session) isCluster() bool {\n\treturn s.isProxy() || s.isMultiplex()\n}\n\nfunc (s *Session) scheduleClusterWriteLoop() {\n\tif globals.cluster != nil && globals.cluster.proxyEventQueue != nil {\n\t\tglobals.cluster.proxyEventQueue.Schedule(\n\t\t\tfunc() { s.clusterWriteLoop(s.proxiedTopic) })\n\t}\n}\n\nfunc (s *Session) supportsMessageBatching() bool {\n\tswitch s.proto {\n\tcase WEBSOCK:\n\t\treturn true\n\tcase GRPC:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// queueOut attempts to send a list of ServerComMessages to a session write loop;\n// it fails if the send buffer is full.\nfunc (s *Session) queueOutBatch(msgs []*ServerComMessage) bool {\n\tif s == nil {\n\t\treturn true\n\t}\n\tif atomic.LoadInt32(&s.terminating) > 0 {\n\t\treturn true\n\t}\n\n\tif s.multi != nil {\n\t\t// In case of a cluster we need to pass a copy of the actual session.\n\t\tfor i := range msgs {\n\t\t\tmsgs[i].sess = s\n\t\t}\n\t\tif s.multi.queueOutBatch(msgs) {\n\t\t\ts.multi.scheduleClusterWriteLoop()\n\t\t\treturn true\n\t\t}\n\t\treturn false\n\t}\n\n\tif s.supportsMessageBatching() {\n\t\tselect {\n\t\tcase s.send <- msgs:\n\t\tdefault:\n\t\t\t// Never block here since it may also block the topic's run() goroutine.\n\t\t\tlogs.Err.Println(\"s.queueOut: session's send queue2 full\", s.sid)\n\t\t\treturn false\n\t\t}\n\t\tif s.isMultiplex() {\n\t\t\ts.scheduleClusterWriteLoop()\n\t\t}\n\t} else {\n\t\tfor _, msg := range msgs {\n\t\t\ts.queueOut(msg)\n\t\t}\n\t}\n\n\treturn true\n}\n\n// queueOut attempts to send a ServerComMessage to a session write loop;\n// it fails, if the send buffer is full.\nfunc (s *Session) queueOut(msg *ServerComMessage) bool {\n\tif s == nil {\n\t\treturn true\n\t}\n\tif atomic.LoadInt32(&s.terminating) > 0 {\n\t\treturn true\n\t}\n\n\tif s.multi != nil {\n\t\t// In case of a cluster we need to pass a copy of the actual session.\n\t\tmsg.sess = s\n\t\tif s.multi.queueOut(msg) {\n\t\t\ts.multi.scheduleClusterWriteLoop()\n\t\t\treturn true\n\t\t}\n\t\treturn false\n\t}\n\n\t// Record latency only on {ctrl} messages and end-user sessions.\n\tif msg.Ctrl != nil && msg.Id != \"\" {\n\t\tif !msg.Ctrl.Timestamp.IsZero() && !s.isCluster() {\n\t\t\tduration := time.Since(msg.Ctrl.Timestamp).Milliseconds()\n\t\t\tstatsAddHistSample(\"RequestLatency\", float64(duration))\n\t\t}\n\t\tif 200 <= msg.Ctrl.Code && msg.Ctrl.Code < 600 {\n\t\t\tstatsInc(fmt.Sprintf(\"CtrlCodesTotal%dxx\", msg.Ctrl.Code/100), 1)\n\t\t} else {\n\t\t\tlogs.Warn.Println(\"Invalid response code: \", msg.Ctrl.Code)\n\t\t}\n\t}\n\n\tselect {\n\tcase s.send <- msg:\n\tdefault:\n\t\t// Never block here since it may also block the topic's run() goroutine.\n\t\tlogs.Err.Println(\"s.queueOut: session's send queue full\", s.sid)\n\t\treturn false\n\t}\n\tif s.isMultiplex() {\n\t\ts.scheduleClusterWriteLoop()\n\t}\n\treturn true\n}\n\n// queueOutBytes attempts to send a ServerComMessage already serialized to []byte.\n// If the send buffer is full, it fails.\nfunc (s *Session) queueOutBytes(data []byte) bool {\n\tif s == nil || atomic.LoadInt32(&s.terminating) > 0 {\n\t\treturn true\n\t}\n\n\tselect {\n\tcase s.send <- data:\n\tdefault:\n\t\tlogs.Err.Println(\"s.queueOutBytes: session's send queue full\", s.sid)\n\t\treturn false\n\t}\n\tif s.isMultiplex() {\n\t\ts.scheduleClusterWriteLoop()\n\t}\n\treturn true\n}\n\nfunc (s *Session) maybeScheduleClusterWriteLoop() {\n\tif s.multi != nil {\n\t\ts.multi.scheduleClusterWriteLoop()\n\t\treturn\n\t}\n\tif s.isMultiplex() {\n\t\ts.scheduleClusterWriteLoop()\n\t}\n}\n\nfunc (s *Session) detachSession(fromTopic string) {\n\tif atomic.LoadInt32(&s.terminating) == 0 {\n\t\ts.detach <- fromTopic\n\t\ts.maybeScheduleClusterWriteLoop()\n\t}\n}\n\nfunc (s *Session) stopSession(data any) {\n\ts.stop <- data\n\ts.maybeScheduleClusterWriteLoop()\n}\n\nfunc (s *Session) purgeChannels() {\n\tfor len(s.send) > 0 {\n\t\t<-s.send\n\t}\n\tfor len(s.stop) > 0 {\n\t\t<-s.stop\n\t}\n\tfor len(s.detach) > 0 {\n\t\t<-s.detach\n\t}\n}\n\n// cleanUp is called when the session is terminated to perform resource cleanup.\nfunc (s *Session) cleanUp(expired bool) {\n\tatomic.StoreInt32(&s.terminating, 1)\n\ts.purgeChannels()\n\ts.inflightReqs.Wait()\n\ts.inflightReqs = nil\n\tif !expired {\n\t\ts.sessionStoreLock.Lock()\n\t\tglobals.sessionStore.Delete(s)\n\t\ts.sessionStoreLock.Unlock()\n\t}\n\n\ts.background = false\n\ts.bkgTimer.Stop()\n\ts.unsubAll()\n\t// Stop the write loop.\n\ts.stopSession(nil)\n}\n\n// Message received, convert bytes to ClientComMessage and dispatch\nfunc (s *Session) dispatchRaw(raw []byte) {\n\tnow := types.TimeNow()\n\tvar msg ClientComMessage\n\n\tif atomic.LoadInt32(&s.terminating) > 0 {\n\t\tlogs.Warn.Println(\"s.dispatch: message received on a terminating session\", s.sid)\n\t\ts.queueOut(ErrLocked(\"\", \"\", now))\n\t\treturn\n\t}\n\n\tif len(raw) == 1 && raw[0] == 0x31 {\n\t\t// 0x31 == '1'. This is a network probe message. Respond with a '0':\n\t\ts.queueOutBytes([]byte{0x30})\n\t\treturn\n\t}\n\n\ttoLog := raw\n\ttruncated := \"\"\n\tif len(raw) > 512 {\n\t\ttoLog = raw[:512]\n\t\ttruncated = \"<...>\"\n\t}\n\tlogs.Info.Printf(\"in: '%s%s' sid='%s' uid='%s'\", toLog, truncated, s.sid, s.uid)\n\n\tif err := json.Unmarshal(raw, &msg); err != nil {\n\t\t// Malformed message\n\t\tlogs.Warn.Println(\"s.dispatch\", err, s.sid)\n\t\ts.queueOut(ErrMalformed(\"\", \"\", now))\n\t\treturn\n\t}\n\n\ts.dispatch(&msg)\n}\n\nfunc (s *Session) dispatch(msg *ClientComMessage) {\n\tnow := types.TimeNow()\n\tatomic.StoreInt64(&s.lastAction, now.UnixNano())\n\n\t// This should be the first block here, before any other checks.\n\tvar resp *ServerComMessage\n\tif msg, resp = pluginFireHose(s, msg); resp != nil {\n\t\t// Plugin provided a response. No further processing is needed.\n\t\ts.queueOut(resp)\n\t\treturn\n\t} else if msg == nil {\n\t\t// Plugin requested to silently drop the request.\n\t\treturn\n\t}\n\n\tif msg.Extra == nil || (msg.Extra.AsUser == \"\" && msg.Extra.AuthLevel == \"\") {\n\t\t// Use current user's ID and auth level.\n\t\tmsg.AsUser = s.uid.UserId()\n\t\tmsg.AuthLvl = int(s.authLvl)\n\t} else if s.authLvl != auth.LevelRoot {\n\t\t// Only root user can set alternative user ID and auth level values.\n\t\ts.queueOut(ErrPermissionDenied(\"\", \"\", now))\n\t\tlogs.Warn.Println(\"s.dispatch: non-root assigned asUser\", s.sid)\n\t\treturn\n\t} else if fromUid := types.ParseUserId(msg.Extra.AsUser); fromUid.IsZero() {\n\t\t// Invalid msg.Extra.AsUser.\n\t\ts.queueOut(ErrMalformed(\"\", \"\", now))\n\t\tlogs.Warn.Println(\"s.dispatch: malformed asUser: \", msg.Extra.AsUser, s.sid)\n\t\treturn\n\t} else {\n\t\t// Use provided msg.Extra.AsUser\n\t\tmsg.AsUser = msg.Extra.AsUser\n\n\t\t// Assign auth level, if one is provided. Ignore invalid strings.\n\t\tif authLvl := auth.ParseAuthLevel(msg.Extra.AuthLevel); authLvl == auth.LevelNone {\n\t\t\t// AuthLvl is not set by the caller, assign default LevelAuth.\n\t\t\tmsg.AuthLvl = int(auth.LevelAuth)\n\t\t} else {\n\t\t\tmsg.AuthLvl = int(authLvl)\n\t\t}\n\t}\n\n\tmsg.Timestamp = now\n\n\tvar handler func(*ClientComMessage)\n\tvar uaRefresh bool\n\n\t// Check if s.ver is defined\n\tcheckVers := func(handler func(*ClientComMessage)) func(*ClientComMessage) {\n\t\treturn func(m *ClientComMessage) {\n\t\t\tif s.ver == 0 {\n\t\t\t\tlogs.Warn.Println(\"s.dispatch: {hi} is missing\", s.sid)\n\t\t\t\ts.queueOut(ErrCommandOutOfSequence(m.Id, m.Original, msg.Timestamp))\n\t\t\t\treturn\n\t\t\t}\n\t\t\thandler(m)\n\t\t}\n\t}\n\n\t// Check if user is logged in\n\tcheckUser := func(handler func(*ClientComMessage)) func(*ClientComMessage) {\n\t\treturn func(m *ClientComMessage) {\n\t\t\tif msg.AsUser == \"\" {\n\t\t\t\tlogs.Warn.Println(\"s.dispatch: authentication required\", s.sid)\n\t\t\t\ts.queueOut(ErrAuthRequiredReply(m, m.Timestamp))\n\t\t\t\treturn\n\t\t\t}\n\t\t\thandler(m)\n\t\t}\n\t}\n\n\tswitch {\n\tcase msg.Pub != nil:\n\t\thandler = checkVers(checkUser(s.publish))\n\t\tmsg.Id = msg.Pub.Id\n\t\tmsg.Original = msg.Pub.Topic\n\t\tuaRefresh = true\n\n\tcase msg.Sub != nil:\n\t\thandler = checkVers(checkUser(s.subscribe))\n\t\tmsg.Id = msg.Sub.Id\n\t\tmsg.Original = msg.Sub.Topic\n\t\tuaRefresh = true\n\n\tcase msg.Leave != nil:\n\t\thandler = checkVers(checkUser(s.leave))\n\t\tmsg.Id = msg.Leave.Id\n\t\tmsg.Original = msg.Leave.Topic\n\n\tcase msg.Hi != nil:\n\t\thandler = s.hello\n\t\tmsg.Id = msg.Hi.Id\n\n\tcase msg.Login != nil:\n\t\thandler = checkVers(s.login)\n\t\tmsg.Id = msg.Login.Id\n\n\tcase msg.Get != nil:\n\t\thandler = checkVers(checkUser(s.get))\n\t\tmsg.Id = msg.Get.Id\n\t\tmsg.Original = msg.Get.Topic\n\t\tuaRefresh = true\n\n\tcase msg.Set != nil:\n\t\thandler = checkVers(checkUser(s.set))\n\t\tmsg.Id = msg.Set.Id\n\t\tmsg.Original = msg.Set.Topic\n\t\tuaRefresh = true\n\n\tcase msg.Del != nil:\n\t\thandler = checkVers(checkUser(s.del))\n\t\tmsg.Id = msg.Del.Id\n\t\tmsg.Original = msg.Del.Topic\n\n\tcase msg.Acc != nil:\n\t\thandler = checkVers(s.acc)\n\t\tmsg.Id = msg.Acc.Id\n\n\tcase msg.Note != nil:\n\t\t// If user is not authenticated or version not set the {note} is silently ignored.\n\t\thandler = s.note\n\t\tmsg.Original = msg.Note.Topic\n\t\tuaRefresh = true\n\n\tdefault:\n\t\t// Unknown message\n\t\ts.queueOut(ErrMalformed(\"\", \"\", msg.Timestamp))\n\t\tlogs.Warn.Println(\"s.dispatch: unknown message\", s.sid)\n\t\treturn\n\t}\n\n\tif globals.cluster.isPartitioned() {\n\t\t// The cluster is partitioned due to network or other failure and this node is a part of the smaller partition.\n\t\t// In order to avoid data inconsistency across the cluster we must reject all requests.\n\t\ts.queueOut(ErrClusterUnreachableReply(msg, msg.Timestamp))\n\t\treturn\n\t}\n\n\tmsg.sess = s\n\tmsg.init = true\n\thandler(msg)\n\n\t// Notify 'me' topic that this session is currently active.\n\tif uaRefresh && msg.AsUser != \"\" && s.userAgent != \"\" {\n\t\tif sub := s.getSub(msg.AsUser); sub != nil {\n\t\t\t// The chan is buffered. If the buffer is exhaused, the session will wait for 'me' to become available\n\t\t\tsub.supd <- &sessionUpdate{userAgent: s.userAgent}\n\t\t}\n\t}\n}\n\n// Request to subscribe to a topic.\nfunc (s *Session) subscribe(msg *ClientComMessage) {\n\tif strings.HasPrefix(msg.Original, \"new\") || strings.HasPrefix(msg.Original, \"nch\") {\n\t\t// Request to create a new group/channel topic.\n\t\t// If we are in a cluster, make sure the new topic belongs to the current node.\n\t\tmsg.RcptTo = globals.cluster.genLocalTopicName()\n\t} else {\n\t\tvar resp *ServerComMessage\n\t\tmsg.RcptTo, resp = s.expandTopicName(msg)\n\t\tif resp != nil {\n\t\t\ts.queueOut(resp)\n\t\t\treturn\n\t\t}\n\t}\n\n\ts.inflightReqs.Add(1)\n\t// Session can subscribe to topic on behalf of a single user at a time.\n\tif sub := s.getSub(msg.RcptTo); sub != nil {\n\t\ts.queueOut(InfoAlreadySubscribed(msg.Id, msg.Original, msg.Timestamp))\n\t\ts.inflightReqs.Done()\n\t} else {\n\t\tselect {\n\t\tcase globals.hub.join <- msg:\n\t\tdefault:\n\t\t\t// Reply with a 503 to the user.\n\t\t\ts.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))\n\t\t\ts.inflightReqs.Done()\n\t\t\tlogs.Err.Println(\"s.subscribe: hub.join queue full, topic \", msg.RcptTo, s.sid)\n\t\t}\n\t\t// Hub will send Ctrl success/failure packets back to session\n\t}\n}\n\n// Leave/Unsubscribe a topic\nfunc (s *Session) leave(msg *ClientComMessage) {\n\t// Expand topic name\n\tvar resp *ServerComMessage\n\tmsg.RcptTo, resp = s.expandTopicName(msg)\n\tif resp != nil {\n\t\ts.queueOut(resp)\n\t\treturn\n\t}\n\n\ts.inflightReqs.Add(1)\n\tif sub := s.getSub(msg.RcptTo); sub != nil {\n\t\t// Session is attached to the topic.\n\t\tif (msg.Original == \"me\" || msg.Original == \"fnd\") && msg.Leave.Unsub {\n\t\t\t// User should not unsubscribe from 'me' or 'find'. Just leaving is fine.\n\t\t\ts.queueOut(ErrPermissionDeniedReply(msg, msg.Timestamp))\n\t\t\ts.inflightReqs.Done()\n\t\t} else {\n\t\t\t// Unlink from topic, topic will send a reply.\n\t\t\tsub.done <- msg\n\t\t}\n\t\treturn\n\t}\n\ts.inflightReqs.Done()\n\tif !msg.Leave.Unsub {\n\t\t// Session is not attached to the topic, wants to leave - fine, no change\n\t\ts.queueOut(InfoNotJoined(msg.Id, msg.Original, msg.Timestamp))\n\t} else {\n\t\t// Session wants to unsubscribe from the topic it did not join\n\t\t// TODO(gene): allow topic to unsubscribe without joining first; send to hub to unsub\n\t\tlogs.Warn.Println(\"s.leave:\", \"must attach first\", s.sid)\n\t\ts.queueOut(ErrAttachFirst(msg, msg.Timestamp))\n\t}\n}\n\n// Broadcast a message to all topic subscribers\nfunc (s *Session) publish(msg *ClientComMessage) {\n\t// TODO(gene): Check for repeated messages with the same ID\n\tvar resp *ServerComMessage\n\tmsg.RcptTo, resp = s.expandTopicName(msg)\n\tif resp != nil {\n\t\ts.queueOut(resp)\n\t\treturn\n\t}\n\n\t// Add \"sender\" header if the message is sent on behalf of another user.\n\tif msg.AsUser != s.uid.UserId() {\n\t\tif msg.Pub.Head == nil {\n\t\t\tmsg.Pub.Head = make(map[string]any)\n\t\t}\n\t\tmsg.Pub.Head[\"sender\"] = s.uid.UserId()\n\t} else if msg.Pub.Head != nil {\n\t\t// Clear potentially false \"sender\" field.\n\t\tdelete(msg.Pub.Head, \"sender\")\n\t\tif len(msg.Pub.Head) == 0 {\n\t\t\tmsg.Pub.Head = nil\n\t\t}\n\t}\n\n\tif sub := s.getSub(msg.RcptTo); sub != nil {\n\t\t// This is a post to a subscribed topic. The message is sent to the topic only\n\t\tselect {\n\t\tcase sub.broadcast <- msg:\n\t\tdefault:\n\t\t\t// Reply with a 503 to the user.\n\t\t\ts.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))\n\t\t\tlogs.Err.Println(\"s.publish: sub.broadcast channel full, topic \", msg.RcptTo, s.sid)\n\t\t}\n\t} else if msg.RcptTo == \"sys\" {\n\t\t// Publishing to \"sys\" topic requires no subscription.\n\t\tselect {\n\t\tcase globals.hub.routeCli <- msg:\n\t\tdefault:\n\t\t\t// Reply with a 503 to the user.\n\t\t\ts.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))\n\t\t\tlogs.Err.Println(\"s.publish: hub.route channel full\", s.sid)\n\t\t}\n\t} else {\n\t\t// Publish request received without attaching to topic first.\n\t\ts.queueOut(ErrAttachFirst(msg, msg.Timestamp))\n\t\tlogs.Warn.Printf(\"s.publish[%s]: must attach first %s\", msg.RcptTo, s.sid)\n\t}\n}\n\n// Client metadata\nfunc (s *Session) hello(msg *ClientComMessage) {\n\tvar params map[string]any\n\tvar deviceIDUpdate bool\n\n\tif s.ver == 0 {\n\t\ts.ver = parseVersion(msg.Hi.Version)\n\t\tif s.ver == 0 {\n\t\t\tlogs.Warn.Println(\"s.hello:\", \"failed to parse version\", s.sid)\n\t\t\ts.queueOut(ErrMalformed(msg.Id, \"\", msg.Timestamp))\n\t\t\treturn\n\t\t}\n\t\t// Check version compatibility\n\t\tif versionCompare(s.ver, minSupportedVersionValue) < 0 {\n\t\t\ts.ver = 0\n\t\t\ts.queueOut(ErrVersionNotSupported(msg.Id, msg.Timestamp))\n\t\t\tlogs.Warn.Println(\"s.hello:\", \"unsupported version\", s.sid)\n\t\t\treturn\n\t\t}\n\n\t\tparams = map[string]any{\n\t\t\t\"ver\":                currentVersion,\n\t\t\t\"build\":              store.Store.GetAdapterName() + \":\" + buildstamp,\n\t\t\t\"maxMessageSize\":     globals.maxMessageSize,\n\t\t\t\"maxSubscriberCount\": globals.maxSubscriberCount,\n\t\t\t\"minTagLength\":       minTagLength,\n\t\t\t\"maxTagLength\":       maxTagLength,\n\t\t\t\"maxTagCount\":        globals.maxTagCount,\n\t\t\t\"maxFileUploadSize\":  globals.maxFileUploadSize,\n\t\t\t\"reqCred\":            globals.validatorClientConfig,\n\t\t\t\"msgDelAge\":          globals.msgDeleteAge.Seconds(),\n\t\t}\n\t\tif len(globals.iceServers) > 0 {\n\t\t\tparams[\"iceServers\"] = globals.iceServers\n\t\t}\n\t\tif globals.callEstablishmentTimeout > 0 {\n\t\t\tparams[\"callTimeout\"] = globals.callEstablishmentTimeout\n\t\t}\n\n\t\tif s.proto == GRPC {\n\t\t\t// gRPC client may need server address to be able to fetch large files over http(s).\n\t\t\t// TODO: add support for fetching files over gRPC, then remove this parameter.\n\t\t\tparams[\"servingAt\"] = globals.servingAt\n\t\t\t// Report cluster size.\n\t\t\tif globals.cluster != nil {\n\t\t\t\tparams[\"clusterSize\"] = len(globals.cluster.nodes) + 1\n\t\t\t} else {\n\t\t\t\tparams[\"clusterSize\"] = 1\n\t\t\t}\n\t\t}\n\n\t\t// Set ua & platform in the beginning of the session.\n\t\t// Don't change them later.\n\t\ts.userAgent = msg.Hi.UserAgent\n\t\ts.platf = msg.Hi.Platform\n\t\tif s.platf == \"\" {\n\t\t\ts.platf = platformFromUA(msg.Hi.UserAgent)\n\t\t}\n\t\t// This is a background session. Start a timer.\n\t\tif msg.Hi.Background {\n\t\t\ts.bkgTimer.Reset(deferredNotificationsTimeout)\n\t\t}\n\t} else if msg.Hi.Version == \"\" || parseVersion(msg.Hi.Version) == s.ver {\n\t\t// Save changed device ID+Lang or delete earlier specified device ID.\n\t\t// Platform cannot be changed.\n\t\tif !s.uid.IsZero() {\n\t\t\tvar err error\n\t\t\tif msg.Hi.DeviceID == types.NullValue {\n\t\t\t\t// User wants to delete device ID.\n\t\t\t\tdeviceIDUpdate = true\n\t\t\t\tif s.deviceID != \"\" {\n\t\t\t\t\terr = store.Devices.Delete(s.uid, s.deviceID)\n\t\t\t\t}\n\t\t\t} else if msg.Hi.DeviceID != \"\" && s.deviceID != msg.Hi.DeviceID {\n\t\t\t\tdeviceIDUpdate = true\n\t\t\t\terr = store.Devices.Update(s.uid, s.deviceID, &types.DeviceDef{\n\t\t\t\t\tDeviceId: msg.Hi.DeviceID,\n\t\t\t\t\tPlatform: s.platf,\n\t\t\t\t\tLastSeen: msg.Timestamp,\n\t\t\t\t\tLang:     msg.Hi.Lang,\n\t\t\t\t})\n\n\t\t\t\tuserChannelsSubUnsub(s.uid, msg.Hi.DeviceID, true)\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\ts.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))\n\t\t\t\tlogs.Warn.Println(\"s.hello:\", \"device ID\", err, s.sid)\n\t\t\t\treturn\n\t\t\t}\n\t\t} else {\n\t\t\t// Session is not authenticated, report an error. Otherwise,\n\t\t\t// the client may think that the device ID was updated successfully,\n\t\t\t// but it will not be saved in the database.\n\t\t\ts.queueOut(ErrAuthRequiredReply(msg, msg.Timestamp))\n\t\t\tlogs.Warn.Println(\"s.hello:\", \"device ID update requires authentication\", s.sid)\n\t\t\treturn\n\t\t}\n\t} else {\n\t\t// Version cannot be changed mid-session.\n\t\ts.queueOut(ErrCommandOutOfSequence(msg.Id, \"\", msg.Timestamp))\n\t\tlogs.Warn.Println(\"s.hello:\", \"version cannot be changed\", s.sid)\n\t\treturn\n\t}\n\n\tif msg.Hi.DeviceID == types.NullValue {\n\t\tmsg.Hi.DeviceID = \"\"\n\t}\n\ts.deviceID = msg.Hi.DeviceID\n\ts.lang = msg.Hi.Lang\n\t// Try to deduce the country from the locale.\n\t// Tag may be well-defined even if err != nil. For example, for 'zh_CN_#Hans'\n\t// the tag is 'zh-CN' exact but the err is 'tag is not well-formed'.\n\tif tag, _ := language.Parse(s.lang); tag != language.Und {\n\t\tif region, conf := tag.Region(); region.IsCountry() && conf >= language.High {\n\t\t\ts.countryCode = region.String()\n\t\t}\n\t}\n\n\tif s.countryCode == \"\" {\n\t\tif len(s.lang) > 2 {\n\t\t\t// Logging strings longer than 2 b/c language.Parse(XX) always succeeds\n\t\t\t// returning confidence Low.\n\t\t\tlogs.Warn.Println(\"s.hello:\", \"could not parse locale \", s.lang)\n\t\t}\n\t\ts.countryCode = globals.defaultCountryCode\n\t}\n\n\tvar httpStatus int\n\tvar httpStatusText string\n\tif s.proto == LPOLL || deviceIDUpdate {\n\t\t// In case of long polling StatusCreated was reported earlier.\n\t\t// In case of deviceID update just report success.\n\t\thttpStatus = http.StatusOK\n\t\thttpStatusText = \"ok\"\n\n\t} else {\n\t\thttpStatus = http.StatusCreated\n\t\thttpStatusText = \"created\"\n\t}\n\n\tctrl := &MsgServerCtrl{Id: msg.Id, Code: httpStatus, Text: httpStatusText, Timestamp: msg.Timestamp}\n\tif len(params) > 0 {\n\t\tctrl.Params = params\n\t}\n\ts.queueOut(&ServerComMessage{Ctrl: ctrl})\n}\n\n// Account creation\nfunc (s *Session) acc(msg *ClientComMessage) {\n\tnewAcc := strings.HasPrefix(msg.Acc.User, \"new\")\n\n\t// If temporary auth parameters are provided, get the user ID from them.\n\tvar rec *auth.Rec\n\tif !newAcc && msg.Acc.TmpScheme != \"\" {\n\t\tif !s.uid.IsZero() {\n\t\t\ts.queueOut(ErrAlreadyAuthenticated(msg.Acc.Id, \"\", msg.Timestamp))\n\t\t\tlogs.Warn.Println(\"s.acc: got temp auth while already authenticated\", s.sid)\n\t\t\treturn\n\t\t}\n\n\t\tauthHdl := store.Store.GetLogicalAuthHandler(msg.Acc.TmpScheme)\n\t\tif authHdl == nil {\n\t\t\tlogs.Warn.Println(\"s.acc: unknown authentication scheme\", msg.Acc.TmpScheme, s.sid)\n\t\t\ts.queueOut(ErrAuthUnknownScheme(msg.Id, \"\", msg.Timestamp))\n\t\t}\n\n\t\tvar err error\n\t\trec, _, err = authHdl.Authenticate(msg.Acc.TmpSecret, s.remoteAddr)\n\t\tif err != nil {\n\t\t\ts.queueOut(decodeStoreError(err, msg.Acc.Id, msg.Timestamp,\n\t\t\t\tmap[string]any{\"what\": \"auth\"}))\n\t\t\tlogs.Warn.Println(\"s.acc: invalid temp auth\", err, s.sid)\n\t\t\treturn\n\t\t}\n\t}\n\n\tif newAcc {\n\t\t// New account\n\t\treplyCreateUser(s, msg, rec)\n\t} else {\n\t\t// Existing account.\n\t\treplyUpdateUser(s, msg, rec)\n\t}\n}\n\n// Authenticate\nfunc (s *Session) login(msg *ClientComMessage) {\n\t// msg.from is ignored here\n\n\tif msg.Login.Scheme == \"reset\" {\n\t\tif err := s.authSecretReset(msg.Login.Secret); err != nil {\n\t\t\ts.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))\n\t\t} else {\n\t\t\ts.queueOut(InfoAuthReset(msg.Id, msg.Timestamp))\n\t\t}\n\t\treturn\n\t}\n\n\tif !s.uid.IsZero() {\n\t\t// TODO: change error to notice InfoNoChange and return current user ID & auth level\n\t\t// params := map[string]interface{}{\"user\": s.uid.UserId(), \"authlvl\": s.authLevel.String()}\n\t\ts.queueOut(ErrAlreadyAuthenticated(msg.Id, \"\", msg.Timestamp))\n\t\treturn\n\t}\n\n\thandler := store.Store.GetLogicalAuthHandler(msg.Login.Scheme)\n\tif handler == nil {\n\t\tlogs.Warn.Println(\"s.login: unknown authentication scheme\", msg.Login.Scheme, s.sid)\n\t\ts.queueOut(ErrAuthUnknownScheme(msg.Id, \"\", msg.Timestamp))\n\t\treturn\n\t}\n\n\trec, challenge, err := handler.Authenticate(msg.Login.Secret, s.remoteAddr)\n\tif err != nil {\n\t\tresp := decodeStoreError(err, msg.Id, msg.Timestamp, nil)\n\t\tif resp.Ctrl.Code >= 500 {\n\t\t\t// Log internal errors\n\t\t\tlogs.Warn.Println(\"s.login: internal\", err, s.sid)\n\t\t}\n\t\ts.queueOut(resp)\n\t\treturn\n\t}\n\n\t// If authenticator did not check user state, it returns state \"undef\". If so, check user state here.\n\tif rec.State == types.StateUndefined {\n\t\trec.State, err = userGetState(rec.Uid)\n\t}\n\tif err == nil && rec.State != types.StateOK {\n\t\terr = types.ErrPermissionDenied\n\t}\n\n\tif err != nil {\n\t\tlogs.Warn.Println(\"s.login: user state check failed\", rec.Uid, err, s.sid)\n\t\ts.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))\n\t\treturn\n\t}\n\n\tif challenge != nil {\n\t\t// Multi-stage authentication. Issue challenge to the client.\n\t\ts.queueOut(InfoChallenge(msg.Id, msg.Timestamp, challenge))\n\t\treturn\n\t}\n\n\tvar missing []string\n\tif rec.Features&auth.FeatureValidated == 0 && len(globals.authValidators[rec.AuthLevel]) > 0 {\n\t\tvar validated []string\n\t\t// Check responses. Ignore invalid responses, just keep cred unvalidated.\n\t\tif validated, _, err = validatedCreds(rec.Uid, rec.AuthLevel, msg.Login.Cred, false); err == nil {\n\t\t\t// Get a list of credentials which have not been validated.\n\t\t\t_, missing, _ = stringSliceDelta(globals.authValidators[rec.AuthLevel], validated)\n\t\t}\n\t}\n\tif err != nil {\n\t\tlogs.Warn.Println(\"s.login: failed to validate credentials:\", err, s.sid)\n\t\ts.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))\n\t} else {\n\t\ts.queueOut(s.onLogin(msg.Id, msg.Timestamp, rec, missing))\n\t}\n}\n\n// authSecretReset resets an authentication secret;\n// params: \"auth-method-to-reset:credential-method:credential-value\",\n// for example: \"basic:email:alice@example.com\".\nfunc (s *Session) authSecretReset(params []byte) error {\n\tvar authScheme, credMethod, credValue string\n\tif parts := strings.Split(string(params), \":\"); len(parts) >= 3 {\n\t\tauthScheme, credMethod, credValue = parts[0], parts[1], parts[2]\n\t} else {\n\t\treturn types.ErrMalformed\n\t}\n\n\t// Technically we don't need to check it here, but we are going to mail the 'authScheme' string to the user.\n\t// We have to make sure it does not contain any exploits. This is the simplest check.\n\tauther := store.Store.GetLogicalAuthHandler(authScheme)\n\tif auther == nil {\n\t\treturn types.ErrUnsupported\n\t}\n\tvalidator := store.Store.GetValidator(credMethod)\n\tif validator == nil {\n\t\treturn types.ErrUnsupported\n\t}\n\tuid, err := store.Users.GetByCred(credMethod, credValue)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif uid.IsZero() {\n\t\t// Prevent discovery of existing contacts: report \"no error\" if contact is not found.\n\t\treturn nil\n\t}\n\n\tresetParams, err := auther.GetResetParams(uid)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttempScheme, err := validator.TempAuthScheme()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttempAuth := store.Store.GetLogicalAuthHandler(tempScheme)\n\tif tempAuth == nil || !tempAuth.IsInitialized() {\n\t\tlogs.Err.Println(\"s.authSecretReset: validator with missing temp auth\", credMethod, tempScheme, s.sid)\n\t\treturn types.ErrInternal\n\t}\n\n\tcode, _, err := tempAuth.GenSecret(&auth.Rec{\n\t\tUid:        uid,\n\t\tAuthLevel:  auth.LevelAuth,\n\t\tFeatures:   auth.FeatureNoLogin,\n\t\tCredential: credMethod + \":\" + credValue,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn validator.ResetSecret(credValue, authScheme, s.lang, code, resetParams)\n}\n\n// onLogin performs steps after successful authentication.\nfunc (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, missing []string) *ServerComMessage {\n\tvar reply *ServerComMessage\n\tvar params map[string]any\n\n\tfeatures := rec.Features\n\n\tparams = map[string]any{\n\t\t\"user\":    rec.Uid.UserId(),\n\t\t\"authlvl\": rec.AuthLevel.String(),\n\t}\n\tif len(missing) > 0 {\n\t\t// Some credentials are not validated yet. Respond with request for validation.\n\t\treply = InfoValidateCredentials(msgID, timestamp)\n\n\t\tparams[\"cred\"] = missing\n\t} else {\n\t\t// Everything is fine, authenticate the session.\n\n\t\treply = NoErr(msgID, \"\", timestamp)\n\n\t\t// Check if the token is suitable for session authentication.\n\t\tif features&auth.FeatureNoLogin == 0 {\n\t\t\t// Authenticate the session.\n\t\t\ts.uid = rec.Uid\n\t\t\ts.authLvl = rec.AuthLevel\n\t\t\t// Reset expiration time.\n\t\t\trec.Lifetime = 0\n\t\t}\n\t\tfeatures |= auth.FeatureValidated\n\n\t\t// Record deviceId used in this session\n\t\tif s.deviceID != \"\" {\n\t\t\tif err := store.Devices.Update(rec.Uid, \"\", &types.DeviceDef{\n\t\t\t\tDeviceId: s.deviceID,\n\t\t\t\tPlatform: s.platf,\n\t\t\t\tLastSeen: timestamp,\n\t\t\t\tLang:     s.lang,\n\t\t\t}); err != nil {\n\t\t\t\tlogs.Warn.Println(\"failed to update device record\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\t// GenSecret fails only if tokenLifetime is < 0. It can't be < 0 here,\n\t// otherwise login would have failed earlier.\n\trec.Features = features\n\tparams[\"token\"], params[\"expires\"], _ = store.Store.GetLogicalAuthHandler(\"token\").GenSecret(rec)\n\n\treply.Ctrl.Params = params\n\treturn reply\n}\n\nfunc (s *Session) get(msg *ClientComMessage) {\n\t// Expand topic name.\n\tvar resp *ServerComMessage\n\tmsg.RcptTo, resp = s.expandTopicName(msg)\n\tif resp != nil {\n\t\ts.queueOut(resp)\n\t\treturn\n\t}\n\n\tmsg.MetaWhat = parseMsgClientMeta(msg.Get.What)\n\n\tsub := s.getSub(msg.RcptTo)\n\tif msg.MetaWhat == 0 {\n\t\ts.queueOut(ErrMalformedReply(msg, msg.Timestamp))\n\t\tlogs.Warn.Println(\"s.get: invalid Get message action\", msg.Get.What)\n\t} else if sub != nil {\n\t\tselect {\n\t\tcase sub.meta <- msg:\n\t\tdefault:\n\t\t\t// Reply with a 503 to the user.\n\t\t\ts.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))\n\t\t\tlogs.Err.Println(\"s.get: sub.meta channel full, topic \", msg.RcptTo, s.sid)\n\t\t}\n\t} else if msg.MetaWhat&(constMsgMetaDesc|constMsgMetaSub) != 0 {\n\t\t// Request some minimal info from a topic not currently attached to.\n\t\tselect {\n\t\tcase globals.hub.meta <- msg:\n\t\tdefault:\n\t\t\t// Reply with a 503 to the user.\n\t\t\ts.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))\n\t\t\tlogs.Err.Println(\"s.get: hub.meta channel full\", s.sid)\n\t\t}\n\t} else {\n\t\tlogs.Warn.Println(\"s.get: subscribe first to get=\", msg.Get.What)\n\t\ts.queueOut(ErrPermissionDeniedReply(msg, msg.Timestamp))\n\t}\n}\n\nfunc (s *Session) set(msg *ClientComMessage) {\n\t// Expand topic name.\n\tvar resp *ServerComMessage\n\tmsg.RcptTo, resp = s.expandTopicName(msg)\n\tif resp != nil {\n\t\ts.queueOut(resp)\n\t\treturn\n\t}\n\n\tif msg.Set.Desc != nil {\n\t\tmsg.MetaWhat = constMsgMetaDesc\n\t}\n\tif msg.Set.Sub != nil {\n\t\tmsg.MetaWhat |= constMsgMetaSub\n\t}\n\tif msg.Set.Tags != nil {\n\t\tmsg.MetaWhat |= constMsgMetaTags\n\t}\n\tif msg.Set.Cred != nil {\n\t\tmsg.MetaWhat |= constMsgMetaCred\n\t}\n\tif msg.Set.Aux != nil {\n\t\tmsg.MetaWhat |= constMsgMetaAux\n\t}\n\n\tif msg.MetaWhat == 0 {\n\t\ts.queueOut(ErrMalformedReply(msg, msg.Timestamp))\n\t\tlogs.Warn.Println(\"s.set: nil Set action\")\n\t} else if sub := s.getSub(msg.RcptTo); sub != nil {\n\t\tselect {\n\t\tcase sub.meta <- msg:\n\t\tdefault:\n\t\t\t// Reply with a 503 to the user.\n\t\t\ts.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))\n\t\t\tlogs.Err.Println(\"s.set: sub.meta channel full, topic \", msg.RcptTo, s.sid)\n\t\t}\n\t} else if msg.MetaWhat&(constMsgMetaTags|constMsgMetaCred|constMsgMetaAux) != 0 {\n\t\tlogs.Warn.Println(\"s.set: setting tags/creds/aux is allowed for subscribed topics only\", msg.MetaWhat)\n\t\ts.queueOut(ErrPermissionDeniedReply(msg, msg.Timestamp))\n\t} else {\n\t\t// Desc.Private and Sub updates are possible without the subscription.\n\t\tselect {\n\t\tcase globals.hub.meta <- msg:\n\t\tdefault:\n\t\t\t// Reply with a 503 to the user.\n\t\t\ts.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))\n\t\t\tlogs.Err.Println(\"s.set: hub.meta channel full\", s.sid)\n\t\t}\n\t}\n}\n\nfunc (s *Session) del(msg *ClientComMessage) {\n\tmsg.MetaWhat = parseMsgClientDel(msg.Del.What)\n\n\t// Delete user\n\tif msg.MetaWhat == constMsgDelUser {\n\t\treplyDelUser(s, msg)\n\t\treturn\n\t}\n\n\t// Delete something other than user: topic, subscription, message(s)\n\n\t// Expand topic name and validate request.\n\tvar resp *ServerComMessage\n\tmsg.RcptTo, resp = s.expandTopicName(msg)\n\tif resp != nil {\n\t\ts.queueOut(resp)\n\t\treturn\n\t}\n\n\tif msg.MetaWhat == 0 {\n\t\ts.queueOut(ErrMalformedReply(msg, msg.Timestamp))\n\t\tlogs.Warn.Println(\"s.del: invalid Del action\", msg.Del.What, s.sid)\n\t\treturn\n\t}\n\n\tif msg.MetaWhat == constMsgDelTopic {\n\t\t// Deleting topic: for sessions attached or not attached, send request to hub first.\n\t\t// Hub will forward to topic, if appropriate.\n\t\tselect {\n\t\tcase globals.hub.unreg <- &topicUnreg{\n\t\t\trcptTo: msg.RcptTo,\n\t\t\tpkt:    msg,\n\t\t\tsess:   s,\n\t\t\tdel:    true,\n\t\t}:\n\t\tdefault:\n\t\t\t// Reply with a 503 to the user.\n\t\t\ts.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))\n\t\t\tlogs.Err.Println(\"s.del: hub.unreg channel full\", s.sid)\n\t\t}\n\t} else if sub := s.getSub(msg.RcptTo); sub != nil {\n\t\t// Session is attached, deleting subscription or messages. Send to topic.\n\t\tselect {\n\t\tcase sub.meta <- msg:\n\t\tdefault:\n\t\t\t// Reply with a 503 to the user.\n\t\t\ts.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))\n\t\t\tlogs.Err.Println(\"s.del: sub.meta channel full, topic \", msg.RcptTo, s.sid)\n\t\t}\n\t} else {\n\t\t// Must join the topic to delete messages or subscriptions.\n\t\ts.queueOut(ErrAttachFirst(msg, msg.Timestamp))\n\t\tlogs.Warn.Println(\"s.del: invalid Del action while unsubbed\", msg.Del.What, s.sid)\n\t}\n}\n\n// Broadcast a transient message to active topic subscribers.\n// Not reporting any errors.\nfunc (s *Session) note(msg *ClientComMessage) {\n\tif s.ver == 0 || msg.AsUser == \"\" {\n\t\t// Silently ignore the message: have not received {hi} or don't know who sent the message.\n\t\treturn\n\t}\n\n\t// Expand topic name and validate request.\n\tvar resp *ServerComMessage\n\tmsg.RcptTo, resp = s.expandTopicName(msg)\n\tif resp != nil {\n\t\t// Silently ignoring the message\n\t\treturn\n\t}\n\n\tswitch msg.Note.What {\n\tcase \"data\":\n\t\tif msg.Note.Payload == nil {\n\t\t\t// Payload must be present in 'data' notifications.\n\t\t\treturn\n\t\t}\n\tcase \"kp\", \"kpa\", \"kpv\":\n\t\tif msg.Note.SeqId != 0 {\n\t\t\treturn\n\t\t}\n\tcase \"call\":\n\t\tif types.GetTopicCat(msg.RcptTo) != types.TopicCatP2P {\n\t\t\t// Calls are only available in P2P topics.\n\t\t\treturn\n\t\t}\n\t\tfallthrough\n\tcase \"read\", \"recv\":\n\t\tif msg.Note.SeqId <= 0 {\n\t\t\treturn\n\t\t}\n\tdefault:\n\t\treturn\n\t}\n\n\tif sub := s.getSub(msg.RcptTo); sub != nil {\n\t\t// Pings can be sent to subscribed topics only\n\t\tselect {\n\t\tcase sub.broadcast <- msg:\n\t\tdefault:\n\t\t\t// Reply with a 503 to the user.\n\t\t\ts.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))\n\t\t\tlogs.Err.Println(\"s.note: sub.broacast channel full, topic \", msg.RcptTo, s.sid)\n\t\t}\n\t} else if msg.Note.What == \"recv\" || (msg.Note.What == \"call\" && (msg.Note.Event == \"ringing\" || msg.Note.Event == \"hang-up\" || msg.Note.Event == \"accept\")) {\n\t\t// One of the following events happened:\n\t\t// 1. Client received a pres notification about a new message, initiated a fetch\n\t\t// from the server (and detached from the topic) and acknowledges receipt.\n\t\t// 2. Client is either accepting or terminating the current video call or\n\t\t// letting the initiator of the call know that it is ringing/notifying\n\t\t// the user about the call.\n\t\t//\n\t\t// Hub will forward to topic, if appropriate.\n\t\tselect {\n\t\tcase globals.hub.routeCli <- msg:\n\t\tdefault:\n\t\t\t// Reply with a 503 to the user.\n\t\t\ts.queueOut(ErrServiceUnavailableReply(msg, msg.Timestamp))\n\t\t\tlogs.Err.Println(\"s.note: hub.route channel full\", s.sid)\n\t\t}\n\t} else {\n\t\ts.queueOut(ErrAttachFirst(msg, msg.Timestamp))\n\t\tlogs.Warn.Println(\"s.note: note to invalid topic - must subscribe first\", msg.Note.What, s.sid)\n\t}\n}\n\n// expandTopicName expands session specific topic name to global name\n// Returns\n//\n//\ttopic: session-specific topic name the message recipient should see\n//\trouteTo: routable global topic name\n//\terr: *ServerComMessage with an error to return to the sender\nfunc (s *Session) expandTopicName(msg *ClientComMessage) (string, *ServerComMessage) {\n\tif msg.Original == \"\" {\n\t\tlogs.Warn.Println(\"s.etn: empty topic name\", s.sid)\n\t\treturn \"\", ErrMalformed(msg.Id, \"\", msg.Timestamp)\n\t}\n\n\t// Expanded name of the topic to route to i.e. rcptto: or s.subs[routeTo]\n\tvar routeTo string\n\tif msg.Original == \"me\" {\n\t\trouteTo = msg.AsUser\n\t} else if msg.Original == \"fnd\" {\n\t\trouteTo = types.ParseUserId(msg.AsUser).FndName()\n\t} else if msg.Original == \"slf\" {\n\t\trouteTo = types.ParseUserId(msg.AsUser).SlfName()\n\t} else if strings.HasPrefix(msg.Original, \"usr\") {\n\t\t// p2p topic\n\t\tuid1 := types.ParseUserId(msg.AsUser)\n\t\tuid2 := types.ParseUserId(msg.Original)\n\t\tif uid2.IsZero() {\n\t\t\t// Ensure the user id is valid.\n\t\t\tlogs.Warn.Println(\"s.etn: failed to parse p2p topic name\", s.sid)\n\t\t\treturn \"\", ErrMalformed(msg.Id, msg.Original, msg.Timestamp)\n\t\t} else if uid2 == uid1 {\n\t\t\t// Use 'me' to access self-topic.\n\t\t\tlogs.Warn.Println(\"s.etn: invalid p2p self-subscription\", s.sid)\n\t\t\treturn \"\", ErrPermissionDeniedReply(msg, msg.Timestamp)\n\t\t}\n\t\trouteTo = uid1.P2PName(uid2)\n\t} else if tmp := types.ChnToGrp(msg.Original); tmp != \"\" {\n\t\trouteTo = tmp\n\t} else {\n\t\trouteTo = msg.Original\n\t}\n\n\treturn routeTo, nil\n}\n\nfunc (s *Session) serializeAndUpdateStats(msg *ServerComMessage) any {\n\tdataSize, data := s.serialize(msg)\n\tif dataSize >= 0 {\n\t\tstatsAddHistSample(\"OutgoingMessageSize\", float64(dataSize))\n\t}\n\treturn data\n}\n\nfunc (s *Session) serialize(msg *ServerComMessage) (int, any) {\n\tif s.proto == GRPC {\n\t\tmsg := pbServSerialize(msg)\n\t\t// TODO: calculate and return the size of `msg`.\n\t\treturn -1, msg\n\t}\n\n\tif s.isMultiplex() {\n\t\t// No need to serialize the message to bytes within the cluster.\n\t\treturn -1, msg\n\t}\n\n\tout, _ := json.Marshal(msg)\n\treturn len(out), out\n}\n\n// onBackgroundTimer marks background session as foreground and informs topics it's subscribed to.\nfunc (s *Session) onBackgroundTimer() {\n\ts.subsLock.RLock()\n\tdefer s.subsLock.RUnlock()\n\n\tupdate := &sessionUpdate{sess: s}\n\tfor _, sub := range s.subs {\n\t\tif sub.supd != nil {\n\t\t\tsub.supd <- update\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "server/session_test.go",
    "content": "package main\n\nimport (\n\t\"net/http\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/golang/mock/gomock\"\n\t\"github.com/tinode/chat/server/auth\"\n\t\"github.com/tinode/chat/server/auth/mock_auth\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/mock_store\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\nfunc test_makeSession(uid types.Uid) *Session {\n\treturn &Session{\n\t\tsend:         make(chan any, 10),\n\t\tuid:          uid,\n\t\tauthLvl:      auth.LevelAuth,\n\t\tinflightReqs: newBoundedWaitGroup(1),\n\t\tver:          22,\n\t}\n}\n\nfunc TestDispatchHello(t *testing.T) {\n\ts := &Session{\n\t\tsend:    make(chan any, 10),\n\t\tuid:     types.Uid(1),\n\t\tauthLvl: auth.LevelAuth,\n\t}\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\tmsg := &ClientComMessage{\n\t\tHi: &MsgClientHi{\n\t\t\tId:        \"123\",\n\t\t\tVersion:   \"1\",\n\t\t\tUserAgent: \"test-ua\",\n\t\t\tLang:      \"en-GB\",\n\t\t},\n\t}\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\tif len(r.messages) != 1 {\n\t\tt.Errorf(\"responses: expected 1, received %d.\", len(r.messages))\n\t}\n\tresp := r.messages[0].(*ServerComMessage)\n\tif resp == nil {\n\t\tt.Fatal(\"Response must be ServerComMessage\")\n\t}\n\tif resp.Ctrl != nil {\n\t\tif resp.Ctrl.Code != 201 {\n\t\t\tt.Errorf(\"Response code: expected 201, got %d\", resp.Ctrl.Code)\n\t\t}\n\t\tif resp.Ctrl.Params == nil {\n\t\t\tt.Error(\"Response is expected to contain params dict.\")\n\t\t}\n\t} else {\n\t\tt.Error(\"Response must contain a ctrl message.\")\n\t}\n\n\tif s.lang != \"en-GB\" {\n\t\tt.Errorf(\"Session language expected to be 'en-GB' vs '%s'\", s.lang)\n\t}\n\tif s.userAgent != \"test-ua\" {\n\t\tt.Errorf(\"Session UA expected to be 'test-ua' vs '%s'\", s.userAgent)\n\t}\n\tif s.countryCode != \"GB\" {\n\t\tt.Errorf(\"Country code expected to be 'GB' vs '%s'\", s.countryCode)\n\t}\n\tif s.ver == 0 {\n\t\tt.Errorf(\"s.ver expected 0 vs found %d\", s.ver)\n\t}\n}\n\nfunc verifyResponseCodes(r *responses, codes []int, t *testing.T) {\n\tif len(r.messages) != len(codes) {\n\t\tt.Errorf(\"responses: expected %d, received %d.\", len(codes), len(r.messages))\n\t}\n\tfor i := range codes {\n\t\tresp := r.messages[i].(*ServerComMessage)\n\t\tif resp == nil {\n\t\t\tt.Fatalf(\"Response %d must be ServerComMessage\", i)\n\t\t}\n\t\tif resp.Ctrl == nil {\n\t\t\tt.Fatalf(\"Response %d must contain a ctrl message.\", i)\n\t\t}\n\t\tif resp.Ctrl.Code != codes[i] {\n\t\t\tt.Errorf(\"Response code: expected %d, got %d\", codes[i], resp.Ctrl.Code)\n\t\t}\n\t}\n}\n\nfunc TestDispatchInvalidVersion(t *testing.T) {\n\ts := &Session{\n\t\tsend:    make(chan any, 10),\n\t\tuid:     types.Uid(1),\n\t\tauthLvl: auth.LevelAuth,\n\t}\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\tmsg := &ClientComMessage{\n\t\tHi: &MsgClientHi{\n\t\t\tId: \"123\",\n\t\t\t// Invalid version string.\n\t\t\tVersion: \"INVALID VERSION STRING\",\n\t\t},\n\t}\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\tverifyResponseCodes(&r, []int{http.StatusBadRequest}, t)\n}\n\nfunc TestDispatchUnsupportedVersion(t *testing.T) {\n\ts := &Session{\n\t\tsend:    make(chan any, 10),\n\t\tuid:     types.Uid(1),\n\t\tauthLvl: auth.LevelAuth,\n\t}\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\tmsg := &ClientComMessage{\n\t\tHi: &MsgClientHi{\n\t\t\tId: \"123\",\n\t\t\t// Invalid version string.\n\t\t\tVersion: \"0.1\",\n\t\t},\n\t}\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\tverifyResponseCodes(&r, []int{http.StatusHTTPVersionNotSupported}, t)\n}\n\nfunc TestDispatchLogin(t *testing.T) {\n\tctrl := gomock.NewController(t)\n\tss := mock_store.NewMockPersistentStorageInterface(ctrl)\n\taa := mock_auth.NewMockAuthHandler(ctrl)\n\n\tuid := types.Uid(1)\n\tstore.Store = ss\n\tdefer func() {\n\t\tstore.Store = nil\n\t\tctrl.Finish()\n\t}()\n\n\tsecret := \"<==auth-secret==>\"\n\tauthRec := &auth.Rec{\n\t\tUid:       uid,\n\t\tAuthLevel: auth.LevelAuth,\n\t\tTags:      []string{\"tag1\", \"tag2\"},\n\t\tState:     types.StateOK,\n\t}\n\tss.EXPECT().GetLogicalAuthHandler(\"basic\").Return(aa)\n\taa.EXPECT().Authenticate([]byte(secret), gomock.Any()).Return(authRec, nil, nil)\n\t// Token generation.\n\tss.EXPECT().GetLogicalAuthHandler(\"token\").Return(aa)\n\ttoken := \"<==auth-token==>\"\n\texpires, _ := time.Parse(time.RFC822, \"01 Jan 50 00:00 UTC\")\n\taa.EXPECT().GenSecret(authRec).Return([]byte(token), expires, nil)\n\n\ts := &Session{\n\t\tsend:    make(chan any, 10),\n\t\tauthLvl: auth.LevelAuth,\n\t\tver:     16,\n\t}\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\tmsg := &ClientComMessage{\n\t\tLogin: &MsgClientLogin{\n\t\t\tId:     \"123\",\n\t\t\tScheme: \"basic\",\n\t\t\tSecret: []byte(secret),\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\tif len(r.messages) != 1 {\n\t\tt.Errorf(\"responses: expected 1, received %d.\", len(r.messages))\n\t}\n\tresp := r.messages[0].(*ServerComMessage)\n\tif resp == nil {\n\t\tt.Fatal(\"Response must be ServerComMessage\")\n\t}\n\tif resp.Ctrl != nil {\n\t\tif resp.Ctrl.Id != \"123\" {\n\t\t\tt.Errorf(\"Response id: expected '123', found '%s'\", resp.Ctrl.Id)\n\t\t}\n\t\tif resp.Ctrl.Code != 200 {\n\t\t\tt.Errorf(\"Response code: expected 200, got %d\", resp.Ctrl.Code)\n\t\t}\n\t\tif resp.Ctrl.Params == nil {\n\t\t\tt.Error(\"Response is expected to contain params dict.\")\n\t\t}\n\t\tp := resp.Ctrl.Params.(map[string]any)\n\t\tif authToken := string(p[\"token\"].([]byte)); authToken != token {\n\t\t\tt.Errorf(\"Auth token: expected '%s', found '%s'.\", token, authToken)\n\t\t}\n\t\tif exp := p[\"expires\"].(time.Time); exp != expires {\n\t\t\tt.Errorf(\"Token expiration: expected '%s', found '%s'.\", expires, exp)\n\t\t}\n\t} else {\n\t\tt.Error(\"Response must contain a ctrl message.\")\n\t}\n}\n\nfunc TestDispatchSubscribe(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\thub := &Hub{\n\t\tjoin: make(chan *ClientComMessage, 10),\n\t}\n\tglobals.hub = hub\n\n\tdefer func() {\n\t\tglobals.hub = nil\n\t}()\n\n\tmsg := &ClientComMessage{\n\t\tSub: &MsgClientSub{\n\t\t\tId:    \"123\",\n\t\t\tTopic: \"me\",\n\t\t\tGet: &MsgGetQuery{\n\t\t\t\tWhat: \"sub desc tags cred\",\n\t\t\t},\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\t// Check we've routed the join request via the hub.\n\tif len(r.messages) != 0 {\n\t\tt.Errorf(\"responses: expected 0, received %d.\", len(r.messages))\n\t}\n\tif len(hub.join) == 1 {\n\t\tjoin := <-hub.join\n\t\tif join.sess != s {\n\t\t\tt.Error(\"Hub.join request: sess field expected to be the session under test.\")\n\t\t}\n\t\tif join != msg {\n\t\t\tt.Error(\"Hub.join request: subscribe message expected to be the original subscribe message.\")\n\t\t}\n\t} else {\n\t\tt.Errorf(\"Hub join messages: expected 1, received %d.\", len(hub.join))\n\t}\n\ts.inflightReqs.Done()\n}\n\nfunc TestDispatchAlreadySubscribed(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\tmsg := &ClientComMessage{\n\t\tSub: &MsgClientSub{\n\t\t\tId:    \"123\",\n\t\t\tTopic: \"me\",\n\t\t\tGet: &MsgGetQuery{\n\t\t\t\tWhat: \"sub desc tags cred\",\n\t\t\t},\n\t\t},\n\t}\n\t// Pretend the session's already subscribed to topic 'me'.\n\ts.subs = make(map[string]*Subscription)\n\ts.subs[uid.UserId()] = &Subscription{}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\tverifyResponseCodes(&r, []int{http.StatusNotModified}, t)\n}\n\nfunc TestDispatchSubscribeJoinChannelFull(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\thub := &Hub{\n\t\t// Make it unbuffered with no readers - so emit operation fails immediately.\n\t\tjoin: make(chan *ClientComMessage),\n\t}\n\tglobals.hub = hub\n\n\tdefer func() {\n\t\tglobals.hub = nil\n\t}()\n\n\tmsg := &ClientComMessage{\n\t\tSub: &MsgClientSub{\n\t\t\tId:    \"123\",\n\t\t\tTopic: \"me\",\n\t\t\tGet: &MsgGetQuery{\n\t\t\t\tWhat: \"sub desc tags cred\",\n\t\t\t},\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\tverifyResponseCodes(&r, []int{http.StatusServiceUnavailable}, t)\n}\n\nfunc TestDispatchLeave(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\tdestUid := types.Uid(2)\n\ttopicName := uid.P2PName(destUid)\n\tleave := make(chan *ClientComMessage, 1)\n\ts.subs = make(map[string]*Subscription)\n\ts.subs[topicName] = &Subscription{\n\t\tdone: leave,\n\t}\n\n\tmsg := &ClientComMessage{\n\t\tLeave: &MsgClientLeave{\n\t\t\tId:    \"123\",\n\t\t\tTopic: destUid.UserId(),\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\t// Check we've routed the join request via the leave channel.\n\tif len(r.messages) != 0 {\n\t\tt.Errorf(\"responses: expected 0, received %d.\", len(r.messages))\n\t}\n\tif len(leave) == 1 {\n\t\treq := <-leave\n\t\tif req.sess != s {\n\t\t\tt.Error(\"Leave request: sess field expected to be the session under test.\")\n\t\t}\n\t\tif req != msg {\n\t\t\tt.Error(\"Leave request: leave message expected to be the original leave message.\")\n\t\t}\n\t\t// leave request handler is expected to clean up subs.\n\t\ts.delSub(topicName)\n\t} else {\n\t\tt.Errorf(\"Unsub messages: expected 1, received %d.\", len(leave))\n\t}\n\tif len(s.subs) != 0 {\n\t\tt.Errorf(\"Session subs: expected to be empty, actual size: %d\", len(s.subs))\n\t}\n\ts.inflightReqs.Done()\n}\n\nfunc TestDispatchLeaveUnsubMe(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\ts.subs = make(map[string]*Subscription)\n\ts.subs[uid.UserId()] = &Subscription{}\n\n\tmsg := &ClientComMessage{\n\t\tLeave: &MsgClientLeave{\n\t\t\tId: \"123\",\n\t\t\t// Cannot unsubscribe from 'me'.\n\t\t\tTopic: \"me\",\n\t\t\tUnsub: true,\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\tverifyResponseCodes(&r, []int{http.StatusForbidden}, t)\n}\n\nfunc TestDispatchLeaveUnknownTopic(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\t// Session isn't subscribed to topic 'me'.\n\t// And wants to leave it => no change.\n\ts.subs = make(map[string]*Subscription)\n\n\tmsg := &ClientComMessage{\n\t\tLeave: &MsgClientLeave{\n\t\t\tId:    \"123\",\n\t\t\tTopic: \"me\",\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\tverifyResponseCodes(&r, []int{http.StatusNotModified}, t)\n}\n\nfunc TestDispatchLeaveUnsubFromUnknownTopic(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\t// Session isn't subscribed to topic 'me'.\n\t// And wants to leave & unsubscribe from it.\n\ts.subs = make(map[string]*Subscription)\n\n\tmsg := &ClientComMessage{\n\t\tLeave: &MsgClientLeave{\n\t\t\tId:    \"123\",\n\t\t\tTopic: \"me\",\n\t\t\tUnsub: true,\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\tverifyResponseCodes(&r, []int{http.StatusConflict}, t)\n}\n\nfunc TestDispatchPublish(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\tdestUid := types.Uid(2)\n\ttopicName := uid.P2PName(destUid)\n\n\tbrdcst := make(chan *ClientComMessage, 1)\n\ts.subs = make(map[string]*Subscription)\n\ts.subs[topicName] = &Subscription{\n\t\tbroadcast: brdcst,\n\t}\n\n\ttestMessage := \"test content\"\n\tmsg := &ClientComMessage{\n\t\tPub: &MsgClientPub{\n\t\t\tId:      \"123\",\n\t\t\tTopic:   destUid.UserId(),\n\t\t\tContent: testMessage,\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\t// Check we've routed the join request via the broadcast channel.\n\tif len(r.messages) != 0 {\n\t\tt.Errorf(\"responses: expected 0, received %d.\", len(r.messages))\n\t}\n\tif len(brdcst) == 1 {\n\t\treq := <-brdcst\n\t\tif req.sess != s {\n\t\t\tt.Error(\"Pub request: sess field expected to be the session under test.\")\n\t\t}\n\t\tif req.Pub.Content != testMessage {\n\t\t\tt.Errorf(\"Pub request content: expected '%s' vs '%s'.\", testMessage, req.Pub.Content)\n\t\t}\n\t\tif req.Pub.Topic != destUid.UserId() {\n\t\t\tt.Errorf(\"Pub request topic: expected '%s' vs '%s'.\", destUid.UserId(), req.Pub.Topic)\n\t\t}\n\t} else {\n\t\tt.Errorf(\"Pub messages: expected 1, received %d.\", len(brdcst))\n\t}\n}\n\nfunc TestDispatchPublishBroadcastChannelFull(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\tdestUid := types.Uid(2)\n\ttopicName := uid.P2PName(destUid)\n\n\t// Make broadcast channel unbuffered with no reader -\n\t// emit op will fail.\n\tbrdcst := make(chan *ClientComMessage)\n\ts.subs = make(map[string]*Subscription)\n\ts.subs[topicName] = &Subscription{\n\t\tbroadcast: brdcst,\n\t}\n\n\ttestMessage := \"test content\"\n\tmsg := &ClientComMessage{\n\t\tPub: &MsgClientPub{\n\t\t\tId:      \"123\",\n\t\t\tTopic:   destUid.UserId(),\n\t\t\tContent: testMessage,\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\tverifyResponseCodes(&r, []int{http.StatusServiceUnavailable}, t)\n}\n\nfunc TestDispatchPublishMissingSubcription(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\tdestUid := types.Uid(2)\n\n\t// Subscription to topic missing.\n\ts.subs = make(map[string]*Subscription)\n\n\ttestMessage := \"test content\"\n\tmsg := &ClientComMessage{\n\t\tPub: &MsgClientPub{\n\t\t\tId:      \"123\",\n\t\t\tTopic:   destUid.UserId(),\n\t\t\tContent: testMessage,\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\tverifyResponseCodes(&r, []int{http.StatusConflict}, t)\n}\n\nfunc TestDispatchGet(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\tdestUid := types.Uid(2)\n\ttopicName := uid.P2PName(destUid)\n\n\tmeta := make(chan *ClientComMessage, 1)\n\ts.subs = make(map[string]*Subscription)\n\ts.subs[topicName] = &Subscription{\n\t\tmeta: meta,\n\t}\n\n\tmsg := &ClientComMessage{\n\t\tGet: &MsgClientGet{\n\t\t\tId:    \"123\",\n\t\t\tTopic: destUid.UserId(),\n\t\t\tMsgGetQuery: MsgGetQuery{\n\t\t\t\tWhat: \"desc sub del cred\",\n\t\t\t},\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\t// Check we've routed the join request via the meta channel.\n\tif len(r.messages) != 0 {\n\t\tt.Errorf(\"responses: expected 0, received %d.\", len(r.messages))\n\t}\n\tif len(meta) == 1 {\n\t\treq := <-meta\n\t\tif req.sess != s {\n\t\t\tt.Error(\"Get request: sess field expected to be the session under test.\")\n\t\t}\n\t} else {\n\t\tt.Errorf(\"Get messages: expected 1, received %d.\", len(meta))\n\t}\n}\n\nfunc TestDispatchGetMalformedWhat(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\tdestUid := types.Uid(2)\n\tmsg := &ClientComMessage{\n\t\tGet: &MsgClientGet{\n\t\t\tId:    \"123\",\n\t\t\tTopic: destUid.UserId(),\n\t\t\tMsgGetQuery: MsgGetQuery{\n\t\t\t\t// Empty 'what'. This will produce an error.\n\t\t\t\tWhat: \"\",\n\t\t\t},\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\tverifyResponseCodes(&r, []int{http.StatusBadRequest}, t)\n}\n\nfunc TestDispatchGetMetaChannelFull(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\tdestUid := types.Uid(2)\n\ttopicName := uid.P2PName(destUid)\n\n\t// Unbuffered chan with no readers - emit will fail.\n\tmeta := make(chan *ClientComMessage)\n\ts.subs = make(map[string]*Subscription)\n\ts.subs[topicName] = &Subscription{\n\t\tmeta: meta,\n\t}\n\n\tmsg := &ClientComMessage{\n\t\tGet: &MsgClientGet{\n\t\t\tId:    \"123\",\n\t\t\tTopic: destUid.UserId(),\n\t\t\tMsgGetQuery: MsgGetQuery{\n\t\t\t\tWhat: \"desc sub\",\n\t\t\t},\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\tverifyResponseCodes(&r, []int{http.StatusServiceUnavailable}, t)\n}\n\nfunc TestDispatchSet(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\tdestUid := types.Uid(2)\n\ttopicName := uid.P2PName(destUid)\n\n\tmeta := make(chan *ClientComMessage, 1)\n\ts.subs = make(map[string]*Subscription)\n\ts.subs[topicName] = &Subscription{\n\t\tmeta: meta,\n\t}\n\n\tmsg := &ClientComMessage{\n\t\tSet: &MsgClientSet{\n\t\t\tId:    \"123\",\n\t\t\tTopic: destUid.UserId(),\n\t\t\tMsgSetQuery: MsgSetQuery{\n\t\t\t\tDesc: &MsgSetDesc{},\n\t\t\t\tSub:  &MsgSetSub{},\n\t\t\t\tTags: []string{\"abc\"},\n\t\t\t\tCred: &MsgCredClient{},\n\t\t\t},\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\t// Check we've routed the join request via the meta channel.\n\tif len(r.messages) != 0 {\n\t\tt.Errorf(\"responses: expected 0, received %d.\", len(r.messages))\n\t}\n\tif len(meta) == 1 {\n\t\treq := <-meta\n\t\tif req.sess != s {\n\t\t\tt.Error(\"Set request: sess field expected to be the session under test.\")\n\t\t}\n\t\texpectedWhat := constMsgMetaDesc | constMsgMetaSub | constMsgMetaTags | constMsgMetaCred\n\t\tif msg.MetaWhat != expectedWhat {\n\t\t\tt.Errorf(\"Set request what: expected %d vs %d\", expectedWhat, msg.MetaWhat)\n\t\t}\n\t} else {\n\t\tt.Errorf(\"Set messages: expected 1, received %d.\", len(meta))\n\t}\n}\n\nfunc TestDispatchSetMalformedWhat(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\tdestUid := types.Uid(2)\n\tmsg := &ClientComMessage{\n\t\tSet: &MsgClientSet{\n\t\t\tId:          \"123\",\n\t\t\tTopic:       destUid.UserId(),\n\t\t\tMsgSetQuery: MsgSetQuery{\n\t\t\t\t// No meta requests.\n\t\t\t},\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\tverifyResponseCodes(&r, []int{http.StatusBadRequest}, t)\n}\n\nfunc TestDispatchSetMetaChannelFull(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\tdestUid := types.Uid(2)\n\ttopicName := uid.P2PName(destUid)\n\n\t// Unbuffered meta channel w/ no readers - emit will fail.\n\tmeta := make(chan *ClientComMessage)\n\ts.subs = make(map[string]*Subscription)\n\ts.subs[topicName] = &Subscription{\n\t\tmeta: meta,\n\t}\n\n\tmsg := &ClientComMessage{\n\t\tSet: &MsgClientSet{\n\t\t\tId:    \"123\",\n\t\t\tTopic: destUid.UserId(),\n\t\t\tMsgSetQuery: MsgSetQuery{\n\t\t\t\t// No meta requests.\n\t\t\t\tDesc: &MsgSetDesc{},\n\t\t\t\tSub:  &MsgSetSub{},\n\t\t\t\tTags: []string{\"abc\"},\n\t\t\t\tCred: &MsgCredClient{},\n\t\t\t},\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\tverifyResponseCodes(&r, []int{http.StatusServiceUnavailable}, t)\n}\n\nfunc TestDispatchDelMsg(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\tdestUid := types.Uid(2)\n\ttopicName := uid.P2PName(destUid)\n\n\tmeta := make(chan *ClientComMessage, 1)\n\ts.subs = make(map[string]*Subscription)\n\ts.subs[topicName] = &Subscription{\n\t\tmeta: meta,\n\t}\n\n\tmsg := &ClientComMessage{\n\t\tDel: &MsgClientDel{\n\t\t\tId:     \"123\",\n\t\t\tTopic:  destUid.UserId(),\n\t\t\tWhat:   \"msg\",\n\t\t\tDelSeq: []MsgRange{{LowId: 3, HiId: 4}},\n\t\t\tHard:   true,\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\t// Check we've routed the join request via the meta channel.\n\tif len(r.messages) != 0 {\n\t\tt.Errorf(\"responses: expected 0, received %d.\", len(r.messages))\n\t}\n\tif len(meta) == 1 {\n\t\treq := <-meta\n\t\tif req.sess != s {\n\t\t\tt.Error(\"Del request: sess field expected to be the session under test.\")\n\t\t}\n\t} else {\n\t\tt.Errorf(\"Del messages: expected 1, received %d.\", len(meta))\n\t}\n}\n\nfunc TestDispatchDelMalformedWhat(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\tdestUid := types.Uid(2)\n\tmsg := &ClientComMessage{\n\t\tDel: &MsgClientDel{\n\t\t\tId:    \"123\",\n\t\t\tTopic: destUid.UserId(),\n\t\t\t// Invalid 'what' - this will produce an error.\n\t\t\tWhat: \"INVALID\",\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\tverifyResponseCodes(&r, []int{http.StatusBadRequest}, t)\n}\n\nfunc TestDispatchDelMetaChanFull(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\tdestUid := types.Uid(2)\n\ttopicName := uid.P2PName(destUid)\n\n\t// Unbuffered chan - to simulate a full buffered chan.\n\tmeta := make(chan *ClientComMessage)\n\ts.subs = make(map[string]*Subscription)\n\ts.subs[topicName] = &Subscription{\n\t\tmeta: meta,\n\t}\n\n\tmsg := &ClientComMessage{\n\t\tDel: &MsgClientDel{\n\t\t\tId:     \"123\",\n\t\t\tTopic:  destUid.UserId(),\n\t\t\tWhat:   \"msg\",\n\t\t\tDelSeq: []MsgRange{{LowId: 3, HiId: 4}},\n\t\t\tHard:   true,\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\tverifyResponseCodes(&r, []int{http.StatusServiceUnavailable}, t)\n}\n\nfunc TestDispatchDelUnsubscribedSession(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\tdestUid := types.Uid(2)\n\t// Session isn't subscribed.\n\ts.subs = make(map[string]*Subscription)\n\tmsg := &ClientComMessage{\n\t\tDel: &MsgClientDel{\n\t\t\tId:     \"123\",\n\t\t\tTopic:  destUid.UserId(),\n\t\t\tWhat:   \"msg\",\n\t\t\tDelSeq: []MsgRange{{LowId: 3, HiId: 4}},\n\t\t\tHard:   true,\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\tverifyResponseCodes(&r, []int{http.StatusConflict}, t)\n}\n\nfunc TestDispatchNote(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\tdestUid := types.Uid(2)\n\ttopicName := uid.P2PName(destUid)\n\n\tbrdcst := make(chan *ClientComMessage, 1)\n\ts.subs = make(map[string]*Subscription)\n\ts.subs[topicName] = &Subscription{\n\t\tbroadcast: brdcst,\n\t}\n\n\tmsg := &ClientComMessage{\n\t\tNote: &MsgClientNote{\n\t\t\tTopic: destUid.UserId(),\n\t\t\tWhat:  \"recv\",\n\t\t\tSeqId: 5,\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\t// Check we've routed the join request via the broadcast channel.\n\tif len(r.messages) != 0 {\n\t\tt.Errorf(\"responses: expected 0, received %d.\", len(r.messages))\n\t}\n\tif len(brdcst) == 1 {\n\t\treq := <-brdcst\n\t\tif req.sess != s {\n\t\t\tt.Error(\"Pub request: sess field expected to be the session under test.\")\n\t\t}\n\t\tif req.Note.What != msg.Note.What {\n\t\t\tt.Errorf(\"Note request what: expected '%s' vs '%s'.\", msg.Note.What, req.Note.What)\n\t\t}\n\t\tif req.Note.SeqId != msg.Note.SeqId {\n\t\t\tt.Errorf(\"Note request seqId: expected %d vs %d.\", msg.Note.SeqId, req.Note.SeqId)\n\t\t}\n\t\tif req.Note.Topic != destUid.UserId() {\n\t\t\tt.Errorf(\"Note request topic: expected '%s' vs '%s'.\", destUid.UserId(), req.Note.Topic)\n\t\t}\n\t} else {\n\t\tt.Errorf(\"Note messages: expected 1, received %d.\", len(brdcst))\n\t}\n}\n\nfunc TestDispatchNoteBroadcastChanFull(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\tdestUid := types.Uid(2)\n\ttopicName := uid.P2PName(destUid)\n\n\t// Unbuffered chan - to simulate a full buffered chan.\n\tbrdcst := make(chan *ClientComMessage)\n\ts.subs = make(map[string]*Subscription)\n\ts.subs[topicName] = &Subscription{\n\t\tbroadcast: brdcst,\n\t}\n\n\tmsg := &ClientComMessage{\n\t\tNote: &MsgClientNote{\n\t\t\tTopic: destUid.UserId(),\n\t\t\tWhat:  \"recv\",\n\t\t\tSeqId: 5,\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\tverifyResponseCodes(&r, []int{http.StatusServiceUnavailable}, t)\n}\n\nfunc TestDispatchNoteOnNonSubscribedTopic(t *testing.T) {\n\tuid := types.Uid(1)\n\ts := test_makeSession(uid)\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\tdestUid := types.Uid(2)\n\ts.subs = make(map[string]*Subscription)\n\n\tmsg := &ClientComMessage{\n\t\tNote: &MsgClientNote{\n\t\t\tTopic: destUid.UserId(),\n\t\t\tWhat:  \"read\",\n\t\t\tSeqId: 5,\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\tverifyResponseCodes(&r, []int{http.StatusConflict}, t)\n}\n\nfunc TestDispatchAccNew(t *testing.T) {\n\tctrl := gomock.NewController(t)\n\tss := mock_store.NewMockPersistentStorageInterface(ctrl)\n\tuu := mock_store.NewMockUsersPersistenceInterface(ctrl)\n\taa := mock_auth.NewMockAuthHandler(ctrl)\n\n\tuid := types.Uid(1)\n\tstore.Store = ss\n\tstore.Users = uu\n\tdefer func() {\n\t\tstore.Store = nil\n\t\tstore.Users = nil\n\t\tctrl.Finish()\n\t}()\n\n\tremoteAddr := \"192.168.0.1\"\n\tsecret := \"<==auth-secret==>\"\n\ttags := []string{\"tag1\", \"tag2\"}\n\tauthRec := &auth.Rec{\n\t\tUid:       uid,\n\t\tAuthLevel: auth.LevelAuth,\n\t\tTags:      tags,\n\t\tState:     types.StateOK,\n\t}\n\tss.EXPECT().GetLogicalAuthHandler(\"basic\").Return(aa)\n\t// This login is available.\n\taa.EXPECT().IsUnique([]byte(secret), remoteAddr).Return(true, nil)\n\tuu.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn(\n\t\tfunc(user *types.User, private any) (*types.User, error) {\n\t\t\tuser.SetUid(uid)\n\t\t\treturn user, nil\n\t\t})\n\taa.EXPECT().AddRecord(gomock.Any(), []byte(secret), remoteAddr).Return(authRec, nil)\n\n\t// Token generation.\n\tss.EXPECT().GetLogicalAuthHandler(\"token\").Return(aa)\n\ttoken := \"<==auth-token==>\"\n\taa.EXPECT().GenSecret(gomock.Any()).Return([]byte(token), time.Now(), nil)\n\tuu.EXPECT().UpdateTags(uid, tags, nil, nil).Return(tags, nil)\n\n\ts := &Session{\n\t\tsend:       make(chan any, 10),\n\t\tauthLvl:    auth.LevelAuth,\n\t\tver:        16,\n\t\tremoteAddr: remoteAddr,\n\t}\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\tpublic := \"public name\"\n\tmsg := &ClientComMessage{\n\t\tAcc: &MsgClientAcc{\n\t\t\tId:     \"123\",\n\t\t\tUser:   \"newXYZ\",\n\t\t\tScheme: \"basic\",\n\t\t\tSecret: []byte(secret),\n\t\t\tTags:   []string{\"abc\", \"123\"},\n\t\t\tDesc:   &MsgSetDesc{Public: public},\n\t\t},\n\t}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\tif len(r.messages) != 1 {\n\t\tt.Errorf(\"responses: expected 1, received %d.\", len(r.messages))\n\t}\n\tresp := r.messages[0].(*ServerComMessage)\n\tif resp == nil {\n\t\tt.Fatal(\"Response must be ServerComMessage\")\n\t}\n\tif resp.Ctrl != nil {\n\t\tif resp.Ctrl.Id != \"123\" {\n\t\t\tt.Errorf(\"Response id: expected '123', found '%s'\", resp.Ctrl.Id)\n\t\t}\n\t\tif resp.Ctrl.Code != 201 {\n\t\t\tt.Errorf(\"Response code: expected 201, got %d\", resp.Ctrl.Code)\n\t\t}\n\t\tif resp.Ctrl.Params == nil {\n\t\t\tt.Error(\"Response is expected to contain params dict.\")\n\t\t}\n\t\tp := resp.Ctrl.Params.(map[string]any)\n\t\tif respUid := string(p[\"user\"].(string)); respUid != uid.UserId() {\n\t\t\tt.Errorf(\"Response uid: expected '%s', found '%s'.\", uid.UserId(), respUid)\n\t\t}\n\t\tif lvl := p[\"authlvl\"].(string); lvl != auth.LevelAuth.String() {\n\t\t\tt.Errorf(\"Auth level: expected '%s', found '%s'.\", auth.LevelAuth.String(), lvl)\n\t\t}\n\t\tif desc := p[\"desc\"].(*MsgTopicDesc); desc.Public.(string) != public {\n\t\t\tt.Errorf(\"Public: expected '%s', found '%s'.\", public, desc.Public.(string))\n\t\t}\n\t} else {\n\t\tt.Error(\"Response must contain a ctrl message.\")\n\t}\n}\n\nfunc TestDispatchNoMessage(t *testing.T) {\n\tremoteAddr := \"192.168.0.1\"\n\ts := &Session{\n\t\tsend:       make(chan any, 10),\n\t\tauthLvl:    auth.LevelAuth,\n\t\tver:        16,\n\t\tremoteAddr: remoteAddr,\n\t}\n\twg := sync.WaitGroup{}\n\tr := responses{}\n\twg.Add(1)\n\tgo s.testWriteLoop(&r, &wg)\n\n\tmsg := &ClientComMessage{}\n\n\ts.dispatch(msg)\n\tclose(s.send)\n\twg.Wait()\n\n\tif len(r.messages) != 1 {\n\t\tt.Errorf(\"responses: expected 1, received %d.\", len(r.messages))\n\t}\n\tresp := r.messages[0].(*ServerComMessage)\n\tif resp == nil {\n\t\tt.Fatal(\"Response must be ServerComMessage\")\n\t}\n\tif resp.Ctrl == nil {\n\t\tt.Fatal(\"Response must contain a ctrl message.\")\n\t}\n\tif resp.Ctrl.Code != 400 {\n\t\tt.Errorf(\"Response code: expected 400, got %d\", resp.Ctrl.Code)\n\t}\n}\n"
  },
  {
    "path": "server/sessionstore.go",
    "content": "/******************************************************************************\n *\n *  Description:\n *\n *  Session management.\n *\n *****************************************************************************/\n\npackage main\n\nimport (\n\t\"container/list\"\n\t\"net/http\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gorilla/websocket\"\n\t\"github.com/tinode/chat/pbx\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\n// WaitGroup with a semaphore functionality\n// (limiting number of threads/goroutines accessing the guarded resource simultaneously).\ntype boundedWaitGroup struct {\n\twg  sync.WaitGroup\n\tsem chan struct{}\n}\n\nfunc newBoundedWaitGroup(capacity int) *boundedWaitGroup {\n\treturn &boundedWaitGroup{sem: make(chan struct{}, capacity)}\n}\n\nfunc (w *boundedWaitGroup) Add(delta int) {\n\tif delta <= 0 {\n\t\treturn\n\t}\n\tfor range delta {\n\t\tw.sem <- struct{}{}\n\t}\n\tw.wg.Add(delta)\n}\n\nfunc (w *boundedWaitGroup) Done() {\n\tselect {\n\tcase _, ok := <-w.sem:\n\t\tif !ok {\n\t\t\tlogs.Err.Panicln(\"boundedWaitGroup.sem closed.\")\n\t\t}\n\tdefault:\n\t\tlogs.Err.Panicln(\"boundedWaitGroup.Done() called before Add().\")\n\t}\n\tw.wg.Done()\n}\n\nfunc (w *boundedWaitGroup) Wait() {\n\tw.wg.Wait()\n}\n\n// SessionStore holds live sessions. Long polling sessions are stored in a linked list with\n// most recent sessions on top. In addition all sessions are stored in a map indexed by session ID.\ntype SessionStore struct {\n\tlock sync.Mutex\n\n\t// Support for long polling sessions: a list of sessions sorted by last access time.\n\t// Needed for cleaning abandoned sessions.\n\tlru      *list.List\n\tlifeTime time.Duration\n\n\t// All sessions indexed by session ID\n\tsessCache map[string]*Session\n}\n\n// NewSession creates a new session and saves it to the session store.\nfunc (ss *SessionStore) NewSession(conn any, sid string) (*Session, int) {\n\tvar s Session\n\n\tif sid == \"\" {\n\t\ts.sid = store.Store.GetUidString()\n\t} else {\n\t\ts.sid = sid\n\t}\n\n\tss.lock.Lock()\n\tif _, found := ss.sessCache[s.sid]; found {\n\t\tlogs.Err.Fatalln(\"ERROR! duplicate session ID\", s.sid)\n\t}\n\tss.lock.Unlock()\n\n\tswitch c := conn.(type) {\n\tcase *websocket.Conn:\n\t\ts.proto = WEBSOCK\n\t\ts.ws = c\n\tcase http.ResponseWriter:\n\t\ts.proto = LPOLL\n\t\t// no need to store c for long polling, it changes with every request\n\tcase *ClusterNode:\n\t\ts.proto = MULTIPLEX\n\t\ts.clnode = c\n\tcase pbx.Node_MessageLoopServer:\n\t\ts.proto = GRPC\n\t\ts.grpcnode = c\n\tdefault:\n\t\tlogs.Err.Panicln(\"session: unknown connection type\", conn)\n\t}\n\n\ts.subs = make(map[string]*Subscription)\n\ts.send = make(chan any, sendQueueLimit+32) // buffered\n\ts.stop = make(chan any, 1)                 // Buffered by 1 just to make it non-blocking\n\ts.detach = make(chan string, 64)           // buffered\n\n\ts.bkgTimer = time.NewTimer(time.Hour)\n\ts.bkgTimer.Stop()\n\n\t// Make sure at most 1 request is modifying session/topic state at any time.\n\t// TODO: use Mutex & CondVar?\n\ts.inflightReqs = newBoundedWaitGroup(1)\n\n\ts.lastTouched = time.Now()\n\n\tss.lock.Lock()\n\n\tif s.proto == LPOLL {\n\t\t// Only LP sessions need to be sorted by last active\n\t\ts.lpTracker = ss.lru.PushFront(&s)\n\t}\n\n\tss.sessCache[s.sid] = &s\n\n\t// Expire stale long polling sessions: ss.lru contains only long polling sessions.\n\t// If ss.lru is empty this is a noop.\n\tvar expired []*Session\n\texpire := s.lastTouched.Add(-ss.lifeTime)\n\tfor elem := ss.lru.Back(); elem != nil; elem = ss.lru.Back() {\n\t\tsess := elem.Value.(*Session)\n\t\tif sess.lastTouched.Before(expire) {\n\t\t\tss.lru.Remove(elem)\n\t\t\tdelete(ss.sessCache, sess.sid)\n\t\t\texpired = append(expired, sess)\n\t\t} else {\n\t\t\tbreak // don't need to traverse further\n\t\t}\n\t}\n\n\tnumSessions := len(ss.sessCache)\n\tstatsSet(\"LiveSessions\", int64(numSessions))\n\tstatsInc(\"TotalSessions\", 1)\n\n\tss.lock.Unlock()\n\n\t// Deleting long polling sessions.\n\tfor _, sess := range expired {\n\t\t// This locks the session. Thus cleaning up outside of the\n\t\t// sessionStore lock. Otherwise deadlock.\n\t\tsess.cleanUp(true)\n\t}\n\n\treturn &s, numSessions\n}\n\n// Get fetches a session from store by session ID.\nfunc (ss *SessionStore) Get(sid string) *Session {\n\tss.lock.Lock()\n\tdefer ss.lock.Unlock()\n\n\tif sess := ss.sessCache[sid]; sess != nil {\n\t\tif sess.proto == LPOLL {\n\t\t\tss.lru.MoveToFront(sess.lpTracker)\n\t\t\tsess.lastTouched = time.Now()\n\t\t}\n\n\t\treturn sess\n\t}\n\n\treturn nil\n}\n\n// Delete removes session from store.\nfunc (ss *SessionStore) Delete(s *Session) {\n\tss.lock.Lock()\n\tdefer ss.lock.Unlock()\n\n\tdelete(ss.sessCache, s.sid)\n\tif s.proto == LPOLL {\n\t\tss.lru.Remove(s.lpTracker)\n\t}\n\n\tstatsSet(\"LiveSessions\", int64(len(ss.sessCache)))\n}\n\n// Range calls given function for all sessions. It stops if the function returns false.\nfunc (ss *SessionStore) Range(f func(sid string, s *Session) bool) {\n\tss.lock.Lock()\n\tfor sid, s := range ss.sessCache {\n\t\tif !f(sid, s) {\n\t\t\tbreak\n\t\t}\n\t}\n\tss.lock.Unlock()\n}\n\n// Shutdown terminates sessionStore. No need to clean up.\n// Don't send to clustered sessions, their servers are not being shut down.\nfunc (ss *SessionStore) Shutdown() {\n\tss.lock.Lock()\n\tdefer ss.lock.Unlock()\n\n\tshutdown := NoErrShutdown(types.TimeNow())\n\tfor _, s := range ss.sessCache {\n\t\tif !s.isMultiplex() {\n\t\t\t_, data := s.serialize(shutdown)\n\t\t\ts.stopSession(data)\n\t\t}\n\t}\n\n\t// TODO: Consider broadcasting shutdown to other cluster nodes.\n\n\tlogs.Info.Println(\"SessionStore shut down, sessions terminated:\", len(ss.sessCache))\n}\n\n// EvictUser terminates all sessions of a given user.\nfunc (ss *SessionStore) EvictUser(uid types.Uid, skipSid string) {\n\tss.lock.Lock()\n\tdefer ss.lock.Unlock()\n\n\t// FIXME: this probably needs to be optimized. This may take very long time if the node hosts 100000 sessions.\n\tevicted := NoErrEvicted(\"\", \"\", types.TimeNow())\n\tevicted.AsUser = uid.UserId()\n\tfor _, s := range ss.sessCache {\n\t\tif s.uid == uid && !s.isMultiplex() && s.sid != skipSid {\n\t\t\t_, data := s.serialize(evicted)\n\t\t\ts.stopSession(data)\n\t\t\tdelete(ss.sessCache, s.sid)\n\t\t\tif s.proto == LPOLL {\n\t\t\t\tss.lru.Remove(s.lpTracker)\n\t\t\t}\n\t\t}\n\t}\n\n\tstatsSet(\"LiveSessions\", int64(len(ss.sessCache)))\n}\n\n// NodeRestarted removes stale sessions from a restarted cluster node.\n//   - nodeName is the name of affected node\n//   - fingerprint is the new fingerprint of the node.\nfunc (ss *SessionStore) NodeRestarted(nodeName string, fingerprint int64) {\n\tss.lock.Lock()\n\tdefer ss.lock.Unlock()\n\n\tfor _, s := range ss.sessCache {\n\t\tif !s.isMultiplex() || s.clnode.name != nodeName {\n\t\t\tcontinue\n\t\t}\n\t\tif s.clnode.fingerprint != fingerprint {\n\t\t\ts.stopSession(nil)\n\t\t\tdelete(ss.sessCache, s.sid)\n\t\t}\n\t}\n\n\tstatsSet(\"LiveSessions\", int64(len(ss.sessCache)))\n}\n\n// NewSessionStore initializes a session store.\nfunc NewSessionStore(lifetime time.Duration) *SessionStore {\n\tss := &SessionStore{\n\t\tlru:      list.New(),\n\t\tlifeTime: lifetime,\n\n\t\tsessCache: make(map[string]*Session),\n\t}\n\n\tstatsRegisterInt(\"LiveSessions\")\n\tstatsRegisterInt(\"TotalSessions\")\n\n\treturn ss\n}\n"
  },
  {
    "path": "server/stats.go",
    "content": "// Logic related to expvar handling: reporting live stats such as\n// session and topic counts, memory usage etc.\n// The stats updates happen in a separate go routine to avoid\n// locking on main logic routines.\n\npackage main\n\nimport (\n\t\"encoding/json\"\n\t\"expvar\"\n\t\"net/http\"\n\t\"runtime\"\n\t\"sort\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store\"\n)\n\n// A simple implementation of histogram expvar.Var.\n// `Bounds` specifies the histogram buckets as follows (length = len(bounds)):\n//\n//\t(-inf, Bounds[i]) for i = 0\n//\t[Bounds[i-1], Bounds[i]) for 0 < i < length\n//\t[Bounds[i-1], +inf) for i = length\ntype histogram struct {\n\tCount          int64     `json:\"count\"`\n\tSum            float64   `json:\"sum\"`\n\tCountPerBucket []int64   `json:\"count_per_bucket\"`\n\tBounds         []float64 `json:\"bounds\"`\n}\n\nfunc (h *histogram) addSample(v float64) {\n\th.Count++\n\th.Sum += v\n\tidx := sort.SearchFloat64s(h.Bounds, v)\n\th.CountPerBucket[idx]++\n}\n\nfunc (h *histogram) String() string {\n\tif r, err := json.Marshal(h); err == nil {\n\t\treturn string(r)\n\t}\n\treturn \"\"\n}\n\ntype varUpdate struct {\n\t// Name of the variable to update\n\tvarname string\n\t// Value to publish (int, float, etc.)\n\tvalue any\n\t// Treat the count as an increment as opposite to the final value.\n\tinc bool\n}\n\n// Initialize stats reporting through expvar.\nfunc statsInit(mux *http.ServeMux, path string) {\n\tif path == \"\" || path == \"-\" {\n\t\treturn\n\t}\n\n\tmux.Handle(path, expvar.Handler())\n\tglobals.statsUpdate = make(chan *varUpdate, 1024)\n\n\tstart := time.Now()\n\texpvar.Publish(\"Uptime\", expvar.Func(func() any {\n\t\treturn time.Since(start).Seconds()\n\t}))\n\texpvar.Publish(\"NumGoroutines\", expvar.Func(func() any {\n\t\treturn runtime.NumGoroutine()\n\t}))\n\n\tgo statsUpdater()\n\n\tlogs.Info.Printf(\"stats: variables exposed at '%s'\", path)\n}\n\nfunc statsRegisterDbStats() {\n\tif f := store.Store.DbStats(); f != nil {\n\t\texpvar.Publish(\"DbStats\", expvar.Func(f))\n\t}\n}\n\n// Register integer variable. Don't check for initialization.\nfunc statsRegisterInt(name string) {\n\texpvar.Publish(name, new(expvar.Int))\n}\n\n// Register histogram variable. `bounds` specifies histogram buckets/bins\n// (see comment next to the `histogram` struct definition).\nfunc statsRegisterHistogram(name string, bounds []float64) {\n\tnumBuckets := len(bounds) + 1\n\texpvar.Publish(name, &histogram{\n\t\tCountPerBucket: make([]int64, numBuckets),\n\t\tBounds:         bounds,\n\t})\n}\n\n// Async publish int variable.\nfunc statsSet(name string, val int64) {\n\tif globals.statsUpdate != nil {\n\t\tselect {\n\t\tcase globals.statsUpdate <- &varUpdate{name, val, false}:\n\t\tdefault:\n\t\t}\n\t}\n}\n\n// Async publish an increment (decrement) to int variable.\nfunc statsInc(name string, val int) {\n\tif globals.statsUpdate != nil {\n\t\tselect {\n\t\tcase globals.statsUpdate <- &varUpdate{name, int64(val), true}:\n\t\tdefault:\n\t\t}\n\t}\n}\n\n// Async publish a value (add a sample) to a histogram variable.\nfunc statsAddHistSample(name string, val float64) {\n\tif globals.statsUpdate != nil {\n\t\tselect {\n\t\tcase globals.statsUpdate <- &varUpdate{varname: name, value: val}:\n\t\tdefault:\n\t\t}\n\t}\n}\n\n// Stop publishing stats.\nfunc statsShutdown() {\n\tif globals.statsUpdate != nil {\n\t\tglobals.statsUpdate <- nil\n\t}\n}\n\n// The go routine which actually publishes stats updates.\nfunc statsUpdater() {\n\tfor upd := range globals.statsUpdate {\n\t\tif upd == nil {\n\t\t\tglobals.statsUpdate = nil\n\t\t\t// Dont' care to close the channel.\n\t\t\tbreak\n\t\t}\n\n\t\t// Handle var update\n\t\tif ev := expvar.Get(upd.varname); ev != nil {\n\t\t\tswitch v := ev.(type) {\n\t\t\tcase *expvar.Int:\n\t\t\t\tcount := upd.value.(int64)\n\t\t\t\tif upd.inc {\n\t\t\t\t\tv.Add(count)\n\t\t\t\t} else {\n\t\t\t\t\tv.Set(count)\n\t\t\t\t}\n\t\t\tcase *histogram:\n\t\t\t\tval := upd.value.(float64)\n\t\t\t\tv.addSample(val)\n\t\t\tdefault:\n\t\t\t\tlogs.Err.Panicf(\"stats: unsupported expvar type %T\", ev)\n\t\t\t}\n\t\t} else {\n\t\t\tpanic(\"stats: update to unknown variable \" + upd.varname)\n\t\t}\n\t}\n\n\tlogs.Info.Println(\"stats: shutdown\")\n}\n"
  },
  {
    "path": "server/store/mock_store/mock_store.go",
    "content": "// Code generated by MockGen. DO NOT EDIT.\n// Source: store/store.go\n\n// Package mock_store is a generated GoMock package.\npackage mock_store\n\nimport (\n\tjson \"encoding/json\"\n\treflect \"reflect\"\n\ttime \"time\"\n\n\tgomock \"github.com/golang/mock/gomock\"\n\tauth \"github.com/tinode/chat/server/auth\"\n\tadapter \"github.com/tinode/chat/server/db\"\n\tmedia \"github.com/tinode/chat/server/media\"\n\ttypes \"github.com/tinode/chat/server/store/types\"\n\tvalidate \"github.com/tinode/chat/server/validate\"\n)\n\n// MockPersistentStorageInterface is a mock of PersistentStorageInterface interface.\ntype MockPersistentStorageInterface struct {\n\tctrl     *gomock.Controller\n\trecorder *MockPersistentStorageInterfaceMockRecorder\n}\n\n// MockPersistentStorageInterfaceMockRecorder is the mock recorder for MockPersistentStorageInterface.\ntype MockPersistentStorageInterfaceMockRecorder struct {\n\tmock *MockPersistentStorageInterface\n}\n\n// NewMockPersistentStorageInterface creates a new mock instance.\nfunc NewMockPersistentStorageInterface(ctrl *gomock.Controller) *MockPersistentStorageInterface {\n\tmock := &MockPersistentStorageInterface{ctrl: ctrl}\n\tmock.recorder = &MockPersistentStorageInterfaceMockRecorder{mock}\n\treturn mock\n}\n\n// EXPECT returns an object that allows the caller to indicate expected use.\nfunc (m *MockPersistentStorageInterface) EXPECT() *MockPersistentStorageInterfaceMockRecorder {\n\treturn m.recorder\n}\n\n// Close mocks base method.\nfunc (m *MockPersistentStorageInterface) Close() error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Close\")\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// Close indicates an expected call of Close.\nfunc (mr *MockPersistentStorageInterfaceMockRecorder) Close() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Close\", reflect.TypeOf((*MockPersistentStorageInterface)(nil).Close))\n}\n\n// DbStats mocks base method.\nfunc (m *MockPersistentStorageInterface) DbStats() func() any {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"DbStats\")\n\tret0, _ := ret[0].(func() any)\n\treturn ret0\n}\n\n// DbStats indicates an expected call of DbStats.\nfunc (mr *MockPersistentStorageInterfaceMockRecorder) DbStats() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"DbStats\", reflect.TypeOf((*MockPersistentStorageInterface)(nil).DbStats))\n}\n\n// GetAdapter mocks base method.\nfunc (m *MockPersistentStorageInterface) GetAdapter() adapter.Adapter {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetAdapter\")\n\tret0, _ := ret[0].(adapter.Adapter)\n\treturn ret0\n}\n\n// GetAdapter indicates an expected call of GetAdapter.\nfunc (mr *MockPersistentStorageInterfaceMockRecorder) GetAdapter() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetAdapter\", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetAdapter))\n}\n\n// GetAdapterName mocks base method.\nfunc (m *MockPersistentStorageInterface) GetAdapterName() string {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetAdapterName\")\n\tret0, _ := ret[0].(string)\n\treturn ret0\n}\n\n// GetAdapterName indicates an expected call of GetAdapterName.\nfunc (mr *MockPersistentStorageInterfaceMockRecorder) GetAdapterName() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetAdapterName\", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetAdapterName))\n}\n\n// GetAdapterVersion mocks base method.\nfunc (m *MockPersistentStorageInterface) GetAdapterVersion() int {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetAdapterVersion\")\n\tret0, _ := ret[0].(int)\n\treturn ret0\n}\n\n// GetAdapterVersion indicates an expected call of GetAdapterVersion.\nfunc (mr *MockPersistentStorageInterfaceMockRecorder) GetAdapterVersion() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetAdapterVersion\", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetAdapterVersion))\n}\n\n// GetAuthHandler mocks base method.\nfunc (m *MockPersistentStorageInterface) GetAuthHandler(name string) auth.AuthHandler {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetAuthHandler\", name)\n\tret0, _ := ret[0].(auth.AuthHandler)\n\treturn ret0\n}\n\n// GetAuthHandler indicates an expected call of GetAuthHandler.\nfunc (mr *MockPersistentStorageInterfaceMockRecorder) GetAuthHandler(name interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetAuthHandler\", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetAuthHandler), name)\n}\n\n// GetAuthNames mocks base method.\nfunc (m *MockPersistentStorageInterface) GetAuthNames() []string {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetAuthNames\")\n\tret0, _ := ret[0].([]string)\n\treturn ret0\n}\n\n// GetAuthNames indicates an expected call of GetAuthNames.\nfunc (mr *MockPersistentStorageInterfaceMockRecorder) GetAuthNames() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetAuthNames\", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetAuthNames))\n}\n\n// GetDbVersion mocks base method.\nfunc (m *MockPersistentStorageInterface) GetDbVersion() int {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetDbVersion\")\n\tret0, _ := ret[0].(int)\n\treturn ret0\n}\n\n// GetDbVersion indicates an expected call of GetDbVersion.\nfunc (mr *MockPersistentStorageInterfaceMockRecorder) GetDbVersion() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetDbVersion\", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetDbVersion))\n}\n\n// GetLogicalAuthHandler mocks base method.\nfunc (m *MockPersistentStorageInterface) GetLogicalAuthHandler(name string) auth.AuthHandler {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetLogicalAuthHandler\", name)\n\tret0, _ := ret[0].(auth.AuthHandler)\n\treturn ret0\n}\n\n// GetLogicalAuthHandler indicates an expected call of GetLogicalAuthHandler.\nfunc (mr *MockPersistentStorageInterfaceMockRecorder) GetLogicalAuthHandler(name interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetLogicalAuthHandler\", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetLogicalAuthHandler), name)\n}\n\n// GetMediaHandler mocks base method.\nfunc (m *MockPersistentStorageInterface) GetMediaHandler() media.Handler {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetMediaHandler\")\n\tret0, _ := ret[0].(media.Handler)\n\treturn ret0\n}\n\n// GetMediaHandler indicates an expected call of GetMediaHandler.\nfunc (mr *MockPersistentStorageInterfaceMockRecorder) GetMediaHandler() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetMediaHandler\", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetMediaHandler))\n}\n\n// GetUid mocks base method.\nfunc (m *MockPersistentStorageInterface) GetUid() types.Uid {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetUid\")\n\tret0, _ := ret[0].(types.Uid)\n\treturn ret0\n}\n\n// GetUid indicates an expected call of GetUid.\nfunc (mr *MockPersistentStorageInterfaceMockRecorder) GetUid() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetUid\", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetUid))\n}\n\n// GetUidString mocks base method.\nfunc (m *MockPersistentStorageInterface) GetUidString() string {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetUidString\")\n\tret0, _ := ret[0].(string)\n\treturn ret0\n}\n\n// GetUidString indicates an expected call of GetUidString.\nfunc (mr *MockPersistentStorageInterfaceMockRecorder) GetUidString() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetUidString\", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetUidString))\n}\n\n// GetValidator mocks base method.\nfunc (m *MockPersistentStorageInterface) GetValidator(name string) validate.Validator {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetValidator\", name)\n\tret0, _ := ret[0].(validate.Validator)\n\treturn ret0\n}\n\n// GetValidator indicates an expected call of GetValidator.\nfunc (mr *MockPersistentStorageInterfaceMockRecorder) GetValidator(name interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetValidator\", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetValidator), name)\n}\n\n// InitDb mocks base method.\nfunc (m *MockPersistentStorageInterface) InitDb(jsonconf json.RawMessage, reset bool) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"InitDb\", jsonconf, reset)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// InitDb indicates an expected call of InitDb.\nfunc (mr *MockPersistentStorageInterfaceMockRecorder) InitDb(jsonconf, reset interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"InitDb\", reflect.TypeOf((*MockPersistentStorageInterface)(nil).InitDb), jsonconf, reset)\n}\n\n// IsOpen mocks base method.\nfunc (m *MockPersistentStorageInterface) IsOpen() bool {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"IsOpen\")\n\tret0, _ := ret[0].(bool)\n\treturn ret0\n}\n\n// IsOpen indicates an expected call of IsOpen.\nfunc (mr *MockPersistentStorageInterfaceMockRecorder) IsOpen() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"IsOpen\", reflect.TypeOf((*MockPersistentStorageInterface)(nil).IsOpen))\n}\n\n// Open mocks base method.\nfunc (m *MockPersistentStorageInterface) Open(workerId int, jsonconf json.RawMessage) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Open\", workerId, jsonconf)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// Open indicates an expected call of Open.\nfunc (mr *MockPersistentStorageInterfaceMockRecorder) Open(workerId, jsonconf interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Open\", reflect.TypeOf((*MockPersistentStorageInterface)(nil).Open), workerId, jsonconf)\n}\n\n// UpgradeDb mocks base method.\nfunc (m *MockPersistentStorageInterface) UpgradeDb(jsonconf json.RawMessage) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"UpgradeDb\", jsonconf)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// UpgradeDb indicates an expected call of UpgradeDb.\nfunc (mr *MockPersistentStorageInterfaceMockRecorder) UpgradeDb(jsonconf interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"UpgradeDb\", reflect.TypeOf((*MockPersistentStorageInterface)(nil).UpgradeDb), jsonconf)\n}\n\n// UseMediaHandler mocks base method.\nfunc (m *MockPersistentStorageInterface) UseMediaHandler(name, config string) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"UseMediaHandler\", name, config)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// UseMediaHandler indicates an expected call of UseMediaHandler.\nfunc (mr *MockPersistentStorageInterfaceMockRecorder) UseMediaHandler(name, config interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"UseMediaHandler\", reflect.TypeOf((*MockPersistentStorageInterface)(nil).UseMediaHandler), name, config)\n}\n\n// MockUsersPersistenceInterface is a mock of UsersPersistenceInterface interface.\ntype MockUsersPersistenceInterface struct {\n\tctrl     *gomock.Controller\n\trecorder *MockUsersPersistenceInterfaceMockRecorder\n}\n\n// MockUsersPersistenceInterfaceMockRecorder is the mock recorder for MockUsersPersistenceInterface.\ntype MockUsersPersistenceInterfaceMockRecorder struct {\n\tmock *MockUsersPersistenceInterface\n}\n\n// NewMockUsersPersistenceInterface creates a new mock instance.\nfunc NewMockUsersPersistenceInterface(ctrl *gomock.Controller) *MockUsersPersistenceInterface {\n\tmock := &MockUsersPersistenceInterface{ctrl: ctrl}\n\tmock.recorder = &MockUsersPersistenceInterfaceMockRecorder{mock}\n\treturn mock\n}\n\n// EXPECT returns an object that allows the caller to indicate expected use.\nfunc (m *MockUsersPersistenceInterface) EXPECT() *MockUsersPersistenceInterfaceMockRecorder {\n\treturn m.recorder\n}\n\n// AddAuthRecord mocks base method.\nfunc (m *MockUsersPersistenceInterface) AddAuthRecord(uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"AddAuthRecord\", uid, authLvl, scheme, unique, secret, expires)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// AddAuthRecord indicates an expected call of AddAuthRecord.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) AddAuthRecord(uid, authLvl, scheme, unique, secret, expires interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"AddAuthRecord\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).AddAuthRecord), uid, authLvl, scheme, unique, secret, expires)\n}\n\n// ConfirmCred mocks base method.\nfunc (m *MockUsersPersistenceInterface) ConfirmCred(id types.Uid, method string) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"ConfirmCred\", id, method)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// ConfirmCred indicates an expected call of ConfirmCred.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) ConfirmCred(id, method interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"ConfirmCred\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).ConfirmCred), id, method)\n}\n\n// Create mocks base method.\nfunc (m *MockUsersPersistenceInterface) Create(user *types.User, private any) (*types.User, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Create\", user, private)\n\tret0, _ := ret[0].(*types.User)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// Create indicates an expected call of Create.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) Create(user, private interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Create\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).Create), user, private)\n}\n\n// DelAuthRecords mocks base method.\nfunc (m *MockUsersPersistenceInterface) DelAuthRecords(uid types.Uid, scheme string) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"DelAuthRecords\", uid, scheme)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// DelAuthRecords indicates an expected call of DelAuthRecords.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) DelAuthRecords(uid, scheme interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"DelAuthRecords\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).DelAuthRecords), uid, scheme)\n}\n\n// DelCred mocks base method.\nfunc (m *MockUsersPersistenceInterface) DelCred(id types.Uid, method, value string) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"DelCred\", id, method, value)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// DelCred indicates an expected call of DelCred.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) DelCred(id, method, value interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"DelCred\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).DelCred), id, method, value)\n}\n\n// Delete mocks base method.\nfunc (m *MockUsersPersistenceInterface) Delete(id types.Uid, hard bool) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Delete\", id, hard)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// Delete indicates an expected call of Delete.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) Delete(id, hard interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Delete\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).Delete), id, hard)\n}\n\n// FailCred mocks base method.\nfunc (m *MockUsersPersistenceInterface) FailCred(id types.Uid, method string) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"FailCred\", id, method)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// FailCred indicates an expected call of FailCred.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) FailCred(id, method interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"FailCred\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).FailCred), id, method)\n}\n\n// FindOne mocks base method.\nfunc (m *MockUsersPersistenceInterface) FindOne(tag string) (string, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"FindOne\", tag)\n\tret0, _ := ret[0].(string)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// FindOne indicates an expected call of FindOne.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) FindOne(tag interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"FindOne\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).FindOne), tag)\n}\n\n// FindSubs mocks base method.\nfunc (m *MockUsersPersistenceInterface) FindSubs(caller types.Uid, prefPrefix string, required [][]string, optional []string, activeOnly bool) ([]types.Subscription, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"FindSubs\", caller, prefPrefix, required, optional, activeOnly)\n\tret0, _ := ret[0].([]types.Subscription)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// FindSubs indicates an expected call of FindSubs.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) FindSubs(caller, prefPrefix, required, optional, activeOnly interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"FindSubs\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).FindSubs), caller, prefPrefix, required, optional, activeOnly)\n}\n\n// Get mocks base method.\nfunc (m *MockUsersPersistenceInterface) Get(uid types.Uid) (*types.User, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Get\", uid)\n\tret0, _ := ret[0].(*types.User)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// Get indicates an expected call of Get.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) Get(uid interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Get\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).Get), uid)\n}\n\n// GetActiveCred mocks base method.\nfunc (m *MockUsersPersistenceInterface) GetActiveCred(id types.Uid, method string) (*types.Credential, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetActiveCred\", id, method)\n\tret0, _ := ret[0].(*types.Credential)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// GetActiveCred indicates an expected call of GetActiveCred.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) GetActiveCred(id, method interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetActiveCred\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetActiveCred), id, method)\n}\n\n// GetAll mocks base method.\nfunc (m *MockUsersPersistenceInterface) GetAll(uid ...types.Uid) ([]types.User, error) {\n\tm.ctrl.T.Helper()\n\tvarargs := []interface{}{}\n\tfor _, a := range uid {\n\t\tvarargs = append(varargs, a)\n\t}\n\tret := m.ctrl.Call(m, \"GetAll\", varargs...)\n\tret0, _ := ret[0].([]types.User)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// GetAll indicates an expected call of GetAll.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) GetAll(uid ...interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetAll\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAll), uid...)\n}\n\n// GetAllCreds mocks base method.\nfunc (m *MockUsersPersistenceInterface) GetAllCreds(id types.Uid, method string, validatedOnly bool) ([]types.Credential, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetAllCreds\", id, method, validatedOnly)\n\tret0, _ := ret[0].([]types.Credential)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// GetAllCreds indicates an expected call of GetAllCreds.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) GetAllCreds(id, method, validatedOnly interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetAllCreds\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAllCreds), id, method, validatedOnly)\n}\n\n// GetAuthRecord mocks base method.\nfunc (m *MockUsersPersistenceInterface) GetAuthRecord(user types.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetAuthRecord\", user, scheme)\n\tret0, _ := ret[0].(string)\n\tret1, _ := ret[1].(auth.Level)\n\tret2, _ := ret[2].([]byte)\n\tret3, _ := ret[3].(time.Time)\n\tret4, _ := ret[4].(error)\n\treturn ret0, ret1, ret2, ret3, ret4\n}\n\n// GetAuthRecord indicates an expected call of GetAuthRecord.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthRecord(user, scheme interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetAuthRecord\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthRecord), user, scheme)\n}\n\n// GetAuthUniqueRecord mocks base method.\nfunc (m *MockUsersPersistenceInterface) GetAuthUniqueRecord(scheme, unique string) (types.Uid, auth.Level, []byte, time.Time, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetAuthUniqueRecord\", scheme, unique)\n\tret0, _ := ret[0].(types.Uid)\n\tret1, _ := ret[1].(auth.Level)\n\tret2, _ := ret[2].([]byte)\n\tret3, _ := ret[3].(time.Time)\n\tret4, _ := ret[4].(error)\n\treturn ret0, ret1, ret2, ret3, ret4\n}\n\n// GetAuthUniqueRecord indicates an expected call of GetAuthUniqueRecord.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthUniqueRecord(scheme, unique interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetAuthUniqueRecord\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthUniqueRecord), scheme, unique)\n}\n\n// GetByCred mocks base method.\nfunc (m *MockUsersPersistenceInterface) GetByCred(method, value string) (types.Uid, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetByCred\", method, value)\n\tret0, _ := ret[0].(types.Uid)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// GetByCred indicates an expected call of GetByCred.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) GetByCred(method, value interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetByCred\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetByCred), method, value)\n}\n\n// GetChannels mocks base method.\nfunc (m *MockUsersPersistenceInterface) GetChannels(id types.Uid) ([]string, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetChannels\", id)\n\tret0, _ := ret[0].([]string)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// GetChannels indicates an expected call of GetChannels.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) GetChannels(id interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetChannels\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetChannels), id)\n}\n\n// GetOwnTopics mocks base method.\nfunc (m *MockUsersPersistenceInterface) GetOwnTopics(id types.Uid) ([]string, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetOwnTopics\", id)\n\tret0, _ := ret[0].([]string)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// GetOwnTopics indicates an expected call of GetOwnTopics.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) GetOwnTopics(id interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetOwnTopics\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetOwnTopics), id)\n}\n\n// GetSubs mocks base method.\nfunc (m *MockUsersPersistenceInterface) GetSubs(id types.Uid) ([]types.Subscription, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetSubs\", id)\n\tret0, _ := ret[0].([]types.Subscription)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// GetSubs indicates an expected call of GetSubs.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) GetSubs(id interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetSubs\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetSubs), id)\n}\n\n// GetTopics mocks base method.\nfunc (m *MockUsersPersistenceInterface) GetTopics(id types.Uid, opts *types.QueryOpt) ([]types.Subscription, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetTopics\", id, opts)\n\tret0, _ := ret[0].([]types.Subscription)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// GetTopics indicates an expected call of GetTopics.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) GetTopics(id, opts interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetTopics\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetTopics), id, opts)\n}\n\n// GetTopicsAny mocks base method.\nfunc (m *MockUsersPersistenceInterface) GetTopicsAny(id types.Uid, opts *types.QueryOpt) ([]types.Subscription, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetTopicsAny\", id, opts)\n\tret0, _ := ret[0].([]types.Subscription)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// GetTopicsAny indicates an expected call of GetTopicsAny.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) GetTopicsAny(id, opts interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetTopicsAny\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetTopicsAny), id, opts)\n}\n\n// GetUnreadCount mocks base method.\nfunc (m *MockUsersPersistenceInterface) GetUnreadCount(ids ...types.Uid) (map[types.Uid]int, error) {\n\tm.ctrl.T.Helper()\n\tvarargs := []interface{}{}\n\tfor _, a := range ids {\n\t\tvarargs = append(varargs, a)\n\t}\n\tret := m.ctrl.Call(m, \"GetUnreadCount\", varargs...)\n\tret0, _ := ret[0].(map[types.Uid]int)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// GetUnreadCount indicates an expected call of GetUnreadCount.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) GetUnreadCount(ids ...interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetUnreadCount\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetUnreadCount), ids...)\n}\n\n// GetUnvalidated mocks base method.\nfunc (m *MockUsersPersistenceInterface) GetUnvalidated(lastUpdatedBefore time.Time, limit int) ([]types.Uid, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetUnvalidated\", lastUpdatedBefore, limit)\n\tret0, _ := ret[0].([]types.Uid)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// GetUnvalidated indicates an expected call of GetUnvalidated.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) GetUnvalidated(lastUpdatedBefore, limit interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetUnvalidated\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetUnvalidated), lastUpdatedBefore, limit)\n}\n\n// Update mocks base method.\nfunc (m *MockUsersPersistenceInterface) Update(uid types.Uid, update map[string]any) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Update\", uid, update)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// Update indicates an expected call of Update.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) Update(uid, update interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Update\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).Update), uid, update)\n}\n\n// UpdateAuthRecord mocks base method.\nfunc (m *MockUsersPersistenceInterface) UpdateAuthRecord(uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"UpdateAuthRecord\", uid, authLvl, scheme, unique, secret, expires)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// UpdateAuthRecord indicates an expected call of UpdateAuthRecord.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) UpdateAuthRecord(uid, authLvl, scheme, unique, secret, expires interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"UpdateAuthRecord\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).UpdateAuthRecord), uid, authLvl, scheme, unique, secret, expires)\n}\n\n// UpdateLastSeen mocks base method.\nfunc (m *MockUsersPersistenceInterface) UpdateLastSeen(uid types.Uid, userAgent string, when time.Time) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"UpdateLastSeen\", uid, userAgent, when)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// UpdateLastSeen indicates an expected call of UpdateLastSeen.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) UpdateLastSeen(uid, userAgent, when interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"UpdateLastSeen\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).UpdateLastSeen), uid, userAgent, when)\n}\n\n// UpdateState mocks base method.\nfunc (m *MockUsersPersistenceInterface) UpdateState(uid types.Uid, state types.ObjState) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"UpdateState\", uid, state)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// UpdateState indicates an expected call of UpdateState.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) UpdateState(uid, state interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"UpdateState\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).UpdateState), uid, state)\n}\n\n// UpdateTags mocks base method.\nfunc (m *MockUsersPersistenceInterface) UpdateTags(uid types.Uid, add, remove, reset []string) ([]string, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"UpdateTags\", uid, add, remove, reset)\n\tret0, _ := ret[0].([]string)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// UpdateTags indicates an expected call of UpdateTags.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) UpdateTags(uid, add, remove, reset interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"UpdateTags\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).UpdateTags), uid, add, remove, reset)\n}\n\n// UpsertCred mocks base method.\nfunc (m *MockUsersPersistenceInterface) UpsertCred(cred *types.Credential) (bool, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"UpsertCred\", cred)\n\tret0, _ := ret[0].(bool)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// UpsertCred indicates an expected call of UpsertCred.\nfunc (mr *MockUsersPersistenceInterfaceMockRecorder) UpsertCred(cred interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"UpsertCred\", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).UpsertCred), cred)\n}\n\n// MockTopicsPersistenceInterface is a mock of TopicsPersistenceInterface interface.\ntype MockTopicsPersistenceInterface struct {\n\tctrl     *gomock.Controller\n\trecorder *MockTopicsPersistenceInterfaceMockRecorder\n}\n\n// MockTopicsPersistenceInterfaceMockRecorder is the mock recorder for MockTopicsPersistenceInterface.\ntype MockTopicsPersistenceInterfaceMockRecorder struct {\n\tmock *MockTopicsPersistenceInterface\n}\n\n// NewMockTopicsPersistenceInterface creates a new mock instance.\nfunc NewMockTopicsPersistenceInterface(ctrl *gomock.Controller) *MockTopicsPersistenceInterface {\n\tmock := &MockTopicsPersistenceInterface{ctrl: ctrl}\n\tmock.recorder = &MockTopicsPersistenceInterfaceMockRecorder{mock}\n\treturn mock\n}\n\n// EXPECT returns an object that allows the caller to indicate expected use.\nfunc (m *MockTopicsPersistenceInterface) EXPECT() *MockTopicsPersistenceInterfaceMockRecorder {\n\treturn m.recorder\n}\n\n// Create mocks base method.\nfunc (m *MockTopicsPersistenceInterface) Create(topic *types.Topic, owner types.Uid, private any) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Create\", topic, owner, private)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// Create indicates an expected call of Create.\nfunc (mr *MockTopicsPersistenceInterfaceMockRecorder) Create(topic, owner, private interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Create\", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).Create), topic, owner, private)\n}\n\n// CreateP2P mocks base method.\nfunc (m *MockTopicsPersistenceInterface) CreateP2P(initiator, invited *types.Subscription) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"CreateP2P\", initiator, invited)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// CreateP2P indicates an expected call of CreateP2P.\nfunc (mr *MockTopicsPersistenceInterfaceMockRecorder) CreateP2P(initiator, invited interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"CreateP2P\", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).CreateP2P), initiator, invited)\n}\n\n// Delete mocks base method.\nfunc (m *MockTopicsPersistenceInterface) Delete(topic string, isChan, hard bool) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Delete\", topic, isChan, hard)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// Delete indicates an expected call of Delete.\nfunc (mr *MockTopicsPersistenceInterfaceMockRecorder) Delete(topic, isChan, hard interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Delete\", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).Delete), topic, isChan, hard)\n}\n\n// Get mocks base method.\nfunc (m *MockTopicsPersistenceInterface) Get(topic string) (*types.Topic, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Get\", topic)\n\tret0, _ := ret[0].(*types.Topic)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// Get indicates an expected call of Get.\nfunc (mr *MockTopicsPersistenceInterfaceMockRecorder) Get(topic interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Get\", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).Get), topic)\n}\n\n// GetSubs mocks base method.\nfunc (m *MockTopicsPersistenceInterface) GetSubs(topic string, opts *types.QueryOpt) ([]types.Subscription, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetSubs\", topic, opts)\n\tret0, _ := ret[0].([]types.Subscription)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// GetSubs indicates an expected call of GetSubs.\nfunc (mr *MockTopicsPersistenceInterfaceMockRecorder) GetSubs(topic, opts interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetSubs\", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).GetSubs), topic, opts)\n}\n\n// GetSubsAny mocks base method.\nfunc (m *MockTopicsPersistenceInterface) GetSubsAny(topic string, opts *types.QueryOpt) ([]types.Subscription, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetSubsAny\", topic, opts)\n\tret0, _ := ret[0].([]types.Subscription)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// GetSubsAny indicates an expected call of GetSubsAny.\nfunc (mr *MockTopicsPersistenceInterfaceMockRecorder) GetSubsAny(topic, opts interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetSubsAny\", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).GetSubsAny), topic, opts)\n}\n\n// GetUsers mocks base method.\nfunc (m *MockTopicsPersistenceInterface) GetUsers(topic string, opts *types.QueryOpt) ([]types.Subscription, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetUsers\", topic, opts)\n\tret0, _ := ret[0].([]types.Subscription)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// GetUsers indicates an expected call of GetUsers.\nfunc (mr *MockTopicsPersistenceInterfaceMockRecorder) GetUsers(topic, opts interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetUsers\", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).GetUsers), topic, opts)\n}\n\n// GetUsersAny mocks base method.\nfunc (m *MockTopicsPersistenceInterface) GetUsersAny(topic string, opts *types.QueryOpt) ([]types.Subscription, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetUsersAny\", topic, opts)\n\tret0, _ := ret[0].([]types.Subscription)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// GetUsersAny indicates an expected call of GetUsersAny.\nfunc (mr *MockTopicsPersistenceInterfaceMockRecorder) GetUsersAny(topic, opts interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetUsersAny\", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).GetUsersAny), topic, opts)\n}\n\n// OwnerChange mocks base method.\nfunc (m *MockTopicsPersistenceInterface) OwnerChange(topic string, newOwner types.Uid) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"OwnerChange\", topic, newOwner)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// OwnerChange indicates an expected call of OwnerChange.\nfunc (mr *MockTopicsPersistenceInterfaceMockRecorder) OwnerChange(topic, newOwner interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"OwnerChange\", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).OwnerChange), topic, newOwner)\n}\n\n// Update mocks base method.\nfunc (m *MockTopicsPersistenceInterface) Update(topic string, update map[string]any) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Update\", topic, update)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// Update indicates an expected call of Update.\nfunc (mr *MockTopicsPersistenceInterfaceMockRecorder) Update(topic, update interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Update\", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).Update), topic, update)\n}\n\n// UpdateSubCnt mocks base method.\nfunc (m *MockTopicsPersistenceInterface) UpdateSubCnt(topic string) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"UpdateSubCnt\", topic)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// UpdateSubCnt indicates an expected call of UpdateSubCnt.\nfunc (mr *MockTopicsPersistenceInterfaceMockRecorder) UpdateSubCnt(topic interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"UpdateSubCnt\", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).UpdateSubCnt), topic)\n}\n\n// MockSubsPersistenceInterface is a mock of SubsPersistenceInterface interface.\ntype MockSubsPersistenceInterface struct {\n\tctrl     *gomock.Controller\n\trecorder *MockSubsPersistenceInterfaceMockRecorder\n}\n\n// MockSubsPersistenceInterfaceMockRecorder is the mock recorder for MockSubsPersistenceInterface.\ntype MockSubsPersistenceInterfaceMockRecorder struct {\n\tmock *MockSubsPersistenceInterface\n}\n\n// NewMockSubsPersistenceInterface creates a new mock instance.\nfunc NewMockSubsPersistenceInterface(ctrl *gomock.Controller) *MockSubsPersistenceInterface {\n\tmock := &MockSubsPersistenceInterface{ctrl: ctrl}\n\tmock.recorder = &MockSubsPersistenceInterfaceMockRecorder{mock}\n\treturn mock\n}\n\n// EXPECT returns an object that allows the caller to indicate expected use.\nfunc (m *MockSubsPersistenceInterface) EXPECT() *MockSubsPersistenceInterfaceMockRecorder {\n\treturn m.recorder\n}\n\n// Create mocks base method.\nfunc (m *MockSubsPersistenceInterface) Create(subs ...*types.Subscription) error {\n\tm.ctrl.T.Helper()\n\tvarargs := []interface{}{}\n\tfor _, a := range subs {\n\t\tvarargs = append(varargs, a)\n\t}\n\tret := m.ctrl.Call(m, \"Create\", varargs...)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// Create indicates an expected call of Create.\nfunc (mr *MockSubsPersistenceInterfaceMockRecorder) Create(subs ...interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Create\", reflect.TypeOf((*MockSubsPersistenceInterface)(nil).Create), subs...)\n}\n\n// Delete mocks base method.\nfunc (m *MockSubsPersistenceInterface) Delete(topic string, user types.Uid) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Delete\", topic, user)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// Delete indicates an expected call of Delete.\nfunc (mr *MockSubsPersistenceInterfaceMockRecorder) Delete(topic, user interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Delete\", reflect.TypeOf((*MockSubsPersistenceInterface)(nil).Delete), topic, user)\n}\n\n// Get mocks base method.\nfunc (m *MockSubsPersistenceInterface) Get(topic string, user types.Uid, keepDeleted bool) (*types.Subscription, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Get\", topic, user, keepDeleted)\n\tret0, _ := ret[0].(*types.Subscription)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// Get indicates an expected call of Get.\nfunc (mr *MockSubsPersistenceInterfaceMockRecorder) Get(topic, user, keepDeleted interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Get\", reflect.TypeOf((*MockSubsPersistenceInterface)(nil).Get), topic, user, keepDeleted)\n}\n\n// Update mocks base method.\nfunc (m *MockSubsPersistenceInterface) Update(topic string, user types.Uid, update map[string]any) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Update\", topic, user, update)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// Update indicates an expected call of Update.\nfunc (mr *MockSubsPersistenceInterfaceMockRecorder) Update(topic, user, update interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Update\", reflect.TypeOf((*MockSubsPersistenceInterface)(nil).Update), topic, user, update)\n}\n\n// MockMessagesPersistenceInterface is a mock of MessagesPersistenceInterface interface.\ntype MockMessagesPersistenceInterface struct {\n\tctrl     *gomock.Controller\n\trecorder *MockMessagesPersistenceInterfaceMockRecorder\n}\n\n// MockMessagesPersistenceInterfaceMockRecorder is the mock recorder for MockMessagesPersistenceInterface.\ntype MockMessagesPersistenceInterfaceMockRecorder struct {\n\tmock *MockMessagesPersistenceInterface\n}\n\n// NewMockMessagesPersistenceInterface creates a new mock instance.\nfunc NewMockMessagesPersistenceInterface(ctrl *gomock.Controller) *MockMessagesPersistenceInterface {\n\tmock := &MockMessagesPersistenceInterface{ctrl: ctrl}\n\tmock.recorder = &MockMessagesPersistenceInterfaceMockRecorder{mock}\n\treturn mock\n}\n\n// EXPECT returns an object that allows the caller to indicate expected use.\nfunc (m *MockMessagesPersistenceInterface) EXPECT() *MockMessagesPersistenceInterfaceMockRecorder {\n\treturn m.recorder\n}\n\n// DeleteList mocks base method.\nfunc (m *MockMessagesPersistenceInterface) DeleteList(topic string, delID int, forUser types.Uid, msgDelAge time.Duration, ranges []types.Range) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"DeleteList\", topic, delID, forUser, msgDelAge, ranges)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// DeleteList indicates an expected call of DeleteList.\nfunc (mr *MockMessagesPersistenceInterfaceMockRecorder) DeleteList(topic, delID, forUser, msgDelAge, ranges interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"DeleteList\", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).DeleteList), topic, delID, forUser, msgDelAge, ranges)\n}\n\n// GetAll mocks base method.\nfunc (m *MockMessagesPersistenceInterface) GetAll(topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Message, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetAll\", topic, forUser, opt)\n\tret0, _ := ret[0].([]types.Message)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// GetAll indicates an expected call of GetAll.\nfunc (mr *MockMessagesPersistenceInterfaceMockRecorder) GetAll(topic, forUser, opt interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetAll\", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).GetAll), topic, forUser, opt)\n}\n\n// GetDeleted mocks base method.\nfunc (m *MockMessagesPersistenceInterface) GetDeleted(topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Range, int, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetDeleted\", topic, forUser, opt)\n\tret0, _ := ret[0].([]types.Range)\n\tret1, _ := ret[1].(int)\n\tret2, _ := ret[2].(error)\n\treturn ret0, ret1, ret2\n}\n\n// GetDeleted indicates an expected call of GetDeleted.\nfunc (mr *MockMessagesPersistenceInterfaceMockRecorder) GetDeleted(topic, forUser, opt interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetDeleted\", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).GetDeleted), topic, forUser, opt)\n}\n\n// Save mocks base method.\nfunc (m *MockMessagesPersistenceInterface) Save(msg *types.Message, attachmentURLs []string, readBySender bool) (error, bool) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Save\", msg, attachmentURLs, readBySender)\n\tret0, _ := ret[0].(error)\n\tret1, _ := ret[1].(bool)\n\treturn ret0, ret1\n}\n\n// Save indicates an expected call of Save.\nfunc (mr *MockMessagesPersistenceInterfaceMockRecorder) Save(msg, attachmentURLs, readBySender interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Save\", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).Save), msg, attachmentURLs, readBySender)\n}\n\n// MockDevicePersistenceInterface is a mock of DevicePersistenceInterface interface.\ntype MockDevicePersistenceInterface struct {\n\tctrl     *gomock.Controller\n\trecorder *MockDevicePersistenceInterfaceMockRecorder\n}\n\n// MockDevicePersistenceInterfaceMockRecorder is the mock recorder for MockDevicePersistenceInterface.\ntype MockDevicePersistenceInterfaceMockRecorder struct {\n\tmock *MockDevicePersistenceInterface\n}\n\n// NewMockDevicePersistenceInterface creates a new mock instance.\nfunc NewMockDevicePersistenceInterface(ctrl *gomock.Controller) *MockDevicePersistenceInterface {\n\tmock := &MockDevicePersistenceInterface{ctrl: ctrl}\n\tmock.recorder = &MockDevicePersistenceInterfaceMockRecorder{mock}\n\treturn mock\n}\n\n// EXPECT returns an object that allows the caller to indicate expected use.\nfunc (m *MockDevicePersistenceInterface) EXPECT() *MockDevicePersistenceInterfaceMockRecorder {\n\treturn m.recorder\n}\n\n// Delete mocks base method.\nfunc (m *MockDevicePersistenceInterface) Delete(uid types.Uid, deviceID string) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Delete\", uid, deviceID)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// Delete indicates an expected call of Delete.\nfunc (mr *MockDevicePersistenceInterfaceMockRecorder) Delete(uid, deviceID interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Delete\", reflect.TypeOf((*MockDevicePersistenceInterface)(nil).Delete), uid, deviceID)\n}\n\n// GetAll mocks base method.\nfunc (m *MockDevicePersistenceInterface) GetAll(uid ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) {\n\tm.ctrl.T.Helper()\n\tvarargs := []interface{}{}\n\tfor _, a := range uid {\n\t\tvarargs = append(varargs, a)\n\t}\n\tret := m.ctrl.Call(m, \"GetAll\", varargs...)\n\tret0, _ := ret[0].(map[types.Uid][]types.DeviceDef)\n\tret1, _ := ret[1].(int)\n\tret2, _ := ret[2].(error)\n\treturn ret0, ret1, ret2\n}\n\n// GetAll indicates an expected call of GetAll.\nfunc (mr *MockDevicePersistenceInterfaceMockRecorder) GetAll(uid ...interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetAll\", reflect.TypeOf((*MockDevicePersistenceInterface)(nil).GetAll), uid...)\n}\n\n// Update mocks base method.\nfunc (m *MockDevicePersistenceInterface) Update(uid types.Uid, oldDeviceID string, dev *types.DeviceDef) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Update\", uid, oldDeviceID, dev)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// Update indicates an expected call of Update.\nfunc (mr *MockDevicePersistenceInterfaceMockRecorder) Update(uid, oldDeviceID, dev interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Update\", reflect.TypeOf((*MockDevicePersistenceInterface)(nil).Update), uid, oldDeviceID, dev)\n}\n\n// MockFilePersistenceInterface is a mock of FilePersistenceInterface interface.\ntype MockFilePersistenceInterface struct {\n\tctrl     *gomock.Controller\n\trecorder *MockFilePersistenceInterfaceMockRecorder\n}\n\n// MockFilePersistenceInterfaceMockRecorder is the mock recorder for MockFilePersistenceInterface.\ntype MockFilePersistenceInterfaceMockRecorder struct {\n\tmock *MockFilePersistenceInterface\n}\n\n// NewMockFilePersistenceInterface creates a new mock instance.\nfunc NewMockFilePersistenceInterface(ctrl *gomock.Controller) *MockFilePersistenceInterface {\n\tmock := &MockFilePersistenceInterface{ctrl: ctrl}\n\tmock.recorder = &MockFilePersistenceInterfaceMockRecorder{mock}\n\treturn mock\n}\n\n// EXPECT returns an object that allows the caller to indicate expected use.\nfunc (m *MockFilePersistenceInterface) EXPECT() *MockFilePersistenceInterfaceMockRecorder {\n\treturn m.recorder\n}\n\n// DeleteUnused mocks base method.\nfunc (m *MockFilePersistenceInterface) DeleteUnused(olderThan time.Time, limit int) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"DeleteUnused\", olderThan, limit)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// DeleteUnused indicates an expected call of DeleteUnused.\nfunc (mr *MockFilePersistenceInterfaceMockRecorder) DeleteUnused(olderThan, limit interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"DeleteUnused\", reflect.TypeOf((*MockFilePersistenceInterface)(nil).DeleteUnused), olderThan, limit)\n}\n\n// FinishUpload mocks base method.\nfunc (m *MockFilePersistenceInterface) FinishUpload(fd *types.FileDef, success bool, size int64) (*types.FileDef, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"FinishUpload\", fd, success, size)\n\tret0, _ := ret[0].(*types.FileDef)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// FinishUpload indicates an expected call of FinishUpload.\nfunc (mr *MockFilePersistenceInterfaceMockRecorder) FinishUpload(fd, success, size interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"FinishUpload\", reflect.TypeOf((*MockFilePersistenceInterface)(nil).FinishUpload), fd, success, size)\n}\n\n// Get mocks base method.\nfunc (m *MockFilePersistenceInterface) Get(fid string) (*types.FileDef, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Get\", fid)\n\tret0, _ := ret[0].(*types.FileDef)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// Get indicates an expected call of Get.\nfunc (mr *MockFilePersistenceInterfaceMockRecorder) Get(fid interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Get\", reflect.TypeOf((*MockFilePersistenceInterface)(nil).Get), fid)\n}\n\n// LinkAttachments mocks base method.\nfunc (m *MockFilePersistenceInterface) LinkAttachments(topic string, msgId types.Uid, attachments []string) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"LinkAttachments\", topic, msgId, attachments)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// LinkAttachments indicates an expected call of LinkAttachments.\nfunc (mr *MockFilePersistenceInterfaceMockRecorder) LinkAttachments(topic, msgId, attachments interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"LinkAttachments\", reflect.TypeOf((*MockFilePersistenceInterface)(nil).LinkAttachments), topic, msgId, attachments)\n}\n\n// StartUpload mocks base method.\nfunc (m *MockFilePersistenceInterface) StartUpload(fd *types.FileDef) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"StartUpload\", fd)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// StartUpload indicates an expected call of StartUpload.\nfunc (mr *MockFilePersistenceInterfaceMockRecorder) StartUpload(fd interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"StartUpload\", reflect.TypeOf((*MockFilePersistenceInterface)(nil).StartUpload), fd)\n}\n\n// MockPersistentCacheInterface is a mock of PersistentCacheInterface interface.\ntype MockPersistentCacheInterface struct {\n\tctrl     *gomock.Controller\n\trecorder *MockPersistentCacheInterfaceMockRecorder\n}\n\n// MockPersistentCacheInterfaceMockRecorder is the mock recorder for MockPersistentCacheInterface.\ntype MockPersistentCacheInterfaceMockRecorder struct {\n\tmock *MockPersistentCacheInterface\n}\n\n// NewMockPersistentCacheInterface creates a new mock instance.\nfunc NewMockPersistentCacheInterface(ctrl *gomock.Controller) *MockPersistentCacheInterface {\n\tmock := &MockPersistentCacheInterface{ctrl: ctrl}\n\tmock.recorder = &MockPersistentCacheInterfaceMockRecorder{mock}\n\treturn mock\n}\n\n// EXPECT returns an object that allows the caller to indicate expected use.\nfunc (m *MockPersistentCacheInterface) EXPECT() *MockPersistentCacheInterfaceMockRecorder {\n\treturn m.recorder\n}\n\n// Delete mocks base method.\nfunc (m *MockPersistentCacheInterface) Delete(key string) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Delete\", key)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// Delete indicates an expected call of Delete.\nfunc (mr *MockPersistentCacheInterfaceMockRecorder) Delete(key interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Delete\", reflect.TypeOf((*MockPersistentCacheInterface)(nil).Delete), key)\n}\n\n// Expire mocks base method.\nfunc (m *MockPersistentCacheInterface) Expire(keyPrefix string, olderThan time.Time) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Expire\", keyPrefix, olderThan)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// Expire indicates an expected call of Expire.\nfunc (mr *MockPersistentCacheInterfaceMockRecorder) Expire(keyPrefix, olderThan interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Expire\", reflect.TypeOf((*MockPersistentCacheInterface)(nil).Expire), keyPrefix, olderThan)\n}\n\n// Get mocks base method.\nfunc (m *MockPersistentCacheInterface) Get(key string) (string, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Get\", key)\n\tret0, _ := ret[0].(string)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// Get indicates an expected call of Get.\nfunc (mr *MockPersistentCacheInterfaceMockRecorder) Get(key interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Get\", reflect.TypeOf((*MockPersistentCacheInterface)(nil).Get), key)\n}\n\n// Upsert mocks base method.\nfunc (m *MockPersistentCacheInterface) Upsert(key, value string, failOnDuplicate bool) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Upsert\", key, value, failOnDuplicate)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// Upsert indicates an expected call of Upsert.\nfunc (mr *MockPersistentCacheInterfaceMockRecorder) Upsert(key, value, failOnDuplicate interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Upsert\", reflect.TypeOf((*MockPersistentCacheInterface)(nil).Upsert), key, value, failOnDuplicate)\n}\n"
  },
  {
    "path": "server/store/store.go",
    "content": "// Package store provides methods for registering and accessing database adapters.\npackage store\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/auth\"\n\tadapter \"github.com/tinode/chat/server/db\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/media\"\n\t\"github.com/tinode/chat/server/store/types\"\n\t\"github.com/tinode/chat/server/validate\"\n)\n\nvar adp adapter.Adapter\nvar availableAdapters = make(map[string]adapter.Adapter)\nvar mediaHandler media.Handler\n\n// Unique ID generator\nvar uGen types.UidGenerator\n\ntype configType struct {\n\t// 16-byte key for XTEA. Used to initialize types.UidGenerator.\n\tUidKey []byte `json:\"uid_key\"`\n\t// Maximum number of results to return from adapter.\n\tMaxResults int `json:\"max_results\"`\n\t// DB adapter name to use. Should be one of those specified in `Adapters`.\n\tUseAdapter string `json:\"use_adapter\"`\n\t// Configurations for individual adapters.\n\tAdapters map[string]json.RawMessage `json:\"adapters\"`\n}\n\nfunc openAdapter(workerId int, jsonconf json.RawMessage) error {\n\tvar config configType\n\tif err := json.Unmarshal(jsonconf, &config); err != nil {\n\t\treturn errors.New(\"store: failed to parse config: \" + err.Error() + \"(\" + string(jsonconf) + \")\")\n\t}\n\n\tif adp == nil {\n\t\tif len(config.UseAdapter) > 0 {\n\t\t\t// Adapter name specified explicitly.\n\t\t\tif ad, ok := availableAdapters[config.UseAdapter]; ok {\n\t\t\t\tadp = ad\n\t\t\t} else {\n\t\t\t\treturn errors.New(\"store: \" + config.UseAdapter + \" adapter is not available in this binary\")\n\t\t\t}\n\t\t} else if len(availableAdapters) == 1 {\n\t\t\t// Default to the only entry in availableAdapters.\n\t\t\tfor _, v := range availableAdapters {\n\t\t\t\tadp = v\n\t\t\t}\n\t\t} else {\n\t\t\treturn errors.New(\"store: db adapter is not specified. Please set `store_config.use_adapter` in `tinode.conf`\")\n\t\t}\n\t}\n\n\tif adp.IsOpen() {\n\t\treturn errors.New(\"store: connection is already opened\")\n\t}\n\n\t// Initialize snowflake.\n\tif workerId < 0 || workerId > 1023 {\n\t\treturn errors.New(\"store: invalid worker ID\")\n\t}\n\n\tif err := uGen.Init(uint(workerId), config.UidKey); err != nil {\n\t\treturn errors.New(\"store: failed to init snowflake: \" + err.Error())\n\t}\n\n\tif err := adp.SetMaxResults(config.MaxResults); err != nil {\n\t\treturn err\n\t}\n\n\tvar adapterConfig json.RawMessage\n\tif config.Adapters != nil {\n\t\tadapterConfig = config.Adapters[adp.GetName()]\n\t}\n\n\treturn adp.Open(adapterConfig)\n}\n\n// PersistentStorageInterface defines methods used for interation with persistent storage.\ntype PersistentStorageInterface interface {\n\tOpen(workerId int, jsonconf json.RawMessage) error\n\tClose() error\n\tIsOpen() bool\n\tGetAdapter() adapter.Adapter\n\tGetAdapterName() string\n\tGetAdapterVersion() int\n\tGetDbVersion() int\n\tInitDb(jsonconf json.RawMessage, reset bool) error\n\tUpgradeDb(jsonconf json.RawMessage) error\n\tGetUid() types.Uid\n\tGetUidString() string\n\tDbStats() func() any\n\tGetAuthNames() []string\n\tGetAuthHandler(name string) auth.AuthHandler\n\tGetLogicalAuthHandler(name string) auth.AuthHandler\n\tGetValidator(name string) validate.Validator\n\tGetMediaHandler() media.Handler\n\tUseMediaHandler(name, config string) error\n}\n\n// Store is the main object for interacting with persistent storage.\nvar Store PersistentStorageInterface\n\ntype storeObj struct{}\n\n// Open initializes the persistence system. Adapter holds a connection pool for a database instance.\n//\n//\t\tname - name of the adapter rquested in the config file\n//\t  jsonconf - configuration string\nfunc (storeObj) Open(workerId int, jsonconf json.RawMessage) error {\n\tif err := openAdapter(workerId, jsonconf); err != nil {\n\t\treturn err\n\t}\n\n\treturn adp.CheckDbVersion()\n}\n\n// Close terminates connection to persistent storage.\nfunc (storeObj) Close() error {\n\tif adp.IsOpen() {\n\t\treturn adp.Close()\n\t}\n\n\treturn nil\n}\n\n// IsOpen checks if persistent storage connection has been initialized.\nfunc (storeObj) IsOpen() bool {\n\tif adp != nil {\n\t\treturn adp.IsOpen()\n\t}\n\n\treturn false\n}\n\n// GetAdapter returns the currently configured adapter.\nfunc (storeObj) GetAdapter() adapter.Adapter {\n\treturn adp\n}\n\n// GetAdapterName returns the name of the current adater.\nfunc (storeObj) GetAdapterName() string {\n\tif adp != nil {\n\t\treturn adp.GetName()\n\t}\n\n\treturn \"\"\n}\n\n// GetAdapterVersion returns version of the current adater.\nfunc (storeObj) GetAdapterVersion() int {\n\tif adp != nil {\n\t\treturn adp.Version()\n\t}\n\n\treturn -1\n}\n\n// GetDbVersion returns version of the underlying database.\nfunc (storeObj) GetDbVersion() int {\n\tif adp != nil {\n\t\tvers, _ := adp.GetDbVersion()\n\t\treturn vers\n\t}\n\n\treturn -1\n}\n\n// InitDb creates and configures a new database instance. If 'reset' is true it will first\n// attempt to drop an existing database. If jsconf is nil it will assume that the adapter is\n// already open. If it's non-nil and the adapter is not open, it will use the config string\n// to open the adapter first.\nfunc (s storeObj) InitDb(jsonconf json.RawMessage, reset bool) error {\n\tif !s.IsOpen() {\n\t\tif err := openAdapter(1, jsonconf); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn adp.CreateDb(reset)\n}\n\n// UpgradeDb performes an upgrade of the database to the current adapter version.\n// If jsconf is nil it will assume that the adapter is already open. If it's non-nil and the\n// adapter is not open, it will use the config string to open the adapter first.\nfunc (s storeObj) UpgradeDb(jsonconf json.RawMessage) error {\n\tif !s.IsOpen() {\n\t\tif err := openAdapter(1, jsonconf); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn adp.UpgradeDb()\n}\n\n// RegisterAdapter makes a persistence adapter available.\n// If Register is called twice or if the adapter is nil, it panics.\nfunc RegisterAdapter(a adapter.Adapter) {\n\tif a == nil {\n\t\tpanic(\"store: Register adapter is nil\")\n\t}\n\n\tadapterName := a.GetName()\n\tif _, ok := availableAdapters[adapterName]; ok {\n\t\tpanic(\"store: adapter '\" + adapterName + \"' is already registered\")\n\t}\n\tavailableAdapters[adapterName] = a\n}\n\n// GetUid generates a unique ID suitable for use as a primary key.\nfunc (storeObj) GetUid() types.Uid {\n\treturn uGen.Get()\n}\n\n// GetUidString generate unique ID as a string.\nfunc (storeObj) GetUidString() string {\n\treturn uGen.GetStr()\n}\n\n// DecodeUid takes an XTEA encrypted Uid and decrypts it into an int64.\n// This is needed for sql compatibility. Tte original int64 values\n// are generated by snowflake which ensures that the top bit is unset.\nfunc DecodeUid(uid types.Uid) int64 {\n\tif uid.IsZero() {\n\t\treturn 0\n\t}\n\treturn uGen.DecodeUid(uid)\n}\n\n// EncodeUid applies XTEA encryption to an int64 value. It's the inverse of DecodeUid.\nfunc EncodeUid(id int64) types.Uid {\n\tif id == 0 {\n\t\treturn types.ZeroUid\n\t}\n\treturn uGen.EncodeInt64(id)\n}\n\n// DbStats returns a callback returning db connection stats object.\nfunc (s storeObj) DbStats() func() any {\n\tif !s.IsOpen() {\n\t\treturn nil\n\t}\n\treturn adp.Stats\n}\n\n// UsersPersistenceInterface is an interface which defines methods for persistent storage of user records.\ntype UsersPersistenceInterface interface {\n\tCreate(user *types.User, private any) (*types.User, error)\n\tGetAuthRecord(user types.Uid, scheme string) (string, auth.Level, []byte, time.Time, error)\n\tGetAuthUniqueRecord(scheme, unique string) (types.Uid, auth.Level, []byte, time.Time, error)\n\tAddAuthRecord(uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error\n\tUpdateAuthRecord(uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error\n\tDelAuthRecords(uid types.Uid, scheme string) error\n\tGet(uid types.Uid) (*types.User, error)\n\tGetAll(uid ...types.Uid) ([]types.User, error)\n\tGetByCred(method, value string) (types.Uid, error)\n\tDelete(id types.Uid, hard bool) error\n\tUpdateLastSeen(uid types.Uid, userAgent string, when time.Time) error\n\tUpdate(uid types.Uid, update map[string]any) error\n\tUpdateTags(uid types.Uid, add, remove, reset []string) ([]string, error)\n\tUpdateState(uid types.Uid, state types.ObjState) error\n\tGetSubs(id types.Uid) ([]types.Subscription, error)\n\tFindSubs(caller types.Uid, prefPrefix string, required [][]string, optional []string, activeOnly bool) ([]types.Subscription, error)\n\tFindOne(tag string) (string, error)\n\tGetTopics(id types.Uid, opts *types.QueryOpt) ([]types.Subscription, error)\n\tGetTopicsAny(id types.Uid, opts *types.QueryOpt) ([]types.Subscription, error)\n\tGetOwnTopics(id types.Uid) ([]string, error)\n\tGetChannels(id types.Uid) ([]string, error)\n\tUpsertCred(cred *types.Credential) (bool, error)\n\tConfirmCred(id types.Uid, method string) error\n\tFailCred(id types.Uid, method string) error\n\tGetActiveCred(id types.Uid, method string) (*types.Credential, error)\n\tGetAllCreds(id types.Uid, method string, validatedOnly bool) ([]types.Credential, error)\n\tDelCred(id types.Uid, method, value string) error\n\tGetUnreadCount(ids ...types.Uid) (map[types.Uid]int, error)\n\tGetUnvalidated(lastUpdatedBefore time.Time, limit int) ([]types.Uid, error)\n}\n\n// usersMapper is a concrete type which implements UsersPersistenceInterface.\ntype usersMapper struct{}\n\n// Users is a singleton ancor object exporting UsersPersistenceInterface methods.\nvar Users UsersPersistenceInterface\n\n// Create inserts User object into a database, updates creation time and assigns UID\nfunc (usersMapper) Create(user *types.User, private any) (*types.User, error) {\n\n\tuser.SetUid(Store.GetUid())\n\tuser.InitTimes()\n\n\terr := adp.UserCreate(user)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Create user's subscription to 'me' && 'fnd'. These topics are ephemeral, the topic object need not to be\n\t// inserted.\n\terr = Subs.Create(\n\t\t&types.Subscription{\n\t\t\tObjHeader: types.ObjHeader{CreatedAt: user.CreatedAt},\n\t\t\tUser:      user.Id,\n\t\t\tTopic:     user.Uid().UserId(),\n\t\t\tModeWant:  types.ModeCMeFnd,\n\t\t\tModeGiven: types.ModeCMeFnd,\n\t\t\tPrivate:   private,\n\t\t},\n\t\t&types.Subscription{\n\t\t\tObjHeader: types.ObjHeader{CreatedAt: user.CreatedAt},\n\t\t\tUser:      user.Id,\n\t\t\tTopic:     user.Uid().FndName(),\n\t\t\tModeWant:  types.ModeCMeFnd,\n\t\t\tModeGiven: types.ModeCMeFnd,\n\t\t\tPrivate:   nil,\n\t\t})\n\tif err != nil {\n\t\t// Best effort to delete incomplete user record. Orphaned user records are not a problem.\n\t\t// They just take up space.\n\t\tadp.UserDelete(user.Uid(), true)\n\t\treturn nil, err\n\t}\n\n\treturn user, nil\n}\n\n// GetAuthRecord takes a user ID and a authentication scheme name, fetches unique scheme-dependent identifier and\n// authentication secret.\nfunc (usersMapper) GetAuthRecord(user types.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) {\n\tunique, authLvl, secret, expires, err := adp.AuthGetRecord(user, scheme)\n\tif err == nil {\n\t\tparts := strings.Split(unique, \":\")\n\t\tif len(parts) > 1 {\n\t\t\tunique = parts[1]\n\t\t} else {\n\t\t\terr = types.ErrInternal\n\t\t}\n\t}\n\n\treturn unique, authLvl, secret, expires, err\n}\n\n// GetAuthUniqueRecord takes a unique identifier and a authentication scheme name, fetches user ID and\n// authentication secret.\nfunc (usersMapper) GetAuthUniqueRecord(scheme, unique string) (types.Uid, auth.Level, []byte, time.Time, error) {\n\treturn adp.AuthGetUniqueRecord(scheme + \":\" + unique)\n}\n\n// AddAuthRecord creates a new authentication record for the given user.\nfunc (usersMapper) AddAuthRecord(uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte,\n\texpires time.Time) error {\n\n\treturn adp.AuthAddRecord(uid, scheme, scheme+\":\"+unique, authLvl, secret, expires)\n}\n\n// UpdateAuthRecord updates authentication record with a new secret and expiration time.\nfunc (usersMapper) UpdateAuthRecord(uid types.Uid, authLvl auth.Level, scheme, unique string,\n\tsecret []byte, expires time.Time) error {\n\n\treturn adp.AuthUpdRecord(uid, scheme, scheme+\":\"+unique, authLvl, secret, expires)\n}\n\n// DelAuthRecords deletes user's auth records of the given scheme.\nfunc (usersMapper) DelAuthRecords(uid types.Uid, scheme string) error {\n\treturn adp.AuthDelScheme(uid, scheme)\n}\n\n// Get returns a user object for the given user ID or nil if the user is not found.\nfunc (usersMapper) Get(uid types.Uid) (*types.User, error) {\n\treturn adp.UserGet(uid)\n}\n\n// GetAll returns a slice of user objects for the given user IDs.\nfunc (usersMapper) GetAll(uid ...types.Uid) ([]types.User, error) {\n\treturn adp.UserGetAll(uid...)\n}\n\n// GetByCred returns user ID for the given validated credential.\nfunc (usersMapper) GetByCred(method, value string) (types.Uid, error) {\n\treturn adp.UserGetByCred(method, value)\n}\n\n// Delete deletes user records.\nfunc (usersMapper) Delete(id types.Uid, hard bool) error {\n\treturn adp.UserDelete(id, hard)\n}\n\n// UpdateLastSeen updates LastSeen and UserAgent.\nfunc (usersMapper) UpdateLastSeen(uid types.Uid, userAgent string, when time.Time) error {\n\treturn adp.UserUpdate(uid, map[string]any{\"LastSeen\": when, \"UserAgent\": userAgent})\n}\n\n// Update is a general-purpose update of user data.\nfunc (usersMapper) Update(uid types.Uid, update map[string]any) error {\n\tif _, ok := update[\"UpdatedAt\"]; !ok {\n\t\tupdate[\"UpdatedAt\"] = types.TimeNow()\n\t}\n\treturn adp.UserUpdate(uid, update)\n}\n\n// UpdateTags either adds, removes, or resets tags to the given slices.\nfunc (usersMapper) UpdateTags(uid types.Uid, add, remove, reset []string) ([]string, error) {\n\treturn adp.UserUpdateTags(uid, add, remove, reset)\n}\n\n// UpdateState changes user's state and state of some topics associated with the user.\nfunc (usersMapper) UpdateState(uid types.Uid, state types.ObjState) error {\n\tupdate := map[string]any{\n\t\t\"State\":   state,\n\t\t\"StateAt\": types.TimeNow()}\n\treturn adp.UserUpdate(uid, update)\n}\n\n// GetSubs loads *all* subscriptions for the given user.\n// Does not load Public/Trusted or Private, does not load deleted subscriptions.\nfunc (usersMapper) GetSubs(id types.Uid) ([]types.Subscription, error) {\n\treturn adp.SubsForUser(id)\n}\n\n// FindSubs find a list of users and topics for the given tags. Results are formatted as subscriptions.\n// `required` specifies an AND of ORs for required terms:\n// at least one element of every sublist in `required` must be present in the object's tags list.\n// `optional` specifies a list of optional terms.\nfunc (usersMapper) FindSubs(caller types.Uid, prefPrefix string, required [][]string, optional []string, activeOnly bool) ([]types.Subscription, error) {\n\tif len(required) == 0 && len(optional) == 0 {\n\t\t// No tags specified, return empty list.\n\t\treturn nil, nil\n\t}\n\treturn adp.Find(caller.UserId(), prefPrefix, required, optional, activeOnly)\n}\n\n// Find returns topics and/or users which match the given tag, with optional partial matching.\nfunc (usersMapper) FindOne(tag string) (string, error) {\n\treturn adp.FindOne(tag)\n}\n\n// GetTopics load a list of user's subscriptions with Public+Trusted fields copied to subscription\nfunc (usersMapper) GetTopics(id types.Uid, opts *types.QueryOpt) ([]types.Subscription, error) {\n\treturn adp.TopicsForUser(id, false, opts)\n}\n\n// GetTopicsAny load a list of user's subscriptions with Public+Trusted fields copied to subscription.\n// Deleted topics are returned too.\nfunc (usersMapper) GetTopicsAny(id types.Uid, opts *types.QueryOpt) ([]types.Subscription, error) {\n\treturn adp.TopicsForUser(id, true, opts)\n}\n\n// GetOwnTopics returns a slice of group topic names where the user is the owner.\nfunc (usersMapper) GetOwnTopics(id types.Uid) ([]string, error) {\n\treturn adp.OwnTopics(id)\n}\n\n// GetChannels returns a slice of group topic names where the user is a channel reader.\nfunc (usersMapper) GetChannels(id types.Uid) ([]string, error) {\n\treturn adp.ChannelsForUser(id)\n}\n\n// UpsertCred adds or updates a credential validation request. Return true if the record was inserted, false if updated.\nfunc (usersMapper) UpsertCred(cred *types.Credential) (bool, error) {\n\tcred.InitTimes()\n\treturn adp.CredUpsert(cred)\n}\n\n// ConfirmCred marks credential method as confirmed.\nfunc (usersMapper) ConfirmCred(id types.Uid, method string) error {\n\treturn adp.CredConfirm(id, method)\n}\n\n// FailCred increments fail count for the given credential method.\nfunc (usersMapper) FailCred(id types.Uid, method string) error {\n\treturn adp.CredFail(id, method)\n}\n\n// GetActiveCred gets a the currently active credential for the given user and method.\nfunc (usersMapper) GetActiveCred(id types.Uid, method string) (*types.Credential, error) {\n\treturn adp.CredGetActive(id, method)\n}\n\n// GetAllCreds returns credentials of the given user, all or validated only.\nfunc (usersMapper) GetAllCreds(id types.Uid, method string, validatedOnly bool) ([]types.Credential, error) {\n\treturn adp.CredGetAll(id, method, validatedOnly)\n}\n\n// DelCred deletes user's credentials. If method is \"\", all credentials are deleted.\nfunc (usersMapper) DelCred(id types.Uid, method, value string) error {\n\treturn adp.CredDel(id, method, value)\n}\n\n// GetUnreadCount returs users' total count of unread messages in all topics with the R permissions.\nfunc (usersMapper) GetUnreadCount(ids ...types.Uid) (map[types.Uid]int, error) {\n\treturn adp.UserUnreadCount(ids...)\n}\n\n// GetUnvalidated returns a list of stale user ids which have unvalidated credentials,\n// their auth levels and a comma-separated list of these credential names.\nfunc (usersMapper) GetUnvalidated(lastUpdatedBefore time.Time, limit int) ([]types.Uid, error) {\n\treturn adp.UserGetUnvalidated(lastUpdatedBefore, limit)\n}\n\n// TopicsPersistenceInterface is an interface which defines methods for persistent storage of topics.\ntype TopicsPersistenceInterface interface {\n\tCreate(topic *types.Topic, owner types.Uid, private any) error\n\tCreateP2P(initiator, invited *types.Subscription) error\n\tGet(topic string) (*types.Topic, error)\n\tGetUsers(topic string, opts *types.QueryOpt) ([]types.Subscription, error)\n\tGetUsersAny(topic string, opts *types.QueryOpt) ([]types.Subscription, error)\n\tGetSubs(topic string, opts *types.QueryOpt) ([]types.Subscription, error)\n\tGetSubsAny(topic string, opts *types.QueryOpt) ([]types.Subscription, error)\n\tUpdate(topic string, update map[string]any) error\n\tUpdateSubCnt(topic string) error\n\tOwnerChange(topic string, newOwner types.Uid) error\n\tDelete(topic string, isChan, hard bool) error\n}\n\n// topicsMapper is a concrete type implementing TopicsPersistenceInterface.\ntype topicsMapper struct{}\n\n// Topics is a singleton ancor object exporting TopicsPersistenceInterface methods.\nvar Topics TopicsPersistenceInterface\n\n// Create creates a topic and owner's subscription to it.\nfunc (topicsMapper) Create(topic *types.Topic, owner types.Uid, private any) error {\n\n\ttopic.InitTimes()\n\ttopic.TouchedAt = topic.CreatedAt\n\ttopic.Owner = owner.String()\n\n\terr := adp.TopicCreate(topic)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif !owner.IsZero() {\n\t\terr = Subs.Create(&types.Subscription{\n\t\t\tObjHeader: types.ObjHeader{CreatedAt: topic.CreatedAt},\n\t\t\tUser:      owner.String(),\n\t\t\tTopic:     topic.Id,\n\t\t\tModeGiven: types.ModeCFull,\n\t\t\tModeWant:  topic.GetAccess(owner),\n\t\t\tPrivate:   private})\n\t}\n\n\treturn err\n}\n\n// CreateP2P creates a P2P topic by generating two user's subsciptions to each other.\nfunc (topicsMapper) CreateP2P(initiator, invited *types.Subscription) error {\n\tinitiator.InitTimes()\n\tinitiator.SetTouchedAt(initiator.CreatedAt)\n\tinvited.InitTimes()\n\tinvited.SetTouchedAt(invited.CreatedAt)\n\n\treturn adp.TopicCreateP2P(initiator, invited)\n}\n\n// Get a single topic with a list of relevant users de-normalized into it\nfunc (topicsMapper) Get(topic string) (*types.Topic, error) {\n\treturn adp.TopicGet(topic)\n}\n\n// GetUsers loads subscriptions for topic plus loads user.Public+Trusted.\n// Deleted subscriptions are not loaded.\nfunc (topicsMapper) GetUsers(topic string, opts *types.QueryOpt) ([]types.Subscription, error) {\n\treturn adp.UsersForTopic(topic, false, opts)\n}\n\n// GetUsersAny loads subscriptions for topic plus loads user.Public+Trusted. It's the same as GetUsers,\n// except it loads deleted subscriptions too.\nfunc (topicsMapper) GetUsersAny(topic string, opts *types.QueryOpt) ([]types.Subscription, error) {\n\treturn adp.UsersForTopic(topic, true, opts)\n}\n\n// GetSubs loads a list of subscriptions to the given topic, user.Public+Trusted and deleted\n// subscriptions are not loaded. Suspended subscriptions are loaded.\nfunc (topicsMapper) GetSubs(topic string, opts *types.QueryOpt) ([]types.Subscription, error) {\n\treturn adp.SubsForTopic(topic, false, opts)\n}\n\n// GetSubsAny loads a list of subscriptions to the given topic including deleted subscription.\n// user.Public/Trusted are not loaded\nfunc (topicsMapper) GetSubsAny(topic string, opts *types.QueryOpt) ([]types.Subscription, error) {\n\treturn adp.SubsForTopic(topic, true, opts)\n}\n\n// UpdateSubCnt refreshes subscriber count value denormalized in topic.\nfunc (topicsMapper) UpdateSubCnt(topic string) error {\n\treturn adp.TopicUpdateSubCnt(topic)\n}\n\n// Update is a generic topic update.\nfunc (topicsMapper) Update(topic string, update map[string]any) error {\n\tif _, ok := update[\"UpdatedAt\"]; !ok {\n\t\tupdate[\"UpdatedAt\"] = types.TimeNow()\n\t}\n\treturn adp.TopicUpdate(topic, update)\n}\n\n// OwnerChange replaces the old topic owner with the new owner.\nfunc (topicsMapper) OwnerChange(topic string, newOwner types.Uid) error {\n\treturn adp.TopicOwnerChange(topic, newOwner)\n}\n\n// Delete deletes topic, messages, attachments, and subscriptions.\nfunc (topicsMapper) Delete(topic string, isChan, hard bool) error {\n\treturn adp.TopicDelete(topic, isChan, hard)\n}\n\n// SubsPersistenceInterface is an interface which defines methods for persistent storage of subscriptions.\ntype SubsPersistenceInterface interface {\n\tCreate(subs ...*types.Subscription) error\n\tGet(topic string, user types.Uid, keepDeleted bool) (*types.Subscription, error)\n\tUpdate(topic string, user types.Uid, update map[string]any) error\n\tDelete(topic string, user types.Uid) error\n}\n\n// subsMapper is a concrete type implementing SubsPersistenceInterface.\ntype subsMapper struct{}\n\n// Subs is a singleton ancor object exporting SubsPersistenceInterface.\nvar Subs SubsPersistenceInterface\n\n// Create creates multiple subscriptions.\nfunc (subsMapper) Create(subs ...*types.Subscription) error {\n\tif len(subs) == 0 {\n\t\t// Nothing to do.\n\t\treturn nil\n\t}\n\n\ttopic := subs[0].Topic\n\tif types.IsEphemeralTopic(topic) {\n\t\t// Ephemeral topics are not persisted in 'topics' table, don't try to update them.\n\t\t// Mixing ephemeral and real topics is not permitted.\n\t\ttopic = \"\"\n\t}\n\n\tfor _, sub := range subs {\n\t\tsub.InitTimes()\n\t\tif topic != \"\" && sub.Topic != topic {\n\t\t\treturn fmt.Errorf(\"all subscriptions must be for the same topic, got %s vs %s\", sub.Topic, topic)\n\t\t}\n\t}\n\n\treturn adp.TopicShare(topic, subs)\n}\n\n// Get subscription given topic and user ID.\nfunc (subsMapper) Get(topic string, user types.Uid, keepDeleted bool) (*types.Subscription, error) {\n\treturn adp.SubscriptionGet(topic, user, keepDeleted)\n}\n\n// Update values of topic's subscriptions.\nfunc (subsMapper) Update(topic string, user types.Uid, update map[string]any) error {\n\tupdate[\"UpdatedAt\"] = types.TimeNow()\n\treturn adp.SubsUpdate(topic, user, update)\n}\n\n// Delete deletes a subscription.\n// To delete channel subscription the channel name must be explicitly specified.\nfunc (subsMapper) Delete(topic string, user types.Uid) error {\n\treturn adp.SubsDelete(topic, user)\n}\n\n// MessagesPersistenceInterface is an interface which defines methods for persistent storage of messages.\ntype MessagesPersistenceInterface interface {\n\tSave(msg *types.Message, attachmentURLs []string, readBySender bool) (error, bool)\n\tDeleteList(topic string, delID int, forUser types.Uid, msgDelAge time.Duration, ranges []types.Range) error\n\tGetAll(topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Message, error)\n\tGetDeleted(topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Range, int, error)\n}\n\n// messagesMapper is a concrete type implementing MessagesPersistenceInterface.\ntype messagesMapper struct{}\n\n// Messages is a singleton ancor object for exporting MessagesPersistenceInterface.\nvar Messages MessagesPersistenceInterface\n\n// Save message\nfunc (messagesMapper) Save(msg *types.Message, attachmentURLs []string, readBySender bool) (error, bool) {\n\tmsg.InitTimes()\n\tmsg.SetUid(Store.GetUid())\n\t// Increment topic's or user's SeqId\n\terr := adp.TopicUpdateOnMessage(msg.Topic, msg)\n\tif err != nil {\n\t\treturn err, false\n\t}\n\n\terr = adp.MessageSave(msg)\n\tif err != nil {\n\t\treturn err, false\n\t}\n\n\tmarkedReadBySender := false\n\t// Mark message as read by the sender.\n\tif readBySender {\n\t\t// Make sure From is valid, otherwise we will reset values for all subscribers.\n\t\tfromUid := types.ParseUid(msg.From)\n\t\tif !fromUid.IsZero() {\n\t\t\t// Ignore the error here. It's not a big deal if it fails.\n\t\t\tif subErr := adp.SubsUpdate(msg.Topic, fromUid,\n\t\t\t\tmap[string]any{\n\t\t\t\t\t\"RecvSeqId\": msg.SeqId,\n\t\t\t\t\t\"ReadSeqId\": msg.SeqId}); subErr != nil {\n\t\t\t\tlogs.Warn.Printf(\"topic[%s]: failed to mark message (seq: %d) read by sender - err: %+v\", msg.Topic, msg.SeqId, subErr)\n\t\t\t} else {\n\t\t\t\tmarkedReadBySender = true\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(attachmentURLs) > 0 {\n\t\tvar attachments []string\n\t\tfor _, url := range attachmentURLs {\n\t\t\t// Convert attachment URLs to file IDs.\n\t\t\tif fid := mediaHandler.GetIdFromUrl(url); !fid.IsZero() {\n\t\t\t\tattachments = append(attachments, fid.String())\n\t\t\t}\n\t\t}\n\t\tif len(attachments) > 0 {\n\t\t\treturn adp.FileLinkAttachments(\"\", types.ZeroUid, msg.Uid(), attachments), markedReadBySender\n\t\t}\n\t}\n\n\treturn nil, markedReadBySender\n}\n\n// DeleteList deletes multiple messages defined by a list of ranges.\nfunc (messagesMapper) DeleteList(topic string, delID int, forUser types.Uid, msgDelAge time.Duration, ranges []types.Range) error {\n\tvar toDel *types.DelMessage\n\tif delID > 0 {\n\t\ttoDel = &types.DelMessage{\n\t\t\tTopic:       topic,\n\t\t\tDelId:       delID,\n\t\t\tDeletedFor:  forUser.String(),\n\t\t\tSeqIdRanges: ranges}\n\t\ttoDel.SetUid(Store.GetUid())\n\t\ttoDel.InitTimes()\n\t\tif msgDelAge > 0 {\n\t\t\ttoDel.SetNewerThan(toDel.CreatedAt.Add(-msgDelAge))\n\t\t}\n\t}\n\n\terr := adp.MessageDeleteList(topic, toDel)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// TODO: move to adapter.\n\tif delID > 0 {\n\t\t// Record ID of the delete transaction\n\t\terr = adp.TopicUpdate(topic, map[string]any{\"DelId\": delID})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Soft-deleting will update one subscription, hard-deleting will ipdate all.\n\t\t// Soft- or hard- is defined by the forUser being defined.\n\t\terr = adp.SubsUpdate(topic, forUser, map[string]any{\"DelId\": delID})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn err\n}\n\n// GetAll returns multiple messages.\nfunc (messagesMapper) GetAll(topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Message, error) {\n\treturn adp.MessageGetAll(topic, forUser, opt)\n}\n\n// GetDeleted returns the ranges of deleted messages and the largest DelId reported in the list.\nfunc (messagesMapper) GetDeleted(topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Range, int, error) {\n\tdmsgs, err := adp.MessageGetDeleted(topic, forUser, opt)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tvar ranges []types.Range\n\tvar maxID int\n\t// Flatten out the ranges\n\tfor i := range dmsgs {\n\t\tdm := &dmsgs[i]\n\t\tif dm.DelId > maxID {\n\t\t\tmaxID = dm.DelId\n\t\t}\n\t\tranges = append(ranges, dm.SeqIdRanges...)\n\t}\n\tsort.Sort(types.RangeSorter(ranges))\n\tranges = types.RangeSorter(ranges).Normalize()\n\n\treturn ranges, maxID, nil\n}\n\n// Registered authentication handlers.\nvar authHandlers map[string]auth.AuthHandler\n\n// Logical auth handler names\nvar authHandlerNames map[string]string\n\n// RegisterAuthScheme registers an authentication scheme handler.\n// The 'name' must be the hardcoded name, NOT the logical name.\nfunc RegisterAuthScheme(name string, handler auth.AuthHandler) {\n\tif name == \"\" {\n\t\tpanic(\"RegisterAuthScheme: empty auth scheme name\")\n\t}\n\tif handler == nil {\n\t\tpanic(\"RegisterAuthScheme: scheme handler is nil\")\n\t}\n\n\tname = strings.ToLower(name)\n\tif authHandlers == nil {\n\t\tauthHandlers = make(map[string]auth.AuthHandler)\n\t}\n\tif _, dup := authHandlers[name]; dup {\n\t\tpanic(\"RegisterAuthScheme: called twice for scheme \" + name)\n\t}\n\tauthHandlers[name] = handler\n}\n\n// GetAuthNames returns all addressable auth handler names, logical and hardcoded\n// excluding those which are disabled like \"basic:\".\nfunc (s storeObj) GetAuthNames() []string {\n\tif len(authHandlers) == 0 {\n\t\treturn nil\n\t}\n\n\tallNames := make(map[string]struct{})\n\tfor name := range authHandlers {\n\t\tallNames[name] = struct{}{}\n\t}\n\tfor name := range authHandlerNames {\n\t\tallNames[name] = struct{}{}\n\t}\n\n\tvar names []string\n\tfor name := range allNames {\n\t\tif s.GetLogicalAuthHandler(name) != nil {\n\t\t\tnames = append(names, name)\n\t\t}\n\t}\n\n\treturn names\n\n}\n\n// GetAuthHandler returns an auth handler by actual hardcoded name irrspectful of logical naming.\nfunc (storeObj) GetAuthHandler(name string) auth.AuthHandler {\n\treturn authHandlers[strings.ToLower(name)]\n}\n\n// GetLogicalAuthHandler returns an auth handler by logical name. If there is no handler by that\n// logical name it tries to find one by the hardcoded name.\nfunc (storeObj) GetLogicalAuthHandler(name string) auth.AuthHandler {\n\tname = strings.ToLower(name)\n\tif len(authHandlerNames) != 0 {\n\t\tif lname, ok := authHandlerNames[name]; ok {\n\t\t\treturn authHandlers[lname]\n\t\t}\n\t}\n\treturn authHandlers[name]\n}\n\n// InitAuthLogicalNames initializes authentication mapping \"logical handler name\":\"actual handler name\".\n// Logical name must not be empty, actual name could be an empty string.\nfunc InitAuthLogicalNames(config json.RawMessage) error {\n\tif config == nil || string(config) == \"null\" {\n\t\treturn nil\n\t}\n\tvar mapping []string\n\tif err := json.Unmarshal(config, &mapping); err != nil {\n\t\treturn errors.New(\"store: failed to parse logical auth names: \" + err.Error() + \"(\" + string(config) + \")\")\n\t}\n\tif len(mapping) == 0 {\n\t\treturn nil\n\t}\n\n\tif authHandlerNames == nil {\n\t\tauthHandlerNames = make(map[string]string)\n\t}\n\tfor _, pair := range mapping {\n\t\tif parts := strings.Split(pair, \":\"); len(parts) == 2 {\n\t\t\tif parts[0] == \"\" {\n\t\t\t\treturn errors.New(\"store: empty logical auth name '\" + pair + \"'\")\n\t\t\t}\n\t\t\tparts[0] = strings.ToLower(parts[0])\n\t\t\tif _, ok := authHandlerNames[parts[0]]; ok {\n\t\t\t\treturn errors.New(\"store: duplicate mapping for logical auth name '\" + pair + \"'\")\n\t\t\t}\n\t\t\tparts[1] = strings.ToLower(parts[1])\n\t\t\tif parts[1] != \"\" {\n\t\t\t\tif _, ok := authHandlers[parts[1]]; !ok {\n\t\t\t\t\treturn errors.New(\"store: unknown handler for logical auth name '\" + pair + \"'\")\n\t\t\t\t}\n\t\t\t}\n\t\t\tif parts[0] == parts[1] {\n\t\t\t\t// Skip useless identity mapping.\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tauthHandlerNames[parts[0]] = parts[1]\n\t\t} else {\n\t\t\treturn errors.New(\"store: invalid logical auth mapping '\" + pair + \"'\")\n\t\t}\n\t}\n\treturn nil\n}\n\n// Registered authentication handlers.\nvar validators map[string]validate.Validator\n\n// RegisterValidator registers validation scheme.\nfunc RegisterValidator(name string, v validate.Validator) {\n\tname = strings.ToLower(name)\n\tif validators == nil {\n\t\tvalidators = make(map[string]validate.Validator)\n\t}\n\n\tif v == nil {\n\t\tpanic(\"RegisterValidator: validator is nil\")\n\t}\n\tif _, dup := validators[name]; dup {\n\t\tpanic(\"RegisterValidator: called twice for validator \" + name)\n\t}\n\tvalidators[name] = v\n}\n\n// GetValidator returns registered validator by name.\nfunc (storeObj) GetValidator(name string) validate.Validator {\n\treturn validators[strings.ToLower(name)]\n}\n\n// DevicePersistenceInterface is an interface which defines methods used for handling device IDs.\n// Mostly used to generate push notifications.\ntype DevicePersistenceInterface interface {\n\tUpdate(uid types.Uid, oldDeviceID string, dev *types.DeviceDef) error\n\tGetAll(uid ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error)\n\tDelete(uid types.Uid, deviceID string) error\n}\n\n// deviceMapper is a concrete type implementing DevicePersistenceInterface.\ntype deviceMapper struct{}\n\n// Devices is a singleton instance of DevicePersistenceInterface to map methods to.\nvar Devices DevicePersistenceInterface\n\n// Update updates a device record.\nfunc (deviceMapper) Update(uid types.Uid, oldDeviceID string, dev *types.DeviceDef) error {\n\t// If the old device Id is specified and it's different from the new ID, delete the old id\n\tif oldDeviceID != \"\" && (dev == nil || dev.DeviceId != oldDeviceID) {\n\t\tif err := adp.DeviceDelete(uid, oldDeviceID); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Insert or update the new DeviceId if one is given.\n\tif dev != nil && dev.DeviceId != \"\" {\n\t\treturn adp.DeviceUpsert(uid, dev)\n\t}\n\treturn nil\n}\n\n// GetAll returns all known device IDs for a given list of user IDs.\n// The second return parameter is the count of found device IDs.\nfunc (deviceMapper) GetAll(uid ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) {\n\treturn adp.DeviceGetAll(uid...)\n}\n\n// Delete deletes device record for a given user.\nfunc (deviceMapper) Delete(uid types.Uid, deviceID string) error {\n\treturn adp.DeviceDelete(uid, deviceID)\n}\n\n// Registered media/file handlers.\nvar fileHandlers map[string]media.Handler\n\n// RegisterMediaHandler saves reference to a media handler (file upload-download handler).\nfunc RegisterMediaHandler(name string, mh media.Handler) {\n\tif fileHandlers == nil {\n\t\tfileHandlers = make(map[string]media.Handler)\n\t}\n\n\tif mh == nil {\n\t\tpanic(\"RegisterMediaHandler: handler is nil\")\n\t}\n\tif _, dup := fileHandlers[name]; dup {\n\t\tpanic(\"RegisterMediaHandler: called twice for handler \" + name)\n\t}\n\tfileHandlers[name] = mh\n}\n\n// GetMediaHandler returns default media handler.\nfunc (storeObj) GetMediaHandler() media.Handler {\n\treturn mediaHandler\n}\n\n// UseMediaHandler sets specified media handler as default.\nfunc (storeObj) UseMediaHandler(name, config string) error {\n\tmediaHandler = fileHandlers[name]\n\tif mediaHandler == nil {\n\t\tpanic(\"UseMediaHandler: unknown handler '\" + name + \"'\")\n\t}\n\treturn mediaHandler.Init(config)\n}\n\n// FilePersistenceInterface is an interface wchich defines methods used for file handling (records or uploaded files).\ntype FilePersistenceInterface interface {\n\t// StartUpload records that the given user initiated a file upload\n\tStartUpload(fd *types.FileDef) error\n\t// FinishUpload marks started upload as successfully finished.\n\tFinishUpload(fd *types.FileDef, success bool, size int64) (*types.FileDef, error)\n\t// Get fetches a file record for a unique file id.\n\tGet(fid string) (*types.FileDef, error)\n\t// DeleteUnused removes unused attachments.\n\tDeleteUnused(olderThan time.Time, limit int) error\n\t// LinkAttachments connects earlier uploaded attachments to a message or topic to prevent it\n\t// from being garbage collected.\n\tLinkAttachments(topic string, msgId types.Uid, attachments []string) error\n}\n\n// fileMapper is concrete type which implements FilePersistenceInterface.\ntype fileMapper struct{}\n\n// Files is a sigleton instance of FilePersistenceInterface to be used for handling file uploads.\nvar Files FilePersistenceInterface\n\n// StartUpload records that the given user initiated a file upload\nfunc (fileMapper) StartUpload(fd *types.FileDef) error {\n\tfd.Status = types.UploadStarted\n\treturn adp.FileStartUpload(fd)\n}\n\n// FinishUpload marks started upload as successfully finished or failed.\nfunc (fileMapper) FinishUpload(fd *types.FileDef, success bool, size int64) (*types.FileDef, error) {\n\treturn adp.FileFinishUpload(fd, success, size)\n}\n\n// Get fetches a file record for a unique file id.\nfunc (fileMapper) Get(fid string) (*types.FileDef, error) {\n\treturn adp.FileGet(fid)\n}\n\n// DeleteUnused removes unused attachments and avatars.\nfunc (fileMapper) DeleteUnused(olderThan time.Time, limit int) error {\n\ttoDel, err := adp.FileDeleteUnused(olderThan, limit)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(toDel) > 0 {\n\t\tlogs.Warn.Println(\"deleting media\", toDel)\n\t\treturn Store.GetMediaHandler().Delete(toDel)\n\t}\n\treturn nil\n}\n\n// LinkAttachments connects earlier uploaded attachments to a message or topic to prevent it\n// from being garbage collected.\nfunc (fileMapper) LinkAttachments(topic string, msgId types.Uid, attachments []string) error {\n\t// Convert attachment URLs to file IDs.\n\tvar fids []string\n\tfor _, url := range attachments {\n\t\tif fid := mediaHandler.GetIdFromUrl(url); !fid.IsZero() {\n\t\t\tfids = append(fids, fid.String())\n\t\t}\n\t}\n\n\tif len(fids) > 0 {\n\t\tuserId := types.ZeroUid\n\t\tif types.GetTopicCat(topic) == types.TopicCatMe {\n\t\t\tuserId = types.ParseUserId(topic)\n\t\t\ttopic = \"\"\n\t\t}\n\t\treturn adp.FileLinkAttachments(topic, userId, msgId, fids)\n\t}\n\treturn nil\n}\n\n// PersistentCacheInterface is an interface which defines methods used for accessing persistent key-value cache.\ntype PersistentCacheInterface interface {\n\t// Get reads a persistent cache entry.\n\tGet(key string) (string, error)\n\t// Upsert creates or updates a persistent cache entry.\n\tUpsert(key string, value string, failOnDuplicate bool) error\n\t// Delete deletes a single persistent cache entry.\n\tDelete(key string) error\n\t// Expire expires older entries with the specified key prefix.\n\tExpire(keyPrefix string, olderThan time.Time) error\n}\n\n// pcacheMapper is concrete type which implements PersistentCacheInterface.\ntype pcacheMapper struct{}\n\nvar PCache PersistentCacheInterface\n\n// Get reads a persistent cache entry.\nfunc (pcacheMapper) Get(key string) (string, error) {\n\treturn adp.PCacheGet(key)\n}\n\n// Upsert creates or updates a persistent cache entry.\nfunc (pcacheMapper) Upsert(key string, value string, failOnDuplicate bool) error {\n\treturn adp.PCacheUpsert(key, value, failOnDuplicate)\n}\n\n// Delete deletes a single persistent cache entry.\nfunc (pcacheMapper) Delete(key string) error {\n\treturn adp.PCacheDelete(key)\n}\n\n// Expire expires older entries with the specified key prefix.\nfunc (pcacheMapper) Expire(keyPrefix string, olderThan time.Time) error {\n\treturn adp.PCacheExpire(keyPrefix, olderThan)\n}\n\nfunc SetTestUidGenerator(g types.UidGenerator) {\n\tuGen = g\n}\n\nfunc init() {\n\tStore = storeObj{}\n\tUsers = usersMapper{}\n\tTopics = topicsMapper{}\n\tSubs = subsMapper{}\n\tMessages = messagesMapper{}\n\tDevices = deviceMapper{}\n\tFiles = fileMapper{}\n\tPCache = pcacheMapper{}\n}\n"
  },
  {
    "path": "server/store/types/types.go",
    "content": "// Package types provides data types for persisting objects in the databases.\npackage types\n\nimport (\n\t\"database/sql/driver\"\n\t\"encoding/base32\"\n\t\"encoding/base64\"\n\t\"encoding/binary\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"slices\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n)\n\n// StoreError satisfies Error interface but allows constant values for\n// direct comparison.\ntype StoreError string\n\n// Error is required by error interface.\nfunc (s StoreError) Error() string {\n\treturn string(s)\n}\n\nconst (\n\t// ErrInternal means DB or other internal failure.\n\tErrInternal = StoreError(\"internal\")\n\t// ErrMalformed means the secret cannot be parsed or otherwise wrong.\n\tErrMalformed = StoreError(\"malformed\")\n\t// ErrFailed means authentication failed (wrong login or password, etc).\n\tErrFailed = StoreError(\"failed\")\n\t// ErrDuplicate means duplicate credential, i.e. non-unique login.\n\tErrDuplicate = StoreError(\"duplicate value\")\n\t// ErrUnsupported means an operation is not supported.\n\tErrUnsupported = StoreError(\"unsupported\")\n\t// ErrExpired means the secret has expired.\n\tErrExpired = StoreError(\"expired\")\n\t// ErrPolicy means policy violation, e.g. password too weak.\n\tErrPolicy = StoreError(\"policy\")\n\t// ErrCredentials means credentials like email or captcha must be validated.\n\tErrCredentials = StoreError(\"credentials\")\n\t// ErrUserNotFound means the user was not found.\n\tErrUserNotFound = StoreError(\"user not found\")\n\t// ErrTopicNotFound means the topic was not found.\n\tErrTopicNotFound = StoreError(\"topic not found\")\n\t// ErrNotFound means the object other then user or topic was not found.\n\tErrNotFound = StoreError(\"not found\")\n\t// ErrPermissionDenied means the operation is not permitted.\n\tErrPermissionDenied = StoreError(\"denied\")\n\t// ErrInvalidResponse means the client's response does not match server's expectation.\n\tErrInvalidResponse = StoreError(\"invalid response\")\n\t// ErrRedirected means the subscription request was redirected to another topic.\n\tErrRedirected = StoreError(\"redirected\")\n)\n\n// Uid is a database-specific record id, suitable to be used as a primary key.\ntype Uid uint64\n\n// ZeroUid is a constant representing uninitialized Uid.\nconst ZeroUid Uid = 0\n\n// NullValue is a Unicode DEL character which indicated that the value is being deleted.\nconst NullValue = \"\\u2421\"\n\n// Lengths of various Uid representations.\nconst (\n\tuidBase64Unpadded = 11\n\tp2pBase64Unpadded = 22\n)\n\n// IsZero checks if Uid is uninitialized.\nfunc (uid Uid) IsZero() bool {\n\treturn uid == ZeroUid\n}\n\n// Compare returns 0 if uid is equal to u2, 1 if u2 is greater than uid, -1 if u2 is smaller.\nfunc (uid Uid) Compare(u2 Uid) int {\n\tif uid < u2 {\n\t\treturn -1\n\t} else if uid > u2 {\n\t\treturn 1\n\t}\n\treturn 0\n}\n\n// MarshalBinary converts Uid to byte slice.\nfunc (uid Uid) MarshalBinary() ([]byte, error) {\n\tdst := make([]byte, 8)\n\tbinary.LittleEndian.PutUint64(dst, uint64(uid))\n\treturn dst, nil\n}\n\n// UnmarshalBinary reads Uid from byte slice.\nfunc (uid *Uid) UnmarshalBinary(b []byte) error {\n\tif len(b) < 8 {\n\t\treturn errors.New(\"Uid.UnmarshalBinary: invalid length\")\n\t}\n\t*uid = Uid(binary.LittleEndian.Uint64(b))\n\treturn nil\n}\n\n// UnmarshalText reads Uid from string represented as byte slice.\nfunc (uid *Uid) UnmarshalText(src []byte) error {\n\tif len(src) != uidBase64Unpadded {\n\t\treturn errors.New(\"Uid.UnmarshalText: invalid length\")\n\t}\n\tdec := make([]byte, base64.URLEncoding.WithPadding(base64.NoPadding).DecodedLen(uidBase64Unpadded))\n\tcount, err := base64.URLEncoding.WithPadding(base64.NoPadding).Decode(dec, src)\n\tif count < 8 {\n\t\tif err != nil {\n\t\t\treturn errors.New(\"Uid.UnmarshalText: failed to decode \" + err.Error())\n\t\t}\n\t\treturn errors.New(\"Uid.UnmarshalText: failed to decode\")\n\t}\n\t*uid = Uid(binary.LittleEndian.Uint64(dec))\n\treturn nil\n}\n\n// MarshalText converts Uid to string represented as byte slice.\nfunc (uid *Uid) MarshalText() ([]byte, error) {\n\tif *uid == ZeroUid {\n\t\treturn []byte{}, nil\n\t}\n\tsrc := make([]byte, 8)\n\tdst := make([]byte, base64.URLEncoding.WithPadding(base64.NoPadding).EncodedLen(8))\n\tbinary.LittleEndian.PutUint64(src, uint64(*uid))\n\tbase64.URLEncoding.WithPadding(base64.NoPadding).Encode(dst, src)\n\treturn dst, nil\n}\n\n// MarshalJSON converts Uid to double quoted (\"ajjj\") string.\nfunc (uid *Uid) MarshalJSON() ([]byte, error) {\n\tdst, _ := uid.MarshalText()\n\treturn append(append([]byte{'\"'}, dst...), '\"'), nil\n}\n\n// UnmarshalJSON reads Uid from a double quoted string.\nfunc (uid *Uid) UnmarshalJSON(b []byte) error {\n\tsize := len(b)\n\tif size != (uidBase64Unpadded + 2) {\n\t\treturn errors.New(\"Uid.UnmarshalJSON: invalid length\")\n\t} else if b[0] != '\"' || b[size-1] != '\"' {\n\t\treturn errors.New(\"Uid.UnmarshalJSON: unrecognized\")\n\t}\n\treturn uid.UnmarshalText(b[1 : size-1])\n}\n\n// String converts Uid to base64 string.\nfunc (uid Uid) String() string {\n\tbuf, _ := uid.MarshalText()\n\treturn string(buf)\n}\n\n// String32 converts Uid to lowercase base32 string (suitable for file names on Windows).\nfunc (uid Uid) String32() string {\n\tdata, _ := uid.MarshalBinary()\n\treturn strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(data))\n}\n\n// ParseUid parses string NOT prefixed with anything.\nfunc ParseUid(s string) Uid {\n\tvar uid Uid\n\tuid.UnmarshalText([]byte(s))\n\treturn uid\n}\n\n// ParseUid32 parses base32-encoded string into Uid.\nfunc ParseUid32(s string) Uid {\n\tvar uid Uid\n\tif data, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(s); err == nil {\n\t\tuid.UnmarshalBinary(data)\n\t}\n\treturn uid\n}\n\n// UserId converts Uid to string prefixed with 'usr', like usrXXXXX.\nfunc (uid Uid) UserId() string {\n\treturn uid.PrefixId(\"usr\")\n}\n\n// FndName generates 'fnd' topic name for the given Uid.\nfunc (uid Uid) FndName() string {\n\treturn uid.PrefixId(\"fnd\")\n}\n\n// SlfName generates 'slf' topic name for the given Uid.\nfunc (uid Uid) SlfName() string {\n\treturn uid.PrefixId(\"slf\")\n}\n\n// PrefixId converts Uid to string prefixed with the given prefix.\nfunc (uid Uid) PrefixId(prefix string) string {\n\tif uid.IsZero() {\n\t\treturn \"\"\n\t}\n\treturn prefix + uid.String()\n}\n\n// ParseUserId parses user ID of the form \"usrXXXXXX\".\nfunc ParseUserId(s string) Uid {\n\tvar uid Uid\n\tif strings.HasPrefix(s, \"usr\") {\n\t\t(&uid).UnmarshalText([]byte(s)[3:])\n\t}\n\treturn uid\n}\n\n// GrpToChn converts group topic name to corresponding channel name.\n// If it's a non-group channel topic, the name is returned unchanged.\n// If it's neither, an empty string is returned.\nfunc GrpToChn(grp string) string {\n\tif strings.HasPrefix(grp, \"grp\") {\n\t\treturn strings.Replace(grp, \"grp\", \"chn\", 1)\n\t}\n\t// Return unchanged if it's a channel already.\n\tif strings.HasPrefix(grp, \"chn\") {\n\t\treturn grp\n\t}\n\treturn \"\"\n}\n\n// IsChannel checks if the given topic name is a reference to a channel.\n// The \"nch\" should not be considered a channel reference because it can only be used by the topic owner at the time of\n// group topic creation.\nfunc IsChannel(name string) bool {\n\treturn strings.HasPrefix(name, \"chn\")\n}\n\n// ChnToGrp gets group topic name from channel name.\n// If it's a non-channel group topic, the name is returned unchanged.\n// If it's neither, an empty string is returned.\nfunc ChnToGrp(chn string) string {\n\tif strings.HasPrefix(chn, \"chn\") {\n\t\treturn strings.Replace(chn, \"chn\", \"grp\", 1)\n\t}\n\t// Return unchanged if it's a group already.\n\tif strings.HasPrefix(chn, \"grp\") {\n\t\treturn chn\n\t}\n\treturn \"\"\n}\n\n// UidSlice is a slice of Uids sorted in ascending order.\ntype UidSlice []Uid\n\nfunc (us UidSlice) find(uid Uid) (int, bool) {\n\tl := len(us)\n\tif l == 0 || us[0] > uid {\n\t\treturn 0, false\n\t}\n\tif uid > us[l-1] {\n\t\treturn l, false\n\t}\n\tidx := sort.Search(l, func(i int) bool {\n\t\treturn uid <= us[i]\n\t})\n\treturn idx, idx < l && us[idx] == uid\n}\n\n// Add uid to UidSlice keeping it sorted. Duplicates are ignored.\nfunc (us *UidSlice) Add(uid Uid) bool {\n\tidx, found := us.find(uid)\n\tif found {\n\t\treturn false\n\t}\n\t// Inserting without creating a temporary slice.\n\t*us = append(*us, ZeroUid)\n\tcopy((*us)[idx+1:], (*us)[idx:])\n\t(*us)[idx] = uid\n\treturn true\n}\n\n// Rem removes uid from UidSlice.\nfunc (us *UidSlice) Rem(uid Uid) bool {\n\tidx, found := us.find(uid)\n\tif !found {\n\t\treturn false\n\t}\n\tif idx == len(*us)-1 {\n\t\t*us = (*us)[:idx]\n\t} else {\n\t\t*us = slices.Delete((*us), idx, idx+1)\n\t}\n\treturn true\n}\n\n// Contains checks if the UidSlice contains the given UID.\nfunc (us UidSlice) Contains(uid Uid) bool {\n\t_, contains := us.find(uid)\n\treturn contains\n}\n\n// P2PName takes two Uids and generates a P2P topic name.\nfunc (uid Uid) P2PName(u2 Uid) string {\n\tif !uid.IsZero() && !u2.IsZero() {\n\t\tb1, _ := uid.MarshalBinary()\n\t\tb2, _ := u2.MarshalBinary()\n\n\t\tif uid < u2 {\n\t\t\tb1 = append(b1, b2...)\n\t\t} else if uid > u2 {\n\t\t\tb1 = append(b2, b1...)\n\t\t} else {\n\t\t\t// Explicitly disable P2P with self\n\t\t\treturn \"\"\n\t\t}\n\n\t\treturn \"p2p\" + base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(b1)\n\t}\n\n\treturn \"\"\n}\n\n// ParseP2P extracts uids from the name of a p2p topic.\nfunc ParseP2P(p2p string) (uid1, uid2 Uid, err error) {\n\tif strings.HasPrefix(p2p, \"p2p\") {\n\t\tsrc := []byte(p2p)[3:]\n\t\tif len(src) != p2pBase64Unpadded {\n\t\t\terr = errors.New(\"ParseP2P: invalid length\")\n\t\t\treturn\n\t\t}\n\t\tdec := make([]byte, base64.URLEncoding.WithPadding(base64.NoPadding).DecodedLen(p2pBase64Unpadded))\n\t\tvar count int\n\t\tcount, err = base64.URLEncoding.WithPadding(base64.NoPadding).Decode(dec, src)\n\t\tif count < 16 {\n\t\t\tif err != nil {\n\t\t\t\terr = errors.New(\"ParseP2P: failed to decode \" + err.Error())\n\t\t\t} else {\n\t\t\t\terr = errors.New(\"ParseP2P: invalid decoded length\")\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\tuid1 = Uid(binary.LittleEndian.Uint64(dec))\n\t\tuid2 = Uid(binary.LittleEndian.Uint64(dec[8:]))\n\t} else {\n\t\terr = errors.New(\"ParseP2P: missing or invalid prefix\")\n\t}\n\treturn\n}\n\n// P2PNameForUser takes a user ID and a full name of a P2P topic and generates the name of the\n// P2P topic as it should be seen by the given user.\nfunc P2PNameForUser(uid Uid, p2p string) (string, error) {\n\tuid1, uid2, err := ParseP2P(p2p)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif uid.Compare(uid1) == 0 {\n\t\treturn uid2.UserId(), nil\n\t}\n\treturn uid1.UserId(), nil\n}\n\n// ObjHeader is the header shared by all stored objects.\ntype ObjHeader struct {\n\t// using string to get around rethinkdb's problems with uint64;\n\t// `bson:\"_id\"` tag is for mongodb to use as primary key '_id'.\n\tId        string `bson:\"_id\"`\n\tid        Uid\n\tCreatedAt time.Time\n\tUpdatedAt time.Time\n}\n\n// Uid assigns Uid header field.\nfunc (h *ObjHeader) Uid() Uid {\n\tif h.id.IsZero() && h.Id != \"\" {\n\t\th.id.UnmarshalText([]byte(h.Id))\n\t}\n\treturn h.id\n}\n\n// SetUid assigns given Uid to appropriate header fields.\nfunc (h *ObjHeader) SetUid(uid Uid) {\n\th.id = uid\n\th.Id = uid.String()\n}\n\n// TimeNow returns current wall time in UTC rounded to milliseconds.\nfunc TimeNow() time.Time {\n\treturn time.Now().UTC().Round(time.Millisecond)\n}\n\n// TimeFormatRFC3339 is a format string for writing timestamps as RFC3339.\nconst TimeFormatRFC3339 = \"2006-01-02T15:04:05.999\"\n\n// InitTimes initializes time.Time variables in the header to current time.\nfunc (h *ObjHeader) InitTimes() {\n\tif h.CreatedAt.IsZero() {\n\t\th.CreatedAt = TimeNow()\n\t}\n\th.UpdatedAt = h.CreatedAt\n}\n\n// MergeTimes intelligently copies time.Time variables from h2 to h.\nfunc (h *ObjHeader) MergeTimes(h2 *ObjHeader) {\n\t// Set the creation time to the earliest value\n\tif h.CreatedAt.IsZero() || (!h2.CreatedAt.IsZero() && h2.CreatedAt.Before(h.CreatedAt)) {\n\t\th.CreatedAt = h2.CreatedAt\n\t}\n\t// Set the update time to the latest value\n\tif h.UpdatedAt.Before(h2.UpdatedAt) {\n\t\th.UpdatedAt = h2.UpdatedAt\n\t}\n}\n\n// StringSlice is defined so Scanner and Valuer can be attached to it.\ntype StringSlice []string\n\n// Scan implements sql.Scanner interface.\nfunc (ss *StringSlice) Scan(val any) error {\n\tif val == nil {\n\t\treturn nil\n\t}\n\treturn json.Unmarshal(val.([]byte), ss)\n}\n\n// Value implements sql/driver.Valuer interface.\nfunc (ss StringSlice) Value() (driver.Value, error) {\n\treturn json.Marshal(ss)\n}\n\n// ObjState represents information on objects state,\n// such as an indication that User or Topic is suspended/soft-deleted.\ntype ObjState int\n\nconst (\n\t// StateOK indicates normal user or topic.\n\tStateOK ObjState = 0\n\t// StateSuspended indicates suspended user or topic.\n\tStateSuspended ObjState = 10\n\t// StateDeleted indicates soft-deleted user or topic.\n\tStateDeleted ObjState = 20\n\t// StateUndefined indicates state which has not been set explicitly.\n\tStateUndefined ObjState = 30\n)\n\n// String returns string representation of ObjState.\nfunc (os ObjState) String() string {\n\tswitch os {\n\tcase StateOK:\n\t\treturn \"ok\"\n\tcase StateSuspended:\n\t\treturn \"susp\"\n\tcase StateDeleted:\n\t\treturn \"del\"\n\tcase StateUndefined:\n\t\treturn \"undef\"\n\t}\n\treturn \"\"\n}\n\n// NewObjState parses string into an ObjState.\nfunc NewObjState(in string) (ObjState, error) {\n\tin = strings.ToLower(in)\n\tswitch in {\n\tcase \"\", \"ok\":\n\t\treturn StateOK, nil\n\tcase \"susp\":\n\t\treturn StateSuspended, nil\n\tcase \"del\":\n\t\treturn StateDeleted, nil\n\tcase \"undef\":\n\t\treturn StateUndefined, nil\n\t}\n\t// This is the default.\n\treturn StateOK, errors.New(\"failed to parse object state\")\n}\n\n// MarshalJSON converts ObjState to a quoted string.\nfunc (os ObjState) MarshalJSON() ([]byte, error) {\n\treturn append(append([]byte{'\"'}, []byte(os.String())...), '\"'), nil\n}\n\n// UnmarshalJSON reads ObjState from a quoted string.\nfunc (os *ObjState) UnmarshalJSON(b []byte) error {\n\tif b[0] != '\"' || b[len(b)-1] != '\"' {\n\t\treturn errors.New(\"syntax error\")\n\t}\n\tstate, err := NewObjState(string(b[1 : len(b)-1]))\n\tif err == nil {\n\t\t*os = state\n\t}\n\treturn err\n}\n\n// Scan is an implementation of sql.Scanner interface. It expects the\n// value to be a byte slice representation of an ASCII string.\nfunc (os *ObjState) Scan(val any) error {\n\tswitch intval := val.(type) {\n\tcase int64:\n\t\t*os = ObjState(intval)\n\t\treturn nil\n\t}\n\treturn errors.New(\"data is not an int64\")\n}\n\n// Value is an implementation of sql.driver.Valuer interface.\nfunc (os ObjState) Value() (driver.Value, error) {\n\treturn int64(os), nil\n}\n\n// User is a representation of a DB-stored user record.\ntype User struct {\n\tObjHeader `bson:\",inline\"`\n\n\tState   ObjState\n\tStateAt *time.Time `json:\"StateAt,omitempty\" bson:\",omitempty\"`\n\n\t// Default access to user for P2P topics (used as default modeGiven)\n\tAccess DefaultAccess\n\n\t// Values for 'me' topic:\n\n\t// Last time when the user joined 'me' topic, by User Agent\n\tLastSeen *time.Time\n\t// User agent provided when accessing the topic last time\n\tUserAgent string\n\n\tPublic  any\n\tTrusted any\n\n\t// Unique indexed tags (email, phone) for finding this user. Stored on the\n\t// 'users' as well as indexed in 'tagunique'\n\tTags StringSlice\n\n\t// Info on known devices, used for push notifications\n\tDevices map[string]*DeviceDef `bson:\"__devices,skip,omitempty\"`\n\t// Same for mongodb scheme. Ignore in other db backends if its not suitable.\n\tDeviceArray []*DeviceDef `json:\"-\" bson:\"devices\"`\n}\n\n// AccessMode is a definition of access mode bits.\ntype AccessMode uint\n\n// Various access mode constants.\nconst (\n\tModeJoin    AccessMode = 1 << iota // user can join, i.e. {sub} (J:1)\n\tModeRead                           // user can receive broadcasts ({data}, {info}) (R:2)\n\tModeWrite                          // user can Write, i.e. {pub} (W:4)\n\tModePres                           // user can receive presence updates (P:8)\n\tModeApprove                        // user can approve new members or evict existing members (A:0x10, 16)\n\tModeShare                          // user can invite new members (S:0x20, 32)\n\tModeDelete                         // user can hard-delete messages (D:0x40, 64)\n\tModeOwner                          // user is the owner (O:0x80, 128) - full access\n\tModeUnset                          // Non-zero value to indicate unknown or undefined mode (:0x100, 256), to make it different from ModeNone.\n\n\tModeNone AccessMode = 0 // No access, requests to gain access are processed normally (N:0)\n\n\t// Normal user's access to a topic (\"JRWPS\", 47, 0x2F).\n\tModeCPublic AccessMode = ModeJoin | ModeRead | ModeWrite | ModePres | ModeShare\n\t// User's subscription to 'me' and 'fnd' (\"JPS\", 41, 0x29).\n\tModeCMeFnd AccessMode = ModeJoin | ModePres | ModeShare\n\t// User's  subscription to 'slf' topic (\"JRWDO\", 199, 0xC7).\n\tModeCSelf = ModeJoin | ModeRead | ModeWrite | ModeDelete | ModeOwner\n\t// Owner's subscription to a generic topic (\"JRWPASDO\", 255, 0xFF).\n\tModeCFull AccessMode = ModeJoin | ModeRead | ModeWrite | ModePres | ModeApprove | ModeShare | ModeDelete | ModeOwner\n\t// Default P2P access mode (\"JRWPA\", 31, 0x1F).\n\tModeCP2P AccessMode = ModeJoin | ModeRead | ModeWrite | ModePres | ModeApprove\n\t// P2P acess mode when hard-deleting messages is enabled (\"JRWPAD\", 95, 0x5F)\n\tModeCP2PD AccessMode = ModeJoin | ModeRead | ModeWrite | ModePres | ModeApprove | ModeDelete\n\t// Default Auth access mode for a user (\"JRWPAS\", 63, 0x3F).\n\tModeCAuth AccessMode = ModeCP2P | ModeCPublic\n\t// Read-only access to topic (\"JR\", 3).\n\tModeCReadOnly = ModeJoin | ModeRead\n\t// Access to 'sys' topic by a root user (\"JRWPD\", 79, 0x4F).\n\tModeCSys = ModeJoin | ModeRead | ModeWrite | ModePres | ModeDelete\n\t// Channel publisher: person authorized to publish content; no J: by invitation only (\"RWPD\", 78, 0x4E).\n\tModeCChnWriter = ModeRead | ModeWrite | ModePres | ModeShare\n\t// Reader's access mode to a channel (JRP, 11, 0xB).\n\tModeCChnReader = ModeJoin | ModeRead | ModePres\n\n\t// Admin: user who can modify access mode (\"OA\", dec: 144, hex: 0x90).\n\tModeCAdmin = ModeOwner | ModeApprove\n\t// Sharer: flags which define user who can be notified of access mode changes (\"OAS\", dec: 176, hex: 0xB0).\n\tModeCSharer = ModeCAdmin | ModeShare\n\n\t// Invalid mode to indicate an error.\n\tModeInvalid AccessMode = 0x100000\n\n\t// All possible valid bits (excluding ModeInvalid and ModeUnset) = 0xFF, 255.\n\tModeBitmask AccessMode = ModeJoin | ModeRead | ModeWrite | ModePres | ModeApprove | ModeShare | ModeDelete | ModeOwner\n)\n\n// MarshalText converts AccessMode to ASCII byte slice.\nfunc (m AccessMode) MarshalText() ([]byte, error) {\n\tif m == ModeNone {\n\t\treturn []byte{'N'}, nil\n\t}\n\n\tif m == ModeInvalid {\n\t\treturn nil, errors.New(\"AccessMode invalid\")\n\t}\n\n\tres := []byte{}\n\tmodes := []byte{'J', 'R', 'W', 'P', 'A', 'S', 'D', 'O'}\n\tfor i, chr := range modes {\n\t\tif (m & (1 << uint(i))) != 0 {\n\t\t\tres = append(res, chr)\n\t\t}\n\t}\n\treturn res, nil\n}\n\n// ParseAcs parses AccessMode from a byte array.\nfunc ParseAcs(b []byte) (AccessMode, error) {\n\tm0 := ModeUnset\n\nLoop:\n\tfor i := range b {\n\t\tswitch b[i] {\n\t\tcase 'J', 'j':\n\t\t\tm0 |= ModeJoin\n\t\tcase 'R', 'r':\n\t\t\tm0 |= ModeRead\n\t\tcase 'W', 'w':\n\t\t\tm0 |= ModeWrite\n\t\tcase 'A', 'a':\n\t\t\tm0 |= ModeApprove\n\t\tcase 'S', 's':\n\t\t\tm0 |= ModeShare\n\t\tcase 'D', 'd':\n\t\t\tm0 |= ModeDelete\n\t\tcase 'P', 'p':\n\t\t\tm0 |= ModePres\n\t\tcase 'O', 'o':\n\t\t\tm0 |= ModeOwner\n\t\tcase 'N', 'n':\n\t\t\tif m0 != ModeUnset {\n\t\t\t\treturn ModeUnset, errors.New(\"AccessMode: access N cannot be combined with any other\")\n\t\t\t}\n\t\t\tm0 = ModeNone // N means explicitly no access, all bits cleared\n\t\t\tbreak Loop\n\t\tdefault:\n\t\t\treturn ModeUnset, errors.New(\"AccessMode: invalid character '\" + string(b[i]) + \"'\")\n\t\t}\n\t}\n\n\treturn m0, nil\n}\n\n// UnmarshalText parses access mode string as byte slice.\n// Does not change the mode if the string is empty or invalid.\nfunc (m *AccessMode) UnmarshalText(b []byte) error {\n\tm0, err := ParseAcs(b)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif m0 != ModeUnset {\n\t\t*m = (m0 & ModeBitmask)\n\t}\n\treturn nil\n}\n\n// String returns string representation of AccessMode.\nfunc (m AccessMode) String() string {\n\tres, err := m.MarshalText()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn string(res)\n}\n\n// MarshalJSON converts AccessMode to a quoted string.\nfunc (m AccessMode) MarshalJSON() ([]byte, error) {\n\tres, err := m.MarshalText()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn append(append([]byte{'\"'}, res...), '\"'), nil\n}\n\n// UnmarshalJSON reads AccessMode from a quoted string.\nfunc (m *AccessMode) UnmarshalJSON(b []byte) error {\n\tif b[0] != '\"' || b[len(b)-1] != '\"' {\n\t\treturn errors.New(\"syntax error\")\n\t}\n\n\treturn m.UnmarshalText(b[1 : len(b)-1])\n}\n\n// Scan is an implementation of sql.Scanner interface. It expects the\n// value to be a byte slice representation of an ASCII string.\nfunc (m *AccessMode) Scan(val any) error {\n\tif bb, ok := val.([]byte); ok {\n\t\treturn m.UnmarshalText(bb)\n\t}\n\treturn errors.New(\"scan failed: data is not a byte slice\")\n}\n\n// Value is an implementation of sql.driver.Valuer interface.\nfunc (m AccessMode) Value() (driver.Value, error) {\n\tres, err := m.MarshalText()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn string(res), nil\n}\n\n// BetterThan checks if grant mode allows more permissions than requested in want mode.\nfunc (grant AccessMode) BetterThan(want AccessMode) bool {\n\treturn ModeBitmask&grant&^want != 0\n}\n\n// BetterEqual checks if grant mode allows all permissions requested in want mode.\nfunc (grant AccessMode) BetterEqual(want AccessMode) bool {\n\treturn ModeBitmask&grant&want == want\n}\n\n// Delta between two modes as a string old.Delta(new). JRPAS -> JRWS: \"+W-PA\"\n// Zero delta is an empty string \"\"\nfunc (o AccessMode) Delta(n AccessMode) string {\n\t// Removed bits, bits present in 'old' but missing in 'new' -> '-'\n\tvar removed string\n\tif o2n := ModeBitmask & o &^ n; o2n > 0 {\n\t\tremoved = o2n.String()\n\t\tif removed != \"\" {\n\t\t\tremoved = \"-\" + removed\n\t\t}\n\t}\n\n\t// Added bits, bits present in 'n' but missing in 'o' -> '+'\n\tvar added string\n\tif n2o := ModeBitmask & n &^ o; n2o > 0 {\n\t\tadded = n2o.String()\n\t\tif added != \"\" {\n\t\t\tadded = \"+\" + added\n\t\t}\n\t}\n\treturn added + removed\n}\n\n// ApplyMutation sets of modifies access mode:\n// * if `mutation` contains either '+' or '-', attempts to apply a delta change on `m`.\n// * otherwise, treats it as an assignment.\nfunc (m *AccessMode) ApplyMutation(mutation string) error {\n\tif mutation == \"\" {\n\t\treturn nil\n\t}\n\tif strings.ContainsAny(mutation, \"+-\") {\n\t\treturn m.ApplyDelta(mutation)\n\t}\n\treturn m.UnmarshalText([]byte(mutation))\n}\n\n// ApplyDelta applies the acs delta to AccessMode.\n// Delta is in the same format as generated by AccessMode.Delta.\n// E.g. JPRA.ApplyDelta(-PR+W) -> JWA.\nfunc (m *AccessMode) ApplyDelta(delta string) error {\n\tif delta == \"\" || delta == \"N\" {\n\t\t// No updates.\n\t\treturn nil\n\t}\n\tm0 := *m\n\tfor next := 0; next+1 < len(delta) && next >= 0; {\n\t\tch := delta[next]\n\t\tend := strings.IndexAny(delta[next+1:], \"+-\")\n\t\tvar chunk string\n\t\tif end >= 0 {\n\t\t\tend += next + 1\n\t\t\tchunk = delta[next+1 : end]\n\t\t} else {\n\t\t\tchunk = delta[next+1:]\n\t\t}\n\t\tnext = end\n\t\tupd, err := ParseAcs([]byte(chunk))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tswitch ch {\n\t\tcase '+':\n\t\t\tif upd != ModeUnset {\n\t\t\t\tm0 |= upd & ModeBitmask\n\t\t\t}\n\t\tcase '-':\n\t\t\tif upd != ModeUnset {\n\t\t\t\tm0 &^= upd & ModeBitmask\n\t\t\t}\n\t\tdefault:\n\t\t\treturn errors.New(\"Invalid acs delta string: '\" + delta + \"'\")\n\t\t}\n\t}\n\t*m = m0\n\treturn nil\n}\n\n// IsJoiner checks if joiner flag J is set.\nfunc (m AccessMode) IsJoiner() bool {\n\treturn m&ModeJoin != 0\n}\n\n// IsOwner checks if owner bit O is set.\nfunc (m AccessMode) IsOwner() bool {\n\treturn m&ModeOwner != 0\n}\n\n// IsApprover checks if approver A bit is set.\nfunc (m AccessMode) IsApprover() bool {\n\treturn m&ModeApprove != 0\n}\n\n// IsAdmin check if owner O or approver A flag is set.\nfunc (m AccessMode) IsAdmin() bool {\n\treturn m.IsOwner() || m.IsApprover()\n}\n\n// IsSharer checks if approver A or sharer S or owner O flag is set.\nfunc (m AccessMode) IsSharer() bool {\n\treturn m.IsAdmin() || (m&ModeShare != 0)\n}\n\n// IsWriter checks if allowed to publish (writer flag W is set).\nfunc (m AccessMode) IsWriter() bool {\n\treturn m&ModeWrite != 0\n}\n\n// IsReader checks if reader flag R is set.\nfunc (m AccessMode) IsReader() bool {\n\treturn m&ModeRead != 0\n}\n\n// IsPresencer checks if user receives presence updates (P flag set).\nfunc (m AccessMode) IsPresencer() bool {\n\treturn m&ModePres != 0\n}\n\n// IsDeleter checks if user can hard-delete messages (D flag is set).\nfunc (m AccessMode) IsDeleter() bool {\n\treturn m&ModeDelete != 0\n}\n\n// IsZero checks if no flags are set.\nfunc (m AccessMode) IsZero() bool {\n\treturn m == ModeNone\n}\n\n// IsInvalid checks if mode is invalid.\nfunc (m AccessMode) IsInvalid() bool {\n\treturn m == ModeInvalid\n}\n\n// IsDefined checks if the mode is defined: not invalid and not unset.\n// ModeNone is considered to be defined.\nfunc (m AccessMode) IsDefined() bool {\n\treturn m != ModeInvalid && m != ModeUnset\n}\n\n// DefaultAccess is a per-topic default access modes\ntype DefaultAccess struct {\n\tAuth AccessMode\n\tAnon AccessMode\n}\n\n// Scan is an implementation of Scanner interface so the value can be read from SQL DBs\n// It assumes the value is serialized and stored as JSON\nfunc (da *DefaultAccess) Scan(val any) error {\n\treturn json.Unmarshal(val.([]byte), da)\n}\n\n// Value implements sql's driver.Valuer interface.\nfunc (da DefaultAccess) Value() (driver.Value, error) {\n\treturn json.Marshal(da)\n}\n\n// Credential hold data needed to validate and check validity of a credential like email or phone.\ntype Credential struct {\n\tObjHeader `bson:\",inline\"`\n\t// Credential owner\n\tUser string\n\t// Verification method (email, tel, captcha, etc)\n\tMethod string\n\t// Credential value - `jdoe@example.com` or `+12345678901`\n\tValue string\n\t// Expected response\n\tResp string\n\t// If credential was successfully confirmed\n\tDone bool\n\t// Retry count\n\tRetries int\n}\n\n// LastSeenUA is a timestamp and a user agent of when the user was last seen.\ntype LastSeenUA struct {\n\t// When is the timestamp when the user was last online.\n\tWhen time.Time\n\t// UserAgent is the client UA of the last online access.\n\tUserAgent string\n}\n\n// Subscription to a topic\ntype Subscription struct {\n\tObjHeader `bson:\",inline\"`\n\t// User who has relationship with the topic\n\tUser string\n\t// Topic subscribed to\n\tTopic     string\n\tDeletedAt *time.Time `bson:\",omitempty\"`\n\n\t// Values persisted through subscription soft-deletion\n\n\t// ID of the latest Soft-delete operation\n\tDelId int\n\t// Last SeqId reported by user as received by at least one of his sessions\n\tRecvSeqId int\n\t// Last SeqID reported read by the user\n\tReadSeqId int\n\n\t// Access mode requested by this user\n\tModeWant AccessMode\n\t// Access mode granted to this user\n\tModeGiven AccessMode\n\t// User's private data associated with the subscription to topic\n\tPrivate any\n\n\t// Deserialized ephemeral values\n\n\t// Deserialized public value from topic or user (depends on context)\n\t// In case of P2P topics this is the Public value of the other user.\n\tpublic any\n\t// In case of P2P topics this is the Trusted value of the other user.\n\ttrusted any\n\t// deserialized SeqID from user or topic\n\tseqId int\n\t// Deserialized TouchedAt from topic\n\ttouchedAt time.Time\n\t// Timestamp & user agent of when the user was last online.\n\tlastSeenUA *LastSeenUA\n\n\t// Count of subscribers.\n\tsubCnt int\n\n\t// P2P only. ID of the other user\n\twith string\n\t// P2P only. Default access: this is the mode given by the other user to this user\n\tmodeDefault *DefaultAccess\n\n\t// Topic's or user's state.\n\tstate ObjState\n\n\t// This is not a fully initialized subscription object\n\tdummy bool\n}\n\n// SetPublic assigns a value to `public`, otherwise not accessible from outside the package.\nfunc (s *Subscription) SetPublic(pub any) {\n\ts.public = pub\n}\n\n// GetPublic reads value of `public`.\nfunc (s *Subscription) GetPublic() any {\n\treturn s.public\n}\n\n// SetTrusted assigns a value to `trusted`, otherwise not accessible from outside the package.\nfunc (s *Subscription) SetTrusted(tstd any) {\n\ts.trusted = tstd\n}\n\n// GetTrusted reads value of `trusted`.\nfunc (s *Subscription) GetTrusted() any {\n\treturn s.trusted\n}\n\n// SetWith sets other user for P2P subscriptions.\nfunc (s *Subscription) SetWith(with string) {\n\ts.with = with\n}\n\n// GetWith returns the other user for P2P subscriptions.\nfunc (s *Subscription) GetWith() string {\n\treturn s.with\n}\n\n// GetTouchedAt returns touchedAt.\nfunc (s *Subscription) GetTouchedAt() time.Time {\n\treturn s.touchedAt\n}\n\n// SetTouchedAt sets the value of touchedAt.\nfunc (s *Subscription) SetTouchedAt(touchedAt time.Time) {\n\tif touchedAt.After(s.touchedAt) {\n\t\ts.touchedAt = touchedAt\n\t}\n}\n\n// LastModified returns the greater of either TouchedAt or UpdatedAt.\nfunc (s *Subscription) LastModified() time.Time {\n\tif s.UpdatedAt.Before(s.touchedAt) {\n\t\treturn s.touchedAt\n\t}\n\treturn s.UpdatedAt\n}\n\n// GetSeqId returns seqId.\nfunc (s *Subscription) GetSeqId() int {\n\treturn s.seqId\n}\n\n// SetSeqId sets seqId field.\nfunc (s *Subscription) SetSeqId(id int) {\n\ts.seqId = id\n}\n\n// GetSubCnt returns subCnt (subscriber count).\nfunc (s *Subscription) GetSubCnt() int {\n\treturn s.subCnt\n}\n\n// SetSubCnt sets subCnt (subscriber count).\nfunc (s *Subscription) SetSubCnt(cnt int) {\n\ts.subCnt = cnt\n}\n\n// GetLastSeen returns lastSeen.\nfunc (s *Subscription) GetLastSeen() *time.Time {\n\tif s.lastSeenUA != nil {\n\t\treturn &s.lastSeenUA.When\n\t}\n\treturn nil\n}\n\n// GetUserAgent returns userAgent.\nfunc (s *Subscription) GetUserAgent() string {\n\tif s.lastSeenUA != nil {\n\t\treturn s.lastSeenUA.UserAgent\n\t}\n\treturn \"\"\n}\n\n// SetLastSeenAndUA updates lastSeen time and userAgent.\nfunc (s *Subscription) SetLastSeenAndUA(when *time.Time, ua string) {\n\tif when != nil && !when.IsZero() {\n\t\ts.lastSeenUA = &LastSeenUA{\n\t\t\tWhen:      *when,\n\t\t\tUserAgent: ua,\n\t\t}\n\t} else {\n\t\ts.lastSeenUA = nil\n\t}\n}\n\n// SetDefaultAccess updates default access values.\nfunc (s *Subscription) SetDefaultAccess(auth, anon AccessMode) {\n\ts.modeDefault = &DefaultAccess{auth, anon}\n}\n\n// GetDefaultAccess returns default access.\nfunc (s *Subscription) GetDefaultAccess() *DefaultAccess {\n\treturn s.modeDefault\n}\n\n// GetState returns topic's or user's state.\nfunc (s *Subscription) GetState() ObjState {\n\treturn s.state\n}\n\n// SetState assigns topic's or user's state.\nfunc (s *Subscription) SetState(state ObjState) {\n\ts.state = state\n}\n\n// SetDummy marks this subscription object as only partially intialized.\nfunc (s *Subscription) SetDummy(dummy bool) {\n\ts.dummy = dummy\n}\n\n// IsDummy is true if this subscription object as only partially intialized.\nfunc (s *Subscription) IsDummy() bool {\n\treturn s.dummy\n}\n\n// Contact is a result of a search for connections\ntype Contact struct {\n\tId       string\n\tMatchOn  []string\n\tAccess   DefaultAccess\n\tLastSeen time.Time\n\tPublic   any\n}\n\ntype perUserData struct {\n\tprivate any\n\twant    AccessMode\n\tgiven   AccessMode\n}\n\n// MessageHeaders is needed to attach Scan() to.\ntype KVMap map[string]any\n\n// Scan implements sql.Scanner interface.\nfunc (kvm *KVMap) Scan(val any) error {\n\tif val == nil {\n\t\tkvm = nil\n\t\treturn nil\n\t}\n\treturn json.Unmarshal(val.([]byte), kvm)\n}\n\n// Value implements sql's driver.Valuer interface.\nfunc (kvm KVMap) Value() (driver.Value, error) {\n\treturn json.Marshal(kvm)\n}\n\n// Topic stored in database. Topic's name is Id\ntype Topic struct {\n\tObjHeader `bson:\",inline\"`\n\n\t// State of the topic: normal (ok), suspended, deleted\n\tState   ObjState\n\tStateAt *time.Time `json:\"StateAt,omitempty\" bson:\",omitempty\"`\n\n\t// Timestamp when the last message has passed through the topic\n\tTouchedAt time.Time\n\n\t// Indicates that the topic is a channel.\n\tUseBt bool\n\n\t// Topic owner. Could be zero\n\tOwner string\n\n\t// Default access to topic\n\tAccess DefaultAccess\n\n\t// Server-issued sequential ID\n\tSeqId int\n\t// If messages were deleted, sequential id of the last operation to delete them\n\tDelId int\n\n\t// Count of topic subscribers.\n\tSubCnt int\n\n\tPublic  any\n\tTrusted any\n\n\t// Indexed tags for finding this topic.\n\tTags StringSlice\n\n\t// Auxiliary set of key-value pairs.\n\tAux KVMap `json:\"Aux,omitempty\" bson:\",omitempty\"`\n\n\t// Deserialized ephemeral params\n\tperUser map[Uid]*perUserData // deserialized from Subscription\n}\n\n// GiveAccess updates access mode for the given user.\nfunc (t *Topic) GiveAccess(uid Uid, want, given AccessMode) {\n\tif t.perUser == nil {\n\t\tt.perUser = make(map[Uid]*perUserData, 1)\n\t}\n\n\tpud := t.perUser[uid]\n\tif pud == nil {\n\t\tpud = &perUserData{}\n\t}\n\n\tpud.want = want\n\tpud.given = given\n\n\tt.perUser[uid] = pud\n\tif want&given&ModeOwner != 0 && t.Owner == \"\" {\n\t\tt.Owner = uid.String()\n\t}\n}\n\n// SetPrivate updates private value for the given user.\nfunc (t *Topic) SetPrivate(uid Uid, private any) {\n\tif t.perUser == nil {\n\t\tt.perUser = make(map[Uid]*perUserData, 1)\n\t}\n\tpud := t.perUser[uid]\n\tif pud == nil {\n\t\tpud = &perUserData{}\n\t}\n\tpud.private = private\n\tt.perUser[uid] = pud\n}\n\n// GetPrivate returns given user's private value.\nfunc (t *Topic) GetPrivate(uid Uid) (private any) {\n\tif t.perUser == nil {\n\t\treturn\n\t}\n\tpud := t.perUser[uid]\n\tif pud == nil {\n\t\treturn\n\t}\n\tprivate = pud.private\n\treturn\n}\n\n// GetAccess returns given user's access mode.\nfunc (t *Topic) GetAccess(uid Uid) (mode AccessMode) {\n\tif t.perUser == nil {\n\t\treturn\n\t}\n\tpud := t.perUser[uid]\n\tif pud == nil {\n\t\treturn\n\t}\n\tmode = pud.given & pud.want\n\treturn\n}\n\n// SoftDelete is a single DB record of soft-deletetion.\ntype SoftDelete struct {\n\tUser  string\n\tDelId int\n}\n\n// Message is a stored {data} message\ntype Message struct {\n\tObjHeader `bson:\",inline\"`\n\tDeletedAt *time.Time `json:\"DeletedAt,omitempty\" bson:\",omitempty\"`\n\n\t// ID of the hard-delete operation\n\tDelId int `json:\"DelId,omitempty\" bson:\",omitempty\"`\n\t// List of users who have marked this message as soft-deleted\n\tDeletedFor []SoftDelete `json:\"DeletedFor,omitempty\" bson:\",omitempty\"`\n\tSeqId      int\n\tTopic      string\n\t// Sender's user ID as string (without 'usr' prefix), could be empty.\n\tFrom    string\n\tHead    KVMap `json:\"Head,omitempty\" bson:\",omitempty\"`\n\tContent any\n}\n\n// Range is a range of message SeqIDs. Low end is inclusive (closed), high end is exclusive (open): [Low, Hi).\n// If the range contains just one ID, Hi is set to 0\ntype Range struct {\n\tLow int\n\tHi  int `json:\"Hi,omitempty\" bson:\",omitempty\"`\n}\n\n// RangeSorter is a helper type required by 'sort' package.\ntype RangeSorter []Range\n\n// Len is the length of the range.\nfunc (rs RangeSorter) Len() int {\n\treturn len(rs)\n}\n\n// Swap swaps two items in a slice.\nfunc (rs RangeSorter) Swap(i, j int) {\n\trs[i], rs[j] = rs[j], rs[i]\n}\n\n// Less is a comparator. Sort by Low ascending, then sort by Hi descending\nfunc (rs RangeSorter) Less(i, j int) bool {\n\tif rs[i].Low < rs[j].Low {\n\t\treturn true\n\t}\n\tif rs[i].Low == rs[j].Low {\n\t\treturn rs[i].Hi >= rs[j].Hi\n\t}\n\treturn false\n}\n\n// Normalize ranges - remove overlaps: [1..4],[2..4],[5..7] -> [1..7].\n// The ranges are expected to be sorted.\n// Ranges are inclusive-inclusive, i.e. [1..3] -> 1, 2, 3.\nfunc (rs RangeSorter) Normalize() RangeSorter {\n\tif ll := rs.Len(); ll > 1 {\n\t\tprev := 0\n\t\tfor i := 1; i < ll; i++ {\n\t\t\tif rs[prev].Low == rs[i].Low {\n\t\t\t\t// Earlier range is guaranteed to be wider or equal to the later range,\n\t\t\t\t// collapse two ranges into one (by doing nothing)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// Check for full or partial overlap\n\t\t\tif rs[prev].Hi > 0 && rs[prev].Hi+1 >= rs[i].Low {\n\t\t\t\t// Partial overlap\n\t\t\t\tif rs[prev].Hi < rs[i].Hi {\n\t\t\t\t\trs[prev].Hi = rs[i].Hi\n\t\t\t\t}\n\t\t\t\t// Otherwise the next range is fully within the previous range, consume it by doing nothing.\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// No overlap\n\t\t\tprev++\n\t\t}\n\t\trs = rs[:prev+1]\n\t}\n\n\treturn rs\n}\n\n// Convert a slice of int values to a slice of ranges.\n// The int slice must be sorted low -> high.\nfunc SliceToRanges(in []int) []Range {\n\tif len(in) == 0 {\n\t\treturn nil\n\t}\n\n\tvar out []Range\n\tfor _, id := range in {\n\t\tsize := len(out)\n\n\t\tif size == 0 {\n\t\t\tout = append(out, Range{Low: id})\n\t\t\tcontinue\n\t\t}\n\n\t\tprev := &out[size-1]\n\t\tif (prev.Hi == 0 && (id != prev.Low+1)) || (id > prev.Hi) {\n\t\t\t// New range.\n\t\t\tout = append(out, Range{Low: id})\n\t\t} else {\n\t\t\t// Expand existing range.\n\t\t\tprev.Hi = id + 1\n\t\t}\n\t}\n\treturn out\n}\n\n// DelMessage is a log entry of a deleted message range.\ntype DelMessage struct {\n\tObjHeader   `bson:\",inline\"`\n\tTopic       string\n\tDeletedFor  string\n\tDelId       int\n\tSeqIdRanges []Range\n\n\t// Delete messages newer than this value. Not serialized.\n\tnewerThan *time.Time\n}\n\n// GetNewerThan returns a newerThan delete query parameter.\nfunc (dm *DelMessage) GetNewerThan() *time.Time {\n\treturn dm.newerThan\n}\n\n// SetNewerThan sets a newerThan delete query parameter.\nfunc (dm *DelMessage) SetNewerThan(t time.Time) {\n\tdm.newerThan = &t\n}\n\n// QueryOpt is options of a query, [since, before] - both ends inclusive (closed)\ntype QueryOpt struct {\n\t// Subscription query\n\tUser            Uid\n\tTopic           string\n\tIfModifiedSince *time.Time\n\t// ID-based query parameters: Messages\n\tSince  int\n\tBefore int\n\t// Common parameter\n\tLimit int\n\t// Ranges of IDs.\n\tIdRanges []Range\n}\n\n// TopicCat is an enum of topic categories.\ntype TopicCat int\n\nconst (\n\t// TopicCatMe is a value denoting 'me' topic.\n\tTopicCatMe TopicCat = iota\n\t// TopicCatFnd is a value denoting 'fnd' topic.\n\tTopicCatFnd\n\t// TopicCatP2P is a value denoting 'p2p topic.\n\tTopicCatP2P\n\t// TopicCatGrp is a value denoting group topic.\n\tTopicCatGrp\n\t// TopicCatSys is a constant indicating a system topic.\n\tTopicCatSys\n\t// TopicCatSlf si a constant indicating a 'self' topic, i.e. topic for saved messages and notes.\n\tTopicCatSlf\n)\n\n// GetTopicCat given topic name returns topic category.\nfunc GetTopicCat(name string) TopicCat {\n\tswitch name[:3] {\n\tcase \"usr\":\n\t\treturn TopicCatMe\n\tcase \"p2p\":\n\t\treturn TopicCatP2P\n\tcase \"grp\", \"chn\":\n\t\treturn TopicCatGrp\n\tcase \"fnd\":\n\t\treturn TopicCatFnd\n\tcase \"sys\":\n\t\treturn TopicCatSys\n\tcase \"slf\":\n\t\treturn TopicCatSlf\n\tdefault:\n\t\tpanic(\"invalid topic type for name '\" + name + \"'\")\n\t}\n}\n\n// IsEphemeralTopic checks if the topic is ephemeral, i.e. it's a reference to the user,\n// it's not stored in the 'topics' table like 'me' or 'fnd' topics.\nfunc IsEphemeralTopic(topic string) bool {\n\tcat := GetTopicCat(topic)\n\treturn cat == TopicCatMe || cat == TopicCatFnd\n}\n\n// DeviceDef is the data provided by connected device. Used primarily for\n// push notifications.\ntype DeviceDef struct {\n\t// Device registration ID\n\tDeviceId string\n\t// Device platform (iOS, Android, Web)\n\tPlatform string\n\t// Last logged in\n\tLastSeen time.Time\n\t// Device language, ISO code\n\tLang string\n}\n\n// Media handling constants\nconst (\n\t// UploadStarted indicates that the upload has started but not finished yet.\n\tUploadStarted = iota\n\t// UploadCompleted indicates that the upload has completed successfully.\n\tUploadCompleted\n\t// UploadFailed indicates that the upload has failed.\n\tUploadFailed\n\t// UploadDeleted indicates that the upload is no longer needed and can be deleted.\n\tUploadDeleted\n)\n\n// FileDef is a stored record of a file upload\ntype FileDef struct {\n\tObjHeader `bson:\",inline\"`\n\t// Status of upload\n\tStatus int\n\t// User who created the file\n\tUser string\n\t// Type of the file.\n\tMimeType string\n\t// Size of the file in bytes.\n\tSize int64\n\t// Internal file location, i.e. path on disk or an S3 blob address.\n\tLocation string\n\t// ETag generated by the file server.\n\tETag string\n}\n\n// FlattenDoubleSlice turns 2d slice into a 1d slice.\nfunc FlattenDoubleSlice(data [][]string) []string {\n\tvar result []string\n\tfor _, el := range data {\n\t\tresult = append(result, el...)\n\t}\n\treturn result\n}\n"
  },
  {
    "path": "server/store/types/uidgen.go",
    "content": "package types\n\nimport (\n\t\"encoding/base64\"\n\t\"encoding/binary\"\n\t\"errors\"\n\n\tsf \"github.com/tinode/snowflake\"\n\t\"golang.org/x/crypto/xtea\"\n)\n\n// UidGenerator holds snowflake and encryption paramenets.\n// RethinkDB generates UUIDs as primary keys. Using snowflake-generated uint64 instead.\ntype UidGenerator struct {\n\tseq    *sf.SnowFlake\n\tcipher *xtea.Cipher\n}\n\nvar ErrUninitialized = errors.New(\"uninitialized\")\n\n// Init initialises the Uid generator\nfunc (ug *UidGenerator) Init(workerID uint, key []byte) error {\n\tvar err error\n\n\tif ug.seq == nil {\n\t\tug.seq, err = sf.NewSnowFlake(uint32(workerID))\n\t}\n\tif err == nil && ug.cipher == nil {\n\t\tug.cipher, err = xtea.NewCipher(key)\n\t}\n\n\treturn err\n}\n\n// Get generates a unique weakly-encryped random-looking ID.\n// The Uid is a unit64 with the highest bit possibly set which makes it\n// incompatible with go's pre-1.9 sql package.\nfunc (ug *UidGenerator) Get() Uid {\n\tbuf, err := getIDBuffer(ug)\n\tif err != nil {\n\t\treturn ZeroUid\n\t}\n\treturn Uid(binary.LittleEndian.Uint64(buf))\n}\n\n// GetStr generates the same unique ID as Get then returns it as\n// base64-encoded string. Slightly more efficient than calling Get()\n// then base64-encoding the result.\nfunc (ug *UidGenerator) GetStr() string {\n\tbuf, err := getIDBuffer(ug)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(buf)\n}\n\n// getIdBuffer returns a byte array holding the Uid bytes\nfunc getIDBuffer(ug *UidGenerator) ([]byte, error) {\n\tif ug == nil || ug.seq == nil {\n\t\treturn nil, ErrUninitialized\n\t}\n\n\tvar id uint64\n\tvar err error\n\tif id, err = ug.seq.Next(); err != nil {\n\t\treturn nil, err\n\t}\n\n\tsrc := make([]byte, 8)\n\tdst := make([]byte, 8)\n\tbinary.LittleEndian.PutUint64(src, id)\n\tug.cipher.Encrypt(dst, src)\n\n\treturn dst, nil\n}\n\n// DecodeUid takes an encrypted Uid and decrypts it into a non-negative int64.\n// This is needed for go/sql compatibility where uint64 with high bit\n// set is unsupported and possibly for other uses such as MySQL's recommendation\n// for sequential primary keys.\nfunc (ug *UidGenerator) DecodeUid(uid Uid) int64 {\n\tif ug == nil || ug.seq == nil {\n\t\treturn 0\n\t}\n\tsrc := make([]byte, 8)\n\tdst := make([]byte, 8)\n\tbinary.LittleEndian.PutUint64(src, uint64(uid))\n\tug.cipher.Decrypt(dst, src)\n\treturn int64(binary.LittleEndian.Uint64(dst))\n}\n\n// EncodeInt64 takes a positive int64 and encrypts it into a Uid.\n// This is needed for go/sql compatibility where uint64 with high bit\n// set is unsupported  and possibly for other uses such as MySQL's recommendation\n// for sequential primary keys.\nfunc (ug *UidGenerator) EncodeInt64(val int64) Uid {\n\tif ug == nil || ug.seq == nil {\n\t\treturn ZeroUid\n\t}\n\tsrc := make([]byte, 8)\n\tdst := make([]byte, 8)\n\tbinary.LittleEndian.PutUint64(src, uint64(val))\n\tug.cipher.Encrypt(dst, src)\n\treturn Uid(binary.LittleEndian.Uint64(dst))\n}\n"
  },
  {
    "path": "server/store/types/uidgen_test.go",
    "content": "package types\n\nimport (\n\t\"encoding/base64\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestUidGeneratorInit(t *testing.T) {\n\tug := &UidGenerator{}\n\tkey := []byte(\"testkey1testkey2\") // 16 bytes for XTEA\n\n\t// Test successful initialization\n\terr := ug.Init(1, key)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\n\tif ug.seq == nil {\n\t\tt.Error(\"Snowflake generator should be initialized\")\n\t}\n\tif ug.cipher == nil {\n\t\tt.Error(\"Cipher should be initialized\")\n\t}\n\n\t// Test initialization with different worker ID\n\tug2 := &UidGenerator{}\n\terr = ug2.Init(2, key)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\n\t// Test that already initialized generator doesn't reinitialize\n\toldSeq := ug.seq\n\toldCipher := ug.cipher\n\terr = ug.Init(3, key)\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got %v\", err)\n\t}\n\tif ug.seq != oldSeq {\n\t\tt.Error(\"Snowflake generator should not be reinitialized\")\n\t}\n\tif ug.cipher != oldCipher {\n\t\tt.Error(\"Cipher should not be reinitialized\")\n\t}\n}\n\nfunc TestUidGeneratorInitWithInvalidKey(t *testing.T) {\n\tug := &UidGenerator{}\n\n\t// Test with key that's too short (XTEA requires 16 bytes)\n\tshortKey := []byte(\"short\")\n\terr := ug.Init(1, shortKey)\n\tif err == nil {\n\t\tt.Error(\"Expected error with short key\")\n\t}\n\n\t// Test with nil key\n\terr = ug.Init(1, nil)\n\tif err == nil {\n\t\tt.Error(\"Expected error with nil key\")\n\t}\n}\n\nfunc TestUidGeneratorGet(t *testing.T) {\n\tug := &UidGenerator{}\n\tkey := []byte(\"testkey1testkey2\")\n\terr := ug.Init(1, key)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to initialize generator: %v\", err)\n\t}\n\n\t// Test basic UID generation\n\tuid1 := ug.Get()\n\tif uid1 == ZeroUid {\n\t\tt.Error(\"Generated UID should not be zero\")\n\t}\n\n\tuid2 := ug.Get()\n\tif uid2 == ZeroUid {\n\t\tt.Error(\"Generated UID should not be zero\")\n\t}\n\n\t// UIDs should be unique\n\tif uid1 == uid2 {\n\t\tt.Error(\"Generated UIDs should be unique\")\n\t}\n\n\t// Test multiple UIDs are unique\n\tuids := make(map[Uid]bool)\n\tfor i := 0; i < 1000; i++ {\n\t\tuid := ug.Get()\n\t\tif uid == ZeroUid {\n\t\t\tt.Errorf(\"UID %d should not be zero\", i)\n\t\t}\n\t\tif uids[uid] {\n\t\t\tt.Errorf(\"Duplicate UID generated: %v\", uid)\n\t\t}\n\t\tuids[uid] = true\n\t}\n}\n\nfunc TestUidGeneratorGetWithUninitializedGenerator(t *testing.T) {\n\tug := &UidGenerator{}\n\n\t// Test Get() without initialization\n\tuid := ug.Get()\n\tif uid != ZeroUid {\n\t\tt.Error(\"Expected ZeroUid from uninitialized generator\")\n\t}\n}\n\nfunc TestUidGeneratorGetStr(t *testing.T) {\n\tug := &UidGenerator{}\n\tkey := []byte(\"testkey1testkey2\")\n\terr := ug.Init(1, key)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to initialize generator: %v\", err)\n\t}\n\n\t// Test basic string UID generation\n\tuidStr1 := ug.GetStr()\n\tif uidStr1 == \"\" {\n\t\tt.Error(\"Generated UID string should not be empty\")\n\t}\n\n\tuidStr2 := ug.GetStr()\n\tif uidStr2 == \"\" {\n\t\tt.Error(\"Generated UID string should not be empty\")\n\t}\n\n\t// UID strings should be unique\n\tif uidStr1 == uidStr2 {\n\t\tt.Error(\"Generated UID strings should be unique\")\n\t}\n\n\t// Test that string is valid base64\n\t_, err = base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(uidStr1)\n\tif err != nil {\n\t\tt.Errorf(\"Generated UID string should be valid base64: %v\", err)\n\t}\n\n\t// Test consistency between Get() and GetStr()\n\tuidStr := ug.GetStr()\n\n\t// Decode the string and compare with binary UID\n\tdecoded, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(uidStr)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to decode UID string: %v\", err)\n\t}\n\tif len(decoded) != 8 {\n\t\tt.Errorf(\"Decoded UID should be 8 bytes, got %d\", len(decoded))\n\t}\n}\n\nfunc TestUidGeneratorGetStrWithUninitializedGenerator(t *testing.T) {\n\tug := &UidGenerator{}\n\n\t// Test GetStr() without initialization\n\tuidStr := ug.GetStr()\n\tif uidStr != \"\" {\n\t\tt.Error(\"Expected empty string from uninitialized generator\")\n\t}\n}\n\nfunc TestUidGeneratorDecodeUid(t *testing.T) {\n\tug := &UidGenerator{}\n\tkey := []byte(\"testkey1testkey2\")\n\terr := ug.Init(1, key)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to initialize generator: %v\", err)\n\t}\n\n\t// Test decoding generated UIDs\n\tuid := ug.Get()\n\tdecoded := ug.DecodeUid(uid)\n\n\t// Decoded value should be non-negative (for SQL compatibility)\n\tif decoded < 0 {\n\t\tt.Errorf(\"Decoded UID should be non-negative, got %d\", decoded)\n\t}\n\n\t// Test multiple UIDs decode to different values\n\tuid1 := ug.Get()\n\tuid2 := ug.Get()\n\tdecoded1 := ug.DecodeUid(uid1)\n\tdecoded2 := ug.DecodeUid(uid2)\n\n\tif decoded1 == decoded2 {\n\t\tt.Error(\"Different UIDs should decode to different values\")\n\t}\n}\n\nfunc TestUidGeneratorEncodeInt64(t *testing.T) {\n\tug := &UidGenerator{}\n\tkey := []byte(\"testkey1testkey2\")\n\terr := ug.Init(1, key)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to initialize generator: %v\", err)\n\t}\n\n\t// Test encoding positive int64 values\n\tval1 := int64(12345)\n\tuid1 := ug.EncodeInt64(val1)\n\tif uid1 == ZeroUid {\n\t\tt.Error(\"Encoded UID should not be zero for positive value\")\n\t}\n\n\tval2 := int64(67890)\n\tuid2 := ug.EncodeInt64(val2)\n\tif uid2 == ZeroUid {\n\t\tt.Error(\"Encoded UID should not be zero for positive value\")\n\t}\n\n\t// Different values should encode to different UIDs\n\tif uid1 == uid2 {\n\t\tt.Error(\"Different values should encode to different UIDs\")\n\t}\n\n\t// Test encoding zero\n\tuid0 := ug.EncodeInt64(0)\n\tif uid0 == ZeroUid {\n\t\tt.Error(\"Encoded UID for 0 should not be ZeroUid (due to encryption)\")\n\t}\n\n\t// Test encoding large values\n\tmaxVal := int64(9223372036854775807) // max int64\n\tuidMax := ug.EncodeInt64(maxVal)\n\tif uidMax == ZeroUid {\n\t\tt.Error(\"Should be able to encode max int64 value\")\n\t}\n}\n\nfunc TestUidGeneratorEncodeDecodeRoundtrip(t *testing.T) {\n\tug := &UidGenerator{}\n\tkey := []byte(\"testkey1testkey2\")\n\terr := ug.Init(1, key)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to initialize generator: %v\", err)\n\t}\n\n\t// Test encode/decode roundtrip for various values\n\ttestValues := []int64{0, 1, 42, 12345, 1000000, 9223372036854775807}\n\n\tfor _, val := range testValues {\n\t\tencoded := ug.EncodeInt64(val)\n\t\tdecoded := ug.DecodeUid(encoded)\n\n\t\tif decoded != val {\n\t\t\tt.Errorf(\"Roundtrip failed for %d: got %d\", val, decoded)\n\t\t}\n\t}\n\n\t// Test that generated UIDs can be decoded back to sequential values\n\tuid := ug.Get()\n\tdecoded := ug.DecodeUid(uid)\n\treencoded := ug.EncodeInt64(decoded)\n\n\tif reencoded != uid {\n\t\tt.Error(\"Generated UID roundtrip failed\")\n\t}\n}\n\nfunc TestUidGeneratorConcurrency(t *testing.T) {\n\tug := &UidGenerator{}\n\tkey := []byte(\"testkey1testkey2\")\n\terr := ug.Init(1, key)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to initialize generator: %v\", err)\n\t}\n\n\t// Test concurrent UID generation\n\tconst numGoroutines = 10\n\tconst uidsPerGoroutine = 100\n\n\tuidChan := make(chan Uid, numGoroutines*uidsPerGoroutine)\n\n\tfor i := 0; i < numGoroutines; i++ {\n\t\tgo func() {\n\t\t\tfor j := 0; j < uidsPerGoroutine; j++ {\n\t\t\t\tuid := ug.Get()\n\t\t\t\tuidChan <- uid\n\t\t\t}\n\t\t}()\n\t}\n\n\t// Collect all UIDs\n\tuids := make(map[Uid]bool)\n\tfor i := 0; i < numGoroutines*uidsPerGoroutine; i++ {\n\t\tuid := <-uidChan\n\t\tif uid == ZeroUid {\n\t\t\tt.Error(\"Generated UID should not be zero\")\n\t\t}\n\t\tif uids[uid] {\n\t\t\tt.Errorf(\"Duplicate UID generated in concurrent test: %v\", uid)\n\t\t}\n\t\tuids[uid] = true\n\t}\n\n\tif len(uids) != numGoroutines*uidsPerGoroutine {\n\t\tt.Errorf(\"Expected %d unique UIDs, got %d\", numGoroutines*uidsPerGoroutine, len(uids))\n\t}\n}\n\nfunc TestUidGeneratorPerformance(t *testing.T) {\n\tif testing.Short() {\n\t\tt.Skip(\"Skipping performance test in short mode\")\n\t}\n\n\tug := &UidGenerator{}\n\tkey := []byte(\"testkey1testkey2\")\n\terr := ug.Init(1, key)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to initialize generator: %v\", err)\n\t}\n\n\t// Performance test for Get()\n\tstart := time.Now()\n\tfor i := 0; i < 100000; i++ {\n\t\tuid := ug.Get()\n\t\tif uid == ZeroUid {\n\t\t\tt.Error(\"Generated UID should not be zero\")\n\t\t}\n\t}\n\tduration := time.Since(start)\n\tt.Logf(\"Generated 100,000 UIDs in %v (%.0f UIDs/sec)\", duration, 100000/duration.Seconds())\n\n\t// Performance test for GetStr()\n\tstart = time.Now()\n\tfor i := 0; i < 100000; i++ {\n\t\tuidStr := ug.GetStr()\n\t\tif uidStr == \"\" {\n\t\t\tt.Error(\"Generated UID string should not be empty\")\n\t\t}\n\t}\n\tduration = time.Since(start)\n\tt.Logf(\"Generated 100,000 UID strings in %v (%.0f UIDs/sec)\", duration, 100000/duration.Seconds())\n}\n\nfunc TestUidGeneratorDifferentWorkerIds(t *testing.T) {\n\tkey := []byte(\"testkey1testkey2\")\n\n\t// Test that different worker IDs produce different sequences\n\tug1 := &UidGenerator{}\n\tug2 := &UidGenerator{}\n\n\terr1 := ug1.Init(1, key)\n\terr2 := ug2.Init(2, key)\n\n\tif err1 != nil || err2 != nil {\n\t\tt.Fatalf(\"Failed to initialize generators: %v, %v\", err1, err2)\n\t}\n\n\t// Generate UIDs from both generators\n\tuids1 := make([]Uid, 100)\n\tuids2 := make([]Uid, 100)\n\n\tfor i := 0; i < 100; i++ {\n\t\tuids1[i] = ug1.Get()\n\t\tuids2[i] = ug2.Get()\n\t}\n\n\t// Check for uniqueness across generators\n\tallUids := make(map[Uid]bool)\n\tfor _, uid := range uids1 {\n\t\tif allUids[uid] {\n\t\t\tt.Error(\"Duplicate UID found across generators\")\n\t\t}\n\t\tallUids[uid] = true\n\t}\n\tfor _, uid := range uids2 {\n\t\tif allUids[uid] {\n\t\t\tt.Error(\"Duplicate UID found across generators\")\n\t\t}\n\t\tallUids[uid] = true\n\t}\n}\nfunc TestUidGeneratorInitErrorConditions(t *testing.T) {\n\t// Test with invalid worker ID (snowflake has limits)\n\tug := &UidGenerator{}\n\tkey := []byte(\"testkey1testkey2\")\n\n\t// Test with worker ID that might cause snowflake to fail\n\t// Snowflake typically supports worker IDs up to 1023 (10 bits)\n\terr := ug.Init(1024, key) // This should potentially fail\n\tif err != nil {\n\t\tt.Logf(\"Expected behavior: worker ID 1024 failed with: %v\", err)\n\t}\n\n\t// Test with extremely large worker ID\n\tug2 := &UidGenerator{}\n\terr = ug2.Init(4294967295, key) // max uint32\n\tif err != nil {\n\t\tt.Logf(\"Expected behavior: max uint32 worker ID failed with: %v\", err)\n\t}\n}\n\nfunc TestUidGeneratorInitKeyValidation(t *testing.T) {\n\t// Test with various invalid key lengths\n\ttestCases := []struct {\n\t\tname string\n\t\tkey  []byte\n\t}{\n\t\t{\"nil key\", nil},\n\t\t{\"empty key\", []byte{}},\n\t\t{\"too short key\", []byte(\"short\")},\n\t\t{\"15 byte key\", []byte(\"testkey1testkey\")},   // 15 bytes\n\t\t{\"17 byte key\", []byte(\"testkey1testkey22\")}, // 17 bytes\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tug := &UidGenerator{}\n\t\t\terr := ug.Init(1, tc.key)\n\t\t\tif err == nil {\n\t\t\t\tt.Errorf(\"Expected error for %s, but got none\", tc.name)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestUidGeneratorInitValidKeys(t *testing.T) {\n\t// Test with exactly 16 byte key (XTEA requirement)\n\tug := &UidGenerator{}\n\tkey16 := []byte(\"testkey1testkey2\") // exactly 16 bytes\n\terr := ug.Init(1, key16)\n\tif err != nil {\n\t\tt.Errorf(\"16-byte key should work: %v\", err)\n\t}\n\n\t// Test with different valid 16-byte keys\n\tvalidKeys := [][]byte{\n\t\t[]byte(\"1234567890123456\"),\n\t\t[]byte(\"abcdefghijklmnop\"),\n\t\t[]byte(\"\\x00\\x01\\x02\\x03\\x04\\x05\\x06\\x07\\x08\\x09\\x0a\\x0b\\x0c\\x0d\\x0e\\x0f\"),\n\t}\n\n\tfor i, key := range validKeys {\n\t\tug := &UidGenerator{}\n\t\terr := ug.Init(uint(i), key)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Valid key %d should work: %v\", i, err)\n\t\t}\n\t}\n}\n\nfunc TestUidGeneratorInitPartialFailure(t *testing.T) {\n\t// Test scenario where snowflake init succeeds but cipher init fails\n\tug := &UidGenerator{}\n\n\t// First, initialize with valid parameters\n\tvalidKey := []byte(\"testkey1testkey2\")\n\terr := ug.Init(1, validKey)\n\tif err != nil {\n\t\tt.Fatalf(\"Initial setup should work: %v\", err)\n\t}\n\n\t// Now try to init again with invalid key - should not affect existing snowflake\n\toldSeq := ug.seq\n\terr = ug.Init(1, []byte(\"short\"))\n\n\t// The snowflake should remain the same (not re-initialized)\n\tif ug.seq != oldSeq {\n\t\tt.Error(\"Snowflake should not be re-initialized on partial failure\")\n\t}\n\n\t// But cipher might be affected depending on implementation\n\tif err != nil {\n\t\tt.Logf(\"Expected behavior: partial init failed with: %v\", err)\n\t}\n}\n\nfunc TestUidGeneratorInitMultipleWorkers(t *testing.T) {\n\tkey := []byte(\"testkey1testkey2\")\n\tgenerators := make([]*UidGenerator, 10)\n\n\t// Initialize multiple generators with different worker IDs\n\tfor i := 0; i < 10; i++ {\n\t\tgenerators[i] = &UidGenerator{}\n\t\terr := generators[i].Init(uint(i), key)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Worker %d initialization failed: %v\", i, err)\n\t\t}\n\t}\n\n\t// Verify they all generate unique UIDs\n\tuids := make(map[Uid]int) // UID -> worker ID\n\tfor i, gen := range generators {\n\t\tfor j := 0; j < 10; j++ {\n\t\t\tuid := gen.Get()\n\t\t\tif uid == ZeroUid {\n\t\t\t\tt.Errorf(\"Worker %d generated ZeroUid\", i)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif existingWorker, exists := uids[uid]; exists {\n\t\t\t\tt.Errorf(\"Duplicate UID %v between workers %d and %d\", uid, existingWorker, i)\n\t\t\t}\n\t\t\tuids[uid] = i\n\t\t}\n\t}\n}\n\nfunc TestUidGeneratorInitIdempotency(t *testing.T) {\n\tug := &UidGenerator{}\n\tkey := []byte(\"testkey1testkey2\")\n\n\t// First initialization\n\terr1 := ug.Init(1, key)\n\tif err1 != nil {\n\t\tt.Fatalf(\"First init failed: %v\", err1)\n\t}\n\n\tseq1 := ug.seq\n\tcipher1 := ug.cipher\n\n\t// Second initialization with same parameters\n\terr2 := ug.Init(1, key)\n\tif err2 != nil {\n\t\tt.Errorf(\"Second init should not fail: %v\", err2)\n\t}\n\n\t// Should not re-initialize\n\tif ug.seq != seq1 {\n\t\tt.Error(\"Snowflake should not be re-initialized\")\n\t}\n\tif ug.cipher != cipher1 {\n\t\tt.Error(\"Cipher should not be re-initialized\")\n\t}\n\n\t// Third initialization with different parameters\n\terr3 := ug.Init(2, key)\n\tif err3 != nil {\n\t\tt.Errorf(\"Third init should not fail: %v\", err3)\n\t}\n\n\t// Still should not re-initialize\n\tif ug.seq != seq1 {\n\t\tt.Error(\"Snowflake should not be re-initialized even with different worker ID\")\n\t}\n\tif ug.cipher != cipher1 {\n\t\tt.Error(\"Cipher should not be re-initialized even with different worker ID\")\n\t}\n}\n\nfunc TestUidGeneratorInitConcurrent(t *testing.T) {\n\tconst numGoroutines = 20\n\tkey := []byte(\"testkey1testkey2\")\n\n\tug := &UidGenerator{}\n\terrChan := make(chan error, numGoroutines)\n\n\t// Concurrent initialization attempts\n\tfor i := 0; i < numGoroutines; i++ {\n\t\tgo func(workerID uint) {\n\t\t\terr := ug.Init(workerID, key)\n\t\t\terrChan <- err\n\t\t}(uint(i))\n\t}\n\n\t// Collect results\n\tvar errors []error\n\tfor i := 0; i < numGoroutines; i++ {\n\t\tif err := <-errChan; err != nil {\n\t\t\terrors = append(errors, err)\n\t\t}\n\t}\n\n\t// At least one should succeed, others might fail due to race conditions\n\t// but the generator should be in a valid state\n\tif ug.seq == nil {\n\t\tt.Error(\"Snowflake should be initialized after concurrent attempts\")\n\t}\n\tif ug.cipher == nil {\n\t\tt.Error(\"Cipher should be initialized after concurrent attempts\")\n\t}\n\n\t// Should be able to generate UIDs\n\tuid := ug.Get()\n\tif uid == ZeroUid {\n\t\tt.Error(\"Should be able to generate UID after concurrent initialization\")\n\t}\n\n\tif len(errors) > 0 {\n\t\tt.Logf(\"Some concurrent initializations failed (may be expected): %v\", errors)\n\t}\n}\n\nfunc TestUidGeneratorInitBoundaryWorkerIDs(t *testing.T) {\n\tkey := []byte(\"testkey1testkey2\")\n\n\t// Test boundary values for worker ID\n\ttestCases := []struct {\n\t\tname     string\n\t\tworkerID uint\n\t\texpect   bool // true if should succeed\n\t}{\n\t\t{\"zero worker ID\", 0, true},\n\t\t{\"worker ID 1\", 1, true},\n\t\t{\"worker ID 1023\", 1023, true},  // Common snowflake limit\n\t\t{\"worker ID 1024\", 1024, false}, // Might exceed limit\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tug := &UidGenerator{}\n\t\t\terr := ug.Init(tc.workerID, key)\n\n\t\t\tif tc.expect && err != nil {\n\t\t\t\tt.Errorf(\"Expected success for worker ID %d, got error: %v\", tc.workerID, err)\n\t\t\t} else if !tc.expect && err == nil {\n\t\t\t\tt.Errorf(\"Expected error for worker ID %d, but succeeded\", tc.workerID)\n\t\t\t}\n\n\t\t\t// If initialization succeeded, verify it works\n\t\t\tif err == nil {\n\t\t\t\tuid := ug.Get()\n\t\t\t\tif uid == ZeroUid {\n\t\t\t\t\tt.Errorf(\"Generator with worker ID %d should produce valid UIDs\", tc.workerID)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\nfunc BenchmarkUidGeneratorGet(b *testing.B) {\n\tug := &UidGenerator{}\n\tkey := []byte(\"testkey1testkey2\")\n\terr := ug.Init(1, key)\n\tif err != nil {\n\t\tb.Fatalf(\"Failed to initialize generator: %v\", err)\n\t}\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\t_ = ug.Get()\n\t}\n}\n\nfunc BenchmarkUidGeneratorGetStr(b *testing.B) {\n\tug := &UidGenerator{}\n\tkey := []byte(\"testkey1testkey2\")\n\terr := ug.Init(1, key)\n\tif err != nil {\n\t\tb.Fatalf(\"Failed to initialize generator: %v\", err)\n\t}\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\t_ = ug.GetStr()\n\t}\n}\n\nfunc BenchmarkUidGeneratorDecodeUid(b *testing.B) {\n\tug := &UidGenerator{}\n\tkey := []byte(\"testkey1testkey2\")\n\terr := ug.Init(1, key)\n\tif err != nil {\n\t\tb.Fatalf(\"Failed to initialize generator: %v\", err)\n\t}\n\n\t// Pre-generate a UID to decode\n\tuid := ug.Get()\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\t_ = ug.DecodeUid(uid)\n\t}\n}\n\nfunc BenchmarkUidGeneratorEncodeInt64(b *testing.B) {\n\tug := &UidGenerator{}\n\tkey := []byte(\"testkey1testkey2\")\n\terr := ug.Init(1, key)\n\tif err != nil {\n\t\tb.Fatalf(\"Failed to initialize generator: %v\", err)\n\t}\n\n\tval := int64(12345)\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\t_ = ug.EncodeInt64(val)\n\t}\n}\n"
  },
  {
    "path": "server/templ/email-password-reset-en.templ",
    "content": "{{/*\n  ENGLISH\n\n  This template defines contents of the password reset email.\n\n  See explanation in ./email-validation-en.templ\n*/}}\n\n\n{{define \"subject\" -}}\nReset Tinode password\n{{- end}}\n\n{{define \"body_html\" -}}\n<html>\n<body>\n\n<p>Hello.</p>\n\n<p>You recently requested to reset the password for your <a href=\"{{.HostUrl}}\">Tinode</a> account.\nUse the link or code below to reset it. The link and code are valid for the next 24 hours only.</p>\n\n<blockquote><a href=\"{{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}\">Click</a> to reset your password.</blockquote>\n\n<p>If you’re having trouble with the link above, copy and paste the URL below into your web browser:</p>\n<blockquote>\n<a href=\"{{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}\">{{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}</a>\n</blockquote>\n\n<p>Please enter the following code if prompted:</p>\n<blockquote><big>{{.Code}}</big></blockquote>\n\n{{with .Login}}\n<p>In case you have forgotten, here is your login: {{.}}.</p>\n{{end}}\n\n<p>If you did not request a password reset, please ignore this message.</p>\n\n<p><a href=\"https://tinode.co/\">Tinode Team</a></p>\n\n</body>\n</html>\n{{- end}}\n\n{{define \"body_plain\" -}}\n\nHello.\n\nYou recently requested to reset the password for your Tinode account ({{.HostUrl}}).\nUse the link or code below to reset it. The link and code are valid for the next 24 hours only.\n\n   {{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}\n\nIf you’re having trouble with clicking the link above, copy and paste it into your web browser.\n\nPlease enter the following code if prompted:\n   {{.Code}}\n\n{{- with .Login}}\nIn case you have forgotten, here is your login: {{.}}.\n{{end -}}\n\nIf you did not request a password reset, please ignore this message.\n\nTinode Team\nhttps://tinode.co/\n\n{{- end}}\n"
  },
  {
    "path": "server/templ/email-password-reset-es.templ",
    "content": "{{/*\n  SPANISH\n\n  This template defines contents of the password reset email in spanish.\n\n  See explanation in ./email-validation-en.templ\n*/}}\n\n\n{{define \"subject\" -}}\nReestablecer contraseña de Tinode\n{{- end}}\n\n{{define \"body_html\" -}}\n<html>\n<body>\n\n<p>Hola.</p>\n\n<p>Recientemente solicitaste reestablecer la contraseña para tu cuenta <a href=\"{{.HostUrl}}\">Tinode</a>.\nUsa el enlace de abajo para reestablecerla. El enlace es válido solamente por las siguientes 24 horas.</p>\n\n<blockquote><a href=\"{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}\">Clic</a> para reestablecer contraseña.</blockquote>\n\n<p>Si tienes problemas con el enlace superior, copia y pega le siguiente URL en tu navegador.</p>\n<blockquote>\n<a href=\"{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}\">{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}</a>\n</blockquote>\n\n<p>Ingrese el siguiente código si se le solicita:</p>\n<blockquote><big>{{.Code}}</big></blockquote>\n\n{{with .Login}}\n<p>En caso de que lo hayas olvidado, tu usuario es: {{.}}.</p>\n{{end}}\n\n<p>Si no solicitaste el reestablecimiento de tu contrseña, por favor ignora este mensaje.</p>\n\n<p><a href=\"https://tinode.co/\">Equipo de Tinode</a></p>\n\n</body>\n</html>\n{{- end}}\n\n{{define \"body_plain\" -}}\n\nHola.\n\nRecientemente solicitaste reestablecer la contraseña para tu cuenta Tinode ({{.HostUrl}}).\nUsa el enlace de abajo para reestablecerla. El enlace es válido solamente por las siguientes 24 horas.\n\n   {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}\n\nSi tienes problemas con el enlace superior, copia y pega le siguiente URL en tu navegador.\n\nIngrese el siguiente código si se le solicita:\n   {{.Code}}\n\n{{- with .Login}}\nEn caso de que lo hayas olvidado, tu usuario es: {{.}}.\n{{end -}}\n\nSi no solicitaste el reestablecimiento de tu contrseña, por favor ignora este mensaje.\n\nEquipo de Tinode\nhttps://tinode.co/\n\n{{- end}}\n"
  },
  {
    "path": "server/templ/email-password-reset-fr.templ",
    "content": "{{/*\n  FRENCH\n\n  This template defines contents of the password reset email.\n\n  See explanation in ./email-validation-en.templ\n*/}}\n\n\n{{define \"subject\" -}}\nRéinitialiser votre mot de passe Tinode\n{{- end}}\n\n{{define \"body_html\" -}}\n<html>\n<body>\n\n<p>Bonjour.</p>\n\n<p>Vous avez récemment demandé à réinitialiser le mot de passe de votre compte <a href=\"{{.HostUrl}}\">Tinode</a>.\nUtilisez le lien ou le code ci-dessous pour le réinitialiser. Le lien et le code sont valides pour les prochaines 24 heures seulement.</p>\n\n<blockquote><a href=\"{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}\">Cliquer ici</a> pour réinitialiser votre mot de passe.</blockquote>\n\n<p>Si vous avez des problèmes avec le lien ci-dessus, copiez et collez l'URL ci-dessous dans votre navigateur web :</p>\n<blockquote>\n<a href=\"{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}\">{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}</a>\n</blockquote>\n\n<p>Veuillez saisir le code suivant si vous y êtes invité :</p>\n<blockquote><big>{{.Code}}</big></blockquote>\n\n{{with .Login}}\n<p>Au cas où vous l'auriez oublié, voici votre login : {{.}}.</p>\n{{end}}\n\n<p>Si vous n'avez pas demandé la réinitialisation de votre mot de passe, veuillez ignorer ce message.</p>\n\n<p><a href=\"https://tinode.co/\">L'équipe Tinode</a></p>\n\n</body>\n</html>\n{{- end}}\n\n{{define \"body_plain\" -}}\n\nBonjour.\n\nVous avez récemment demandé à réinitialiser le mot de passe de votre compte ({{.HostUrl}}).\nUtilisez le lien ou le code ci-dessous pour le réinitialiser. Le lien et le code sont valides pour les prochaines 24 heures seulement.\n\n   {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}\n\nSi vous avez des difficultés à cliquer sur le lien ci-dessus, copiez et collez-le dans votre navigateur web.\n\nVeuillez saisir le code suivant si vous y êtes invité:\n   {{.Code}}\n\n{{- with .Login}}\nAu cas où vous l'auriez oublié, voici votre login : {{.}}.\n{{end -}}\n\nSi vous n'avez pas demandé la réinitialisation de votre mot de passe, veuillez ignorer ce message.\n\nL'équipe Tinode\nhttps://tinode.co/\n\n{{- end}}\n"
  },
  {
    "path": "server/templ/email-password-reset-pt.templ",
    "content": "{{/*\n  PORTUGUESE\n\n  This template defines contents of the password reset e-mail in portuguese.\n\n  See explanation in ./email-validation-en.templ\n*/}}\n\n\n{{define \"subject\" -}}\nRedefinir senha Tinode\n{{- end}}\n\n{{define \"body_html\" -}}\n<html>\n<body>\n\n<p>Olá.</p>\n\n<p>Você solicitou recentemente a redefinição da sua senha <a href=\"{{.HostUrl}}\">Tinode</a>.\nUse o link ou código abaixo para redefini-lo. O link e o código são válidos apenas pelas próximas 24 horas.</p>\n\n<blockquote><a href=\"{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}\">Clic</a> para redefinir sua senha.</blockquote>\n\n<p>Se você tiver problema com o link acima, copie e cole a URL abaixo no seu navegador:</p>\n<blockquote>\n<a href=\"{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}\">{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}</a>\n</blockquote>\n\n<p>Digite o seguinte código se solicitado:</p>\n<blockquote><big>{{.Code}}</big></blockquote>\n\n{{with .Login}}\n<p>Em caso de esquecimento, aqui seu login: {{.}}.</p>\n{{end}}\n\n<p>Se não solicitaste a redefinição da senha, por favor ignorar essa mensagem.</p>\n\n<p><a href=\"https://tinode.co/\">Equipe Tinode</a></p>\n\n</body>\n</html>\n{{- end}}\n\n{{define \"body_plain\" -}}\n\nOlá.\n\nVocê solicitou recentemente a redefinição da sua senha Tinode ({{.HostUrl}}).\nUse o link ou código abaixo para redefini-lo. O link e o código são válidos apenas pelas próximas 24 horas.\n\n   {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}\n\nSi tienes problemas con el enlace superior, copia y pega le siguiente URL en tu navegador.\n\nDigite o seguinte código se solicitado:\n   {{.Code}}\n\n{{- with .Login}}\nEm caso de esquecimento, aqui seu login: {{.}}.\n{{end -}}\n\nSe não solicitaste a redefinição da senha, por favor ignorar essa mensagem..\n\nEquipe Tinode\nhttps://tinode.co/\n\n{{- end}}\n"
  },
  {
    "path": "server/templ/email-password-reset-ru.templ",
    "content": "{{/*\n  RUSSIAN\n\n  Password reset email.\n\n  See explanation in ./email-validation-en.templ\n*/}}\n\n\n{{define \"subject\" -}}\nИзменить пароль Tinode\n{{- end}}\n\n{{define \"body_html\" -}}\n<html>\n<body>\n\n<p>Здравствуйте.</p>\n\n<p>Вы прислали запрос на изменение пароля для вашего аккаунта <a href=\"{{.HostUrl}}\">Tinode</a>.\nИспользуйте ссылку или код ниже, чтобы сбросить его. Ссылка и код действительны только в течение следующих 24 часов.</p>\n\n<blockquote><a href=\"{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=RU\">Кликните</a> для изменения пароля.</blockquote>\n\n<p>Если ссылка по какой-то причине не работает, скопируйте следующий URL и вставьте его в адресную строку браузера:</p>\n<blockquote>\n<a href=\"{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=RU\">{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=RU</a>\n</blockquote>\n\n<p>Пожалуйста, введите следующий код, если потребуется:</p>\n<blockquote><big>{{.Code}}</big></blockquote>\n\n{{with .Login}}\n<p>В случае, если вы забыли, ваш логин: {{.}}.</p>\n{{end}}\n\n<p>Если вы не отправляли запрос на изменение пароля, просто игнорируйте это сообщение.</p>\n\n<p><a href=\"https://tinode.co/\">Команда Tinode</a></p>\n\n</body>\n</html>\n{{- end}}\n\n{{define \"body_plain\" -}}\n\nЗдравствуйте.\n\nВы прислали запрос на изменение пароля для вашего аккаунта Tinode ({{.HostUrl}}).\nИспользуйте ссылку или код ниже, чтобы сбросить его. Ссылка и код действительны только в течение следующих 24 часов.\n\n   {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=RU\n\nЕсли ссылка по какой-то вы не можете кликнуть по ссылке выше, скопируйте ее и вставьте его в адресную строку браузера.\n\nПожалуйста, введите следующий код, если потребуется:\n   {{.Code}}\n\n{{- with .Login}}\nВ случае, если вы забыли, ваш логин: {{.}}.\n{{end -}}\n\nЕсли вы не отправляли запрос на изменение пароля, просто игнорируйте это сообщение.\n\nКоманда Tinode\nhttps://tinode.co/\n\n{{- end}}\n"
  },
  {
    "path": "server/templ/email-password-reset-uk.templ",
    "content": "{{/*\n  UKRAINIAN\n\n  Password reset email.\n\n  See explanation in ./email-validation-en.templ\n*/}}\n\n\n{{define \"subject\" -}}\nЗмінити пароль Tinode\n{{- end}}\n\n{{define \"body_html\" -}}\n<html>\n<body>\n\n<p>Доброго дня.</p>\n\n<p>Ви надіслали запит на зміну пароля для вашого акаунта <a href=\"{{.HostUrl}}\">Tinode</a>.\nЩоб скинути його, скористайтеся посиланням або кодом нижче. Посилання та код дійсні лише протягом наступних 24 годин.</p>\n\n<blockquote><a href=\"{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=UK\">Натисніть</a> для зміни пароля.</blockquote>\n\n<p>Якщо посилання з якоїсь причини не працює, скопіюйте наступну URL-адресу та вставте її в адресний рядок браузера:</p>\n<blockquote>\n<a href=\"{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=UK\">{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=UK</a>\n</blockquote>\n\n<p>Будь ласка, введіть наступний код, якщо потрібно:</p>\n<blockquote><big>{{.Code}}</big></blockquote>\n\n{{with .Login}}\n<p>У випадку, якщо ви забули ваш логін: {{.}}.</p>\n{{end}}\n\n<p>Якщо ви не надсилали запит на зміну пароля, просто ігноруйте це повідомлення.</p>\n\n<p><a href=\"https://tinode.co/\">Команда Tinode</a></p>\n\n</body>\n</html>\n{{- end}}\n\n{{define \"body_plain\" -}}\n\nДоброго дня.\n\nВи надіслали запит на зміну пароля для вашого акаунта Tinode ({{.HostUrl}}).\nЩоб скинути його, скористайтеся посиланням або кодом нижче. Посилання та код дійсні лише протягом наступних 24 годин.\n\n   {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}&hl=UK\n\nЯкщо посилання з якоїсь причини не працює, скопіюйте наступну URL-адресу та вставте її в адресний рядок браузера.\n\nБудь ласка, введіть наступний код, якщо потрібно:\n   {{.Code}}\n\n{{- with .Login}}\nУ випадку, якщо ви забули ваш логін: {{.}}.\n{{end -}}\n\nЯкщо ви не надсилали запит на зміну пароля, просто ігноруйте це повідомлення.\n\nКоманда Tinode\nhttps://tinode.co/\n\n{{- end}}\n"
  },
  {
    "path": "server/templ/email-password-reset-vi.templ",
    "content": "{{/*\n  VIETNAMESE\n\n  This template defines contents of the password reset email.\n\n  See explanation in ./email-validation-en.templ\n*/}}\n\n\n{{define \"subject\" -}}\nĐặt lại mật khẩu Tinode\n{{- end}}\n\n{{define \"body_html\" -}}\n<html>\n<body>\n\n<p>Xin chào.</p>\n\n<p>Bạn vừa gửi yêu cầu đặt lại mật khẩu cho tài khoản <a href=\"{{.HostUrl}}\">Tinode</a> của bạn.\nSử dụng liên kết hoặc mã dưới đây để thiết lập lại nó. Liên kết và mã chỉ có hiệu lực trong 24 giờ tới.</p>\n\n<blockquote><a href=\"{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}\">Bấm</a> để đặt lại mật khẩu.</blockquote>\n\n<p>Nếu bạn gặp vấn đề khi bấm vào liên kết trên, vui lòng sao chép liên kết dưới đây và dán vào trình duyệt của bạn:</p>\n<blockquote>\n<a href=\"{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}\">{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}</a>\n</blockquote>\n\n<p>Vui lòng nhập mã sau nếu được nhắc:</p>\n<blockquote><big>{{.Code}}</big></blockquote>\n\n{{with .Login}}\n<p>Trong trường hợp bạn quên, thì đây là tên đăng nhập của bạn: {{.}}.</p>\n{{end}}\n\n<p>Nếu bạn không yêu cầu cấp lại mật khẩu, Vui lòng bỏ qua tin nhắn này.</p>\n\n<p><a href=\"https://tinode.co/\">Tinode Team</a></p>\n\n</body>\n</html>\n{{- end}}\n\n{{define \"body_plain\" -}}\n\nXin chào.\n\nBạn vừa gửi yêu cầu đặt lại mật khẩu cho tài khoản Tinode ({{.HostUrl}}).\nSử dụng liên kết hoặc mã dưới đây để thiết lập lại nó. Liên kết và mã chỉ có hiệu lực trong 24 giờ tới.\n\n   {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}\n\nNếu bạn gặp vấn đề khi bấm vào liên kết trên, vui lòng sao chép và dán vào trình duyệt của bạn\n\nVui lòng nhập mã sau nếu được nhắc:\n   {{.Code}}\n\n{{- with .Login}}\nTrong trường hợp bạn quên, thì đây là tên đăng nhập của bạn: {{.}}.\n{{end -}}\n\nNếu bạn không yêu cầu cấp lại mật khẩu, Vui lòng bỏ qua tin nhắn này.\n\nTinode Team\nhttps://tinode.co/\n\n{{- end}}\n"
  },
  {
    "path": "server/templ/email-password-reset-zh-TW.templ",
    "content": "{{/*\n  繁體中文\n\n  此模板定義密碼重設電子郵件的內容。\n\n  說明請參見 ./email-validation-en.templ\n*/}}\n\n\n{{define \"subject\" -}}\n重設 Tinode 密碼\n{{- end}}\n\n{{define \"body_html\" -}}\n<html>\n<body>\n\n<p>您好。</p>\n\n<p>您最近請求重設您的 <a href=\"{{.HostUrl}}\">Tinode</a> 帳戶密碼。\n請使用以下連結或驗證碼進行重設。此連結和驗證碼僅在接下來的 24 小時內有效。</p>\n\n<blockquote><a href=\"{{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}\">點擊</a>重設您的密碼。</blockquote>\n\n<p>如果您無法使用上方的連結，請複製以下網址並貼到您的網頁瀏覽器中：</p>\n<blockquote>\n<a href=\"{{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}\">{{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}</a>\n</blockquote>\n\n<p>如有提示，請輸入以下驗證碼：</p>\n<blockquote><big>{{.Code}}</big></blockquote>\n\n{{with .Login}}\n<p>如果您忘記了，您的登入名稱是：{{.}}。</p>\n{{end}}\n\n<p>如果您沒有請求密碼重設，請忽略此訊息。</p>\n\n<p><a href=\"https://tinode.co/\">Tinode 團隊</a></p>\n\n</body>\n</html>\n{{- end}}\n\n{{define \"body_plain\" -}}\n\n您好。\n\n您最近請求重設您的 Tinode 帳戶密碼 ({{.HostUrl}})。\n請使用以下連結或驗證碼進行重設。此連結和驗證碼僅在接下來的 24 小時內有效。\n\n   {{.HostUrl}}#reset?scheme={{.Scheme}}&code={{.Code}}&cred={{.Cred}}\n\n如果您無法點擊上方的連結，請複製並貼到您的網頁瀏覽器中。\n\n如有提示，請輸入以下驗證碼：\n   {{.Code}}\n\n{{- with .Login}}\n如果您忘記了，您的登入名稱是：{{.}}。\n{{end -}}\n\n如果您沒有請求密碼重設，請忽略此訊息。\n\nTinode 團隊\nhttps://tinode.co/\n\n{{- end}}\n"
  },
  {
    "path": "server/templ/email-password-reset-zh.templ",
    "content": "{{/*\n  CHINESE\n\n  定义重置密码文案的模版。\n\n  参阅 ./email-validation-zh.templ\n*/}}\n\n\n{{define \"subject\" -}}\n重置 Tinode 密码\n{{- end}}\n\n{{define \"body_html\" -}}\n<html>\n<body>\n\n<p>您好！</p>\n\n<p>您正在申请重置 <a href=\"{{.HostUrl}}\">Tinode</a> 使用下面的链接或代码重置它。 链接和代码仅在接下来的 24 小时内有效。</p>\n\n<blockquote><a href=\"{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}\">重置密码</a></blockquote>\n\n<p>如果无法点击上面的链接，您可以复制该地址，并粘帖在浏览器的地址栏中访问：</p>\n<blockquote>\n    <a href=\"{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}\">{{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}</a>\n</blockquote>\n\n<p>如果出现提示，请输入以下代码：</p>\n<blockquote><big>{{.Code}}</big></blockquote>\n\n{{with .Login}}\n<p>如果您忘记了，您的登录名是: {{.}}.</p>\n{{end}}\n\n<p>如非您没有申请重置密码，请忽略这条消息。</p>\n\n<p><a href=\"https://tinode.co/\">Tinode 团队</a></p>\n\n</body>\n</html>\n{{- end}}\n\n{{define \"body_plain\" -}}\n\n您好！\n\n您正在申请重置 Tinode ({{.HostUrl}}) 账号密码。\n使用下面的链接或代码重置它。 链接和代码仅在接下来的 24 小时内有效。\n\n   {{.HostUrl}}#reset?scheme={{.Scheme}}&token={{.Token}}\n\n如果无法点击上面的链接，您可以复制该地址，并粘帖在浏览器的地址栏中访问：\n\n如果出现提示，请输入以下代码：\n   {{.Code}}\n\n{{- with .Login}}\n如果您忘记了，您的登录名是: {{.}}.\n{{end -}}\n\n如非您没有申请重置密码，请忽略这条消息。\n\nTinode 团队\nhttps://tinode.co/\n\n{{- end}}\n"
  },
  {
    "path": "server/templ/email-validation-en.templ",
    "content": "{{/*\n  ENGLISH\n\n  This template defines content of the email sent to users as a request to confirm registration email address.\n  See https://golang.org/pkg/text/template/ for syntax.\n\n  The template must contain the following parts parts:\n   - 'subject': Subject line of an email message\n   - One or both of the following:\n     - 'body_html': HTML content of the message. A header \"Content-type: text/html\" will be added.\n     - 'body_plain': plain text content of the message. A header \"Content-type: text/plain\" will be added.\n\n   If both body_html and body_plain are included, both are sent as parts of 'multipart/alternative' message.\n*/}}\n\n{{define \"subject\" -}}\nTinode registration: confirm email\n{{- end}}\n\n{{define \"body_html\" -}}\n<html>\n<body>\n\n<p>Hello.</p>\n\n<p>You're receiving this message because someone used your email to register at\n<a href=\"{{.HostUrl}}\">Tinode</a>.</p>\n\n<p><a href=\"{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}\">Click to confirm</a>\nor go to\n<a href=\"{{.HostUrl}}#cred?what=email\">{{.HostUrl}}#cred?method=email</a>\nand enter the following code:</p>\n<blockquote><big>{{.Code}}</big></blockquote>\n<p>You may need to enter login and password.</p>\n\n<p>If you did not register at Tinode just ignore this message.</p>\n\n<p><a href=\"https://tinode.co/\">Tinode Team</a></p>\n\n</body>\n</html>\n{{- end}}\n\n{{define \"body_plain\" -}}\n\nHello.\n\nYou're receiving this message because someone used your email to register at Tinode ({{.HostUrl}}).\n\nClick the link {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} to confirm or go to {{.HostUrl}}#cred?what=email\nand enter the following code:\n\n\t{{.Code}}\n\nYou may need to enter login and password.\n\nIf you did not register at Tinode just ignore this message.\n\nTinode Team\nhttps://tinode.co/\n\n{{- end}}\n"
  },
  {
    "path": "server/templ/email-validation-es.templ",
    "content": "{{/*\n  SPANISH\n\n  See explanation in ./email-validation-en.templ\n*/}}\n\n{{define \"subject\" -}}\nRegistro Tinode: Correo de confirmación\n{{- end}}\n\n{{define \"body_html\" -}}\n<html>\n<body>\n\n<p>Hola.</p>\n\n<p>Estás recibiendo este correo porque alguien uso tu correo para registrarse en\n<a href=\"{{.HostUrl}}\">Tinode</a>.</p>\n\n<p><a href=\"{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}\">Clic para confirmar</a>\no ve a\n<a href=\"{{.HostUrl}}#cred?what=email\">{{.HostUrl}}#cred?method=email</a>\ne ingresa el siguiente código:</p>\n<blockquote><big>{{.Code}}</big></blockquote>\n<p>Necesitas ingresar tu usuario y contraseña.</p>\n\n<p>Si tú no te registraste en Tinode solo ignora este mensaje.</p>\n\n<p><a href=\"https://tinode.co/\">Equipo de Tinode</a></p>\n\n</body>\n</html>\n{{- end}}\n\n{{define \"body_plain\" -}}\n\nHola.\n\nEstás recibiendo este correo porque alguien uso tu correo para registrarse en Tinode ({{.HostUrl}}).\n\nDa clic en el enlace {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} para confirmar o ve a {{.HostUrl}}#cred?what=email\ne ingresa el siguiente código:\n\n\t{{.Code}}\n\nNecesitas ingresar tu usuario y contraseña.\n\nSi tú no te registraste en Tinode solo ignora este mensaje.\n\nEquipo de Tinode\nhttps://tinode.co/\n\n{{- end}}\n"
  },
  {
    "path": "server/templ/email-validation-fr.templ",
    "content": "{{/*\n  FRENCH\n\n  See explanation in ./email-validation-en.templ\n*/}}\n\n{{define \"subject\" -}}\nTinode enregistrement : confirmer l'adresse e-mail\n{{- end}}\n\n{{define \"body_html\" -}}\n<html>\n<body>\n\n<p>Bonjour.</p>\n\n<p>Vous recevez ce message car quelqu'un a utilisé votre adresse électronique pour s'inscrire sur le site\n<a href=\"{{.HostUrl}}\">Tinode</a>.</p>\n\n<p><a href=\"{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}\">Cliquer ici pour confimer</a>\nou rendez-vous à l'adresse\n<a href=\"{{.HostUrl}}#cred?what=email\">{{.HostUrl}}#cred?method=email</a>\net entrez le code suivant :</p>\n<blockquote><big>{{.Code}}</big></blockquote>\n<p>Vous devrez peut-être entrer un login et un mot de passe.</p>\n\n<p>Si vous ne vous êtes pas inscrit à Tinode, ignorez ce message.</p>\n\n<p><a href=\"https://tinode.co/\">L'équipe Tinode</a></p>\n\n</body>\n</html>\n{{- end}}\n\n{{define \"body_plain\" -}}\n\nBonjour.\n\nVous recevez ce message car quelqu'un a utilisé votre adresse électronique pour s'inscrire sur le site Tinode ({{.HostUrl}}).\n\nCliquer sur le lien {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} pour confimer ou rendez-vous à l'adresse {{.HostUrl}}#cred?what=email\net entrez le code suivant :\n\n\t{{.Code}}\n\nVous devrez peut-être entrer un login et un mot de passe.\n\nSi vous ne vous êtes pas inscrit à Tinode, ignorez ce message.\n\nL'équipe Tinode\nhttps://tinode.co/\n\n{{- end}}\n"
  },
  {
    "path": "server/templ/email-validation-pt.templ",
    "content": "{{/*\n  PORTUGUESE\n\n  See explanation in ./email-validation-en.templ\n*/}}\n\n{{define \"subject\" -}}\nRegistro Tinode: E-mail de confirmação\n{{- end}}\n\n{{define \"body_html\" -}}\n<html>\n<body>\n\n<p>Olá.</p>\n\n<p> Você está recebendo esse e-mail porque alguém usou seu e-mail para registrar-se em\n<a href=\"{{.HostUrl}}\">Tinode</a>.</p>\n\n<p><a href=\"{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}\">Clique para confirmar</a>\nou acesse\n<a href=\"{{.HostUrl}}#cred?what=email\">{{.HostUrl}}#cred?method=email</a>\ne entre com o seguinte código:</p>\n<blockquote><big>{{.Code}}</big></blockquote>\n<p>Necessário entrar com login e senha.</p>\n\n<p>Se você não se registrou em Tinode apenas ignore essa mensagem.</p>\n\n<p><a href=\"https://tinode.co/\">Equipe Tinode</a></p>\n\n</body>\n</html>\n{{- end}}\n\n{{define \"body_plain\" -}}\n\nOlá.\n\nVocê está recebendo este e-mail porque alguém usou seu e-mail para registrar-se em Tinode ({{.HostUrl}}).\n\nClique no link {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} para confirmar {{.HostUrl}}#cred?what=email\ne entre com o seguinte código:\n\n\t{{.Code}}\n\nNecessário entrar com login e senha.\n\nSe você não se registrou em Tinode apenas ignore essa mensagem..\n\nEquipe Tinode\nhttps://tinode.co/\n\n{{- end}}\n"
  },
  {
    "path": "server/templ/email-validation-ru.templ",
    "content": "{{/*\n  RUSSIAN\n\n  See explanation in ./email-validation-en.templ\n*/}}\n\n{{define \"subject\" -}}\nРегистрация Tinode: подтвердите емейл\n{{- end}}\n\n{{define \"body_html\" -}}\n<html>\n<body>\n\n<p>Здравствуйте.</p>\n\n<p>Вы получили это сообщение потому, что зарегистрировались в <a href=\"{{.HostUrl}}\">Tinode</a>.</p>\n\n<p><a href=\"{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}&hl=RU\">Кликните здесь чтобы подтвердить</a>\nрегистрацию или перейдите по сслыке\n<a href=\"{{.HostUrl}}#cred?what=email\">{{.HostUrl}}#cred?method=email&hl=RU</a>\nи введите следующий код:</p>\n<blockquote><big>{{.Code}}</big></blockquote>\n<p>Возможно, вам потребуется ввести логин и пароль.</p>\n\n<p>Если вы не регистрировались в Tinode, просто игнорируйте это сообщение.</p>\n\n<p><a href=\"https://tinode.co/\">Команда Tinode</a></p>\n\n</body>\n</html>\n{{- end}}\n\n{{define \"body_plain\" -}}\n\nЗдравствуйте.\n\nВы получили это сообщение потому, что зарегистрировались в Tinode ({{.HostUrl}}).\n\nКликните на {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}&hl=RU чтобы подтвердить\nрегистрацию или перейдите по сслыке {{.HostUrl}}#cred?what=email\">{{.HostUrl}}#cred?method=email&hl=RU\nи введите следующий код:\n\n\t{{.Code}}\n\nВозможно, вам также потребуется ввести логин и пароль.\n\nЕсли вы не регистрировались в Tinode, просто игнорируйте это сообщение.\n\nКоманда Tinode\nhttps://tinode.co/\n\n{{- end}}\n"
  },
  {
    "path": "server/templ/email-validation-uk.templ",
    "content": "{{/*\n  UKRAINIAN\n\n  See explanation in ./email-validation-en.templ\n*/}}\n\n{{define \"subject\" -}}\nРеєстрація Tinode: підтвердіть емейл\n{{- end}}\n\n{{define \"body_html\" -}}\n<html>\n<body>\n\n<p>Доброго дня.</p>\n\n<p>Ви отримали це повідомлення тому, що зареєструвалися в <a href=\"{{.HostUrl}}\">Tinode</a>.</p>\n\n<p><a href=\"{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}&hl=UK\">Натисніть тут, щоб підтвердити</a>\nреєстрацію або перейдіть за посиланням\n<a href=\"{{.HostUrl}}#cred?what=email\">{{.HostUrl}}#cred?method=email&hl=UK</a>\nта введіть наступний код:</p>\n<blockquote><big>{{.Code}}</big></blockquote>\n<p>Можливо, вам потрібно буде ввести логін та пароль.</p>\n\n<p>Якщо ви не реєструвалися в Tinode, просто ігноруйте це повідомлення.</p>\n\n<p><a href=\"https://tinode.co/\">Команда Tinode</a></p>\n\n</body>\n</html>\n{{- end}}\n\n{{define \"body_plain\" -}}\n\nДоброго дня.\n\nВи отримали це повідомлення тому, що зареєструвалися в Tinode ({{.HostUrl}}).\n\nНатисніть на {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}&hl=UK щоб підтвердити\nреєстрацію або перейдіть за посиланням {{.HostUrl}}#cred?what=email\">{{.HostUrl}}#cred?method=email&hl=UK\nта введіть наступний код:\n\n\t{{.Code}}\n\nМожливо, вам потрібно буде ввести логін та пароль.\n\nЯкщо ви не реєструвалися в Tinode, просто ігноруйте це повідомлення.\n\nКоманда Tinode\nhttps://tinode.co/\n\n{{- end}}\n"
  },
  {
    "path": "server/templ/email-validation-vi.templ",
    "content": "{{/*\n  VIETNAMESE\n\n  See explanation in ./email-validation-en.templ\n*/}}\n\n{{define \"subject\" -}}\nXác thực đăng ký tài khoản Tinode\n{{- end}}\n\n{{define \"body_html\" -}}\n<html>\n<body>\n\n<p>Xin chào.</p>\n\n<p>Bạn nhận được tin nhắn này bởi vì có ai đó đã dùng email này để đăng ký tài khoản tại\n<a href=\"{{.HostUrl}}\">Tinode</a>.</p>\n\n<p><a href=\"{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}\">Bấm để xác nhận</a>\nhoặc đi tới liên kết\n<a href=\"{{.HostUrl}}#cred?what=email\">{{.HostUrl}}#cred?method=email</a>\nvà nhập mã xác thực:</p>\n<blockquote><big>{{.Code}}</big></blockquote>\n<p>Có thể bạn cần nhập tên đăng nhập và mật khẩu.</p>\n\n<p>Nếu bạn không đăng ký tài khoản tại Tinode vui lòng bỏ qua tin nhắn này.</p>\n\n<p><a href=\"https://tinode.co/\">Tinode Team</a></p>\n\n</body>\n</html>\n{{- end}}\n\n{{define \"body_plain\" -}}\n\nXin chào.\n\nBạn nhận được tin nhắn này bởi vì có ai đó đã dùng email này để đăng ký tài khoản tại Tinode ({{.HostUrl}}).\n\nBấm vào liên kết {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} để xác nhận hoặc đi tới {{.HostUrl}}#cred?what=email\nvà nhập mã xác thực\n\n\t{{.Code}}\n\nCó thể bạn sẽ cần nhập tên đăng nhập và mật khẩu.\n\nNếu bạn không đăng ký tài khoản tại Tinode vui lòng bỏ qua tin nhắn này.\n\nTinode Team\nhttps://tinode.co/\n\n{{- end}}\n"
  },
  {
    "path": "server/templ/email-validation-zh-TW.templ",
    "content": "{{/*\n  CHINESE Traditional (Taiwan)\n  繁體中文\n\n  此模板定義發送給用戶的電子郵件內容，用於請求確認註冊電子郵件地址。\n  語法請參見 https://golang.org/pkg/text/template/\n\n  模板必須包含以下部分：\n   - 'subject': 電子郵件的主題行\n   - 以下一個或兩個：\n     - 'body_html': 訊息的 HTML 內容。將添加標頭 \"Content-type: text/html\"。\n     - 'body_plain': 訊息的純文字內容。將添加標頭 \"Content-type: text/plain\"。\n\n   如果同時包含 body_html 和 body_plain，兩者都將作為 'multipart/alternative' 訊息的部分發送。\n*/}}\n\n{{define \"subject\" -}}\nTinode 註冊：確認電子郵件\n{{- end}}\n\n{{define \"body_html\" -}}\n<html>\n<body>\n\n<p>您好。</p>\n\n<p>您收到此訊息是因為有人使用您的電子郵件在\n<a href=\"{{.HostUrl}}\">Tinode</a> 註冊。</p>\n\n<p><a href=\"{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}\">點擊確認</a>\n或前往\n<a href=\"{{.HostUrl}}#cred?what=email\">{{.HostUrl}}#cred?method=email</a>\n並輸入以下驗證碼：</p>\n<blockquote><big>{{.Code}}</big></blockquote>\n<p>您可能需要輸入登入名稱和密碼。</p>\n\n<p>如果您沒有在 Tinode 註冊，請忽略此訊息。</p>\n\n<p><a href=\"https://tinode.co/\">Tinode 團隊</a></p>\n\n</body>\n</html>\n{{- end}}\n\n{{define \"body_plain\" -}}\n\n您好。\n\n您收到此訊息是因為有人使用您的電子郵件在 Tinode ({{.HostUrl}}) 註冊。\n\n點擊連結 {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} 進行確認，或前往 {{.HostUrl}}#cred?what=email\n並輸入以下驗證碼：\n\n\t{{.Code}}\n\n您可能需要輸入登入名稱和密碼。\n\n如果您沒有在 Tinode 註冊，請忽略此訊息。\n\nTinode 團隊\nhttps://tinode.co/\n\n{{- end}}\n"
  },
  {
    "path": "server/templ/email-validation-zh.templ",
    "content": "{{/*\n  CHINESE\n\n  定义用户注册邮件确认文案的模版。\n  语法参阅 https://golang.org/pkg/text/template/ 。\n\n  模版必须包含以下内容:\n   - 'subject'：邮件主题\n   - 以下一项或两项：\n     - 'body_html': 包含请求头\"Content-type: text/html\"的HTML格式消息内容。\n     - 'body_plain': 包含请求头\"Content-type: text/plain\"的文本格式消息内容。\n\n   如果同时包含 body_html 和 body_plain，则都作为 'multipart/alternative' 消息的一部分发送。\n*/}}\n\n{{define \"subject\" -}}\nTinode 注册: 确认邮件\n{{- end}}\n\n{{define \"body_html\" -}}\n<html>\n<body>\n\n<p>您好！</p>\n\n<p>您收到此消息是因为您注册了<a href=\"{{.HostUrl}}\">Tinode</a>。</p>\n\n<p><a href=\"{{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}}\">确认注册</a>\n或者跳转至链接\n<a href=\"{{.HostUrl}}#cred?what=email\">{{.HostUrl}}#cred?method=email</a>\n并输入验证码：</p>\n<blockquote><big>{{.Code}}</big></blockquote>\n<p>您可能需要输入登录名和密码。</p>\n\n<p>如果您没有注册Tinode，请忽略这条消息。</p>\n\n<p><a href=\"https://tinode.co/\">Tinode 团队</a></p>\n\n</body>\n</html>\n{{- end}}\n\n{{define \"body_plain\" -}}\n\n您好！\n\n您收到此消息是因为您注册了 Tinode ({{.HostUrl}})。\n\n点击链接 {{.HostUrl}}#cred?method=email&code={{.Code}}&token={{.Token}} 确认注册或者跳转至链接 {{.HostUrl}}#cred?what=email\n并输入验证码：\n\n\t{{.Code}}\n\n您可能需要输入登录名和密码。\n\n如果您没有注册Tinode，请忽略这条消息。\n\nTinode 团队\nhttps://tinode.co/\n\n{{- end}}\n"
  },
  {
    "path": "server/templ/sms-universal-en.templ",
    "content": "{{/*\n  ENGLISH\n\n  Universal confirmation and password reset template for SMS.\n*/}}\n\nTinode confirmation code: {{.Code}}\n{{.HostUrl}}\n"
  },
  {
    "path": "server/templ/sms-universal-es.templ",
    "content": "{{/*\n  SPANISH\n\n  Universal confirmation and password reset template for SMS.\n*/}}\n\nCódigo de confirmación de Tinode: {{.Code}}\n{{.HostUrl}}\n"
  },
  {
    "path": "server/templ/sms-universal-fr.templ",
    "content": "{{/*\n   FRENCH\n\n   Modèle universel de confirmation et de réinitialisation du mot de passe pour SMS.\n*/}}\n\nCode de confirmation Tinode : {{.Code}}\n{{.HostUrl}}\n"
  },
  {
    "path": "server/templ/sms-universal-pt.templ",
    "content": "{{/*\n   PORTUGESE\n\n   Modelo universal de confirmação e redefinição de senha para SMS.\n*/}}\n\nCódigo de confirmação Tinode: {{.Code}}\n{{.HostUrl}}\n"
  },
  {
    "path": "server/templ/sms-universal-ru.templ",
    "content": "{{/*\n   RUSSIAN\n\n   Универсальный шаблон подтверждения и сброса пароля для СМС.\n*/}}\n\nКод подтверждения Tinode: {{.Code}}\n{{.HostUrl}}\n"
  },
  {
    "path": "server/templ/sms-universal-uk.templ",
    "content": "{{/*\n   UKRAINIAN\n\n   Універсальний шаблон підтвердження та скидання пароля для СМС.\n*/}}\n\nКод підтвердження Tinode: {{.Code}}\n{{.HostUrl}}\n"
  },
  {
    "path": "server/templ/sms-universal-vi.templ",
    "content": "{{/*\n  VIETNAMESE\n\n  Universal confirmation and password reset template for SMS.\n*/}}\n\nMã xác thực từ Tinode: {{.Code}}\n{{.HostUrl}}\n"
  },
  {
    "path": "server/templ/sms-universal-zh-TW.templ",
    "content": "{{/*\n  繁體中文\n\n  通用確認和密碼重設簡訊模板。\n*/}}\n\nTinode 確認驗證碼：{{.Code}}\n{{.HostUrl}}\n"
  },
  {
    "path": "server/templ/sms-universal-zh.templ",
    "content": "{{/*\n  CHINESE\n\n  Universal confirmation and password reset template for SMS.\n*/}}\n\n【Tinode】验证码： {{.Code}}\n{{.HostUrl}}\n"
  },
  {
    "path": "server/tinode.conf",
    "content": "// The JSON comments are somewhat brittle. Don't try anything too fancy.\n{\n\t// HTTP(S) address to listen on for websocket and long polling clients. Either a TCP host:port pair\n\t// or a path to Unix socket as \"unix:/path/to/socket.sock\".\n\t// The TCP port is either a numerical value or a canonical name, e.g. \":80\" or \":https\". May include\n\t// the host name, e.g. \"localhost:80\" or \"hostname.example.com:https\".\n\t// It could be blank: if TLS is not configured it will default to \":80\", otherwise to \":443\".\n\t// Can be overridden from the command line, see option --listen.\n\t\"listen\": \":6060\",\n\n\t// Base URL path for serving streaming and large file API calls.\n\t// Can be overridden from the command line, see option --api_path.\n\t\"api_path\": \"/\",\n\n\t// Cache-Control header for static content in seconds. 39600 is 11 hours.\n\t\"cache_control\": 39600,\n\n\t// If true, do not attempt to negotiate websocket per message compression (RFC 7692.4).\n\t// It should be disabled (set to true) if you are using MSFT IIS as a reverse proxy.\n\t\"ws_compression_disabled\": false,\n\n\t// URL path for mounting the directory with static files.\n\t\"static_mount\": \"/\",\n\n\t// TCP host:port or unix:/path/to/socket to listen for gRPC clients.\n\t// Leave blank to disable gRPC support.\n\t// Could be overridden from the command line with --grpc_listen.\n\t\"grpc_listen\": \":16060\",\n\n\t// Enable handling of gRPC keepalives https://github.com/grpc/grpc/blob/master/doc/keepalive.md\n\t// This sets server's GRPC_ARG_KEEPALIVE_TIME_MS to 60 seconds instead of the default 2 hours.\n\t\"grpc_keepalive_enabled\": true,\n\n\t// Salt for signing API key. 32 random bytes base64-encoded. Use 'keygen' tool (included in this\n\t// distro) to generate the API key and the salt.\n\t\"api_key_salt\": \"T713/rYYgW7g4m3vG6zGRh7+FM1t0T8j13koXScOAj4=\",\n\n\t// Maximum message size allowed from the clients in bytes (131072 = 128KB).\n\t// Media files with sizes greater than this limit are sent out of band.\n\t// Don't change this limit to a much higher value because it would likely cause failures:\n\t// * on Android & iOS due to a limit on the SQLite cursor window size;\n\t// * on the server-side with MySQL adapter due to the limit on the sort buffer size.\n\t\"max_message_size\": 131072,\n\n\t// Maximum number of subscribers per group topic.\n\t// This is the max number of subscribers to a group topic who may be configured to have specific access\n\t// permissions, like writing (posting), admin permissions, etc.\n\t// This setting is unrelated to channels readers (those have immutable read-only access). Channels have no\n\t// limit on readers.\n\t\"max_subscriber_count\": 128,\n\n\t// Maximum number of indexable tags per topic or user.\n\t\"max_tag_count\": 16,\n\n\t// If true, ordinary users cannot delete their accounts.\n\t\"permanent_accounts\": false,\n\n\t// URL path for exposing runtime stats. Disabled if the path is blank or \"-\".\n\t// Could be overriden from the command line with --expvar.\n\t\"expvar\": \"/debug/vars\",\n\n\t// URL path for server's internal status, useful when debugging.\n\t// Do not use this URL for docker status checks and some such. It's not a health check,\n\t// it is a debug endpoint. Disabled if the path is blank or \"-\". Could be overriden\n\t// from the command line with --server_status.\n\t// \"server_status\": \"/debug/status\",\n\n\t// Read IP address of the client from the HTTP header 'X-Forwarded-For'.\n\t// Useful when Tinode is behind a proxy. If missing, fallback to default RemoteAddr.\n\t\"use_x_forwarded_for\": true,\n\n\t// Add X-Frame-Options to HTTP response headers. It should be one of \"DENY\", \"SAMEORIGIN\",\n\t// \"-\" (disabled). If the option is missing then it's treated as SAMEORIGIN.\n\t\"x_frame_options\": \"SAMEORIGIN\",\n\n\t// 2-letter country code to assign to sessions by default when the country isn't specified\n\t// by the client explicitly and it's impossible to infer it.\n\t// If missing, the server will default to \"US\".\n\t\"default_country_code\": \"\",\n\n\t// Permit hard-deleting messages in p2p topics for both participants.\n\t// If it's set to 'false' then the message is only deleted for the peer who issued the command.\n\t// If it's 'true' then the message is deleted completely by either participant.\n\t// Changing the value affects the ability to hard-delete (the added or removed the D permission)\n\t// only for new topics going forward.\n\t\"p2p_delete_enabled\": true,\n\n\t// The maximum age of a message in seconds when it can be deleted by users with the 'D' permission.\n\t// E.g. 600 means messages up to 10 minutes old can be deleted, older than that cannot be deleted.\n\t// Missing or 0 means no age limit.\n\t// Does not affect topic owners: owners can delete any message.\n\t\"msg_delete_age\": 600,\n\n\t// Globally unique namespace. This is a special tag namespace which is used to store\n\t// aliases of the user. The alias is a tag which is not a valid Tinode user ID.\n\t\"alias_tag\": \"alias\",\n\n\t// Large media/blob handlers: large files/images included in messages.\n\t\"media\": {\n\t\t// The name of the media handler to use.\n\t\t\"use_handler\": \"fs\",\n\t\t// Maximum size of uploaded file (8MB here for testing, maybe increase to 100MB = 104857600 in prod)\n\t\t\"max_size\": 8388608,\n\t\t// Garbage collection periodicity in seconds: unused or abandoned uploads are deleted.\n\t\t\"gc_period\": 60,\n\t\t// The number of unused/abandoned entries to delete in one pass.\n\t\t\"gc_block_size\": 100,\n\t\t// Configurations of individual handlers.\n\t\t\"handlers\": {\n\t\t\t// File system storage.\n\t\t\t\"fs\": {\n\t\t\t\t// File system location to store uploaded files. In case of a cluster it\n\t\t\t\t// must be accessible by all cluster members, i.e. a network drive like https://www.samba.org/\n\t\t\t\t\"upload_dir\": \"uploads\",\n\t\t\t\t// Cache-Control header to use for uploaded files. 86400 seconds = 24 hours.\n\t\t\t\t\"cache_control\": \"max-age=86400\",\n\t\t\t\t// Origin URLs allowed to download/upload files, e.g. [\"https://www.example.com\", \"http://example.com\", \"https://*.example.com\", \"http://*.*.example.com\"].\n\t\t\t\t// Not necessary in most cases.\n\t\t\t\t// \"cors_origins\": [\"*\"]\n\t\t\t},\n\t\t\t// Amazon AWS S3 storage.\n\t\t\t// See detailed explanation at https://pkg.go.dev/github.com/aws/aws-sdk-go/aws#Config\n\t\t\t\"s3\":{\n\t\t\t\t// Use AWS console to get Access Key ID and Secret Access Key.\n\t\t\t\t// https://aws.amazon.com/blogs/security/wheres-my-secret-access-key/\n\t\t\t\t\"access_key_id\": \"your_s3_access_key_id\",\n\t\t\t\t\"secret_access_key\": \"your_s3_secret_access_key\",\n\t\t\t\t// Region where the bucket is hosted.\n\t\t\t\t\"region\": \"s3 region, like us-east-2\",\n\t\t\t\t// Name of the S3 bucket.\n\t\t\t\t\"bucket\": \"your_s3_bucket_name\",\n\t\t\t\t// Set this to `true` to disable SSL when sending requests. Defaults to `false`.\n\t\t\t\t\"disable_ssl\": false,\n\t\t\t\t// Set this to `true` to force the request to use path-style addressing,\n\t\t\t\t// i.e., `http://s3.amazonaws.com/BUCKET/KEY`. By default, the S3 client\n\t\t\t\t// will use virtual hosted bucket addressing when possible\n\t\t\t\t// (`http://BUCKET.s3.amazonaws.com/KEY`).\n\t\t\t\t\"force_path_style\": false,\n\t\t\t\t// An optional endpoint URL (hostname only or fully qualified URI)\n\t\t\t\t// to override the default generated endpoint, or `\"\"` to use the default generated endpoint.\n\t\t\t\t// The endpoint can be of any S3-compatible service, such as \"minio-api.x.io\".\n\t\t\t\t\"endpoint\": \"\",\n\t\t\t\t// Expiration time for presigned URLs in seconds.\n\t\t\t\t\"presign_ttl\": 3600,\n\t\t\t\t// Cache-Control header to use for uploaded files. 86400 seconds = 24 hours.\n\t\t\t\t\"cache_control\": \"max-age=86400\",\n\t\t\t\t// Origin URLs allowed to download files, e.g. [\"https://www.example.com\", \"http://example.com\"].\n\t\t\t\t// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin\n\t\t\t\t\"cors_origins\": [\"*\"]\n\t\t\t}\n\t\t}\n\t},\n\n\t// TLS (httpS) configuration. Applies to both web and gRPC interfaces.\n\t\"tls\": {\n\t\t// Enable TLS.\n\t\t\"enabled\": false,\n\n\t\t// Listen for connections on this port and redirect them to HTTPS port.\n\t\t// Cannot be a Unix socket.\n\t\t\"http_redirect\": \":80\",\n\n\t\t// Add Strict-Transport-Security to headers, the value signifies age.\n\t\t// Zero or negative value turns it off.\n\t\t\"strict_max_age\": 604800,\n\n\t\t// Letsencrypt configuration.\n\t\t\"autocert\": {\n\t\t\t// Location of certificates.\n\t\t\t\"cache\": \"/etc/letsencrypt/live/your.domain.here\",\n\n\t\t\t// Contact address for this installation. LetsEncrypt will send\n\t\t\t// messages to this address in case of problems. Replace with your\n\t\t\t// own address or remove this line.\n\t\t\t\"email\": \"noreply@example.com\",\n\n\t\t\t// Domains served. Replace with your own domain name.\n\t\t\t\"domains\": [\"whatever.example.com\"]\n\t\t},\n\n\t\t// If \"autocert\" config is not defined, read static certificates from\n\t\t// these locations. Ignored if \"autocert\" is defined.\n\t\t\"cert_file\": \"/etc/httpd/conf/your.domain.crt\",\n\t\t\"key_file\": \"/etc/httpd/conf/your.domain.key\"\n\t},\n\n\t// Authentication configuration.\n\t\"auth_config\": {\n\t\t// Optional mapping of externally-visible authenticator names to internal names.\n\t\t// For example use [\"my-auth:basic\", \"basic:\"] to rename \"basic\" authenticator to\n\t\t// \"my-auth\" and make \"basic\" unaccessible by the old name. If you want to use REST-auth, then\n\t\t// the config is [\"basic:rest\", \"rest:\"].\n\t\t// Default is identity mapping.\n\t\t\"logical_names\": [],\n\n\t\t// Basic (login + password) authentication.\n\t\t\"basic\": {\n\t\t\t// Add 'auth-name:username' to tags making user discoverable by username.\n\t\t\t\"add_to_tags\": true,\n\t\t\t// The minimum length of a login in unicode runes, i.e. \"登录\" is length 2, not 6.\n\t\t\t// The maximum length is 32 and it cannot be changed.\n\t\t\t\"min_login_length\": 4,\n\t\t\t// The minimum length of a password in unicode runes, \"пароль\" is length 6, not 12.\n\t\t\t// There is no limit on maximum length, but MySQL & PgSQL adapters have a limit of 32 bytes.\n\t\t\t\"min_password_length\": 6\n\t\t},\n\n\t\t// Token authentication\n\t\t\"token\": {\n\t\t\t// Lifetime of a security token in seconds. 1209600 = 2 weeks.\n\t\t\t\"expire_in\": 1209600,\n\n\t\t\t// Serial number of the token. Can be used to invalidate all issued tokens at once.\n\t\t\t\"serial_num\": 1,\n\n\t\t\t// Secret key (HMAC salt) for signing the tokens. Generate your own then keep it secret.\n\t\t\t// Any 32 random bytes base64 encoded.\n\t\t\t//\n\t\t\t// === IMPORTANT ===\n\t\t\t//\n\t\t\t// CHANGE IT IN PRODUCTION!!! Otherwise anyone will be able to log in\n\t\t\t// to your server without the password. It's just random base64-encoded bytes, use any suitable\n\t\t\t// means to get it. For example:\n\t\t\t// Linux/Mac:\n\t\t\t//    echo $(head -c 32 /dev/urandom | base64 | tr -d '\\n')\n\t\t\t// Windows:\n\t\t\t//    powershell -command \"[Convert]::ToBase64String((1..32|%{[byte](Get-Random -Max 256)}))\"\n\t\t\t\"key\": \"wfaY2RgF2S1OQI/ZlK+LSrp1KB2jwAdGAIHQ7JZn+Kc=\"\n\t\t},\n\n\t\t// Short code authenticator for resetting passwords.\n\t\t\"code\": {\n\t\t\t// Lifetime of a security code in seconds. 900 seconds = 15 minutes.\n\t\t\t\"expire_in\": 900,\n\n\t\t\t// Number of times a user can try to enter the code.\n\t\t\t\"max_retries\": 3,\n\n\t\t\t// Length of the secret code.\n\t\t\t\"code_length\": 6\n\t\t}\n\t},\n\n\t// Database configuration\n\t\"store_config\": {\n\t\t// XTEA encryption key for user IDs and topic names. 16 random bytes base64-encoded.\n\t\t// Generate your own and keep it secret. Otherwise your user IDs will be predictable\n\t\t// and it will be easy to spam your users.\n\t\t\"uid_key\": \"la6YsO+bNX/+XIkOqc5Svw==\",\n\n\t\t// Maximum number of results fetched in one DB call.\n\t\t\"max_results\": 1024,\n\n\t\t// DB adapter name to communicate with the DB backend.\n\t\t// Must be one of the adapters from the list below.\n\t\t\"use_adapter\": \"\",\n\n\t\t// Configurations of individual adapters.\n\t\t\"adapters\": {\n\t\t\t// PostgreSQL configuration. See https://godoc.org/github.com/jackc/pgx#Config\n\t\t\t// for other possible options.\n\t\t\t\"postgres\": {\n\t\t\t\t// PostgreSQL connection settings.\n\t\t\t\t// Don't change the username before reading the FAQ!\n\t\t\t\t\"User\": \"postgres\",\n\t\t\t\t\"Passwd\": \"postgres\",\n\t\t\t\t\"Host\": \"localhost\",\n\t\t\t\t\"Port\": \"5432\",\n\t\t\t\t\"DBName\": \"tinode\",\n\t\t\t\t\"SSLMode\": \"disable\",\n\n\t\t\t\t// DSN: alternative way of specifying database configuration, passed unchanged\n\t\t\t\t// to the driver. See https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING\n\t\t\t\t// \"dsn\": \"postgresql://postgres:postgres@localhost:5432/tinode?sslmode=disable&connect_timeout=10\",\n\n\t\t\t\t// PostgreSQL connection pool settings.\n\t\t\t\t// Maximum number of open connections to the database. Zero means unlimited.\n\t\t\t\t\"max_open_conns\": 50,\n\t\t\t\t// Maximum number of connections in the idle connection pool. Zero means no idle connections are retained.\n\t\t\t\t\"max_idle_conns\": 50,\n\t\t\t\t// Maximum amount of time a connection may be reused. Zero means unlimited.\n\t\t\t\t\"conn_max_lifetime\": 60,\n\t\t\t\t// Maximum amount of time waiting for a connection from the pool. Zero means no timeout.\n\t\t\t\t\"sql_timeout\": 10\n\t\t\t},\n\n\n\t\t\t// MySQL configuration. See https://godoc.org/github.com/go-sql-driver/mysql#Config\n\t\t\t// for other possible options.\n\t\t\t\"mysql\": {\n\t\t\t\t// MySQL connection settings.\n\t\t\t\t// See https://pkg.go.dev/github.com/go-sql-driver/mysql#Config for more info\n\t\t\t\t// and available fields and options.\n\t\t\t\t\"User\": \"root\",\n\t\t\t\t\"Net\": \"tcp\",\n\t\t\t\t\"Addr\": \"localhost\",\n\t\t\t\t\"DBName\": \"tinode\",\n\t\t\t\t// The 'collation=utf8mb4_0900_ai_ci' is default in MySQL 8.0 and above. It is optional but highly\n\t\t\t\t// recommended for emoji and certain CJK characters in earlier versions of MySQL.\n\t\t\t\t\"Collation\": \"utf8mb4_0900_ai_ci\",\n\t\t\t\t// Parse time values to time.Time. Required.\n\t\t\t\t\"ParseTime\": true,\n\n\t\t\t\t// DSN: alternative way of specifying database configuration, passed unchanged\n\t\t\t\t// to MySQL driver. See https://github.com/go-sql-driver/mysql#dsn-data-source-name for syntax.\n\t\t\t\t// DSN may optionally start with mysql://\n\t\t\t\t// \"dsn\": \"root@tcp(localhost)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci\",\n\n\t\t\t\t// MySQL connection pool settings.\n\t\t\t\t// Maximum number of open connections to the database. Default: 0 (unlimited).\n\t\t\t\t\"max_open_conns\": 64,\n\t\t\t\t// Maximum number of connections in the idle connection pool. If negative or zero,\n\t\t\t\t// no idle connections are retained.\n\t\t\t\t\"max_idle_conns\": 64,\n\t\t\t\t// Maximum amount of time a connection may be reused (in seconds).\n\t\t\t\t\"conn_max_lifetime\": 60,\n\n\t\t\t\t// DB request timeout (in seconds).\n\t\t\t\t// If not set (or <= 0), DB queries and transactions will run without a timeout.\n\t\t\t\t\"sql_timeout\": 10\n\t\t\t},\n\n\t\t\t// RethinkDB configuration. See\n\t\t\t// https://godoc.org/github.com/rethinkdb/rethinkdb-go#ConnectOpts for other possible\n\t\t\t// options.\n\t\t\t\"rethinkdb\": {\n\t\t\t\t// Address(es) of RethinkDB node(s): either a string or an array of strings.\n\t\t\t\t\"addresses\": \"localhost:28015\",\n\t\t\t\t// Name of the main database.\n\t\t\t\t\"database\": \"tinode\"\n\t\t\t},\n\n\t\t\t// MongoDB configuration.\n\t\t\t\"mongodb\": {\n\t\t\t\t// Connection string https://www.mongodb.com/docs/manual/reference/connection-string/\n\t\t\t\t// Options configured with the 'uri' connection string override all other options\n\t\t\t\t// (only 'uri' is sent to the server, all other options are ignored).\n\t\t\t\t// If you are using Atlas, then you MUST use 'uri' to connect. See here:\n\t\t\t\t// https://www.mongodb.com/docs/manual/reference/connection-string/#std-label-connections-dns-seedlist\n\t\t\t\t// Something like\n\t\t\t\t// \"uri\": \"mongodb+srv://CREDENTIALS@PROJECT.gmuaq.mongodb.net/DATABASE?retryWrites=true&w=majority\",\n\t\t\t\t\"uri\": \"\",\n\t\t\t\t// The only supported server API version is \"1\". May or maynot be needed depending on server version.\n\t\t\t\t\"api_version\": \"\",\n\n\t\t\t\t// Address(es) of MongoDB node(s): either a string or an array of strings.\n\t\t\t\t\"addresses\": \"localhost:27017\",\n\t\t\t\t// Name of the main database.\n\t\t\t\t\"database\": \"tinode\",\n\t\t\t\t// Name of replica set of mongodb instance. Remove this line to use a standalone instance.\n\t\t\t\t// If replica_set is disabled, transactions will be disabled as well.\n\t\t\t\t\"replica_set\": \"rs0\",\n\n\t\t\t\t// Authentication options. Uncomment if auth is configured on your MongoDB.\n\n\t\t\t\t// Authentication mechanism. See https://www.mongodb.com/docs/manual/core/authentication/\n\t\t\t\t// Default \"SCRAM-SHA-256\"\n\t\t\t\t// \"auth_mechanism\": \"SCRAM-SHA-256\",\n\n\t\t\t\t// The name of database that has the collection with the user credentials. Default \"admin\".\n\t\t\t\t// \"auth_source\": \"admin\",\n\n\t\t\t\t// Username:\n\t\t\t\t// \"username\": \"tinode\",\n\n\t\t\t\t// Password:\n\t\t\t\t// \"password\": \"tinode\",\n\n\t\t\t\t// Driver's TLS configuration. Uncomment to enable TLS.\n\t\t\t\t// \"tls\": true,\n\n\t\t\t\t// Path to the client certificate. Optional.\n\t\t\t\t// \"tls_cert_file\": \"/path/to/cert_file\",\n\n\t\t\t\t// Path to private key. Optional.\n\t\t\t\t// \"tls_private_key\": \"/path/to/private_key\",\n\n\t\t\t\t// Specifies whether or not certificates and hostnames received from the server should be validated.\n\t\t\t\t// Not recommended to enable in production. Default is false.\n\t\t\t\t// \"tls_skip_verify\": false\n\t\t\t}\n\t\t}\n\t},\n\n\t// Account validators (email or SMS or captcha).\n\t\"acc_validation\": {\n\n\t\t// Email validator config.\n\t\t\"email\": {\n\t\t\t// Restrict use of \"email\" namespace: make users searchable by their emails,\n\t\t\t// disable manual creation of email: tags.\n\t\t\t\"add_to_tags\": true,\n\n\t\t\t// List of authentication levels which require this validation method.\n\t\t\t// Remove this line to disable email validation.\n\t\t\t\"required\": [\"auth\"],\n\n\t\t\t// Configuration passed to the validator unchanged.\n\t\t\t\"config\": {\n\t\t\t\t// Address of the host where the Tinode server is running. This will be used\n\t\t\t\t// in URLs in the email.\n\t\t\t\t\"host_url\": \"http://localhost:6060/\",\n\n\t\t\t\t// Address of the SMPT server to use.\n\t\t\t\t\"smtp_server\": \"smtp.example.com\",\n\n\t\t\t\t// SMTP port to use. \"25\" for basic email RFC 5321 (2821, 821), \"587\" for RFC 3207 (TLS).\n\t\t\t\t\"smtp_port\": \"25\",\n\n\t\t\t\t// RFC 5322 email address to show in the From: field.\n\t\t\t\t\"sender\": \"\\\"Tinode\\\" <noreply@example.com>\",\n\n\t\t\t\t// Optional login to use for authentication; if missing, the connection is not authenticated.\n\t\t\t\t\"login\": \"john.doe@example.com\",\n\n\t\t\t\t// Password to use when authenticating the sender; used only if \"login\" is provided.\n\t\t\t\t\"sender_password\": \"your-password-here\",\n\n\t\t\t\t// Authentication mechanism to use, optional. One of \"login\", \"cram-md5\", \"plain\" (default).\n\t\t\t\t\"auth_mechanism\": \"login\",\n\n\t\t\t\t// FQDN to use in SMTP HELO/EHLO command; if missing, the hostname from \"host_url\" is used.\n\t\t\t\t\"smtp_helo_host\": \"example.com\",\n\n\t\t\t\t// Skip verification of the server's certificate chain and host name.\n\t\t\t\t// In this mode, TLS is susceptible to man-in-the-middle attacks.\n\t\t\t\t\"insecure_skip_verify\": false,\n\n\t\t\t\t// Optional list of human languages to try to load templates for. If you don't care about i18n,\n\t\t\t\t// leave it blank or remove. The first language in the list is the default language.\n\t\t\t\t\"languages\": [\"en\", \"es\", \"fr\", \"pt\", \"ru\", \"uk\", \"vi\", \"zh\", \"zh-TW\"],\n\n\t\t\t\t// Message template for credential validation.\n\t\t\t\t// The file path itself is treated as a template. It's resolved by using the\n\t\t\t\t// \"languages\" field above. One template per language.\n\t\t\t\t// See the template file for the explanation of the expected structure.\n\t\t\t\t\"validation_templ\": \"./templ/email-validation-{{.Language}}.templ\",\n\n\t\t\t\t// Message template for resetting authentication secret.\n\t\t\t\t// One template per language. See email-validation-en template for the explanation\n\t\t\t\t// of the expected structure.\n\t\t\t\t\"reset_secret_templ\": \"./templ/email-password-reset-{{.Language}}.templ\",\n\n\t\t\t\t// Allow this many confirmation attempts before blocking the credential.\n\t\t\t\t\"max_retries\": 3,\n\n\t\t\t\t// List of email domains allowed to be used for registration.\n\t\t\t\t// Missing or empty list means any email domain is accepted.\n\t\t\t\t\"domains\": [],\n\n\t\t\t\t// Dummy response to accept.\n\t\t\t\t//\n\t\t\t\t// === IMPORTANT ===\n\t\t\t\t//\n\t\t\t\t// REMOVE IN PRODUCTION!!! Otherwise anyone will be able to register\n\t\t\t\t// with fake emails.\n\t\t\t\t\"debug_response\": \"123456\"\n\t\t\t}\n\t\t},\n\n\t\t// Placeholder validator for SMS and voice validation. Disabled by default.\n\t\t// Use something like twilio.com or sinch.com in production.\n\t\t\"tel\": {\n\t\t\t\"add_to_tags\": true,\n\t\t\t\"config\": {\n\t\t\t\t// Address of the host where the Tinode server is running. This will be used\n\t\t\t\t// in URLs in the SMS.\n\t\t\t\t\"host_url\": \"http://localhost:6060/\",\n\n\t\t\t\t// Optional list of locales to try to load templates for. If you don't care about i18n,\n\t\t\t\t// leave it blank or remove. The first language in the list is the default language.\n\t\t\t\t\"languages\": [\"en\", \"es\", \"fr\", \"pt\", \"ru\", \"uk\", \"vi\", \"zh\", \"zh-TW\"],\n\n\t\t\t\t// String to use in the From field of the SMS.\n\t\t\t\t\"sender\": \"Tinode\",\n\n\t\t\t\t// Message template for credential validation and password reset. The file path itself is\n\t\t\t\t// treated as a template. It's resolved by using the \"languages\" field above. One template\n\t\t\t\t// per language.\n\t\t\t\t\"universal_templ\": \"./templ/sms-universal-{{.Language}}.templ\",\n\n\t\t\t\t// Allow this many confirmation attempts before blocking the credential.\n\t\t\t\t\"max_retries\": 3,\n\n\t\t\t\t// Twilio configuration (optional).\n\t\t\t\t//\"twilio_conf\": {\n\t\t\t\t//\t\"account_sid\": \"ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\",\n\t\t\t\t//\t\"auth_token\": \"f2xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"\n\t\t\t\t//},\n\n\t\t\t\t// Dummy response to accept.\n\t\t\t\t//\n\t\t\t\t// === IMPORTANT ===\n\t\t\t\t//\n\t\t\t\t// REMOVE IN PRODUCTION!!! Otherwise anyone will be able to register\n\t\t\t\t// with fake phone numbers.\n\t\t\t\t\"debug_response\": \"123456\"\n\t\t\t}\n\t\t}\n\t},\n\n\t// Configuration for stale account garbage collector: remove\n\t// stale unvalidated user accounts which have been last updated at least\n\t// 'gc_min_account_age' hours ago.\n\t\"acc_gc_config\": {\n\t\t\"enabled\": true,\n\t\t// How often to run GC (seconds).\n\t\t\"gc_period\": 3600,\n\t\t// Number of accounts to delete in one pass.\n\t\t\"gc_block_size\": 10,\n\t\t// Minimum hours since account was last modified.\n\t\t\"gc_min_account_age\": 30\n\t},\n\n\t// Configuration of push notifications.\n\t\"push\": [\n\t\t{\n\t\t\t// Notificator which writes to STDOUT. Useful for debugging.\n\t\t\t\"name\":\"stdout\",\n\t\t\t\"config\": {\n\t\t\t\t// Disabled.\n\t\t\t\t\"enabled\": false\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\t// Google FCM notificator.\n\t\t\t\"name\":\"fcm\",\n\t\t\t\"config\": {\n\t\t\t\t// Disabled. Won't work without the server key anyway. See below.\n\t\t\t\t\"enabled\": false,\n\n\t\t\t\t// Firebase project ID.\n\t\t\t\t\"project_id\": \"your-project-id\",\n\n\t\t\t\t// Service account credentials as json.\n\t\t\t\t// See instructions how to download the service account credentials file:\n\t\t\t\t// https://cloud.google.com/iam/docs/creating-managing-service-account-keys\n\t\t\t\t// Then insert the file contents here. Yes, this is convoluted, but that's Google's fault.\n\t\t\t\t\"credentials\": {\n\t\t\t\t\t\"type\": \"service_account\",\n\t\t\t\t\t\t\"project_id\": \"your-project-id\",\n\t\t\t\t\t\t\"private_key_id\": \"some-random-looking-hex-number\",\n\t\t\t\t\t\t\"private_key\": \"-----BEGIN PRIVATE KEY----- base64-encoded bits of your private key \\n-----END PRIVATE KEY-----\\n\",\n\t\t\t\t\t\t\"client_email\": \"firebase-adminsdk-abc123@your-project-id.iam.gserviceaccount.com\",\n\t\t\t\t\t\t\"client_id\": \"1234567890123456789\",\n\t\t\t\t\t\t\"auth_uri\": \"https://accounts.google.com/o/oauth2/auth\",\n\t\t\t\t\t\t\"token_uri\": \"https://oauth2.googleapis.com/token\",\n\t\t\t\t\t\t\"auth_provider_x509_cert_url\": \"https://www.googleapis.com/oauth2/v1/certs\",\n\t\t\t\t\t\t\"client_x509_cert_url\": \"https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-abc123%40your-project-id.iam.gserviceaccount.com\"\n\t\t\t\t},\n\n\t\t\t\t// An alternative way to provide Firebase service account credentials.\n\t\t\t\t\"credentials_file\": \"/path/to/service-account-file-with-credentials.json\",\n\n\t\t\t\t// Time in seconds before notification is discarded (by Google) if undelivered.\n\t\t\t\t\"time_to_live\": 3600,\n\n\t\t\t\t// Payload of AndroidNotification. If enabled, this will take precedence over data payload.\n\t\t\t\t\"android\": {\n\t\t\t\t\t// Set to false to push a data-only message.\n\t\t\t\t\t\"enabled\": false,\n\n\t\t\t\t\t// Android drawable resource ID to use as a notification icon.\n\t\t\t\t\t\"icon\": \"ic_logo_push\",\n\n\t\t\t\t\t// Notification color.\n\t\t\t\t\t\"color\": \"#3949AB\",\n\n\t\t\t\t\t// Name of intent filter which will catch this notification.\n\t\t\t\t\t\"click_action\": \".MessageActivity\",\n\n\t\t\t\t\t// Notification of a new message. You can include custom \"icon\", \"color\", \"click_action\"\n\t\t\t\t\t// into this section and it will override the value above.\n\t\t\t\t\t\"msg\": {\n\t\t\t\t\t\t// Literal title string. Not recommended because it's not localized.\n\t\t\t\t\t\t\"title\": \"\",\n\n\t\t\t\t\t\t// Literal message body. Not recommended because it's not localized.\n\t\t\t\t\t\t\"body\": \"\",\n\n\t\t\t\t\t\t// Android string resource ID to use as a notification title. Localized.\n\t\t\t\t\t\t// Takes precedence over \"title\". \"new_message\" is \"New message\" in Tindroid.\n\t\t\t\t\t\t\"title_loc_key\": \"new_message\",\n\n\t\t\t\t\t\t// Android string resource ID to use as a notification body. Localized.\n\t\t\t\t\t\t// Takes precedence over \"body\".\n\t\t\t\t\t\t\"body_loc_key\": \"\"\n\t\t\t\t\t},\n\n\t\t\t\t\t// Notification of a new subscription. Same rules as section \"msg\" above.\n\t\t\t\t\t\"sub\": {\n\t\t\t\t\t\t// Android resource string ID to use as notification title. Localized.\n\t\t\t\t\t\t// \"new_chat\" is \"New chat\" in Tindroid.\n\t\t\t\t\t\t\"title_loc_key\": \"new_chat\",\n\n\t\t\t\t\t\t// Android resource string ID to use as notification body. Localized.\n\t\t\t\t\t\t\"body_loc_key\": \"\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\t// Tinode Push Gateway, see https://github.com/tinode/chat/tree/master/server/push/tnpg.\n\t\t\t\"name\":\"tnpg\",\n\t\t\t\"config\": {\n\t\t\t\t// Disabled. Configure first then enable.\n\t\t\t\t\"enabled\": false,\n\t\t\t\t// Short name (URL) of the organization you registered at console.tinode.co.\n\t\t\t\t\"org\": \"test\",\n\t\t\t\t// Authentication token obtained from console.tinode.co\n\t\t\t\t\"token\": \"jwt-security-token-obtained-from-console.tinode.co\",\n\t\t\t}\n\t\t}\n\t],\n\n\t// Configuration for voice and video calls.\n\t\"webrtc\": {\n\t\t// Disabled. Won't work without functioning ice_servers (see below).\n\t\t\"enabled\": false,\n\t\t// Timeout in seconds before a video/voice call is dropped if not answered.\n\t\t\"call_establishment_timeout\": 30,\n\t\t// Interactive Communication Establishment (ICE) STUN and TURN server configuration for video calls.\n\t\t// You need to configure your own servers or consider https://www.metered.ca/tools/openrelay/.\n\t\t// Video calls will not work if both parties are behind NAT and no ICE servers are configured.\n\t\t\"ice_servers\": [\n\t\t\t{\n\t\t\t\t\"urls\": [\n\t\t\t\t\t\"stun:stun.example.com\"\n\t\t\t\t]\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"username\": \"user-name-to-use-for-authentication-with-the-server\",\n\t\t\t\t\"credential\": \"your-password\",\n\t\t\t\t\"urls\": [\n\t\t\t\t\t\"turn:turn.example.com:80?transport=udp\",\n\t\t\t\t\t\"turn:turn.example.com:3478?transport=udp\",\n\t\t\t\t\t\"turn:turn.example.com:80?transport=tcp\",\n\t\t\t\t\t\"turn:turn.example.com:3478?transport=tcp\",\n\t\t\t\t\t\"turns:turn.example.com:443?transport=tcp\",\n\t\t\t\t\t\"turns:turn.example.com:5349?transport=tcp\"\n\t\t\t\t]\n\t\t\t}\n\t\t],\n\t\t// An alternative way to provide STUN/TURN configuration.\n\t\t\"ice_servers_file\": \"/path/to/ice-servers-config.json\"\n\t},\n\n\t// Cluster-mode configuration.\n\t\"cluster_config\": {\n\t\t// Name of this node. Can be assigned from the command line as --cluster_self.\n\t\t// Empty string disables clustering.\n\t\t\"self\": \"\",\n\n\t\t// List of available nodes.\n\t\t\"nodes\": [\n\t\t\t// Name and TCP address of every node in the cluster. The ports 12001..12003\n\t\t\t// are cluster communication ports. They don't need to be exposed to end-users.\n\t\t\t{\"name\": \"one\", \"addr\":\"localhost:12001\"},\n\t\t\t{\"name\": \"two\", \"addr\":\"localhost:12002\"},\n\t\t\t{\"name\": \"three\", \"addr\":\"localhost:12003\"}\n\t\t],\n\n\t\t// Failover config. No need to change unless you are doing something unusual.\n\t\t\"failover\": {\n\t\t\t// Failover is enabled.\n\t\t\t\"enabled\": true,\n\t\t\t// Time in milliseconds between heartbeats.\n\t\t\t\"heartbeat\": 100,\n\t\t\t// Initiate leader election when the leader is not available for this many heartbeats.\n\t\t\t\"vote_after\": 8,\n\t\t\t// Consider node failed when it missed this many heartbeats.\n\t\t\t\"node_fail_after\": 16\n\t\t}\n\t},\n\n\t// Configuration of plugins.\n\t\"plugins\": [\n\t\t{\n\t\t\t// Enable or disable this plugin.\n\t\t\t\"enabled\": false,\n\n\t\t\t// Name of the plugin, must be unique.\n\t\t\t\"name\": \"python_chat_bot\",\n\n\t\t\t// Timeout in microseconds.\n\t\t\t\"timeout\": 20000,\n\n\t\t\t// Events to send to the plugin.\n\t\t\t\"filters\": {\n\t\t\t\t// Account creation events.\n\t\t\t\t\"account\": \"C\"\n\t\t\t},\n\n\t\t\t// Error code to use in case plugin has failed; 0 means to ignore the failures.\n\t\t\t\"failure_code\": 0,\n\n\t\t\t// Text of an error message to report in case of plugin failure.\n\t\t\t\"failure_text\": null,\n\n\t\t\t// Address of the plugin.\n\t\t\t\"service_addr\": \"tcp://localhost:40051\"\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "server/topic.go",
    "content": "/******************************************************************************\n *\n *  Description :\n *    An isolated communication channel (chat room, 1:1 conversation) for\n *    usually multiple users. There is no communication across topics.\n *\n *****************************************************************************/\n\npackage main\n\nimport (\n\t\"errors\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/auth\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\n// Topic is an isolated communication channel\ntype Topic struct {\n\t// Еxpanded/unique name of the topic.\n\tname string\n\t// For single-user topics session-specific topic name, such as 'me',\n\t// otherwise the same as 'name'.\n\txoriginal string\n\n\t// Topic category\n\tcat types.TopicCat\n\n\t// Name of the master node for this topic if isProxy is true.\n\tmasterNode string\n\n\t// Time when the topic was first created.\n\tcreated time.Time\n\t// Time when the topic was last updated.\n\tupdated time.Time\n\t// Time of the last outgoing message.\n\ttouched time.Time\n\n\t// Server-side ID of the last data message\n\tlastID int\n\t// ID of the deletion operation. Not an ID of the message.\n\tdelID int\n\n\t// Total count of subscribers (excluding deleted).\n\t// This is different from subsCount() for channels.\n\tsubCnt int\n\n\t// Last published userAgent ('me' topic only)\n\tuserAgent string\n\n\t// User ID of the topic owner/creator. Could be zero.\n\towner types.Uid\n\n\t// Default access mode\n\taccessAuth types.AccessMode\n\taccessAnon types.AccessMode\n\n\t// Topic discovery tags\n\ttags []string\n\n\t// Auxiliary set of key-value pairs\n\taux map[string]any\n\n\t// Topic's public data\n\tpublic any\n\t// Topic's trusted data\n\ttrusted any\n\n\t// Topic's per-subscriber data\n\tperUser map[types.Uid]perUserData\n\t// Union of permissions across all users (used by proxy sessions with uid = 0).\n\t// These are used by master topics only (in the proxy-master topic context)\n\t// as a coarse-grained attempt to perform acs checks since proxy sessions \"impersonate\"\n\t// multiple normal sessions (uids) which may have different uids.\n\tmodeWantUnion  types.AccessMode\n\tmodeGivenUnion types.AccessMode\n\n\t// User's contact list (not nil for 'me' topic only).\n\t// The map keys are UserIds for P2P topics and grpXXX for group topics.\n\tperSubs map[string]perSubsData\n\n\t// Sessions attached to this topic. The UID kept here may not match Session.uid if session is\n\t// subscribed on behalf of another user.\n\tsessions map[*Session]perSessionData\n\n\t// Present video call data. Null when there's no call in progress or being established.\n\t// Only available for p2p topics.\n\tcurrentCall *videoCall\n\n\t// Channel for receiving client messages from sessions or other topics, buffered = 256.\n\tclientMsg chan *ClientComMessage\n\t// Channel for receiving server messages generated on the server or received from other cluster nodes, buffered = 64.\n\tserverMsg chan *ServerComMessage\n\t// Channel for receiving {get}/{set}/{del} requests, buffered = 64\n\tmeta chan *ClientComMessage\n\t// Subscribe requests from sessions, buffered = 256\n\treg chan *ClientComMessage\n\t// Unsubscribe requests from sessions, buffered = 256\n\tunreg chan *ClientComMessage\n\t// Session updates: background sessions coming online, User Agent changes. Buffered = 32\n\tsupd chan *sessionUpdate\n\t// Channel to terminate topic  -- either the topic is deleted or system is being shut down. Buffered = 1.\n\texit chan *shutDown\n\t// Channel to receive topic master responses (used only by proxy topics).\n\tproxy chan *ClusterResp\n\t// Channel to receive topic proxy service requests, e.g. sending deferred notifications.\n\tmaster chan *ClusterSessUpdate\n\n\t// Flag which tells topic lifecycle status: new, ready, paused, marked for deletion.\n\tstatus int32\n\n\t// Channel functionality is enabled for the group topic.\n\tisChan bool\n\n\t// If isProxy == true, the actual topic is hosted by another cluster member.\n\t// The topic should:\n\t// 1. forward all messages to master\n\t// 2. route replies from the master to sessions.\n\t// 3. disconnect sessions at master's request.\n\t// 4. shut down the topic at master's request.\n\t// 5. aggregate access permissions on behalf of attached sessions.\n\tisProxy bool\n\n\t// Countdown timer for destroying the topic when there are no more attached sessions to it.\n\tkillTimer *time.Timer\n\n\t// Countdown timer for terminating iniatated (but not established) calls.\n\tcallEstablishmentTimer *time.Timer\n}\n\n// perUserData holds topic's cache of per-subscriber data\ntype perUserData struct {\n\t// Count of subscription online and announced (presence not deferred).\n\tonline int\n\n\t// Last t.lastId reported by user through {pres} as received or read\n\trecvID int\n\treadID int\n\t// ID of the latest Delete operation\n\tdelID int\n\n\tprivate any\n\n\tmodeWant  types.AccessMode\n\tmodeGiven types.AccessMode\n\n\t// P2P only:\n\tpublic   any\n\ttrusted  any\n\tlastSeen *time.Time\n\tlastUA   string\n\n\ttopicName string\n\tdeleted   bool\n\n\t// The user is a channel subscriber.\n\tisChan bool\n}\n\n// perSubsData holds user's (on 'me' topic) cache of subscription data\ntype perSubsData struct {\n\t// The other user's/topic's online status as seen by this user.\n\tonline bool\n\t// True if we care about the updates from the other user/topic: (want&given).IsPresencer().\n\t// Does not affect sending notifications from this user to other users.\n\tenabled bool\n}\n\n// Data related to a subscription of a session to a topic.\ntype perSessionData struct {\n\t// ID of the subscribed user (asUid); not necessarily the session owner.\n\t// Could be zero for multiplexed sessions in cluster.\n\tuid types.Uid\n\t// This is a channel subscription\n\tisChanSub bool\n\t// IDs of subscribed users in a multiplexing session.\n\tmuids []types.Uid\n}\n\n// Reasons why topic is being shut down.\nconst (\n\t// StopNone no reason given/default.\n\tStopNone = iota\n\t// StopShutdown terminated due to system shutdown.\n\tStopShutdown\n\t// StopDeleted terminated due to being deleted.\n\tStopDeleted\n\t// StopRehashing terminated due to cluster rehashing (moved to a different node).\n\tStopRehashing\n)\n\n// Topic shutdown\ntype shutDown struct {\n\t// Channel to report back completion of topic shutdown. Could be nil\n\tdone chan<- bool\n\t// Topic is being deleted as opposite to total system shutdown\n\treason int\n}\n\n// Session update: user agent change or background session becoming normal.\n// If sess is nil then user agent change, otherwise bg to fg update.\ntype sessionUpdate struct {\n\tsess      *Session\n\tuserAgent string\n}\n\nvar (\n\tnilPresParams  = &presParams{}\n\tnilPresFilters = &presFilters{}\n)\n\nfunc (t *Topic) run(hub *Hub) {\n\tif !t.isProxy {\n\t\tt.runLocal(hub)\n\t} else {\n\t\tt.runProxy(hub)\n\t}\n}\n\n// getPerUserAcs returns `want` and `given` permissions for the given user id.\nfunc (t *Topic) getPerUserAcs(uid types.Uid) (types.AccessMode, types.AccessMode) {\n\tif uid.IsZero() {\n\t\t// For zero uids (typically for proxy sessions), return the union of all permissions.\n\t\treturn t.modeWantUnion, t.modeGivenUnion\n\t}\n\tpud := t.perUser[uid]\n\treturn pud.modeWant, pud.modeGiven\n}\n\n// passesPresenceFilters applies presence filters to `msg`\n// depending on per-user want and given acls for the provided `uid`.\nfunc (t *Topic) passesPresenceFilters(pres *MsgServerPres, uid types.Uid) bool {\n\tmodeWant, modeGiven := t.getPerUserAcs(uid)\n\t// \"gone\" and \"acs\" notifications are sent even if the topic is muted.\n\treturn ((modeGiven & modeWant).IsPresencer() || pres.What == \"gone\" || pres.What == \"acs\") &&\n\t\t(pres.FilterIn == 0 || int(modeGiven&modeWant)&pres.FilterIn != 0) &&\n\t\t(pres.FilterOut == 0 || int(modeGiven&modeWant)&pres.FilterOut == 0)\n}\n\n// userIsReader returns true if the user (specified by `uid`) may read the given topic.\nfunc (t *Topic) userIsReader(uid types.Uid) bool {\n\tmodeWant, modeGiven := t.getPerUserAcs(uid)\n\treturn (modeGiven & modeWant).IsReader()\n}\n\n// prepareBroadcastableMessage sets the topic field in `msg` depending on the uid and subscription type.\nfunc (t *Topic) prepareBroadcastableMessage(msg *ServerComMessage, uid types.Uid, isChanSub bool) {\n\t// We are only interested in broadcastable messages.\n\tif msg.Data == nil && msg.Pres == nil && msg.Info == nil {\n\t\treturn\n\t}\n\n\tif (t.cat == types.TopicCatP2P && !uid.IsZero()) || (t.cat == types.TopicCatGrp && t.isChan) {\n\t\t// For p2p topics topic name is dependent on receiver.\n\t\t// Channel topics may be presented as grpXXX or chnXXX.\n\t\tvar topicName string\n\t\tif isChanSub {\n\t\t\ttopicName = types.GrpToChn(t.xoriginal)\n\t\t} else {\n\t\t\ttopicName = t.original(uid)\n\t\t}\n\t\tswitch {\n\t\tcase msg.Data != nil:\n\t\t\tmsg.Data.Topic = topicName\n\t\tcase msg.Pres != nil:\n\t\t\tmsg.Pres.Topic = topicName\n\t\tcase msg.Info != nil:\n\t\t\tmsg.Info.Topic = topicName\n\t\t}\n\t}\n\n\t// Send channel messages anonymously.\n\tif isChanSub && msg.Data != nil {\n\t\tmsg.Data.From = \"\"\n\t}\n}\n\n// computePerUserAcsUnion computes want and given permissions unions over all topic's subscribers.\nfunc (t *Topic) computePerUserAcsUnion() {\n\twantUnion := types.ModeNone\n\tgivenUnion := types.ModeNone\n\tfor _, pud := range t.perUser {\n\t\tif pud.isChan {\n\t\t\tcontinue\n\t\t}\n\t\twantUnion |= pud.modeWant\n\t\tgivenUnion |= pud.modeGiven\n\t}\n\n\tif t.isChan {\n\t\t// Apply standard channel permissions to channel topics.\n\t\twantUnion |= types.ModeCChnReader\n\t\tgivenUnion |= types.ModeCChnReader\n\t}\n\n\tt.modeWantUnion = wantUnion\n\tt.modeGivenUnion = givenUnion\n}\n\n// unregisterSession implements all logic following receipt of a leave\n// request via the Topic.unreg channel.\nfunc (t *Topic) unregisterSession(msg *ClientComMessage) {\n\tif t.currentCall != nil {\n\t\tshouldTerminateCall := false\n\t\tif msg.sess.isMultiplex() {\n\t\t\t// Check if any of the call party sessions is multiplexed over msg.sess.\n\t\t\tfor _, p := range t.currentCall.parties {\n\t\t\t\tif p.sess.isProxy() && p.sess.multi == msg.sess {\n\t\t\t\t\tshouldTerminateCall = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t} else if _, found := t.currentCall.parties[msg.sess.sid]; found {\n\t\t\t// Normal session disconnecting from topic. Just terminate the call.\n\t\t\tshouldTerminateCall = true\n\t\t}\n\t\tif shouldTerminateCall {\n\t\t\tt.terminateCallInProgress(false)\n\t\t}\n\t}\n\tt.handleLeaveRequest(msg, msg.sess)\n\tif msg.init && msg.sess.inflightReqs != nil {\n\t\t// If it's a client initiated request.\n\t\tmsg.sess.inflightReqs.Done()\n\t}\n\n\t// If there are no more subscriptions to this topic, start a kill timer\n\tif len(t.sessions) == 0 && t.cat != types.TopicCatSys {\n\t\tt.killTimer.Reset(idleMasterTopicTimeout)\n\t}\n}\n\n// registerSession handles a session join (registration) request\n// received via the Topic.reg channel.\nfunc (t *Topic) registerSession(msg *ClientComMessage) {\n\t// Request to add a connection to this topic\n\tif t.isInactive() {\n\t\tmsg.sess.queueOut(ErrLockedReply(msg, types.TimeNow()))\n\t} else if msg.sess.getSub(t.name) != nil {\n\t\t// Session is already subscribed to topic. Subscription is checked in session.go,\n\t\t// but there is a gap between topic creation/un-pausing and processing the\n\t\t// first subscription request, before the topic is linked to session: a client\n\t\t// may send several subscription requests in that gap.\n\t\tmsg.sess.queueOut(InfoAlreadySubscribed(msg.Id, msg.Original, msg.Timestamp))\n\t} else {\n\t\t// The topic is alive, so stop the kill timer, if it's ticking. We don't want the topic to die\n\t\t// while processing the call.\n\t\tt.killTimer.Stop()\n\t\tif err := t.handleSubscription(msg); err == nil {\n\t\t\tif msg.Sub.Created {\n\t\t\t\t// Call plugins with the new topic\n\t\t\t\tpluginTopic(t, plgActCreate)\n\t\t\t}\n\t\t} else {\n\t\t\tif len(t.sessions) == 0 && t.cat != types.TopicCatSys {\n\t\t\t\t// Failed to subscribe, the topic is still inactive\n\t\t\t\tt.killTimer.Reset(idleMasterTopicTimeout)\n\t\t\t}\n\t\t\tlogs.Warn.Printf(\"topic[%s] subscription failed %v, sid=%s\", t.name, err, msg.sess.sid)\n\t\t}\n\t}\n\tif msg.sess.inflightReqs != nil {\n\t\tmsg.sess.inflightReqs.Done()\n\t}\n}\n\nfunc (t *Topic) handleMetaGet(msg *ClientComMessage, asUid types.Uid, asChan bool, authLevel auth.Level) {\n\tif msg.MetaWhat&constMsgMetaDesc != 0 {\n\t\tif err := t.replyGetDesc(msg.sess, asUid, asChan, msg.Get.Desc, msg); err != nil {\n\t\t\tlogs.Warn.Printf(\"topic[%s] meta.Get.Desc failed: %s\", t.name, err)\n\t\t}\n\t}\n\tif msg.MetaWhat&constMsgMetaSub != 0 {\n\t\tif err := t.replyGetSub(msg.sess, asUid, authLevel, asChan, msg); err != nil {\n\t\t\tlogs.Warn.Printf(\"topic[%s] meta.Get.Sub failed: %s\", t.name, err)\n\t\t}\n\t}\n\tif msg.MetaWhat&constMsgMetaData != 0 {\n\t\tif err := t.replyGetData(msg.sess, asUid, asChan, msg.Get.Data, msg); err != nil {\n\t\t\tlogs.Warn.Printf(\"topic[%s] meta.Get.Data failed: %s\", t.name, err)\n\t\t}\n\t}\n\tif msg.MetaWhat&constMsgMetaDel != 0 {\n\t\tif err := t.replyGetDel(msg.sess, asUid, msg.Get.Del, msg); err != nil {\n\t\t\tlogs.Warn.Printf(\"topic[%s] meta.Get.Del failed: %s\", t.name, err)\n\t\t}\n\t}\n\tif msg.MetaWhat&constMsgMetaTags != 0 {\n\t\tif err := t.replyGetTags(msg.sess, asUid, msg); err != nil {\n\t\t\tlogs.Warn.Printf(\"topic[%s] meta.Get.Tags failed: %s\", t.name, err)\n\t\t}\n\t}\n\tif msg.MetaWhat&constMsgMetaCred != 0 {\n\t\tif err := t.replyGetCreds(msg.sess, asUid, msg); err != nil {\n\t\t\tlogs.Warn.Printf(\"topic[%s] meta.Get.Creds failed: %s\", t.name, err)\n\t\t}\n\t}\n\tif msg.MetaWhat&constMsgMetaAux != 0 {\n\t\tlogs.Warn.Printf(\"topic[%s] handle getAux\", t.name)\n\t\tif err := t.replyGetAux(msg.sess, asUid, msg); err != nil {\n\t\t\tlogs.Warn.Printf(\"topic[%s] meta.Get.Aux failed: %s\", t.name, err)\n\t\t}\n\t}\n}\n\nfunc (t *Topic) handleMetaSet(msg *ClientComMessage, asUid types.Uid, asChan bool, authLevel auth.Level) {\n\tif msg.MetaWhat&constMsgMetaDesc != 0 {\n\t\tif err := t.replySetDesc(msg.sess, asUid, asChan, authLevel, msg); err == nil {\n\t\t\t// Notify plugins of the update\n\t\t\tpluginTopic(t, plgActUpd)\n\t\t} else {\n\t\t\tlogs.Warn.Printf(\"topic[%s] meta.Set.Desc failed: %v\", t.name, err)\n\t\t}\n\t}\n\tif msg.MetaWhat&constMsgMetaSub != 0 {\n\t\tif err := t.replySetSub(msg.sess, msg, asChan); err != nil {\n\t\t\tlogs.Warn.Printf(\"topic[%s] meta.Set.Sub failed: %v\", t.name, err)\n\t\t}\n\t}\n\tif msg.MetaWhat&constMsgMetaTags != 0 {\n\t\tif err := t.replySetTags(msg.sess, asUid, msg); err != nil {\n\t\t\tlogs.Warn.Printf(\"topic[%s] meta.Set.Tags failed: %v\", t.name, err)\n\t\t}\n\t}\n\tif msg.MetaWhat&constMsgMetaCred != 0 {\n\t\tif err := t.replySetCred(msg.sess, asUid, authLevel, msg); err != nil {\n\t\t\tlogs.Warn.Printf(\"topic[%s] meta.Set.Cred failed: %v\", t.name, err)\n\t\t}\n\t}\n\tif msg.MetaWhat&constMsgMetaAux != 0 {\n\t\tif err := t.replySetAux(msg.sess, asUid, msg); err != nil {\n\t\t\tlogs.Warn.Printf(\"topic[%s] meta.Set.Aux failed: %v\", t.name, err)\n\t\t}\n\t}\n}\n\nfunc (t *Topic) handleMetaDel(msg *ClientComMessage, asUid types.Uid, asChan bool, authLevel auth.Level) {\n\tvar err error\n\tswitch msg.MetaWhat {\n\tcase constMsgDelMsg:\n\t\terr = t.replyDelMsg(msg.sess, asUid, asChan, msg)\n\tcase constMsgDelSub:\n\t\terr = t.replyDelSub(msg.sess, asUid, msg)\n\tcase constMsgDelTopic:\n\t\terr = t.replyDelTopic(msg.sess, asUid, msg)\n\tcase constMsgDelCred:\n\t\terr = t.replyDelCred(msg.sess, asUid, authLevel, msg)\n\t}\n\n\tif err != nil {\n\t\tlogs.Warn.Printf(\"topic[%s] meta.Del failed: %v\", t.name, err)\n\t}\n}\n\n// handleMeta implements logic handling meta requests\n// received via the Topic.meta channel.\nfunc (t *Topic) handleMeta(msg *ClientComMessage) {\n\t// Request to get/set topic metadata\n\tasUid := types.ParseUserId(msg.AsUser)\n\tauthLevel := auth.Level(msg.AuthLvl)\n\tasChan, err := t.verifyChannelAccess(msg.Original)\n\tif err != nil {\n\t\t// User should not be able to address non-channel topic as channel.\n\t\tmsg.sess.queueOut(ErrNotFoundReply(msg, types.TimeNow()))\n\t\treturn\n\t}\n\tswitch {\n\tcase msg.Get != nil:\n\t\t// Get request\n\t\tt.handleMetaGet(msg, asUid, asChan, authLevel)\n\n\tcase msg.Set != nil:\n\t\t// Set request\n\t\tt.handleMetaSet(msg, asUid, asChan, authLevel)\n\n\tcase msg.Del != nil:\n\t\t// Del request\n\t\tt.handleMetaDel(msg, asUid, asChan, authLevel)\n\t}\n}\n\nfunc (t *Topic) handleSessionUpdate(upd *sessionUpdate, currentUA *string, uaTimer *time.Timer) {\n\tif upd.sess != nil {\n\t\t// 'me' & 'grp' only. Background session timed out and came online.\n\t\tt.sessToForeground(upd.sess)\n\t} else if *currentUA != upd.userAgent {\n\t\tif t.cat != types.TopicCatMe {\n\t\t\tlogs.Warn.Panicln(\"invalid topic category in UA update\", t.name)\n\t\t}\n\t\t// 'me' only. Process an update to user agent from one of the sessions.\n\t\t*currentUA = upd.userAgent\n\t\tuaTimer.Reset(uaTimerDelay)\n\t}\n}\n\nfunc (t *Topic) handleUATimerEvent(currentUA string) {\n\t// Publish user agent changes after a delay\n\tif currentUA == \"\" || currentUA == t.userAgent {\n\t\treturn\n\t}\n\tt.userAgent = currentUA\n\tt.presUsersOfInterest(\"ua\", t.userAgent)\n}\n\nfunc (t *Topic) handleTopicTimeout(hub *Hub, currentUA string, uaTimer, defrNotifTimer *time.Timer) {\n\t// Topic timeout\n\thub.unreg <- &topicUnreg{rcptTo: t.name}\n\tdefrNotifTimer.Stop()\n\tswitch t.cat {\n\tcase types.TopicCatMe:\n\t\tuaTimer.Stop()\n\t\tt.presUsersOfInterest(\"off\", currentUA)\n\tcase types.TopicCatGrp:\n\t\tt.presSubsOffline(\"off\", nilPresParams, nilPresFilters, nilPresFilters, \"\", false)\n\t}\n}\n\nfunc (t *Topic) handleTopicTermination(sd *shutDown) {\n\t// Handle four cases:\n\t// 1. Topic is shutting down by timer due to inactivity (reason == StopNone)\n\t// 2. Topic is being deleted (reason == StopDeleted)\n\t// 3. System shutdown (reason == StopShutdown, done != nil).\n\t// 4. Cluster rehashing (reason == StopRehashing)\n\n\tswitch sd.reason {\n\tcase StopDeleted:\n\t\tif t.cat == types.TopicCatGrp {\n\t\t\tt.presSubsOffline(\"gone\", nilPresParams, nilPresFilters, nilPresFilters, \"\", false)\n\t\t}\n\t\t// P2P users get \"off+remove\" earlier in the process\n\n\t\t// Inform plugins that the topic is deleted\n\t\tpluginTopic(t, plgActDel)\n\n\tcase StopRehashing:\n\t\t// Must send individual messages to sessions because normal sending through the topic's\n\t\t// broadcast channel won't work - it will be shut down too soon.\n\t\tt.presSubsOnlineDirect(\"term\", nilPresParams, nilPresFilters, \"\")\n\t}\n\t// In case of a system shutdown don't bother with notifications. They won't be delivered anyway.\n\n\t// Tell sessions to remove the topic\n\tfor s := range t.sessions {\n\t\ts.detachSession(t.name)\n\t}\n\n\tif t.cat == types.TopicCatGrp {\n\t\t// Update topic subscriber count.\n\t\tif err := store.Topics.UpdateSubCnt(t.name); err != nil {\n\t\t\tlogs.Warn.Println(\"topic update sub cnt:\", err)\n\t\t}\n\t}\n\n\tusersRegisterTopic(t, false)\n\n\t// Report completion back to sender, if 'done' is not nil.\n\tif sd.done != nil {\n\t\tsd.done <- true\n\t}\n}\n\nfunc (t *Topic) runLocal(hub *Hub) {\n\t// Kills topic after a period of inactivity.\n\tt.killTimer = time.NewTimer(time.Hour)\n\tt.killTimer.Stop()\n\n\t// Notifies about user agent change. 'me' only\n\tuaTimer := time.NewTimer(time.Minute)\n\tvar currentUA string\n\tuaTimer.Stop()\n\n\t// Ticker for deferred presence notifications.\n\tdefrNotifTimer := time.NewTimer(time.Millisecond * 500)\n\n\tt.callEstablishmentTimer = time.NewTimer(time.Second)\n\tt.callEstablishmentTimer.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase msg := <-t.reg:\n\t\t\tt.registerSession(msg)\n\n\t\tcase msg := <-t.unreg:\n\t\t\tt.unregisterSession(msg)\n\n\t\tcase msg := <-t.clientMsg:\n\t\t\tt.handleClientMsg(msg)\n\n\t\tcase msg := <-t.serverMsg:\n\t\t\tt.handleServerMsg(msg)\n\n\t\tcase meta := <-t.meta:\n\t\t\tt.handleMeta(meta)\n\n\t\tcase upd := <-t.supd:\n\t\t\tt.handleSessionUpdate(upd, &currentUA, uaTimer)\n\n\t\tcase <-uaTimer.C:\n\t\t\tt.handleUATimerEvent(currentUA)\n\n\t\tcase <-t.killTimer.C:\n\t\t\tt.handleTopicTimeout(hub, currentUA, uaTimer, defrNotifTimer)\n\n\t\tcase <-t.callEstablishmentTimer.C:\n\t\t\tt.terminateCallInProgress(true)\n\n\t\tcase sd := <-t.exit:\n\t\t\tt.handleTopicTermination(sd)\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// handleClientMsg is the top-level handler of messages received by the topic from sessions.\nfunc (t *Topic) handleClientMsg(msg *ClientComMessage) {\n\tif msg.Pub != nil {\n\t\tt.handlePubBroadcast(msg)\n\t} else if msg.Note != nil {\n\t\tt.handleNoteBroadcast(msg)\n\t} else {\n\t\t// TODO(gene): maybe remove this panic.\n\t\tlogs.Err.Panic(\"topic: wrong client message type for broadcasting\", t.name)\n\t}\n}\n\n// handleServerMsg is the top-level handler of messages generated at the server.\nfunc (t *Topic) handleServerMsg(msg *ServerComMessage) {\n\t// Server-generated message: {info} or {pres}.\n\tif t.isInactive() {\n\t\t// Ignore message - the topic is paused or being deleted.\n\t\treturn\n\t}\n\tif msg.Pres != nil {\n\t\tt.handlePresence(msg)\n\t} else if msg.Info != nil {\n\t\tt.broadcastToSessions(msg)\n\t} else {\n\t\t// TODO(gene): maybe remove this panic.\n\t\tlogs.Err.Panic(\"topic: wrong server message type for broadcasting\", t.name)\n\t}\n}\n\n// Session subscribed to a topic, created == true if topic was just created and {pres} needs to be announced\nfunc (t *Topic) handleSubscription(msg *ClientComMessage) error {\n\tasUid := types.ParseUserId(msg.AsUser)\n\tauthLevel := auth.Level(msg.AuthLvl)\n\tasChan, err := t.verifyChannelAccess(msg.Original)\n\tif err != nil {\n\t\t// User should not be able to address non-channel topic as channel.\n\t\tmsg.sess.queueOut(ErrNotFoundReply(msg, types.TimeNow()))\n\t\treturn err\n\t}\n\n\tif err := t.subscriptionReply(asChan, msg); err != nil {\n\t\treturn err\n\t}\n\n\tmsgsub := msg.Sub\n\tgetWhat := 0\n\tif msgsub.Get != nil {\n\t\tgetWhat = parseMsgClientMeta(msgsub.Get.What)\n\t}\n\tif getWhat&constMsgMetaDesc != 0 {\n\t\t// Send get.desc as a {meta} packet.\n\t\tif err := t.replyGetDesc(msg.sess, asUid, asChan, msgsub.Get.Desc, msg); err != nil {\n\t\t\tlogs.Warn.Printf(\"topic[%s] handleSubscription Get.Desc failed: %v sid=%s\", t.name, err, msg.sess.sid)\n\t\t}\n\t}\n\n\tif getWhat&constMsgMetaSub != 0 {\n\t\t// Send get.sub response as a separate {meta} packet\n\t\tif err := t.replyGetSub(msg.sess, asUid, authLevel, asChan, msg); err != nil {\n\t\t\tlogs.Warn.Printf(\"topic[%s] handleSubscription Get.Sub failed: %v sid=%s\", t.name, err, msg.sess.sid)\n\t\t}\n\t}\n\n\tif getWhat&constMsgMetaTags != 0 {\n\t\t// Send get.tags response as a separate {meta} packet\n\t\tif err := t.replyGetTags(msg.sess, asUid, msg); err != nil {\n\t\t\tlogs.Warn.Printf(\"topic[%s] handleSubscription Get.Tags failed: %v sid=%s\", t.name, err, msg.sess.sid)\n\t\t}\n\t}\n\n\tif getWhat&constMsgMetaCred != 0 {\n\t\t// Send get.tags response as a separate {meta} packet\n\t\tif err := t.replyGetCreds(msg.sess, asUid, msg); err != nil {\n\t\t\tlogs.Warn.Printf(\"topic[%s] handleSubscription Get.Cred failed: %v sid=%s\", t.name, err, msg.sess.sid)\n\t\t}\n\t}\n\n\tif getWhat&constMsgMetaAux != 0 {\n\t\t// Send get.aux response as a separate {meta} packet\n\t\tif err := t.replyGetAux(msg.sess, asUid, msg); err != nil {\n\t\t\tlogs.Warn.Printf(\"topic[%s] handleSubscription Get.Aux failed: %v sid=%s\", t.name, err, msg.sess.sid)\n\t\t}\n\t}\n\n\tif getWhat&constMsgMetaData != 0 {\n\t\t// Send get.data response as {data} packets\n\t\tif err := t.replyGetData(msg.sess, asUid, asChan, msgsub.Get.Data, msg); err != nil {\n\t\t\tlogs.Warn.Printf(\"topic[%s] handleSubscription Get.Data failed: %v sid=%s\", t.name, err, msg.sess.sid)\n\t\t}\n\t}\n\n\tif getWhat&constMsgMetaDel != 0 {\n\t\t// Send get.del response as a separate {meta} packet\n\t\tif err := t.replyGetDel(msg.sess, asUid, msgsub.Get.Del, msg); err != nil {\n\t\t\tlogs.Warn.Printf(\"topic[%s] handleSubscription Get.Del failed: %v sid=%s\", t.name, err, msg.sess.sid)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// handleLeaveRequest processes a session leave request.\nfunc (t *Topic) handleLeaveRequest(msg *ClientComMessage, sess *Session) {\n\t// Remove connection from topic; session may continue to function\n\tnow := types.TimeNow()\n\n\tvar asUid types.Uid\n\tvar asChan bool\n\tif msg.init {\n\t\tasUid = types.ParseUserId(msg.AsUser)\n\t\tvar err error\n\t\tasChan, err = t.verifyChannelAccess(msg.Original)\n\t\tif err != nil {\n\t\t\t// Group topic cannot be addressed as channel unless channel functionality is enabled.\n\t\t\tsess.queueOut(ErrNotFoundReply(msg, now))\n\t\t}\n\t}\n\n\tif t.isInactive() {\n\t\tif !asUid.IsZero() && msg.init {\n\t\t\tsess.queueOut(ErrLockedReply(msg, now))\n\t\t}\n\t\treturn\n\t}\n\n\t// User wants to leave and unsubscribe.\n\tif msg.init && msg.Leave.Unsub {\n\t\t// asUid must not be Zero.\n\t\tif err := t.replyLeaveUnsub(sess, msg, asUid); err != nil {\n\t\t\tlogs.Err.Println(\"failed to unsub\", err, sess.sid)\n\t\t}\n\t\treturn\n\t}\n\n\t// User wants to leave without unsubscribing.\n\tif pssd, _ := t.remSession(sess, asUid); pssd != nil {\n\t\tif !sess.isProxy() {\n\t\t\tsess.delSub(t.name)\n\t\t}\n\t\tif pssd.isChanSub != asChan {\n\t\t\t// Cannot address non-channel subscription as channel and vice versa.\n\t\t\tif msg.init {\n\t\t\t\t// Group topic cannot be addressed as channel unless channel functionality is enabled.\n\t\t\t\tsess.queueOut(ErrNotFoundReply(msg, now))\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tvar uid types.Uid\n\t\tif sess.isProxy() {\n\t\t\t// Multiplexing session, multiple UIDs.\n\t\t\tuid = asUid\n\t\t} else {\n\t\t\t// Simple session, single UID.\n\t\t\tuid = pssd.uid\n\t\t}\n\n\t\tvar pud perUserData\n\t\t// uid may be zero when a proxy session is trying to terminate (it called unsubAll).\n\t\tif !uid.IsZero() {\n\t\t\t// UID not zero: one user removed.\n\t\t\tpud = t.perUser[uid]\n\t\t\tif !sess.background {\n\t\t\t\tpud.online--\n\t\t\t\tt.perUser[uid] = pud\n\t\t\t}\n\t\t} else if len(pssd.muids) > 0 {\n\t\t\t// UID is zero: multiplexing session is dropped altogether.\n\t\t\t// Using new 'uid' and 'pud' variables.\n\t\t\tfor _, uid := range pssd.muids {\n\t\t\t\tpud := t.perUser[uid]\n\t\t\t\tpud.online--\n\t\t\t\tt.perUser[uid] = pud\n\t\t\t}\n\t\t} else if !sess.isCluster() {\n\t\t\tlogs.Warn.Panic(\"cannot determine uid: leave req\", msg, sess)\n\t\t}\n\n\t\tswitch t.cat {\n\t\tcase types.TopicCatMe:\n\t\t\tmrs := t.mostRecentSession()\n\t\t\tif mrs == nil {\n\t\t\t\t// Last session\n\t\t\t\tmrs = sess\n\t\t\t} else {\n\t\t\t\t// Change UA to the most recent live session and announce it. Don't block.\n\t\t\t\tselect {\n\t\t\t\tcase t.supd <- &sessionUpdate{userAgent: mrs.userAgent}:\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tmeUid := uid\n\t\t\tif meUid.IsZero() && len(pssd.muids) > 0 {\n\t\t\t\t// The entire multiplexing session is being dropped. Need to find owner's UID.\n\t\t\t\t// len(pssd.muids) could be zero if the session was a background session.\n\t\t\t\tmeUid = pssd.muids[0]\n\t\t\t}\n\t\t\tif !meUid.IsZero() {\n\t\t\t\t// Update user's last online timestamp & user agent. Only one user can be subscribed to 'me' topic.\n\t\t\t\tif err := store.Users.UpdateLastSeen(meUid, mrs.userAgent, now); err != nil {\n\t\t\t\t\tlogs.Warn.Println(\"user update last seen:\", err)\n\t\t\t\t}\n\t\t\t}\n\t\tcase types.TopicCatFnd:\n\t\t\t// FIXME: this does not work correctly in case of a multiplexing session.\n\t\t\t// Remove ephemeral query.\n\t\t\tt.fndRemovePublic(sess)\n\t\tcase types.TopicCatGrp:\n\t\t\t// Subscriber is going offline in the topic: notify other subscribers who are currently online.\n\t\t\treadFilter := &presFilters{filterIn: types.ModeRead}\n\t\t\tif !uid.IsZero() {\n\t\t\t\tif pud.online == 0 {\n\t\t\t\t\tif asChan {\n\t\t\t\t\t\t// Simply delete record from perUserData\n\t\t\t\t\t\tdelete(t.perUser, uid)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tt.presSubsOnline(\"off\", uid.UserId(), nilPresParams, readFilter, \"\")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if len(pssd.muids) > 0 {\n\t\t\t\tfor _, uid := range pssd.muids {\n\t\t\t\t\tif t.perUser[uid].online == 0 {\n\t\t\t\t\t\tif asChan {\n\t\t\t\t\t\t\t// delete record from perUserData\n\t\t\t\t\t\t\tdelete(t.perUser, uid)\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tt.presSubsOnline(\"off\", uid.UserId(), nilPresParams, readFilter, \"\")\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif !uid.IsZero() {\n\t\t\t// Respond if contains an id.\n\t\t\tif msg.init {\n\t\t\t\tsess.queueOut(NoErrReply(msg, now))\n\t\t\t}\n\t\t}\n\t}\n}\n\n// sessToForeground updates perUser online status accounting and fires due\n// deferred notifications for the provided session.\nfunc (t *Topic) sessToForeground(sess *Session) {\n\ts := sess\n\tif s.multi != nil {\n\t\ts = s.multi\n\t}\n\n\tif pssd, ok := t.sessions[s]; ok && !pssd.isChanSub {\n\t\tuid := pssd.uid\n\t\tif s.isMultiplex() {\n\t\t\t// If 's' is a multiplexing session, then sess is a proxy and it contains correct UID.\n\t\t\t// Add UID to the list of online users.\n\t\t\tuid = sess.uid\n\t\t\tpssd.muids = append(pssd.muids, uid)\n\t\t}\n\t\t// Mark user as online\n\t\tpud := t.perUser[uid]\n\t\tpud.online++\n\t\tt.perUser[uid] = pud\n\n\t\tt.sendSubNotifications(uid, sess.sid, sess.userAgent)\n\t}\n}\n\n// Send immediate presence notification in response to a subscription.\n// Send push notification to the P2P counterpart.\n// In case of a new channel subscription subscribe user to an FCM topic.\n// These notifications are always sent immediately even if background is requested.\nfunc (t *Topic) sendImmediateSubNotifications(asUid types.Uid, acs *MsgAccessMode, sreg *ClientComMessage, now time.Time) {\n\tmodeWant, _ := types.ParseAcs([]byte(acs.Want))\n\tmodeGiven, _ := types.ParseAcs([]byte(acs.Given))\n\tmode := modeWant & modeGiven\n\n\tasChan := t.isChan && types.IsChannel(sreg.Original)\n\n\tif t.cat == types.TopicCatP2P {\n\t\tuid2 := t.p2pOtherUser(asUid)\n\t\tpud2 := t.perUser[uid2]\n\t\tmode2 := pud2.modeGiven & pud2.modeWant\n\t\tif pud2.deleted {\n\t\t\tmode2 = types.ModeInvalid\n\t\t}\n\n\t\t// Inform the other user that the topic was just created.\n\t\tif sreg.Sub.Created {\n\t\t\tt.presSingleUserOffline(uid2, mode2, \"acs\", &presParams{\n\t\t\t\tdWant:  pud2.modeWant.String(),\n\t\t\t\tdGiven: pud2.modeGiven.String(),\n\t\t\t\tactor:  asUid.UserId(),\n\t\t\t}, \"\", false)\n\t\t}\n\n\t\tif sreg.Sub.Newsub {\n\t\t\t// Notify current user's 'me' topic to accept notifications from user2\n\t\t\tt.presSingleUserOffline(asUid, mode, \"?none+en\", nilPresParams, \"\", false)\n\n\t\t\t// Initiate exchange of 'online' status with the other user.\n\t\t\t// We don't know if the current user is online in the 'me' topic,\n\t\t\t// so sending an '?unkn' status to user2. His 'me' topic\n\t\t\t// will reply with user2's status and request an actual status from user1.\n\t\t\tstatus := \"?unkn\"\n\t\t\tif mode2.IsPresencer() {\n\t\t\t\t// If user2 should receive notifications, enable it.\n\t\t\t\tstatus += \"+en\"\n\t\t\t}\n\t\t\tt.presSingleUserOffline(uid2, mode2, status, nilPresParams, \"\", false)\n\n\t\t\t// Also send a push notification to the other user.\n\t\t\tsendPush(t.pushForP2PSub(asUid, uid2, pud2.modeWant, pud2.modeGiven, now))\n\t\t}\n\t} else if t.cat == types.TopicCatGrp && !asChan && sreg.Sub.Newsub {\n\t\t// For new group subscriptions, notify other group members.\n\t\tsendPush(t.pushForGroupSub(asUid, now))\n\t}\n\n\t// newsub could be true only for p2p and group topics, no need to check topic category explicitly.\n\tif sreg.Sub.Newsub {\n\t\t// Notify creator's other sessions that the subscription (or the entire topic) was created.\n\t\tt.presSingleUserOffline(asUid, mode, \"acs\",\n\t\t\t&presParams{\n\t\t\t\tdWant:  acs.Want,\n\t\t\t\tdGiven: acs.Given,\n\t\t\t\tactor:  asUid.UserId(),\n\t\t\t},\n\t\t\tsreg.sess.sid, false)\n\n\t\tif asChan {\n\t\t\tt.channelSubUnsub(asUid, true)\n\t\t}\n\t}\n}\n\n// Send immediate or deferred presence notification in response to a subscription.\n// Not used by channels.\nfunc (t *Topic) sendSubNotifications(asUid types.Uid, sid, userAgent string) {\n\tswitch t.cat {\n\tcase types.TopicCatMe:\n\t\t// Notify user's contact that the given user is online now.\n\t\tif !t.isLoaded() {\n\t\t\tt.markLoaded()\n\t\t\tif err := t.loadContacts(asUid); err != nil {\n\t\t\t\tlogs.Err.Println(\"topic: failed to load contacts\", t.name, err.Error())\n\t\t\t}\n\t\t\t// User online: notify users of interest without forcing response (no +en here).\n\t\t\tt.presUsersOfInterest(\"on\", userAgent)\n\t\t}\n\n\tcase types.TopicCatGrp:\n\t\tpud := t.perUser[asUid]\n\t\tif pud.isChan {\n\t\t\t// Not sendng notifications for channel readers.\n\t\t\treturn\n\t\t}\n\n\t\t// Enable notifications for a new group topic, if appropriate.\n\t\tif !t.isLoaded() {\n\t\t\tt.markLoaded()\n\t\t\tstatus := \"on\"\n\t\t\tif (pud.modeGiven & pud.modeWant).IsPresencer() {\n\t\t\t\tstatus += \"+en\"\n\t\t\t}\n\n\t\t\t// Notify topic subscribers that the topic is online now.\n\t\t\tt.presSubsOffline(status, nilPresParams, nilPresFilters, nilPresFilters, \"\", false)\n\t\t} else if pud.online == 1 {\n\t\t\t// If this is the first session of the user in the topic.\n\t\t\t// Notify other online group members that the user is online now.\n\t\t\tt.presSubsOnline(\"on\", asUid.UserId(), nilPresParams,\n\t\t\t\t&presFilters{filterIn: types.ModeRead}, sid)\n\t\t}\n\t}\n}\n\n// Saves a new message (defined by head, content and attachments) in the topic\n// in response to a client request (msg, asUid) and broadcasts it to the attached sessions.\nfunc (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid, noEcho bool, attachments []string, head map[string]any, content any) error {\n\tpud, userFound := t.perUser[asUid]\n\t// Anyone is allowed to post to 'sys' topic.\n\tif t.cat != types.TopicCatSys {\n\t\t// If it's not 'sys' check write permission.\n\t\tif !(pud.modeWant & pud.modeGiven).IsWriter() {\n\t\t\tmsg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp))\n\t\t\treturn types.ErrPermissionDenied\n\t\t}\n\t}\n\n\tif msg.sess != nil && msg.sess.uid != asUid {\n\t\t// The \"sender\" header contains ID of the user who sent the message on behalf of asUid.\n\t\tif head == nil {\n\t\t\thead = map[string]any{}\n\t\t}\n\t\thead[\"sender\"] = msg.sess.uid.UserId()\n\t} else if head != nil {\n\t\t// Make sure the received Head does not include a fake \"sender\" header.\n\t\tdelete(head, \"sender\")\n\t}\n\n\tmarkedReadBySender := false\n\tif err, unreadUpdated := store.Messages.Save(\n\t\t&types.Message{\n\t\t\tObjHeader: types.ObjHeader{CreatedAt: msg.Timestamp},\n\t\t\tSeqId:     t.lastID + 1,\n\t\t\tTopic:     t.name,\n\t\t\tFrom:      asUid.String(),\n\t\t\tHead:      head,\n\t\t\tContent:   content,\n\t\t}, attachments, (pud.modeGiven & pud.modeWant).IsReader()); err != nil {\n\t\tlogs.Warn.Printf(\"topic[%s]: failed to save message: %v\", t.name, err)\n\t\tmsg.sess.queueOut(ErrUnknown(msg.Id, t.original(asUid), msg.Timestamp))\n\n\t\treturn err\n\t} else {\n\t\tmarkedReadBySender = unreadUpdated\n\t}\n\n\tt.lastID++\n\tt.touched = msg.Timestamp\n\n\tif userFound {\n\t\tpud.readID = t.lastID\n\t\tpud.recvID = t.lastID\n\t\tt.perUser[asUid] = pud\n\t}\n\n\tif msg.Id != \"\" && msg.sess != nil {\n\t\treply := NoErrAccepted(msg.Id, t.original(asUid), msg.Timestamp)\n\t\treply.Ctrl.Params = map[string]any{\"seq\": t.lastID}\n\t\tmsg.sess.queueOut(reply)\n\t}\n\n\tdata := &ServerComMessage{\n\t\tData: &MsgServerData{\n\t\t\tTopic:     msg.Original,\n\t\t\tFrom:      msg.AsUser,\n\t\t\tTimestamp: msg.Timestamp,\n\t\t\tSeqId:     t.lastID,\n\t\t\tHead:      head,\n\t\t\tContent:   content,\n\t\t},\n\t\t// Internal-only values.\n\t\tId:        msg.Id,\n\t\tRcptTo:    msg.RcptTo,\n\t\tAsUser:    msg.AsUser,\n\t\tTimestamp: msg.Timestamp,\n\t\tsess:      msg.sess,\n\t}\n\tif noEcho {\n\t\tdata.SkipSid = msg.sess.sid\n\t}\n\n\t// Message sent: notify offline 'R' subscrbers on 'me'.\n\tt.presSubsOffline(\"msg\", &presParams{seqID: t.lastID, actor: msg.AsUser},\n\t\t&presFilters{filterIn: types.ModeRead}, nilPresFilters, \"\", true)\n\n\t// Tell the plugins that a message was accepted for delivery\n\tpluginMessage(data.Data, plgActCreate)\n\n\tt.broadcastToSessions(data)\n\n\t// sendPush will update unread message count and send push notification.\n\tif pushRcpt := t.pushForData(asUid, data.Data, markedReadBySender); pushRcpt != nil {\n\t\tsendPush(pushRcpt)\n\t}\n\treturn nil\n}\n\n// handlePubBroadcast fans out {pub} -> {data} messages to recipients in a master topic.\n// This is a NON-proxy broadcast.\nfunc (t *Topic) handlePubBroadcast(msg *ClientComMessage) {\n\tasUid := types.ParseUserId(msg.AsUser)\n\tif t.isInactive() {\n\t\t// Ignore broadcast - topic is paused or being deleted.\n\t\tmsg.sess.queueOut(ErrLocked(msg.Id, t.original(asUid), msg.Timestamp))\n\t\treturn\n\t}\n\n\tif t.isReadOnly() {\n\t\tmsg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp))\n\t\treturn\n\t}\n\n\tisCall := msg.Pub.Head != nil && msg.Pub.Head[\"webrtc\"] != nil\n\tif isCall {\n\t\tif len(globals.iceServers) == 0 {\n\t\t\tmsg.sess.queueOut(ErrNotImplementedReply(msg, types.TimeNow()))\n\t\t\treturn\n\t\t}\n\t\tif t.cat != types.TopicCatP2P {\n\t\t\tmsg.sess.queueOut(ErrPermissionDeniedReply(msg, types.TimeNow()))\n\t\t\treturn\n\t\t}\n\t\tif t.currentCall != nil {\n\t\t\tmsg.sess.queueOut(ErrCallBusyReply(msg, types.TimeNow()))\n\t\t\treturn\n\t\t}\n\t}\n\n\t// Save to DB at master topic.\n\tvar attachments []string\n\tif msg.Extra != nil && len(msg.Extra.Attachments) > 0 {\n\t\tattachments = msg.Extra.Attachments\n\t}\n\n\tif err := t.saveAndBroadcastMessage(msg, asUid, msg.Pub.NoEcho, attachments, msg.Pub.Head, msg.Pub.Content); err != nil {\n\t\tlogs.Err.Printf(\"topic[%s]: failed to save messagge - %s\", t.name, err)\n\t\treturn\n\t}\n\n\tif isCall {\n\t\tt.handleCallInvite(msg, asUid)\n\t}\n}\n\n// handleNoteBroadcast fans out {note} -> {info} messages to recipients in a master topic.\n// This is a NON-proxy broadcast (at master topic).\nfunc (t *Topic) handleNoteBroadcast(msg *ClientComMessage) {\n\tif t.isInactive() {\n\t\t// Ignore broadcast - topic is paused or being deleted.\n\t\treturn\n\t}\n\n\tif msg.Note.SeqId > t.lastID {\n\t\t// Drop bogus read notification\n\t\treturn\n\t}\n\n\tasChan, err := t.verifyChannelAccess(msg.Original)\n\tif err != nil {\n\t\t// Silently drop invalid notification.\n\t\treturn\n\t}\n\n\tasUid := types.ParseUserId(msg.AsUser)\n\tpud := t.perUser[asUid]\n\tmode := pud.modeGiven & pud.modeWant\n\tif pud.deleted {\n\t\tmode = types.ModeInvalid\n\t}\n\n\tswitch msg.Note.What {\n\tcase \"kp\", \"kpa\", \"kpv\":\n\t\t// Filter out \"kp*\" from users with no 'W' permission (or people without a subscription).\n\t\tif !mode.IsWriter() || t.isReadOnly() {\n\t\t\treturn\n\t\t}\n\tcase \"read\", \"recv\":\n\t\t// Filter out \"read/recv\" from users with no 'R' permission (or people without a subscription).\n\t\tif !mode.IsReader() {\n\t\t\treturn\n\t\t}\n\tcase \"call\":\n\t\t// Handle calls separately.\n\t\tt.handleCallEvent(msg)\n\t\treturn\n\t}\n\n\tvar read, recv, unread, seq int\n\n\tswitch msg.Note.What {\n\tcase \"read\":\n\t\tif msg.Note.SeqId <= pud.readID {\n\t\t\t// No need to report stale or bogus read status.\n\t\t\treturn\n\t\t}\n\n\t\t// The number of unread messages has decreased, negative value.\n\t\tunread = pud.readID - msg.Note.SeqId\n\t\tpud.readID = msg.Note.SeqId\n\t\tif pud.readID > pud.recvID {\n\t\t\tpud.recvID = pud.readID\n\t\t}\n\t\tread = pud.readID\n\t\tseq = read\n\tcase \"recv\":\n\t\tif msg.Note.SeqId <= pud.recvID {\n\t\t\t// Stale or bogus recv status.\n\t\t\treturn\n\t\t}\n\n\t\tpud.recvID = msg.Note.SeqId\n\t\tif pud.readID > pud.recvID {\n\t\t\tpud.recvID = pud.readID\n\t\t}\n\t\trecv = pud.recvID\n\t\tseq = recv\n\t}\n\n\tif seq > 0 {\n\t\ttopicName := t.name\n\t\tif asChan {\n\t\t\ttopicName = msg.Note.Topic\n\t\t}\n\n\t\tupd := map[string]any{}\n\t\tif recv > 0 {\n\t\t\tupd[\"RecvSeqId\"] = recv\n\t\t}\n\t\tif read > 0 {\n\t\t\tupd[\"ReadSeqId\"] = read\n\t\t}\n\t\tif err := store.Subs.Update(topicName, asUid, upd); err != nil {\n\t\t\tlogs.Warn.Printf(\"topic[%s]: failed to update SeqRead/Recv counter: %v\", t.name, err)\n\t\t\treturn\n\t\t}\n\n\t\t// Read/recv updated: notify user's other sessions of the change\n\t\tt.presPubMessageCount(asUid, mode, read, recv, msg.sess.sid)\n\n\t\tif read > 0 {\n\t\t\t// Send push notification to other user devices.\n\t\t\tsendPush(t.pushForReadRcpt(asUid, read, msg.Timestamp))\n\t\t}\n\n\t\t// Update cached count of unread messages (not tracking unread messages fror channels).\n\t\tif !asChan {\n\t\t\tusersUpdateUnread(asUid, unread, true)\n\t\t}\n\t}\n\n\tif asChan {\n\t\t// No need to forward {note} to other subscribers in channels\n\t\treturn\n\t}\n\n\tif seq > 0 {\n\t\tt.perUser[asUid] = pud\n\t}\n\n\t// Read/recv/kp: notify users offline in the topic on their 'me'.\n\tt.infoSubsOffline(asUid, msg.Note.What, seq, msg.sess.sid)\n\n\tinfo := &ServerComMessage{\n\t\tInfo: &MsgServerInfo{\n\t\t\tTopic: msg.Original,\n\t\t\tFrom:  msg.AsUser,\n\t\t\tWhat:  msg.Note.What,\n\t\t\tSeqId: msg.Note.SeqId,\n\t\t},\n\t\tRcptTo:    msg.RcptTo,\n\t\tAsUser:    msg.AsUser,\n\t\tTimestamp: msg.Timestamp,\n\t\tSkipSid:   msg.sess.sid,\n\t\tsess:      msg.sess,\n\t}\n\n\tt.broadcastToSessions(info)\n}\n\n// handlePresence fans out {pres} messages to recipients in topic.\nfunc (t *Topic) handlePresence(msg *ServerComMessage) {\n\twhat := t.procPresReq(msg.Pres.Src, msg.Pres.What, msg.Pres.WantReply)\n\tif t.xoriginal != msg.Pres.Topic || what == \"\" {\n\t\t// This is just a request for status, don't forward it to sessions\n\t\treturn\n\t}\n\n\t// \"what\" may have changed, i.e. unset or \"+command\" removed (\"on+en\" -> \"on\")\n\tmsg.Pres.What = what\n\n\tt.broadcastToSessions(msg)\n}\n\n// broadcastToSessions writes message to attached sessions.\nfunc (t *Topic) broadcastToSessions(msg *ServerComMessage) {\n\t// List of sessions to be dropped.\n\tvar dropSessions []*Session\n\t// Broadcast the message. Only {data}, {pres}, {info} are broadcastable.\n\t// {meta} and {ctrl} are sent to the session only\n\tfor sess, pssd := range t.sessions {\n\t\t// Send all messages to multiplexing session.\n\t\tif !sess.isMultiplex() {\n\t\t\tif sess.sid == msg.SkipSid {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif msg.Pres != nil {\n\t\t\t\t// Skip notifying - already notified on topic.\n\t\t\t\tif msg.Pres.SkipTopic != \"\" && sess.getSub(msg.Pres.SkipTopic) != nil {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// Notification addressed to a single user only.\n\t\t\t\tif msg.Pres.SingleUser != \"\" && pssd.uid.UserId() != msg.Pres.SingleUser {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\t// Notification should skip a single user.\n\t\t\t\tif msg.Pres.ExcludeUser != \"\" && pssd.uid.UserId() == msg.Pres.ExcludeUser {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// Check presence filters\n\t\t\t\tif !t.passesPresenceFilters(msg.Pres, pssd.uid) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t} else {\n\t\t\t\tif msg.Info != nil {\n\t\t\t\t\t// Don't forward read receipts and key presses to channel readers and those without the R permission.\n\t\t\t\t\t// OK to forward with Src != \"\" because it's sent from another topic to 'me', permissions already\n\t\t\t\t\t// checked there.\n\t\t\t\t\tif msg.Info.Src == \"\" && (pssd.isChanSub || !t.userIsReader(pssd.uid)) {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\t// Skip notifying - already notified on topic.\n\t\t\t\t\tif msg.Info.SkipTopic != \"\" && sess.getSub(msg.Info.SkipTopic) != nil {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\t// Don't send key presses from one user's session to the other sessions of the same user.\n\t\t\t\t\tif msg.Info.What == \"kp\" && msg.Info.From == pssd.uid.UserId() {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t} else if !t.userIsReader(pssd.uid) && !pssd.isChanSub {\n\t\t\t\t\t// Skip {data} if the user has no Read permission and not a channel reader.\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t} else if pssd.isChanSub && types.IsChannel(sess.sid) {\n\t\t\t// If it's a chnX multiplexing session, check if there's a corresponding\n\t\t\t// grpX multiplexing session as we don't want to send the message to both.\n\t\t\tgrpSid := types.ChnToGrp(sess.sid)\n\t\t\tif grpSess := globals.sessionStore.Get(grpSid); grpSess != nil && grpSess.isMultiplex() {\n\t\t\t\t// If grpX multiplexing session's attached to topic, skip this chnX session\n\t\t\t\t// (message will be routed to the topic proxy via the grpX session).\n\t\t\t\tif _, attached := t.sessions[grpSess]; attached {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Make a copy of msg since messages sent to sessions differ.\n\t\tmsgCopy := msg.copy()\n\t\t// Topic name may be different depending on the user to which the `sess` belongs.\n\t\tt.prepareBroadcastableMessage(msgCopy, pssd.uid, pssd.isChanSub)\n\t\t// Send message to session.\n\t\tif !sess.queueOut(msgCopy) {\n\t\t\tlogs.Warn.Printf(\"topic[%s]: connection stuck, detaching - %s\", t.name, sess.sid)\n\t\t\tdropSessions = append(dropSessions, sess)\n\t\t}\n\t}\n\n\t// Drop \"bad\" sessions.\n\tfor _, sess := range dropSessions {\n\t\t// The whole session is being dropped, so ClientComMessage.init is false.\n\t\t// keep redundant init: false so it can be searched for.\n\t\tt.unregisterSession(&ClientComMessage{sess: sess, init: false})\n\t}\n}\n\n// subscriptionReply generates a response to a subscription request\nfunc (t *Topic) subscriptionReply(asChan bool, msg *ClientComMessage) error {\n\t// The topic is already initialized by the Hub\n\n\tmsgsub := msg.Sub\n\n\t// For newly created topics report topic creation time.\n\tvar now time.Time\n\tif msgsub.Created {\n\t\tnow = t.updated\n\t} else {\n\t\tnow = types.TimeNow()\n\t}\n\n\tasUid := types.ParseUserId(msg.AsUser)\n\n\tif !msgsub.Newsub && (t.cat == types.TopicCatP2P || t.cat == types.TopicCatGrp) {\n\t\t// Check if this is a new subscription (P2P & GRP only. SLF, SYS are excluded here).\n\t\tpud, found := t.perUser[asUid]\n\t\tmsgsub.Newsub = !found || pud.deleted\n\t}\n\n\tvar private any\n\tvar mode string\n\tif msgsub.Set != nil {\n\t\tif msgsub.Set.Sub != nil {\n\t\t\tif msgsub.Set.Sub.User != \"\" {\n\t\t\t\tmsg.sess.queueOut(ErrMalformedReply(msg, now))\n\t\t\t\treturn errors.New(\"user id must not be specified\")\n\t\t\t}\n\t\t\tmode = msgsub.Set.Sub.Mode\n\t\t}\n\n\t\tif msgsub.Set.Desc != nil {\n\t\t\tprivate = msgsub.Set.Desc.Private\n\t\t}\n\t}\n\n\tvar err error\n\tvar modeChanged *MsgAccessMode\n\t// Create new subscription or modify an existing one.\n\tif modeChanged, err = t.thisUserSub(msg.sess, msg, asUid, asChan, mode, private); err != nil {\n\t\treturn err\n\t}\n\n\thasJoined := true\n\tif modeChanged != nil {\n\t\tif acs, err := types.ParseAcs([]byte(modeChanged.Mode)); err == nil {\n\t\t\thasJoined = acs.IsJoiner()\n\t\t}\n\t}\n\n\tif hasJoined {\n\t\t// Subscription successfully created. Link topic to session.\n\t\tmsg.sess.addSub(t.name, &Subscription{\n\t\t\tbroadcast: t.clientMsg,\n\t\t\tdone:      t.unreg,\n\t\t\tmeta:      t.meta,\n\t\t\tsupd:      t.supd,\n\t\t})\n\t\tt.addSession(msg.sess, asUid, asChan)\n\n\t\t// The user is online in the topic. Increment the counter if notifications are not deferred.\n\t\tif !msg.sess.background {\n\t\t\tuserData := t.perUser[asUid]\n\t\t\tuserData.online++\n\t\t\tt.perUser[asUid] = userData\n\t\t}\n\n\t\tif t.cat == types.TopicCatGrp && msgsub.Newsub {\n\t\t\t// Increment subscriber count for new group subscriptions only.\n\t\t\tt.subCnt++\n\t\t}\n\t}\n\n\tparams := map[string]any{}\n\t// Report back the assigned access mode.\n\tif modeChanged != nil {\n\t\tparams[\"acs\"] = modeChanged\n\t}\n\ttoriginal := t.original(asUid)\n\n\t// When a group topic is created, it's given a temporary name by the client.\n\t// Then this name changes. Report back the original name here.\n\tif msgsub.Created && msg.Original != toriginal {\n\t\tparams[\"tmpname\"] = msg.Original\n\t\t// The new123ABC name is no longer useful after this.\n\t\tmsg.Original = toriginal\n\t}\n\n\tif len(params) == 0 {\n\t\t// Don't send empty params '{}'\n\t\tmsg.sess.queueOut(NoErr(msg.Id, toriginal, now))\n\t} else {\n\t\tmsg.sess.queueOut(NoErrParams(msg.Id, toriginal, now, params))\n\t}\n\n\t// Some notifications are always sent immediately.\n\tif modeChanged != nil {\n\t\tt.sendImmediateSubNotifications(asUid, modeChanged, msg, now)\n\t}\n\n\tif !msg.sess.background && hasJoined {\n\t\t// Other notifications are also sent immediately for foreground sessions.\n\t\tt.sendSubNotifications(asUid, msg.sess.sid, msg.sess.userAgent)\n\t}\n\n\treturn nil\n}\n\n// User requests or updates a self-subscription to a topic. Called as a\n// result of {sub} or {meta set=sub}.\n// Returns new access mode as *MsgAccessMode if user's access mode has changed, nil otherwise.\n//\n//\tsess\t\t- originating session\n//\tpkt\t\t\t- client message which triggered this request; {sub} or {set}\n//\tasUid\t\t- id of the user making the request\n//\tasChan\t\t- true if the user is subscribing to a channel topic\n//\twant\t\t- requested access mode\n//\tprivate\t\t- private value to assign to the subscription\n//\tbackground\t- presence notifications are deferred\n//\n// Handle these cases:\n// A. User is trying to subscribe for the first time (no subscription).\n// A.1 Normal user is subscribing to the topic.\n// A.2 Reader is joining the channel.\n// B. User is already subscribed, just joining without changing anything.\n// C. User is responding to an earlier invite (modeWant was \"N\" in subscription).\n// D. User is already subscribed, changing modeWant.\n// E. User is accepting ownership transfer (requesting ownership transfer is not permitted).\n// In case of a group topic the user may be a reader or a full subscriber.\nfunc (t *Topic) thisUserSub(sess *Session, pkt *ClientComMessage, asUid types.Uid, asChan bool, want string,\n\tprivate any) (*MsgAccessMode, error) {\n\n\tnow := types.TimeNow()\n\tasLvl := auth.Level(pkt.AuthLvl)\n\n\t// Access mode values as they were before this request was processed.\n\toldWant := types.ModeNone\n\toldGiven := types.ModeNone\n\n\t// Parse access mode requested by the user\n\tmodeWant := types.ModeUnset\n\tif want != \"\" {\n\t\tif err := modeWant.UnmarshalText([]byte(want)); err != nil {\n\t\t\tsess.queueOut(ErrMalformedReply(pkt, now))\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tvar err error\n\t// Check if it's an attempt at a new subscription to the topic / a first connection of a channel reader\n\t// (channel readers are not permanently cached).\n\t// It could be an actual subscription (IsJoiner() == true) or a ban (IsJoiner() == false).\n\tuserData, existingSub := t.perUser[asUid]\n\tif !existingSub || userData.deleted {\n\t\t// New subscription or a not yet cached channel reader, either new or existing.\n\n\t\t// Check if the max number of subscriptions is already reached.\n\t\tif t.cat == types.TopicCatGrp && !asChan && t.subsCount() >= globals.maxSubscriberCount {\n\t\t\tsess.queueOut(ErrPolicyReply(pkt, now))\n\t\t\treturn nil, errors.New(\"max subscription count exceeded\")\n\t\t}\n\n\t\tvar sub *types.Subscription\n\t\ttname := t.name\n\t\tif t.cat == types.TopicCatP2P {\n\t\t\t// P2P could be here only if it was previously deleted. I.e. existingSub is always true for P2P.\n\t\t\tif modeWant != types.ModeUnset {\n\t\t\t\tuserData.modeWant = modeWant\n\t\t\t}\n\t\t\t// If no modeWant is provided, leave existing one unchanged.\n\n\t\t\t// Make sure the user is not asking for unreasonable permissions\n\t\t\tuserData.modeWant = (userData.modeWant & globals.typesModeCP2P) | types.ModeApprove\n\t\t} else if t.cat == types.TopicCatSys {\n\t\t\tif asLvl != auth.LevelRoot {\n\t\t\t\tsess.queueOut(ErrPermissionDeniedReply(pkt, now))\n\t\t\t\treturn nil, errors.New(\"subscription to 'sys' topic requires root access level\")\n\t\t\t}\n\n\t\t\t// Assign default access levels\n\t\t\tuserData.modeWant = types.ModeCSys\n\t\t\tuserData.modeGiven = types.ModeCSys\n\t\t\tif modeWant != types.ModeUnset {\n\t\t\t\tuserData.modeWant = (modeWant & types.ModeCSys) | types.ModeWrite | types.ModeJoin\n\t\t\t}\n\t\t} else if asChan {\n\t\t\tuserData.isChan = true\n\n\t\t\t// Check if user is already subscribed.\n\t\t\tsub, err = store.Subs.Get(pkt.Original, asUid, false)\n\t\t\tif err != nil {\n\t\t\t\tsess.queueOut(ErrUnknownReply(pkt, now))\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\t// Given mode is immutable.\n\t\t\toldGiven = types.ModeCChnReader\n\t\t\tuserData.modeGiven = types.ModeCChnReader\n\n\t\t\tif sub != nil {\n\t\t\t\t// Subscription exists, read old access mode.\n\t\t\t\toldWant = sub.ModeWant\n\t\t\t} else {\n\t\t\t\t// Subscription not found, use default.\n\t\t\t\toldWant = types.ModeCChnReader\n\t\t\t}\n\n\t\t\tif modeWant != types.ModeUnset {\n\t\t\t\t// New access mode is explicitly assigned.\n\t\t\t\tuserData.modeWant = (modeWant & types.ModeCChnReader) | types.ModeRead | types.ModeJoin\n\t\t\t} else {\n\t\t\t\t// Default: unchanged.\n\t\t\t\tuserData.modeWant = oldWant\n\t\t\t}\n\n\t\t\t// User is subscribed to chnXXX, not grpXXX.\n\t\t\ttname = pkt.Original\n\t\t} else {\n\t\t\t// All other topic types.\n\n\t\t\tif !existingSub {\n\n\t\t\t\t// Check if the user has been subscribed previously and if so, use previous modeGiven.\n\t\t\t\t// Otherwise the user may delete subscription and resubscribe to avoid being blocked.\n\t\t\t\tsub, err = store.Subs.Get(t.name, asUid, true)\n\t\t\t\tif err != nil {\n\t\t\t\t\tsess.queueOut(ErrUnknownReply(pkt, now))\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\n\t\t\t\tif sub != nil {\n\t\t\t\t\tuserData.modeGiven = sub.ModeGiven\n\t\t\t\t} else {\n\t\t\t\t\t// If no mode was previously given, give default access.\n\t\t\t\t\tuserData.modeGiven = types.ModeUnset\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif userData.modeGiven == types.ModeUnset {\n\t\t\t\t// New user: default access.\n\t\t\t\tuserData.modeGiven = t.accessFor(asLvl)\n\t\t\t}\n\n\t\t\tif modeWant == types.ModeUnset {\n\t\t\t\t// User wants default access mode.\n\t\t\t\tuserData.modeWant = t.accessFor(asLvl)\n\t\t\t} else {\n\t\t\t\tuserData.modeWant = modeWant\n\t\t\t}\n\t\t}\n\n\t\t// Reject new subscription: 'given' permissions have no 'J'.\n\t\tif !userData.modeGiven.IsJoiner() {\n\t\t\tsess.queueOut(ErrPermissionDeniedReply(pkt, now))\n\t\t\treturn nil, errors.New(\"subscription rejected due to permissions\")\n\t\t}\n\n\t\t// Undelete.\n\t\tif userData.deleted {\n\t\t\tuserData.deleted = false\n\t\t\tuserData.delID, userData.readID, userData.recvID = 0, 0, 0\n\t\t}\n\n\t\tif isNullValue(private) {\n\t\t\tprivate = nil\n\t\t}\n\t\tuserData.private = private\n\n\t\t// Add subscription to database, if missing.\n\t\tif sub == nil || sub.DeletedAt != nil {\n\t\t\tsub = &types.Subscription{\n\t\t\t\tUser:      asUid.String(),\n\t\t\t\tTopic:     tname,\n\t\t\t\tModeWant:  userData.modeWant,\n\t\t\t\tModeGiven: userData.modeGiven,\n\t\t\t\tPrivate:   userData.private,\n\t\t\t}\n\n\t\t\tif err := store.Subs.Create(sub); err != nil {\n\t\t\t\tsess.queueOut(ErrUnknownReply(pkt, now))\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t} else if asChan && userData.modeWant != oldWant {\n\t\t\t// Channel reader changed access mode, save changed mode to db.\n\t\t\tif err := store.Subs.Update(tname, asUid,\n\t\t\t\tmap[string]any{\"ModeWant\": userData.modeWant}); err != nil {\n\t\t\t\tsess.queueOut(ErrUnknownReply(pkt, now))\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\t// Enable or disable fcm push notifications for the subsciption.\n\t\t\tt.channelSubUnsub(asUid, userData.modeWant.IsPresencer())\n\t\t}\n\n\t\tif asChan {\n\t\t\tif userData.modeWant != oldWant {\n\t\t\t\tpluginSubscription(sub, plgActCreate)\n\t\t\t} else {\n\t\t\t\tpluginSubscription(sub, plgActUpd)\n\t\t\t}\n\t\t} else {\n\t\t\t// Add subscribed user to cache.\n\t\t\tusersRegisterUser(asUid, true)\n\t\t\t// Notify plugins of a new subscription\n\t\t\tpluginSubscription(sub, plgActCreate)\n\t\t}\n\n\t} else {\n\t\t// Process update to existing subscription. It could be an incomplete subscription for a new topic.\n\t\tif !userData.isChan && asChan {\n\t\t\t// A normal subscriber is trying to access topic as a channel.\n\t\t\t// Direct the subscriber to use non-channel topic name.\n\t\t\tsess.queueOut(InfoUseOtherReply(pkt, t.name, now))\n\t\t\treturn nil, types.ErrNotFound\n\t\t}\n\n\t\tvar ownerChange bool\n\n\t\t// Save old access values\n\n\t\toldWant = userData.modeWant\n\t\toldGiven = userData.modeGiven\n\n\t\tif modeWant != types.ModeUnset {\n\t\t\t// Explicit modeWant is provided\n\n\t\t\t// Make sure the current owner cannot unset the owner flag or ban himself.\n\t\t\tif t.owner == asUid && (!modeWant.IsOwner() || !modeWant.IsJoiner()) {\n\t\t\t\tsess.queueOut(ErrPermissionDeniedReply(pkt, now))\n\t\t\t\treturn nil, errors.New(\"cannot unset ownership or self-ban the owner\")\n\t\t\t}\n\n\t\t\t// Perform sanity checks\n\t\t\tif userData.modeGiven.IsOwner() {\n\t\t\t\t// Check for possible ownership transfer. Handle the following cases:\n\t\t\t\t// 1. Acceptance or rejection of the ownership transfer\n\t\t\t\t// 2. Owner changing own settings\n\n\t\t\t\t// Ownership transfer\n\t\t\t\townerChange = modeWant.IsOwner() && !userData.modeWant.IsOwner()\n\n\t\t\t\t// The owner should be able to grant himself any access permissions.\n\t\t\t\tif modeWant.IsOwner() && !userData.modeGiven.BetterEqual(modeWant) {\n\t\t\t\t\tuserData.modeGiven |= modeWant\n\t\t\t\t}\n\t\t\t} else if modeWant.IsOwner() {\n\t\t\t\t// Ownership transfer can only be initiated by the owner.\n\t\t\t\tsess.queueOut(ErrPermissionDeniedReply(pkt, now))\n\t\t\t\treturn nil, errors.New(\"non-owner cannot request ownership transfer\")\n\t\t\t} else if t.cat == types.TopicCatGrp && userData.modeGiven.IsAdmin() && modeWant.IsAdmin() {\n\t\t\t\t// A group topic Admin should be able to grant himself any permissions except\n\t\t\t\t// ownership (checked previously) & hard-deleting messages.\n\t\t\t\tif !userData.modeGiven.BetterEqual(modeWant & ^types.ModeDelete) {\n\t\t\t\t\tuserData.modeGiven |= (modeWant & ^types.ModeDelete)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tswitch t.cat {\n\t\t\tcase types.TopicCatP2P:\n\t\t\t\t// For P2P topics ignore requests exceeding the maximum allowed. Otherwise it will generate\n\t\t\t\t// a useless announcement.\n\t\t\t\tmodeWant = (modeWant & globals.typesModeCP2P) | types.ModeApprove\n\t\t\tcase types.TopicCatSys:\n\t\t\t\t// Anyone can always write to Sys topic.\n\t\t\t\tmodeWant &= (modeWant & types.ModeCSys) | types.ModeWrite\n\t\t\t}\n\t\t}\n\n\t\t// If user has not requested a new access mode, provide one by default.\n\t\tif modeWant == types.ModeUnset {\n\t\t\t// If the user has self-banned before, un-self-ban. Otherwise do not make a change.\n\t\t\tif !oldWant.IsJoiner() {\n\t\t\t\t// Set permissions NO WORSE than default, but possibly better (admin or owner banned himself).\n\t\t\t\tuserData.modeWant = userData.modeGiven | t.accessFor(asLvl)\n\t\t\t}\n\t\t} else if userData.modeWant != modeWant {\n\t\t\t// The user has provided a new modeWant and it' different from the one before\n\t\t\tuserData.modeWant = modeWant\n\t\t}\n\n\t\t// Create a subscription object to notify plugins.\n\t\tsub := types.Subscription{\n\t\t\tUser:  asUid.String(),\n\t\t\tTopic: t.name,\n\t\t}\n\n\t\t// Save changes to DB\n\t\tupdate := map[string]any{}\n\t\tif isNullValue(private) {\n\t\t\tupdate[\"Private\"] = nil\n\t\t\tuserData.private = nil\n\t\t\tsub.Private = private\n\t\t} else if private != nil {\n\t\t\tupdate[\"Private\"] = private\n\t\t\tuserData.private = private\n\t\t\tsub.Private = private\n\t\t}\n\t\tif userData.modeWant != oldWant {\n\t\t\tupdate[\"ModeWant\"] = userData.modeWant\n\t\t\tsub.ModeWant = userData.modeWant\n\t\t}\n\t\tif userData.modeGiven != oldGiven {\n\t\t\tupdate[\"ModeGiven\"] = userData.modeGiven\n\t\t\tsub.ModeGiven = userData.modeGiven\n\t\t}\n\n\t\tif len(update) > 0 {\n\t\t\tif err := store.Subs.Update(t.name, asUid, update); err != nil {\n\t\t\t\tsess.queueOut(ErrUnknownReply(pkt, now))\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tpluginSubscription(&sub, plgActUpd)\n\t\t}\n\n\t\t// No transactions in RethinkDB, but two owners are better than none\n\t\tif ownerChange {\n\t\t\toldOwnerData := t.perUser[t.owner]\n\t\t\toldOwnerOldWant, oldOwnerOldGiven := oldOwnerData.modeWant, oldOwnerData.modeGiven\n\t\t\toldOwnerData.modeGiven = (oldOwnerData.modeGiven & ^types.ModeOwner)\n\t\t\toldOwnerData.modeWant = (oldOwnerData.modeWant & ^types.ModeOwner)\n\t\t\tif err := store.Subs.Update(t.name, t.owner,\n\t\t\t\tmap[string]any{\n\t\t\t\t\t\"ModeWant\":  oldOwnerData.modeWant,\n\t\t\t\t\t\"ModeGiven\": oldOwnerData.modeGiven,\n\t\t\t\t}); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tif err := store.Topics.OwnerChange(t.name, asUid); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tt.perUser[t.owner] = oldOwnerData\n\t\t\t// Send presence notifications.\n\t\t\tt.notifySubChange(t.owner, asUid, false,\n\t\t\t\toldOwnerOldWant, oldOwnerOldGiven, oldOwnerData.modeWant, oldOwnerData.modeGiven, \"\")\n\t\t\tt.owner = asUid\n\t\t}\n\t}\n\n\tif !asChan {\n\t\t// If topic is being muted, send \"off\" notification and disable updates.\n\t\t// Do it before applying the new permissions.\n\t\tif (oldWant & oldGiven).IsPresencer() && !(userData.modeWant & userData.modeGiven).IsPresencer() {\n\t\t\tif t.cat == types.TopicCatMe {\n\t\t\t\tt.presUsersOfInterest(\"off+dis\", t.userAgent)\n\t\t\t} else {\n\t\t\t\tt.presSingleUserOffline(asUid, userData.modeWant&userData.modeGiven,\n\t\t\t\t\t\"off+dis\", nilPresParams, \"\", false)\n\t\t\t}\n\t\t}\n\t}\n\t// Apply changes.\n\tt.perUser[asUid] = userData\n\n\tvar modeChanged *MsgAccessMode\n\t// Send presence notifications and update cached unread count.\n\tif oldWant != userData.modeWant || oldGiven != userData.modeGiven {\n\t\tif !asChan {\n\t\t\toldReader := (oldWant & oldGiven).IsReader()\n\t\t\tnewReader := (userData.modeWant & userData.modeGiven).IsReader()\n\n\t\t\tif oldReader && !newReader {\n\t\t\t\t// Decrement unread count\n\t\t\t\tusersUpdateUnread(asUid, userData.readID-t.lastID, true)\n\t\t\t} else if !oldReader && newReader {\n\t\t\t\t// Increment unread count\n\t\t\t\tusersUpdateUnread(asUid, t.lastID-userData.readID, true)\n\t\t\t}\n\t\t}\n\n\t\t// Notify actor of the changes in access mode.\n\t\tt.notifySubChange(asUid, asUid, asChan, oldWant, oldGiven, userData.modeWant, userData.modeGiven, sess.sid)\n\t}\n\n\tif (pkt.Sub != nil && pkt.Sub.Newsub) || oldWant != userData.modeWant || oldGiven != userData.modeGiven {\n\t\tmodeChanged = &MsgAccessMode{\n\t\t\tWant:  userData.modeWant.String(),\n\t\t\tGiven: userData.modeGiven.String(),\n\t\t\tMode:  (userData.modeGiven & userData.modeWant).String(),\n\t\t}\n\t}\n\n\tif !userData.modeWant.IsJoiner() {\n\t\t// The user is self-banning from the topic. Re-subscription will unban.\n\t\tt.evictUser(asUid, false, \"\")\n\t\t// The callee will send NoErrOK\n\t\treturn modeChanged, nil\n\t}\n\n\tif !userData.modeGiven.IsJoiner() {\n\t\t// User was banned\n\t\tsess.queueOut(ErrPermissionDeniedReply(pkt, now))\n\t\treturn nil, errors.New(\"topic access denied; user is banned\")\n\t}\n\n\treturn modeChanged, nil\n}\n\n// anotherUserSub processes a request to initiate an invite or approve a subscription request from another user.\n// Returns changed == true if user's access mode has changed.\n// Handle these cases:\n// A. Sharer or Approver is inviting another user for the first time (no prior subscription)\n// B. Sharer or Approver is re-inviting another user (adjusting modeGiven, modeWant is still Unset)\n// C. Approver is changing modeGiven for another user, modeWant != Unset\nfunc (t *Topic) anotherUserSub(sess *Session, asUid, target types.Uid, asChan bool,\n\tpkt *ClientComMessage) (*MsgAccessMode, error) {\n\n\tnow := types.TimeNow()\n\tset := pkt.Set\n\n\t// Check if approver actually has permission to manage sharing\n\thostData, ok := t.perUser[asUid]\n\t// Access mode of the person who is executing this approval process\n\thostMode := hostData.modeGiven & hostData.modeWant\n\tif !ok || !hostMode.IsSharer() {\n\t\tsess.queueOut(ErrPermissionDeniedReply(pkt, now))\n\t\treturn nil, errors.New(\"topic access denied; approver has no permission\")\n\t}\n\n\tif asChan {\n\t\t// TODO: need to implement promoting reader to subscriber. Rejecting for now.\n\t\tsess.queueOut(ErrPermissionDeniedReply(pkt, now))\n\t\treturn nil, errors.New(\"topic access denied: cannot subscribe reader to channel\")\n\t}\n\n\t// Check if topic is suspended.\n\tif t.isReadOnly() {\n\t\tsess.queueOut(ErrPermissionDeniedReply(pkt, now))\n\t\treturn nil, errors.New(\"topic is suspended\")\n\t}\n\n\t// Parse the access mode granted\n\tmodeGiven := types.ModeUnset\n\tif set.Sub.Mode != \"\" {\n\t\tif err := modeGiven.UnmarshalText([]byte(set.Sub.Mode)); err != nil {\n\t\t\tsess.queueOut(ErrMalformedReply(pkt, now))\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// Make sure the new permissions are reasonable in P2P topics: permissions no greater than allowed,\n\t\t// approver permission cannot be removed.\n\t\tif t.cat == types.TopicCatP2P {\n\t\t\tmodeGiven = (modeGiven & globals.typesModeCP2P) | types.ModeApprove\n\t\t}\n\t}\n\n\t// Make sure only the owner & approvers can set non-default access mode\n\tif modeGiven != types.ModeUnset && !hostMode.IsAdmin() {\n\t\tsess.queueOut(ErrPermissionDeniedReply(pkt, now))\n\t\treturn nil, errors.New(\"sharer cannot set explicit modeGiven\")\n\t}\n\n\t// Make sure no one but the owner can do an ownership transfer\n\tif modeGiven.IsOwner() && t.owner != asUid {\n\t\tsess.queueOut(ErrPermissionDeniedReply(pkt, now))\n\t\treturn nil, errors.New(\"attempt to transfer ownership by non-owner\")\n\t}\n\n\t// Access mode values as they were before this request was processed.\n\toldWant := types.ModeUnset\n\toldGiven := types.ModeUnset\n\n\t// Check if it's a new invite. If so, save it to database as a subscription.\n\t// Saved subscription does not mean the user is allowed to post/read\n\tuserData, existingSub := t.perUser[target]\n\tif !existingSub || userData.deleted {\n\t\t// Check if the max number of subscriptions is already reached.\n\t\tif t.cat == types.TopicCatGrp && t.subsCount() >= globals.maxSubscriberCount {\n\t\t\tsess.queueOut(ErrPolicyReply(pkt, now))\n\t\t\treturn nil, errors.New(\"max subscription count exceeded\")\n\t\t}\n\n\t\tif modeGiven == types.ModeUnset {\n\t\t\t// Request to use default access mode for the new subscriptions.\n\t\t\t// Assuming LevelAuth. Approver should use non-default access if that is not suitable.\n\t\t\tmodeGiven = t.accessFor(auth.LevelAuth)\n\t\t\t// Enable new subscription even if default is no joiner.\n\t\t\tmodeGiven |= types.ModeJoin\n\t\t}\n\n\t\tvar modeWant types.AccessMode\n\t\t// Check if the invitee has been subscribed previously and if so, use previous modeWant.\n\t\t// Otherwise the inviter may delete blocked subscription and reinvite to spam the user.\n\t\tsub, err := store.Subs.Get(t.name, target, true)\n\t\tif err != nil {\n\t\t\tsess.queueOut(ErrUnknownReply(pkt, now))\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif sub != nil {\n\t\t\t// Existing deleted subscription.\n\t\t\tmodeWant = sub.ModeWant\n\t\t} else {\n\t\t\t// Get user's default access mode to be used as modeWant\n\t\t\tif user, err := store.Users.Get(target); err != nil {\n\t\t\t\tsess.queueOut(ErrUnknownReply(pkt, now))\n\t\t\t\treturn nil, err\n\t\t\t} else if user == nil {\n\t\t\t\tsess.queueOut(ErrUserNotFoundReply(pkt, now))\n\t\t\t\treturn nil, errors.New(\"user not found\")\n\t\t\t} else if user.State != types.StateOK {\n\t\t\t\tsess.queueOut(ErrPermissionDeniedReply(pkt, now))\n\t\t\t\treturn nil, errors.New(\"user is suspended\")\n\t\t\t} else {\n\t\t\t\t// Don't ask by default for more permissions than the granted ones.\n\t\t\t\tmodeWant = user.Access.Auth & modeGiven\n\t\t\t}\n\t\t}\n\n\t\t// Reject invitation: 'want' permissions have no 'J'.\n\t\tif !modeWant.IsJoiner() {\n\t\t\tsess.queueOut(ErrPermissionDeniedReply(pkt, now))\n\t\t\treturn nil, errors.New(\"invitation rejected due to permissions\")\n\t\t}\n\n\t\t// Add subscription to database\n\t\tsub = &types.Subscription{\n\t\t\tUser:      target.String(),\n\t\t\tTopic:     t.name,\n\t\t\tModeWant:  modeWant,\n\t\t\tModeGiven: modeGiven,\n\t\t}\n\n\t\tif err := store.Subs.Create(sub); err != nil {\n\t\t\tsess.queueOut(ErrUnknownReply(pkt, now))\n\t\t\treturn nil, err\n\t\t}\n\n\t\tuserData = perUserData{\n\t\t\tmodeGiven: sub.ModeGiven,\n\t\t\tmodeWant:  sub.ModeWant,\n\t\t\tprivate:   nil,\n\t\t}\n\t\tt.perUser[target] = userData\n\t\tt.computePerUserAcsUnion()\n\n\t\t// Cache user's record\n\t\tusersRegisterUser(target, true)\n\n\t\t// Notify plugins of a new subscription.\n\t\tpluginSubscription(sub, plgActCreate)\n\n\t\t// Send push notification for the new subscription.\n\t\t// TODO: maybe skip user's devices which were online when this event has happened.\n\t\tsendPush(t.pushForP2PSub(asUid, target, userData.modeWant, userData.modeGiven, now))\n\t} else {\n\t\t// Action on an existing subscription: re-invite, change existing permission, confirm/decline request.\n\t\toldGiven = userData.modeGiven\n\t\toldWant = userData.modeWant\n\n\t\tif modeGiven == types.ModeUnset {\n\t\t\t// Request to re-send invite without changing the access mode\n\t\t\tmodeGiven = userData.modeGiven\n\t\t} else if modeGiven != userData.modeGiven {\n\t\t\t// Changing the previously assigned value.\n\n\t\t\t// Cannot strip owner of ownership or ban the owner.\n\t\t\tif t.owner == target && (!modeGiven.IsOwner() || !modeGiven.IsJoiner()) {\n\t\t\t\tsess.queueOut(ErrPermissionDeniedReply(pkt, now))\n\t\t\t\treturn nil, errors.New(\"cannot stip ownership or ban the owner\")\n\t\t\t}\n\n\t\t\t// Save changed value to database\n\t\t\tif err := store.Subs.Update(t.name, target,\n\t\t\t\tmap[string]any{\"ModeGiven\": modeGiven}); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tuserData.modeGiven = modeGiven\n\t\t\tt.perUser[target] = userData\n\t\t}\n\t}\n\n\tvar modeChanged *MsgAccessMode\n\t// Access mode has changed.\n\tif oldGiven != userData.modeGiven {\n\n\t\toldReader := (oldWant & oldGiven).IsReader()\n\t\tnewReader := (userData.modeWant & userData.modeGiven).IsReader()\n\t\tif oldReader && !newReader {\n\t\t\t// Decrement unread count\n\t\t\tusersUpdateUnread(target, userData.readID-t.lastID, true)\n\t\t} else if !oldReader && newReader {\n\t\t\t// Increment unread count\n\t\t\tusersUpdateUnread(target, t.lastID-userData.readID, true)\n\t\t}\n\t\tt.notifySubChange(target, asUid, false,\n\t\t\toldWant, oldGiven, userData.modeWant, userData.modeGiven, sess.sid)\n\n\t\tmodeChanged = &MsgAccessMode{\n\t\t\tGiven: userData.modeGiven.String(),\n\t\t\tWant:  userData.modeWant.String(),\n\t\t\tMode:  (userData.modeGiven & userData.modeWant).String(),\n\t\t}\n\t}\n\n\tif !userData.modeGiven.IsJoiner() {\n\t\t// The user is banned from the topic.\n\t\tt.evictUser(target, false, \"\")\n\t}\n\n\treturn modeChanged, nil\n}\n\n// replyGetDesc is a response to a get.desc request on a topic, sent to just the session as a {meta} packet\nfunc (t *Topic) replyGetDesc(sess *Session, asUid types.Uid, _ bool, opts *MsgGetOpts, msg *ClientComMessage) error {\n\tnow := types.TimeNow()\n\tid := msg.Id\n\n\tif opts != nil && (opts.User != \"\" || opts.Limit != 0) {\n\t\tsess.queueOut(ErrMalformedReply(msg, now))\n\t\treturn errors.New(\"invalid GetDesc query\")\n\t}\n\n\t// Check if user requested modified data\n\tifUpdated := opts == nil || opts.IfModifiedSince == nil || opts.IfModifiedSince.Before(t.updated)\n\n\tdesc := &MsgTopicDesc{}\n\tif opts == nil || opts.IfModifiedSince == nil {\n\t\t// Send CreatedAt only when the user requests full information (nothing is cached at the client).\n\t\tdesc.CreatedAt = &t.created\n\t}\n\tif !t.updated.IsZero() {\n\t\tdesc.UpdatedAt = &t.updated\n\t}\n\n\tpud, full := t.perUser[asUid]\n\n\tfull = full || t.cat == types.TopicCatMe\n\n\tif t.cat == types.TopicCatGrp {\n\t\tdesc.IsChan = t.isChan\n\t\tdesc.SubCnt = t.subCnt\n\t\tlogs.Info.Println(\"replyGetDesc: grp topic\", t.name, \"subs\", t.subCnt)\n\t}\n\n\tif ifUpdated {\n\t\tif t.public != nil || t.trusted != nil {\n\t\t\t// Not a p2p topic.\n\t\t\tdesc.Public = t.public\n\t\t\tdesc.Trusted = t.trusted\n\t\t} else if full && t.cat == types.TopicCatP2P {\n\t\t\t// FIXME: when a P2P participant updates desc at 'me', these cached values are not updated.\n\t\t\tdesc.Public = pud.public\n\t\t\tdesc.Trusted = pud.trusted\n\t\t}\n\t}\n\n\t// Request may come from a subscriber (full == true) or a stranger.\n\t// Give subscriber a fuller description than to a stranger/channel reader.\n\tif full {\n\t\tif t.cat == types.TopicCatP2P {\n\t\t\t// For p2p topics default access mode makes no sense: only participants have access to topic.\n\t\t\t// Don't report it.\n\t\t} else if t.cat == types.TopicCatMe || (pud.modeGiven & pud.modeWant).IsSharer() {\n\t\t\tdesc.DefaultAcs = &MsgDefaultAcsMode{\n\t\t\t\tAuth: t.accessAuth.String(),\n\t\t\t\tAnon: t.accessAnon.String(),\n\t\t\t}\n\t\t}\n\n\t\tdesc.Acs = &MsgAccessMode{\n\t\t\tWant:  pud.modeWant.String(),\n\t\t\tGiven: pud.modeGiven.String(),\n\t\t\tMode:  (pud.modeGiven & pud.modeWant).String(),\n\t\t}\n\n\t\tif t.cat == types.TopicCatMe && sess.authLvl == auth.LevelRoot {\n\t\t\t// If 'me' is in memory then user account is invariably not suspended.\n\t\t\tdesc.State = types.StateOK.String()\n\t\t}\n\n\t\tif (pud.modeGiven & pud.modeWant).IsPresencer() {\n\t\t\tswitch t.cat {\n\t\t\tcase types.TopicCatGrp:\n\t\t\t\tdesc.Online = t.isOnline()\n\t\t\tcase types.TopicCatP2P:\n\t\t\t\t// This is the timestamp when the other user logged off last time.\n\t\t\t\t// It does not change while the topic is loaded into memory and that's OK most of the time\n\t\t\t\t// because to stay in memory at least one of the users must be connected to topic.\n\t\t\t\t// FIXME(gene): it breaks when user A stays active in one session and connects-disconnects\n\t\t\t\t// from another session. The second session will not see correct LastSeen time and UserAgent.\n\t\t\t\tif pud.lastSeen != nil {\n\t\t\t\t\tdesc.LastSeen = &MsgLastSeenInfo{\n\t\t\t\t\t\tWhen:      pud.lastSeen,\n\t\t\t\t\t\tUserAgent: pud.lastUA,\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif ifUpdated {\n\t\t\tdesc.Private = pud.private\n\t\t}\n\n\t\t// Don't report message IDs to users without Read access.\n\t\tif (pud.modeGiven & pud.modeWant).IsReader() {\n\t\t\tdesc.SeqId = t.lastID\n\t\t\tif !t.touched.IsZero() {\n\t\t\t\tdesc.TouchedAt = &t.touched\n\t\t\t}\n\n\t\t\t// Make sure reported values are sane:\n\t\t\t// t.delID <= pud.delID; t.readID <= t.recvID <= t.lastID\n\t\t\tdesc.DelId = max(pud.delID, t.delID)\n\t\t\tdesc.ReadSeqId = pud.readID\n\t\t\tdesc.RecvSeqId = max(pud.recvID, pud.readID)\n\t\t} else {\n\t\t\t// Send some sane value of touched.\n\t\t\tdesc.TouchedAt = &t.updated\n\t\t}\n\t}\n\n\tsess.queueOut(&ServerComMessage{\n\t\tMeta: &MsgServerMeta{\n\t\t\tId:        id,\n\t\t\tTopic:     msg.Original,\n\t\t\tDesc:      desc,\n\t\t\tTimestamp: &now,\n\t\t},\n\t})\n\n\treturn nil\n}\n\n// replySetDesc updates topic metadata, saves it to DB, replies to the caller as {ctrl} message,\n// generates {pres} update if necessary.\nfunc (t *Topic) replySetDesc(sess *Session, asUid types.Uid, asChan bool,\n\tauthLevel auth.Level, msg *ClientComMessage) error {\n\tnow := types.TimeNow()\n\n\tassignAccess := func(upd map[string]any, mode *MsgDefaultAcsMode) error {\n\t\tif mode == nil {\n\t\t\treturn nil\n\t\t}\n\t\tif auth, anon, err := parseTopicAccess(mode, types.ModeUnset, types.ModeUnset); err != nil {\n\t\t\treturn err\n\t\t} else if auth.IsOwner() || anon.IsOwner() {\n\t\t\treturn errors.New(\"default 'owner' access is not permitted\")\n\t\t} else {\n\t\t\taccess := types.DefaultAccess{Auth: t.accessAuth, Anon: t.accessAnon}\n\t\t\tif auth != types.ModeUnset {\n\t\t\t\tif t.cat == types.TopicCatMe {\n\t\t\t\t\tauth &= types.ModeCAuth\n\t\t\t\t\tif auth != types.ModeNone {\n\t\t\t\t\t\t// This is the default access mode for P2P topics.\n\t\t\t\t\t\t// It must be either an N or must include an A permission.\n\t\t\t\t\t\tauth |= types.ModeApprove\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\taccess.Auth = auth\n\t\t\t}\n\t\t\tif anon != types.ModeUnset {\n\t\t\t\tif t.cat == types.TopicCatMe {\n\t\t\t\t\tanon &= globals.typesModeCP2P\n\t\t\t\t\tif anon != types.ModeNone {\n\t\t\t\t\t\tanon |= types.ModeApprove\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\taccess.Anon = anon\n\t\t\t}\n\t\t\tif access.Auth != t.accessAuth || access.Anon != t.accessAnon {\n\t\t\t\tupd[\"Access\"] = access\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\n\tassignGenericValues := func(upd map[string]any, what string, dst, src any) (changed bool) {\n\t\tif dst, changed = mergeInterfaces(dst, src); changed {\n\t\t\tupd[what] = dst\n\t\t}\n\t\treturn\n\t}\n\n\t// DefaultAccess and/or Public have chanegd\n\tvar sendCommon bool\n\t// Private has changed\n\tvar sendPriv bool\n\tvar err error\n\n\t// Change to the main object (user or topic).\n\tcore := make(map[string]any)\n\t// Change to subscription.\n\tsub := make(map[string]any)\n\tif set := msg.Set; set.Desc != nil {\n\t\tif set.Desc.Trusted != nil && authLevel != auth.LevelRoot {\n\t\t\t// Only ROOT can change Trusted.\n\t\t\tsess.queueOut(ErrPermissionDeniedReply(msg, now))\n\t\t\treturn errors.New(\"attempt to change Trusted by non-root\")\n\t\t}\n\n\t\tswitch t.cat {\n\t\tcase types.TopicCatMe:\n\t\t\t// Update current user\n\t\t\terr = assignAccess(core, set.Desc.DefaultAcs)\n\t\t\tsendCommon = assignGenericValues(core, \"Public\", t.public, set.Desc.Public)\n\t\t\tsendCommon = assignGenericValues(core, \"Trusted\", t.trusted, set.Desc.Trusted) || sendCommon\n\t\tcase types.TopicCatFnd:\n\t\t\t// set.Desc.DefaultAcs is ignored.\n\t\t\tif set.Desc.Trusted != nil {\n\t\t\t\t// 'fnd' does not support Trusted.\n\t\t\t\tsess.queueOut(ErrPermissionDeniedReply(msg, now))\n\t\t\t\treturn errors.New(\"attempt to assign Trusted in fnd topic\")\n\t\t\t}\n\t\t\t// Do not send presence if fnd.Public has changed.\n\t\t\tassignGenericValues(core, \"Public\", t.fndGetPublic(sess), set.Desc.Public)\n\t\tcase types.TopicCatP2P:\n\t\t\t// Reject direct changes to P2P topics.\n\t\t\tif set.Desc.Public != nil || set.Desc.Trusted != nil || set.Desc.DefaultAcs != nil {\n\t\t\t\tsess.queueOut(ErrPermissionDeniedReply(msg, now))\n\t\t\t\treturn errors.New(\"incorrect attempt to change metadata of a p2p topic\")\n\t\t\t}\n\t\tcase types.TopicCatGrp:\n\t\t\t// Update group topic\n\t\t\tif t.owner == asUid {\n\t\t\t\terr = assignAccess(core, set.Desc.DefaultAcs)\n\t\t\t\tsendCommon = assignGenericValues(core, \"Public\", t.public, set.Desc.Public)\n\t\t\t\tsendCommon = assignGenericValues(core, \"Trusted\", t.trusted, set.Desc.Trusted) || sendCommon\n\t\t\t} else if set.Desc.DefaultAcs != nil || set.Desc.Public != nil || set.Desc.Trusted != nil {\n\t\t\t\t// This is a request from non-owner\n\t\t\t\tsess.queueOut(ErrPermissionDeniedReply(msg, now))\n\t\t\t\treturn errors.New(\"attempt to change public or permissions by non-owner\")\n\t\t\t}\n\t\t}\n\n\t\tif err != nil {\n\t\t\tsess.queueOut(ErrMalformedReply(msg, now))\n\t\t\treturn err\n\t\t}\n\n\t\tsendPriv = assignGenericValues(sub, \"Private\", t.perUser[asUid].private, set.Desc.Private)\n\t}\n\n\tif len(core)+len(sub) == 0 {\n\t\tsess.queueOut(InfoNotModifiedReply(msg, now))\n\t\treturn errors.New(\"{set} generated no update to DB\")\n\t}\n\n\tif len(core) > 0 {\n\t\tcore[\"UpdatedAt\"] = now\n\t\tswitch t.cat {\n\t\tcase types.TopicCatMe:\n\t\t\terr = store.Users.Update(asUid, core)\n\t\tcase types.TopicCatFnd:\n\t\t\t// The only value to be stored in topic is Public, and Public for fnd is not saved according to specs.\n\t\tdefault:\n\t\t\terr = store.Topics.Update(t.name, core)\n\t\t}\n\t}\n\tif err == nil && len(sub) > 0 {\n\t\ttname := t.name\n\t\tif asChan {\n\t\t\ttname = types.GrpToChn(tname)\n\t\t}\n\t\terr = store.Subs.Update(tname, asUid, sub)\n\t}\n\n\tif err != nil {\n\t\tsess.queueOut(ErrUnknownReply(msg, now))\n\t\treturn err\n\t}\n\n\tif len(core) > 0 && msg.Extra != nil && len(msg.Extra.Attachments) > 0 {\n\t\tif err := store.Files.LinkAttachments(t.name, types.ZeroUid, msg.Extra.Attachments); err != nil {\n\t\t\tlogs.Warn.Printf(\"topic[%s] failed to link avatar attachment: %v\", t.name, err)\n\t\t\t// This is not a critical error, continue execution.\n\t\t}\n\t}\n\n\t// Update values cached in the topic object\n\tswitch t.cat {\n\tcase types.TopicCatMe, types.TopicCatGrp:\n\t\tif tmp, ok := core[\"Access\"]; ok {\n\t\t\taccess := tmp.(types.DefaultAccess)\n\t\t\tt.accessAuth = access.Auth\n\t\t\tt.accessAnon = access.Anon\n\t\t}\n\t\tif public, ok := core[\"Public\"]; ok {\n\t\t\tt.public = public\n\t\t}\n\t\tif trusted, ok := core[\"Trusted\"]; ok {\n\t\t\tt.trusted = trusted\n\t\t}\n\tcase types.TopicCatFnd:\n\t\t// Assign per-session fnd.Public.\n\t\tt.fndSetPublic(sess, core[\"Public\"])\n\t}\n\n\tpud := t.perUser[asUid]\n\tmode := pud.modeGiven & pud.modeWant\n\tif private, ok := sub[\"Private\"]; ok {\n\t\tpud.private = private\n\t\tt.perUser[asUid] = pud\n\t}\n\n\tif sendCommon || sendPriv {\n\t\t// t.public/t.trusted, t.accessAuth/Anon have changed, make an announcement\n\t\tif sendCommon {\n\t\t\tif t.cat == types.TopicCatMe {\n\t\t\t\tt.presUsersOfInterest(\"upd\", \"\")\n\t\t\t} else {\n\t\t\t\t// Notify all subscribers on 'me' except the user who made the change and blocked users.\n\t\t\t\t// The user who made the change will be notified separately (see below).\n\t\t\t\tfilter := &presFilters{excludeUser: asUid.UserId(), filterIn: types.ModeJoin}\n\t\t\t\tt.presSubsOffline(\"upd\", nilPresParams, filter, filter, sess.sid, false)\n\t\t\t}\n\n\t\t\tt.updated = now\n\t\t}\n\t\t// Notify user's other sessions.\n\t\tt.presSingleUserOffline(asUid, mode, \"upd\", nilPresParams, sess.sid, false)\n\t}\n\n\tsess.queueOut(NoErrReply(msg, now))\n\n\treturn nil\n}\n\n// replyGetSub is a response to a get.sub request on a topic - load a list of subscriptions/subscribers,\n// send it just to the session as a {meta} packet\nfunc (t *Topic) replyGetSub(sess *Session, asUid types.Uid, authLevel auth.Level, asChan bool, msg *ClientComMessage) error {\n\tnow := types.TimeNow()\n\tid := msg.Id\n\tincomingReqTs := msg.Timestamp\n\tvar req *MsgGetOpts\n\tif msg.Sub != nil {\n\t\treq = msg.Sub.Get.Sub\n\t} else {\n\t\treq = msg.Get.Sub\n\t}\n\n\tif req != nil && (req.SinceId != 0 || req.BeforeId != 0) {\n\t\tsess.queueOut(ErrMalformedReply(msg, now))\n\t\treturn errors.New(\"invalid MsgGetOpts query\")\n\t}\n\n\tvar err error\n\n\tvar ifModified time.Time\n\tif req != nil && req.IfModifiedSince != nil {\n\t\tifModified = *req.IfModifiedSince\n\t}\n\n\tuserData := t.perUser[asUid]\n\tvar subs []types.Subscription\n\n\tswitch t.cat {\n\tcase types.TopicCatMe:\n\t\tif req != nil {\n\t\t\t// If topic is provided, it could be in the form of user ID 'usrAbCd'.\n\t\t\t// Convert it to P2P topic name. Likewise for Self topic 'slf' -> 'slfAbcD'.\n\t\t\tif uid2 := types.ParseUserId(req.Topic); !uid2.IsZero() {\n\t\t\t\treq.Topic = uid2.P2PName(asUid)\n\t\t\t}\n\t\t\tif req.Topic == \"slf\" {\n\t\t\t\treq.Topic = asUid.SlfName()\n\t\t\t}\n\t\t}\n\t\t// Fetch user's subscriptions, with Topic.Public+Topic.Trusted denormalized into subscription.\n\t\tif ifModified.IsZero() {\n\t\t\t// No cache management. Skip deleted subscriptions.\n\t\t\tsubs, err = store.Users.GetTopics(asUid, msgOpts2storeOpts(req))\n\t\t} else {\n\t\t\t// User manages cache. Include deleted subscriptions too.\n\t\t\tsubs, err = store.Users.GetTopicsAny(asUid, msgOpts2storeOpts(req))\n\n\t\t\t// Returned subscriptions do not contain topics which are online now but otherwise unchanged.\n\t\t\t// We need to add these topic to the list otherwise the user would see them as offline.\n\t\t\tselected := map[string]struct{}{}\n\t\t\tfor i := range subs {\n\t\t\t\tsub := &subs[i]\n\t\t\t\twith := sub.GetWith()\n\t\t\t\tif with != \"\" {\n\t\t\t\t\tselected[with] = struct{}{}\n\t\t\t\t} else {\n\t\t\t\t\tselected[sub.Topic] = struct{}{}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Add dummy subscriptions for missing online topics.\n\t\t\tfor topic, psd := range t.perSubs {\n\t\t\t\t_, present := selected[topic]\n\t\t\t\tif !present && psd.online {\n\t\t\t\t\tsub := types.Subscription{Topic: topic}\n\t\t\t\t\tsub.SetWith(topic)\n\t\t\t\t\tsub.SetDummy(true)\n\t\t\t\t\tsubs = append(subs, sub)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\tcase types.TopicCatFnd:\n\t\t// Select public or private query. Public is set interactively and has priority.\n\t\tquery := t.fndGetPublic(sess)\n\t\tif query == \"\" {\n\t\t\tquery, _ = userData.private.(string)\n\t\t}\n\n\t\t// Empty queries are ignored with \"NoContent\".\n\t\tif query != \"\" {\n\t\t\tquery, subs, err = pluginFind(asUid, query)\n\t\t\tif err == nil && subs == nil && query != \"\" {\n\t\t\t\tif and, opt, err := parseSearchQuery(query); err == nil {\n\t\t\t\t\tvar req [][]string\n\t\t\t\t\tfor _, tag := range and {\n\t\t\t\t\t\trewritten := rewriteTag(tag, sess.countryCode)\n\t\t\t\t\t\tif len(rewritten) > 0 {\n\t\t\t\t\t\t\treq = append(req, rewritten)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\topt = rewriteTagSlice(opt, sess.countryCode)\n\n\t\t\t\t\t// Check if the query contains terms that the user is not allowed to use.\n\t\t\t\t\tif restr, _, _ := stringSliceDelta(t.tags,\n\t\t\t\t\t\tfilterTags(append(types.FlattenDoubleSlice(req), opt...), globals.maskedTagNS)); len(restr) > 0 {\n\t\t\t\t\t\tsess.queueOut(ErrPermissionDeniedReply(msg, now))\n\t\t\t\t\t\treturn errors.New(\"attempt to search by restricted tags\")\n\t\t\t\t\t}\n\n\t\t\t\t\t// Ordinary users: find only active topics and accounts.\n\t\t\t\t\t// Root users: find all topics and accounts, including suspended and soft-deleted.\n\t\t\t\t\tsubs, err = store.Users.FindSubs(asUid, globals.aliasTagNS, req, opt, sess.authLvl != auth.LevelRoot)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tsess.queueOut(decodeStoreErrorExplicitTs(err, id, msg.Original, now, incomingReqTs, nil))\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\tcase types.TopicCatP2P:\n\t\t// TODO(gene): don't load subs from DB, use perUserData - it already contains subscriptions.\n\t\t// No need to load Public for p2p topics.\n\t\tif ifModified.IsZero() {\n\t\t\t// No cache management. Skip deleted subscriptions.\n\t\t\tsubs, err = store.Topics.GetSubs(t.name, msgOpts2storeOpts(req))\n\t\t} else {\n\t\t\t// User manages cache. Include deleted subscriptions too.\n\t\t\tsubs, err = store.Topics.GetSubsAny(t.name, msgOpts2storeOpts(req))\n\t\t}\n\tcase types.TopicCatGrp:\n\t\ttopicName := t.name\n\t\tif asChan {\n\t\t\t// In case of a channel allow fetching the subscription of the current user only.\n\t\t\tif req == nil {\n\t\t\t\treq = &MsgGetOpts{}\n\t\t\t}\n\t\t\treq.User = asUid.UserId()\n\t\t\t// Channel subscribers are using chnXXX topic name rather than grpXXX.\n\t\t\ttopicName = msg.Original\n\t\t}\n\t\t// Include sub.Public.\n\t\tif ifModified.IsZero() {\n\t\t\t// No cache management. Skip deleted subscriptions.\n\t\t\tsubs, err = store.Topics.GetUsers(topicName, msgOpts2storeOpts(req))\n\t\t} else {\n\t\t\t// User manages cache. Include deleted subscriptions too.\n\t\t\tsubs, err = store.Topics.GetUsersAny(topicName, msgOpts2storeOpts(req))\n\t\t}\n\t\t// Do nothing for all other topic types, like 'sys', 'slf'.\n\t}\n\n\tif err != nil {\n\t\tsess.queueOut(decodeStoreErrorExplicitTs(err, id, msg.Original, now, incomingReqTs, nil))\n\t\treturn err\n\t}\n\n\tif len(subs) == 0 {\n\t\t// Inform the client that there are no subscriptions.\n\t\tsess.queueOut(NoContentParamsReply(msg, now, map[string]any{\"what\": \"sub\"}))\n\t\treturn nil\n\t}\n\n\tmeta := &MsgServerMeta{\n\t\tId:        id,\n\t\tTopic:     msg.Original,\n\t\tSub:       make([]MsgTopicSub, 0, len(subs)),\n\t\tTimestamp: &now}\n\tpresencer := (userData.modeGiven & userData.modeWant).IsPresencer()\n\tsharer := (userData.modeGiven & userData.modeWant).IsSharer()\n\n\tfor i := range subs {\n\t\tsub := &subs[i]\n\t\t// Indicator if the requester has provided a cut off date for ts of pub & priv updates.\n\t\tvar sendPubPriv bool\n\t\tvar banned bool\n\t\tvar mts MsgTopicSub\n\t\tdeleted := sub.DeletedAt != nil\n\n\t\tif ifModified.IsZero() {\n\t\t\tsendPubPriv = true\n\t\t} else {\n\t\t\t// Skip sending deleted subscriptions if they were deleted before the cut off date.\n\t\t\t// If they are freshly deleted send minimum info\n\t\t\tif deleted {\n\t\t\t\tif !sub.DeletedAt.After(ifModified) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tmts.DeletedAt = sub.DeletedAt\n\t\t\t}\n\t\t\tsendPubPriv = !deleted && sub.UpdatedAt.After(ifModified)\n\t\t}\n\n\t\tuid := types.ParseUid(sub.User)\n\t\tsubMode := sub.ModeGiven & sub.ModeWant\n\t\tisReader := subMode.IsReader()\n\t\tif t.cat == types.TopicCatMe {\n\t\t\t// Mark subscriptions that the user does not care about.\n\t\t\tif !subMode.IsJoiner() {\n\t\t\t\tbanned = true\n\t\t\t}\n\n\t\t\t// Reporting user's subscriptions to other topics. P2P topic name is the\n\t\t\t// UID of the other user.\n\t\t\twith := sub.GetWith()\n\t\t\tif with != \"\" {\n\t\t\t\tmts.Topic = with\n\t\t\t\tmts.Online = t.perSubs[with].online && !deleted && presencer\n\t\t\t} else if strings.HasPrefix(sub.Topic, \"slf\") {\n\t\t\t\tmts.Topic = \"slf\"\n\t\t\t\t// Not reporting Online as it makes no sense for slf.\n\t\t\t} else {\n\t\t\t\tmts.Topic = sub.Topic\n\t\t\t\tmts.Online = t.perSubs[sub.Topic].online && !deleted && presencer\n\t\t\t}\n\n\t\t\tif !deleted && !banned {\n\t\t\t\tif isReader {\n\t\t\t\t\ttouchedAt := sub.GetTouchedAt()\n\t\t\t\t\tif touchedAt.IsZero() {\n\t\t\t\t\t\tmts.TouchedAt = nil\n\t\t\t\t\t} else {\n\t\t\t\t\t\tmts.TouchedAt = &touchedAt\n\t\t\t\t\t}\n\t\t\t\t\tmts.SeqId = sub.GetSeqId()\n\t\t\t\t\tmts.DelId = sub.DelId\n\t\t\t\t} else if !sub.UpdatedAt.IsZero() {\n\t\t\t\t\tmts.TouchedAt = &sub.UpdatedAt\n\t\t\t\t}\n\n\t\t\t\tlastSeen := sub.GetLastSeen()\n\t\t\t\tif lastSeen != nil && !mts.Online {\n\t\t\t\t\tmts.LastSeen = &MsgLastSeenInfo{\n\t\t\t\t\t\tWhen:      lastSeen,\n\t\t\t\t\t\tUserAgent: sub.GetUserAgent(),\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tmts.SubCnt = sub.GetSubCnt()\n\t\t\t}\n\t\t} else {\n\t\t\t// Mark subscriptions that the user does not care about.\n\t\t\tif t.cat == types.TopicCatGrp && !subMode.IsJoiner() {\n\t\t\t\tbanned = true\n\t\t\t}\n\n\t\t\t// Reporting subscribers to fnd, a group or a p2p topic\n\t\t\tmts.User = uid.UserId()\n\t\t\tif t.cat == types.TopicCatFnd {\n\t\t\t\tmts.Topic = sub.Topic\n\t\t\t}\n\n\t\t\tif !deleted {\n\t\t\t\tif uid == asUid && isReader && !banned {\n\t\t\t\t\t// Report deleted ID for own subscriptions only\n\t\t\t\t\tmts.DelId = sub.DelId\n\t\t\t\t}\n\n\t\t\t\tif t.cat == types.TopicCatGrp {\n\t\t\t\t\tpud := t.perUser[uid]\n\t\t\t\t\tmts.Online = pud.online > 0 && presencer\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif !deleted {\n\t\t\tif !sub.UpdatedAt.IsZero() {\n\t\t\t\tmts.UpdatedAt = &sub.UpdatedAt\n\t\t\t}\n\n\t\t\tif isReader && !banned {\n\t\t\t\tmts.ReadSeqId = sub.ReadSeqId\n\t\t\t\tmts.RecvSeqId = sub.RecvSeqId\n\t\t\t}\n\n\t\t\tif t.cat != types.TopicCatFnd {\n\t\t\t\t// p2p and grp\n\t\t\t\tif !sub.IsDummy() && (sharer || uid == asUid || subMode.IsAdmin()) {\n\t\t\t\t\t// If user is not a sharer, the access mode of other ordinary users if not accessible.\n\t\t\t\t\t// Own and admin permissions only are visible to non-sharers.\n\t\t\t\t\tmts.Acs.Mode = subMode.String()\n\t\t\t\t\tmts.Acs.Want = sub.ModeWant.String()\n\t\t\t\t\tmts.Acs.Given = sub.ModeGiven.String()\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Topic 'fnd'\n\t\t\t\t// sub.ModeXXX may be defined by the plugin.\n\t\t\t\tif sub.ModeGiven.IsDefined() && sub.ModeWant.IsDefined() {\n\t\t\t\t\tmts.Acs.Mode = subMode.String()\n\t\t\t\t\tmts.Acs.Want = sub.ModeWant.String()\n\t\t\t\t\tmts.Acs.Given = sub.ModeGiven.String()\n\t\t\t\t} else if types.IsChannel(sub.Topic) {\n\t\t\t\t\tmts.Acs.Mode = types.ModeCChnReader.String()\n\t\t\t\t} else if defacs := sub.GetDefaultAccess(); defacs != nil {\n\t\t\t\t\tswitch authLevel {\n\t\t\t\t\tcase auth.LevelAnon:\n\t\t\t\t\t\tmts.Acs.Mode = defacs.Anon.String()\n\t\t\t\t\tcase auth.LevelAuth, auth.LevelRoot:\n\t\t\t\t\t\tmts.Acs.Mode = defacs.Auth.String()\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tmts.SubCnt = sub.GetSubCnt()\n\t\t\t}\n\n\t\t\t// Returning public and private only if they have changed since ifModified\n\t\t\tif sendPubPriv {\n\t\t\t\t// 'sub' has nil 'public'/'trusted' in P2P topics which is OK.\n\t\t\t\tmts.Public = sub.GetPublic()\n\t\t\t\tmts.Trusted = sub.GetTrusted()\n\t\t\t\t// Reporting 'private' only if it's user's own subscription.\n\t\t\t\tif uid == asUid {\n\t\t\t\t\tmts.Private = sub.Private\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Always reporting 'private' for fnd topic.\n\t\t\tif t.cat == types.TopicCatFnd {\n\t\t\t\tmts.Private = sub.Private\n\t\t\t}\n\t\t}\n\n\t\tmeta.Sub = append(meta.Sub, mts)\n\t}\n\n\tsess.queueOut(&ServerComMessage{Meta: meta})\n\n\treturn nil\n}\n\n// replySetSub is a response to new subscription request or an update to a subscription {set.sub}:\n// update topic metadata cache, save/update subs, reply to the caller as {ctrl} message,\n// generate a presence notification, if appropriate.\nfunc (t *Topic) replySetSub(sess *Session, pkt *ClientComMessage, asChan bool) error {\n\tnow := types.TimeNow()\n\n\tasUid := types.ParseUserId(pkt.AsUser)\n\tset := pkt.Set\n\n\tvar target types.Uid\n\tif target = types.ParseUserId(set.Sub.User); target.IsZero() && set.Sub.User != \"\" {\n\t\t// Invalid user ID\n\t\tsess.queueOut(ErrMalformedReply(pkt, now))\n\t\treturn errors.New(\"invalid user id\")\n\t}\n\n\t// if set.User is not set, request is for the current user\n\tif target.IsZero() {\n\t\ttarget = asUid\n\t}\n\n\tvar err error\n\tvar modeChanged *MsgAccessMode\n\tif target == asUid {\n\t\t// Request new subscription or modify own subscription\n\t\tmodeChanged, err = t.thisUserSub(sess, pkt, asUid, asChan, set.Sub.Mode, nil)\n\t} else {\n\t\t// Request to approve/change someone's subscription\n\t\tmodeChanged, err = t.anotherUserSub(sess, asUid, target, asChan, pkt)\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar resp *ServerComMessage\n\tif modeChanged != nil {\n\t\t// Report resulting access mode.\n\t\tparams := map[string]any{\"acs\": modeChanged}\n\t\tif target != asUid {\n\t\t\tparams[\"user\"] = target.UserId()\n\t\t}\n\t\tresp = NoErrParamsReply(pkt, now, params)\n\t} else {\n\t\tresp = InfoNotModifiedReply(pkt, now)\n\t}\n\n\tsess.queueOut(resp)\n\n\treturn nil\n}\n\n// replyGetData is a response to a get.data request - load a list of stored messages, send them to session as {data}\n// response goes to a single session rather than all sessions in a topic\nfunc (t *Topic) replyGetData(sess *Session, asUid types.Uid, asChan bool, req *MsgGetOpts, msg *ClientComMessage) error {\n\tnow := types.TimeNow()\n\ttoriginal := t.original(asUid)\n\n\tif req != nil && (req.IfModifiedSince != nil || req.User != \"\" || req.Topic != \"\") {\n\t\tsess.queueOut(ErrMalformedReply(msg, now))\n\t\treturn errors.New(\"invalid MsgGetOpts query\")\n\t}\n\n\t// Check if the user has permission to read the topic data\n\tcount := 0\n\tif userData := t.perUser[asUid]; (userData.modeGiven & userData.modeWant).IsReader() {\n\t\t// Read messages from DB\n\t\tmessages, err := store.Messages.GetAll(t.name, asUid, msgOpts2storeOpts(req))\n\t\tif err != nil {\n\t\t\tsess.queueOut(ErrUnknownReply(msg, now))\n\t\t\treturn err\n\t\t}\n\n\t\t// Push the list of messages to the client as {data}.\n\t\tif messages != nil {\n\t\t\tcount = len(messages)\n\t\t\tif count > 0 {\n\t\t\t\toutgoingMessages := make([]*ServerComMessage, count)\n\t\t\t\tfor i := range messages {\n\t\t\t\t\tmm := &messages[i]\n\t\t\t\t\tfrom := \"\"\n\t\t\t\t\tif !asChan {\n\t\t\t\t\t\t// Don't show sender for channel readers\n\t\t\t\t\t\tfrom = types.ParseUid(mm.From).UserId()\n\t\t\t\t\t}\n\t\t\t\t\toutgoingMessages[i] = &ServerComMessage{\n\t\t\t\t\t\tData: &MsgServerData{\n\t\t\t\t\t\t\tTopic:     toriginal,\n\t\t\t\t\t\t\tHead:      mm.Head,\n\t\t\t\t\t\t\tSeqId:     mm.SeqId,\n\t\t\t\t\t\t\tFrom:      from,\n\t\t\t\t\t\t\tTimestamp: mm.CreatedAt,\n\t\t\t\t\t\t\tContent:   mm.Content,\n\t\t\t\t\t\t},\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tsess.queueOutBatch(outgoingMessages)\n\t\t\t}\n\t\t}\n\t} else {\n\t\tsess.queueOut(ErrPermissionDeniedReply(msg, now))\n\t\treturn errors.New(\"attempt to get messages by non-reader\")\n\t}\n\n\t// Inform the requester that all the data has been served.\n\tif count == 0 {\n\t\tsess.queueOut(NoContentParamsReply(msg, now, map[string]any{\"what\": \"data\"}))\n\t} else {\n\t\tsess.queueOut(NoErrDeliveredParams(msg.Id, msg.Original, now,\n\t\t\tmap[string]any{\"what\": \"data\", \"count\": count}))\n\t}\n\n\treturn nil\n}\n\n// replyGetTags returns topics' tags - tokens used for discovery.\nfunc (t *Topic) replyGetTags(sess *Session, asUid types.Uid, msg *ClientComMessage) error {\n\tnow := types.TimeNow()\n\n\tif t.cat == types.TopicCatFnd {\n\t\t// Fnd: checking for alias availability.\n\n\t\t// Checking public (session) data only.\n\t\tif tag := t.fndGetPublic(sess); tag != \"\" {\n\t\t\tvar found string\n\t\t\ttag, subs, err := pluginFind(asUid, tag)\n\t\t\tif err == nil {\n\t\t\t\tif subs == nil {\n\t\t\t\t\tif prefix, _ := validateTag(tag); prefix != \"\" {\n\t\t\t\t\t\t// Check only if a fully-qualified tag was sent. Otherwise ignore the request.\n\t\t\t\t\t\tfound, err = store.Users.FindOne(tag)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// The plugin returned a list of topics. Send the first one.\n\t\t\t\t\tfound = subs[0].Topic\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tsess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil))\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif found != \"\" {\n\t\t\t\tsess.queueOut(&ServerComMessage{\n\t\t\t\t\tMeta: &MsgServerMeta{\n\t\t\t\t\t\tId:        msg.Id,\n\t\t\t\t\t\tTopic:     msg.Original,\n\t\t\t\t\t\tTimestamp: &now,\n\t\t\t\t\t\tTags:      []string{found},\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\n\t\t// Inform the requester that there are no tags.\n\t\tsess.queueOut(NoContentParamsReply(msg, now, map[string]string{\"what\": \"tags\"}))\n\t\treturn nil\n\t}\n\n\tif t.cat != types.TopicCatMe && t.cat != types.TopicCatGrp {\n\t\tsess.queueOut(ErrOperationNotAllowedReply(msg, now))\n\t\treturn errors.New(\"invalid topic category for getting tags\")\n\t}\n\tif t.cat == types.TopicCatGrp && t.owner != asUid {\n\t\tsess.queueOut(ErrPermissionDeniedReply(msg, now))\n\t\treturn errors.New(\"request for tags from non-owner\")\n\t}\n\n\tif len(t.tags) > 0 {\n\t\tsess.queueOut(&ServerComMessage{\n\t\t\tMeta: &MsgServerMeta{\n\t\t\t\tId:        msg.Id,\n\t\t\t\tTopic:     t.original(asUid),\n\t\t\t\tTimestamp: &now,\n\t\t\t\tTags:      t.tags,\n\t\t\t},\n\t\t})\n\t\treturn nil\n\t}\n\n\t// Inform the requester that there are no tags.\n\tsess.queueOut(NoContentParamsReply(msg, now, map[string]string{\"what\": \"tags\"}))\n\n\treturn nil\n}\n\n// replySetTags updates topic's tags - tokens used for discovery.\nfunc (t *Topic) replySetTags(sess *Session, asUid types.Uid, msg *ClientComMessage) error {\n\tnow := types.TimeNow()\n\n\tif t.cat != types.TopicCatMe && t.cat != types.TopicCatGrp {\n\t\tsess.queueOut(ErrOperationNotAllowedReply(msg, now))\n\t\treturn errors.New(\"invalid topic category to assign tags\")\n\t}\n\n\tif t.cat == types.TopicCatGrp && t.owner != asUid {\n\t\tsess.queueOut(ErrPermissionDeniedReply(msg, now))\n\t\treturn errors.New(\"tags update by non-owner\")\n\t}\n\n\ttags := normalizeTags(msg.Set.Tags, globals.maxTagCount)\n\tif len(tags) == 0 {\n\t\tsess.queueOut(InfoNotModifiedReply(msg, now))\n\t\treturn nil\n\t}\n\n\tif !restrictedTagsEqual(t.tags, tags, globals.immutableTagNS) {\n\t\tsess.queueOut(ErrPermissionDeniedReply(msg, now))\n\t\treturn errors.New(\"attempt to mutate restricted tags\")\n\t}\n\n\tif hasDuplicateNamespaceTags(tags, globals.aliasTagNS) {\n\t\tsess.queueOut(ErrMalformedReply(msg, now))\n\t\treturn errors.New(\"duplicate unique tags\")\n\t}\n\n\tadded, removed, _ := stringSliceDelta(t.tags, tags)\n\n\tif t.cat == types.TopicCatMe && len(added) > 0 {\n\t\t// User tags must all be prefixed. Users are not rearchable by generic tags.\n\t\tvar prefixed []string\n\t\tfor _, tag := range added {\n\t\t\tif prefix, _ := validateTag(tag); prefix != \"\" {\n\t\t\t\tprefixed = append(prefixed, prefix)\n\t\t\t}\n\t\t}\n\t\tadded = prefixed\n\t}\n\n\tif len(added) == 0 && len(removed) == 0 {\n\t\tsess.queueOut(InfoNotModifiedReply(msg, now))\n\t\treturn nil\n\t}\n\n\t// Remove unprefixed tags\n\tif unique := filterTags(added, map[string]bool{globals.aliasTagNS: true}); len(unique) > 0 {\n\t\t// Check for global uniqueness.\n\t\t// It's not inside a transaction, so a race may happen.\n\t\tfor _, tag := range unique {\n\t\t\tresult, err := store.Users.FindOne(tag)\n\n\t\t\tif err != nil {\n\t\t\t\tsess.queueOut(ErrUnknownReply(msg, now))\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif result != \"\" {\n\t\t\t\tsess.queueOut(ErrMalformedReply(msg, now))\n\t\t\t\treturn errors.New(\"globally duplicate unique tags\")\n\t\t\t}\n\t\t}\n\t}\n\n\tupdate := map[string]any{\"Tags\": tags, \"UpdatedAt\": now}\n\tvar err error\n\tswitch t.cat {\n\tcase types.TopicCatMe:\n\t\terr = store.Users.Update(asUid, update)\n\tcase types.TopicCatGrp:\n\t\terr = store.Topics.Update(t.name, update)\n\t}\n\n\tif err != nil {\n\t\tsess.queueOut(ErrUnknownReply(msg, now))\n\t\treturn err\n\t}\n\n\tt.tags = tags\n\tt.presSubsOnline(\"tags\", \"\", nilPresParams, &presFilters{singleUser: asUid.UserId()}, sess.sid)\n\n\tparams := make(map[string]any)\n\tif len(added) > 0 {\n\t\tparams[\"added\"] = len(added)\n\t}\n\tif len(removed) > 0 {\n\t\tparams[\"removed\"] = len(removed)\n\t}\n\n\tsess.queueOut(NoErrParamsReply(msg, now, params))\n\treturn nil\n}\n\n// replyGetCreds returns user's credentials such as email and phone numbers.\nfunc (t *Topic) replyGetCreds(sess *Session, asUid types.Uid, msg *ClientComMessage) error {\n\tnow := types.TimeNow()\n\tid := msg.Id\n\n\tif t.cat != types.TopicCatMe {\n\t\tsess.queueOut(ErrOperationNotAllowedReply(msg, now))\n\t\treturn errors.New(\"invalid topic category for getting credentials\")\n\t}\n\n\tscreds, err := store.Users.GetAllCreds(asUid, \"\", false)\n\tif err != nil {\n\t\tsess.queueOut(decodeStoreErrorExplicitTs(err, id, msg.Original, now, msg.Timestamp, nil))\n\t\treturn err\n\t}\n\n\tif len(screds) > 0 {\n\t\tcreds := make([]*MsgCredServer, len(screds))\n\t\tfor i, sc := range screds {\n\t\t\tcreds[i] = &MsgCredServer{Method: sc.Method, Value: sc.Value, Done: sc.Done}\n\t\t}\n\t\tsess.queueOut(&ServerComMessage{\n\t\t\tMeta: &MsgServerMeta{\n\t\t\t\tId:        id,\n\t\t\t\tTopic:     t.original(asUid),\n\t\t\t\tTimestamp: &now,\n\t\t\t\tCred:      creds,\n\t\t\t},\n\t\t})\n\t\treturn nil\n\t}\n\n\t// Inform the requester that there are no credentials.\n\tsess.queueOut(NoContentParamsReply(msg, now, map[string]string{\"what\": \"creds\"}))\n\n\treturn nil\n}\n\n// replySetCred adds or validates user credentials such as email and phone numbers.\nfunc (t *Topic) replySetCred(sess *Session, asUid types.Uid, authLevel auth.Level, msg *ClientComMessage) error {\n\tnow := types.TimeNow()\n\tset := msg.Set\n\n\tif t.cat != types.TopicCatMe {\n\t\tsess.queueOut(ErrOperationNotAllowedReply(msg, now))\n\t\treturn errors.New(\"invalid topic category for updating credentials\")\n\t}\n\n\tvar err error\n\tvar tags []string\n\tcreds := []MsgCredClient{*set.Cred}\n\tif set.Cred.Response != \"\" {\n\t\t// Credential is being validated. Return an arror if response is invalid.\n\t\t_, tags, err = validatedCreds(asUid, authLevel, creds, true)\n\t} else {\n\t\t// Credential is being added or updated.\n\t\ttmpToken, _, _ := store.Store.GetLogicalAuthHandler(\"token\").GenSecret(&auth.Rec{\n\t\t\tUid:       asUid,\n\t\t\tAuthLevel: auth.LevelNone,\n\t\t\tLifetime:  auth.Duration(time.Hour * 24),\n\t\t\tFeatures:  auth.FeatureNoLogin,\n\t\t})\n\t\t_, tags, err = addCreds(asUid, creds, nil, sess.lang, tmpToken)\n\t}\n\n\tif tags != nil {\n\t\tt.tags = tags\n\t\tt.presSubsOnline(\"tags\", \"\", nilPresParams, nilPresFilters, \"\")\n\t}\n\n\tsess.queueOut(decodeStoreErrorExplicitTs(err, set.Id, t.original(asUid), now, msg.Timestamp, nil))\n\n\treturn err\n}\n\n// replyGetAux returns topic's auxiliary set of key-value pairs.\nfunc (t *Topic) replyGetAux(sess *Session, asUid types.Uid, msg *ClientComMessage) error {\n\tnow := types.TimeNow()\n\n\tif t.cat != types.TopicCatP2P && t.cat != types.TopicCatGrp && t.cat != types.TopicCatSlf {\n\t\tsess.queueOut(ErrOperationNotAllowedReply(msg, now))\n\t\treturn errors.New(\"invalid topic category to query aux\")\n\t}\n\n\tif len(t.aux) > 0 {\n\t\tsess.queueOut(&ServerComMessage{\n\t\t\tMeta: &MsgServerMeta{\n\t\t\t\tId:        msg.Id,\n\t\t\t\tTopic:     t.original(asUid),\n\t\t\t\tTimestamp: &now,\n\t\t\t\tAux:       t.aux,\n\t\t\t},\n\t\t})\n\t\treturn nil\n\t}\n\n\t// Inform the requester that there are no tags.\n\tsess.queueOut(NoContentParamsReply(msg, now, map[string]string{\"what\": \"aux\"}))\n\n\treturn nil\n}\n\n// replyGetAux returns topic's auxiliary set of key-value pairs.\nfunc (t *Topic) replySetAux(sess *Session, asUid types.Uid, msg *ClientComMessage) error {\n\tnow := types.TimeNow()\n\n\tif t.cat != types.TopicCatP2P && t.cat != types.TopicCatGrp && t.cat != types.TopicCatSlf {\n\t\tsess.queueOut(ErrOperationNotAllowedReply(msg, now))\n\t\treturn errors.New(\"invalid topic category to assign aux\")\n\t}\n\n\tif userData := t.perUser[asUid]; !(userData.modeGiven & userData.modeWant).IsAdmin() {\n\t\tsess.queueOut(ErrPermissionDeniedReply(msg, now))\n\t\treturn errors.New(\"aux update by non-admin\")\n\t}\n\n\tif aux, changed := mergeMaps(copyMap(t.aux), msg.Set.Aux); changed {\n\t\terr := store.Topics.Update(t.name, map[string]any{\"Aux\": aux, \"UpdatedAt\": now})\n\t\tif err == nil {\n\t\t\tt.aux = aux\n\t\t\tt.presSubsOnline(\"aux\", \"\", nilPresParams, nilPresFilters, sess.sid)\n\t\t}\n\t\tsess.queueOut(decodeStoreErrorExplicitTs(err, msg.Set.Id, t.original(asUid), now, msg.Timestamp, nil))\n\t\treturn err\n\t}\n\n\tsess.queueOut(InfoNotModifiedReply(msg, now))\n\treturn nil\n}\n\n// replyGetDel is a response to a get[what=del] request: load a list of deleted message ids, send them to\n// a session as {meta}\n// response goes to a single session rather than all sessions in a topic\nfunc (t *Topic) replyGetDel(sess *Session, asUid types.Uid, req *MsgGetOpts, msg *ClientComMessage) error {\n\tnow := types.TimeNow()\n\ttoriginal := t.original(asUid)\n\n\tid := msg.Id\n\tincomingReqTs := msg.Timestamp\n\n\tif req != nil && (req.IfModifiedSince != nil || req.User != \"\" || req.Topic != \"\") {\n\t\tsess.queueOut(ErrMalformedReply(msg, now))\n\t\treturn errors.New(\"invalid MsgGetOpts query\")\n\t}\n\n\t// Check if the user has permission to read the topic data and the request is valid.\n\tif userData := t.perUser[asUid]; (userData.modeGiven & userData.modeWant).IsReader() {\n\t\tranges, delID, err := store.Messages.GetDeleted(t.name, asUid, msgOpts2storeOpts(req))\n\t\tif err != nil {\n\t\t\tsess.queueOut(ErrUnknownReply(msg, now))\n\t\t\treturn err\n\t\t}\n\n\t\tif len(ranges) > 0 {\n\t\t\tsess.queueOut(&ServerComMessage{\n\t\t\t\tMeta: &MsgServerMeta{\n\t\t\t\t\tId:    id,\n\t\t\t\t\tTopic: toriginal,\n\t\t\t\t\tDel: &MsgDelValues{\n\t\t\t\t\t\tDelId:  delID,\n\t\t\t\t\t\tDelSeq: rangeDeserialize(ranges),\n\t\t\t\t\t},\n\t\t\t\t\tTimestamp: &now,\n\t\t\t\t},\n\t\t\t})\n\t\t\treturn nil\n\t\t}\n\t}\n\n\tsess.queueOut(NoContentParams(id, toriginal, now, incomingReqTs, map[string]string{\"what\": \"del\"}))\n\n\treturn nil\n}\n\n// replyDelMsg deletes (soft or hard) messages in response to del.msg packet.\nfunc (t *Topic) replyDelMsg(sess *Session, asUid types.Uid, asChan bool, msg *ClientComMessage) error {\n\tnow := types.TimeNow()\n\n\tif asChan {\n\t\t// Do not allow channel readers delete messages.\n\t\tsess.queueOut(ErrOperationNotAllowedReply(msg, now))\n\t\treturn errors.New(\"channel readers cannot delete messages\")\n\t}\n\n\tdel := msg.Del\n\n\tpud := t.perUser[asUid]\n\tif !(pud.modeGiven & pud.modeWant).IsDeleter() {\n\t\t// User must have an R permission: if the user cannot read messages, he has\n\t\t// no business of deleting them.\n\t\tif !(pud.modeGiven & pud.modeWant).IsReader() {\n\t\t\tsess.queueOut(ErrPermissionDeniedReply(msg, now))\n\t\t\treturn errors.New(\"del.msg: permission denied\")\n\t\t}\n\n\t\t// User has just the R permission, cannot hard-delete messages, silently\n\t\t// switching to soft-deleting\n\t\tdel.Hard = false\n\t}\n\n\tvar err error\n\tvar ranges []types.Range\n\tif len(del.DelSeq) == 0 {\n\t\terr = errors.New(\"del.msg: no IDs to delete\")\n\t} else {\n\t\tcount := 0\n\t\tfor _, dq := range del.DelSeq {\n\t\t\tif dq.LowId > t.lastID || dq.LowId < 0 || dq.HiId < 0 ||\n\t\t\t\t(dq.HiId > 0 && dq.LowId > dq.HiId) ||\n\t\t\t\t(dq.LowId == 0 && dq.HiId == 0) {\n\t\t\t\terr = errors.New(\"del.msg: invalid entry in list\")\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tif dq.HiId > t.lastID {\n\t\t\t\t// Range is inclusive - exclusive [low, hi),\n\t\t\t\t// to delete all messages hi must be lastId + 1\n\t\t\t\tdq.HiId = t.lastID + 1\n\t\t\t} else if dq.LowId == dq.HiId || dq.LowId+1 == dq.HiId {\n\t\t\t\tdq.HiId = 0\n\t\t\t}\n\n\t\t\tif dq.HiId == 0 {\n\t\t\t\tcount++\n\t\t\t} else {\n\t\t\t\tcount += dq.HiId - dq.LowId\n\t\t\t}\n\n\t\t\tranges = append(ranges, types.Range{Low: dq.LowId, Hi: dq.HiId})\n\t\t}\n\n\t\tif err == nil {\n\t\t\t// Sort by Low ascending then by Hi descending.\n\t\t\tsort.Sort(types.RangeSorter(ranges))\n\t\t\t// Collapse overlapping ranges\n\t\t\tranges = types.RangeSorter(ranges).Normalize()\n\t\t}\n\n\t\tif count > defaultMaxDeleteCount && len(ranges) > 1 {\n\t\t\terr = errors.New(\"del.msg: too many messages to delete\")\n\t\t}\n\t}\n\n\tif err != nil {\n\t\tsess.queueOut(ErrMalformedReply(msg, now))\n\t\treturn err\n\t}\n\n\tforUser := asUid\n\tvar age time.Duration\n\tif del.Hard {\n\t\tforUser = types.ZeroUid\n\t\tage = globals.msgDeleteAge\n\t}\n\tif err = store.Messages.DeleteList(t.name, t.delID+1, forUser, age, ranges); err != nil {\n\t\tsess.queueOut(ErrUnknownReply(msg, now))\n\t\treturn err\n\t}\n\n\t// Increment Delete transaction ID\n\tt.delID++\n\tdr := rangeDeserialize(ranges)\n\tif del.Hard {\n\t\tfor uid, pud := range t.perUser {\n\t\t\tpud.delID = t.delID\n\t\t\tt.perUser[uid] = pud\n\n\t\t\t// Update unread counters for all users who may have had these messages as unread\n\t\t\tif (pud.modeGiven & pud.modeWant).IsReader() {\n\t\t\t\t// Calculate how many unread messages were deleted for this user\n\t\t\t\tunreadDeleted := calculateUnreadInRanges(pud.readID, t.lastID, ranges)\n\t\t\t\tif unreadDeleted > 0 {\n\t\t\t\t\t// Decrease unread count (negative value)\n\t\t\t\t\tusersUpdateUnread(uid, -unreadDeleted, true)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Broadcast the change to all, online and offline, exclude the session making the change.\n\t\tparams := &presParams{delID: t.delID, delSeq: dr, actor: asUid.UserId()}\n\t\tfilters := &presFilters{filterIn: types.ModeRead}\n\t\tt.presSubsOnline(\"del\", params.actor, params, filters, sess.sid)\n\t\tt.presSubsOffline(\"del\", params, filters, nilPresFilters, sess.sid, true)\n\t} else {\n\t\tpud := t.perUser[asUid]\n\t\tpud.delID = t.delID\n\t\tt.perUser[asUid] = pud\n\n\t\t// Notify user's other sessions\n\t\tt.presPubMessageDelete(asUid, pud.modeGiven&pud.modeWant, t.delID, dr, sess.sid)\n\t}\n\n\tsess.queueOut(NoErrParamsReply(msg, now, map[string]int{\"del\": t.delID}))\n\n\treturn nil\n}\n\n// Handle request to delete the topic {del what=\"topic\"}.\n// 1. If requester is the owner then it should have been handled at the hub, log an error.\n// 2. If requester is not the owner, treat it like {leave unsub=true}.\nfunc (t *Topic) replyDelTopic(sess *Session, asUid types.Uid, msg *ClientComMessage) error {\n\tif t.owner != asUid {\n\t\treturn t.replyLeaveUnsub(sess, msg, asUid)\n\t}\n\n\t// This is an indication of a bug.\n\tlogs.Err.Println(\"replyDelTopic called by owner (SHOULD NOT HAPPEN!)\")\n\treturn nil\n}\n\n// Delete credential\nfunc (t *Topic) replyDelCred(sess *Session, asUid types.Uid, authLvl auth.Level, msg *ClientComMessage) error {\n\tnow := types.TimeNow()\n\tincomingReqTs := msg.Timestamp\n\tdel := msg.Del\n\n\tif t.cat != types.TopicCatMe {\n\t\tsess.queueOut(ErrPermissionDeniedReply(msg, now))\n\t\treturn errors.New(\"del.cred: invalid topic category\")\n\t}\n\tif del.Cred == nil || del.Cred.Method == \"\" {\n\t\tsess.queueOut(ErrMalformedReply(msg, now))\n\t\treturn errors.New(\"del.cred: missing method\")\n\t}\n\n\ttags, err := deleteCred(asUid, authLvl, del.Cred)\n\tif tags != nil {\n\t\t// Check if anything has been actually removed.\n\t\t_, removed, _ := stringSliceDelta(t.tags, tags)\n\t\tif len(removed) > 0 {\n\t\t\tt.tags = tags\n\t\t\tt.presSubsOnline(\"tags\", \"\", nilPresParams, nilPresFilters, \"\")\n\t\t}\n\t} else if err == nil {\n\t\tsess.queueOut(InfoNoActionReply(msg, now))\n\t\treturn nil\n\t}\n\tsess.queueOut(decodeStoreErrorExplicitTs(err, del.Id, del.Topic, now, incomingReqTs, nil))\n\treturn err\n}\n\n// Delete subscription.\nfunc (t *Topic) replyDelSub(sess *Session, asUid types.Uid, msg *ClientComMessage) error {\n\tnow := types.TimeNow()\n\tdel := msg.Del\n\n\tasChan, err := t.verifyChannelAccess(msg.Original)\n\tif err != nil {\n\t\t// User should not be able to address non-channel topic as channel.\n\t\tsess.queueOut(ErrNotFoundReply(msg, now))\n\t\treturn types.ErrNotFound\n\t}\n\tif asChan {\n\t\t// Don't allow channel readers to delete self-subscription. Use leave-unsub or del-topic.\n\t\tsess.queueOut(ErrPermissionDeniedReply(msg, now))\n\t\treturn errors.New(\"channel access denied: cannot delete subscription\")\n\t}\n\n\t// Get ID of the affected user\n\tuid := types.ParseUserId(del.User)\n\n\tpud := t.perUser[asUid]\n\tif !(pud.modeGiven & pud.modeWant).IsAdmin() {\n\t\terr = errors.New(\"del.sub: permission denied\")\n\t} else if uid.IsZero() || uid == asUid {\n\t\t// Cannot delete self-subscription. User [leave unsub] or [delete topic]\n\t\terr = errors.New(\"del.sub: cannot delete self-subscription\")\n\t} else if t.cat == types.TopicCatP2P {\n\t\t// Don't try to delete the other P2P user\n\t\terr = errors.New(\"del.sub: cannot apply to a P2P topic\")\n\t}\n\n\tif err != nil {\n\t\tsess.queueOut(ErrPermissionDeniedReply(msg, now))\n\t\treturn err\n\t}\n\n\tpud, ok := t.perUser[uid]\n\tif !ok {\n\t\tsess.queueOut(InfoNoActionReply(msg, now))\n\t\treturn errors.New(\"del.sub: user not found\")\n\t}\n\n\t// Check if the user being ejected is the owner.\n\tif (pud.modeGiven & pud.modeWant).IsOwner() {\n\t\terr = errors.New(\"del.sub: cannot evict topic owner\")\n\t} else if !pud.modeWant.IsJoiner() {\n\t\t// If the user has banned the topic, subscription should not be deleted. Otherwise user may be re-invited\n\t\t// which defeats the purpose of banning.\n\t\terr = errors.New(\"del.sub: cannot delete banned subscription\")\n\t}\n\n\tif err != nil {\n\t\tsess.queueOut(ErrPermissionDeniedReply(msg, now))\n\t\treturn err\n\t}\n\n\t// Delete user's subscription from the database\n\tif err := store.Subs.Delete(t.name, uid); err != nil {\n\t\tif err == types.ErrNotFound {\n\t\t\tsess.queueOut(InfoNoActionReply(msg, now))\n\t\t} else {\n\t\t\tsess.queueOut(ErrUnknownReply(msg, now))\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\tsess.queueOut(NoErrReply(msg, now))\n\t}\n\n\t// Update cached unread count: negative value\n\tif (pud.modeWant & pud.modeGiven).IsReader() {\n\t\tusersUpdateUnread(uid, pud.readID-t.lastID, true)\n\t}\n\n\t// ModeUnset signifies deleted subscription as opposite to ModeNone - no access.\n\tt.notifySubChange(uid, asUid, false,\n\t\tpud.modeWant, pud.modeGiven, types.ModeUnset, types.ModeUnset, sess.sid)\n\n\tt.evictUser(uid, true, \"\")\n\n\t// Notify plugins.\n\tpluginSubscription(&types.Subscription{Topic: t.name, User: uid.String()}, plgActDel)\n\n\t// If all P2P users were deleted, suspend the topic to let it shut down.\n\tif t.cat == types.TopicCatP2P && t.subsCount() == 0 {\n\t\tt.markPaused(true)\n\t\tglobals.hub.unreg <- &topicUnreg{del: true, sess: nil, rcptTo: t.name, pkt: nil}\n\t}\n\n\treturn nil\n}\n\n// replyLeaveUnsub is a request to unsubscribe user and detach all user's sessions from topic.\nfunc (t *Topic) replyLeaveUnsub(sess *Session, msg *ClientComMessage, asUid types.Uid) error {\n\tnow := types.TimeNow()\n\n\tif asUid.IsZero() {\n\t\tpanic(\"replyLeaveUnsub: zero asUid\")\n\t}\n\n\tif t.owner == asUid {\n\t\tif msg.init {\n\t\t\tsess.queueOut(ErrPermissionDeniedReply(msg, now))\n\t\t}\n\t\treturn errors.New(\"replyLeaveUnsub: owner cannot unsubscribe\")\n\t}\n\n\tvar err error\n\tvar asChan bool\n\tif msg.init {\n\t\tasChan, err = t.verifyChannelAccess(msg.Original)\n\t\tif err != nil {\n\t\t\tsess.queueOut(ErrNotFoundReply(msg, now))\n\t\t\treturn errors.New(\"replyLeaveUnsub: incorrect addressing of channel\")\n\t\t}\n\t}\n\n\tpud := t.perUser[asUid]\n\t// Delete user's subscription from the database; msg could be nil, so cannot use msg.Original.\n\tif pud.isChan {\n\t\t// Handle channel reader.\n\t\terr = store.Subs.Delete(types.GrpToChn(t.name), asUid)\n\t} else {\n\t\t// Handle subscriber.\n\t\terr = store.Subs.Delete(t.name, asUid)\n\t}\n\n\tif err != nil {\n\t\tif msg.init {\n\t\t\tif err == types.ErrNotFound {\n\t\t\t\tsess.queueOut(InfoNoActionReply(msg, now))\n\t\t\t\terr = nil\n\t\t\t} else {\n\t\t\t\tsess.queueOut(ErrUnknownReply(msg, now))\n\t\t\t}\n\t\t}\n\t\treturn err\n\t}\n\n\tif msg.init {\n\t\tsess.queueOut(NoErrReply(msg, now))\n\t}\n\n\tvar oldWant types.AccessMode\n\tvar oldGiven types.AccessMode\n\tif !asChan {\n\t\t// Update cached unread count: negative value\n\t\tif (pud.modeWant & pud.modeGiven).IsReader() {\n\t\t\tusersUpdateUnread(asUid, pud.readID-t.lastID, true)\n\t\t}\n\t\toldWant, oldGiven = pud.modeWant, pud.modeGiven\n\t} else {\n\t\toldWant, oldGiven = types.ModeCChnReader, types.ModeCChnReader\n\t\t// Unsubscribe user's devices from the channel (FCM topic).\n\t\tt.channelSubUnsub(asUid, false)\n\t}\n\n\t// Send prsence notifictions to admins, other users, and user's other sessions.\n\tt.notifySubChange(asUid, asUid, asChan, oldWant, oldGiven, types.ModeUnset, types.ModeUnset, sess.sid)\n\n\t// Evict all user's sessions, clear cached data, send notifications.\n\tt.evictUser(asUid, true, sess.sid)\n\n\t// Notify plugins.\n\tpluginSubscription(&types.Subscription{Topic: t.name, User: asUid.String()}, plgActDel)\n\n\tif t.cat == types.TopicCatGrp {\n\t\t// Decrement group's cached member count.\n\t\tt.subCnt--\n\t}\n\n\t// If all P2P users were deleted, suspend the topic to let it shut down.\n\tif t.cat == types.TopicCatP2P && t.subsCount() == 0 {\n\t\tt.markPaused(true)\n\t\tglobals.hub.unreg <- &topicUnreg{del: true, sess: nil, rcptTo: t.name, pkt: nil}\n\t}\n\n\treturn nil\n}\n\n// evictUser evicts all given user's sessions from the topic and clears user's cached data, if appropriate.\nfunc (t *Topic) evictUser(uid types.Uid, unsub bool, skip string) {\n\tnow := types.TimeNow()\n\tpud, ok := t.perUser[uid]\n\n\t// Detach user from topic\n\tif unsub {\n\t\tif t.cat == types.TopicCatP2P {\n\t\t\t// P2P: mark user as deleted\n\t\t\tpud.online = 0\n\t\t\tpud.deleted = true\n\t\t\tt.perUser[uid] = pud\n\t\t} else if ok {\n\t\t\t// Grp: delete per-user data\n\t\t\tdelete(t.perUser, uid)\n\t\t\tt.computePerUserAcsUnion()\n\n\t\t\tif !pud.isChan {\n\t\t\t\tusersRegisterUser(uid, false)\n\t\t\t}\n\t\t}\n\t} else if ok {\n\t\tif pud.isChan {\n\t\t\tdelete(t.perUser, uid)\n\t\t\t// No need to call computePerUserAcsUnion because removal of a channel reader does not change union permissions.\n\t\t\t// No need to unregister user as we ignore unread channel messages.\n\t\t} else {\n\t\t\t// Clear online status\n\t\t\tpud.online = 0\n\t\t\tt.perUser[uid] = pud\n\t\t}\n\t}\n\n\t// Detach all user's sessions\n\tmsg := NoErrEvicted(\"\", t.original(uid), now)\n\tmsg.Ctrl.Params = map[string]any{\"unsub\": unsub}\n\tmsg.SkipSid = skip\n\tmsg.uid = uid\n\tmsg.AsUser = uid.UserId()\n\tfor s := range t.sessions {\n\t\tif pssd, removed := t.remSession(s, uid); pssd != nil {\n\t\t\tif removed {\n\t\t\t\ts.detachSession(t.name)\n\t\t\t}\n\t\t\tif s.sid != skip {\n\t\t\t\ts.queueOut(msg)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// User's subscription to a topic has changed, send presence notifications.\n// 1. New subscription\n// 2. Deleted subscription\n// 3. Permissions changed\n// Sending to\n// (a) Topic admins online on topic itself.\n// (b) Topic admins offline on 'me' if approval is needed.\n// (c) If subscription is deleted, 'gone' to target.\n// (d) 'off' to topic members online if deleted or muted.\n// (e) To target user.\nfunc (t *Topic) notifySubChange(uid, actor types.Uid, isChan bool,\n\toldWant, oldGiven, newWant, newGiven types.AccessMode, skip string) {\n\n\tunsub := newWant == types.ModeUnset || newGiven == types.ModeUnset\n\n\ttarget := uid.UserId()\n\n\tdWant := types.ModeNone.String()\n\tif newWant.IsDefined() {\n\t\tif oldWant.IsDefined() && !oldWant.IsZero() {\n\t\t\tdWant = oldWant.Delta(newWant)\n\t\t} else {\n\t\t\tdWant = newWant.String()\n\t\t}\n\t}\n\n\tdGiven := types.ModeNone.String()\n\tif newGiven.IsDefined() {\n\t\tif oldGiven.IsDefined() && !oldGiven.IsZero() {\n\t\t\tdGiven = oldGiven.Delta(newGiven)\n\t\t} else {\n\t\t\tdGiven = newGiven.String()\n\t\t}\n\t}\n\tparams := &presParams{\n\t\ttarget: target,\n\t\tactor:  actor.UserId(),\n\t\tdWant:  dWant,\n\t\tdGiven: dGiven,\n\t}\n\n\tfilterSharers := &presFilters{\n\t\tfilterIn:    types.ModeCSharer,\n\t\texcludeUser: target,\n\t}\n\n\t// Announce the change in permissions to the admins who are online in the topic, exclude the target\n\t// and exclude the actor's session.\n\tt.presSubsOnline(\"acs\", target, params, filterSharers, skip)\n\n\t// If it's a new subscription or if the user asked for permissions in excess of what was granted,\n\t// announce the request to topic admins on 'me' so they can approve the request. The notification\n\t// is not sent to the target user or the actor's session.\n\tif newWant.BetterThan(newGiven) || oldWant == types.ModeNone {\n\t\tt.presSubsOffline(\"acs\", params, filterSharers, filterSharers, skip, true)\n\t}\n\n\t// Handling of muting/unmuting.\n\t// Case A: subscription deleted.\n\t// Case B: subscription muted only.\n\tif unsub {\n\t\t// Subscription deleted.\n\n\t\t// In case of a P2P topic subscribe/unsubscribe users from each other's notifications.\n\t\tif t.cat == types.TopicCatP2P {\n\t\t\tuid2 := t.p2pOtherUser(uid)\n\t\t\t// Remove user1's subscription to user2 and notify user1's other sessions that he is gone.\n\t\t\tt.presSingleUserOffline(uid, newWant&newGiven, \"gone\", nilPresParams, skip, false)\n\t\t\t// Tell user2 that user1 is offline but let him keep sending updates in case user1 resubscribes.\n\t\t\tpresSingleUserOfflineOffline(uid2, target, \"off\", nilPresParams, \"\")\n\t\t} else if t.cat == types.TopicCatGrp && !isChan {\n\t\t\t// Notify all sharers that the user is offline now.\n\t\t\tt.presSubsOnline(\"off\", uid.UserId(), nilPresParams, filterSharers, skip)\n\t\t\t// Notify target that the subscription is gone.\n\t\t\tpresSingleUserOfflineOffline(uid, t.name, \"gone\", nilPresParams, skip)\n\t\t}\n\t} else {\n\t\t// Subscription altered.\n\n\t\tif !(newWant & newGiven).IsPresencer() && (oldWant & oldGiven).IsPresencer() {\n\t\t\t// Subscription just muted.\n\n\t\t\tvar source string\n\t\t\tif t.cat == types.TopicCatP2P {\n\t\t\t\tsource = t.p2pOtherUser(uid).UserId()\n\t\t\t} else if t.cat == types.TopicCatGrp && !isChan {\n\t\t\t\tsource = t.name\n\t\t\t}\n\t\t\tif source != \"\" {\n\t\t\t\t// Tell user1 to start discarding updates from muted topic/user.\n\t\t\t\tpresSingleUserOfflineOffline(uid, source, \"off+dis\", nilPresParams, \"\")\n\t\t\t}\n\n\t\t} else if (newWant & newGiven).IsPresencer() && !(oldWant & oldGiven).IsPresencer() {\n\t\t\t// Subscription un-muted.\n\n\t\t\t// Notify subscriber of topic's online status.\n\t\t\tif t.cat == types.TopicCatGrp && !isChan {\n\t\t\t\tt.presSingleUserOffline(uid, newWant&newGiven, \"?unkn+en\", nilPresParams, \"\", false)\n\t\t\t} else if t.cat == types.TopicCatMe {\n\t\t\t\t// User is visible online now, notify subscribers.\n\t\t\t\tt.presUsersOfInterest(\"on+en\", t.userAgent)\n\t\t\t}\n\t\t}\n\n\t\t// Notify target that permissions have changed.\n\n\t\t// Notify sessions online in the topic.\n\t\tt.presSubsOnlineDirect(\"acs\", params, &presFilters{singleUser: target}, skip)\n\t\t// Notify target's other sessions on 'me'.\n\t\tt.presSingleUserOffline(uid, newWant&newGiven, \"acs\", params, skip, true)\n\t}\n}\n\n// FIXME: this won't work correctly with multiplexing sessions.\nfunc (t *Topic) mostRecentSession() *Session {\n\tvar sess *Session\n\tvar latest int64\n\tfor s := range t.sessions {\n\t\tsessionLastAction := atomic.LoadInt64(&s.lastAction)\n\t\tif sessionLastAction > latest {\n\t\t\tsess = s\n\t\t\tlatest = sessionLastAction\n\t\t}\n\t}\n\treturn sess\n}\n\nconst (\n\t// Topic is fully initialized.\n\ttopicStatusLoaded = 0x1\n\t// Topic is paused: all packets are rejected.\n\ttopicStatusPaused = 0x2\n\n\t// Topic is in the process of being deleted. This is irrecoverable.\n\ttopicStatusMarkedDeleted = 0x10\n\t// Topic is suspended: read-only mode.\n\ttopicStatusReadOnly = 0x20\n)\n\n// statusChangeBits sets or removes given bits from t.status\nfunc (t *Topic) statusChangeBits(bits int32, set bool) {\n\tfor {\n\t\toldStatus := atomic.LoadInt32(&t.status)\n\t\tnewStatus := oldStatus\n\t\tif set {\n\t\t\tnewStatus |= bits\n\t\t} else {\n\t\t\tnewStatus &= ^bits\n\t\t}\n\t\tif newStatus == oldStatus {\n\t\t\tbreak\n\t\t}\n\t\tif atomic.CompareAndSwapInt32(&t.status, oldStatus, newStatus) {\n\t\t\tbreak\n\t\t}\n\t}\n}\n\n// markLoaded indicates that topic subscribers have been loaded into memory.\nfunc (t *Topic) markLoaded() {\n\tt.statusChangeBits(topicStatusLoaded, true)\n}\n\n// markPaused pauses or unpauses the topic. When the topic is paused all\n// messages are rejected.\nfunc (t *Topic) markPaused(pause bool) {\n\tt.statusChangeBits(topicStatusPaused, pause)\n}\n\n// markDeleted marks topic as being deleted.\nfunc (t *Topic) markDeleted() {\n\tt.statusChangeBits(topicStatusMarkedDeleted, true)\n}\n\n// markReadOnly suspends/un-suspends the topic: adds or removes the 'read-only' flag.\nfunc (t *Topic) markReadOnly(readOnly bool) {\n\tt.statusChangeBits(topicStatusReadOnly, readOnly)\n}\n\n// isInactive checks if topic is paused or being deleted.\nfunc (t *Topic) isInactive() bool {\n\treturn (atomic.LoadInt32(&t.status) & (topicStatusPaused | topicStatusMarkedDeleted)) != 0\n}\n\nfunc (t *Topic) isReadOnly() bool {\n\treturn (atomic.LoadInt32(&t.status) & topicStatusReadOnly) != 0\n}\n\nfunc (t *Topic) isLoaded() bool {\n\treturn (atomic.LoadInt32(&t.status) & topicStatusLoaded) != 0\n}\n\nfunc (t *Topic) isDeleted() bool {\n\treturn (atomic.LoadInt32(&t.status) & topicStatusMarkedDeleted) != 0\n}\n\n// Get topic name suitable for the given client\nfunc (t *Topic) original(uid types.Uid) string {\n\tif t.cat == types.TopicCatP2P {\n\t\tif pud, ok := t.perUser[uid]; ok {\n\t\t\treturn pud.topicName\n\t\t}\n\t\tpanic(\"Invalid P2P topic\")\n\t}\n\n\tif t.cat == types.TopicCatGrp && t.isChan {\n\t\tif t.perUser[uid].isChan {\n\t\t\t// This is a channel reader.\n\t\t\treturn types.GrpToChn(t.xoriginal)\n\t\t}\n\t}\n\treturn t.xoriginal\n}\n\n// Get ID of the other user in a P2P topic\nfunc (t *Topic) p2pOtherUser(uid types.Uid) types.Uid {\n\tif t.cat == types.TopicCatP2P {\n\t\t// Try to find user in subscribers.\n\t\tfor u2 := range t.perUser {\n\t\t\tif u2.Compare(uid) != 0 {\n\t\t\t\treturn u2\n\t\t\t}\n\t\t}\n\t}\n\n\t// Even when one user is deleted, the subscription must be restored\n\t// before p2pOtherUser is called.\n\tpanic(\"Not a valid P2P topic\")\n}\n\n// Get per-session value of fnd.Public\nfunc (t *Topic) fndGetPublic(sess *Session) string {\n\tif t.cat == types.TopicCatFnd {\n\t\tif t.public == nil {\n\t\t\treturn \"\"\n\t\t}\n\t\tif pubmap, ok := t.public.(map[string]any); ok {\n\t\t\tif public, ok := pubmap[sess.sid].(string); ok {\n\t\t\t\treturn public\n\t\t\t}\n\t\t\treturn \"\"\n\t\t}\n\t\tpanic(\"Invalid Fnd.Public type\")\n\t}\n\tpanic(\"Not Fnd topic\")\n}\n\n// Assign per-session fnd.Public. Returns true if value has been changed.\nfunc (t *Topic) fndSetPublic(sess *Session, public any) bool {\n\tif t.cat != types.TopicCatFnd {\n\t\tpanic(\"Not Fnd topic\")\n\t}\n\n\tvar pubmap map[string]any\n\tvar ok bool\n\tif t.public != nil {\n\t\tif pubmap, ok = t.public.(map[string]any); !ok {\n\t\t\t// This could only happen if fnd.public is assigned outside of this function.\n\t\t\tpanic(\"Invalid Fnd.Public type\")\n\t\t}\n\t}\n\tif pubmap == nil {\n\t\tpubmap = make(map[string]any)\n\t}\n\n\tif public != nil {\n\t\tpubmap[sess.sid] = public\n\t} else {\n\t\tok = (pubmap[sess.sid] != nil)\n\t\tdelete(pubmap, sess.sid)\n\t\tif len(pubmap) == 0 {\n\t\t\tpubmap = nil\n\t\t}\n\t}\n\tt.public = pubmap\n\treturn ok\n}\n\n// Remove per-session value of fnd.Public.\nfunc (t *Topic) fndRemovePublic(sess *Session) {\n\tif t.public == nil {\n\t\treturn\n\t}\n\t// FIXME: case of a multiplexing session won't work correctly.\n\t// Maybe handle it at the proxy topic.\n\tif pubmap, ok := t.public.(map[string]any); ok {\n\t\tdelete(pubmap, sess.sid)\n\t\treturn\n\t}\n\tpanic(\"Invalid Fnd.Public type\")\n}\n\nfunc (t *Topic) accessFor(authLvl auth.Level) types.AccessMode {\n\treturn selectAccessMode(authLvl, t.accessAnon, t.accessAuth, getDefaultAccess(t.cat, true, false))\n}\n\n// subsCount returns the number of topic subscribers. This method is different from subCnt with respect to channels:\n// * subsCount counts subscribers + attached channel users.\n// * subCnt counts all subscribers (including all channel users).\nfunc (t *Topic) subsCount() int {\n\tif t.cat == types.TopicCatP2P {\n\t\tcount := 0\n\t\tfor uid := range t.perUser {\n\t\t\tif !t.perUser[uid].deleted {\n\t\t\t\tcount++\n\t\t\t}\n\t\t}\n\t\treturn count\n\t}\n\treturn len(t.perUser)\n}\n\n// Add session record. 'user' may be different from sess.uid.\nfunc (t *Topic) addSession(sess *Session, asUid types.Uid, isChanSub bool) {\n\ts := sess\n\tif sess.multi != nil {\n\t\ts = s.multi\n\t}\n\n\tif pssd, ok := t.sessions[s]; ok {\n\t\t// Subscription already exists.\n\t\tif s.isMultiplex() && !sess.background {\n\t\t\t// This slice is expected to be relatively short.\n\t\t\t// Not doing anything fancy here like maps or sorting.\n\t\t\tpssd.muids = append(pssd.muids, asUid)\n\t\t\tt.sessions[s] = pssd\n\t\t}\n\t\t// Maybe panic here.\n\t\treturn\n\t}\n\n\tif s.isMultiplex() {\n\t\tif sess.background {\n\t\t\tt.sessions[s] = perSessionData{}\n\t\t} else {\n\t\t\tt.sessions[s] = perSessionData{muids: []types.Uid{asUid}, isChanSub: isChanSub}\n\t\t}\n\t} else {\n\t\tt.sessions[s] = perSessionData{uid: asUid, isChanSub: isChanSub}\n\t}\n}\n\n// Disconnects session from topic if either one of the following is true:\n// * 's' is an ordinary session AND ('asUid' is zero OR 'asUid' matches subscribed user).\n// * 's' is a multiplexing session and it's being dropped all together ('asUid' is zero ).\n// If 's' is a multiplexing session and asUid is not zero, it's removed from the list of session\n// users 'muids'.\n// Returns perSessionData if it was found and true if session was actually detached from topic.\nfunc (t *Topic) remSession(sess *Session, asUid types.Uid) (*perSessionData, bool) {\n\ts := sess\n\tif sess.multi != nil {\n\t\ts = s.multi\n\t}\n\tpssd, ok := t.sessions[s]\n\tif !ok {\n\t\t// Session not found at all.\n\t\treturn nil, false\n\t}\n\n\tif pssd.uid == asUid || asUid.IsZero() {\n\t\tdelete(t.sessions, s)\n\t\treturn &pssd, true\n\t}\n\n\tfor i := range pssd.muids {\n\t\tif pssd.muids[i] == asUid {\n\t\t\tpssd.muids[i] = pssd.muids[len(pssd.muids)-1]\n\t\t\tpssd.muids = pssd.muids[:len(pssd.muids)-1]\n\t\t\tt.sessions[s] = pssd\n\t\t\tif len(pssd.muids) == 0 {\n\t\t\t\tdelete(t.sessions, s)\n\t\t\t\treturn &pssd, true\n\t\t\t}\n\n\t\t\treturn &pssd, false\n\t\t}\n\t}\n\n\treturn nil, false\n}\n\n// Check if topic has any online (non-background) users.\nfunc (t *Topic) isOnline() bool {\n\t// Find at least one non-background session.\n\tfor s, pssd := range t.sessions {\n\t\tif s.isMultiplex() && len(pssd.muids) > 0 {\n\t\t\treturn true\n\t\t}\n\t\tif !s.background {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// Verifies if topic can be access by the provided name: access any topic as non-channel, access channel as channel.\n// Returns true if access is for channel, false if not and error if access is invalid.\nfunc (t *Topic) verifyChannelAccess(asTopic string) (bool, error) {\n\tif !types.IsChannel(asTopic) {\n\t\treturn false, nil\n\t}\n\tif t.isChan {\n\t\treturn true, nil\n\t}\n\treturn false, types.ErrNotFound\n}\n\n// Infer topic category from name.\nfunc topicCat(name string) types.TopicCat {\n\treturn types.GetTopicCat(name)\n}\n\n// Generate the name of the group topic as a \"grp\" followed by random-looking\n// unique string.\nfunc genTopicName() string {\n\treturn \"grp\" + store.Store.GetUidString()\n}\n\n// Convert expanded (routable) topic name into name suitable for sending to the user.\n// For example p2pAbCDef123 -> usrAbCDef\nfunc topicNameForUser(name string, uid types.Uid, isChan bool) string {\n\tswitch topicCat(name) {\n\tcase types.TopicCatMe:\n\t\treturn \"me\"\n\tcase types.TopicCatFnd:\n\t\treturn \"fnd\"\n\tcase types.TopicCatP2P:\n\t\ttopic, _ := types.P2PNameForUser(uid, name)\n\t\treturn topic\n\tcase types.TopicCatGrp:\n\t\tif isChan {\n\t\t\treturn types.GrpToChn(name)\n\t\t}\n\t}\n\treturn name\n}\n\n// calculateUnreadInRanges calculates how many unread messages are within the given ranges.\n// unreadStart is the first unread message SeqId (readID + 1), unreadEnd is the last possible message SeqId.\n// Assumes ranges are sorted by Low ascending.\nfunc calculateUnreadInRanges(readID, lastID int, ranges []types.Range) int {\n\tif readID >= lastID {\n\t\t// No unread messages\n\t\treturn 0\n\t}\n\n\tunreadStart := readID + 1\n\tunreadEnd := lastID\n\n\t// Sum up unread messages.\n\tcount := 0\n\n\tfor i := 0; i < len(ranges); i++ {\n\t\trangeStart := ranges[i].Low\n\t\trangeEnd := ranges[i].Hi\n\t\tif rangeEnd == 0 {\n\t\t\trangeEnd = rangeStart + 1\n\t\t}\n\t\t// Find the first range where rangeEnd > readID\n\t\tif rangeEnd <= readID {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Find intersection of [unreadStart, unreadEnd] and [rangeStart, rangeEnd)\n\t\tintersectionStart := max(unreadStart, rangeStart)\n\t\tintersectionEnd := min(unreadEnd+1, rangeEnd) // +1 because unreadEnd is inclusive\n\n\t\tif intersectionStart < intersectionEnd {\n\t\t\tcount += intersectionEnd - intersectionStart\n\t\t}\n\t}\n\n\treturn count\n}\n"
  },
  {
    "path": "server/topic_proxy.go",
    "content": "/******************************************************************************\n *  Description :\n *    Topic in a cluster which serves as a local representation of the master\n *    topic hosted at another node.\n *****************************************************************************/\n\npackage main\n\nimport (\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\nfunc (t *Topic) runProxy(hub *Hub) {\n\tkillTimer := time.NewTimer(time.Hour)\n\tkillTimer.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase msg := <-t.reg:\n\t\t\t// Request to add a connection to this topic\n\t\t\tif t.isInactive() {\n\t\t\t\tmsg.sess.queueOut(ErrLockedReply(msg, types.TimeNow()))\n\t\t\t} else if err := globals.cluster.routeToTopicMaster(ProxyReqJoin, msg, t.name, msg.sess); err != nil {\n\t\t\t\t// Response (ctrl message) will be handled when it's received via the proxy channel.\n\t\t\t\tlogs.Warn.Printf(\"proxy topic[%s]: route join request from proxy to master failed - %s\", t.name, err)\n\t\t\t\tmsg.sess.queueOut(ErrClusterUnreachableReply(msg, types.TimeNow()))\n\t\t\t}\n\t\t\tif msg.sess.inflightReqs != nil {\n\t\t\t\tmsg.sess.inflightReqs.Done()\n\t\t\t}\n\n\t\tcase msg := <-t.unreg:\n\t\t\tif !t.handleProxyLeaveRequest(msg, killTimer) {\n\t\t\t\tsid := \"nil\"\n\t\t\t\tif msg.sess != nil {\n\t\t\t\t\tsid = msg.sess.sid\n\t\t\t\t}\n\t\t\t\tlogs.Warn.Printf(\"proxy topic[%s]: failed to update proxy topic state for leave request - sid %s\", t.name, sid)\n\t\t\t\tmsg.sess.queueOut(ErrClusterUnreachableReply(msg, types.TimeNow()))\n\t\t\t}\n\t\t\tif msg.init && msg.sess.inflightReqs != nil {\n\t\t\t\t// If it's a client initiated request.\n\t\t\t\tmsg.sess.inflightReqs.Done()\n\t\t\t}\n\n\t\tcase msg := <-t.clientMsg:\n\t\t\t// Content message intended for broadcasting to recipients\n\t\t\tif err := globals.cluster.routeToTopicMaster(ProxyReqBroadcast, msg, t.name, msg.sess); err != nil {\n\t\t\t\tlogs.Warn.Printf(\"topic proxy[%s]: route broadcast request from proxy to master failed - %s\", t.name, err)\n\t\t\t\tmsg.sess.queueOut(ErrClusterUnreachableReply(msg, types.TimeNow()))\n\t\t\t}\n\n\t\tcase msg := <-t.serverMsg:\n\t\t\tif msg.Info != nil || msg.Pres != nil {\n\t\t\t\tglobals.cluster.routeToTopicIntraCluster(t.name, msg, msg.sess)\n\t\t\t} else {\n\t\t\t\t// FIXME: should something be done here?\n\t\t\t\tlogs.Err.Printf(\"ERROR!!! topic proxy[%s]: unexpected server-side message in proxy topic %s\", t.name, msg.describe())\n\t\t\t}\n\n\t\tcase msg := <-t.meta:\n\t\t\t// Request to get/set topic metadata\n\t\t\tif err := globals.cluster.routeToTopicMaster(ProxyReqMeta, msg, t.name, msg.sess); err != nil {\n\t\t\t\tlogs.Warn.Printf(\"proxy topic[%s]: route meta request from proxy to master failed - %s\", t.name, err)\n\t\t\t\tmsg.sess.queueOut(ErrClusterUnreachableReply(msg, types.TimeNow()))\n\t\t\t}\n\n\t\tcase upd := <-t.supd:\n\t\t\t// Either an update to 'me' user agent from one of the sessions or\n\t\t\t// background session comes to foreground.\n\t\t\treq := ProxyReqMeUserAgent\n\t\t\ttmpSess := &Session{userAgent: upd.userAgent}\n\t\t\tif upd.sess != nil {\n\t\t\t\t// Subscribed user may not match session user. Find out who is subscribed\n\t\t\t\tpssd, ok := t.sessions[upd.sess]\n\t\t\t\tif !ok {\n\t\t\t\t\tlogs.Warn.Printf(\"proxy topic[%s]: sess update request from detached session - sid %s\", t.name, upd.sess.sid)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\treq = ProxyReqBgSession\n\t\t\t\ttmpSess.uid = pssd.uid\n\t\t\t\ttmpSess.sid = upd.sess.sid\n\t\t\t\ttmpSess.userAgent = upd.sess.userAgent\n\t\t\t}\n\t\t\tif err := globals.cluster.routeToTopicMaster(req, nil, t.name, tmpSess); err != nil {\n\t\t\t\tlogs.Warn.Printf(\"proxy topic[%s]: route sess update request from proxy to master failed - %s\", t.name, err)\n\t\t\t}\n\n\t\tcase msg := <-t.proxy:\n\t\t\tt.proxyMasterResponse(msg, killTimer)\n\n\t\tcase sd := <-t.exit:\n\t\t\t// Tell sessions to remove the topic\n\t\t\tfor s := range t.sessions {\n\t\t\t\ts.detachSession(t.name)\n\t\t\t}\n\n\t\t\tif err := globals.cluster.topicProxyGone(t.name); err != nil {\n\t\t\t\tlogs.Warn.Printf(\"proxy topic[%s] shutdown: failed to notify master - %s\", t.name, err)\n\t\t\t}\n\n\t\t\t// Report completion back to sender, if 'done' is not nil.\n\t\t\tif sd.done != nil {\n\t\t\t\tsd.done <- true\n\t\t\t}\n\t\t\treturn\n\n\t\tcase <-killTimer.C:\n\t\t\t// Topic timeout\n\t\t\thub.unreg <- &topicUnreg{rcptTo: t.name}\n\t\t}\n\t}\n}\n\n// Takes a session leave request, forwards it to the topic master and\n// modifies the local state accordingly.\n// Returns whether the operation was successful.\nfunc (t *Topic) handleProxyLeaveRequest(msg *ClientComMessage, killTimer *time.Timer) bool {\n\t// Detach session from topic; session may continue to function.\n\tvar asUid types.Uid\n\tif msg.init {\n\t\tasUid = types.ParseUserId(msg.AsUser)\n\t}\n\n\tif asUid.IsZero() {\n\t\tif pssd, ok := t.sessions[msg.sess]; ok {\n\t\t\tasUid = pssd.uid\n\t\t} else {\n\t\t\tlogs.Warn.Printf(\"proxy topic[%s]: leave request sent for unknown session\", t.name)\n\t\t\treturn false\n\t\t}\n\t}\n\t// Remove the session from the topic without waiting for a response from the master node\n\t// because by the time the response arrives this session may be already gone from the session store\n\t// and we won't be able to find and remove it by its sid.\n\tpssd, result := t.remSession(msg.sess, asUid)\n\tif result {\n\t\tmsg.sess.delSub(t.name)\n\t}\n\tif !msg.init {\n\t\t// Explicitly specify the uid because the master multiplex session needs to know which\n\t\t// of its multiple hosted sessions to delete.\n\t\tmsg.AsUser = asUid.UserId()\n\t\tmsg.Leave = &MsgClientLeave{}\n\t\tmsg.init = true\n\t}\n\t// Make sure we set the Original field if it's empty (e.g. when session is terminating altogether).\n\tif msg.Original == \"\" {\n\t\tif t.cat == types.TopicCatGrp && t.isChan {\n\t\t\t// It's a channel topic. Original topic name depends the subscription type.\n\t\t\tif result && pssd.isChanSub {\n\t\t\t\tmsg.Original = types.GrpToChn(t.xoriginal)\n\t\t\t} else {\n\t\t\t\tmsg.Original = t.xoriginal\n\t\t\t}\n\t\t} else {\n\t\t\tmsg.Original = t.original(asUid)\n\t\t}\n\t}\n\n\tif err := globals.cluster.routeToTopicMaster(ProxyReqLeave, msg, t.name, msg.sess); err != nil {\n\t\tlogs.Warn.Printf(\"proxy topic[%s]: route leave request from proxy to master failed - %s\", t.name, err)\n\t}\n\tif len(t.sessions) == 0 {\n\t\t// No more sessions attached. Start the countdown.\n\t\tkillTimer.Reset(idleProxyTopicTimeout)\n\t}\n\treturn result\n}\n\n// proxyMasterResponse at proxy topic processes a master topic response to an earlier request.\nfunc (t *Topic) proxyMasterResponse(msg *ClusterResp, killTimer *time.Timer) {\n\t// Kills topic after a period of inactivity.\n\tkeepAlive := idleProxyTopicTimeout\n\n\tif msg.SrvMsg.Pres != nil && msg.SrvMsg.Pres.What == \"acs\" && msg.SrvMsg.Pres.Acs != nil {\n\t\t// If the server changed acs on this topic, update the internal state.\n\t\tt.updateAcsFromPresMsg(msg.SrvMsg.Pres)\n\t}\n\n\tif msg.OrigSid == \"*\" {\n\t\t// It is a broadcast.\n\t\tswitch {\n\t\tcase msg.SrvMsg.Pres != nil || msg.SrvMsg.Data != nil || msg.SrvMsg.Info != nil:\n\t\t\t// Regular broadcast.\n\t\t\tt.handleProxyBroadcast(msg.SrvMsg)\n\t\tcase msg.SrvMsg.Ctrl != nil:\n\t\t\t// Ctrl broadcast. E.g. for user eviction.\n\t\t\tt.proxyCtrlBroadcast(msg.SrvMsg)\n\t\tdefault:\n\t\t}\n\t} else {\n\t\tsess := globals.sessionStore.Get(msg.OrigSid)\n\t\tif sess == nil {\n\t\t\tlogs.Warn.Printf(\"proxy topic[%s]: session %s not found; already terminated?\", t.name, msg.OrigSid)\n\t\t}\n\t\tswitch msg.OrigReqType {\n\t\tcase ProxyReqJoin:\n\t\t\tif sess != nil && msg.SrvMsg.Ctrl != nil {\n\t\t\t\t// TODO: do we need to let the master topic know that the subscription is not longer valid\n\t\t\t\t// or is it already informed by the session when it terminated?\n\n\t\t\t\t// Subscription result.\n\t\t\t\tif msg.SrvMsg.Ctrl.Code < 300 {\n\t\t\t\t\tsess.sessionStoreLock.Lock()\n\t\t\t\t\t// Make sure the session isn't gone yet.\n\t\t\t\t\tif session := globals.sessionStore.Get(msg.OrigSid); session != nil {\n\t\t\t\t\t\t// Successful subscriptions.\n\t\t\t\t\t\tt.addSession(session, msg.SrvMsg.uid, types.IsChannel(msg.SrvMsg.Ctrl.Topic))\n\t\t\t\t\t\tsession.addSub(t.name, &Subscription{\n\t\t\t\t\t\t\tbroadcast: t.clientMsg,\n\t\t\t\t\t\t\tdone:      t.unreg,\n\t\t\t\t\t\t\tmeta:      t.meta,\n\t\t\t\t\t\t\tsupd:      t.supd,\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t\tsess.sessionStoreLock.Unlock()\n\n\t\t\t\t\tkillTimer.Stop()\n\t\t\t\t} else if len(t.sessions) == 0 {\n\t\t\t\t\tkillTimer.Reset(keepAlive)\n\t\t\t\t}\n\t\t\t}\n\t\tcase ProxyReqBroadcast, ProxyReqMeta, ProxyReqCall:\n\t\t\t// no processing\n\t\tcase ProxyReqLeave:\n\t\t\tif msg.SrvMsg != nil && msg.SrvMsg.Ctrl != nil {\n\t\t\t\tif msg.SrvMsg.Ctrl.Code < 300 {\n\t\t\t\t\tif sess != nil {\n\t\t\t\t\t\tt.remSession(sess, sess.uid)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// All sessions are gone. Start the kill timer.\n\t\t\t\tif len(t.sessions) == 0 {\n\t\t\t\t\tkillTimer.Reset(keepAlive)\n\t\t\t\t}\n\t\t\t}\n\n\t\tdefault:\n\t\t\tlogs.Err.Printf(\"proxy topic[%s] received response referencing unexpected request type %d\",\n\t\t\t\tt.name, msg.OrigReqType)\n\t\t}\n\n\t\tif sess != nil && !sess.queueOut(msg.SrvMsg) {\n\t\t\tlogs.Err.Printf(\"proxy topic[%s]: timeout in sending response - sid %s\", t.name, sess.sid)\n\t\t}\n\t}\n}\n\n// handleProxyBroadcast broadcasts a Data, Info or Pres message to sessions attached to this proxy topic.\nfunc (t *Topic) handleProxyBroadcast(msg *ServerComMessage) {\n\tif t.isInactive() {\n\t\t// Ignore broadcast - topic is paused or being deleted.\n\t\treturn\n\t}\n\n\tif msg.Data != nil {\n\t\tt.lastID = msg.Data.SeqId\n\t}\n\n\tt.broadcastToSessions(msg)\n}\n\n// proxyCtrlBroadcast broadcasts a ctrl command to certain sessions attached to this proxy topic.\nfunc (t *Topic) proxyCtrlBroadcast(msg *ServerComMessage) {\n\tif msg.Ctrl.Code == http.StatusResetContent && msg.Ctrl.Text == \"evicted\" {\n\t\t// We received a ctrl command for evicting a user.\n\t\tif msg.uid.IsZero() {\n\t\t\tlogs.Err.Panicf(\"proxy topic[%s]: proxy received evict message with empty uid\", t.name)\n\t\t}\n\t\tfor sess := range t.sessions {\n\t\t\t// Proxy topic may only have ordinary sessions. No multiplexing or proxy sessions here.\n\t\t\tif _, removed := t.remSession(sess, msg.uid); removed {\n\t\t\t\tsess.detachSession(t.name)\n\t\t\t\tif sess.sid != msg.SkipSid {\n\t\t\t\t\tsess.queueOut(msg)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\n// updateAcsFromPresMsg modifies user acs in Topic's perUser struct based on the data in `pres`.\nfunc (t *Topic) updateAcsFromPresMsg(pres *MsgServerPres) {\n\tuid := types.ParseUserId(pres.Src)\n\tif uid.IsZero() {\n\t\tif t.cat != types.TopicCatMe {\n\t\t\tlogs.Warn.Printf(\"proxy topic[%s]: received acs change for invalid user id '%s'\", t.name, pres.Src)\n\t\t}\n\t\treturn\n\t}\n\n\t// If t.perUser[uid] does not exist, pud is initialized with blanks, otherwise it gets existing values.\n\tpud := t.perUser[uid]\n\tdacs := pres.Acs\n\tif err := pud.modeWant.ApplyMutation(dacs.Want); err != nil {\n\t\tlogs.Warn.Printf(\"proxy topic[%s]: could not process acs change - want: %s\", t.name, err)\n\t\treturn\n\t}\n\tif err := pud.modeGiven.ApplyMutation(dacs.Given); err != nil {\n\t\tlogs.Warn.Printf(\"proxy topic[%s]: could not process acs change - given: %s\", t.name, err)\n\t\treturn\n\t}\n\t// Update existing or add new.\n\tt.perUser[uid] = pud\n}\n"
  },
  {
    "path": "server/topic_test.go",
    "content": "package main\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/golang/mock/gomock\"\n\t\"github.com/tinode/chat/server/auth\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/mock_store\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\ntype responses struct {\n\tmessages []any\n}\n\n// Test fixture.\ntype TopicTestHelper struct {\n\tnumUsers int\n\tuids     []types.Uid\n\n\t// Gomock controller.\n\tctrl *gomock.Controller\n\n\t// Sessions.\n\tsessions []*Session\n\tsessWg   *sync.WaitGroup\n\t// Per-session responses (i.e. what gets dumped into sessions' write loops).\n\tresults []*responses\n\n\t// Hub.\n\thub *Hub\n\t// Messages captured from Hub.route channel on the per-user (RcptTo) basis.\n\thubMessages map[string][]*ServerComMessage\n\t// For stopping hub loop.\n\thubDone chan bool\n\n\t// Topic.\n\ttopic *Topic\n\n\t// Mock objects.\n\tmm *mock_store.MockMessagesPersistenceInterface\n\tuu *mock_store.MockUsersPersistenceInterface\n\ttt *mock_store.MockTopicsPersistenceInterface\n\tss *mock_store.MockSubsPersistenceInterface\n}\n\nfunc (b *TopicTestHelper) finish() {\n\tb.topic.killTimer.Stop()\n\tb.topic.callEstablishmentTimer.Stop()\n\t// Stop session write loops.\n\tfor _, s := range b.sessions {\n\t\tclose(s.send)\n\t}\n\tb.sessWg.Wait()\n\t// Hub loop.\n\tclose(b.hub.routeSrv)\n\tclose(b.hub.routeCli)\n\t<-b.hubDone\n}\n\nfunc (b *TopicTestHelper) newSession(sid string, uid types.Uid) (*Session, *responses) {\n\ts := &Session{\n\t\tsid:    sid,\n\t\tuid:    uid,\n\t\tsubs:   make(map[string]*Subscription),\n\t\tsend:   make(chan any, 10),\n\t\tdetach: make(chan string, 10),\n\t}\n\tr := &responses{}\n\tb.sessWg.Add(1)\n\tgo s.testWriteLoop(r, b.sessWg)\n\treturn s, r\n}\n\nfunc (b *TopicTestHelper) setUp(t *testing.T, numUsers int, cat types.TopicCat, topicName string, attachSessions bool) {\n\tt.Helper()\n\tb.numUsers = numUsers\n\tb.uids = make([]types.Uid, numUsers)\n\tfor i := range numUsers {\n\t\t// Can't use 0 as a valid uid.\n\t\tb.uids[i] = types.Uid(i + 1)\n\t}\n\n\t// Mocks.\n\tb.ctrl = gomock.NewController(t)\n\tb.mm = mock_store.NewMockMessagesPersistenceInterface(b.ctrl)\n\tb.uu = mock_store.NewMockUsersPersistenceInterface(b.ctrl)\n\tb.tt = mock_store.NewMockTopicsPersistenceInterface(b.ctrl)\n\tb.ss = mock_store.NewMockSubsPersistenceInterface(b.ctrl)\n\tstore.Messages = b.mm\n\tstore.Users = b.uu\n\tstore.Topics = b.tt\n\tstore.Subs = b.ss\n\t// Sessions.\n\tb.sessions = make([]*Session, b.numUsers)\n\tb.results = make([]*responses, b.numUsers)\n\tb.sessWg = &sync.WaitGroup{}\n\tfor i := range b.sessions {\n\t\ts, r := b.newSession(fmt.Sprintf(\"sid%d\", i), b.uids[i])\n\t\tb.results[i] = r\n\t\tb.sessions[i] = s\n\t}\n\n\t// Hub.\n\tb.hub = &Hub{\n\t\trouteCli: make(chan *ClientComMessage, 10),\n\t\trouteSrv: make(chan *ServerComMessage, 10),\n\t}\n\tglobals.hub = b.hub\n\tb.hubMessages = make(map[string][]*ServerComMessage)\n\tb.hubDone = make(chan bool)\n\tgo b.hub.testHubLoop(t, b.hubMessages, b.hubDone)\n\n\t// Topic.\n\tpu := make(map[types.Uid]perUserData)\n\tps := make(map[*Session]perSessionData)\n\tfor i, uid := range b.uids {\n\t\tpuData := perUserData{\n\t\t\tmodeWant:  types.ModeCFull,\n\t\t\tmodeGiven: types.ModeCFull,\n\t\t}\n\t\tif cat == types.TopicCatP2P {\n\t\t\tpuData.topicName = b.uids[i^1].UserId()\n\t\t}\n\t\tif attachSessions {\n\t\t\tps[b.sessions[i]] = perSessionData{uid: uid}\n\t\t\tpuData.online = 1\n\t\t}\n\t\tpu[uid] = puData\n\t}\n\tb.topic = &Topic{\n\t\tname:                   topicName,\n\t\tcat:                    cat,\n\t\tstatus:                 topicStatusLoaded,\n\t\tperUser:                pu,\n\t\tisProxy:                false,\n\t\tsessions:               ps,\n\t\tkillTimer:              time.NewTimer(time.Hour),\n\t\tcallEstablishmentTimer: time.NewTimer(time.Second),\n\t}\n\tif cat != types.TopicCatSys {\n\t\tb.topic.accessAuth = getDefaultAccess(cat, true, false)\n\t\tb.topic.accessAnon = getDefaultAccess(cat, true, false)\n\t}\n\tif cat == types.TopicCatMe {\n\t\tb.topic.xoriginal = \"me\"\n\t}\n\tif cat == types.TopicCatGrp {\n\t\tb.topic.xoriginal = topicName\n\t\tb.topic.owner = b.uids[0]\n\t}\n}\n\nfunc (b *TopicTestHelper) tearDown() {\n\tglobals.hub = nil\n\tstore.Messages = nil\n\tstore.Users = nil\n\tstore.Topics = nil\n\tstore.Subs = nil\n\tb.ctrl.Finish()\n}\n\nfunc (s *Session) testWriteLoop(results *responses, wg *sync.WaitGroup) {\n\tfor msg := range s.send {\n\t\tresults.messages = append(results.messages, msg)\n\t}\n\twg.Done()\n}\n\nfunc (h *Hub) testHubLoop(t *testing.T, results map[string][]*ServerComMessage, done chan bool) {\n\tt.Helper()\n\tfor msg := range h.routeSrv {\n\t\tif msg.RcptTo == \"\" {\n\t\t\t// Don't call t.Fatal from goroutine - instead send error info back\n\t\t\tresults[\"__ERROR__\"] = []*ServerComMessage{{\n\t\t\t\tCtrl: &MsgServerCtrl{\n\t\t\t\t\tCode: 500,\n\t\t\t\t\tText: \"Hub.route received a message without addressee.\",\n\t\t\t\t},\n\t\t\t}}\n\t\t\tdone <- true\n\t\t\treturn\n\t\t}\n\t\tresults[msg.RcptTo] = append(results[msg.RcptTo], msg)\n\t}\n\tdone <- true\n}\n\nfunc TestHandleBroadcastDataP2P(t *testing.T) {\n\tnumUsers := 2\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatP2P, \"p2p-test\" /*attach=*/, true)\n\tdefer helper.tearDown()\n\thelper.mm.EXPECT().Save(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true)\n\n\tfrom := helper.uids[0].UserId()\n\tmsg := &ClientComMessage{\n\t\tAsUser:   from,\n\t\tOriginal: from,\n\t\tPub: &MsgClientPub{\n\t\t\tTopic:   \"p2p\",\n\t\t\tContent: \"test\",\n\t\t\tNoEcho:  true,\n\t\t},\n\t\tsess: helper.sessions[0],\n\t}\n\thelper.topic.handleClientMsg(msg)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\t// Message uid1 -> uid2.\n\tfor i, m := range helper.results {\n\t\tif i == 0 {\n\t\t\tif len(m.messages) != 0 {\n\t\t\t\tt.Fatalf(\"Uid1: expected 0 messages, got %d\", len(m.messages))\n\t\t\t}\n\t\t} else {\n\t\t\tif len(m.messages) != 1 {\n\t\t\t\tt.Fatalf(\"Uid2: expected 1 messages, got %d\", len(m.messages))\n\t\t\t}\n\t\t\tr := m.messages[0].(*ServerComMessage)\n\t\t\tif r.Data == nil {\n\t\t\t\tt.Fatalf(\"Response[0] must have a ctrl message\")\n\t\t\t}\n\t\t\tif r.Data.Topic != from {\n\t\t\t\tt.Errorf(\"Response[0] topic: expected '%s', got '%s'\", from, r.Data.Topic)\n\t\t\t}\n\t\t\tif r.Data.Content.(string) != \"test\" {\n\t\t\t\tt.Errorf(\"Response[0] content: expected 'test', got '%s'\", r.Data.Content.(string))\n\t\t\t}\n\t\t\tif r.Data.From != from {\n\t\t\t\tt.Errorf(\"Response[0] from: expected '%s', got '%s'\", from, r.Data.From)\n\t\t\t}\n\t\t}\n\t}\n\t// Checking presence messages routed through the helper.\n\tif len(helper.hubMessages) != 2 {\n\t\tt.Fatal(\"Huhelper.route expected exactly two recipients routed via huhelper.\")\n\t}\n\tfor i, uid := range helper.uids {\n\t\tif mm, ok := helper.hubMessages[uid.UserId()]; ok {\n\t\t\tif len(mm) == 1 {\n\t\t\t\ts := mm[0]\n\t\t\t\tif s.Pres != nil {\n\t\t\t\t\tp := s.Pres\n\t\t\t\t\tif p.Topic != \"me\" {\n\t\t\t\t\t\tt.Errorf(\"Uid %s: pres notify on topic is expected to be 'me', got %s\", uid.UserId(), p.Topic)\n\t\t\t\t\t}\n\t\t\t\t\tif p.SkipTopic != \"p2p-test\" {\n\t\t\t\t\t\tt.Errorf(\"Uid %s: pres skip topic is expected to be 'p2p-test', got %s\", uid.UserId(), p.SkipTopic)\n\t\t\t\t\t}\n\t\t\t\t\texpectedSrc := helper.uids[i^1].UserId()\n\t\t\t\t\tif p.Src != expectedSrc {\n\t\t\t\t\t\tt.Errorf(\"Uid %s: pres.src expected: %s, found: %s\", uid.UserId(), expectedSrc, p.Src)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tt.Errorf(\"Uid %s: hub message expected to be {pres}.\", uid.UserId())\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tt.Errorf(\"Uid %s: expected 1 hub message, got %d.\", uid.UserId(), len(mm))\n\t\t\t}\n\t\t} else {\n\t\t\tt.Errorf(\"Uid %s: no hub results found.\", uid.UserId())\n\t\t}\n\t}\n}\n\nfunc TestHandleBroadcastCall(t *testing.T) {\n\tnumUsers := 2\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatP2P, \"p2p-test\" /*attach=*/, true)\n\tglobals.iceServers = []iceServer{{Username: \"dummy\"}}\n\thelper.topic.lastID = 5\n\tdefer helper.tearDown()\n\thelper.mm.EXPECT().Save(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true)\n\n\tfrom := helper.uids[0].UserId()\n\tmsg := &ClientComMessage{\n\t\tAsUser:   from,\n\t\tOriginal: from,\n\t\tPub: &MsgClientPub{\n\t\t\tTopic:   \"p2p\",\n\t\t\tHead:    map[string]any{\"webrtc\": \"started\"},\n\t\t\tContent: \"test\",\n\t\t\tNoEcho:  true,\n\t\t},\n\t\tsess: helper.sessions[0],\n\t}\n\thelper.topic.handleClientMsg(msg)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tglobals.iceServers = nil\n\n\t// Message uid1 -> uid2.\n\tfor i, m := range helper.results {\n\t\tif i == 0 {\n\t\t\tif len(m.messages) != 0 {\n\t\t\t\tt.Fatalf(\"Uid1: expected 0 messages, got %d\", len(m.messages))\n\t\t\t}\n\t\t} else {\n\t\t\tif len(m.messages) != 1 {\n\t\t\t\tt.Fatalf(\"Uid2: expected 1 messages, got %d\", len(m.messages))\n\t\t\t}\n\t\t\tr := m.messages[0].(*ServerComMessage)\n\t\t\tif r.Data == nil {\n\t\t\t\tt.Fatalf(\"Response[0] must have a ctrl message\")\n\t\t\t}\n\t\t\tif r.Data.Topic != from {\n\t\t\t\tt.Errorf(\"Response[0] topic: expected '%s', got '%s'\", from, r.Data.Topic)\n\t\t\t}\n\t\t\tif r.Data.Content.(string) != \"test\" {\n\t\t\t\tt.Errorf(\"Response[0] content: expected 'test', got '%s'\", r.Data.Content.(string))\n\t\t\t}\n\t\t\tif r.Data.Head == nil || r.Data.Head[\"webrtc\"].(string) != \"started\" {\n\t\t\t\tt.Errorf(\"Response[0] head: expected {'webrtc': 'started'}', got '%s'\", r.Data.Content.(string))\n\t\t\t}\n\t\t\tif r.Data.From != from {\n\t\t\t\tt.Errorf(\"Response[0] from: expected '%s', got '%s'\", from, r.Data.From)\n\t\t\t}\n\t\t}\n\t}\n\t// Checking presence messages routed through the helper.\n\tif len(helper.hubMessages) != 2 {\n\t\tt.Fatal(\"Huhelper.route expected exactly two recipients routed via huhelper.\")\n\t}\n\tfor i, uid := range helper.uids {\n\t\tif mm, ok := helper.hubMessages[uid.UserId()]; ok {\n\t\t\tif len(mm) == 1 {\n\t\t\t\ts := mm[0]\n\t\t\t\tif s.Pres != nil {\n\t\t\t\t\tp := s.Pres\n\t\t\t\t\tif p.Topic != \"me\" {\n\t\t\t\t\t\tt.Errorf(\"Uid %s: pres notify on topic is expected to be 'me', got %s\", uid.UserId(), p.Topic)\n\t\t\t\t\t}\n\t\t\t\t\tif p.SkipTopic != \"p2p-test\" {\n\t\t\t\t\t\tt.Errorf(\"Uid %s: pres skip topic is expected to be 'p2p-test', got %s\", uid.UserId(), p.SkipTopic)\n\t\t\t\t\t}\n\t\t\t\t\texpectedSrc := helper.uids[i^1].UserId()\n\t\t\t\t\tif p.Src != expectedSrc {\n\t\t\t\t\t\tt.Errorf(\"Uid %s: pres.src expected: %s, found: %s\", uid.UserId(), expectedSrc, p.Src)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tt.Errorf(\"Uid %s: hub message expected to be {pres}.\", uid.UserId())\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tt.Errorf(\"Uid %s: expected 1 hub message, got %d.\", uid.UserId(), len(mm))\n\t\t\t}\n\t\t} else {\n\t\t\tt.Errorf(\"Uid %s: no hub results found.\", uid.UserId())\n\t\t}\n\t}\n\tif helper.topic.currentCall == nil {\n\t\tt.Fatal(\"No call in progress\")\n\t}\n\tif helper.topic.currentCall.seq != 6 {\n\t\tt.Errorf(\"Call seq: expected 6, found %d.\", helper.topic.currentCall.seq)\n\t}\n\tif len(helper.topic.currentCall.parties) != 1 {\n\t\tt.Fatalf(\"Call parties: expected 1, found %d.\", len(helper.topic.currentCall.parties))\n\t}\n\tif p, ok := helper.topic.currentCall.parties[helper.sessions[0].sid]; ok {\n\t\tif !p.isOriginator {\n\t\t\tt.Error(\"Call party is not a call originator.\")\n\t\t}\n\t\tif p.uid != helper.uids[0] {\n\t\t\tt.Errorf(\"Call party wrong uid: expected %s, found %s.\", helper.uids[0].UserId(), p.uid.UserId())\n\t\t}\n\t} else {\n\t\tt.Errorf(\"Call party for session %s not found.\", helper.sessions[0].sid)\n\t}\n}\n\nfunc TestHandleBroadcastDataGroup(t *testing.T) {\n\ttopicName := \"grp-test\"\n\tnumUsers := 4\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatGrp, topicName, true)\n\tdefer func() {\n\t\tstore.Messages = nil\n\t\thelper.tearDown()\n\t}()\n\thelper.mm.EXPECT().Save(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true)\n\n\t// User 3 isn't allowed to read.\n\tpu3 := helper.topic.perUser[helper.uids[3]]\n\tpu3.modeWant = types.ModeJoin | types.ModeWrite | types.ModePres\n\tpu3.modeGiven = pu3.modeWant\n\thelper.topic.perUser[helper.uids[3]] = pu3\n\n\tfrom := helper.uids[0].UserId()\n\tmsg := &ClientComMessage{\n\t\tAsUser:   from,\n\t\tOriginal: topicName,\n\t\tPub: &MsgClientPub{\n\t\t\tTopic:   topicName,\n\t\t\tContent: \"test\",\n\t\t\tNoEcho:  true,\n\t\t},\n\t\tsess: helper.sessions[0],\n\t}\n\n\tif helper.topic.lastID != 0 {\n\t\tt.Errorf(\"Topic.lastID: expected 0, found %d\", helper.topic.lastID)\n\t}\n\thelper.topic.handleClientMsg(msg)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif helper.topic.lastID != 1 {\n\t\tt.Errorf(\"Topic.lastID: expected 1, found %d\", helper.topic.lastID)\n\t}\n\t// Message uid0 -> uid1, uid2, uid3.\n\t// Uid0 is the sender.\n\tif len(helper.results[0].messages) != 0 {\n\t\tt.Fatalf(\"Uid0 is the sender: expected 0 messages, got %d\", len(helper.results[0].messages))\n\t}\n\t// Uid3 is not a topic reader.\n\tif len(helper.results[3].messages) != 0 {\n\t\tt.Fatalf(\"Uid3 isn't allowed to read messages: expected 0 messages, got %d\", len(helper.results[3].messages))\n\t}\n\tfor i := 1; i < 3; i++ {\n\t\tm := helper.results[i]\n\t\tif len(m.messages) != 1 {\n\t\t\tt.Fatalf(\"Uid%d: expected 1 messages, got %d\", i, len(m.messages))\n\t\t}\n\t\tr := m.messages[0].(*ServerComMessage)\n\t\tif r.Data == nil {\n\t\t\tt.Fatalf(\"Response[0] must have a ctrl message\")\n\t\t}\n\t\tif r.Data.Topic != topicName {\n\t\t\tt.Errorf(\"Response[0] topic: expected '%s', got '%s'\", topicName, r.Data.Topic)\n\t\t}\n\t\tif r.Data.From != from {\n\t\t\tt.Errorf(\"Response[0] from: expected '%s', got '%s'\", from, r.Data.From)\n\t\t}\n\t\tif r.Data.Content.(string) != \"test\" {\n\t\t\tt.Errorf(\"Response[0] content: expected 'test', got '%s'\", r.Data.Content.(string))\n\t\t}\n\t}\n\t// Presence messages.\n\tif len(helper.hubMessages) != 3 {\n\t\tt.Fatal(\"Hubhelper.route expected exactly three recipients routed via huhelper.\")\n\t}\n\tfor i, uid := range helper.uids {\n\t\tif i == 3 {\n\t\t\tif _, ok := helper.hubMessages[uid.UserId()]; ok {\n\t\t\t\tt.Errorf(\"Uid %s: not expected to receive pres notifications.\", uid.UserId())\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif mm, ok := helper.hubMessages[uid.UserId()]; ok {\n\t\t\tif len(mm) == 1 {\n\t\t\t\ts := mm[0]\n\t\t\t\tif s.Pres != nil {\n\t\t\t\t\tp := s.Pres\n\t\t\t\t\tif p.Topic != \"me\" {\n\t\t\t\t\t\tt.Errorf(\"Uid %s: pres notify on topic is expected to be 'me', got %s\", uid.UserId(), p.Topic)\n\t\t\t\t\t}\n\t\t\t\t\tif p.SkipTopic != topicName {\n\t\t\t\t\t\tt.Errorf(\"Uid %s: pres skip topic is expected to be 'p2p-test', got %s\", uid.UserId(), p.SkipTopic)\n\t\t\t\t\t}\n\t\t\t\t\tif p.Src != topicName {\n\t\t\t\t\t\tt.Errorf(\"Uid %s: pres.src expected: %s, found: %s\", uid.UserId(), topicName, p.Src)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tt.Errorf(\"Uid %s: hub message expected to be {pres}.\", uid.UserId())\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tt.Errorf(\"Uid %s: expected 1 hub message, got %d.\", uid.UserId(), len(mm))\n\t\t\t}\n\t\t} else {\n\t\t\tt.Errorf(\"Uid %s: no hub results found.\", uid.UserId())\n\t\t}\n\t}\n}\n\nfunc TestHandleBroadcastDataMissingWritePermission(t *testing.T) {\n\ttopicName := \"p2p-test\"\n\tnumUsers := 2\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatP2P, topicName, true)\n\tdefer helper.tearDown()\n\n\t// Remove W permission for uid1.\n\tuid1 := helper.uids[0]\n\tpud := helper.topic.perUser[uid1]\n\tpud.modeGiven = types.ModeRead | types.ModeJoin\n\thelper.topic.perUser[uid1] = pud\n\n\t// Make test message.\n\tfrom := helper.uids[0].UserId()\n\tmsg := &ClientComMessage{\n\t\tAsUser:   from,\n\t\tOriginal: from,\n\t\tPub: &MsgClientPub{\n\t\t\tTopic:   \"p2p\",\n\t\t\tContent: \"test\",\n\t\t},\n\t\tsess: helper.sessions[0],\n\t}\n\n\thelper.topic.handleClientMsg(msg)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\t// Message uid1 -> uid2.\n\tif len(helper.results[0].messages) == 1 {\n\t\tem := helper.results[0].messages[0].(*ServerComMessage)\n\t\tif em.Ctrl == nil {\n\t\t\tt.Fatal(\"User 1 is expected to receive a ctrl message\")\n\t\t}\n\t\tif em.Ctrl.Code < 400 || em.Ctrl.Code >= 500 {\n\t\t\tt.Errorf(\"User1: expected ctrl.code 4xx, received %d\", em.Ctrl.Code)\n\t\t}\n\t} else {\n\t\tt.Errorf(\"User 1 is expected to receive one message vs %d received.\", len(helper.results[0].messages))\n\t}\n\tif len(helper.results[1].messages) != 0 {\n\t\tt.Errorf(\"User 2 is not expected to receive any messages, %d received.\", len(helper.results[1].messages))\n\t}\n\t// Checking presence messages routed through hubhelper.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hubhelper.route did not expect any messages, however %d received.\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestHandleBroadcastDataDbError(t *testing.T) {\n\tnumUsers := 2\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatP2P, \"p2p-test\", true)\n\tdefer helper.tearDown()\n\n\t// DB returns an error.\n\thelper.mm.EXPECT().Save(gomock.Any(), gomock.Any(), gomock.Any()).Return(types.ErrInternal, false)\n\n\t// Make test message.\n\tfrom := helper.uids[0].UserId()\n\tmsg := &ClientComMessage{\n\t\tAsUser: from,\n\t\tPub: &MsgClientPub{\n\t\t\tTopic:   \"p2p\",\n\t\t\tContent: \"test\",\n\t\t},\n\t\tsess: helper.sessions[0],\n\t}\n\n\tif helper.topic.lastID != 0 {\n\t\tt.Errorf(\"Topic.lastID: expected 0, found %d\", helper.topic.lastID)\n\t}\n\thelper.topic.handleClientMsg(msg)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif helper.topic.lastID != 0 {\n\t\tt.Errorf(\"Topic.lastID: expected to remain 0, found %d\", helper.topic.lastID)\n\t}\n\t// Message uid1 -> uid2.\n\tif len(helper.results[0].messages) == 1 {\n\t\tem := helper.results[0].messages[0].(*ServerComMessage)\n\t\tif em.Ctrl == nil {\n\t\t\tt.Fatal(\"User 1 is expected to receive a ctrl message\")\n\t\t}\n\t\tif em.Ctrl.Code < 500 || em.Ctrl.Code >= 600 {\n\t\t\tt.Errorf(\"User1: expected ctrl.code 5xx, received %d\", em.Ctrl.Code)\n\t\t}\n\t} else {\n\t\tt.Errorf(\"User 1 is expected to receive one message vs %d received.\", len(helper.results[0].messages))\n\t}\n\tif len(helper.results[1].messages) != 0 {\n\t\tt.Errorf(\"User 2 is not expected to receive any messages, %d received.\", len(helper.results[1].messages))\n\t}\n\t// Checking presence messages routed through hubhelper.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hubhelper.route did not expect any messages, however %d received.\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestHandleBroadcastDataInactiveTopic(t *testing.T) {\n\tnumUsers := 2\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatP2P, \"p2p-test\", true)\n\tdefer helper.tearDown()\n\n\t// Make test message.\n\tfrom := helper.uids[0].UserId()\n\tmsg := &ClientComMessage{\n\t\tAsUser: from,\n\t\tPub: &MsgClientPub{\n\t\t\tTopic:   \"p2p\",\n\t\t\tContent: \"test\",\n\t\t},\n\t\tsess: helper.sessions[0],\n\t}\n\n\t// Deactivate topic.\n\thelper.topic.markDeleted()\n\n\thelper.topic.handleClientMsg(msg)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\t// Message uid1 -> uid2.\n\tif len(helper.results[0].messages) == 1 {\n\t\tem := helper.results[0].messages[0].(*ServerComMessage)\n\t\tif em.Ctrl == nil {\n\t\t\tt.Fatal(\"User 1 is expected to receive a ctrl message\")\n\t\t}\n\t\tif em.Ctrl.Code < 500 || em.Ctrl.Code >= 600 {\n\t\t\tt.Errorf(\"User1: expected ctrl.code 5xx, received %d\", em.Ctrl.Code)\n\t\t}\n\t} else {\n\t\tt.Errorf(\"User 1 is expected to receive one message vs %d received.\", len(helper.results[0].messages))\n\t}\n\tif len(helper.results[1].messages) != 0 {\n\t\tt.Errorf(\"User 2 is not expected to receive any messages, %d received.\", len(helper.results[1].messages))\n\t}\n\t// Checking presence messages routed through hubhelper.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hubhelper.route did not expect any messages, however %d received.\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestHandleBroadcastInfoP2P(t *testing.T) {\n\ttopicName := \"usrP2P\"\n\tnumUsers := 2\n\treadId := 8\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatP2P, topicName, true)\n\tdefer helper.tearDown()\n\t// Pretend we have 10 messages.\n\thelper.topic.lastID = 10\n\t// uid1 notifies uid2 that uid1 has read messages up to seqid 8.\n\tfrom := helper.uids[0]\n\tto := helper.uids[1]\n\n\thelper.ss.EXPECT().Update(topicName, from, map[string]any{\"ReadSeqId\": readId}).Return(nil)\n\n\tmsg := &ClientComMessage{\n\t\tAsUser: from.UserId(),\n\t\tNote: &MsgClientNote{\n\t\t\tTopic: to.UserId(),\n\t\t\tWhat:  \"read\",\n\t\t\tSeqId: readId,\n\t\t},\n\t\tsess: helper.sessions[0],\n\t}\n\thelper.topic.handleClientMsg(msg)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\t// Topic metadata.\n\tif actualReadId := helper.topic.perUser[from].readID; actualReadId != readId {\n\t\tt.Errorf(\"perUser[%s].readID: expected %d, found %d.\", from.UserId(), readId, actualReadId)\n\t}\n\t// Server messages.\n\tif len(helper.results[0].messages) != 0 {\n\t\tt.Errorf(\"Session 0 isn't expected to receive any messages. Received %d\", len(helper.results[0].messages))\n\t}\n\tif len(helper.results[1].messages) != 1 {\n\t\tt.Fatalf(\"Session 1 is expected to receive exactly 1 message. Received %d\", len(helper.results[1].messages))\n\t}\n\tres := helper.results[1].messages[0].(*ServerComMessage)\n\tif res.Info != nil {\n\t\tinfo := res.Info\n\t\t// Topic name will be fixed (to -> from).\n\t\tif info.Topic != from.UserId() {\n\t\t\tt.Errorf(\"Info.Topic: expected '%s', found '%s'\", to.UserId(), info.Topic)\n\t\t}\n\t\tif info.From != from.UserId() {\n\t\t\tt.Errorf(\"Info.From: expected '%s', found '%s'\", from.UserId(), info.From)\n\t\t}\n\t\tif info.What != \"read\" {\n\t\t\tt.Errorf(\"Info.What: expected 'read', found '%s'\", info.What)\n\t\t}\n\t\tif info.SeqId != readId {\n\t\t\tt.Errorf(\"Info.SeqId: expected %d, found %d\", readId, info.SeqId)\n\t\t}\n\t} else {\n\t\tt.Error(\"Session message is expected to contain `info` section.\")\n\t}\n\t// Checking presence messages routed through hub helper. These are intended for offline sessions.\n\tif len(helper.hubMessages) != 2 {\n\t\tt.Fatalf(\"Hubhelper.route expected exactly two recipients routed via hubhelper. Found %d\", len(helper.hubMessages))\n\t}\n\tfor i, uid := range helper.uids {\n\t\tif routedMsgs, ok := helper.hubMessages[uid.UserId()]; ok {\n\t\t\texpectedSrc := helper.uids[i^1].UserId()\n\t\t\tfor _, s := range routedMsgs {\n\t\t\t\tif s.Info != nil {\n\t\t\t\t\t// Info messages for offline sessions.\n\t\t\t\t\tinfo := s.Info\n\t\t\t\t\tif info.Topic != \"me\" {\n\t\t\t\t\t\tt.Errorf(\"Uid %s: info.topic is expected to be 'me', got %s\", uid.UserId(), info.Topic)\n\t\t\t\t\t}\n\t\t\t\t\tif info.Src != expectedSrc {\n\t\t\t\t\t\tt.Errorf(\"Uid %s: info.src expected: %s, found: %s\", uid.UserId(), expectedSrc, info.Src)\n\t\t\t\t\t}\n\t\t\t\t\tif info.What != \"read\" {\n\t\t\t\t\t\tt.Error(\"info.what expected to be 'read'\")\n\t\t\t\t\t}\n\t\t\t\t\tif info.SeqId != readId {\n\t\t\t\t\t\tt.Errorf(\"info.seq: expected %d, found %d\", readId, info.SeqId)\n\t\t\t\t\t}\n\t\t\t\t} else if s.Pres != nil {\n\t\t\t\t\t// Pres messages for offline sessions.\n\t\t\t\t\tpres := s.Pres\n\t\t\t\t\tif pres.Topic != \"me\" {\n\t\t\t\t\t\tt.Errorf(\"Uid %s: pres.topic is expected to be 'me', got %s\", uid.UserId(), pres.Topic)\n\t\t\t\t\t}\n\t\t\t\t\tif pres.What != \"read\" {\n\t\t\t\t\t\tt.Error(\"pres.what expected to be 'read'\")\n\t\t\t\t\t}\n\t\t\t\t\tif pres.Src != expectedSrc {\n\t\t\t\t\t\tt.Errorf(\"Uid %s: pres.src expected: %s, found: %s\", uid.UserId(), expectedSrc, pres.Src)\n\t\t\t\t\t}\n\t\t\t\t\tif pres.SeqId != readId {\n\t\t\t\t\t\tt.Errorf(\"pres.seq: expected %d, found %d\", readId, pres.SeqId)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tt.Error(\"Hub messages must be either `info` or `pres`.\")\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tt.Errorf(\"Uid %s: no hub results found.\", uid.UserId())\n\t\t}\n\t}\n}\n\nfunc TestHandleBroadcastInfoBogusNotification(t *testing.T) {\n\ttopicName := \"usrP2P\"\n\tnumUsers := 2\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatP2P, topicName, true)\n\tdefer helper.tearDown()\n\t// Pretend we have 10 messages.\n\thelper.topic.lastID = 10\n\t// uid1 notifies uid2 that uid1 has read messages up to seqid 11.\n\treadId := 11\n\tfrom := helper.uids[0]\n\tto := helper.uids[1]\n\n\tmsg := &ClientComMessage{\n\t\tAsUser:   from.UserId(),\n\t\tOriginal: to.UserId(),\n\t\tNote: &MsgClientNote{\n\t\t\tTopic: to.UserId(),\n\t\t\tWhat:  \"read\",\n\t\t\tSeqId: readId,\n\t\t},\n\t\tsess: helper.sessions[0],\n\t}\n\thelper.topic.handleClientMsg(msg)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\t// Read id should not be updated.\n\tif actualReadId := helper.topic.perUser[from].readID; actualReadId != 0 {\n\t\tt.Errorf(\"perUser[%s].readID: expected 0, found %d.\", from.UserId(), actualReadId)\n\t}\n\t// Server messages.\n\tfor i, r := range helper.results {\n\t\tif numMessages := len(r.messages); numMessages != 0 {\n\t\t\tt.Errorf(\"User %d is not expected to receive any messages, %d received.\", i, numMessages)\n\t\t}\n\t}\n\n\t// Nothing should be routed through the hub.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hubhelper.route did not expect any messages, however %d received.\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestHandleBroadcastInfoFilterOutRecvWithoutRPermission(t *testing.T) {\n\ttopicName := \"usrP2P\"\n\tnumUsers := 2\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatP2P, topicName, true)\n\tdefer helper.tearDown()\n\t// Pretend we have 10 messages.\n\thelper.topic.lastID = 10\n\t// uid1 notifies uid2 that uid1 has read messages up to seqid 11.\n\treadId := 8\n\tfrom := helper.uids[0]\n\tto := helper.uids[1]\n\n\t// Revoke R permission from the sender.\n\tpud := helper.topic.perUser[from]\n\tpud.modeGiven = types.ModeWrite | types.ModeJoin\n\thelper.topic.perUser[from] = pud\n\n\tmsg := &ClientComMessage{\n\t\tAsUser:   from.UserId(),\n\t\tOriginal: to.UserId(),\n\t\tNote: &MsgClientNote{\n\t\t\tTopic: to.UserId(),\n\t\t\tWhat:  \"recv\",\n\t\t\tSeqId: readId,\n\t\t},\n\t\tsess: helper.sessions[0],\n\t}\n\thelper.topic.handleClientMsg(msg)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\t// Read id should not be updated.\n\tif actualReadId := helper.topic.perUser[from].readID; actualReadId != 0 {\n\t\tt.Errorf(\"perUser[%s].readID: expected 0, found %d.\", from.UserId(), actualReadId)\n\t}\n\t// Server messages.\n\tfor i, r := range helper.results {\n\t\tif numMessages := len(r.messages); numMessages != 0 {\n\t\t\tt.Errorf(\"User %d is not expected to receive any messages, %d received.\", i, numMessages)\n\t\t}\n\t}\n\n\t// Nothing should be routed through the hub.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hubhelper.route did not expect any messages, however %d received.\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestHandleBroadcastInfoFilterOutKpWithoutWPermission(t *testing.T) {\n\ttopicName := \"usrP2P\"\n\tnumUsers := 2\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatP2P, topicName, true)\n\tdefer helper.tearDown()\n\t// Pretend we have 10 messages.\n\thelper.topic.lastID = 10\n\t// uid1 notifies uid2 that uid1 has read messages up to seqid 11.\n\treadId := 8\n\tfrom := helper.uids[0]\n\tto := helper.uids[1]\n\n\t// Revoke W permission from the sender.\n\tpud := helper.topic.perUser[from]\n\tpud.modeGiven = types.ModeRead | types.ModeJoin\n\thelper.topic.perUser[from] = pud\n\n\tmsg := &ClientComMessage{\n\t\tAsUser:   from.UserId(),\n\t\tOriginal: to.UserId(),\n\t\tNote: &MsgClientNote{\n\t\t\tTopic: to.UserId(),\n\t\t\tWhat:  \"kp\",\n\t\t\tSeqId: readId,\n\t\t},\n\t\tsess: helper.sessions[0],\n\t}\n\thelper.topic.handleClientMsg(msg)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\t// Read id should not be updated.\n\tif actualReadId := helper.topic.perUser[from].readID; actualReadId != 0 {\n\t\tt.Errorf(\"perUser[%s].readID: expected 0, found %d.\", from.UserId(), actualReadId)\n\t}\n\t// Server messages.\n\tfor i, r := range helper.results {\n\t\tif numMessages := len(r.messages); numMessages != 0 {\n\t\t\tt.Errorf(\"User %d is not expected to receive any messages, %d received.\", i, numMessages)\n\t\t}\n\t}\n\n\t// Nothing should be routed through the hub.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hubhelper.route did not expect any messages, however %d received.\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestHandleBroadcastInfoDuplicatedRead(t *testing.T) {\n\ttopicName := \"usrP2P\"\n\tnumUsers := 2\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatP2P, topicName /*attach=*/, true)\n\tdefer helper.tearDown()\n\t// Pretend we have 10 messages.\n\thelper.topic.lastID = 10\n\t// uid1 notifies uid2 that uid1 has read messages up to seqid 11.\n\treadId := 8\n\tfrom := helper.uids[0]\n\tto := helper.uids[1]\n\n\t// Revoke R permission from the sender.\n\tpud := helper.topic.perUser[from]\n\tpud.readID = 8\n\thelper.topic.perUser[from] = pud\n\n\tmsg := &ClientComMessage{\n\t\tAsUser:   from.UserId(),\n\t\tOriginal: to.UserId(),\n\t\tNote: &MsgClientNote{\n\t\t\tTopic: to.UserId(),\n\t\t\tWhat:  \"read\",\n\t\t\tSeqId: readId,\n\t\t},\n\t\tsess: helper.sessions[0],\n\t}\n\thelper.topic.handleClientMsg(msg)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\t// Read id should not be updated.\n\tif actualReadId := helper.topic.perUser[from].readID; actualReadId != 8 {\n\t\tt.Errorf(\"perUser[%s].readID: expected 8, found %d.\", from.UserId(), actualReadId)\n\t}\n\t// Server messages.\n\tfor i, r := range helper.results {\n\t\tif numMessages := len(r.messages); numMessages != 0 {\n\t\t\tt.Errorf(\"User %d is not expected to receive any messages, %d received.\", i, numMessages)\n\t\t}\n\t}\n\n\t// Nothing should be routed through the hub.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hubhelper.route did not expect any messages, however %d received.\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestHandleBroadcastInfoDbError(t *testing.T) {\n\ttopicName := \"usrP2P\"\n\tnumUsers := 2\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatP2P, topicName, true)\n\tdefer helper.tearDown()\n\t// Pretend we have 10 messages.\n\thelper.topic.lastID = 10\n\t// uid1 notifies uid2 that uid1 has read messages up to seqid 11.\n\treadId := 8\n\tfrom := helper.uids[0]\n\tto := helper.uids[1]\n\n\thelper.ss.EXPECT().Update(topicName, from, map[string]any{\"ReadSeqId\": readId}).Return(types.ErrInternal)\n\n\tmsg := &ClientComMessage{\n\t\tAsUser:   from.UserId(),\n\t\tOriginal: to.UserId(),\n\t\tNote: &MsgClientNote{\n\t\t\tTopic: to.UserId(),\n\t\t\tWhat:  \"read\",\n\t\t\tSeqId: readId,\n\t\t},\n\t\tsess: helper.sessions[0],\n\t}\n\thelper.topic.handleClientMsg(msg)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\t// Read id should not be updated.\n\tif actualReadId := helper.topic.perUser[from].readID; actualReadId != 0 {\n\t\tt.Errorf(\"perUser[%s].readID: expected 0, found %d.\", from.UserId(), actualReadId)\n\t}\n\t// Server messages.\n\tfor i, r := range helper.results {\n\t\tif numMessages := len(r.messages); numMessages != 0 {\n\t\t\tt.Errorf(\"User %d is not expected to receive any messages, %d received.\", i, numMessages)\n\t\t}\n\t}\n\n\t// Nothing should be routed through the hub.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hubhelper.route did not expect any messages, however %d received.\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestHandleBroadcastInfoInvalidChannelAccess(t *testing.T) {\n\ttopicName := \"grpTest\"\n\tchanName := \"chnTest\"\n\tnumUsers := 3\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatGrp, topicName, true)\n\t// This is not a channel. However, we will try to handle an info message where\n\t// the topic is referenced as \"chn\".\n\thelper.topic.isChan = false\n\tdefer helper.tearDown()\n\t// Pretend we have 10 messages.\n\thelper.topic.lastID = 10\n\t// uid1 notifies uid2 that uid1 has read messages up to seqid 11.\n\treadId := 8\n\tfrom := helper.uids[0]\n\tfor i := 1; i < numUsers; i++ {\n\t\tuid := helper.uids[i]\n\t\tpud := helper.topic.perUser[uid]\n\t\tpud.modeGiven = types.ModeCChnReader\n\t\thelper.topic.perUser[uid] = pud\n\t}\n\n\tmsg := &ClientComMessage{\n\t\tOriginal: chanName,\n\t\tAsUser:   from.UserId(),\n\t\tNote: &MsgClientNote{\n\t\t\tTopic: chanName,\n\t\t\tWhat:  \"read\",\n\t\t\tSeqId: readId,\n\t\t},\n\t\tsess: helper.sessions[0],\n\t}\n\thelper.topic.handleClientMsg(msg)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\t// Read id should not be updated.\n\tif actualReadId := helper.topic.perUser[from].readID; actualReadId != 0 {\n\t\tt.Errorf(\"perUser[%s].readID: expected 0, found %d.\", from.UserId(), actualReadId)\n\t}\n\t// Server messages.\n\tfor i, r := range helper.results {\n\t\tif numMessages := len(r.messages); numMessages != 0 {\n\t\t\tt.Errorf(\"User %d is not expected to receive any messages, %d received.\", i, numMessages)\n\t\t}\n\t}\n\t// Nothing should be routed through the hub.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hubhelper.route did not expect any messages, however %d received.\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestHandleBroadcastInfoChannelProcessing(t *testing.T) {\n\ttopicName := \"grpTest\"\n\tchanName := \"chnTest\"\n\tnumUsers := 3\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatGrp, topicName, true)\n\thelper.topic.isChan = true\n\tdefer helper.tearDown()\n\t// Pretend we have 10 messages.\n\thelper.topic.lastID = 10\n\t// uid1 notifies uid2 that uid1 has read messages up to seqid 11.\n\treadId := 8\n\tfrom := helper.uids[0]\n\tfor i := 1; i < numUsers; i++ {\n\t\tuid := helper.uids[i]\n\t\tpud := helper.topic.perUser[uid]\n\t\tpud.modeGiven = types.ModeCChnReader\n\t\tpud.isChan = true\n\t\thelper.topic.perUser[uid] = pud\n\t}\n\n\thelper.ss.EXPECT().Update(chanName, from, map[string]any{\"ReadSeqId\": readId}).Return(nil)\n\n\tmsg := &ClientComMessage{\n\t\tAsUser:   from.UserId(),\n\t\tOriginal: chanName,\n\t\tNote: &MsgClientNote{\n\t\t\tTopic: chanName,\n\t\t\tWhat:  \"read\",\n\t\t\tSeqId: readId,\n\t\t},\n\t\tsess: helper.sessions[0],\n\t}\n\thelper.topic.handleClientMsg(msg)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\t// Topic metadata.\n\t// We do not update read ids for channel topics.\n\tif actualReadId := helper.topic.perUser[from].readID; actualReadId != 0 {\n\t\tt.Errorf(\"perUser[%s].readID: expected 0, found %d.\", from.UserId(), actualReadId)\n\t}\n\t// Server messages. Note messages aren't forwarded by channel topics.\n\tfor i, r := range helper.results {\n\t\tif numMessages := len(r.messages); numMessages != 0 {\n\t\t\tt.Errorf(\"User %d is not expected to receive any messages, %d received.\", i, numMessages)\n\t\t}\n\t}\n\n\t// Send a pres back to the sender.\n\tif len(helper.hubMessages) != 1 {\n\t\tt.Fatalf(\"Hubhelper.route did not expect any messages, however %d received.\", len(helper.hubMessages))\n\t}\n\tif mm, ok := helper.hubMessages[from.UserId()]; ok || len(mm) != 1 {\n\t\ts := mm[0]\n\t\tif s.Pres != nil {\n\t\t\tp := s.Pres\n\t\t\tif p.Topic != \"me\" {\n\t\t\t\tt.Errorf(\"Uid %s: pres notify on topic is expected to be 'me', got %s\", from.UserId(), p.Topic)\n\t\t\t}\n\t\t\tif p.SkipTopic != topicName {\n\t\t\t\tt.Errorf(\"Uid %s: pres skip topic is expected to be '%s', got %s\", from.UserId(), topicName, p.SkipTopic)\n\t\t\t}\n\t\t\tif p.Src != topicName {\n\t\t\t\tt.Errorf(\"Uid %s: pres.src expected: %s, found: %s\", from.UserId(), topicName, p.Src)\n\t\t\t}\n\t\t\tif p.What != \"read\" {\n\t\t\t\tt.Errorf(\"Uid %s: pres.what expected: 'read', found: %s\", from.UserId(), p.What)\n\t\t\t}\n\t\t} else {\n\t\t\tt.Errorf(\"Uid %s: hub message expected to be {pres}.\", from.UserId())\n\t\t}\n\t} else {\n\t\tt.Errorf(\"Uid %s: expected 1 hub message, got %d.\", from.UserId(), len(mm))\n\t}\n}\n\nfunc TestHandleBroadcastPresMe(t *testing.T) {\n\ttopicName := \"usrMe\"\n\tnumUsers := 1\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatMe, topicName, true)\n\tdefer helper.tearDown()\n\n\tuid := helper.uids[0]\n\tsrcUid := types.Uid(10)\n\thelper.topic.perSubs = make(map[string]perSubsData)\n\thelper.topic.perSubs[srcUid.UserId()] = perSubsData{enabled: true, online: false}\n\n\tmsg := &ServerComMessage{\n\t\tAsUser: uid.UserId(),\n\t\tRcptTo: uid.UserId(),\n\t\tPres: &MsgServerPres{\n\t\t\tTopic: \"me\",\n\t\t\tSrc:   srcUid.UserId(),\n\t\t\tWhat:  \"on\",\n\t\t},\n\t}\n\thelper.topic.handleServerMsg(msg)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\t// Topic metadata.\n\tif online := helper.topic.perSubs[srcUid.UserId()].online; !online {\n\t\tt.Errorf(\"User %s is expected to be online.\", srcUid.UserId())\n\t}\n\t// Server messages.\n\tif len(helper.results[0].messages) != 1 {\n\t\tt.Fatalf(\"Session 0 is expected to receive one message. Received %d.\", len(helper.results[0].messages))\n\t}\n\ts := helper.results[0].messages[0].(*ServerComMessage)\n\tif s.RcptTo != uid.UserId() {\n\t\tt.Errorf(\"Message.RcptTo: expected '%s', found '%s'\", uid.UserId(), s.RcptTo)\n\t}\n\tif s.Pres != nil {\n\t\tpres := s.Pres\n\t\tif pres.Topic != \"me\" {\n\t\t\tt.Errorf(\"Expected to notify user on 'me' topic. Found: '%s'\", pres.Topic)\n\t\t}\n\t\tif pres.Src != srcUid.UserId() {\n\t\t\tt.Errorf(\"Expected notification from '%s'. Found: '%s'\", srcUid.UserId(), pres.Topic)\n\t\t}\n\t\tif pres.What != \"on\" {\n\t\t\tt.Errorf(\"Expected an online notification. Found: '%s'\", pres.What)\n\t\t}\n\t} else {\n\t\tt.Error(\"Message is expected to be pres.\")\n\t}\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hubhelper.route isn't expected to receive messages. Received %d\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestHandleBroadcastPresInactiveTopic(t *testing.T) {\n\ttopicName := \"usrMe\"\n\tnumUsers := 1\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatMe, topicName, true)\n\tdefer helper.tearDown()\n\n\tuid := helper.uids[0]\n\tsrcUid := types.Uid(10)\n\thelper.topic.perSubs = make(map[string]perSubsData)\n\thelper.topic.perSubs[srcUid.UserId()] = perSubsData{enabled: true, online: false}\n\n\tmsg := &ServerComMessage{\n\t\tAsUser: uid.UserId(),\n\t\tRcptTo: uid.UserId(),\n\t\tPres: &MsgServerPres{\n\t\t\tTopic: \"me\",\n\t\t\tSrc:   srcUid.UserId(),\n\t\t\tWhat:  \"on\",\n\t\t},\n\t}\n\n\t// Deactivate topic.\n\thelper.topic.markDeleted()\n\n\thelper.topic.handleServerMsg(msg)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\t// Topic metadata.\n\tif online := helper.topic.perSubs[srcUid.UserId()].online; online {\n\t\tt.Errorf(\"User %s is expected to be offline.\", srcUid.UserId())\n\t}\n\t// Server messages.\n\tif len(helper.results[0].messages) != 0 {\n\t\tt.Fatalf(\"Session 0 is not expected to receive messages. Received %d.\", len(helper.results[0].messages))\n\t}\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hubhelper.route isn't expected to receive messages. Received %d\", len(helper.hubMessages))\n\t}\n}\n\nconst (\n\tNoSub               = 0\n\tExistingSubEnabled  = 1\n\tExistingSubDisabled = 2\n)\n\nfunc NoChangeInStatusTest(t *testing.T, subscriptionStatus int, what string) *TopicTestHelper {\n\tt.Helper()\n\ttopicName := \"usrMe\"\n\tnumUsers := 1\n\thelper := &TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatMe, topicName, true)\n\n\tuid := helper.uids[0]\n\tsrcUid := types.Uid(10)\n\thelper.topic.perSubs = make(map[string]perSubsData)\n\tenabled := false\n\tswitch subscriptionStatus {\n\tcase NoSub:\n\tcase ExistingSubEnabled:\n\t\tenabled = true\n\t\tfallthrough\n\tcase ExistingSubDisabled:\n\t\thelper.topic.perSubs[srcUid.UserId()] = perSubsData{enabled: enabled, online: false}\n\t}\n\n\tmsg := &ServerComMessage{\n\t\tAsUser: uid.UserId(),\n\t\tRcptTo: uid.UserId(),\n\t\tPres: &MsgServerPres{\n\t\t\tTopic: \"me\",\n\t\t\tSrc:   srcUid.UserId(),\n\t\t\t// No change in online status.\n\t\t\tWhat: what,\n\t\t},\n\t}\n\n\thelper.topic.handleServerMsg(msg)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\t// Topic metadata.\n\tif online := helper.topic.perSubs[srcUid.UserId()].online; online {\n\t\tt.Errorf(\"User %s is expected to be offline.\", srcUid.UserId())\n\t}\n\t// Server messages.\n\tif len(helper.results[0].messages) != 0 {\n\t\tt.Fatalf(\"Session 0 is not expected to receive messages. Received %d.\", len(helper.results[0].messages))\n\t}\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hubhelper.route isn't expected to receive messages. Received %d\", len(helper.hubMessages))\n\t}\n\treturn helper\n}\n\nfunc TestHandleBroadcastPresUnkn(t *testing.T) {\n\tNoChangeInStatusTest(t, ExistingSubEnabled, \"?unkn\").tearDown()\n}\n\nfunc TestHandleBroadcastPresNone(t *testing.T) {\n\tNoChangeInStatusTest(t, ExistingSubEnabled, \"?none\").tearDown()\n}\n\nfunc TestHandleBroadcastPresRedundantUpdate(t *testing.T) {\n\th := NoChangeInStatusTest(t, ExistingSubDisabled, \"off+rem\")\n\tuid := h.uids[0]\n\tif _, ok := h.topic.perSubs[uid.UserId()]; ok {\n\t\tt.Errorf(\"Subscription for user %s expected to be deleted.\", uid.UserId())\n\t}\n\th.tearDown()\n}\n\nfunc TestHandleBroadcastPresNewSub(t *testing.T) {\n\tNoChangeInStatusTest(t, NoSub, \"off+wrong\").tearDown()\n}\n\nfunc TestHandleBroadcastPresUnknownSub(t *testing.T) {\n\tNoChangeInStatusTest(t, NoSub, \"on+rem\").tearDown()\n}\n\nfunc TestReplyGetDescInvalidOpts(t *testing.T) {\n\tnumUsers := 1\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatMe, \"\" /*attach=*/, true)\n\tdefer helper.tearDown()\n\n\tmsg := ClientComMessage{\n\t\tOriginal: \"dummy\",\n\t}\n\t// Can't specify User in opts.\n\tif err := helper.topic.replyGetDesc(helper.sessions[0], 123, false, &MsgGetOpts{User: \"abcdef\"}, &msg); err == nil {\n\t\tt.Error(\"replyGetDesc expected to error out.\")\n\t} else if err.Error() != \"invalid GetDesc query\" {\n\t\tt.Errorf(\"Unexpected error: expected 'invalid GetDesc query', got '%s'\", err.Error())\n\t}\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif len(helper.results[0].messages) != 1 {\n\t\tt.Fatalf(\"`responses` expected to contain 1 element, found %d\", len(helper.results[0].messages))\n\t}\n\tresp := helper.results[0].messages[0].(*ServerComMessage)\n\tif resp.Ctrl == nil {\n\t\tt.Fatalf(\"response expected to contain a Ctrl message\")\n\t}\n\tif resp.Ctrl.Code != 400 {\n\t\tt.Errorf(\"response code: expected 400, found: %d\", resp.Ctrl.Code)\n\t}\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hub isn't expected to receive any messages, received %d\", len(helper.hubMessages))\n\t}\n}\n\n// Verifies ctrl codes in session outputs.\nfunc registerSessionVerifyOutputs(t *testing.T, sessionOutput *responses, expectedCtrlCodes []int) {\n\tt.Helper()\n\t// Session output.\n\tif len(sessionOutput.messages) == len(expectedCtrlCodes) {\n\t\tn := len(expectedCtrlCodes)\n\t\tfor i := range n {\n\t\t\tresp := sessionOutput.messages[i].(*ServerComMessage)\n\t\t\tcode := expectedCtrlCodes[i]\n\t\t\tif resp.Ctrl != nil {\n\t\t\t\tif resp.Ctrl.Code != code {\n\t\t\t\t\tt.Errorf(\"response code: expected %d, found: %d\", code, resp.Ctrl.Code)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tt.Errorf(\"response %d: expected to contain a Ctrl message\", i)\n\t\t\t}\n\t\t}\n\t} else {\n\t\tt.Errorf(\"Session output: expected %d responses, received %d\", len(expectedCtrlCodes),\n\t\t\tlen(sessionOutput.messages))\n\t}\n}\n\nfunc TestRegisterSessionMe(t *testing.T) {\n\ttopicName := \"usrMe\"\n\tnumUsers := 1\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatMe, topicName, false)\n\tdefer helper.tearDown()\n\tif len(helper.topic.sessions) != 0 {\n\t\thelper.finish()\n\t\tt.Fatalf(\"Initially attached sessions: expected 0 vs found %d\", len(helper.topic.sessions))\n\t}\n\n\tuid := helper.uids[0]\n\n\t// Add a couple more sessions.\n\tfor i := 1; i < 3; i++ {\n\t\ts, r := helper.newSession(fmt.Sprintf(\"sid%d\", i), uid)\n\t\thelper.sessions = append(helper.sessions, s)\n\t\thelper.results = append(helper.results, r)\n\t}\n\n\tfor i, s := range helper.sessions {\n\t\tjoin := &ClientComMessage{\n\t\t\tSub: &MsgClientSub{\n\t\t\t\tId:    fmt.Sprintf(\"id456-%d\", i),\n\t\t\t\tTopic: \"me\",\n\t\t\t},\n\t\t\tAsUser: uid.UserId(),\n\t\t\tsess:   s,\n\t\t}\n\t\thelper.topic.registerSession(join)\n\t}\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif len(helper.topic.sessions) != 3 {\n\t\tt.Errorf(\"Attached sessions: expected 3, found %d\", len(helper.topic.sessions))\n\t}\n\tfor _, s := range helper.sessions {\n\t\tif len(s.subs) != 1 {\n\t\t\tt.Errorf(\"Session subscriptions: expected 3, found %d\", len(s.subs))\n\t\t}\n\t}\n\tonline := helper.topic.perUser[uid].online\n\tif online != 3 {\n\t\tt.Errorf(\"Number of online sessions: expected 3, found %d\", online)\n\t}\n\t// Session output.\n\tfor _, r := range helper.results {\n\t\tregisterSessionVerifyOutputs(t, r, []int{http.StatusOK})\n\t}\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hub isn't expected to receive any messages, received %d\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestRegisterSessionInactiveTopic(t *testing.T) {\n\ttopicName := \"usrMe\"\n\tnumUsers := 1\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatMe, topicName, false)\n\tdefer helper.tearDown()\n\tif len(helper.topic.sessions) != 0 {\n\t\thelper.finish()\n\t\tt.Fatalf(\"Initially attached sessions: expected 0 vs found %d\", len(helper.topic.sessions))\n\t}\n\n\tuid := helper.uids[0]\n\n\ts := helper.sessions[0]\n\tjoin := &ClientComMessage{\n\t\tSub: &MsgClientSub{\n\t\t\tId:    \"id456\",\n\t\t\tTopic: \"me\",\n\t\t},\n\t\tAsUser: uid.UserId(),\n\t\tsess:   s,\n\t}\n\n\t// Deactivate topic.\n\thelper.topic.markDeleted()\n\n\thelper.topic.registerSession(join)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif len(s.subs) != 0 {\n\t\tt.Errorf(\"Session subscriptions: expected 0, found %d\", len(s.subs))\n\t}\n\tonline := helper.topic.perUser[uid].online\n\tif online != 0 {\n\t\tt.Errorf(\"Number of online sessions: expected 0, found %d\", online)\n\t}\n\t// Session output.\n\tregisterSessionVerifyOutputs(t, helper.results[0], []int{http.StatusServiceUnavailable})\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hub isn't expected to receive any messages, received %d\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestRegisterSessionUserSpecifiedInSetMessage(t *testing.T) {\n\ttopicName := \"grpTest\"\n\tnumUsers := 1\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatGrp, topicName, false)\n\tdefer helper.tearDown()\n\tif len(helper.topic.sessions) != 0 {\n\t\thelper.finish()\n\t\tt.Fatalf(\"Initially attached sessions: expected 0 vs found %d\", len(helper.topic.sessions))\n\t}\n\n\tuid := helper.uids[0]\n\n\ts := helper.sessions[0]\n\tjoin := &ClientComMessage{\n\t\tOriginal: topicName,\n\t\tSub: &MsgClientSub{\n\t\t\tId:    \"id456\",\n\t\t\tTopic: topicName,\n\t\t\tSet: &MsgSetQuery{\n\t\t\t\tSub: &MsgSetSub{\n\t\t\t\t\t// Specify the user. This should result in an error.\n\t\t\t\t\tUser: \"foo\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tAsUser: uid.UserId(),\n\t\tsess:   s,\n\t}\n\n\thelper.topic.registerSession(join)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif len(s.subs) != 0 {\n\t\tt.Errorf(\"Session subscriptions: expected 0, found %d\", len(s.subs))\n\t}\n\tonline := helper.topic.perUser[uid].online\n\tif online != 0 {\n\t\tt.Errorf(\"Number of online sessions: expected 0, found %d\", online)\n\t}\n\t// Session output.\n\tregisterSessionVerifyOutputs(t, helper.results[0], []int{http.StatusBadRequest})\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hub isn't expected to receive any messages, received %d\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestRegisterSessionInvalidWantStrInSetMessage(t *testing.T) {\n\ttopicName := \"grpTest\"\n\tnumUsers := 1\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatGrp, topicName, false)\n\tdefer helper.tearDown()\n\tif len(helper.topic.sessions) != 0 {\n\t\thelper.finish()\n\t\tt.Fatalf(\"Initially attached sessions: expected 0 vs found %d\", len(helper.topic.sessions))\n\t}\n\n\tuid := helper.uids[0]\n\n\ts := helper.sessions[0]\n\tjoin := &ClientComMessage{\n\t\tOriginal: topicName,\n\t\tSub: &MsgClientSub{\n\t\t\tId:    \"id456\",\n\t\t\tTopic: topicName,\n\t\t\tSet: &MsgSetQuery{\n\t\t\t\tSub: &MsgSetSub{\n\t\t\t\t\t// Specify the user. This should result in an error.\n\t\t\t\t\tMode: \"Invalid mode string\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tAsUser: uid.UserId(),\n\t\tsess:   s,\n\t}\n\n\thelper.topic.registerSession(join)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif len(s.subs) != 0 {\n\t\tt.Errorf(\"Session subscriptions: expected 0, found %d\", len(s.subs))\n\t}\n\tonline := helper.topic.perUser[uid].online\n\tif online != 0 {\n\t\tt.Errorf(\"Number of online sessions: expected 0, found %d\", online)\n\t}\n\t// Session output.\n\tregisterSessionVerifyOutputs(t, helper.results[0], []int{http.StatusBadRequest})\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hub isn't expected to receive any messages, received %d\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestRegisterSessionMaxSubscriberCountExceeded(t *testing.T) {\n\ttopicName := \"grpTest\"\n\t// Pretend we already exceeded the maximum user count. This should produce an error.\n\tnumUsers := 10\n\toldMaxSubscribers := globals.maxSubscriberCount\n\tglobals.maxSubscriberCount = 10\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatGrp, topicName, false)\n\tdefer func() {\n\t\thelper.tearDown()\n\t\tglobals.maxSubscriberCount = oldMaxSubscribers\n\t}()\n\tif len(helper.topic.sessions) != 0 {\n\t\thelper.finish()\n\t\tt.Fatalf(\"Initially attached sessions: expected 0 vs found %d\", len(helper.topic.sessions))\n\t}\n\n\t// New uid. This should attempt to add a new subscription.\n\tuid := types.Uid(10001)\n\ts, r := helper.newSession(\"test-sid\", uid)\n\thelper.sessions = append(helper.sessions, s)\n\thelper.results = append(helper.results, r)\n\n\tjoin := &ClientComMessage{\n\t\tOriginal: topicName,\n\t\tSub: &MsgClientSub{\n\t\t\tId:    \"id456\",\n\t\t\tTopic: topicName,\n\t\t},\n\t\tAsUser: uid.UserId(),\n\t\tsess:   s,\n\t}\n\n\thelper.topic.registerSession(join)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif len(s.subs) != 0 {\n\t\tt.Errorf(\"Session subscriptions: expected 0, found %d\", len(s.subs))\n\t}\n\tonline := helper.topic.perUser[uid].online\n\tif online != 0 {\n\t\tt.Errorf(\"Number of online sessions: expected 0, found %d\", online)\n\t}\n\t// Session output.\n\tregisterSessionVerifyOutputs(t, r, []int{http.StatusUnprocessableEntity})\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hub isn't expected to receive any messages, received %d\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestRegisterSessionLowAuthLevelWithSysTopic(t *testing.T) {\n\ttopicName := \"sys\"\n\t// No one is subscribed to sys.\n\tnumUsers := 0\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatSys, topicName, false)\n\tdefer helper.tearDown()\n\tif len(helper.topic.sessions) != 0 {\n\t\thelper.finish()\n\t\tt.Fatalf(\"Initially attached sessions: expected 0 vs found %d\", len(helper.topic.sessions))\n\t}\n\n\t// New uid. This should attempt to add a new subscription\n\t// which produces an error b/c authLevel isn't root.\n\tuid := types.Uid(10001)\n\ts, r := helper.newSession(\"test-sid\", uid)\n\thelper.sessions = append(helper.sessions, s)\n\thelper.results = append(helper.results, r)\n\n\tjoin := &ClientComMessage{\n\t\tOriginal: topicName,\n\t\tSub: &MsgClientSub{\n\t\t\tId:    \"id456\",\n\t\t\tTopic: topicName,\n\t\t},\n\t\tAsUser: uid.UserId(),\n\t\tsess:   s,\n\t}\n\n\thelper.topic.registerSession(join)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif len(s.subs) != 0 {\n\t\tt.Errorf(\"Session subscriptions: expected 0, found %d\", len(s.subs))\n\t}\n\tonline := helper.topic.perUser[uid].online\n\tif online != 0 {\n\t\tt.Errorf(\"Number of online sessions: expected 0, found %d\", online)\n\t}\n\t// Session output.\n\tregisterSessionVerifyOutputs(t, r, []int{http.StatusForbidden})\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hub isn't expected to receive any messages, received %d\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestRegisterSessionNewChannelGetSubDbError(t *testing.T) {\n\ttopicName := \"grpTest\"\n\tchanName := \"chnTest\"\n\tnumUsers := 1\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatGrp, topicName, false)\n\t// It is a channel.\n\thelper.topic.isChan = true\n\tdefer helper.tearDown()\n\tif len(helper.topic.sessions) != 0 {\n\t\thelper.finish()\n\t\tt.Fatalf(\"Initially attached sessions: expected 0 vs found %d\", len(helper.topic.sessions))\n\t}\n\n\t// New uid. This should attempt to add a new subscription\n\t// which produces an error b/c authLevel isn't root.\n\tuid := types.Uid(10001)\n\ts, r := helper.newSession(\"test-sid\", uid)\n\thelper.sessions = append(helper.sessions, s)\n\thelper.results = append(helper.results, r)\n\n\tjoin := &ClientComMessage{\n\t\tOriginal: chanName,\n\t\tSub: &MsgClientSub{\n\t\t\tId:    \"id456\",\n\t\t\tTopic: chanName,\n\t\t},\n\t\tAsUser: uid.UserId(),\n\t\tsess:   s,\n\t}\n\n\thelper.ss.EXPECT().Get(chanName, uid, false).Return(nil, types.ErrInternal)\n\n\thelper.topic.registerSession(join)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif len(s.subs) != 0 {\n\t\tt.Errorf(\"Session subscriptions: expected 0, found %d\", len(s.subs))\n\t}\n\tonline := helper.topic.perUser[uid].online\n\tif online != 0 {\n\t\tt.Errorf(\"Number of online sessions: expected 0, found %d\", online)\n\t}\n\t// Session output.\n\tregisterSessionVerifyOutputs(t, r, []int{http.StatusInternalServerError})\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hub isn't expected to receive any messages, received %d\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestRegisterSessionCreateSubFailed(t *testing.T) {\n\ttopicName := \"grpTest\"\n\tnumUsers := 1\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatGrp, topicName, false)\n\tdefer helper.tearDown()\n\tif len(helper.topic.sessions) != 0 {\n\t\thelper.finish()\n\t\tt.Fatalf(\"Initially attached sessions: expected 0 vs found %d\", len(helper.topic.sessions))\n\t}\n\n\t// New uid. This should attempt to add a new subscription\n\t// which produces an error b/c authLevel isn't root.\n\tuid := types.Uid(10001)\n\ts, r := helper.newSession(\"test-sid\", uid)\n\thelper.sessions = append(helper.sessions, s)\n\thelper.results = append(helper.results, r)\n\n\tjoin := &ClientComMessage{\n\t\tOriginal: topicName,\n\t\tSub: &MsgClientSub{\n\t\t\tId:    \"id456\",\n\t\t\tTopic: topicName,\n\t\t},\n\t\tAsUser:  uid.UserId(),\n\t\tAuthLvl: int(auth.LevelAuth),\n\t\tsess:    s,\n\t}\n\n\thelper.ss.EXPECT().Get(topicName, uid, true).Return(nil, types.ErrInternal)\n\n\thelper.topic.registerSession(join)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif len(s.subs) != 0 {\n\t\tt.Errorf(\"Session subscriptions: expected 0, found %d\", len(s.subs))\n\t}\n\tonline := helper.topic.perUser[uid].online\n\tif online != 0 {\n\t\tt.Errorf(\"Number of online sessions: expected 0, found %d\", online)\n\t}\n\t// Session output.\n\tregisterSessionVerifyOutputs(t, r, []int{http.StatusInternalServerError})\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hub isn't expected to receive any messages, received %d\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestRegisterSessionAsChanUserNotChanSubcriber(t *testing.T) {\n\ttopicName := \"grpTest\"\n\tchanName := \"chnTest\"\n\tnumUsers := 1\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatGrp, topicName, false)\n\t// The topic is a channel.\n\thelper.topic.isChan = true\n\tdefer helper.tearDown()\n\tif len(helper.topic.sessions) != 0 {\n\t\thelper.finish()\n\t\tt.Fatalf(\"Initially attached sessions: expected 0 vs found %d\", len(helper.topic.sessions))\n\t}\n\n\ts := helper.sessions[0]\n\tuid := helper.uids[0]\n\tr := helper.results[0]\n\n\t// User is not a channel subscriber (userData.isChan is false).\n\tjoin := &ClientComMessage{\n\t\tOriginal: chanName,\n\t\tSub: &MsgClientSub{\n\t\t\tId:    \"id456\",\n\t\t\tTopic: chanName,\n\t\t},\n\t\tAsUser:  uid.UserId(),\n\t\tAuthLvl: int(auth.LevelAuth),\n\t\tsess:    s,\n\t}\n\n\thelper.topic.registerSession(join)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif len(s.subs) != 0 {\n\t\tt.Errorf(\"Session subscriptions: expected 0, found %d\", len(s.subs))\n\t}\n\tonline := helper.topic.perUser[uid].online\n\tif online != 0 {\n\t\tt.Errorf(\"Number of online sessions: expected 0, found %d\", online)\n\t}\n\t// Session output. Tell the subscriber to use non-channel name.\n\tregisterSessionVerifyOutputs(t, r, []int{http.StatusSeeOther})\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hub isn't expected to receive any messages, received %d\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestRegisterSessionOwnerBansHimself(t *testing.T) {\n\ttopicName := \"grpTest\"\n\tnumUsers := 1\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatGrp, topicName, false)\n\tdefer helper.tearDown()\n\tif len(helper.topic.sessions) != 0 {\n\t\thelper.finish()\n\t\tt.Fatalf(\"Initially attached sessions: expected 0 vs found %d\", len(helper.topic.sessions))\n\t}\n\n\ts := helper.sessions[0]\n\tuid := helper.uids[0]\n\tr := helper.results[0]\n\n\t// User is the topic owner.\n\thelper.topic.owner = uid\n\tpud := helper.topic.perUser[uid]\n\tpud.modeGiven |= types.ModeOwner\n\thelper.topic.perUser[uid] = pud\n\n\tjoin := &ClientComMessage{\n\t\tOriginal: topicName,\n\t\tSub: &MsgClientSub{\n\t\t\tId:    \"id456\",\n\t\t\tTopic: topicName,\n\t\t\tSet: &MsgSetQuery{\n\t\t\t\tSub: &MsgSetSub{\n\t\t\t\t\t// No O permission.\n\t\t\t\t\tMode: \"JPRW\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tAsUser:  uid.UserId(),\n\t\tAuthLvl: int(auth.LevelAuth),\n\t\tsess:    s,\n\t}\n\n\thelper.topic.registerSession(join)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif len(s.subs) != 0 {\n\t\tt.Errorf(\"Session subscriptions: expected 0, found %d\", len(s.subs))\n\t}\n\tonline := helper.topic.perUser[uid].online\n\tif online != 0 {\n\t\tt.Errorf(\"Number of online sessions: expected 0, found %d\", online)\n\t}\n\t// Session output.\n\tregisterSessionVerifyOutputs(t, r, []int{http.StatusForbidden})\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hub isn't expected to receive any messages, received %d\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestRegisterSessionInvalidOwnershipTransfer(t *testing.T) {\n\ttopicName := \"grpTest\"\n\tnumUsers := 2\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatGrp, topicName, false)\n\tdefer helper.tearDown()\n\tif len(helper.topic.sessions) != 0 {\n\t\thelper.finish()\n\t\tt.Fatalf(\"Initially attached sessions: expected 0 vs found %d\", len(helper.topic.sessions))\n\t}\n\n\ts := helper.sessions[1]\n\tuid := helper.uids[1]\n\tr := helper.results[1]\n\n\t// User is the topic owner.\n\tpud := helper.topic.perUser[uid]\n\tpud.modeWant = types.ModeCPublic\n\tpud.modeGiven = types.ModeCPublic\n\thelper.topic.perUser[uid] = pud\n\n\tjoin := &ClientComMessage{\n\t\tOriginal: topicName,\n\t\tSub: &MsgClientSub{\n\t\t\tId:    \"id456\",\n\t\t\tTopic: topicName,\n\t\t\tSet: &MsgSetQuery{\n\t\t\t\tSub: &MsgSetSub{\n\t\t\t\t\t// Want ownership.\n\t\t\t\t\tMode: \"JPRWSO\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tAsUser:  uid.UserId(),\n\t\tAuthLvl: int(auth.LevelAuth),\n\t\tsess:    s,\n\t}\n\n\thelper.topic.registerSession(join)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif len(s.subs) != 0 {\n\t\tt.Errorf(\"Session subscriptions: expected 0, found %d\", len(s.subs))\n\t}\n\tonline := helper.topic.perUser[uid].online\n\tif online != 0 {\n\t\tt.Errorf(\"Number of online sessions: expected 0, found %d\", online)\n\t}\n\t// Session output.\n\tregisterSessionVerifyOutputs(t, r, []int{http.StatusForbidden})\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hub isn't expected to receive any messages, received %d\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestRegisterSessionMetadataUpdateFails(t *testing.T) {\n\ttopicName := \"grpTest\"\n\tnumUsers := 2\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatGrp, topicName, false)\n\tdefer helper.tearDown()\n\tif len(helper.topic.sessions) != 0 {\n\t\thelper.finish()\n\t\tt.Fatalf(\"Initially attached sessions: expected 0 vs found %d\", len(helper.topic.sessions))\n\t}\n\n\ts := helper.sessions[1]\n\tuid := helper.uids[1]\n\tr := helper.results[1]\n\n\tpud := helper.topic.perUser[uid]\n\tpud.modeWant = types.ModeCPublic\n\tpud.modeGiven = types.ModeCPublic\n\thelper.topic.perUser[uid] = pud\n\n\t// Want ownership.\n\tnewWant := \"JRWP\"\n\tjoin := &ClientComMessage{\n\t\tOriginal: topicName,\n\t\tSub: &MsgClientSub{\n\t\t\tId:    \"id456\",\n\t\t\tTopic: topicName,\n\t\t\tSet: &MsgSetQuery{\n\t\t\t\tSub: &MsgSetSub{\n\t\t\t\t\tMode: newWant,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tAsUser:  uid.UserId(),\n\t\tAuthLvl: int(auth.LevelAuth),\n\n\t\tsess: s,\n\t}\n\t// DB call fails.\n\thelper.ss.EXPECT().Update(topicName, uid, gomock.Any()).Return(types.ErrInternal)\n\n\thelper.topic.registerSession(join)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif len(s.subs) != 0 {\n\t\tt.Errorf(\"Session subscriptions: expected 0, found %d\", len(s.subs))\n\t}\n\tonline := helper.topic.perUser[uid].online\n\tif online != 0 {\n\t\tt.Errorf(\"Number of online sessions: expected 0, found %d\", online)\n\t}\n\t// Session output.\n\tregisterSessionVerifyOutputs(t, r, []int{http.StatusInternalServerError})\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hub isn't expected to receive any messages, received %d\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestRegisterSessionOwnerChangeDbCallFails(t *testing.T) {\n\ttopicName := \"grpTest\"\n\tnumUsers := 1\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatGrp, topicName, false)\n\tdefer helper.tearDown()\n\tif len(helper.topic.sessions) != 0 {\n\t\thelper.finish()\n\t\tt.Fatalf(\"Initially attached sessions: expected 0 vs found %d\", len(helper.topic.sessions))\n\t}\n\n\ts := helper.sessions[0]\n\tuid := helper.uids[0]\n\tr := helper.results[0]\n\n\t// User is the topic owner.\n\tpud := helper.topic.perUser[uid]\n\tpud.modeWant = types.ModeCPublic\n\thelper.topic.perUser[uid] = pud\n\n\t// Want ownership.\n\tnewWant := \"JRWPASO\"\n\tjoin := &ClientComMessage{\n\t\tOriginal: topicName,\n\t\tSub: &MsgClientSub{\n\t\t\tId:    \"id456\",\n\t\t\tTopic: topicName,\n\t\t\tSet: &MsgSetQuery{\n\t\t\t\tSub: &MsgSetSub{\n\t\t\t\t\tMode: newWant,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tAsUser:  uid.UserId(),\n\t\tAuthLvl: int(auth.LevelAuth),\n\t\tsess:    s,\n\t}\n\thelper.ss.EXPECT().Update(topicName, uid, gomock.Any()).Return(nil).Times(2)\n\t// OwnerChange call fails.\n\thelper.tt.EXPECT().OwnerChange(topicName, uid).Return(types.ErrInternal)\n\n\thelper.topic.registerSession(join)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif len(s.subs) != 0 {\n\t\tt.Errorf(\"Session subscriptions: expected 0, found %d\", len(s.subs))\n\t}\n\tonline := helper.topic.perUser[uid].online\n\tif online != 0 {\n\t\tt.Errorf(\"Number of online sessions: expected 0, found %d\", online)\n\t}\n\tregisterSessionVerifyOutputs(t, r, []int{})\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hub isn't expected to receive any messages, received %d\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestUnregisterSessionSimple(t *testing.T) {\n\ttopicName := \"usrMe\"\n\tnumUsers := 1\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatMe, topicName /*attach=*/, true)\n\tdefer helper.tearDown()\n\n\tuid := helper.uids[0]\n\thelper.uu.EXPECT().UpdateLastSeen(uid, gomock.Any(), gomock.Any()).Return(nil)\n\n\t// Add a couple more sessions.\n\tfor i := 1; i < 3; i++ {\n\t\ts, r := helper.newSession(fmt.Sprintf(\"sid%d\", i), uid)\n\t\thelper.sessions = append(helper.sessions, s)\n\t\thelper.results = append(helper.results, r)\n\t\thelper.topic.sessions[s] = perSessionData{uid: uid}\n\t\tpu := helper.topic.perUser[uid]\n\t\tpu.online++\n\t\thelper.topic.perUser[uid] = pu\n\t}\n\n\t// Initial online and attach session counts.\n\tif len(helper.topic.sessions) != 3 {\n\t\thelper.finish()\n\t\tt.Fatalf(\"Initially attached sessions: expected 3 vs found %d\", len(helper.topic.sessions))\n\t}\n\tif online := helper.topic.perUser[uid].online; online != 3 {\n\t\tt.Errorf(\"Number of online sessions: expected 3 vs found %d\", online)\n\t}\n\n\ts := helper.sessions[0]\n\tr := helper.results[0]\n\tleave := &ClientComMessage{\n\t\tLeave: &MsgClientLeave{\n\t\t\tId:    \"id456\",\n\t\t\tTopic: topicName,\n\t\t},\n\t\tAsUser: uid.UserId(),\n\t\tsess:   s,\n\t\tinit:   true,\n\t}\n\thelper.topic.unregisterSession(leave)\n\n\thelper.finish()\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif len(helper.topic.sessions) != 2 {\n\t\tt.Errorf(\"Attached sessions: expected 2, found %d\", len(helper.topic.sessions))\n\t}\n\tif len(s.subs) != 0 {\n\t\tt.Errorf(\"Session subscriptions: expected 0, found %d\", len(helper.sessions[0].subs))\n\t}\n\tif online := helper.topic.perUser[uid].online; online != 2 {\n\t\tt.Errorf(\"Number of online sessions after unregistering: expected 2, found %d\", online)\n\t}\n\t// Session output.\n\tregisterSessionVerifyOutputs(t, r, []int{http.StatusOK})\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hub isn't expected to receive any messages, received %d\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestUnregisterSessionInactiveTopic(t *testing.T) {\n\ttopicName := \"usrMe\"\n\tnumUsers := 1\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatMe, topicName, true)\n\tdefer helper.tearDown()\n\n\tuid := helper.uids[0]\n\n\t// Initial online and attach session counts.\n\tif len(helper.topic.sessions) != 1 {\n\t\thelper.finish()\n\t\tt.Fatalf(\"Initially attached sessions: expected 1 vs found %d\", len(helper.topic.sessions))\n\t}\n\tif online := helper.topic.perUser[uid].online; online != 1 {\n\t\tt.Errorf(\"Number of online sessions: expected 1 vs found %d\", online)\n\t}\n\n\ts := helper.sessions[0]\n\tr := helper.results[0]\n\tleave := &ClientComMessage{\n\t\tLeave: &MsgClientLeave{\n\t\t\tId:    \"id456\",\n\t\t\tTopic: topicName,\n\t\t},\n\t\tAsUser: uid.UserId(),\n\t\tsess:   s,\n\t\tinit:   true,\n\t}\n\n\t// Deactivate topic.\n\thelper.topic.markDeleted()\n\n\thelper.topic.unregisterSession(leave)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif len(helper.topic.sessions) != 1 {\n\t\tt.Errorf(\"Attached sessions: expected 1, found %d\", len(helper.topic.sessions))\n\t}\n\tif len(s.subs) != 0 {\n\t\tt.Errorf(\"Session subscriptions: expected 0, found %d\", len(s.subs))\n\t}\n\tif online := helper.topic.perUser[uid].online; online != 1 {\n\t\tt.Errorf(\"Number of online sessions after unregistering: expected 1, found %d\", online)\n\t}\n\t// Session output.\n\tregisterSessionVerifyOutputs(t, r, []int{http.StatusServiceUnavailable})\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hub isn't expected to receive any messages, received %d\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestUnregisterSessionUnsubscribe(t *testing.T) {\n\ttopicName := \"grpTest\"\n\tnumUsers := 3\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatGrp, topicName, true)\n\tdefer helper.tearDown()\n\n\tuid := helper.uids[2]\n\thelper.ss.EXPECT().Delete(topicName, uid).Return(nil)\n\n\t// Add a couple more sessions.\n\tfor i := range 2 {\n\t\ts, r := helper.newSession(fmt.Sprintf(\"sid-uid-%d-%d\", uid, i), uid)\n\t\thelper.sessions = append(helper.sessions, s)\n\t\thelper.results = append(helper.results, r)\n\t\thelper.topic.sessions[s] = perSessionData{uid: uid}\n\t\tpu := helper.topic.perUser[uid]\n\t\tpu.online++\n\t\thelper.topic.perUser[uid] = pu\n\t}\n\n\t// Initial online and attach session counts.\n\tif len(helper.topic.sessions) != 5 {\n\t\thelper.finish()\n\t\tt.Fatalf(\"Initially attached sessions: expected 5 vs found %d\", len(helper.topic.sessions))\n\t}\n\tif online := helper.topic.perUser[uid].online; online != 3 {\n\t\tt.Errorf(\"Number of online sessions: expected 3 vs found %d\", online)\n\t}\n\n\tleave := &ClientComMessage{\n\t\tLeave: &MsgClientLeave{\n\t\t\tId:    \"id456\",\n\t\t\tTopic: topicName,\n\t\t\tUnsub: true,\n\t\t},\n\t\tAsUser: uid.UserId(),\n\t\tsess:   helper.sessions[0],\n\t\tinit:   true,\n\t}\n\thelper.topic.unregisterSession(leave)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif len(helper.topic.sessions) != 2 {\n\t\tt.Errorf(\"Attached sessions: expected 2, found %d\", len(helper.topic.sessions))\n\t}\n\tif len(helper.sessions[0].subs) != 0 {\n\t\tt.Errorf(\"Session subscriptions: expected 0, found %d\", len(helper.sessions[0].subs))\n\t}\n\tif pu, ok := helper.topic.perUser[uid]; pu.online != 0 || ok {\n\t\tt.Errorf(\"Number of online sessions after unsubscribing: expected 2, found %d; perUser entry found: %t\", pu.online, ok)\n\t}\n\t// Session output. Sessions 2, 3, 4 are the evicted/unsubscribed uid.\n\tfor i := 2; i < 5; i++ {\n\t\tr := helper.results[i]\n\t\tregisterSessionVerifyOutputs(t, r, []int{http.StatusResetContent})\n\t}\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 2 {\n\t\tt.Errorf(\"Hub messages recipients: expected 2, received %d\", len(helper.hubMessages))\n\t}\n\t// Group presSubs.\n\tif grpPres, ok := helper.hubMessages[topicName]; ok {\n\t\tif len(grpPres) != 2 {\n\t\t\tt.Fatalf(\"Group presence messages: expected 2, got %d\", len(grpPres))\n\t\t}\n\t\tfor _, msg := range grpPres {\n\t\t\t//\n\t\t\tpres := msg.Pres\n\t\t\tif pres == nil {\n\t\t\t\tt.Fatal(\"Presence message expected in hub output, but not found.\")\n\t\t\t}\n\t\t\tif pres.Topic != topicName {\n\t\t\t\tt.Errorf(\"Presence message topic: expected %s, found %s\", topicName, pres.Topic)\n\t\t\t}\n\t\t\tif pres.Src != uid.UserId() {\n\t\t\t\tt.Errorf(\"Presence message src: expected %s, found %s\", uid.UserId(), pres.Src)\n\t\t\t}\n\t\t\tif pres.What != \"acs\" && pres.What != \"off\" {\n\t\t\t\tt.Errorf(\"Presence message what: expected 'acs' or 'off', found %s\", pres.What)\n\t\t\t}\n\t\t}\n\t} else {\n\t\tt.Errorf(\"Hub expected to pres recipient %s\", topicName)\n\t}\n\t// User notification.\n\tif userPres, ok := helper.hubMessages[uid.UserId()]; ok {\n\t\tif len(userPres) != 1 {\n\t\t\tt.Fatalf(\"User presence messages: expected 1, got %d\", len(userPres))\n\t\t}\n\t\tpres := userPres[0].Pres\n\t\tif pres == nil {\n\t\t\tt.Fatal(\"Presence message expected in hub output, but not found.\")\n\t\t}\n\t\tif pres.Topic != \"me\" {\n\t\t\tt.Errorf(\"Presence message topic: expected 'me', found %s\", pres.Topic)\n\t\t}\n\t\tif pres.Src != topicName {\n\t\t\tt.Errorf(\"Presence message src: expected %s, found %s\", topicName, pres.Src)\n\t\t}\n\t\tif pres.What != \"gone\" {\n\t\t\tt.Errorf(\"Presence message what: expected 'gone', found %s\", pres.What)\n\t\t}\n\t} else {\n\t\tt.Errorf(\"Hub expected to pres recipient %s\", uid.UserId())\n\t}\n}\n\nfunc TestUnregisterSessionOwnerCannotUnsubscribe(t *testing.T) {\n\ttopicName := \"grpTest\"\n\tnumUsers := 3\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatGrp, topicName, true)\n\tdefer helper.tearDown()\n\n\tuid := helper.uids[0]\n\ts := helper.sessions[0]\n\tr := helper.results[0]\n\n\tleave := &ClientComMessage{\n\t\tLeave: &MsgClientLeave{\n\t\t\tId:    \"id456\",\n\t\t\tTopic: topicName,\n\t\t\tUnsub: true,\n\t\t},\n\t\tAsUser: uid.UserId(),\n\t\tsess:   s,\n\t\tinit:   true,\n\t}\n\n\thelper.topic.unregisterSession(leave)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif len(helper.topic.sessions) != 3 {\n\t\tt.Errorf(\"Attached sessions: expected 3, found %d\", len(helper.topic.sessions))\n\t}\n\tif len(s.subs) != 0 {\n\t\tt.Errorf(\"Session subscriptions: expected 0, found %d\", len(helper.sessions[0].subs))\n\t}\n\tif online := helper.topic.perUser[uid].online; online != 1 {\n\t\tt.Errorf(\"Number of online sessions after failed unsubscribing: expected 1, found %d.\", online)\n\t}\n\t// Session output.\n\tregisterSessionVerifyOutputs(t, r, []int{http.StatusForbidden})\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hub messages recipients: expected 0, received %d\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestUnregisterSessionUnsubDeleteCallFails(t *testing.T) {\n\ttopicName := \"grpTest\"\n\tnumUsers := 3\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatGrp, topicName, true)\n\tdefer helper.tearDown()\n\n\t// Unsubscribe user 1 (cannot unsub user 0, the owner).\n\tuid := helper.uids[1]\n\ts := helper.sessions[1]\n\tr := helper.results[1]\n\n\tleave := &ClientComMessage{\n\t\tLeave: &MsgClientLeave{\n\t\t\tId:    \"id456\",\n\t\t\tTopic: topicName,\n\t\t\tUnsub: true,\n\t\t},\n\t\tAsUser: uid.UserId(),\n\t\tsess:   s,\n\t\tinit:   true,\n\t}\n\t// DB call fails.\n\thelper.ss.EXPECT().Delete(topicName, uid).Return(types.ErrInternal)\n\n\thelper.topic.unregisterSession(leave)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif len(helper.topic.sessions) != 3 {\n\t\tt.Errorf(\"Attached sessions: expected 3, found %d\", len(helper.topic.sessions))\n\t}\n\tif len(s.subs) != 0 {\n\t\tt.Errorf(\"Session subscriptions: expected 0, found %d\", len(helper.sessions[0].subs))\n\t}\n\tif online := helper.topic.perUser[uid].online; online != 1 {\n\t\tt.Errorf(\"Number of online sessions after failed unsubscribing: expected 1, found %d.\", online)\n\t}\n\t// Session output.\n\tregisterSessionVerifyOutputs(t, r, []int{http.StatusInternalServerError})\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hub messages recipients: expected 0, received %d\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestHandleMetaChanErr(t *testing.T) {\n\ttopicName := \"grpTest\"\n\tchanName := \"chnTest\"\n\tnumUsers := 3\n\thelper := TopicTestHelper{}\n\tdefer helper.tearDown()\n\thelper.setUp(t, numUsers, types.TopicCatGrp, topicName, false)\n\n\t// This is not a channel. However, we will try to handle an info message where\n\t// the topic is referenced as \"chn\".\n\thelper.topic.isChan = false\n\t// Empty message since this request should trigger an error anyway.\n\tmeta := &ClientComMessage{\n\t\tAsUser:   helper.uids[0].UserId(),\n\t\tOriginal: chanName,\n\t\tMetaWhat: constMsgMetaDesc | constMsgMetaSub | constMsgMetaData | constMsgMetaDel,\n\t\tsess:     helper.sessions[0],\n\t}\n\thelper.topic.handleMeta(meta)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\t// Session output.\n\tregisterSessionVerifyOutputs(t, helper.results[0], []int{http.StatusNotFound})\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hub messages recipients: expected 0, received %d\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestHandleMetaGet(t *testing.T) {\n\ttopicName := \"usrMe\"\n\tnumUsers := 1\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatMe, topicName, true)\n\tdefer helper.tearDown()\n\n\tuid := helper.uids[0]\n\thelper.mm.EXPECT().GetAll(topicName, uid, gomock.Any()).Return([]types.Message{}, nil)\n\thelper.mm.EXPECT().GetDeleted(topicName, uid, gomock.Any()).Return([]types.Range{}, 0, nil)\n\thelper.uu.EXPECT().GetTopics(uid, gomock.Any()).Return([]types.Subscription{}, nil)\n\n\tmeta := &ClientComMessage{\n\t\tGet: &MsgClientGet{\n\t\t\tId:    \"id456\",\n\t\t\tTopic: topicName,\n\t\t\tMsgGetQuery: MsgGetQuery{\n\t\t\t\tWhat: \"desc sub data del\",\n\t\t\t\tDesc: &MsgGetOpts{},\n\t\t\t\tSub:  &MsgGetOpts{},\n\t\t\t\tData: &MsgGetOpts{},\n\t\t\t\tDel:  &MsgGetOpts{},\n\t\t\t},\n\t\t},\n\t\tAsUser:   uid.UserId(),\n\t\tMetaWhat: constMsgMetaDesc | constMsgMetaSub | constMsgMetaData | constMsgMetaDel,\n\t\tsess:     helper.sessions[0],\n\t}\n\thelper.topic.handleMeta(meta)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tr := helper.results[0]\n\tif len(r.messages) != 4 {\n\t\tt.Errorf(\"responses received: expected 4, received %d\", len(r.messages))\n\t}\n\tfor _, msg := range r.messages {\n\t\tm := msg.(*ServerComMessage)\n\t\tif m.Meta != nil {\n\t\t\tif m.Meta.Desc == nil {\n\t\t\t\tt.Error(\"Meta.Desc expected to be specified.\")\n\t\t\t}\n\t\t} else if m.Ctrl == nil {\n\t\t\tt.Error(\"Expected only meta or ctrl messages.\")\n\t\t}\n\t}\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Errorf(\"Hub messages recipients: expected 0, received %d\", len(helper.hubMessages))\n\t}\n}\n\n// Matches a subset in a superset.\ntype supersetOf struct{ subset map[string]string }\n\nfunc SupersetOf(subset map[string]string) gomock.Matcher {\n\treturn &supersetOf{subset}\n}\n\nfunc (s *supersetOf) Matches(x any) bool {\n\tsuper := x.(map[string]any)\n\tif super == nil {\n\t\treturn false\n\t}\n\tfor k, v := range s.subset {\n\t\tif x, ok := super[k]; ok {\n\t\t\tval := x.(string)\n\t\t\tif val != v {\n\t\t\t\treturn false\n\t\t\t}\n\t\t} else {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc (s *supersetOf) String() string {\n\treturn fmt.Sprintf(\"%+v is subset\", s.subset)\n}\n\nfunc TestHandleMetaSetDescMePublicPrivate(t *testing.T) {\n\ttopicName := \"usrMe\"\n\tnumUsers := 1\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatMe, topicName /*attach=*/, true)\n\tdefer helper.tearDown()\n\n\tuid := helper.uids[0]\n\tgomock.InOrder(\n\t\thelper.uu.EXPECT().Update(uid, SupersetOf(map[string]string{\"Public\": \"new public\"})).Return(nil),\n\t\thelper.ss.EXPECT().Update(topicName, uid, map[string]any{\"Private\": \"new private\"}).Return(nil),\n\t)\n\n\tmeta := &ClientComMessage{\n\t\tSet: &MsgClientSet{\n\t\t\tId:    \"id456\",\n\t\t\tTopic: topicName,\n\t\t\tMsgSetQuery: MsgSetQuery{\n\t\t\t\tDesc: &MsgSetDesc{\n\t\t\t\t\tPublic:  \"new public\",\n\t\t\t\t\tPrivate: \"new private\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tAsUser:   uid.UserId(),\n\t\tMetaWhat: constMsgMetaDesc,\n\t\tsess:     helper.sessions[0],\n\t}\n\thelper.topic.handleMeta(meta)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tr := helper.results[0]\n\tif len(r.messages) != 1 {\n\t\tt.Fatalf(\"responses received: expected 1, received %d\", len(r.messages))\n\t}\n\tmsg := r.messages[0].(*ServerComMessage)\n\tif msg == nil || msg.Ctrl == nil {\n\t\tt.Fatalf(\"Server message expected to have a ctrl submessage: %+v\", msg)\n\t}\n\tif msg.Ctrl.Code != 200 {\n\t\tt.Errorf(\"Response code: expected 200, found %d\", msg.Ctrl.Code)\n\t}\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 1 {\n\t\tt.Fatalf(\"Hub messages recipients: expected 1, received %d\", len(helper.hubMessages))\n\t}\n\t// Make sure uid's sessions are notified.\n\tif userPres, ok := helper.hubMessages[uid.UserId()]; ok {\n\t\tif len(userPres) != 1 {\n\t\t\tt.Fatalf(\"User presence messages: expected 1, got %d\", len(userPres))\n\t\t}\n\t\tif userPres[0].SkipSid != helper.sessions[0].sid {\n\t\t\tt.Errorf(\"Pres notification SkipSid: %s expected vs %s found\", helper.sessions[0].sid, userPres[0].SkipSid)\n\t\t}\n\t\tpres := userPres[0].Pres\n\t\tif pres == nil {\n\t\t\tt.Fatal(\"Presence message expected in hub output, but not found.\")\n\t\t}\n\t\tif pres.Topic != \"me\" {\n\t\t\tt.Errorf(\"Presence message topic: expected 'me', found %s\", pres.Topic)\n\t\t}\n\t\tif pres.What != \"upd\" {\n\t\t\tt.Errorf(\"Presence message what: expected 'upd', found %s\", pres.What)\n\t\t}\n\t} else {\n\t\tt.Errorf(\"Hub expected to pres recipient %s\", uid.UserId())\n\t}\n}\n\nfunc TestHandleSessionUpdateSessToForeground(t *testing.T) {\n\ttopicName := \"usrMe\"\n\tnumUsers := 1\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatMe, topicName /*attach=*/, true)\n\tdefer helper.tearDown()\n\n\tuid := helper.uids[0]\n\tsupd := &sessionUpdate{\n\t\tsess: helper.sessions[0],\n\t}\n\tvar uaAgent string\n\thelper.topic.handleSessionUpdate(supd, &uaAgent, nil)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\t// Expect online count bumped up to 2.\n\tif online := helper.topic.perUser[uid].online; online != 2 {\n\t\tt.Errorf(\"online count for %s: expected 2, found %d\", uid.UserId(), online)\n\t}\n}\n\nfunc TestHandleSessionUpdateUserAgent(t *testing.T) {\n\ttopicName := \"usrMe\"\n\tnumUsers := 1\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatMe, topicName /*attach=*/, true)\n\tdefer helper.tearDown()\n\n\tuid := helper.uids[0]\n\tsupd := &sessionUpdate{\n\t\tuserAgent: \"newUA\",\n\t}\n\tuaAgent := \"oldUA\"\n\ttimer := time.NewTimer(time.Hour)\n\thelper.topic.handleSessionUpdate(supd, &uaAgent, timer)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\t// online count stays 1.\n\tif online := helper.topic.perUser[uid].online; online != 1 {\n\t\tt.Errorf(\"online count for %s: expected 1, found %d\", uid.UserId(), online)\n\t}\n\tif uaAgent != \"newUA\" {\n\t\tt.Errorf(\"User agent: expected 'newUA', found '%s'\", uaAgent)\n\t}\n\ttimer.Stop()\n}\n\nfunc TestHandleUATimerEvent(t *testing.T) {\n\ttopicName := \"usrMe\"\n\tnumUsers := 1\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatMe, topicName /*attach=*/, true)\n\tdefer helper.tearDown()\n\n\tuid := helper.uids[0]\n\thelper.topic.perSubs = make(map[string]perSubsData)\n\thelper.topic.perSubs[uid.UserId()] = perSubsData{online: true}\n\thelper.topic.handleUATimerEvent(\"newUA\")\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif helper.topic.userAgent != \"newUA\" {\n\t\tt.Errorf(\"Topic's user agent: expected 'newUA', found '%s'\", helper.topic.userAgent)\n\t}\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 1 {\n\t\tt.Fatalf(\"Hub messages recipients: expected 1, received %d\", len(helper.hubMessages))\n\t}\n\t// Make sure uid's sessions are notified.\n\tif userPres, ok := helper.hubMessages[uid.UserId()]; ok {\n\t\tif len(userPres) != 1 {\n\t\t\tt.Fatalf(\"User presence messages: expected 1, got %d\", len(userPres))\n\t\t}\n\t\tpres := userPres[0].Pres\n\t\tif pres == nil {\n\t\t\tt.Fatal(\"Presence message expected in hub output, but not found.\")\n\t\t}\n\t\tif pres.Topic != \"me\" {\n\t\t\tt.Errorf(\"Presence message topic: expected 'me', found '%s'\", pres.Topic)\n\t\t}\n\t\tif pres.What != \"ua\" {\n\t\t\tt.Errorf(\"Presence message what: expected 'ua', found '%s'\", pres.What)\n\t\t}\n\t\tif pres.Src != topicName {\n\t\t\tt.Errorf(\"Presence message src: expected '%s', found '%s'\", topicName, pres.Src)\n\t\t}\n\t} else {\n\t\tt.Errorf(\"Hub expected to pres recipient %s\", uid.UserId())\n\t}\n}\n\nfunc TestHandleTopicTimeout(t *testing.T) {\n\ttopicName := \"usrMe\"\n\tnumUsers := 1\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatMe, topicName /*attach=*/, true)\n\tdefer helper.tearDown()\n\n\tuid := helper.uids[0]\n\thelper.topic.perSubs = make(map[string]perSubsData)\n\thelper.topic.perSubs[uid.UserId()] = perSubsData{online: true}\n\thelper.hub.unreg = make(chan *topicUnreg, 10)\n\tuaTimer := time.NewTimer(time.Hour)\n\tnotifTimer := time.NewTimer(time.Hour)\n\thelper.topic.handleTopicTimeout(helper.hub, \"newUA\", uaTimer, notifTimer)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif len(helper.hub.unreg) != 1 {\n\t\tt.Fatalf(\"Hub.unreg chan must contain exactly 1 message. Found %d.\", len(helper.hub.unreg))\n\t}\n\tif unreg := <-helper.hub.unreg; unreg.rcptTo != topicName {\n\t\tt.Errorf(\"unreg.rcptTo: expected '%s', found '%s'\", topicName, unreg.rcptTo)\n\t}\n\tuaTimer.Stop()\n\tnotifTimer.Stop()\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 1 {\n\t\tt.Fatalf(\"Hub messages recipients: expected 1, received %d\", len(helper.hubMessages))\n\t}\n\t// Make sure uid's sessions are notified.\n\tif userPres, ok := helper.hubMessages[uid.UserId()]; ok {\n\t\tif len(userPres) != 1 {\n\t\t\tt.Fatalf(\"User presence messages: expected 1, got %d\", len(userPres))\n\t\t}\n\t\tpres := userPres[0].Pres\n\t\tif pres == nil {\n\t\t\tt.Fatal(\"Presence message expected in hub output, but not found.\")\n\t\t}\n\t\tif pres.Topic != \"me\" {\n\t\t\tt.Errorf(\"Presence message topic: expected 'me', found '%s'\", pres.Topic)\n\t\t}\n\t\tif pres.What != \"off\" {\n\t\t\tt.Errorf(\"Presence message what: expected 'off', found '%s'\", pres.What)\n\t\t}\n\t\tif pres.Src != topicName {\n\t\t\tt.Errorf(\"Presence message src: expected '%s', found '%s'\", topicName, pres.Src)\n\t\t}\n\t} else {\n\t\tt.Errorf(\"Hub expected to pres recipient %s\", uid.UserId())\n\t}\n}\n\nfunc TestHandleTopicTermination(t *testing.T) {\n\ttopicName := \"usrMe\"\n\tnumUsers := 1\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatMe, topicName /*attach=*/, true)\n\tdefer helper.tearDown()\n\n\tdone := make(chan bool, 1)\n\texit := &shutDown{\n\t\treason: StopDeleted,\n\t\tdone:   done,\n\t}\n\thelper.topic.handleTopicTermination(exit)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif len(done) != 1 {\n\t\tt.Fatal(\"done callback isn't invoked.\")\n\t}\n\t<-done\n\tfor i, s := range helper.sessions {\n\t\tif len(s.detach) != 1 {\n\t\t\tt.Fatalf(\"Session %d: detach channel is empty.\", i)\n\t\t}\n\t\tval := <-s.detach\n\t\tif val != topicName {\n\t\t\tt.Errorf(\"Session %d is expected to detach from topic '%s', found '%s'.\", i, topicName, val)\n\t\t}\n\t}\n\t// Presence notifications.\n\tif len(helper.hubMessages) != 0 {\n\t\tt.Fatalf(\"Hub messages recipients: expected 0, received %d\", len(helper.hubMessages))\n\t}\n}\n\nfunc TestHandleBroadcastDataWithAttachments(t *testing.T) {\n\tnumUsers := 2\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatP2P, \"p2p-test\", true)\n\tdefer helper.tearDown()\n\thelper.mm.EXPECT().Save(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true)\n\n\tfrom := helper.uids[0].UserId()\n\tmsg := &ClientComMessage{\n\t\tAsUser:   from,\n\t\tOriginal: from,\n\t\tPub: &MsgClientPub{\n\t\t\tTopic:   \"p2p\",\n\t\t\tContent: \"Check out this image!\",\n\t\t\tHead: map[string]any{\n\t\t\t\t\"attachments\": []map[string]any{\n\t\t\t\t\t{\"mime\": \"image/jpeg\", \"name\": \"photo.jpg\", \"size\": 1024000},\n\t\t\t\t},\n\t\t\t},\n\t\t\tNoEcho: true,\n\t\t},\n\t\tsess: helper.sessions[0],\n\t}\n\thelper.topic.handleClientMsg(msg)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\t// Verify message with attachments was delivered\n\tif len(helper.results[1].messages) != 1 {\n\t\tt.Fatalf(\"Uid2: expected 1 message, got %d\", len(helper.results[1].messages))\n\t}\n\tr := helper.results[1].messages[0].(*ServerComMessage)\n\tif r.Data == nil {\n\t\tt.Fatal(\"Response must have a data message\")\n\t}\n\tif r.Data.Head == nil {\n\t\tt.Fatal(\"Response must have attachments in head\")\n\t}\n\tattachments := r.Data.Head[\"attachments\"]\n\tif attachments == nil {\n\t\tt.Fatal(\"Expected attachments in message head\")\n\t}\n}\n\nfunc TestHandleBroadcastInfoChannelWithMultipleReaders(t *testing.T) {\n\ttopicName := \"grpTest\"\n\tchanName := \"chnTest\"\n\tnumUsers := 5\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatGrp, topicName, true)\n\thelper.topic.isChan = true\n\tdefer helper.tearDown()\n\thelper.topic.lastID = 15\n\n\treadId := 12\n\tfrom := helper.uids[0]\n\n\t// Set up multiple channel readers\n\tfor i := 1; i < numUsers; i++ {\n\t\tuid := helper.uids[i]\n\t\tpud := helper.topic.perUser[uid]\n\t\tpud.modeGiven = types.ModeCChnReader\n\t\tpud.isChan = true\n\t\thelper.topic.perUser[uid] = pud\n\t}\n\n\thelper.ss.EXPECT().Update(chanName, from, map[string]any{\"ReadSeqId\": readId}).Return(nil)\n\n\tmsg := &ClientComMessage{\n\t\tAsUser:   from.UserId(),\n\t\tOriginal: chanName,\n\t\tNote: &MsgClientNote{\n\t\t\tTopic: chanName,\n\t\t\tWhat:  \"read\",\n\t\t\tSeqId: readId,\n\t\t},\n\t\tsess: helper.sessions[0],\n\t}\n\thelper.topic.handleClientMsg(msg)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\t// Channel topics don't forward note messages to other users\n\tfor i, r := range helper.results {\n\t\tif numMessages := len(r.messages); numMessages != 0 {\n\t\t\tt.Errorf(\"User %d is not expected to receive any messages, %d received\", i, numMessages)\n\t\t}\n\t}\n\n\t// Only sender gets presence notification\n\tif len(helper.hubMessages) != 1 {\n\t\tt.Fatalf(\"Hub expected exactly 1 recipient, got %d\", len(helper.hubMessages))\n\t}\n\tif _, ok := helper.hubMessages[from.UserId()]; !ok {\n\t\tt.Fatal(\"Expected presence notification for sender\")\n\t}\n}\n\nfunc TestRegisterSessionWithComplexModeString(t *testing.T) {\n\ttopicName := \"grpTest\"\n\tnumUsers := 2\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatGrp, topicName, false)\n\tdefer helper.tearDown()\n\n\tuid := helper.uids[1]\n\ts := helper.sessions[1]\n\tr := helper.results[1]\n\n\t// User with existing subscription wants to change mode\n\tpud := helper.topic.perUser[uid]\n\tpud.modeWant = types.ModeCPublic\n\tpud.modeGiven = types.ModeCPublic\n\thelper.topic.perUser[uid] = pud\n\n\tjoin := &ClientComMessage{\n\t\tOriginal: topicName,\n\t\tSub: &MsgClientSub{\n\t\t\tId:    \"id456\",\n\t\t\tTopic: topicName,\n\t\t\tSet: &MsgSetQuery{\n\t\t\t\tSub: &MsgSetSub{\n\t\t\t\t\tMode: \"JRWPAS\", // Complex mode string with multiple permissions\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tAsUser:  uid.UserId(),\n\t\tAuthLvl: int(auth.LevelAuth),\n\t\tsess:    s,\n\t}\n\n\thelper.ss.EXPECT().Update(topicName, uid, gomock.Any()).Return(nil)\n\n\thelper.topic.registerSession(join)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\tif len(helper.topic.sessions) != 1 {\n\t\tt.Fatalf(\"Attached sessions: expected 1, found %d\", len(helper.topic.sessions))\n\t}\n\tif len(s.subs) != 1 {\n\t\tt.Fatalf(\"Session subscriptions: expected 1, found %d\", len(s.subs))\n\t}\n\tonline := helper.topic.perUser[uid].online\n\tif online != 1 {\n\t\tt.Fatalf(\"Number of online sessions: expected 1, found %d\", online)\n\t}\n\tregisterSessionVerifyOutputs(t, r, []int{http.StatusOK})\n}\n\nfunc TestHandleBroadcastDataGroupWithMutedUser(t *testing.T) {\n\ttopicName := \"grp-test\"\n\tnumUsers := 4\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatGrp, topicName, true)\n\tdefer helper.tearDown()\n\thelper.mm.EXPECT().Save(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true)\n\n\t// User 2 has muted the topic (no Pres permission)\n\tpu2 := helper.topic.perUser[helper.uids[2]]\n\tpu2.modeWant = types.ModeJoin | types.ModeRead | types.ModeWrite\n\tpu2.modeGiven = pu2.modeWant\n\thelper.topic.perUser[helper.uids[2]] = pu2\n\n\tfrom := helper.uids[0].UserId()\n\tmsg := &ClientComMessage{\n\t\tAsUser:   from,\n\t\tOriginal: topicName,\n\t\tPub: &MsgClientPub{\n\t\t\tTopic:   topicName,\n\t\t\tContent: \"test message\",\n\t\t\tNoEcho:  true,\n\t\t},\n\t\tsess: helper.sessions[0],\n\t}\n\n\thelper.topic.handleClientMsg(msg)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\t// User 2 should still receive the message (has Read permission)\n\tif len(helper.results[2].messages) != 1 {\n\t\tt.Fatalf(\"Uid2: expected 1 message, got %d\", len(helper.results[2].messages))\n\t}\n\n\t// Check presence notifications - muted user should not receive presence\n\tif len(helper.hubMessages) != 3 { // Users 0, 1, 3 but not 2\n\t\tt.Fatalf(\"Hub expected 3 recipients, got %d\", len(helper.hubMessages))\n\t}\n\n\t// Verify user 2 is not in presence notifications\n\tif _, ok := helper.hubMessages[helper.uids[2].UserId()]; ok {\n\t\tt.Fatal(\"Muted user should not receive presence notifications\")\n\t}\n}\n\nfunc TestUnregisterSessionWithPendingCall(t *testing.T) {\n\tnumUsers := 2\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, numUsers, types.TopicCatP2P, \"p2p-test\", true)\n\tdefer helper.tearDown()\n\n\tuid := helper.uids[0]\n\ts := helper.sessions[0]\n\tr := helper.results[0]\n\n\t// Set up a pending call matching the actual videoCall structure\n\thelper.topic.currentCall = &videoCall{\n\t\tseq:     123,\n\t\tparties: make(map[string]callPartyData),\n\t}\n\thelper.topic.currentCall.parties[s.sid] = callPartyData{\n\t\tuid:          uid,\n\t\tisOriginator: true,\n\t\tsess:         s,\n\t}\n\thelper.mm.EXPECT().Save(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true)\n\n\tleave := &ClientComMessage{\n\t\tLeave: &MsgClientLeave{\n\t\t\tId:    \"id456\",\n\t\t\tTopic: \"p2p-test\",\n\t\t},\n\t\tAsUser: uid.UserId(),\n\t\tsess:   s,\n\t\tinit:   true,\n\t}\n\n\thelper.topic.unregisterSession(leave)\n\thelper.finish()\n\n\t// Check for errors from testHubLoop\n\tif errorMsgs, hasError := helper.hubMessages[\"__ERROR__\"]; hasError {\n\t\tt.Fatal(errorMsgs[0].Ctrl.Text)\n\t}\n\n\t// Verify session was unregistered\n\tif len(helper.topic.sessions) != 1 {\n\t\tt.Errorf(\"Attached sessions: expected 1, found %d\", len(helper.topic.sessions))\n\t}\n\tif len(s.subs) != 0 {\n\t\tt.Errorf(\"Session subscriptions: expected 0, found %d\", len(s.subs))\n\t}\n\n\t// Verify call party was removed (if the implementation handles this)\n\tif helper.topic.currentCall != nil && helper.topic.currentCall.parties != nil {\n\t\tif _, exists := helper.topic.currentCall.parties[s.sid]; exists {\n\t\t\tt.Error(\"Call party should have been removed when session unregistered\")\n\t\t}\n\t}\n\n\tif len(r.messages) != 3 {\n\t\tt.Fatalf(\"`responses` expected to contain 3 elements, found %d\", len(r.messages))\n\t}\n\n\t// Expected one of each: {data}, {info}, {ctrl}.\n\tvar found = 0\n\tfor _, msg := range r.messages {\n\t\tm := msg.(*ServerComMessage)\n\t\tif m.Data != nil {\n\t\t\tfound++\n\t\t\tif m.Data.Head == nil || m.Data.Head[\"webrtc\"] != \"disconnected\" || m.Data.Head[\"replace\"] != \":123\" {\n\t\t\t\tt.Fatalf(\"Unexpected Data.Head: %+v\", m.Data.Head)\n\t\t\t}\n\t\t} else if m.Info != nil {\n\t\t\tfound++\n\t\t\tif m.Info.SeqId != 123 {\n\t\t\t\tt.Fatalf(\"Unexpected Info.SeqId: %d\", m.Info.SeqId)\n\t\t\t}\n\t\t\tif m.Info.What != \"call\" {\n\t\t\t\tt.Fatalf(\"Unexpected Info.What: %s\", m.Info.What)\n\t\t\t}\n\t\t\tif m.Info.Event != \"hang-up\" {\n\t\t\t\tt.Fatalf(\"Unexpected Info.Event: %s\", m.Info.Event)\n\t\t\t}\n\t\t} else if m.Ctrl != nil {\n\t\t\tfound++\n\t\t\tif m.Ctrl.Code != http.StatusOK {\n\t\t\t\tt.Fatalf(\"Unexpected Ctrl.Code: %d\", m.Ctrl.Code)\n\t\t\t}\n\t\t} else {\n\t\t\tt.Error(\"Expected only {data}, {info}, {ctrl} messages.\")\n\t\t}\n\t}\n\n\tif found != 3 {\n\t\tt.Fatal(\"Expected only {data}, {info}, {ctrl} messages, but some are missing\")\n\t}\n}\n\nfunc TestReplyDelMsgHardDelete(t *testing.T) {\n\t// Test hard delete scenario - hard deletes affect all users equally\n\t// and don't update individual unread counters the same way as soft deletes\n\n\ttopicName := \"p2pTest\"\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, 2, types.TopicCatP2P, topicName, true)\n\tdefer helper.tearDown()\n\n\tuser1 := helper.uids[0] // User with delete permission\n\tuser2 := helper.uids[1] // Other user\n\n\t// Set up initial state: user2 has read up to message 5, topic has messages up to 10\n\thelper.topic.lastID = 10\n\n\tpud1 := helper.topic.perUser[user1]\n\tpud1.readID = 10\n\tpud1.modeGiven = types.ModeCFull  // Full permissions including delete\n\tpud1.modeWant = types.ModeCFull\n\thelper.topic.perUser[user1] = pud1\n\n\tpud2 := helper.topic.perUser[user2]\n\tpud2.readID = 5\n\tpud2.modeGiven = types.ModeCFull\n\tpud2.modeWant = types.ModeCFull\n\thelper.topic.perUser[user2] = pud2\n\n\t// Simulate user1 doing a hard delete of messages 7 and 8\n\tmsg := &ClientComMessage{\n\t\tDel: &MsgClientDel{\n\t\t\tId: \"del123\",\n\t\t\tWhat: \"msg\",\n\t\t\tDelSeq: []MsgRange{\n\t\t\t\t{LowId: 7, HiId: 9}, // Deletes messages 7 and 8 [7, 9)\n\t\t\t},\n\t\t\tHard: true, // Hard delete\n\t\t},\n\t\tAsUser: user1.UserId(),\n\t\tsess:   helper.sessions[0],\n\t\tinit:   true,\n\t}\n\n\t// Mock the message deletion for hard delete (forUser = types.ZeroUid)\n\thelper.mm.EXPECT().DeleteList(topicName, 1, types.ZeroUid, gomock.Any(), []types.Range{{Low: 7, Hi: 9}}).Return(nil)\n\n\t// Call the function under test\n\terr := helper.topic.replyDelMsg(helper.sessions[0], user1, false, msg)\n\n\t// Verify\n\tif err != nil {\n\t\tt.Fatalf(\"replyDelMsg failed: %v\", err)\n\t}\n\n\t// Verify session got success response\n\thelper.finish()\n\tregisterSessionVerifyOutputs(t, helper.results[0], []int{http.StatusOK})\n\n\t// For hard deletes, all users' delID should be updated\n\tif helper.topic.perUser[user1].delID != 1 {\n\t\tt.Errorf(\"Expected user1.delID to be 1, got %d\", helper.topic.perUser[user1].delID)\n\t}\n\tif helper.topic.perUser[user2].delID != 1 {\n\t\tt.Errorf(\"Expected user2.delID to be 1, got %d\", helper.topic.perUser[user2].delID)\n\t}\n}\n\nfunc TestReplyDelMsgUpdatesUnreadCounters(t *testing.T) {\n\t// This test simulates the scenario from issue #898:\n\t// 1. User1 sends messages to User2\n\t// 2. User1 deletes some messages (soft delete)\n\t// 3. Verify that the unread calculation logic works correctly\n\n\ttopicName := \"p2pTest\"\n\thelper := TopicTestHelper{}\n\thelper.setUp(t, 2, types.TopicCatP2P, topicName, true)\n\tdefer helper.tearDown()\n\n\tuser1 := helper.uids[0] // Sender/deleter\n\tuser2 := helper.uids[1] // Recipient\n\n\t// Set up initial state: user2 has read up to message 5, topic has messages up to 10\n\t// So user2 has 5 unread messages (6, 7, 8, 9, 10)\n\thelper.topic.lastID = 10\n\n\tpud1 := helper.topic.perUser[user1]\n\tpud1.readID = 10  // user1 has read all\n\thelper.topic.perUser[user1] = pud1\n\n\tpud2 := helper.topic.perUser[user2]\n\tpud2.readID = 5   // user2 has 5 unread messages\n\thelper.topic.perUser[user2] = pud2\n\n\t// Simulate user1 deleting messages 7 and 8 (2 of user2's unread messages)\n\tmsg := &ClientComMessage{\n\t\tDel: &MsgClientDel{\n\t\t\tId: \"del123\",\n\t\t\tWhat: \"msg\",\n\t\t\tDelSeq: []MsgRange{\n\t\t\t\t{LowId: 7, HiId: 9}, // Deletes messages 7 and 8 [7, 9)\n\t\t\t},\n\t\t\tHard: false, // Soft delete\n\t\t},\n\t\tAsUser: user1.UserId(),\n\t\tsess:   helper.sessions[0],\n\t\tinit:   true,\n\t}\n\n\t// Mock the message deletion\n\thelper.mm.EXPECT().DeleteList(topicName, 1, user1, time.Duration(0), []types.Range{{Low: 7, Hi: 9}}).Return(nil)\n\n\t// Call the function under test\n\terr := helper.topic.replyDelMsg(helper.sessions[0], user1, false, msg)\n\n\t// Verify\n\tif err != nil {\n\t\tt.Fatalf(\"replyDelMsg failed: %v\", err)\n\t}\n\n\t// Verify session got success response\n\thelper.finish()\n\tregisterSessionVerifyOutputs(t, helper.results[0], []int{http.StatusOK})\n\n\t// The key verification is that calculateUnreadInRanges should have been called\n\t// with the correct parameters. We can test this indirectly by testing the function:\n\tranges := []types.Range{{Low: 7, Hi: 9}}\n\tunreadDeleted := calculateUnreadInRanges(5, 10, ranges) // user2's readID=5, lastID=10\n\tif unreadDeleted != 2 {\n\t\tt.Errorf(\"Expected 2 unread messages to be deleted for user2, got %d\", unreadDeleted)\n\t}\n}\n\nfunc TestCalculateUnreadInRanges(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\treadID   int\n\t\tlastID   int\n\t\tranges   []types.Range\n\t\texpected int\n\t}{\n\t\t{\n\t\t\tname:     \"no unread messages\",\n\t\t\treadID:   10,\n\t\t\tlastID:   10,\n\t\t\tranges:   []types.Range{{Low: 5, Hi: 15}},\n\t\t\texpected: 0,\n\t\t},\n\t\t{\n\t\t\tname:     \"no deleted messages in unread range\",\n\t\t\treadID:   5,\n\t\t\tlastID:   10,\n\t\t\tranges:   []types.Range{{Low: 1, Hi: 5}},\n\t\t\texpected: 0,\n\t\t},\n\t\t{\n\t\t\tname:     \"all unread messages deleted\",\n\t\t\treadID:   5,\n\t\t\tlastID:   10,\n\t\t\tranges:   []types.Range{{Low: 6, Hi: 11}},\n\t\t\texpected: 5,\n\t\t},\n\t\t{\n\t\t\tname:     \"partial unread messages deleted\",\n\t\t\treadID:   5,\n\t\t\tlastID:   10,\n\t\t\tranges:   []types.Range{{Low: 7, Hi: 9}},\n\t\t\texpected: 2,\n\t\t},\n\t\t{\n\t\t\tname:     \"single message deleted\",\n\t\t\treadID:   5,\n\t\t\tlastID:   10,\n\t\t\tranges:   []types.Range{{Low: 7, Hi: 0}}, // Hi: 0 means single message\n\t\t\texpected: 1,\n\t\t},\n\t\t{\n\t\t\tname:     \"multiple ranges\",\n\t\t\treadID:   5,\n\t\t\tlastID:   15,\n\t\t\tranges:   []types.Range{{Low: 7, Hi: 9}, {Low: 12, Hi: 14}},\n\t\t\texpected: 4, // 2 messages in range [7,9) + 2 messages in range [12,14)\n\t\t},\n\t\t{\n\t\t\tname:     \"overlapping with unread boundaries\",\n\t\t\treadID:   5,\n\t\t\tlastID:   10,\n\t\t\tranges:   []types.Range{{Low: 4, Hi: 8}, {Low: 9, Hi: 12}},\n\t\t\texpected: 4, // [6,8) + [9,11) = 2 + 2 = 4 unread messages deleted\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := calculateUnreadInRanges(tt.readID, tt.lastID, tt.ranges)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"calculateUnreadInRanges(%d, %d, %v) = %d; want %d\",\n\t\t\t\t\ttt.readID, tt.lastID, tt.ranges, result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestMain(m *testing.M) {\n\tlogs.Init(os.Stderr, \"stdFlags\")\n\t// Set max subscriber count to effective infinity.\n\tglobals.maxSubscriberCount = 1_000_000_000\n\tos.Exit(m.Run())\n}\n"
  },
  {
    "path": "server/user.go",
    "content": "package main\n\nimport (\n\t\"container/heap\"\n\t\"math/rand\"\n\t\"time\"\n\n\t\"slices\"\n\n\t\"github.com/tinode/chat/server/auth\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/push\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\nconst (\n\t// Unread counter update return codes.\n\t// Counter not initialized, IO pending.\n\tunreadUpdateIOPending = -1\n\t// Counter initialization error.\n\tunreadUpdateError = -2\n)\n\n// Process request for a new account.\nfunc replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {\n\t// The session cannot authenticate with the new account because  it's already authenticated.\n\tif msg.Acc.Login && (!s.uid.IsZero() || rec != nil) {\n\t\ts.queueOut(ErrAlreadyAuthenticated(msg.Id, \"\", msg.Timestamp))\n\t\tlogs.Warn.Println(\"create user: login requested while authenticated, sid=\", s.sid)\n\t\treturn\n\t}\n\n\t// Find authenticator for the requested scheme.\n\tauthhdl := store.Store.GetLogicalAuthHandler(msg.Acc.Scheme)\n\tif authhdl == nil {\n\t\t// New accounts must have an authentication scheme\n\t\ts.queueOut(ErrMalformed(msg.Id, \"\", msg.Timestamp))\n\t\tlogs.Warn.Println(\"create user: unknown auth handler, sid=\", s.sid)\n\t\treturn\n\t}\n\n\t// Check if login is unique and compliance with the policy (not too long or too short).\n\tif ok, err := authhdl.IsUnique(msg.Acc.Secret, s.remoteAddr); !ok {\n\t\tlogs.Warn.Println(\"create user: auth secret is not compliant\", err, \"sid=\", s.sid)\n\t\ts.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp,\n\t\t\tmap[string]any{\"what\": \"auth\"}))\n\t\treturn\n\t}\n\n\tvar user types.User\n\tvar private any\n\n\t// If account state is being assigned, make sure the sender is a root user.\n\tif msg.Acc.State != \"\" {\n\t\tif auth.Level(msg.AuthLvl) != auth.LevelRoot {\n\t\t\tlogs.Warn.Println(\"create user: attempt to set account state by non-root, sid=\", s.sid)\n\t\t\tmsg := ErrPermissionDenied(msg.Id, \"\", msg.Timestamp)\n\t\t\tmsg.Ctrl.Params = map[string]any{\"what\": \"state\"}\n\t\t\ts.queueOut(msg)\n\t\t\treturn\n\t\t}\n\n\t\tstate, err := types.NewObjState(msg.Acc.State)\n\t\tif err != nil || state == types.StateUndefined || state == types.StateDeleted {\n\t\t\tlogs.Warn.Println(\"create user: invalid account state\", err, \"sid=\", s.sid)\n\t\t\ts.queueOut(ErrMalformed(msg.Id, \"\", msg.Timestamp))\n\t\t\treturn\n\t\t}\n\t\tuser.State = state\n\t}\n\n\t// Ensure tags are unique and not restricted.\n\tif tags := normalizeTags(msg.Acc.Tags, globals.maxTagCount); tags != nil {\n\t\tif !restrictedTagsEqual(tags, nil, globals.immutableTagNS) {\n\t\t\tlogs.Warn.Println(\"create user: attempt to directly assign restricted tags, sid=\", s.sid)\n\t\t\tmsg := ErrPermissionDenied(msg.Id, \"\", msg.Timestamp)\n\t\t\tmsg.Ctrl.Params = map[string]any{\"what\": \"tags\"}\n\t\t\ts.queueOut(msg)\n\t\t\treturn\n\t\t}\n\t\tuser.Tags = tags\n\t}\n\n\t// Pre-check credentials for validity. We don't know user's access level\n\t// consequently cannot check presence of required credentials. Must do that later.\n\tcreds := normalizeCredentials(msg.Acc.Cred, true)\n\tfor i := range creds {\n\t\tcr := &creds[i]\n\t\tvld := store.Store.GetValidator(cr.Method)\n\t\tif _, err := vld.PreCheck(cr.Value, cr.Params); err != nil {\n\t\t\tlogs.Warn.Println(\"create user: failed credential pre-check\", cr, err, \"sid=\", s.sid)\n\t\t\ts.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp,\n\t\t\t\tmap[string]any{\"what\": cr.Method}))\n\t\t\treturn\n\t\t}\n\t}\n\n\t// Assign default access values in case the acc creator has not provided them\n\tuser.Access.Auth = getDefaultAccess(types.TopicCatP2P, true, false) |\n\t\tgetDefaultAccess(types.TopicCatGrp, true, false)\n\tuser.Access.Anon = getDefaultAccess(types.TopicCatP2P, false, false) |\n\t\tgetDefaultAccess(types.TopicCatGrp, false, false)\n\n\t// Assign actual access values, public and private.\n\tif msg.Acc.Desc != nil {\n\t\tif msg.Acc.Desc.DefaultAcs != nil {\n\t\t\tif msg.Acc.Desc.DefaultAcs.Auth != \"\" {\n\t\t\t\tuser.Access.Auth.UnmarshalText([]byte(msg.Acc.Desc.DefaultAcs.Auth))\n\t\t\t\tuser.Access.Auth &= globals.typesModeCP2P\n\t\t\t\tif user.Access.Auth != types.ModeNone {\n\t\t\t\t\tuser.Access.Auth |= types.ModeApprove\n\t\t\t\t}\n\t\t\t}\n\t\t\tif msg.Acc.Desc.DefaultAcs.Anon != \"\" {\n\t\t\t\tuser.Access.Anon.UnmarshalText([]byte(msg.Acc.Desc.DefaultAcs.Anon))\n\t\t\t\tuser.Access.Anon &= globals.typesModeCP2P\n\t\t\t\tif user.Access.Anon != types.ModeNone {\n\t\t\t\t\tuser.Access.Anon |= types.ModeApprove\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif !isNullValue(msg.Acc.Desc.Public) {\n\t\t\tuser.Public = msg.Acc.Desc.Public\n\t\t}\n\t\tif !isNullValue(msg.Acc.Desc.Private) {\n\t\t\tprivate = msg.Acc.Desc.Private\n\t\t}\n\t}\n\n\t// Create user record in the database.\n\tif _, err := store.Users.Create(&user, private); err != nil {\n\t\tlogs.Warn.Println(\"create user: failed to create user\", err, \"sid=\", s.sid)\n\t\ts.queueOut(ErrUnknown(msg.Id, \"\", msg.Timestamp))\n\t\treturn\n\t}\n\n\t// Add authentication record. The authhdl.AddRecord may change tags.\n\trec, err := authhdl.AddRecord(&auth.Rec{Uid: user.Uid(), Tags: user.Tags}, msg.Acc.Secret, s.remoteAddr)\n\tif err != nil {\n\t\tlogs.Warn.Println(\"create user: add auth record failed\", err, \"sid=\", s.sid)\n\t\ts.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))\n\n\t\t// Attempt to delete incomplete user record\n\t\tif err = store.Users.Delete(user.Uid(), true); err != nil {\n\t\t\tlogs.Warn.Println(\"create user: failed to delete incomplete user record\", err, \"sid=\", s.sid)\n\t\t}\n\t\treturn\n\t}\n\n\t// When creating an account, the user must provide all required credentials.\n\t// If any are missing, reject the request.\n\tif len(creds) < len(globals.authValidators[rec.AuthLevel]) {\n\t\tlogs.Warn.Println(\"create user: missing credentials; have:\", creds, \"want:\",\n\t\t\tglobals.authValidators[rec.AuthLevel], s.sid)\n\t\t_, missing, _ := stringSliceDelta(globals.authValidators[rec.AuthLevel], credentialMethods(creds))\n\t\ts.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp,\n\t\t\tmap[string]any{\"creds\": missing}))\n\n\t\t// Attempt to delete incomplete user record\n\t\tif err = store.Users.Delete(user.Uid(), true); err != nil {\n\t\t\tlogs.Warn.Println(\"create user: failed to delete incomplete user record\", err, \"sid=\", s.sid)\n\t\t}\n\t\treturn\n\t}\n\n\t// Save credentials, update tags if necessary.\n\ttmpToken, _, _ := store.Store.GetLogicalAuthHandler(\"token\").GenSecret(&auth.Rec{\n\t\tUid:       user.Uid(),\n\t\tAuthLevel: auth.LevelAuth,\n\t\tLifetime:  auth.Duration(time.Hour * 24),\n\t})\n\tvalidated, _, err := addCreds(user.Uid(), creds, rec.Tags, s.lang, tmpToken)\n\tif err != nil {\n\t\tlogs.Warn.Println(\"create user: failed to save or validate credential\", err, \"sid=\", s.sid)\n\t\ts.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))\n\n\t\t// Delete incomplete user record.\n\t\tif err = store.Users.Delete(user.Uid(), true); err != nil {\n\t\t\tlogs.Warn.Println(\"create user: failed to delete incomplete user record\", err, \"sid=\", s.sid)\n\t\t}\n\t\treturn\n\t}\n\n\tif msg.Extra != nil && len(msg.Extra.Attachments) > 0 {\n\t\tif err := store.Files.LinkAttachments(user.Uid().UserId(), types.ZeroUid, msg.Extra.Attachments); err != nil {\n\t\t\tlogs.Warn.Println(\"create user: failed to link avatar attachment\", err, \"sid=\", s.sid)\n\t\t\t// This is not a critical error, continue execution.\n\t\t}\n\t}\n\n\tvar reply *ServerComMessage\n\tif msg.Acc.Login {\n\t\t// Process user's login request.\n\t\t_, missing, _ := stringSliceDelta(globals.authValidators[rec.AuthLevel], validated)\n\t\treply = s.onLogin(msg.Id, msg.Timestamp, rec, missing)\n\t} else {\n\t\t// Not using the new account for logging in.\n\t\treply = NoErrCreated(msg.Id, \"\", msg.Timestamp)\n\t\treply.Ctrl.Params = map[string]any{\n\t\t\t\"user\":    user.Uid().UserId(),\n\t\t\t\"authlvl\": rec.AuthLevel.String(),\n\t\t}\n\t}\n\n\tparams := reply.Ctrl.Params.(map[string]any)\n\tparams[\"desc\"] = &MsgTopicDesc{\n\t\tCreatedAt: &user.CreatedAt,\n\t\tUpdatedAt: &user.UpdatedAt,\n\t\tDefaultAcs: &MsgDefaultAcsMode{\n\t\t\tAuth: user.Access.Auth.String(),\n\t\t\tAnon: user.Access.Anon.String(),\n\t\t},\n\t\tPublic:  user.Public,\n\t\tPrivate: private,\n\t}\n\n\ts.queueOut(reply)\n\n\tpluginAccount(&user, plgActCreate)\n}\n\n// Process update to an account:\n// * Authentication update, i.e. login/password change\n// * Credentials update\nfunc replyUpdateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {\n\tif s.uid.IsZero() && rec == nil {\n\t\t// Session is not authenticated and no temporary auth is provided.\n\t\tlogs.Warn.Println(\"replyUpdateUser: not a new account and not authenticated\", s.sid)\n\t\ts.queueOut(ErrPermissionDenied(msg.Id, \"\", msg.Timestamp))\n\t\treturn\n\t} else if msg.AsUser != \"\" && rec != nil {\n\t\t// Two UIDs: one from msg.from, one from temporary auth. Ambigous, reject.\n\t\tlogs.Warn.Println(\"replyUpdateUser: got both authenticated session and token\", s.sid)\n\t\ts.queueOut(ErrMalformed(msg.Id, \"\", msg.Timestamp))\n\t\treturn\n\t}\n\n\tuserId := msg.AsUser\n\tauthLvl := auth.Level(msg.AuthLvl)\n\tif rec != nil {\n\t\tuserId = rec.Uid.UserId()\n\t\tauthLvl = rec.AuthLevel\n\t}\n\n\tif msg.Acc.User != \"\" && msg.Acc.User != userId {\n\t\tif s.authLvl != auth.LevelRoot {\n\t\t\tlogs.Warn.Println(\"replyUpdateUser: attempt to change another's account by non-root\", s.sid)\n\t\t\ts.queueOut(ErrPermissionDenied(msg.Id, \"\", msg.Timestamp))\n\t\t\treturn\n\t\t}\n\t\t// Root is editing someone else's account.\n\t\tuserId = msg.Acc.User\n\t\tauthLvl = auth.ParseAuthLevel(msg.Acc.AuthLevel)\n\t}\n\n\tuid := types.ParseUserId(userId)\n\tif uid.IsZero() {\n\t\t// msg.Acc.User contains invalid data.\n\t\ts.queueOut(ErrMalformed(msg.Id, \"\", msg.Timestamp))\n\t\tlogs.Warn.Println(\"replyUpdateUser: user id is invalid or missing\", s.sid)\n\t\treturn\n\t}\n\n\t// Only root can suspend accounts, including own account.\n\tif msg.Acc.State != \"\" && s.authLvl != auth.LevelRoot {\n\t\ts.queueOut(ErrPermissionDenied(msg.Id, \"\", msg.Timestamp))\n\t\tlogs.Warn.Println(\"replyUpdateUser: attempt to change account state by non-root\", s.sid)\n\t\treturn\n\t}\n\n\tuser, err := store.Users.Get(uid)\n\tif user == nil && err == nil {\n\t\terr = types.ErrNotFound\n\t}\n\tif err != nil {\n\t\tlogs.Warn.Println(\"replyUpdateUser: failed to fetch user from DB\", err, s.sid)\n\t\ts.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))\n\t\treturn\n\t}\n\n\tvar params map[string]any\n\tif msg.Acc.Scheme != \"\" {\n\t\terr = updateUserAuth(msg, user, rec, s.remoteAddr)\n\t} else if len(msg.Acc.Cred) > 0 {\n\t\tif authLvl == auth.LevelNone {\n\t\t\t// msg.Acc.AuthLevel contains invalid data.\n\t\t\ts.queueOut(ErrMalformed(msg.Id, \"\", msg.Timestamp))\n\t\t\tlogs.Warn.Println(\"replyUpdateUser: auth level is missing\", s.sid)\n\t\t\treturn\n\t\t}\n\t\t// Handle request to update credentials.\n\t\ttmpToken, _, _ := store.Store.GetLogicalAuthHandler(\"token\").GenSecret(&auth.Rec{\n\t\t\tUid:       uid,\n\t\t\tAuthLevel: auth.LevelNone,\n\t\t\tLifetime:  auth.Duration(time.Hour * 24),\n\t\t\tFeatures:  auth.FeatureNoLogin,\n\t\t})\n\t\t_, _, err := addCreds(uid, msg.Acc.Cred, nil, s.lang, tmpToken)\n\t\tif err == nil {\n\t\t\tif allCreds, err := store.Users.GetAllCreds(uid, \"\", true); err != nil {\n\t\t\t\tvar validated []string\n\t\t\t\tfor i := range allCreds {\n\t\t\t\t\tvalidated = append(validated, allCreds[i].Method)\n\t\t\t\t}\n\t\t\t\t_, missing, _ := stringSliceDelta(globals.authValidators[authLvl], validated)\n\t\t\t\tif len(missing) > 0 {\n\t\t\t\t\tparams = map[string]any{\"cred\": missing}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t} else if msg.Acc.State != \"\" {\n\t\tvar changed bool\n\t\tchanged, err = changeUserState(s, uid, user, msg)\n\t\tif !changed && err == nil {\n\t\t\ts.queueOut(InfoNotModified(msg.Id, \"\", msg.Timestamp))\n\t\t\treturn\n\t\t}\n\t} else {\n\t\terr = types.ErrMalformed\n\t}\n\n\tif err != nil {\n\t\tlogs.Warn.Println(\"replyUpdateUser: failed to update user\", err, s.sid)\n\t\ts.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))\n\t\treturn\n\t}\n\n\ts.queueOut(NoErrParams(msg.Id, \"\", msg.Timestamp, params))\n\n\t// Call plugin with the account update\n\tpluginAccount(user, plgActUpd)\n}\n\n// Authentication update\nfunc updateUserAuth(msg *ClientComMessage, user *types.User, _ *auth.Rec, remoteAddr string) error {\n\tauthhdl := store.Store.GetLogicalAuthHandler(msg.Acc.Scheme)\n\tif authhdl != nil {\n\t\t// Request to update auth of an existing account. Only basic & rest auth are currently supported\n\n\t\t// TODO(gene): support adding new auth schemes\n\n\t\trec, err := authhdl.UpdateRecord(&auth.Rec{Uid: user.Uid(), Tags: user.Tags}, msg.Acc.Secret, remoteAddr)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Tags may have been changed by authhdl.UpdateRecord, reset them.\n\t\t// Can't do much with the error here, logging it but not returning.\n\t\tif _, err = store.Users.UpdateTags(user.Uid(), nil, nil, rec.Tags); err != nil {\n\t\t\tlogs.Warn.Println(\"updateUserAuth tags update failed:\", err)\n\t\t}\n\t\treturn nil\n\t}\n\n\t// Invalid or unknown auth scheme\n\treturn types.ErrMalformed\n}\n\n// addCreds adds new credentials and re-send validation request for existing ones.\n// It also adds credential-defined tags if necessary.\n// Returns methods validated in this call only. Returns either a full set of tags\n// or nil for tags when tags are unchanged.\nfunc addCreds(uid types.Uid, creds []MsgCredClient, extraTags []string,\n\tlang string, tmpToken []byte) ([]string, []string, error) {\n\tvar validated []string\n\tfor i := range creds {\n\t\tcr := &creds[i]\n\t\tvld := store.Store.GetValidator(cr.Method)\n\t\tif vld == nil || !vld.IsInitialized() {\n\t\t\t// Ignore unknown or un-initialized validator.\n\t\t\tcontinue\n\t\t}\n\n\t\tisNew, err := vld.Request(uid, cr.Value, lang, cr.Response, tmpToken)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\n\t\tif isNew && cr.Response != \"\" {\n\t\t\t// If response is provided and vld.Request did not return an error, the new request was\n\t\t\t// successfully validated.\n\t\t\tvalidated = append(validated, cr.Method)\n\n\t\t\t// Generate tags for these confirmed credentials.\n\t\t\tif globals.validators[cr.Method].addToTags {\n\t\t\t\textraTags = append(extraTags, cr.Method+\":\"+cr.Value)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Save tags potentially changed by the validator.\n\tif len(extraTags) > 0 {\n\t\tif utags, err := store.Users.UpdateTags(uid, extraTags, nil, nil); err == nil {\n\t\t\textraTags = utags\n\t\t} else {\n\t\t\tlogs.Warn.Println(\"add cred tags update failed:\", err)\n\t\t}\n\t} else {\n\t\textraTags = nil\n\t}\n\treturn validated, extraTags, nil\n}\n\n// validatedCreds returns the list of validated credentials including those validated in this call.\n// Returns all validated methods including those validated earlier and now.\n// Returns either a full set of tags or nil for tags if tags are unchanged.\nfunc validatedCreds(uid types.Uid, authLvl auth.Level, creds []MsgCredClient,\n\terrorOnFail bool) ([]string, []string, error) {\n\t// Check if credential validation is required.\n\tif len(globals.authValidators[authLvl]) == 0 {\n\t\treturn nil, nil, nil\n\t}\n\n\t// Get all validated methods\n\tallCreds, err := store.Users.GetAllCreds(uid, \"\", true)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tmethods := make(map[string]struct{})\n\tfor i := range allCreds {\n\t\tmethods[allCreds[i].Method] = struct{}{}\n\t}\n\n\t// Add credentials which are validated in this call.\n\t// Unknown validators are removed.\n\tcreds = normalizeCredentials(creds, false)\n\tvar tagsToAdd []string\n\tfor i := range creds {\n\t\tcr := &creds[i]\n\t\tif cr.Response == \"\" {\n\t\t\t// Ignore empty response.\n\t\t\tcontinue\n\t\t}\n\n\t\tvld := store.Store.GetValidator(cr.Method) // No need to check for nil, unknown methods are removed earlier.\n\t\tvalue, err := vld.Check(uid, cr.Response)\n\n\t\tif err != nil {\n\t\t\t// Check failed.\n\t\t\tif storeErr, ok := err.(types.StoreError); ok && storeErr == types.ErrCredentials {\n\t\t\t\tif errorOnFail {\n\t\t\t\t\t// Report invalid response.\n\t\t\t\t\treturn nil, nil, types.ErrInvalidResponse\n\t\t\t\t}\n\t\t\t\t// Skip invalid response. Keep credential unvalidated.\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// Actual error. Report back.\n\t\t\treturn nil, nil, err\n\t\t}\n\n\t\t// Check did not return an error: the request was successfully validated.\n\t\tmethods[cr.Method] = struct{}{}\n\n\t\t// Add validated credential to user's tags.\n\t\tif globals.validators[cr.Method].addToTags {\n\t\t\ttagsToAdd = append(tagsToAdd, cr.Method+\":\"+value)\n\t\t}\n\t}\n\n\tvar tags []string\n\tif len(tagsToAdd) > 0 {\n\t\t// Save update to tags\n\t\tif utags, err := store.Users.UpdateTags(uid, tagsToAdd, nil, nil); err == nil {\n\t\t\ttags = utags\n\t\t} else {\n\t\t\tlogs.Warn.Println(\"validated creds tags update failed:\", err)\n\t\t\ttags = nil\n\t\t}\n\t} else {\n\t\ttags = nil\n\t}\n\n\tvalidated := make([]string, 0, len(methods))\n\tfor method := range methods {\n\t\tvalidated = append(validated, method)\n\t}\n\n\treturn validated, tags, nil\n}\n\n// deleteCred deletes user's credential.\n// Returns full set of remaining tags or nil if tags are unchanged.\nfunc deleteCred(uid types.Uid, authLvl auth.Level, cred *MsgCredClient) ([]string, error) {\n\tvld := store.Store.GetValidator(cred.Method)\n\tif vld == nil || cred.Value == \"\" {\n\t\t// Reject invalid request: unknown validation method or missing credential value.\n\t\treturn nil, types.ErrMalformed\n\t}\n\n\t// Is this a required credential for this validation level?\n\tisRequired := slices.Contains(globals.authValidators[authLvl], cred.Method)\n\n\t// If credential is required, make sure the method remains validated even after this credential is deleted.\n\tif isRequired {\n\t\t// There could be multiple validated credentials for the same method thus we are getting a map with count\n\t\t// for each method.\n\n\t\t// Get all credentials of the given method.\n\t\tallCreds, err := store.Users.GetAllCreds(uid, cred.Method, false)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// Check if it's OK to delete: there is another validated value\n\t\t// or this value is not validated in the first place.\n\t\tvar okTodelete bool\n\t\tfor _, cr := range allCreds {\n\t\t\tif (cr.Done && cr.Value != cred.Value) || (!cr.Done && cr.Value == cred.Value) {\n\t\t\t\tokTodelete = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif !okTodelete {\n\t\t\t// Reject: this is the only validated credential and it must be provided.\n\t\t\treturn nil, types.ErrPolicy\n\t\t}\n\t}\n\n\t// The credential is either not required or more than one credential is validated for the given method.\n\terr := vld.Remove(uid, cred.Value)\n\tif err != nil {\n\t\tif err == types.ErrNotFound {\n\t\t\t// Credential is not deleted because it's not found\n\t\t\terr = nil\n\t\t}\n\t\treturn nil, err\n\t}\n\n\t// Remove generated tags for the deleted credential.\n\tvar tags []string\n\tif globals.validators[cred.Method].addToTags {\n\t\t// This error should not be returned to user.\n\t\tif utags, err := store.Users.UpdateTags(uid, nil, []string{cred.Method + \":\" + cred.Value}, nil); err == nil {\n\t\t\ttags = utags\n\t\t} else {\n\t\t\tlogs.Warn.Println(\"delete cred: failed to update tags:\", err)\n\t\t\ttags = nil\n\t\t}\n\t} else {\n\t\ttags = nil\n\t}\n\n\treturn tags, nil\n}\n\n// Change user state: suspended/normal (ok).\n// 1. Not needed -- Disable/enable logins (state checked after login).\n// 2. If suspending, evict user's sessions. Skip this step if resuming.\n// 3. Suspend/activate p2p with the user.\n// 4. Suspend/activate grp topics where the user is the owner.\n// 5. Update user's DB record.\nfunc changeUserState(s *Session, uid types.Uid, user *types.User, msg *ClientComMessage) (bool, error) {\n\tstate, err := types.NewObjState(msg.Acc.State)\n\tif err != nil || state == types.StateUndefined {\n\t\tlogs.Warn.Println(\"replyUpdateUser: invalid account state\", s.sid)\n\t\treturn false, types.ErrMalformed\n\t}\n\n\t// State unchanged.\n\tif user.State == state {\n\t\treturn false, nil\n\t}\n\n\tif state != types.StateOK {\n\t\t// Terminate all sessions.\n\t\tglobals.sessionStore.EvictUser(uid, \"\")\n\t}\n\n\terr = store.Users.UpdateState(uid, state)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\t// Update state of all loaded in memory user's p2p & grp-owner topics.\n\tglobals.hub.userStatus <- &userStatusReq{forUser: uid, state: state}\n\tuser.State = state\n\n\treturn true, err\n}\n\n// Request to delete a user:\n// 1. Disable user's login\n// 2. Terminate all user's sessions except the current session.\n// 3. Stop all active topics\n// 4. Notify other subscribers that topics are being deleted.\n// 5. Delete user from the database.\n// 6. Report success or failure.\n// 7. Terminate user's last session.\nfunc replyDelUser(s *Session, msg *ClientComMessage) {\n\tvar uid types.Uid\n\n\tif msg.Del.User == \"\" || msg.Del.User == s.uid.UserId() {\n\t\t// Check if account deletion is disabled.\n\t\tif globals.permanentAccounts && s.authLvl != auth.LevelRoot {\n\t\t\tlogs.Warn.Println(\"replyDelUser: account deletion disabled\", s.sid)\n\t\t\ts.queueOut(ErrPolicy(msg.Id, \"\", msg.Timestamp))\n\t\t\treturn\n\t\t}\n\n\t\t// Delete current user.\n\t\tuid = s.uid\n\t} else if s.authLvl == auth.LevelRoot {\n\t\t// Delete another user.\n\t\tuid = types.ParseUserId(msg.Del.User)\n\t\tif uid.IsZero() {\n\t\t\tlogs.Warn.Println(\"replyDelUser: invalid user ID\", msg.Del.User, s.sid)\n\t\t\ts.queueOut(ErrMalformed(msg.Id, \"\", msg.Timestamp))\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tlogs.Warn.Println(\"replyDelUser: illegal attempt to delete another user\", msg.Del.User, s.sid)\n\t\ts.queueOut(ErrPermissionDenied(msg.Id, \"\", msg.Timestamp))\n\t\treturn\n\t}\n\n\t// Disable all authenticators\n\tauthnames := store.Store.GetAuthNames()\n\tfor _, name := range authnames {\n\t\thdl := store.Store.GetLogicalAuthHandler(name)\n\t\tif !hdl.IsInitialized() {\n\t\t\tcontinue\n\t\t}\n\t\tif err := hdl.DelRecords(uid); err != nil {\n\t\t\t// This could be completely benign, i.e. authenticator exists but not used.\n\t\t\tlogs.Warn.Println(\"replyDelUser: failed to delete auth record\", uid.UserId(), name, err, s.sid)\n\t\t\tif storeErr, ok := err.(types.StoreError); ok && storeErr == types.ErrUnsupported {\n\t\t\t\t// Authenticator refused to delete record: user account cannot be deleted.\n\t\t\t\ts.queueOut(ErrOperationNotAllowed(msg.Id, \"\", msg.Timestamp))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\n\t// Terminate all sessions. Skip the current session so the requester gets a response.\n\tglobals.sessionStore.EvictUser(uid, s.sid)\n\t// Remove user from cache and announce to cluster that the user is deleted.\n\tusersRemoveUser(uid)\n\n\t// Stop topics where the user is the owner and p2p topics.\n\tdone := make(chan bool)\n\tglobals.hub.unreg <- &topicUnreg{forUser: uid, del: msg.Del.Hard, done: done}\n\t<-done\n\n\t// Notify users of interest that the user is gone.\n\tif uoi, err := store.Users.GetSubs(uid); err == nil {\n\t\tpresUsersOfInterestOffline(uid, uoi, \"gone\")\n\t} else {\n\t\tlogs.Warn.Println(\"replyDelUser: failed to send notifications to users\", err, s.sid)\n\t}\n\n\t// Notify subscribers of the group topics where the user was the owner that the topics were deleted.\n\tif ownTopics, err := store.Users.GetOwnTopics(uid); err == nil {\n\t\tfor _, topicName := range ownTopics {\n\t\t\tif subs, err := store.Topics.GetSubs(topicName, nil); err == nil {\n\t\t\t\tpresSubsOfflineOffline(topicName, types.TopicCatGrp, subs, \"gone\", &presParams{}, s.sid)\n\t\t\t} else {\n\t\t\t\tlogs.Warn.Println(\"replyDelUser: failed to notify topic subscribers\", err, topicName, s.sid)\n\t\t\t}\n\t\t}\n\t} else {\n\t\tlogs.Warn.Println(\"replyDelUser: failed to send notifications to owned topics\", err, s.sid)\n\t}\n\n\t// TODO: suspend all P2P topics with the user.\n\n\t// Delete user's records from the database.\n\tif err := store.Users.Delete(uid, msg.Del.Hard); err != nil {\n\t\tlogs.Warn.Println(\"replyDelUser: failed to delete user\", err, s.sid)\n\t\ts.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))\n\t\treturn\n\t}\n\n\ts.queueOut(NoErr(msg.Id, \"\", msg.Timestamp))\n\n\tif s.uid == uid && s.multi == nil {\n\t\t// Evict the current session if it belongs to the deleted user.\n\t\t// No need to send it to multiplexing session: remote node will be notified separately.\n\t\t_, data := s.serialize(NoErrEvicted(\"\", \"\", msg.Timestamp))\n\t\ts.stopSession(data)\n\t}\n}\n\n// Read user's state from DB.\nfunc userGetState(uid types.Uid) (types.ObjState, error) {\n\tuser, err := store.Users.Get(uid)\n\tif err != nil {\n\t\treturn types.StateUndefined, err\n\t}\n\tif user == nil {\n\t\treturn types.StateUndefined, types.ErrUserNotFound\n\t}\n\treturn user.State, nil\n}\n\n// Subscribe or unsubscribe a single user's device to/from all FCM topics (channels).\nfunc userChannelsSubUnsub(uid types.Uid, deviceID string, sub bool) {\n\tpush.ChannelSub(&push.ChannelReq{\n\t\tUid:      uid,\n\t\tDeviceID: deviceID,\n\t\tUnsub:    !sub,\n\t})\n}\n\n// UserCacheReq contains data which mutates one or more user cache entries.\ntype UserCacheReq struct {\n\t// Name of the node sending this request in case of cluster. Not set otherwise.\n\tNode string\n\n\t// UserId is set when count of unread messages is updated for a single user or\n\t// when the user is being deleted.\n\tUserId types.Uid\n\t// UserIdList  is set when subscription count is updated for users of a topic.\n\tUserIdList []types.Uid\n\t// Unread count (UserId is set)\n\tUnread int\n\n\t// In case of set UserId: treat Unread count as an increment as opposite to the final value.\n\t// In case of set UserIdList: intement (Inc == true) or decrement subscription count by one.\n\tInc bool\n\t// User is being deleted, remove user from cache.\n\tGone bool\n\n\t// Optional push notification\n\tPushRcpt *push.Receipt\n}\n\ntype userCacheEntry struct {\n\tunread int\n\ttopics int\n}\n\n// Preserved update entry kept while we read the unread counter from the DB.\ntype bufferedUpdate struct {\n\tval int\n\tinc bool\n}\n\ntype ioResult struct {\n\tcounts map[types.Uid]int\n\terr    error\n}\n\n// Represents pending push notification receipt.\ntype pendingReceipt struct {\n\t// Number of unread counters currently being read from the DB.\n\tpendingIOs int\n\t// The index is needed by update and is maintained by the heap.Interface methods.\n\tindex int\n\t// Underlying receipt.\n\trcpt *push.Receipt\n}\n\n// Pending pushes organized as a priority queue (priority = number of pending IOs).\n// It allows to quickly discover receipts ready for sending (num pending IOs is 0).\ntype pendingReceiptsQueue []*pendingReceipt\n\n// Heap interface methods.\nfunc (pq pendingReceiptsQueue) Len() int { return len(pq) }\n\nfunc (pq pendingReceiptsQueue) Less(i, j int) bool {\n\t// We want Pop to give us the highest, not lowest, priority so we use greater than here.\n\treturn pq[i].pendingIOs < pq[j].pendingIOs\n}\n\nfunc (pq pendingReceiptsQueue) Swap(i, j int) {\n\tpq[i], pq[j] = pq[j], pq[i]\n\tpq[i].index = i\n\tpq[j].index = j\n}\n\nfunc (pq *pendingReceiptsQueue) Push(x any) {\n\tn := len(*pq)\n\titem := x.(*pendingReceipt)\n\titem.index = n\n\t*pq = append(*pq, item)\n}\n\nfunc (pq *pendingReceiptsQueue) Pop() any {\n\told := *pq\n\tn := len(old)\n\titem := old[n-1]\n\told[n-1] = nil  // avoid memory leak\n\titem.index = -1 // for safety\n\t*pq = old[0 : n-1]\n\treturn item\n}\n\nfunc (pq *pendingReceiptsQueue) fix(index int) {\n\theap.Fix(pq, index)\n}\n\n// Initialize users cache.\nfunc usersInit() {\n\tglobals.usersUpdate = make(chan *UserCacheReq, 1024)\n\n\tgo userUpdater()\n}\n\n// Shutdown users cache.\nfunc usersShutdown() {\n\tif globals.usersUpdate != nil {\n\t\tglobals.usersUpdate <- nil\n\t}\n}\n\nfunc usersUpdateUnread(uid types.Uid, val int, inc bool) {\n\tif globals.usersUpdate == nil || (val == 0 && inc) {\n\t\treturn\n\t}\n\n\tupd := &UserCacheReq{UserId: uid, Unread: val, Inc: inc}\n\tif globals.cluster.isRemoteTopic(uid.UserId()) {\n\t\t// Send request to remote node which owns the user.\n\t\tglobals.cluster.routeUserReq(upd)\n\t} else {\n\t\tselect {\n\t\tcase globals.usersUpdate <- upd:\n\t\tdefault:\n\t\t}\n\t}\n}\n\n// Start tracking a single user. Used for cache management.\n// 'add' increments/decrements user's count of subscribed topics.\nfunc usersRegisterUser(uid types.Uid, add bool) {\n\tif globals.usersUpdate == nil {\n\t\treturn\n\t}\n\n\tupd := &UserCacheReq{UserIdList: make([]types.Uid, 1), Inc: add}\n\tupd.UserIdList[0] = uid\n\n\tif globals.cluster.isRemoteTopic(uid.UserId()) {\n\t\t// Send request to remote node which owns the user.\n\t\tglobals.cluster.routeUserReq(upd)\n\t} else {\n\t\tselect {\n\t\tcase globals.usersUpdate <- upd:\n\t\tdefault:\n\t\t}\n\t}\n}\n\n// Stop tracking user and remove him from cache.\nfunc usersRemoveUser(uid types.Uid) {\n\tif globals.usersUpdate == nil {\n\t\treturn\n\t}\n\n\tupd := &UserCacheReq{UserId: uid, Gone: true}\n\tif !globals.cluster.isRemoteTopic(uid.UserId()) {\n\t\tselect {\n\t\tcase globals.usersUpdate <- upd:\n\t\tdefault:\n\t\t}\n\t}\n\n\tif globals.cluster != nil {\n\t\t// Announce to cluster even if the user is local.\n\t\tglobals.cluster.routeUserReq(upd)\n\t}\n}\n\n// Account users as members of an active topic. Used for cache management.\n// In case of a cluster this method is called only when the topic is local:\n// globals.cluster.isRemoteTopic(t.name) == false\nfunc usersRegisterTopic(t *Topic, add bool) {\n\tif globals.usersUpdate == nil {\n\t\treturn\n\t}\n\n\tif t.cat == types.TopicCatFnd || t.cat == types.TopicCatMe {\n\t\t// Ignoring me and fnd topics.\n\t\treturn\n\t}\n\n\tlocal := &UserCacheReq{Inc: add}\n\n\t// In case of a cluster UIDs could be local and remote. Process local UIDs locally,\n\t// send remote UIDs to other cluster nodes for processing. The UIDs may have to be\n\t// sent to multiple nodes.\n\tremote := &UserCacheReq{Inc: add}\n\tfor uid, pud := range t.perUser {\n\t\tif pud.isChan {\n\t\t\t// Skip channel subscribers.\n\t\t\tcontinue\n\t\t}\n\t\tif globals.cluster.isRemoteTopic(uid.UserId()) {\n\t\t\tremote.UserIdList = append(remote.UserIdList, uid)\n\t\t} else {\n\t\t\tlocal.UserIdList = append(local.UserIdList, uid)\n\t\t}\n\t}\n\n\tif len(remote.UserIdList) > 0 {\n\t\tglobals.cluster.routeUserReq(remote)\n\t}\n\n\tif len(local.UserIdList) > 0 {\n\t\tselect {\n\t\tcase globals.usersUpdate <- local:\n\t\tdefault:\n\t\t\tlogs.Err.Println(\"User cache: globals.usersUpdate queue full: \", len(globals.usersUpdate))\n\t\t}\n\t}\n}\n\n// usersRequestFromCluster handles requests which came from other cluser nodes.\nfunc usersRequestFromCluster(req *UserCacheReq) {\n\tif globals.usersUpdate == nil {\n\t\treturn\n\t}\n\n\tselect {\n\tcase globals.usersUpdate <- req:\n\tdefault:\n\t}\n}\n\nvar usersCache map[types.Uid]userCacheEntry\n\n// The go routine for processing updates to users cache.\nfunc userUpdater() {\n\t// Caches unread counters and numbers of topics the user's subscribed to.\n\tusersCache = make(map[types.Uid]userCacheEntry)\n\n\t// Unread counter updates blocked by IO on per user basis. We flush them when the IO completes.\n\tperUserBuffers := make(map[types.Uid][]bufferedUpdate)\n\n\t// Push notification recipients blocked by IO (unread counters for some of the recipients\n\t// are being read from the database) on the per user basis.\n\tperUserPendingReceipts := make(map[types.Uid][]*pendingReceipt)\n\n\t// All pending push receipts organized as a priority queue by the number of pending IOs.\n\treceiptQueue := pendingReceiptsQueue{}\n\n\t// IO callback queue.\n\tioDone := make(chan *ioResult, 1024)\n\n\tunreadUpdater := func(uids []types.Uid, vals []int, inc bool) map[types.Uid]int {\n\t\tvar dbPending []types.Uid\n\t\tcounts := make(map[types.Uid]int, len(uids))\n\t\tfor i, uid := range uids {\n\t\t\tcounts[uid] = 0\n\t\t\tuce, ok := usersCache[uid]\n\t\t\tif !ok {\n\t\t\t\tlogs.Err.Println(\"ERROR: attempt to update unread count for user who has not been loaded\", uid)\n\t\t\t\tcounts[uid] = unreadUpdateError\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tval := vals[i]\n\t\t\tif uce.unread < 0 {\n\t\t\t\t// Unread counter not initialized yet. Maybe start a DB read?\n\t\t\t\tif updateBuf, ioInProgress := perUserBuffers[uid]; ioInProgress {\n\t\t\t\t\t// Buffer this update.\n\t\t\t\t\tupdateBuf = append(updateBuf, bufferedUpdate{val: val, inc: inc})\n\t\t\t\t\tperUserBuffers[uid] = updateBuf\n\t\t\t\t} else {\n\t\t\t\t\t// Schedule reading the counter from DB.\n\t\t\t\t\tupdateBuf = []bufferedUpdate{}\n\t\t\t\t\tperUserBuffers[uid] = updateBuf\n\t\t\t\t\tdbPending = append(dbPending, uid)\n\t\t\t\t}\n\t\t\t\tcounts[uid] = unreadUpdateIOPending\n\t\t\t\tcontinue\n\n\t\t\t} else if inc {\n\t\t\t\tuce.unread += val\n\t\t\t} else {\n\t\t\t\tuce.unread = val\n\t\t\t}\n\n\t\t\tusersCache[uid] = uce\n\t\t\tcounts[uid] = uce.unread\n\t\t}\n\n\t\tif len(dbPending) > 0 {\n\t\t\tgo func() {\n\t\t\t\tdbUnread, err := store.Users.GetUnreadCount(dbPending...)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogs.Warn.Println(\"users: failed to load unread count: \", err)\n\t\t\t\t}\n\t\t\t\tioDone <- &ioResult{counts: dbUnread, err: err}\n\t\t\t}()\n\t\t}\n\n\t\treturn counts\n\t}\n\n\tfor {\n\t\tselect {\n\t\tcase io := <-ioDone:\n\t\t\t// Unread counter read has completed.\n\t\t\tfor uid, count := range io.counts {\n\t\t\t\tupdateBuf, ok := perUserBuffers[uid]\n\t\t\t\t// Stop buffering updates. New updates will be handled normally.\n\t\t\t\tdelete(perUserBuffers, uid)\n\t\t\t\tif io.err != nil {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// Update counter.\n\t\t\t\tif ok {\n\t\t\t\t\tfor _, upd := range updateBuf {\n\t\t\t\t\t\tif upd.inc {\n\t\t\t\t\t\t\tcount += upd.val\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tcount = upd.val\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tlogs.Warn.Println(\"ERROR: io didn't have an update buffer, uid\", uid)\n\t\t\t\t}\n\n\t\t\t\tif uce, ok := usersCache[uid]; ok {\n\t\t\t\t\tif uce.unread >= 0 {\n\t\t\t\t\t\tlogs.Warn.Println(\"users: unread count double initialization, uid\", uid)\n\t\t\t\t\t}\n\t\t\t\t\tuce.unread = count\n\t\t\t\t\tusersCache[uid] = uce\n\t\t\t\t} else {\n\t\t\t\t\tlogs.Warn.Println(\"users: missing users cache entry after IO completion, uid\", uid)\n\t\t\t\t}\n\n\t\t\t\t// Now that the unread counter is initialized, handle pending push notification receipts.\n\t\t\t\t// Decrease pending IO counts in pending push receipts for this user.\n\t\t\t\tif pendingReceipts, ok := perUserPendingReceipts[uid]; ok {\n\t\t\t\t\tfor _, pp := range pendingReceipts {\n\t\t\t\t\t\tpp.pendingIOs--\n\t\t\t\t\t\treceiptQueue.fix(pp.index)\n\t\t\t\t\t}\n\t\t\t\t\tdelete(perUserPendingReceipts, uid)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif io.err != nil {\n\t\t\t\tlogs.Err.Println(\"users: failed to read unread count:\", io.err)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// Send ready receipts.\n\t\t\tfor receiptQueue.Len() > 0 && receiptQueue[0].pendingIOs == 0 {\n\t\t\t\trcpt := heap.Pop(&receiptQueue).(*pendingReceipt).rcpt\n\t\t\t\tfor uid, rcptTo := range rcpt.To {\n\t\t\t\t\tif uce, ok := usersCache[uid]; ok && uce.unread >= 0 {\n\t\t\t\t\t\trcptTo.Unread = uce.unread\n\t\t\t\t\t\trcpt.To[uid] = rcptTo\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tpush.Push(rcpt)\n\t\t\t}\n\t\tcase upd := <-globals.usersUpdate:\n\t\t\tif globals.shuttingDown {\n\t\t\t\t// If shutdown is in progress we don't care to process anything.\n\t\t\t\t// ignore all calls.\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// Shutdown requested.\n\t\t\tif upd == nil {\n\t\t\t\tglobals.usersUpdate = nil\n\t\t\t\t// Dont' care to close the channel.\n\t\t\t\tgoto Exit\n\t\t\t}\n\n\t\t\t// Request to send push notifications.\n\t\t\tif upd.PushRcpt != nil {\n\t\t\t\t// List of uids for which the unread count is being read from the DB.\n\t\t\t\tpendingUsers := []types.Uid{}\n\n\t\t\t\tallUids := make([]types.Uid, 0, len(upd.PushRcpt.To))\n\t\t\t\tallDeltas := make([]int, 0, len(upd.PushRcpt.To))\n\t\t\t\tfor uid, r := range upd.PushRcpt.To {\n\t\t\t\t\tallUids = append(allUids, uid)\n\t\t\t\t\tdelta := 0\n\t\t\t\t\tif r.ShouldIncrementUnreadCountInCache {\n\t\t\t\t\t\tdelta = 1\n\t\t\t\t\t}\n\t\t\t\t\tallDeltas = append(allDeltas, delta)\n\t\t\t\t}\n\n\t\t\t\tallUnread := unreadUpdater(allUids, allDeltas, true)\n\t\t\t\tfor uid, unread := range allUnread {\n\t\t\t\t\trcptTo := upd.PushRcpt.To[uid]\n\t\t\t\t\t// Handle update\n\t\t\t\t\tif unread >= 0 {\n\t\t\t\t\t\trcptTo.Unread = unread\n\t\t\t\t\t\tupd.PushRcpt.To[uid] = rcptTo\n\t\t\t\t\t} else if unread == unreadUpdateIOPending {\n\t\t\t\t\t\tpendingUsers = append(pendingUsers, uid)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif len(pendingUsers) == 0 {\n\t\t\t\t\t// All data present in memory. Just send the push.\n\t\t\t\t\tpush.Push(upd.PushRcpt)\n\t\t\t\t} else {\n\t\t\t\t\t// We are waiting for IO. Add this receipt to the queues.\n\t\t\t\t\tpp := &pendingReceipt{\n\t\t\t\t\t\tpendingIOs: len(pendingUsers),\n\t\t\t\t\t\trcpt:       upd.PushRcpt,\n\t\t\t\t\t}\n\t\t\t\t\tfor _, uid := range pendingUsers {\n\t\t\t\t\t\tvar queue []*pendingReceipt\n\t\t\t\t\t\tvar ok bool\n\t\t\t\t\t\tif queue, ok = perUserPendingReceipts[uid]; !ok {\n\t\t\t\t\t\t\tqueue = []*pendingReceipt{}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tperUserPendingReceipts[uid] = append(queue, pp)\n\t\t\t\t\t}\n\t\t\t\t\theap.Push(&receiptQueue, pp)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// Request to add/remove user from cache.\n\t\t\tif len(upd.UserIdList) > 0 {\n\t\t\t\tfor _, uid := range upd.UserIdList {\n\t\t\t\t\tuce, ok := usersCache[uid]\n\t\t\t\t\tif upd.Inc {\n\t\t\t\t\t\tif !ok {\n\t\t\t\t\t\t\t// This is a registration of a new user.\n\t\t\t\t\t\t\t// We are not loading unread count here, so set it to -1.\n\t\t\t\t\t\t\tuce.unread = -1\n\t\t\t\t\t\t}\n\t\t\t\t\t\tuce.topics++\n\t\t\t\t\t\tusersCache[uid] = uce\n\t\t\t\t\t} else if ok {\n\t\t\t\t\t\tif uce.topics > 1 {\n\t\t\t\t\t\t\tuce.topics--\n\t\t\t\t\t\t\tusersCache[uid] = uce\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// Remove user from cache\n\t\t\t\t\t\t\tdelete(usersCache, uid)\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// BUG!\n\t\t\t\t\t\tlogs.Err.Println(\"ERROR: request to unregister user which has not been registered\", uid)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif upd.Gone {\n\t\t\t\t// User is being deleted. Don't care if there is a record.\n\t\t\t\tdelete(usersCache, upd.UserId)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// Request to update unread count for one user.\n\t\t\tunreadUpdater([]types.Uid{upd.UserId}, []int{upd.Unread}, upd.Inc)\n\t\t}\n\t}\n\nExit:\n\tlogs.Info.Println(\"users: shutdown\")\n}\n\n// garbageCollectUsers runs every 'period' and deletes up to 'blockSize'\n// stale unvalidated user accounts which have been last updated at least\n// 'minAccountAgeHours' hours.\n// Returns channel which can be used to stop the process.\nfunc garbageCollectUsers(period time.Duration, blockSize, minAccountAgeHours int) chan<- bool {\n\t// Unbuffered stop channel. Whomever stops the gc must wait for the process to finish.\n\tstop := make(chan bool)\n\tgo func() {\n\t\t// Add some randomness to the tick period to desynchronize runs on cluster nodes:\n\t\t// 0.75 * period + rand(0, 0.5) * period.\n\t\tperiod = period - (period >> 2) + time.Duration(rand.Intn(int(period>>1)))\n\t\tgcTicker := time.Tick(period)\n\t\tlogs.Info.Printf(\"Stale account GC started with period %s, block size %d, min account age %d hours\",\n\t\t\tperiod.Round(time.Second), blockSize, minAccountAgeHours)\n\t\tstaleAge := time.Hour * time.Duration(minAccountAgeHours)\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-gcTicker:\n\t\t\t\tif uids, err := store.Users.GetUnvalidated(time.Now().Add(-staleAge), blockSize); err == nil {\n\t\t\t\t\tif len(uids) > 0 {\n\t\t\t\t\t\tlogs.Info.Println(\"Stale account GC will delete uids:\", uids)\n\t\t\t\t\t\tfor _, uid := range uids {\n\t\t\t\t\t\t\tif err = store.Users.Delete(uid, true); err != nil {\n\t\t\t\t\t\t\t\tlogs.Warn.Printf(\"Stale account GC failed to delete %s: %+v\", uid.UserId(), err)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tlogs.Warn.Println(\"Stale account GC error:\", err)\n\t\t\t\t}\n\t\t\tcase <-stop:\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn stop\n}\n"
  },
  {
    "path": "server/utils.go",
    "content": "// Generic data manipulation utilities.\n\npackage main\n\nimport (\n\t\"crypto/tls\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"regexp\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\t\"unicode\"\n\t\"unicode/utf8\"\n\n\t\"github.com/tinode/chat/server/auth\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/types\"\n\n\t\"maps\"\n\n\t\"golang.org/x/crypto/acme/autocert\"\n)\n\n// Tag with prefix:\n// * prefix starts with an ASCII letter, contains ASCII letters, numbers, from 2 to 16 chars\n// * tag body may contain Unicode letters and numbers, as well as the following symbols: +-.!?#@_\n// Tag body can be up to maxTagLength (96) chars long.\nvar prefixedTagRegexp = regexp.MustCompile(`^([a-z]\\w{1,15}):([-_+.!?#@\\pL\\pN]{1,96})$`)\n\n// Generic tag: the same restrictions as tag body.\nvar tagRegexp = regexp.MustCompile(`^[-_+.!?#@\\pL\\pN]{1,96}$`)\n\nconst nullValue = \"\\u2421\"\n\n// Convert database ranges into wire protocol ranges.\nfunc rangeDeserialize(in []types.Range) []MsgRange {\n\tif len(in) == 0 {\n\t\treturn nil\n\t}\n\n\tout := make([]MsgRange, 0, len(in))\n\tfor _, r := range in {\n\t\tout = append(out, MsgRange{LowId: r.Low, HiId: r.Hi})\n\t}\n\n\treturn out\n}\n\n// Convert wire protocol ranges into database ranges.\nfunc rangeSerialize(in []MsgRange) []types.Range {\n\tif len(in) == 0 {\n\t\treturn nil\n\t}\n\n\tout := make([]types.Range, 0, len(in))\n\tfor _, r := range in {\n\t\tout = append(out, types.Range{Low: r.LowId, Hi: r.HiId})\n\t}\n\n\treturn out\n}\n\n// stringSliceDelta extracts the slices of added and removed strings from two slices:\n//\n//\tadded :=  newSlice - (oldSlice & newSlice) -- present in new but missing in old\n//\tremoved := oldSlice - (oldSlice & newSlice) -- present in old but missing in new\n//\tintersection := oldSlice & newSlice -- present in both old and new\nfunc stringSliceDelta(rold, rnew []string) (added, removed, intersection []string) {\n\tif len(rold) == 0 && len(rnew) == 0 {\n\t\treturn nil, nil, nil\n\t}\n\tif len(rold) == 0 {\n\t\treturn rnew, nil, nil\n\t}\n\tif len(rnew) == 0 {\n\t\treturn nil, rold, nil\n\t}\n\n\tsort.Strings(rold)\n\tsort.Strings(rnew)\n\n\t// Match old slice against the new slice and separate removed strings from added.\n\to, n := 0, 0\n\tlold, lnew := len(rold), len(rnew)\n\tfor o < lold || n < lnew {\n\t\tif o == lold || (n < lnew && rold[o] > rnew[n]) {\n\t\t\t// Present in new, missing in old: added\n\t\t\tadded = append(added, rnew[n])\n\t\t\tn++\n\n\t\t} else if n == lnew || rold[o] < rnew[n] {\n\t\t\t// Present in old, missing in new: removed\n\t\t\tremoved = append(removed, rold[o])\n\t\t\to++\n\n\t\t} else {\n\t\t\t// present in both\n\t\t\tintersection = append(intersection, rold[o])\n\t\t\tif o < lold {\n\t\t\t\to++\n\t\t\t}\n\t\t\tif n < lnew {\n\t\t\t\tn++\n\t\t\t}\n\t\t}\n\t}\n\treturn added, removed, intersection\n}\n\n// Process credentials for correctness: remove duplicate and unknown methods.\n// In case of duplicate methods only the first one satisfying valueRequired is kept.\n// If valueRequired is true, keep only those where Value is non-empty.\nfunc normalizeCredentials(creds []MsgCredClient, valueRequired bool) []MsgCredClient {\n\tif len(creds) == 0 {\n\t\treturn nil\n\t}\n\n\tindex := make(map[string]*MsgCredClient)\n\tfor i := range creds {\n\t\tc := &creds[i]\n\t\tif _, ok := globals.validators[c.Method]; ok && (!valueRequired || c.Value != \"\") {\n\t\t\tindex[c.Method] = c\n\t\t}\n\t}\n\tcreds = make([]MsgCredClient, 0, len(index))\n\tfor _, c := range index {\n\t\tcreds = append(creds, *index[c.Method])\n\t}\n\treturn creds\n}\n\n// Get a string slice with methods of credentials.\nfunc credentialMethods(creds []MsgCredClient) []string {\n\tout := make([]string, len(creds))\n\tfor i := range creds {\n\t\tout[i] = creds[i].Method\n\t}\n\treturn out\n}\n\n// Takes MsgClientGet query parameters, returns database query parameters\nfunc msgOpts2storeOpts(req *MsgGetOpts) *types.QueryOpt {\n\tvar opts *types.QueryOpt\n\tif req != nil {\n\t\topts = &types.QueryOpt{\n\t\t\tUser:            types.ParseUserId(req.User),\n\t\t\tTopic:           req.Topic,\n\t\t\tIfModifiedSince: req.IfModifiedSince,\n\t\t\tLimit:           req.Limit,\n\t\t\tSince:           req.SinceId,\n\t\t\tBefore:          req.BeforeId,\n\t\t\tIdRanges:        rangeSerialize(req.IdRanges),\n\t\t}\n\t}\n\treturn opts\n}\n\n// Check if the interface contains a string with a single Unicode Del control character.\nfunc isNullValue(i any) bool {\n\tif str, ok := i.(string); ok {\n\t\treturn str == nullValue\n\t}\n\treturn false\n}\n\nfunc decodeStoreError(err error, id string, ts time.Time, params map[string]any) *ServerComMessage {\n\treturn decodeStoreErrorExplicitTs(err, id, \"\", ts, ts, params)\n}\n\nfunc decodeStoreErrorExplicitTs(err error, id, topic string, serverTs, incomingReqTs time.Time,\n\tparams map[string]any) *ServerComMessage {\n\n\tvar errmsg *ServerComMessage\n\n\tif err == nil {\n\t\terrmsg = NoErrExplicitTs(id, topic, serverTs, incomingReqTs)\n\t} else if storeErr, ok := err.(types.StoreError); !ok {\n\t\terrmsg = ErrUnknownExplicitTs(id, topic, serverTs, incomingReqTs)\n\t} else {\n\t\tswitch storeErr {\n\t\tcase types.ErrInternal:\n\t\t\terrmsg = ErrUnknownExplicitTs(id, topic, serverTs, incomingReqTs)\n\t\tcase types.ErrMalformed:\n\t\t\terrmsg = ErrMalformedExplicitTs(id, topic, serverTs, incomingReqTs)\n\t\tcase types.ErrFailed:\n\t\t\terrmsg = ErrAuthFailed(id, topic, serverTs, incomingReqTs)\n\t\tcase types.ErrPermissionDenied:\n\t\t\terrmsg = ErrPermissionDeniedExplicitTs(id, topic, serverTs, incomingReqTs)\n\t\tcase types.ErrDuplicate:\n\t\t\terrmsg = ErrDuplicateCredential(id, topic, serverTs, incomingReqTs)\n\t\tcase types.ErrUnsupported:\n\t\t\terrmsg = ErrNotImplemented(id, topic, serverTs, incomingReqTs)\n\t\tcase types.ErrExpired:\n\t\t\terrmsg = ErrAuthFailed(id, topic, serverTs, incomingReqTs)\n\t\tcase types.ErrPolicy:\n\t\t\terrmsg = ErrPolicyExplicitTs(id, topic, serverTs, incomingReqTs)\n\t\tcase types.ErrCredentials:\n\t\t\terrmsg = InfoValidateCredentialsExplicitTs(id, serverTs, incomingReqTs)\n\t\tcase types.ErrUserNotFound:\n\t\t\terrmsg = ErrUserNotFound(id, topic, serverTs, incomingReqTs)\n\t\tcase types.ErrTopicNotFound:\n\t\t\terrmsg = ErrTopicNotFound(id, topic, serverTs, incomingReqTs)\n\t\tcase types.ErrNotFound:\n\t\t\terrmsg = ErrNotFoundExplicitTs(id, topic, serverTs, incomingReqTs)\n\t\tcase types.ErrInvalidResponse:\n\t\t\terrmsg = ErrInvalidResponse(id, topic, serverTs, incomingReqTs)\n\t\tcase types.ErrRedirected:\n\t\t\terrmsg = InfoUseOther(id, topic, params[\"topic\"].(string), serverTs, incomingReqTs)\n\t\tdefault:\n\t\t\terrmsg = ErrUnknownExplicitTs(id, topic, serverTs, incomingReqTs)\n\t\t}\n\t}\n\n\tif params != nil {\n\t\terrmsg.Ctrl.Params = params\n\t}\n\n\treturn errmsg\n}\n\n// Helper function to select access mode for the given auth level\nfunc selectAccessMode(authLvl auth.Level, anonMode, authMode, rootMode types.AccessMode) types.AccessMode {\n\tswitch authLvl {\n\tcase auth.LevelNone:\n\t\treturn types.ModeNone\n\tcase auth.LevelAnon:\n\t\treturn anonMode\n\tcase auth.LevelAuth:\n\t\treturn authMode\n\tcase auth.LevelRoot:\n\t\treturn rootMode\n\tdefault:\n\t\treturn types.ModeNone\n\t}\n}\n\n// Get default modeWant for the given topic category\nfunc getDefaultAccess(cat types.TopicCat, authUser, isChan bool) types.AccessMode {\n\tif !authUser {\n\t\treturn types.ModeNone\n\t}\n\n\tswitch cat {\n\tcase types.TopicCatP2P:\n\t\treturn globals.typesModeCP2P\n\tcase types.TopicCatFnd:\n\t\treturn types.ModeNone\n\tcase types.TopicCatGrp:\n\t\tif isChan {\n\t\t\treturn types.ModeCChnWriter\n\t\t}\n\t\treturn types.ModeCPublic\n\tcase types.TopicCatMe:\n\t\treturn types.ModeCMeFnd\n\tcase types.TopicCatSlf:\n\t\treturn types.ModeCSelf\n\tdefault:\n\t\tpanic(\"Unknown topic category\")\n\t}\n}\n\n// Parse topic access parameters\nfunc parseTopicAccess(acs *MsgDefaultAcsMode, defAuth, defAnon types.AccessMode) (authMode, anonMode types.AccessMode,\n\terr error) {\n\n\tauthMode, anonMode = defAuth, defAnon\n\n\tif acs.Auth != \"\" {\n\t\terr = authMode.UnmarshalText([]byte(acs.Auth))\n\t}\n\tif acs.Anon != \"\" {\n\t\terr = anonMode.UnmarshalText([]byte(acs.Anon))\n\t}\n\n\treturn\n}\n\n// Parse one component of a semantic version string.\nfunc parseVersionPart(vers string) int {\n\tend := strings.IndexFunc(vers, func(r rune) bool {\n\t\treturn !unicode.IsDigit(r)\n\t})\n\n\tt := 0\n\tvar err error\n\tif end > 0 {\n\t\tt, err = strconv.Atoi(vers[:end])\n\t} else if len(vers) > 0 {\n\t\tt, err = strconv.Atoi(vers)\n\t}\n\tif err != nil || t > 0x1fff || t <= 0 {\n\t\treturn 0\n\t}\n\treturn t\n}\n\n// Parses semantic version string in the following formats:\n//\n//\t1.2, 1.2abc, 1.2.3, 1.2.3-abc, v0.12.34-rc5\n//\n// Unparceable values are replaced with zeros.\nfunc parseVersion(vers string) int {\n\tvar major, minor, patch int\n\t// Maybe remove the optional \"v\" prefix.\n\tvers = strings.TrimPrefix(vers, \"v\")\n\n\t// We can handle 3 parts only.\n\tparts := strings.SplitN(vers, \".\", 3)\n\tcount := len(parts)\n\tif count > 0 {\n\t\tmajor = parseVersionPart(parts[0])\n\t\tif count > 1 {\n\t\t\tminor = parseVersionPart(parts[1])\n\t\t\tif count > 2 {\n\t\t\t\tpatch = parseVersionPart(parts[2])\n\t\t\t}\n\t\t}\n\t}\n\n\treturn (major << 16) | (minor << 8) | patch\n}\n\n// Version as a base-10 number. Used by monitoring.\nfunc base10Version(hex int) int64 {\n\tmajor := hex >> 16 & 0xFF\n\tminor := hex >> 8 & 0xFF\n\ttrailer := hex & 0xFF\n\treturn int64(major*10000 + minor*100 + trailer)\n}\n\nfunc versionToString(vers int) string {\n\tstr := strconv.Itoa(vers>>16) + \".\" + strconv.Itoa((vers>>8)&0xff)\n\tif vers&0xff != 0 {\n\t\tstr += \"-\" + strconv.Itoa(vers&0xff)\n\t}\n\treturn str\n}\n\n// Tag handling\n\n// filterTags takes a slice of tags and a map of namespaces, return a slice of namespace tags\n// contained in the input.\n// params: Tags to filter, namespaces to use as the filter.\nfunc filterTags(tags []string, namespaces map[string]bool) []string {\n\tvar out []string\n\tif len(namespaces) == 0 {\n\t\treturn out\n\t}\n\n\tfor _, s := range tags {\n\t\tparts := prefixedTagRegexp.FindStringSubmatch(s)\n\n\t\tif len(parts) < 2 {\n\t\t\tcontinue\n\t\t}\n\n\t\t// [1] is the prefix. [0] is the whole tag.\n\t\tif namespaces[parts[1]] {\n\t\t\tout = append(out, s)\n\t\t}\n\t}\n\n\treturn out\n}\n\n// rewriteTag attempts to match the original token against the email and telephone number.\n// The tag is expected to be in lowercase.\n// On success, it returns a slice with the original tag and the tag with the corresponding prefix. It returns an\n// empty slice if the tag is invalid.\n// TODO: consider inferring country code from user location.\nfunc rewriteTag(orig, countryCode string) []string {\n\t// Check if the tag already has a prefix e.g. basic:alice.\n\tif prefixedTagRegexp.MatchString(orig) {\n\t\treturn []string{orig}\n\t}\n\n\t// Check if token can be rewritten by any of the validators\n\tparam := map[string]any{\"countryCode\": countryCode}\n\tfor name, conf := range globals.validators {\n\t\tif conf.addToTags {\n\t\t\tval := store.Store.GetValidator(name)\n\t\t\tif tag, _ := val.PreCheck(orig, param); tag != \"\" {\n\t\t\t\treturn []string{orig, tag}\n\t\t\t}\n\t\t}\n\t}\n\n\tif tagRegexp.MatchString(orig) {\n\t\treturn []string{orig}\n\t}\n\n\t// invalid generic tag\n\n\treturn nil\n}\n\n// rewriteTagSlice calls rewriteTag for each slice member and return a new slice with original and converted values.\nfunc rewriteTagSlice(tags []string, countryCode string) []string {\n\tvar result []string\n\tfor _, tag := range tags {\n\t\trewritten := rewriteTag(tag, countryCode)\n\t\tif len(rewritten) != 0 {\n\t\t\tresult = append(result, rewritten...)\n\t\t}\n\t}\n\treturn result\n}\n\n// restrictedTagsEqual checks if two sets of tags contain the same set of restricted tags:\n// true - same, false - different.\nfunc restrictedTagsEqual(oldTags, newTags []string, namespaces map[string]bool) bool {\n\trold := filterTags(oldTags, namespaces)\n\trnew := filterTags(newTags, namespaces)\n\n\tif len(rold) != len(rnew) {\n\t\treturn false\n\t}\n\n\tsort.Strings(rold)\n\tsort.Strings(rnew)\n\n\t// Match old tags against the new tags.\n\tfor i := range rnew {\n\t\tif rold[i] != rnew[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\n\treturn true\n}\n\n// Trim whitespace, remove short/empty tags and duplicates, convert to lowercase, ensure\n// the number of tags does not exceed the maximum.\nfunc normalizeTags(src []string, maxTags int) types.StringSlice {\n\tif src == nil {\n\t\treturn nil\n\t}\n\n\t// Make sure the number of tags does not exceed the maximum.\n\t// Technically it may result in fewer tags than the maximum due to empty tags and\n\t// duplicates, but that's user's fault.\n\tif len(src) > maxTags {\n\t\tsrc = src[:maxTags]\n\t}\n\n\t// Trim whitespace and force to lowercase.\n\tfor i := range src {\n\t\tsrc[i] = strings.ToLower(strings.TrimSpace(src[i]))\n\t}\n\n\t// Sort tags\n\tsort.Strings(src)\n\n\t// Remove short, invalid tags and de-dupe keeping the order. It may result in fewer tags than could have\n\t// been if length were enforced later, but that's client's fault.\n\tvar prev string\n\tvar dst []string\n\tfor _, curr := range src {\n\t\tif isNullValue(curr) {\n\t\t\t// Return non-nil empty array\n\t\t\treturn make([]string, 0, 1)\n\t\t}\n\n\t\t// Unicode handling\n\t\tucurr := []rune(curr)\n\n\t\t// Enforce length in characters, not in bytes.\n\t\tif len(ucurr) < minTagLength || len(ucurr) > maxTagLength || curr == prev {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Make sure the tag starts with a letter or a number.\n\t\tif unicode.IsLetter(ucurr[0]) || unicode.IsDigit(ucurr[0]) {\n\t\t\tdst = append(dst, curr)\n\t\t\tprev = curr\n\t\t}\n\t}\n\n\treturn types.StringSlice(dst)\n}\n\nfunc validateTag(tag string) (string, string) {\n\t// Check if the tag already has a prefix e.g. basic:alice.\n\tif parts := prefixedTagRegexp.FindStringSubmatch(tag); len(parts) == 3 {\n\t\t// Valid prefixed tag.\n\t\treturn parts[1], parts[2]\n\t}\n\n\tif tagRegexp.MatchString(tag) {\n\t\t// Valid unprefixed tag (tag value only).\n\t\treturn \"\", tag\n\t}\n\n\treturn \"\", \"\"\n}\n\n// hasDuplicateNamespaceTags checks for duplication of unique NS tags.\n// Each namespace can have only one tag. This does not prevent tags from\n// being duplicate across requests, just saves an extra DB call.\nfunc hasDuplicateNamespaceTags(src []string, uniqueNS string) bool {\n\tfound := map[string]bool{}\n\tfor _, tag := range src {\n\t\tparts := prefixedTagRegexp.FindStringSubmatch(tag)\n\t\tif len(parts) != 3 {\n\t\t\t// Invalid tag, ignored.\n\t\t\tcontinue\n\t\t}\n\n\t\tif uniqueNS == parts[1] && found[parts[1]] {\n\t\t\treturn true\n\t\t}\n\t\tfound[parts[1]] = true\n\t}\n\treturn false\n}\n\n// Parser for search queries. The query may contain non-ASCII characters,\n// i.e. length of string in bytes != length of string in runes.\n// Returns\n// * required tags: AND tags (at least one must be present in every result),\n// * optional tags\n// * error.\nfunc parseSearchQuery(query string) ([]string, []string, error) {\n\tconst (\n\t\tNONE = iota\n\t\tQUO  // 1\n\t\tAND  // 2\n\t\tOR   // 3\n\t\tEND  // 4\n\t\tORD  // 5\n\t)\n\ttype token struct {\n\t\top  int\n\t\tval string\n\t}\n\ttype context struct {\n\t\t// Pre-token operand.\n\t\tpreOp int\n\t\t// Post-token operand.\n\t\tpostOp int\n\t\t// Inside quoted string.\n\t\tquo bool\n\t\t// Start of the current token.\n\t\tstart int\n\t\t// End of the current token.\n\t\tend int\n\t}\n\tctx := context{preOp: AND}\n\tvar out []token\n\tvar prev int\n\tquery = strings.TrimSpace(query)\n\t// Split query into tokens.\n\t//   i - character index into the string.\n\t//   pos - rune index into the string.\n\t//   w - width of the current rune in characters.\n\tfor i, w, pos := 0, 0, 0; prev != END; i, pos = i+w, pos+1 {\n\t\t//\n\t\tvar emit bool\n\n\t\t// Lexer: get next rune.\n\t\tvar r rune\n\t\t// Ordinary character by default.\n\t\tcurr := ORD\n\t\tr, w = utf8.DecodeRuneInString(query[i:])\n\t\tswitch {\n\t\tcase w == 0:\n\t\t\t// Width zero: end of the string.\n\t\t\tcurr = END\n\t\tcase r == '\"':\n\t\t\t// Quote opening or closing.\n\t\t\tcurr = QUO\n\t\tcase !ctx.quo:\n\t\t\t// Not inside quoted string, test for control characters.\n\t\t\tif r == ' ' || r == '\\t' {\n\t\t\t\t// Tab or space.\n\t\t\t\tcurr = AND\n\t\t\t} else if r == ',' {\n\t\t\t\tcurr = OR\n\t\t\t}\n\t\t}\n\n\t\tif curr == QUO {\n\t\t\tif ctx.quo {\n\t\t\t\t// End of the quoted string. Close the quote.\n\t\t\t\tctx.quo = false\n\t\t\t} else {\n\t\t\t\tif prev == ORD {\n\t\t\t\t\t// Reject strings like a\"b\n\t\t\t\t\treturn nil, nil, fmt.Errorf(\"missing operator at or near %d\", pos)\n\t\t\t\t}\n\t\t\t\t// Start of the quoted string. Open the quote.\n\t\t\t\tctx.quo = true\n\t\t\t}\n\t\t\t// Treat quoted string as ordinary.\n\t\t\tcurr = ORD\n\t\t}\n\n\t\t// Parser: process the current lexem in context.\n\t\tswitch curr {\n\t\tcase OR:\n\t\t\tif ctx.postOp == OR {\n\t\t\t\t// More than one comma: ' , ,,'\n\t\t\t\treturn nil, nil, fmt.Errorf(\"invalid operator sequence at or near %d\", pos)\n\t\t\t}\n\t\t\t// Ensure context is not \"and\", i.e. the case like ' ,' -> ','\n\t\t\tctx.postOp = OR\n\t\t\tif prev == ORD {\n\t\t\t\t// Close the current token.\n\t\t\t\tctx.end = i\n\t\t\t}\n\t\tcase AND:\n\t\t\tif prev == ORD {\n\t\t\t\t// Close the current token.\n\t\t\t\tctx.end = i\n\t\t\t\tctx.postOp = AND\n\t\t\t} else if ctx.postOp != OR {\n\t\t\t\t// \"and\" does not change the \"or\" context.\n\t\t\t\tctx.postOp = AND\n\t\t\t}\n\t\tcase ORD:\n\t\t\tif prev == OR || prev == AND {\n\t\t\t\t// Ordinary character after a comma or a space: ' a' or ',a'.\n\t\t\t\t// Emit without changing the operation.\n\t\t\t\temit = true\n\t\t\t}\n\t\tcase END:\n\t\t\tif prev == ORD {\n\t\t\t\t// Close the current token.\n\t\t\t\tctx.end = i\n\t\t\t}\n\t\t\temit = true\n\t\t}\n\n\t\tif emit {\n\t\t\tif ctx.quo && curr == END {\n\t\t\t\treturn nil, nil, fmt.Errorf(\"unterminated quoted string at or near %d %#v\", pos, ctx)\n\t\t\t}\n\n\t\t\t// Emit the new token.\n\t\t\top := ctx.preOp\n\t\t\tif ctx.postOp == OR {\n\t\t\t\top = OR\n\t\t\t}\n\t\t\tstart, end := ctx.start, ctx.end\n\t\t\tif query[start] == '\"' && query[end-1] == '\"' {\n\t\t\t\tstart++\n\t\t\t\tend--\n\t\t\t}\n\t\t\t// Add token if non-empty.\n\t\t\tif start < end {\n\t\t\t\tout = append(out, token{val: strings.ToLower(query[start:end]), op: op})\n\t\t\t}\n\t\t\tctx.start = i\n\t\t\tctx.preOp, ctx.postOp = ctx.postOp, NONE\n\t\t}\n\n\t\tprev = curr\n\t}\n\n\tif len(out) == 0 {\n\t\treturn nil, nil, nil\n\t}\n\n\t// Convert tokens to two string slices.\n\tvar and []string\n\tvar or []string\n\tfor _, t := range out {\n\t\tswitch t.op {\n\t\tcase AND:\n\t\t\tand = append(and, t.val)\n\t\tcase OR:\n\t\t\tor = append(or, t.val)\n\t\t}\n\t}\n\treturn and, or, nil\n}\n\n// Returns > 0 if v1 > v2; zero if equal; < 0 if v1 < v2\n// Only Major and Minor parts are compared, the trailer is ignored.\nfunc versionCompare(v1, v2 int) int {\n\treturn (v1 >> 8) - (v2 >> 8)\n}\n\nfunc max(a, b int) int {\n\tif a > b {\n\t\treturn a\n\t}\n\treturn b\n}\n\n// Truncate string if it's too long. Used in logging.\nfunc truncateStringIfTooLong(s string) string {\n\tif len(s) <= 1024 {\n\t\treturn s\n\t}\n\n\treturn s[:1024] + \"...\"\n}\n\n// Convert relative filepath to absolute.\nfunc toAbsolutePath(base, path string) string {\n\tif filepath.IsAbs(path) {\n\t\treturn path\n\t}\n\treturn filepath.Clean(filepath.Join(base, path))\n}\n\n// Detect platform from the UserAgent string.\nfunc platformFromUA(ua string) string {\n\tua = strings.ToLower(ua)\n\tswitch {\n\tcase strings.Contains(ua, \"reactnative\"):\n\t\tswitch {\n\t\tcase strings.Contains(ua, \"iphone\"),\n\t\t\tstrings.Contains(ua, \"ipad\"):\n\t\t\treturn \"ios\"\n\t\tcase strings.Contains(ua, \"android\"):\n\t\t\treturn \"android\"\n\t\t}\n\t\treturn \"\"\n\tcase strings.Contains(ua, \"tinodejs\"):\n\t\treturn \"web\"\n\tcase strings.Contains(ua, \"tindroid\"):\n\t\treturn \"android\"\n\tcase strings.Contains(ua, \"tinodios\"):\n\t\treturn \"ios\"\n\t}\n\treturn \"\"\n}\n\nfunc parseTLSConfig(tlsEnabled bool, jsconfig json.RawMessage) (*tls.Config, error) {\n\ttype tlsAutocertConfig struct {\n\t\t// Domains to support by autocert\n\t\tDomains []string `json:\"domains\"`\n\t\t// Name of directory where auto-certificates are cached, e.g. /etc/letsencrypt/live/your-domain-here\n\t\tCertCache string `json:\"cache\"`\n\t\t// Contact email for letsencrypt\n\t\tEmail string `json:\"email\"`\n\t}\n\n\ttype tlsConfig struct {\n\t\t// Flag enabling TLS\n\t\tEnabled bool `json:\"enabled\"`\n\t\t// Listen for connections on this address:port and redirect them to HTTPS port.\n\t\tRedirectHTTP string `json:\"http_redirect\"`\n\t\t// Enable Strict-Transport-Security by setting max_age > 0\n\t\tStrictMaxAge int `json:\"strict_max_age\"`\n\t\t// ACME autocert config, e.g. letsencrypt.org\n\t\tAutocert *tlsAutocertConfig `json:\"autocert\"`\n\t\t// If Autocert is not defined, provide file names of static certificate and key\n\t\tCertFile string `json:\"cert_file\"`\n\t\tKeyFile  string `json:\"key_file\"`\n\t}\n\n\tvar config tlsConfig\n\n\tif jsconfig != nil {\n\t\tif err := json.Unmarshal(jsconfig, &config); err != nil {\n\t\t\treturn nil, errors.New(\"http: failed to parse tls_config: \" + err.Error() + \"(\" + string(jsconfig) + \")\")\n\t\t}\n\t}\n\n\tif !tlsEnabled && !config.Enabled {\n\t\treturn nil, nil\n\t}\n\n\tif config.StrictMaxAge > 0 {\n\t\tglobals.tlsStrictMaxAge = strconv.Itoa(config.StrictMaxAge)\n\t}\n\n\tglobals.tlsRedirectHTTP = config.RedirectHTTP\n\n\t// If autocert is provided, use it.\n\tif config.Autocert != nil {\n\t\tcertManager := autocert.Manager{\n\t\t\tPrompt:     autocert.AcceptTOS,\n\t\t\tHostPolicy: autocert.HostWhitelist(config.Autocert.Domains...),\n\t\t\tCache:      autocert.DirCache(config.Autocert.CertCache),\n\t\t\tEmail:      config.Autocert.Email,\n\t\t}\n\t\treturn certManager.TLSConfig(), nil\n\t}\n\n\t// Otherwise try to use static keys.\n\tcert, err := tls.LoadX509KeyPair(config.CertFile, config.KeyFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &tls.Config{Certificates: []tls.Certificate{cert}}, nil\n}\n\n// Merge source interface{} into destination interface.\n// If values are maps,deep-merge them. Otherwise shallow-copy.\n// Returns dst, true if the dst value was changed.\nfunc mergeInterfaces(dst, src any) (any, bool) {\n\tvar changed bool\n\n\tif src == nil {\n\t\treturn dst, changed\n\t}\n\n\tvsrc := reflect.ValueOf(src)\n\tswitch vsrc.Kind() {\n\tcase reflect.Map:\n\t\tif xsrc, ok := src.(map[string]any); ok {\n\t\t\txdst, _ := dst.(map[string]any)\n\t\t\tdst, changed = mergeMaps(xdst, xsrc)\n\t\t} else {\n\t\t\tchanged = true\n\t\t\tdst = src\n\t\t}\n\tcase reflect.String:\n\t\tif vsrc.String() == nullValue {\n\t\t\tchanged = dst != nil\n\t\t\tdst = nil\n\t\t} else {\n\t\t\tchanged = true\n\t\t\tdst = src\n\t\t}\n\tdefault:\n\t\tchanged = true\n\t\tdst = src\n\t}\n\treturn dst, changed\n}\n\n// Deep copy maps.\nfunc mergeMaps(dst, src map[string]any) (map[string]any, bool) {\n\tvar changed bool\n\n\tif len(src) == 0 {\n\t\treturn dst, changed\n\t}\n\n\tif dst == nil {\n\t\tdst = make(map[string]any)\n\t}\n\n\tfor key, val := range src {\n\t\txval := reflect.ValueOf(val)\n\t\tswitch xval.Kind() {\n\t\tcase reflect.Map:\n\t\t\tif xsrc, _ := val.(map[string]any); xsrc != nil {\n\t\t\t\t// Deep-copy map[string]any\n\t\t\t\txdst, _ := dst[key].(map[string]any)\n\t\t\t\tvar lchange bool\n\t\t\t\tdst[key], lchange = mergeMaps(xdst, xsrc)\n\t\t\t\tchanged = changed || lchange\n\t\t\t} else if val != nil {\n\t\t\t\t// The map is shallow-copied if it's not of the type map[string]any\n\t\t\t\tdst[key] = val\n\t\t\t\tchanged = true\n\t\t\t}\n\t\tcase reflect.String:\n\t\t\tchanged = true\n\t\t\tif xval.String() == nullValue {\n\t\t\t\tdelete(dst, key)\n\t\t\t} else if val != nil {\n\t\t\t\tdst[key] = val\n\t\t\t}\n\t\tdefault:\n\t\t\tif val != nil {\n\t\t\t\tdst[key] = val\n\t\t\t\tchanged = true\n\t\t\t}\n\t\t}\n\t}\n\n\treturn dst, changed\n}\n\n// Shallow copy of a map\nfunc copyMap(src map[string]any) map[string]any {\n\tdst := make(map[string]any, len(src))\n\tmaps.Copy(dst, src)\n\treturn dst\n}\n\n// netListener creates net.Listener for tcp and unix domains:\n// if addr is in the form \"unix:/run/tinode.sock\" it's a unix socket, otherwise TCP host:port.\nfunc netListener(addr string) (net.Listener, error) {\n\taddrParts := strings.SplitN(addr, \":\", 2)\n\tif len(addrParts) == 2 && addrParts[0] == \"unix\" {\n\t\treturn net.Listen(\"unix\", addrParts[1])\n\t}\n\treturn net.Listen(\"tcp\", addr)\n}\n\n// Check if specified address is a unix socket like \"unix:/run/tinode.sock\".\nfunc isUnixAddr(addr string) bool {\n\taddrParts := strings.SplitN(addr, \":\", 2)\n\treturn len(addrParts) == 2 && addrParts[0] == \"unix\"\n}\n\nvar privateIPBlocks []*net.IPNet\n\nfunc isRoutableIP(ipStr string) bool {\n\tip := net.ParseIP(ipStr)\n\tif ip == nil {\n\t\treturn false\n\t}\n\n\tif ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {\n\t\treturn false\n\t}\n\n\tif privateIPBlocks == nil {\n\t\tfor _, cidr := range []string{\n\t\t\t\"10.0.0.0/8\",     // RFC1918\n\t\t\t\"172.16.0.0/12\",  // RFC1918\n\t\t\t\"192.168.0.0/16\", // RFC1918\n\t\t\t\"fc00::/7\",       // RFC4193, IPv6 unique local addr\n\t\t} {\n\t\t\t_, block, _ := net.ParseCIDR(cidr)\n\t\t\tprivateIPBlocks = append(privateIPBlocks, block)\n\t\t}\n\t}\n\n\tfor _, block := range privateIPBlocks {\n\t\tif block.Contains(ip) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n"
  },
  {
    "path": "server/utils_test.go",
    "content": "package main\n\nimport (\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\nfunc slicesEqual(expected, gotten []string) bool {\n\tif len(expected) != len(gotten) {\n\t\treturn false\n\t}\n\tfor i, v := range expected {\n\t\tif v != gotten[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc expectSlicesEqual(t *testing.T, name string, expected, gotten []string) {\n\tif !slicesEqual(expected, gotten) {\n\t\te := \"'\" + strings.Join(expected, \"','\") + \"'\"\n\t\tg := \"'\" + strings.Join(gotten, \"','\") + \"'\"\n\t\tt.Errorf(\"%s: expected %+v, got %+v\", name, e, g)\n\t}\n}\n\nfunc TestStringSliceDelta(t *testing.T) {\n\t// Case format:\n\t// - inputs: old, new\n\t// - expected outputs: added, removed, intersection\n\tcases := [][5][]string{\n\t\t{\n\t\t\t{\"abc\", \"def\", \"fff\"}, {},\n\t\t\t{}, {\"abc\", \"def\", \"fff\"}, {},\n\t\t},\n\t\t{\n\t\t\t{}, {}, {}, {}, {},\n\t\t},\n\t\t{\n\t\t\t{\"aa\", \"xx\", \"bb\", \"aa\", \"bb\"}, {\"yy\", \"aa\"},\n\t\t\t{\"yy\"}, {\"aa\", \"bb\", \"bb\", \"xx\"}, {\"aa\"},\n\t\t},\n\t\t{\n\t\t\t{\"bb\", \"aa\", \"bb\"}, {\"yy\", \"aa\", \"bb\", \"zzz\", \"zzz\", \"cc\"},\n\t\t\t{\"cc\", \"yy\", \"zzz\", \"zzz\"}, {\"bb\"}, {\"aa\", \"bb\"},\n\t\t},\n\t\t{\n\t\t\t{\"aa\", \"aa\", \"aa\"}, {\"aa\", \"aa\", \"aa\"},\n\t\t\t{}, {}, {\"aa\", \"aa\", \"aa\"},\n\t\t},\n\t}\n\n\tfor _, tc := range cases {\n\t\tadded, removed, both := stringSliceDelta(\n\t\t\ttc[0], tc[1],\n\t\t)\n\t\texpectSlicesEqual(t, \"added\", tc[2], added)\n\t\texpectSlicesEqual(t, \"removed\", tc[3], removed)\n\t\texpectSlicesEqual(t, \"both\", tc[4], both)\n\n\t}\n}\n\nfunc TestParseSearchQuery(t *testing.T) {\n\tcases := []struct {\n\t\tquery       string\n\t\texpectedAnd []string\n\t\texpectedOr  []string\n\t\texpectError bool\n\t}{\n\t\t{\n\t\t\tquery:       `tag1 tag2 tag3`,\n\t\t\texpectedAnd: []string{\"tag1\", \"tag2\", \"tag3\"},\n\t\t\texpectedOr:  []string{},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tquery:       `tag1,tag2,tag3`,\n\t\t\texpectedAnd: []string{},\n\t\t\texpectedOr:  []string{\"tag1\", \"tag2\", \"tag3\"},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tquery:       `tag1 tag2,tag3`,\n\t\t\texpectedAnd: []string{\"tag1\"},\n\t\t\texpectedOr:  []string{\"tag2\", \"tag3\"},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tquery:       `tag1 tag2,tag3 \"tag4 tag5\"`,\n\t\t\texpectedAnd: []string{\"tag1\", \"tag4 tag5\"},\n\t\t\texpectedOr:  []string{\"tag2\", \"tag3\"},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tquery:       `tag1,tag2 tag3`,\n\t\t\texpectedAnd: []string{\"tag3\"},\n\t\t\texpectedOr:  []string{\"tag1\", \"tag2\"},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tquery:       `\"tag1 tag2\" tag3,tag4`,\n\t\t\texpectedAnd: []string{\"tag1 tag2\"},\n\t\t\texpectedOr:  []string{\"tag3\", \"tag4\"},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tquery:       `tag1 \"tag2 tag3\"`,\n\t\t\texpectedAnd: []string{\"tag1\", \"tag2 tag3\"},\n\t\t\texpectedOr:  []string{},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tquery:       `tag1, tag2, tag3`,\n\t\t\texpectedAnd: []string{},\n\t\t\texpectedOr:  []string{\"tag1\", \"tag2\", \"tag3\"},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tquery:       `tag1 , tag2 ,tag3`,\n\t\t\texpectedAnd: []string{},\n\t\t\texpectedOr:  []string{\"tag1\", \"tag2\", \"tag3\"},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tquery:       `tag1     ,    tag2     tag3`,\n\t\t\texpectedAnd: []string{\"tag3\"},\n\t\t\texpectedOr:  []string{\"tag1\", \"tag2\"},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tquery:       `tag1 \"unterminated quote`,\n\t\t\texpectedAnd: nil,\n\t\t\texpectedOr:  nil,\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tquery:       `tag1,,tag2`,\n\t\t\texpectedAnd: nil,\n\t\t\texpectedOr:  nil,\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tquery:       `tag1 \"tag2\" tag3`,\n\t\t\texpectedAnd: []string{\"tag1\", \"tag2\", \"tag3\"},\n\t\t\texpectedOr:  []string{},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tquery:       `tag1\"tag2\" quote in the middle`,\n\t\t\texpectedAnd: nil,\n\t\t\texpectedOr:  nil,\n\t\t\texpectError: true,\n\t\t},\n\t}\n\n\tfor _, tc := range cases {\n\t\tand, or, err := parseSearchQuery(tc.query)\n\t\tif tc.expectError {\n\t\t\tif err == nil {\n\t\t\t\tt.Errorf(\"expected error for query '%s', got none\", tc.query)\n\t\t\t}\n\t\t} else {\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"unexpected error for query '%s': %v\", tc.query, err)\n\t\t\t} else {\n\t\t\t\texpectSlicesEqual(t, tc.query+\" AND\", tc.expectedAnd, and)\n\t\t\t\texpectSlicesEqual(t, tc.query+\" OR\", tc.expectedOr, or)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestNormalizeTags(t *testing.T) {\n\tcases := []struct {\n\t\tinput    []string\n\t\texpected types.StringSlice\n\t}{\n\t\t{\n\t\t\tinput:    []string{\"  Tag1  \", \"tag2\", \"TAG3\", \"tag1\"},\n\t\t\texpected: types.StringSlice{\"tag1\", \"tag2\", \"tag3\"},\n\t\t},\n\t\t{\n\t\t\tinput:    []string{\"  \", \"tag2\", \"TAG3\", \"tag1\"},\n\t\t\texpected: types.StringSlice{\"tag1\", \"tag2\", \"tag3\"},\n\t\t},\n\t\t{\n\t\t\tinput:    []string{\"tag1\"},\n\t\t\texpected: types.StringSlice{\"tag1\"},\n\t\t},\n\t\t{\n\t\t\tinput:    []string{\"tag1\", nullValue},\n\t\t\texpected: []string{},\n\t\t},\n\t\t{\n\t\t\tinput:    []string{},\n\t\t\texpected: nil,\n\t\t},\n\t}\n\n\tfor _, tc := range cases {\n\t\tgot := normalizeTags(tc.input, 16)\n\t\texpectSlicesEqual(t, \"normalizeTags\", tc.expected, got)\n\t}\n}\n\nfunc TestRestrictedTagsEqual(t *testing.T) {\n\tcases := []struct {\n\t\toldTags    []string\n\t\tnewTags    []string\n\t\tnamespaces map[string]bool\n\t\texpected   bool\n\t}{\n\t\t{\n\t\t\toldTags:    []string{\"ns1:tag1\", \"ns2:tag2\"},\n\t\t\tnewTags:    []string{\"ns1:tag1\", \"ns2:tag2\"},\n\t\t\tnamespaces: map[string]bool{\"ns1\": true, \"ns2\": true},\n\t\t\texpected:   true,\n\t\t},\n\t\t{\n\t\t\toldTags:    []string{\"ns1:tag1\", \"ns2:tag2\"},\n\t\t\tnewTags:    []string{\"ns1:tag1\", \"ns2:tag3\"},\n\t\t\tnamespaces: map[string]bool{\"ns1\": true, \"ns2\": true},\n\t\t\texpected:   false,\n\t\t},\n\t\t{\n\t\t\toldTags:    []string{\"ns1:tag1\", \"ns2:tag2\"},\n\t\t\tnewTags:    []string{\"ns1:tag1\"},\n\t\t\tnamespaces: map[string]bool{\"ns1\": true, \"ns2\": true},\n\t\t\texpected:   false,\n\t\t},\n\t}\n\n\tfor _, tc := range cases {\n\t\tgot := restrictedTagsEqual(tc.oldTags, tc.newTags, tc.namespaces)\n\t\tif got != tc.expected {\n\t\t\tt.Errorf(\"restrictedTagsEqual: expected %v, got %v\", tc.expected, got)\n\t\t}\n\t}\n}\n\nfunc TestIsNullValue(t *testing.T) {\n\tcases := []struct {\n\t\tinput    any\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tinput:    nullValue,\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tinput:    \"some string\",\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tinput:    123,\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tinput:    nil,\n\t\t\texpected: false,\n\t\t},\n\t}\n\n\tfor _, tc := range cases {\n\t\tgot := isNullValue(tc.input)\n\t\tif got != tc.expected {\n\t\t\tt.Errorf(\"isNullValue: expected %v, got %v\", tc.expected, got)\n\t\t}\n\t}\n}\n\nfunc TestParseVersion(t *testing.T) {\n\tcases := []struct {\n\t\tinput    string\n\t\texpected int\n\t}{\n\t\t{\n\t\t\tinput:    \"1.2.3\",\n\t\t\texpected: (1 << 16) | (2 << 8) | 3,\n\t\t},\n\t\t{\n\t\t\tinput:    \"1.2\",\n\t\t\texpected: (1 << 16) | (2 << 8),\n\t\t},\n\t\t{\n\t\t\tinput:    \"1\",\n\t\t\texpected: (1 << 16),\n\t\t},\n\t\t{\n\t\t\tinput:    \"v1.2.3\",\n\t\t\texpected: (1 << 16) | (2 << 8) | 3,\n\t\t},\n\t\t{\n\t\t\tinput:    \"v1.2\",\n\t\t\texpected: (1 << 16) | (2 << 8),\n\t\t},\n\t\t{\n\t\t\tinput:    \"v1\",\n\t\t\texpected: (1 << 16),\n\t\t},\n\t\t{\n\t\t\tinput:    \"1.2.3-rc1\",\n\t\t\texpected: (1 << 16) | (2 << 8) | 3,\n\t\t},\n\t\t{\n\t\t\tinput:    \"v1.2.3-rc1\",\n\t\t\texpected: (1 << 16) | (2 << 8) | 3,\n\t\t},\n\t\t{\n\t\t\tinput:    \"0.0.0\",\n\t\t\texpected: 0,\n\t\t},\n\t\t{\n\t\t\tinput:    \"v0.0.0\",\n\t\t\texpected: 0,\n\t\t},\n\t\t{\n\t\t\tinput:    \"1.2.8192\", // 8192 is greater than 0x1fff, should be ignored\n\t\t\texpected: (1 << 16) | (2 << 8),\n\t\t},\n\t\t{\n\t\t\tinput:    \"1.8192.3\", // 8192 is greater than 0x1fff, should be ignored\n\t\t\texpected: (1 << 16) | 3,\n\t\t},\n\t\t{\n\t\t\tinput:    \"8192.2.3\", // 8192 is greater than 0x1fff, should be ignored\n\t\t\texpected: (2 << 8) | 3,\n\t\t},\n\t\t{\n\t\t\tinput:    \"\",\n\t\t\texpected: 0,\n\t\t},\n\t}\n\n\tfor _, tc := range cases {\n\t\tgot := parseVersion(tc.input)\n\t\tif got != tc.expected {\n\t\t\tt.Errorf(\"parseVersion(%q): expected %d, got %d\", tc.input, tc.expected, got)\n\t\t}\n\t}\n}\n\nfunc TestMergeMaps(t *testing.T) {\n\tcases := []struct {\n\t\tdst      map[string]any\n\t\tsrc      map[string]any\n\t\texpected map[string]any\n\t\tchanged  bool\n\t}{\n\t\t{\n\t\t\tdst:      map[string]any{\"a\": 1, \"b\": 2},\n\t\t\tsrc:      map[string]any{\"b\": 3, \"c\": 4},\n\t\t\texpected: map[string]any{\"a\": 1, \"b\": 3, \"c\": 4},\n\t\t\tchanged:  true,\n\t\t},\n\t\t{\n\t\t\tdst:      map[string]any{\"a\": 1, \"b\": map[string]any{\"x\": 1}},\n\t\t\tsrc:      map[string]any{\"b\": map[string]any{\"y\": 2}},\n\t\t\texpected: map[string]any{\"a\": 1, \"b\": map[string]any{\"x\": 1, \"y\": 2}},\n\t\t\tchanged:  true,\n\t\t},\n\t\t{\n\t\t\tdst:      map[string]any{\"a\": 1, \"b\": map[string]any{\"x\": 1}},\n\t\t\tsrc:      map[string]any{\"b\": map[string]any{\"x\": nullValue}},\n\t\t\texpected: map[string]any{\"a\": 1, \"b\": map[string]any{}},\n\t\t\tchanged:  true,\n\t\t},\n\t\t{\n\t\t\tdst:      map[string]any{\"a\": 1, \"b\": 2},\n\t\t\tsrc:      map[string]any{},\n\t\t\texpected: map[string]any{\"a\": 1, \"b\": 2},\n\t\t\tchanged:  false,\n\t\t},\n\t\t{\n\t\t\tdst:      nil,\n\t\t\tsrc:      map[string]any{\"a\": 1},\n\t\t\texpected: map[string]any{\"a\": 1},\n\t\t\tchanged:  true,\n\t\t},\n\t\t{\n\t\t\tdst:      map[string]any{\"a\": 1},\n\t\t\tsrc:      map[string]any{\"a\": nullValue},\n\t\t\texpected: map[string]any{},\n\t\t\tchanged:  true,\n\t\t},\n\t}\n\n\tfor _, tc := range cases {\n\t\tgot, changed := mergeMaps(tc.dst, tc.src)\n\t\tif !reflect.DeepEqual(got, tc.expected) || changed != tc.changed {\n\t\t\tt.Errorf(\"mergeMaps(%v, %v): expected (%v, %v), got (%v, %v)\", tc.dst, tc.src, tc.expected, tc.changed, got, changed)\n\t\t}\n\t}\n}\nfunc TestMergeInterfaces(t *testing.T) {\n\tcases := []struct {\n\t\tdst      any\n\t\tsrc      any\n\t\texpected any\n\t\tchanged  bool\n\t}{\n\t\t{\n\t\t\tdst:      map[string]any{\"a\": 1, \"b\": map[string]any{\"x\": 1}},\n\t\t\tsrc:      map[string]any{\"b\": map[string]any{\"y\": 2}},\n\t\t\texpected: map[string]any{\"a\": 1, \"b\": map[string]any{\"x\": 1, \"y\": 2}},\n\t\t\tchanged:  true,\n\t\t},\n\t\t{\n\t\t\tdst:      map[string]any{\"a\": 1, \"b\": map[string]any{\"x\": 1}},\n\t\t\tsrc:      map[string]any{\"b\": map[string]any{\"x\": nullValue}},\n\t\t\texpected: map[string]any{\"a\": 1, \"b\": map[string]any{}},\n\t\t\tchanged:  true,\n\t\t},\n\t\t{\n\t\t\tdst:      map[string]any{\"a\": 1},\n\t\t\tsrc:      nullValue,\n\t\t\texpected: nil,\n\t\t\tchanged:  true,\n\t\t},\n\t\t{\n\t\t\tdst:      \"old string\",\n\t\t\tsrc:      \"new string\",\n\t\t\texpected: \"new string\",\n\t\t\tchanged:  true,\n\t\t},\n\t\t{\n\t\t\tdst:      \"old string\",\n\t\t\tsrc:      12345,\n\t\t\texpected: 12345,\n\t\t\tchanged:  true,\n\t\t},\n\t\t{\n\t\t\tdst:      \"old string\",\n\t\t\tsrc:      nullValue,\n\t\t\texpected: nil,\n\t\t\tchanged:  true,\n\t\t},\n\t\t{\n\t\t\tdst:      123,\n\t\t\tsrc:      456,\n\t\t\texpected: 456,\n\t\t\tchanged:  true,\n\t\t},\n\t\t{\n\t\t\tdst:      123,\n\t\t\tsrc:      nil,\n\t\t\texpected: 123,\n\t\t\tchanged:  false,\n\t\t},\n\t\t{\n\t\t\tdst:      123,\n\t\t\tsrc:      true,\n\t\t\texpected: true,\n\t\t\tchanged:  true,\n\t\t},\n\t\t{\n\t\t\tdst:      []string{\"a\", \"b\", \"c\"},\n\t\t\tsrc:      []string{\"d\", \"e\", \"f\"},\n\t\t\texpected: []string{\"d\", \"e\", \"f\"},\n\t\t\tchanged:  true,\n\t\t},\n\t}\n\n\tfor _, tc := range cases {\n\t\tgot, changed := mergeInterfaces(tc.dst, tc.src)\n\t\tif !reflect.DeepEqual(got, tc.expected) || changed != tc.changed {\n\t\t\tt.Errorf(\"mergeInterfaces(%v, %v): expected (%v, %v), got (%v, %v)\", tc.dst, tc.src, tc.expected, tc.changed, got, changed)\n\t\t}\n\t}\n}\n\nfunc TestFilterTags(t *testing.T) {\n\tcases := []struct {\n\t\ttags     []string\n\t\tns       map[string]bool\n\t\texpected []string\n\t}{\n\t\t{\n\t\t\ttags:     []string{\"ns1:tag1\", \"ns2:tag3\", \"nons\", \"inval::tag\", \":tag3\", \"tag4:\", \"tag5: \"},\n\t\t\tns:       map[string]bool{\"ns1\": true, \"ns2\": false, \"xtra\": true},\n\t\t\texpected: []string{\"ns1:tag1\"},\n\t\t},\n\t\t{\n\t\t\ttags:     []string{\"ns1:tag1\", \"ns2:tag3\", \"nons\", \"inval::tag\", \":tag3\", \"tag4:\", \"tag5: \"},\n\t\t\tns:       map[string]bool{},\n\t\t\texpected: nil,\n\t\t},\n\t}\n\n\tfor _, tc := range cases {\n\t\tgot := filterTags(tc.tags, tc.ns)\n\t\tif !reflect.DeepEqual(got, tc.expected) {\n\t\t\tt.Errorf(\"filterTags(%v, %v): expected (%v), got (%v)\", tc.tags, tc.ns, tc.expected, got)\n\t\t}\n\t}\n}\n\nfunc TestHasDuplicateNamespaceTags(t *testing.T) {\n\tcases := []struct {\n\t\ttags     []string\n\t\tns       string\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\ttags:     []string{\"ns1:tag1\", \"ns2:tag3\", \"nons\", \"inval::tag\", \":tag3\", \"tag4:\", \"tag5: \"},\n\t\t\tns:       \"ns1\",\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\ttags:     []string{\"ns1:tag1\", \"ns2:tag3\", \"nons\", \"inval::tag\", \":tag3\", \"tag4:\", \"tag5: \", \"ns1:tag2\"},\n\t\t\tns:       \"ns1\",\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\ttags:     []string{\"ns1:tag1\", \"ns2:tag3\", \"nons\", \"inval::tag\", \":tag3\", \"tag4:\", \"tag5: \"},\n\t\t\tns:       \"\",\n\t\t\texpected: false,\n\t\t},\n\t}\n\n\tfor _, tc := range cases {\n\t\tgot := hasDuplicateNamespaceTags(tc.tags, tc.ns)\n\t\tif !reflect.DeepEqual(got, tc.expected) {\n\t\t\tt.Errorf(\"filterTags(%v, %v): expected (%v), got (%v)\", tc.tags, tc.ns, tc.expected, got)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "server/validate/email/validate.go",
    "content": "// Package email is a credential validator which uses an external SMTP server.\npackage email\n\nimport (\n\t\"bytes\"\n\tcrand \"crypto/rand\"\n\t\"crypto/tls\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math/big\"\n\t\"math/rand\"\n\t\"mime\"\n\tqp \"mime/quotedprintable\"\n\t\"net/mail\"\n\t\"net/smtp\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\ttextt \"text/template\"\n\n\t\"slices\"\n\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store\"\n\tt \"github.com/tinode/chat/server/store/types\"\n\t\"github.com/tinode/chat/server/validate\"\n\ti18n \"golang.org/x/text/language\"\n)\n\n// Validator configuration.\ntype validator struct {\n\t// Base URL of the web client.\n\tHostUrl string `json:\"host_url\"`\n\t// List of languages supported by templates.\n\tLanguages []string `json:\"languages\"`\n\t// Path to email validation templates, either a template itself or a literal string.\n\tValidationTemplFile string `json:\"validation_templ\"`\n\t// Path to templates for resetting the authentication secret.\n\tResetTemplFile string `json:\"reset_secret_templ\"`\n\t// Sender RFC 5322 email address.\n\tSendFrom string `json:\"sender\"`\n\t// Login to use for SMTP authentication.\n\tLogin string `json:\"login\"`\n\t// Password to use for SMTP authentication.\n\tSenderPassword string `json:\"sender_password\"`\n\t// Authentication mechanism to use, optional. One of \"login\", \"md5\", \"plain\" (default).\n\tAuthMechanism string `json:\"auth_mechanism\"`\n\t// Optional response which bypasses the validation.\n\tDebugResponse string `json:\"debug_response\"`\n\t// Number of validation attempts before email is locked.\n\tMaxRetries int `json:\"max_retries\"`\n\t// Address of the SMTP server.\n\tSMTPAddr string `json:\"smtp_server\"`\n\t// Port of the SMTP server.\n\tSMTPPort string `json:\"smtp_port\"`\n\t// ServerName used in SMTP HELO/EHLO command.\n\tSMTPHeloHost string `json:\"smtp_helo_host\"`\n\t// Skip verification of the server's certificate chain and host name.\n\t// In this mode, TLS is susceptible to machine-in-the-middle attacks.\n\tTLSInsecureSkipVerify bool `json:\"insecure_skip_verify\"`\n\t// Optional whitelist of email domains accepted for registration.\n\tDomains []string `json:\"domains\"`\n\t// Length of secret numeric code to sent for validation.\n\tCodeLength int `json:\"code_length\"`\n\n\t// Must use index into language array instead of language tags because language.Matcher is brain damaged:\n\t// https://github.com/golang/go/issues/24211\n\tvalidationTempl []*textt.Template\n\tresetTempl      []*textt.Template\n\tauth            smtp.Auth\n\tsenderEmail     string\n\tlangMatcher     i18n.Matcher\n\tmaxCodeValue    *big.Int\n}\n\nconst (\n\tvalidatorName = \"email\"\n\n\tdefaultMaxRetries = 3\n\tdefaultPort       = \"25\"\n\n\t// Technically email could be up to 255 bytes long but practically 128 is enough.\n\tmaxEmailLength = 128\n\n\t// Default code length when one is not provided in the config\n\tdefaultCodeLength = 6\n)\n\n// Email template parts\nvar templateParts = []string{\"subject\", \"body_plain\", \"body_html\"}\n\n// Init: initialize validator.\nfunc (v *validator) Init(jsonconf string) error {\n\tif err := json.Unmarshal([]byte(jsonconf), v); err != nil {\n\t\treturn err\n\t}\n\n\tsender, err := mail.ParseAddress(v.SendFrom)\n\tif err != nil {\n\t\treturn err\n\t}\n\tv.senderEmail = sender.Address\n\n\t// Enable auth if login is provided.\n\tif v.Login != \"\" {\n\t\tmechanism := strings.ToLower(v.AuthMechanism)\n\t\tswitch mechanism {\n\t\tcase \"cram-md5\":\n\t\t\tv.auth = smtp.CRAMMD5Auth(v.Login, v.SenderPassword)\n\t\tcase \"login\":\n\t\t\tv.auth = &loginAuth{[]byte(v.Login), []byte(v.SenderPassword)}\n\t\tcase \"\", \"plain\":\n\t\t\tv.auth = smtp.PlainAuth(\"\", v.Login, v.SenderPassword, v.SMTPAddr)\n\t\tdefault:\n\t\t\treturn errors.New(\"unknown auth_mechanism\")\n\t\t}\n\t}\n\n\t// Optionally resolve paths.\n\tv.ValidationTemplFile, err = validate.ResolveTemplatePath(v.ValidationTemplFile)\n\tif err != nil {\n\t\treturn err\n\t}\n\tv.ResetTemplFile, err = validate.ResolveTemplatePath(v.ResetTemplFile)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Paths to templates could be templates themselves: they may be language-dependent.\n\tvar validationPathTempl, resetPathTempl *textt.Template\n\tvalidationPathTempl, err = textt.New(\"validation\").Parse(v.ValidationTemplFile)\n\tif err != nil {\n\t\treturn err\n\t}\n\tresetPathTempl, err = textt.New(\"reset\").Parse(v.ResetTemplFile)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar path string\n\tif len(v.Languages) > 0 {\n\t\tv.validationTempl = make([]*textt.Template, len(v.Languages))\n\t\tv.resetTempl = make([]*textt.Template, len(v.Languages))\n\t\tvar langTags []i18n.Tag\n\t\t// Find actual content templates for each defined language.\n\t\tfor idx, lang := range v.Languages {\n\t\t\ttag, err := i18n.Parse(lang)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tlangTags = append(langTags, tag)\n\t\t\tif v.validationTempl[idx], path, err = validate.ReadTemplateFile(validationPathTempl, lang); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif err = isTemplateValid(v.validationTempl[idx]); err != nil {\n\t\t\t\treturn fmt.Errorf(\"parsing %s: %w\", path, err)\n\t\t\t}\n\n\t\t\tif v.resetTempl[idx], path, err = validate.ReadTemplateFile(resetPathTempl, lang); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif err = isTemplateValid(v.resetTempl[idx]); err != nil {\n\t\t\t\treturn fmt.Errorf(\"parsing %s: %w\", path, err)\n\t\t\t}\n\t\t}\n\t\tv.langMatcher = i18n.NewMatcher(langTags)\n\t} else {\n\t\tv.validationTempl = make([]*textt.Template, 1)\n\t\tv.resetTempl = make([]*textt.Template, 1)\n\t\t// No i18n support. Use defaults.\n\t\tv.validationTempl[0], path, err = validate.ReadTemplateFile(validationPathTempl, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err = isTemplateValid(v.validationTempl[0]); err != nil {\n\t\t\treturn fmt.Errorf(\"parsing %s: %w\", path, err)\n\t\t}\n\n\t\tv.resetTempl[0], path, err = validate.ReadTemplateFile(resetPathTempl, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err = isTemplateValid(v.resetTempl[0]); err != nil {\n\t\t\treturn fmt.Errorf(\"parsing %s: %w\", path, err)\n\t\t}\n\t}\n\n\tif v.HostUrl, err = validate.ValidateHostURL(v.HostUrl); err != nil {\n\t\treturn err\n\t}\n\n\tif v.SMTPHeloHost == \"\" {\n\t\thostUrl, _ := url.Parse(v.HostUrl)\n\t\tv.SMTPHeloHost = hostUrl.Hostname()\n\t}\n\tif v.SMTPHeloHost == \"\" {\n\t\treturn errors.New(\"missing SMTP host\")\n\t}\n\n\tif v.MaxRetries == 0 {\n\t\tv.MaxRetries = defaultMaxRetries\n\t}\n\tif v.CodeLength == 0 {\n\t\tv.CodeLength = defaultCodeLength\n\t}\n\tv.maxCodeValue = big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(v.CodeLength)), nil)\n\n\tif v.SMTPPort == \"\" {\n\t\tv.SMTPPort = defaultPort\n\t}\n\n\treturn nil\n}\n\n// IsInitialized returns true if the validator is initialized.\nfunc (v *validator) IsInitialized() bool {\n\treturn v.SMTPHeloHost != \"\"\n}\n\n// PreCheck validates the credential and parameters without sending an email.\n// If the credential is valid, it's returned with an appropriate prefix.\nfunc (v *validator) PreCheck(cred string, _ map[string]any) (string, error) {\n\tif len(cred) > maxEmailLength {\n\t\treturn \"\", t.ErrMalformed\n\t}\n\n\t// The email must be plain user@domain.\n\taddr, err := mail.ParseAddress(cred)\n\tif err != nil || addr.Address != cred {\n\t\treturn \"\", t.ErrMalformed\n\t}\n\n\t// Normalize email to make sure Unicode case collisions don't lead to security problems.\n\taddr.Address = strings.ToLower(addr.Address)\n\n\t// If a whitelist of domains is provided, make sure the email belongs to the list.\n\tif len(v.Domains) > 0 {\n\t\t// Parse email into user and domain parts.\n\t\tparts := strings.Split(addr.Address, \"@\")\n\t\tif len(parts) != 2 {\n\t\t\treturn \"\", t.ErrMalformed\n\t\t}\n\n\t\tif !slices.Contains(v.Domains, parts[1]) {\n\t\t\treturn \"\", t.ErrPolicy\n\t\t}\n\t}\n\n\treturn validatorName + \":\" + addr.Address, nil\n}\n\n// Send a request for confirmation to the user: makes a record in DB and nothing else.\nfunc (v *validator) Request(user t.Uid, email, lang, resp string, tmpToken []byte) (bool, error) {\n\t// Email validator cannot accept an immediate response.\n\tif resp != \"\" {\n\t\treturn false, t.ErrFailed\n\t}\n\n\t// Normalize email to make sure Unicode case collisions don't lead to security problems.\n\temail = strings.ToLower(email)\n\n\ttoken := make([]byte, base64.StdEncoding.EncodedLen(len(tmpToken)))\n\tbase64.StdEncoding.Encode(token, tmpToken)\n\n\t// Generate expected response as a random numeric string between 0 and 999999.\n\tcode, err := crand.Int(crand.Reader, v.maxCodeValue)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tresp = strconv.FormatInt(code.Int64(), 10)\n\tresp = strings.Repeat(\"0\", v.CodeLength-len(resp)) + resp\n\n\tvar template *textt.Template\n\tif v.langMatcher != nil {\n\t\t// Find the template for the requested language.\n\t\t// Make sure the language tag is standardized. Matcher is a bit dumber than Parse().\n\t\tnormalized, _ := i18n.Parse(lang)\n\t\t// The matched tag is usually not in the list of available languages (e.g. es_ES -> es-u-rg-eszzzz).\n\t\t// Use index to find the template instead of tag.\n\t\t_, idx := i18n.MatchStrings(v.langMatcher, normalized.String())\n\t\ttemplate = v.validationTempl[idx]\n\t} else {\n\t\ttemplate = v.validationTempl[0]\n\t}\n\n\tcontent, err := validate.ExecuteTemplate(template, templateParts, map[string]any{\n\t\t\"Token\":   url.QueryEscape(string(token)),\n\t\t\"Code\":    resp,\n\t\t\"HostUrl\": v.HostUrl})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\t// Create or update validation record in DB.\n\tisNew, err := store.Users.UpsertCred(&t.Credential{\n\t\tUser:   user.String(),\n\t\tMethod: validatorName,\n\t\tValue:  email,\n\t\tResp:   resp})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\t// Send email without blocking. Email sending may take long time.\n\tgo v.send(email, content)\n\n\treturn isNew, nil\n}\n\n// ResetSecret sends a message with instructions for resetting an authentication secret.\nfunc (v *validator) ResetSecret(email, scheme, lang string, code []byte, params map[string]any) error {\n\t// Normalize email to make sure Unicode case collisions don't lead to security problems.\n\temail = strings.ToLower(email)\n\n\tvar template *textt.Template\n\tif v.langMatcher != nil {\n\t\t_, idx := i18n.MatchStrings(v.langMatcher, lang)\n\t\ttemplate = v.resetTempl[idx]\n\t} else {\n\t\ttemplate = v.resetTempl[0]\n\t}\n\n\tvar login string\n\tif params != nil {\n\t\t// Invariant: params[\"login\"] is a string. Will panic if the invariant doesn't hold.\n\t\tlogin = params[\"login\"].(string)\n\t}\n\n\tcontent, err := validate.ExecuteTemplate(template, templateParts, map[string]any{\n\t\t\"Login\":   login,\n\t\t\"Code\":    string(code),\n\t\t\"Cred\":    email,\n\t\t\"Scheme\":  scheme,\n\t\t\"HostUrl\": v.HostUrl})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Send email without blocking. Email sending may take long time.\n\tgo v.send(email, content)\n\n\treturn nil\n}\n\n// Check checks if the provided validation response matches the expected response.\n// Returns the value of validated credential on success.\nfunc (v *validator) Check(user t.Uid, resp string) (string, error) {\n\tcred, err := store.Users.GetActiveCred(user, validatorName)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif cred == nil {\n\t\t// Request to validate non-existent credential.\n\t\treturn \"\", t.ErrNotFound\n\t}\n\n\tif cred.Retries > v.MaxRetries {\n\t\treturn \"\", t.ErrPolicy\n\t}\n\n\tif resp == \"\" {\n\t\treturn \"\", t.ErrCredentials\n\t}\n\n\t// Comparing with dummy response too.\n\tif cred.Resp == resp || v.DebugResponse == resp {\n\t\t// Valid response, save confirmation.\n\t\treturn cred.Value, store.Users.ConfirmCred(user, validatorName)\n\t}\n\n\t// Invalid response, increment fail counter, ignore possible error.\n\tstore.Users.FailCred(user, validatorName)\n\n\treturn \"\", t.ErrCredentials\n}\n\n// Delete deletes user's records.\nfunc (v *validator) Delete(user t.Uid) error {\n\treturn store.Users.DelCred(user, validatorName, \"\")\n}\n\n// Remove deactivates or removes user's credential.\nfunc (v *validator) Remove(user t.Uid, value string) error {\n\treturn store.Users.DelCred(user, validatorName, value)\n}\n\n// TempAuthScheme returns a temporary authentication method used by this validator.\nfunc (v *validator) TempAuthScheme() (string, error) {\n\treturn \"code\", nil\n}\n\n// SendMail replacement\nfunc (v *validator) sendMail(rcpt []string, msg []byte) error {\n\tclient, err := smtp.Dial(v.SMTPAddr + \":\" + v.SMTPPort)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer client.Close()\n\tif err = client.Hello(v.SMTPHeloHost); err != nil {\n\t\treturn err\n\t}\n\tif istls, _ := client.Extension(\"STARTTLS\"); istls {\n\t\ttlsConfig := &tls.Config{\n\t\t\tInsecureSkipVerify: v.TLSInsecureSkipVerify,\n\t\t\tServerName:         v.SMTPAddr,\n\t\t}\n\t\tif err = client.StartTLS(tlsConfig); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif v.auth != nil {\n\t\tif isauth, _ := client.Extension(\"AUTH\"); isauth {\n\t\t\terr = client.Auth(v.auth)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\tif err = client.Mail(strings.ReplaceAll(strings.ReplaceAll(v.senderEmail, \"\\r\", \" \"), \"\\n\", \" \")); err != nil {\n\t\treturn err\n\t}\n\tfor _, to := range rcpt {\n\t\tif err = client.Rcpt(strings.ReplaceAll(strings.ReplaceAll(to, \"\\r\", \" \"), \"\\n\", \" \")); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tw, err := client.Data()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif _, err = w.Write(msg); err != nil {\n\t\treturn err\n\t}\n\tif err = w.Close(); err != nil {\n\t\treturn err\n\t}\n\treturn client.Quit()\n}\n\n// This is a basic SMTP sender which connects to a server using login/password.\n// -\n// See here how to send email from Amazon SES:\n// https://docs.aws.amazon.com/sdk-for-go/api/service/ses/#example_SES_SendEmail_shared00\n// -\n// Mailjet and SendGrid have some free email limits.\nfunc (v *validator) send(to string, content map[string]string) error {\n\tmessage := &bytes.Buffer{}\n\n\t// Common headers.\n\tfmt.Fprintf(message, \"From: %s\\r\\n\", v.SendFrom)\n\tfmt.Fprintf(message, \"To: %s\\r\\n\", to)\n\tmessage.WriteString(\"Subject: \")\n\t// Old email clients may barf on UTF-8 strings.\n\t// Encode as quoted printable with 75-char strings separated by spaces, split by spaces, reassemble.\n\tmessage.WriteString(strings.Join(strings.Split(mime.QEncoding.Encode(\"utf-8\", content[\"subject\"]), \" \"), \"\\r\\n    \"))\n\tmessage.WriteString(\"\\r\\n\")\n\tmessage.WriteString(\"MIME-version: 1.0;\\r\\n\")\n\n\tif content[\"body_html\"] == \"\" {\n\t\t// Plain text message\n\t\tmessage.WriteString(\"Content-Type: text/plain; charset=\\\"UTF-8\\\"; format=flowed; delsp=yes\\r\\n\")\n\t\tmessage.WriteString(\"Content-Transfer-Encoding: base64\\r\\n\\r\\n\")\n\t\tb64w := base64.NewEncoder(base64.StdEncoding, message)\n\t\tb64w.Write([]byte(content[\"body_plain\"]))\n\t\tb64w.Close()\n\t} else if content[\"body_plain\"] == \"\" {\n\t\t// HTML-formatted message\n\t\tmessage.WriteString(\"Content-Type: text/html; charset=\\\"UTF-8\\\"\\r\\n\")\n\t\tmessage.WriteString(\"Content-Transfer-Encoding: quoted-printable\\r\\n\\r\\n\")\n\t\tqpw := qp.NewWriter(message)\n\t\tqpw.Write([]byte(content[\"body_html\"]))\n\t\tqpw.Close()\n\t} else {\n\t\t// Multipart-alternative message includes both HTML and plain text components.\n\t\tboundary := randomBoundary()\n\t\tmessage.WriteString(\"Content-Type: multipart/alternative; boundary=\\\"\" + boundary + \"\\\"\\r\\n\\r\\n\")\n\n\t\tmessage.WriteString(\"--\" + boundary + \"\\r\\n\")\n\t\tmessage.WriteString(\"Content-Type: text/plain; charset=\\\"UTF-8\\\"; format=flowed; delsp=yes\\r\\n\")\n\t\tmessage.WriteString(\"Content-Transfer-Encoding: base64\\r\\n\\r\\n\")\n\t\tb64w := base64.NewEncoder(base64.StdEncoding, message)\n\t\tb64w.Write([]byte(content[\"body_plain\"]))\n\t\tb64w.Close()\n\n\t\tmessage.WriteString(\"\\r\\n\")\n\n\t\tmessage.WriteString(\"--\" + boundary + \"\\r\\n\")\n\t\tmessage.WriteString(\"Content-Type: text/html; charset=\\\"UTF-8\\\"\\r\\n\")\n\t\tmessage.WriteString(\"Content-Transfer-Encoding: quoted-printable\\r\\n\\r\\n\")\n\t\tqpw := qp.NewWriter(message)\n\t\tqpw.Write([]byte(content[\"body_html\"]))\n\t\tqpw.Close()\n\n\t\tmessage.WriteString(\"\\r\\n--\" + boundary + \"--\")\n\t}\n\n\tmessage.WriteString(\"\\r\\n\")\n\n\terr := v.sendMail([]string{to}, message.Bytes())\n\tif err != nil {\n\t\tlogs.Warn.Println(\"SMTP error\", to, err)\n\t}\n\n\treturn err\n}\n\n// Check if the template contains all required parts.\nfunc isTemplateValid(templ *textt.Template) error {\n\tif templ.Lookup(\"subject\") == nil {\n\t\treturn fmt.Errorf(\"template invalid: '%s' not found\", \"subject\")\n\t}\n\tif templ.Lookup(\"body_plain\") == nil && templ.Lookup(\"body_html\") == nil {\n\t\treturn fmt.Errorf(\"template invalid: neither of '%s', '%s' is found\", \"body_plain\", \"body_html\")\n\t}\n\treturn nil\n}\n\ntype loginAuth struct {\n\tusername, password []byte\n}\n\n// Start begins an authentication with a server. Exported only to satisfy the interface definition.\nfunc (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {\n\treturn \"LOGIN\", []byte(a.username), nil\n}\n\n// Next continues the authentication. Exported only to satisfy the interface definition.\nfunc (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {\n\tif more {\n\t\tswitch strings.ToLower(string(fromServer)) {\n\t\tcase \"username:\":\n\t\t\treturn a.username, nil\n\t\tcase \"password:\":\n\t\t\treturn a.password, nil\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"LOGIN AUTH unknown server response '%s'\", string(fromServer))\n\t\t}\n\t}\n\treturn nil, nil\n}\n\nfunc randomBoundary() string {\n\tvar buf [24]byte\n\trand.Read(buf[:])\n\treturn fmt.Sprintf(\"tinode--%x\", buf[:])\n}\n\nfunc init() {\n\tstore.RegisterValidator(validatorName, &validator{})\n}\n"
  },
  {
    "path": "server/validate/tel/twilio.go",
    "content": "package tel\n\nimport (\n\t\"encoding/json\"\n\n\t\"github.com/twilio/twilio-go\"\n\ttwapi \"github.com/twilio/twilio-go/rest/api/v2010\"\n)\n\ntype twilioConfig struct {\n\tAccountSid string `json:\"account_sid\"`\n\tAuthToken  string `json:\"auth_token\"`\n}\n\nvar twilioClient *twilio.RestClient\n\nfunc twilioInit(jsonconf json.RawMessage) error {\n\tvar conf twilioConfig\n\n\tif err := json.Unmarshal(jsonconf, &conf); err != nil {\n\t\treturn err\n\t}\n\n\ttwilioClient = twilio.NewRestClientWithParams(twilio.ClientParams{\n\t\tUsername: conf.AccountSid,\n\t\tPassword: conf.AuthToken,\n\t})\n\n\treturn nil\n}\n\nfunc twilioSend(from, to, body string) error {\n\t_, err := twilioClient.Api.CreateMessage(&twapi.CreateMessageParams{\n\t\tFrom: &from,\n\t\tTo:   &to,\n\t\tBody: &body,\n\t})\n\treturn err\n}\n"
  },
  {
    "path": "server/validate/tel/validate.go",
    "content": "// Package tel is an incomplete implementation of SMS or voice credential validator.\npackage tel\n\nimport (\n\t\"crypto/rand\"\n\t\"encoding/json\"\n\t\"math/big\"\n\t\"strconv\"\n\t\"strings\"\n\ttextt \"text/template\"\n\n\t\"github.com/nyaruka/phonenumbers\"\n\t\"github.com/tinode/chat/server/logs\"\n\t\"github.com/tinode/chat/server/store\"\n\tt \"github.com/tinode/chat/server/store/types\"\n\t\"github.com/tinode/chat/server/validate\"\n\ti18n \"golang.org/x/text/language\"\n)\n\n// Empty placeholder struct.\ntype validator struct {\n\t// Base URL of the web client to tell clients.\n\tHostUrl string `json:\"host_url\"`\n\t// List of languages supported by templates.\n\tLanguages []string `json:\"languages\"`\n\t// Path to email validation and password reset templates, either a template itself or a literal string.\n\tUniversalTemplFile string `json:\"universal_templ\"`\n\t// Sender address (phone number).\n\tSender string `json:\"sender\"`\n\t// Debug response to accept during testing.\n\tDebugResponse string `json:\"debug_response\"`\n\t// Maximum number of validation retires.\n\tMaxRetries int `json:\"max_retries\"`\n\t// Length of secret numeric code to sent for validation.\n\tCodeLength int `json:\"code_length\"`\n\t// Twilio-specific config.\n\tTwilio json.RawMessage `json:\"twilio_conf\"`\n\n\t// Must use index into language array instead of language tags because language.Matcher is brain damaged:\n\t// https://github.com/golang/go/issues/24211\n\tuniversalTempl []*textt.Template\n\tlangMatcher    i18n.Matcher\n\tmaxCodeValue   *big.Int\n}\n\nconst (\n\tvalidatorName = \"tel\"\n\n\tdefaultMaxRetries = 3\n\n\t// Default code length when one is not provided in the config\n\tdefaultCodeLength = 6\n\n\tdefaultSender = \"Tinode\"\n)\n\nfunc (v *validator) Init(jsonconf string) error {\n\tvar err error\n\n\tif err = json.Unmarshal([]byte(jsonconf), v); err != nil {\n\t\treturn err\n\t}\n\n\tif v.HostUrl, err = validate.ValidateHostURL(v.HostUrl); err != nil {\n\t\treturn err\n\t}\n\n\tvar universalPathTempl *textt.Template\n\tuniversalPathTempl, err = textt.New(\"universal\").Parse(v.UniversalTemplFile)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif len(v.Languages) > 0 {\n\t\tv.universalTempl = make([]*textt.Template, len(v.Languages))\n\t\tvar langTags []i18n.Tag\n\t\t// Find actual content templates for each defined language.\n\t\tfor idx, lang := range v.Languages {\n\t\t\ttag, err := i18n.Parse(lang)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tlangTags = append(langTags, tag)\n\t\t\tif v.universalTempl[idx], _, err = validate.ReadTemplateFile(universalPathTempl, lang); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tv.langMatcher = i18n.NewMatcher(langTags)\n\t} else {\n\t\tv.universalTempl = make([]*textt.Template, 1)\n\t\t// No i18n support. Use defaults.\n\t\tv.universalTempl[0], _, err = validate.ReadTemplateFile(universalPathTempl, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif v.Twilio != nil {\n\t\tif err = twilioInit(v.Twilio); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif v.Sender == \"\" {\n\t\tv.Sender = defaultSender\n\t}\n\tif v.MaxRetries == 0 {\n\t\tv.MaxRetries = defaultMaxRetries\n\t}\n\tif v.CodeLength == 0 {\n\t\tv.CodeLength = defaultCodeLength\n\t}\n\tv.maxCodeValue = big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(v.CodeLength)), nil)\n\n\treturn nil\n}\n\n// IsInitialized returns true if the validator is initialized.\nfunc (v *validator) IsInitialized() bool {\n\treturn v.CodeLength > 0\n}\n\n// PreCheck validates the credential and parameters without sending an SMS or making the call.\n// If credential is valid, it's formatted and prefixed with a tag namespace.\nfunc (*validator) PreCheck(cred string, params map[string]any) (string, error) {\n\t// Parse will try to extract the number from any text, make sure it's just the number.\n\tif !phonenumbers.VALID_PHONE_NUMBER_PATTERN.MatchString(cred) {\n\t\treturn \"\", t.ErrMalformed\n\t}\n\tcountryCode, ok := params[\"countryCode\"].(string)\n\tif !ok {\n\t\tcountryCode = \"US\"\n\t}\n\tnumber, err := phonenumbers.Parse(cred, countryCode)\n\tif err != nil {\n\t\treturn \"\", t.ErrMalformed\n\t}\n\tif !phonenumbers.IsValidNumber(number) {\n\t\treturn \"\", t.ErrMalformed\n\t}\n\tif numType := phonenumbers.GetNumberType(number); numType != phonenumbers.FIXED_LINE_OR_MOBILE &&\n\t\tnumType != phonenumbers.MOBILE {\n\t\treturn \"\", t.ErrMalformed\n\t}\n\treturn validatorName + \":\" + phonenumbers.Format(number, phonenumbers.E164), nil\n}\n\n// Request sends a request for confirmation to the user: makes a record in DB and nothing else.\nfunc (v *validator) Request(user t.Uid, phone, lang, resp string, tmpToken []byte) (bool, error) {\n\t// Phone validator cannot accept an immediate response.\n\tif resp != \"\" {\n\t\treturn false, t.ErrFailed\n\t}\n\n\t// Generate expected response as a random numeric string between 0 and 999999.\n\tcode, err := rand.Int(rand.Reader, v.maxCodeValue)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tresp = strconv.FormatInt(code.Int64(), 10)\n\tresp = strings.Repeat(\"0\", v.CodeLength-len(resp)) + resp\n\n\tvar template *textt.Template\n\tif v.langMatcher != nil {\n\t\t_, idx := i18n.MatchStrings(v.langMatcher, lang)\n\t\ttemplate = v.universalTempl[idx]\n\t} else {\n\t\ttemplate = v.universalTempl[0]\n\t}\n\n\tcontent, err := validate.ExecuteTemplate(template, nil, map[string]any{\n\t\t\"Code\":    resp,\n\t\t\"HostUrl\": v.HostUrl})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\t// Create or update validation record in DB.\n\tisNew, err := store.Users.UpsertCred(&t.Credential{\n\t\tUser:   user.String(),\n\t\tMethod: validatorName,\n\t\tValue:  phone,\n\t\tResp:   resp})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\t// Send SMS without blocking. It sending may take long time.\n\tgo v.send(phone, content[\"\"])\n\n\treturn isNew, nil\n}\n\n// ResetSecret sends a message with instructions for resetting an authentication secret.\nfunc (v *validator) ResetSecret(phone, scheme, lang string, code []byte, params map[string]any) error {\n\tvar template *textt.Template\n\tif v.langMatcher != nil {\n\t\t_, idx := i18n.MatchStrings(v.langMatcher, lang)\n\t\ttemplate = v.universalTempl[idx]\n\t} else {\n\t\ttemplate = v.universalTempl[0]\n\t}\n\n\tcontent, err := validate.ExecuteTemplate(template, nil, map[string]any{\n\t\t\"Code\":    string(code),\n\t\t\"HostUrl\": v.HostUrl})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Send SMS without blocking. Sending may take long time.\n\tgo v.send(phone, content[\"\"])\n\n\treturn nil\n}\n\n// Check checks validity of user's response.\nfunc (v *validator) Check(user t.Uid, resp string) (string, error) {\n\tcred, err := store.Users.GetActiveCred(user, validatorName)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif cred == nil {\n\t\t// Blank credential.\n\t\treturn \"\", t.ErrNotFound\n\t}\n\n\tif cred.Retries > v.MaxRetries {\n\t\treturn \"\", t.ErrPolicy\n\t}\n\n\tif resp == \"\" {\n\t\treturn \"\", t.ErrCredentials\n\t}\n\n\t// Comparing with dummy response too.\n\tif cred.Resp == resp || v.DebugResponse == resp {\n\t\t// Valid response, save confirmation.\n\t\treturn cred.Value, store.Users.ConfirmCred(user, validatorName)\n\t}\n\n\t// Invalid response, increment fail counter, ignore possible error.\n\tstore.Users.FailCred(user, validatorName)\n\n\treturn \"\", t.ErrCredentials\n}\n\n// Delete deletes user's records. Returns deleted credentials.\nfunc (*validator) Delete(user t.Uid) error {\n\treturn store.Users.DelCred(user, validatorName, \"\")\n}\n\n// Remove or disable the given record.\nfunc (*validator) Remove(user t.Uid, value string) error {\n\treturn store.Users.DelCred(user, validatorName, value)\n}\n\n// TempAuthScheme returns a temporary authentication method used by this validator.\nfunc (v *validator) TempAuthScheme() (string, error) {\n\treturn \"code\", nil\n}\n\n// Implement sending the SMS.\nfunc (v *validator) send(to, body string) error {\n\tif v.Twilio != nil {\n\t\tif err := twilioSend(v.Sender, to, body); err != nil {\n\t\t\tlogs.Warn.Println(\"Twilio SMS error\", to, err)\n\t\t}\n\t} else {\n\t\tlogs.Info.Println(\"Send SMS, To:\", to, \"\\nText:\", body)\n\t}\n\treturn nil\n}\n\nfunc init() {\n\tstore.RegisterValidator(validatorName, &validator{})\n}\n"
  },
  {
    "path": "server/validate/validator.go",
    "content": "// Package validate defines an interface which must be implmented by credential validators.\npackage validate\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"text/template\"\n\n\tt \"github.com/tinode/chat/server/store/types\"\n)\n\n// Validator handles validation of user's credentials, like email or phone.\ntype Validator interface {\n\t// Init initializes the validator.\n\tInit(jsonconf string) error\n\n\t// IsInitialized returns true if the validator is initialized.\n\tIsInitialized() bool\n\n\t// PreCheck pre-validates the credential without sending an actual request for validation:\n\t// check uniqueness (if appropriate), format, etc\n\t// Returns normalized credential prefixed with an appropriate namespace prefix.\n\tPreCheck(cred string, params map[string]any) (string, error)\n\n\t// Request sends a request for validation to the user. Returns true if it's a new credential,\n\t// false if it re-sent request for an existing unconfirmed credential.\n\t//   user: UID of the user making the request.\n\t//   cred: credential being validated, such as email or phone.\n\t//   lang: user's human language as repored in the session.\n\t//   resp: optional response if user already has it (i.e. captcha/recaptcha).\n\t//   tmpToken: temporary authentication token to include in the request.\n\tRequest(user t.Uid, cred, lang, resp string, tmpToken []byte) (bool, error)\n\n\t// ResetSecret sends a message with instructions for resetting an authentication secret.\n\t//   cred: address to use for the message.\n\t//   scheme: authentication scheme being reset.\n\t//   lang: human language as reported in the session.\n\t//   tmpToken: temporary authentication token\n\t//   params: authentication params.\n\tResetSecret(cred, scheme, lang string, tmpToken []byte, params map[string]any) error\n\n\t// Check checks validity of user's response.\n\t// Returns the value of validated credential on success.\n\tCheck(user t.Uid, resp string) (string, error)\n\n\t// Remove deletes or deactivates user's given value.\n\tRemove(user t.Uid, value string) error\n\n\t// Delete deletes user's record.\n\tDelete(user t.Uid) error\n\n\t// TempAuthScheme returns a temporary authentication method used by this validator.\n\t// It should be either \"code\" or \"token\".\n\tTempAuthScheme() (string, error)\n}\n\nfunc ValidateHostURL(origUrl string) (string, error) {\n\thostUrl, err := url.Parse(origUrl)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !hostUrl.IsAbs() {\n\t\treturn \"\", errors.New(\"host_url must be absolute\")\n\t}\n\tif hostUrl.Hostname() == \"\" {\n\t\treturn \"\", errors.New(\"invalid host_url\")\n\t}\n\tif hostUrl.Fragment != \"\" {\n\t\treturn \"\", errors.New(\"fragment is not allowed in host_url\")\n\t}\n\tif hostUrl.Path == \"\" {\n\t\thostUrl.Path = \"/\"\n\t}\n\treturn hostUrl.String(), nil\n}\n\nfunc ExecuteTemplate(template *template.Template, parts []string, params map[string]any) (map[string]string, error) {\n\tcontent := map[string]string{}\n\tbuffer := new(bytes.Buffer)\n\n\tif parts == nil {\n\t\tif err := template.Execute(buffer, params); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcontent[\"\"] = buffer.String()\n\t} else {\n\t\tfor _, part := range parts {\n\t\t\tbuffer.Reset()\n\t\t\tif templBody := template.Lookup(part); templBody != nil {\n\t\t\t\tif err := templBody.Execute(buffer, params); err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t}\n\t\t\tcontent[part] = buffer.String()\n\t\t}\n\t}\n\n\treturn content, nil\n}\n\nfunc ResolveTemplatePath(path string) (string, error) {\n\tif filepath.IsAbs(path) {\n\t\treturn path, nil\n\t}\n\n\tcurwd, err := os.Getwd()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn filepath.Clean(filepath.Join(curwd, path)), nil\n}\n\nfunc ReadTemplateFile(pathTempl *template.Template, lang string) (*template.Template, string, error) {\n\tbuffer := bytes.Buffer{}\n\terr := pathTempl.Execute(&buffer, map[string]any{\"Language\": lang})\n\tpath := buffer.String()\n\tif err != nil {\n\t\treturn nil, path, fmt.Errorf(\"reading %s: %w\", path, err)\n\t}\n\n\ttempl, err := template.ParseFiles(path)\n\treturn templ, path, err\n}\n"
  },
  {
    "path": "tinode-db/README.md",
    "content": "# Utility to initialize or upgrade `tinode` DB\n\nThis utility initializes the `tinode` database (or upgrades an existing DB from an earlier version) and optionally loads it with data. To force database reset use command line option `--reset=true`.\n\n## Build the package:\n\n - **RethinkDB**\n  `go build -tags rethinkdb` or `go build -i -tags rethinkdb` to automatically install missing dependencies.\n\n - **MySQL**\n  `go build -tags mysql` or `go build -i -tags mysql` to automatically install missing dependencies.\n\n - **MongoDB**\n  `go build -tags mongodb` or `go build -i -tags mongodb` to automatically install missing dependencies.\n\n - **PostgreSQL**\n  `go build -tags postgres` or `go build -i -tags postgres` to automatically install missing dependencies.\n\n\n## Run\n\nRun from the command line.\n\n`tinode-db [parameters]`\n\nCommand line parameters:\n - `--reset`: delete the database then re-create it in a blank state; it has no effect if the database does not exist.\n - `--upgrade`: upgrade database from an earlier version retaining all the data; make sure to backup the DB before upgrading.\n - `--no_init`: check that database exists but don't create it if missing.\n - `--data=FILENAME`: fill `tinode` database with data from the provided file. See [data.json](data.json).\n - `--config=FILENAME`: load configuration from FILENAME. Example config is included as [tinode.conf](tinode.conf).\n - `--make_root=USER_ID`: promote an existing user to root user, `USER_ID` of the form `usrAbCDef123`.\n - `--add_root=USERNAME[:PASSWORD]`: create a new user account and make it root; if password is missing, a strong password will be generated.\n\nConfiguration file options:\n - `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.\n - `store_config.adapters.mysql` and `store_config.adapters.rethinkdb` are database-specific sections:\n  - `database` is the name of the database to generate.\n  - `addresses` is RethinkDB/MongoDB's host and port number to connect to. An array of hosts can be provided as well `[\"host1\", \"host2\"]`.\n  - `dsn` is MySQL's Data Source Name.\n  - `replica_set` is MongoDB's Replicaset name.\n\nThe `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.\n\nThe default `data.json` file creates six users with user names `alice`, `bob`, `carol`, `dave`, `frank`, and `tino` (chat bot user). Passwords are the same as the user names with 123 appended, e.g. user `alice` gets password `alice123`; `tino` gets a randomly generated password. It also creates three group topics, and multiple peer to peer topics. Users are subscribed to topics and to each other. All topics are randomly filled with messages.\n\nAvatar photos curtesy of https://www.pexels.com/ under [CC0 license](https://www.pexels.com/photo-license/).\n\n## Links:\n\n* [RethinkDB schema](https://github.com/tinode/chat/tree/master/server/db/rethinkdb/schema.md)\n* [MySQL schema](https://github.com/tinode/chat/tree/master/server/db/mysql/schema.sql)\n* [MongoDB schema](https://github.com/tinode/chat/tree/master/server/db/mongodb/schema.md)\n* [PostgreSQL schema](https://github.com/tinode/chat/tree/master/server/db/postgres/schema.sql)\n"
  },
  {
    "path": "tinode-db/credentials.sh",
    "content": "#!/bin/bash\n\n# Credential extractor. Tino the Chatbot is created with a random password. The password is written\n# to stdout by tinode-db. The script converts it to chatbot's authentication cookie.\n\n# The script takes a string like 'usr;tino;usrImlot_X9vAc;cOuTvzVa' (ignored;login;user_id;password)\n# and formats it into a json chatbot's authentication cookie like\n# '{\"schema\": \"basic\", \"secret\": \"username:password\", \"user\": \"user_id\"}'.\n\nCOOKIE_FILE=$@\n\nwhile read line; do\n  IFS=';' read -r -a parts <<< \"$line\"\n  if [ ${#parts[@]} -eq 0 ] ; then\n    continue\n  fi\n\n  # If the name of the cookie file is given, write to file\n  # Otherwise write to stdout\n  if [ \"$COOKIE_FILE\" ]; then\n    exec 3>\"$COOKIE_FILE\"\n  else\n    exec 3>&1\n  fi\n\n  echo \"{\\\"schema\\\": \\\"basic\\\", \\\"secret\\\": \\\"${parts[1]}:${parts[3]}\\\", \\\"user\\\": \\\"${parts[2]}\\\"}\" 1>&3\n  break\ndone < /dev/stdin\n"
  },
  {
    "path": "tinode-db/data.json",
    "content": "{\n\"users\": [\n  {\n   \"createdAt\": \"-140h\",\n   \"email\": \"alice@example.com\",\n   \"tel\": \"+17025550001\",\n   \"passhash\": \"alice123\",\n   \"private\": {\"comment\": \"some comment 123\"},\n   \"public\": {\"fn\": \"Alice Hatter\", \"photo\": \"alice-128.jpg\", \"type\": \"jpg\"},\n   \"trusted\": {\"verified\": true},\n   \"tags\": [\"Alice\"],\n   \"state\": \"ok\",\n   \"status\": {\n    \"text\": \"DND\"\n   },\n   \"username\": \"alice\",\n   \"addressBook\": [\"email:bob@example.com\", \"email:carol@example.com\", \"email:dave@example.com\",\n      \"email:eve@example.com\",\"email:frank@example.com\",\"email:george@example.com\",\"email:tino@example.com\",\n      \"tel:+17025550001\", \"tel:+17025550002\", \"tel:+17025550003\", \"tel:+17025550004\", \"tel:+17025550005\",\n      \"tel:+17025550006\", \"tel:+17025550007\", \"tel:+17025550008\", \"tel:+17025550009\"]\n  },\n  {\n   \"createdAt\": \"-139h\",\n   \"email\": \"bob@example.com\",\n   \"tel\": \"+17025550002\",\n   \"passhash\": \"bob123\",\n   \"private\": {\"comment\": \"no comments :)\"},\n   \"public\": {\"fn\": \"Bob Smith\", \"photo\": \"bob-128.jpg\", \"type\": \"jpg\"},\n   \"state\": \"ok\",\n   \"status\": \"stuff\",\n   \"username\": \"bob\",\n   \"addressBook\": [\"email:alice@example.com\", \"email:carol@example.com\", \"email:dave@example.com\",\n      \"email:eve@example.com\", \"email:frank@example.com\", \"email:george@example.com\", \"email:tino@example.com\",\n      \"tel:+17025550001\", \"tel:+17025550002\", \"tel:+17025550003\", \"tel:+17025550004\", \"tel:+17025550005\",\n      \"tel:+17025550006\", \"tel:+17025550007\", \"tel:+17025550008\", \"tel:+17025550009\"]\n  },\n  {\n   \"createdAt\": \"-138h\",\n   \"email\": \"carol@example.com\",\n   \"tel\": \"+17025550003\",\n   \"passhash\": \"carol123\",\n   \"private\": {\"comment\": \"more stuff\"},\n   \"public\": {\"fn\": \"Carol Xmas\", \"photo\": \"carol-128.jpg\", \"type\": \"jpg\"},\n   \"state\": \"\",\n   \"status\": \"ho ho ho\",\n   \"username\": \"carol\",\n   \"addressBook\": [\"email:alice@example.com\", \"email:bob@example.com\", \"email:dave@example.com\",\n      \"email:eve@example.com\", \"email:frank@example.com\", \"email:george@example.com\", \"email:tino@example.com\",\n      \"tel:+17025550001\", \"tel:+17025550002\", \"tel:+17025550003\", \"tel:+17025550004\", \"tel:+17025550005\",\n      \"tel:+17025550006\", \"tel:+17025550007\", \"tel:+17025550008\", \"tel:+17025550009\"]\n  },\n  {\n   \"createdAt\": \"-137h\",\n   \"email\": \"dave@example.com\",\n   \"tel\": \"+17025550004\",\n   \"passhash\": \"dave123\",\n   \"private\": {\"comment\": \"stuff 123\"},\n   \"public\": {\"fn\": \"Dave Goliathsson\", \"photo\": \"dave-128.jpg\", \"type\": \"jpg\"},\n   \"state\": \"ok\",\n   \"status\": \"hiding!\",\n   \"username\": \"dave\",\n   \"addressBook\": [\"email:alice@example.com\", \"email:bob@example.com\", \"email:carol@example.com\",\n      \"email:eve@example.com\", \"email:frank@example.com\", \"email:george@example.com\", \"email:tino@example.com\",\n      \"tel:+17025550001\", \"tel:+17025550002\", \"tel:+17025550003\", \"tel:+17025550004\", \"tel:+17025550005\",\n      \"tel:+17025550006\", \"tel:+17025550007\", \"tel:+17025550008\", \"tel:+17025550009\"]\n  },\n  {\n   \"createdAt\": \"-136h\",\n   \"email\": \"eve@example.com\",\n   \"tel\": \"+17025550005\",\n   \"passhash\": \"eve123\",\n   \"private\": {\"comment\": \"apples?\"},\n   \"public\": {\"fn\": \"Eve Adams\", \"photo\": \"eve-128.jpg\", \"type\": \"jpg\"},\n   \"state\": \"ok\",\n   \"username\": \"eve\",\n   \"addressBook\": [\"email:alice@example.com\", \"email:bob@example.com\", \"email:carol@example.com\",\n      \"email:dave@example.com\", \"email:george@example.com\", \"email:tino@example.com\",\n      \"tel:+17025550001\", \"tel:+17025550002\", \"tel:+17025550003\", \"tel:+17025550004\", \"tel:+17025550005\",\n      \"tel:+17025550006\", \"tel:+17025550007\", \"tel:+17025550008\", \"tel:+17025550009\"]\n  },\n  {\n   \"createdAt\": \"-135h\",\n   \"email\": \"frank@example.com\",\n   \"tel\": \"+17025550006\",\n   \"passhash\": \"frank123\",\n   \"private\": {\"comment\": \"things, not stuff\"},\n   \"public\": {\"fn\": \"Frank Singer\"},\n   \"state\": \"ok\",\n   \"status\": \"singing!\",\n   \"username\": \"frank\",\n   \"addressBook\": [\"email:bob@example.com\", \"email:carol@example.com\", \"email:dave@example.com\",\n      \"email:eve@example.com\", \"email:george@example.com\", \"email:tino@example.com\",\n      \"tel:+17025550001\", \"tel:+17025550002\", \"tel:+17025550003\", \"tel:+17025550004\", \"tel:+17025550005\",\n      \"tel:+17025550007\", \"tel:+17025550008\", \"tel:+17025550009\"]\n  },\n  {\n   \"createdAt\": \"-134h\",\n   \"email\": \"tino@example.com\",\n   \"tel\": \"+17025550010\",\n   \"passhash\": \"(random)\",\n   \"private\": {\"comment\": \"I'm a chatbot short and stout\"},\n   \"trusted\": {\"staff\": true},\n   \"public\": {\"fn\": \"Tino the Chatbot\", \"photo\": \"tino-128.jpg\", \"type\": \"jpg\"},\n   \"state\": \"\",\n   \"status\": \"Chatting nonsensically\",\n   \"username\": \"tino\",\n   \"addressBook\": []\n  },\n  {\n   \"createdAt\": \"-133h\",\n   \"email\": \"xena@example.com\",\n   \"tel\": \"+17025550099\",\n   \"passhash\": \"xena123\",\n   \"authLevel\": \"root\",\n   \"private\": {\"comment\": \"No one knowns that Xena exists\"},\n   \"public\": {\"fn\": \"Xena Pacifist Peasant\", \"photo\": \"xena-128.jpg\", \"type\": \"jpg\"},\n   \"trusted\": {\"staff\": true},\n   \"state\": \"\",\n   \"status\": \"Just a simple peaceful agriculture specialist\",\n   \"username\": \"xena\",\n   \"addressBook\": []\n  }\n ],\n\n \"grouptopics\": [\n  {\n   \"createdAt\": \"-128h\",\n   \"name\": \"*ABC\",\n   \"owner\": \"carol\",\n   \"tags\": [\"flower\", \"flowers\", \"flora\"],\n   \"public\": {\"fn\": \"Let's talk about flowers\", \"photo\": \"abc-128.jpg\", \"type\": \"jpg\"}\n  },\n  {\n   \"createdAt\": \"-126h\",\n   \"name\": \"*ABCDEF\",\n   \"owner\": \"alice\",\n   \"tags\": [\"travel\"],\n   \"public\": {\"fn\": \"Travel, travel, travel\", \"photo\": \"abcdef-128.jpg\", \"type\": \"jpg\"}\n  },\n  {\n   \"createdAt\": \"-124h\",\n   \"name\": \"*BF\",\n   \"owner\": \"frank\",\n   \"tags\": [\"sikrit\", \"secret\"],\n   \"public\": {\"fn\": \"Sikrit group!\", \"photo\": \"bf-128.jpg\", \"type\": \"jpg\"}\n  },\n  {\n   \"createdAt\": \"-123h\",\n   \"name\": \"*X\",\n   \"owner\": \"xena\",\n   \"tags\": [\"support\",\"public\"],\n   \"public\": {\"fn\": \"Support\", \"photo\": \"support-128.jpg\", \"type\": \"jpg\"},\n   \"trusted\": {\"staff\": true},\n   \"access\": {\"auth\": \"JRWP\", \"anon\": \"JW\"}\n  },\n  {\n   \"createdAt\": \"-122h\",\n   \"name\": \"*BACDF\",\n   \"owner\": \"bob\",\n   \"channel\": true,\n   \"tags\": [\"coffee\",\"channel\"],\n   \"public\": {\"fn\": \"Coffee Channel\", \"photo\": \"chan-128.jpg\", \"type\": \"jpg\"},\n   \"trusted\": {\"verified\": true},\n   \"access\": {\"auth\": \"RWPD\", \"anon\": \"N\"}\n  }\n ],\n \"p2psubs\": [\n  {\n   \"createdAt\": \"-120h\",\n   \"users\": [{\"name\": \"alice\"}, {\"name\": \"bob\", \"private\": {\"comment\": \"Alice Jo\"}}]\n  },\n  {\n   \"createdAt\": \"-119h\",\n   \"users\": [{\"name\": \"alice\"}, {\"name\": \"carol\", \"private\": {\"comment\": \"Alice Joha\"}}]\n  },\n  {\n   \"createdAt\": \"-118h\",\n   \"users\": [{\"name\": \"alice\"}, {\"name\": \"dave\", \"private\": {\"comment\": \"Alice not in Wunderland\"}}]\n  },\n  {\n   \"createdAt\": \"-117h\",\n   \"private\": {\"comment\": \"apples to oranges\"},\n   \"users\": [{\"name\": \"alice\", \"private\": {\"comment\": \"Apples\"}},\n             {\"name\": \"eve\", \"private\": {\"comment\": \"Alice is not what she seems\"}}]\n  },\n  {\n   \"createdAt\": \"-116h\",\n   \"users\": [{\"name\": \"alice\", \"private\": {\"comment\": \"Frank Frank Frank a-\\u003ef\"}},\n             {\"name\": \"frank\", \"private\": {\"comment\": \"Johnson f-\\u003ea\"}}]\n  },\n  {\n   \"createdAt\": \"-115.5h\",\n   \"users\": [{\"name\": \"alice\", \"private\": {\"comment\": \"Python chatbot, short and stout\"}}, {\"name\": \"tino\"}]\n  },\n  {\n   \"createdAt\": \"-114h\",\n   \"users\": [{\"name\": \"bob\", \"private\": {\"comment\": \"I'm banned by Dave!\"}, \"have\": \"A\"},\n             {\"name\": \"dave\", \"private\": {\"comment\": \"I banned Bob.\"}, \"want\": \"N\"}]\n  },\n  {\n   \"createdAt\": \"-113.5h\",\n   \"users\": [{\"name\": \"bob\", \"private\": {\"comment\": \"Eve nee Ng\"}}, {\"name\": \"eve\"}]\n  },\n  {\n   \"createdAt\": \"-113.45h\",\n   \"users\": [{\"name\": \"bob\", \"private\": {\"comment\": \"Python chatbot, short and stout\"}}, {\"name\": \"tino\"}]\n  },\n  {\n   \"createdAt\": \"-113.3h\",\n   \"users\": [{\"name\": \"carol\", \"private\": {\"comment\": \"Python chatbot, short and stout\"}}, {\"name\": \"tino\"}]\n  },\n  {\n   \"createdAt\": \"-112.7h\",\n   \"private\": {\"comment\": \"Python chatbot short and stout\"},\n   \"users\": [{\"name\": \"dave\", \"private\": {\"comment\": \"Python chatbot, short and stout\"}}, {\"name\": \"tino\"}]\n  },\n  {\n   \"createdAt\": \"-112.3h\",\n   \"users\": [{\"name\": \"eve\", \"private\": {\"comment\": \"Python chatbot, short and stout\"}}, {\"name\": \"tino\"}]\n  },\n  {\n   \"createdAt\": \"-112.1h\",\n   \"users\": [{\"name\": \"frank\", \"private\": {\"comment\": \"Python chatbot, short and stout\"}}, {\"name\": \"tino\"}]\n  }\n ],\n \"groupsubs\": [{\n   \"createdAt\": \"-112h\",\n   \"private\": {\"comment\": \"My super cool group topic\"},\n   \"topic\": \"*ABC\",\n   \"user\": \"alice\"\n  },\n  {\n   \"createdAt\": \"-111.9h\",\n   \"private\": {\"comment\": \"Wow\"},\n   \"topic\": \"*ABC\",\n   \"user\": \"bob\"\n  },\n  {\n   \"createdAt\": \"-111.8h\",\n   \"private\": {\"comment\": \"Custom group description by Bob\"},\n   \"topic\": \"*ABCDEF\",\n   \"user\": \"bob\"\n  },\n  {\n   \"createdAt\": \"-111.7h\",\n   \"private\": {\"comment\": \"Kirgudu\"},\n   \"topic\": \"*ABCDEF\",\n   \"user\": \"carol\"\n  },\n  {\n   \"createdAt\": \"-111.6h\",\n   \"topic\": \"*ABCDEF\",\n   \"user\": \"dave\"\n  },\n  {\n   \"createdAt\": \"-111.5h\",\n   \"topic\": \"*ABCDEF\",\n   \"user\": \"eve\"\n  },\n  {\n   \"createdAt\": \"-111.4h\",\n   \"topic\": \"*ABCDEF\",\n   \"user\": \"frank\"\n  },\n  {\n   \"createdAt\": \"-111.3h\",\n   \"private\": {\"comment\": \"I'm not the owner, Frank is\"},\n   \"topic\": \"*BF\",\n   \"user\": \"bob\"\n  },\n  {\n   \"createdAt\": \"-111.2h\",\n   \"topic\": \"*BACDF\",\n   \"user\": \"alice\"\n  },\n  {\n   \"createdAt\": \"-111.1h\",\n   \"topic\": \"*BACDF\",\n   \"asChan\": true,\n   \"user\": \"carol\"\n  },\n  {\n   \"createdAt\": \"-111.0h\",\n   \"topic\": \"*BACDF\",\n   \"asChan\": true,\n   \"user\": \"dave\"\n  },\n  {\n   \"createdAt\": \"-110.8h\",\n   \"topic\": \"*BACDF\",\n   \"asChan\": true,\n   \"user\": \"frank\"\n  }\n ],\n \"messages\": [\n  \"Caution: Do not view laser light with remaining eye.\",\n  \"Caution: breathing may be hazardous to your health.\",\n  \"Celebrate Hannibal Day this year. Take an elephant to lunch.\",\n  \"Celibacy is not hereditary.\",\n  \"Center 1127 -- It's not just a job, it's an adventure!\",\n  \"Center meeting at 4pm in 2C-543\",\n  \"Centran manuals are available in 2B-515.\",\n  \"Charlie don't surf.\",\n  \"Children are hereditary: if your parents didn't have any, neither will you.\",\n  \"Clothes make the man. Naked people have little or no influence on society.\",\n  \"Club sandwiches, not baby seals.\",\n  \"Cocaine is nature's way of saying you make too much money.\",\n  \"Cogito Ergo Spud.\",\n  \"Cogito cogito ergo cogito sum.\",\n  \"Colorless green ideas sleep furiously.\",\n  \"Communication is only possible between equals.\",\n  \"Computers are not intelligent.  They only think they are.\",\n  \"Consistency is always easier to defend than correctness.\",\n  \"Constants aren't.  Variables don't.  LISP does.  Functions won't.  Bytes do.\",\n  \"Contains no kung fu, car chases or decapitations.\",\n  \"Continental Life.  Why do you ask?\",\n  \"Convictions cause convicts -- what you believe imprisons you.\",\n  \"Core Error - Bus Dumped\",\n  \"Could not open 2147478952 framebuffers.\",\n  \"Courage is something you can never totally own or totally lose.\",\n  \"Cowards die many times before their deaths;/The valiant never taste of death but once.\",\n  \"Crazee Edeee, his prices are INSANE!!!\",\n  \"Creativity is no substitute for knowing what you are doing.\",\n  \"Creditors have much better memories than debtors.\",\n  \"Critics are like eunuchs in a harem: they know how it's done, they've seen it done\",\n  \"every day, but they're unable to do it themselves.  -Brendan Behan\",\n  \"Cthulhu Saves!  ...  in case He's hungry later.\",\n  \"Dames is like streetcars -- The oceans is full of 'em.  -Archie Bunker\",\n  \"Dames lie about anything - just for practice.  -Raymond Chandler\",\n  \"Damn it, i gotta get outta here!\",\n  \"Dangerous knowledge is a little thing.\",\n  \"It is certain\",\n  \"It is decidedly so\",\n  \"Without a doubt\",\n  \"Yes definitely\",\n  \"You may rely on it\",\n  \"As I see it yes\",\n  \"Most likely\",\n  \"Outlook good\",\n  \"Yes\",\n  \"No\",\n  \"No! No, no, no! No!!\",\n  \"Signs point to yes\",\n  \"Reply hazy try again\",\n  \"Ask again later\",\n  \"Better not tell you now\",\n  \"Cannot predict now\",\n  \"Concentrate and ask again\",\n  \"Don't count on it\",\n  \"My reply is no\",\n  \"My sources say no.\",\n  \"Outlook not so good\",\n  \"Very doubtful...\",\n  \"All your base are belong to us.\"\n ],\n\"forms\": [\n  {\n    \"txt\": \"Post responseYesДа\",\n    \"fmt\": [\n      {\"at\": 0, \"len\": 18, \"tp\": \"FM\"},\n      {\"at\": 0, \"len\": 13, \"tp\": \"ST\"},\n      {\"at\": 13, \"len\": 3, \"key\": 0},\n      {\"at\": 16, \"len\": 2, \"key\": 1}\n    ],\n    \"ent\": [\n      {\"tp\": \"BN\", \"data\": {\"name\": \"yes\", \"act\": \"pub\"}},\n      {\"tp\": \"BN\", \"data\": {\"name\": \"duh\", \"val\": \"42\", \"act\": \"pub\"}}\n    ]\n  },\n  {\n    \"txt\": \"Go to URL:Yes OK\",\n    \"fmt\": [\n      {\"at\": 0, \"len\": 16, \"tp\": \"FM\"},\n      {\"at\": 0, \"len\": 10, \"tp\": \"ST\"},\n      {\"at\": 10, \"len\": 6, \"tp\": \"RW\"},\n      {\"at\": 10, \"len\": 3, \"key\": 0},\n      {\"at\": 14, \"len\": 2, \"key\": 1}\n    ],\n    \"ent\": [\n      {\"tp\": \"BN\", \"data\": {\"name\": \"ok\", \"act\": \"url\", \"ref\": \"https://github.com/tinode/chat/?key=val\"}},\n      {\"tp\": \"BN\", \"data\": {\"name\": \"oops\", \"val\": \"test\", \"act\": \"url\", \"ref\": \"https://github.com/tinode/chat\"}}\n    ]\n  }\n]\n}\n"
  },
  {
    "path": "tinode-db/gendb.go",
    "content": "package main\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"math/rand\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/auth\"\n\t_ \"github.com/tinode/chat/server/auth/basic\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/types\"\n)\n\nfunc genDb(data *Data, p2pDel bool) {\n\tvar err error\n\tvar botAccount string\n\n\tif len(data.Users) == 0 {\n\t\tlog.Println(\"No data provided, stopping\")\n\n\t\treturn\n\t}\n\n\t// Add authentication record\n\tauthHandler := store.Store.GetAuthHandler(\"basic\")\n\tauthHandler.Init([]byte(`{\"add_to_tags\": true}`), \"basic\")\n\n\tnameIndex := make(map[string]string, len(data.Users))\n\n\tlog.Println(\"Generating users...\")\n\n\tfor _, uu := range data.Users {\n\t\tstate, err := types.NewObjState(uu.State)\n\t\tif err != nil {\n\t\t\tlog.Fatal(err)\n\t\t}\n\t\tuser := types.User{\n\t\t\tState: state,\n\t\t\tAccess: types.DefaultAccess{\n\t\t\t\tAuth: types.ModeCAuth,\n\t\t\t\tAnon: types.ModeNone,\n\t\t\t},\n\t\t\tTags:   uu.Tags,\n\t\t\tPublic: parsePublic(&uu.Public, data.datapath),\n\t\t}\n\t\tif !uu.Trusted.IsZero() {\n\t\t\tuser.Trusted = uu.Trusted\n\t\t}\n\t\tuser.CreatedAt = getCreatedTime(uu.CreatedAt)\n\n\t\tuser.Tags = append(user.Tags, \"basic:\"+uu.Username)\n\t\tif uu.Email != \"\" {\n\t\t\tuser.Tags = append(user.Tags, \"email:\"+uu.Email)\n\t\t}\n\t\tif uu.Tel != \"\" {\n\t\t\tuser.Tags = append(user.Tags, \"tel:\"+uu.Tel)\n\t\t}\n\n\t\t// store.Users.Create will subscribe user to !me topic but won't create a !me topic\n\t\tif _, err := store.Users.Create(&user, uu.Private); err != nil {\n\t\t\tlog.Fatal(err)\n\t\t}\n\n\t\t// Save credentials: email and phone number as if they were confirmed.\n\t\tif uu.Email != \"\" {\n\t\t\tif _, err := store.Users.UpsertCred(&types.Credential{\n\t\t\t\tUser:   user.Id,\n\t\t\t\tMethod: \"email\",\n\t\t\t\tValue:  uu.Email,\n\t\t\t\tDone:   true,\n\t\t\t}); err != nil {\n\t\t\t\tlog.Fatal(err)\n\t\t\t}\n\t\t}\n\t\tif uu.Tel != \"\" {\n\t\t\tif _, err := store.Users.UpsertCred(&types.Credential{\n\t\t\t\tUser:   user.Id,\n\t\t\t\tMethod: \"tel\",\n\t\t\t\tValue:  uu.Tel,\n\t\t\t\tDone:   true,\n\t\t\t}); err != nil {\n\t\t\t\tlog.Fatal(err)\n\t\t\t}\n\t\t}\n\n\t\tauthLevel := auth.LevelAuth\n\t\tif uu.AuthLevel != \"\" {\n\t\t\tauthLevel = auth.ParseAuthLevel(uu.AuthLevel)\n\t\t\tif authLevel == auth.LevelNone {\n\t\t\t\tlog.Fatal(\"Unknown authLevel\", uu.AuthLevel)\n\t\t\t}\n\t\t}\n\t\t// Add authentication record\n\t\tauthHandler := store.Store.GetAuthHandler(\"basic\")\n\t\tpasswd := uu.Password\n\t\tif passwd == \"(random)\" {\n\t\t\t// Generate random password\n\t\t\tpasswd = getPassword(8)\n\t\t\tbotAccount = uu.Username\n\t\t}\n\t\tif _, err := authHandler.AddRecord(&auth.Rec{Uid: user.Uid(), AuthLevel: authLevel},\n\t\t\t[]byte(uu.Username+\":\"+passwd), \"\"); err != nil {\n\t\t\tlog.Fatal(err)\n\t\t}\n\t\tnameIndex[uu.Username] = user.Id\n\n\t\t// Add address book as fnd.private\n\t\tif len(uu.AddressBook) > 0 {\n\t\t\tif err := store.Subs.Update(user.Uid().FndName(), user.Uid(),\n\t\t\t\tmap[string]any{\"Private\": strings.Join(uu.AddressBook, \",\")}); err != nil {\n\t\t\t\tlog.Fatal(err)\n\t\t\t}\n\t\t}\n\n\t\tfmt.Println(\"usr;\" + uu.Username + \";\" + user.Uid().UserId() + \";\" + passwd)\n\t}\n\n\tif botAccount == \"\" && len(data.Users) > 0 {\n\t\tbotAccount = data.Users[0].Username\n\t}\n\n\tlog.Println(\"Generating group topics...\")\n\n\tfor _, gt := range data.Grouptopics {\n\t\tname := genTopicName()\n\t\tnameIndex[gt.Name] = name\n\n\t\taccessAuth := types.ModeCPublic\n\t\tif gt.Access.Auth != \"\" {\n\t\t\tif err := accessAuth.UnmarshalText([]byte(gt.Access.Auth)); err != nil {\n\t\t\t\tlog.Fatal(\"Invalid Auth access mode\", gt.Access.Auth, err)\n\t\t\t}\n\t\t}\n\t\taccessAnon := types.ModeCReadOnly\n\t\tif gt.Access.Anon != \"\" {\n\t\t\tif err := accessAnon.UnmarshalText([]byte(gt.Access.Anon)); err != nil {\n\t\t\t\tlog.Fatal(\"Invalid Anon access mode\", gt.Access.Anon, err)\n\t\t\t}\n\t\t}\n\t\ttopic := &types.Topic{\n\t\t\tObjHeader: types.ObjHeader{Id: name},\n\t\t\tAccess: types.DefaultAccess{\n\t\t\t\tAuth: accessAuth,\n\t\t\t\tAnon: accessAnon,\n\t\t\t},\n\t\t\tUseBt:  gt.Channel,\n\t\t\tTags:   gt.Tags,\n\t\t\tPublic: parsePublic(&gt.Public, data.datapath),\n\t\t}\n\t\tif !gt.Trusted.IsZero() {\n\t\t\ttopic.Trusted = gt.Trusted\n\t\t}\n\t\tvar owner types.Uid\n\t\tif gt.Owner != \"\" {\n\t\t\towner = types.ParseUid(nameIndex[gt.Owner])\n\t\t\tif owner.IsZero() {\n\t\t\t\tlog.Fatal(\"Invalid owner\", gt.Owner, \"for topic\", gt.Name)\n\t\t\t}\n\t\t\ttopic.GiveAccess(owner, types.ModeCFull, types.ModeCFull)\n\t\t}\n\t\ttopic.CreatedAt = getCreatedTime(gt.CreatedAt)\n\n\t\tif err = store.Topics.Create(topic, owner, gt.OwnerPrivate); err != nil {\n\t\t\tlog.Fatal(err)\n\t\t}\n\t\tfmt.Println(\"grp;\" + gt.Name + \";\" + name)\n\t}\n\n\tlog.Println(\"Generating P2P subscriptions...\")\n\n\tfor i, ss := range data.P2psubs {\n\t\tif ss.Users[0].Name < ss.Users[1].Name {\n\t\t\tss.pair = ss.Users[0].Name + \":\" + ss.Users[1].Name\n\t\t} else {\n\t\t\tss.pair = ss.Users[1].Name + \":\" + ss.Users[0].Name\n\t\t}\n\n\t\tuid1 := types.ParseUid(nameIndex[ss.Users[0].Name])\n\t\tuid2 := types.ParseUid(nameIndex[ss.Users[1].Name])\n\t\ttopic := uid1.P2PName(uid2)\n\t\tcreated := getCreatedTime(ss.CreatedAt)\n\n\t\t// Assign default access mode\n\t\tdefaultMode := types.ModeCP2P\n\t\tif p2pDel {\n\t\t\tdefaultMode = types.ModeCP2PD\n\t\t}\n\t\ts0want := defaultMode\n\t\ts0given := defaultMode\n\t\ts1want := defaultMode\n\t\ts1given := defaultMode\n\n\t\t// Check of non-default access mode was provided\n\t\tif ss.Users[0].Want != \"\" {\n\t\t\tif err := s0want.UnmarshalText([]byte(ss.Users[0].Want)); err != nil {\n\t\t\t\tlog.Fatal(err)\n\t\t\t}\n\t\t}\n\t\tif ss.Users[0].Have != \"\" {\n\t\t\tif err := s0given.UnmarshalText([]byte(ss.Users[0].Have)); err != nil {\n\t\t\t\tlog.Fatal(err)\n\t\t\t}\n\t\t}\n\t\tif ss.Users[1].Want != \"\" {\n\t\t\tif err := s1want.UnmarshalText([]byte(ss.Users[1].Want)); err != nil {\n\t\t\t\tlog.Fatal(err)\n\t\t\t}\n\t\t}\n\t\tif ss.Users[1].Have != \"\" {\n\t\t\tif err := s1given.UnmarshalText([]byte(ss.Users[1].Have)); err != nil {\n\t\t\t\tlog.Fatal(err)\n\t\t\t}\n\t\t}\n\n\t\terr := store.Topics.CreateP2P(\n\t\t\t&types.Subscription{\n\t\t\t\tObjHeader: types.ObjHeader{CreatedAt: created},\n\t\t\t\tUser:      uid1.String(),\n\t\t\t\tTopic:     topic,\n\t\t\t\tModeWant:  s0want,\n\t\t\t\tModeGiven: s0given,\n\t\t\t\tPrivate:   ss.Users[0].Private},\n\t\t\t&types.Subscription{\n\t\t\t\tObjHeader: types.ObjHeader{CreatedAt: created},\n\t\t\t\tUser:      uid2.String(),\n\t\t\t\tTopic:     topic,\n\t\t\t\tModeWant:  s1want,\n\t\t\t\tModeGiven: s1given,\n\t\t\t\tPrivate:   ss.Users[1].Private})\n\n\t\tif err != nil {\n\t\t\tlog.Fatal(err)\n\t\t}\n\n\t\tdata.P2psubs[i].pair = ss.pair\n\t\tnameIndex[ss.pair] = topic\n\t\tfmt.Println(\"p2p;\" + ss.pair + \";\" + topic)\n\t}\n\n\tlog.Println(\"Generating group subscriptions...\")\n\n\tfor _, ss := range data.Groupsubs {\n\t\tvar want, given types.AccessMode\n\t\tif ss.AsChan {\n\t\t\twant = types.ModeCChnReader\n\t\t\tgiven = types.ModeCChnReader\n\t\t} else {\n\t\t\twant = types.ModeCPublic\n\t\t\tgiven = types.ModeCPublic\n\t\t}\n\t\tif ss.Want != \"\" {\n\t\t\tif err := want.UnmarshalText([]byte(ss.Want)); err != nil {\n\t\t\t\tlog.Fatal(err)\n\t\t\t}\n\t\t}\n\t\tif ss.Have != \"\" {\n\t\t\tif err := given.UnmarshalText([]byte(ss.Have)); err != nil {\n\t\t\t\tlog.Fatal(err)\n\t\t\t}\n\t\t}\n\t\ttname := nameIndex[ss.Topic]\n\t\tif ss.AsChan {\n\t\t\ttname = types.GrpToChn(tname)\n\t\t}\n\t\tif err = store.Subs.Create(&types.Subscription{\n\t\t\tObjHeader: types.ObjHeader{CreatedAt: getCreatedTime(ss.CreatedAt)},\n\t\t\tUser:      nameIndex[ss.User],\n\t\t\tTopic:     tname,\n\t\t\tModeWant:  want,\n\t\t\tModeGiven: given,\n\t\t\tPrivate:   ss.Private}); err != nil {\n\n\t\t\tlog.Fatal(err)\n\t\t}\n\t}\n\n\tseqIds := map[string]int{}\n\tnow := types.TimeNow().Add(-time.Minute * 10)\n\n\tmessageCount := len(data.Messages)\n\tif messageCount > 0 {\n\t\tlog.Println(\"Inserting messages...\")\n\n\t\tif messageCount > 1 {\n\t\t\t// Shuffle messages\n\t\t\trand.Shuffle(len(data.Messages), func(i, j int) {\n\t\t\t\tdata.Messages[i], data.Messages[j] = data.Messages[j], data.Messages[i]\n\t\t\t})\n\n\t\t\t// Starting 4 days ago.\n\t\t\ttimestamp := now.Add(time.Hour * time.Duration(-24*4))\n\t\t\ttoInsert := 96 // 96 is the maximum, otherwise messages may appear in the future\n\t\t\t// Initial maximum increment of the message sent time in milliseconds\n\t\t\tincrement := 3600 * 1000\n\t\t\tsubIdx := rand.Intn(len(data.Groupsubs) + len(data.P2psubs)*2)\n\t\t\tfor i := range toInsert {\n\t\t\t\t// At least 20% of subsequent messages should come from the same user in the same topic.\n\t\t\t\tif rand.Intn(5) > 0 {\n\t\t\t\t\tsubIdx = rand.Intn(len(data.Groupsubs) + len(data.P2psubs)*2)\n\t\t\t\t}\n\n\t\t\t\tvar topic string\n\t\t\t\tvar from types.Uid\n\t\t\t\tif subIdx < len(data.Groupsubs) {\n\t\t\t\t\tif data.Groupsubs[subIdx].AsChan {\n\t\t\t\t\t\t// Channel readers should not have any published messages.\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\ttopic = nameIndex[data.Groupsubs[subIdx].Topic]\n\t\t\t\t\tfrom = types.ParseUid(nameIndex[data.Groupsubs[subIdx].User])\n\t\t\t\t} else {\n\t\t\t\t\tidx := (subIdx - len(data.Groupsubs)) / 2\n\t\t\t\t\tusr := (subIdx - len(data.Groupsubs)) % 2\n\t\t\t\t\tsub := data.P2psubs[idx]\n\t\t\t\t\ttopic = nameIndex[sub.pair]\n\t\t\t\t\tfrom = types.ParseUid(nameIndex[sub.Users[usr].Name])\n\t\t\t\t}\n\n\t\t\t\tseqIds[topic]++\n\t\t\t\tseqId := seqIds[topic]\n\t\t\t\tstr := data.Messages[i%len(data.Messages)]\n\t\t\t\t// Max time between messages is 2 hours, averate - 1 hour, time is increasing as seqId increases\n\t\t\t\ttimestamp = timestamp.Add(time.Microsecond * time.Duration(rand.Intn(increment)))\n\t\t\t\tif timestamp.After(now) {\n\t\t\t\t\tnow = timestamp\n\t\t\t\t}\n\t\t\t\tif err, _ = store.Messages.Save(&types.Message{\n\t\t\t\t\tObjHeader: types.ObjHeader{CreatedAt: timestamp},\n\t\t\t\t\tSeqId:     seqId,\n\t\t\t\t\tTopic:     topic,\n\t\t\t\t\tFrom:      from.String(),\n\t\t\t\t\tContent:   str,\n\t\t\t\t}, nil, true); err != nil {\n\t\t\t\t\tlog.Fatal(\"Failed to insert message: \", err)\n\t\t\t\t}\n\n\t\t\t\t// New increment: remaining time until 'now' divided by the number of messages to be inserted,\n\t\t\t\t// then converted to milliseconds.\n\t\t\t\tincrement = int(now.Sub(timestamp).Nanoseconds() / int64(toInsert-i) / 1000000)\n\n\t\t\t\t// log.Printf(\"Msg.seq=%d at %v, topic='%s' from='%s'\", msg.SeqId, msg.CreatedAt, topic, from.UserId())\n\t\t\t}\n\t\t} else {\n\t\t\t// Only one message is provided. Just insert it into every topic.\n\t\t\tnow := time.Now().UTC().Add(-time.Minute).Round(time.Millisecond)\n\n\t\t\tfor _, gt := range data.Grouptopics {\n\t\t\t\tseqIds[nameIndex[gt.Name]] = 1\n\t\t\t\tif err, _ = store.Messages.Save(&types.Message{\n\t\t\t\t\tObjHeader: types.ObjHeader{CreatedAt: now},\n\t\t\t\t\tSeqId:     1,\n\t\t\t\t\tTopic:     nameIndex[gt.Name],\n\t\t\t\t\tFrom:      nameIndex[gt.Owner],\n\t\t\t\t\tContent:   data.Messages[0],\n\t\t\t\t}, nil, true); err != nil {\n\t\t\t\t\tlog.Fatal(\"Failed to insert message: \", err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tusedp2p := make(map[string]bool)\n\t\t\tfor i := 0; len(usedp2p) < len(data.P2psubs)/2; i++ {\n\t\t\t\tsub := data.P2psubs[i]\n\t\t\t\tif usedp2p[nameIndex[sub.pair]] {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tusedp2p[nameIndex[sub.pair]] = true\n\t\t\t\tseqIds[nameIndex[sub.pair]] = 1\n\t\t\t\tif err, _ = store.Messages.Save(&types.Message{\n\t\t\t\t\tObjHeader: types.ObjHeader{CreatedAt: now},\n\t\t\t\t\tSeqId:     1,\n\t\t\t\t\tTopic:     nameIndex[sub.pair],\n\t\t\t\t\tFrom:      nameIndex[sub.Users[0].Name],\n\t\t\t\t\tContent:   data.Messages[0],\n\t\t\t\t}, nil, true); err != nil {\n\t\t\t\t\tlog.Fatal(\"Failed to insert message: \", err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(data.Forms) != 0 {\n\t\tfrom := nameIndex[botAccount]\n\t\tlog.Println(\"Inserting forms as \", botAccount, from)\n\t\tts := now\n\t\tfor _, form := range data.Forms {\n\t\t\tfor _, sub := range data.P2psubs {\n\t\t\t\tts = ts.Add(time.Second)\n\t\t\t\tseqIds[nameIndex[sub.pair]]++\n\t\t\t\tseqId := seqIds[nameIndex[sub.pair]]\n\t\t\t\tif sub.Users[0].Name == botAccount || sub.Users[1].Name == botAccount {\n\t\t\t\t\tif err, _ = store.Messages.Save(&types.Message{\n\t\t\t\t\t\tObjHeader: types.ObjHeader{CreatedAt: ts},\n\t\t\t\t\t\tSeqId:     seqId,\n\t\t\t\t\t\tTopic:     nameIndex[sub.pair],\n\t\t\t\t\t\tHead:      types.KVMap{\"mime\": \"text/x-drafty\"},\n\t\t\t\t\t\tFrom:      from,\n\t\t\t\t\t\tContent:   form,\n\t\t\t\t\t}, nil, true); err != nil {\n\t\t\t\t\t\tlog.Fatal(\"Failed to insert form: \", err)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tlog.Println(\"Sample data processing completed.\")\n}\n\n// Go json cannot unmarshal Duration from a string, thus this hack.\nfunc getCreatedTime(delta string) time.Time {\n\tdd, err := time.ParseDuration(delta)\n\tif err != nil && delta != \"\" {\n\t\tlog.Fatal(\"Invalid duration string\", delta)\n\t}\n\n\treturn time.Now().UTC().Round(time.Millisecond).Add(dd)\n}\n\ntype photoStruct struct {\n\tType string `json:\"type\" db:\"type\"`\n\tData []byte `json:\"data\" db:\"data\"`\n}\n\ntype card struct {\n\tFn    string       `json:\"fn\" db:\"fn\"`\n\tPhoto *photoStruct `json:\"photo,omitempty\" db:\"photo\"`\n}\n\n// {\"fn\": \"Alice Johnson\", \"photo\": \"alice-128.jpg\"}\nfunc parsePublic(public *theCard, path string) *card {\n\tvar photo *photoStruct\n\tvar err error\n\n\tif public.Fn == \"\" && public.Photo == \"\" {\n\t\treturn nil\n\t}\n\n\tif fname := public.Photo; fname != \"\" {\n\t\tphoto = &photoStruct{Type: public.Type}\n\t\tdir, _ := filepath.Split(fname)\n\t\tif dir == \"\" {\n\t\t\tdir = path\n\t\t}\n\t\tphoto.Data, err = os.ReadFile(filepath.Join(dir, fname))\n\t\tif err != nil {\n\t\t\tlog.Fatal(err)\n\t\t}\n\t}\n\n\treturn &card{Fn: public.Fn, Photo: photo}\n}\n"
  },
  {
    "path": "tinode-db/generate_dataset.py",
    "content": "# Script that generates a synthetic Tinode dataset in the same format as data.json.\n# Run as:\n# $ python generate_dataset.py --num_users X\n#\n# User ids and passwords are: userN and userN123 where N is in [0..X).\n# The output will be dumped to stdout.\n\nimport argparse as ap\nimport json\nimport numpy as np\nimport random\nimport sys\n\nparser = ap.ArgumentParser(description='Simple python script to generate synthetic Tinode datasets.')\nparser.add_argument('--num_users', type=int, default=50, help='Number of user accounts (default: 50).')\nargs = parser.parse_args()\n\nrandom.seed()\nnp.random.seed()\n\ndata = dict()\n\n# Users.\nnum_users = args.num_users\nusers = list()\nul = ['user%d' % i for i in range(num_users)]\n\nfor i in range(num_users):\n  userid = ul[i]\n  u = {\n    'createdAt': '-%dh' % random.randint(1, 300),\n    'email': '%s@example.com' % userid,\n    'passhash': '%s123' % userid,\n    'private': {'comment': 'some comment 123'},\n    'public': {'fn': userid},\n    'tags': [userid],\n    'state': 'ok',\n    'status': {\n      'text': 'my status %s' % userid\n    },\n    'username': userid,\n  }\n  users.append(u)\n\ndata['users'] = users\n\n# Groups.\ngroup_topics = list()\n# TODO: make it configurable.\nnum_groups = random.randint(1, num_users >> 1)\nfor i in range(num_groups):\n  owner = random.choice(ul)\n  g = { \n    'createdAt': '-%dh' % random.randint(1, 300),\n    'name': '*ABCgroup%d' % i,\n    'owner': owner,\n    'tags': ['group%d' % i],\n    'public': {'fn': 'My group %d' % i}\n  }\n  group_topics.append(g)\n\ndata['grouptopics'] = group_topics\n\n# P2P subs.\np2p_subs = list()\nids = set()\n\n# TODO: Poisson mean should be configurable.\nnum_contacts = np.random.poisson(40, num_users).tolist()\nnum_contacts = [min(max(2, int(s)), num_users - 1) for s in num_contacts]\n\nall_ids = [i for i in range(num_users)]\n\nfor i in range(num_users):\n  contacts = [ul[x] for x in random.sample(all_ids, num_contacts[i]) if x > i]\n  for c in contacts:\n    p2p = { \n      'createdAt': '-%dh' % random.randint(1, 300),\n      'users': [{'name': ul[i]}, {'name': c}]\n    }\n    p2p_subs.append(p2p)\n\ndata['p2psubs'] = p2p_subs\n\n# Group subs.\n# TODO: Poisson mean should be configurable as well.\nsizes = np.random.poisson(4, num_groups).tolist()\nsizes = [min(max(2, int(s)), num_users) for s in sizes]\ngroup_subs = list()\n\nfor i in range(num_groups): \n  k = sizes[i]\n  gs = random.sample(ul, k) \n  for uid in gs:\n    g = {\n      'createdAt': '-%dh' % random.randint(1, 300),\n      'topic': '*ABCgroup%d' % i,\n      'user': uid\n    }\n    group_subs.append(g)\n\ndata['groupsubs'] = group_subs\n\njson.dump(data, sys.stdout, ensure_ascii=False, indent=4)\n"
  },
  {
    "path": "tinode-db/main.go",
    "content": "package main\n\nimport (\n\tcrand \"crypto/rand\"\n\t\"encoding/json\"\n\t\"flag\"\n\t\"log\"\n\t\"math/rand\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tinode/chat/server/auth\"\n\t_ \"github.com/tinode/chat/server/db/mongodb\"\n\t_ \"github.com/tinode/chat/server/db/mysql\"\n\t_ \"github.com/tinode/chat/server/db/postgres\"\n\t_ \"github.com/tinode/chat/server/db/rethinkdb\"\n\t\"github.com/tinode/chat/server/store\"\n\t\"github.com/tinode/chat/server/store/types\"\n\tjcr \"github.com/tinode/jsonco\"\n)\n\ntype configType struct {\n\tP2PDeleteEnabled bool            `json:\"p2p_delete_enabled\"`\n\tStoreConfig      json.RawMessage `json:\"store_config\"`\n}\n\ntype theCard struct {\n\tFn    string `json:\"fn\"`\n\tPhoto string `json:\"photo\"`\n\tType  string `json:\"type\"`\n}\n\ntype tPrivate struct {\n\tComment string `json:\"comment\"`\n}\n\ntype tTrusted struct {\n\tVerified bool `json:\"verified,omitempty\"`\n\tStaff    bool `json:\"staff,omitempty\"`\n}\n\nfunc (t tTrusted) IsZero() bool {\n\treturn !t.Verified && !t.Staff\n}\n\n// DefAccess is default access mode.\ntype DefAccess struct {\n\tAuth string `json:\"auth\"`\n\tAnon string `json:\"anon\"`\n}\n\n/*\nUser object in data.json\n\n\t   \"createdAt\": \"-140h\",\n\t   \"email\": \"alice@example.com\",\n\t   \"tel\": \"17025550001\",\n\t   \"passhash\": \"alice123\",\n\t   \"private\": {\"comment\": \"some comment 123\"},\n\t   \"public\": {\"fn\": \"Alice Johnson\", \"photo\": \"alice-64.jpg\", \"type\": \"jpg\"},\n\t   \"state\": \"ok\",\n\t   \"authLevel\": \"auth\",\n\t   \"status\": {\n\t     \"text\": \"DND\"\n\t   },\n\t   \"username\": \"alice\",\n\t\t\"tags\": [\"tag1\"],\n\t\t\"addressBook\": [\"email:bob@example.com\", \"email:carol@example.com\", \"email:dave@example.com\",\n\t\t\t\"email:eve@example.com\",\"email:frank@example.com\",\"email:george@example.com\",\"email:tob@example.com\",\n\t\t\t\"tel:17025550001\", \"tel:17025550002\", \"tel:17025550003\", \"tel:17025550004\", \"tel:17025550005\",\n\t\t\t\"tel:17025550006\", \"tel:17025550007\", \"tel:17025550008\", \"tel:17025550009\"]\n\t  }\n*/\ntype User struct {\n\tCreatedAt   string   `json:\"createdAt\"`\n\tEmail       string   `json:\"email\"`\n\tTel         string   `json:\"tel\"`\n\tAuthLevel   string   `json:\"authLevel\"`\n\tUsername    string   `json:\"username\"`\n\tPassword    string   `json:\"passhash\"`\n\tPrivate     tPrivate `json:\"private\"`\n\tPublic      theCard  `json:\"public\"`\n\tTrusted     tTrusted `json:\"trusted\"`\n\tState       string   `json:\"state\"`\n\tStatus      any      `json:\"status\"`\n\tAddressBook []string `json:\"addressBook\"`\n\tTags        []string `json:\"tags\"`\n}\n\n/*\nGroupTopic object in data.json\n\n\t\"createdAt\": \"-128h\",\n\t\"name\": \"*ABC\",\n\t\"owner\": \"carol\",\n\t\"channel\": true,\n\t\"public\": {\"fn\": \"Let's talk about flowers\", \"photo\": \"abc-64.jpg\", \"type\": \"jpg\"}\n*/\ntype GroupTopic struct {\n\tCreatedAt    string    `json:\"createdAt\"`\n\tName         string    `json:\"name\"`\n\tOwner        string    `json:\"owner\"`\n\tChannel      bool      `json:\"channel\"`\n\tPublic       theCard   `json:\"public\"`\n\tTrusted      tTrusted  `json:\"trusted\"`\n\tAccess       DefAccess `json:\"access\"`\n\tTags         []string  `json:\"tags\"`\n\tOwnerPrivate tPrivate  `json:\"ownerPrivate\"`\n}\n\n/*\nGroupSub object in data.json\n\n\t\"createdAt\": \"-112h\",\n\t\"private\": \"My super cool group topic\",\n\t\"topic\": \"*ABC\",\n\t\"user\": \"alice\",\n\t\"asChan: false,\n\t\"want\": \"JRWPSA\",\n\t\"have\": \"JRWP\"\n*/\ntype GroupSub struct {\n\tCreatedAt string   `json:\"createdAt\"`\n\tPrivate   tPrivate `json:\"private\"`\n\tTopic     string   `json:\"topic\"`\n\tUser      string   `json:\"user\"`\n\tAsChan    bool     `json:\"asChan\"`\n\tWant      string   `json:\"want\"`\n\tHave      string   `json:\"have\"`\n}\n\n/*\nP2PUser topic in data.json\n\n\"createdAt\": \"-117h\",\n\"users\": [\n\n\t{\"name\": \"eve\", \"private\": {\"comment\":\"ho ho\"}, \"want\": \"JRWP\", \"have\": \"N\"},\n\t{\"name\": \"alice\", \"private\": {\"comment\": \"ha ha\"}}\n\n]\n*/\ntype P2PUser struct {\n\tName    string   `json:\"name\"`\n\tPrivate tPrivate `json:\"private\"`\n\tWant    string   `json:\"want\"`\n\tHave    string   `json:\"have\"`\n}\n\n// P2PSub is a p2p subscription in data.json\ntype P2PSub struct {\n\tCreatedAt string    `json:\"createdAt\"`\n\tUsers     []P2PUser `json:\"users\"`\n\t// Cached value 'user1:user2' as a surrogare topic name\n\tpair string\n}\n\n// Data is a message in data.json.\ntype Data struct {\n\tUsers       []User           `json:\"users\"`\n\tGrouptopics []GroupTopic     `json:\"grouptopics\"`\n\tGroupsubs   []GroupSub       `json:\"groupsubs\"`\n\tP2psubs     []P2PSub         `json:\"p2psubs\"`\n\tMessages    []string         `json:\"messages\"`\n\tForms       []map[string]any `json:\"forms\"`\n\tdatapath    string\n}\n\n// Generate random string as a name of the group topic\nfunc genTopicName() string {\n\treturn \"grp\" + store.Store.GetUidString()\n}\n\n// Generates password of length n\nfunc getPassword(n int) string {\n\tconst letters = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-/.+?=&\"\n\n\trbuf := make([]byte, n)\n\tif _, err := crand.Read(rbuf); err != nil {\n\t\tlog.Fatalln(\"Unable to generate password\", err)\n\t}\n\n\tpasswd := make([]byte, n)\n\tfor i, r := range rbuf {\n\t\tpasswd[i] = letters[int(r)%len(letters)]\n\t}\n\n\treturn string(passwd)\n}\n\nfunc main() {\n\treset := flag.Bool(\"reset\", false, \"force database reset\")\n\tupgrade := flag.Bool(\"upgrade\", false, \"perform database version upgrade\")\n\tnoInit := flag.Bool(\"no_init\", false, \"check that database exists but don't create if missing\")\n\taddRoot := flag.String(\"add_root\", \"\", \"create ROOT user, auth scheme 'basic'\")\n\tmakeRoot := flag.String(\"make_root\", \"\", \"promote ordinary user to ROOT, auth scheme 'basic'\")\n\tdatafile := flag.String(\"data\", \"\", \"name of file with sample data to load\")\n\tconffile := flag.String(\"config\", \"./tinode.conf\", \"config of the database connection\")\n\n\tflag.Parse()\n\n\tvar data Data\n\tif *datafile != \"\" && *datafile != \"-\" {\n\t\traw, err := os.ReadFile(*datafile)\n\t\tif err != nil {\n\t\t\tlog.Fatalln(\"Failed to read sample data file:\", err)\n\t\t}\n\t\terr = json.Unmarshal(raw, &data)\n\t\tif err != nil {\n\t\t\tlog.Fatalln(\"Failed to parse sample data:\", err)\n\t\t}\n\t}\n\n\trand.Seed(time.Now().UnixNano())\n\tdata.datapath, _ = filepath.Split(*datafile)\n\n\tvar config configType\n\tif file, err := os.Open(*conffile); err != nil {\n\t\tlog.Fatalln(\"Failed to read config file:\", err)\n\t} else {\n\t\tjr := jcr.New(file)\n\t\tif err = json.NewDecoder(jr).Decode(&config); err != nil {\n\t\t\tswitch jerr := err.(type) {\n\t\t\tcase *json.UnmarshalTypeError:\n\t\t\t\tlnum, cnum, _ := jr.LineAndChar(jerr.Offset)\n\t\t\t\tlog.Fatalf(\"Unmarshall error in config file in %s at %d:%d (offset %d bytes): %s\",\n\t\t\t\t\tjerr.Field, lnum, cnum, jerr.Offset, jerr.Error())\n\t\t\tcase *json.SyntaxError:\n\t\t\t\tlnum, cnum, _ := jr.LineAndChar(jerr.Offset)\n\t\t\t\tlog.Fatalf(\"Syntax error in config file at %d:%d (offset %d bytes): %s\",\n\t\t\t\t\tlnum, cnum, jerr.Offset, jerr.Error())\n\t\t\tdefault:\n\t\t\t\tlog.Fatal(\"Failed to parse config file: \", err)\n\t\t\t}\n\t\t}\n\t}\n\n\terr := store.Store.Open(1, config.StoreConfig)\n\tdefer store.Store.Close()\n\n\tadapterVersion := store.Store.GetAdapterVersion()\n\tdatabaseVersion := 0\n\tif store.Store.IsOpen() {\n\t\tdatabaseVersion = store.Store.GetDbVersion()\n\t}\n\tlog.Printf(\"Database adapter: '%s'; version: %d\", store.Store.GetAdapterName(), adapterVersion)\n\n\tvar created bool\n\n\tif err != nil {\n\t\tif strings.Contains(err.Error(), \"Database not initialized\") {\n\t\t\tif *noInit {\n\t\t\t\tlog.Fatalln(\"Database not found.\")\n\t\t\t}\n\t\t\tlog.Println(\"Database not found. Creating.\")\n\t\t\terr = store.Store.InitDb(config.StoreConfig, false)\n\t\t\tif err == nil {\n\t\t\t\tlog.Println(\"Database successfully created.\")\n\t\t\t\tcreated = true\n\t\t\t}\n\t\t} else if strings.Contains(err.Error(), \"Invalid database version\") {\n\t\t\tmsg := \"Wrong DB version: expected \" + strconv.Itoa(adapterVersion) + \", got \" +\n\t\t\t\tstrconv.Itoa(databaseVersion) + \".\"\n\n\t\t\tif *reset {\n\t\t\t\tlog.Println(msg, \"Reset Requested. Dropping and recreating the database.\")\n\t\t\t\terr = store.Store.InitDb(config.StoreConfig, true)\n\t\t\t\tif err == nil {\n\t\t\t\t\tlog.Println(\"Database successfully reset.\")\n\t\t\t\t}\n\t\t\t} else if *upgrade {\n\t\t\t\tif databaseVersion > adapterVersion {\n\t\t\t\t\tlog.Fatalln(msg, \"Unable to upgrade: database has greater version than the adapter.\")\n\t\t\t\t}\n\t\t\t\tlog.Println(msg, \"Upgrading the database.\")\n\t\t\t\terr = store.Store.UpgradeDb(config.StoreConfig)\n\t\t\t\tif err == nil {\n\t\t\t\t\tlog.Println(\"Database successfully upgraded.\")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tlog.Fatalln(msg, \"Use --reset to reset, --upgrade to upgrade.\")\n\t\t\t}\n\t\t} else {\n\t\t\tlog.Fatalln(\"Failed to init DB adapter:\", err)\n\t\t}\n\t} else if *reset {\n\t\tlog.Println(\"Reset requested. Dropping and recreating the database.\")\n\t\terr = store.Store.InitDb(config.StoreConfig, true)\n\t\tif err == nil {\n\t\t\tlog.Println(\"Database successfully reset.\")\n\t\t}\n\t} else {\n\t\tlog.Println(\"Database exists, version is correct.\")\n\t}\n\n\tif err != nil {\n\t\tlog.Fatalln(\"Failure:\", err)\n\t}\n\n\tif *reset || created {\n\t\tgenDb(&data, config.P2PDeleteEnabled)\n\t} else if len(data.Users) > 0 {\n\t\tlog.Println(\"Sample data ignored.\")\n\t}\n\n\t// Promote existing user account to root\n\tif *makeRoot != \"\" {\n\t\tadapter := store.Store.GetAdapter()\n\t\tuserId := types.ParseUserId(*makeRoot)\n\t\tif userId.IsZero() {\n\t\t\tlog.Fatalf(\"Must specify a valid user ID '%s' to promote to ROOT\", *makeRoot)\n\t\t}\n\t\tif err := adapter.AuthUpdRecord(userId, \"basic\", \"\", auth.LevelRoot, nil, time.Time{}); err != nil {\n\t\t\tlog.Fatalln(\"Failed to promote user to ROOT\", err)\n\t\t}\n\t\tlog.Printf(\"User '%s' promoted to ROOT\", *makeRoot)\n\t}\n\n\t// Create root user account.\n\tif *addRoot != \"\" {\n\t\tvar password string\n\t\tparts := strings.Split(*addRoot, \":\")\n\t\tuname := parts[0]\n\t\tif len(uname) < 3 {\n\t\t\tlog.Fatalf(\"Failed to create a ROOT user: username '%s' is too short\", uname)\n\t\t}\n\n\t\tif len(parts) == 1 || parts[1] == \"\" {\n\t\t\tpassword = getPassword(10)\n\t\t} else {\n\t\t\tpassword = parts[1]\n\t\t}\n\n\t\tvar user types.User\n\t\tuser.Public = &card{\n\t\t\tFn: \"ROOT \" + uname,\n\t\t}\n\t\tstore.Users.Create(&user, nil)\n\n\t\tif _, err := store.Users.Create(&user, nil); err != nil {\n\t\t\tlog.Fatalln(\"Failed to create ROOT user:\", err)\n\t\t}\n\n\t\tauthHandler := store.Store.GetAuthHandler(\"basic\")\n\t\tif _, err := authHandler.AddRecord(&auth.Rec{Uid: user.Uid(), AuthLevel: auth.LevelRoot},\n\t\t\t[]byte(uname+\":\"+password), \"\"); err != nil {\n\t\t\tstore.Users.Delete(user.Uid(), true)\n\t\t\tlog.Fatalln(\"Failed to add ROOT auth record:\", err)\n\t\t}\n\t\tlog.Printf(\"ROOT user created: '%s:%s'\", uname, password)\n\t}\n\n\tlog.Println(\"All done.\")\n\n\tos.Exit(0)\n}\n"
  },
  {
    "path": "tinode-db/tinode.conf",
    "content": "{\n\t\"p2p_delete_enabled\": true,\n\t\"store_config\": {\n\t\t\"uid_key\": \"la6YsO+bNX/+XIkOqc5Svw==\",\n\t\t\"use_adapter\": \"\",\n\t\t\"adapters\": {\n\t\t\t\"postgres\": {\n\t\t\t\t\"database\": \"tinode\",\n\t\t\t\t\"dsn\": \"postgresql://postgres:postgres@localhost:5432/tinode?sslmode=disable\"\n\t\t\t},\n\t\t\t\"mysql\": {\n\t\t\t\t\"database\": \"tinode\",\n\t\t\t\t\"dsn\": \"root@tcp(localhost)/tinode?parseTime=true&collation=utf8mb4_unicode_ci\"\n\t\t\t},\n\t\t\t\"rethinkdb\": {\n\t\t\t\t\"database\": \"tinode\",\n\t\t\t\t\"addresses\": \"localhost:28015\"\n\t\t\t},\n\t\t\t\"mongodb\": {\n\t\t\t\t\"database\": \"tinode\",\n\t\t\t\t\"addresses\": \"localhost:27017\",\n\t\t\t\t//\"replica_set\": \"rs0\",\n\t\t\t\t//\"auth_source\": \"admin\",\n\t\t\t\t//\"username\": \"tinode\",\n\t\t\t\t//\"password\": \"tinode\",\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "tn-cli/CODE-STRUCTURE.md",
    "content": "# tn-cli Refactoring Summary\n\nThe code is organized into following focused modules:\n\n## New Structure\n\n### 1. **tn-cli.py** (Entry Point - ~120 lines)\n- Command-line argument parsing\n- Application initialization\n- Version handling\n- Authentication setup (token, basic, cookie)\n- Macro loading\n- Entry point that calls `run()` from client module\n\n**Key functions:**\n- `exception_hook()` - Crash handler\n- `if __name__ == '__main__'` - Main entry point\n\n---\n\n### 2. **utils.py** (Utility Functions - ~200 lines)\n- Helper functions and data structures\n- File/image processing utilities\n- Encoding and parsing functions\n\n**Key functions:**\n- `dotdict` - Dictionary with dot notation access\n- `makeTheCard()` - Pack user profile data\n- `inline_image()` - Create drafty image messages\n- `attachment()` - Create drafty attachment messages\n- `encode_to_bytes()` - Convert objects to bytes\n- `parse_cred()` - Parse credentials\n- `parse_trusted()` - Parse trusted values\n\n**Constants:**\n- `MAX_INBAND_ATTACHMENT_SIZE`\n- `MAX_EXTERN_ATTACHMENT_SIZE`\n- `MAX_IMAGE_DIM`\n- `DELETE_MARKER`\n- `TINODE_DEL`\n\n---\n\n### 3. **commands.py** (Command Parsing & Message Building - ~850 lines)\n- Command-line parsing for all commands\n- Protobuf message construction\n- Variable dereferencing\n- Command serialization\n\n**Key functions:**\n- `parse_input()` - Parse command line input\n- `parse_cmd()` - Create argument parsers\n- `serialize_cmd()` - Convert commands to protobuf\n- `derefVals()` / `getVar()` - Variable dereferencing\n- Message builders: `hiMsg()`, `accMsg()`, `loginMsg()`, `subMsg()`, `leaveMsg()`, `pubMsg()`, `getMsg()`, `setMsg()`, `delMsg()`, `noteMsg()`\n- File operations: `upload()`, `fileUpload()`, `fileDownload()`\n- `print_server_params()` - Log server info\n\n---\n\n### 4. **client.py** (gRPC Client & Communication - ~260 lines)\n- gRPC connection management\n- Message generation and streaming\n- Server response handling\n- Login/authentication handling\n- Cookie management\n\n**Key functions:**\n- `run()` - Main client loop\n- `gen_message()` - Generate outgoing messages\n- `handle_ctrl()` - Handle server control responses\n- `handle_login()` - Process login response\n- `save_cookie()` / `read_cookie()` - Cookie persistence\n- `pop_from_output_queue()` - Output queue management\n\n---\n\n### 5. **input_handler.py** (User Input - ~70 lines)\n- Terminal input reading\n- Multi-line input support\n- Interactive and non-interactive modes\n\n**Key functions:**\n- `stdin()` - Main input loop\n- `readLinesFromStdin()` - Read with prompt support\n\n---\n\n### 6. **tn_globals.py** (Shared Global State - ~104 lines)\n- Global variables shared across all modules\n- Asynchronous I/O queue management\n- Utility functions for logging and output\n- Protobuf to JSON conversion\n\n**Key variables:**\n- `OnCompletion` - Dictionary of callbacks for server responses\n- `WaitingFor` - Outstanding synchronous command request\n- `AuthToken` - Current authentication token\n- `InputQueue` / `OutputQueue` - Async I/O queues\n- `InputThread` - Background input thread\n- `IsInteractive` - Detect if running in interactive mode\n- `Prompt` - PromptSession for interactive input\n- `DefaultUser` / `DefaultTopic` - Default context values\n- `Variables` - Store command execution results\n- `Connection` - gRPC connection to server\n- `Verbose` - Extended logging flag\n\n**Key functions:**\n- `printout()` - Print in interactive mode only\n- `printerr()` - Write to stderr\n- `stdout()` / `stdoutln()` - Async output to stdout\n- `clip_long_string()` - Shorten long strings for logging\n- `to_json()` - Convert protobuf messages to JSON\n\n---\n\n### 7. **macros.py** (Command Macros - ~341 lines)\n- High-level command macros that expand into basic commands\n- Simplifies complex multi-step operations\n- Requires root privileges for most operations\n\n**Macro base class:**\n- `Macro` - Base class for all macros with parsing and execution\n\n**Available macros:**\n- `usermod` - Modify user account (suspend/unsuspend, update theCard, trusted values)\n- `resolve` - Resolve login name to user ID\n- `passwd` - Set user's password\n- `useradd` - Create new user account with credentials\n- `chacs` - Change default permissions/acs for a user\n- `userdel` - Delete user account (soft or hard delete)\n- `chcred` - Add/delete/validate user credentials\n- `thecard` - Print user's public/private data or credentials\n\n**Key functions:**\n- `parse_macro()` - Find parser for macro command\n- `Macro.expand()` - Expand macro to list of basic commands\n- `Macro.run()` - Execute macro or explain expansion\n\n**Macro dictionary:**\n- `Macros` - Dictionary mapping macro names to instances\n\n---\n\n## Module Dependencies\n\n```\ntn-cli.py\n├── tn_globals\n├── client (run, read_cookie)\n└── commands (set_macros_module)\n\nclient.py\n├── tn_globals\n├── tinode_grpc (pb, pbx)\n├── utils (dotdict)\n├── input_handler (stdin)\n└── commands (hiMsg, loginMsg, serialize_cmd)\n\ncommands.py\n├── tn_globals\n├── tinode_grpc (pb, pbx)\n├── utils (makeTheCard, inline_image, attachment, etc.)\n└── client (handle_ctrl, handle_login, save_cookie) [for specific commands]\n\nutils.py\n├── tn_globals\n└── tinode_grpc (pb)\n\ninput_handler.py\n└── tn_globals\n\nmacros.py\n└── tn_globals\n\ntn_globals.py\n└── (no dependencies - provides shared state)\n```\n\n## Why\n\n1. **Separation of Concerns**: Each module has a clear, focused responsibility\n2. **Maintainability**: Easier to find and modify specific functionality\n3. **Testability**: Individual modules can be tested independently\n4. **Readability**: Smaller files are easier to understand\n5. **Reusability**: Utilities and client code can be reused\n6. **Extensibility**: Easy to add new macros or commands without modifying core logic\n7. **Shared State Management**: `tn_globals.py` provides centralized state accessible to all modules\n\n## Usage\n\nRun the application using:\n```bash\npython3 tn-cli.py [arguments]\n```\n\nOr make it executable:\n```bash\nchmod +x tn-cli.py\n./tn-cli.py [arguments]\n```\n"
  },
  {
    "path": "tn-cli/LICENSE",
    "content": "Code in this folder is licensed under Apache 2.0\nhttp://www.apache.org/licenses/LICENSE-2.0\n"
  },
  {
    "path": "tn-cli/README.md",
    "content": "# Command Line Client for Tinode\n\nThis is a scriptable command line chat client. It's written in Python and can be used to extend Tinode using [gRPC](https://grpc.io) [API](../pbx/).\n\nPython 2.7 or 3.4+ is required. PIP 9.0.1 or newer is required.\n\nInstall dependencies:\n```\n$ python -m pip install -r requirements.txt\n```\n\nRun the client from the command line:\n```\npython tn-cli.py --login-basic=alice:alice123\n```\n\nIf you are updating an existent installation, make sure the `tinode_grpc` version matches the [server](../server/) version. Upgrade `tinode_grpc` if needed:\n```\npython -m pip install --upgrade tinode_grpc==X.XX.XX\n```\nwhere `X.XX.XX` is the version number which must match the server version number.\n\nThe client takes optional parameters:\n\n * `--host` is the address of the gRPC server to connect to; default `localhost:16060`.\n * `--web-host` is the address of Tinode web server, used for file uploads only; default `localhost:6060`.\n * `--ssl` the server requires a secure connection (SSL)\n * `--ssl-host` the domain name to use for SNI if different from the `--host` domain name.\n * `--login-basic` is the `login:password` to be authenticated with.\n * `--login-token` is the token to be authenticated with.\n * `--login-cookie` direct the client to read the token from the cookie file `.tn-cli-cookie` generated during an earlier login.\n * `--no-login` do not login even if cookie file is present; this is the default in non-interactive (scripted) mode.\n * `--no-cookie` do not save cookie on successful login; this is the default in non-interactive (scripted) mode.\n * `--api-key` web API key for file uploads; default `AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K`\n * `--load-macros` path to a macro file.\n * `--verbose` log incoming and outgoing messages as JSON.\n * `--background` start interactive session in background; non-interactive sessions are always started in background.\n\nIf multiple `login-XYZ` are provided, `login-cookie` is considered first, then `login-token` then `login-basic`. Authentication with token (and cookie) is much faster than with the username-password pair.\n\n## Commands\n\nType `<command> -h` for help\n\nSee some of these commands in use in the [sample-script.txt](sample-script.txt). Try it as\n```\npython tn-cli.py < sample-script.txt\n```\n\n### Local (non-networking)\n\n* `.await` - issue a gRPC call and wait for completion, optionally assign result to a variable.\n* `.delmark` - use custom delete marker instead of default `DEL!`; needed when some value is to be removed rather than set to blank.\n* `.exit` - terminate execution and exit the CLI; also `.quit`.\n* `.log` - write a value of a variable to `stdout`.\n* `.must` - issue a gRPC call and wait for completion, optionally assign result to a variable; raise an exception if result is not a success.\n* `.quit` - terminate execution and exit the CLI; also `.exit`.\n* `.sleep` - suspend the process for a number of milliseconds.\n* `.use` - set default user (on_behalf_of user) or topic.\n* `.verbose` - toggle logging verbosity.\n\n### gRPC calls\n\n* `acc` - create  or modify an account\n* `login` - authenticate current session\n* `sub` - subscribe to topic\n* `leave` - detach or unsubscribe from topic\n* `pub` - post message to topic\n* `get` - query topic for metadata or messages\n* `set` - update topic metadata\n* `del` - delete message(s), topic, subscription, or user\n* `note` - send notification\n* `file` - upload or download large file out of band\n\n### HTTP requests\n\n* `upload` - (deprecated, use `file`) upload file out of band\n\n### Macros\n\nMacros are high-level wrappers for series of gRPC calls. Currently, the following macros are [available](macros.py):\n\n* `chacs` - change default permissions/acs for a user (requires root privileges)\n* `chcred` - add or delete a credential for a user (requires root privileges)\n* `passwd` - set user's password (requires root privileges)\n* `resolve` - resolve login and print the corresponding user id\n* `useradd` - create a new user account\n* `userdel` - delete user account (requires root privileges)\n* `usermod` - modify user account (requires root privileges)\n* `thecard` - print user's public and private info (requires root privileges)\n\nYou can define your own macros in [macros.py](macros.py) or create a separate python module (you can load it via `--load-macros`).\nRefer to [macros.py](macros.py) for examples.\n\n## Connecting to secure (HTTPS) server\n\nIf the server is configured to use TLS, i.e. running as `httpS://my-server.example.com/`, the gRPC endpoint also uses the same SSL certificate. In that case add the `--ssl` option.\n\nIf you want to connect to the secure gRPC endpoint over a local network or under a different name i.e. as `localhost` instead of  `my-server.example.com` in this example, you must specify the SSL domain name to use, otherwise the server will not be able to find the right SSL certificate:\n```\npython tn-cli.py --host=localhost:6001 --ssl --ssl-host=my-server.example.com\n```\nThe `--ssl-host` option makes the connection susceptible to the [Man-in-the-middle attack](https://en.wikipedia.org/wiki/Man-in-the-middle_attack), so don't do it over public networks.\n\n## Crash on shutdown\n\nPython 3.6 sometimes crashes on shutdown with a message `Fatal Python error: PyImport_GetModuleDict: no module dictionary!`. That happens because Python is buggy: https://bugs.python.org/issue26153\n"
  },
  {
    "path": "tn-cli/client.py",
    "content": "\"\"\"Tinode gRPC client operations and message handling.\"\"\"\n\nfrom __future__ import print_function\n\nimport grpc\nimport json\nimport sys\nimport time\n\nfrom tinode_grpc import pb\nfrom tinode_grpc import pbx\n\nimport tn_globals\nfrom tn_globals import printerr, stdoutln, to_json\nfrom utils import dotdict\n\n# 5 seconds timeout for .await/.must commands.\nAWAIT_TIMEOUT = 5\n\n\n# Handle {ctrl} server response\ndef handle_ctrl(ctrl):\n    # Run code on command completion\n    func = tn_globals.OnCompletion.get(ctrl.id)\n    if func:\n        del tn_globals.OnCompletion[ctrl.id]\n        if ctrl.code >= 200 and ctrl.code < 400:\n            func(ctrl.params)\n\n    if tn_globals.WaitingFor and tn_globals.WaitingFor.await_id == ctrl.id:\n        if 'varname' in tn_globals.WaitingFor:\n            tn_globals.Variables[tn_globals.WaitingFor.varname] = ctrl\n        if tn_globals.WaitingFor.failOnError and ctrl.code >= 400:\n            raise Exception(str(ctrl.code) + \" \" + ctrl.text)\n        tn_globals.WaitingFor = None\n\n    topic = \" (\" + str(ctrl.topic) + \")\" if ctrl.topic else \"\"\n    stdoutln(\"\\r<= \" + str(ctrl.code) + \" \" + ctrl.text + topic)\n\n\n# Lambda for handling login\ndef handle_login(params):\n    if params == None:\n        return None\n\n    # Protobuf map 'params' is a map which is not a python object or a dictionary. Convert it.\n    nice = {}\n    for p in params:\n        nice[p] = json.loads(params[p])\n\n    stdoutln(\"Authenticated as\", nice.get('user'))\n\n    tn_globals.AuthToken = nice.get('token')\n\n    return nice\n\n\n# Save cookie to file after successful login.\ndef save_cookie(params):\n    if params == None:\n        return\n\n    try:\n        cookie = open('.tn-cli-cookie', 'w')\n        json.dump(handle_login(params), cookie)\n        cookie.close()\n    except Exception as err:\n        stdoutln(\"Failed to save authentication cookie\", err)\n\n\n# Read cookie file for logging in with the cookie.\ndef read_cookie():\n    try:\n        cookie = open('.tn-cli-cookie', 'r')\n        params = json.load(cookie)\n        cookie.close()\n        return params.get(\"token\")\n\n    except Exception as err:\n        printerr(\"Missing or invalid cookie file '.tn-cli-cookie'\", err)\n        return None\n\n\ndef pop_from_output_queue():\n    if tn_globals.OutputQueue.empty():\n        return False\n    sys.stdout.write(\"\\r<= \"+tn_globals.OutputQueue.get())\n    sys.stdout.flush()\n    return True\n\n\n# Generator of protobuf messages.\ndef gen_message(scheme, secret, args):\n    \"\"\"Client message generator: reads user input as string,\n    converts to pb.ClientMsg, and yields\"\"\"\n    import random\n    import threading\n    from input_handler import stdin\n    from commands import hiMsg, loginMsg, serialize_cmd\n\n    random.seed()\n    id = random.randint(10000,60000)\n\n    # Asynchronous input-output\n    tn_globals.InputThread = threading.Thread(target=stdin, args=(tn_globals.InputQueue,))\n    tn_globals.InputThread.daemon = True\n    tn_globals.InputThread.start()\n\n    try:\n        from importlib.metadata import version\n    except ImportError:\n        from importlib_metadata import version\n    import platform\n\n    APP_NAME = \"tn-cli\"\n    APP_VERSION = \"3.0.1\"\n    LIB_VERSION = version(\"tinode_grpc\")\n    GRPC_VERSION = version(\"grpcio\")\n\n    user_agent = APP_NAME + \"/\" + APP_VERSION + \" (\" + \\\n        platform.system() + \"/\" + platform.release() + \"); gRPC-python/\" + LIB_VERSION + \"+\" + GRPC_VERSION\n\n    msg = hiMsg(id, args.background, user_agent, LIB_VERSION)\n    if tn_globals.Verbose:\n        stdoutln(\"\\r=> \" + to_json(msg))\n    yield msg\n\n    if scheme != None:\n        id += 1\n        login = lambda:None\n        setattr(login, 'scheme', scheme)\n        setattr(login, 'secret', secret)\n        setattr(login, 'cred', None)\n        msg = loginMsg(id, login, args)\n        if tn_globals.Verbose:\n            stdoutln(\"\\r=> \" + to_json(msg))\n        yield msg\n\n    print_prompt = True\n\n    while True:\n        try:\n            if not tn_globals.WaitingFor and tn_globals.InputQueue:\n                id += 1\n                inp = tn_globals.InputQueue.popleft()\n\n                if inp == 'exit' or inp == 'quit' or inp == '.exit' or inp == '.quit':\n                    # Drain the output queue.\n                    while pop_from_output_queue():\n                        pass\n                    return\n\n                pbMsg, cmd = serialize_cmd(inp, id, args)\n                print_prompt = tn_globals.IsInteractive\n                if isinstance(cmd, list):\n                    # Push the expanded macro back on the command queue.\n                    tn_globals.InputQueue.extendleft(reversed(cmd))\n                    continue\n                if pbMsg != None:\n                    if not tn_globals.IsInteractive:\n                        sys.stdout.write(\"=> \" + inp + \"\\n\")\n                        sys.stdout.flush()\n\n                    if cmd.synchronous:\n                        cmd.await_ts = time.time()\n                        cmd.await_id = str(id)\n                        tn_globals.WaitingFor = cmd\n\n                    if not hasattr(cmd, 'no_yield'):\n                        if tn_globals.Verbose:\n                            stdoutln(\"\\r=> \" + to_json(pbMsg))\n                        yield pbMsg\n\n            elif not tn_globals.OutputQueue.empty():\n                pop_from_output_queue()\n                print_prompt = tn_globals.IsInteractive\n\n            else:\n                if print_prompt:\n                    sys.stdout.write(\"tn> \")\n                    sys.stdout.flush()\n                    print_prompt = False\n                if tn_globals.WaitingFor:\n                    if time.time() - tn_globals.WaitingFor.await_ts > AWAIT_TIMEOUT:\n                        stdoutln(\"Timeout while waiting for '{0}' response\".format(tn_globals.WaitingFor.cmd))\n                        tn_globals.WaitingFor = None\n\n                if tn_globals.IsInteractive:\n                    time.sleep(0.1)\n                else:\n                    time.sleep(0.01)\n\n        except Exception as err:\n            stdoutln(\"Exception in generator: {0}\".format(err))\n\n\n# The main processing loop: send messages to server, receive responses.\ndef run(args, schema, secret):\n    failed = False\n    try:\n        from prompt_toolkit import PromptSession\n\n        if tn_globals.IsInteractive:\n            tn_globals.Prompt = PromptSession()\n        # Create channel with default credentials.\n        tn_globals.Connection = None\n        if args.ssl:\n            opts = (('grpc.ssl_target_name_override', args.ssl_host),) if args.ssl_host else None\n            tn_globals.Connection = grpc.secure_channel(args.host, grpc.ssl_channel_credentials(), opts)\n        else:\n            tn_globals.Connection = grpc.insecure_channel(args.host)\n\n        # Call the server\n        stream = pbx.NodeStub(tn_globals.Connection).MessageLoop(gen_message(schema, secret, args))\n\n        # Read server responses\n        for msg in stream:\n            if tn_globals.Verbose:\n                stdoutln(\"\\r<= \" + to_json(msg))\n\n            if msg.HasField(\"ctrl\"):\n                handle_ctrl(msg.ctrl)\n\n            elif msg.HasField(\"meta\"):\n                what = []\n                if len(msg.meta.sub) > 0:\n                    what.append(\"sub\")\n                if msg.meta.HasField(\"desc\"):\n                    what.append(\"desc\")\n                if msg.meta.HasField(\"del\"):\n                    what.append(\"del\")\n                if len(msg.meta.tags) > 0:\n                    what.append(\"tags\")\n                stdoutln(\"\\r<= meta \" + \",\".join(what) + \" \" + msg.meta.topic)\n\n                if tn_globals.WaitingFor and tn_globals.WaitingFor.await_id == msg.meta.id:\n                    if 'varname' in tn_globals.WaitingFor:\n                        tn_globals.Variables[tn_globals.WaitingFor.varname] = msg.meta\n                    tn_globals.WaitingFor = None\n\n            elif msg.HasField(\"data\"):\n                stdoutln(\"\\n\\rFrom: \" + msg.data.from_user_id)\n                stdoutln(\"Topic: \" + msg.data.topic)\n                stdoutln(\"Seq: \" + str(msg.data.seq_id))\n                if msg.data.head:\n                    stdoutln(\"Headers:\")\n                    for key in msg.data.head:\n                        stdoutln(\"\\t\" + key + \": \"+str(msg.data.head[key]))\n                stdoutln(json.loads(msg.data.content))\n\n            elif msg.HasField(\"pres\"):\n                # 'ON', 'OFF', 'UA', 'UPD', 'GONE', 'ACS', 'TERM', 'MSG', 'READ', 'RECV', 'DEL', 'TAGS', 'AUX'\n                what = pb.ServerPres.What.Name(msg.pres.what)\n                stdoutln(\"\\r<= pres \" + what + \" \" + msg.pres.topic)\n\n            elif msg.HasField(\"info\"):\n                switcher = {\n                    pb.READ: 'READ',\n                    pb.RECV: 'RECV',\n                    pb.KP: 'KP',\n                    pb.CALL: 'CALL'\n                }\n                stdoutln(\"\\rMessage #\" + str(msg.info.seq_id) + \" \" + switcher.get(msg.info.what, \"unknown\") +\n                    \" by \" + msg.info.from_user_id + \"; topic=\" + msg.info.topic + \" (\" + msg.topic + \")\")\n\n            else:\n                stdoutln(\"\\rMessage type not handled\" + str(msg))\n\n    except grpc.RpcError as err:\n        # print(err)\n        printerr(\"gRPC failed with {0}: {1}\".format(err.code(), err.details()))\n        failed = True\n    except Exception as ex:\n        printerr(\"Request failed: {0}\".format(ex))\n        failed = True\n    finally:\n        from tn_globals import printout\n        printout('Shutting down...')\n        tn_globals.Connection.close()\n        if tn_globals.InputThread != None:\n            tn_globals.InputThread.join(0.3)\n\n    return 1 if failed else 0\n"
  },
  {
    "path": "tn-cli/commands.py",
    "content": "\"\"\"Command parsing and message construction for tn-cli.\"\"\"\n\nfrom __future__ import print_function\n\nimport argparse\nimport base64\nimport json\nimport mimetypes\nimport os\nimport re\nimport requests\nimport shlex\nimport threading\nimport time\n\nfrom tinode_grpc import pb\nfrom tinode_grpc import pbx\n\nimport tn_globals\nfrom tn_globals import printout, stdoutln\nfrom utils import (\n    makeTheCard, inline_image, attachment, encode_to_bytes,\n    parse_cred, parse_trusted, dotdict, DELETE_MARKER, TINODE_DEL\n)\n\nAPP_NAME = \"tn-cli\"\nAPP_VERSION = \"3.0.1\"\nPROTOCOL_VERSION = \"0\"\n\n# Regex to match and parse subscripted entries in variable paths.\nRE_INDEX = re.compile(r\"(\\w+)\\[(\\w+)\\]\")\n\n# Macros module (may be None).\nmacros = None\n\n\ndef set_macros_module(m):\n    \"\"\"Set the macros module for use in command parsing.\"\"\"\n    global macros\n    macros = m\n\n\n# Create proto for ClientExtra\ndef pack_extra(cmd):\n    return pb.ClientExtra(on_behalf_of=tn_globals.DefaultUser, auth_level=pb.ROOT if cmd.as_root else pb.NONE)\n\n\n# Read a value in the server response using dot notation, i.e.\n# $user.params.token or $meta.sub[1].user\ndef getVar(path):\n    if not path.startswith(\"$\"):\n        return path\n\n    parts = path.split('.')\n    if parts[0] not in tn_globals.Variables:\n        return None\n    var = tn_globals.Variables[parts[0]]\n    if len(parts) > 1:\n        parts = parts[1:]\n        for p in parts:\n            x = None\n            m = RE_INDEX.match(p)\n            if m:\n                p = m.group(1)\n                if m.group(2).isdigit():\n                    x = int(m.group(2))\n                else:\n                    x = m.group(2)\n            var = getattr(var, p)\n            if x or x == 0:\n                var = var[x]\n    if isinstance(var, bytes):\n      var = var.decode('utf-8')\n    return var\n\n\n# Dereference values, i.e. cmd.val == $usr => cmd.val == <actual value of usr>\ndef derefVals(cmd):\n    for key in dir(cmd):\n        if not key.startswith(\"__\") and key != 'varname':\n            val = getattr(cmd, key)\n            if type(val) is str and val.startswith(\"$\"):\n                setattr(cmd, key, getVar(val))\n    return cmd\n\n\n# Constructing individual messages\n# {hi}\ndef hiMsg(id, background, user_agent, lib_version):\n    tn_globals.OnCompletion[str(id)] = lambda params: print_server_params(params)\n    return pb.ClientMsg(hi=pb.ClientHi(id=str(id), user_agent=user_agent,\n        ver=lib_version, lang=\"EN\", background=background))\n\n\n# {acc}\ndef accMsg(id, cmd, ignored):\n    if cmd.uname:\n        cmd.scheme = 'basic'\n        if cmd.password == None:\n            cmd.password = ''\n        cmd.secret = str(cmd.uname) + \":\" + str(cmd.password)\n\n    if cmd.secret:\n        if cmd.scheme == None:\n            cmd.scheme = 'basic'\n        cmd.secret = cmd.secret.encode('utf-8')\n    else:\n        cmd.secret = b''\n\n    state = None\n    if cmd.suspend == 'true':\n        state = 'susp'\n    elif cmd.suspend == 'false':\n        state = 'ok'\n\n    cmd.public = encode_to_bytes(makeTheCard(cmd.fn, cmd.note, cmd.photo))\n    cmd.private = encode_to_bytes(cmd.private)\n    return pb.ClientMsg(acc=pb.ClientAcc(id=str(id), user_id=cmd.user, state=state,\n        scheme=cmd.scheme, secret=cmd.secret, login=cmd.do_login, tags=cmd.tags.split(\",\") if cmd.tags else None,\n        desc=pb.SetDesc(default_acs=pb.DefaultAcsMode(auth=cmd.auth, anon=cmd.anon),\n            public=cmd.public, private=cmd.private, trusted=encode_to_bytes(parse_trusted(cmd.trusted))),\n        cred=parse_cred(cmd.cred)),\n        extra=pack_extra(cmd))\n\n\n# {login}\ndef loginMsg(id, cmd, args):\n    if cmd.secret == None:\n        if cmd.uname == None:\n            cmd.uname = ''\n        if cmd.password == None:\n            cmd.password = ''\n        cmd.secret = str(cmd.uname) + \":\" + str(cmd.password)\n        cmd.secret = cmd.secret.encode('utf-8')\n    elif cmd.scheme == \"basic\":\n        # Assuming secret is a uname:password string.\n        cmd.secret = str(cmd.secret).encode('utf-8')\n    else:\n        # All other schemes: assume secret is a base64-encoded string\n        cmd.secret = base64.b64decode(cmd.secret)\n\n    from client import handle_login, save_cookie\n    msg = pb.ClientMsg(login=pb.ClientLogin(id=str(id), scheme=cmd.scheme, secret=cmd.secret,\n        cred=parse_cred(cmd.cred)))\n\n    if args.no_cookie or not tn_globals.IsInteractive:\n        tn_globals.OnCompletion[str(id)] = lambda params: handle_login(params)\n    else:\n        tn_globals.OnCompletion[str(id)] = lambda params: save_cookie(params)\n\n    return msg\n\n\n# {sub}\ndef subMsg(id, cmd, ignored):\n    if not cmd.topic:\n        cmd.topic = tn_globals.DefaultTopic\n    if cmd.get_query:\n        cmd.get_query = pb.GetQuery(what=\" \".join(cmd.get_query.split(\",\")))\n    cmd.public = encode_to_bytes(makeTheCard(cmd.fn, cmd.note, cmd.photo))\n    cmd.private = TINODE_DEL if cmd.private == DELETE_MARKER else encode_to_bytes(cmd.private)\n    return pb.ClientMsg(sub=pb.ClientSub(id=str(id), topic=cmd.topic,\n        set_query=pb.SetQuery(\n            desc=pb.SetDesc(public=cmd.public, private=cmd.private,\n                            trusted=encode_to_bytes(parse_trusted(cmd.trusted)),\n                            default_acs=pb.DefaultAcsMode(auth=cmd.auth, anon=cmd.anon)),\n            sub=pb.SetSub(mode=cmd.mode),\n            tags=cmd.tags.split(\",\") if cmd.tags else None),\n        get_query=cmd.get_query),\n        extra=pack_extra(cmd))\n\n\n# {leave}\ndef leaveMsg(id, cmd, ignored):\n    if not cmd.topic:\n        cmd.topic = tn_globals.DefaultTopic\n    return pb.ClientMsg(leave=pb.ClientLeave(id=str(id), topic=cmd.topic, unsub=cmd.unsub),\n        extra=pack_extra(cmd))\n\n\n# {pub}\ndef pubMsg(id, cmd, ignored):\n    if not cmd.topic:\n        cmd.topic = tn_globals.DefaultTopic\n\n    head = {}\n    if cmd.drafty or cmd.image or cmd.attachment:\n        head['mime'] = encode_to_bytes('text/x-drafty')\n\n    # Excplicitly provided 'mime' will override the one assigned above.\n    if cmd.head:\n        for h in cmd.head.split(\",\"):\n            key, val = h.split(\":\")\n            head[key] = encode_to_bytes(val)\n\n    content = json.loads(cmd.drafty) if cmd.drafty \\\n        else inline_image(cmd.image) if cmd.image \\\n        else attachment(cmd.attachment) if cmd.attachment \\\n        else cmd.content\n\n    if not content:\n        return None\n\n    return pb.ClientMsg(pub=pb.ClientPub(id=str(id), topic=cmd.topic, no_echo=True,\n        head=head, content=encode_to_bytes(content)),\n        extra=pack_extra(cmd))\n\n\n# {get}\ndef getMsg(id, cmd, ignored):\n    if not cmd.topic:\n        cmd.topic = tn_globals.DefaultTopic\n\n    what = []\n    if cmd.desc:\n        what.append(\"desc\")\n    if cmd.sub:\n        what.append(\"sub\")\n    if cmd.tags:\n        what.append(\"tags\")\n    if cmd.data:\n        what.append(\"data\")\n    if cmd.cred:\n        what.append(\"cred\")\n    return pb.ClientMsg(get=pb.ClientGet(id=str(id), topic=cmd.topic,\n        query=pb.GetQuery(what=\" \".join(what))),\n        extra=pack_extra(cmd))\n\n\n# {set}\ndef setMsg(id, cmd, ignored):\n    if not cmd.topic:\n        cmd.topic = tn_globals.DefaultTopic\n\n    if cmd.public == None:\n        cmd.public = encode_to_bytes(makeTheCard(cmd.fn, cmd.note, cmd.photo))\n    else:\n        cmd.public = TINODE_DEL if cmd.public == DELETE_MARKER else encode_to_bytes(cmd.public)\n    cmd.private = TINODE_DEL if cmd.private == DELETE_MARKER else encode_to_bytes(cmd.private)\n    cred = parse_cred(cmd.cred)\n    if cred:\n        if len(cred) > 1:\n            stdoutln('Warning: multiple credentials specified. Will use only the first one.')\n        cred = cred[0]\n\n    return pb.ClientMsg(set=pb.ClientSet(id=str(id), topic=cmd.topic,\n        query=pb.SetQuery(\n            desc=pb.SetDesc(default_acs=pb.DefaultAcsMode(auth=cmd.auth, anon=cmd.anon),\n                public=cmd.public, private=cmd.private,\n                trusted=encode_to_bytes(parse_trusted(cmd.trusted))),\n        sub=pb.SetSub(user_id=cmd.user, mode=cmd.mode),\n        tags=cmd.tags.split(\",\") if cmd.tags else None,\n        cred=cred)),\n        extra=pack_extra(cmd))\n\n\n# {del}\ndef delMsg(id, cmd, ignored):\n    if not cmd.what:\n        stdoutln(\"Must specify what to delete\")\n        return None\n\n    enum_what = None\n    before = None\n    seq_list = None\n    cred = None\n    if cmd.what == 'msg':\n        enum_what = pb.ClientDel.MSG\n        cmd.topic = cmd.topic if cmd.topic else tn_globals.DefaultTopic\n        if not cmd.topic:\n            stdoutln(\"Must specify topic to delete messages\")\n            return None\n        if cmd.user:\n            stdoutln(\"Unexpected '--user' parameter\")\n            return None\n        if not cmd.seq:\n            stdoutln(\"Must specify message IDs to delete\")\n            return None\n\n        if cmd.seq == 'all':\n            seq_list = [pb.SeqRange(low=1, hi=0x8FFFFFF)]\n        else:\n            # Split a list like '1,2,3,10-22' into ranges.\n            try:\n                seq_list = []\n                for item in cmd.seq.split(','):\n                    if '-' in item:\n                        low, hi = [int(x.strip()) for x in item.split('-')]\n                        if low>=hi or low<=0:\n                            stdoutln(\"Invalid message ID range {0}-{1}\".format(low, hi))\n                            return None\n                        seq_list.append(pb.SeqRange(low=low, hi=hi))\n                    else:\n                        seq_list.append(pb.SeqRange(low=int(item.strip())))\n            except ValueError as err:\n                stdoutln(\"Invalid message IDs: {0}\".format(err))\n                return None\n\n    elif cmd.what == 'sub':\n        cmd.topic = cmd.topic if cmd.topic else tn_globals.DefaultTopic\n        cmd.user = cmd.user if cmd.user else tn_globals.DefaultUser\n        if not cmd.user or not cmd.topic:\n            stdoutln(\"Must specify topic and user to delete subscription\")\n            return None\n        enum_what = pb.ClientDel.SUB\n\n    elif cmd.what == 'topic':\n        cmd.topic = cmd.topic if cmd.topic else tn_globals.DefaultTopic\n        if cmd.user:\n            stdoutln(\"Unexpected '--user' parameter\")\n            return None\n        if not cmd.topic:\n            stdoutln(\"Must specify topic to delete\")\n            return None\n        enum_what = pb.ClientDel.TOPIC\n\n    elif cmd.what == 'user':\n        cmd.user = cmd.user if cmd.user else tn_globals.DefaultUser\n        if cmd.topic:\n            stdoutln(\"Unexpected '--topic' parameter\")\n            return None\n        enum_what = pb.ClientDel.USER\n\n    elif cmd.what == 'cred':\n        if cmd.user:\n            stdoutln(\"Unexpected '--user' parameter\")\n            return None\n        if cmd.topic != 'me':\n            stdoutln(\"Topic must be 'me'\")\n            return None\n        cred = parse_cred(cmd.cred)\n        if cred is None:\n            stdoutln(\"Failed to parse credential '{0}'\".format(cmd.cred))\n            return None\n        cred = cred[0]\n        enum_what = pb.ClientDel.CRED\n\n    else:\n        stdoutln(\"Unrecognized delete option '\", cmd.what, \"'\")\n        return None\n\n    msg = pb.ClientMsg(extra=pack_extra(cmd))\n    # Field named 'del' conflicts with the keyword 'del. This is a work around.\n    xdel = getattr(msg, 'del')\n    \"\"\"\n    setattr(msg, 'del', pb.ClientDel(id=str(id), topic=topic, what=enum_what, hard=hard,\n        del_seq=seq_list, user_id=user))\n    \"\"\"\n    xdel.id = str(id)\n    xdel.what = enum_what\n    if cmd.hard != None:\n        xdel.hard = cmd.hard\n    if seq_list != None:\n        xdel.del_seq.extend(seq_list)\n    if cmd.user != None:\n        xdel.user_id = cmd.user\n    if cmd.topic != None:\n        xdel.topic = cmd.topic\n    if cred != None:\n        xdel.cred.MergeFrom(cred)\n\n    return msg\n\n\n# {note}\ndef noteMsg(id, cmd, ignored):\n    if not cmd.topic:\n        cmd.topic = tn_globals.DefaultTopic\n\n    enum_what = None\n    cmd.seq = int(cmd.seq)\n    if cmd.what == 'kp':\n        enum_what = pb.KP\n        cmd.seq = None\n    elif cmd.what == 'read':\n        enum_what = pb.READ\n    elif cmd.what == 'recv':\n        enum_what = pb.RECV\n    elif cmd.what == 'call':\n        enum_what = pb.CALL\n\n    enum_event = None\n    if enum_what == pb.CALL:\n        if cmd.what == 'accept':\n            enum_event = pb.ACCEPT\n        elif cmd.what == 'answer':\n            enum_event = pb.ANSWER\n        elif cmd.what == 'ice-candidate':\n            enum_event = pb.ICE_CANDIDATE\n        elif cmd.what == 'hang-up':\n            enum_event = pb.HANG_UP\n        elif cmd.what == 'offer':\n            enum_event = pb.OFFER\n        elif cmd.what == 'ringing':\n            enum_event = pb.RINGING\n    else:\n        cmd.payload = None\n\n    return pb.ClientMsg(note=pb.ClientNote(topic=cmd.topic, what=enum_what,\n        seq_id=cmd.seq, event=enum_event, payload=cmd.payload),\n        extra=pack_extra(cmd))\n\n\n# Upload file out of band over HTTP(S) (not gRPC).\ndef upload(id, cmd, args):\n    try:\n        from client import handle_ctrl\n        scheme = 'https' if args.ssl else 'http'\n        try:\n            from importlib.metadata import version\n        except ImportError:\n            from importlib_metadata import version\n        LIB_VERSION = version(\"tinode_grpc\")\n\n        result = requests.post(\n            scheme + '://' + args.web_host + '/v' + PROTOCOL_VERSION + '/file/u/',\n            headers = {\n                'X-Tinode-APIKey': args.api_key,\n                'X-Tinode-Auth': 'Token ' + tn_globals.AuthToken,\n                'User-Agent': APP_NAME + \" \" + APP_VERSION + \"/\" + LIB_VERSION\n            },\n            data = {'id': id},\n            files = {'file': (cmd.filename, open(cmd.filename, 'rb'))})\n        handle_ctrl(dotdict(json.loads(result.text)['ctrl']))\n\n    except Exception as ex:\n        stdoutln(\"Failed to upload '{0}'\".format(cmd.filename), ex)\n\n    return None\n\n\ndef fileUpload(id, cmd, args):\n    def iter_file(filepath, size=1024*1024):\n        _, name = os.path.split(filepath)\n        mimeType = mimetypes.guess_type(filepath)[0]\n        with open(filepath, mode='rb') as fd:\n            try:\n                yield pb.FileUpReq(id=str(id), auth=pb.Auth(scheme='token', secret=tn_globals.AuthToken),\n                                   topic=\"\", meta=pb.FileMeta(name=name, mime_type=mimeType, size=0))\n                while True:\n                    chunk = fd.read(size)\n                    if chunk:\n                        yield pb.FileUpReq(content=chunk)\n                    else:  # Finished.\n                        break\n            except Exception as ex:\n                stdoutln(\"Failed to read '{0}':\".format(cmd.filename), ex)\n\n    try:\n        response = pbx.NodeStub(tn_globals.Connection).LargeFileReceive(iter_file(cmd.filename))\n        if response.code == 200:\n            stdoutln(\"Upload OK: '{0}' ({1}), size={2}\"\n                     .format(response.meta.name, response.meta.mime_type, response.meta.size))\n        else:\n            stdoutln(\"Upload failed: {0} {1}\".format(response.code, response.text))\n    except Exception as ex:\n        stdoutln(\"Failed to upload '{0}':\".format(cmd.filename), ex)\n\n\ndef fileDownload(id, cmd, args):\n    req = pb.FileDownReq(id=str(id), auth=pb.Auth(scheme='token', secret=tn_globals.AuthToken),\n                         uri=cmd.filename, if_modified=\"\")\n\n    # Call the server\n    stream = pbx.NodeStub(tn_globals.Connection).LargeFileServe(req)\n    # Read file chunks\n    fd = None\n    for chunk in stream:\n        if chunk:\n            if chunk.code >= 400:\n                stdoutln(\"Failed to download '{0}': {1} {2}\".format(cmd.filename, chunk.code, chunk.text))\n                break\n            if chunk.code >= 300:\n                stdoutln(\"Use HTTP {0} to download from {1}\".format(chunk.code, chunk.redir_url))\n                break\n            if not fd:\n                fd = open(chunk.meta.name, mode='wb')\n            fd.write(chunk.content)\n            continue\n    if fd:\n        fd.close()\n\n\n# Given an array of parts, parse commands and arguments\ndef parse_cmd(parts):\n    parser = None\n    if parts[0] == \"acc\":\n        parser = argparse.ArgumentParser(prog=parts[0], description='Create or alter an account')\n        parser.add_argument('--user', default='new', help='ID of the account to update')\n        parser.add_argument('--scheme', default=None, help='authentication scheme, default=basic')\n        parser.add_argument('--secret', default=None, help='secret for authentication')\n        parser.add_argument('--uname', default=None, help='user name for basic authentication')\n        parser.add_argument('--password', default=None, help='password for basic authentication')\n        parser.add_argument('--do-login', action='store_true', help='login with the newly created account')\n        parser.add_argument('--tags', action=None, help='tags for user discovery, comma separated list without spaces')\n        parser.add_argument('--fn', default=None, help='user\\'s human name')\n        parser.add_argument('--photo', default=None, help='avatar file name')\n        parser.add_argument('--private', default=None, help='user\\'s private info')\n        parser.add_argument('--note', default=None, help='user\\'s description')\n        parser.add_argument('--trusted', default=None, help='trusted markers: verified, staff, danger, prepend with rm- to remove, e.g. rm-verified')\n        parser.add_argument('--auth', default=None, help='default access mode for authenticated users')\n        parser.add_argument('--anon', default=None, help='default access mode for anonymous users')\n        parser.add_argument('--cred', default=None, help='credentials, comma separated list in method:value format, e.g. email:test@example.com,tel:12345')\n        parser.add_argument('--suspend', default=None, help='true to suspend the account, false to un-suspend')\n    elif parts[0] == \"del\":\n        parser = argparse.ArgumentParser(prog=parts[0], description='Delete message(s), subscription, topic, user')\n        parser.add_argument('what', default=None, help='what to delete')\n        parser.add_argument('--topic', default=None, help='topic being affected')\n        parser.add_argument('--user', default=None, help='either delete this user or a subscription with this user')\n        parser.add_argument('--seq', default=None, help='\"all\" or a list of comma- and dash-separated message IDs to delete, e.g. \"1,2,9-12\"')\n        parser.add_argument('--hard', action='store_true', help='request to hard-delete')\n        parser.add_argument('--cred', help='credential to delete in method:value format, e.g. email:test@example.com, tel:12345')\n    elif parts[0] == \"file\":\n        parser = argparse.ArgumentParser(prog=parts[0], description='Download or upload a large file')\n        parser.add_argument('--what', default='down', choices=['down', 'up'], help='download \\'down\\' or upload \\'up\\'')\n        parser.add_argument('filename', help='name of the file to upload')\n    elif parts[0] == \"get\":\n        parser = argparse.ArgumentParser(prog=parts[0], description='Query topic for messages or metadata')\n        parser.add_argument('topic', nargs='?', default=argparse.SUPPRESS, help='topic to query')\n        parser.add_argument('--topic', dest='topic', default=None, help='topic to query')\n        parser.add_argument('--desc', action='store_true', help='query topic description')\n        parser.add_argument('--sub', action='store_true', help='query topic subscriptions')\n        parser.add_argument('--tags', action='store_true', help='query topic tags')\n        parser.add_argument('--data', action='store_true', help='query topic messages')\n        parser.add_argument('--cred', action='store_true', help='query account credentials')\n    elif parts[0] == \"leave\":\n        parser = argparse.ArgumentParser(prog=parts[0], description='Detach or unsubscribe from topic')\n        parser.add_argument('topic', nargs='?', default=argparse.SUPPRESS, help='topic to detach from')\n        parser.add_argument('--topic', dest='topic', default=None, help='topic to detach from')\n        parser.add_argument('--unsub', action='store_true', help='detach and unsubscribe from topic')\n    elif parts[0] == \"login\":\n        parser = argparse.ArgumentParser(prog=parts[0], description='Authenticate current session')\n        parser.add_argument('secret', nargs='?', default=argparse.SUPPRESS, help='secret for authentication')\n        parser.add_argument('--scheme', default='basic', help='authentication schema, default=basic')\n        parser.add_argument('--secret', dest='secret', default=None, help='secret for authentication')\n        parser.add_argument('--uname', default=None, help='user name in basic authentication scheme')\n        parser.add_argument('--password', default=None, help='password in basic authentication scheme')\n        parser.add_argument('--cred', default=None, help='credentials, comma separated list in method:value:response format, e.g. email:test@example.com,tel:12345')\n    elif parts[0] == \"note\":\n        parser = argparse.ArgumentParser(prog=parts[0], description='Send notification to topic, ex \"note kp\"')\n        parser.add_argument('topic', help='topic to notify')\n        parser.add_argument('what', nargs='?', default='kp', const='kp', choices=['call', 'kp', 'read', 'recv'],\n            help='notification type: kp (key press), recv, read - message received or read receipt')\n        parser.add_argument('--seq', help='message ID being reported')\n        parser.add_argument('--event', help='video call event', choices=['accept', 'answer', 'ice-candidate', 'hang-up', 'offer', 'ringing'])\n        parser.add_argument('--payload', help='video call payload')\n    elif parts[0] == \"pub\":\n        parser = argparse.ArgumentParser(prog=parts[0], description='Send message to topic')\n        parser.add_argument('topic', nargs='?', default=argparse.SUPPRESS, help='topic to publish to')\n        parser.add_argument('--topic', dest='topic', default=None, help='topic to publish to')\n        parser.add_argument('content', nargs='?', default=argparse.SUPPRESS, help='message to send')\n        parser.add_argument('--head', help='message headers')\n        parser.add_argument('--content', dest='content', help='message to send')\n        parser.add_argument('--drafty', help='structured message to send, e.g. drafty content')\n        parser.add_argument('--image', help='image file to insert into message (not implemented yet)')\n        parser.add_argument('--attachment', help='file to send as an attachment (not implemented yet)')\n    elif parts[0] == \"set\":\n        parser = argparse.ArgumentParser(prog=parts[0], description='Update topic metadata')\n        parser.add_argument('topic', help='topic to update')\n        parser.add_argument('--fn', help='topic\\'s title')\n        parser.add_argument('--photo', help='avatar file name')\n        parser.add_argument('--public', help='topic\\'s public info, alternative to fn+photo+note')\n        parser.add_argument('--private', help='topic\\'s private info')\n        parser.add_argument('--note', default=None, help='topic\\'s description')\n        parser.add_argument('--trusted', default=None, help='trusted markers: verified, staff, danger')\n        parser.add_argument('--auth', help='default access mode for authenticated users')\n        parser.add_argument('--anon', help='default access mode for anonymous users')\n        parser.add_argument('--user', help='ID of the account to update')\n        parser.add_argument('--mode', help='new value of access mode')\n        parser.add_argument('--tags', help='tags for topic discovery, comma separated list without spaces')\n        parser.add_argument('--cred', help='credential to add in method:value format, e.g. email:test@example.com, tel:12345')\n    elif parts[0] == \"sub\":\n        parser = argparse.ArgumentParser(prog=parts[0], description='Subscribe to topic')\n        parser.add_argument('topic', nargs='?', default=argparse.SUPPRESS, help='topic to subscribe to')\n        parser.add_argument('--topic', dest='topic', default=None, help='topic to subscribe to')\n        parser.add_argument('--fn', default=None, help='topic\\'s user-visible name')\n        parser.add_argument('--photo', default=None, help='avatar file name')\n        parser.add_argument('--private', default=None, help='topic\\'s private info')\n        parser.add_argument('--note', default=None, help='topic\\'s description')\n        parser.add_argument('--trusted', default=None, help='trusted markers: verified, staff, danger')\n        parser.add_argument('--auth', default=None, help='default access mode for authenticated users')\n        parser.add_argument('--anon', default=None, help='default access mode for anonymous users')\n        parser.add_argument('--mode', default=None, help='new value of access mode')\n        parser.add_argument('--tags', default=None, help='tags for topic discovery, comma separated list without spaces')\n        parser.add_argument('--get-query', default=None, help='query for topic metadata or messages, comma separated list without spaces')\n    elif parts[0] == \"upload\":\n        parser = argparse.ArgumentParser(prog=parts[0], description='Upload file out of band over HTTP(S)')\n        parser.add_argument('filename', help='name of the file to upload')\n    elif macros:\n        parser = macros.parse_macro(parts)\n\n    if parser:\n        try:\n            parser.add_argument('--as_root', action='store_true', help='execute command at ROOT auth level')\n        except Exception:\n            # Ignore exception here: --as_root has been added already, macro parser is persistent.\n            pass\n    return parser\n\n\n# Parses command line into command and parameters.\ndef parse_input(cmd):\n    # Split line into parts using shell-like syntax.\n    try:\n        parts = shlex.split(cmd, comments=True)\n    except Exception as err:\n        printout('Error parsing command: ', err)\n        return None\n    if len(parts) == 0:\n        return None\n\n    parser = None\n    varname = None\n    synchronous = False\n    failOnError = False\n\n    if parts[0] == \".use\":\n        parser = argparse.ArgumentParser(prog=parts[0], description='Set default user or topic')\n        parser.add_argument('--user', default=\"unchanged\", help='ID of default (on_behalf_of) user')\n        parser.add_argument('--topic', default=\"unchanged\", help='Name of default topic')\n\n    elif parts[0] == \".await\" or parts[0] == \".must\":\n        # .await|.must [<$variable_name>] <waitable_command> <params>\n        if len(parts) > 1:\n            synchronous = True\n            failOnError = parts[0] == \".must\"\n            if len(parts) > 2 and parts[1][0] == '$':\n                # Varname is given\n                varname = parts[1]\n                parts = parts[2:]\n                parser = parse_cmd(parts)\n            else:\n                # No varname\n                parts = parts[1:]\n                parser = parse_cmd(parts)\n\n    elif parts[0] == \".log\":\n        parser = argparse.ArgumentParser(prog=parts[0], description='Write value of a variable to stdout')\n        parser.add_argument('varname', help='name of the variable to print')\n\n    elif parts[0] == \".sleep\":\n        parser = argparse.ArgumentParser(prog=parts[0], description='Pause execution')\n        parser.add_argument('millis', type=int, help='milliseconds to wait')\n\n    elif parts[0] == \".verbose\":\n        parser = argparse.ArgumentParser(prog=parts[0], description='Toggle logging verbosity')\n\n    elif parts[0] == \".delmark\":\n        parser = argparse.ArgumentParser(prog=parts[0], description='Use custom delete maker instead of default DEL!')\n        parser.add_argument('delmark', help='marker to use')\n\n    else:\n        parser = parse_cmd(parts)\n\n    if not parser:\n        printout(\"Unrecognized:\", parts[0])\n        printout(\"Possible commands:\")\n        printout(\"\\t.await\\t\\t- wait for completion of an operation\")\n        printout(\"\\t.delmark\\t- custom delete marker to use instead of default DEL!\")\n        printout(\"\\t.exit\\t\\t- exit the program (also .quit)\")\n        printout(\"\\t.log\\t\\t- write value of a variable to stdout\")\n        printout(\"\\t.must\\t\\t- wait for completion of an operation, terminate on failure\")\n        printout(\"\\t.sleep\\t\\t- pause execution\")\n        printout(\"\\t.use\\t\\t- set default user (on_behalf_of) or topic\")\n        printout(\"\\t.verbose\\t- toggle logging verbosity on/off\")\n        printout(\"\\tacc\\t\\t- create or alter an account\")\n        printout(\"\\tdel\\t\\t- delete message(s), topic, subscription, or user\")\n        printout(\"\\tfile\\t\\t- download or upload a large file\")\n        printout(\"\\tget\\t\\t- query topic for metadata or messages\")\n        printout(\"\\tleave\\t\\t- detach or unsubscribe from topic\")\n        printout(\"\\tlogin\\t\\t- authenticate current session\")\n        printout(\"\\tnote\\t\\t- send a notification\")\n        printout(\"\\tpub\\t\\t- post message to topic\")\n        printout(\"\\tset\\t\\t- update topic metadata\")\n        printout(\"\\tsub\\t\\t- subscribe to topic\")\n        printout(\"\\tupload\\t\\t- upload file out of band over HTTP(S)\")\n        printout(\"\\tusermod\\t\\t- modify user account\")\n        printout(\"\\n\\tType <command> -h for help\")\n\n        if macros:\n            printout(\"\\nMacro commands:\")\n            for key in sorted(macros.Macros):\n                macro = macros.Macros[key]\n                printout(\"\\t%s\\t\\t- %s\" % (macro.name(), macro.description()))\n        return None\n\n    try:\n        args = parser.parse_args(parts[1:])\n        args.cmd = parts[0]\n        args.synchronous = synchronous\n        args.failOnError = failOnError\n        if varname:\n            args.varname = varname\n        return args\n\n    except SystemExit:\n        return None\n\n\n# Process command-line input string: execute local commands, generate\n# protobuf messages for remote commands.\ndef serialize_cmd(string, id, args):\n    \"\"\"Take string read from the command line, convert in into a protobuf message\"\"\"\n    global DELETE_MARKER\n\n    messages = {\n        \"acc\": accMsg,\n        \"login\": loginMsg,\n        \"sub\": subMsg,\n        \"leave\": leaveMsg,\n        \"pub\": pubMsg,\n        \"get\": getMsg,\n        \"set\": setMsg,\n        \"del\": delMsg,\n        \"note\": noteMsg,\n    }\n    try:\n        # Convert string into a dictionary\n        cmd = parse_input(string)\n        if cmd == None:\n            return None, None\n\n        elif cmd.cmd == \"file\":\n            # Start async upload\n            target = fileUpload if cmd.what == 'up' else fileDownload\n            upload_thread = threading.Thread(target=target, args=(id, derefVals(cmd), args), name=\"file_\"+cmd.filename)\n            upload_thread.start()\n            cmd.no_yield = True\n            return True, cmd\n\n        # Process dictionary\n        elif cmd.cmd == \".log\":\n            stdoutln(getVar(cmd.varname))\n            return None, None\n\n        elif cmd.cmd == \".use\":\n            if cmd.user != \"unchanged\":\n                if cmd.user:\n                    if len(cmd.user) > 3 and cmd.user.startswith(\"usr\"):\n                        tn_globals.DefaultUser = cmd.user\n                    else:\n                        stdoutln(\"Error: user ID '{}' is invalid\".format(cmd.user))\n                else:\n                    tn_globals.DefaultUser = None\n                stdoutln(\"Default user='{}'\".format(tn_globals.DefaultUser))\n\n            if cmd.topic != \"unchanged\":\n                if cmd.topic:\n                    if cmd.topic[:3] in ['me', 'fnd', 'sys', 'usr', 'grp', 'chn']:\n                        tn_globals.DefaultTopic = cmd.topic\n                    else:\n                        stdoutln(\"Error: topic '{}' is invalid\".format(cmd.topic))\n                else:\n                    tn_globals.DefaultTopic = None\n                stdoutln(\"Default topic='{}'\".format(tn_globals.DefaultTopic))\n\n            return None, None\n\n        elif cmd.cmd == \".sleep\":\n            stdoutln(\"Pausing for {}ms...\".format(cmd.millis))\n            time.sleep(cmd.millis/1000.)\n            return None, None\n\n        elif cmd.cmd == \".verbose\":\n            tn_globals.Verbose = not tn_globals.Verbose\n            stdoutln(\"Logging is {}\".format(\"verbose\" if tn_globals.Verbose else \"normal\"))\n            return None, None\n\n        elif cmd.cmd == \".delmark\":\n            DELETE_MARKER = cmd.delmark\n            stdoutln(\"Using {} as delete marker\".format(DELETE_MARKER))\n            return None, None\n\n        elif cmd.cmd == \"upload\":\n            # Start async upload\n            upload_thread = threading.Thread(target=upload, args=(id, derefVals(cmd), args), name=\"Uploader_\"+cmd.filename)\n            upload_thread.start()\n            cmd.no_yield = True\n            return True, cmd\n\n        elif cmd.cmd in messages:\n            return messages[cmd.cmd](id, derefVals(cmd), args), cmd\n        elif macros and cmd.cmd in macros.Macros:\n            return True, macros.Macros[cmd.cmd].run(id, derefVals(cmd), args)\n\n        else:\n            stdoutln(\"Error: unrecognized: '{}'\".format(cmd.cmd))\n            return None, None\n\n    except Exception as err:\n        stdoutln(\"Error in '{0}': {1}\".format(string, err))\n        return None, None\n\n\n# Log server info.\ndef print_server_params(params):\n    servParams = []\n    for p in params:\n        servParams.append(p + \": \" + str(json.loads(params[p])))\n    stdoutln(\"\\r<= Connected to server: \" + \"; \".join(servParams))\n"
  },
  {
    "path": "tn-cli/input_handler.py",
    "content": "\"\"\"User input handling for tn-cli.\"\"\"\n\nfrom __future__ import print_function\n\nimport sys\n\nimport tn_globals\nfrom tn_globals import printerr\n\n\n# Prints prompt and reads lines from stdin.\ndef readLinesFromStdin():\n    if tn_globals.IsInteractive:\n        while True:\n            try:\n                line = tn_globals.Prompt.prompt()\n                yield line\n            except EOFError as e:\n                # Ctrl+D.\n                break\n    else:\n        # iter(...) is a workaround for a python2 bug https://bugs.python.org/issue3907\n        for cmd in iter(sys.stdin.readline, ''):\n            yield cmd\n\n\n# Stdin reads a possibly multiline input from stdin and queues it for asynchronous processing.\ndef stdin(InputQueue):\n    partial_input = \"\"\n    try:\n        for cmd in readLinesFromStdin():\n            cmd = cmd.strip()\n            # Check for continuation symbol \\ in the end of the line.\n            if len(cmd) > 0 and cmd[-1] == \"\\\\\":\n                cmd = cmd[:-1].rstrip()\n                if cmd:\n                    if partial_input:\n                        partial_input += \" \" + cmd\n                    else:\n                        partial_input = cmd\n\n                if tn_globals.IsInteractive:\n                    sys.stdout.write(\"... \")\n                    sys.stdout.flush()\n\n                continue\n\n            # Check if we have cached input from a previous multiline command.\n            if partial_input:\n                if cmd:\n                    partial_input += \" \" + cmd\n                InputQueue.append(partial_input)\n                partial_input = \"\"\n                continue\n\n            InputQueue.append(cmd)\n\n            # Stop processing input\n            if cmd == 'exit' or cmd == 'quit' or cmd == '.exit' or cmd == '.quit':\n                return\n\n    except Exception as ex:\n        printerr(\"Exception in stdin\", ex)\n\n    InputQueue.append('exit')\n"
  },
  {
    "path": "tn-cli/macros.py",
    "content": "\"\"\"Tinode command line macro definitions.\"\"\"\n\nimport argparse\nimport tn_globals\nfrom tn_globals import stdoutln\n\n\nclass Macro:\n    \"\"\"Macro base class. The external callers are expected to access\n    * self.parser - an instance of argparse.Argument parser which attempts to\n      turn a list of tokens into the corresponding argparse command.\n    * self.run() - executes the macro as instructed by the user.\"\"\"\n\n    def __init__(self):\n        self.parser = argparse.ArgumentParser(prog=self.name(), description=self.description())\n        self.add_parser_args()\n        # Explain argument.\n        self.parser.add_argument('--explain', action='store_true', help='Only print out expanded macro')\n    def name(self):\n        \"\"\"Macro name.\"\"\"\n        pass\n\n    def description(self):\n        \"\"\"Macro description.\"\"\"\n        pass\n\n    def add_parser_args(self):\n        \"\"\"Method which adds custom command line arguments.\"\"\"\n        pass\n\n    def expand(self, id, cmd, args):\n        \"\"\"Expands the macro to a list of basic Tinode CLI commands.\"\"\"\n        pass\n\n    def run(self, id, cmd, args):\n        \"\"\"Expands the macro and returns the list of commands to actually execute to the caller\n        depending on the presence of the --explain argument.\n        \"\"\"\n        cmds = self.expand(id, cmd, args)\n        if cmd.explain:\n            if cmds is None:\n                return None\n            for item in cmds:\n                stdoutln(item)\n            return []\n        return cmds\n\n\nclass Usermod(Macro):\n    \"\"\"Modifies user account. The following modes are available:\n    * suspend/unsuspend account.\n    * change user's theCard (public name, description, avatar), private comment, trusted values.\n\n    This macro requires root privileges.\"\"\"\n\n    def name(self):\n        return \"usermod\"\n\n    def description(self):\n        return 'Modify user account (requires root privileges)'\n\n    def add_parser_args(self):\n        self.parser.add_argument('userid', help='user to update')\n        self.parser.add_argument('-L', '--suspend', action='store_true', help='Suspend account')\n        self.parser.add_argument('-U', '--unsuspend', action='store_true', help='Unsuspend account')\n        self.parser.add_argument('--name', help='Public name')\n        self.parser.add_argument('--avatar', help='Avatar file name')\n        self.parser.add_argument('--comment', help='Private comment on account')\n        self.parser.add_argument('--note', help='Account description')\n        self.parser.add_argument('--trusted', help='Add/remove trusted marker: verified, staff, danger')\n\n    def expand(self, id, cmd, args):\n        if not cmd.userid:\n            return None\n\n        # Suspend/unsuspend user.\n        if cmd.suspend or cmd.unsuspend:\n            if cmd.suspend and cmd.unsuspend:\n                stdoutln(\"Cannot both suspend and unsuspend account\")\n                return None\n            new_cmd = 'acc --user %s --as_root' % cmd.userid\n            if cmd.suspend:\n                new_cmd += ' --suspend true'\n            if cmd.unsuspend:\n                new_cmd += ' --suspend false'\n            return [new_cmd]\n\n        # Change theCard.\n        varname = cmd.varname if hasattr(cmd, 'varname') and cmd.varname else '$temp'\n        set_cmd = '.must ' + varname + ' set me'\n        if cmd.name is not None:\n            set_cmd += ' --fn=\"%s\"' % cmd.name\n        if cmd.avatar is not None:\n            set_cmd += ' --photo=\"%s\"' % cmd.avatar\n        if cmd.comment is not None:\n            set_cmd += ' --private=\"%s\"' % cmd.comment\n        if cmd.note is not None:\n            set_cmd += ' --note=\"%s\"' % cmd.note\n        if cmd.trusted is not None:\n            set_cmd += ' --trusted=\"%s\" --as_root' % cmd.trusted\n        old_user = tn_globals.DefaultUser if tn_globals.DefaultUser else ''\n        return ['.use --user %s' % cmd.userid,\n                '.must sub me',\n                set_cmd,\n                '.must leave me',\n                '.use --user \"%s\"' % old_user]\n\n\nclass Resolve(Macro):\n    \"\"\"Looks up user id by login name and prints it.\"\"\"\n\n    def name(self):\n        return \"resolve\"\n\n    def description(self):\n        return \"Resolve login and print the corresponding user id\"\n\n    def add_parser_args(self):\n        self.parser.add_argument('login', help='login to resolve')\n\n    def expand(self, id, cmd, args):\n        if not cmd.login:\n            return None\n\n        varname = cmd.varname if hasattr(cmd, 'varname') and cmd.varname else '$temp'\n        return ['.must sub fnd',\n                '.must set fnd --public=basic:%s' % cmd.login,\n                '.must %s get fnd --sub' % varname,\n                '.must leave fnd',\n                '.log %s.sub[0].topic' % varname]\n\n\nclass Passwd(Macro):\n    \"\"\"Sets user's password (requires root privileges).\"\"\"\n\n    def name(self):\n        return \"passwd\"\n\n    def description(self):\n        return \"Set user's password (requires root privileges)\"\n\n    def add_parser_args(self):\n        self.parser.add_argument('userid', help='Id of the user')\n        self.parser.add_argument('-P', '--password', help='New password')\n\n    def expand(self, id, cmd, args):\n        if not cmd.userid:\n            return None\n\n        if not cmd.password:\n            stdoutln(\"Password (-P) not specified\")\n            return None\n\n        return ['acc --user %s --scheme basic --secret :%s' % (cmd.userid, cmd.password)]\n\n\nclass Useradd(Macro):\n    \"\"\"Creates a new user account.\"\"\"\n\n    def name(self):\n        return \"useradd\"\n\n    def description(self):\n        return \"Create a new user account\"\n\n    def add_parser_args(self):\n        self.parser.add_argument('login', help='User login')\n        self.parser.add_argument('-P', '--password', help='Password')\n        self.parser.add_argument('--cred', help='List of comma-separated credentials in format \"(email|tel):value1,(email|tel):value2,...\"')\n        self.parser.add_argument('--name', help='Public name of the user')\n        self.parser.add_argument('--comment', help='Private comment')\n        self.parser.add_argument('--note', help='Public description')\n        self.parser.add_argument('--trusted', help='Add/remove trusted marker: verified, staff, danger')\n        self.parser.add_argument('--tags', help='Comma-separated list of tags')\n        self.parser.add_argument('--avatar', help='Path to avatar file')\n        self.parser.add_argument('--auth', help='Default auth acs')\n        self.parser.add_argument('--anon', help='Default anon acs')\n\n    def expand(self, id, cmd, args):\n        if not cmd.login:\n            return None\n        if not cmd.password:\n            stdoutln(\"Password --password must be specified\")\n            return None\n        if not cmd.cred:\n            stdoutln(\"Must specify at least one credential: --cred.\")\n            return None\n        varname = cmd.varname if hasattr(cmd, 'varname') and cmd.varname else '$temp'\n        new_cmd = '.must ' + varname + ' acc --scheme basic --secret=\"%s:%s\" --cred=\"%s\"' % (cmd.login, cmd.password, cmd.cred)\n        if cmd.name:\n            new_cmd += ' --fn=\"%s\"' % cmd.name\n        if cmd.comment:\n            new_cmd += ' --private=\"%s\"' % cmd.comment\n        if cmd.note is not None:\n            set_cmd += ' --note=\"%s\"' % cmd.note\n        if cmd.trusted is not None:\n            set_cmd += ' --trusted=\"%s\" --as_root' % cmd.trusted\n        if cmd.tags:\n            new_cmd += ' --tags=\"%s\"' % cmd.tags\n        if cmd.avatar:\n            new_cmd += ' --photo=\"%s\"' % cmd.avatar\n        if cmd.auth:\n            new_cmd += ' --auth=\"%s\"' % cmd.auth\n        if cmd.anon:\n            new_cmd += ' --anon=\"%s\"' % cmd.anon\n        return [new_cmd]\n\n\nclass Chacs(Macro):\n    \"\"\"Modifies default acs (permissions) on a user account.\"\"\"\n\n    def name(self):\n        return \"chacs\"\n\n    def description(self):\n        return \"Change default permissions/acs for a user (requires root privileges)\"\n\n    def add_parser_args(self):\n        self.parser.add_argument('userid', help='User id')\n        self.parser.add_argument('--auth', help='New auth acs value')\n        self.parser.add_argument('--anon', help='New anon acs value')\n\n    def expand(self, id, cmd, args):\n        if not cmd.userid:\n            return None\n        if not cmd.auth and not cmd.anon:\n            stdoutln('Must specify at least either of --auth, --anon')\n            return None\n        set_cmd = '.must set me'\n        if cmd.auth:\n            set_cmd += ' --auth=%s' % cmd.auth\n        if cmd.anon:\n            set_cmd += ' --anon=%s' % cmd.anon\n        old_user = tn_globals.DefaultUser if tn_globals.DefaultUser else ''\n        return ['.use --user %s' % cmd.userid,\n                '.must sub me',\n                set_cmd,\n                '.must leave me',\n                '.use --user \"%s\"' % old_user]\n\nclass Userdel(Macro):\n    \"\"\"Deletes a user account.\"\"\"\n\n    def name(self):\n        return \"userdel\"\n\n    def description(self):\n        return \"Delete user account (requires root privileges)\"\n\n    def add_parser_args(self):\n        self.parser.add_argument('userid', help='User id')\n        self.parser.add_argument('--hard', action='store_true', help='Hard delete')\n\n    def expand(self, id, cmd, args):\n        if not cmd.userid:\n            return None\n        del_cmd = 'del user --user %s --as_root' % cmd.userid\n        if cmd.hard:\n            del_cmd += ' --hard'\n        return [del_cmd]\n\n\nclass Chcred(Macro):\n    \"\"\"Adds, deletes or validates credentials for a user account.\"\"\"\n\n    def name(self):\n        return \"chcred\"\n\n    def description(self):\n        return \"Add/delete/validate credentials (requires root privileges)\"\n\n    def add_parser_args(self):\n        self.parser.add_argument('userid', help='User id')\n        self.parser.add_argument('cred', help='Affected credential in formt method:value, e.g. email: abc@example.com, tel:17771112233')\n        self.parser.add_argument('--add', action='store_true', help='Add credential')\n        self.parser.add_argument('--rm', action='store_true', help='Delete credential')\n        self.parser.add_argument('--validate', action='store_true', help='Validate credential')\n\n    def expand(self, id, cmd, args):\n        if not cmd.userid:\n            return None\n        if not cmd.cred:\n            stdoutln('Must specify cred')\n            return None\n\n        num_actions = (1 if cmd.add else 0) + (1 if cmd.rm else 0) + (1 if cmd.validate else 0)\n        if num_actions == 0 or num_actions > 1:\n            stdoutln('Must specify exactly one action: --add, --rm, --validate')\n            return None\n        if cmd.add:\n            cred_cmd = '.must set me --cred %s' % cmd.cred\n        if cmd.rm:\n            cred_cmd = '.must del --topic me --cred %s cred' % cmd.cred\n\n        old_user = tn_globals.DefaultUser if tn_globals.DefaultUser else ''\n        return ['.use --user %s' % cmd.userid,\n                '.must sub me',\n                cred_cmd,\n                '.must leave me',\n                '.use --user \"%s\"' % old_user]\n\n\nclass Thecard(Macro):\n    \"\"\"Prints user's theCard.\"\"\"\n\n    def name(self):\n        return \"thecard\"\n\n    def description(self):\n        return \"Print theCard for a user (requires root privileges)\"\n\n    def add_parser_args(self):\n        self.parser.add_argument('userid', help='User id')\n        self.parser.add_argument('--what', choices=['desc', 'cred'], required=True, help='Type of data to print (desc - public/private data, cred - list of credentials.')\n\n    def expand(self, id, cmd, args):\n        if not cmd.userid:\n            return None\n\n        varname = cmd.varname if hasattr(cmd, 'varname') and cmd.varname else '$temp'\n        old_user = tn_globals.DefaultUser if tn_globals.DefaultUser else ''\n        return ['.use --user %s' % cmd.userid,\n                '.must sub me',\n                '.must %s get me --%s' % (varname, cmd.what),\n                '.must leave me',\n                '.use --user \"%s\"' % old_user,\n                '.log %s' % varname]\n\n\ndef parse_macro(parts):\n    \"\"\"Attempts to find a parser for the provided sequence of tokens.\"\"\"\n    global Macros\n    # parts[0] is the command.\n    macro = Macros.get(parts[0])\n    if not macro:\n        return None\n    return macro.parser\n\n\nMacros = {x.name(): x for x in [Usermod(), Resolve(), Passwd(), Useradd(), Chacs(), Userdel(), Chcred(), Thecard()]}\n"
  },
  {
    "path": "tn-cli/requirements.txt",
    "content": "futures>=3.2.0; python_version<'3'\ngrpcio>=1.40.0\nPillow>=5.4.1\nrequests>=2.21.0\ntinode-grpc>=0.22.0\nprompt_toolkit>=2.0.10\nimportlib-metadata>=1.0; python_version<'3.8'\n"
  },
  {
    "path": "tn-cli/sample-macro-script.txt",
    "content": "# This script shows what can be done with tn-cli macros. Run it as\n#\n#   python tn-cli.py --no-login < sample-macro-script.txt\n#\n# The script performs the following:\n#\n#  - Creates a new user Test User with useradd\n#  - Logs in as Xena (user has root privileges)\n#  - Changes Test User's name to 'Test User 1'\n#  - Modifies Test User's default anon acs to JR with chacs\n#  - Sets Test User's password to test456\n#  - Deletes Test User with userdel\n#  - Uses resolve to locate user Alice\n#  - Subscribes to Alice\n#  - Sends a message to Alice\n\n# Create user 'Test User'\n# .must directive ensures the command succeeds (in case of failure, the script execution stops.\n# $user is the variable that will hold the result of the command execution.\n.must $user useradd --name 'Test User' --password test123 --comment test0 \\\n  --cred email:test@example.com --avatar ./test-128.jpg --tags test,test-user \\\n  --auth=JRWPA --anon=JW test\n\n# Login as xena.\n.must $xena login --scheme=basic --secret=xena:xena123\n\n# Change test's public name.\n.must usermod $user.params[user] --name 'Test User 1'\n\n# Change test's anon acs.\n.must chacs $user.params[user] --anon=JR\n\n# Set test's password to test456.\npasswd $user.params[user] -P test456\n\n# Delete test user.\n.must userdel $user.params[user] --hard\n\n# Find Alice.\n.must $alice resolve alice\n\n# Subscribe to Alice\n.must sub $alice.sub[0].topic\n\n# Send a plain text message to Alice (async)\npub $alice.sub[0].topic 'Hello, Alice'\n\n.sleep 2000\n"
  },
  {
    "path": "tn-cli/sample-script.txt",
    "content": "# This script shows what can be done with tn-cli. Run it as\n#\n#   python tn-cli.py --no-login < sample-script.txt\n#\n# The script performs the following:\n#\n#  - Creates a new user Test User\n#  - Uses 'fnd' topic to locate user Alice\n#  - Subscribes Test User to Alice\n#  - Sends typing notification to Alice\n#  - Sends a message to Alice\n#  - Creates a group topic Test Group\n#  - Adds Alice to the Test Group\n#  - Sends a message with a drafty-formatted form to the Test Group\n#  - Deletes Test Group\n#  - Deletes Test User\n\n# Create user 'Test User'\n\n.must $user acc --scheme=basic --secret=test:test123 \\\n  --fn='Test User' --photo=./test-128.jpg --tags=test,test-user --do-login \\\n  --auth=JRWPA --anon=JW \\\n  --cred=email:test@example.com\n\n# Print out user's auth token.\n# Params is a map, thus must be addressed as params[x] instead of params.x\n.log $user.params[token]\n\n# Login and confirm credentials with a dummy response\n.must login --scheme=token --secret=$user.params[token] --cred=email::123456\n\n# Alternatively just login with an existing user.\n# .must $user login bob:bob123\n\n# Find Alice\n.must sub fnd\n.must set fnd --public=email:alice@example.com\n.must $meta get fnd --sub\n\n# Print out Alice's UID.\n.log $meta.sub[0].topic\n\n# Subscribe to Alice\n.must sub $meta.sub[0].topic\n\n# Send typing notification to Alice (async)\nnote $meta.sub[0].topic\n\n# Send a plain text message to Alice (async)\npub $meta.sub[0].topic 'Hello, Alice'\n\n# Create a new group topic Test Group\n.must $group sub new --fn='Test Group' --tags=test,test-group \\\n  --auth=JRWPA --anon=JR\n\n# Add Alice to the new topic.\n.must set $group.topic --user=$meta.sub[0].topic\n\n# Publish a drafty-formatted form to the new topic.\npub $group.topic --drafty='{\"txt\": \"What’s your favorite color?red green none\",\\\n  \"fmt\": [ {\"at\": 0, \"len\": 42, \"tp\": \"FM\"}, {\"at\": 0, \"len\": 27, \"tp\": \"ST\"},\\\n  {\"at\": 27, \"len\": 14, \"tp\": \"RW\"}, {\"at\": 27, \"len\": 3, \"key\": 0}, {\"at\": 31, \"len\": 5, \"key\": 1},\\\n  {\"at\": 37, \"len\": 4, \"key\": 2} ], \"ent\": [ {\"tp\": \"BN\", \"data\": {\"name\": \"red\", \"act\": \"pub\", \"val\": 3840}},\\\n  {\"tp\": \"BN\", \"data\": {\"name\": \"green\", \"act\": \"pub\", \"val\": 240}},\\\n  {\"tp\": \"BN\", \"data\": {\"name\": \"none\", \"act\": \"pub\"}}]}'\n\n\n# Wait 2 seconds before cleaning up.\n.sleep 2000\n\n# Delete Test Group.\n.await del topic --topic=$group.topic --hard\n\n# Delete Test User\n.await del user --hard\n\n# Wait for more messages before exiting.\n.sleep 1000\n"
  },
  {
    "path": "tn-cli/tn-cli.py",
    "content": "#!/usr/bin/env python\n# coding=utf-8\n\n\"\"\"Python implementation of Tinode command line client using gRPC.\"\"\"\n\nfrom __future__ import print_function\n\nimport argparse\nimport os\nimport platform\nimport sys\n\ntry:\n    from importlib.metadata import version\nexcept ImportError:\n    # Fallback for Python < 3.8\n    from importlib_metadata import version\n\nimport tn_globals\nfrom tn_globals import printout\nfrom client import run, read_cookie\nfrom commands import set_macros_module\n\nAPP_NAME = \"tn-cli\"\nAPP_VERSION = \"3.1.0\"  # format: 1.9.0b1\nLIB_VERSION = version(\"tinode_grpc\")\nGRPC_VERSION = version(\"grpcio\")\n\n# This is needed for gRPC SSL to work correctly.\nos.environ[\"GRPC_SSL_CIPHER_SUITES\"] = \"HIGH+ECDSA\"\n\n\n# Setup crash handler: close input reader otherwise a crash\n# makes terminal session unusable.\ndef exception_hook(type, value, traceBack):\n    if tn_globals.InputThread != None:\n        tn_globals.InputThread.join(0.3)\nsys.excepthook = exception_hook\n\n\n# Enable the following variables for debugging.\n# os.environ[\"GRPC_TRACE\"] = \"all\"\n# os.environ[\"GRPC_VERBOSITY\"] = \"INFO\"\n\n\nif __name__ == '__main__':\n    \"\"\"Parse command-line arguments. Extract host name and authentication scheme, if one is provided\"\"\"\n    version_str = APP_VERSION + \"/\" + LIB_VERSION + \"; gRPC/\" + GRPC_VERSION + \"; Python \" + platform.python_version()\n    purpose = \"Tinode command line client. Version \" + version_str + \".\"\n\n    parser = argparse.ArgumentParser(description=purpose)\n    parser.add_argument('--host', default='localhost:16060', help='address of Tinode gRPC server')\n    parser.add_argument('--web-host', default='localhost:6060', help='address of Tinode web server (for file uploads)')\n    parser.add_argument('--ssl', action='store_true', help='connect to server over secure connection')\n    parser.add_argument('--ssl-host', help='SSL host name to use instead of default (useful for connecting to localhost)')\n    parser.add_argument('--login-basic', help='login using basic authentication username:password')\n    parser.add_argument('--login-token', help='login using token authentication')\n    parser.add_argument('--login-cookie', action='store_true', help='read token from cookie file and use it for authentication')\n    parser.add_argument('--no-login', action='store_true', help='do not login even if cookie file is present; default in non-interactive (scripted) mode')\n    parser.add_argument('--no-cookie', action='store_true', help='do not save login cookie; default in non-interactive (scripted) mode')\n    parser.add_argument('--api-key', default='AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K', help='API key for file uploads')\n    parser.add_argument('--load-macros', default='./macros.py', help='path to macro module to load')\n    parser.add_argument('--version', action='store_true', help='print version')\n    parser.add_argument('--verbose', action='store_true', help='log full JSON representation of all messages')\n    parser.add_argument('--background', action='store_const', const=True, help='start interactive sessionin background (non-intractive is always in background)')\n\n    args = parser.parse_args()\n\n    if args.version:\n        printout(version_str)\n        exit()\n\n    if args.verbose:\n        tn_globals.Verbose = True\n\n    printout(purpose)\n    printout(\"Secure server\" if args.ssl else \"Server\", \"at '\"+args.host+\"'\",\n        \"SNI=\"+args.ssl_host if args.ssl_host else \"\")\n\n    schema = None\n    secret = None\n\n    if not args.no_login:\n        if args.login_token:\n            \"\"\"Use token to login\"\"\"\n            schema = 'token'\n            secret = args.login_token.encode('ascii')\n            printout(\"Logging in with token\", args.login_token)\n\n        elif args.login_basic:\n            \"\"\"Use username:password\"\"\"\n            schema = 'basic'\n            secret = args.login_basic\n            printout(\"Logging in with login:password\", args.login_basic)\n\n        elif tn_globals.IsInteractive:\n            \"\"\"Interactive mode only: try reading the cookie file\"\"\"\n            printout(\"Logging in with cookie file\")\n            schema = 'token'\n            secret = read_cookie()\n            if not secret:\n                schema = None\n\n    # Attempt to load the macro file if available.\n    macros = None\n    if args.load_macros:\n        import importlib\n        macros = importlib.import_module('macros', args.load_macros) if args.load_macros else None\n        set_macros_module(macros)\n\n    # Check if background session is specified explicitly. If not set it to\n    # True for non-interactive sessions.\n    if args.background is None and not tn_globals.IsInteractive:\n        args.background = True\n\n    sys.exit(run(args, schema, secret))\n"
  },
  {
    "path": "tn-cli/tn_globals.py",
    "content": "\"\"\"Global objects for Tinode command line client.\"\"\"\n# To make print() compatible between p2 and p3\nfrom __future__ import print_function\n\nimport json\nimport sys\nfrom collections import deque\nfrom google.protobuf.json_format import MessageToDict\ntry:\n    import Queue as queue\nexcept ImportError:\n    import queue\n\nif sys.version_info[0] >= 3:\n    # for compatibility with python2\n    unicode = str\n\n# Dictionary wich contains lambdas to be executed when server {ctrl} response is received.\nOnCompletion = {}\n\n# Outstanding request for a synchronous message.\nWaitingFor = None\n\n# Last obtained authentication token\nAuthToken = ''\n\n# IO queues and a thread for asynchronous input/output\n#InputQueue = queue.Queue()\nInputQueue = deque()\nOutputQueue = queue.Queue()\nInputThread = None\n\n# Detect if the tn-cli is running interactively or being piped.\nIsInteractive = sys.stdin.isatty()\nPrompt = None\n\n# Default values for user and topic\nDefaultUser = None\nDefaultTopic = None\n\n# Variables: results of command execution\nVariables = {}\n\n# Connection to the server\nConnection = None\n\n# Flag to enable extended logging. Useful for debugging.\nVerbose = False\n\n# Print prompts in interactive mode only.\ndef printout(*args):\n    if IsInteractive:\n        print(*args)\n\ndef printerr(*args):\n    text = \"\"\n    for a in args:\n        text = text + str(a) + \" \"\n    # Strip just the spaces here, don't strip the newline or tabs.\n    text = text.strip(\" \")\n    if text:\n        sys.stderr.write(text + \"\\n\")\n\n# Support for asynchronous input-output to/from stdin/stdout\n\n# Stdout asynchronously writes to sys.stdout\ndef stdout(*args):\n    text = \"\"\n    for a in args:\n        text = text + str(a) + \" \"\n    # Strip just the spaces here, don't strip the newline or tabs.\n    text = text.strip(\" \")\n    if text:\n        OutputQueue.put(text)\n\n# Stdoutln asynchronously writes to sys.stdout and adds a new line to input.\ndef stdoutln(*args):\n    args = args + (\"\\n\",)\n    stdout(*args)\n\n# Shorten long strings for logging.\ndef clip_long_string(obj):\n    if isinstance(obj, str) or isinstance(obj, unicode):\n        if len(obj) > 64:\n            return '<' + str(len(obj)) + ' bytes: ' + obj[:12] + '...' + obj[-12:] + '>'\n        return obj\n    elif isinstance(obj, (list, tuple)):\n        return [clip_long_string(item) for item in obj]\n    elif isinstance(obj, dict):\n        return dict((key, clip_long_string(val)) for key, val in obj.items())\n    else:\n        return obj\n\n# Convert protobuff message to json. Shorten very long strings.\ndef to_json(msg):\n    if not msg:\n        return 'null'\n    try:\n        return json.dumps(clip_long_string(MessageToDict(msg)))\n    except Exception as err:\n        stdoutln(\"Exception: {}\".format(err))\n\n    return 'exception'\n"
  },
  {
    "path": "tn-cli/utils.py",
    "content": "\"\"\"Utility functions for tn-cli.\"\"\"\n\nfrom __future__ import print_function\n\nimport base64\nimport json\nfrom PIL import Image\ntry:\n    from io import BytesIO as memory_io\nexcept ImportError:\n    from cStringIO import StringIO as memory_io\nimport mimetypes\nimport os\n\nfrom tn_globals import stdoutln\n\n# Maximum in-band (included directly into the message) attachment size which fits into\n# a message of 256K in size, assuming base64 encoding and 1024 bytes of overhead.\n# This is size of an object *before* base64 encoding is applied.\nMAX_INBAND_ATTACHMENT_SIZE = 195840\n\n# Absolute maximum attachment size to be used with the server = 8MB.\nMAX_EXTERN_ATTACHMENT_SIZE = 1 << 23\n\n# Maximum allowed linear dimension of an inline image in pixels.\nMAX_IMAGE_DIM = 768\n\n# String used as a delete marker. I.e. when a value needs to be deleted, use this string\nDELETE_MARKER = 'DEL!'\n\n# Unicode DEL character used internally by Tinode when a value needs to be deleted.\nTINODE_DEL = '␡'\n\n\n# Python is retarded.\nclass dotdict(dict):\n    \"\"\"dot.notation access to dictionary attributes\"\"\"\n    __getattr__ = dict.get\n    __setattr__ = dict.__setitem__\n    __delattr__ = dict.__delitem__\n\n\n# Pack name, description, and avatar into a theCard.\ndef makeTheCard(fn, note, photofile):\n    card = None\n\n    if (fn != None and fn.strip() != \"\") or photofile != None or note != None:\n        card = {}\n        if fn != None:\n            fn = fn.strip()\n            card['fn'] = TINODE_DEL if fn == DELETE_MARKER or fn == '' else fn\n\n        if note != None:\n            note = note.strip()\n            card['note'] = TINODE_DEL if note == DELETE_MARKER or note == '' else note\n\n        if photofile != None:\n            if photofile == '' or photofile == DELETE_MARKER:\n                # Delete the avatar.\n                card['photo'] = {\n                    'data': TINODE_DEL\n                }\n            else:\n                try:\n                    f = open(photofile, 'rb')\n                    # File extension is used as a file type\n                    mimetype = mimetypes.guess_type(photofile)\n                    if mimetype[0]:\n                        mimetype = mimetype[0].split(\"/\")[1]\n                    else:\n                        mimetype = 'jpeg'\n                    data = base64.b64encode(f.read())\n                    # python3 fix.\n                    if type(data) is not str:\n                        data = data.decode()\n                    card['photo'] = {\n                        'data': data,\n                        'type': mimetype\n                    }\n                    f.close()\n                except IOError as err:\n                    stdoutln(\"Error opening '\" + photofile + \"':\", err)\n\n    return card\n\n\n# Create drafty representation of a message with an inline image.\ndef inline_image(filename):\n    try:\n        im = Image.open(filename, 'r')\n        width = im.width\n        height = im.height\n        format = im.format if im.format else \"JPEG\"\n        if width > MAX_IMAGE_DIM or height > MAX_IMAGE_DIM:\n            # Scale the image\n            scale = min(min(width, MAX_IMAGE_DIM) / width, min(height, MAX_IMAGE_DIM) / height)\n            width = int(width * scale)\n            height = int(height * scale)\n            resized = im.resize((width, height))\n            im.close()\n            im = resized\n\n        mimetype = 'image/' + format.lower()\n        bitbuffer = memory_io()\n        im.save(bitbuffer, format=format)\n        data = base64.b64encode(bitbuffer.getvalue())\n\n        # python3 fix.\n        if type(data) is not str:\n            data = data.decode()\n\n        result = {\n            'txt': ' ',\n            'fmt': [{'len': 1}],\n            'ent': [{'tp': 'IM', 'data':\n                {'val': data, 'mime': mimetype, 'width': width, 'height': height,\n                    'name': os.path.basename(filename)}}]\n        }\n        im.close()\n        return result\n    except IOError as err:\n        stdoutln(\"Failed processing image '\" + filename + \"':\", err)\n        return None\n\n\n# Create a drafty message with an *in-band* attachment.\ndef attachment(filename):\n    try:\n        f = open(filename, 'rb')\n        # Try to guess the mime type.\n        mimetype = mimetypes.guess_type(filename)[0]\n        data = base64.b64encode(f.read())\n        # python3 fix.\n        if type(data) is not str:\n            data = data.decode()\n        result = {\n            'fmt': [{'at': -1}],\n            'ent': [{'tp': 'EX', 'data':{\n                'val': data, 'mime': mimetype, 'name':os.path.basename(filename)\n            }}]\n        }\n        f.close()\n        return result\n    except IOError as err:\n        stdoutln(\"Error processing attachment '\" + filename + \"':\", err)\n        return None\n\n\n# encode_to_bytes converts the 'src' to a byte array.\n# An object/dictionary is first converted to json string then it's converted to bytes.\n# A string is directly converted to bytes.\ndef encode_to_bytes(src):\n    if src == None:\n        return None\n    if isinstance(src, str):\n        return ('\"' + src + '\"').encode()\n    return json.dumps(src).encode('utf-8')\n\n\n# Parse credentials\ndef parse_cred(cred):\n    result = None\n    if cred != None:\n        result = []\n        for c in cred.split(\",\"):\n            parts = c.split(\":\")\n            from tinode_grpc import pb\n            result.append(pb.ClientCred(method=parts[0] if len(parts) > 0 else None,\n                value=parts[1] if len(parts) > 1 else None,\n                response=parts[2] if len(parts) > 2 else None))\n\n    return result\n\n\n# Parse trusted values: [staff,rm-verified].\ndef parse_trusted(trusted):\n    result = None\n    if trusted != None:\n        result = {}\n        for t in trusted.split(\",\"):\n            t = t.strip()\n            if t.startswith(\"rm-\"):\n                result[t[3:]] = False\n            else:\n                result[t] = True\n\n    return result\n"
  }
]