From 7b2b1026e583a181f081a32667df461e9c54d6cf Mon Sep 17 00:00:00 2001 From: Hermann Mayer Date: Wed, 8 Aug 2018 13:05:27 +0200 Subject: [PATCH] Initial commit. Signed-off-by: Hermann Mayer --- .editorconfig | 30 + .gitignore | 5 + .travis.yml | 63 ++ .travis/Makefile | 88 ++ .travis/exe/docker-glue | 18 + CHANGELOG.md | 6 + CODE_OF_CONDUCT.md | 74 ++ COPYING | 1 + Dockerfile | 46 + INSTALL.md | 37 + LICENSE | 21 + Makefile | 322 +++++++ README.md | 137 +++ README.txt | 1 + config/docker/shell/.bash_profile | 3 + config/docker/shell/.bashrc | 193 ++++ config/docker/shell/.gemrc | 2 + config/docker/shell/.inputrc | 17 + config/docker/wait-for-start | 36 + config/ejabberd.yml | 799 ++++++++++++++++ config/ejabberdctl.cfg | 187 ++++ config/mod_read_markers.yml | 3 + config/postgres/00-pg-ejabberd.sql | 423 +++++++++ config/postgres/99-pg-read-markers.sql | 12 + config/supervisor/ejabberd-admin.conf | 15 + config/supervisor/ejabberd.conf | 15 + config/supervisor/logs.conf | 13 + doc/assets/architecture.graphml | 248 +++++ doc/assets/architecture.png | Bin 0 -> 15594 bytes doc/assets/project.png | Bin 0 -> 9518 bytes doc/assets/project.xcf | Bin 0 -> 28455 bytes doc/assets/table.png | Bin 0 -> 19624 bytes doc/assets/workflow.graphml | 1061 +++++++++++++++++++++ doc/assets/workflow.png | Bin 0 -> 72956 bytes doc/concept.md | 214 +++++ docker-compose.yml | 25 + exe/compile-xmpp-specs | 45 + include/hg_read_markers.hrl | 14 + include/mod_read_markers.hrl | 8 + install.sh | 91 ++ mod_read_markers.spec | 5 + specs/mod_read_markers.spec | 52 ++ src/hg_read_markers.erl | 320 +++++++ src/mod_read_markers.erl | 190 ++++ src/mod_read_markers_sql.erl | 104 +++ tests/.gitignore | 1 + tests/config.json | 12 + tests/index.js | 58 ++ tests/lib/hljs-console.js | 96 ++ tests/lib/rooms.js | 69 ++ tests/lib/users.js | 60 ++ tests/lib/utils.js | 143 +++ tests/lib/xml-format.js | 43 + tests/package-lock.json | 1175 ++++++++++++++++++++++++ tests/package.json | 25 + tests/src/client.js | 28 + tests/src/seeds.js | 17 + tests/src/stanza-middleware.js | 28 + tests/src/stanza-validator.js | 148 +++ tests/src/testcases.js | 79 ++ tests/src/xmpp-specs.js | 27 + 61 files changed, 6953 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 .travis/Makefile create mode 100755 .travis/exe/docker-glue create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 120000 COPYING create mode 100644 Dockerfile create mode 100644 INSTALL.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 120000 README.txt create mode 100644 config/docker/shell/.bash_profile create mode 100644 config/docker/shell/.bashrc create mode 100644 config/docker/shell/.gemrc create mode 100644 config/docker/shell/.inputrc create mode 100755 config/docker/wait-for-start create mode 100644 config/ejabberd.yml create mode 100644 config/ejabberdctl.cfg create mode 100644 config/mod_read_markers.yml create mode 100644 config/postgres/00-pg-ejabberd.sql create mode 100644 config/postgres/99-pg-read-markers.sql create mode 100644 config/supervisor/ejabberd-admin.conf create mode 100644 config/supervisor/ejabberd.conf create mode 100644 config/supervisor/logs.conf create mode 100644 doc/assets/architecture.graphml create mode 100644 doc/assets/architecture.png create mode 100644 doc/assets/project.png create mode 100644 doc/assets/project.xcf create mode 100644 doc/assets/table.png create mode 100644 doc/assets/workflow.graphml create mode 100644 doc/assets/workflow.png create mode 100644 doc/concept.md create mode 100644 docker-compose.yml create mode 100755 exe/compile-xmpp-specs create mode 100644 include/hg_read_markers.hrl create mode 100644 include/mod_read_markers.hrl create mode 100755 install.sh create mode 100644 mod_read_markers.spec create mode 100644 specs/mod_read_markers.spec create mode 100644 src/hg_read_markers.erl create mode 100644 src/mod_read_markers.erl create mode 100644 src/mod_read_markers_sql.erl create mode 100644 tests/.gitignore create mode 100644 tests/config.json create mode 100755 tests/index.js create mode 100644 tests/lib/hljs-console.js create mode 100644 tests/lib/rooms.js create mode 100644 tests/lib/users.js create mode 100644 tests/lib/utils.js create mode 100644 tests/lib/xml-format.js create mode 100644 tests/package-lock.json create mode 100644 tests/package.json create mode 100644 tests/src/client.js create mode 100644 tests/src/seeds.js create mode 100644 tests/src/stanza-middleware.js create mode 100644 tests/src/stanza-validator.js create mode 100644 tests/src/testcases.js create mode 100644 tests/src/xmpp-specs.js diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a5aa269 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,30 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = true + +[*.json] +indent_style = space +indent_size = 2 + +[*.yml] +indent_style = space +indent_size = 2 + +[Makefile] +trim_trailing_whitespace = true +indent_style = tab +indent_size = 4 + +[*.sh] +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a08d153 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.travis/ejabberd* +ebin +tmp +log +*.tar.gz diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..349d444 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,63 @@ +language: erlang +otp_release: 20.2 +sudo: true +dist: trusty + +cache: + directories: + - .travis/ejabberd + - tests/node_modules + +stages: + # We want to build and test each and every branch. + - name: build + - name: test + # We just run the release stage, when we have a tag build. + - name: release + if: tag =~ .* AND type IN (push, api) + +jobs: + include: + - stage: build + install: skip + script: + # Reown the build workspace to the travis user (due to Docker, + # and caching) + - sudo chown travis:travis -R $PWD/.. + # Build the ejabberd module + - make -C .travis build + + - stage: test + install: + # Install docker-compose version 1.22.0 + - sudo rm /usr/local/bin/docker-compose + - curl -L http://bit.ly/2B4msDT > docker-compose + - chmod +x docker-compose + - sudo mv docker-compose /usr/local/bin + # Fix some travis/2000 common/1000 user id mapping issues + - source .travis/exe/docker-glue + # Install the test suite dependencies + - make install + script: + - docker --version + - docker-compose --version + - START=background make start reload test + + - stage: release + install: skip + script: + # Reown the build workspace to the travis user (due to Docker, + # and caching) + - sudo chown travis:travis -R $PWD/.. + # Setup the module version environment variable for the release + - export MOD_VERSION=${TRAVIS_TAG} + - \[ -n "${MOD_VERSION}" \] || export MOD_VERSION=latest + # Build and package the ejabberd module + - make -C .travis build package + deploy: + provider: releases + api_key: ${GITHUB_AUTH_TOKEN} + file: .travis/ejabberd-read-markers-${MOD_VERSION}.tar.gz + skip_cleanup: true + on: + tags: true diff --git a/.travis/Makefile b/.travis/Makefile new file mode 100644 index 0000000..6171883 --- /dev/null +++ b/.travis/Makefile @@ -0,0 +1,88 @@ +MAKEFLAGS += --warn-undefined-variables -j1 +SHELL := bash +.SHELLFLAGS := -eu -o pipefail -c +.DEFAULT_GOAL := all +.DELETE_ON_ERROR: +.SUFFIXES: +.PHONY: package + +# Environment switches +MODULE ?= ejabberd-read-markers +VERSION ?= 18.01 +MOD_VERSION ?= latest +SOURCE_URL ?= https://github.com/processone/ejabberd/archive/$(VERSION).tar.gz + +# Directories +INCLUDE_DIRS ?= ../include +EBIN_DIRS ?= ../ebin +SRC_DIR ?= ../src + +# Host binaries +APTGET ?= apt-get +CD ?= cd +CP ?= cp +ERLC ?= erlc +FIND ?= find +MKDIR ?= mkdir +PWD ?= pwd +SED ?= sed +SUDO ?= sudo +TAR ?= tar +TEST ?= test +WGET ?= wget + +.download-ejabberd-sources: + # Download the ejabberd $(VERSION) sources + @$(TEST) -f ejabberd/autogen.sh || ( \ + $(WGET) -O ejabberd.tar.gz $(SOURCE_URL) && \ + $(MKDIR) -p ejabberd && \ + $(TAR) xf ejabberd.tar.gz -C ejabberd --strip-components=1 \ + ) + +.install-ejabberd-build-deps: + # Install the ejabberd $(VERSION) build dependencies + @$(SUDO) $(APTGET) update -y + @$(SUDO) $(APTGET) build-dep -y ejabberd + @$(SUDO) $(APTGET) install -y libssl-dev libyaml-dev libgd-dev libwebp-dev + +.build-ejabberd: + # Build ejabberd $(VERSION) from source + @cd ejabberd && ./autogen.sh && ./configure && $(MAKE) + +.find-deps: + # Find all build dependencies + $(eval INCLUDES = $(addprefix -I ,\ + $(shell $(FIND) `$(PWD)` -type d -name include) $(INCLUDE_DIRS))) + $(eval EBINS = $(addprefix -pa ,\ + $(shell $(FIND) `$(PWD)` -type d -name ebin) $(EBIN_DIRS))) + +install: \ + .download-ejabberd-sources \ + .install-ejabberd-build-deps \ + .build-ejabberd \ + .find-deps + +build: install + # Build $(MODULE) module from source + @$(MKDIR) -p $(EBIN_DIRS) + @$(ERLC) \ + -o $(EBIN_DIRS) \ + $(INCLUDES) \ + $(EBINS) \ + -DLAGER \ + -DNO_EXT_LIB \ + $(SRC_DIR)/*.erl + +package: + # Create a new release package ($(MODULE)-$(MOD_VERSION).tar.gz) + @$(MKDIR) -p package package/{conf,sql} + @$(CP) -r $(EBIN_DIRS) package/ + @$(CP) ../LICENSE ../README.md ../INSTALL.md \ + ../mod_read_markers.spec ../CHANGELOG.md \ + package/ + @$(CP) ../config/mod_read_markers.yml package/conf/ + @$(CP) ../config/postgres/99-pg-read-markers.sql package/sql/pg.sql + @$(SED) -i -e '/\\connect/d' -e '/^$$/d' package/sql/pg.sql + @$(CD) package && \ + $(TAR) cfvz ../$(MODULE)-$(MOD_VERSION).tar.gz --owner=0 --group=0 . + @$(RM) -rf package diff --git a/.travis/exe/docker-glue b/.travis/exe/docker-glue new file mode 100755 index 0000000..c505a35 --- /dev/null +++ b/.travis/exe/docker-glue @@ -0,0 +1,18 @@ +#!/bin/bash +# +# Add a new glue user with the uid 1000 and add him to the docker and travis +# group. Then add the travis user to the glue group and reload the glue group +# on the current shell session. Then re-own the whole build directory to the +# glue user and group. +# +# @author Hermann Mayer + +# Setup the glue user +sudo useradd -m -u 1000 -G travis,docker glue +sudo usermod -aG glue travis + +# Reown the build workspace to the glue user +sudo chown glue:travis -R $PWD/.. + +# Ensure correct permissions on the SSH files +sudo chmod 600 ~/.ssh/config diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2b8aff8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +## 0.15.0 + +* Initial release of the ejabberd read markers (mod_read_markers) module +* Implementation of the initial concept (IQ handler, automatic unseen messages + increment, database (SQL) backend) +* Added a simple test suite to verify the functional state of the module diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..e05b70b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,74 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at hermann.mayer92@gmail.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/COPYING b/COPYING new file mode 120000 index 0000000..7a694c9 --- /dev/null +++ b/COPYING @@ -0,0 +1 @@ +LICENSE \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c8f0475 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +FROM hausgold/ejabberd:18.01 +MAINTAINER Hermann Mayer + +# Install custom supervisord units +COPY config/supervisor/* /etc/supervisor/conf.d/ + +# Install system packages and the ruby bundles +RUN rm -rf /var/lib/apt/lists/* && \ + apt-get update -yqqq && \ + apt-get install -y \ + build-essential libicu-dev locales sudo curl wget \ + vim bash-completion inotify-tools git libexpat1-dev \ + fakeroot dpkg-dev libssl-dev libyaml-dev libgd-dev libwebp-dev && \ + echo 'en_US.UTF-8 UTF-8' >> /etc/locale.gen && /usr/sbin/locale-gen + +# Install nodejs 8 +RUN rm -rf /var/lib/apt/lists/* && \ + curl -sL https://deb.nodesource.com/setup_10.x | bash - && \ + apt-get install -y nodejs + +# Setup additional build dependencies for ejabberd/erlang +RUN cd /tmp && \ + apt-get source ejabberd && \ + apt-get build-dep -y ejabberd + +# Setup the runtime directories for ejabberd +RUN mkdir /run/ejabberd && chmod ugo+rwx /run/ejabberd + +# Setup a contrib modules directory +RUN mkdir -p /opt/modules.d/sources && \ + chmod ugo+rwx /opt/modules.d + +# Add new app user +RUN mkdir /app && \ + adduser app --home /home/app --shell /bin/bash \ + --disabled-password --gecos "" +COPY config/docker/shell/* /home/app/ +COPY config/docker/shell/* /root/ +RUN chown app:app -R /app /home/app && \ + mkdir -p /home/app/.ssh + +# Set the root password and grant root access to app +RUN echo 'root:root' | chpasswd +RUN echo 'app ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers + +WORKDIR /app diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..6d2b572 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,37 @@ +# mod_read_markers Installation + +- [Common notes](#common-notes) +- [Manual installation](#manual-installation) +- [Manual uninstall](#manual-uninstall) +- [Automatic install on Ubuntu/Debian](#automatic-install-on-ubuntudebian) + +## Common notes + +Take care of the mod_read_markers module configuration on you ejabberd config, +otherwise the module won't be started on your instance and you cannot use the +features. The second important note is the database migration. Execute the SQL +statement on your database which was bundled on the binary relase package. + +## Manual installation + +The ejabberd project is able to compile and load contribution modules at +runtime. You just need to download the source of this module into +`~/.ejabberd-modules` directory or the one defined by the +`CONTRIB_MODULES_PATH` setting in `ejabberdctl.cfg`. There you create a +directory named `mod_read_markers`. + +Then run `ejabberdctl module_install mod_read_markers` while the ejabberd +server is running and you should see a logged info about the read markers +module was started. Then you are able to use it. + +## Manual uninstall + +Just run `ejabberdctl module_uninstall mod_read_markers` while the ejabberd +server is running and delete the `mod_read_markers` directory from your +contribution modules directory (`~/.ejabberd-modules`). + +## Automatic install on Ubuntu/Debian + +Be sure that the `ejabberd` package is installed correctly via `apt`. Then you +can use the following curl-pipe command to automatically install the module to +the ejabberd server. The server MUST be restarted afterwards. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e572858 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 HAUSGOLD + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d77ab5f --- /dev/null +++ b/Makefile @@ -0,0 +1,322 @@ +MAKEFLAGS += --warn-undefined-variables -j1 +SHELL := bash +.SHELLFLAGS := -eu -o pipefail -c +.DEFAULT_GOAL := all +.DELETE_ON_ERROR: +.SUFFIXES: +.PHONY: + +# Environment switches +MAKE_ENV ?= docker +IMAGE_VENDOR ?= hausgold +PROJECT_NAME ?= jabberreadmarkers +START ?= foreground +START_CONTAINERS ?= jabber +BUNDLE_FLAGS ?= +COMPOSE_RUN_COMMAND ?= run +COMPOSE_RUN_SHELL_FLAGS ?= --rm +BASH_RUN_SHELL_FLAGS ?= +BASH_RUN_SHELL_USER ?= app +BASH_RUN_SHELL_CONTAINER ?= jabber +MODULE ?= mod_read_markers +DOMAIN ?= jabber.local +DATABASE ?= jabber + +# Directories +APP_DIR ?= /app +LOG_DIR ?= log +TMP_DIR ?= tmp +VENDOR_DIR ?= vendor/bundle +VENDOR_CACHE_DIR ?= vendor/cache + +# Host binaries +AWK ?= awk +BASH ?= bash +CHMOD ?= chmod +COMPOSE ?= docker-compose +CUT ?= cut +CP ?= cp +DOCKER ?= docker +ECHO ?= echo +FIND ?= find +GREP ?= grep +HEAD ?= head +INOTIFYWAIT ?= inotifywait +LS ?= ls +MKDIR ?= mkdir +MV ?= mv +NODE ?= node +NPM ?= npm +NPROC ?= nproc +PRINTF ?= printf +RM ?= rm +SED ?= sed +SLEEP ?= sleep +TAIL ?= tail +TEE ?= tee +TEST ?= test +TOUCH ?= touch +WC ?= wc +XARGS ?= xargs + +# Container binaries +COMPILE_XMPP_SPECS ?= exe/compile-xmpp-specs +EJABBERDCTL ?= ejabberdctl +PSQL ?= psql +WAITFORSTART ?= config/docker/wait-for-start + +ifeq ($(MAKE_ENV),docker) +# Check also the docker binaries +CHECK_BINS += COMPOSE DOCKER +else ifeq ($(MAKE_ENV),baremetal) +# Nothing to do here - just a env check +else +$(error MAKE_ENV got an invalid value. Use `docker` or `baremetal`) +endif + +all: + # Jabber Read Markers + # + # install Install the application + # start Start the application + # stop Stop all running containers + # + # logs Monitor the started application + # relevant-logs Show only relevant logs (with [RM] prefix) + # + # shell Attach an interactive shell session (jabber) + # + # reload Uninstall, check and build, install at once + # uninstall-module Uninstall the $(MODULE) module + # build Check and build the $(MODULE) module + # install-module Install the $(MODULE) module + # + # watch Watch for file changes and reload the module and + # run the test suite against it + # + # test Run the test suite + # + # clean Clean all temporary application files + # clean-containers Clean the Docker containers (also database data) + # distclean Same as clean and cleans Docker images + +# Define a generic shell run wrapper +# $1 - The command to run +ifeq ($(MAKE_ENV),docker) +define run-shell + $(COMPOSE) $(COMPOSE_RUN_COMMAND) $(COMPOSE_RUN_SHELL_FLAGS) \ + -e LANG=en_US.UTF-8 -e LANGUAGE=en_US.UTF-8 -e LC_ALL=en_US.UTF-8 \ + -u $(BASH_RUN_SHELL_USER) $(BASH_RUN_SHELL_CONTAINER) \ + bash $(BASH_RUN_SHELL_FLAGS) -c 'sleep 0.1; echo; $(1)' +endef +else ifeq ($(MAKE_ENV),baremetal) +define run-shell + $(1) +endef +endif + +# Define a retry helper +# $1 - The command to run +define retry + if eval "$(call run-shell,$(1))"; then exit 0; fi; \ + for i in 1; do sleep 10s; echo "Retrying $$i..."; \ + if eval "$(call run-shell,$(1))"; then exit 0; fi; \ + done; \ + exit 1 +endef + +COMPOSE := $(COMPOSE) -p $(PROJECT_NAME) + +.start: install clean-tmpfiles + @$(eval BASH_RUN_SHELL_FLAGS = --login) + +.jabber: + @$(eval BASH_RUN_SHELL_CONTAINER = jabber) + @$(eval COMPOSE_RUN_COMMAND = exec) + @$(eval BASH_RUN_SHELL_USER = root) + @$(eval COMPOSE_RUN_SHELL_FLAGS = ) + +.database: + @$(eval BASH_RUN_SHELL_CONTAINER = db) + @$(eval COMPOSE_RUN_COMMAND = exec) + @$(eval BASH_RUN_SHELL_USER = root) + @$(eval COMPOSE_RUN_SHELL_FLAGS = ) + +.test: + @$(eval BASH_RUN_SHELL_CONTAINER = jabber) + @$(eval COMPOSE_RUN_COMMAND = exec) + @$(eval BASH_RUN_SHELL_USER = app) + @$(eval COMPOSE_RUN_SHELL_FLAGS = ) + +.disable-module-conf: + # Disable $(MODULE) configuration + @$(CP) config/ejabberd.yml config/ejabberd.yml.old + @$(SED) 's/^\(\s*\)\(mod_read_markers:.*\)/\1# \2/g' \ + config/ejabberd.yml.old > config/ejabberd.yml + @$(RM) config/ejabberd.yml.old + +.enable-module-conf: + # Enable $(MODULE) configuration + @$(CP) config/ejabberd.yml config/ejabberd.yml.old + @$(SED) 's/^\(\s*\)# \(mod_read_markers:.*\)/\1\2/g' \ + config/ejabberd.yml.old > config/ejabberd.yml + @$(RM) config/ejabberd.yml.old + +install: .test + # Install the application + @$(eval COMPOSE_RUN_COMMAND = run) + @$(call retry,cd tests && $(NPM) install) + +.wait-for-start: + # Monitor the started application until it is booted + @COMPOSE='$(COMPOSE)' $(WAITFORSTART) + +start: clean-tmpfiles clean-logs .disable-module-conf + # Start the application +ifeq ($(START),foreground) + @$(COMPOSE) up $(START_CONTAINERS) +else ifeq ($(START),background) + @$(COMPOSE) up -d $(START_CONTAINERS) + @$(MAKE) .wait-for-start +else + $(error START got an invalid value. Use `foreground` or `background`) +endif + +uninstall-module: .jabber + # Uninstall the $(MODULE) module + @-$(call run-shell,$(EJABBERDCTL) module_uninstall $(MODULE)) + +build: .jabber uninstall-module + # Check and build the $(MODULE) module + @$(call run-shell,$(EJABBERDCTL) module_check $(MODULE)) + +.update-build-number: + @$(eval VERSION = $(shell \ + $(GREP) -oP '\d+\.\d+\.\d+-\d+' include/mod_read_markers.hrl)) + @$(eval BUILD = $(lastword $(subst -, ,$(VERSION)))) + @$(eval NEXT_BUILD = $(shell $(ECHO) $$(($(BUILD)+1)))) + @$(eval NEXT_VERSION = $(firstword $(subst -, ,$(VERSION)))-$(NEXT_BUILD)) + # Update build number ($(VERSION) -> $(NEXT_VERSION)) + @$(SED) -i 's/$(VERSION)/$(NEXT_VERSION)/g' include/mod_read_markers.hrl + +install-module: .jabber .update-build-number .enable-module-conf + # Install the $(MODULE) module + @$(call run-shell,\ + $(MKDIR) -p ebin && $(CHMOD) 777 ebin && \ + $(EJABBERDCTL) module_install $(MODULE)) + +reload: .update-build-number .enable-module-conf \ + uninstall-module install-module + @$(SLEEP) 1 + # Reloaded the $(MODULE) module + +restart-module: .jabber + # Reload the module code and restart the module (0 means success) + @$(call run-shell,$(EJABBERDCTL) restart_module $(DOMAIN) $(MODULE)) + +specs: .jabber + # Build the XMPP/XML specs + @$(call run-shell,$(COMPILE_XMPP_SPECS)) + +watch: .jabber + # Watch for file changes and reload the $(MODULE) module + @while true; do \ + $(INOTIFYWAIT) --quiet -r `pwd` -e close_write --format "%e -> %w%f"; \ + $(SHELL) -c "reset; \ + $(MAKE) --no-print-directory reload test || true"; $(ECHO); done + +test: clean-database .test + # Run the test suite + @$(call run-shell,$(NODE) tests/index.js) + +restart: + # Restart the application + @$(MAKE) stop start + +logs: + # Monitor the started application + @$(COMPOSE) logs -f --tail='all' + +relevant-logs: + # Monitor all relevant logs + @$(COMPOSE) logs -f --tail='0' | $(GREP) --line-buffered '\[RM\]' + +stop: clean-containers +stop-containers: + # Stop all running containers + @$(COMPOSE) stop -t 5 || true + @$(DOCKER) ps -a | $(GREP) $(PROJECT_NAME)_ | $(CUT) -d ' ' -f1 \ + | $(XARGS) -rn10 $(DOCKER) stop -t 5 || true + +shell: .test + # Start an interactive shell session + @$(eval BASH_RUN_SHELL_USER = app) + @$(call run-shell,$(BASH) -i) + +shell-db: .database + # Start an interactive database session + @$(call run-shell,PGPASSWORD=postgres $(PSQL) $(DATABASE) postgres) + +clean-database: .database + # Clean the database tables + @$(call run-shell,PGPASSWORD=postgres $(PSQL) $(DATABASE) postgres -c \ + "TRUNCATE TABLE archive CASCADE; \ + TRUNCATE TABLE archive_prefs CASCADE; \ + TRUNCATE TABLE muc_online_room CASCADE; \ + TRUNCATE TABLE muc_online_users CASCADE; \ + TRUNCATE TABLE muc_registered CASCADE; \ + TRUNCATE TABLE muc_room CASCADE; \ + TRUNCATE TABLE muc_room_subscribers CASCADE; \ + TRUNCATE TABLE read_messages CASCADE; \ + TRUNCATE TABLE sm CASCADE; \ + TRUNCATE TABLE spool CASCADE; \ + TRUNCATE TABLE sr_user CASCADE;" \ + >/dev/null 2>&1) + +clean-vendors: + # Clean vendors + @$(RM) -rf $(VENDOR_DIR) || true + @$(RM) -rf .bundle + +clean-logs: + # Clean logs + @$(MKDIR) -p $(LOG_DIR) + @$(FIND) $(LOG_DIR) -type f -name *.log \ + | $(XARGS) -rn1 -I{} $(BASH) -c '$(PRINTF) "\n" > {}' + @$(TOUCH) $(LOG_DIR)/ejabberd.log + +clean-tmpfiles: + # Clean temporary files + @$(MKDIR) -p $(TMP_DIR) + @$(RM) -rf $(TMP_DIR)/build || true + @$(FIND) $(TMP_DIR) -type f \ + | $(XARGS) -rn1 -I{} $(BASH) -c "$(RM) '{}'" + @$(RM) -rf ebin + +clean-containers: stop-containers + # Stop and kill all containers + @$(COMPOSE) rm -vf || true + @$(DOCKER) ps -a | $(GREP) $(PROJECT_NAME)_ | $(CUT) -d ' ' -f1 \ + | $(XARGS) -rn10 $(DOCKER) rm -vf || true + +clean-images: clean-containers + # Remove all docker images + $(eval EMPTY = ) $(eval CLEAN_IMAGES = $(PROJECT_NAME)_) + $(eval CLEAN_IMAGES += $(IMAGE_VENDOR)/app:$(PROJECT_NAME)) + $(eval CLEAN_IMAGES += ) + @$(DOCKER) images -a --format '{{.ID}} {{.Repository}}:{{.Tag}}' \ + | $(GREP) -P "$(subst $(EMPTY) $(EMPTY),|,$(CLEAN_IMAGES))" \ + | $(AWK) '{print $$0}' \ + | $(XARGS) -rn1 $(DOCKER) rmi -f || true + +clean-test-results: + # Clean test results + @$(RM) -rf coverage || true + @$(RM) -rf snapshots || true + +clean-vendor-cache: + # Clean the vendor cache + @$(RM) -rf $(VENDOR_CACHE_DIR) || true + +clean: clean-vendors clean-logs clean-tmpfiles clean-containers +distclean: clean clean-vendor-cache clean-images diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f3374c --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +![ejabberd Read Markers](doc/assets/project.png) + +[![Build Status](https://api.travis-ci.com/hausgold/ejabberd-read-markers.svg?token=4XcyqxxmkyBSSV3wWRt7&branch=master)](https://travis-ci.com/hausgold/ejabberd-read-markers) + +This is a custom [ejabberd](https://www.ejabberd.im/) module which allows users +to acknowledge/retrieve their last read message on a multi user conference. +There is also an unseen counter per user per room which gets updated on new +messages on the room. Think of the WhatsApp read message markers, with this +module and a custom client you can implement the same feature. You can find +[further details of the concept](./doc/concept.md) to learn more about the +client usage. This project comes with a self-contained test setup with all +required parts of the stack. powerful. + +- [Requirements](#requirements) + - [Runtime](#runtime) + - [Build and development](#build-and-development) +- [Installation](#installation) +- [Configuration](#configuration) + - [Database](#database) +- [Development](#development) + - [Getting started](#getting-started) + - [mDNS host configuration](#mdns-host-configuration) + - [Test suite](#test-suite) +- [Additional readings](#additional-readings) + +## Requirements + +### Runtime + +* [ejabberd](https://www.ejabberd.im/) (=18.01) +* [PostgreSQL](https://www.postgresql.org/) (>=9.0) + +### Build and development + +* [GNU Make](https://www.gnu.org/software/make/) (>=4.2.1) +* [Docker](https://www.docker.com/get-docker) (>=17.09.0-ce) +* [Docker Compose](https://docs.docker.com/compose/install/) (>=1.22.0) + +## Installation + +See the [detailed installation instructions](./INSTALL.md) to get the ejabberd +module up and running. When you are using Debian/Ubuntu, you can use an +automatic curl pipe script which simplifies the installation process for you. + +## Configuration + +We make use of the global database settings of ejabberd, but you can also +specify a different database type by setting it explicitly. + +```yaml +modules: + mod_read_markers: + db_type: sql +``` + +Keep in mind that this implementation just features the `sql` database type, +and only this. + +### Database + +The concept outlined the `read_messages` table definition which is required to +store the read messages per user per room. The [actual SQL +schema](./config/postgres/99-pg-read-markers.sql) MUST be executed on +the Jabber service database (PostgreSQL). + +## Development + +### Getting started + +The project bootstrapping is straightforward. We just assume you took already +care of the requirements and you have your favorite terminal emulator pointed +on the project directory. Follow the instructions below and then relaxen and +watchen das blinkenlichten. + +```bash +# Installs and starts the ejabberd server and it's database +$ make start + +# (The jabber server should already running now on its Docker container) + +# Open a new terminal on the project path, +# install the custom module and run the test suite +$ make reload test +``` + +When your host mDNS Stack is fine, you can also inspect the [ejabberd admin +webconsole](http://jabber.local/admin) with +`admin@jabber.local` as username and `defaultpw` as password. In the +case you want to shut this thing down use `make stop`. + +#### mDNS host configuration + +If you running Ubuntu, everything should be in place out of the box. When +you however find yourself unable to resolve the domains, read on. + +**Heads up:** This is the Arch Linux way. (package and service names may +differ, config is the same) Install the `nss-mdns` and `avahi` packages, enable +and start the `avahi-daemon.service`. Then, edit the file /etc/nsswitch.conf +and change the hosts line like this: + +```bash +hosts: ... mdns4_minimal [NOTFOUND=return] resolve [!UNAVAIL=return] dns ... +``` + +**Further readings** +* Archlinux howto: https://wiki.archlinux.org/index.php/avahi +* Ubuntu/Debian howto: https://wiki.ubuntuusers.de/Avahi/ + +### Test suite + +The test suite sets up a simple environment with 3 independent users. (admin, +alice and bob). A new test room is created by the admin user, as well as alice +and bob were made members by setting their affiliations on the room. (This is +the same procedure we use on production for lead/user/agent integrations on the +Jabber service) The suite performs then some common tasks on the service: +sending two text messages, acknowledge the last read message of the admin user +and last but not least retrieve the last read message for multiple users. The +database table contains then 3 records (user per room). + +The test suite was written in JavaScript and is executed by Node.js inside a +Docker container. We picked JavaScript here due to the easy and good featured +[stanza.io](http://stanza.io) client library for XMPP. It got all the things +which were needed to fulfil the job. + +## Additional readings + +* [mod_mam MUC IQ integration](http://bit.ly/2M2cSWl) +* [mod_mam MUC message integration](http://bit.ly/2Kx69iF) +* [mod_muc implementation](http://bit.ly/2AJTSYq) +* [mod_muc_room implementation](http://bit.ly/2LX6As4) +* [mod_muc_room IQ implementation](http://bit.ly/2LWgXfI) +* [muc_filter_message hook example](http://bit.ly/2Oey9K0) +* [MUC message definition](http://bit.ly/2MavaVo) +* [MUCState definition](http://bit.ly/2AM4CWi) +* [XMPP codec API docs](http://bit.ly/2LXQ235) +* [XMPP codec guide](http://bit.ly/2LHKFoq) +* [XMPP codec script example](http://bit.ly/2M8sgNM) diff --git a/README.txt b/README.txt new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/README.txt @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/config/docker/shell/.bash_profile b/config/docker/shell/.bash_profile new file mode 100644 index 0000000..45cb87e --- /dev/null +++ b/config/docker/shell/.bash_profile @@ -0,0 +1,3 @@ +if [ -f ~/.bashrc ]; then + . ~/.bashrc +fi diff --git a/config/docker/shell/.bashrc b/config/docker/shell/.bashrc new file mode 100644 index 0000000..b364623 --- /dev/null +++ b/config/docker/shell/.bashrc @@ -0,0 +1,193 @@ +# ~/.bashrc: executed by bash(1) for non-login shells. +# see /usr/share/doc/bash/examples/startup-files (in the package bash-doc) +# for examples + +export LANG=en_US.UTF-8 +export LANGUAGE=en_US.UTF-8 +export LC_ALL=en_US.UTF-8 + +_GEM_PATHS=$(ls -d1 ${HOME}/.gem/ruby/*/bin 2>/dev/null | paste -sd ':') +_APP_PATHS=$(ls -d1 /app/vendor/bundle/ruby/*/bin 2>/dev/null | paste -sd ':') + +export PATH="${_GEM_PATHS}:${_APP_PATHS}:${PATH}" +export PATH="/app/node_modules/.bin:${HOME}/.bin:/app/bin:${PATH}" + +if [ "${MDNS_STACK}" = 'true' ]; then + # Disable the autostart of all supervisord units + sudo sed -i 's/autostart=.*/autostart=false/g' /etc/supervisor/conf.d/* + + # Start the supervisord (empty, no units) + sudo supervisord >/dev/null 2>&1 & + + # Wait for supervisord + while ! supervisorctl status >/dev/null 2>&1; do sleep 1; done + + # Boot the mDNS stack + echo '# Start the mDNS stack' + sudo supervisorctl start dbus avahi + echo +fi + +if [ "${SSH_STACK}" = 'true' ]; then + # Start the ssh-agent + echo '# Start the SSH agent' + eval "$(ssh-agent -s)" >/dev/null + + # Run a user script for adding the relevant ssh keys + if [ -f ~/.ssh/add-all ]; then + . ~/.ssh/add-all + fi +fi + +# If not running interactively, don't do anything +case $- in + *i*) ;; + *) return;; +esac + +# Clear the color for the first time +echo -e "\e[0m" + +export HISTCONTROL="ignoreboth:erasedups" +export HISTSIZE=1000000 + +# Enable less mouse scrolling +export LESS=-r + +# Default Editor +export EDITOR=vim + +# set variable identifying the chroot you work in (used in the prompt below) +if [ -z "${debian_chroot:-}" ] && [ -r /etc/debian_chroot ]; then + debian_chroot=$(cat /etc/debian_chroot) +fi + +# If this is an xterm set the title to user@host:dir +case "$TERM" in +xterm*|rxvt*) + PS1="\[\e]0;${debian_chroot:+($debian_chroot)}\u@\h: \w\a\]$PS1" + ;; +*) + ;; +esac + +# enable color support of ls and also add handy aliases +if [ -x /usr/bin/dircolors ]; then + test -r ~/.dircolors && eval "$(dircolors -b ~/.dircolors)" \ + || eval "$(dircolors -b)" +fi + +if [ -f ~/.bash_aliases ]; then + . ~/.bash_aliases +fi + +# enable programmable completion features (you don't need to enable +# this, if it's already enabled in /etc/bash.bashrc and /etc/profile +# sources /etc/bash.bashrc). +if ! shopt -oq posix; then + if [ -f /usr/share/bash-completion/bash_completion ]; then + . /usr/share/bash-completion/bash_completion + elif [ -f /etc/bash_completion ]; then + . /etc/bash_completion + fi +fi + +export COLOR_OPTIONS='--color=auto' + +alias ..="cd .." +alias ...="cd ../.." +alias ....="cd ../../.." +alias .....="cd ../../../.." +alias ls='ls $COLOR_OPTIONS --group-directories-first --time-style="+%F, %T "' +alias ll='ls $COLOR_OPTIONS -lh' +alias l='ls $COLOR_OPTIONS -lAh' +alias grep='grep $COLOR_OPTIONS' +alias egrep='egrep $COLOR_OPTIONS' +alias g='git' +alias p='pwd' +alias mkdir='mkdir -p -v' +alias less='less -R' +alias x='exit' + +# Bash won't get SIGWINCH if another process is in the foreground. +# Enable checkwinsize so that bash will check the terminal size when +# it regains control. #65623 +# http://cnswww.cns.cwru.edu/~chet/bash/FAQ (E11) +shopt -s checkwinsize + +# Enable history appending instead of overwriting. +shopt -s histappend + +# Enable extended globbing +shopt -s extglob + +# Enable globbing for dotfiles +shopt -s dotglob + +# Enable globstars for recursive globbing +shopt -s globstar + +# Auto "cd" when entering just a path +shopt -s autocd + +# Disable XOFF (interrupt data flow) +stty -ixoff + +# Disable XON (interrupt data flow) +stty -ixon + +bind "set completion-ignore-case on" # note: bind used instead of sticking these in .inputrc +bind "set bell-style none" # no bell +bind "set show-all-if-ambiguous On" # show list automatically, without double tab + +# use ctl keys to move forward and back in words +bind '"\e[1;5C": forward-word' +bind '"\e[1;5D": backward-word' +bind '"\e[5C": forward-word' +bind '"\e[5D": backward-word' +bind '"\e\e[C": forward-word' +bind '"\e\e[D": backward-word' + +# use arrow keys to fast search +bind '"\e[A": history-search-backward' +bind '"\e[B": history-search-forward' + +# Enable colors for ls, etc. Prefer ~/.dir_colors #64489 +if type -P dircolors >/dev/null ; then + if [[ -f ~/.dir_colors ]] ; then + eval $(dircolors -b ~/.dir_colors) + elif [[ -f /etc/DIR_COLORS ]] ; then + eval $(dircolors -b /etc/DIR_COLORS) + fi +fi + +function watch-run() +{ + while [ 1 ]; do + inotifywait --quiet -r `pwd` -e close_write --format '%e -> %w%f' + bash -c "$@" + done +} + +PROMPT_COMMAND='RET=$?;' +RET_OUT='$(if [[ $RET = 0 ]]; then echo -ne "\[\e[0;32m\][G]"; else echo -ne "\[\e[0;31m\][Err: $RET]"; fi;)' +RET_OUT="\n$RET_OUT" + +HOST="${MDNS_HOSTNAME}" +if [ -z "${HOST}" ]; then + HOST="\h" +fi + +_TIME='\t' +_FILES="\$(ls -a1 | grep -vE '\.$' | wc -l)" +_SIZE="\$(ls -lah | head -n1 | cut -d ' ' -f2)" +_META="${_TIME} | Files: ${_FILES} | Size: ${_SIZE} | \[\e[0;36m\]\w" +META=" \[\e[0;31m\][\[\e[1;37m\]${_META}\[\e[0;31m\]]\[\e[0;32m\]\033]2;\w\007" + +PSL1=${RET_OUT}${META} +PSL2="\n\[\e[0;31m\][\u\[\e[0;33m\]@\[\e[0;37m\]${HOST}\[\e[0;31m\]] \[\e[0;31m\]$\[\e[0;32m\] " + +export PS1=${PSL1}${PSL2} + +# Rebind enter key to insert newline before command output +trap 'echo -e "\e[0m"' DEBUG diff --git a/config/docker/shell/.gemrc b/config/docker/shell/.gemrc new file mode 100644 index 0000000..22fd901 --- /dev/null +++ b/config/docker/shell/.gemrc @@ -0,0 +1,2 @@ +install: --no-ri --no-rdoc +update: --no-ri --no-rdoc diff --git a/config/docker/shell/.inputrc b/config/docker/shell/.inputrc new file mode 100644 index 0000000..4414089 --- /dev/null +++ b/config/docker/shell/.inputrc @@ -0,0 +1,17 @@ +# mappings for Ctrl-left-arrow and Ctrl-right-arrow for word moving +"\e[1;5C": forward-word +"\e[1;5D": backward-word +"\e[5C": forward-word +"\e[5D": backward-word +"\e\e[C": forward-word +"\e\e[D": backward-word + +# handle common Home/End escape codes +"\e[1~": beginning-of-line +"\e[4~": end-of-line +"\e[7~": beginning-of-line +"\e[8~": end-of-line +"\eOH": beginning-of-line +"\eOF": end-of-line +"\e[H": beginning-of-line +"\e[F": end-of-line diff --git a/config/docker/wait-for-start b/config/docker/wait-for-start new file mode 100755 index 0000000..f311b78 --- /dev/null +++ b/config/docker/wait-for-start @@ -0,0 +1,36 @@ +#!/bin/bash +# +# @author Hermann Mayer + +if [ -z "${COMPOSE}" ]; then + COMPOSE='docker-compose' +fi + +# We wait and print the logs until we find this regex on the output +STOP_TRIGGER='Executing command ejabberd_admin:register' + +# Save the current pid for bad times +CURRENT_PID=$$ + +# Read every line form the logs we follow +${COMPOSE} logs -f --tail="all" | while read -t 30 LINE; do + # Proxy every line from the log to stdout + echo -e "${LINE}" + # Search for the stop trigger + if [ -n "`echo "${LINE}" | grep "${STOP_TRIGGER}"`" ]; then + echo + echo "# Start completed." + # We found our stop trigger, now we need to kill the + # logs we are following. This is hairy because of the + # BSD/GNU compatibility, so we drop every comfort and + # be POSIX only. + ps xao ppid,pid,command \ + | sed 's/^\s\+//g' \ + | grep "^${CURRENT_PID} " \ + | grep "${COMPOSE} logs -f --tail" \ + | awk '{print $2}' \ + | xargs -n1 kill -9 + # For safety reasons we die, too + exit 0 + fi +done diff --git a/config/ejabberd.yml b/config/ejabberd.yml new file mode 100644 index 0000000..82e5b02 --- /dev/null +++ b/config/ejabberd.yml @@ -0,0 +1,799 @@ +### +###' ejabberd configuration file +### +### + +### The parameters used in this configuration file are explained in more detail +### in the ejabberd Installation and Operation Guide. +### Please consult the Guide in case of doubts, it is included with +### your copy of ejabberd, and is also available online at +### http://www.process-one.net/en/ejabberd/docs/ + +### The configuration file is written in YAML. +### Refer to http://en.wikipedia.org/wiki/YAML for the brief description. +### However, ejabberd treats different literals as different types: +### +### - unquoted or single-quoted strings. They are called "atoms". +### Example: dog, 'Jupiter', '3.14159', YELLOW +### +### - numeric literals. Example: 3, -45.0, .0 +### +### - quoted or folded strings. +### Examples of quoted string: "Lizzard", "orange". +### Example of folded string: +### > Art thou not Romeo, +### and a Montague? +--- +###. ======= +###' LOGGING + +## +## loglevel: Verbosity of log files generated by ejabberd. +## 0: No ejabberd log at all (not recommended) +## 1: Critical +## 2: Error +## 3: Warning +## 4: Info +## 5: Debug +## +loglevel: 5 + +## +## rotation: Disable ejabberd's internal log rotation, as the Debian package +## uses logrotate(8). +log_rotate_size: 0 +log_rotate_date: "" + +## +## overload protection: If you want to limit the number of messages per second +## allowed from error_logger, which is a good idea if you want to avoid a flood +## of messages when system is overloaded, you can set a limit. +## 100 is ejabberd's default. +log_rate_limit: 1000 + +## +## watchdog_admins: Only useful for developers: if an ejabberd process +## consumes a lot of memory, send live notifications to these XMPP +## accounts. +## +## watchdog_admins: +## - "bob@example.com" + +###. =============== +###' NODE PARAMETERS + +## +## net_ticktime: Specifies net_kernel tick time in seconds. This options must have +## identical value on all nodes, and in most cases shouldn't be changed at all from +## default value. +## +## net_ticktime: 60 + +###. ================ +###' SERVED HOSTNAMES + +## +## hosts: Domains served by ejabberd. +## You can define one or several, for example: +## hosts: +## - "example.net" +## - "example.com" +## - "example.org" +## +hosts: + # - "{[MDNS_HOSTNAME]}" + - "jabber.local" + +## +## route_subdomains: Delegate subdomains to other XMPP servers. +## For example, if this ejabberd serves example.org and you want +## to allow communication with an XMPP server called im.example.org. +## +## route_subdomains: s2s + +###. =============== +###' LISTENING PORTS + +## Define common macros used by listeners +define_macro: + 'CERTFILE': "/etc/ejabberd/ejabberd.pem" +## 'CIPHERS': "ECDH:DH:!3DES:!aNULL:!eNULL:!MEDIUM@STRENGTH" + 'TLSOPTS': + - "no_sslv3" + - "no_tlsv1" + - "cipher_server_preference" + - "no_compression" +## 'DHFILE': "/path/to/dhparams.pem" # generated with: openssl dhparam -out dhparams.pem 2048 + +## +## listen: The ports ejabberd will listen on, which service each is handled +## by and what options to start it with. +## +listen: + - + port: 5222 + ip: "::" + module: ejabberd_c2s + starttls_required: true + certfile: 'CERTFILE' + protocol_options: 'TLSOPTS' + ## dhfile: 'DHFILE' + ## ciphers: 'CIPHERS' + max_stanza_size: 65536 + shaper: c2s_shaper + access: c2s + resend_on_timeout: if_offline + - + port: 5269 + ip: "::" + module: ejabberd_s2s_in + - + port: 5280 + ip: "::" + module: ejabberd_http + request_handlers: + "/ws": ejabberd_http_ws + "/bosh": mod_bosh + "/api": mod_http_api + ## "/pub/archive": mod_http_fileserver + web_admin: true + ## register: true + ## captcha: true + tls: false + certfile: 'CERTFILE' + protocol_options: 'TLSOPTS' + ## + ## ejabberd_service: Interact with external components (transports, ...) + ## + ## - + ## port: 8888 + ## ip: "::" + ## module: ejabberd_service + ## access: all + ## shaper_rule: fast + ## ip: "127.0.0.1" + ## privilege_access: + ## roster: "both" + ## message: "outgoing" + ## presence: "roster" + ## delegations: + ## "urn:xmpp:mam:1": + ## filtering: ["node"] + ## "http://jabber.org/protocol/pubsub": + ## filtering: [] + ## hosts: + ## "icq.example.org": + ## password: "secret" + ## "sms.example.org": + ## password: "secret" + + ## + ## ejabberd_stun: Handles STUN Binding requests + ## + ## - + ## port: 3478 + ## transport: udp + ## module: ejabberd_stun + + ## + ## To handle XML-RPC requests that provide admin credentials: + ## + ## - + ## port: 4560 + ## ip: "::" + ## module: ejabberd_xmlrpc + ## access_commands: {} + + ## + ## To enable secure http upload + ## + ## - + ## port: 5444 + ## ip: "::" + ## module: ejabberd_http + ## request_handlers: + ## "": mod_http_upload + ## tls: true + ## certfile: 'CERTFILE' + ## protocol_options: 'TLSOPTS' + ## dhfile: 'DHFILE' + ## ciphers: 'CIPHERS' + +## Disabling digest-md5 SASL authentication. digest-md5 requires plain-text +## password storage (see auth_password_format option). +disable_sasl_mechanisms: "digest-md5" + +###. ================== +###' S2S GLOBAL OPTIONS + +## +## s2s_use_starttls: Enable STARTTLS for S2S connections. +## Allowed values are: false optional required required_trusted +## You must specify a certificate file. +## +s2s_use_starttls: required + +## +## s2s_certfile: Specify a certificate file. +## +s2s_certfile: 'CERTFILE' + +## Custom OpenSSL options +## +s2s_protocol_options: 'TLSOPTS' + +## +## domain_certfile: Specify a different certificate for each served hostname. +## +## host_config: +## "example.org": +## domain_certfile: "/path/to/example_org.pem" +## "example.com": +## domain_certfile: "/path/to/example_com.pem" + +## +## S2S whitelist or blacklist +## +## Default s2s policy for undefined hosts. +## +## s2s_access: s2s + +## +## Outgoing S2S options +## +## Preferred address families (which to try first) and connect timeout +## in seconds. +## +## outgoing_s2s_families: +## - ipv4 +## - ipv6 +## outgoing_s2s_timeout: 190 + +###. ============== +###' AUTHENTICATION + +## +## auth_method: Method used to authenticate the users. +## The default method is the internal. +## If you want to use a different method, +## comment this line and enable the correct ones. +## +# auth_method: +# - internal +# - external +# auth_use_cache: false + +## +## Store the plain passwords or hashed for SCRAM: +## auth_password_format: plain +auth_password_format: scram +## +## Define the FQDN if ejabberd doesn't detect it: +## fqdn: "server3.example.com" + +## +## Authentication using external script +## Make sure the script is executable by ejabberd. +## +## auth_method: external +## extauth_program: "/path/to/authentication/script" + +## +## Authentication using SQL +## Remember to setup a database in the next section. +## +auth_method: sql + +## +## Authentication using PAM +## +## auth_method: pam +## pam_service: "pamservicename" + +## +## Authentication using LDAP +## +## auth_method: ldap +## +## List of LDAP servers: +## ldap_servers: +## - "localhost" +## +## Encryption of connection to LDAP servers: +## ldap_encrypt: none +## ldap_encrypt: tls +## +## Port to connect to on LDAP servers: +## ldap_port: 389 +## ldap_port: 636 +## +## LDAP manager: +## ldap_rootdn: "dc=example,dc=com" +## +## Password of LDAP manager: +## ldap_password: "******" +## +## Search base of LDAP directory: +## ldap_base: "dc=example,dc=com" +## +## LDAP attribute that holds user ID: +## ldap_uids: +## - "mail": "%u@mail.example.org" +## +## LDAP filter: +## ldap_filter: "(objectClass=shadowAccount)" + +## +## Anonymous login support: +## auth_method: anonymous +## anonymous_protocol: sasl_anon | login_anon | both +## allow_multiple_connections: true | false +## +## host_config: +## "public.example.org": +## auth_method: anonymous +## allow_multiple_connections: false +## anonymous_protocol: sasl_anon +## +## To use both anonymous and internal authentication: +## +## host_config: +## "public.example.org": +## auth_method: +## - internal +## - anonymous + +###. ============== +###' DATABASE SETUP + +# However, if you want to use MySQL for all modules that support MySQL as +# db_type, you can simply use global option default_db: sql: +default_db: sql + +## ejabberd by default uses the internal Mnesia database, +## so you do not necessarily need this section. +## This section provides configuration examples in case +## you want to use other database backends. +## Please consult the ejabberd Guide for details on database creation. + +## +## MySQL server: +## +## sql_type: mysql +## sql_server: "server" +## sql_database: "database" +## sql_username: "username" +## sql_password: "password" +## +## If you want to specify the port: +## sql_port: 1234 + +## +## PostgreSQL server: +## +sql_type: pgsql +sql_server: "db" +sql_database: "jabber" +sql_username: "postgres" +sql_password: "postgres" +## +## If you want to specify the port: +## sql_port: 1234 +## +## If you use PostgreSQL, have a large database, and need a +## faster but inexact replacement for "select count(*) from users" +## +## pgsql_users_number_estimate: true + +## +## SQLite: +## +## sql_type: sqlite +## sql_database: "/path/to/database.db" + +## +## ODBC compatible or MSSQL server: +## +## sql_type: odbc +## sql_server: "DSN=ejabberd;UID=ejabberd;PWD=ejabberd" + +## +## Number of connections to open to the database for each virtual host +## +## sql_pool_size: 10 + +## +## Interval to make a dummy SQL request to keep the connections to the +## database alive. Specify in seconds: for example 28800 means 8 hours +## +## sql_keepalive_interval: undefined + +###. =============== +###' TRAFFIC SHAPERS + +shaper: + ## + ## The "normal" shaper limits traffic speed to 1000 B/s + ## + normal: 1000 + + ## + ## The "fast" shaper limits traffic speed to 50000 B/s + ## + fast: 50000 + +## +## This option specifies the maximum number of elements in the queue +## of the FSM. Refer to the documentation for details. +## +max_fsm_queue: 1000 + +###. ==================== +###' ACCESS CONTROL LISTS +acl: + ## + ## The 'admin' ACL grants administrative privileges to XMPP accounts. + ## You can put here as many accounts as you want. + ## + admin: + user: + # - "admin@{[MDNS_HOSTNAME]}" + - "admin@jabber.local" + + ## + ## Blocked users + ## + ## blocked: + ## user: + ## - "baduser@example.org" + ## - "test" + + ## Local users: don't modify this. + ## + local: + user_regexp: "" + + ## + ## More examples of ACLs + ## + ## jabberorg: + ## server: + ## - "jabber.org" + ## aleksey: + ## user: + ## - "aleksey@jabber.ru" + ## test: + ## user_regexp: "^test" + ## user_glob: "test*" + + ## + ## Loopback network + ## + loopback: + ip: + - "127.0.0.0/8" + - "::1/128" + - "::FFFF:127.0.0.1/128" + + ## + ## Bad XMPP servers + ## + ## bad_servers: + ## server: + ## - "xmpp.zombie.org" + ## - "xmpp.spam.com" + +## +## Define specific ACLs in a virtual host. +## +## host_config: +## "localhost": +## acl: +## admin: +## user: +## - "bob-local@localhost" + +###. ============ +###' SHAPER RULES + +shaper_rules: + ## Maximum number of simultaneous sessions allowed for a single user: + max_user_sessions: 10 + ## Maximum number of offline messages that users can have: + max_user_offline_messages: + - 5000: admin + - 100 + ## For C2S connections, all users except admins use the "normal" shaper + c2s_shaper: + - none: admin + - normal + ## All S2S connections use the "fast" shaper + s2s_shaper: fast + +###. ============ +###' ACCESS RULES +access_rules: + ## This rule allows access only for local users: + local: + - allow: local + ## Only non-blocked users can use c2s connections: + c2s: + - deny: blocked + - allow + ## Only admins can send announcement messages: + announce: + - allow: admin + ## Only admins can use the configuration interface: + configure: + - allow: admin + ## Only admin accounts can create rooms: + muc_admin: + - allow: admin + ## Only accounts on the local ejabberd server can create Pubsub nodes: + pubsub_createnode: + - allow: local + ## In-band registration allows registration of any possible username. + ## To disable in-band registration, replace 'allow' with 'deny'. + register: + - deny + ## Only allow to register from localhost + trusted_network: + - allow: loopback + ## Do not establish S2S connections with bad servers + ## If you enable this you also have to uncomment "s2s_access: s2s" + ## s2s: + ## - deny: + ## - ip: "XXX.XXX.XXX.XXX/32" + ## - deny: + ## - ip: "XXX.XXX.XXX.XXX/32" + ## - allow + +## =============== +## API PERMISSIONS +## =============== +## +## This section allows you to define who and using what method +## can execute commands offered by ejabberd. +## +## By default "console commands" section allow executing all commands +## issued using ejabberdctl command, and "admin access" section allows +## users in admin acl that connect from 127.0.0.1 to execute all +## commands except start and stop with any available access method +## (ejabberdctl, http-api, xmlrpc depending what is enabled on server). +## +## If you remove "console commands" there will be one added by +## default allowing executing all commands, but if you just change +## permissions in it, version from config file will be used instead +## of default one. +## +api_permissions: + "console commands": + from: + - ejabberd_ctl + who: all + what: "*" + "admin access": + who: + - access: + - allow: + - acl: admin + - oauth: + - scope: "ejabberd:admin" + - access: + - allow: + - acl: admin + what: + - "*" + - "!stop" + - "!start" + "public commands": + who: + - ip: "127.0.0.1/8" + what: + - "status" + +## By default the frequency of account registrations from the same IP +## is limited to 1 account every 10 minutes. To disable, specify: infinity +## registration_timeout: 600 + +## +## Define specific Access Rules in a virtual host. +## +## host_config: +## "localhost": +## access: +## c2s: +## - allow: admin +## - deny +## register: +## - deny + +###. ================ +###' DEFAULT LANGUAGE + +## +## language: Default language used for server messages. +## +language: "en" + +## +## Set a different default language in a virtual host. +## +## host_config: +## "localhost": +## language: "ru" + +###. ======= +###' CAPTCHA + +## +## Full path to a script that generates the image. +## +## captcha_cmd: "/usr/share/ejabberd/captcha.sh" + +## +## Host for the URL and port where ejabberd listens for CAPTCHA requests. +## +## captcha_host: "example.org:5280" + +## +## Limit CAPTCHA calls per minute for JID/IP to avoid DoS. +## +## captcha_limit: 5 + +###. ======= +###' MODULES + +## +## Modules enabled in all ejabberd virtual hosts. +## +modules: + mod_read_markers: { db_type: sql } + mod_adhoc: {} + mod_admin_extra: {} + mod_announce: # recommends mod_adhoc + access: announce + mod_blocking: {} # requires mod_privacy + mod_caps: {} + mod_carboncopy: {} + mod_client_state: {} + mod_configure: {} # requires mod_adhoc + ## mod_delegation: {} # for xep0356 + mod_disco: {} + mod_echo: {} + mod_irc: {} + mod_bosh: {} + ## mod_http_fileserver: + ## docroot: "/var/www" + ## accesslog: "/var/log/ejabberd/access.log" + ## mod_http_upload: + ## # docroot: "@HOME@/upload" + ## put_url: "https://@HOST@:5444" + ## thumbnail: false # otherwise needs the identify command from ImageMagick installed + ## mod_http_upload_quota: + ## max_days: 30 + mod_last: {} + ## XEP-0313: Message Archive Management + ## You might want to setup a SQL backend for MAM because the mnesia database is + ## limited to 2GB which might be exceeded on large servers + ## mod_mam: {} # for xep0313, mnesia is limited to 2GB, better use an SQL backend + mod_mam: + request_activates_archiving: false + iqdisc: one_queue + default: always + db_type: sql + mod_muc: + ## host: "conference.@HOST@" + access: + - local + access_admin: muc_admin + access_create: muc_admin + access_persistent: muc_admin + ## Rooms should be persistent by default + default_room_options: + allow_subscription: true + persistent: true + mam: true + anonymous: false + mod_muc_admin: {} + ## mod_muc_log: {} + ## mod_multicast: {} + mod_offline: + access_max_user_messages: max_user_offline_messages + mod_ping: {} + ## mod_pres_counter: + ## count: 5 + ## interval: 60 + mod_privacy: {} + mod_private: {} + ## mod_proxy65: {} + mod_pubsub: + access_createnode: pubsub_createnode + ## reduces resource comsumption, but XEP incompliant + ignore_pep_from_offline: true + ## XEP compliant, but increases resource comsumption + ## ignore_pep_from_offline: false + last_item_cache: false + plugins: + - "flat" + - "hometree" + - "pep" # pep requires mod_caps + mod_push: {} + mod_push_keepalive: {} + ## mod_register: + ## + ## Protect In-Band account registrations with CAPTCHA. + ## + ## captcha_protected: true + ## + ## Set the minimum informational entropy for passwords. + ## + ## password_strength: 32 + ## + ## After successful registration, the user receives + ## a message with this subject and body. + ## + ## welcome_message: + ## subject: "Welcome!" + ## body: |- + ## Hi. + ## Welcome to this XMPP server. + ## + ## When a user registers, send a notification to + ## these XMPP accounts. + ## + ## registration_watchers: + ## - "admin1@example.org" + ## + ## Only clients in the server machine can register accounts + ## + ## ip_access: trusted_network + ## + ## Local c2s or remote s2s users cannot register accounts + ## + ## access_from: deny + ## access: register + mod_roster: + versioning: true + mod_shared_roster: {} + mod_stats: {} + mod_time: {} + mod_vcard: + search: false + mod_version: {} + mod_stream_mgmt: {} + ## Non-SASL Authentication (XEP-0078) is now disabled by default + ## because it's obsoleted and is used mostly by abandoned + ## client software + ## mod_legacy_auth: {} + ## The module for S2S dialback (XEP-0220). Please note that you cannot + ## rely solely on dialback if you want to federate with other servers, + ## because a lot of servers have dialback disabled and instead rely on + ## PKIX authentication. Make sure you have proper certificates installed + ## and check your accessibility at https://xmpp.net/ + mod_s2s_dialback: {} + mod_http_api: {} + +## +## Enable modules with custom options in a specific virtual host +## +## host_config: +## "localhost": +## modules: +## mod_echo: +## host: "mirror.localhost" + +## +## Enable modules management via ejabberdctl for installation and +## uninstallation of public/private contributed modules +## (enabled by default) +## + +allow_contrib_modules: true + +###. +###' +### Local Variables: +### mode: yaml +### End: +### vim: set filetype=yaml tabstop=8 foldmarker=###',###. foldmethod=marker: diff --git a/config/ejabberdctl.cfg b/config/ejabberdctl.cfg new file mode 100644 index 0000000..0ab4aa2 --- /dev/null +++ b/config/ejabberdctl.cfg @@ -0,0 +1,187 @@ +# +# In this file you can configure options that are passed by ejabberdctl +# to the erlang runtime system when starting ejabberd +# + +#' POLL: Kernel polling ([true|false]) +# +# The kernel polling option requires support in the kernel. +# Additionally, you need to enable this feature while compiling Erlang. +# +# Default: true +# +#POLL=true + +#. +#' SMP: SMP support ([enable|auto|disable]) +# +# Explanation in Erlang/OTP documentation: +# enable: starts the Erlang runtime system with SMP support enabled. +# This may fail if no runtime system with SMP support is available. +# auto: starts the Erlang runtime system with SMP support enabled if it +# is available and more than one logical processor are detected. +# disable: starts a runtime system without SMP support. +# +# Default: auto +# +#SMP=auto + +#. +#' ERL_MAX_PORTS: Maximum number of simultaneously open Erlang ports +# +# ejabberd consumes two or three ports for every connection, either +# from a client or from another Jabber server. So take this into +# account when setting this limit. +# +# Default: 32000 +# Maximum: 268435456 +# +#ERL_MAX_PORTS=32000 + +#. +#' FIREWALL_WINDOW: Range of allowed ports to pass through a firewall +# +# If Ejabberd is configured to run in cluster, and a firewall is blocking ports, +# it's possible to make Erlang use a defined range of port (instead of dynamic +# ports) for node communication. +# +# Default: not defined +# Example: 4200-4210 +# +#FIREWALL_WINDOW= + +#. +#' INET_DIST_INTERFACE: IP address where this Erlang node listens other nodes +# +# This communication is used by ejabberdctl command line tool, +# and in a cluster of several ejabberd nodes. +# +# Default: 0.0.0.0 +# +#INET_DIST_INTERFACE=127.0.0.1 + +#. +#' ERL_EPMD_ADDRESS: IP addresses where epmd listens for connections +# +# IMPORTANT: This option works only in Erlang/OTP R14B03 and newer. +# +# This environment variable may be set to a comma-separated +# list of IP addresses, in which case the epmd daemon +# will listen only on the specified address(es) and on the +# loopback address (which is implicitly added to the list if it +# has not been specified). The default behaviour is to listen on +# all available IP addresses. +# +# Default: 0.0.0.0 +# +#ERL_EPMD_ADDRESS=127.0.0.1 + +#. +#' ERL_PROCESSES: Maximum number of Erlang processes +# +# Erlang consumes a lot of lightweight processes. If there is a lot of activity +# on ejabberd so that the maximum number of processes is reached, people will +# experience greater latency times. As these processes are implemented in +# Erlang, and therefore not related to the operating system processes, you do +# not have to worry about allowing a huge number of them. +# +# Default: 250000 +# Maximum: 268435456 +# +#ERL_PROCESSES=250000 + +#. +#' ERL_MAX_ETS_TABLES: Maximum number of ETS and Mnesia tables +# +# The number of concurrent ETS and Mnesia tables is limited. When the limit is +# reached, errors will appear in the logs: +# ** Too many db tables ** +# You can safely increase this limit when starting ejabberd. It impacts memory +# consumption but the difference will be quite small. +# +# Default: 1400 +# +#ERL_MAX_ETS_TABLES=1400 + +#. +#' ERL_OPTIONS: Additional Erlang options +# +# The next variable allows to specify additional options passed to erlang while +# starting ejabberd. Some useful options are -noshell, -detached, -heart. When +# ejabberd is started from an init.d script options -noshell and -detached are +# added implicitly. See erl(1) for more info. +# +# It might be useful to add "-pa /usr/local/lib/ejabberd/ebin" if you +# want to add local modules in this path. +# +# Default: "-env ERL_CRASH_DUMP_BYTES 0" +# +ERL_OPTIONS="-env ERL_CRASH_DUMP_BYTES 0" + +#. +#' ERLANG_NODE: Erlang node name +# +# The next variable allows to explicitly specify erlang node for ejabberd +# It can be given in different formats: +# ERLANG_NODE=ejabberd +# Lets erlang add hostname to the node (ejabberd uses short name in this case) +# ERLANG_NODE=ejabberd@hostname +# Erlang uses node name as is (so make sure that hostname is a real +# machine hostname or you'll not be able to control ejabberd) +# ERLANG_NODE=ejabberd@hostname.domainname +# The same as previous, but erlang will use long hostname +# (see erl (1) manual for details) +# +# Default: ejabberd@localhost +# +ERLANG_NODE=ejabberd@jabber.local + +#. +#' EJABBERD_PID_PATH: ejabberd PID file +# +# Indicate the full path to the ejabberd Process identifier (PID) file. +# If this variable is defined, ejabberd writes the PID file when starts, +# and deletes it when stops. +# Remember to create the directory and grant write permission to ejabberd. +# +# Default: don't write PID file +# +EJABBERD_PID_PATH=/run/ejabberd/ejabberd.pid + +#. +#' EJABBERD_CONFIG_PATH: ejabberd configuration file +# +# Specify the full path to the ejabberd configuration file. If the file name has +# yml or yaml extension, it is parsed as a YAML file; otherwise, Erlang syntax is +# expected. +# +# Default: $ETC_DIR/ejabberd.yml +# +EJABBERD_CONFIG_PATH=/etc/ejabberd/ejabberd.yml + +#. +#' CONTRIB_MODULES_PATH: contributed ejabberd modules path +# +# Specify the full path to the contributed ejabberd modules. If the path is not +# defined, ejabberd will use ~/.ejabberd-modules in home of user running ejabberd. +# Note: this is not needed for the ejabberd-mod-* packages +# +# Default: $HOME/.ejabberd-modules +# +CONTRIB_MODULES_PATH=/opt/modules.d + +#. +#' CONTRIB_MODULES_CONF_DIR: configuration directory for contributed modules +# +# Specify the full path to the configuration directory for contributed ejabberd +# modules. In order to configure a module named mod_foo, a mod_foo.yml file can +# be created in this directory. This file will then be used instead of the +# default configuration file provided with the module. +# +# Default: $CONTRIB_MODULES_PATH/conf +# +# CONTRIB_MODULES_CONF_DIR=/app/modules-conf.d + +#. +#' +# vim: foldmarker=#',#. foldmethod=marker: diff --git a/config/mod_read_markers.yml b/config/mod_read_markers.yml new file mode 100644 index 0000000..cacd552 --- /dev/null +++ b/config/mod_read_markers.yml @@ -0,0 +1,3 @@ +modules: + mod_read_markers: + db_type: sql diff --git a/config/postgres/00-pg-ejabberd.sql b/config/postgres/00-pg-ejabberd.sql new file mode 100644 index 0000000..675cb79 --- /dev/null +++ b/config/postgres/00-pg-ejabberd.sql @@ -0,0 +1,423 @@ +CREATE DATABASE "jabber" ENCODING = 'unicode'; +\connect jabber; + +-- +-- ejabberd, Copyright (C) 2002-2017 ProcessOne +-- +-- This program is free software; you can redistribute it and/or +-- modify it under the terms of the GNU General Public License as +-- published by the Free Software Foundation; either version 2 of the +-- License, or (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +-- General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along +-- with this program; if not, write to the Free Software Foundation, Inc., +-- 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +-- + +CREATE TABLE users ( + username text PRIMARY KEY, + "password" text NOT NULL, + serverkey text NOT NULL DEFAULT '', + salt text NOT NULL DEFAULT '', + iterationcount integer NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +-- Add support for SCRAM auth to a database created before ejabberd 16.03: +-- ALTER TABLE users ADD COLUMN serverkey text NOT NULL DEFAULT ''; +-- ALTER TABLE users ADD COLUMN salt text NOT NULL DEFAULT ''; +-- ALTER TABLE users ADD COLUMN iterationcount integer NOT NULL DEFAULT 0; + +CREATE TABLE last ( + username text PRIMARY KEY, + seconds text NOT NULL, + state text NOT NULL +); + + +CREATE TABLE rosterusers ( + username text NOT NULL, + jid text NOT NULL, + nick text NOT NULL, + subscription character(1) NOT NULL, + ask character(1) NOT NULL, + askmessage text NOT NULL, + server character(1) NOT NULL, + subscribe text NOT NULL, + "type" text, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX i_rosteru_user_jid ON rosterusers USING btree (username, jid); +CREATE INDEX i_rosteru_username ON rosterusers USING btree (username); +CREATE INDEX i_rosteru_jid ON rosterusers USING btree (jid); + + +CREATE TABLE rostergroups ( + username text NOT NULL, + jid text NOT NULL, + grp text NOT NULL +); + +CREATE INDEX pk_rosterg_user_jid ON rostergroups USING btree (username, jid); + +CREATE TABLE sr_group ( + name text NOT NULL, + opts text NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE TABLE sr_user ( + jid text NOT NULL, + grp text NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX i_sr_user_jid_grp ON sr_user USING btree (jid, grp); +CREATE INDEX i_sr_user_jid ON sr_user USING btree (jid); +CREATE INDEX i_sr_user_grp ON sr_user USING btree (grp); + +CREATE TABLE spool ( + username text NOT NULL, + xml text NOT NULL, + seq SERIAL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX i_despool ON spool USING btree (username); + +CREATE TABLE archive ( + username text NOT NULL, + timestamp BIGINT NOT NULL, + peer text NOT NULL, + bare_peer text NOT NULL, + xml text NOT NULL, + txt text, + id SERIAL, + kind text, + nick text, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX i_username_timestamp ON archive USING btree (username, timestamp); +CREATE INDEX i_timestamp ON archive USING btree (timestamp); +CREATE INDEX i_peer ON archive USING btree (peer); +CREATE INDEX i_bare_peer ON archive USING btree (bare_peer); + +CREATE TABLE archive_prefs ( + username text NOT NULL PRIMARY KEY, + def text NOT NULL, + always text NOT NULL, + never text NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE TABLE vcard ( + username text PRIMARY KEY, + vcard text NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE TABLE vcard_search ( + username text NOT NULL, + lusername text PRIMARY KEY, + fn text NOT NULL, + lfn text NOT NULL, + family text NOT NULL, + lfamily text NOT NULL, + given text NOT NULL, + lgiven text NOT NULL, + middle text NOT NULL, + lmiddle text NOT NULL, + nickname text NOT NULL, + lnickname text NOT NULL, + bday text NOT NULL, + lbday text NOT NULL, + ctry text NOT NULL, + lctry text NOT NULL, + locality text NOT NULL, + llocality text NOT NULL, + email text NOT NULL, + lemail text NOT NULL, + orgname text NOT NULL, + lorgname text NOT NULL, + orgunit text NOT NULL, + lorgunit text NOT NULL +); + +CREATE INDEX i_vcard_search_lfn ON vcard_search(lfn); +CREATE INDEX i_vcard_search_lfamily ON vcard_search(lfamily); +CREATE INDEX i_vcard_search_lgiven ON vcard_search(lgiven); +CREATE INDEX i_vcard_search_lmiddle ON vcard_search(lmiddle); +CREATE INDEX i_vcard_search_lnickname ON vcard_search(lnickname); +CREATE INDEX i_vcard_search_lbday ON vcard_search(lbday); +CREATE INDEX i_vcard_search_lctry ON vcard_search(lctry); +CREATE INDEX i_vcard_search_llocality ON vcard_search(llocality); +CREATE INDEX i_vcard_search_lemail ON vcard_search(lemail); +CREATE INDEX i_vcard_search_lorgname ON vcard_search(lorgname); +CREATE INDEX i_vcard_search_lorgunit ON vcard_search(lorgunit); + +CREATE TABLE privacy_default_list ( + username text PRIMARY KEY, + name text NOT NULL +); + +CREATE TABLE privacy_list ( + username text NOT NULL, + name text NOT NULL, + id SERIAL UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX i_privacy_list_username ON privacy_list USING btree (username); +CREATE UNIQUE INDEX i_privacy_list_username_name ON privacy_list USING btree (username, name); + +CREATE TABLE privacy_list_data ( + id bigint REFERENCES privacy_list(id) ON DELETE CASCADE, + t character(1) NOT NULL, + value text NOT NULL, + action character(1) NOT NULL, + ord NUMERIC NOT NULL, + match_all boolean NOT NULL, + match_iq boolean NOT NULL, + match_message boolean NOT NULL, + match_presence_in boolean NOT NULL, + match_presence_out boolean NOT NULL +); + +CREATE INDEX i_privacy_list_data_id ON privacy_list_data USING btree (id); + +CREATE TABLE private_storage ( + username text NOT NULL, + namespace text NOT NULL, + data text NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX i_private_storage_username ON private_storage USING btree (username); +CREATE UNIQUE INDEX i_private_storage_username_namespace ON private_storage USING btree (username, namespace); + + +CREATE TABLE roster_version ( + username text PRIMARY KEY, + version text NOT NULL +); + +-- To update from 0.9.8: +-- CREATE SEQUENCE spool_seq_seq; +-- ALTER TABLE spool ADD COLUMN seq integer; +-- ALTER TABLE spool ALTER COLUMN seq SET DEFAULT nextval('spool_seq_seq'); +-- UPDATE spool SET seq = DEFAULT; +-- ALTER TABLE spool ALTER COLUMN seq SET NOT NULL; + +-- To update from 1.x: +-- ALTER TABLE rosterusers ADD COLUMN askmessage text; +-- UPDATE rosterusers SET askmessage = ''; +-- ALTER TABLE rosterusers ALTER COLUMN askmessage SET NOT NULL; + +CREATE TABLE pubsub_node ( + host text NOT NULL, + node text NOT NULL, + parent text NOT NULL DEFAULT '', + plugin text NOT NULL, + nodeid SERIAL UNIQUE +); +CREATE INDEX i_pubsub_node_parent ON pubsub_node USING btree (parent); +CREATE UNIQUE INDEX i_pubsub_node_tuple ON pubsub_node USING btree (host, node); + +CREATE TABLE pubsub_node_option ( + nodeid bigint REFERENCES pubsub_node(nodeid) ON DELETE CASCADE, + name text NOT NULL, + val text NOT NULL +); +CREATE INDEX i_pubsub_node_option_nodeid ON pubsub_node_option USING btree (nodeid); + +CREATE TABLE pubsub_node_owner ( + nodeid bigint REFERENCES pubsub_node(nodeid) ON DELETE CASCADE, + owner text NOT NULL +); +CREATE INDEX i_pubsub_node_owner_nodeid ON pubsub_node_owner USING btree (nodeid); + +CREATE TABLE pubsub_state ( + nodeid bigint REFERENCES pubsub_node(nodeid) ON DELETE CASCADE, + jid text NOT NULL, + affiliation character(1), + subscriptions text NOT NULL DEFAULT '', + stateid SERIAL UNIQUE +); +CREATE INDEX i_pubsub_state_jid ON pubsub_state USING btree (jid); +CREATE UNIQUE INDEX i_pubsub_state_tuple ON pubsub_state USING btree (nodeid, jid); + +CREATE TABLE pubsub_item ( + nodeid bigint REFERENCES pubsub_node(nodeid) ON DELETE CASCADE, + itemid text NOT NULL, + publisher text NOT NULL, + creation text NOT NULL, + modification text NOT NULL, + payload text NOT NULL DEFAULT '' +); +CREATE INDEX i_pubsub_item_itemid ON pubsub_item USING btree (itemid); +CREATE UNIQUE INDEX i_pubsub_item_tuple ON pubsub_item USING btree (nodeid, itemid); + +CREATE TABLE pubsub_subscription_opt ( + subid text NOT NULL, + opt_name varchar(32), + opt_value text NOT NULL +); +CREATE UNIQUE INDEX i_pubsub_subscription_opt ON pubsub_subscription_opt USING btree (subid, opt_name); + +CREATE TABLE muc_room ( + name text NOT NULL, + host text NOT NULL, + opts text NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX i_muc_room_name_host ON muc_room USING btree (name, host); + +CREATE TABLE muc_registered ( + jid text NOT NULL, + host text NOT NULL, + nick text NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX i_muc_registered_nick ON muc_registered USING btree (nick); +CREATE UNIQUE INDEX i_muc_registered_jid_host ON muc_registered USING btree (jid, host); + +CREATE TABLE muc_online_room ( + name text NOT NULL, + host text NOT NULL, + node text NOT NULL, + pid text NOT NULL +); + +CREATE UNIQUE INDEX i_muc_online_room_name_host ON muc_online_room USING btree (name, host); + +CREATE TABLE muc_online_users ( + username text NOT NULL, + server text NOT NULL, + resource text NOT NULL, + name text NOT NULL, + host text NOT NULL, + node text NOT NULL +); + +CREATE UNIQUE INDEX i_muc_online_users ON muc_online_users USING btree (username, server, resource, name, host); +CREATE INDEX i_muc_online_users_us ON muc_online_users USING btree (username, server); + +CREATE TABLE muc_room_subscribers ( + room text NOT NULL, + host text NOT NULL, + jid text NOT NULL, + nick text NOT NULL, + nodes text NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX i_muc_room_subscribers_host_jid ON muc_room_subscribers USING btree (host, jid); +CREATE UNIQUE INDEX i_muc_room_subscribers_host_room_jid ON muc_room_subscribers USING btree (host, room, jid); + +CREATE TABLE irc_custom ( + jid text NOT NULL, + host text NOT NULL, + data text NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX i_irc_custom_jid_host ON irc_custom USING btree (jid, host); + +CREATE TABLE motd ( + username text PRIMARY KEY, + xml text, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE TABLE caps_features ( + node text NOT NULL, + subnode text NOT NULL, + feature text, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX i_caps_features_node_subnode ON caps_features USING btree (node, subnode); + +CREATE TABLE sm ( + usec bigint NOT NULL, + pid text NOT NULL, + node text NOT NULL, + username text NOT NULL, + resource text NOT NULL, + priority text NOT NULL, + info text NOT NULL +); + +CREATE UNIQUE INDEX i_sm_sid ON sm USING btree (usec, pid); +CREATE INDEX i_sm_node ON sm USING btree (node); +CREATE INDEX i_sm_username ON sm USING btree (username); + +CREATE TABLE oauth_token ( + token text NOT NULL, + jid text NOT NULL, + scope text NOT NULL, + expire bigint NOT NULL +); + +CREATE UNIQUE INDEX i_oauth_token_token ON oauth_token USING btree (token); + +CREATE TABLE route ( + domain text NOT NULL, + server_host text NOT NULL, + node text NOT NULL, + pid text NOT NULL, + local_hint text NOT NULL +); + +CREATE UNIQUE INDEX i_route ON route USING btree (domain, server_host, node, pid); +CREATE INDEX i_route_domain ON route USING btree (domain); + +CREATE TABLE bosh ( + sid text NOT NULL, + node text NOT NULL, + pid text NOT NULL +); + +CREATE UNIQUE INDEX i_bosh_sid ON bosh USING btree (sid); + +CREATE TABLE carboncopy ( + username text NOT NULL, + resource text NOT NULL, + namespace text NOT NULL, + node text NOT NULL +); + +CREATE UNIQUE INDEX i_carboncopy_ur ON carboncopy USING btree (username, resource); +CREATE INDEX i_carboncopy_user ON carboncopy USING btree (username); + +CREATE TABLE proxy65 ( + sid text NOT NULL, + pid_t text NOT NULL, + pid_i text NOT NULL, + node_t text NOT NULL, + node_i text NOT NULL, + jid_i text NOT NULL +); + +CREATE UNIQUE INDEX i_proxy65_sid ON proxy65 USING btree (sid); +CREATE INDEX i_proxy65_jid ON proxy65 USING btree (jid_i); + +CREATE TABLE push_session ( + username text NOT NULL, + timestamp bigint NOT NULL, + service text NOT NULL, + node text NOT NULL, + xml text NOT NULL +); + +CREATE UNIQUE INDEX i_push_usn ON push_session USING btree (username, service, node); +CREATE UNIQUE INDEX i_push_ut ON push_session USING btree (username, timestamp); diff --git a/config/postgres/99-pg-read-markers.sql b/config/postgres/99-pg-read-markers.sql new file mode 100644 index 0000000..760a247 --- /dev/null +++ b/config/postgres/99-pg-read-markers.sql @@ -0,0 +1,12 @@ +\connect jabber; + +-- The additional table for custom chat markers +CREATE TABLE IF NOT EXISTS read_messages ( + user_jid TEXT NOT NULL, + room_jid TEXT NOT NULL, + last_message_id BIGINT NOT NULL, + last_message_at BIGINT NOT NULL, + unseen_messages INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT now(), + PRIMARY KEY (user_jid, room_jid) +); diff --git a/config/supervisor/ejabberd-admin.conf b/config/supervisor/ejabberd-admin.conf new file mode 100644 index 0000000..69216a4 --- /dev/null +++ b/config/supervisor/ejabberd-admin.conf @@ -0,0 +1,15 @@ +[program:ejabberd-admin] +priority=10 +startretries=15 +directory=/tmp +command=/bin/sh -c "ejabberdctl started && ejabberdctl register admin ${MDNS_HOSTNAME} defaultpw && tail -F /dev/null" +user=root +autostart=true +autorestart=unexpected +exitcodes=0,1 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +stopsignal=KILL +stopwaitsecs=1 diff --git a/config/supervisor/ejabberd.conf b/config/supervisor/ejabberd.conf new file mode 100644 index 0000000..dbddbeb --- /dev/null +++ b/config/supervisor/ejabberd.conf @@ -0,0 +1,15 @@ +[program:ejabberd] +priority=10 +startretries=20 +directory=/tmp +environment=LANG="en_US.UTF-8",LANGUAGE="en_US.UTF-8",LC_ALL="en_US.UTF-8" +command=ejabberdctl foreground +user=root +autostart=true +autorestart=true +stdout_logfile=/app/log/ejabberd.log +stdout_logfile_maxbytes=0 +stderr_logfile=/app/log/ejabberd.log +stderr_logfile_maxbytes=0 +stopsignal=KILL +stopwaitsecs=1 diff --git a/config/supervisor/logs.conf b/config/supervisor/logs.conf new file mode 100644 index 0000000..ab34db9 --- /dev/null +++ b/config/supervisor/logs.conf @@ -0,0 +1,13 @@ +[program:logs] +priority=10 +directory=/app +command=tail -F + /app/log/ejabberd.log +user=root +autostart=true +autorestart=true +startretries=0 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/doc/assets/architecture.graphml b/doc/assets/architecture.graphml new file mode 100644 index 0000000..215606a --- /dev/null +++ b/doc/assets/architecture.graphml @@ -0,0 +1,248 @@ + + + + + + + + + + + + + + + + + + + + + + + ejabberd + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Multi user conversation +module (mod_muc_room) + + + + + + + + + + + + + + + + + Multi user conversation +module (mod_muc) + + + + + + + + + + + + + + + + + ejabberd_sql library + + + + + + + + + + + + + + + + + + + Read Markers +module + + + + + + + + + + + + + + + + + Custom module + + + + + + + + + + + + + + + + + ejabberd core + + + + + + + + + + + + + + + + + iq hook + + + + + + + + + + + + + + + + + + + + + iq response + + + + + + + + + + + + + + + + + + + + new message hook, +used for the unread +messages counter + + + + + + + + + + + + + + + + + + + + read/write the +metadata to a +new table + + + + + + + + + + + + + + + + diff --git a/doc/assets/architecture.png b/doc/assets/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..fd9accfc2ddb681aff308bdf64061ba6f17bdd54 GIT binary patch literal 15594 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 a#|)!IE>4KPS~bCoYns2)Mbqp~m;VjQs_-lT literal 0 HcmV?d00001 diff --git a/doc/assets/table.png b/doc/assets/table.png new file mode 100644 index 0000000000000000000000000000000000000000..b54b61076ae750a5bbe0fc7b2fa06c6c61a1f18c GIT binary patch literal 19624 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 + + + + + + + + + + + + + + + + + + + + + + + Bob + + + + + + + + + + + + + + + John + + + + + + + + + + + + + + + Alice + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + XMPP Server + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + + + + Bob + + + + + + + + + + + + + + + John + + + + + + + + + + + + + + + Alice + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + XMPP Server + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + + + + Bob + + + + + + + + + + + + + + + John + + + + + + + + + + + + + + + Alice + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + XMPP Server + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + + + + + + (1) Send a message +to a room + + + + + + + + + + + + + + + + + + + (2) Acknowledge +the last message + + + + + + + + + + + + + + + + + + + + + (3) Is offline and did not +read the message yet + + + + + + + + + + + + + + + + + + + + + (4) Acknowledge +the last message + + + + + + + + + + + + + + + + + + + + + (5) Retrieve the last +read message of John + and Alice (incl. unseen +count) + + + + + + + + + + + + + + + <?xml version="1.0" encoding="iso-8859-1"?> +<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 60 60" style="enable-background:new 0 0 60 60;" xml:space="preserve"> +<path d="M60,30C60,13.458,46.542,0,30,0S0,13.458,0,30c0,6.142,1.858,11.857,5.038,16.618l-0.002,0.021l0.207,0.303 + c0.18,0.263,0.374,0.512,0.562,0.768c0.076,0.104,0.151,0.209,0.229,0.313c0.238,0.316,0.483,0.626,0.732,0.931 + c0.072,0.088,0.145,0.175,0.218,0.262c1.157,1.385,2.427,2.653,3.793,3.794c0.083,0.069,0.165,0.139,0.249,0.208 + c0.298,0.243,0.599,0.481,0.906,0.712c0.124,0.094,0.25,0.185,0.375,0.277c0.292,0.214,0.584,0.426,0.884,0.629 + c0.16,0.109,0.326,0.211,0.488,0.317c0.416,0.27,0.836,0.531,1.264,0.779c0.298,0.174,0.597,0.347,0.902,0.511 + c0.184,0.099,0.372,0.191,0.558,0.286c0.324,0.166,0.651,0.327,0.982,0.481c0.167,0.077,0.334,0.153,0.502,0.227 + c0.383,0.17,0.771,0.331,1.162,0.485c0.121,0.048,0.241,0.097,0.363,0.144c1.073,0.406,2.175,0.754,3.302,1.036 + c0.046,0.012,0.093,0.021,0.139,0.032c0.498,0.122,1.001,0.231,1.509,0.328c0.135,0.026,0.27,0.049,0.405,0.073 + c0.02,0.004,0.039,0.007,0.059,0.01l-0.003,0.02l0.528,0.074c0.12,0.019,0.24,0.033,0.36,0.05l0.11,0.015 + c0.021,0.003,0.041,0.004,0.062,0.006c0.054,0.007,0.109,0.014,0.163,0.021c0.164,0.022,0.327,0.043,0.491,0.063 + c0.397,0.046,0.796,0.082,1.198,0.112c0.084,0.006,0.168,0.016,0.251,0.022c0.096,0.006,0.193,0.013,0.289,0.019 + C28.847,59.979,29.421,60,30,60s1.153-0.021,1.724-0.053c0.099-0.006,0.198-0.013,0.297-0.02c0.084-0.006,0.168-0.016,0.253-0.022 + c0.398-0.03,0.795-0.066,1.189-0.111c0.164-0.019,0.328-0.041,0.491-0.063c0.065-0.009,0.13-0.016,0.194-0.025 + c0.02-0.003,0.041-0.004,0.061-0.006l0.101-0.014c0.113-0.016,0.227-0.03,0.339-0.048l0.561-0.079l-0.003-0.021 + c0.009-0.002,0.018-0.003,0.027-0.005c0.135-0.024,0.27-0.047,0.405-0.073c0.508-0.097,1.011-0.206,1.509-0.328 + c0.046-0.011,0.093-0.021,0.139-0.032c1.127-0.282,2.229-0.63,3.302-1.036c0.122-0.046,0.243-0.096,0.365-0.144 + c0.391-0.154,0.778-0.315,1.161-0.484c0.168-0.074,0.336-0.15,0.502-0.227c0.331-0.154,0.658-0.315,0.982-0.481 + c0.187-0.095,0.374-0.188,0.558-0.286c0.305-0.164,0.603-0.337,0.902-0.511c0.428-0.249,0.849-0.509,1.264-0.779 + c0.163-0.106,0.328-0.208,0.488-0.317c0.299-0.203,0.592-0.415,0.884-0.629c0.125-0.092,0.251-0.183,0.375-0.277 + c0.306-0.231,0.608-0.469,0.906-0.712c0.084-0.069,0.166-0.139,0.249-0.208c1.366-1.142,2.636-2.409,3.794-3.795 + c0.073-0.087,0.145-0.173,0.216-0.261c0.249-0.305,0.494-0.615,0.732-0.931c0.078-0.103,0.152-0.208,0.229-0.313 + c0.188-0.256,0.382-0.505,0.562-0.768l0.207-0.303l-0.002-0.021C58.142,41.857,60,36.142,60,30z M58,30 + c0,4.972-1.309,9.642-3.591,13.694c-1.607-5.328-6.326-9.19-11.985-9.642L42.386,34h-1.075h-4.779h-1.166 + C35.164,34,35,33.836,35,33.635V32.99c0-0.183,0.149-0.303,0.276-0.352c6.439-2.421,10.455-12.464,9.613-19.488 + c-0.439-3.658-2.25-6.927-4.883-9.295C50.517,7.892,58,18.086,58,30z M52.538,46.59c-0.081,0.109-0.162,0.217-0.244,0.325 + c-0.223,0.293-0.448,0.584-0.682,0.868c-0.024,0.029-0.049,0.057-0.073,0.086c-0.808,0.972-1.681,1.888-2.611,2.743 + c-0.055,0.051-0.11,0.103-0.166,0.153c-0.277,0.251-0.561,0.495-0.848,0.735c-0.09,0.075-0.181,0.149-0.272,0.223 + c-0.279,0.227-0.56,0.45-0.847,0.666c-0.097,0.073-0.197,0.142-0.295,0.214c-0.165,0.121-0.332,0.238-0.5,0.355V48h-2v6.233 + c-0.039,0.023-0.078,0.045-0.118,0.068c-0.255,0.146-0.51,0.291-0.769,0.428c-0.177,0.094-0.357,0.185-0.537,0.276 + c-0.302,0.152-0.606,0.299-0.913,0.44c-0.15,0.069-0.299,0.138-0.45,0.204c-0.385,0.168-0.774,0.328-1.166,0.479 + c-0.081,0.031-0.16,0.065-0.241,0.095c-1,0.374-2.022,0.692-3.063,0.95c-0.075,0.019-0.151,0.034-0.226,0.052 + c-0.431,0.103-0.865,0.197-1.302,0.279c-0.102,0.019-0.205,0.037-0.308,0.055L33.162,46H34v-2.465l4.471,2.98l4.173-10.432 + c5.279,0.595,9.524,4.673,10.247,10.012C52.774,46.261,52.659,46.427,52.538,46.59z M23.482,57.226 + c-0.075-0.018-0.15-0.034-0.225-0.052c-1.041-0.259-2.064-0.576-3.064-0.95c-0.081-0.03-0.161-0.064-0.241-0.095 + c-0.392-0.151-0.781-0.311-1.165-0.479c-0.151-0.066-0.301-0.135-0.451-0.204c-0.307-0.141-0.611-0.288-0.913-0.44 + c-0.18-0.091-0.36-0.181-0.537-0.276c-0.259-0.137-0.514-0.283-0.769-0.428c-0.039-0.023-0.079-0.045-0.118-0.068V48h-2v4.958 + c-0.167-0.117-0.335-0.235-0.5-0.355c-0.098-0.072-0.198-0.141-0.295-0.214c-0.287-0.216-0.568-0.439-0.846-0.665 + c-0.092-0.074-0.183-0.149-0.274-0.224c-0.287-0.239-0.57-0.483-0.846-0.734c-0.057-0.051-0.112-0.104-0.168-0.155 + c-0.93-0.855-1.803-1.77-2.61-2.742c-0.024-0.029-0.049-0.057-0.073-0.086c-0.234-0.284-0.459-0.575-0.682-0.868 + c-0.082-0.108-0.164-0.216-0.244-0.325c-0.12-0.163-0.235-0.329-0.352-0.495c0.724-5.339,4.968-9.417,10.247-10.012l4.173,10.432 + L26,43.535V46h0.84l-1.72,11.566c-0.112-0.02-0.224-0.039-0.335-0.06C24.348,57.423,23.913,57.329,23.482,57.226z M25.491,30.791 + C20.709,29.022,17,20.85,17,15c0-0.128,0.015-0.253,0.019-0.38l0.732-0.903c1.651-1.964,4.469-2.526,7.012-1.4 + C25.785,12.771,26.874,13,28,13c2.971,0,5.64-1.615,7.028-4.184c3.182,1.045,6.022,3.015,7.943,5.498 + c0.293,6.1-3.294,14.533-8.398,16.452C33.617,31.126,33,31.999,33,32.99v0.645c0,1.146,0.82,2.103,1.904,2.319L33.198,38h-6.396 + l-1.706-2.047C26.18,35.738,27,34.78,27,33.635V33C27,32.014,26.395,31.126,25.491,30.791z M30.04,2.002c0.012,0,0.022,0,0.033,0 + c0.489,0.003,0.97,0.03,1.43,0.083c4.959,0.553,9.126,4.005,10.752,8.589c-2.115-1.842-4.708-3.26-7.497-4.027l-0.86-0.236 + l-0.333,0.826C32.644,9.522,30.46,11,28,11c-0.845,0-1.662-0.172-2.427-0.511c-2.766-1.224-5.786-0.893-8.017,0.73 + c0.613-2.026,1.72-3.882,3.261-5.42C23.271,3.35,26.53,2.002,30.04,2.002z M34,40.162L37.469,36h3.054l-2.993,7.484L34,41.132 + V40.162z M32.365,57.888c-0.27,0.023-0.541,0.049-0.809,0.065C31.042,57.981,30.525,58,30.007,58c-0.034,0-0.069-0.003-0.103-0.003 + c-0.485-0.002-0.969-0.017-1.45-0.044c-0.264-0.015-0.53-0.041-0.796-0.063c-0.186-0.016-0.371-0.031-0.556-0.051L28.862,46h2.277 + l1.786,11.837C32.739,57.856,32.552,57.872,32.365,57.888z M32,44h-4v-1.798V40h4v2.202V44z M26,40.162v0.97l-3.529,2.353L19.478,36 + h3.054L26,40.162z M19.982,3.86c-0.193,0.174-0.392,0.338-0.578,0.523C16.564,7.218,15,10.987,15,15 + c0,6.629,4.19,15.593,9.797,17.666C24.916,32.711,25,32.848,25,33v0.635C25,33.836,24.836,34,24.635,34h-1.166h-4.779h-0.545 + c-0.096,0-0.191,0.014-0.281,0.039c-5.785,0.343-10.638,4.239-12.272,9.656C3.309,39.642,2,34.972,2,30 + C2,18.09,9.478,7.9,19.982,3.86z"/> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +</svg> + + <?xml version="1.0" encoding="iso-8859-1"?> +<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 60 60" style="enable-background:new 0 0 60 60;" xml:space="preserve"> +<path d="M60,30C60,13.458,46.542,0,30,0S0,13.458,0,30c0,6.142,1.858,11.857,5.038,16.618l-0.002,0.021l0.207,0.303 + c0.18,0.263,0.374,0.512,0.562,0.768c0.076,0.104,0.151,0.209,0.229,0.313c0.238,0.316,0.483,0.626,0.732,0.931 + c0.072,0.088,0.145,0.175,0.218,0.262c1.157,1.385,2.427,2.653,3.793,3.794c0.083,0.069,0.165,0.139,0.249,0.208 + c0.298,0.243,0.599,0.481,0.906,0.712c0.124,0.094,0.25,0.185,0.375,0.277c0.292,0.214,0.584,0.426,0.884,0.629 + c0.16,0.109,0.326,0.211,0.488,0.317c0.416,0.27,0.836,0.531,1.264,0.779c0.298,0.174,0.597,0.347,0.902,0.511 + c0.184,0.099,0.372,0.191,0.558,0.286c0.325,0.166,0.651,0.327,0.982,0.481c0.167,0.077,0.334,0.153,0.502,0.227 + c0.383,0.17,0.771,0.331,1.162,0.485c0.121,0.048,0.241,0.097,0.363,0.144c1.073,0.406,2.175,0.754,3.302,1.036 + c0.046,0.012,0.093,0.021,0.139,0.032c0.498,0.122,1.001,0.231,1.509,0.328c0.135,0.026,0.27,0.049,0.405,0.073 + c0.424,0.075,0.85,0.141,1.28,0.198c0.164,0.022,0.327,0.043,0.491,0.063c0.419,0.048,0.841,0.086,1.265,0.117 + c0.158,0.012,0.315,0.027,0.473,0.036C28.847,59.979,29.421,60,30,60s1.153-0.021,1.724-0.053c0.158-0.009,0.315-0.025,0.473-0.036 + c0.424-0.031,0.846-0.068,1.265-0.117c0.164-0.019,0.328-0.041,0.491-0.063c0.43-0.057,0.856-0.123,1.28-0.198 + c0.135-0.024,0.27-0.047,0.405-0.073c0.508-0.097,1.011-0.206,1.509-0.328c0.046-0.011,0.093-0.021,0.139-0.032 + c1.127-0.282,2.229-0.63,3.302-1.036c0.122-0.046,0.243-0.096,0.365-0.144c0.391-0.154,0.778-0.315,1.161-0.484 + c0.168-0.074,0.336-0.15,0.502-0.227c0.331-0.154,0.658-0.315,0.982-0.481c0.186-0.095,0.374-0.188,0.558-0.286 + c0.305-0.164,0.603-0.337,0.902-0.511c0.428-0.249,0.849-0.509,1.264-0.779c0.163-0.106,0.328-0.208,0.488-0.317 + c0.299-0.203,0.591-0.415,0.884-0.629c0.125-0.092,0.251-0.183,0.375-0.277c0.306-0.231,0.608-0.469,0.906-0.712 + c0.084-0.069,0.166-0.139,0.249-0.208c1.367-1.142,2.636-2.409,3.794-3.795c0.073-0.087,0.145-0.173,0.216-0.261 + c0.249-0.305,0.494-0.615,0.732-0.931c0.078-0.103,0.152-0.208,0.229-0.313c0.187-0.256,0.382-0.505,0.562-0.768l0.207-0.303 + l-0.002-0.021C58.142,41.857,60,36.142,60,30z M58,30c0,4.972-1.309,9.642-3.591,13.694C52.697,38.02,47.458,34,41.311,34h-0.896 + h-5.049C35.164,34,35,33.836,35,33.635V32.99c0-0.183,0.149-0.303,0.276-0.352c6.439-2.421,10.455-12.464,9.613-19.488 + c-0.439-3.658-2.251-6.927-4.883-9.295C50.517,7.892,58,18.086,58,30z M52.538,46.59c-0.081,0.109-0.162,0.217-0.244,0.325 + c-0.223,0.293-0.448,0.584-0.682,0.868c-0.024,0.029-0.049,0.057-0.073,0.086c-0.808,0.972-1.681,1.888-2.611,2.743 + c-0.055,0.051-0.11,0.103-0.166,0.153c-0.277,0.251-0.561,0.495-0.848,0.735c-0.09,0.075-0.181,0.149-0.272,0.223 + c-0.279,0.227-0.56,0.45-0.847,0.666c-0.097,0.073-0.197,0.142-0.295,0.214c-0.165,0.121-0.332,0.238-0.499,0.355V48h-2v6.233 + c-0.039,0.023-0.078,0.045-0.118,0.068c-0.255,0.146-0.509,0.291-0.768,0.428c-0.177,0.094-0.357,0.185-0.537,0.276 + c-0.302,0.152-0.606,0.299-0.913,0.44c-0.15,0.069-0.299,0.138-0.45,0.204c-0.385,0.168-0.774,0.328-1.166,0.479 + c-0.081,0.031-0.16,0.065-0.241,0.095c-1,0.374-2.022,0.692-3.063,0.95c-0.075,0.019-0.151,0.034-0.226,0.052 + c-0.431,0.103-0.866,0.197-1.303,0.279c-0.13,0.025-0.26,0.047-0.391,0.07c-0.388,0.068-0.778,0.127-1.17,0.178 + c-0.151,0.02-0.302,0.041-0.454,0.058c-0.388,0.045-0.778,0.078-1.17,0.107c-0.145,0.01-0.289,0.025-0.435,0.033 + C31.065,57.982,30.534,58,30,58s-1.065-0.018-1.595-0.048c-0.146-0.008-0.29-0.023-0.435-0.033c-0.391-0.029-0.782-0.062-1.17-0.107 + c-0.152-0.017-0.303-0.038-0.454-0.058c-0.392-0.052-0.782-0.111-1.17-0.178c-0.13-0.023-0.261-0.045-0.391-0.07 + c-0.437-0.083-0.872-0.176-1.303-0.28c-0.075-0.018-0.15-0.034-0.225-0.052c-1.041-0.259-2.064-0.576-3.064-0.95 + c-0.081-0.03-0.161-0.064-0.241-0.095c-0.392-0.151-0.78-0.311-1.165-0.479c-0.151-0.066-0.301-0.135-0.451-0.204 + c-0.307-0.141-0.611-0.288-0.913-0.44c-0.18-0.091-0.36-0.181-0.537-0.276c-0.259-0.137-0.514-0.283-0.768-0.428 + c-0.039-0.023-0.079-0.045-0.118-0.068V48h-2v4.958c-0.168-0.117-0.335-0.235-0.499-0.355c-0.098-0.072-0.198-0.141-0.295-0.214 + c-0.287-0.216-0.568-0.439-0.846-0.665c-0.092-0.074-0.183-0.149-0.274-0.224c-0.287-0.239-0.57-0.483-0.847-0.734 + c-0.056-0.051-0.112-0.104-0.168-0.155c-0.93-0.855-1.803-1.77-2.61-2.742c-0.024-0.029-0.049-0.057-0.073-0.086 + c-0.234-0.284-0.459-0.575-0.682-0.868c-0.082-0.108-0.164-0.216-0.244-0.325c-0.12-0.163-0.236-0.329-0.352-0.495 + C7.893,40.313,12.804,36,18.689,36h2.896L30,44.414L38.414,36h2.896c5.885,0,10.797,4.313,11.58,10.095 + C52.774,46.261,52.659,46.427,52.538,46.59z M25.491,30.791C20.709,29.022,17,20.85,17,15c0-0.128,0.015-0.253,0.018-0.38 + l0.732-0.903c1.651-1.964,4.469-2.526,7.012-1.4C25.785,12.771,26.874,13,28,13c2.971,0,5.64-1.615,7.028-4.184 + c3.182,1.045,6.022,3.015,7.943,5.498c0.293,6.1-3.294,14.533-8.398,16.452C33.617,31.126,33,31.999,33,32.99v0.645 + C33,34.938,34.062,36,35.365,36h0.221L30,41.586L24.414,36h0.221C25.938,36,27,34.938,27,33.635V33 + C27,32.014,26.395,31.126,25.491,30.791z M30.04,2.002c0.012,0,0.022,0,0.033,0c0.489,0.003,0.97,0.03,1.43,0.083 + c4.959,0.553,9.127,4.005,10.752,8.589c-2.115-1.842-4.708-3.26-7.497-4.027l-0.86-0.236l-0.333,0.826C32.644,9.522,30.46,11,28,11 + c-0.845,0-1.662-0.172-2.427-0.511c-2.766-1.224-5.786-0.893-8.017,0.73c0.613-2.026,1.72-3.882,3.261-5.42 + C23.271,3.35,26.53,2.002,30.04,2.002z M19.982,3.86c-0.193,0.174-0.392,0.338-0.578,0.523C16.564,7.218,15,10.987,15,15 + c0,6.629,4.19,15.593,9.797,17.666C24.916,32.711,25,32.848,25,33v0.635C25,33.836,24.836,34,24.635,34h-5.049h-0.896 + c-6.148,0-11.387,4.02-13.099,9.694C3.309,39.642,2,34.972,2,30C2,18.09,9.477,7.9,19.982,3.86z"/> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +</svg> + + <?xml version="1.0" encoding="iso-8859-1"?> +<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 60 60" style="enable-background:new 0 0 60 60;" xml:space="preserve"> +<path d="M60,30C60,13.458,46.542,0,30,0c-0.226,0-0.449,0.012-0.673,0.017c-0.005,0-0.009,0-0.014,0 + c-0.02,0-0.038,0.002-0.058,0.002c-0.373,0.009-0.746,0.022-1.116,0.045c-0.292,0.017-0.57,0.04-0.847,0.065 + C12.015,1.503,0,14.372,0,30c0,5.583,1.537,10.811,4.204,15.292l-0.005,0.025l0.206,0.336c0.188,0.306,0.391,0.597,0.588,0.895 + c0.061,0.092,0.119,0.185,0.181,0.275c0.252,0.372,0.512,0.734,0.777,1.092c0.012,0.017,0.025,0.033,0.037,0.049 + c4.637,6.216,11.378,10.322,18.942,11.598c0.26,0.044,0.519,0.088,0.781,0.126c0.27,0.038,0.54,0.072,0.811,0.103 + c0.357,0.041,0.716,0.075,1.077,0.103c0.211,0.017,0.422,0.036,0.635,0.048C28.819,59.978,29.407,60,30,60 + c0.551,0,1.098-0.017,1.641-0.047c0.222-0.012,0.442-0.034,0.662-0.051c0.311-0.024,0.623-0.047,0.932-0.08 + c0.294-0.031,0.586-0.072,0.878-0.112c0.219-0.03,0.438-0.059,0.655-0.094c0.351-0.056,0.699-0.12,1.047-0.188 + c0.136-0.027,0.272-0.054,0.407-0.083c0.412-0.086,0.822-0.181,1.229-0.284c0.03-0.008,0.06-0.016,0.091-0.024 + c5.581-1.437,10.626-4.463,14.582-8.802c0.042-0.046,0.084-0.091,0.126-0.138c0.268-0.297,0.531-0.6,0.788-0.91 + c0.09-0.108,0.178-0.218,0.267-0.328c0.161-0.199,0.328-0.391,0.485-0.594l0.208-0.271l0-0.016C57.765,42.966,60,36.74,60,30z + M58,30c0,5.463-1.578,10.562-4.295,14.875C52.656,39.88,48.869,35.889,44,34.52V15l-0.012-1h-0.035 + c-0.018-0.285-0.031-0.573-0.064-0.85c-0.477-3.978-2.576-7.498-5.592-9.895C49.698,6.8,58,17.448,58,30z M51.694,47.678 + c-0.084,0.103-0.17,0.204-0.255,0.305c-1.842,2.193-4.015,4.096-6.439,5.641V49h-2v5.788c-1.869,0.984-3.86,1.768-5.947,2.311 + c-0.04,0.01-0.079,0.021-0.119,0.031c-0.379,0.097-0.763,0.184-1.148,0.266c-0.125,0.026-0.25,0.051-0.375,0.075 + c-0.327,0.064-0.655,0.124-0.985,0.176c-0.199,0.031-0.4,0.058-0.6,0.085c-0.275,0.038-0.551,0.076-0.828,0.105 + c-0.283,0.03-0.569,0.051-0.854,0.072c-0.208,0.016-0.414,0.036-0.623,0.048C31.016,57.984,30.509,58,30,58 + c-0.547,0-1.091-0.021-1.632-0.051c-0.199-0.011-0.397-0.03-0.595-0.045c-0.33-0.026-0.658-0.056-0.985-0.093 + c-0.255-0.029-0.51-0.061-0.764-0.097c-0.24-0.034-0.478-0.074-0.716-0.114c-0.314-0.053-0.628-0.104-0.939-0.168 + c-0.012-0.002-0.023-0.005-0.035-0.008c-3.373-0.696-6.521-2.006-9.334-3.798V49h-2v3.228c-2.366-1.814-4.437-3.994-6.123-6.459 + c-0.056-0.082-0.112-0.164-0.167-0.247c-0.131-0.196-0.255-0.395-0.381-0.594c0.613-2.529,2.046-4.706,3.974-6.279L29,55.341 + l18.699-16.694c2.526,2.069,4.177,5.171,4.292,8.655C51.892,47.427,51.795,47.555,51.694,47.678z M16,34.107v-10.23 + c0.025,0.058,0.056,0.114,0.081,0.172c0.145,0.327,0.299,0.646,0.457,0.965c0.071,0.144,0.137,0.29,0.211,0.432 + c0.236,0.454,0.483,0.899,0.744,1.329c0.032,0.053,0.067,0.101,0.1,0.153c0.231,0.374,0.471,0.739,0.72,1.091 + c0.098,0.138,0.202,0.268,0.303,0.402c0.192,0.256,0.386,0.509,0.587,0.749c0.12,0.143,0.243,0.278,0.365,0.415 + c0.194,0.217,0.391,0.426,0.592,0.626c0.131,0.131,0.263,0.257,0.398,0.38c0.207,0.19,0.417,0.367,0.631,0.539 + c0.134,0.108,0.267,0.217,0.404,0.317c0.236,0.173,0.478,0.326,0.721,0.474c0.12,0.073,0.238,0.155,0.36,0.222 + c0.368,0.202,0.742,0.381,1.124,0.522C23.916,32.711,24,32.848,24,33v0.635C24,33.836,23.836,34,23.635,34h-5.945 + c-0.52,0-1.032,0.04-1.54,0.098C16.1,34.104,16.05,34.101,16,34.107z M15.362,36.245c0.306-0.062,0.612-0.111,0.918-0.149 + c0.083-0.01,0.166-0.024,0.249-0.032c0.389-0.04,0.776-0.064,1.16-0.064h5.945C24.938,36,26,34.938,26,33.635V33 + c0-0.986-0.605-1.874-1.509-2.209C19.709,29.022,16,20.85,16,15c0-0.228,0.02-0.451,0.031-0.676l0.492-0.606 + c1.651-1.964,4.469-2.526,7.012-1.4C24.558,12.771,25.646,13,26.772,13c2.971,0,5.64-1.615,7.028-4.184 + c3.302,1.084,6.245,3.158,8.165,5.775l0.022-0.016l0,0.193c0.002,0.117-0.003,0.238-0.004,0.357 + c-0.031,5.177-2.76,11.859-6.646,14.673c-0.05,0.036-0.102,0.065-0.152,0.099c-0.228,0.158-0.458,0.309-0.693,0.439 + c-0.301,0.165-0.607,0.312-0.92,0.429C32.617,31.126,32,31.999,32,32.99v0.645C32,34.938,33.062,36,34.365,36h5.945 + c0.407,0,0.819,0.023,1.232,0.068c0.011,0.001,0.023,0.004,0.034,0.005c0.393,0.044,0.787,0.108,1.18,0.192 + c1.151,0.247,2.237,0.666,3.236,1.225L30,51.766V51c0-4.411,3.589-8,8-8c0.553,0,1-0.447,1-1s-0.447-1-1-1 + c-3.961,0-7.382,2.322-9,5.67C27.382,43.322,23.961,41,20,41c-0.553,0-1,0.447-1,1s0.447,1,1,1c4.411,0,8,3.589,8,8v0.766 + l-15.996-14.28C13.042,36.907,14.173,36.488,15.362,36.245z M27.191,2.141c0.356-0.036,0.716-0.06,1.076-0.082 + c0.318-0.018,0.648-0.031,0.99-0.04c0.022,0,0.044-0.001,0.066-0.002c0.39,0.009,0.782,0.022,1.18,0.068 + c5.061,0.565,9.301,4.147,10.852,8.873c-2.17-1.983-4.892-3.505-7.824-4.311L32.67,6.41l-0.333,0.826 + C31.416,9.522,29.232,11,26.772,11c-0.845,0-1.662-0.172-2.427-0.511c-2.634-1.165-5.497-0.924-7.692,0.499 + C18.168,6.375,22.201,2.823,27.191,2.141z M42,34.107c-0.031-0.004-0.062-0.002-0.094-0.006C41.382,34.04,40.851,34,40.311,34 + h-5.945C34.164,34,34,33.836,34,33.635V32.99c0-0.183,0.149-0.303,0.276-0.351v-0.001c3.346-1.258,6.028-4.578,7.724-8.459V34.107z + M18.247,4.595C15.616,7.314,14,10.997,14,15v19.516c-4.055,1.144-7.416,4.119-9.007,8.056C3.083,38.789,2,34.52,2,30 + C2,18.757,8.664,9.047,18.247,4.595z"/> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +</svg> + + <?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + width="600px" height="600px" viewBox="0 0 600 600" enable-background="new 0 0 600 600" xml:space="preserve"> +<path fill="#010002" d="M398.767,10H201.223C166.583,10,138.4,37.961,138.4,72.33v455.35c0,34.35,28.184,62.32,62.823,62.32h197.535 + c34.64,0,62.832-27.971,62.832-62.32V72.33C461.6,37.961,433.406,10,398.767,10z M440.829,527.68 + c0,22.906-18.866,41.55-42.063,41.55H201.223c-23.186,0-42.052-18.644-42.052-41.55V72.33h0.009c0-22.916,18.866-41.55,42.053-41.55 + h197.534c23.188,0,42.063,18.634,42.063,41.55C440.839,72.33,440.839,527.68,440.829,527.68z M419.47,71.035h-238.92 + c-2.871,0-5.19,2.319-5.19,5.19V479.77c0,2.861,2.319,5.2,5.19,5.2h238.92c2.87,0,5.2-2.329,5.2-5.2V76.225 + C424.66,73.354,422.34,71.035,419.47,71.035z M414.28,474.57H185.729V81.416h228.54C414.27,81.416,414.27,474.57,414.28,474.57z + M300.009,493.62c-17.503,0-31.75,14.237-31.75,31.739c0,17.504,14.246,31.741,31.75,31.741s31.731-14.246,31.731-31.741 + C331.74,507.857,317.513,493.62,300.009,493.62z M300.009,546.711c-11.781,0-21.36-9.579-21.36-21.352 + c0-11.771,9.579-21.358,21.36-21.358c11.772,0,21.351,9.587,21.351,21.358C321.36,537.133,311.781,546.711,300.009,546.711z + M265.65,50.632c0-2.117,2.068-3.828,4.62-3.828h59.45c2.552,0,4.61,1.71,4.61,3.828c0,2.116-2.049,3.827-4.601,3.827h-59.45 + C267.738,54.459,265.66,52.749,265.65,50.632z"/> +</svg> + + <?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 0 48 48" width="48"><path d="M0 0h48v48h-48z" fill="none"/><path d="M42 4h-36c-2.21 0-4 1.79-4 4v24c0 2.21 1.79 4 4 4h14v4h-4v4h16v-4h-4v-4h14c2.21 0 4-1.79 4-4v-24c0-2.21-1.79-4-4-4zm0 28h-36v-24h36v24z"/> + + + <metadata> + <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#" xmlns:dc="http://purl.org/dc/elements/1.1/"> + <rdf:Description about="https://iconscout.com/legal#licenses" dc:title="Desktop, Windows, Screen, Monitor, Display" dc:description="Desktop, Windows, Screen, Monitor, Display" dc:publisher="Iconscout" dc:date="2016-12-14" dc:format="image/svg+xml" dc:language="en"> + <dc:creator> + <rdf:Bag> + <rdf:li>Google Inc.</rdf:li> + </rdf:Bag> + </dc:creator> + </rdf:Description> + </rdf:RDF> + </metadata></svg> + + <?xml version="1.0" ?><svg height="1024" width="768" xmlns="http://www.w3.org/2000/svg"><path d="M0 128v128c0 0 1.906 64 64 64s577.125 0 640 0 64-64 64-64V128c0 0-0.5-64-64-64s-588.468 0-640 0C-0.906 64 0 128 0 128zM0 448v128c0 0 1.906 64 64 64s577.125 0 640 0 64-64 64-64V448c0 0-0.5-64-64-64s-588.468 0-640 0C-0.906 384 0 448 0 448zM0 768v128c0 0 1.906 64 64 64s577.125 0 640 0 64-64 64-64V768c0 0-0.5-64-64-64s-588.468 0-640 0C-0.906 704 0 768 0 768zM64 576V448h64v128H64zM192 576V448h64v128H192zM320 576V448h64v128H320zM448 576V448h64v128H448zM640 512v-64h64v64H640zM64 256V128h64v128H64zM192 256V128h64v128H192zM320 256V128h64v128H320zM448 256V128h64v128H448zM640 192v-64h64v64H640zM64 896V768h64v128H64zM192 896V768h64v128H192zM320 896V768h64v128H320zM448 896V768h64v128H448zM640 832v-64h64v64H640z"/></svg> + + + diff --git a/doc/assets/workflow.png b/doc/assets/workflow.png new file mode 100644 index 0000000000000000000000000000000000000000..54b36b2aff0f0a75dc3786a91a3d0ac7b8ab9499 GIT binary patch literal 72956 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 tktQij#?P*lNjhSG{fs~T^)votE4ISQM;LZD^A_ + + +``` + +The resulting iq stanza looks like this: + +```xml + +``` + +The service SHOULD send the answer iq stanza to be XMPP conform, even when the +result is empty and therefore successful. The result does not reflect the +persistence of the acknowledgment. Errors MAY just be swallowed. + +### Receive last acknowledged message + +Any client/presence application can retrieve the last seen/read stanza of the +user by querying the `urn:xmpp:read-markers` namespace of an actual room/user +combination. A client MAY also query the last seen/read stanza of any other +participant of a room. The iq stanza recipient (`to` attribute) specifies the +room to ask for. The user MAY be specified on the `query` element (using the +`jid` attribute). When this information is missing the service SHOULD assume +the sender (`from` attribute) to be the subject of the query. + +```xml + + + +``` + +The result of the query iq stanza MUST contain the last seen `stanza-id` which +was send by the last acknowledgment iq stanza and the moment when the service +received the acknowledgment. Additionally the service SHOULD send the amount of +unseen/unread messages on the room for the user. An example result looks like +this: + +```xml + + + 2018-08-06T11:58:37.651721Z + + + +``` + +The service MUST NOT validate the acknowledged message stanza id. The client +MUST provide a useful value to the service. + +## Implementation + +### Architecture + +![Architecture](./assets/architecture.png) + +The module MUST be capable of handling iq requsts passed from the IQ hook of +the mod_muc_room and listening for messages with a hook from the mod_muc +module. The MUC service triggers a hook for new messages on a room, so we can +collect and sum up unread messages. The acknowledgment iq requsts update the +metadata records on the SQL database and reset the unread message counter +accordingly. (This is not a safe/atomar counter, use it as an approximately +value) The module MUST register an XML namespace for listening only to relevant +IQ requests. + +### ejabberd module + +The module MAY be written in Elixir or pure Erlang. See the [ejabberd +module development +guide](https://docs.ejabberd.im/developer/extending-ejabberd/modules/) for +futher details. See the [`ejabberd_sql` +library](https://github.com/processone/ejabberd-contrib/issues/227#issuecomment-328638111) +for details on interacting with the SQL database. + +### Database schema + +The underlying database schema features an additional table which holds the +last seen/read message data. The ejabberd module MUST manage this table by +concrete inserts/updates. The schema is designed to be all-time persistent. +There is no regular cleanup required and no database pollution by design. + +| Property | Data type | Notes | +| ----------- | ---------- | --------- | +| user_jid | string | The bare JID of the user | +| room_jid | string | The bare JID of the room | +| last_message_id | bigint | The last message which was seen (and displayed) by any user session | +| last_message_at | bigint | The timestamp when the last message was seen by any user session | +| unseen_messages | integer | The amount of unseen messages by the user | + +The additional database table MAY be called `read_messages`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bff599a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +version: "3" +services: + db: + image: postgres:9.6 + network_mode: bridge + volumes: + - ./config/postgres:/docker-entrypoint-initdb.d + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + + jabber: + build: . + network_mode: bridge + extra_hosts: + - jabber.local:127.0.0.1 + volumes: + - ./:/app + - ./:/opt/modules.d/sources/mod_read_markers + - ./config/ejabberd.yml:/etc/ejabberd/ejabberd.yml + - ./config/ejabberdctl.cfg:/etc/ejabberd/ejabberdctl.cfg + links: + - db + environment: + MDNS_HOSTNAME: jabber.local diff --git a/exe/compile-xmpp-specs b/exe/compile-xmpp-specs new file mode 100755 index 0000000..622d4a5 --- /dev/null +++ b/exe/compile-xmpp-specs @@ -0,0 +1,45 @@ +#!/bin/bash +# +# See: http://bit.ly/2LHKFoq +# See: http://bit.ly/2M8sgNM +set -e + +rm -rf tmp +mkdir -p tmp +cd tmp +git clone https://github.com/processone/xmpp.git +cd xmpp + +# See: http://bit.ly/2KrucQ0 +git checkout 1.2.2 + +# make all +make spec + +cat ../../specs/mod_read_markers.spec >> specs/xmpp_codec.spec +cp include/xmpp_codec.hrl include/xmpp_codec.hrl.old + +make spec + +cp src/hg_read_markers.erl ../../src/hg_read_markers.erl +sed -i 's/\t/ /g' ../../src/hg_read_markers.erl + +echo -e "%% This file was generated automatically by compile-xmpp-specs\n" \ + > ../../include/hg_read_markers.hrl + +diff -n include/xmpp_codec.hrl.old include/xmpp_codec.hrl \ + | grep -v "^\a" \ + | grep -v "^\d" \ + | grep -v "() |" \ + | grep -v "() \." \ + >> ../../include/hg_read_markers.hrl +sed -i '/^$/N;/^\n$/D' ../../include/hg_read_markers.hrl +sed -i '${/^$/d;}' ../../include/hg_read_markers.hrl + +chmod ugo+rw \ + ../../include/hg_read_markers.hrl \ + ../../src/hg_read_markers.erl + +chown 1000:1000 \ + ../../include/hg_read_markers.hrl \ + ../../src/hg_read_markers.erl diff --git a/include/hg_read_markers.hrl b/include/hg_read_markers.hrl new file mode 100644 index 0000000..70010c1 --- /dev/null +++ b/include/hg_read_markers.hrl @@ -0,0 +1,14 @@ +%% This file was generated automatically by compile-xmpp-specs + +-record(rm_query, {jid :: jid:jid()}). +-type rm_query() :: #rm_query{}. + +-record(rm_ack, {id :: non_neg_integer()}). +-type rm_ack() :: #rm_ack{}. + +-record(rm_unseen_messages, {amount :: 'undefined' | non_neg_integer()}). +-type rm_unseen_messages() :: #rm_unseen_messages{}. + +-record(rm_last_message, {id :: non_neg_integer(), + seen_at :: undefined | erlang:timestamp()}). +-type rm_last_message() :: #rm_last_message{}. diff --git a/include/mod_read_markers.hrl b/include/mod_read_markers.hrl new file mode 100644 index 0000000..e587785 --- /dev/null +++ b/include/mod_read_markers.hrl @@ -0,0 +1,8 @@ +-define(MODULE_VERSION, <<"0.15.0-210">>). +-define(NS_READ_MARKERS, <<"urn:xmpp:read-markers">>). + +-record(db_entry, {user_jid = 'undefined' :: binary(), + room_jid = 'undefined' :: binary(), + message_id = 0 :: non_neg_integer(), + message_at = undefined :: erlang:timestamp(), + unseen = 0 :: non_neg_integer()}). diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ef0e568 --- /dev/null +++ b/install.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# +# This script will perform the installation of the mod_read_markers ejabberd +# module. It has the same requirements as described on the readme file. We +# download the module and install it to your systems ejabberd files. +# +# This script was tested on Ubuntu Bionic (18), and works just on +# Ubuntu/Debian. +# +# This script should be called like this: +# +# $ curl -L 'http://bit.ly/2M5YEoo' | bash +# +# Used Ubuntu packages: wget +# +# @author Hermann Mayer + +# Fail on any errors +set -eE + +# Specify the module/ejabberd version +MOD_VERSION=0.15.0 +SUPPORTED_EJABBERD_VERSION=18.01 + +# Check for Debian/Ubuntu, otherwise die +if ! grep -P 'Ubuntu|Debian' /etc/issue >/dev/null 2>&1; then + echo 'Looks like you are not running Debian/Ubuntu.' + echo 'This installer is only working for them.' + echo 'Sorry.' + exit 1 +fi + +# Discover the installed ejabberd version +EJABBERD_VERSION=$(dpkg -l ejabberd | grep '^ii' \ + | awk '{print $3}' | cut -d- -f1) + +# Check for the ejabberd ebin repository, otherwise die +if [ -z "${EJABBERD_VERSION}" ]; then + echo 'ejabberd is currently not installed via apt.' + echo 'Suggestion: sudo apt-get install ejabberd' + exit 1 +fi + +# Check for the correct ejabberd version is available +if [ "${EJABBERD_VERSION}" != "${SUPPORTED_EJABBERD_VERSION}" ]; then + echo "The installed ejabberd version (${EJABBERD_VERSION}) is not supported." + echo "We just support ejabberd ${SUPPORTED_EJABBERD_VERSION}." + echo 'Sorry.' + exit 1 +fi + +# Discover the ejabberd ebin repository on the system +EBINS_PATH=$(dirname $(dpkg -L ejabberd \ + | grep 'ejabberd.*/ebin/.*\.beam$' | head -n1)) + +# Check for the ejabberd ebin repository, otherwise die +if [ ! -d "${EBINS_PATH}" ]; then + echo 'No ejabberd ebin repository path was found.' + echo 'Sorry.' + exit 1 +fi + +# Download the module binary distribution and install it +URL="https://github.com/hausgold/ejabberd-read-markers/releases/" +URL+="download/${MOD_VERSION}/ejabberd-read-markers-${MOD_VERSION}.tar.gz" + +cd /tmp +rm -rf ejabberd-read-markers ejabberd-read-markers.tar.gz + +mkdir ejabberd-read-markers +wget -O ejabberd-read-markers.tar.gz "${URL}" +tar xf ejabberd-read-markers.tar.gz \ + --no-same-owner --no-same-permissions -C ejabberd-read-markers + +echo "Install ejabberd-read-markers to ${EBINS_PATH} .." +sudo chown root:root ejabberd-read-markers/{sql,ebin}/* +sudo chmod 0644 ejabberd-read-markers/{sql,ebin}/* +sudo cp -far ejabberd-read-markers/ebin/* "${EBINS_PATH}" +sudo mkdir -p "${EBINS_PATH}/../sql" +sudo cp -far ejabberd-read-markers/sql/* \ + "${EBINS_PATH}/../sql/mod_read_markers.sql" +rm -rf ejabberd-read-markers ejabberd-read-markers.tar.gz + +echo -e "\n\n" +echo -n 'The SQL migration file was installed to: ' +echo $(realpath "${EBINS_PATH}/../sql/mod_read_markers.sql") +echo -n 'Take care of the configuration of mod_read_markers on ' +echo '/etc/ejabberd/ejabberd.yml' +echo 'Restart the ejabberd server afterwards.' +echo +echo 'Done.' diff --git a/mod_read_markers.spec b/mod_read_markers.spec new file mode 100644 index 0000000..65df582 --- /dev/null +++ b/mod_read_markers.spec @@ -0,0 +1,5 @@ +author: "Hermann Mayer " +category: "admin" +summary: "Acknowledge readed messages and ask for them" +home: "https://github.com/hausgold/ejabberd-read-markers" +url: "git@github.com:hausgold/ejabberd-read-markers.git" diff --git a/specs/mod_read_markers.spec b/specs/mod_read_markers.spec new file mode 100644 index 0000000..a4de994 --- /dev/null +++ b/specs/mod_read_markers.spec @@ -0,0 +1,52 @@ +-xml(rm_ack, + #elem{name = <<"ack">>, + xmlns = <<"urn:xmpp:read-markers">>, + module = hg_read_markers, + result = {rm_ack, '$id'}, + attrs = [#attr{name = <<"id">>, + required = true, + dec = {dec_int, [0, infinity]}, + enc = {enc_int, []}}]}). + +-xml(rm_query, + #elem{name = <<"query">>, + xmlns = <<"urn:xmpp:read-markers">>, + module = hg_read_markers, + result = {rm_query, '$jid'}, + attrs = [#attr{name = <<"jid">>, + required = true, + dec = {jid, decode, []}, + enc = {jid, encode, []}}]}). + +-xml(rm_unseen_messages, + #elem{name = <<"unseen-messages">>, + xmlns = <<"urn:xmpp:read-markers">>, + module = hg_read_markers, + result = {rm_unseen_messages, '$amount'}, + attrs = [#attr{name = <<"amount">>, + dec = {dec_int, [0, infinity]}, + enc = {enc_int, []}}]}). + +-xml(rm_seen_at, + #elem{name = <<"seen-at">>, + xmlns = <<"urn:xmpp:read-markers">>, + module = hg_read_markers, + result = '$cdata', + cdata = #cdata{dec = {dec_utc, []}, + enc = {enc_utc, []}}}). + +-xml(rm_last_message, + #elem{name = <<"last-message">>, + xmlns = <<"urn:xmpp:read-markers">>, + module = hg_read_markers, + result = {rm_last_message, '$id', '$seen_at'}, + attrs = [#attr{name = <<"id">>, + required = true, + dec = {dec_int, [0, infinity]}, + enc = {enc_int, []}}], + refs = [#ref{name = rm_seen_at, + label = '$seen_at', + min = 0, + max = 1}]}). + +%% vim: set filetype=erlang tabstop=2: diff --git a/src/hg_read_markers.erl b/src/hg_read_markers.erl new file mode 100644 index 0000000..38007b8 --- /dev/null +++ b/src/hg_read_markers.erl @@ -0,0 +1,320 @@ +%% Created automatically by XML generator (fxml_gen.erl) +%% Source: xmpp_codec.spec + +-module(hg_read_markers). + +-compile(export_all). + +do_decode(<<"last-message">>, + <<"urn:xmpp:read-markers">>, El, Opts) -> + decode_rm_last_message(<<"urn:xmpp:read-markers">>, + Opts, El); +do_decode(<<"seen-at">>, <<"urn:xmpp:read-markers">>, + El, Opts) -> + decode_rm_seen_at(<<"urn:xmpp:read-markers">>, Opts, + El); +do_decode(<<"unseen-messages">>, + <<"urn:xmpp:read-markers">>, El, Opts) -> + decode_rm_unseen_messages(<<"urn:xmpp:read-markers">>, + Opts, El); +do_decode(<<"query">>, <<"urn:xmpp:read-markers">>, El, + Opts) -> + decode_rm_query(<<"urn:xmpp:read-markers">>, Opts, El); +do_decode(<<"ack">>, <<"urn:xmpp:read-markers">>, El, + Opts) -> + decode_rm_ack(<<"urn:xmpp:read-markers">>, Opts, El); +do_decode(Name, <<>>, _, _) -> + erlang:error({xmpp_codec, {missing_tag_xmlns, Name}}); +do_decode(Name, XMLNS, _, _) -> + erlang:error({xmpp_codec, {unknown_tag, Name, XMLNS}}). + +tags() -> + [{<<"last-message">>, <<"urn:xmpp:read-markers">>}, + {<<"seen-at">>, <<"urn:xmpp:read-markers">>}, + {<<"unseen-messages">>, <<"urn:xmpp:read-markers">>}, + {<<"query">>, <<"urn:xmpp:read-markers">>}, + {<<"ack">>, <<"urn:xmpp:read-markers">>}]. + +do_encode({rm_ack, _} = Ack, TopXMLNS) -> + encode_rm_ack(Ack, TopXMLNS); +do_encode({rm_query, _} = Query, TopXMLNS) -> + encode_rm_query(Query, TopXMLNS); +do_encode({rm_unseen_messages, _} = Unseen_messages, + TopXMLNS) -> + encode_rm_unseen_messages(Unseen_messages, TopXMLNS); +do_encode({rm_last_message, _, _} = Last_message, + TopXMLNS) -> + encode_rm_last_message(Last_message, TopXMLNS). + +do_get_name({rm_ack, _}) -> <<"ack">>; +do_get_name({rm_last_message, _, _}) -> + <<"last-message">>; +do_get_name({rm_query, _}) -> <<"query">>; +do_get_name({rm_unseen_messages, _}) -> + <<"unseen-messages">>. + +do_get_ns({rm_ack, _}) -> <<"urn:xmpp:read-markers">>; +do_get_ns({rm_last_message, _, _}) -> + <<"urn:xmpp:read-markers">>; +do_get_ns({rm_query, _}) -> <<"urn:xmpp:read-markers">>; +do_get_ns({rm_unseen_messages, _}) -> + <<"urn:xmpp:read-markers">>. + +pp(rm_ack, 1) -> [id]; +pp(rm_query, 1) -> [jid]; +pp(rm_unseen_messages, 1) -> [amount]; +pp(rm_last_message, 2) -> [id, seen_at]; +pp(_, _) -> no. + +records() -> + [{rm_ack, 1}, {rm_query, 1}, {rm_unseen_messages, 1}, + {rm_last_message, 2}]. + +dec_int(Val, Min, Max) -> + case erlang:binary_to_integer(Val) of + Int when Int =< Max, Min == infinity -> Int; + Int when Int =< Max, Int >= Min -> Int + end. + +dec_utc(Val) -> xmpp_util:decode_timestamp(Val). + +enc_int(Int) -> erlang:integer_to_binary(Int). + +enc_utc(Val) -> xmpp_util:encode_timestamp(Val). + +decode_rm_last_message(__TopXMLNS, __Opts, + {xmlel, <<"last-message">>, _attrs, _els}) -> + Seen_at = decode_rm_last_message_els(__TopXMLNS, __Opts, + _els, undefined), + Id = decode_rm_last_message_attrs(__TopXMLNS, _attrs, + undefined), + {rm_last_message, Id, Seen_at}. + +decode_rm_last_message_els(__TopXMLNS, __Opts, [], + Seen_at) -> + Seen_at; +decode_rm_last_message_els(__TopXMLNS, __Opts, + [{xmlel, <<"seen-at">>, _attrs, _} = _el | _els], + Seen_at) -> + case xmpp_codec:get_attr(<<"xmlns">>, _attrs, + __TopXMLNS) + of + <<"urn:xmpp:read-markers">> -> + decode_rm_last_message_els(__TopXMLNS, __Opts, _els, + decode_rm_seen_at(<<"urn:xmpp:read-markers">>, + __Opts, _el)); + _ -> + decode_rm_last_message_els(__TopXMLNS, __Opts, _els, + Seen_at) + end; +decode_rm_last_message_els(__TopXMLNS, __Opts, + [_ | _els], Seen_at) -> + decode_rm_last_message_els(__TopXMLNS, __Opts, _els, + Seen_at). + +decode_rm_last_message_attrs(__TopXMLNS, + [{<<"id">>, _val} | _attrs], _Id) -> + decode_rm_last_message_attrs(__TopXMLNS, _attrs, _val); +decode_rm_last_message_attrs(__TopXMLNS, [_ | _attrs], + Id) -> + decode_rm_last_message_attrs(__TopXMLNS, _attrs, Id); +decode_rm_last_message_attrs(__TopXMLNS, [], Id) -> + decode_rm_last_message_attr_id(__TopXMLNS, Id). + +encode_rm_last_message({rm_last_message, Id, Seen_at}, + __TopXMLNS) -> + __NewTopXMLNS = + xmpp_codec:choose_top_xmlns(<<"urn:xmpp:read-markers">>, + [], __TopXMLNS), + _els = + lists:reverse('encode_rm_last_message_$seen_at'(Seen_at, + __NewTopXMLNS, [])), + _attrs = encode_rm_last_message_attr_id(Id, + xmpp_codec:enc_xmlns_attrs(__NewTopXMLNS, + __TopXMLNS)), + {xmlel, <<"last-message">>, _attrs, _els}. + +'encode_rm_last_message_$seen_at'(undefined, __TopXMLNS, + _acc) -> + _acc; +'encode_rm_last_message_$seen_at'(Seen_at, __TopXMLNS, + _acc) -> + [encode_rm_seen_at(Seen_at, __TopXMLNS) | _acc]. + +decode_rm_last_message_attr_id(__TopXMLNS, undefined) -> + erlang:error({xmpp_codec, + {missing_attr, <<"id">>, <<"last-message">>, + __TopXMLNS}}); +decode_rm_last_message_attr_id(__TopXMLNS, _val) -> + case catch dec_int(_val, 0, infinity) of + {'EXIT', _} -> + erlang:error({xmpp_codec, + {bad_attr_value, <<"id">>, <<"last-message">>, + __TopXMLNS}}); + _res -> _res + end. + +encode_rm_last_message_attr_id(_val, _acc) -> + [{<<"id">>, enc_int(_val)} | _acc]. + +decode_rm_seen_at(__TopXMLNS, __Opts, + {xmlel, <<"seen-at">>, _attrs, _els}) -> + Cdata = decode_rm_seen_at_els(__TopXMLNS, __Opts, _els, + <<>>), + Cdata. + +decode_rm_seen_at_els(__TopXMLNS, __Opts, [], Cdata) -> + decode_rm_seen_at_cdata(__TopXMLNS, Cdata); +decode_rm_seen_at_els(__TopXMLNS, __Opts, + [{xmlcdata, _data} | _els], Cdata) -> + decode_rm_seen_at_els(__TopXMLNS, __Opts, _els, + <>); +decode_rm_seen_at_els(__TopXMLNS, __Opts, [_ | _els], + Cdata) -> + decode_rm_seen_at_els(__TopXMLNS, __Opts, _els, Cdata). + +encode_rm_seen_at(Cdata, __TopXMLNS) -> + __NewTopXMLNS = + xmpp_codec:choose_top_xmlns(<<"urn:xmpp:read-markers">>, + [], __TopXMLNS), + _els = encode_rm_seen_at_cdata(Cdata, []), + _attrs = xmpp_codec:enc_xmlns_attrs(__NewTopXMLNS, + __TopXMLNS), + {xmlel, <<"seen-at">>, _attrs, _els}. + +decode_rm_seen_at_cdata(__TopXMLNS, <<>>) -> undefined; +decode_rm_seen_at_cdata(__TopXMLNS, _val) -> + case catch dec_utc(_val) of + {'EXIT', _} -> + erlang:error({xmpp_codec, + {bad_cdata_value, <<>>, <<"seen-at">>, __TopXMLNS}}); + _res -> _res + end. + +encode_rm_seen_at_cdata(undefined, _acc) -> _acc; +encode_rm_seen_at_cdata(_val, _acc) -> + [{xmlcdata, enc_utc(_val)} | _acc]. + +decode_rm_unseen_messages(__TopXMLNS, __Opts, + {xmlel, <<"unseen-messages">>, _attrs, _els}) -> + Amount = decode_rm_unseen_messages_attrs(__TopXMLNS, + _attrs, undefined), + {rm_unseen_messages, Amount}. + +decode_rm_unseen_messages_attrs(__TopXMLNS, + [{<<"amount">>, _val} | _attrs], _Amount) -> + decode_rm_unseen_messages_attrs(__TopXMLNS, _attrs, + _val); +decode_rm_unseen_messages_attrs(__TopXMLNS, + [_ | _attrs], Amount) -> + decode_rm_unseen_messages_attrs(__TopXMLNS, _attrs, + Amount); +decode_rm_unseen_messages_attrs(__TopXMLNS, [], + Amount) -> + decode_rm_unseen_messages_attr_amount(__TopXMLNS, + Amount). + +encode_rm_unseen_messages({rm_unseen_messages, Amount}, + __TopXMLNS) -> + __NewTopXMLNS = + xmpp_codec:choose_top_xmlns(<<"urn:xmpp:read-markers">>, + [], __TopXMLNS), + _els = [], + _attrs = encode_rm_unseen_messages_attr_amount(Amount, + xmpp_codec:enc_xmlns_attrs(__NewTopXMLNS, + __TopXMLNS)), + {xmlel, <<"unseen-messages">>, _attrs, _els}. + +decode_rm_unseen_messages_attr_amount(__TopXMLNS, + undefined) -> + undefined; +decode_rm_unseen_messages_attr_amount(__TopXMLNS, + _val) -> + case catch dec_int(_val, 0, infinity) of + {'EXIT', _} -> + erlang:error({xmpp_codec, + {bad_attr_value, <<"amount">>, <<"unseen-messages">>, + __TopXMLNS}}); + _res -> _res + end. + +encode_rm_unseen_messages_attr_amount(undefined, + _acc) -> + _acc; +encode_rm_unseen_messages_attr_amount(_val, _acc) -> + [{<<"amount">>, enc_int(_val)} | _acc]. + +decode_rm_query(__TopXMLNS, __Opts, + {xmlel, <<"query">>, _attrs, _els}) -> + Jid = decode_rm_query_attrs(__TopXMLNS, _attrs, + undefined), + {rm_query, Jid}. + +decode_rm_query_attrs(__TopXMLNS, + [{<<"jid">>, _val} | _attrs], _Jid) -> + decode_rm_query_attrs(__TopXMLNS, _attrs, _val); +decode_rm_query_attrs(__TopXMLNS, [_ | _attrs], Jid) -> + decode_rm_query_attrs(__TopXMLNS, _attrs, Jid); +decode_rm_query_attrs(__TopXMLNS, [], Jid) -> + decode_rm_query_attr_jid(__TopXMLNS, Jid). + +encode_rm_query({rm_query, Jid}, __TopXMLNS) -> + __NewTopXMLNS = + xmpp_codec:choose_top_xmlns(<<"urn:xmpp:read-markers">>, + [], __TopXMLNS), + _els = [], + _attrs = encode_rm_query_attr_jid(Jid, + xmpp_codec:enc_xmlns_attrs(__NewTopXMLNS, + __TopXMLNS)), + {xmlel, <<"query">>, _attrs, _els}. + +decode_rm_query_attr_jid(__TopXMLNS, undefined) -> + erlang:error({xmpp_codec, + {missing_attr, <<"jid">>, <<"query">>, __TopXMLNS}}); +decode_rm_query_attr_jid(__TopXMLNS, _val) -> + case catch jid:decode(_val) of + {'EXIT', _} -> + erlang:error({xmpp_codec, + {bad_attr_value, <<"jid">>, <<"query">>, __TopXMLNS}}); + _res -> _res + end. + +encode_rm_query_attr_jid(_val, _acc) -> + [{<<"jid">>, jid:encode(_val)} | _acc]. + +decode_rm_ack(__TopXMLNS, __Opts, + {xmlel, <<"ack">>, _attrs, _els}) -> + Id = decode_rm_ack_attrs(__TopXMLNS, _attrs, undefined), + {rm_ack, Id}. + +decode_rm_ack_attrs(__TopXMLNS, + [{<<"id">>, _val} | _attrs], _Id) -> + decode_rm_ack_attrs(__TopXMLNS, _attrs, _val); +decode_rm_ack_attrs(__TopXMLNS, [_ | _attrs], Id) -> + decode_rm_ack_attrs(__TopXMLNS, _attrs, Id); +decode_rm_ack_attrs(__TopXMLNS, [], Id) -> + decode_rm_ack_attr_id(__TopXMLNS, Id). + +encode_rm_ack({rm_ack, Id}, __TopXMLNS) -> + __NewTopXMLNS = + xmpp_codec:choose_top_xmlns(<<"urn:xmpp:read-markers">>, + [], __TopXMLNS), + _els = [], + _attrs = encode_rm_ack_attr_id(Id, + xmpp_codec:enc_xmlns_attrs(__NewTopXMLNS, + __TopXMLNS)), + {xmlel, <<"ack">>, _attrs, _els}. + +decode_rm_ack_attr_id(__TopXMLNS, undefined) -> + erlang:error({xmpp_codec, + {missing_attr, <<"id">>, <<"ack">>, __TopXMLNS}}); +decode_rm_ack_attr_id(__TopXMLNS, _val) -> + case catch dec_int(_val, 0, infinity) of + {'EXIT', _} -> + erlang:error({xmpp_codec, + {bad_attr_value, <<"id">>, <<"ack">>, __TopXMLNS}}); + _res -> _res + end. + +encode_rm_ack_attr_id(_val, _acc) -> + [{<<"id">>, enc_int(_val)} | _acc]. diff --git a/src/mod_read_markers.erl b/src/mod_read_markers.erl new file mode 100644 index 0000000..79ba5b2 --- /dev/null +++ b/src/mod_read_markers.erl @@ -0,0 +1,190 @@ +-module(mod_read_markers). +-author("hermann.mayer92@gmail.com"). +-behaviour(gen_mod). +-export([%% ejabberd module API + start/2, stop/1, reload/3, mod_opt_type/1, depends/2, + %% Database + get_last/2, store_last/3, increment_unseen/2, + %% Hooks + on_muc_iq/2, on_muc_message/3 + ]). + +-include("ejabberd.hrl"). +-include("logger.hrl"). +-include("xmpp.hrl"). +-include("mod_muc_room.hrl"). +-include("hg_read_markers.hrl"). +-include("mod_read_markers.hrl"). + +-callback init(binary(), gen_mod:opts()) + -> ok | {ok, pid()}. +-callback get_last(binary(), binary(), binary()) + -> {acked, #db_entry{}} | {unacked, #db_entry{}} | not_found | {error, any()}. +-callback store_last(binary(), binary(), binary(), non_neg_integer()) + -> ok | {error, any()}. +-callback increment_unseen(binary(), binary(), binary()) + -> any(). + +%% Start the module by implementing the +gen_mod+ behaviour. Here we register +%% the custom XMPP codec, the IQ handler and the hooks to listen to, for the +%% custom read markers functionality. +-spec start(binary(), gen_mod:opts()) -> ok. +start(Host, Opts) -> + %% Initialize the database module + Mod = gen_mod:db_mod(Host, Opts, ?MODULE), + Mod:init(Host, Opts), + %% Register the custom XMPP codec + xmpp:register_codec(hg_read_markers), + %% Register hooks + %% Run the MUC IQ hook after mod_mam (50) + ejabberd_hooks:add(muc_process_iq, Host, ?MODULE, on_muc_iq, 51), + %% Run the MUC message hook after mod_mam (50) + ejabberd_hooks:add(muc_filter_message, Host, ?MODULE, on_muc_message, 51), + %% Log the boot up + ?INFO_MSG("[RM] Start read markers (v~s) for ~s", [?MODULE_VERSION, Host]), + ok. + +%% Stop the module, and deregister the XMPP codec and all hooks as well as the +%% IQ handler. +-spec stop(binary()) -> any(). +stop(Host) -> + %% Deregister the custom XMPP codec + xmpp:unregister_codec(hg_read_markers), + %% Deregister all the hooks + ejabberd_hooks:delete(muc_process_iq, Host, ?MODULE, on_muc_iq, 51), + ejabberd_hooks:delete(muc_filter_message, Host, ?MODULE, on_muc_message, 51), + ?INFO_MSG("[RM] Stop read markers", []), + ok. + +%% Inline reload the module in case of external triggered +ejabberdctl+ reloads. +-spec reload(binary(), gen_mod:opts(), gen_mod:opts()) -> ok. +reload(Host, NewOpts, OldOpts) -> + %% Reload the custom XMPP codec + xmpp:register_codec(hg_read_markers), + %% Reload the database module on changes + NewMod = gen_mod:db_mod(Host, NewOpts, ?MODULE), + OldMod = gen_mod:db_mod(Host, OldOpts, ?MODULE), + if NewMod /= OldMod -> NewMod:init(Host, NewOpts); + true -> ok + end, + ok. + +%% This function matches acknowledgement requests and writes them to the +%% database in order to persist the last read message of a user on a given MUC. +-spec on_muc_iq(ignore | iq(), mod_muc_room:state()) -> ignore | iq(). +on_muc_iq(#iq{type = set, from = User, to = Room, + sub_els = [#rm_ack{id = Id}]} = IQ, _MUCState) -> + log_ack(Room, User, Id), + store_last(Room, User, Id), + xmpp:make_iq_result(IQ); + +%% This function matches requests for the last read message of a given user on +%% a given MUC. We lookup the combination on the database and pass back the +%% data. Simple as cake. +on_muc_iq(#iq{type = get, to = Room, lang = Lang, + sub_els = [#rm_query{jid = User}]} = IQ, _MUCState) -> + log_receive(Room, User), + case get_last(Room, User) of + {acked, #db_entry{message_id = Id, message_at = At, unseen = Unseen}} -> + make_iq_result_els(IQ, [#rm_last_message{id = Id, seen_at = At}, + #rm_unseen_messages{amount = Unseen}]); + {unacked, #db_entry{unseen = Unseen}} -> + xmpp:make_iq_result(IQ, #rm_unseen_messages{amount = Unseen}); + not_found -> + xmpp:make_iq_result(IQ, #rm_unseen_messages{amount = 0}); + _ -> + Error = xmpp:err_internal_server_error(<<"Database failure">>, Lang), + xmpp:make_error(IQ, Error) + end; + +%% Match all MUC IQ's and pass them back, unmodified. +on_muc_iq(IQ, _MUCState) -> IQ. + +%% Listen to MUC messages and acknowledge the sender last read message and +%% increment the unseen counter for all room members. +-spec on_muc_message(message(), mod_muc_room:state(), binary()) -> message(). +on_muc_message(#message{from = Sender, meta = Meta} = Packet, + #state{jid = Room, affiliations = Affiliations}, + _FromNick) -> + %% Extract and prepare the message details which are required to increment + %% the unseen messages for all room members (except the sender) and to + %% acknowledge the senders last read message. + Id = maps:get(stanza_id, Meta), + %% Acknowledge the message to be seen by the sender. It's fine to assume the + %% sender saw his very own message. + store_last(Room, Sender, Id), + %% Filter all members, except the sender for an increment of their unseen + %% messages for the message room. + Members = maps:remove(bare_jid(Sender), + affiliations_to_jid_list(Affiliations)), + %% Increment the unseen message counter for all room members. + maps:fold(fun(_BareJid, User, ok) -> increment_unseen(Room, User) end, + ok, Members), + %% We do not filter, we listen only. + Packet; + +%% Match all MUC messages and pass back the unmodified packet. +on_muc_message(Packet, _MUCState, _FromNick) -> Packet. + +%% This function is dedicated to read a database record of the last read +%% message for a given user/room combination. When the lookup fails we deliver +%% no last message details, but an zero count for unseen messages. +-spec get_last(jid(), jid()) -> any(). +get_last(#jid{} = Room, #jid{lserver = LServer} = User) -> + Mod = gen_mod:db_mod(LServer, ?MODULE), + Mod:get_last(LServer, bare_jid(Room), bare_jid(User)). + +%% This function writes a new row to the last read messages database in order +%% to persist the acknowledgement. +-spec store_last(jid(), jid(), non_neg_integer()) -> any(). +store_last(#jid{} = Room, #jid{lserver = LServer} = User, Id) -> + Mod = gen_mod:db_mod(LServer, ?MODULE), + Mod:store_last(LServer, bare_jid(Room), bare_jid(User), Id). + +%% This function increments the last unseen message counter for the given +%% user/room combination. In case there is no read message record yet, we +%% create a new one and increment the unseen counter afterwards. +-spec increment_unseen(jid(), jid()) -> any(). +increment_unseen(#jid{} = Room, #jid{lserver = LServer} = User) -> + Mod = gen_mod:db_mod(LServer, ?MODULE), + Mod:increment_unseen(LServer, bare_jid(Room), bare_jid(User)). + +%% Logs when we see a read message ack request. +-spec log_ack(jid(), jid(), non_neg_integer()) -> any(). +log_ack(#jid{} = Room, #jid{} = User, Id) -> + ?DEBUG("[RM][Ack] Room: ~s, User: ~s, Id: ~p", + [bare_jid(Room), bare_jid(User), Id]). + +%% Logs when we see a read message receive request. +-spec log_receive(jid(), jid()) -> any(). +log_receive(#jid{} = Room, #jid{} = User) -> + ?DEBUG("[RM][Receive] Room: ~s, User: ~s", + [bare_jid(Room), bare_jid(User)]). + +%% Allow IQ results to have multiple sub elements. +%% See: http://bit.ly/2KgmAQb +-spec make_iq_result_els(iq(), [xmpp_element() | xmlel() | undefined]) -> iq(). +make_iq_result_els(#iq{from = From, to = To} = IQ, SubEls) -> + IQ#iq{type = result, to = From, from = To, sub_els = SubEls}. + +%% Convert the given JID (full, or bare) to a bare JID and encode it to a +%% string. +-spec bare_jid(jid()) -> binary(). +bare_jid(#jid{} = Jid) -> jid:encode(jid:remove_resource(Jid)). + +%% Convert the given affiliation dictionary to a map of bare JID's (binary) as +%% keys and their corresponding +jid()+ records. +-spec affiliations_to_jid_list(?TDICT) -> map(). +affiliations_to_jid_list(Dict) -> + lists:foldl(fun({{User, Host, Resource}, _Aff}, Map) -> + Jid = jid:make(User, Host, Resource), + maps:put(bare_jid(Jid), Jid, Map) + end, #{}, dict:to_list(Dict)). + +%% Some ejabberd custom module API fullfilments +depends(_Host, _Opts) -> [{mod_muc, hard}]. + +mod_opt_type(db_type) -> fun(T) -> ejabberd_config:v_db(?MODULE, T) end; +%% TODO: http://bit.ly/2LU3jto +%% mod_opt_type(_) -> [db_type]. +mod_opt_type(_) -> []. diff --git a/src/mod_read_markers_sql.erl b/src/mod_read_markers_sql.erl new file mode 100644 index 0000000..28db70a --- /dev/null +++ b/src/mod_read_markers_sql.erl @@ -0,0 +1,104 @@ +-module(mod_read_markers_sql). +-author("hermann.mayer92@gmail.com"). +-behaviour(mod_read_markers). +-compile([{parse_transform, ejabberd_sql_pt}]). +-export([init/2, get_last/3, store_last/4, increment_unseen/3]). + +-include("mod_read_markers.hrl"). +-include("ejabberd.hrl"). +-include("logger.hrl"). +-include("xmpp.hrl"). +-include("ejabberd_sql_pt.hrl"). + +init(_Host, _Opts) -> + ok. + +%% This function is dedicated to read a database record of the last read +%% message for a given user/room combination. When the lookup fails we deliver +%% no last message details, but an zero count for unseen messages. +-spec get_last(binary(), binary(), binary()) -> any(). +get_last(LServer, RoomJid, UserJid) -> + Query = ?SQL("SELECT @(last_message_id)d, " + "@(last_message_at)d, @(unseen_messages)d " + "FROM read_messages " + "WHERE user_jid=%(UserJid)s " + "AND room_jid=%(RoomJid)s"), + case ejabberd_sql:sql_query(LServer, Query) of + %% An unacknowledged read message was found + {selected, [{0, 0, Unseen}]} -> + {unacked, #db_entry{user_jid = UserJid, + room_jid = RoomJid, + unseen = Unseen}}; + %% An acknowledged read message was found + {selected, [{Id, Timestamp, Unseen}]} -> + At = integer_to_timestamp(Timestamp), + {acked, #db_entry{user_jid = UserJid, + room_jid = RoomJid, + message_id = Id, + message_at = At, + unseen = Unseen}}; + %% We see database issues or no row was found + {'EXIT', _Reason} -> {error, db_fail}; + _ -> not_found + end. + +%% This function writes a new row to the last read messages database in order +%% to persist the acknowledgement. +-spec store_last(binary(), binary(), binary(), non_neg_integer()) -> any(). +store_last(LServer, RoomJid, UserJid, Id) -> + if + Id == 0 -> Now = 0; + Id > 0 -> + Now = p1_time_compat:system_time(micro_seconds), + ?DEBUG("[RM][Record] Acknowledge (~p) of ~s on ~s", + [Id, UserJid, RoomJid]) + end, + case ?SQL_UPSERT(LServer, + "read_messages", + ["!user_jid=%(UserJid)s", + "!room_jid=%(RoomJid)s", + "last_message_id=%(Id)d", + "last_message_at=%(Now)d", + "unseen_messages=0"]) of + ok -> ok; + _Err -> {error, db_failure} + end. + +%% This function increments the last unseen message counter for the given +%% user/room combination. In case there is no read message record yet, we +%% create a new one and increment the unseen counter afterwards. +-spec increment_unseen(binary(), binary(), binary()) -> any(). +increment_unseen(LServer, RoomJid, UserJid) -> + Result = get_last(LServer, RoomJid, UserJid), + case Result of + %% In case now read message record was found, we create a new one and + %% increment the unseen messages counter accordingly. This will result in + %% +1+ as new unseen counter value. (We call our self for this) + not_found -> + store_last(LServer, RoomJid, UserJid, 0), + increment_unseen(LServer, RoomJid, UserJid); + %% When we found a read message record, no matter if acknowledged or not we + %% increment the unseen counter. + {acked, #db_entry{} = Row} -> increment_unseen_row(LServer, Row); + {unacked, #db_entry{} = Row} -> increment_unseen_row(LServer, Row); + %% In case of database failures we just stop doing anything. + _ -> ok + end. + +%% Increment the unseen counter on an actual read message record. +-spec increment_unseen_row(binary(), #db_entry{}) -> any(). +increment_unseen_row(LServer, #db_entry{user_jid = UserJid, + room_jid = RoomJid, + unseen = Unseen}) -> + NewUnseen = Unseen + 1, + ?DEBUG("[RM][Record] Increment unseen messages (~p -> ~p) of ~s on ~s", + [Unseen, NewUnseen, UserJid, RoomJid]), + ?SQL_UPSERT(LServer, "read_messages", ["!user_jid=%(UserJid)s", + "!room_jid=%(RoomJid)s", + "unseen_messages=%(NewUnseen)d"]). + +%% Convert the given integer to an Erlang timestamp. We assume the given +%% timestamp is in micro seconds. +-spec integer_to_timestamp(non_neg_integer()) -> erlang:timestamp(). +integer_to_timestamp(Int) -> + {Int div 1000000000000, Int div 1000000 rem 1000000, Int rem 100000}. diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/tests/config.json b/tests/config.json new file mode 100644 index 0000000..c461635 --- /dev/null +++ b/tests/config.json @@ -0,0 +1,12 @@ +{ + "hostname": "jabber.local", + "jid": "admin@jabber.local", + "password": "defaultpw", + "transport": "websocket", + "wsURL": "ws://jabber.local/ws", + "room": "test@conference.jabber.local", + "users": ["bob", "alice"], + "match": "read-markers", + "skipUnrelated": true, + "debug": false +} diff --git a/tests/index.js b/tests/index.js new file mode 100755 index 0000000..a7fb436 --- /dev/null +++ b/tests/index.js @@ -0,0 +1,58 @@ +#!/usr/bin/env node + +const async = require('async'); + +// Setup a new client and run the test suite +require('./src/client')(require('./config'), (client, utils) => { + + // Get the seeds and test cases available + const seeds = require('./src/seeds')(client); + const test = require('./src/testcases')(client, utils); + + // Run each test case, sequentially + async.waterfall([ + seeds, + + // Initial state + test.receiveMuc('admin', { amount: 0, message: null }), + test.receiveMuc('alice', { amount: 0, message: null }), + test.receiveMuc('bob', { amount: 0, message: null }), + + // After a message the initial state changes + test.message, + test.receiveMuc('admin', { amount: 0, message: { at: utils.isoHour() } }), + test.receiveMuc('alice', { amount: 1, message: null }), + test.receiveMuc('bob', { amount: 1, message: null }), + + // A second message increases the counters + test.message, + test.receiveMuc('admin', { amount: 0, message: { at: utils.isoHour() } }), + test.receiveMuc('alice', { amount: 2, message: null }), + test.receiveMuc('bob', { amount: 2, message: null }), + + // Alice acknowledges and the counters should be correct + test.acknowledgeMuc('alice'), + test.receiveMuc('admin', { amount: 0 }), + test.receiveMuc('alice', { amount: 0, message: { at: utils.isoHour() } }), + test.receiveMuc('bob', { amount: 2, message: null }), + + // A third message changes the counters + test.message, + test.receiveMuc('admin', { amount: 0, message: { at: utils.isoHour() } }), + test.receiveMuc('alice', { amount: 1, message: { at: utils.isoHour() } }), + test.receiveMuc('bob', { amount: 3, message: null }), + + // The last seen message of bob should have the correct id + test.acknowledgeMuc('bob', '1500000000000001'), + test.acknowledgeMuc('alice', '1500000000000002'), + test.receiveMuc('admin', { amount: 0, message: { at: utils.isoHour() } }), + test.receiveMuc('bob', { amount: 0, message: { + id: '1500000000000001', + at: utils.isoHour() + } }), + test.receiveMuc('alice', { amount: 0, message: { + id: '1500000000000002', + at: utils.isoHour() + } }), + ], utils.exit); +}); diff --git a/tests/lib/hljs-console.js b/tests/lib/hljs-console.js new file mode 100644 index 0000000..3677108 --- /dev/null +++ b/tests/lib/hljs-console.js @@ -0,0 +1,96 @@ +const hljs = require('highlight.js') +const h2j = require('html2json'); +const css2json = require('css2json'); +const chalk = require('chalk') +const fs = require('fs'); +const path = require('path'); + +const readStylesheet = function(name) { + var styleRaw = fs.readFileSync(path.join(__dirname, '..', 'node_modules', + 'highlight.js', 'styles', + name + '.css')); + return css2json(styleRaw.toString()); +}; + +const stylize = function(name, text, styleData) { + var currentStyle = styleData['.'+name]; + if (currentStyle !== undefined) { + // Handle foreground color + if (currentStyle.color !== undefined) { + if (currentStyle.color.startsWith('#')) { + if (currentStyle.color.length === 4) { + var expandColor = '#'; + var char = currentStyle.color.substring(1,2); + expandColor = expandColor + char + char; + char = currentStyle.color.substring(2,3); + expandColor = expandColor + char + char; + char = currentStyle.color.substring(3,4); + expandColor = expandColor + char + char; + text = chalk.hex(expandColor)(text); + } else { + text = chalk.hex(currentStyle.color)(text); + } + } else { + text = chalk.keyword(currentStyle.color)(text); + } + } + + // Handle bold/italics/underline + if (currentStyle["text-decoration"] !== undefined && + currentStyle["text-decoration"].toLowerCase() === "underline") { + text = chalk.underline(text); + } + + if (currentStyle["font-weight"] !== undefined && + currentStyle["font-weight"].toLowerCase() === "bold") { + text = chalk.bold(text); + } + + if (currentStyle["font-style"] !== undefined && + currentStyle["font-style"].toLowerCase() === "italics") { + text = chalk.italics(text); + } + } + return text; +}; + +const deentitize = function(str) { + str = str.replace(/>/g, '>'); + str = str.replace(/</g, '<'); + str = str.replace(/"/g, '"'); + str = str.replace(/'/g, "'"); + str = str.replace(/&/g, '&'); + return str; +}; + +const replaceSpan = function(obj, styleData) { + // If there are child objects, convert on each child first + if (obj.child) { + for (var i = 0; i < obj.child.length; i++) { + obj.child[i] = replaceSpan(obj.child[i], styleData); + } + } + + if (obj.node === "element") { + return stylize(obj.attr.class, obj.child.join(''), styleData); + } else if (obj.node === "text") { + return obj.text; + } else if (obj.node === "root") { + return obj.child.join(''); + } else { + console.error("Found a node type of " + obj.node + " that I can't handle!"); + } +}; + +const convertHLJS = function(hljsHTML, styleName) { + var styleData = readStylesheet(styleName); + var json = h2j.html2json(hljsHTML); + var text = replaceSpan(json, styleData); + text = stylize('hljs', text, styleData); + text = deentitize(text); + return text; +} + +exports.convert = function(hljsHTML, styleName) { + return convertHLJS(hljsHTML, styleName); +}; diff --git a/tests/lib/rooms.js b/tests/lib/rooms.js new file mode 100644 index 0000000..67e35cd --- /dev/null +++ b/tests/lib/rooms.js @@ -0,0 +1,69 @@ +const async = require('async'); +const colors = require('colors'); +const xmpp = require('stanza.io'); +const config = require('../config'); +const utils = require('./utils')(config); + +/** + * Create a brand new MUC. + * + * @param {String} name The name of the room + * @param {Object} client The XMPP client + * @param {Function} callback The function to call on finish + */ +var createRoom = function(name, client, callback) +{ + utils.log(`Create room ${name.magenta}`, false, 1); + client.joinRoom(name, 'admin'); + callback && callback(); +}; + +/** + * Invite the given list of users to the given room. + * + * @param {String} name The name of the user + * @param {Array} users The users to invite + * @param {Object} client The XMPP client + * @param {Function} callback The function to call on finish + */ +var inviteUsers = function(name, users, client, callback) +{ + async.each(users, function(user, callback) { + utils.log(`Make ${user.blue} member of ${name.magenta}`, false, 1); + client.setRoomAffiliation(name, user, 'member', null, function(err) { + callback && callback(err); + }); + }, function(err) { + // client.getRoomMembers(name, { + // items: [{ affiliation: 'member' }] + // }, function() { + // callback && callback(); + // utils.log(arguments[1].mucAdmin.items); + // }); + + callback && callback(err); + }); +}; + +/** + * Create all configured MUCs and invite all configured users to it. + * + * @param {String} room The name of the room + * @param {Array} users The users to invite + * @param {Object} client The XMPP client + * @param {Function} callback The function to call on finish + */ +module.exports = function(room, users, client, callback) +{ + async.waterfall([ + function(callback) { + createRoom(room, client, callback); + }, + + function(callback) { + inviteUsers(room, users, client, callback); + } + ], function(err) { + callback && callback(err); + }); +}; diff --git a/tests/lib/users.js b/tests/lib/users.js new file mode 100644 index 0000000..be6153d --- /dev/null +++ b/tests/lib/users.js @@ -0,0 +1,60 @@ +const request = require('request'); +const async = require('async'); +const config = require('../config'); +const utils = require('./utils')(config); + +/** + * Create a new ejabberd user account. + * + * @param {String} name The name of the user + * @param {Function} callback The function to call on finish + */ +var createUser = function(name, callback) +{ + request.post( + `http://${config.hostname}/admin/server/${config.hostname}/users/`, + { + auth: { + user: config.jid, + pass: config.password + }, + form: { + newusername: name, + newuserpassword: name, + addnewuser: 'add' + } + }, + function(err, res, body) { + if (!err && res.statusCode === 200) { + let jid = `${name}@${config.hostname}`; + utils.log(`Create user ${jid.blue}`, false, 1); + return callback && callback(null, { + user: name, + password: name, + jid: jid + }); + } + + utils.log(`User creation failed. (${name})`, false, 1); + utils.log(`Error: ${err.message}`, false, 1); + callback && callback(new Error()); + }); +}; + +/** + * Create all given users and pass them back as an array of + * user objects. + * + * @param {Array} users An array of user names + * @param {Function} callback The function to call on finish + */ +module.exports = function(users, callback) +{ + async.map(users, createUser, function(err, users) { + if (err) { + return callback && callback(err); + } + + callback && callback(null, users); + }); +}; diff --git a/tests/lib/utils.js b/tests/lib/utils.js new file mode 100644 index 0000000..34832d3 --- /dev/null +++ b/tests/lib/utils.js @@ -0,0 +1,143 @@ +const moment = require('moment'); +const colors = require('colors'); +const format = require('./xml-format'); +const startAt = new moment(); + +module.exports = (config) => { + // Presave some defaults + const origMatch = `${config.match}`; + var stanzas = {}; + var matchers = 0; + + // Setup some defaults + config.errors = []; + config.matchCallback = null; + + const utils = { + isRelevant: (xml, direction) => { + // In case config says log all, everything is related + if (!config.skipUnrelated) { return true; } + + // Stop further stanzas when we already had this one before + if (stanzas[config.match]) { return false; } + + // We have a match, so we save it + if (~xml.indexOf(config.match)) { + // Except we are on the default matcher again + if (config.match !== origMatch) { + stanzas[config.match] = true; + } + + // In case we have a matching stanza, save it's id + utils.saveId(xml); + + // Run hooks if there are any + if (config.matchCallback) { + config.matchCallback(xml, direction); + } + + return true; + } + + return false; + }, + + saveId: (xml) => { + let match = xml.match(/id=['"]([^'"]+)/); + if (match && match[1]) { config.match = match[1]; } + }, + + restoreMatcher: () => { + config.match = origMatch; + config.matchCallback = null; + matchers++; + }, + + setMatcher: (match, callback = null) => { + config.match = match; + config.matchCallback = callback; + matchers++; + }, + + setMatcherCallback: (callback) => { + config.matchCallback = callback; + }, + + log: (str, multiple = true, level = 2) => { + if (!config.debug) { + multiple = false; + level = 1; + } + + let pre = Array(level + 1).join('#'); + + if (multiple === true) { + console.log(`${pre}\n${pre} ${str}\n${pre}`); + } else { + console.log(`${pre} ${str}`); + } + }, + + logError: (message, xml, meta = null) => { + config.errors.push({ + message: message, + xml: xml, + meta: meta + }); + utils.log('> ' + `${message} (#${config.errors.length})`.red); + }, + + errors: () => { + if (!config.errors.length) { return; } + + console.log('#'); + utils.log('Error details'.red); + config.errors.forEach((err, idx) => { + console.log('#'); + utils.log(`#${++idx} ${err.message}`.red); + console.log('#'); + console.log(format(err.xml, '# ')); + console.log('#'); + if (err.meta) { + utils.log(` ${err.meta}`); + console.log('#'); + } + }); + }, + + stats: () => { + const endAt = new moment(); + const duration = moment.duration(endAt.diff(startAt)); + const seconds = new String(duration.as('seconds')); + const good = matchers - config.errors.length; + const bad = config.errors.length; + + utils.log([ + 'Statistics: ' + + `${matchers} test cases`.magenta, + `${good} successful`.green, + `${bad} failed`.red + ].join(', ')); + utils.log('Finished in ' + `${seconds}s`.green); + }, + + isoMinute: () => { + return moment().toISOString().split(':').slice(0, 2).join(':'); + }, + + isoHour: () => { + return moment().toISOString().split(':').slice(0, 1).join(':'); + }, + + exit: () => { + utils.errors(); + utils.stats(); + setTimeout(() => { + let code = (!config.errors.length) ? 0 : 1; + process.exit(code); + }, 200); + } + }; + + return utils; +}; diff --git a/tests/lib/xml-format.js b/tests/lib/xml-format.js new file mode 100644 index 0000000..389672e --- /dev/null +++ b/tests/lib/xml-format.js @@ -0,0 +1,43 @@ +const format = require('xml-formatter'); +const hljs = require('highlight.js'); +const h2c = require('./hljs-console'); + +module.exports = (xml, indentation = '') => { + let formatted = format(xml, { indentation: ' ' }); + let level = 0; + + // Break all attributes on a new line + formatted = formatted.replace(/(=['"][^'"]+)(['"])/g, "$1$2\n"); + + // Fix the attribute indentation level + formatted = formatted.split("\n").map((line, idx) => { + // Tag line + if (/^\s*, />) + formatted = formatted.replace(/(['"])\s*>/g, '$1>'); + formatted = formatted.replace(/(['"])\s*\/>/g, '$1/>'); + formatted = formatted.replace(/\/>/g, ' />'); + + // Highlight the formatted XML + let highlighted = hljs.highlightAuto(formatted, ['xml']); + + // Good styles: androidstudio hybrid obsidian solarized-dark + highlighted = h2c.convert(highlighted.value, 'androidstudio'); + + return highlighted.split("\n").map((line, idx) => { + return indentation + line; + }).join("\n"); +}; diff --git a/tests/package-lock.json b/tests/package-lock.json new file mode 100644 index 0000000..dfc47c9 --- /dev/null +++ b/tests/package-lock.json @@ -0,0 +1,1175 @@ +{ + "name": "test", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "ajv": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", + "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=", + "requires": { + "co": "^4.6.0", + "fast-deep-equal": "^1.0.0", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.3.0" + } + }, + "alt-sasl-digest-md5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/alt-sasl-digest-md5/-/alt-sasl-digest-md5-1.0.2.tgz", + "integrity": "sha1-JIEdahFj9I/xkLUOUsmASHzMgAo=", + "requires": { + "create-hash": "^1.1.0", + "randombytes": "^2.0.1" + } + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + } + }, + "asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", + "requires": { + "safer-buffer": "~2.1.0" + } + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + }, + "async": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.1.tgz", + "integrity": "sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ==", + "requires": { + "lodash": "^4.17.10" + } + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + }, + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" + }, + "aws4": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.7.0.tgz", + "integrity": "sha512-32NDda82rhwD9/JBCCkB+MRYDp0oSvlo2IL6rQWA10PQi7tDUM3eqMSltXmY+Oyl/7N3P3qNtAlv7X0d9bI28w==" + }, + "babel-runtime": { + "version": "5.8.38", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-5.8.38.tgz", + "integrity": "sha1-HAsC62MxL18If/IEUIJ7QlydTBk=", + "requires": { + "core-js": "^1.0.0" + } + }, + "bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "optional": true, + "requires": { + "tweetnacl": "^0.14.3" + } + }, + "bindings": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.2.1.tgz", + "integrity": "sha1-FK1hE4EtLTfXLme0ystLtyZQXxE=", + "optional": true + }, + "bitwise-xor": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/bitwise-xor/-/bitwise-xor-0.0.0.tgz", + "integrity": "sha1-BAqBcrW7jMVisLcRnyMLKhp4Dj0=" + }, + "browserify-versionify": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/browserify-versionify/-/browserify-versionify-1.0.6.tgz", + "integrity": "sha1-qy3GHWoRnmJ77Eh1mNGYO3/bJ14=", + "requires": { + "find-root": "^0.1.1", + "through2": "0.6.3" + } + }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" + }, + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "cipher-base": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", + "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=" + }, + "color-convert": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.2.tgz", + "integrity": "sha512-3NUJZdhMhcdPn8vJ9v2UQJoH0qqoGUkYTgFEPZaPjEtwmmKUfNV46zZmgB2M5M4DCEQHMaCfWHCxiBflLm04Tg==", + "requires": { + "color-name": "1.1.1" + } + }, + "color-name": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.1.tgz", + "integrity": "sha1-SxQVMEz1ACjqgWQ2Q72C6gWANok=" + }, + "colors": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.3.1.tgz", + "integrity": "sha512-jg/vxRmv430jixZrC+La5kMbUWqIg32/JsYNZb94+JEmzceYbWKTsv1OuTp+7EaqiaWRR2tPcykibwCRgclIsw==" + }, + "combined-stream": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", + "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "core-js": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz", + "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=" + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + }, + "create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "requires": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "requires": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "css2json": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/css2json/-/css2json-0.0.4.tgz", + "integrity": "sha1-t5gpaBM3CFWZqdsQXVVxGBglQ/c=" + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "requires": { + "assert-plus": "^1.0.0" + } + }, + "debug": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz", + "integrity": "sha1-+HBX6ZWxofauaklgZkE3vFbwOdo=", + "requires": { + "ms": "0.7.1" + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + }, + "dom-walk": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.1.tgz", + "integrity": "sha1-ZyIm3HTI95mtNTB9+TaroRrNYBg=" + }, + "ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "optional": true, + "requires": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "extend-object": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/extend-object/-/extend-object-1.0.0.tgz", + "integrity": "sha1-QlFPhAFdE1bK9Rh5ad+yvBvaCCM=" + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" + }, + "faker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/faker/-/faker-4.1.0.tgz", + "integrity": "sha1-HkW7vsxndLPBlfrSg1EJxtdIzD8=" + }, + "fast-deep-equal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz", + "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=" + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=" + }, + "faye-websocket": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.1.tgz", + "integrity": "sha1-8O/hjE9W5PQK/H4Gxxn9XuYYjzg=", + "requires": { + "websocket-driver": ">=0.5.1" + } + }, + "filetransfer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/filetransfer/-/filetransfer-2.0.5.tgz", + "integrity": "sha1-iHyARv5UbsiugVwzAaXDE1+js10=", + "requires": { + "async": "^0.9.0", + "iana-hashes": "^1.0.0", + "wildemitter": "1.x" + }, + "dependencies": { + "async": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz", + "integrity": "sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=" + } + } + }, + "find-root": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-0.1.2.tgz", + "integrity": "sha1-mNImfP8ZFsyvJ0OzoO6oHXnX3NE=" + }, + "for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "requires": { + "is-callable": "^1.1.3" + } + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" + }, + "form-data": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz", + "integrity": "sha1-SXBJi+YEwgwAXU9cI67NIda0kJk=", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "1.0.6", + "mime-types": "^2.1.12" + } + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "requires": { + "assert-plus": "^1.0.0" + } + }, + "global": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/global/-/global-4.3.2.tgz", + "integrity": "sha1-52mJJopsdMOJCLEwWxD8DjlOnQ8=", + "requires": { + "min-document": "^2.19.0", + "process": "~0.5.1" + } + }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" + }, + "har-validator": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz", + "integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=", + "requires": { + "ajv": "^5.1.0", + "har-schema": "^2.0.0" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" + }, + "hash-base": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.4.tgz", + "integrity": "sha1-X8hoaEfs1zSZQDMZprCj8/auSRg=", + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "highlight.js": { + "version": "9.12.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.12.0.tgz", + "integrity": "sha1-5tnb5Xy+/mB1HwKvM2GVhwyQwB4=" + }, + "hostmeta": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hostmeta/-/hostmeta-2.0.2.tgz", + "integrity": "sha512-VpTNg8KQ4ALgoGrMGBYqHLBFBrmZ5ANiQC1mLb/uroilTmuzjymChvDmDe3K/LRd1ZbszSkvKq65AW1VBykDJQ==", + "requires": { + "async": "^2.5.0", + "jxt": "^3.0.1", + "request": "^2.53.0", + "xhr": "^2.0.1" + } + }, + "html2json": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/html2json/-/html2json-1.0.2.tgz", + "integrity": "sha1-ydbSAvplQCOGwgKzRc9RvOgO0e8=" + }, + "http-parser-js": { + "version": "0.4.13", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.4.13.tgz", + "integrity": "sha1-O9bW/ebjFyyTNMOzO2wZPYD+ETc=" + }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "requires": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "iana-hashes": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/iana-hashes/-/iana-hashes-1.0.3.tgz", + "integrity": "sha1-vqpvIOpIPw8631/+b1lLtQySjKo=", + "requires": { + "create-hash": "^1.1.0", + "create-hmac": "^1.1.3" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "intersect": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/intersect/-/intersect-0.1.0.tgz", + "integrity": "sha1-AaZRNFvVWnvlCuLw9yV/i6EULMs=" + }, + "is-callable": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz", + "integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==" + }, + "is-function": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.1.tgz", + "integrity": "sha1-Es+5i2W1fdPRk6MSH19uL0N2ArU=" + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" + }, + "jingle": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jingle/-/jingle-3.0.3.tgz", + "integrity": "sha512-/aLl0GuggF9E4GjDTmIS93/m+FXE9Ukdz2hKOyOYvE9BnfOfpyp+6QaLU0fBdQm6lbuvd7/Z32t3FNpjrKIeHg==", + "requires": { + "extend-object": "^1.0.0", + "intersect": "^0.1.0", + "jingle-filetransfer-session": "^2.0.0", + "jingle-media-session": "^2.0.0", + "jingle-session": "^2.0.0", + "wildemitter": "^1.0.1" + } + }, + "jingle-filetransfer-session": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/jingle-filetransfer-session/-/jingle-filetransfer-session-2.0.2.tgz", + "integrity": "sha512-go+xcXj9pwXGhhSvqGrn9cB+FizW0ryif4OK8oAQzoxlH6jXR/Hczcb6h9pGU/rgojTrV0CiXRlncRKyoJWIJg==", + "requires": { + "extend-object": "^1.0.0", + "filetransfer": "^2.0.4", + "jingle-session": "^2.0.0", + "rtcpeerconnection": "^8.0.0" + } + }, + "jingle-media-session": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/jingle-media-session/-/jingle-media-session-2.3.1.tgz", + "integrity": "sha512-5QnBSHamP33hWm5/sLCQd+7IWrN9Qsg1VevAwMo0uLBAX/OqGQXI7f21S/KhZ+GuB7M1Gw3EfSyWd12Q3LyEgA==", + "requires": { + "extend-object": "^1.0.0", + "jingle-session": "^2.0.0", + "rtcpeerconnection": "^8.3.1" + } + }, + "jingle-session": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/jingle-session/-/jingle-session-2.0.3.tgz", + "integrity": "sha512-Nv4GTjI+mqVbaAKy0J03UUIAG/7dunOWvFAjQ83seyzu1Wfxn0iiQCZMCphWNa04SYWiVzQVqkeCxsA0OAylMw==", + "requires": { + "async": "^2.5.0", + "extend-object": "^1.0.0", + "uuid": "^3.1.0", + "wildemitter": "^1.0.1" + } + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "optional": true + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" + }, + "json-schema-traverse": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz", + "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=" + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + } + }, + "jxt": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jxt/-/jxt-3.1.0.tgz", + "integrity": "sha512-rKWfE6BflsT1pDQCbyyN2pSHmVxZPjPwibzVUwlI3n4iOHgALVc08wDDB3ZuJ/lolTKdDQvWNCaGz3lLW4yoog==", + "requires": { + "lodash.assign": "^3.0.0", + "ltx": "^2.2.0", + "uuid": "^3.0.0" + } + }, + "jxt-xmpp": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jxt-xmpp/-/jxt-xmpp-3.2.2.tgz", + "integrity": "sha512-cK1+0+xaXniTXO1lHB8J3Y7TFIXZNMO6f4yegq3IFkXlkIvcNTPt9n/S3xU+33XfT8+M3+Qxe/UsOiGaMbqm5Q==", + "requires": { + "babel-runtime": "^5.6.15", + "lodash.foreach": "^3.0.3", + "xmpp-constants": "^2.3.0", + "xmpp-jid": "^1.1.1" + } + }, + "jxt-xmpp-types": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/jxt-xmpp-types/-/jxt-xmpp-types-3.0.0.tgz", + "integrity": "sha1-wBW6458Mlry9T0yp64GXdDr+bgg=", + "requires": { + "jxt": "^3.0.1", + "xmpp-constants": "^2.0.0", + "xmpp-jid": "^1.0.2" + } + }, + "lodash": { + "version": "4.17.10", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz", + "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg==" + }, + "lodash._arrayeach": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._arrayeach/-/lodash._arrayeach-3.0.0.tgz", + "integrity": "sha1-urFWsqkNPxu9XGU0AzSeXlkz754=" + }, + "lodash._arrayfilter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._arrayfilter/-/lodash._arrayfilter-3.0.0.tgz", + "integrity": "sha1-LevhHuxp5dzG9LhhNxKKSPFSQjc=" + }, + "lodash._baseassign": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz", + "integrity": "sha1-jDigmVAPIVrQnlnxci/QxSv+Ck4=", + "requires": { + "lodash._basecopy": "^3.0.0", + "lodash.keys": "^3.0.0" + } + }, + "lodash._basecallback": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/lodash._basecallback/-/lodash._basecallback-3.3.1.tgz", + "integrity": "sha1-t7K7Q9whYEJKIczybFfkQ3cqjic=", + "requires": { + "lodash._baseisequal": "^3.0.0", + "lodash._bindcallback": "^3.0.0", + "lodash.isarray": "^3.0.0", + "lodash.pairs": "^3.0.0" + } + }, + "lodash._basecopy": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz", + "integrity": "sha1-jaDmqHbPNEwK2KVIghEd08XHyjY=" + }, + "lodash._baseeach": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/lodash._baseeach/-/lodash._baseeach-3.0.4.tgz", + "integrity": "sha1-z4cGVyyhROjZ11InyZDamC+TKvM=", + "requires": { + "lodash.keys": "^3.0.0" + } + }, + "lodash._basefilter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._basefilter/-/lodash._basefilter-3.0.0.tgz", + "integrity": "sha1-S3ZAPfDihtA9Xg9yle00QeEB0SE=", + "requires": { + "lodash._baseeach": "^3.0.0" + } + }, + "lodash._baseindexof": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash._baseindexof/-/lodash._baseindexof-3.1.0.tgz", + "integrity": "sha1-/lK1OhxnYeQmGNZU5KJXie1hgiw=" + }, + "lodash._baseisequal": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/lodash._baseisequal/-/lodash._baseisequal-3.0.7.tgz", + "integrity": "sha1-2AJfdjOdKTQnZ9zIh85cuVpbUfE=", + "requires": { + "lodash.isarray": "^3.0.0", + "lodash.istypedarray": "^3.0.0", + "lodash.keys": "^3.0.0" + } + }, + "lodash._baseuniq": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash._baseuniq/-/lodash._baseuniq-3.0.3.tgz", + "integrity": "sha1-ISP6DbLWnCjVvrHB821hUip0AjQ=", + "requires": { + "lodash._baseindexof": "^3.0.0", + "lodash._cacheindexof": "^3.0.0", + "lodash._createcache": "^3.0.0" + } + }, + "lodash._bindcallback": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz", + "integrity": "sha1-5THCdkTPi1epnhftlbNcdIeJOS4=" + }, + "lodash._cacheindexof": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/lodash._cacheindexof/-/lodash._cacheindexof-3.0.2.tgz", + "integrity": "sha1-PcaayCSY0u5ePOVgkbr9Ktx73pI=" + }, + "lodash._createassigner": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lodash._createassigner/-/lodash._createassigner-3.1.1.tgz", + "integrity": "sha1-g4pbri/aymOsIt7o4Z+k5taXCxE=", + "requires": { + "lodash._bindcallback": "^3.0.0", + "lodash._isiterateecall": "^3.0.0", + "lodash.restparam": "^3.0.0" + } + }, + "lodash._createcache": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lodash._createcache/-/lodash._createcache-3.1.2.tgz", + "integrity": "sha1-VtagZAF2JeeevKa4AY4XRAvc8JM=", + "requires": { + "lodash._getnative": "^3.0.0" + } + }, + "lodash._getnative": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz", + "integrity": "sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=" + }, + "lodash._isiterateecall": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz", + "integrity": "sha1-UgOte6Ql+uhCRg5pbbnPPmqsBXw=" + }, + "lodash.assign": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-3.2.0.tgz", + "integrity": "sha1-POnwI0tLIiPilrj6CsH+6OvKZPo=", + "requires": { + "lodash._baseassign": "^3.0.0", + "lodash._createassigner": "^3.0.0", + "lodash.keys": "^3.0.0" + } + }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" + }, + "lodash.filter": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lodash.filter/-/lodash.filter-3.1.1.tgz", + "integrity": "sha1-m7HUUzctnPkpEnw1Tfcj9Hri3D8=", + "requires": { + "lodash._arrayfilter": "^3.0.0", + "lodash._basecallback": "^3.0.0", + "lodash._basefilter": "^3.0.0", + "lodash.isarray": "^3.0.0", + "lodash.keys": "^3.0.0" + } + }, + "lodash.foreach": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-3.0.3.tgz", + "integrity": "sha1-b9fvt5aRrs1n/erCdhyY5wHWw5o=", + "requires": { + "lodash._arrayeach": "^3.0.0", + "lodash._baseeach": "^3.0.0", + "lodash._bindcallback": "^3.0.0", + "lodash.isarray": "^3.0.0" + } + }, + "lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=" + }, + "lodash.isarray": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz", + "integrity": "sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=" + }, + "lodash.istypedarray": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/lodash.istypedarray/-/lodash.istypedarray-3.0.6.tgz", + "integrity": "sha1-yaR3SYYHUB2OhJTSg7h8OSgc72I=" + }, + "lodash.keys": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz", + "integrity": "sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=", + "requires": { + "lodash._getnative": "^3.0.0", + "lodash.isarguments": "^3.0.0", + "lodash.isarray": "^3.0.0" + } + }, + "lodash.pairs": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.pairs/-/lodash.pairs-3.0.1.tgz", + "integrity": "sha1-u+CNV4bu6qCaFckevw3LfSvjJqk=", + "requires": { + "lodash.keys": "^3.0.0" + } + }, + "lodash.restparam": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/lodash.restparam/-/lodash.restparam-3.6.1.tgz", + "integrity": "sha1-k2pOMJ7zMKdkXtQUWYbIWuWyCAU=" + }, + "lodash.uniq": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-3.2.2.tgz", + "integrity": "sha1-FGw28l510ZUBukAuiLoUk39jzYs=", + "requires": { + "lodash._basecallback": "^3.0.0", + "lodash._baseuniq": "^3.0.0", + "lodash._getnative": "^3.0.0", + "lodash._isiterateecall": "^3.0.0", + "lodash.isarray": "^3.0.0" + } + }, + "ltx": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/ltx/-/ltx-2.7.1.tgz", + "integrity": "sha1-Dly9y1vxeM+ngx6kHcMj2XQiMVo=", + "requires": { + "inherits": "^2.0.1" + } + }, + "md5.js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.4.tgz", + "integrity": "sha1-6b296UogpawYsENA/Fdk1bCdkB0=", + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1" + } + }, + "mime-db": { + "version": "1.35.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.35.0.tgz", + "integrity": "sha512-JWT/IcCTsB0Io3AhWUMjRqucrHSPsSf2xKLaRldJVULioggvkJvggZ3VXNNSRkCddE6D+BUI4HEIZIA2OjwIvg==" + }, + "mime-types": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.19.tgz", + "integrity": "sha512-P1tKYHVSZ6uFo26mtnve4HQFE3koh1UWVkp8YUC+ESBHe945xWSoXuHHiGarDqcEZ+whpCDnlNw5LON0kLo+sw==", + "requires": { + "mime-db": "~1.35.0" + } + }, + "min-document": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", + "integrity": "sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU=", + "requires": { + "dom-walk": "^0.1.0" + } + }, + "moment": { + "version": "2.22.2", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.22.2.tgz", + "integrity": "sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y=" + }, + "ms": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz", + "integrity": "sha1-nNE8A62/8ltl7/3nzoZO6VIBcJg=" + }, + "nan": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.3.5.tgz", + "integrity": "sha1-gioNwmYpDOTNOhIoLKPn42Rmigg=", + "optional": true + }, + "node-stringprep": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/node-stringprep/-/node-stringprep-0.8.0.tgz", + "integrity": "sha1-5KOeSOpEhuxFS8CNylHdGqRoZBc=", + "optional": true, + "requires": { + "bindings": "~1.2.1", + "debug": "~2.2.0", + "nan": "~2.3.3" + } + }, + "oauth-sign": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", + "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=" + }, + "parse-headers": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.1.tgz", + "integrity": "sha1-aug6eqJanZtwCswoaYzR8e1+lTY=", + "requires": { + "for-each": "^0.3.2", + "trim": "0.0.1" + } + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + }, + "process": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/process/-/process-0.5.2.tgz", + "integrity": "sha1-FjjYqONML0QKkduVq5rrZ3/Bhc8=" + }, + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" + }, + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + }, + "randombytes": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.0.6.tgz", + "integrity": "sha512-CIQ5OFxf4Jou6uOKe9t1AOgqpeU5fd70A8NPdHSGeYXqXsPe6peOwI0cUl88RWZ6sP1vPMV3avd/R6cZ5/sP1A==", + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "request": { + "version": "2.87.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.87.0.tgz", + "integrity": "sha512-fcogkm7Az5bsS6Sl0sibkbhcKsnyon/jV1kF3ajGmF0c8HrttdKTPRT9hieOaQHA5HEq6r8OyWOo/o781C1tNw==", + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.6.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.5", + "extend": "~3.0.1", + "forever-agent": "~0.6.1", + "form-data": "~2.3.1", + "har-validator": "~5.0.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.17", + "oauth-sign": "~0.8.2", + "performance-now": "^2.1.0", + "qs": "~6.5.1", + "safe-buffer": "^5.1.1", + "tough-cookie": "~2.3.3", + "tunnel-agent": "^0.6.0", + "uuid": "^3.1.0" + } + }, + "ripemd160": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", + "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1" + } + }, + "rtcpeerconnection": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/rtcpeerconnection/-/rtcpeerconnection-8.4.0.tgz", + "integrity": "sha512-HgntXWv+7DRufcGUroOSSNTpAIFRwLWCiLyutwfyVfrmPI7E5n7xP4JlwFWC6XNJV/LBILNru9bYa/FWcvyUuA==", + "requires": { + "lodash.clonedeep": "^4.3.2", + "sdp-jingle-json": "^3.0.0", + "wildemitter": "1.x" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "sasl-anonymous": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/sasl-anonymous/-/sasl-anonymous-0.1.0.tgz", + "integrity": "sha1-9UTH6CTfKkDZrUczgpVyzI2e1aU=" + }, + "sasl-external": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/sasl-external/-/sasl-external-0.1.0.tgz", + "integrity": "sha1-n2vL6ccZKxyFzkhMu8QJlAkYbGI=" + }, + "sasl-plain": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/sasl-plain/-/sasl-plain-0.1.0.tgz", + "integrity": "sha1-zxRefAIiK2TWDAgG2c0q5TgEJsw=" + }, + "sasl-scram-sha-1": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/sasl-scram-sha-1/-/sasl-scram-sha-1-1.2.1.tgz", + "integrity": "sha1-2I1R/qoP8yDY6x1vx1ZXZT+dzUs=", + "requires": { + "bitwise-xor": "0.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.3", + "randombytes": "^2.0.1" + } + }, + "sasl-x-oauth2": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/sasl-x-oauth2/-/sasl-x-oauth2-0.1.0.tgz", + "integrity": "sha1-NcREC8JG9bUuW+RXTOP29P2CKpk=" + }, + "saslmechanisms": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/saslmechanisms/-/saslmechanisms-0.1.1.tgz", + "integrity": "sha1-R4vhQpUA/PqngL6IszQ87X0qkYI=" + }, + "sdp-jingle-json": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sdp-jingle-json/-/sdp-jingle-json-3.0.3.tgz", + "integrity": "sha512-MoRCqjk8bUVHhNm86yiCfOmOCMnXCrbAyhrPCv9qUkK6Ye0NpxU7S6la8791+Rta/IQxf/+801rjmv7IWzl2zQ==" + }, + "sha.js": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", + "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "sshpk": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.14.2.tgz", + "integrity": "sha1-xvxhZIo9nE52T9P8306hBeSSupg=", + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + } + }, + "stanza.io": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/stanza.io/-/stanza.io-9.1.0.tgz", + "integrity": "sha512-+iSiG4x+qeMkXmSQ1PeX+aHfJc0L+VNBKe9BQW5x+ugNPZm5Eu2P6vlz5kRNKBvIvA14JGnWKyFmPqvmrba3ig==", + "requires": { + "alt-sasl-digest-md5": "^1.0.0", + "async": "^2.5.0", + "browserify-versionify": "^1.0.4", + "faye-websocket": "^0.11.0", + "hostmeta": "^2.0.0", + "iana-hashes": "^1.0.2", + "jingle": "^3.0.0", + "jxt": "^3.1.0", + "jxt-xmpp": "^3.1.0", + "jxt-xmpp-types": "^3.0.0", + "lodash.assign": "^3.0.0", + "lodash.filter": "^3.1.0", + "lodash.foreach": "^3.0.2", + "lodash.isarray": "^3.0.1", + "lodash.uniq": "^3.1.0", + "request": "^2.53.0", + "sasl-anonymous": "^0.1.0", + "sasl-external": "^0.1.0", + "sasl-plain": "^0.1.0", + "sasl-scram-sha-1": "^1.1.0", + "sasl-x-oauth2": "^0.1.0", + "saslmechanisms": "^0.1.1", + "uuid": "^3.0.1", + "wildemitter": "^1.0.1", + "xhr": "^2.0.1", + "xmpp-jid": "^1.0.0" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" + }, + "supports-color": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", + "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", + "requires": { + "has-flag": "^3.0.0" + } + }, + "through2": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.3.tgz", + "integrity": "sha1-eVKS/enyVMKjaLOPnMXRvUZjr7Y=", + "requires": { + "readable-stream": ">=1.0.33-1 <1.1.0-0", + "xtend": ">=4.0.0 <4.1.0-0" + } + }, + "tough-cookie": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz", + "integrity": "sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA==", + "requires": { + "punycode": "^1.4.1" + } + }, + "trim": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz", + "integrity": "sha1-WFhUf2spB1fulczMZm+1AITEYN0=" + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "optional": true + }, + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "vkbeautify": { + "version": "0.99.3", + "resolved": "https://registry.npmjs.org/vkbeautify/-/vkbeautify-0.99.3.tgz", + "integrity": "sha512-2ozZEFfmVvQcHWoHLNuiKlUfDKlhh4KGsy54U0UrlLMR1SO+XKAIDqBxtBwHgNrekurlJwE8A9K6L49T78ZQ9Q==" + }, + "websocket-driver": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.0.tgz", + "integrity": "sha1-DK+dLXVdk67gSdS90NP+LMoqJOs=", + "requires": { + "http-parser-js": ">=0.4.0", + "websocket-extensions": ">=0.1.1" + } + }, + "websocket-extensions": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.3.tgz", + "integrity": "sha512-nqHUnMXmBzT0w570r2JpJxfiSD1IzoI+HGVdd3aZ0yNi3ngvQ4jv1dtHt5VGxfI2yj5yqImPhOK4vmIh2xMbGg==" + }, + "wildemitter": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/wildemitter/-/wildemitter-1.2.0.tgz", + "integrity": "sha1-Kd06ctaZw+J53QIcPNIVC4LJohE=" + }, + "xhr": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/xhr/-/xhr-2.5.0.tgz", + "integrity": "sha512-4nlO/14t3BNUZRXIXfXe+3N6w3s1KoxcJUUURctd64BLRe67E4gRwp4PjywtDY72fXpZ1y6Ch0VZQRY/gMPzzQ==", + "requires": { + "global": "~4.3.0", + "is-function": "^1.0.1", + "parse-headers": "^2.0.0", + "xtend": "^4.0.0" + } + }, + "xml-formatter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml-formatter/-/xml-formatter-1.0.1.tgz", + "integrity": "sha1-OAgz3dC86iwJht7+u6cfhDhPNT0=", + "requires": { + "xml-parser-xo": "^2.1.1" + } + }, + "xml-parser-xo": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/xml-parser-xo/-/xml-parser-xo-2.1.3.tgz", + "integrity": "sha1-TqjrhW36TddcSrVLJRVY3grcHGE=", + "requires": { + "debug": "^2.2.0" + } + }, + "xmpp-constants": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/xmpp-constants/-/xmpp-constants-2.4.0.tgz", + "integrity": "sha1-uP5bgAWrBdtnNlhtfFpllq96yOE=" + }, + "xmpp-jid": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/xmpp-jid/-/xmpp-jid-1.2.3.tgz", + "integrity": "sha1-pgo+mr+p4CeRAgFdZJQWgc+z8yA=", + "requires": { + "node-stringprep": "^0.8.0", + "punycode": "^1.3.0" + } + }, + "xtend": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", + "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=" + } + } +} diff --git a/tests/package.json b/tests/package.json new file mode 100644 index 0000000..944acb8 --- /dev/null +++ b/tests/package.json @@ -0,0 +1,25 @@ +{ + "name": "test", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "async": "^2.6.1", + "chalk": "^2.4.1", + "colors": "^1.3.1", + "css2json": "0.0.4", + "faker": "^4.1.0", + "highlight.js": "^9.12.0", + "html2json": "^1.0.2", + "moment": "^2.22.2", + "request": "^2.87.0", + "stanza.io": "^9.1.0", + "vkbeautify": "^0.99.3", + "xml-formatter": "^1.0.1" + } +} diff --git a/tests/src/client.js b/tests/src/client.js new file mode 100644 index 0000000..09b9738 --- /dev/null +++ b/tests/src/client.js @@ -0,0 +1,28 @@ +const xmpp = require('stanza.io'); +const colors = require('colors'); + +const installSpecs = require('./xmpp-specs'); +const installMiddleware = require('./stanza-middleware'); +const setupUtils = require('../lib/utils'); + +module.exports = (config, callback) => { + // Setup a new XMPP client + const client = xmpp.createClient(config); + const utils = setupUtils(client.config); + + // Install all the nifty handlers to that thing + installSpecs(client); + installMiddleware(client); + + // Call the user given function when we have a connection + client.on('session:started', () => { + client.sendPresence(); + client.enableCarbons(); + setTimeout(() => { + callback && callback(client, utils); + }, 500); + }); + + // Connect the new client + client.connect(); +}; diff --git a/tests/src/seeds.js b/tests/src/seeds.js new file mode 100644 index 0000000..d650f8a --- /dev/null +++ b/tests/src/seeds.js @@ -0,0 +1,17 @@ +const async = require('async'); +const createUsers = require('../lib/users'); +const createRoom = require('../lib/rooms'); + +module.exports = (client) => { + return (callback) => { + async.waterfall([ + function(callback) { createUsers(client.config.users, callback); }, + function(users, callback) { + createRoom(client.config.room, + users.map((user) => user.jid), + client, + callback); + } + ], () => callback()); + }; +}; diff --git a/tests/src/stanza-middleware.js b/tests/src/stanza-middleware.js new file mode 100644 index 0000000..85fbc73 --- /dev/null +++ b/tests/src/stanza-middleware.js @@ -0,0 +1,28 @@ +const colors = require('colors'); +const format = require('../lib/xml-format'); + +module.exports = (client) => { + const utils = require('../lib/utils')(client.config); + + client.on('raw:outgoing', (xml) => { + // In case we ignore non-relevant stanzas + if (!utils.isRelevant(xml, 'request')) { return; } + + // Format and log the stanza + if (client.config.debug) { + console.log("###\n### Request\n###".magenta); + console.log(format(xml, '>>> '.magenta)); + } + }); + + client.on('raw:incoming', (xml) => { + // In case we ignore non-relevant stanzas + if (!utils.isRelevant(xml, 'response')) { return; } + + // Format and log the stanza + if (client.config.debug) { + console.log("###\n### Response\n###".blue); + console.log(format(xml, '<<< '.blue)); + } + }); +}; diff --git a/tests/src/stanza-validator.js b/tests/src/stanza-validator.js new file mode 100644 index 0000000..90d1401 --- /dev/null +++ b/tests/src/stanza-validator.js @@ -0,0 +1,148 @@ +module.exports = (utils) => { + const match = (xml, regex, message) => { + if (regex.constructor !== RegExp) { + regex = new RegExp(regex); + } + + if (!regex.test(xml)) { + utils.logError(message, xml, `Match failed for ${regex}`); + } + }; + + const matchMissing = (xml, regex, message) => { + if (regex.constructor !== RegExp) { + regex = new RegExp(regex); + } + + if (regex.test(xml)) { + utils.logError(message, xml, `Match for ${regex}`); + } + }; + + return { + message: (room) => { + return (xml, direction) => { + if (direction !== 'response') { return; } + const contains = (regex, message) => match(xml, regex, message); + const missing = (regex, message) => matchMissing(xml, regex, message); + + contains( + `.*`, + `Message response for ${room} failed.` + ); + }; + }, + + receiveMuc: (room, to, expected) => { + return (xml, direction) => { + if (direction !== 'response') { return; } + const contains = (regex, message) => match(xml, regex, message); + const missing = (regex, message) => matchMissing(xml, regex, message); + + contains( + /result<.' + ); + + if (expected.amount && expected.amount !== null) { + contains( + /`, + `The unseen message amount should be ${expected.amount}.` + ); + } + + if (expected.amount === null) { + missing( + ``, + `The unseen-messages element should be missing.` + ); + } + + if (expected.message && expected.message !== null) { + contains( + /`, + `The last message id should not be empty.` + ); + + if (expected.message.id) { + contains( + ``, + `The last message id should be ${expected.message.id}.` + ); + } + + if (expected.message.at) { + contains( + `.*`, + `The last message should contain a seen-at element.` + ); + + contains( + `.*`, + `The seen-at cdata should not be empty.` + ); + + contains( + `${expected.message.at}.*Z`, + `The seen-at cdata is not valid.` + ); + } + } + + if (expected.message === null) { + missing( + ``, + `The last-message element should be missing.` + ); + } + }; + }, + + acknowledgeMuc: (room, user) => { + return (xml, direction) => { + if (direction !== 'response') { return; } + const contains = (regex, message) => match(xml, regex, message); + const missing = (regex, message) => matchMissing(xml, regex, message); + + contains( + /result<.' + ); + }; + } + }; +}; diff --git a/tests/src/testcases.js b/tests/src/testcases.js new file mode 100644 index 0000000..cb8af60 --- /dev/null +++ b/tests/src/testcases.js @@ -0,0 +1,79 @@ +const faker = require('faker'); +const setupClient = require('./client'); + +module.exports = (client, utils) => { + // Setup a reference shortcut + var config = client.config; + + // Setup the stanza validator + const validator = require('./stanza-validator')(utils); + + return { + // Send message to room + message: (callback) => { + utils.log('Send a new message to ' + config.room.blue); + client.joinRoom(config.room, 'admin'); + setTimeout(() => { + let message = faker.hacker.phrase(); + utils.setMatcher(message, validator.message(config.room)); + client.sendMessage({ + to: config.room, + body: message, + type: 'groupchat' + }); + setTimeout(callback, 1000); + }, 200); + }, + + // Ask for the last read message + receiveMuc: (user, expected) => { + if (!user) { user = config.jid; } + else { user = `${user}@${config.hostname}`; } + + return (callback) => { + utils.restoreMatcher(); + utils.setMatcherCallback( + validator.receiveMuc(config.room, config.jid, expected) + ); + utils.log('Ask for the last read message of ' + user.blue); + client.sendIq({ + type: 'get', + to: config.room, + query: { jid: user } + }, () => { setTimeout(callback, 1000); }); + }; + }, + + // Ack the last read message + acknowledgeMuc: (username, id = '1500000000000000') => { + if (!username) { + var user = config.jid; + var password = config.password; + } else { + var user = `${username}@${config.hostname}`; + var password = username; + } + + return (callback) => { + let newConf = Object.assign({}, client.config, { + jid: user, + password: password + }); + delete newConf.credentials; + + setupClient(newConf, (client, utils) => { + utils.restoreMatcher(); + utils.setMatcherCallback( + validator.acknowledgeMuc(config.room, user) + ); + utils.log('Acknowledge the last read message of ' + user.blue); + client.sendIq({ + type: 'set', + to: config.room, + ack: { id: id } + }, () => { setTimeout(callback, 1000); }); + }); + }; + } + }; +}; diff --git a/tests/src/xmpp-specs.js b/tests/src/xmpp-specs.js new file mode 100644 index 0000000..851d078 --- /dev/null +++ b/tests/src/xmpp-specs.js @@ -0,0 +1,27 @@ +// Here are the XMPP specifications for our custom read markers module. +// Require this file and pass in a +stanza.io+ client instance. The code +// below takes care to register the new stanzas. +module.exports = (client) => { + const jxt = client.stanzas; + const Iq = jxt.getDefinition('iq', 'jabber:client'); + + const AckReadMarkers = jxt.define({ + name: 'ack', + element: 'ack', + namespace: 'urn:xmpp:read-markers', + fields: { + id: jxt.utils.attribute('id') + } + }); + jxt.extend(Iq, AckReadMarkers); + + const QueryReadMarkers = jxt.define({ + name: 'query', + element: 'query', + namespace: 'urn:xmpp:read-markers', + fields: { + jid: jxt.utils.attribute('jid') + } + }); + jxt.extend(Iq, QueryReadMarkers); +};