From 7601143bad3d69827c2a8c89a7b18fd30b3d17fb Mon Sep 17 00:00:00 2001 From: Stefan Saenger Date: Mon, 5 Sep 2022 06:50:25 +0200 Subject: [PATCH] fix: initial dockerbuild of current prosody for alpine --- .gitlab-ci.yml | 88 + Dockerfile | 37 + README.md | 91 +- config/prosody.cfg.lua.template | 291 +++ config/startup.sh | 34 + patches/mod_admin_telnet.lua | 1560 ++++++++++++++++ patches/mod_carbons.lua | 116 ++ patches/mod_mam.lua | 416 +++++ patches/mod_muc.lua | 535 ++++++ patches/mod_muc_unique.lua | 37 + patches/moduleapi.lua | 440 +++++ patches/muc.lib.lua | 1604 +++++++++++++++++ patches/patches.list | 5 + patches/portmanager.lua | 244 +++ patches/register.lib.lua | 229 +++ scan/.keep | 0 test.sh | 16 + vnctalk/mod_alias.lua | 44 + vnctalk/mod_auth_any.lua | 51 + vnctalk/mod_auth_http_async/README.markdown | 31 + .../mod_auth_http_async.lua | 126 ++ .../README.markdown | 39 + .../mod_auto_accept_subscriptions/README.wiki | 27 + .../mod_auto_accept_subscriptions.lua | 96 + .../mod_carbons_copies/mod_carbons_copies.lua | 63 + vnctalk/mod_discoitems/README.markdown | 44 + vnctalk/mod_discoitems/README.wiki | 32 + vnctalk/mod_discoitems/mod_discoitems.lua | 25 + vnctalk/mod_filter_chatstates/README.markdown | 26 + vnctalk/mod_filter_chatstates/README.wiki | 18 + .../mod_filter_chatstates.lua | 31 + .../mod_http_altconnect.lua | 60 + vnctalk/mod_http_index/http_index.html | 54 + vnctalk/mod_http_index/mod_http_index.lua | 58 + vnctalk/mod_http_rest/README.markdown | 29 + vnctalk/mod_http_rest/mod_http_rest.lua | 46 + .../mod_http_upload_external/README.markdown | 108 ++ .../mod_http_upload_external.lua | 126 ++ vnctalk/mod_http_upload_external/share.php | 119 ++ vnctalk/mod_idlecompat/README.markdown | 24 + vnctalk/mod_idlecompat/README.wiki | 13 + vnctalk/mod_idlecompat/mod_idlecompat.lua | 31 + vnctalk/mod_roster_command.lua | 165 ++ vnctalk/mod_smacks/README.markdown | 63 + vnctalk/mod_smacks/README.wiki | 31 + vnctalk/mod_smacks/mod_smacks.lua | 659 +++++++ vnctalk/mod_smacks/mod_smacks.lua.orig | 586 ++++++ .../mod_smacks_offline/mod_smacks_offline.lua | 4 + vnctalk/mod_vcard_muc/mod_vcard_muc.lua | 174 ++ vnctalk/mod_vnc_broadcast.lua | 106 ++ vnctalk/mod_vnc_delfile.lua | 123 ++ vnctalk/mod_vnc_e2ehints.lua | 54 + vnctalk/mod_vnc_fcm.lua | 982 ++++++++++ vnctalk/mod_vnc_fcm_hin.lua | 932 ++++++++++ vnctalk/mod_vnc_lastactivity.lua | 370 ++++ vnctalk/mod_vnc_muc_automember.lua | 32 + vnctalk/mod_vnc_muc_data.lua | 92 + vnctalk/mod_vnc_muc_fcm.lua | 695 +++++++ vnctalk/mod_vnc_muc_fcm_hin.lua | 654 +++++++ vnctalk/mod_vnc_muc_hook.lua | 129 ++ vnctalk/mod_vnc_receipts.lua | 59 + vnctalk/mod_vnc_remotemucinvite.lua | 72 + vnctalk/mod_vnc_remotemucstore.lua | 166 ++ vnctalk/mod_vnc_timestamp.lua | 70 + vnctalk/mod_vnc_track_kicks.lua | 40 + vnctalk/mod_vnc_vcard_avatar.lua | 117 ++ vnctalk/mod_vnc_vcard_fallback.lua | 106 ++ vnctalk/mod_webpresence/README.markdown | 65 + vnctalk/mod_webpresence/README.wiki | 43 + vnctalk/mod_webpresence/icons/status_away.png | Bin 0 -> 948 bytes vnctalk/mod_webpresence/icons/status_chat.png | Bin 0 -> 920 bytes vnctalk/mod_webpresence/icons/status_dnd.png | Bin 0 -> 822 bytes .../mod_webpresence/icons/status_offline.png | Bin 0 -> 905 bytes .../mod_webpresence/icons/status_online.png | Bin 0 -> 920 bytes vnctalk/mod_webpresence/icons/status_xa.png | Bin 0 -> 954 bytes vnctalk/mod_webpresence/mod_webpresence.lua | 118 ++ 76 files changed, 13681 insertions(+), 90 deletions(-) create mode 100644 .gitlab-ci.yml create mode 100644 Dockerfile create mode 100644 config/prosody.cfg.lua.template create mode 100755 config/startup.sh create mode 100644 patches/mod_admin_telnet.lua create mode 100644 patches/mod_carbons.lua create mode 100644 patches/mod_mam.lua create mode 100644 patches/mod_muc.lua create mode 100644 patches/mod_muc_unique.lua create mode 100644 patches/moduleapi.lua create mode 100644 patches/muc.lib.lua create mode 100644 patches/patches.list create mode 100644 patches/portmanager.lua create mode 100644 patches/register.lib.lua create mode 100644 scan/.keep create mode 100755 test.sh create mode 100644 vnctalk/mod_alias.lua create mode 100644 vnctalk/mod_auth_any.lua create mode 100644 vnctalk/mod_auth_http_async/README.markdown create mode 100644 vnctalk/mod_auth_http_async/mod_auth_http_async.lua create mode 100644 vnctalk/mod_auto_accept_subscriptions/README.markdown create mode 100644 vnctalk/mod_auto_accept_subscriptions/README.wiki create mode 100644 vnctalk/mod_auto_accept_subscriptions/mod_auto_accept_subscriptions.lua create mode 100644 vnctalk/mod_carbons_copies/mod_carbons_copies.lua create mode 100644 vnctalk/mod_discoitems/README.markdown create mode 100644 vnctalk/mod_discoitems/README.wiki create mode 100644 vnctalk/mod_discoitems/mod_discoitems.lua create mode 100644 vnctalk/mod_filter_chatstates/README.markdown create mode 100644 vnctalk/mod_filter_chatstates/README.wiki create mode 100644 vnctalk/mod_filter_chatstates/mod_filter_chatstates.lua create mode 100644 vnctalk/mod_http_altconnect/mod_http_altconnect.lua create mode 100644 vnctalk/mod_http_index/http_index.html create mode 100644 vnctalk/mod_http_index/mod_http_index.lua create mode 100644 vnctalk/mod_http_rest/README.markdown create mode 100644 vnctalk/mod_http_rest/mod_http_rest.lua create mode 100644 vnctalk/mod_http_upload_external/README.markdown create mode 100644 vnctalk/mod_http_upload_external/mod_http_upload_external.lua create mode 100644 vnctalk/mod_http_upload_external/share.php create mode 100644 vnctalk/mod_idlecompat/README.markdown create mode 100644 vnctalk/mod_idlecompat/README.wiki create mode 100644 vnctalk/mod_idlecompat/mod_idlecompat.lua create mode 100644 vnctalk/mod_roster_command.lua create mode 100644 vnctalk/mod_smacks/README.markdown create mode 100644 vnctalk/mod_smacks/README.wiki create mode 100644 vnctalk/mod_smacks/mod_smacks.lua create mode 100644 vnctalk/mod_smacks/mod_smacks.lua.orig create mode 100644 vnctalk/mod_smacks_offline/mod_smacks_offline.lua create mode 100644 vnctalk/mod_vcard_muc/mod_vcard_muc.lua create mode 100644 vnctalk/mod_vnc_broadcast.lua create mode 100644 vnctalk/mod_vnc_delfile.lua create mode 100644 vnctalk/mod_vnc_e2ehints.lua create mode 100644 vnctalk/mod_vnc_fcm.lua create mode 100644 vnctalk/mod_vnc_fcm_hin.lua create mode 100644 vnctalk/mod_vnc_lastactivity.lua create mode 100644 vnctalk/mod_vnc_muc_automember.lua create mode 100644 vnctalk/mod_vnc_muc_data.lua create mode 100644 vnctalk/mod_vnc_muc_fcm.lua create mode 100644 vnctalk/mod_vnc_muc_fcm_hin.lua create mode 100644 vnctalk/mod_vnc_muc_hook.lua create mode 100644 vnctalk/mod_vnc_receipts.lua create mode 100644 vnctalk/mod_vnc_remotemucinvite.lua create mode 100644 vnctalk/mod_vnc_remotemucstore.lua create mode 100644 vnctalk/mod_vnc_timestamp.lua create mode 100644 vnctalk/mod_vnc_track_kicks.lua create mode 100644 vnctalk/mod_vnc_vcard_avatar.lua create mode 100644 vnctalk/mod_vnc_vcard_fallback.lua create mode 100644 vnctalk/mod_webpresence/README.markdown create mode 100644 vnctalk/mod_webpresence/README.wiki create mode 100644 vnctalk/mod_webpresence/icons/status_away.png create mode 100644 vnctalk/mod_webpresence/icons/status_chat.png create mode 100644 vnctalk/mod_webpresence/icons/status_dnd.png create mode 100644 vnctalk/mod_webpresence/icons/status_offline.png create mode 100644 vnctalk/mod_webpresence/icons/status_online.png create mode 100644 vnctalk/mod_webpresence/icons/status_xa.png create mode 100644 vnctalk/mod_webpresence/mod_webpresence.lua diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..8e82f1f --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,88 @@ +image: docker:20.10.17 + +variables: + DOCKER_HOST: tcp://localhost:2375 + DOCKER_TLS_CERTDIR: '' + DEV_CONTAINER_REGISTRY: eu.gcr.io/vnc-development/vnctalk-prosody:development + IMAGE_TAG: eu.gcr.io/vnc-development/vnctalk-prosody:development-$CI_COMMIT_SHORT_SHA + IMAGE_TAG_PROD: eu.gcr.io/vnc-dev-275609/vnctalk-prosody:production + IMAGE_TAG_STABLE: eu.gcr.io/vnc-stable/vnctalk-prosody:stable + +services: + - name: docker:20.10.17-dind + alias: docker + +stages: + - docker + - scan + - notification + + +dev_deploy: + before_script: + - until docker info; do sleep 1; done + - base64 -d $SA_Development | docker login -u _json_key --password-stdin https://eu.gcr.io + stage: docker + artifacts: + public: false + expire_in: 12h + paths: + - scan + script: + - docker build -t $IMAGE_TAG . + - docker push $IMAGE_TAG + - docker image save $IMAGE_TAG -o scan/vnctalk-prosody.tgz + only: + - main + +dev_trivy: + image: eu.gcr.io/vnc-development/docker-vnc-runner:latest + stage: scan + script: + - apt -y update + - apt -y install wget apt-transport-https gnupg lsb-release + - wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | apt-key add - + - echo "deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" >> /etc/apt/sources.list.d/trivy.list + - apt -y update + - apt -y install trivy + - trivy image --input scan/vnctalk-prosody.tgz + only: + - main + +prod_deploy: + before_script: + - until docker info; do sleep 1; done + - base64 -d $SA_Production | docker login -u _json_key --password-stdin https://eu.gcr.io + stage: docker + script: + - echo $CI_COMMIT_TAG + - COMMIT_VERSION=$(echo $CI_COMMIT_TAG | awk -F'prod-' '{print $2}') + - echo $COMMIT_VERSION + + - DEV_TAG=$DEV_CONTAINER_REGISTRY-$COMMIT_VERSION + - echo $DEV_TAG + - docker pull $DEV_TAG + - PROD_IMAGE=$IMAGE_TAG_PROD-$COMMIT_VERSION + - docker tag $DEV_TAG $PROD_IMAGE + - docker push $PROD_IMAGE + rules: + - if: $CI_COMMIT_TAG =~ /^prod-.*/ + +stable_deploy: + before_script: + - until docker info; do sleep 1; done + - base64 -d $SA_Production | docker login -u _json_key --password-stdin https://eu.gcr.io + stage: docker + script: + - echo $CI_COMMIT_TAG + - COMMIT_VERSION=$(echo $CI_COMMIT_TAG | awk -F'stable-' '{print $2}') + - echo $COMMIT_VERSION + + - DEV_TAG=$DEV_CONTAINER_REGISTRY-$COMMIT_VERSION + - echo $DEV_TAG + - docker pull $DEV_TAG + - STABLE_IMAGE=$IMAGE_TAG_STABLE-$COMMIT_VERSION + - docker tag $DEV_TAG $STABLE_IMAGE + - docker push $STABLE_IMAGE + rules: + - if: $CI_COMMIT_TAG =~ /^stable-.*/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c5efcad --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM alpine:3.13 + +RUN apk update +RUN apk upgrade +RUN apk add gettext lua5.2 lua5.2-bitop lua5.2-socket lua5.2-dbi-postgresql lua5.2-expat lua5.2-sql-postgres luarocks5.2 \ + lua5.2-filesize lua5.2-lpeg_patterns lua5.2-hiredis lua5.2-socket lua5.2-lpeg lua5.2-filesystem lua5.2-ldap \ + lua5.2-rapidjson lua5.2-redis lua5.2-sec lua5.2-lzlib lua5.2-cjson lua5.2-dev \ + libidn-dev libidn icu icu-dev libpq postgresql-dev make gcc expat expat-dev \ + libxmlb-dev libstdc++6 libc-dev openssl openssl-dev + +RUN wget https://prosody.im/downloads/source/prosody-0.11.6.tar.gz +RUN tar xvfz prosody-0.11.6.tar.gz + +RUN cd prosody-0.11.6/ && ./configure --sysconfdir="/etc/prosody" --no-example-certs && \ + make && make install + +RUN mkdir /vnc +RUN mkdir -p /var/run/prosody/ + +RUN addgroup prosody +RUN adduser -D -S -h /var/lib/prosody -H -G prosody prosody + +ADD patches /vnc/patches +RUN cp /vnc/patches/mod_carbons.lua /usr/local/lib/prosody/modules/mod_carbons.lua +RUN cp /vnc/patches/mod_mam.lua /usr/local/lib/prosody/modules/mod_mam/mod_mam.lua +RUN cp /vnc/patches/mod_muc.lua /usr/local/lib/prosody/modules/muc/mod_muc.lua +RUN cp /vnc/patches/mod_muc_unique.lua /usr/local/lib/prosody/modules/mod_muc_unique.lua +RUN cp /vnc/patches/moduleapi.lua /usr/local/lib/prosody/core/moduleapi.lua +RUN cp /vnc/patches/muc.lib.lua /usr/local/lib/prosody/modules/muc/muc.lib.lua +RUN cp /vnc/patches/register.lib.lua /usr/local/lib/prosody/modules/muc/register.lib.lua +RUN cp /vnc/patches/mod_admin_telnet.lua /usr/local/lib/prosody/modules/mod_admin_telnet.lua +RUN cp /vnc/patches/portmanager.lua /usr/local/lib/prosody/core/portmanager.lua +ADD vnctalk /vnc/vnctalk +RUN cd /vnc/vnctalk && cp -Rp * /usr/local/lib/prosody/modules/ +ADD config /vnc/config + +CMD ["/vnc/config/startup.sh"] diff --git a/README.md b/README.md index b6b42dc..1205ce8 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,3 @@ # Vnctalk Prosody - - -## Getting started - -To make it easy for you to get started with GitLab, here's a list of recommended next steps. - -Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! - -## Add your files - -- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files -- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command: - -``` -cd existing_repo -git remote add origin http://gitlab.vnc.biz/uxf/vnctalk-prosody.git -git branch -M main -git push -uf origin main -``` - -## Integrate with your tools - -- [ ] [Set up project integrations](http://gitlab.vnc.biz/uxf/vnctalk-prosody/-/settings/integrations) - -## Collaborate with your team - -- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) -- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) -- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) -- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) -- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html) - -## Test and Deploy - -Use the built-in continuous integration in GitLab. - -- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html) -- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) -- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) -- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) -- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) - -*** - -# Editing this README - -When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template. - -## Suggestions for a good README -Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. - -## Name -Choose a self-explaining name for your project. - -## Description -Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. - -## Badges -On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. - -## Visuals -Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. - -## Installation -Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. - -## Usage -Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. - -## Support -Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. - -## Roadmap -If you have ideas for releases in the future, it is a good idea to list them in the README. - -## Contributing -State if you are open to contributions and what your requirements are for accepting them. - -For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. - -You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. - -## Authors and acknowledgment -Show your appreciation to those who have contributed to the project. - -## License -For open source projects, say how it is licensed. - -## Project status -If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. +dockerized build for vnctalk prosody diff --git a/config/prosody.cfg.lua.template b/config/prosody.cfg.lua.template new file mode 100644 index 0000000..c28d024 --- /dev/null +++ b/config/prosody.cfg.lua.template @@ -0,0 +1,291 @@ +-------------------------------------------------------------------------------- +---- VNCtalk Prosody/XMPP Settings +-------------------------------------------------------------------------------- + +daemonize = false; + +pidfile = "/var/run/prosody/prosody.pid"; -- Required for init scripts and prosodyctl + +-- Enable use of libevent for better performance under high load +-- For more information see: http://prosody.im/doc/libevent +-- use_libevent = true; +network_backend = "epoll"; + + +allow_registration = false; + +network_default_read_size = 8192; -- Increase buffer size to allow jitsi meet and file transfers. Default 4096. + +-- for docker only! +run_as_root = true; + +admins = { + "admin@${prosodyDomain}" +} + +plugin_paths = { + "/usr/local/lib/prosody/modules", + "/usr/lib/prosody/modules", +} + +modules_enabled = { + "roster"; -- Allow users to have a roster. Recommended ;) + "saslauth"; -- Authentication for clients and servers. Recommended if you want to log in. + "tls"; -- Add support for secure TLS on c2s/s2s connections + "dialback"; -- s2s dialback support + "disco"; -- Service discovery + "private"; -- Private XML storage (for room bookmarks, etc.) + "vcard"; -- Allow users to set vCards + "blocklist"; -- Allow users to block communications with other users + "smacks"; + "smacks_offline"; + "carbons"; + "carbons_copies"; + "mam"; + "vnc_lastactivity"; + "offline"; -- Store offline messages + "pubsub"; + "version"; -- Replies to server version requests + "uptime"; -- Report how long server has been running + "time"; -- Let others know the time here on this server + "ping"; -- Replies to XMPP pings with pongs + "pep"; -- Enables users to publish their mood, activity, playing music and more + "vnc_vcard_avatar"; -- publish avatars + + "adhoc"; + "admin_adhoc"; -- Allows administration via an XMPP client that supports ad-hoc commands + "admin_telnet"; -- Opens telnet console interface on localhost port 5582 + "posix"; -- POSIX functionality, sends server to background, enables syslog, etc. + "bosh"; -- Enable mod_bosh + "websocket"; + "http_altconnect"; + "idlecompat"; +-- "groups"; -- Shared roster support + + -- Other specific functionality + "announce"; -- Send announcement to all online users + "watchregistrations"; -- Alert admins of registrations + "motd"; -- Send a message to users when they log in + "legacyauth"; -- Legacy authentication. Only used by some old clients and bots. + "webpresence"; + "http_rest"; + "csi"; + -- "throttle_presence"; + "filter_chatstates"; + + -- VNCtalk modules + "vnc_receipts"; + "vnc_remotemucstore"; + +} + +modules_disabled = { + -- "c2s"; -- Handle client connections + -- "s2s"; -- Handle server-to-server connections + + "archive"; + "register"; -- Allow users to register on this server using a client and change passwords +} + +-------------------------------------------------------------------------------- +---- Logging configuration +-------------------------------------------------------------------------------- + +-- The supported levels are: "debug", "info", "warn", "error". +log = { + levels = { "info", "warn", "error" }, to = "console" ; +} + +-------------------------------------------------------------------------------- +---- Components Settings +-------------------------------------------------------------------------------- + +component_ports = { 5347 } +component_interface = "0.0.0.0"; +--component_interface = "192.168.0.10"; -- Prosody to listen for component connections on port XXXX, coming only to the IP address 192.168.0.10. + +-------------------------------------------------------------------------------- +---- BOSH Settings +-------------------------------------------------------------------------------- + +bosh_ports = { 5280 } +bosh_max_inactivity = 10; --Maximum amount of time in seconds a client may remain silent for, with no requests +cross_domain_bosh = true; --Allow access from scripts on any site with no proxy (requires a modern browser) +consider_bosh_secure = true; --Use if proxying HTTPS->HTTP on the server side +bosh_default_hold = 1; --Maximum number of requests the server will hold open for a client +bosh_max_requests = 2; --Maximum number of requests a client may make to the server at a time +-- 2check: internel network in k8s ? +trusted_proxies = { "127.0.0.1" } --A list of proxies to trust, used to find a client's real IP + +-------------------------------------------------------------------------------- +---- Websocket Settings +-------------------------------------------------------------------------------- + +cross_domain_websocket = true; --Allow access from scripts on any site with no proxy (requires a modern browser) +consider_websocket_secure = true; --Use if proxying HTTPS->HTTP on the server side + +-------------------------------------------------------------------------------- +---- SSL/TLS Settings +-------------------------------------------------------------------------------- + +ssl = { + key = "/etc/prosody-ssl.key"; + certificate = "/etc/prosody-ssl.pem"; +} + +c2s_require_encryption = true; -- Force clients to use encrypted connections +s2s_secure_auth = false; -- Force certificate authentication for server-to-server connections + +--s2s_secure_domains = { "jabber.org" } +-- list of domains here that will not be required to authenticate using certificates +--s2s_insecure_domains = { "gmail.com" } + +allow_anonymous_s2s = true + +-------------------------------------------------------------------------------- +---- Authentication Settings +-------------------------------------------------------------------------------- + +authentication = "internal_hashed" + +-------------------------------------------------------------------------------- +---- Storage Settings +-------------------------------------------------------------------------------- + +default_storage = "sql" +storage = "sql" + +sql = { + driver = "PostgreSQL", + host = "${prosodyDBhost}", + database = "${prosodyDBname}", + username = "${prosodyDBuser}", + password = "${prosodyDBpass}", +} + +-- storage = { archive2 = "sql2" } + +sql_manage_tables = true; --allow auto create table + +-- PEP limit + +pep_max_items = 256 + +-------------------------------------------------------------------------------- +---- MAM XEP-0313 Settings +-------------------------------------------------------------------------------- + +max_archive_query_results = 20; +archive_cleanup_interval = 4*60*60; +archive_expires_after = "never" +default_archive_policy = "roster" + +-------------------------------------------------------------------------------- +---- SMACKS config +-------------------------------------------------------------------------------- +smacks_hibernation_time = 300; +smacks_enabled_s2s = false; +smacks_max_unacked_stanzas = 0; +smack_max_ack_delay = 60; +smacks_max_hibernated_sessions = 10; +smacks_max_old = 10; +-------------------------------------------------------------------------------- + +-------------------------------------------------------------------------------- +---- MOTD config +-------------------------------------------------------------------------------- + +motd_jid = "admin@${prosodyDomain}" +-- motd_text = "Currently the XMPP service is under maintence." + + +----------- Virtual hosts ----------- + +VirtualHost "${prosodyDomain}" + enabled = true; -- Remove this line to enable this host + + admins = { "admin@${prosodyDomain}" } + + authentication = "http_async" + -- http_auth_url = "http://10.0.201.48:9544/" + http_auth_url = "${hybridaAuthUrl}" + + fcm_api_key = "${fcmApiKey}" + fcm_api_url = "${fcmApiUrl}" + del_api_url = "${fcmDelUrl}" + + + modules_enabled = { + "bosh"; + "pubsub"; + "ping"; -- Enable mod_ping +-- "auto_accept_subscriptions"; + "vnc_delfile"; + "vnc_fcm"; + "http_upload_external"; + "alias"; + "vnc_timestamp"; + } + + modules_disabled = { + "discoitems"; --manually override the service discovery items for a host + } + + http_upload_external_base_url = "${fileShareBaseUrl}" + http_upload_external_secret = "${fileShareSecret}" + http_upload_external_file_size_limit = 104857600 -- bytes + + -- avatar_upload_url = "https://vnctalk.dev.vnc.de/avatarupload/" + avatar_upload_url = "${avatarUploadUrl}" + avatar_upload_user = "${avatarUploadUser}" + avatar_upload_pass = "${avatarUploadPass}" + + ssl = { + key = "/etc/prosody-ssl.key"; + certificate = "/etc/prosody-ssl.pem"; + } + + + + disco_items = { + -- Do not display/publicy any disco item to avoid information leaking due to public reachable chatroom service. + } + + +Component "conference.${prosodyDomain}" "muc" + component_secret = "Weew0Ooheivei3aizohmalohLieshohs" + modules_enabled = { + "muc_mam", + "vnc_muc_automember", + "vnc_muc_fcm", + "vnc_remotemucinvite", + "vnc_delfile", + "vnc_e2ehints", + "vnc_muc_hook", + "vnc_muc_data", + "vnc_track_kicks", + "vcard_muc"; -- allow vcards for muc + } + storage = { + -- This makes mod_mam_muc use the sql2 storage backend (others will use internal) + -- which at the time of this writing is the only one supporting stanza archives + muc_log = "sql"; + } + muc_log_by_default = true; -- Enable logging by default (can be disabled in room config) + muc_log_all_rooms = true; -- set to true to force logging of all rooms + + -- This is the largest number of messages that are allowed to be retrieved in one MAM request. + max_archive_query_results = 20; + + -- This is the largest number of messages that are allowed to be retrieved when joining a room. + max_history_messages = 1000; + fcm_api_key = "${fcmApiKey}" + fcm_api_url = "${fcmApiUrl}" + del_api_url = "${fcmDelUrl}" + + avatar_upload_url = "${avatarUploadUrl}" + avatar_upload_user = "${avatarUploadUser}" + avatar_upload_pass = "${avatarUploadPass}" + storage_host = "${prosodyDomain}"; + +Component "broadcast@${prosodyDomain}" "vnc_broadcast" diff --git a/config/startup.sh b/config/startup.sh new file mode 100755 index 0000000..62780b2 --- /dev/null +++ b/config/startup.sh @@ -0,0 +1,34 @@ +#!/bin/sh + +echo "running startup" + +mkdir -p /etc/prosody/certs + +defaultProsodySSLkey="LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2d0lCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktrd2dnU2xBZ0VBQW9JQkFRRHdqakRJL2hiWERXZTQKRlB0RGtCbG9wUjBJSTQ2U25tY001R3ZQSzlXVkx6dXM0YlBsTmF5Y2dVVFhGTUR2OThmTTdCa3pYaEFuRGorTAo4VTQ1dnp6bW1STHQ3aDBHdStud0t1dXg2Z2MvTW9XVjJYd3JlY1U0bUp2Z0IvTW9QZ05ReGNVVlhHYTF6VFJLCm5VMDJDa28zUEZaNmxWNkFEN3dISGR0cXhTbGQxdTVxMnZMa3VHM2psellqZFJmc2tTdjNLS1BJS3FKdUszM0sKUnRDb1J1TzBvZFNzOHN2MmhmYVdEangvZEEyQ29ubXh3RTlIa3VCUFhIWnQzM2s3ajdTWkh6ZnZxZ1BibVRXNwpuTmhTWVRCa05LRXpHU051a0gvQTcrS0VUWXJoVFcwTUNVSkNIbE5oSlY5MmYxaFBjeGVHMGhJV0J0bEoyS1RuClNHQUI3S0FaQWdNQkFBRUNnZ0VBQ3g4bjFrTVpIenc1YldOYThwYTQ5Z09jUWdJUzEyaUhieXNvYU4ybjlFdkkKd0lkMjM4UUpoUHJkQ2V5bHR2S0h4RXV1SnBmVXUxbUhTcHNQZU5BRXFzeXMrVjhtN0diSHZCamY2TXZmSmViYQpFa0dKNGlDeEk1cTZxakx6NVFwbzhOVVQ5WmJlRU0yNSt6RW1ucmIvRTJxd3ZONG5EbGlyNHRCWWtxa203bldvCmdjbFk4RHpWSy9qcHZpSThSVmkzckFBTGlPbU50NWgwUHJGN0dFYVBRQ3Z4eVVEa1VZVGRaUjB1bTFMalZmZjUKcEMvVlRhUzFsdkNCd3JTb0FRdUlnQVRjQ0xGTDRpS0FIQjFHdnEwK2FsQXBCaWFpY1Z0WGQ5c0tBalBsS3R2dgpOamJFc3BBUzJFSVBBRHRZNmg4Yzd3R016NGxCenEzSm5oaVppUFJkZVFLQmdRRDVqMGtpbmFORitXR3dFbVVYCjBLWkk2dnM3R0YwelVQaW1FaFBPRmxrYVczcTZYUXFpN3ArbVpJUUhreEpmWlVMcEdtUm1rL3JsMlBHUnZzTzkKWHAyamVTbmQyRnlGdnRkYUpTcXMyOTNVRC8vZmtuQTQ5U0E2NW5tSkYrdUFkMlRBQW5JOFNmTVZHV2hLNGltbQo0aU1BNzRKM2RvUG1XdzFHWm9JZzZ6NzVCd0tCZ1FEMncyc09md2lJVlNvT3JqWitNMzJrUytmVWFjRUxxSmNQCk4wc0Z6Um1YY0RPY1VyamlCcDNwOXRzcmxqNzc4TkpWUkVSTG1PVHRleVFJWjVpTVBLQW15cTNleHBhY2FFSHIKN1M3WUtVcXBRRGZhbkNCVkdYcFdmcW84bDZZeEM3SVhKSFY5SjVZV05HWWVzWW5QWVh0dEdPQVlRQWlxUnpRYwpHdHpycHRMMTN3S0JnUURzNmVIOFFycWx5MEc1N1R5Ymh6RFZPSDZZYzJESFBxOU91UXFrTXdNVHlwU1FKNVBaCmFiK1dMd1pJU282K2tWc2NBRG9nUjR1QWVKdWNTUVY1MlVDOTJHQ3VncUFUT0VFcEN1K2dlbm4wdjl1aFcxdSsKTnJjS001SmRXNS91cldtdi9xS0VLWS9qRCtPNmMwb25hN2JmUklFTytDdnZtN0RXaktvc3BpZDZNUUtCZ1FEWgpIcGpUNkhKWmdwVysxUXFkZ0RETW52MGNxUEJ5a2FSWWZ3NUR6SEw0RElGY3lteGljKy8rTEhZcm84ZEhabDJyCktLWHUzbGlQbkFuWVcwNmVGYm5VYUFFVVF5ZkI5dm9KM0dlYlNybW1zaHB5LzJ4emREZHdZNExtVFlpdEh0MVQKLzRCZzNyUHdWYXFFMXpHcWh0dFM0dEJLaGcwWnhnelRyYzgwaVZTVmdRS0JnUUNCZ1pYaVEwY3FpWkpHdWkrcwoxRXhWdnNBdjh4NnJLMmEzc0ZvOXE1WEc4NnZuVEdrVElNNmZCWXo2Qm1lMTdiTDgrYmVKcjRmeVc4WWlENGVWCjFrQWxuaUdlYnZDcTNrSlh4U0VhdjQxM0dLQzhkMFZqWVpFbUVaN1hkU1dxcWVWa0xpUGNNVFdnaC9DdmpLU3YKVnpnOGFyN3VZaUh3WGdPQXNFVWJpWllNaUE9PQotLS0tLUVORCBQUklWQVRFIEtFWS0tLS0tCg==" + +defaultProsodySSLcert="LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUQ5ekNDQXQrZ0F3SUJBZ0lVZSsyVmpVM3psWDVzUHVqWGFZNnY0UWZOK2VRd0RRWUpLb1pJaHZjTkFRRUwKQlFBd2JqRUxNQWtHQTFVRUJoTUNSMEl4RXpBUkJnTlZCQW9NQ2xCeWIzTnZaSGtnU1UweExEQXFCZ05WQkFzTQpJMmgwZEhCek9pOHZjSEp2YzI5a2VTNXBiUzlrYjJNdlkyVnlkR2xtYVdOaGRHVnpNUnd3R2dZRFZRUUREQk5GCmVHRnRjR3hsSUdObGNuUnBabWxqWVhSbE1CNFhEVEl5TURrd01qRXlNVGt3T1ZvWERUSXpNRGt3TWpFeU1Ua3cKT1Zvd2JqRUxNQWtHQTFVRUJoTUNSMEl4RXpBUkJnTlZCQW9NQ2xCeWIzTnZaSGtnU1UweExEQXFCZ05WQkFzTQpJMmgwZEhCek9pOHZjSEp2YzI5a2VTNXBiUzlrYjJNdlkyVnlkR2xtYVdOaGRHVnpNUnd3R2dZRFZRUUREQk5GCmVHRnRjR3hsSUdObGNuUnBabWxqWVhSbE1JSUJJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBUThBTUlJQkNnS0MKQVFFQThJNHd5UDRXMXcxbnVCVDdRNUFaYUtVZENDT09rcDVuRE9Scnp5dlZsUzg3ck9HejVUV3NuSUZFMXhUQQo3L2ZIek93Wk0xNFFKdzQvaS9GT09iODg1cGtTN2U0ZEJydnA4Q3Jyc2VvSFB6S0ZsZGw4SzNuRk9KaWI0QWZ6CktENERVTVhGRlZ4bXRjMDBTcDFOTmdwS056eFdlcFZlZ0ErOEJ4M2Jhc1VwWGRidWF0cnk1TGh0NDVjMkkzVVgKN0pFcjl5aWp5Q3FpYml0OXlrYlFxRWJqdEtIVXJQTEw5b1gybGc0OGYzUU5ncUo1c2NCUFI1TGdUMXgyYmQ5NQpPNCswbVI4Mzc2b0QyNWsxdTV6WVVtRXdaRFNoTXhramJwQi93Ty9paEUySzRVMXREQWxDUWg1VFlTVmZkbjlZClQzTVhodElTRmdiWlNkaWs1MGhnQWV5Z0dRSURBUUFCbzRHTU1JR0pNQXdHQTFVZEV3UUZNQU1CQWY4d2VRWUQKVlIwUkJISXdjSUlKYkc5allXeG9iM04wb0NRR0NDc0dBUVVGQndnSG9CZ1dGbDk0YlhCd0xXTnNhV1Z1ZEM1cwpiMk5oYkdodmMzU2dKQVlJS3dZQkJRVUhDQWVnR0JZV1gzaHRjSEF0YzJWeWRtVnlMbXh2WTJGc2FHOXpkS0FYCkJnZ3JCZ0VGQlFjSUJhQUxEQWxzYjJOaGJHaHZjM1F3RFFZSktvWklodmNOQVFFTEJRQURnZ0VCQUpGK0dBZ28KSW8vWlBqVWM4YWJQbXZCMHZNTlhRVURYVGlzdkxRdDg5MGJmNzVDWkM2SFVGekw5ZjljamQ1NU1ZTG80QllxZQpVcjZOL3R3ZGRHWVNwb3JBY1dQWmtIb3F5bmQ5QXhIR0VpQUtvV2s5VXB0U2crcWhRd1JLQ1owQnBKemcrOENqCmtUdHBiWmdMa0s5RWhmSWlXbW1LSG41cHovYkhaK0FHaU9ESzcrYmsxZk1IZ0dDemFjVWVsU2lwSS9WOE1jY3IKbitwaGRRc2pjN1Vpd05zNlFEL2dxSS9GME1pV2FCd3ZiSjg0dWk3YkF4OFJqZ29OZC9ya3htUGhyQzBtL2VYYgpjUGFTOXpEMGFuUFN3ZUQ5VEQzUXBvNXZaNlJMempzNjZRR09VK0Y0a1dIU2NMTmVhaW9mOUV1aEMxQ0VrMDk4CmxQZ0F2SEQydTlKcURLdz0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=" + + +if [ -n "$prosodySSLkey" ]; then + echo "$prosodySSLkey" | base64 -d > /etc/prosody-ssl.key +else + echo "$defaultProsodySSLkey" | base64 -d > /etc/prosody-ssl.key +fi + +if [ -n "$prosodySSLcert" ]; then + echo "$prosodySSLcert" | base64 -d > /etc/prosody-ssl.pem +else + echo "$defaultProsodySSLcert" | base64 -d > /etc/prosody-ssl.pem +fi + +chmod 444 /etc/prosody-ssl.pem +chmod 444 /etc/prosody-ssl.key + +mkdir -p /etc/prosody +chmod 755 /etc/prosody +envsubst < /vnc/config/prosody.cfg.lua.template > /etc/prosody/prosody.cfg.lua + +cat /etc/prosody/prosody.cfg.lua + +# su prosody -c /usr/local/bin/prosody -F +/usr/local/bin/prosody -F diff --git a/patches/mod_admin_telnet.lua b/patches/mod_admin_telnet.lua new file mode 100644 index 0000000..d7f8314 --- /dev/null +++ b/patches/mod_admin_telnet.lua @@ -0,0 +1,1560 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- +-- luacheck: ignore 212/self + +module:set_global(); + +local hostmanager = require "core.hostmanager"; +local modulemanager = require "core.modulemanager"; +local s2smanager = require "core.s2smanager"; +local portmanager = require "core.portmanager"; +local helpers = require "util.helpers"; +local server = require "net.server"; + +local _G = _G; + +local prosody = _G.prosody; + +local console_listener = { default_port = 5582; default_mode = "*a"; interface = "*" }; + +local iterators = require "util.iterators"; +local keys, values = iterators.keys, iterators.values; +local jid_bare, jid_split, jid_join = import("util.jid", "bare", "prepped_split", "join"); +local set, array = require "util.set", require "util.array"; +local cert_verify_identity = require "util.x509".verify_identity; +local envload = require "util.envload".envload; +local envloadfile = require "util.envload".envloadfile; +local has_pposix, pposix = pcall(require, "util.pposix"); + +local commands = module:shared("commands") +local def_env = module:shared("env"); +local default_env_mt = { __index = def_env }; + +local function redirect_output(target, session) + local env = setmetatable({ print = session.print }, { __index = function (_, k) return rawget(target, k); end }); + env.dofile = function(name) + local f, err = envloadfile(name, env); + if not f then return f, err; end + return f(); + end; + return env; +end + +console = {}; + +function console:new_session(conn) + local w = function(s) conn:write(s:gsub("\n", "\r\n")); end; + local session = { conn = conn; + send = function (t) w(tostring(t)); end; + print = function (...) + local t = {}; + for i=1,select("#", ...) do + t[i] = tostring(select(i, ...)); + end + w("| "..table.concat(t, "\t").."\n"); + end; + disconnect = function () conn:close(); end; + }; + session.env = setmetatable({}, default_env_mt); + + -- Load up environment with helper objects + for name, t in pairs(def_env) do + if type(t) == "table" then + session.env[name] = setmetatable({ session = session }, { __index = t }); + end + end + + return session; +end + +function console:process_line(session, line) + local useglobalenv; + + if line:match("^>") then + line = line:gsub("^>", ""); + useglobalenv = true; + elseif line == "\004" then + commands["bye"](session, line); + return; + else + local command = line:match("^%w+") or line:match("%p"); + if commands[command] then + commands[command](session, line); + return; + end + end + + session.env._ = line; + + local chunkname = "=console"; + local env = (useglobalenv and redirect_output(_G, session)) or session.env or nil + local chunk, err = envload("return "..line, chunkname, env); + if not chunk then + chunk, err = envload(line, chunkname, env); + if not chunk then + err = err:gsub("^%[string .-%]:%d+: ", ""); + err = err:gsub("^:%d+: ", ""); + err = err:gsub("''", "the end of the line"); + session.print("Sorry, I couldn't understand that... "..err); + return; + end + end + + local ranok, taskok, message = pcall(chunk); + + if not (ranok or message or useglobalenv) and commands[line:lower()] then + commands[line:lower()](session, line); + return; + end + + if not ranok then + session.print("Fatal error while running command, it did not complete"); + session.print("Error: "..taskok); + return; + end + + if not message then + session.print("Result: "..tostring(taskok)); + return; + elseif (not taskok) and message then + session.print("Command completed with a problem"); + session.print("Message: "..tostring(message)); + return; + end + + session.print("OK: "..tostring(message)); +end + +local sessions = {}; + +function console_listener.onconnect(conn) + -- Handle new connection + local session = console:new_session(conn); + sessions[conn] = session; + printbanner(session); + session.send(string.char(0)); +end + +function console_listener.onincoming(conn, data) + local session = sessions[conn]; + + local partial = session.partial_data; + if partial then + data = partial..data; + end + + for line in data:gmatch("[^\n]*[\n\004]") do + if session.closed then return end + console:process_line(session, line); + session.send(string.char(0)); + end + session.partial_data = data:match("[^\n]+$"); +end + +function console_listener.onreadtimeout(conn) + local session = sessions[conn]; + if session then + session.send("\0"); + return true; + end +end + +function console_listener.ondisconnect(conn, err) -- luacheck: ignore 212/err + local session = sessions[conn]; + if session then + session.disconnect(); + sessions[conn] = nil; + end +end + +function console_listener.ondetach(conn) + sessions[conn] = nil; +end + +-- Console commands -- +-- These are simple commands, not valid standalone in Lua + +function commands.bye(session) + session.print("See you! :)"); + session.closed = true; + session.disconnect(); +end +commands.quit, commands.exit = commands.bye, commands.bye; + +commands["!"] = function (session, data) + if data:match("^!!") and session.env._ then + session.print("!> "..session.env._); + return console_listener.onincoming(session.conn, session.env._); + end + local old, new = data:match("^!(.-[^\\])!(.-)!$"); + if old and new then + local ok, res = pcall(string.gsub, session.env._, old, new); + if not ok then + session.print(res) + return; + end + session.print("!> "..res); + return console_listener.onincoming(session.conn, res); + end + session.print("Sorry, not sure what you want"); +end + + +function commands.help(session, data) + local print = session.print; + local section = data:match("^help (%w+)"); + if not section then + print [[Commands are divided into multiple sections. For help on a particular section, ]] + print [[type: help SECTION (for example, 'help c2s'). Sections are: ]] + print [[]] + print [[c2s - Commands to manage local client-to-server sessions]] + print [[s2s - Commands to manage sessions between this server and others]] + print [[module - Commands to load/reload/unload modules/plugins]] + print [[host - Commands to activate, deactivate and list virtual hosts]] + print [[user - Commands to create and delete users, and change their passwords]] + print [[server - Uptime, version, shutting down, etc.]] + print [[port - Commands to manage ports the server is listening on]] + print [[dns - Commands to manage and inspect the internal DNS resolver]] + print [[config - Reloading the configuration, etc.]] + print [[console - Help regarding the console itself]] + elseif section == "c2s" then + print [[c2s:show(jid) - Show all client sessions with the specified JID (or all if no JID given)]] + print [[c2s:show_insecure() - Show all unencrypted client connections]] + print [[c2s:show_secure() - Show all encrypted client connections]] + print [[c2s:show_tls() - Show TLS cipher info for encrypted sessions]] + print [[c2s:close(jid) - Close all sessions for the specified JID]] + elseif section == "s2s" then + print [[s2s:show(domain) - Show all s2s connections for the given domain (or all if no domain given)]] + print [[s2s:show_tls(domain) - Show TLS cipher info for encrypted sessions]] + print [[s2s:close(from, to) - Close a connection from one domain to another]] + print [[s2s:closeall(host) - Close all the incoming/outgoing s2s sessions to specified host]] + elseif section == "module" then + print [[module:load(module, host) - Load the specified module on the specified host (or all hosts if none given)]] + print [[module:reload(module, host) - The same, but unloads and loads the module (saving state if the module supports it)]] + print [[module:unload(module, host) - The same, but just unloads the module from memory]] + print [[module:list(host) - List the modules loaded on the specified host]] + elseif section == "host" then + print [[host:activate(hostname) - Activates the specified host]] + print [[host:deactivate(hostname) - Disconnects all clients on this host and deactivates]] + print [[host:list() - List the currently-activated hosts]] + elseif section == "user" then + print [[user:create(jid, password) - Create the specified user account]] + print [[user:password(jid, password) - Set the password for the specified user account]] + print [[user:delete(jid) - Permanently remove the specified user account]] + print [[user:list(hostname, pattern) - List users on the specified host, optionally filtering with a pattern]] + elseif section == "server" then + print [[server:version() - Show the server's version number]] + print [[server:uptime() - Show how long the server has been running]] + print [[server:memory() - Show details about the server's memory usage]] + print [[server:shutdown(reason) - Shut down the server, with an optional reason to be broadcast to all connections]] + elseif section == "port" then + print [[port:list() - Lists all network ports prosody currently listens on]] + print [[port:close(port, interface) - Close a port]] + elseif section == "dns" then + print [[dns:lookup(name, type, class) - Do a DNS lookup]] + print [[dns:addnameserver(nameserver) - Add a nameserver to the list]] + print [[dns:setnameserver(nameserver) - Replace the list of name servers with the supplied one]] + print [[dns:purge() - Clear the DNS cache]] + print [[dns:cache() - Show cached records]] + elseif section == "config" then + print [[config:reload() - Reload the server configuration. Modules may need to be reloaded for changes to take effect.]] + elseif section == "console" then + print [[Hey! Welcome to Prosody's admin console.]] + print [[First thing, if you're ever wondering how to get out, simply type 'quit'.]] + print [[Secondly, note that we don't support the full telnet protocol yet (it's coming)]] + print [[so you may have trouble using the arrow keys, etc. depending on your system.]] + print [[]] + print [[For now we offer a couple of handy shortcuts:]] + print [[!! - Repeat the last command]] + print [[!old!new! - repeat the last command, but with 'old' replaced by 'new']] + print [[]] + print [[For those well-versed in Prosody's internals, or taking instruction from those who are,]] + print [[you can prefix a command with > to escape the console sandbox, and access everything in]] + print [[the running server. Great fun, but be careful not to break anything :)]] + end + print [[]] +end + +-- Session environment -- +-- Anything in def_env will be accessible within the session as a global variable + +--luacheck: ignore 212/self + +def_env.server = {}; + +function def_env.server:insane_reload() + prosody.unlock_globals(); + dofile "prosody" + prosody = _G.prosody; + return true, "Server reloaded"; +end + +function def_env.server:version() + return true, tostring(prosody.version or "unknown"); +end + +function def_env.server:uptime() + local t = os.time()-prosody.start_time; + local seconds = t%60; + t = (t - seconds)/60; + local minutes = t%60; + t = (t - minutes)/60; + local hours = t%24; + t = (t - hours)/24; + local days = t; + return true, string.format("This server has been running for %d day%s, %d hour%s and %d minute%s (since %s)", + days, (days ~= 1 and "s") or "", hours, (hours ~= 1 and "s") or "", + minutes, (minutes ~= 1 and "s") or "", os.date("%c", prosody.start_time)); +end + +function def_env.server:shutdown(reason) + prosody.shutdown(reason); + return true, "Shutdown initiated"; +end + +local function human(kb) + local unit = "K"; + if kb > 1024 then + kb, unit = kb/1024, "M"; + end + return ("%0.2f%sB"):format(kb, unit); +end + +function def_env.server:memory() + if not has_pposix or not pposix.meminfo then + return true, "Lua is using "..human(collectgarbage("count")); + end + local mem, lua_mem = pposix.meminfo(), collectgarbage("count"); + local print = self.session.print; + print("Process: "..human((mem.allocated+mem.allocated_mmap)/1024)); + print(" Used: "..human(mem.used/1024).." ("..human(lua_mem).." by Lua)"); + print(" Free: "..human(mem.unused/1024).." ("..human(mem.returnable/1024).." returnable)"); + return true, "OK"; +end + +def_env.module = {}; + +local function get_hosts_set(hosts, module) + if type(hosts) == "table" then + if hosts[1] then + return set.new(hosts); + elseif hosts._items then + return hosts; + end + elseif type(hosts) == "string" then + return set.new { hosts }; + elseif hosts == nil then + local hosts_set = set.new(array.collect(keys(prosody.hosts))) + / function (host) return (prosody.hosts[host].type == "local" or module and modulemanager.is_loaded(host, module)) and host or nil; end; + if module and modulemanager.get_module("*", module) then + hosts_set:add("*"); + end + return hosts_set; + end +end + +function def_env.module:load(name, hosts, config) + hosts = get_hosts_set(hosts); + + -- Load the module for each host + local ok, err, count, mod = true, nil, 0; + for host in hosts do + if (not modulemanager.is_loaded(host, name)) then + mod, err = modulemanager.load(host, name, config); + if not mod then + ok = false; + if err == "global-module-already-loaded" then + if count > 0 then + ok, err, count = true, nil, 1; + end + break; + end + self.session.print(err or "Unknown error loading module"); + else + count = count + 1; + self.session.print("Loaded for "..mod.module.host); + end + end + end + + return ok, (ok and "Module loaded onto "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err)); +end + +function def_env.module:unload(name, hosts) + hosts = get_hosts_set(hosts, name); + + -- Unload the module for each host + local ok, err, count = true, nil, 0; + for host in hosts do + if modulemanager.is_loaded(host, name) then + ok, err = modulemanager.unload(host, name); + if not ok then + ok = false; + self.session.print(err or "Unknown error unloading module"); + else + count = count + 1; + self.session.print("Unloaded from "..host); + end + end + end + return ok, (ok and "Module unloaded from "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err)); +end + +local function _sort_hosts(a, b) + if a == "*" then return true + elseif b == "*" then return false + else return a < b; end +end + +function def_env.module:reload(name, hosts) + hosts = array.collect(get_hosts_set(hosts, name)):sort(_sort_hosts) + + -- Reload the module for each host + local ok, err, count = true, nil, 0; + for _, host in ipairs(hosts) do + if modulemanager.is_loaded(host, name) then + ok, err = modulemanager.reload(host, name); + if not ok then + ok = false; + self.session.print(err or "Unknown error reloading module"); + else + count = count + 1; + if ok == nil then + ok = true; + end + self.session.print("Reloaded on "..host); + end + end + end + return ok, (ok and "Module reloaded on "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err)); +end + +function def_env.module:list(hosts) + if hosts == nil then + hosts = array.collect(keys(prosody.hosts)); + table.insert(hosts, 1, "*"); + end + if type(hosts) == "string" then + hosts = { hosts }; + end + if type(hosts) ~= "table" then + return false, "Please supply a host or a list of hosts you would like to see"; + end + + local print = self.session.print; + for _, host in ipairs(hosts) do + print((host == "*" and "Global" or host)..":"); + local modules = array.collect(keys(modulemanager.get_modules(host) or {})):sort(); + if #modules == 0 then + if prosody.hosts[host] then + print(" No modules loaded"); + else + print(" Host not found"); + end + else + for _, name in ipairs(modules) do + print(" "..name); + end + end + end +end + +def_env.config = {}; +function def_env.config:load(filename, format) + local config_load = require "core.configmanager".load; + local ok, err = config_load(filename, format); + if not ok then + return false, err or "Unknown error loading config"; + end + return true, "Config loaded"; +end + +function def_env.config:get(host, section, key) + local config_get = require "core.configmanager".get + return true, tostring(config_get(host, section, key)); +end + +function def_env.config:reload() + local ok, err = prosody.reload_config(); + return ok, (ok and "Config reloaded (you may need to reload modules to take effect)") or tostring(err); +end + +local function common_info(session, line) + if session.id then + line[#line+1] = "["..session.id.."]" + else + line[#line+1] = "["..session.type..(tostring(session):match("%x*$")).."]" + end +end + +local function session_flags(session, line) + line = line or {}; + common_info(session, line); + if session.type == "c2s" then + local status, priority = "unavailable", tostring(session.priority or "-"); + if session.presence then + status = session.presence:get_child_text("show") or "available"; + end + line[#line+1] = status.."("..priority..")"; + end + if session.cert_identity_status == "valid" then + line[#line+1] = "(authenticated)"; + end + if session.secure then + line[#line+1] = "(encrypted)"; + end + if session.compressed then + line[#line+1] = "(compressed)"; + end + if session.smacks then + line[#line+1] = "(sm)"; + end + if session.ip and session.ip:match(":") then + line[#line+1] = "(IPv6)"; + end + if session.remote then + line[#line+1] = "(remote)"; + end + return table.concat(line, " "); +end + +local function tls_info(session, line) + line = line or {}; + common_info(session, line); + if session.secure then + local sock = session.conn and session.conn.socket and session.conn:socket(); + if sock then + local info = sock.info and sock:info(); + if info then + line[#line+1] = ("(%s with %s)"):format(info.protocol, info.cipher); + else + -- TLS session might not be ready yet + line[#line+1] = "(cipher info unavailable)"; + end + end + else + line[#line+1] = "(insecure)"; + end + return table.concat(line, " "); +end + +def_env.c2s = {}; + +local function get_jid(session) + if session.username then + return session.full_jid or jid_join(session.username, session.host, session.resource); + end + + local conn = session.conn; + local ip = session.ip or "?"; + local clientport = conn and conn:clientport() or "?"; + local serverip = conn and conn.server and conn:server():ip() or "?"; + local serverport = conn and conn:serverport() or "?" + return jid_join("["..ip.."]:"..clientport, session.host or "["..serverip.."]:"..serverport); +end + +local function show_c2s(callback) + local c2s = array.collect(values(module:shared"/*/c2s/sessions")); + c2s:sort(function(a, b) + if a.host == b.host then + if a.username == b.username then + return (a.resource or "") > (b.resource or ""); + end + return (a.username or "") > (b.username or ""); + end + return (a.host or "") > (b.host or ""); + end):map(function (session) + callback(get_jid(session), session) + end); +end + +function def_env.c2s:count() + return true, "Total: ".. iterators.count(values(module:shared"/*/c2s/sessions")) .." clients"; +end + +function def_env.c2s:show(match_jid, annotate) + local print, count = self.session.print, 0; + annotate = annotate or session_flags; + local curr_host = false; + show_c2s(function (jid, session) + if curr_host ~= session.host then + curr_host = session.host; + print(curr_host or "(not connected to any host yet)"); + end + if (not match_jid) or jid:match(match_jid) then + count = count + 1; + print(annotate(session, { " ", jid })); + end + end); + return true, "Total: "..count.." clients"; +end + +function def_env.c2s:show_insecure(match_jid) + local print, count = self.session.print, 0; + show_c2s(function (jid, session) + if ((not match_jid) or jid:match(match_jid)) and not session.secure then + count = count + 1; + print(jid); + end + end); + return true, "Total: "..count.." insecure client connections"; +end + +function def_env.c2s:show_secure(match_jid) + local print, count = self.session.print, 0; + show_c2s(function (jid, session) + if ((not match_jid) or jid:match(match_jid)) and session.secure then + count = count + 1; + print(jid); + end + end); + return true, "Total: "..count.." secure client connections"; +end + +function def_env.c2s:show_tls(match_jid) + return self:show(match_jid, tls_info); +end + +function def_env.c2s:close(match_jid) + local count = 0; + show_c2s(function (jid, session) + if jid == match_jid or jid_bare(jid) == match_jid then + count = count + 1; + session:close(); + end + end); + return true, "Total: "..count.." sessions closed"; +end + + +def_env.s2s = {}; +function def_env.s2s:show(match_jid, annotate) + local print = self.session.print; + annotate = annotate or session_flags; + + local count_in, count_out = 0,0; + local s2s_list = { }; + + local s2s_sessions = module:shared"/*/s2s/sessions"; + for _, session in pairs(s2s_sessions) do + local remotehost, localhost, direction; + if session.direction == "outgoing" then + direction = "->"; + count_out = count_out + 1; + remotehost, localhost = session.to_host or "?", session.from_host or "?"; + else + direction = "<-"; + count_in = count_in + 1; + remotehost, localhost = session.from_host or "?", session.to_host or "?"; + end + local sess_lines = { l = localhost, r = remotehost, + annotate(session, { "", direction, remotehost or "?" })}; + + if (not match_jid) or remotehost:match(match_jid) or localhost:match(match_jid) then + table.insert(s2s_list, sess_lines); + -- luacheck: ignore 421/print + local print = function (s) table.insert(sess_lines, " "..s); end + if session.sendq then + print("There are "..#session.sendq.." queued outgoing stanzas for this connection"); + end + if session.type == "s2sout_unauthed" then + if session.connecting then + print("Connection not yet established"); + if not session.srv_hosts then + if not session.conn then + print("We do not yet have a DNS answer for this host's SRV records"); + else + print("This host has no SRV records, using A record instead"); + end + elseif session.srv_choice then + print("We are on SRV record "..session.srv_choice.." of "..#session.srv_hosts); + local srv_choice = session.srv_hosts[session.srv_choice]; + print("Using "..(srv_choice.target or ".")..":"..(srv_choice.port or 5269)); + end + elseif session.notopen then + print("The has not yet been opened"); + elseif not session.dialback_key then + print("Dialback has not been initiated yet"); + elseif session.dialback_key then + print("Dialback has been requested, but no result received"); + end + end + if session.type == "s2sin_unauthed" then + print("Connection not yet authenticated"); + elseif session.type == "s2sin" then + for name in pairs(session.hosts) do + if name ~= session.from_host then + print("also hosts "..tostring(name)); + end + end + end + end + end + + -- Sort by local host, then remote host + table.sort(s2s_list, function(a,b) + if a.l == b.l then return a.r < b.r; end + return a.l < b.l; + end); + local lasthost; + for _, sess_lines in ipairs(s2s_list) do + if sess_lines.l ~= lasthost then print(sess_lines.l); lasthost=sess_lines.l end + for _, line in ipairs(sess_lines) do print(line); end + end + return true, "Total: "..count_out.." outgoing, "..count_in.." incoming connections"; +end + +function def_env.s2s:show_tls(match_jid) + return self:show(match_jid, tls_info); +end + +local function print_subject(print, subject) + for _, entry in ipairs(subject) do + print( + (" %s: %q"):format( + entry.name or entry.oid, + entry.value:gsub("[\r\n%z%c]", " ") + ) + ); + end +end + +-- As much as it pains me to use the 0-based depths that OpenSSL does, +-- I think there's going to be more confusion among operators if we +-- break from that. +local function print_errors(print, errors) + for depth, t in pairs(errors) do + print( + (" %d: %s"):format( + depth-1, + table.concat(t, "\n| ") + ) + ); + end +end + +function def_env.s2s:showcert(domain) + local print = self.session.print; + local s2s_sessions = module:shared"/*/s2s/sessions"; + local domain_sessions = set.new(array.collect(values(s2s_sessions))) + /function(session) return (session.to_host == domain or session.from_host == domain) and session or nil; end; + local cert_set = {}; + for session in domain_sessions do + local conn = session.conn; + conn = conn and conn:socket(); + if not conn.getpeerchain then + if conn.dohandshake then + error("This version of LuaSec does not support certificate viewing"); + end + else + local cert = conn:getpeercertificate(); + if cert then + local certs = conn:getpeerchain(); + local digest = cert:digest("sha1"); + if not cert_set[digest] then + local chain_valid, chain_errors = conn:getpeerverification(); + cert_set[digest] = { + { + from = session.from_host, + to = session.to_host, + direction = session.direction + }; + chain_valid = chain_valid; + chain_errors = chain_errors; + certs = certs; + }; + else + table.insert(cert_set[digest], { + from = session.from_host, + to = session.to_host, + direction = session.direction + }); + end + end + end + end + local domain_certs = array.collect(values(cert_set)); + -- Phew. We now have a array of unique certificates presented by domain. + local n_certs = #domain_certs; + + if n_certs == 0 then + return "No certificates found for "..domain; + end + + local function _capitalize_and_colon(byte) + return string.upper(byte)..":"; + end + local function pretty_fingerprint(hash) + return hash:gsub("..", _capitalize_and_colon):sub(1, -2); + end + + for cert_info in values(domain_certs) do + local certs = cert_info.certs; + local cert = certs[1]; + print("---") + print("Fingerprint (SHA1): "..pretty_fingerprint(cert:digest("sha1"))); + print(""); + local n_streams = #cert_info; + print("Currently used on "..n_streams.." stream"..(n_streams==1 and "" or "s")..":"); + for _, stream in ipairs(cert_info) do + if stream.direction == "incoming" then + print(" "..stream.to.." <- "..stream.from); + else + print(" "..stream.from.." -> "..stream.to); + end + end + print(""); + local chain_valid, errors = cert_info.chain_valid, cert_info.chain_errors; + local valid_identity = cert_verify_identity(domain, "xmpp-server", cert); + if chain_valid then + print("Trusted certificate: Yes"); + else + print("Trusted certificate: No"); + print_errors(print, errors); + end + print(""); + print("Issuer: "); + print_subject(print, cert:issuer()); + print(""); + print("Valid for "..domain..": "..(valid_identity and "Yes" or "No")); + print("Subject:"); + print_subject(print, cert:subject()); + end + print("---"); + return ("Showing "..n_certs.." certificate" + ..(n_certs==1 and "" or "s") + .." presented by "..domain.."."); +end + +function def_env.s2s:close(from, to) + local print, count = self.session.print, 0; + local s2s_sessions = module:shared"/*/s2s/sessions"; + + local match_id; + if from and not to then + match_id, from = from, nil; + elseif not to then + return false, "Syntax: s2s:close('from', 'to') - Closes all s2s sessions from 'from' to 'to'"; + elseif from == to then + return false, "Both from and to are the same... you can't do that :)"; + end + + for _, session in pairs(s2s_sessions) do + local id = session.type..tostring(session):match("[a-f0-9]+$"); + if (match_id and match_id == id) + or (session.from_host == from and session.to_host == to) then + print(("Closing connection from %s to %s [%s]"):format(session.from_host, session.to_host, id)); + (session.close or s2smanager.destroy_session)(session); + count = count + 1 ; + end + end + return true, "Closed "..count.." s2s session"..((count == 1 and "") or "s"); +end + +function def_env.s2s:closeall(host) + local count = 0; + local s2s_sessions = module:shared"/*/s2s/sessions"; + for _,session in pairs(s2s_sessions) do + if not host or session.from_host == host or session.to_host == host then + session:close(); + count = count + 1; + end + end + if count == 0 then return false, "No sessions to close."; + else return true, "Closed "..count.." s2s session"..((count == 1 and "") or "s"); end +end + +def_env.host = {}; def_env.hosts = def_env.host; + +function def_env.host:activate(hostname, config) + return hostmanager.activate(hostname, config); +end +function def_env.host:deactivate(hostname, reason) + return hostmanager.deactivate(hostname, reason); +end + +function def_env.host:list() + local print = self.session.print; + local i = 0; + local type; + for host, host_session in iterators.sorted_pairs(prosody.hosts) do + i = i + 1; + type = host_session.type; + if type == "local" then + print(host); + else + type = module:context(host):get_option_string("component_module", type); + if type ~= "component" then + type = type .. " component"; + end + print(("%s (%s)"):format(host, type)); + end + end + return true, i.." hosts"; +end + +def_env.port = {}; + +function def_env.port:list() + local print = self.session.print; + local services = portmanager.get_active_services().data; + local n_services, n_ports = 0, 0; + for service, interfaces in iterators.sorted_pairs(services) do + n_services = n_services + 1; + local ports_list = {}; + for interface, ports in pairs(interfaces) do + for port in pairs(ports) do + table.insert(ports_list, "["..interface.."]:"..port); + end + end + n_ports = n_ports + #ports_list; + print(service..": "..table.concat(ports_list, ", ")); + end + return true, n_services.." services listening on "..n_ports.." ports"; +end + +function def_env.port:close(close_port, close_interface) + close_port = assert(tonumber(close_port), "Invalid port number"); + local n_closed = 0; + local services = portmanager.get_active_services().data; + for service, interfaces in pairs(services) do -- luacheck: ignore 213 + for interface, ports in pairs(interfaces) do + if not close_interface or close_interface == interface then + if ports[close_port] then + self.session.print("Closing ["..interface.."]:"..close_port.."..."); + local ok, err = portmanager.close(interface, close_port) + if not ok then + self.session.print("Failed to close "..interface.." "..close_port..": "..err); + else + n_closed = n_closed + 1; + end + end + end + end + end + return true, "Closed "..n_closed.." ports"; +end + +def_env.muc = {}; + +local console_room_mt = { + __index = function (self, k) return self.room[k]; end; + __tostring = function (self) + return "MUC room <"..self.room.jid..">"; + end; +}; + +local function check_muc(jid) + local room_name, host = jid_split(jid); + if not prosody.hosts[host] then + return nil, "No such host: "..host; + elseif not prosody.hosts[host].modules.muc then + return nil, "Host '"..host.."' is not a MUC service"; + end + return room_name, host; +end + +function def_env.muc:create(room_jid, config) + local room_name, host = check_muc(room_jid); + if not room_name then + return room_name, host; + end + if not room_name then return nil, host end + if config ~= nil and type(config) ~= "table" then return nil, "Config must be a table"; end + if prosody.hosts[host].modules.muc.get_room_from_jid(room_jid) then return nil, "Room exists already" end + return prosody.hosts[host].modules.muc.create_room(room_jid, config); +end + +function def_env.muc:room(room_jid) + local room_name, host = check_muc(room_jid); + if not room_name then + return room_name, host; + end + local room_obj = prosody.hosts[host].modules.muc.get_room_from_jid(room_jid); + if not room_obj then + return nil, "No such room: "..room_jid; + end + return setmetatable({ room = room_obj }, console_room_mt); +end + +function def_env.muc:list(host) + local host_session = prosody.hosts[host]; + if not host_session or not host_session.modules.muc then + return nil, "Please supply the address of a local MUC component"; + end + local print = self.session.print; + local c = 0; + for room in host_session.modules.muc.each_room() do + print(room.jid); + c = c + 1; + end + return true, c.." rooms"; +end + +local um = require"core.usermanager"; + +def_env.user = {}; +function def_env.user:create(jid, password) + local username, host = jid_split(jid); + if not prosody.hosts[host] then + return nil, "No such host: "..host; + elseif um.user_exists(username, host) then + return nil, "User exists"; + end + local ok, err = um.create_user(username, password, host); + if ok then + return true, "User created"; + else + return nil, "Could not create user: "..err; + end +end + +function def_env.user:delete(jid) + local username, host = jid_split(jid); + if not prosody.hosts[host] then + return nil, "No such host: "..host; + elseif not um.user_exists(username, host) then + return nil, "No such user"; + end + local ok, err = um.delete_user(username, host); + if ok then + return true, "User deleted"; + else + return nil, "Could not delete user: "..err; + end +end + +function def_env.user:password(jid, password) + local username, host = jid_split(jid); + if not prosody.hosts[host] then + return nil, "No such host: "..host; + elseif not um.user_exists(username, host) then + return nil, "No such user"; + end + local ok, err = um.set_password(username, password, host, nil); + if ok then + return true, "User password changed"; + else + return nil, "Could not change password for user: "..err; + end +end + +function def_env.user:list(host, pat) + if not host then + return nil, "No host given"; + elseif not prosody.hosts[host] then + return nil, "No such host"; + end + local print = self.session.print; + local total, matches = 0, 0; + for user in um.users(host) do + if not pat or user:match(pat) then + print(user.."@"..host); + matches = matches + 1; + end + total = total + 1; + end + return true, "Showing "..(pat and (matches.." of ") or "all " )..total.." users"; +end + +def_env.xmpp = {}; + +local st = require "util.stanza"; +function def_env.xmpp:ping(localhost, remotehost) + if prosody.hosts[localhost] then + module:send(st.iq{ from=localhost, to=remotehost, type="get", id="ping" } + :tag("ping", {xmlns="urn:xmpp:ping"}), prosody.hosts[localhost]); + return true, "Sent ping"; + else + return nil, "No such host"; + end +end + +def_env.dns = {}; +local adns = require"net.adns"; + +local function get_resolver(session) + local resolver = session.dns_resolver; + if not resolver then + resolver = adns.resolver(); + session.dns_resolver = resolver; + end + return resolver; +end + +function def_env.dns:lookup(name, typ, class) + local resolver = get_resolver(self.session); + local ret = "Query sent"; + local print = self.session.print; + local function handler(...) + ret = "Got response"; + print(...); + end + resolver:lookup(handler, name, typ, class); + return true, ret; +end + +function def_env.dns:addnameserver(...) + local resolver = get_resolver(self.session); + resolver._resolver:addnameserver(...) + return true +end + +function def_env.dns:setnameserver(...) + local resolver = get_resolver(self.session); + resolver._resolver:setnameserver(...) + return true +end + +function def_env.dns:purge() + local resolver = get_resolver(self.session); + resolver._resolver:purge() + return true +end + +function def_env.dns:cache() + local resolver = get_resolver(self.session); + return true, "Cache:\n"..tostring(resolver._resolver.cache) +end + +def_env.http = {}; + +function def_env.http:list() + local print = self.session.print; + + for host in pairs(prosody.hosts) do + local http_apps = modulemanager.get_items("http-provider", host); + if #http_apps > 0 then + local http_host = module:context(host):get_option_string("http_host"); + print("HTTP endpoints on "..host..(http_host and (" (using "..http_host.."):") or ":")); + for _, provider in ipairs(http_apps) do + local url = module:context(host):http_url(provider.name, provider.default_path); + print("", url); + end + print(""); + end + end + + local default_host = module:get_option_string("http_default_host"); + if not default_host then + print("HTTP requests to unknown hosts will return 404 Not Found"); + else + print("HTTP requests to unknown hosts will be handled by "..default_host); + end + return true; +end + +def_env.debug = {}; + +function def_env.debug:logevents(host) + helpers.log_host_events(host); + return true; +end + +function def_env.debug:events(host, event) + local events_obj; + if host and host ~= "*" then + if host == "http" then + events_obj = require "net.http.server"._events; + elseif not prosody.hosts[host] then + return false, "Unknown host: "..host; + else + events_obj = prosody.hosts[host].events; + end + else + events_obj = prosody.events; + end + return true, helpers.show_events(events_obj, event); +end + +function def_env.debug:timers() + local socket = require "socket"; + local print = self.session.print; + local add_task = require"util.timer".add_task; + local h, params = add_task.h, add_task.params; + if h then + print("-- util.timer"); + for i, id in ipairs(h.ids) do + if not params[id] then + print(os.date("%F %T", h.priorities[i]), h.items[id]); + elseif not params[id].callback then + print(os.date("%F %T", h.priorities[i]), h.items[id], unpack(params[id])); + else + print(os.date("%F %T", h.priorities[i]), params[id].callback, unpack(params[id])); + end + end + end + if server.event_base then + local count = 0; + for _, v in pairs(debug.getregistry()) do + if type(v) == "function" and v.callback and v.callback == add_task._on_timer then + count = count + 1; + end + end + print(count .. " libevent callbacks"); + end + if h then + local next_time = h:peek(); + if next_time then + return true, os.date("Next event at %F %T (in %%.6fs)", next_time):format(next_time - socket.gettime()); + end + end + return true; +end + +-- COMPAT: debug:timers() was timer:info() for some time in trunk +def_env.timer = { info = def_env.debug.timers }; + +module:hook("server-stopping", function(event) + for _, session in pairs(sessions) do + session.print("Shutting down: "..(event.reason or "unknown reason")); + end +end); + +def_env.stats = {}; + +local function format_stat(type, value, ref_value) + ref_value = ref_value or value; + --do return tostring(value) end + if type == "duration" then + if ref_value < 0.001 then + return ("%d µs"):format(value*1000000); + elseif ref_value < 0.9 then + return ("%0.2f ms"):format(value*1000); + end + return ("%0.2f"):format(value); + elseif type == "size" then + if ref_value > 1048576 then + return ("%d MB"):format(value/1048576); + elseif ref_value > 1024 then + return ("%d KB"):format(value/1024); + end + return ("%d bytes"):format(value); + elseif type == "rate" then + if ref_value < 0.9 then + return ("%0.2f/min"):format(value*60); + end + return ("%0.2f/sec"):format(value); + end + return tostring(value); +end + +local stats_methods = {}; +function stats_methods:bounds(_lower, _upper) + for _, stat_info in ipairs(self) do + local data = stat_info[4]; + if data then + local lower = _lower or data.min; + local upper = _upper or data.max; + local new_data = { + min = lower; + max = upper; + samples = {}; + sample_count = 0; + count = data.count; + units = data.units; + }; + local sum = 0; + for _, v in ipairs(data.samples) do + if v > upper then + break; + elseif v>=lower then + table.insert(new_data.samples, v); + sum = sum + v; + end + end + new_data.sample_count = #new_data.samples; + stat_info[4] = new_data; + stat_info[3] = sum/new_data.sample_count; + end + end + return self; +end + +function stats_methods:trim(lower, upper) + upper = upper or (100-lower); + local statistics = require "util.statistics"; + for _, stat_info in ipairs(self) do + -- Strip outliers + local data = stat_info[4]; + if data then + local new_data = { + min = statistics.get_percentile(data, lower); + max = statistics.get_percentile(data, upper); + samples = {}; + sample_count = 0; + count = data.count; + units = data.units; + }; + local sum = 0; + for _, v in ipairs(data.samples) do + if v > new_data.max then + break; + elseif v>=new_data.min then + table.insert(new_data.samples, v); + sum = sum + v; + end + end + new_data.sample_count = #new_data.samples; + stat_info[4] = new_data; + stat_info[3] = sum/new_data.sample_count; + end + end + return self; +end + +function stats_methods:max(upper) + return self:bounds(nil, upper); +end + +function stats_methods:min(lower) + return self:bounds(lower, nil); +end + +function stats_methods:summary() + local statistics = require "util.statistics"; + for _, stat_info in ipairs(self) do + local type, value, data = stat_info[2], stat_info[3], stat_info[4]; + if data and data.samples then + table.insert(stat_info.output, string.format("Count: %d (%d captured)", + data.count, + data.sample_count + )); + table.insert(stat_info.output, string.format("Min: %s Mean: %s Max: %s", + format_stat(type, data.min), + format_stat(type, value), + format_stat(type, data.max) + )); + table.insert(stat_info.output, string.format("Q1: %s Median: %s Q3: %s", + format_stat(type, statistics.get_percentile(data, 25)), + format_stat(type, statistics.get_percentile(data, 50)), + format_stat(type, statistics.get_percentile(data, 75)) + )); + end + end + return self; +end + +function stats_methods:cfgraph() + for _, stat_info in ipairs(self) do + local name, type, value, data = unpack(stat_info, 1, 4); + local function print(s) + table.insert(stat_info.output, s); + end + + if data and data.sample_count and data.sample_count > 0 then + local raw_histogram = require "util.statistics".get_histogram(data); + + local graph_width, graph_height = 50, 10; + local eighth_chars = " ▁▂▃▄▅▆▇█"; + + local range = data.max - data.min; + + if range > 0 then + local x_scaling = #raw_histogram/graph_width; + local histogram = {}; + for i = 1, graph_width do + histogram[i] = math.max(raw_histogram[i*x_scaling-1] or 0, raw_histogram[i*x_scaling] or 0); + end + + print(""); + print(("_"):rep(52)..format_stat(type, data.max)); + for row = graph_height, 1, -1 do + local row_chars = {}; + local min_eighths, max_eighths = 8, 0; + for i = 1, #histogram do + local char_eighths = math.ceil(math.max(math.min((graph_height/(data.max/histogram[i]))-(row-1), 1), 0)*8); + if char_eighths < min_eighths then + min_eighths = char_eighths; + end + if char_eighths > max_eighths then + max_eighths = char_eighths; + end + if char_eighths == 0 then + row_chars[i] = "-"; + else + local char = eighth_chars:sub(char_eighths*3+1, char_eighths*3+3); + row_chars[i] = char; + end + end + print(table.concat(row_chars).."|-"..format_stat(type, data.max/(graph_height/(row-0.5)))); + end + print(("\\ "):rep(11)); + local x_labels = {}; + for i = 1, 11 do + local s = ("%-4s"):format((i-1)*10); + if #s > 4 then + s = s:sub(1, 3).."…"; + end + x_labels[i] = s; + end + print(" "..table.concat(x_labels, " ")); + local units = "%"; + local margin = math.floor((graph_width-#units)/2); + print((" "):rep(margin)..units); + else + print("[range too small to graph]"); + end + print(""); + end + end + return self; +end + +function stats_methods:histogram() + for _, stat_info in ipairs(self) do + local name, type, value, data = unpack(stat_info, 1, 4); + local function print(s) + table.insert(stat_info.output, s); + end + + if not data then + print("[no data]"); + return self; + elseif not data.sample_count then + print("[not a sampled metric type]"); + return self; + end + + local graph_width, graph_height = 50, 10; + local eighth_chars = " ▁▂▃▄▅▆▇█"; + + local range = data.max - data.min; + + if range > 0 then + local n_buckets = graph_width; + + local histogram = {}; + for i = 1, n_buckets do + histogram[i] = 0; + end + local max_bin_samples = 0; + for _, d in ipairs(data.samples) do + local bucket = math.floor(1+(n_buckets-1)/(range/(d-data.min))); + histogram[bucket] = histogram[bucket] + 1; + if histogram[bucket] > max_bin_samples then + max_bin_samples = histogram[bucket]; + end + end + + print(""); + print(("_"):rep(52)..max_bin_samples); + for row = graph_height, 1, -1 do + local row_chars = {}; + local min_eighths, max_eighths = 8, 0; + for i = 1, #histogram do + local char_eighths = math.ceil(math.max(math.min((graph_height/(max_bin_samples/histogram[i]))-(row-1), 1), 0)*8); + if char_eighths < min_eighths then + min_eighths = char_eighths; + end + if char_eighths > max_eighths then + max_eighths = char_eighths; + end + if char_eighths == 0 then + row_chars[i] = "-"; + else + local char = eighth_chars:sub(char_eighths*3+1, char_eighths*3+3); + row_chars[i] = char; + end + end + print(table.concat(row_chars).."|-"..math.ceil((max_bin_samples/graph_height)*(row-0.5))); + end + print(("\\ "):rep(11)); + local x_labels = {}; + for i = 1, 11 do + local s = ("%-4s"):format(format_stat(type, data.min+range*i/11, data.min):match("^%S+")); + if #s > 4 then + s = s:sub(1, 3).."…"; + end + x_labels[i] = s; + end + print(" "..table.concat(x_labels, " ")); + local units = format_stat(type, data.min):match("%s+(.+)$") or data.units or ""; + local margin = math.floor((graph_width-#units)/2); + print((" "):rep(margin)..units); + else + print("[range too small to graph]"); + end + print(""); + end + return self; +end + +local function stats_tostring(stats) + local print = stats.session.print; + for _, stat_info in ipairs(stats) do + if #stat_info.output > 0 then + print("\n#"..stat_info[1]); + print(""); + for _, v in ipairs(stat_info.output) do + print(v); + end + print(""); + else + print(("%-50s %s"):format(stat_info[1], format_stat(stat_info[2], stat_info[3]))); + end + end + return #stats.." statistics displayed"; +end + +local stats_mt = {__index = stats_methods, __tostring = stats_tostring } +local function new_stats_context(self) + return setmetatable({ session = self.session, stats = true }, stats_mt); +end + +function def_env.stats:show(filter) + local stats, changed, extra = require "core.statsmanager".get_stats(); + local available, displayed = 0, 0; + local displayed_stats = new_stats_context(self); + for name, value in pairs(stats) do + available = available + 1; + if not filter or name:match(filter) then + displayed = displayed + 1; + local type = name:match(":(%a+)$"); + table.insert(displayed_stats, { + name, type, value, extra[name]; + output = {}; + }); + end + end + return displayed_stats; +end + + + +------------- + +function printbanner(session) + local option = module:get_option_string("console_banner", "full"); + if option == "full" or option == "graphic" then + session.print [[ + ____ \ / _ + | _ \ _ __ ___ ___ _-_ __| |_ _ + | |_) | '__/ _ \/ __|/ _ \ / _` | | | | + | __/| | | (_) \__ \ |_| | (_| | |_| | + |_| |_| \___/|___/\___/ \__,_|\__, | + A study in simplicity |___/ + +]] + end + if option == "short" or option == "full" then + session.print("Welcome to the Prosody administration console. For a list of commands, type: help"); + session.print("You may find more help on using this console in our online documentation at "); + session.print("https://prosody.im/doc/console\n"); + end + if option ~= "short" and option ~= "full" and option ~= "graphic" then + session.print(option); + end +end + +module:provides("net", { + name = "console"; + listener = console_listener; + default_port = 5582; + private = true; +}); diff --git a/patches/mod_carbons.lua b/patches/mod_carbons.lua new file mode 100644 index 0000000..c8cf00f --- /dev/null +++ b/patches/mod_carbons.lua @@ -0,0 +1,116 @@ +-- XEP-0280: Message Carbons implementation for Prosody +-- Copyright (C) 2011-2016 Kim Alvefur +-- +-- This file is MIT/X11 licensed. + +local st = require "util.stanza"; +local jid_bare = require "util.jid".bare; +local xmlns_carbons = "urn:xmpp:carbons:2"; +local xmlns_forward = "urn:xmpp:forward:0"; +local full_sessions, bare_sessions = prosody.full_sessions, prosody.bare_sessions; + +local function toggle_carbons(event) + local origin, stanza = event.origin, event.stanza; + local state = stanza.tags[1].name; + module:log("debug", "%s %sd carbons", origin.full_jid, state); + origin.want_carbons = state == "enable" and stanza.tags[1].attr.xmlns; + origin.send(st.reply(stanza)); + return true; +end +module:hook("iq-set/self/"..xmlns_carbons..":disable", toggle_carbons); +module:hook("iq-set/self/"..xmlns_carbons..":enable", toggle_carbons); + +local function message_handler(event, c2s) + local origin, stanza = event.origin, event.stanza; + local orig_type = stanza.attr.type or "normal"; + local orig_from = stanza.attr.from; + local bare_from = jid_bare(orig_from); + local orig_to = stanza.attr.to; + local bare_to = jid_bare(orig_to); + + if not(orig_type == "chat" or (orig_type == "normal" and stanza:get_child("body"))) then + return -- Only chat type messages + end + + -- Stanza sent by a local client + local bare_jid = bare_from; -- JID of the local user + local target_session = origin; + local top_priority = false; + local user_sessions = bare_sessions[bare_from]; + + -- Stanza about to be delivered to a local client + if not c2s then + bare_jid = bare_to; + target_session = full_sessions[orig_to]; + user_sessions = bare_sessions[bare_jid]; + if not target_session and user_sessions then + -- The top resources will already receive this message per normal routing rules, + -- so we are going to skip them in order to avoid sending duplicated messages. + local top_resources = user_sessions.top_resources; + top_priority = top_resources and top_resources[1].priority + end + end + + if not user_sessions then + module:log("debug", "Skip carbons for offline user"); + return -- No use in sending carbons to an offline user + end + + if stanza:get_child("private", xmlns_carbons) then + if not c2s then + stanza:maptags(function(tag) + if not ( tag.attr.xmlns == xmlns_carbons and tag.name == "private" ) then + return tag; + end + end); + end + module:log("debug", "Message tagged private, ignoring"); + return + elseif stanza:get_child("no-copy", "urn:xmpp:hints") then + module:log("debug", "Message has no-copy hint, ignoring"); + return + elseif not c2s and bare_jid ~= orig_to and stanza:get_child("x", "http://jabber.org/protocol/muc#user") then + module:log("debug", "MUC PM, ignoring"); + return + end + + -- Create the carbon copy and wrap it as per the Stanza Forwarding XEP + local copy = st.clone(stanza); + if c2s and not orig_to then + stanza.attr.to = bare_from; + end + copy.attr.xmlns = "jabber:client"; + local carbon = st.message{ from = bare_jid, type = orig_type, } + :tag(c2s and "sent" or "received", { xmlns = xmlns_carbons }) + :tag("forwarded", { xmlns = xmlns_forward }) + :add_child(copy):reset(); + + user_sessions = user_sessions and user_sessions.sessions; + for _, session in pairs(user_sessions) do + -- Carbons are sent to resources that have enabled it + if session.want_carbons + -- but not the resource that sent the message, or the one that it's directed to + and session ~= target_session + -- and isn't among the top resources that would receive the message per standard routing rules + and (c2s or session.priority ~= top_priority) then + carbon.attr.to = session.full_jid; + module:log("debug", "Sending carbon to %s", session.full_jid); + session.send(carbon); + end + end +end + +local function c2s_message_handler(event) + return message_handler(event, true) +end + +-- Stanzas sent by local clients +module:hook("pre-message/host", c2s_message_handler, -0.5); +module:hook("pre-message/bare", c2s_message_handler, -0.5); +module:hook("pre-message/full", c2s_message_handler, -0.5); +module:hook("vnc-rest-message", c2s_message_handler, -0.5); +-- Stanzas to local clients +module:hook("message/bare", message_handler, -0.5); +module:hook("message/full", message_handler, -0.5); + +module:add_feature(xmlns_carbons); diff --git a/patches/mod_mam.lua b/patches/mod_mam.lua new file mode 100644 index 0000000..8ba376b --- /dev/null +++ b/patches/mod_mam.lua @@ -0,0 +1,416 @@ +-- Prosody IM +-- Copyright (C) 2008-2017 Matthew Wild +-- Copyright (C) 2008-2017 Waqas Hussain +-- Copyright (C) 2011-2017 Kim Alvefur +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- +-- XEP-0313: Message Archive Management for Prosody +-- + +local xmlns_mam = "urn:xmpp:mam:2"; +local xmlns_delay = "urn:xmpp:delay"; +local xmlns_forward = "urn:xmpp:forward:0"; +local xmlns_st_id = "urn:xmpp:sid:0"; + +local um = require "core.usermanager"; +local st = require "util.stanza"; +local rsm = require "util.rsm"; +local get_prefs = module:require"mamprefs".get; +local set_prefs = module:require"mamprefs".set; +local prefs_to_stanza = module:require"mamprefsxml".tostanza; +local prefs_from_stanza = module:require"mamprefsxml".fromstanza; +local jid_bare = require "util.jid".bare; +local jid_split = require "util.jid".split; +local jid_prepped_split = require "util.jid".prepped_split; +local dataform = require "util.dataforms".new; +local host = module.host; + +local rm_load_roster = require "core.rostermanager".load_roster; + +local is_stanza = st.is_stanza; +local tostring = tostring; +local time_now = os.time; +local m_min = math.min; +local timestamp, timestamp_parse, datestamp = import( "util.datetime", "datetime", "parse", "date"); +local default_max_items, max_max_items = 20, module:get_option_number("max_archive_query_results", 50); +local strip_tags = module:get_option_set("dont_archive_namespaces", { "http://jabber.org/protocol/chatstates" }); + +local archive_store = module:get_option_string("archive_store", "archive"); +local archive = module:open_store(archive_store, "archive"); + +if not archive.find then + error("mod_"..(archive._provided_by or archive.name and "storage_"..archive.name).." does not support archiving\n" + .."See https://prosody.im/doc/storage and https://prosody.im/doc/archiving for more information"); +end +local use_total = module:get_option_boolean("mam_include_total", true); + +function schedule_cleanup() + -- replaced by non-noop later if cleanup is enabled +end + +-- Handle prefs. +module:hook("iq/self/"..xmlns_mam..":prefs", function(event) + local origin, stanza = event.origin, event.stanza; + local user = origin.username; + if stanza.attr.type == "set" then + local new_prefs = stanza:get_child("prefs", xmlns_mam); + local prefs = prefs_from_stanza(new_prefs); + local ok, err = set_prefs(user, prefs); + if not ok then + origin.send(st.error_reply(stanza, "cancel", "internal-server-error", "Error storing preferences: "..tostring(err))); + return true; + end + end + local prefs = prefs_to_stanza(get_prefs(user, true)); + local reply = st.reply(stanza):add_child(prefs); + origin.send(reply); + return true; +end); + +local query_form = dataform { + { name = "FORM_TYPE"; type = "hidden"; value = xmlns_mam; }; + { name = "with"; type = "jid-single"; }; + { name = "start"; type = "text-single" }; + { name = "end"; type = "text-single"; }; +}; + +-- Serve form +module:hook("iq-get/self/"..xmlns_mam..":query", function(event) + local origin, stanza = event.origin, event.stanza; + get_prefs(origin.username, true); + origin.send(st.reply(stanza):query(xmlns_mam):add_child(query_form:form())); + return true; +end); + +-- Handle archive queries +module:hook("iq-set/self/"..xmlns_mam..":query", function(event) + local origin, stanza = event.origin, event.stanza; + local query = stanza.tags[1]; + local qid = query.attr.queryid; + + origin.mam_requested = true; + + get_prefs(origin.username, true); + + -- Search query parameters + local qwith, qstart, qend; + local form = query:get_child("x", "jabber:x:data"); + if form then + local err; + form, err = query_form:data(form); + if err then + origin.send(st.error_reply(stanza, "modify", "bad-request", select(2, next(err)))); + return true; + end + qwith, qstart, qend = form["with"], form["start"], form["end"]; + qwith = qwith and jid_bare(qwith); -- dataforms does jidprep + end + + if qstart or qend then -- Validate timestamps + local vstart, vend = (qstart and timestamp_parse(qstart)), (qend and timestamp_parse(qend)); + if (qstart and not vstart) or (qend and not vend) then + origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid timestamp")) + return true; + end + qstart, qend = vstart, vend; + end + + module:log("debug", "Archive query, id %s with %s from %s until %s", + tostring(qid), qwith or "anyone", + qstart and timestamp(qstart) or "the dawn of time", + qend and timestamp(qend) or "now"); + + -- RSM stuff + local qset = rsm.get(query); + local qmax = m_min(qset and qset.max or default_max_items, max_max_items); + local reverse = qset and qset.before or false; + local before, after = qset and qset.before, qset and qset.after; + if type(before) ~= "string" then before = nil; end + + -- Load all the data! + local data, err = archive:find(origin.username, { + start = qstart; ["end"] = qend; -- Time range + with = qwith; + limit = qmax == 0 and 0 or qmax + 1; + before = before; after = after; + reverse = reverse; + total = use_total or qmax == 0; + }); + + if not data then + origin.send(st.error_reply(stanza, "cancel", "internal-server-error", err)); + return true; + end + local total = tonumber(err); + + local msg_reply_attr = { to = stanza.attr.from, from = stanza.attr.to }; + + local results = {}; + + -- Wrap it in stuff and deliver + local first, last; + local count = 0; + local complete = "true"; + for id, item, when in data do + count = count + 1; + if count > qmax then + -- We requested qmax+1 items. If that many items are retrieved then + -- there are more results to page through, so: + complete = nil; + break; + end + local fwd_st = st.message(msg_reply_attr) + :tag("result", { xmlns = xmlns_mam, queryid = qid, id = id }) + :tag("forwarded", { xmlns = xmlns_forward }) + :tag("delay", { xmlns = xmlns_delay, stamp = timestamp(when) }):up(); + + if not is_stanza(item) then + item = st.deserialize(item); + end + item.attr.xmlns = "jabber:client"; + fwd_st:add_child(item); + + if not first then first = id; end + last = id; + + if reverse then + results[count] = fwd_st; + else + origin.send(fwd_st); + end + end + + if reverse then + for i = #results, 1, -1 do + origin.send(results[i]); + end + first, last = last, first; + end + + -- That's all folks! + module:log("debug", "Archive query %s completed", tostring(qid)); + + origin.send(st.reply(stanza) + :tag("fin", { xmlns = xmlns_mam, queryid = qid, complete = complete }) + :add_child(rsm.generate { + first = first, last = last, count = total })); + return true; +end); + +local function has_in_roster(user, who) + local roster = rm_load_roster(user, host); + module:log("debug", "%s has %s in roster? %s", user, who, roster[who] and "yes" or "no"); + return roster[who]; +end + +local function shall_store(user, who) + return true; +end + +local function strip_stanza_id(stanza, user) + if stanza:get_child("stanza-id", xmlns_st_id) then + stanza = st.clone(stanza); + stanza:maptags(function (tag) + if tag.name == "stanza-id" and tag.attr.xmlns == xmlns_st_id then + local by_user, by_host, res = jid_prepped_split(tag.attr.by); + if not res and by_host == host and by_user == user then + return nil; + end + end + return tag; + end); + end + return stanza; +end + +-- Handle messages +local function message_handler(event, c2s, vnc_rest) + local origin, stanza = event.origin, event.stanza; + local log = c2s and origin.log or module._log; + local orig_type = stanza.attr.type or "normal"; + local orig_from = stanza.attr.from; + local orig_to = stanza.attr.to or orig_from; + -- Stanza without 'to' are treated as if it was to their own bare jid + + -- Whos storage do we put it in? + local store_user = c2s and origin.username or jid_split(orig_to); + if vnc_rest then + store_user = jid_split(orig_from); + end + -- And who are they chatting with? + local with = jid_bare(c2s and orig_to or orig_from); + + -- Filter out that claim to be from us + event.stanza = strip_stanza_id(stanza, store_user); + + -- We store chat messages or normal messages that have a body + if not(orig_type == "chat" or (orig_type == "normal" and stanza:get_child("body")) ) then + log("debug", "Not archiving stanza: %s (type)", stanza:top_tag()); + return; + end + + -- or if hints suggest we shouldn't + if not stanza:get_child("store", "urn:xmpp:hints") then -- No hint telling us we should store + if stanza:get_child("no-permanent-store", "urn:xmpp:hints") + or stanza:get_child("no-store", "urn:xmpp:hints") then -- Hint telling us we should NOT store + log("debug", "Not archiving stanza: %s (hint)", stanza:top_tag()); + return; + end + end + + local clone_for_storage; + if not strip_tags:empty() then + clone_for_storage = st.clone(stanza); + clone_for_storage:maptags(function (tag) + if strip_tags:contains(tag.attr.xmlns) then + return nil; + else + return tag; + end + end); + if #clone_for_storage.tags == 0 then + log("debug", "Not archiving stanza: %s (empty when stripped)", stanza:top_tag()); + return; + end + else + clone_for_storage = stanza; + end + + local storebody = stanza:get_child("body") and stanza:get_child("body")[1]; + + -- Check with the users preferences + if (storebody and shall_store(store_user, with)) then + log("debug", "Archiving stanza: %s", stanza:top_tag()); + + -- And stash it + local ok, err = archive:append(store_user, nil, clone_for_storage, time_now(), with); + if ok then + local clone_for_other_handlers = st.clone(stanza); + local id = ok; + clone_for_other_handlers:tag("stanza-id", { xmlns = xmlns_st_id, by = store_user.."@"..host, id = id }):up(); + event.stanza = clone_for_other_handlers; + schedule_cleanup(store_user); + module:fire_event("archive-message-added", { origin = origin, stanza = clone_for_storage, for_user = store_user, id = id }); + else + log("error", "Could not archive stanza: %s", err); + end + else + log("debug", "Not archiving stanza: %s (prefs)", stanza:top_tag()); + end +end + +local function c2s_message_handler(event) + return message_handler(event, true); +end + +local function vnc_message_handler(event) + return message_handler(event, true, true); +end + +-- Filter out before the message leaves the server to prevent privacy leak. +local function strip_stanza_id_after_other_events(event) + event.stanza = strip_stanza_id(event.stanza, event.origin.username); +end + +module:hook("pre-message/bare", strip_stanza_id_after_other_events, -1); +module:hook("pre-message/full", strip_stanza_id_after_other_events, -1); + +local cleanup_after = module:get_option_string("archive_expires_after", "1w"); +local cleanup_interval = module:get_option_number("archive_cleanup_interval", 4 * 60 * 60); +if cleanup_after ~= "never" then + local cleanup_storage = module:open_store("archive_cleanup"); + local cleanup_map = module:open_store("archive_cleanup", "map"); + + local day = 86400; + local multipliers = { d = day, w = day * 7, m = 31 * day, y = 365.2425 * day }; + local n, m = cleanup_after:lower():match("(%d+)%s*([dwmy]?)"); + if not n then + module:log("error", "Could not parse archive_expires_after string %q", cleanup_after); + return false; + end + + cleanup_after = tonumber(n) * ( multipliers[m] or 1 ); + + module:log("debug", "archive_expires_after = %d -- in seconds", cleanup_after); + + if not archive.delete then + module:log("error", "archive_expires_after set but mod_%s does not support deleting", archive._provided_by); + return false; + end + + -- For each day, store a set of users that have new messages. To expire + -- messages, we collect the union of sets of users from dates that fall + -- outside the cleanup range. + + local last_date = require "util.cache".new(module:get_option_number("archive_cleanup_date_cache_size", 1000)); + function schedule_cleanup(username, date) + date = date or datestamp(); + if last_date:get(username) == date then return end + local ok = cleanup_map:set(date, username, true); + if ok then + last_date:set(username, date); + end + end + + local async = require "util.async"; + cleanup_runner = async.runner(function () + local users = {}; + local cut_off = datestamp(os.time() - cleanup_after); + for date in cleanup_storage:users() do + if date <= cut_off then + module:log("debug", "Messages from %q should be expired", date); + local messages_this_day = cleanup_storage:get(date); + if messages_this_day then + for user in pairs(messages_this_day) do + users[user] = true; + end + if date < cut_off then + -- Messages from the same day as the cut-off might not have expired yet, + -- but all earlier will have, so clear storage for those days. + cleanup_storage:set(date, nil); + end + end + end + end + local sum, num_users = 0, 0; + for user in pairs(users) do + local ok, err = archive:delete(user, { ["end"] = os.time() - cleanup_after; }) + if ok then + num_users = num_users + 1; + sum = sum + (tonumber(ok) or 0); + else + cleanup_map:set(cut_off, user, true); + module:log("error", "Could not delete messages for user '%s': %s", user, err); + end + local wait, done = async.waiter(); + module:add_timer(0.01, done); + wait(); + end + module:log("info", "Deleted %d expired messages for %d users", sum, num_users); + end); + + cleanup_task = module:add_timer(1, function () + cleanup_runner:run(true); + return cleanup_interval; + end); +else + module:log("debug", "Archive expiry disabled"); + -- Don't ask the backend to count the potentially unbounded number of items, + -- it'll get slow. + use_total = module:get_option_boolean("mam_include_total", false); +end + +-- Stanzas sent by local clients +module:hook("pre-message/bare", c2s_message_handler, 0); +module:hook("pre-message/full", c2s_message_handler, 0); +module:hook("vnc-rest-message", vnc_message_handler, 0); +-- Stanzas to local clients +module:hook("message/bare", message_handler, 0); +module:hook("message/full", message_handler, 0); + +module:hook("account-disco-info", function(event) + (event.reply or event.stanza):tag("feature", {var=xmlns_mam}):up(); + (event.reply or event.stanza):tag("feature", {var=xmlns_st_id}):up(); +end); diff --git a/patches/mod_muc.lua b/patches/mod_muc.lua new file mode 100644 index 0000000..9bf539e --- /dev/null +++ b/patches/mod_muc.lua @@ -0,0 +1,535 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +-- Exposed functions: +-- +-- create_room(jid) -> room +-- track_room(room) +-- delete_room(room) +-- forget_room(room) +-- get_room_from_jid(jid) -> room +-- each_room(live_only) -> () -> room [DEPRECATED] +-- all_rooms() -> room +-- live_rooms() -> room +-- shutdown_component() + +local function dumpTable(t, depth) + local result = ""; + if (not depth) then depth = 0 end; + + local prefix = string.rep(" ", depth); + + if (type(t) ~= "table") then + result = result.." "..tostring(t); + else + for key,value in pairs(t) do + result = result..prefix..tostring(key).." => "..dumpTable(value, depth+1).."\n" + end + end + + return result; +end + +if module:get_host_type() ~= "component" then + error("MUC should be loaded as a component, please see https://prosody.im/doc/components", 0); +end + +local muclib = module:require "muc"; +room_mt = muclib.room_mt; -- Yes, global. +new_room = muclib.new_room; + +local name = module:require "muc/name"; +room_mt.get_name = name.get; +room_mt.set_name = name.set; + +local description = module:require "muc/description"; +room_mt.get_description = description.get; +room_mt.set_description = description.set; + +local language = module:require "muc/language"; +room_mt.get_language = language.get; +room_mt.set_language = language.set; + +local hidden = module:require "muc/hidden"; +room_mt.get_hidden = hidden.get; +room_mt.set_hidden = hidden.set; +function room_mt:get_public() + return not self:get_hidden(); +end +function room_mt:set_public(public) + return self:set_hidden(not public); +end + +local password = module:require "muc/password"; +room_mt.get_password = password.get; +room_mt.set_password = password.set; + +local members_only = module:require "muc/members_only"; +room_mt.get_members_only = members_only.get; +room_mt.set_members_only = members_only.set; +room_mt.get_allow_member_invites = members_only.get_allow_member_invites; +room_mt.set_allow_member_invites = members_only.set_allow_member_invites; + +local moderated = module:require "muc/moderated"; +room_mt.get_moderated = moderated.get; +room_mt.set_moderated = moderated.set; + +local request = module:require "muc/request"; +room_mt.handle_role_request = request.handle_request; + +local persistent = module:require "muc/persistent"; +room_mt.get_persistent = persistent.get; +room_mt.set_persistent = persistent.set; + +local subject = module:require "muc/subject"; +room_mt.get_changesubject = subject.get_changesubject; +room_mt.set_changesubject = subject.set_changesubject; +room_mt.get_subject = subject.get; +room_mt.set_subject = subject.set; +room_mt.send_subject = subject.send; + +local history = module:require "muc/history"; +room_mt.send_history = history.send; +room_mt.get_historylength = history.get_length; +room_mt.set_historylength = history.set_length; + +local register = module:require "muc/register"; +room_mt.get_registered_nick = register.get_registered_nick; +room_mt.get_registered_jid = register.get_registered_jid; +room_mt.handle_register_iq = register.handle_register_iq; +room_mt.handle_unregister_iq = register.handle_unregister_iq; + +local jid_split = require "util.jid".split; +local jid_bare = require "util.jid".bare; +local st = require "util.stanza"; +local cache = require "util.cache"; +local um_is_admin = require "core.usermanager".is_admin; + +module:require "muc/config_form_sections"; + +module:depends("disco"); +module:add_identity("conference", "text", module:get_option_string("name", "Prosody Chatrooms")); +module:add_feature("http://jabber.org/protocol/muc"); +module:depends "muc_unique" +module:require "muc/lock"; + +local function is_admin(jid) + return um_is_admin(jid, module.host); +end + +if module:get_option_boolean("component_admins_as_room_owners", true) then + -- Monkey patch to make server admins room owners + local _get_affiliation = room_mt.get_affiliation; + function room_mt:get_affiliation(jid) + if is_admin(jid) then return "owner"; end + return _get_affiliation(self, jid); + end + + local _set_affiliation = room_mt.set_affiliation; + function room_mt:set_affiliation(actor, jid, affiliation, reason, data) + if affiliation ~= "owner" and is_admin(jid) then return nil, "modify", "not-acceptable"; end + return _set_affiliation(self, actor, jid, affiliation, reason, data); + end +end + +local persistent_rooms_storage = module:open_store("persistent"); +local persistent_rooms = module:open_store("persistent", "map"); +local room_configs = module:open_store("config"); +local room_state = module:open_store("state"); + +local room_items_cache = {}; + +local function room_save(room, forced, savestate) + local node = jid_split(room.jid); + local is_persistent = persistent.get(room); + room_items_cache[room.jid] = room:get_public() and room:get_name() or nil; + if is_persistent or savestate then + persistent_rooms:set(nil, room.jid, true); + local data, state = room:freeze(savestate); + room_state:set(node, state); + return room_configs:set(node, data); + elseif forced then + persistent_rooms:set(nil, room.jid, nil); + room_state:set(node, nil); + return room_configs:set(node, nil); + end +end + +local max_rooms = module:get_option_number("muc_max_rooms"); +local max_live_rooms = module:get_option_number("muc_room_cache_size", 100); + +local room_hit = module:measure("room_hit", "rate"); +local room_miss = module:measure("room_miss", "rate") +local room_eviction = module:measure("room_eviction", "rate"); +local rooms = cache.new(max_rooms or max_live_rooms, function (jid, room) + if max_rooms then + module:log("info", "Room limit of %d reached, no new rooms allowed", max_rooms); + return false; + end + module:log("debug", "Evicting room %s", jid); + room_eviction(); + room_items_cache[room.jid] = room:get_public() and room:get_name() or nil; + local ok, err = room_save(room, nil, true); -- Force to disk + if not ok then + module:log("error", "Failed to swap inactive room %s to disk: %s", jid, err); + return false; + end +end); + +-- Automatically destroy empty non-persistent rooms +module:hook("muc-occupant-left",function(event) + local room = event.room + if room.destroying then return end + if not room:has_occupant() and not persistent.get(room) then -- empty, non-persistent room + module:log("debug", "%q empty, destroying", room.jid); + module:fire_event("muc-room-destroyed", { room = room }); + end +end, -1); + +function track_room(room) + if rooms:set(room.jid, room) then + -- When room is created, over-ride 'save' method + room.save = room_save; + return room; + end + -- Resource limit reached + return false; +end + +local function handle_broken_room(room, origin, stanza) + module:log("debug", "Returning error from broken room %s", room.jid); + origin.send(st.error_reply(stanza, "wait", "internal-server-error")); + return true; +end + +local function restore_room(jid) + local node = jid_split(jid); + local data, err = room_configs:get(node); + if data then + module:log("debug", "Restoring room %s from storage", jid); + if module:fire_event("muc-room-pre-restore", { jid = jid, data = data }) == false then + return false; + end + local state, s_err = room_state:get(node); + if not state and s_err then + module:log("debug", "Could not restore state of room %s: %s", jid, s_err); + end + local room = muclib.restore_room(data, state); + if track_room(room) then + room_state:set(node, nil); + module:fire_event("muc-room-restored", { jid = jid, room = room }); + return room; + else + return false; + end + elseif err then + module:log("error", "Error restoring room %s from storage: %s", jid, err); + local room = muclib.new_room(jid, { locked = math.huge }); + room.handle_normal_presence = handle_broken_room; + room.handle_first_presence = handle_broken_room; + return room; + end +end + +-- Removes a room from memory, without saving it (save first if required) +function forget_room(room) + module:log("debug", "Forgetting %s", room.jid); + rooms.save = nil; + rooms:set(room.jid, nil); +end + +-- Removes a room from the database (may remain in memory) +function delete_room(room) + module:log("debug", "Deleting %s", room.jid); + room_configs:set(jid_split(room.jid), nil); + room_state:set(jid_split(room.jid), nil); + persistent_rooms:set(nil, room.jid, nil); + room_items_cache[room.jid] = nil; +end + +function module.unload() + for room in live_rooms() do + room:save(nil, true); + forget_room(room); + end +end + +function get_room_from_jid(room_jid) + local room = rooms:get(room_jid); + if room then + room_hit(); + rooms:set(room_jid, room); -- bump to top; + return room; + end + room_miss(); + return restore_room(room_jid); +end + +local function set_room_defaults(room, lang) + room:set_public(module:get_option_boolean("muc_room_default_public", false)); + room:set_persistent(module:get_option_boolean("muc_room_default_persistent", room:get_persistent())); + room:set_members_only(module:get_option_boolean("muc_room_default_members_only", room:get_members_only())); + room:set_allow_member_invites(module:get_option_boolean("muc_room_default_allow_member_invites", + room:get_allow_member_invites())); + room:set_moderated(module:get_option_boolean("muc_room_default_moderated", room:get_moderated())); + room:set_whois(module:get_option_boolean("muc_room_default_public_jids", + room:get_whois() == "anyone") and "anyone" or "moderators"); + room:set_changesubject(module:get_option_boolean("muc_room_default_change_subject", room:get_changesubject())); + room:set_historylength(module:get_option_number("muc_room_default_history_length", room:get_historylength())); + room:set_language(lang or module:get_option_string("muc_room_default_language")); +end + +function create_room(room_jid, config) + local exists = get_room_from_jid(room_jid); + if exists then + return nil, "room-exists"; + end + local room = muclib.new_room(room_jid, config); + if not config then + set_room_defaults(room); + end + module:fire_event("muc-room-created", { + room = room; + }); + return track_room(room); +end + +function all_rooms() + return coroutine.wrap(function () + local seen = {}; -- Don't iterate over persistent rooms twice + for room in live_rooms() do + coroutine.yield(room); + seen[room.jid] = true; + end + local all_persistent_rooms, err = persistent_rooms_storage:get(nil); + if not all_persistent_rooms then + if err then + module:log("error", "Error loading list of persistent rooms, only rooms live in memory were iterated over"); + module:log("debug", "%s", debug.traceback(err)); + end + return nil; + end + for room_jid in pairs(all_persistent_rooms) do + if not seen[room_jid] then + local room = restore_room(room_jid); + if room then + coroutine.yield(room); + else + module:log("error", "Missing data for room '%s', omitting from iteration", room_jid); + end + end + end + end); +end + +function live_rooms() + return rooms:values(); +end + +function each_room(live_only) + if live_only then + return live_rooms(); + end + return all_rooms(); +end + +module:hook("host-disco-items", function(event) + local reply = event.reply; + module:log("debug", "host-disco-items called"); + if next(room_items_cache) ~= nil then + for jid, room_name in pairs(room_items_cache) do + reply:tag("item", { jid = jid, name = room_name }):up(); + end + else + for room in all_rooms() do + if not room:get_hidden() then + local jid, room_name = room.jid, room:get_name(); + room_items_cache[jid] = room_name; + reply:tag("item", { jid = jid, name = room_name }):up(); + end + end + end +end); + +module:hook("muc-room-pre-create", function (event) + set_room_defaults(event.room, event.stanza.attr["xml:lang"]); +end, 1); + +module:hook("muc-room-pre-create", function(event) + local origin, stanza = event.origin, event.stanza; + if not track_room(event.room) then + origin.send(st.error_reply(stanza, "wait", "resource-constraint")); + return true; + end +end, -1000); + +module:hook("muc-room-destroyed",function(event) + local room = event.room; + forget_room(room); + delete_room(room); +end); + +if module:get_option_boolean("muc_tombstones", true) then + + local ttl = module:get_option_number("muc_tombstone_expiry", 86400 * 31); + + module:hook("muc-room-destroyed",function(event) + local room = event.room; + if not room:get_persistent() then return end + if room._data.destroyed then + return -- Allow destruction of tombstone + end + + local tombstone = new_room(room.jid, { + locked = os.time() + ttl; + destroyed = true; + reason = event.reason; + newjid = event.newjid; + -- password? + }); + tombstone.save = room_save; + tombstone:set_persistent(true); + tombstone:set_hidden(true); + tombstone:save(true); + return true; + end, -10); +end + +do + local restrict_room_creation = module:get_option("restrict_room_creation"); + if restrict_room_creation == true then + restrict_room_creation = "admin"; + end + if restrict_room_creation then + local host_suffix = module.host:gsub("^[^%.]+%.", ""); + module:hook("muc-room-pre-create", function(event) + local origin, stanza = event.origin, event.stanza; + local user_jid = stanza.attr.from; + if not is_admin(user_jid) and not ( + restrict_room_creation == "local" and + select(2, jid_split(user_jid)) == host_suffix + ) then + origin.send(st.error_reply(stanza, "cancel", "not-allowed", "Room creation is restricted")); + return true; + end + end); + end +end + +for event_name, method in pairs { + -- Normal room interactions + ["iq-get/bare/http://jabber.org/protocol/disco#info:query"] = "handle_disco_info_get_query" ; + ["iq-get/bare/http://jabber.org/protocol/disco#items:query"] = "handle_disco_items_get_query" ; + ["iq-set/bare/http://jabber.org/protocol/muc#admin:query"] = "handle_admin_query_set_command" ; + ["iq-get/bare/http://jabber.org/protocol/muc#admin:query"] = "handle_admin_query_get_command" ; + ["iq-set/bare/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_set_to_room" ; + ["iq-get/bare/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_get_to_room" ; + ["message/bare"] = "handle_message_to_room" ; + ["presence/bare"] = "handle_presence_to_room" ; + ["iq/bare/jabber:iq:register:query"] = "handle_register_iq"; + ["iq-set/bare/xmpp:vnctalk:unregister:query"] = "handle_unregister_iq"; + -- Host room + ["iq-get/host/http://jabber.org/protocol/disco#info:query"] = "handle_disco_info_get_query" ; + ["iq-get/host/http://jabber.org/protocol/disco#items:query"] = "handle_disco_items_get_query" ; + ["iq-set/host/http://jabber.org/protocol/muc#admin:query"] = "handle_admin_query_set_command" ; + ["iq-get/host/http://jabber.org/protocol/muc#admin:query"] = "handle_admin_query_get_command" ; + ["iq-set/host/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_set_to_room" ; + ["iq-get/host/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_get_to_room" ; + ["message/host"] = "handle_message_to_room" ; + ["presence/host"] = "handle_presence_to_room" ; + -- Direct to occupant (normal rooms and host room) + ["presence/full"] = "handle_presence_to_occupant" ; + ["iq/full"] = "handle_iq_to_occupant" ; + ["message/full"] = "handle_message_to_occupant" ; +} do + module:hook(event_name, function (event) + local origin, stanza = event.origin, event.stanza; + local room_jid = jid_bare(stanza.attr.to); + local room = get_room_from_jid(room_jid); + + if room and room._data.destroyed then + if room._data.locked < os.time() + or (is_admin(stanza.attr.from) and stanza.name == "presence" and stanza.attr.type == nil) then + -- Allow the room to be recreated by admin or after time has passed + delete_room(room); + room = nil; + else + if stanza.attr.type ~= "error" then + local reply = st.error_reply(stanza, "cancel", "gone", room._data.reason) + if room._data.newjid then + local uri = "xmpp:"..room._data.newjid.."?join"; + reply:get_child("error"):child_with_name("gone"):text(uri); + end + event.origin.send(reply); + end + return true; + end + end + + if room == nil then + -- Watch presence to create rooms + if stanza.attr.type == nil and stanza.name == "presence" then + room = muclib.new_room(room_jid); + return room:handle_first_presence(origin, stanza); + elseif stanza.attr.type ~= "error" then + origin.send(st.error_reply(stanza, "cancel", "item-not-found")); + return true; + else + return; + end + elseif room == false then -- Error loading room + origin.send(st.error_reply(stanza, "wait", "resource-constraint")); + return true; + end + return room[method](room, origin, stanza); + end, -2) +end + +function shutdown_component() + for room in live_rooms() do + room:save(nil, true); + end +end +module:hook_global("server-stopping", shutdown_component, -300); + +do -- Ad-hoc commands + module:depends "adhoc"; + local t_concat = table.concat; + local adhoc_new = module:require "adhoc".new; + local adhoc_initial = require "util.adhoc".new_initial_data_form; + local array = require "util.array"; + local dataforms_new = require "util.dataforms".new; + + local destroy_rooms_layout = dataforms_new { + title = "Destroy rooms"; + instructions = "Select the rooms to destroy"; + + { name = "FORM_TYPE", type = "hidden", value = "http://prosody.im/protocol/muc#destroy" }; + { name = "rooms", type = "list-multi", required = true, label = "Rooms to destroy:"}; + }; + + local destroy_rooms_handler = adhoc_initial(destroy_rooms_layout, function() + return { rooms = array.collect(all_rooms()):pluck("jid"):sort(); }; + end, function(fields, errors) + if errors then + local errmsg = {}; + for field, err in pairs(errors) do + errmsg[#errmsg + 1] = field .. ": " .. err; + end + return { status = "completed", error = { message = t_concat(errmsg, "\n") } }; + end + for _, room in ipairs(fields.rooms) do + get_room_from_jid(room):destroy(); + end + return { status = "completed", info = "The following rooms were destroyed:\n"..t_concat(fields.rooms, "\n") }; + end); + local destroy_rooms_desc = adhoc_new("Destroy Rooms", + "http://prosody.im/protocol/muc#destroy", destroy_rooms_handler, "admin"); + + module:provides("adhoc", destroy_rooms_desc); +end diff --git a/patches/mod_muc_unique.lua b/patches/mod_muc_unique.lua new file mode 100644 index 0000000..e07b111 --- /dev/null +++ b/patches/mod_muc_unique.lua @@ -0,0 +1,37 @@ +-- XEP-0307: Unique Room Names for Multi-User Chat +local st = require "util.stanza"; +local unique_name = require "util.id".medium; +module:add_feature "http://jabber.org/protocol/muc#unique" + +module:log("info", "uniqe activated"); + +local function handle_iq(event) + local origin, stanza = event.origin, event.stanza; + local uname = unique_name():lower(); + local origto = stanza.attr.to or nil; + +-- module:log("info", "orig to: %s", origto); +-- module:log("info", "uname: %s", uname); + + + origin.send(st.reply(stanza) + :tag("unique", {xmlns = "http://jabber.org/protocol/muc#unique"}) + :text(unique_name():lower()) + ); + +end; + +local function handle_iq_tobare(event) + local origin, stanza = event.origin, event.stanza; + local uname = unique_name():lower(); + local origto = stanza.attr.to or nil; + +-- module:log("info", "orig to: %s", origto); +-- module:log("info", "uname: %s", uname); + + origin.send(st.error_reply(stanza, "cancel", "item-not-found")); + +end; + +module:hook("iq-get/bare/http://jabber.org/protocol/muc#unique:unique", handle_iq_tobare, 1); +module:hook("iq-get/host/http://jabber.org/protocol/muc#unique:unique", handle_iq, -1); diff --git a/patches/moduleapi.lua b/patches/moduleapi.lua new file mode 100644 index 0000000..6aa6d62 --- /dev/null +++ b/patches/moduleapi.lua @@ -0,0 +1,440 @@ +-- Prosody IM +-- Copyright (C) 2008-2012 Matthew Wild +-- Copyright (C) 2008-2012 Waqas Hussain +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local array = require "util.array"; +local set = require "util.set"; +local it = require "util.iterators"; +local logger = require "util.logger"; +local pluginloader = require "util.pluginloader"; +local timer = require "util.timer"; +local resolve_relative_path = require"util.paths".resolve_relative_path; +local st = require "util.stanza"; + +local t_insert, t_remove, t_concat = table.insert, table.remove, table.concat; +local error, setmetatable, type = error, setmetatable, type; +local ipairs, pairs, select = ipairs, pairs, select; +local tonumber, tostring = tonumber, tostring; +local require = require; +local pack = table.pack or function(...) return {n=select("#",...), ...}; end -- table.pack is only in 5.2 +local unpack = table.unpack or unpack; --luacheck: ignore 113 -- renamed in 5.2 + +local prosody = prosody; +local hosts = prosody.hosts; + +-- FIXME: This assert() is to try and catch an obscure bug (2013-04-05) +local core_post_stanza = assert(prosody.core_post_stanza, + "prosody.core_post_stanza is nil, please report this as a bug"); + +-- Registry of shared module data +local shared_data = setmetatable({}, { __mode = "v" }); + +local NULL = {}; + +local api = {}; + +-- Returns the name of the current module +function api:get_name() + return self.name; +end + +-- Returns the host that the current module is serving +function api:get_host() + return self.host; +end + +function api:get_host_type() + return (self.host == "*" and "global") or hosts[self.host].type or "local"; +end + +function api:set_global() + self.host = "*"; + -- Update the logger + local _log = logger.init("mod_"..self.name); + self.log = function (self, ...) return _log(...); end; --luacheck: ignore self + self._log = _log; + self.global = true; +end + +function api:add_feature(xmlns) + self:add_item("feature", xmlns); +end +function api:add_identity(category, identity_type, name) + self:add_item("identity", {category = category, type = identity_type, name = name}); +end +function api:add_extension(data) + self:add_item("extension", data); +end + +function api:fire_event(...) + return (hosts[self.host] or prosody).events.fire_event(...); +end + +function api:hook_object_event(object, event, handler, priority) + self.event_handlers:set(object, event, handler, true); + return object.add_handler(event, handler, priority); +end + +function api:unhook_object_event(object, event, handler) + self.event_handlers:set(object, event, handler, nil); + return object.remove_handler(event, handler); +end + +function api:hook(event, handler, priority) + return self:hook_object_event((hosts[self.host] or prosody).events, event, handler, priority); +end + +function api:hook_global(event, handler, priority) + return self:hook_object_event(prosody.events, event, handler, priority); +end + +function api:hook_tag(xmlns, name, handler, priority) + if not handler and type(name) == "function" then + -- If only 2 options then they specified no xmlns + xmlns, name, handler, priority = nil, xmlns, name, handler; + elseif not (handler and name) then + self:log("warn", "Error: Insufficient parameters to module:hook_stanza()"); + return; + end + return self:hook("stanza/"..(xmlns and (xmlns..":") or "")..name, + function (data) return handler(data.origin, data.stanza, data); end, priority); +end +api.hook_stanza = api.hook_tag; -- COMPAT w/pre-0.9 + +function api:unhook(event, handler) + return self:unhook_object_event((hosts[self.host] or prosody).events, event, handler); +end + +function api:wrap_object_event(events_object, event, handler) + return self:hook_object_event(assert(events_object.wrappers, "no wrappers"), event, handler); +end + +function api:wrap_event(event, handler) + return self:wrap_object_event((hosts[self.host] or prosody).events, event, handler); +end + +function api:wrap_global(event, handler) + return self:hook_object_event(prosody.events, event, handler); +end + +function api:require(lib) + local f, n = pluginloader.load_code_ext(self.name, lib, "lib.lua", self.environment); + if not f then error("Failed to load plugin library '"..lib.."', error: "..n); end -- FIXME better error message + return f(); +end + +function api:depends(name) + local modulemanager = require"core.modulemanager"; + if self:get_option_inherited_set("modules_disabled", {}):contains(name) then + error("Dependency on disabled module mod_"..name); + end + if not self.dependencies then + self.dependencies = {}; + self:hook("module-reloaded", function (event) + if self.dependencies[event.module] and not self.reloading then + self:log("info", "Auto-reloading due to reload of %s:%s", event.host, event.module); + modulemanager.reload(self.host, self.name); + return; + end + end); + self:hook("module-unloaded", function (event) + if self.dependencies[event.module] then + self:log("info", "Auto-unloading due to unload of %s:%s", event.host, event.module); + modulemanager.unload(self.host, self.name); + end + end); + end + local mod = modulemanager.get_module(self.host, name) or modulemanager.get_module("*", name); + if mod and mod.module.host == "*" and self.host ~= "*" + and modulemanager.module_has_method(mod, "add_host") then + mod = nil; -- Target is a shared module, so we still want to load it on our host + end + if not mod then + local err; + mod, err = modulemanager.load(self.host, name); + if not mod then + return error(("Unable to load required module, mod_%s: %s"):format(name, ((err or "unknown error"):gsub("%-", " ")) )); + end + end + self.dependencies[name] = true; + return mod; +end + +local function get_shared_table_from_path(module, tables, path) + if path:sub(1,1) ~= "/" then -- Prepend default components + local default_path_components = { module.host, module.name }; + local n_components = select(2, path:gsub("/", "%1")); + path = (n_components<#default_path_components and "/" or "") + ..t_concat(default_path_components, "/", 1, #default_path_components-n_components).."/"..path; + end + local shared = tables[path]; + if not shared then + shared = {}; + if path:match("%-cache$") then + setmetatable(shared, { __mode = "kv" }); + end + tables[path] = shared; + end + return shared; +end + +-- Returns a shared table at the specified virtual path +-- Intentionally does not allow the table to be _set_, it +-- is auto-created if it does not exist. +function api:shared(path) + if not self.shared_data then self.shared_data = {}; end + local shared = get_shared_table_from_path(self, shared_data, path); + self.shared_data[path] = shared; + return shared; +end + +function api:get_option(name, default_value) + local config = require "core.configmanager"; + local value = config.get(self.host, name); + if value == nil then + value = default_value; + end + return value; +end + +function api:get_option_scalar(name, default_value) + local value = self:get_option(name, default_value); + if type(value) == "table" then + if #value > 1 then + self:log("error", "Config option '%s' does not take a list, using just the first item", name); + end + value = value[1]; + end + return value; +end + +function api:get_option_string(name, default_value) + local value = self:get_option_scalar(name, default_value); + if value == nil then + return nil; + end + return tostring(value); +end + +function api:get_option_number(name, ...) + local value = self:get_option_scalar(name, ...); + local ret = tonumber(value); + if value ~= nil and ret == nil then + self:log("error", "Config option '%s' not understood, expecting a number", name); + end + return ret; +end + +function api:get_option_boolean(name, ...) + local value = self:get_option_scalar(name, ...); + if value == nil then + return nil; + end + local ret = value == true or value == "true" or value == 1 or nil; + if ret == nil then + ret = (value == false or value == "false" or value == 0); + if ret then + ret = false; + else + ret = nil; + end + end + if ret == nil then + self:log("error", "Config option '%s' not understood, expecting true/false", name); + end + return ret; +end + +function api:get_option_array(name, ...) + local value = self:get_option(name, ...); + + if value == nil then + return nil; + end + + if type(value) ~= "table" then + return array{ value }; -- Assume any non-list is a single-item list + end + + return array():append(value); -- Clone +end + +function api:get_option_set(name, ...) + local value = self:get_option_array(name, ...); + + if value == nil then + return nil; + end + + return set.new(value); +end + +function api:get_option_inherited_set(name, ...) + local value = self:get_option_set(name, ...); + local global_value = self:context("*"):get_option_set(name, ...); + if not value then + return global_value; + elseif not global_value then + return value; + end + value:include(global_value); + return value; +end + +function api:get_option_path(name, default, parent) + if parent == nil then + parent = self:get_directory(); + elseif prosody.paths[parent] then + parent = prosody.paths[parent]; + end + local value = self:get_option_string(name, default); + if value == nil then + return nil; + end + return resolve_relative_path(parent, value); +end + + +function api:context(host) + return setmetatable({host=host or "*"}, {__index=self,__newindex=self}); +end + +function api:add_item(key, value) + self.items = self.items or {}; + self.items[key] = self.items[key] or {}; + t_insert(self.items[key], value); + self:fire_event("item-added/"..key, {source = self, item = value}); +end +function api:remove_item(key, value) + local t = self.items and self.items[key] or NULL; + for i = #t,1,-1 do + if t[i] == value then + t_remove(self.items[key], i); + self:fire_event("item-removed/"..key, {source = self, item = value}); + return value; + end + end +end + +function api:get_host_items(key) + local modulemanager = require"core.modulemanager"; + local result = modulemanager.get_items(key, self.host) or {}; + return result; +end + +function api:handle_items(item_type, added_cb, removed_cb, existing) + self:hook("item-added/"..item_type, added_cb); + self:hook("item-removed/"..item_type, removed_cb); + if existing ~= false then + for _, item in ipairs(self:get_host_items(item_type)) do + added_cb({ item = item }); + end + end +end + +function api:provides(name, item) + -- if not item then item = setmetatable({}, { __index = function(t,k) return rawget(self.environment, k); end }); end + if not item then + item = {} + for k,v in pairs(self.environment) do + if k ~= "module" then item[k] = v; end + end + end + if not item.name then + local item_name = self.name; + -- Strip a provider prefix to find the item name + -- (e.g. "auth_foo" -> "foo" for an auth provider) + if item_name:find(name.."_", 1, true) == 1 then + item_name = item_name:sub(#name+2); + end + item.name = item_name; + end + item._provided_by = self.name; + self:add_item(name.."-provider", item); +end + +function api:send(stanza, origin) + return core_post_stanza(origin or hosts[self.host], stanza); +end + +function api:broadcast(jids, stanza, iter) + for jid in (iter or it.values)(jids) do + local new_stanza = st.clone(stanza); + new_stanza.attr.to = jid; + self:send(new_stanza); + end +end + +local timer_methods = { } +local timer_mt = { + __index = timer_methods; +} +function timer_methods:stop( ) + timer.stop(self.id); +end +timer_methods.disarm = timer_methods.stop +function timer_methods:reschedule(delay) + timer.reschedule(self.id, delay) +end + +local function timer_callback(now, id, t) --luacheck: ignore 212/id + if t.module_env.loaded == false then return; end + return t.callback(now, unpack(t, 1, t.n)); +end + +function api:add_timer(delay, callback, ...) + local t = pack(...) + t.module_env = self; + t.callback = callback; + t.id = timer.add_task(delay, timer_callback, t); + return setmetatable(t, timer_mt); +end + +local path_sep = package.config:sub(1,1); +function api:get_directory() + return self.path and (self.path:gsub("%"..path_sep.."[^"..path_sep.."]*$", "")) or nil; +end + +function api:load_resource(path, mode) + path = resolve_relative_path(self:get_directory(), path); + return io.open(path, mode); +end + +function api:open_store(name, store_type) + return require"core.storagemanager".open(self.host, name or self.name, store_type); +end + +function api:open_host_store(host, name, store_type) + return require"core.storagemanager".open(host, name or self.name, store_type); +end + + +function api:measure(name, stat_type) + local measure = require "core.statsmanager".measure; + return measure(stat_type, "/"..self.host.."/mod_"..self.name.."/"..name); +end + +function api:measure_object_event(events_object, event_name, stat_name) + local m = self:measure(stat_name or event_name, "times"); + local function handler(handlers, _event_name, _event_data) + local finished = m(); + local ret = handlers(_event_name, _event_data); + finished(); + return ret; + end + return self:hook_object_event(events_object, event_name, handler); +end + +function api:measure_event(event_name, stat_name) + return self:measure_object_event((hosts[self.host] or prosody).events.wrappers, event_name, stat_name); +end + +function api:measure_global_event(event_name, stat_name) + return self:measure_object_event(prosody.events.wrappers, event_name, stat_name); +end + +return api; diff --git a/patches/muc.lib.lua b/patches/muc.lib.lua new file mode 100644 index 0000000..5d100be --- /dev/null +++ b/patches/muc.lib.lua @@ -0,0 +1,1604 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- Copyright (C) 2014 Daurnimator +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local select = select; +local pairs = pairs; +local next = next; +local setmetatable = setmetatable; + +local dataform = require "util.dataforms"; +local iterators = require "util.iterators"; +local jid_split = require "util.jid".split; +local jid_bare = require "util.jid".bare; +local jid_prep = require "util.jid".prep; +local jid_join = require "util.jid".join; +local jid_resource = require "util.jid".resource; +local resourceprep = require "util.encodings".stringprep.resourceprep; +local st = require "util.stanza"; +local base64 = require "util.encodings".base64; +local md5 = require "util.hashes".md5; + +local log = module._log; + +local occupant_lib = module:require "muc/occupant" +local muc_util = module:require "muc/util"; +local is_kickable_error = muc_util.is_kickable_error; +local valid_roles, valid_affiliations = muc_util.valid_roles, muc_util.valid_affiliations; + +local function dumpTable(t, depth) + local result = ""; + if (not depth) then depth = 0 end; + + local prefix = string.rep(" ", depth); + + if (type(t) ~= "table") then + result = result.." "..tostring(t); + else + for key,value in pairs(t) do + result = result..prefix..tostring(key).." => "..dumpTable(value, depth+1).."\n" + end + end + + return result; +end + +local function table_clone(org) + return {table.unpack(org)} +end + +local room_mt = {}; +room_mt.__index = room_mt; + +function room_mt:__tostring() + return "MUC room ("..self.jid..")"; +end + +function room_mt.save() + -- overriden by mod_muc.lua +end + +function room_mt:get_occupant_jid(real_jid) + return self._jid_nick[real_jid] +end + +function room_mt:get_default_role(affiliation) + local role = module:fire_event("muc-get-default-role", { + room = self; + affiliation = affiliation; + affiliation_rank = valid_affiliations[affiliation or "none"]; + }); + role = role ~= "none" and role or nil; -- coerces `role == false` to `nil` + return role, valid_roles[role or "none"]; +end +module:hook("muc-get-default-role", function(event) + if event.affiliation_rank >= valid_affiliations.admin then + return "moderator"; + elseif event.affiliation_rank >= valid_affiliations.none then + return "participant"; + end +end, -1); + +--- Occupant functions +function room_mt:new_occupant(bare_real_jid, nick) + local occupant = occupant_lib.new(bare_real_jid, nick); + local affiliation = self:get_affiliation(bare_real_jid); + occupant.role = self:get_default_role(affiliation); + return occupant; +end + +-- nick is in the form of an in-room JID +function room_mt:get_occupant_by_nick(nick) + local occupant = self._occupants[nick]; + if occupant == nil then return nil end + return occupant_lib.copy(occupant); +end + +do + local function next_copied_occupant(occupants, occupant_jid) + local next_occupant_jid, raw_occupant = next(occupants, occupant_jid); + if next_occupant_jid == nil then return nil end + return next_occupant_jid, occupant_lib.copy(raw_occupant); + end + -- FIXME Explain what 'read_only' is supposed to be + function room_mt:each_occupant(read_only) -- luacheck: ignore 212 + return next_copied_occupant, self._occupants, nil; + end +end + +function room_mt:has_occupant() + return next(self._occupants, nil) ~= nil +end + +function room_mt:get_occupant_by_real_jid(real_jid) + local occupant_jid = self:get_occupant_jid(real_jid); + if occupant_jid == nil then return nil end + return self:get_occupant_by_nick(occupant_jid); +end + +function room_mt:save_occupant(occupant) + occupant = occupant_lib.copy(occupant); -- So that occupant can be modified more + local id = occupant.nick + + -- Need to maintain _jid_nick secondary index + local old_occupant = self._occupants[id]; + if old_occupant then + for real_jid in old_occupant:each_session() do + self._jid_nick[real_jid] = nil; + end + end + + local has_live_session = false + if occupant.role ~= nil then + for real_jid, presence in occupant:each_session() do + if presence.attr.type == nil then + has_live_session = true + self._jid_nick[real_jid] = occupant.nick; + end + end + if not has_live_session then + -- Has no live sessions left; they have left the room. + occupant.role = nil + end + end + if not has_live_session then + occupant = nil + end + self._occupants[id] = occupant + return occupant +end + +function room_mt:route_to_occupant(occupant, stanza) + local to = stanza.attr.to; + for jid in occupant:each_session() do + stanza.attr.to = jid; + self:route_stanza(stanza); + end + stanza.attr.to = to; +end + +-- actor is the attribute table +local function add_item(x, affiliation, role, jid, nick, actor_nick, actor_jid, reason) + x:tag("item", {affiliation = affiliation or "none"; role = role; jid = jid; nick = nick;}) + if actor_nick or actor_jid then + x:tag("actor", {nick = actor_nick; jid = actor_jid;}):up() + end + if reason then + x:tag("reason"):text(reason):up() + end + x:up(); + return x +end + +-- actor is (real) jid +function room_mt:build_item_list(occupant, x, is_anonymous, nick, actor_nick, actor_jid, reason) + local affiliation = self:get_affiliation(occupant.bare_jid) or "none"; + local role = occupant.role or "none"; + if is_anonymous then + add_item(x, affiliation, role, nil, nick, actor_nick, actor_jid, reason); + else + for real_jid in occupant:each_session() do + add_item(x, affiliation, role, real_jid, nick, actor_nick, actor_jid, reason); + end + end + return x +end + +function room_mt:broadcast_message(stanza) + if module:fire_event("muc-broadcast-message", {room = self, stanza = stanza}) then + return true; + end + self:broadcast(stanza); + return true; +end + +-- Strip delay tags claiming to be from us +module:hook("muc-occupant-groupchat", function (event) + local stanza = event.stanza; + local room = event.room; + local room_jid = room.jid; + + stanza:maptags(function (child) + if child.name == "delay" and child.attr["xmlns"] == "urn:xmpp:delay" then + if child.attr["from"] == room_jid then + return nil; + end + end + if child.name == "x" and child.attr["xmlns"] == "jabber:x:delay" then + if child.attr["from"] == room_jid then + return nil; + end + end + return child; + end) +end); + +-- Broadcast a stanza to all occupants in the room. +-- optionally checks conditional called with (nick, occupant) +function room_mt:broadcast(stanza, cond_func) + local xaffs = {}; + if self._affiliations then + for _xaff, _xrol in pairs(self._affiliations) do + xaffs[_xaff] = true; + end + end + + log("info", "cloned xaffs is %s", dumpTable(xaffs)); + for nick, occupant in self:each_occupant() do + xaffs[occupant.bare_jid] = nil; + if cond_func == nil or cond_func(nick, occupant) then + self:route_to_occupant(occupant, stanza) + end + end + + log("info", "remaining xaffs is %s", dumpTable(xaffs)); + for offlineaff, orol in pairs(xaffs) do + local ouser, ohost = jid_split(offlineaff); + if not (prosody.hosts[ohost]) then + log("debug", "need to send to remote offline user: %s", offlineaff); + stanza.attr.to = offlineaff; + self:route_stanza(stanza); + end + end + + +end + +local function can_see_real_jids(whois, occupant) + if whois == "anyone" then + return true; + elseif whois == "moderators" then + return valid_roles[occupant.role or "none"] >= valid_roles.moderator; + end +end + +-- Broadcasts an occupant's presence to the whole room +-- Takes the x element that goes into the stanzas +function room_mt:publicise_occupant_status(occupant, x, nick, actor, reason) + local base_x = x.base or x; + -- Build real jid and (optionally) occupant jid template presences + local base_presence do + -- Try to use main jid's presence + local pr = occupant:get_presence(); + if pr and (occupant.role ~= nil or pr.attr.type == "unavailable") then + base_presence = st.clone(pr); + else -- user is leaving but didn't send a leave presence. make one for them + base_presence = st.presence {from = occupant.nick; type = "unavailable";}; + end + end + + -- Fire event (before full_p and anon_p are created) + local event = { + room = self; stanza = base_presence; x = base_x; + occupant = occupant; nick = nick; actor = actor; + reason = reason; + } + module:fire_event("muc-broadcast-presence", event); + + -- Allow muc-broadcast-presence listeners to change things + nick = event.nick; + actor = event.actor; + reason = event.reason; + + local whois = self:get_whois(); + + local actor_nick; + if actor then + actor_nick = jid_resource(self:get_occupant_jid(actor)); + end + + local full_p, full_x; + local function get_full_p() + if full_p == nil then + full_x = st.clone(x.full or base_x); + self:build_item_list(occupant, full_x, false, nick, actor_nick, actor, reason); + full_p = st.clone(base_presence):add_child(full_x); + end + return full_p, full_x; + end + + local anon_p, anon_x; + local function get_anon_p() + if anon_p == nil then + anon_x = st.clone(x.anon or base_x); + self:build_item_list(occupant, anon_x, true, nick, actor_nick, nil, reason); + anon_p = st.clone(base_presence):add_child(anon_x); + end + return anon_p, anon_x; + end + + local self_p, self_x; + do + -- Can always see your own full jids + -- But not allowed to see actor's + self_x = st.clone(x.self or base_x); + self:build_item_list(occupant, self_x, false, nick, actor_nick, nil, reason); + self_p = st.clone(base_presence):add_child(self_x); + end + + -- General populance + for occupant_nick, n_occupant in self:each_occupant() do + if occupant_nick ~= occupant.nick then + local pr; + if can_see_real_jids(whois, n_occupant) then + pr = get_full_p(); + elseif occupant.bare_jid == n_occupant.bare_jid then + pr = self_p; + else + pr = get_anon_p(); + end + self:route_to_occupant(n_occupant, pr); + end + end + + -- Presences for occupant itself + self_x:tag("status", {code = "110";}):up(); + if occupant.role == nil then + -- They get an unavailable + log("info", "skipping unavailable presence"); + -- self:route_to_occupant(occupant, self_p); + else + -- use their own presences as templates + for full_jid, pr in occupant:each_session() do + pr = st.clone(pr); + pr.attr.to = full_jid; + pr:add_child(self_x); + self:route_stanza(pr); + end + end +end + +function room_mt:send_occupant_list(to, filter) + local to_bare = jid_bare(to); + local is_anonymous = false; + local whois = self:get_whois(); + if whois ~= "anyone" then + local affiliation = self:get_affiliation(to); + if affiliation ~= "admin" and affiliation ~= "owner" then + local occupant = self:get_occupant_by_real_jid(to); + if not (occupant and can_see_real_jids(whois, occupant)) then + is_anonymous = true; + end + end + end + for occupant_jid, occupant in self:each_occupant() do + if filter == nil or filter(occupant_jid, occupant) then + local x = st.stanza("x", {xmlns='http://jabber.org/protocol/muc#user'}); + self:build_item_list(occupant, x, is_anonymous and to_bare ~= occupant.bare_jid); -- can always see your own jids + local pres = st.clone(occupant:get_presence()); + pres.attr.to = to; + pres:add_child(x); + self:route_stanza(pres); + end + end +end + +function room_mt:get_disco_info(stanza) + local node = stanza.tags[1].attr.node or ""; + local reply = st.reply(stanza):tag("query", { xmlns = "http://jabber.org/protocol/disco#info", node = node }); + local event_name = "muc-disco#info"; + local event_data = { room = self, reply = reply, stanza = stanza }; + + if node ~= "" then + event_name = event_name.."/"..node; + else + event_data.form = dataform.new { + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/muc#roominfo" }; + }; + event_data.formdata = {}; + end + module:fire_event(event_name, event_data); + if event_data.form then + reply:add_child(event_data.form:form(event_data.formdata, "result")); + end + return reply; +end +module:hook("muc-disco#info", function(event) + event.reply:tag("feature", {var = "http://jabber.org/protocol/muc"}):up(); + event.reply:tag("feature", {var = "http://jabber.org/protocol/muc#stable_id"}):up(); + event.reply:tag("feature", {var = "http://jabber.org/protocol/muc#self-ping-optimization"}):up(); +end); +module:hook("muc-disco#info", function(event) + table.insert(event.form, { name = "muc#roominfo_occupants", label = "Number of occupants" }); + event.formdata["muc#roominfo_occupants"] = tostring(iterators.count(event.room:each_occupant())); +end); + +function room_mt:get_disco_items(stanza) -- luacheck: ignore 212 + return st.reply(stanza):query("http://jabber.org/protocol/disco#items"); +end + +function room_mt:handle_kickable(origin, stanza) -- luacheck: ignore 212 + local real_jid = stanza.attr.from; + local occupant = self:get_occupant_by_real_jid(real_jid); + if occupant == nil then return nil; end + local type, condition, text = stanza:get_error(); + local error_message = "Kicked: "..(condition and condition:gsub("%-", " ") or "presence error"); + if text and self:get_whois() == "anyone" then + error_message = error_message..": "..text; + end + occupant:set_session(real_jid, st.presence({type="unavailable"}) + :tag('status'):text(error_message)); + local is_last_session = occupant.jid == real_jid; + if is_last_session then + occupant.role = nil; + end + local new_occupant = self:save_occupant(occupant); + local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";}); + if is_last_session then + x:tag("status", {code = "333"}); + end + self:publicise_occupant_status(new_occupant or occupant, x); + if is_last_session then + module:fire_event("muc-occupant-left", {room = self; nick = occupant.nick; occupant = occupant;}); + end + return true; +end + +-- Give the room creator owner affiliation +module:hook("muc-room-pre-create", function(event) + event.room:set_affiliation(true, jid_bare(event.stanza.attr.from), "owner"); +end, -1); + +-- check if user is banned +module:hook("muc-occupant-pre-join", function(event) + local room, stanza = event.room, event.stanza; + local affiliation = room:get_affiliation(stanza.attr.from); + if affiliation == "outcast" then + local reply = st.error_reply(stanza, "auth", "forbidden"):up(); + reply.tags[1].attr.code = "403"; + event.origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); + return true; + end +end, -10); + +module:hook("muc-occupant-pre-join", function(event) + local nick = jid_resource(event.occupant.nick); + if not nick:find("%S") then + event.origin.send(st.error_reply(event.stanza, "modify", "not-allowed", "Invisible Nicknames are forbidden")); + return true; + end +end, 1); + +module:hook("muc-occupant-pre-change", function(event) + if not jid_resource(event.dest_occupant.nick):find("%S") then + event.origin.send(st.error_reply(event.stanza, "modify", "not-allowed", "Invisible Nicknames are forbidden")); + return true; + end +end, 1); + +function room_mt:handle_first_presence(origin, stanza) + if not stanza:get_child("x", "http://jabber.org/protocol/muc") then + module:log("debug", "Room creation without , possibly desynced"); + + origin.send(st.error_reply(stanza, "cancel", "item-not-found")); + return true; + end + + local real_jid = stanza.attr.from; + local dest_jid = stanza.attr.to; + local bare_jid = jid_bare(real_jid); + if module:fire_event("muc-room-pre-create", { + room = self; + origin = origin; + stanza = stanza; + }) then return true; end + local is_first_dest_session = true; + local dest_occupant = self:new_occupant(bare_jid, dest_jid); + + local orig_nick = dest_occupant.nick; + if module:fire_event("muc-occupant-pre-join", { + room = self; + origin = origin; + stanza = stanza; + is_first_session = is_first_dest_session; + is_new_room = true; + occupant = dest_occupant; + }) then return true; end + local nick_changed = orig_nick ~= dest_occupant.nick; + + dest_occupant:set_session(real_jid, stanza); + local dest_x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";}); + dest_x:tag("status", {code = "201"}):up(); + if self:get_whois() == "anyone" then + dest_x:tag("status", {code = "100"}):up(); + end + if nick_changed then + dest_x:tag("status", {code = "210"}):up(); + end + self:save_occupant(dest_occupant); + + self:publicise_occupant_status(dest_occupant, dest_x); + + module:fire_event("muc-occupant-joined", { + room = self; + nick = dest_occupant.nick; + occupant = dest_occupant; + stanza = stanza; + origin = origin; + }); + module:fire_event("muc-occupant-session-new", { + room = self; + nick = dest_occupant.nick; + occupant = dest_occupant; + stanza = stanza; + origin = origin; + jid = real_jid; + }); + module:fire_event("muc-room-created", { + room = self; + creator = dest_occupant; + stanza = stanza; + origin = origin; + }); + return true; +end + +function room_mt:handle_normal_presence(origin, stanza) + local type = stanza.attr.type; + local real_jid = stanza.attr.from; + local bare_jid = jid_bare(real_jid); + local orig_occupant = self:get_occupant_by_real_jid(real_jid); + local muc_x = stanza:get_child("x", "http://jabber.org/protocol/muc"); + + if orig_occupant == nil and not muc_x and stanza.attr.type == nil then + module:log("debug", "Attempted join without , possibly desynced"); + origin.send(st.error_reply(stanza, "cancel", "item-not-found", + "You must join the room before sending presence updates")); + return true; + end + + local is_first_dest_session; + local dest_occupant; + if type == "unavailable" then + if orig_occupant == nil then return true; end -- Unavailable from someone not in the room + -- dest_occupant = nil + elseif orig_occupant and orig_occupant.nick == stanza.attr.to then -- Just a presence update + log("debug", "presence update for %s from session %s", orig_occupant.nick, real_jid); + dest_occupant = orig_occupant; + else + local dest_jid = stanza.attr.to; + dest_occupant = self:get_occupant_by_nick(dest_jid); + if dest_occupant == nil then + log("debug", "no occupant found for %s; creating new occupant object for %s", dest_jid, real_jid); + is_first_dest_session = true; + dest_occupant = self:new_occupant(bare_jid, dest_jid); + if orig_occupant then + dest_occupant.role = orig_occupant.role; + end + else + is_first_dest_session = false; + end + end + local is_last_orig_session; + if orig_occupant ~= nil then + -- Is there are least 2 sessions? + local iter, ob, last = orig_occupant:each_session(); + is_last_orig_session = iter(ob, iter(ob, last)) == nil; + end + + local orig_nick = dest_occupant and dest_occupant.nick; + + local event, event_name = { + room = self; + origin = origin; + stanza = stanza; + is_first_session = is_first_dest_session; + is_last_session = is_last_orig_session; + }; + if orig_occupant == nil then + event_name = "muc-occupant-pre-join"; + event.occupant = dest_occupant; + elseif dest_occupant == nil then + event_name = "muc-occupant-pre-leave"; + event.occupant = orig_occupant; + else + event_name = "muc-occupant-pre-change"; + event.orig_occupant = orig_occupant; + event.dest_occupant = dest_occupant; + end + if module:fire_event(event_name, event) then return true; end + + local nick_changed = dest_occupant and orig_nick ~= dest_occupant.nick; + + -- Check for nick conflicts + if dest_occupant ~= nil and not is_first_dest_session + and bare_jid ~= jid_bare(dest_occupant.bare_jid) then + -- new nick or has different bare real jid + log("debug", "%s couldn't join due to nick conflict: %s", real_jid, dest_occupant.nick); + local reply = st.error_reply(stanza, "cancel", "conflict"):up(); + reply.tags[1].attr.code = "409"; + origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); + return true; + end + + -- Send presence stanza about original occupant + if orig_occupant ~= nil and orig_occupant ~= dest_occupant then + local orig_x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";}); + local dest_nick; + if dest_occupant == nil then -- Session is leaving + log("debug", "session %s is leaving occupant %s", real_jid, orig_occupant.nick); + if is_last_orig_session then + orig_occupant.role = nil; + end + orig_occupant:set_session(real_jid, stanza); + else + log("debug", "session %s is changing from occupant %s to %s", real_jid, orig_occupant.nick, dest_occupant.nick); + local generated_unavail = st.presence {from = orig_occupant.nick, to = real_jid, type = "unavailable"}; + orig_occupant:set_session(real_jid, generated_unavail); + dest_nick = jid_resource(dest_occupant.nick); + if not is_first_dest_session then -- User is swapping into another pre-existing session + log("debug", "session %s is swapping into multisession %s, showing it leave.", real_jid, dest_occupant.nick); + -- Show the other session leaving + local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";}); + add_item(x, self:get_affiliation(bare_jid), "none"); + local pr = st.presence{from = dest_occupant.nick, to = real_jid, type = "unavailable"} + :tag("status"):text("you are joining pre-existing session " .. dest_nick):up() + :add_child(x); + self:route_stanza(pr); + end + if is_first_dest_session and is_last_orig_session then -- Normal nick change + log("debug", "no sessions in %s left; publicly marking as nick change", orig_occupant.nick); + orig_x:tag("status", {code = "303";}):up(); + else -- The session itself always needs to see a nick change + -- don't want to get our old nick's available presence, + -- so remove our session from there, and manually generate an unavailable + orig_occupant:remove_session(real_jid); + log("debug", "generating nick change for %s", real_jid); + local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";}); + -- COMPAT: clients get confused if they see other items besides their own + -- self:build_item_list(orig_occupant, x, false, dest_nick); + add_item(x, self:get_affiliation(bare_jid), orig_occupant.role, real_jid, dest_nick); + x:tag("status", {code = "303";}):up(); + x:tag("status", {code = "110";}):up(); + self:route_stanza(generated_unavail:add_child(x)); + dest_nick = nil; -- set dest_nick to nil; so general populance doesn't see it for whole orig_occupant + end + end + + self:save_occupant(orig_occupant); + self:publicise_occupant_status(orig_occupant, orig_x, dest_nick); + + if is_last_orig_session then + module:fire_event("muc-occupant-left", { + room = self; + nick = orig_occupant.nick; + occupant = orig_occupant; + origin = origin; + stanza = stanza; + }); + end + end + + if dest_occupant ~= nil then + dest_occupant:set_session(real_jid, stanza); + self:save_occupant(dest_occupant); + + if orig_occupant == nil or muc_x then + -- Send occupant list to newly joined or desynced user + self:send_occupant_list(real_jid, function(nick, occupant) -- luacheck: ignore 212 + -- Don't include self + return occupant:get_presence(real_jid) == nil; + end) + end + local dest_x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";}); + local self_x = st.clone(dest_x); + if orig_occupant == nil and self:get_whois() == "anyone" then + self_x:tag("status", {code = "100"}):up(); + end + if nick_changed then + self_x:tag("status", {code="210"}):up(); + end + self:publicise_occupant_status(dest_occupant, {base=dest_x,self=self_x}); + + if orig_occupant ~= nil and orig_occupant ~= dest_occupant and not is_last_orig_session then + -- If user is swapping and wasn't last original session + log("debug", "session %s split nicks; showing %s rejoining", real_jid, orig_occupant.nick); + -- Show the original nick joining again + local pr = st.clone(orig_occupant:get_presence()); + pr.attr.to = real_jid; + local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";}); + self:build_item_list(orig_occupant, x, false); + -- TODO: new status code to inform client this was the multi-session it left? + pr:add_child(x); + self:route_stanza(pr); + end + + if orig_occupant == nil or muc_x then + if is_first_dest_session then + module:fire_event("muc-occupant-joined", { + room = self; + nick = dest_occupant.nick; + occupant = dest_occupant; + stanza = stanza; + origin = origin; + }); + end + module:fire_event("muc-occupant-session-new", { + room = self; + nick = dest_occupant.nick; + occupant = dest_occupant; + stanza = stanza; + origin = origin; + jid = real_jid; + }); + end + end + return true; +end + +function room_mt:handle_presence_to_occupant(origin, stanza) + local type = stanza.attr.type; + if type == "error" then -- error, kick em out! + return self:handle_kickable(origin, stanza) + elseif type == nil or type == "unavailable" then + return self:handle_normal_presence(origin, stanza); + elseif type ~= 'result' then -- bad type + if type ~= 'visible' and type ~= 'invisible' then -- COMPAT ejabberd can broadcast or forward XEP-0018 presences + origin.send(st.error_reply(stanza, "modify", "bad-request")); -- FIXME correct error? + end + end + return true; +end + +function room_mt:handle_iq_to_occupant(origin, stanza) + local from, to = stanza.attr.from, stanza.attr.to; + local type = stanza.attr.type; + local id = stanza.attr.id; + local occupant = self:get_occupant_by_nick(to); + if (type == "error" or type == "result") then + do -- deconstruct_stanza_id + if not occupant then return nil; end + local from_jid, orig_id, to_jid_hash = (base64.decode(id) or ""):match("^(%Z+)%z(%Z*)%z(.+)$"); + if not(from == from_jid or from == jid_bare(from_jid)) then return nil; end + local from_occupant_jid = self:get_occupant_jid(from_jid); + if from_occupant_jid == nil then return nil; end + local session_jid + for to_jid in occupant:each_session() do + if md5(to_jid) == to_jid_hash then + session_jid = to_jid; + break; + end + end + if session_jid == nil then return nil; end + stanza.attr.from, stanza.attr.to, stanza.attr.id = from_occupant_jid, session_jid, orig_id; + end + log("debug", "%s sent private iq stanza to %s (%s)", from, to, stanza.attr.to); + self:route_stanza(stanza); + stanza.attr.from, stanza.attr.to, stanza.attr.id = from, to, id; + return true; + else -- Type is "get" or "set" + local current_nick = self:get_occupant_jid(from); + if not current_nick then + origin.send(st.error_reply(stanza, "cancel", "not-acceptable", "You are not currently connected to this chat")); + return true; + end + if not occupant then -- recipient not in room + origin.send(st.error_reply(stanza, "cancel", "item-not-found", "Recipient not in room")); + return true; + end + -- XEP-0410 MUC Self-Ping #1220 + if to == current_nick and stanza.attr.type == "get" and stanza:get_child("ping", "urn:xmpp:ping") then + self:route_stanza(st.reply(stanza)); + return true; + end + do -- construct_stanza_id + stanza.attr.id = base64.encode(occupant.jid.."\0"..stanza.attr.id.."\0"..md5(from)); + end + stanza.attr.from, stanza.attr.to = current_nick, occupant.jid; + log("debug", "%s sent private iq stanza to %s (%s)", from, to, occupant.jid); + local iq_ns = stanza.tags[1].attr.xmlns; + if iq_ns == 'vcard-temp' or iq_ns == "http://jabber.org/protocol/pubsub" or iq_ns == "urn:ietf:params:xml:ns:vcard-4.0" then + stanza.attr.to = jid_bare(stanza.attr.to); + end + self:route_stanza(stanza); + stanza.attr.from, stanza.attr.to, stanza.attr.id = from, to, id; + return true; + end +end + +function room_mt:handle_message_to_occupant(origin, stanza) + local from, to = stanza.attr.from, stanza.attr.to; + local current_nick = self:get_occupant_jid(from); + local type = stanza.attr.type; + if not current_nick then -- not in room + if type ~= "error" then + origin.send(st.error_reply(stanza, "cancel", "not-acceptable", "You are not currently connected to this chat")); + end + return true; + end + if type == "groupchat" then -- groupchat messages not allowed in PM + origin.send(st.error_reply(stanza, "modify", "bad-request")); + return true; + elseif type == "error" and is_kickable_error(stanza) then + log("debug", "%s kicked from %s for sending an error message", current_nick, self.jid); + return self:handle_kickable(origin, stanza); -- send unavailable + end + + local o_data = self:get_occupant_by_nick(to); + if not o_data then + origin.send(st.error_reply(stanza, "cancel", "item-not-found", "Recipient not in room")); + return true; + end + log("debug", "%s sent private message stanza to %s (%s)", from, to, o_data.jid); + stanza = muc_util.filter_muc_x(st.clone(stanza)); + stanza:tag("x", { xmlns = "http://jabber.org/protocol/muc#user" }):up(); + stanza.attr.from = current_nick; + self:route_to_occupant(o_data, stanza) + -- TODO: Remove x tag? + stanza.attr.from = from; + return true; +end + +function room_mt:send_form(origin, stanza) + origin.send(st.reply(stanza):query("http://jabber.org/protocol/muc#owner") + :add_child(self:get_form_layout(stanza.attr.from):form()) + ); +end + +function room_mt:get_form_layout(actor) + local form = dataform.new({ + title = "Configuration for "..self.jid, + instructions = "Complete and submit this form to configure the room.", + { + name = 'FORM_TYPE', + type = 'hidden', + value = 'http://jabber.org/protocol/muc#roomconfig' + } + }); + return module:fire_event("muc-config-form", { room = self, actor = actor, form = form }) or form; +end + +function room_mt:process_form(origin, stanza) + local form = stanza.tags[1]:get_child("x", "jabber:x:data"); + if form.attr.type == "cancel" then + origin.send(st.reply(stanza)); + elseif form.attr.type == "submit" then + local fields, errors, present; + if form.tags[1] == nil then -- Instant room + fields, present = {}, {}; + else + fields, errors, present = self:get_form_layout(stanza.attr.from):data(form); + if fields.FORM_TYPE ~= "http://jabber.org/protocol/muc#roomconfig" then + origin.send(st.error_reply(stanza, "cancel", "bad-request", "Form is not of type room configuration")); + return true; + end + end + + local event = { + room = self; + origin = origin; + stanza = stanza; + fields = fields; + status_codes = {}; + actor = stanza.attr.from; + }; + function event.update_option(name, field, allowed) + local new = fields[field]; + if new == nil then return; end + if allowed and not allowed[new] then return; end + if new == self["get_"..name](self) then return; end + event.status_codes["104"] = true; + self["set_"..name](self, new); + return true; + end + module:fire_event("muc-config-submitted", event); + module:fire_event("muc-config-sub-mitted", event); + for submitted_field in pairs(present) do + event.field, event.value = submitted_field, fields[submitted_field]; + module:fire_event("muc-config-submitted/"..submitted_field, event); + end + event.field, event.value = nil, nil; + + self:save(true); + origin.send(st.reply(stanza)); + + if next(event.status_codes) then + local msg = st.message({type='groupchat', from=self.jid}) + :tag('x', {xmlns='http://jabber.org/protocol/muc#user'}) + for code in pairs(event.status_codes) do + msg:tag("status", {code = code;}):up(); + end + -- SSA add config here? + -- :add_child(self:get_form_layout(stanza.attr.from):form()) + msg:up(); + self:broadcast_message(msg); + end + else + origin.send(st.error_reply(stanza, "cancel", "bad-request", "Not a submitted form")); + end + return true; +end + +-- Removes everyone from the room +function room_mt:clear(x) + x = x or st.stanza("x", {xmlns='http://jabber.org/protocol/muc#user'}); + local occupants_updated = {}; + for nick, occupant in self:each_occupant() do -- luacheck: ignore 213 + occupant.role = nil; + self:save_occupant(occupant); + occupants_updated[occupant] = true; + end + for occupant in pairs(occupants_updated) do + self:publicise_occupant_status(occupant, x); + module:fire_event("muc-occupant-left", { room = self; nick = occupant.nick; occupant = occupant;}); + end +end + +function room_mt:destroy(newjid, reason, password) + local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user"}) + :tag("destroy", {jid=newjid}); + if reason then x:tag("reason"):text(reason):up(); end + if password then x:tag("password"):text(password):up(); end + x:up(); + self.destroying = reason or true; + self:clear(x); + module:fire_event("muc-room-destroyed", { room = self, reason = reason, newjid = newjid, password = password }); + return true; +end + +function room_mt:handle_disco_info_get_query(origin, stanza) + origin.send(self:get_disco_info(stanza)); + return true; +end + +function room_mt:handle_disco_items_get_query(origin, stanza) + origin.send(self:get_disco_items(stanza)); + return true; +end + +function room_mt:handle_admin_query_set_command(origin, stanza) + local item = stanza.tags[1].tags[1]; + if not item then + origin.send(st.error_reply(stanza, "cancel", "bad-request")); + return true; + end + if item.attr.jid then -- Validate provided JID + item.attr.jid = jid_prep(item.attr.jid); + if not item.attr.jid then + origin.send(st.error_reply(stanza, "modify", "jid-malformed")); + return true; + end + end + if item.attr.nick then -- Validate provided nick + item.attr.nick = resourceprep(item.attr.nick); + if not item.attr.nick then + origin.send(st.error_reply(stanza, "modify", "jid-malformed", "invalid nickname")); + return true; + end + end + if not item.attr.jid and item.attr.nick then + -- COMPAT Workaround for Miranda sending 'nick' instead of 'jid' when changing affiliation + local occupant = self:get_occupant_by_nick(self.jid.."/"..item.attr.nick); + if occupant then item.attr.jid = occupant.bare_jid; end + elseif item.attr.role and not item.attr.nick and item.attr.jid then + -- Role changes should use nick, but we have a JID so pull the nick from that + local nick = self:get_occupant_jid(item.attr.jid); + if nick then item.attr.nick = jid_resource(nick); end + end + local actor = stanza.attr.from; + local reason = item:get_child_text("reason"); + local success, errtype, err + if item.attr.affiliation and item.attr.jid and not item.attr.role then + local registration_data; + if item.attr.nick then + local room_nick = self.jid.."/"..item.attr.nick; + local existing_occupant = self:get_occupant_by_nick(room_nick); + if existing_occupant and existing_occupant.bare_jid ~= item.attr.jid then + module:log("debug", "Existing occupant for %s: %s does not match %s", room_nick, existing_occupant.bare_jid, item.attr.jid); + self:set_role(true, room_nick, nil, "This nickname is reserved"); + end + module:log("debug", "Reserving %s for %s (%s)", item.attr.nick, item.attr.jid, item.attr.affiliation); + registration_data = { reserved_nickname = item.attr.nick }; + end + success, errtype, err = self:set_affiliation(actor, item.attr.jid, item.attr.affiliation, reason, registration_data); + elseif item.attr.role and item.attr.nick and not item.attr.affiliation then + success, errtype, err = self:set_role(actor, self.jid.."/"..item.attr.nick, item.attr.role, reason); + else + success, errtype, err = nil, "cancel", "bad-request"; + end + self:save(true); + if not success then + origin.send(st.error_reply(stanza, errtype, err)); + else + origin.send(st.reply(stanza)); + end + return true; +end + +function room_mt:handle_admin_query_get_command(origin, stanza) + local actor = stanza.attr.from; + local affiliation = self:get_affiliation(actor); + local item = stanza.tags[1].tags[1]; + local _aff = item.attr.affiliation; + local _aff_rank = valid_affiliations[_aff or "none"]; + local _rol = item.attr.role; + if _aff and _aff_rank and not _rol then + -- You need to be at least an admin, and be requesting info about your affifiliation or lower + -- e.g. an admin can't ask for a list of owners + local affiliation_rank = valid_affiliations[affiliation or "none"]; + if (affiliation_rank >= valid_affiliations.admin and affiliation_rank >= _aff_rank) + or (self:get_whois() == "anyone") then + local reply = st.reply(stanza):query("http://jabber.org/protocol/muc#admin"); + for jid in self:each_affiliation(_aff or "none") do + local nick = self:get_registered_nick(jid); + reply:tag("item", {affiliation = _aff, jid = jid, nick = nick }):up(); + end + origin.send(reply:up()); + return true; + else + origin.send(st.error_reply(stanza, "auth", "forbidden")); + return true; + end + elseif _rol and valid_roles[_rol or "none"] and not _aff then + local role = self:get_role(self:get_occupant_jid(actor)) or self:get_default_role(affiliation); + if valid_roles[role or "none"] >= valid_roles.moderator then + if _rol == "none" then _rol = nil; end + local reply = st.reply(stanza):query("http://jabber.org/protocol/muc#admin"); + -- TODO: whois check here? (though fully anonymous rooms are not supported) + for occupant_jid, occupant in self:each_occupant() do + if occupant.role == _rol then + local nick = jid_resource(occupant_jid); + self:build_item_list(occupant, reply, false, nick); + end + end + origin.send(reply:up()); + return true; + else + origin.send(st.error_reply(stanza, "auth", "forbidden")); + return true; + end + else + origin.send(st.error_reply(stanza, "cancel", "bad-request")); + return true; + end +end + +function room_mt:handle_owner_query_get_to_room(origin, stanza) + if self:get_affiliation(stanza.attr.from) ~= "owner" then + origin.send(st.error_reply(stanza, "auth", "forbidden", "Only owners can configure rooms")); + return true; + end + + self:send_form(origin, stanza); + return true; +end +function room_mt:handle_owner_query_set_to_room(origin, stanza) + module:log("info", "from: %s", stanza.attr.from); + module:log("info", "aff: %s", self:get_affiliation(stanza.attr.from)); + if self:get_affiliation(stanza.attr.from) ~= "owner" then + origin.send(st.error_reply(stanza, "auth", "forbidden", "Only owners can configure rooms")); + return true; + end + + local child = stanza.tags[1].tags[1]; + if not child then + origin.send(st.error_reply(stanza, "modify", "bad-request")); + return true; + elseif child.name == "destroy" then + local newjid = child.attr.jid; + local reason = child:get_child_text("reason"); + local password = child:get_child_text("password"); + self:destroy(newjid, reason, password); + origin.send(st.reply(stanza)); + return true; + elseif child.name == "x" and child.attr.xmlns == "jabber:x:data" then + return self:process_form(origin, stanza); + else + origin.send(st.error_reply(stanza, "cancel", "service-unavailable")); + return true; + end +end + +function room_mt:handle_groupchat_to_room(origin, stanza) + local from = stanza.attr.from; + local occupant = self:get_occupant_by_real_jid(from); + local from_res = jid_resource(stanza.attr.from); + local affiliate = self:get_affiliation(from_res) or "none"; + -- module:log("debug", "affiliation: %s", affiliate); + local aff_allowed = false; + if (affiliate == "owner" or affiliate == "member" or affiliate == "admin") then + aff_allowed = true; + else + if module:fire_event("muc-occupant-groupchat", { + room = self; origin = origin; stanza = stanza; from = from; occupant = occupant; + }) then return true; end + end + if occupant then + stanza.attr.from = occupant.nick; + end + self:broadcast_message(stanza); + stanza.attr.from = from; + return true; +end + +-- Role check +module:hook("muc-occupant-groupchat", function(event) + local role_rank = valid_roles[event.occupant and event.occupant.role or "none"]; + if role_rank <= valid_roles.none then + event.origin.send(st.error_reply(event.stanza, "cancel", "not-acceptable", "You are not currently connected to this chat")); + return true; + elseif role_rank <= valid_roles.visitor then + event.origin.send(st.error_reply(event.stanza, "auth", "forbidden")); + return true; + end +end, 50); + +-- hack - some buggy clients send presence updates to the room rather than their nick +function room_mt:handle_presence_to_room(origin, stanza) + local current_nick = self:get_occupant_jid(stanza.attr.from); + local handled + if current_nick then + local to = stanza.attr.to; + stanza.attr.to = current_nick; + handled = self:handle_presence_to_occupant(origin, stanza); + stanza.attr.to = to; + end + return handled; +end + +-- Need visitor role or higher to invite +module:hook("muc-pre-invite", function(event) + local room, stanza = event.room, event.stanza; + local _from = stanza.attr.from; + local inviter = room:get_occupant_by_real_jid(_from); + local role = inviter and inviter.role or room:get_default_role(room:get_affiliation(_from)); + if valid_roles[role or "none"] <= valid_roles.visitor then + event.origin.send(st.error_reply(stanza, "auth", "forbidden")); + return true; + end +end); + +function room_mt:handle_mediated_invite(origin, stanza) + local payload = stanza:get_child("x", "http://jabber.org/protocol/muc#user"):get_child("invite"); + local invitee = jid_prep(payload.attr.to); + if not invitee then + origin.send(st.error_reply(stanza, "cancel", "jid-malformed")); + return true; + elseif module:fire_event("muc-pre-invite", {room = self, origin = origin, stanza = stanza}) then + return true; + end + local invite = muc_util.filter_muc_x(st.clone(stanza)); + invite.attr.from = self.jid; + invite.attr.to = invitee; + invite:tag('x', {xmlns='http://jabber.org/protocol/muc#user'}) + :tag('invite', {from = stanza.attr.from;}) + :tag('reason'):text(payload:get_child_text("reason")):up() + :up() + :up(); + if not module:fire_event("muc-invite", {room = self, stanza = invite, origin = origin, incoming = stanza}) then + self:route_stanza(invite); + end + return true; +end + +-- COMPAT: Some older clients expect this +module:hook("muc-invite", function(event) + local room, stanza = event.room, event.stanza; + local invite = stanza:get_child("x", "http://jabber.org/protocol/muc#user"):get_child("invite"); + local reason = invite:get_child_text("reason"); + stanza:tag('x', {xmlns = "jabber:x:conference"; jid = room.jid;}) + :text(reason or "") + :up(); +end); + +-- Add a plain message for clients which don't support invites +module:hook("muc-invite", function(event) + local room, stanza = event.room, event.stanza; + if not stanza:get_child("body") then + local invite = stanza:get_child("x", "http://jabber.org/protocol/muc#user"):get_child("invite"); + local reason = invite:get_child_text("reason") or ""; + stanza:tag("body") + :text(invite.attr.from.." invited you to the room "..room.jid..(reason ~= "" and (" ("..reason..")") or "")) + :up(); + end +end); + +function room_mt:handle_mediated_decline(origin, stanza) + local payload = stanza:get_child("x", "http://jabber.org/protocol/muc#user"):get_child("decline"); + local declinee = jid_prep(payload.attr.to); + if not declinee then + origin.send(st.error_reply(stanza, "cancel", "jid-malformed")); + return true; + elseif module:fire_event("muc-pre-decline", {room = self, origin = origin, stanza = stanza}) then + return true; + end + local decline = muc_util.filter_muc_x(st.clone(stanza)); + decline.attr.from = self.jid; + decline.attr.to = declinee; + decline:tag("x", {xmlns = "http://jabber.org/protocol/muc#user"}) + :tag("decline", {from = stanza.attr.from}) + :tag("reason"):text(payload:get_child_text("reason")):up() + :up() + :up(); + if not module:fire_event("muc-decline", {room = self, stanza = decline, origin = origin, incoming = stanza}) then + declinee = decline.attr.to; -- re-fetch, in case event modified it + local occupant + if jid_bare(declinee) == self.jid then -- declinee jid is already an in-room jid + occupant = self:get_occupant_by_nick(declinee); + end + if occupant then + self:route_to_occupant(occupant, decline); + else + self:route_stanza(decline); + end + end + return true; +end + +-- Add a plain message for clients which don't support declines +module:hook("muc-decline", function(event) + local room, stanza = event.room, event.stanza; + if not stanza:get_child("body") then + local decline = stanza:get_child("x", "http://jabber.org/protocol/muc#user"):get_child("decline"); + local reason = decline:get_child_text("reason") or ""; + stanza:body(decline.attr.from.." declined your invite to the room " + ..room.jid..(reason ~= "" and (" ("..reason..")") or "")); + end +end); + +function room_mt:handle_message_to_room(origin, stanza) + local type = stanza.attr.type; + if type == "groupchat" then + return self:handle_groupchat_to_room(origin, stanza) + elseif type == "error" and is_kickable_error(stanza) then + return self:handle_kickable(origin, stanza) + elseif type == nil or type == "normal" then + local x = stanza:get_child("x", "http://jabber.org/protocol/muc#user"); + if x then + local payload = x.tags[1]; + if payload == nil then --luacheck: ignore 542 + -- fallthrough + elseif payload.name == "invite" and payload.attr.to then + return self:handle_mediated_invite(origin, stanza) + elseif payload.name == "decline" and payload.attr.to then + return self:handle_mediated_decline(origin, stanza) + end + origin.send(st.error_reply(stanza, "cancel", "bad-request")); + return true; + end + + local form = stanza:get_child("x", "jabber:x:data"); + local form_type = dataform.get_type(form); + if form_type == "http://jabber.org/protocol/muc#request" then + self:handle_role_request(origin, stanza, form); + return true; + end + end +end + +function room_mt:route_stanza(stanza) -- luacheck: ignore 212 + module:send(stanza); +end + +function room_mt:get_affiliation(jid) + local node, host, resource = jid_split(jid); + -- Affiliations are granted, revoked, and maintained based on the user's bare JID. + local bare = node and node.."@"..host or host; + local result = self._affiliations[bare]; + if not result and self._affiliations[host] == "outcast" then result = "outcast"; end -- host banned + return result; +end + +-- Iterates over jid, affiliation pairs +function room_mt:each_affiliation(with_affiliation) + local _affiliations, _affiliation_data = self._affiliations, self._affiliation_data; + return function(_, jid) + local affiliation; + repeat -- Iterate until we get a match + jid, affiliation = next(_affiliations, jid); + until with_affiliation == nil or jid == nil or affiliation == with_affiliation + return jid, affiliation, _affiliation_data[jid]; + end, nil, nil; +end + +function room_mt:set_affiliation(actor, jid, affiliation, reason, data) + if not actor then return nil, "modify", "not-acceptable"; end; + + local node, host, resource = jid_split(jid); + if not host then return nil, "modify", "not-acceptable"; end + jid = jid_join(node, host); -- Bare + local is_host_only = node == nil; + + if valid_affiliations[affiliation or "none"] == nil then + return nil, "modify", "not-acceptable"; + end + affiliation = affiliation ~= "none" and affiliation or nil; -- coerces `affiliation == false` to `nil` + + local target_affiliation = self._affiliations[jid]; -- Raw; don't want to check against host + local is_downgrade = valid_affiliations[target_affiliation or "none"] > valid_affiliations[affiliation or "none"]; + + if actor == true then + actor = nil -- So we can pass it safely to 'publicise_occupant_status' below + else + local actor_affiliation = self:get_affiliation(actor); + if actor_affiliation == "owner" then + if jid_bare(actor) == jid and is_downgrade then -- self change + -- need at least one owner + local is_last = true; + for j in self:each_affiliation("owner") do + if j ~= jid then is_last = false; break; end + end + if is_last then + return nil, "cancel", "conflict"; + end + end + -- owners can do anything else + elseif affiliation == "owner" or affiliation == "admin" + or actor_affiliation ~= "admin" + or target_affiliation == "owner" or target_affiliation == "admin" then + -- Can't demote owners or other admins + return nil, "cancel", "not-allowed"; + end + end + + -- Set in 'database' + self._affiliations[jid] = affiliation; + if not affiliation or data == false or (data ~= nil and next(data) == nil) then + module:log("debug", "Clearing affiliation data for %s", jid); + self._affiliation_data[jid] = nil; + elseif data then + module:log("debug", "Updating affiliation data for %s", jid); + self._affiliation_data[jid] = data; + end + + -- Update roles + local role = self:get_default_role(affiliation); + local role_rank = valid_roles[role or "none"]; + local occupants_updated = {}; -- Filled with old roles + for nick, occupant in self:each_occupant() do -- luacheck: ignore 213 + if occupant.bare_jid == jid or ( + -- Outcast can be by host. + is_host_only and affiliation == "outcast" and select(2, jid_split(occupant.bare_jid)) == host + ) then + -- need to publcize in all cases; as affiliation in has changed. + occupants_updated[occupant] = occupant.role; + if occupant.role ~= role and ( + is_downgrade or + valid_roles[occupant.role or "none"] < role_rank -- upgrade + ) then + occupant.role = role; + self:save_occupant(occupant); + end + end + end + + -- Tell the room of the new occupant affiliations+roles + local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user"}); + if not role then -- getting kicked + if affiliation == "outcast" then + x:tag("status", {code="301"}):up(); -- banned + else + x:tag("status", {code="321"}):up(); -- affiliation change + end + end + local is_semi_anonymous = self:get_whois() == "moderators"; + + if next(occupants_updated) ~= nil then + for occupant, old_role in pairs(occupants_updated) do + self:publicise_occupant_status(occupant, x, nil, actor, reason); + if occupant.role == nil then + module:fire_event("muc-occupant-left", {room = self; nick = occupant.nick; occupant = occupant;}); + elseif is_semi_anonymous and + ((old_role == "moderator" and occupant.role ~= "moderator") or + (old_role ~= "moderator" and occupant.role == "moderator")) then -- Has gained or lost moderator status + -- Send everyone else's presences (as jid visibility has changed) + for real_jid in occupant:each_session() do + self:send_occupant_list(real_jid, function(occupant_jid, occupant) --luacheck: ignore 212 433 + return occupant.bare_jid ~= jid; + end); + end + end + end + else + -- Announce affiliation change for a user that is not currently in the room, + -- XEP-0045 (v1.31.2) example 195 + -- add_item(x, affiliation, role, jid, nick, actor_nick, actor_jid, reason) + local announce_msg = st.message({ from = self.jid }) + :add_child(add_item(st.clone(x), affiliation, nil, jid, nil, nil, nil, reason)); + local min_role = is_semi_anonymous and "moderator" or "none"; + self:broadcast(announce_msg, muc_util.only_with_min_role(min_role)); + end + + self:save(true); + + module:fire_event("muc-set-affiliation", { + room = self; + actor = actor; + jid = jid; + affiliation = affiliation or "none"; + reason = reason; + previous_affiliation = target_affiliation; + data = data and data or nil; -- coerce false to nil + in_room = next(occupants_updated) ~= nil; + }); + + return true; +end + +function room_mt:get_affiliation_data(jid, key) + local data = self._affiliation_data[jid]; + if not data then return nil; end + if key then + return data[key]; + end + return data; +end + +function room_mt:get_role(nick) + local occupant = self:get_occupant_by_nick(nick); + return occupant and occupant.role or nil; +end + +function room_mt:set_role(actor, occupant_jid, role, reason) + if not actor then return nil, "modify", "not-acceptable"; end + + local occupant = self:get_occupant_by_nick(occupant_jid); + if not occupant then return nil, "modify", "item-not-found"; end + + if valid_roles[role or "none"] == nil then + return nil, "modify", "not-acceptable"; + end + role = role ~= "none" and role or nil; -- coerces `role == false` to `nil` + + if actor == true then + actor = nil -- So we can pass it safely to 'publicise_occupant_status' below + else + -- Can't do anything to other owners or admins + local occupant_affiliation = self:get_affiliation(occupant.bare_jid); + if occupant_affiliation == "owner" or occupant_affiliation == "admin" then + return nil, "cancel", "not-allowed"; + end + + -- If you are trying to give or take moderator role you need to be an owner or admin + if occupant.role == "moderator" or role == "moderator" then + local actor_affiliation = self:get_affiliation(actor); + if actor_affiliation ~= "owner" and actor_affiliation ~= "admin" then + return nil, "cancel", "not-allowed"; + end + end + + -- Need to be in the room and a moderator + local actor_occupant = self:get_occupant_by_real_jid(actor); + if not actor_occupant or actor_occupant.role ~= "moderator" then + return nil, "cancel", "not-allowed"; + end + end + + local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user"}); + if not role then + x:tag("status", {code = "307"}):up(); + end + occupant.role = role; + self:save_occupant(occupant); + self:publicise_occupant_status(occupant, x, nil, actor, reason); + if role == nil then + module:fire_event("muc-occupant-left", {room = self; nick = occupant.nick; occupant = occupant;}); + end + return true; +end + +local whois = module:require "muc/whois"; +room_mt.get_whois = whois.get; +room_mt.set_whois = whois.set; + +local _M = {}; -- module "muc" + +function _M.new_room(jid, config) + return setmetatable({ + jid = jid; + _jid_nick = {}; + _occupants = {}; + _data = config or {}; + _affiliations = {}; + _affiliation_data = {}; + }, room_mt); +end + +local new_format = module:get_option_boolean("new_muc_storage_format", false); + +function room_mt:freeze(live) + local frozen, state; + if new_format then + frozen = { + _jid = self.jid; + _data = self._data; + _affiliation_data = self._affiliation_data; + }; + for user, affiliation in pairs(self._affiliations) do + frozen[user] = affiliation; + end + else + frozen = { + jid = self.jid; + _data = self._data; + _affiliations = self._affiliations; + _affiliation_data = self._affiliation_data; + }; + end + if live then + state = {}; + for nick, occupant in self:each_occupant() do + state[nick] = { + bare_jid = occupant.bare_jid; + role = occupant.role; + jid = occupant.jid; + } + for jid, presence in occupant:each_session() do + state[jid] = st.preserialize(presence); + end + end + local history = self._history; + if history and history[1] ~= nil then + state._last_message = st.preserialize(history[#history].stanza); + state._last_message_at = history[#history].timestamp; + end + end + return frozen, state; +end + +function _M.restore_room(frozen, state) + local room_jid = frozen._jid or frozen.jid; + local room = _M.new_room(room_jid, frozen._data); + + if state and state._last_message and state._last_message_at then + room._history = { + { stanza = st.deserialize(state._last_message), + timestamp = state._last_message_at, }, + }; + end + + local occupants = {}; + local room_name, room_host = jid_split(room_jid); + + room._affiliation_data = frozen._affiliation_data or {}; + + if frozen.jid and frozen._affiliations then + -- Old storage format + room._affiliations = frozen._affiliations; + else + -- New storage format + for jid, data in pairs(frozen) do + local node, host, resource = jid_split(jid); + if host:sub(1,1) ~= "_" and not resource and type(data) == "string" then + -- bare jid: affiliation + room._affiliations[jid] = data; + end + end + end + for jid, data in pairs(state or frozen) do + local node, host, resource = jid_split(jid); + if node or host:sub(1,1) ~= "_" then + if host == room_host and node == room_name and resource and type(data) == "table" then + -- full room jid: bare real jid and role + local nick = jid; + local occupant = occupants[nick] or occupant_lib.new(data.bare_jid, nick); + occupant.bare_jid = data.bare_jid; + occupant.role = data.role; + occupant.jid = data.jid; -- Primary session JID + occupants[nick] = occupant; + elseif type(data) == "table" and data.name == "presence" then + -- full user jid: presence + local nick = data.attr.from; + local occupant = occupants[nick] or occupant_lib.new(nil, nick); + local presence = st.deserialize(data); + occupant:set_session(jid, presence); + occupants[nick] = occupant; + end + end + end + + for _, occupant in pairs(occupants) do + room:save_occupant(occupant); + end + + return room; +end + +_M.room_mt = room_mt; + +return _M; diff --git a/patches/patches.list b/patches/patches.list new file mode 100644 index 0000000..7c65e32 --- /dev/null +++ b/patches/patches.list @@ -0,0 +1,5 @@ +mod_mam.lua /usr/lib/prosody/modules/mod_mam/mod_mam.lua +mod_muc_unique.lua usr/lib/prosody/modules/mod_muc_unique.lua +moduleapi.lua usr/lib/prosody/core/moduleapi.lua +mod_muc.lua usr/lib/prosody/modules/muc/mod_muc.lua +register.lib.lua /usr/lib/prosody/modules/muc/register.lib.lua diff --git a/patches/portmanager.lua b/patches/portmanager.lua new file mode 100644 index 0000000..052f854 --- /dev/null +++ b/patches/portmanager.lua @@ -0,0 +1,244 @@ +local config = require "core.configmanager"; +local certmanager = require "core.certmanager"; +local server = require "net.server"; +local socket = require "socket"; + +local log = require "util.logger".init("portmanager"); +local multitable = require "util.multitable"; +local set = require "util.set"; + +local table = table; +local setmetatable, rawset, rawget = setmetatable, rawset, rawget; +local type, tonumber, tostring, ipairs = type, tonumber, tostring, ipairs; + +local prosody = prosody; +local fire_event = prosody.events.fire_event; + +local _ENV = nil; +-- luacheck: std none + +--- Config + +local default_interfaces = { }; +local default_local_interfaces = { }; +if config.get("*", "use_ipv4") ~= false then + table.insert(default_interfaces, "*"); + table.insert(default_local_interfaces, "*"); +end +if socket.tcp6 and config.get("*", "use_ipv6") ~= false then + table.insert(default_interfaces, "::"); + table.insert(default_local_interfaces, "::"); +end + +local default_mode = config.get("*", "network_default_read_size") or 4096; + +--- Private state + +-- service_name -> { service_info, ... } +local services = setmetatable({}, { __index = function (t, k) rawset(t, k, {}); return rawget(t, k); end }); + +-- service_name, interface (string), port (number) +local active_services = multitable.new(); + +--- Private helpers + +local function error_to_friendly_message(service_name, port, err) --luacheck: ignore 212/service_name + local friendly_message = err; + if err:match(" in use") then + -- FIXME: Use service_name here + if port == 5222 or port == 5223 or port == 5269 then + friendly_message = "check that Prosody or another XMPP server is " + .."not already running and using this port"; + elseif port == 80 or port == 81 then + friendly_message = "check that a HTTP server is not already using " + .."this port"; + elseif port == 5280 then + friendly_message = "check that Prosody or a BOSH connection manager " + .."is not already running"; + else + friendly_message = "this port is in use by another application"; + end + elseif err:match("permission") then + friendly_message = "Prosody does not have sufficient privileges to use this port"; + end + return friendly_message; +end + +--- Public API + +local function activate(service_name) + local service_info = services[service_name][1]; + if not service_info then + return nil, "Unknown service: "..service_name; + end + + local listener = service_info.listener; + + local config_prefix = (service_info.config_prefix or service_name).."_"; + if config_prefix == "_" then + config_prefix = ""; + end + + local bind_interfaces = config.get("*", config_prefix.."interfaces") + or config.get("*", config_prefix.."interface") -- COMPAT w/pre-0.9 + or (service_info.private and (config.get("*", "local_interfaces") or default_local_interfaces)) + or config.get("*", "interfaces") + or config.get("*", "interface") -- COMPAT w/pre-0.9 + or listener.default_interface -- COMPAT w/pre0.9 + or default_interfaces + bind_interfaces = set.new(type(bind_interfaces)~="table" and {bind_interfaces} or bind_interfaces); + + local bind_ports = config.get("*", config_prefix.."ports") + or service_info.default_ports + or {service_info.default_port + or listener.default_port -- COMPAT w/pre-0.9 + } + bind_ports = set.new(type(bind_ports) ~= "table" and { bind_ports } or bind_ports ); + + local mode, ssl = listener.default_mode or default_mode; + local hooked_ports = {}; + + for interface in bind_interfaces do + for port in bind_ports do + local port_number = tonumber(port); + if not port_number then + log("error", "Invalid port number specified for service '%s': %s", service_info.name, tostring(port)); + elseif #active_services:search(nil, interface, port_number) > 0 then + log("error", "Multiple services configured to listen on the same port ([%s]:%d): %s, %s", interface, port, + active_services:search(nil, interface, port)[1][1].service.name or "", service_name or ""); + else + local err; + -- Create SSL context for this service/port + if service_info.encryption == "ssl" then + local global_ssl_config = config.get("*", "ssl") or {}; + local prefix_ssl_config = config.get("*", config_prefix.."ssl") or global_ssl_config; + log("debug", "Creating context for direct TLS service %s on port %d", service_info.name, port); + ssl, err = certmanager.create_context(service_info.name.." port "..port, "server", + prefix_ssl_config[interface], + prefix_ssl_config[port], + prefix_ssl_config, + service_info.ssl_config or {}, + global_ssl_config[interface], + global_ssl_config[port]); + if not ssl then + log("error", "Error binding encrypted port for %s: %s", service_info.name, + error_to_friendly_message(service_name, port_number, err) or "unknown error"); + end + end + if not err then + -- Start listening on interface+port + local handler, err = server.addserver(interface, port_number, listener, mode, ssl); + if not handler then + log("error", "Failed to open server port %d on %s, %s", port_number, interface, + error_to_friendly_message(service_name, port_number, err)); + else + table.insert(hooked_ports, "["..interface.."]:"..port_number); + log("debug", "Added listening service %s to [%s]:%d", service_name, interface, port_number); + active_services:add(service_name, interface, port_number, { + server = handler; + service = service_info; + }); + end + end + end + end + end + log("info", "Activated service '%s' on %s", service_name, + #hooked_ports == 0 and "no ports" or table.concat(hooked_ports, ", ")); + return true; +end + +local close; -- forward declaration + +local function deactivate(service_name, service_info) + for name, interface, port, n, active_service --luacheck: ignore 213/name 213/n + in active_services:iter(service_name or service_info and service_info.name, nil, nil, nil) do + if service_info == nil or active_service.service == service_info then + close(interface, port); + end + end + log("info", "Deactivated service '%s'", service_name or service_info.name); +end + +local function register_service(service_name, service_info) + table.insert(services[service_name], service_info); + + if not active_services:get(service_name) then + log("debug", "No active service for %s, activating...", service_name); + local ok, err = activate(service_name); + if not ok then + log("error", "Failed to activate service '%s': %s", service_name, err or "unknown error"); + end + end + + fire_event("service-added", { name = service_name, service = service_info }); + return true; +end + +local function unregister_service(service_name, service_info) + log("debug", "Unregistering service: %s", service_name); + local service_info_list = services[service_name]; + for i, service in ipairs(service_info_list) do + if service == service_info then + table.remove(service_info_list, i); + end + end + deactivate(nil, service_info); + if #service_info_list > 0 then -- Other services registered with this name + activate(service_name); -- Re-activate with the next available one + end + fire_event("service-removed", { name = service_name, service = service_info }); +end + +local get_service_at -- forward declaration + +function close(interface, port) + local service, service_server = get_service_at(interface, port); + if not service then + return false, "port-not-open"; + end + service_server:close(); + active_services:remove(service.name, interface, port); + log("debug", "Removed listening service %s from [%s]:%d", service.name, interface, port); + return true; +end + +function get_service_at(interface, port) + local data = active_services:search(nil, interface, port)[1][1]; + return data.service, data.server; +end + +local function get_service(service_name) + return (services[service_name] or {})[1]; +end + +local function get_active_services() + return active_services; +end + +local function get_registered_services() + return services; +end + +-- Event handlers + +prosody.events.add_handler("item-added/net-provider", function (event) + local item = event.item; + register_service(item.name, item); +end); +prosody.events.add_handler("item-removed/net-provider", function (event) + local item = event.item; + unregister_service(item.name, item); +end); + +return { + activate = activate; + deactivate = deactivate; + register_service = register_service; + unregister_service = unregister_service; + close = close; + get_service_at = get_service_at; + get_service = get_service; + get_active_services = get_active_services; + get_registered_services = get_registered_services; +}; diff --git a/patches/register.lib.lua b/patches/register.lib.lua new file mode 100644 index 0000000..9c70f7b --- /dev/null +++ b/patches/register.lib.lua @@ -0,0 +1,229 @@ +local jid_bare = require "util.jid".bare; +local jid_resource = require "util.jid".resource; +local resourceprep = require "util.encodings".stringprep.resourceprep; +local st = require "util.stanza"; +local dataforms = require "util.dataforms"; + +local allow_unaffiliated = module:get_option_boolean("allow_unaffiliated_register", false); + +local enforce_nick = module:get_option_boolean("enforce_registered_nickname", false); + +local function dumpTable(t, depth) + local result = ""; + if (not depth) then depth = 0 end; + + local prefix = string.rep(" ", depth); + + if (type(t) ~= "table") then + result = result.." "..tostring(t); + else + for key,value in pairs(t) do + result = result..prefix..tostring(key).." => "..dumpTable(value, depth+1).."\n" + end + end + + return result; +end + +-- reserved_nicks[nick] = jid +local function get_reserved_nicks(room) + if room._reserved_nicks then + return room._reserved_nicks; + end + module:log("debug", "Refreshing reserved nicks..."); + local reserved_nicks = {}; + for jid in room:each_affiliation() do + local data = room._affiliation_data[jid]; + local nick = data and data.reserved_nickname; + module:log("debug", "Refreshed for %s: %s", jid, nick); + if nick then + reserved_nicks[nick] = jid; + end + end + room._reserved_nicks = reserved_nicks; + return reserved_nicks; +end + +-- Returns the registered nick, if any, for a JID +-- Note: this is just the *nick* part, i.e. the resource of the in-room JID +local function get_registered_nick(room, jid) + local registered_data = room._affiliation_data[jid]; + if not registered_data then + return; + end + return registered_data.reserved_nickname; +end + +-- Returns the JID, if any, that registered a nick (not in-room JID) +local function get_registered_jid(room, nick) + local reserved_nicks = get_reserved_nicks(room); + return reserved_nicks[nick]; +end + +module:hook("muc-set-affiliation", function (event) + -- Clear reserved nick cache + event.room._reserved_nicks = nil; +end); + +module:hook("muc-disco#info", function (event) + event.reply:tag("feature", { var = "jabber:iq:register" }):up(); +end); + +local registration_form = dataforms.new { + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/muc#register" }, + { name = "muc#register_roomnick", type = "text-single", label = "Nickname"}, +}; + +local function enforce_nick_policy(event) + local origin, stanza = event.origin, event.stanza; + local room = assert(event.room); -- FIXME + if not room then return; end + + -- Check if the chosen nickname is reserved + local requested_nick = jid_resource(stanza.attr.to); + local reserved_by = get_registered_jid(room, requested_nick); + if reserved_by and reserved_by ~= jid_bare(stanza.attr.from) then + module:log("debug", "%s attempted to use nick %s reserved by %s", stanza.attr.from, requested_nick, reserved_by); + local reply = st.error_reply(stanza, "cancel", "conflict"):up(); + origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); + return true; + end + + -- Check if the occupant has a reservation they must use + if enforce_nick then + local nick = get_registered_nick(room, jid_bare(stanza.attr.from)); + if nick then + if event.occupant then + event.occupant.nick = jid_bare(event.occupant.nick) .. "/" .. nick; + elseif event.dest_occupant.nick ~= jid_bare(event.dest_occupant.nick) .. "/" .. nick then + module:log("debug", "Attempt by %s to join as %s, but their reserved nick is %s", stanza.attr.from, requested_nick, nick); + local reply = st.error_reply(stanza, "cancel", "not-acceptable"):up(); + origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); + return true; + end + end + end +end + +module:hook("muc-occupant-pre-join", enforce_nick_policy); +module:hook("muc-occupant-pre-change", enforce_nick_policy); + +-- Discovering Reserved Room Nickname +-- http://xmpp.org/extensions/xep-0045.html#reservednick +module:hook("muc-disco#info/x-roomuser-item", function (event) + local nick = get_registered_nick(event.room, jid_bare(event.stanza.attr.from)); + if nick then + event.reply:tag("identity", { category = "conference", type = "text", name = nick }) + end +end); + +local function handle_register_iq(room, origin, stanza) + local user_jid = jid_bare(stanza.attr.from) + local affiliation = room:get_affiliation(user_jid); + if affiliation == "outcast" then + origin.send(st.error_reply(stanza, "auth", "forbidden")); + return true; + elseif not (affiliation or allow_unaffiliated) then + origin.send(st.error_reply(stanza, "auth", "registration-required")); + return true; + end + local reply = st.reply(stanza); + local registered_nick = get_registered_nick(room, user_jid); + if stanza.attr.type == "get" then + reply:query("jabber:iq:register"); + if registered_nick then + reply:tag("registered"):up(); + reply:tag("username"):text(registered_nick); + origin.send(reply); + return true; + end + reply:add_child(registration_form:form()); + else -- type == set -- handle registration form + local query = stanza.tags[1]; + if query:get_child("remove") then + -- Remove "member" affiliation, but preserve if any other + local new_affiliation = affiliation ~= "member" and affiliation; + local ok, err_type, err_condition = room:set_affiliation(true, user_jid, new_affiliation, nil, false); + if not ok then + origin.send(st.error_reply(stanza, err_type, err_condition)); + return true; + end + origin.send(reply); + return true; + end + local form_tag = query:get_child("x", "jabber:x:data"); + local reg_data = form_tag and registration_form:data(form_tag); + if not reg_data then + origin.send(st.error_reply(stanza, "modify", "bad-request", "Error in form")); + return true; + end + -- Is the nickname valid? + local desired_nick = resourceprep(reg_data["muc#register_roomnick"]); + if not desired_nick then + origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid Nickname")); + return true; + end + -- Is the nickname currently in use by another user? + local current_occupant = room:get_occupant_by_nick(room.jid.."/"..desired_nick); + if current_occupant and current_occupant.bare_jid ~= user_jid then + origin.send(st.error_reply(stanza, "cancel", "conflict")); + return true; + end + -- Is the nickname currently reserved by another user? + local reserved_by = get_registered_jid(room, desired_nick); + if reserved_by and reserved_by ~= user_jid then + origin.send(st.error_reply(stanza, "cancel", "conflict")); + return true; + end + + if enforce_nick then + -- Kick any sessions that are not using this nick before we register it + local required_room_nick = room.jid.."/"..desired_nick; + for room_nick, occupant in room:each_occupant() do + if occupant.bare_jid == user_jid and room_nick ~= required_room_nick then + room:set_role(true, room_nick, nil); -- Kick (TODO: would be nice to use 333 code) + end + end + end + + -- Checks passed, save the registration + if registered_nick ~= desired_nick then + local registration_data = { reserved_nickname = desired_nick }; + local ok, err_type, err_condition = room:set_affiliation(true, user_jid, affiliation or "member", nil, registration_data); + if not ok then + origin.send(st.error_reply(stanza, err_type, err_condition)); + return true; + end + module:log("debug", "Saved nick registration for %s: %s", user_jid, desired_nick); + origin.send(reply); + return true; + end + end + origin.send(reply); + return true; +end + +local function handle_unregister_iq(room, origin, stanza) + local user_jid = jid_bare(stanza.attr.from) + local affiliation = room:get_affiliation(user_jid); + local affs = room._affiliations; + local occupants = room._occupants; + module:fire_event("vnc-muc-kick", { room = stanza.attr.to, who = user_jid }); + + for nick, occupant in room:each_occupant() do + module:log("info", "occupant bare: %s", occupant.bare_jid); + end + if (affiliation ~= "") then + module:log("info","removing affiliaton from %s for %s", stanza.attr.to, user_jid); + affs[user_jid] = nil; + end + + return true; +end + +return { + get_registered_nick = get_registered_nick; + get_registered_jid = get_registered_jid; + handle_register_iq = handle_register_iq; + handle_unregister_iq = handle_unregister_iq; +} diff --git a/scan/.keep b/scan/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..1443835 --- /dev/null +++ b/test.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +docker run -e avatarUploadPass="123" \ + -e avatarUploadUser="avatar" \ + -e fcmApiKey="asdfghjkl" \ + -e fcmApiUrl="http://fcmp:80/notify" \ + -e fcmDelUrl="http://fcmp:80/delete" \ + -e fileShareBaseUrl="https://phpfile.uxf.example.com/share.php/" \ + -e fileShareSecret="g3h31m!" \ + -e hybridaAuthUrl="http://192.168.23.3:80/" \ + -e prosodyDBhost="172.17.0.1" \ + -e prosodyDBname="prosody" \ + -e prosodyDBpass="q3tx65test" \ + -e prosodyDBuser="prosody" \ + -e prosodyDomain="example.com" \ + -p 5280:5280 -p 5281:5281 -p 5222:5222 -p 5269:5269 -p 5582:5582 "$1" diff --git a/vnctalk/mod_alias.lua b/vnctalk/mod_alias.lua new file mode 100644 index 0000000..6c6222c --- /dev/null +++ b/vnctalk/mod_alias.lua @@ -0,0 +1,44 @@ +-- Copyright (C) 2015 Travis Burtrum +-- This file is MIT/X11 licensed. + +-- set like so in prosody config, works on full or bare jids, or hosts: +--aliases = { +-- ["old@example.net"] = "new@example.net"; +-- ["you@example.com"] = "you@example.net"; +-- ["conference.example.com"] = "conference.example.net"; +--} + +local aliases = module:get_option("aliases", {}); +local bcaddr = module:get_option("bc_prefix", ""); +local alias_response = module:get_option("alias_response", "User $alias can be contacted at $target"); + +local st = require "util.stanza"; +local jid_split = require "util.jid".split; + +local function starts_with(str, start) + return str:sub(1, #start) == start +end + +function handle_alias(event) + + if ((event.stanza.attr.type ~= "error") and (event.stanza.attr.type == "normal")) then + local alias = event.stanza.attr.to; + local forward_stanza = event.stanza; + module:log("info", "alias handling - checking %s", alias); + local tonode, tohost = jid_split(alias); + if tonode ~= nil then + if starts_with(tonode, "broadcast-") then + module:log("info", "determined %s as broadcast", alias); + local target = "broadcast@"..tohost; + forward_stanza.attr.to = target; + forward_stanza.attr.origtarget = alias; + module:send(forward_stanza); + end + end + end + +end + +module:hook("message/bare", handle_alias, 300); +module:hook("message/full", handle_alias, 300); +module:hook("message/host", handle_alias, 300); diff --git a/vnctalk/mod_auth_any.lua b/vnctalk/mod_auth_any.lua new file mode 100644 index 0000000..61a762d --- /dev/null +++ b/vnctalk/mod_auth_any.lua @@ -0,0 +1,51 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- +-- luacheck: ignore 212 + +local datamanager = require "util.datamanager"; +local new_sasl = require "util.sasl".new; + +local host = module.host; +local provider = { name = "any" }; + +function provider.test_password(username, password) + return true; +end + +function provider.set_password(username, password) + local account = datamanager.load(username, host, "accounts"); + if account then + account.password = password; + return datamanager.store(username, host, "accounts", account); + end + return nil, "Account not available."; +end + +function provider.user_exists(username) + return true; +end + +function provider.create_user(username, password) + return datamanager.store(username, host, "accounts", {password = password}); +end + +function provider.delete_user(username) + return datamanager.store(username, host, "accounts", nil); +end + +function provider.get_sasl_handler() + local getpass_authentication_profile = { + plain_test = function(sasl, username, password, realm) + return true, true; + end + }; + return new_sasl(module.host, getpass_authentication_profile); +end + +module:add_item("auth-provider", provider); + diff --git a/vnctalk/mod_auth_http_async/README.markdown b/vnctalk/mod_auth_http_async/README.markdown new file mode 100644 index 0000000..f7e28b7 --- /dev/null +++ b/vnctalk/mod_auth_http_async/README.markdown @@ -0,0 +1,31 @@ +--- +labels: +- Stage-Alpha +... + +Introduction +============ + +This is an experimental authentication module that does an asynchronous +HTTP call to verify username and password. + +Details +======= + +When a user attempts to authenticate to Prosody, this module takes the +username and password and does a HTTP GET request with [Basic +authentication][rfc7617] to the configured `http_auth_url`. + +Configuration +============= + +``` lua +VirtualHost "example.com" +authentication = "http_async" +http_auth_url = "http://example.com/auth" +``` + +Compatibility +============= + +Requires Prosody trunk diff --git a/vnctalk/mod_auth_http_async/mod_auth_http_async.lua b/vnctalk/mod_auth_http_async/mod_auth_http_async.lua new file mode 100644 index 0000000..b3b7266 --- /dev/null +++ b/vnctalk/mod_auth_http_async/mod_auth_http_async.lua @@ -0,0 +1,126 @@ +-- Prosody IM +-- Copyright (C) 2008-2013 Matthew Wild +-- Copyright (C) 2008-2013 Waqas Hussain +-- Copyright (C) 2014 Kim Alvefur +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local new_sasl = require "util.sasl".new; +local base64 = require "util.encodings".base64.encode; +local have_async, async = pcall(require, "util.async"); +-- local have_async = false; + +local log = module._log; +local host = module.host; + +local api_base = module:get_option_string("http_auth_url", ""):gsub("$host", host); +if api_base == "" then error("http_auth_url required") end + +local provider = {}; + +-- globals required by socket.http +if rawget(_G, "PROXY") == nil then + rawset(_G, "PROXY", false) +end +if rawget(_G, "base_parsed") == nil then + rawset(_G, "base_parsed", false) +end +if not have_async then -- FINE! Set your globals then + prosody.unlock_globals() + require "ltn12" + require "socket" + require "socket.http" + require "ssl.https" + prosody.lock_globals() +end + +local function async_http_auth(url, username, password) + module:log("debug", "async_http_auth()"); + local http = require "net.http"; + local wait, done = async.waiter(); + local content, code, request, response; + local ex = { + headers = { Authorization = "Basic "..base64(username.."@"..host..":"..password); }; + } + local function cb(content_, code_, request_, response_) + content, code, request, response = content_, code_, request_, response_; + done(); + end + http.request(url, ex, cb); + wait(); + if code >= 200 and code <= 299 then + module:log("debug", "HTTP auth provider confirmed valid password"); + return true; + else + module:log("debug", "HTTP auth provider returned status code %d", code); + end + return nil, "Auth failed. Invalid username or password."; +end + +local function sync_http_auth(url,username, password) + module:log("debug", "sync_http_auth()"); + require "ltn12"; + local http = require "socket.http"; + local https = require "ssl.https"; + local request; + if string.sub(url, 1, string.len('https')) == 'https' then + request = https.request; + else + request = http.request; + end + local _, code, headers, status = request{ + url = url, + headers = { Authorization = "Basic "..base64(username.."@"..host..":"..password); } + }; + if type(code) == "number" and code >= 200 and code <= 299 then + module:log("debug", "HTTP auth provider confirmed valid password"); + return true; + else + module:log("debug", "HTTP auth provider returned status code: "..code); + end + return nil, "Auth failed. Invalid username or password."; +end + +function provider.test_password(username, password) + local url = api_base:gsub("$user", username):gsub("$password", password); + log("debug", "Testing password for user %s at host %s with URL %s", username, host, url); + if (have_async) then + return async_http_auth(url, username, password); + else + return sync_http_auth(url, username, password); + end +end + +function provider.users() + return function() + return nil; + end +end + +function provider.set_password(username, password) + return nil, "Changing passwords not supported"; +end + +function provider.user_exists(username) + return true; +end + +function provider.create_user(username, password) + return nil, "User creation not supported"; +end + +function provider.delete_user(username) + return nil , "User deletion not supported"; +end + +function provider.get_sasl_handler() + return new_sasl(host, { + plain_test = function(sasl, username, password, realm) + return provider.test_password(username, password), true; + end + }); +end + +module:provides("auth", provider); diff --git a/vnctalk/mod_auto_accept_subscriptions/README.markdown b/vnctalk/mod_auto_accept_subscriptions/README.markdown new file mode 100644 index 0000000..8ad3d59 --- /dev/null +++ b/vnctalk/mod_auto_accept_subscriptions/README.markdown @@ -0,0 +1,39 @@ +--- +labels: +- 'Stage-Beta' +summary: Automatically accept incoming subscription requests on behalf of users +... + +Introduction +============ + +In some environments where all users on the system have mutual trust in +each other, it's sometimes fine to skip the usual authorization process +to add someone to your contact list and see their status. + +This module sets Prosody to automatically accept incoming subscription +authorization requests, and add the contact to the user's contact list, +without intervention from the user. + +Configuration +============= + +Simply add the module to your modules\_enabled list like any other +module: + + modules_enabled = { + ... + "auto_accept_subscriptions"; + ... + } + +This module has no further configuration. + +Compatibility +============= + + ------- ------- + trunk Works + 0.9 Works + 0.8 Works + ------- ------- diff --git a/vnctalk/mod_auto_accept_subscriptions/README.wiki b/vnctalk/mod_auto_accept_subscriptions/README.wiki new file mode 100644 index 0000000..38dc3eb --- /dev/null +++ b/vnctalk/mod_auto_accept_subscriptions/README.wiki @@ -0,0 +1,27 @@ +#summary Automatically accept incoming subscription requests on behalf of users +#labels Stage-Beta + += Introduction = + +In some environments where all users on the system have mutual trust in each other, it's sometimes fine to skip the usual authorization process to +add someone to your contact list and see their status. + +This module sets Prosody to automatically accept incoming subscription authorization requests, and add the contact to the user's contact list, without intervention from the user. + += Configuration = +Simply add the module to your modules_enabled list like any other module: + +{{{ + modules_enabled = { + ... + "auto_accept_subscriptions"; + ... + } +}}} + +This module has no further configuration. + += Compatibility = +||trunk||Works|| +||0.9||Works|| +||0.8||Works|| \ No newline at end of file diff --git a/vnctalk/mod_auto_accept_subscriptions/mod_auto_accept_subscriptions.lua b/vnctalk/mod_auto_accept_subscriptions/mod_auto_accept_subscriptions.lua new file mode 100644 index 0000000..a388193 --- /dev/null +++ b/vnctalk/mod_auto_accept_subscriptions/mod_auto_accept_subscriptions.lua @@ -0,0 +1,96 @@ +local usermanager = require "core.usermanager"; +local rostermanager = require "core.rostermanager"; +local storagemanager = require "core.storagemanager"; +local jid = require "util.jid"; +local st = require "util.stanza"; +local core_post_stanza = prosody.core_post_stanza; + +-- Unsubscribes user from contact (not contact from user, if subscribed). +function unsubscribe(user_jid, contact_jid) + local user_username, user_host = jid.split(user_jid); + local contact_username, contact_host = jid.split(contact_jid); + if not hosts[user_host] then + warn("The host '%s' is not configured for this server.", user_host); + return; + end + if hosts[user_host].users.name == "null" then + storagemanager.initialize_host(user_host); + usermanager.initialize_host(user_host); + end + -- Update user's roster to say subscription is cancelled... + rostermanager.unsubscribe(user_username, user_host, contact_jid); + if hosts[contact_host] then + if contact_host ~= user_host and hosts[contact_host].users.name == "null" then + storagemanager.initialize_host(contact_host); + usermanager.initialize_host(contact_host); + end + -- Update contact's roster to say subscription is cancelled... + rostermanager.unsubscribed(contact_username, contact_host, user_jid); + end +end + + +-- Cancel any subscription in either direction. +function unsubscribe_both(jid1, jid2) + unsubscribe(jid1, jid2); + unsubscribe(jid2, jid1); +end + + +local function handle_inbound_subscription_request(origin, stanza) + local to_bare, from_bare = jid.bare(stanza.attr.to), jid.bare(stanza.attr.from); + local node, host = jid.split(to_bare); + stanza.attr.from, stanza.attr.to = from_bare, to_bare; + module:log("info", "Auto-accepting inbound subscription request from %s to %s", tostring(from_bare), tostring(to_bare)); + + local do_process = host or nil; + if host then + if not rostermanager.is_contact_subscribed(node, host, from_bare) then + core_post_stanza(hosts[host], st.presence({from=to_bare, to=from_bare, type="unavailable"}), true); -- acknowledging receipt + -- module:log("info", "receipt acknowledged"); + if rostermanager.set_contact_pending_in(node, host, from_bare) then + -- module:log("info", "set pending in"); + if rostermanager.subscribed(node, host, from_bare) then + -- module:log("info", "set subscribed"); + rostermanager.roster_push(node, host, to_bare); + -- module:log("info", "pushed roster item"); + local subscribed_stanza = st.reply(stanza); + subscribed_stanza.attr.type = "subscribed"; + core_post_stanza(hosts[host], subscribed_stanza); + -- module:log("info", "sent subscribed"); + hosts[host].modules.presence.send_presence_of_available_resources(node, host, to_bare, origin); + -- module:log("info", "sent available presence of all resources"); + -- Add return subscription from user to contact + local subscribe_stanza = st.reply(stanza); + subscribe_stanza.attr.type = "subscribe"; + if rostermanager.set_contact_pending_out(node, host, from_bare) then + rostermanager.roster_push(node, host, from_bare); + end + core_post_stanza(hosts[host], subscribe_stanza); + return true; + end + end + end + else + module:log("warn", "Failed to auto-accept subscription request from %s to %s", tostring(from_bare), tostring(to_bare)); + return true; + end +end + +local function handle_inbound_unsubscription_request(origin, stanza) + local to_bare, from_bare = jid.bare(stanza.attr.to), jid.bare(stanza.attr.from); + local node, host = jid.split(to_bare); + stanza.attr.from, stanza.attr.to = from_bare, to_bare; + -- module:log("info", "auto-unsubscribing subscription request from %s to %s", tostring(from_bare), tostring(to_bare)); + unsubscribe_both(from_bare, to_bare); +end + +module:hook("presence/bare", function (event) + local stanza = event.stanza; + if stanza.attr.type == "subscribe" then + handle_inbound_subscription_request(event.origin, stanza); + return true; + elseif stanza.attr.type == "unsubscribed" then + handle_inbound_unsubscription_request(event.origin, stanza); + end +end, 0.1); diff --git a/vnctalk/mod_carbons_copies/mod_carbons_copies.lua b/vnctalk/mod_carbons_copies/mod_carbons_copies.lua new file mode 100644 index 0000000..a798263 --- /dev/null +++ b/vnctalk/mod_carbons_copies/mod_carbons_copies.lua @@ -0,0 +1,63 @@ +-- Send carbons v0 style copies of incoming messages to clients which +-- are not (yet) capable of Message Carbons (XEP-0280). +-- +-- This extension integrates with the mod_carbons plugin in such a way +-- that a client capable of Message Carbons will not get a v0 copy. +-- +-- This extension can be enabled for all users by default by setting +-- carbons_copies_default = true. +-- +-- Alternatively or additionally setting carbons_copies_adhoc = true +-- will allow the user to enable or disable copies through Adhoc +-- commands. +-- +-- Copyright (C) 2012 Michael Holzt +-- +-- This file is MIT/X11 licensed. + +local jid_split = require "util.jid".split; +local dm_load = require "util.datamanager".load; +local dm_store = require "util.datamanager".store; +local adhoc_new = module:require "adhoc".new; +local xmlns_carbons_v0 = "urn:xmpp:carbons:0"; +local storename = "mod_carbons_copies"; + +local function toggle_copies(data, on) + local username, hostname, resource = jid_split(data.from); + dm_store(username, hostname, storename, { enabled = on }); +end + +local function adhoc_enable_copies(self, data, state) + toggle_copies(data, true); + return { info = "Copies are enabled for you now.\nPlease restart/reconnect clients.", status = "completed" }; +end + +local function adhoc_disable_copies(self, data, state) + toggle_copies(data, false); + return { info = "Copies are disabled for you now.\nPlease restart/reconnect clients.", status = "completed" }; +end + +module:hook("resource-bind", function(event) + local session = event.session; + local username, hostname, resource = jid_split(session.full_jid); + + local store = dm_load(username, hostname, storename) or + { enabled = + module:get_option_boolean("carbons_copies_default") }; + + if store.enabled then + session.want_carbons = xmlns_carbons_v0; + module:log("debug", "%s enabling copies", session.full_jid); + end +end); + +-- Adhoc-Support +if module:get_option_boolean("carbons_copies_adhoc") then + local enable_desc = adhoc_new("Carbons: Enable Copies", + "mod_carbons_copies#enable", adhoc_enable_copies); + local disable_desc = adhoc_new("Carbons: Disable Copies", + "mod_carbons_copies#disable", adhoc_disable_copies); + + module:add_item("adhoc", enable_desc); + module:add_item("adhoc", disable_desc); +end diff --git a/vnctalk/mod_discoitems/README.markdown b/vnctalk/mod_discoitems/README.markdown new file mode 100644 index 0000000..28b3b3a --- /dev/null +++ b/vnctalk/mod_discoitems/README.markdown @@ -0,0 +1,44 @@ +--- +labels: +- 'Stage-Beta' +summary: Manually override the list of service discovery items +... + +Introduction +============ + +This Prosody plugin lets you manually override the service discovery +items for a host. + +Usage +===== + +Simply add `"discoitems"` to your modules\_enabled list. Then add the +`disco_items` option to hosts for which you wish to override the default +response. + +Note: mod\_disco in Prosody 0.8+ supports the `disco_items` option; this +plugin changes the behavior from appending items to replacing items + +Configuration +============= + +The `disco_items` option can be added to relevant hosts: + + disco_items = { + {"proxy.eu.jabber.org", "Jabber.org SOCKS5 service"}; + {"conference.jabber.org", "The Jabber.org MUC"}; + } + +The format for individual items is `{JID, display-name}`. The +display-name can be omitted: `{JID}`. + +Compatibility +============= + + ----- ------------- + 0.8 Works + 0.7 Works + 0.6 Works + 0.5 Should work + ----- ------------- diff --git a/vnctalk/mod_discoitems/README.wiki b/vnctalk/mod_discoitems/README.wiki new file mode 100644 index 0000000..ca0d039 --- /dev/null +++ b/vnctalk/mod_discoitems/README.wiki @@ -0,0 +1,32 @@ +#summary Manually override the list of service discovery items +#labels Stage-Beta + += Introduction = + +This Prosody plugin lets you manually override the service discovery items for a host. + += Usage = + +Simply add `"discoitems"` to your modules_enabled list. Then add the `disco_items` option to hosts for which you wish to override the default response. + +Note: mod_disco in Prosody 0.8+ supports the `disco_items` option; this plugin changes the behavior from appending items to replacing items + += Configuration = + +The `disco_items` option can be added to relevant hosts: + +{{{ +disco_items = { + {"proxy.eu.jabber.org", "Jabber.org SOCKS5 service"}; + {"conference.jabber.org", "The Jabber.org MUC"}; +} +}}} + +The format for individual items is `{JID, display-name}`. The display-name can be omitted: `{JID}`. + += Compatibility = +||0.8||Works|| +||0.7||Works|| +||0.6||Works|| +||0.5||Should work|| + diff --git a/vnctalk/mod_discoitems/mod_discoitems.lua b/vnctalk/mod_discoitems/mod_discoitems.lua new file mode 100644 index 0000000..0a1ca68 --- /dev/null +++ b/vnctalk/mod_discoitems/mod_discoitems.lua @@ -0,0 +1,25 @@ +-- mod_discoitems.lua +-- +-- In the config, you can add: +-- +-- disco_items = { +-- {"proxy.eu.jabber.org", "Jabber.org SOCKS5 service"}; +-- {"conference.jabber.org", "The Jabber.org MUC"}; +-- }; +-- + +local st = require "util.stanza"; + +local result_query = st.stanza("query", {xmlns="http://jabber.org/protocol/disco#items"}); +for _, item in ipairs(module:get_option("disco_items") or {}) do + result_query:tag("item", {jid=item[1], name=item[2]}):up(); +end + +module:hook('iq/host/http://jabber.org/protocol/disco#items:query', function(event) + local stanza = event.stanza; + local query = stanza.tags[1]; + if stanza.attr.type == 'get' and not query.attr.node then + event.origin.send(st.reply(stanza):add_child(result_query)); + return true; + end +end, 100); diff --git a/vnctalk/mod_filter_chatstates/README.markdown b/vnctalk/mod_filter_chatstates/README.markdown new file mode 100644 index 0000000..66d8898 --- /dev/null +++ b/vnctalk/mod_filter_chatstates/README.markdown @@ -0,0 +1,26 @@ +--- +summary: Drop chat states from messages to inactive sessions +... + +Introduction +============ + +Some mobile XMPP client developers consider [Chat State +Notifications](http://xmpp.org/extensions/xep-0085.html) to be a waste +of power and bandwidth, especially when the user is not actively looking +at their device. This module will filter them out while the session is +considered inactive. It depends on [mod\_csi](/mod_csi.html) for +deciding when to begin and end filtering. + +Configuration +============= + +There is no configuration for this module, just add it to +modules\_enabled as normal. + +Compatibility +============= + + ----- ------- + 0.9 Works + ----- ------- diff --git a/vnctalk/mod_filter_chatstates/README.wiki b/vnctalk/mod_filter_chatstates/README.wiki new file mode 100644 index 0000000..7bf17d3 --- /dev/null +++ b/vnctalk/mod_filter_chatstates/README.wiki @@ -0,0 +1,18 @@ +#summary Drop chat states from messages to inactive sessions + += Introduction = + +Some mobile XMPP client developers consider +[http://xmpp.org/extensions/xep-0085.html Chat State Notifications] to be +a waste of power and bandwidth, especially when the user is not actively +looking at their device. This module will filter them out while the +session is considered inactive. It depends on `mod_csi` for deciding +when to begin and end filtering. + += Configuration = + +There is no configuration for this module, just add it to modules_enabled as normal. + += Compatibility = +||0.9||Works|| + diff --git a/vnctalk/mod_filter_chatstates/mod_filter_chatstates.lua b/vnctalk/mod_filter_chatstates/mod_filter_chatstates.lua new file mode 100644 index 0000000..ea21578 --- /dev/null +++ b/vnctalk/mod_filter_chatstates/mod_filter_chatstates.lua @@ -0,0 +1,31 @@ +local filters = require "util.filters"; +local st = require "util.stanza"; + +module:depends("csi"); + +local function chatstate_tag_filter(tag) + if tag.attr.xmlns ~= "http://jabber.org/protocol/chatstates" then + return tag; + end +end + +local function filter_chatstates(stanza) + if stanza.name == "message" then + stanza = st.clone(stanza); + stanza:maptags(chatstate_tag_filter); + if #stanza.tags == 0 then + return nil; + end + end + return stanza; +end + +module:hook("csi-client-inactive", function (event) + local session = event.origin; + filters.add_filter(session, "stanzas/out", filter_chatstates); +end); + +module:hook("csi-client-active", function (event) + local session = event.origin; + filters.remove_filter(session, "stanzas/out", filter_chatstates); +end); diff --git a/vnctalk/mod_http_altconnect/mod_http_altconnect.lua b/vnctalk/mod_http_altconnect/mod_http_altconnect.lua new file mode 100644 index 0000000..54fd6fa --- /dev/null +++ b/vnctalk/mod_http_altconnect/mod_http_altconnect.lua @@ -0,0 +1,60 @@ +-- mod_http_altconnect +-- XEP-0156: Discovering Alternative XMPP Connection Methods + +module:depends"http"; + +local json = require"util.json"; +local st = require"util.stanza"; +local array = require"util.array"; + +local host_modules = hosts[module.host].modules; + +local function get_supported() + local uris = array(); + if host_modules["bosh"] then + uris:push({ rel = "urn:xmpp:alt-connections:xbosh", href = module:http_url("bosh", "/http-bind") }); + end + if host_modules["websocket"] then + uris:push({ rel = "urn:xmpp:alt-connections:websocket", href = module:http_url("websocket", "xmpp-websocket"):gsub("^http", "ws") }); + end + return uris; +end + + +local function GET_xml(event) + local request, response = event.request, event.response; + local xrd = st.stanza("XRD", { xmlns='http://docs.oasis-open.org/ns/xri/xrd-1.0' }); + local uris = get_supported(); + for i, method in ipairs(uris) do + xrd:tag("Link", method):up(); + end + response.headers.content_type = "application/xrd+xml" + response.headers.access_control_allow_origin = "*"; + return '' .. tostring(xrd); +end + +local function GET_json(event) + local request, response = event.request, event.response; + local jrd = { links = get_supported() }; + response.headers.content_type = "application/json" + response.headers.access_control_allow_origin = "*"; + return json.encode(jrd); +end; + +local function GET_either(event) + local accept_type = event.request.headers.accept or ""; + if ( accept_type:find("xml") or #accept_type ) < ( accept_type:find("json") or #accept_type+1 ) then + return GET_xml(event); + else + return GET_json(event); + end +end; + +module:provides("http", { + default_path = "/.well-known"; + route = { + ["GET /host-meta"] = GET_either; + -- ["GET /host-meta.xml"] = GET_xml; -- Hmmm + ["GET /host-meta.json"] = GET_json; + }; +}); diff --git a/vnctalk/mod_http_index/http_index.html b/vnctalk/mod_http_index/http_index.html new file mode 100644 index 0000000..32ff7de --- /dev/null +++ b/vnctalk/mod_http_index/http_index.html @@ -0,0 +1,54 @@ + + + + + + + +{title} + + + +
+

Prosody IM

+

HTTP Services

+
+
+
+ +
+
+
+
+
Prosody {prosody_version?}
+
+ + diff --git a/vnctalk/mod_http_index/mod_http_index.lua b/vnctalk/mod_http_index/mod_http_index.lua new file mode 100644 index 0000000..c1d24c0 --- /dev/null +++ b/vnctalk/mod_http_index/mod_http_index.lua @@ -0,0 +1,58 @@ +local url = require"socket.url"; +local render = require"util.interpolation".new("%b{}", require"util.stanza".xml_escape); + +module:depends"http"; + +local show_all = module:get_option_boolean(module.name .. "_show_all", false); + +local base_template; +do + local template_file = module:get_option_string(module.name .. "_template", module.name .. ".html"); + template_file = assert(module:load_resource(template_file)); + base_template = template_file:read("*a"); + template_file:close(); +end + +local canonical = module:http_url(nil, "/"); + +local function relative(base, link) + base = url.parse(base); + link = url.parse(link); + for k,v in pairs(base) do + if link[k] == v then + link[k] = nil; + end + end + return url.build(link); +end + +local function handler(event) + local host_items = module:get_host_items("http-provider"); + local http_apps = {} + for _, item in ipairs(host_items) do + if module.name ~= item._provided_by and (show_all or item.title) then + table.insert(http_apps, { + title = item.title or item.name; + name = item.name; + module = "mod_" .. item._provided_by; + url = relative(canonical, module:http_url(item.name, item.default_path)); + }); + end + end + table.sort(http_apps, function (a, b) return a.name < b.name; end); + event.response.headers.content_type = "text/html"; + return render(base_template, { + title = "Prosody IM - HTTP Services"; + items = http_apps; + prosody_version = prosody.version; + mod_name = module.name; + canonical = canonical; + }); +end + +module:provides("http", { + route = { + ["GET /"] = handler; + }; + default_path = "/"; +}); diff --git a/vnctalk/mod_http_rest/README.markdown b/vnctalk/mod_http_rest/README.markdown new file mode 100644 index 0000000..da793a5 --- /dev/null +++ b/vnctalk/mod_http_rest/README.markdown @@ -0,0 +1,29 @@ +--- +labels: +- 'Stage-Alpha' +summary: Send XMPP stanzas via REST/HTTP +... + +This module provides a [REST](https://en.wikipedia.org/wiki/Representational_state_transfer)ful +method for sending XMPP stanzas. + +This enables you to send stanzas by making HTTP requests to `http://${prosody-url}/rest`. + +**DANGER/ACHTUNG!: This module does NOT enforce any authentication or user-checking. +This means that by default stanzas can be sent *anyone* on behalf of *any* user.** + +You should enable [mod_http_authentication](https://modules.prosody.im/mod_http_authentication.html), +to require authentication for calls made to this module, or alternatively, you +could use a reverse proxy like Nginx. + +# To enable this module + +Add `"http_rest"` to `modules_enabled`, either globally or for a particular virtual +host. + +# How to test: + +You can use curl to make the HTTP request to Prosody, to test whether this +module is working properly: + + curl -k http://localhost:5280/rest -u username:password -H "Content-Type: text/xml" -d 'Hello World!' diff --git a/vnctalk/mod_http_rest/mod_http_rest.lua b/vnctalk/mod_http_rest/mod_http_rest.lua new file mode 100644 index 0000000..fa2d1d5 --- /dev/null +++ b/vnctalk/mod_http_rest/mod_http_rest.lua @@ -0,0 +1,46 @@ +module:depends"http" + +local jid_split = require "util.jid".split; +local jid_prep = require "util.jid".prep; +local stanza = require "util.stanza"; +local test_password = require "core.usermanager".test_password; +local b64_decode = require "util.encodings".base64.decode; +local formdecode = require "net.http".formdecode; +-- local xml = require"util.xml"; +local xml_parse = require "util.xml".parse; + +local function handle_post(event, path, authed_user) + local request = event.request; + local headers = request.headers; + local body_type = headers.content_type; + -- module:log("info","got body type: %s", body_type); + -- module:log("info","request body: %s", request.body); + if body_type == "text/xml" and request.body then + local parsed, err = xml_parse(request.body); + -- module:log("info","parsed: %s", parsed); + if parsed then + module:log("info", "Sending %s", parsed); + local parsed_stanza = stanza.deserialize(parsed); + module:fire_event("vnc-rest-message", { stanza = parsed_stanza , origin = "" } ); + module:send(parsed); + return 201; + end + else + -- module:log("info","got error: %s", err); + return 415; + end + return 422; +end + +module:provides("http", { + default_path = "/rest"; + route = { + ["POST"] = handle_post; + OPTIONS = function(e) + local headers = e.response.headers; + headers.allow = "POST"; + headers.accept = "text/xml"; + return 200; + end; + } +}); diff --git a/vnctalk/mod_http_upload_external/README.markdown b/vnctalk/mod_http_upload_external/README.markdown new file mode 100644 index 0000000..db000ba --- /dev/null +++ b/vnctalk/mod_http_upload_external/README.markdown @@ -0,0 +1,108 @@ +--- +description: HTTP File Upload (external service) +labels: 'Stage-Alpha' +--- + +Introduction +============ + +This module implements [XEP-0363], which lets clients upload files +over HTTP to an external web server. + +This module generates URLs that are signed using a HMAC. Any web service that can authenticate +these URLs can be used. There is a PHP implementation available +[here](https://hg.prosody.im/prosody-modules/raw-file/tip/mod_http_upload_external/share.php). To +implement your own service compatible with this module, check out the implementation notes below (and if you +publish your implementation - let us know!). + +Configuration +============= + +Add `"http_upload_external"` to modules_enabled in your global section, or under the host(s) you wish +to use it on. + +External URL +------------ + +You need to provide the path to the external service. Ensure it ends with '/'. + +For example, to use the PHP implementation linked above, you might set it to: + +``` {.lua} +http_upload_external_base_url = "https://your.example.com/path/to/share.php/" +``` + +Secret +------ + +Set a long and unpredictable string as your secret. This is so the upload service can verify that +the upload comes from mod_http_upload_external, and random strangers can't upload to your server. + +``` {.lua} +http_upload_external_secret = "this is a secret string!" +``` + +You need to set exactly the same secret string in your external service. + +Limits +------ + +A maximum file size can be set by: + +``` {.lua} +http_upload_external_file_size_limit = 123 -- bytes +``` + +Default is 100MB (100\*1024\*1024). + +Compatibility +============= + +Works with Prosody 0.9.x and later. + +Implementation +============== + +To implement your own external service that is compatible with this module, you need to expose a +simple API that allows the HTTP GET, HEAD and PUT methods on arbitrary URLs located on your service. + +For example, if http_upload_external_base_url is set to `https://example.com/upload/` then your service +might receive the following requests: + +Upload a new file: + +``` +PUT https://example.com/upload/foo/bar.jpg?v=49e9309ff543ace93d25be90635ba8e9965c4f23fc885b2d86c947a5d59e55b2 +``` + +Recipient checks the file size and other headers: + +``` +HEAD https://example.com/upload/foo/bar.jpg +``` + +Recipient downloads the file: + +``` +GET https://example.com/upload/foo/bar.jpg +``` + +The only tricky logic is in validation of the PUT request. Firstly, don't overwrite existing files (return 409 Conflict). + +Then you need to validate the auth token. This will be in the URL query parameter 'v'. If it is absent, fail with 403 Forbidden. + +Calculate the expected auth token by reading the value of the Content-Length header of the PUT request. E.g. for a 1MB file +will have a Content-Length of '1048576'. Append this to the uploaded file name, separated by a space (0x20) character. + +For the above example, you would end up with the following string: "foo/bar.jpg 1048576" + +The auth token is a SHA256 HMAC of this string, using the configured secret as the key. E.g. + +``` +calculated_auth_token = hmac_sha256("foo/bar.jpg 1048576", "secret string") +``` + +If this is not equal to the 'v' parameter provided in the upload URL, reject the upload with 403 Forbidden. + +Note: your language/environment may provide a function for doing a constant-time comparison of these, to guard against +timing attacks that may be used to discover the secret key. diff --git a/vnctalk/mod_http_upload_external/mod_http_upload_external.lua b/vnctalk/mod_http_upload_external/mod_http_upload_external.lua new file mode 100644 index 0000000..67ee671 --- /dev/null +++ b/vnctalk/mod_http_upload_external/mod_http_upload_external.lua @@ -0,0 +1,126 @@ +-- mod_http_upload_external +-- +-- Copyright (C) 2015-2016 Kim Alvefur +-- +-- This file is MIT/X11 licensed. +-- + +-- imports +local st = require"util.stanza"; +local uuid = require"util.uuid".generate; +local http = require "util.http"; +local dataform = require "util.dataforms".new; +local HMAC = require "util.hashes".hmac_sha256; + +-- config +local file_size_limit = module:get_option_number(module.name .. "_file_size_limit", 100 * 1024 * 1024); -- 100 MB +local base_url = assert(module:get_option_string(module.name .. "_base_url"), module.name .. "_base_url is a required option"); +local secret = assert(module:get_option_string(module.name .. "_secret"), module.name .. "_secret is a required option"); + +-- depends +module:depends("disco"); + +-- namespace +local legacy_namespace = "urn:xmpp:http:upload"; +local namespace = "urn:xmpp:http:upload:0"; + +-- identity and feature advertising +module:add_identity("store", "file", module:get_option_string("name", "HTTP File Upload")) +module:add_feature(namespace); +module:add_feature(legacy_namespace); + +module:add_extension(dataform { + { name = "FORM_TYPE", type = "hidden", value = namespace }, + { name = "max-file-size", type = "text-single" }, +}:form({ ["max-file-size"] = tostring(file_size_limit) }, "result")); + +module:add_extension(dataform { + { name = "FORM_TYPE", type = "hidden", value = legacy_namespace }, + { name = "max-file-size", type = "text-single" }, +}:form({ ["max-file-size"] = tostring(file_size_limit) }, "result")); + +local function magic_crypto_dust(random, filename, filesize) + local message = string.format("%s/%s %d", random, filename, filesize); + local digest = HMAC(secret, message, true); + random, filename = http.urlencode(random), http.urlencode(filename); + return base_url .. random .. "/" .. filename, "?v=" .. digest; +end + +local function handle_request(origin, stanza, xmlns, filename, filesize) + -- local clients only + if origin.type ~= "c2s" then + module:log("debug", "Request for upload slot from a %s", origin.type); + origin.send(st.error_reply(stanza, "cancel", "not-authorized")); + return nil, nil; + end + -- validate + if not filename or filename:find("/") then + module:log("debug", "Filename %q not allowed", filename or ""); + origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid filename")); + return nil, nil; + end + if not filesize then + module:log("debug", "Missing file size"); + origin.send(st.error_reply(stanza, "modify", "bad-request", "Missing or invalid file size")); + return nil, nil; + elseif filesize > file_size_limit then + module:log("debug", "File too large (%d > %d)", filesize, file_size_limit); + origin.send(st.error_reply(stanza, "modify", "not-acceptable", "File too large", + st.stanza("file-too-large", {xmlns=xmlns}) + :tag("max-size"):text(tostring(file_size_limit)))); + return nil, nil; + end + local random = uuid(); + local get_url, verify = magic_crypto_dust(random, filename, filesize); + local put_url = get_url .. verify; + + module:log("info", "Handing out upload slot %s to %s@%s", get_url, origin.username, origin.host); + + return get_url, put_url; +end + +-- hooks +module:hook("iq/host/"..legacy_namespace..":request", function (event) + local stanza, origin = event.stanza, event.origin; + local request = stanza.tags[1]; + local filename = request:get_child_text("filename"); + local filesize = tonumber(request:get_child_text("size")); + + local get_url, put_url = handle_request( + origin, stanza, legacy_namespace, filename, filesize); + + if not get_url then + -- error was already sent + return true; + end + + local reply = st.reply(stanza) + :tag("slot", { xmlns = legacy_namespace }) + :tag("get"):text(get_url):up() + :tag("put"):text(put_url):up() + :up(); + origin.send(reply); + return true; +end); + +module:hook("iq/host/"..namespace..":request", function (event) + local stanza, origin = event.stanza, event.origin; + local request = stanza.tags[1]; + local filename = request.attr.filename; + local filesize = tonumber(request.attr.size); + local get_url, put_url = handle_request( + origin, stanza, legacy_namespace, filename, filesize); + + if not get_url then + -- error was already sent + return true; + end + + local reply = st.reply(stanza) + :tag("slot", { xmlns = namespace}) + :tag("get", { url = get_url }):up() + :tag("put", { url = put_url }):up() + :up(); + origin.send(reply); + return true; +end); diff --git a/vnctalk/mod_http_upload_external/share.php b/vnctalk/mod_http_upload_external/share.php new file mode 100644 index 0000000..4f51113 --- /dev/null +++ b/vnctalk/mod_http_upload_external/share.php @@ -0,0 +1,119 @@ + + + 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. + +*/ + +/*\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\*/ +/* CONFIGURATION OPTIONS */ +/*\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\*/ + +/* Change this to a directory that is writable by your web server, but is outside your web root */ +$CONFIG_STORE_DIR = '/tmp'; + +/* This must be the same as 'http_upload_external_secret' that you set in Prosody's config file */ +$CONFIG_SECRET = 'this is your secret string'; + +/* For people who need options to tweak that they don't understand... here you are */ +$CONFIG_CHUNK_SIZE = 4096; + +/*\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\*/ +/* END OF CONFIGURATION */ +/*\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\*/ + +/* Do not edit below this line unless you know what you are doing (spoiler: nobody does) */ + +$upload_file_name = substr($_SERVER['PHP_SELF'], strlen($_SERVER['SCRIPT_NAME'])+1); +$store_file_name = $CONFIG_STORE_DIR . '/store-' . hash('sha256', $upload_file_name); + +$request_method = $_SERVER['REQUEST_METHOD']; + +if(array_key_exists('v', $_GET) === TRUE && $request_method === 'PUT') { + $headers = getallheaders(); + + $upload_file_size = $headers['Content-Length']; + $upload_token = $_GET['v']; + + $calculated_token = hash_hmac('sha256', "$upload_file_name $upload_file_size", $CONFIG_SECRET); + if($upload_token !== $calculated_token) { + header('HTTP/1.0 403 Forbidden'); + exit; + } + + /* Open a file for writing */ + $store_file = fopen($store_file_name, 'x'); + + if($store_file === FALSE) { + header('HTTP/1.0 409 Conflict'); + exit; + } + + /* PUT data comes in on the stdin stream */ + $incoming_data = fopen('php://input', 'r'); + + /* Read the data a chunk at a time and write to the file */ + while ($data = fread($incoming_data, $CONFIG_CHUNK_SIZE)) { + fwrite($store_file, $data); + } + + /* Close the streams */ + fclose($incoming_data); + fclose($store_file); +} else if($request_method === 'GET' || $request_method === 'HEAD') { + // Send file (using X-Sendfile would be nice here...) + if(file_exists($store_file_name)) { + header('Content-Disposition: attachment'); + header('Content-Type: application/octet-stream'); + header('Content-Length: '.filesize($store_file_name)); + if($request_method !== 'HEAD') { + readfile($store_file_name); + } + } else { + header('HTTP/1.0 404 Not Found'); + } +} else { + header('HTTP/1.0 400 Bad Request'); +} + +exit; diff --git a/vnctalk/mod_idlecompat/README.markdown b/vnctalk/mod_idlecompat/README.markdown new file mode 100644 index 0000000..b488cb2 --- /dev/null +++ b/vnctalk/mod_idlecompat/README.markdown @@ -0,0 +1,24 @@ +--- +labels: +- 'Stage-Beta' +summary: 'XEP-0319 compatibility module' +... + +Introduction +============ + +This module adds [XEP-0319](http://xmpp.org/extensions/xep-0319.html) +idle tags to presence stanzas containing [XEP-0012: Last +Activity](http://xmpp.org/extensions/xep-0012.html) tags for idle +indication (e.g. supported by libpurple clients). It works on outgoing +and incoming presence stanzas. + +Install and enable it like any other module. It has no configuration. + +Compatibility +============= + + ------- ------- + trunk Works + 0.9 Works + ------- ------- diff --git a/vnctalk/mod_idlecompat/README.wiki b/vnctalk/mod_idlecompat/README.wiki new file mode 100644 index 0000000..8ceb64f --- /dev/null +++ b/vnctalk/mod_idlecompat/README.wiki @@ -0,0 +1,13 @@ +#summary XEP-0319 compatibility module +#labels Stage-Beta + += Introduction = + +This module adds [http://xmpp.org/extensions/xep-0319.html XEP-0319] idle tags to presence stanzas containing [http://xmpp.org/extensions/xep-0012.html XEP-0012: Last Activity] tags for idle indication (e.g. supported by libpurple clients). It works on outgoing and incoming presence stanzas. + +Install and enable it like any other module. It has no configuration. + += Compatibility = + +||trunk||Works|| +||0.9||Works|| diff --git a/vnctalk/mod_idlecompat/mod_idlecompat.lua b/vnctalk/mod_idlecompat/mod_idlecompat.lua new file mode 100644 index 0000000..1ceacb1 --- /dev/null +++ b/vnctalk/mod_idlecompat/mod_idlecompat.lua @@ -0,0 +1,31 @@ +-- Last User Interaction in Presence via Last Activity compatibility module +-- http://xmpp.org/extensions/xep-0319.html +-- http://xmpp.org/extensions/xep-0256.html +-- Copyright (C) 2014 Tobias Markmann +-- +-- This file is MIT/X11 licensed. + +local st = require "util.stanza"; +local datetime = require "util.datetime"; + +local function on_presence(event) + local stanza = event.stanza; + + local last_activity = stanza:get_child("query", "jabber:iq:last"); + local has_idle = stanza:get_child("idle", "urn:xmpp:idle:1"); + if last_activity and not has_idle then + module:log("debug", "Adding XEP-0319 tag from Last Activity."); + local seconds = last_activity.attr.seconds; + local last_userinteraction = datetime.datetime(os.time() - seconds); + stanza:tag("idle", { xmlns = "urn:xmpp:idle:1", since = last_userinteraction }):up(); + end +end + +-- incoming +module:hook("presence/full", on_presence, 900); +module:hook("presence/bare", on_presence, 900); + +-- outgoing +module:hook("pre-presence/bare", on_presence, 900); +module:hook("pre-presence/full", on_presence, 900); +module:hook("pre-presence/host", on_presence, 900); diff --git a/vnctalk/mod_roster_command.lua b/vnctalk/mod_roster_command.lua new file mode 100644 index 0000000..67ac9ca --- /dev/null +++ b/vnctalk/mod_roster_command.lua @@ -0,0 +1,165 @@ +----------------------------------------------------------- +-- mod_roster_command: Manage rosters through prosodyctl +-- version 0.02 +----------------------------------------------------------- +-- Copyright (C) 2011 Matthew Wild +-- Copyright (C) 2011 Adam Nielsen +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +----------------------------------------------------------- + +if module.host ~= "*" then + module:log("error", "Do not load this module in Prosody, for correct usage see: https://modules.prosody.im/mod_roster_command.html"); + return; +end + + +-- Workaround for lack of util.startup... +local prosody = _G.prosody; +local hosts = prosody.hosts; +prosody.bare_sessions = prosody.bare_sessions or {}; +_G.bare_sessions = _G.bare_sessions or prosody.bare_sessions; + +local usermanager = require "core.usermanager"; +local rostermanager = require "core.rostermanager"; +local storagemanager = require "core.storagemanager"; +local jid = require "util.jid"; +local warn = require"util.prosodyctl".show_warning; + +-- Make a *one-way* subscription. User will see when contact is online, +-- contact will not see when user is online. +function subscribe(user_jid, contact_jid) + local user_username, user_host = jid.split(user_jid); + local contact_username, contact_host = jid.split(contact_jid); + if not hosts[user_host] then + warn("The host '%s' is not configured for this server.", user_host); + return; + end + if hosts[user_host].users.name == "null" then + storagemanager.initialize_host(user_host); + usermanager.initialize_host(user_host); + end + -- Update user's roster to say subscription request is pending. Bare hosts (e.g. components) don't have rosters. + if user_username ~= nil then + rostermanager.set_contact_pending_out(user_username, user_host, contact_jid); + end + if hosts[contact_host] then + if contact_host ~= user_host and hosts[contact_host].users.name == "null" then + storagemanager.initialize_host(contact_host); + usermanager.initialize_host(contact_host); + end + -- Update contact's roster to say subscription request is pending... + rostermanager.set_contact_pending_in(contact_username, contact_host, user_jid); + -- Update contact's roster to say subscription request approved... + rostermanager.subscribed(contact_username, contact_host, user_jid); + -- Update user's roster to say subscription request approved. Bare hosts (e.g. components) don't have rosters. + if user_username ~= nil then + rostermanager.process_inbound_subscription_approval(user_username, user_host, contact_jid); + end + end +end + +-- Make a mutual subscription between jid1 and jid2. Each JID will see +-- when the other one is online. +function subscribe_both(jid1, jid2) + subscribe(jid1, jid2); + subscribe(jid2, jid1); +end + +-- Unsubscribes user from contact (not contact from user, if subscribed). +function unsubscribe(user_jid, contact_jid) + local user_username, user_host = jid.split(user_jid); + local contact_username, contact_host = jid.split(contact_jid); + if not hosts[user_host] then + warn("The host '%s' is not configured for this server.", user_host); + return; + end + if hosts[user_host].users.name == "null" then + storagemanager.initialize_host(user_host); + usermanager.initialize_host(user_host); + end + -- Update user's roster to say subscription is cancelled... + rostermanager.unsubscribe(user_username, user_host, contact_jid); + if hosts[contact_host] then + if contact_host ~= user_host and hosts[contact_host].users.name == "null" then + storagemanager.initialize_host(contact_host); + usermanager.initialize_host(contact_host); + end + -- Update contact's roster to say subscription is cancelled... + rostermanager.unsubscribed(contact_username, contact_host, user_jid); + end +end + +-- Cancel any subscription in either direction. +function unsubscribe_both(jid1, jid2) + unsubscribe(jid1, jid2); + unsubscribe(jid2, jid1); +end + +-- Set the name shown and group used in the contact list +function rename(user_jid, contact_jid, contact_nick, contact_group) + local user_username, user_host = jid.split(user_jid); + if not hosts[user_host] then + warn("The host '%s' is not configured for this server.", user_host); + return; + end + if hosts[user_host].users.name == "null" then + storagemanager.initialize_host(user_host); + usermanager.initialize_host(user_host); + end + + -- Load user's roster and find the contact + local roster = rostermanager.load_roster(user_username, user_host); + local item = roster[contact_jid]; + if item then + if contact_nick then + item.name = contact_nick; + end + if contact_group then + item.groups = {}; -- Remove from all current groups + item.groups[contact_group] = true; + end + rostermanager.save_roster(user_username, user_host, roster); + end +end + +function remove(user_jid, contact_jid) + unsubscribe_both(user_jid, contact_jid); + local user_username, user_host = jid.split(user_jid); + local roster = rostermanager.load_roster(user_username, user_host); + roster[contact_jid] = nil; + rostermanager.save_roster(user_username, user_host, roster); +end + +function module.command(arg) + local command = arg[1]; + if not command then + warn("Valid subcommands: (un)subscribe(_both) | rename"); + return 0; + end + table.remove(arg, 1); + if command == "subscribe" then + subscribe(arg[1], arg[2]); + return 0; + elseif command == "subscribe_both" then + subscribe_both(arg[1], arg[2]); + return 0; + elseif command == "unsubscribe" then + unsubscribe(arg[1], arg[2]); + return 0; + elseif command == "unsubscribe_both" then + unsubscribe_both(arg[1], arg[2]); + return 0; + elseif command == "remove" then + remove(arg[1], arg[2]); + return 0; + elseif command == "rename" then + rename(arg[1], arg[2], arg[3], arg[4]); + return 0; + else + warn("Unknown command: %s", command); + return 1; + end + return 0; +end diff --git a/vnctalk/mod_smacks/README.markdown b/vnctalk/mod_smacks/README.markdown new file mode 100644 index 0000000..dadf23b --- /dev/null +++ b/vnctalk/mod_smacks/README.markdown @@ -0,0 +1,63 @@ +--- +labels: +- 'Stage-Alpha' +summary: 'XEP-0198: Reliability and fast reconnects for XMPP' +... + +Introduction +============ + +By default XMPP is as reliable as your network is. Unfortunately in some +cases that is not very reliable - in some network conditions disconnects +can be frequent and message loss can occur. + +To overcome this, XMPP has an optional extension (XEP-0198: Stream +Management) which, when supported by both the client and server, can +allow a client to resume a disconnected session, and prevent message +loss. + +Details +======= + +When using XEP-0198 both the client and the server keep a queue of the +most recently sent stanzas - this is cleared when the other end +acknowledges they have received the stanzas. If the client disconnects, +instead of marking the user offline the server pretends the client is +still online for a short (configurable) period of time. If the client +reconnects within this period, any stanzas in the queue that the client +did not receive are re-sent. + +If the client fails to reconnect before the timeout then it is marked +offline as normal, and any stanzas in the queue are returned to the +sender as a "recipient-unavailable" error. + +Configuration +============= + + Option Default Description + ------------------------------ ----------------- ----------------------------------------------------------------------------------------- + `smacks_hibernation_time` 300 (5 minutes) The number of seconds a disconnected session should stay alive for (to allow reconnect) + `smacks_enabled_s2s` false Enable Stream Management on server connections? *Experimental* + `smacks_max_unacked_stanzas` 0 How many stanzas to send before requesting acknowledgement + +Compatibility +============= + + ----- ----------------------------------- + 0.9 Works + 0.8 Works, use version [7693724881b3] + ----- ----------------------------------- + + +Clients +======= + +Clients that support XEP-0198: + +- Gajim +- Swift (but not resumption, as of version 2.0 and alphas of 3.0) +- Psi (in an unreleased branch) +- Conversations +- Yaxim + +[7693724881b3]: //hg.prosody.im/prosody-modules/raw-file/7693724881b3/mod_smacks/mod_smacks.lua diff --git a/vnctalk/mod_smacks/README.wiki b/vnctalk/mod_smacks/README.wiki new file mode 100644 index 0000000..044b20b --- /dev/null +++ b/vnctalk/mod_smacks/README.wiki @@ -0,0 +1,31 @@ +#summary XEP-0198: Reliability and fast reconnects for XMPP +#labels Stage-Beta + += Introduction = + +By default XMPP is as reliable as your network is. Unfortunately in some cases that is not very reliable - in some network conditions disconnects can be frequent and message loss can occur. + +To overcome this, XMPP has an optional extension (XEP-0198: Stream Management) which, when supported by both the client and server, can allow a client to resume a disconnected session, and prevent message loss. + += Details = + +When using XEP-0198 both the client and the server keep a queue of the most recently sent stanzas - this is cleared when the other end acknowledges they have received the stanzas. If the client disconnects, instead of marking the user offline the server pretends the client is still online for a short (configurable) period of time. If the client reconnects within this period, any stanzas in the queue that the client did not receive are re-sent. + +If the client fails to reconnect before the timeout then it is marked offline as normal, and any stanzas in the queue are returned to the sender as a "recipient-unavailable" error. + += Configuration = + +|| *Option* || *Default* || *Description* || +|| smacks_hibernation_time || 300 (5 minutes) || The number of seconds a disconnected session should stay alive for (to allow reconnect) || + += Compatibility = +||0.9||Works|| +||0.8||Works, use version [http://prosody-modules.googlecode.com/hg-history/7693724881b3f3cdafa35763f00dd040d02313bf/mod_smacks/mod_smacks.lua 7693724881b3]|| + += Clients = +Clients that support XEP-0198: + * Gajim + * Swift (but not resumption, as of version 2.0 and alphas of 3.0) + * Psi (in an unreleased branch) + * Yaxim + diff --git a/vnctalk/mod_smacks/mod_smacks.lua b/vnctalk/mod_smacks/mod_smacks.lua new file mode 100644 index 0000000..21ba2ff --- /dev/null +++ b/vnctalk/mod_smacks/mod_smacks.lua @@ -0,0 +1,659 @@ +-- XEP-0198: Stream Management for Prosody IM +-- +-- Copyright (C) 2010-2015 Matthew Wild +-- Copyright (C) 2010 Waqas Hussain +-- Copyright (C) 2012-2015 Kim Alvefur +-- Copyright (C) 2012 Thijs Alkemade +-- Copyright (C) 2014 Florian Zeitz +-- Copyright (C) 2016-2020 Thilo Molitor +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local st = require "util.stanza"; +local dep = require "util.dependencies"; +local cache = dep.softreq("util.cache"); -- only available in prosody 0.10+ +local uuid_generate = require "util.uuid".generate; +local jid = require "util.jid"; + +local t_remove = table.remove; +local math_min = math.min; +local math_max = math.max; +local os_time = os.time; +local tonumber, tostring = tonumber, tostring; +local add_filter = require "util.filters".add_filter; +local timer = require "util.timer"; +local datetime = require "util.datetime"; + +local xmlns_sm2 = "urn:xmpp:sm:2"; +local xmlns_sm3 = "urn:xmpp:sm:3"; +local xmlns_errors = "urn:ietf:params:xml:ns:xmpp-stanzas"; +local xmlns_delay = "urn:xmpp:delay"; + +local sm2_attr = { xmlns = xmlns_sm2 }; +local sm3_attr = { xmlns = xmlns_sm3 }; + +local resume_timeout = module:get_option_number("smacks_hibernation_time", 600); +local s2s_smacks = module:get_option_boolean("smacks_enabled_s2s", false); +local s2s_resend = module:get_option_boolean("smacks_s2s_resend", false); +local max_unacked_stanzas = module:get_option_number("smacks_max_unacked_stanzas", 0); +local delayed_ack_timeout = module:get_option_number("smacks_max_ack_delay", 30); +local max_hibernated_sessions = module:get_option_number("smacks_max_hibernated_sessions", 10); +local max_old_sessions = module:get_option_number("smacks_max_old_sessions", 10); +local core_process_stanza = prosody.core_process_stanza; +local sessionmanager = require"core.sessionmanager"; + +assert(max_hibernated_sessions > 0, "smacks_max_hibernated_sessions must be greater than 0"); +assert(max_old_sessions > 0, "smacks_max_old_sessions must be greater than 0"); + +local c2s_sessions = module:shared("/*/c2s/sessions"); + +local function init_session_cache(max_entries, evict_callback) + -- old prosody version < 0.10 (no limiting at all!) + if not cache then + local store = {}; + return { + get = function(user, key) + if not user then return nil; end + if not key then return nil; end + return store[key]; + end; + set = function(user, key, value) + if not user then return nil; end + if not key then return nil; end + store[key] = value; + end; + }; + end + + -- use per user limited cache for prosody >= 0.10 + local stores = {}; + return { + get = function(user, key) + if not user then return nil; end + if not key then return nil; end + if not stores[user] then + stores[user] = cache.new(max_entries, evict_callback); + end + return stores[user]:get(key); + end; + set = function(user, key, value) + if not user then return nil; end + if not key then return nil; end + if not stores[user] then stores[user] = cache.new(max_entries, evict_callback); end + stores[user]:set(key, value); + -- remove empty caches completely + if not stores[user]:count() then stores[user] = nil; end + end; + }; +end +local old_session_registry = init_session_cache(max_old_sessions, nil); +local session_registry = init_session_cache(max_hibernated_sessions, function(resumption_token, session) + if session.destroyed then return true; end -- destroyed session can always be removed from cache + session.log("warn", "User has too much hibernated sessions, removing oldest session (token: %s)", resumption_token); + -- store old session's h values on force delete + -- save only actual h value and username/host (for security) + old_session_registry.set(session.username, resumption_token, { + h = session.handled_stanza_count, + username = session.username, + host = session.host + }); + return true; -- allow session to be removed from full cache to make room for new one +end); + +local function stoppable_timer(delay, callback) + local stopped = false; + local timer = module:add_timer(delay, function (t) + if stopped then return; end + return callback(t); + end); + if timer and timer.stop then return timer; end -- new prosody api includes stop() function + return { + stop = function(self) stopped = true end; + timer; + }; +end + +local function delayed_ack_function(session) + -- fire event only if configured to do so and our session is not already hibernated or destroyed + if delayed_ack_timeout > 0 and session.awaiting_ack + and not session.hibernating and not session.destroyed then + session.log("debug", "Firing event 'smacks-ack-delayed', queue = %d", + session.outgoing_stanza_queue and #session.outgoing_stanza_queue or 0); + module:fire_event("smacks-ack-delayed", {origin = session, queue = session.outgoing_stanza_queue}); + end + session.delayed_ack_timer = nil; +end + +local function can_do_smacks(session, advertise_only) + if session.smacks then return false, "unexpected-request", "Stream management is already enabled"; end + + local session_type = session.type; + if session.username then + if not(advertise_only) and not(session.resource) then -- Fail unless we're only advertising sm + return false, "unexpected-request", "Client must bind a resource before enabling stream management"; + end + return true; + elseif s2s_smacks and (session_type == "s2sin" or session_type == "s2sout") then + return true; + end + return false, "service-unavailable", "Stream management is not available for this stream"; +end + +module:hook("stream-features", + function (event) + if can_do_smacks(event.origin, true) then + event.features:tag("sm", sm2_attr):tag("optional"):up():up(); + event.features:tag("sm", sm3_attr):tag("optional"):up():up(); + end + end); + +module:hook("s2s-stream-features", + function (event) + if can_do_smacks(event.origin, true) then + event.features:tag("sm", sm2_attr):tag("optional"):up():up(); + event.features:tag("sm", sm3_attr):tag("optional"):up():up(); + end + end); + +local function request_ack_if_needed(session, force, reason) + local queue = session.outgoing_stanza_queue; + local expected_h = session.last_acknowledged_stanza + #queue; + -- session.log("debug", "*** SMACKS(1) ***: awaiting_ack=%s, hibernating=%s", tostring(session.awaiting_ack), tostring(session.hibernating)); + if session.awaiting_ack == nil and not session.hibernating then + -- this check of last_requested_h prevents ack-loops if missbehaving clients report wrong + -- stanza counts. it is set when an is really sent (e.g. inside timer), preventing any + -- further requests until a higher h-value would be expected. + -- session.log("debug", "*** SMACKS(2) ***: #queue=%s, max_unacked_stanzas=%s, expected_h=%s, last_requested_h=%s", tostring(#queue), tostring(max_unacked_stanzas), tostring(expected_h), tostring(session.last_requested_h)); + if (#queue > max_unacked_stanzas and expected_h ~= session.last_requested_h) or force then + session.log("debug", "Queuing (in a moment) from %s - #queue=%d", reason, #queue); + session.awaiting_ack = false; + session.awaiting_ack_timer = stoppable_timer(1e-06, function () + -- session.log("debug", "*** SMACKS(3) ***: awaiting_ack=%s, hibernating=%s", tostring(session.awaiting_ack), tostring(session.hibernating)); + -- only request ack if needed and our session is not already hibernated or destroyed + if not session.awaiting_ack and not session.hibernating and not session.destroyed then + session.log("debug", "Sending (inside timer, before send) from %s - #queue=%d", reason, #queue); + (session.sends2s or session.send)(st.stanza("r", { xmlns = session.smacks })) + session.awaiting_ack = true; + -- expected_h could be lower than this expression e.g. more stanzas added to the queue meanwhile) + session.last_requested_h = session.last_acknowledged_stanza + #queue; + session.log("debug", "Sending (inside timer, after send) from %s - #queue=%d", reason, #queue); + if not session.delayed_ack_timer then + session.delayed_ack_timer = stoppable_timer(delayed_ack_timeout, function() + delayed_ack_function(session); + end); + end + end + end); + end + end + + -- Trigger "smacks-ack-delayed"-event if we added new (ackable) stanzas to the outgoing queue + -- and there isn't already a timer for this event running. + -- If we wouldn't do this, stanzas added to the queue after the first "smacks-ack-delayed"-event + -- would not trigger this event (again). + if #queue > max_unacked_stanzas and session.awaiting_ack and session.delayed_ack_timer == nil then + session.log("debug", "Calling delayed_ack_function directly (still waiting for ack)"); + delayed_ack_function(session); + end +end + +local function outgoing_stanza_filter(stanza, session) + -- XXX: Normally you wouldn't have to check the xmlns for a stanza as it's + -- supposed to be nil. + -- However, when using mod_smacks with mod_websocket, then mod_websocket's + -- stanzas/out filter can get called before this one and adds the xmlns. + local is_stanza = stanza.attr and + (not stanza.attr.xmlns or stanza.attr.xmlns == 'jabber:client') + and not stanza.name:find":"; + + if is_stanza and not stanza._cached then + local queue = session.outgoing_stanza_queue; + local cached_stanza = st.clone(stanza); + cached_stanza._cached = true; + + if cached_stanza and cached_stanza.name ~= "iq" and cached_stanza:get_child("delay", xmlns_delay) == nil then + cached_stanza = cached_stanza:tag("delay", { + xmlns = xmlns_delay, + from = jid.bare(session.full_jid or session.host), + stamp = datetime.datetime() + }); + end + + queue[#queue+1] = cached_stanza; + if session.hibernating then + session.log("debug", "hibernating, stanza queued"); + module:fire_event("smacks-hibernation-stanza-queued", {origin = session, queue = queue, stanza = cached_stanza}); + return nil; + end + request_ack_if_needed(session, false, "outgoing_stanza_filter"); + end + return stanza; +end + +local function count_incoming_stanzas(stanza, session) + if not stanza.attr.xmlns then + session.handled_stanza_count = session.handled_stanza_count + 1; + session.log("debug", "Handled %d incoming stanzas", session.handled_stanza_count); + end + return stanza; +end + +local function wrap_session_out(session, resume) + if not resume then + session.outgoing_stanza_queue = {}; + session.last_acknowledged_stanza = 0; + end + + add_filter(session, "stanzas/out", outgoing_stanza_filter, -999); + + local session_close = session.close; + function session.close(...) + if session.resumption_token then + session_registry.set(session.username, session.resumption_token, nil); + old_session_registry.set(session.username, session.resumption_token, nil); + session.resumption_token = nil; + end + -- send out last ack as per revision 1.5.2 of XEP-0198 + if session.smacks and session.conn then + (session.sends2s or session.send)(st.stanza("a", { xmlns = session.smacks, h = string.format("%d", session.handled_stanza_count) })); + end + return session_close(...); + end + return session; +end + +local function wrap_session_in(session, resume) + if not resume then + session.handled_stanza_count = 0; + end + add_filter(session, "stanzas/in", count_incoming_stanzas, 999); + + return session; +end + +local function wrap_session(session, resume) + wrap_session_out(session, resume); + wrap_session_in(session, resume); + return session; +end + +function handle_enable(session, stanza, xmlns_sm) + local ok, err, err_text = can_do_smacks(session); + if not ok then + session.log("warn", "Failed to enable smacks: %s", err_text); -- TODO: XEP doesn't say we can send error text, should it? + (session.sends2s or session.send)(st.stanza("failed", { xmlns = xmlns_sm }):tag(err, { xmlns = xmlns_errors})); + return true; + end + + module:log("debug", "Enabling stream management"); + session.smacks = xmlns_sm; + + wrap_session(session, false); + + local resume_token; + local resume = stanza.attr.resume; + if resume == "true" or resume == "1" then + resume_token = uuid_generate(); + session_registry.set(session.username, resume_token, session); + session.resumption_token = resume_token; + end + (session.sends2s or session.send)(st.stanza("enabled", { xmlns = xmlns_sm, id = resume_token, resume = resume, max = tostring(resume_timeout) })); + return true; +end +module:hook_stanza(xmlns_sm2, "enable", function (session, stanza) return handle_enable(session, stanza, xmlns_sm2); end, 100); +module:hook_stanza(xmlns_sm3, "enable", function (session, stanza) return handle_enable(session, stanza, xmlns_sm3); end, 100); + +module:hook_stanza("http://etherx.jabber.org/streams", "features", + function (session, stanza) + stoppable_timer(1e-6, function () + if can_do_smacks(session) then + if stanza:get_child("sm", xmlns_sm3) then + session.sends2s(st.stanza("enable", sm3_attr)); + session.smacks = xmlns_sm3; + elseif stanza:get_child("sm", xmlns_sm2) then + session.sends2s(st.stanza("enable", sm2_attr)); + session.smacks = xmlns_sm2; + else + return; + end + wrap_session_out(session, false); + end + end); + end); + +function handle_enabled(session, stanza, xmlns_sm) + module:log("debug", "Enabling stream management"); + session.smacks = xmlns_sm; + + wrap_session_in(session, false); + + -- FIXME Resume? + + return true; +end +module:hook_stanza(xmlns_sm2, "enabled", function (session, stanza) return handle_enabled(session, stanza, xmlns_sm2); end, 100); +module:hook_stanza(xmlns_sm3, "enabled", function (session, stanza) return handle_enabled(session, stanza, xmlns_sm3); end, 100); + +function handle_r(origin, stanza, xmlns_sm) + if not origin.smacks then + module:log("debug", "Received ack request from non-smack-enabled session"); + return; + end + module:log("debug", "Received ack request, acking for %d", origin.handled_stanza_count); + -- Reply with + (origin.sends2s or origin.send)(st.stanza("a", { xmlns = xmlns_sm, h = string.format("%d", origin.handled_stanza_count) })); + -- piggyback our own ack request if needed (see request_ack_if_needed() for explanation of last_requested_h) + local expected_h = origin.last_acknowledged_stanza + #origin.outgoing_stanza_queue; + if #origin.outgoing_stanza_queue > 0 and expected_h ~= origin.last_requested_h then + request_ack_if_needed(origin, true, "piggybacked by handle_r"); + end + return true; +end +module:hook_stanza(xmlns_sm2, "r", function (origin, stanza) return handle_r(origin, stanza, xmlns_sm2); end); +module:hook_stanza(xmlns_sm3, "r", function (origin, stanza) return handle_r(origin, stanza, xmlns_sm3); end); + +function handle_a(origin, stanza) + if not origin.smacks then return; end + origin.awaiting_ack = nil; + if origin.awaiting_ack_timer then + origin.awaiting_ack_timer:stop(); + end + if origin.delayed_ack_timer then + origin.delayed_ack_timer:stop(); + origin.delayed_ack_timer = nil; + end + -- Remove handled stanzas from outgoing_stanza_queue + -- origin.log("debug", "ACK: h=%s, last=%s", stanza.attr.h or "", origin.last_acknowledged_stanza or ""); + local h = tonumber(stanza.attr.h); + if not h then + origin:close{ condition = "invalid-xml"; text = "Missing or invalid 'h' attribute"; }; + return; + end + local handled_stanza_count = h-origin.last_acknowledged_stanza; + local queue = origin.outgoing_stanza_queue; + if handled_stanza_count > #queue then + origin.log("warn", "The client says it handled %d new stanzas, but we only sent %d :)", + handled_stanza_count, #queue); + origin.log("debug", "Client h: %d, our h: %d", tonumber(stanza.attr.h), origin.last_acknowledged_stanza); + for i=1,#queue do + origin.log("debug", "Q item %d: %s", i, tostring(queue[i])); + end + end + + for i=1,math_min(handled_stanza_count,#queue) do + local handled_stanza = t_remove(origin.outgoing_stanza_queue, 1); + module:fire_event("delivery/success", { session = origin, stanza = handled_stanza }); + end + + origin.log("debug", "#queue = %d", #queue); + origin.last_acknowledged_stanza = origin.last_acknowledged_stanza + handled_stanza_count; + request_ack_if_needed(origin, false, "handle_a") + return true; +end +module:hook_stanza(xmlns_sm2, "a", handle_a); +module:hook_stanza(xmlns_sm3, "a", handle_a); + +--TODO: Optimise... incoming stanzas should be handled by a per-session +-- function that has a counter as an upvalue (no table indexing for increments, +-- and won't slow non-198 sessions). We can also then remove the .handled flag +-- on stanzas + +local function handle_unacked_stanzas(session) + local queue = session.outgoing_stanza_queue; + local error_attr = { type = "cancel" }; + if #queue > 0 then + session.outgoing_stanza_queue = {}; + for i=1,#queue do + if not module:fire_event("delivery/failure", { session = session, stanza = queue[i] }) then + if queue[i].attr.type ~= "error" then + local reply = st.reply(queue[i]); + if reply.attr.to ~= session.full_jid then + reply.attr.type = "error"; + reply:tag("error", error_attr) + :tag("recipient-unavailable", {xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas"}); + core_process_stanza(session, reply); + end + end + end + end + end +end + +-- don't send delivery errors for messages which will be delivered by mam later on +-- check if stanza was archived --> this will allow us to send back errors for stanzas not archived +-- because the user configured the server to do so ("no-archive"-setting for one special contact for example) +local function get_stanza_id(stanza, by_jid) + for tag in stanza:childtags("stanza-id", "urn:xmpp:sid:0") do + if tag.attr.by == by_jid then + return tag.attr.id; + end + end + return nil; +end +module:hook("delivery/failure", function(event) + local session, stanza = event.session, event.stanza; + -- Only deal with authenticated (c2s) sessions + if session.username then + if stanza.name == "message" and stanza.attr.xmlns == nil and + ( stanza.attr.type == "chat" or ( stanza.attr.type or "normal" ) == "normal" ) then + -- do nothing here for normal messages and don't send out "message delivery errors", + -- because messages are already in MAM at this point (no need to frighten users) + local stanza_id = get_stanza_id(stanza, jid.bare(session.full_jid)); + if session.mam_requested and stanza_id ~= nil then + session.log("debug", "mod_smacks delivery/failuere returning true for mam-handled stanza: mam-archive-id=%s", tostring(stanza_id)); + return true; -- stanza handled, don't send an error + end + -- store message in offline store, if this client does not use mam *and* was the last client online + local sessions = prosody.hosts[module.host].sessions[session.username] and + prosody.hosts[module.host].sessions[session.username].sessions or nil; + if sessions and next(sessions) == session.resource and next(sessions, session.resource) == nil then + local ok = module:fire_event("message/offline/handle", { origin = session, stanza = stanza } ); + session.log("debug", "mod_smacks delivery/failuere returning %s for offline-handled stanza", tostring(ok)); + return ok; -- if stanza was handled, don't send an error + end + end + end +end); + +module:hook("pre-resource-unbind", function (event) + local session, err = event.session, event.error; + if session.smacks then + if not session.resumption_token then + local queue = session.outgoing_stanza_queue; + if #queue > 0 then + session.log("debug", "Destroying session with %d unacked stanzas", #queue); + handle_unacked_stanzas(session); + end + else + session.log("debug", "mod_smacks hibernating session for up to %d seconds", resume_timeout); + local hibernate_time = os_time(); -- Track the time we went into hibernation + session.hibernating = hibernate_time; + local resumption_token = session.resumption_token; + module:fire_event("smacks-hibernation-start", {origin = session, queue = session.outgoing_stanza_queue}); + timer.add_task(resume_timeout, function () + session.log("debug", "mod_smacks hibernation timeout reached..."); + -- We need to check the current resumption token for this resource + -- matches the smacks session this timer is for in case it changed + -- (for example, the client may have bound a new resource and + -- started a new smacks session, or not be using smacks) + local curr_session = full_sessions[session.full_jid]; + if session.destroyed then + session.log("debug", "The session has already been destroyed"); + elseif curr_session and curr_session.resumption_token == resumption_token + -- Check the hibernate time still matches what we think it is, + -- otherwise the session resumed and re-hibernated. + and session.hibernating == hibernate_time then + -- wait longer if the timeout isn't reached because push was enabled for this session + -- session.first_hibernated_push is the starting point for hibernation timeouts of those push enabled clients + -- wait for an additional resume_timeout seconds if no push occured since hibernation at all + local current_time = os_time(); + local timeout_start = math_max(session.hibernating, session.first_hibernated_push or session.hibernating); + if session.push_identifier ~= nil and not session.first_hibernated_push then + session.log("debug", "No push happened since hibernation started, hibernating session for up to %d extra seconds", resume_timeout); + return resume_timeout; + end + if current_time-timeout_start < resume_timeout and session.push_identifier ~= nil then + session.log("debug", "A push happened since hibernation started, hibernating session for up to %d extra seconds", current_time-timeout_start); + return current_time-timeout_start; -- time left to wait + end + session.log("debug", "Destroying session for hibernating too long"); + session_registry.set(session.username, session.resumption_token, nil); + -- save only actual h value and username/host (for security) + old_session_registry.set(session.username, session.resumption_token, { + h = session.handled_stanza_count, + username = session.username, + host = session.host + }); + session.resumption_token = nil; + sessionmanager.destroy_session(session); + else + session.log("debug", "Session resumed before hibernation timeout, all is well") + end + end); + return true; -- Postpone destruction for now + end + end +end); + +local function handle_s2s_destroyed(event) + local session = event.session; + local queue = session.outgoing_stanza_queue; + if queue and #queue > 0 then + session.log("warn", "Destroying session with %d unacked stanzas", #queue); + if s2s_resend then + for i = 1, #queue do + module:send(queue[i]); + end + session.outgoing_stanza_queue = nil; + else + handle_unacked_stanzas(session); + end + end +end + +module:hook("s2sout-destroyed", handle_s2s_destroyed); +module:hook("s2sin-destroyed", handle_s2s_destroyed); + +local function get_session_id(session) + return session.id or (tostring(session):match("[a-f0-9]+$")); +end + +function handle_resume(session, stanza, xmlns_sm) + if session.full_jid then + session.log("warn", "Tried to resume after resource binding"); + session.send(st.stanza("failed", { xmlns = xmlns_sm }) + :tag("unexpected-request", { xmlns = xmlns_errors }) + ); + return true; + end + + local id = stanza.attr.previd; + local original_session = session_registry.get(session.username, id); + if not original_session then + session.log("debug", "Tried to resume non-existent session with id %s", id); + local old_session = old_session_registry.get(session.username, id); + if old_session and session.username == old_session.username + and session.host == old_session.host + and old_session.h then + session.send(st.stanza("failed", { xmlns = xmlns_sm, h = string.format("%d", old_session.h) }) + :tag("item-not-found", { xmlns = xmlns_errors }) + ); + else + session.send(st.stanza("failed", { xmlns = xmlns_sm }) + :tag("item-not-found", { xmlns = xmlns_errors }) + ); + end; + elseif session.username == original_session.username + and session.host == original_session.host then + session.log("debug", "mod_smacks resuming existing session %s...", get_session_id(original_session)); + original_session.log("debug", "mod_smacks session resumed from %s...", get_session_id(session)); + -- TODO: All this should move to sessionmanager (e.g. session:replace(new_session)) + if original_session.conn then + original_session.log("debug", "mod_smacks closing an old connection for this session"); + local conn = original_session.conn; + c2s_sessions[conn] = nil; + conn:close(); + end + local migrated_session_log = session.log; + original_session.ip = session.ip; + original_session.conn = session.conn; + original_session.send = session.send; + original_session.close = session.close; + original_session.filter = session.filter; + original_session.filter.session = original_session; + original_session.filters = session.filters; + original_session.stream = session.stream; + original_session.secure = session.secure; + original_session.hibernating = nil; + session.log = original_session.log; + session.type = original_session.type; + wrap_session(original_session, true); + -- Inform xmppstream of the new session (passed to its callbacks) + original_session.stream:set_session(original_session); + -- Similar for connlisteners + c2s_sessions[session.conn] = original_session; + + original_session.send(st.stanza("resumed", { xmlns = xmlns_sm, + h = string.format("%d", original_session.handled_stanza_count), previd = id })); + + -- Fake an with the h of the from the client + original_session:dispatch_stanza(st.stanza("a", { xmlns = xmlns_sm, + h = stanza.attr.h })); + + -- Ok, we need to re-send any stanzas that the client didn't see + -- ...they are what is now left in the outgoing stanza queue + -- We have to use the send of "session" because we don't want to add our resent stanzas + -- to the outgoing queue again + local queue = original_session.outgoing_stanza_queue; + session.log("debug", "resending all unacked stanzas that are still queued after resume, #queue = %d", #queue); + for i=1,#queue do + session.send(queue[i]); + end + session.log("debug", "all stanzas resent, now disabling send() in this migrated session, #queue = %d", #queue); + function session.send(stanza) + migrated_session_log("error", "Tried to send stanza on old session migrated by smacks resume (maybe there is a bug?): %s", tostring(stanza)); + return false; + end + module:fire_event("smacks-hibernation-end", {origin = session, resumed = original_session, queue = queue}); + request_ack_if_needed(original_session, true, "handle_resume"); + else + module:log("warn", "Client %s@%s[%s] tried to resume stream for %s@%s[%s]", + session.username or "?", session.host or "?", session.type, + original_session.username or "?", original_session.host or "?", original_session.type); + session.send(st.stanza("failed", { xmlns = xmlns_sm }) + :tag("not-authorized", { xmlns = xmlns_errors })); + end + return true; +end +module:hook_stanza(xmlns_sm2, "resume", function (session, stanza) return handle_resume(session, stanza, xmlns_sm2); end); +module:hook_stanza(xmlns_sm3, "resume", function (session, stanza) return handle_resume(session, stanza, xmlns_sm3); end); + +local function handle_read_timeout(event) + local session = event.session; + if session.smacks then + if session.awaiting_ack then + if session.awaiting_ack_timer then + session.awaiting_ack_timer:stop(); + end + if session.delayed_ack_timer then + session.delayed_ack_timer:stop(); + session.delayed_ack_timer = nil; + end + return false; -- Kick the session + end + session.log("debug", "Sending (read timeout)"); + (session.sends2s or session.send)(st.stanza("r", { xmlns = session.smacks })); + session.awaiting_ack = true; + if not session.delayed_ack_timer then + session.delayed_ack_timer = stoppable_timer(delayed_ack_timeout, function() + delayed_ack_function(session); + end); + end + return true; + end +end + +module:hook("s2s-read-timeout", handle_read_timeout); +module:hook("c2s-read-timeout", handle_read_timeout); diff --git a/vnctalk/mod_smacks/mod_smacks.lua.orig b/vnctalk/mod_smacks/mod_smacks.lua.orig new file mode 100644 index 0000000..89e9afb --- /dev/null +++ b/vnctalk/mod_smacks/mod_smacks.lua.orig @@ -0,0 +1,586 @@ +-- XEP-0198: Stream Management for Prosody IM +-- +-- Copyright (C) 2010-2015 Matthew Wild +-- Copyright (C) 2010 Waqas Hussain +-- Copyright (C) 2012-2015 Kim Alvefur +-- Copyright (C) 2012 Thijs Alkemade +-- Copyright (C) 2014 Florian Zeitz +-- Copyright (C) 2016-2017 Thilo Molitor +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local st = require "util.stanza"; +local dep = require "util.dependencies"; +local cache = dep.softreq("util.cache"); -- only available in prosody 0.10+ +local uuid_generate = require "util.uuid".generate; +local jid = require "util.jid"; + +local t_insert, t_remove = table.insert, table.remove; +local math_min = math.min; +local math_max = math.max; +local os_time = os.time; +local tonumber, tostring = tonumber, tostring; +local add_filter = require "util.filters".add_filter; +local timer = require "util.timer"; +local datetime = require "util.datetime"; + +local xmlns_sm2 = "urn:xmpp:sm:2"; +local xmlns_sm3 = "urn:xmpp:sm:3"; +local xmlns_errors = "urn:ietf:params:xml:ns:xmpp-stanzas"; +local xmlns_delay = "urn:xmpp:delay"; + +local sm2_attr = { xmlns = xmlns_sm2 }; +local sm3_attr = { xmlns = xmlns_sm3 }; + +local resume_timeout = module:get_option_number("smacks_hibernation_time", 300); +local s2s_smacks = module:get_option_boolean("smacks_enabled_s2s", false); +local s2s_resend = module:get_option_boolean("smacks_s2s_resend", false); +local max_unacked_stanzas = module:get_option_number("smacks_max_unacked_stanzas", 0); +local delayed_ack_timeout = module:get_option_number("smacks_max_ack_delay", 60); +local max_hibernated_sessions = module:get_option_number("smacks_max_hibernated_sessions", 10); +local max_old_sessions = module:get_option_number("smacks_max_old_sessions", 10); +local core_process_stanza = prosody.core_process_stanza; +local sessionmanager = require"core.sessionmanager"; + +local c2s_sessions = module:shared("/*/c2s/sessions"); + +local function init_session_cache(max_entries, evict_callback) + -- old prosody version < 0.10 (no limiting at all!) + if not cache then + local store = {}; + return { + get = function(user, key) + if not user then return nil; end + if not key then return nil; end + return store[key]; + end; + set = function(user, key, value) + if not user then return nil; end + if not key then return nil; end + store[key] = value; + end; + }; + end + + -- use per user limited cache for prosody >= 0.10 + local stores = {}; + return { + get = function(user, key) + if not user then return nil; end + if not key then return nil; end + if not stores[user] then + stores[user] = cache.new(max_entries, evict_callback); + end + return stores[user]:get(key); + end; + set = function(user, key, value) + if not user then return nil; end + if not key then return nil; end + if not stores[user] then stores[user] = cache.new(max_entries, evict_callback); end + stores[user]:set(key, value); + -- remove empty caches completely + if not stores[user]:count() then stores[user] = nil; end + end; + }; +end +local old_session_registry = init_session_cache(max_old_sessions, nil); +local session_registry = init_session_cache(max_hibernated_sessions, function(resumption_token, session) + if session.destroyed then return true; end -- destroyed session can always be removed from cache + session.log("warn", "User has too much hibernated sessions, removing oldest session (token: %s)", resumption_token); + -- store old session's h values on force delete + -- save only actual h value and username/host (for security) + old_session_registry.set(session.username, resumption_token, { + h = session.handled_stanza_count, + username = session.username, + host = session.host + }); + return true; -- allow session to be removed from full cache to make room for new one +end); + +local function stoppable_timer(delay, callback) + local stopped = false; + return { + stop = function () stopped = true end; + module:add_timer(delay, function (t) + if stopped then return; end + return callback(t); + end); + }; +end + +local function delayed_ack_function(session) + -- fire event only if configured to do so and our session is not hibernated or destroyed + if delayed_ack_timeout > 0 and session.awaiting_ack + and not session.hibernating and not session.destroyed then + session.log("debug", "Firing event 'smacks-ack-delayed', queue = %d", + session.outgoing_stanza_queue and #session.outgoing_stanza_queue or 0); + module:fire_event("smacks-ack-delayed", {origin = session, queue = session.outgoing_stanza_queue}); + end + session.delayed_ack_timer = nil; +end + +local function can_do_smacks(session, advertise_only) + if session.smacks then return false, "unexpected-request", "Stream management is already enabled"; end + + local session_type = session.type; + if session.username then + if not(advertise_only) and not(session.resource) then -- Fail unless we're only advertising sm + return false, "unexpected-request", "Client must bind a resource before enabling stream management"; + end + return true; + elseif s2s_smacks and (session_type == "s2sin" or session_type == "s2sout") then + return true; + end + return false, "service-unavailable", "Stream management is not available for this stream"; +end + +module:hook("stream-features", + function (event) + if can_do_smacks(event.origin, true) then + event.features:tag("sm", sm2_attr):tag("optional"):up():up(); + event.features:tag("sm", sm3_attr):tag("optional"):up():up(); + end + end); + +module:hook("s2s-stream-features", + function (event) + if can_do_smacks(event.origin, true) then + event.features:tag("sm", sm2_attr):tag("optional"):up():up(); + event.features:tag("sm", sm3_attr):tag("optional"):up():up(); + end + end); + +local function request_ack_if_needed(session, force) + local queue = session.outgoing_stanza_queue; + if session.awaiting_ack == nil and not session.hibernating then + if (#queue > max_unacked_stanzas and session.last_queue_count ~= #queue) or force then + session.log("debug", "Queuing (in a moment)"); + session.awaiting_ack = false; + session.awaiting_ack_timer = stoppable_timer(1e-06, function () + if not session.awaiting_ack and not session.hibernating then + session.log("debug", "Sending (inside timer, before send)"); + (session.sends2s or session.send)(st.stanza("r", { xmlns = session.smacks })) + session.log("debug", "Sending (inside timer, after send)"); + session.awaiting_ack = true; + if not session.delayed_ack_timer then + session.delayed_ack_timer = stoppable_timer(delayed_ack_timeout, function() + delayed_ack_function(session); + end); + end + end + end); + end + end + + -- Trigger "smacks-ack-delayed"-event if we added new (ackable) stanzas to the outgoing queue + -- and there isn't already a timer for this event running. + -- If we wouldn't do this, stanzas added to the queue after the first "smacks-ack-delayed"-event + -- would not trigger this event (again). + if #queue > max_unacked_stanzas and session.awaiting_ack and session.delayed_ack_timer == nil then + session.log("debug", "Calling delayed_ack_function directly (still waiting for ack)"); + delayed_ack_function(session); + end + + session.last_queue_count = #queue; +end + +local function outgoing_stanza_filter(stanza, session) + local is_stanza = stanza.attr and not stanza.attr.xmlns and not stanza.name:find":"; + if is_stanza and not stanza._cached then -- Stanza in default stream namespace + local queue = session.outgoing_stanza_queue; + local cached_stanza = st.clone(stanza); + cached_stanza._cached = true; + + if cached_stanza and cached_stanza.name ~= "iq" and cached_stanza:get_child("delay", xmlns_delay) == nil then + cached_stanza = cached_stanza:tag("delay", { + xmlns = xmlns_delay, + from = jid.bare(session.full_jid or session.host), + stamp = datetime.datetime() + }); + end + + queue[#queue+1] = cached_stanza; + session.log("debug", "#queue = %d", #queue); + if session.hibernating then + session.log("debug", "hibernating, stanza queued"); + return nil; + end + request_ack_if_needed(session, false); + end + return stanza; +end + +local function count_incoming_stanzas(stanza, session) + if not stanza.attr.xmlns then + session.handled_stanza_count = session.handled_stanza_count + 1; + session.log("debug", "Handled %d incoming stanzas", session.handled_stanza_count); + end + return stanza; +end + +local function wrap_session_out(session, resume) + if not resume then + session.outgoing_stanza_queue = {}; + session.last_acknowledged_stanza = 0; + end + + add_filter(session, "stanzas/out", outgoing_stanza_filter, -999); + + local session_close = session.close; + function session.close(...) + if session.resumption_token then + session_registry.set(session.username, session.resumption_token, nil); + old_session_registry.set(session.username, session.resumption_token, nil); + session.resumption_token = nil; + end + -- send out last ack as per revision 1.5.2 of XEP-0198 + if session.smacks and session.conn then + (session.sends2s or session.send)(st.stanza("a", { xmlns = session.smacks, h = string.format("%d", session.handled_stanza_count) ,t = tostring(os.time())) })); + end + return session_close(...); + end + return session; +end + +local function wrap_session_in(session, resume) + if not resume then + session.handled_stanza_count = 0; + end + add_filter(session, "stanzas/in", count_incoming_stanzas, 999); + + return session; +end + +local function wrap_session(session, resume) + wrap_session_out(session, resume); + wrap_session_in(session, resume); + return session; +end + +function handle_enable(session, stanza, xmlns_sm) + local ok, err, err_text = can_do_smacks(session); + if not ok then + session.log("warn", "Failed to enable smacks: %s", err_text); -- TODO: XEP doesn't say we can send error text, should it? + (session.sends2s or session.send)(st.stanza("failed", { xmlns = xmlns_sm }):tag(err, { xmlns = xmlns_errors})); + return true; + end + + module:log("debug", "Enabling stream management"); + session.smacks = xmlns_sm; + + wrap_session(session, false); + + local resume_token; + local resume = stanza.attr.resume; + if resume == "true" or resume == "1" then + resume_token = uuid_generate(); + session_registry.set(session.username, resume_token, session); + session.resumption_token = resume_token; + end + (session.sends2s or session.send)(st.stanza("enabled", { xmlns = xmlns_sm, id = resume_token, resume = resume, max = tostring(resume_timeout) })); + return true; +end +module:hook_stanza(xmlns_sm2, "enable", function (session, stanza) return handle_enable(session, stanza, xmlns_sm2); end, 100); +module:hook_stanza(xmlns_sm3, "enable", function (session, stanza) return handle_enable(session, stanza, xmlns_sm3); end, 100); + +module:hook_stanza("http://etherx.jabber.org/streams", "features", + function (session, stanza) + stoppable_timer(1e-6, function () + if can_do_smacks(session) then + if stanza:get_child("sm", xmlns_sm3) then + session.sends2s(st.stanza("enable", sm3_attr)); + session.smacks = xmlns_sm3; + elseif stanza:get_child("sm", xmlns_sm2) then + session.sends2s(st.stanza("enable", sm2_attr)); + session.smacks = xmlns_sm2; + else + return; + end + wrap_session_out(session, false); + end + end); + end); + +function handle_enabled(session, stanza, xmlns_sm) + module:log("debug", "Enabling stream management"); + session.smacks = xmlns_sm; + + wrap_session_in(session, false); + + -- FIXME Resume? + + return true; +end +module:hook_stanza(xmlns_sm2, "enabled", function (session, stanza) return handle_enabled(session, stanza, xmlns_sm2); end, 100); +module:hook_stanza(xmlns_sm3, "enabled", function (session, stanza) return handle_enabled(session, stanza, xmlns_sm3); end, 100); + +function handle_r(origin, stanza, xmlns_sm) + if not origin.smacks then + module:log("debug", "Received ack request from non-smack-enabled session"); + return; + end + module:log("debug", "Received ack request, acking for %d", origin.handled_stanza_count); + -- Reply with + -- ssa: add t with os.time + (origin.sends2s or origin.send)(st.stanza("a", { xmlns = xmlns_sm, h = string.format("%d", origin.handled_stanza_count), t = tostring(os.time()) })); + return true; +end +module:hook_stanza(xmlns_sm2, "r", function (origin, stanza) return handle_r(origin, stanza, xmlns_sm2); end); +module:hook_stanza(xmlns_sm3, "r", function (origin, stanza) return handle_r(origin, stanza, xmlns_sm3); end); + +function handle_a(origin, stanza) + if not origin.smacks then return; end + origin.awaiting_ack = nil; + if origin.awaiting_ack_timer then + origin.awaiting_ack_timer:stop(); + end + if origin.delayed_ack_timer then + origin.delayed_ack_timer:stop(); + origin.delayed_ack_timer = nil; + end + -- Remove handled stanzas from outgoing_stanza_queue + -- origin.log("debug", "ACK: h=%s, last=%s", stanza.attr.h or "", origin.last_acknowledged_stanza or ""); + local h = tonumber(stanza.attr.h); + if not h then + origin:close{ condition = "invalid-xml"; text = "Missing or invalid 'h' attribute"; }; + end + local handled_stanza_count = h-origin.last_acknowledged_stanza; + local queue = origin.outgoing_stanza_queue; + if handled_stanza_count > #queue then + origin.log("warn", "The client says it handled %d new stanzas, but we only sent %d :)", + handled_stanza_count, #queue); + origin.log("debug", "Client h: %d, our h: %d", tonumber(stanza.attr.h), origin.last_acknowledged_stanza); + for i=1,#queue do + origin.log("debug", "Q item %d: %s", i, tostring(queue[i])); + end + end + for i=1,math_min(handled_stanza_count,#queue) do + t_remove(origin.outgoing_stanza_queue, 1); + end + origin.log("debug", "#queue = %d", #queue); + origin.last_acknowledged_stanza = origin.last_acknowledged_stanza + handled_stanza_count; + request_ack_if_needed(origin, false) + return true; +end +module:hook_stanza(xmlns_sm2, "a", handle_a); +module:hook_stanza(xmlns_sm3, "a", handle_a); + +--TODO: Optimise... incoming stanzas should be handled by a per-session +-- function that has a counter as an upvalue (no table indexing for increments, +-- and won't slow non-198 sessions). We can also then remove the .handled flag +-- on stanzas + +function handle_unacked_stanzas(session) + local queue = session.outgoing_stanza_queue; + local error_attr = { type = "cancel" }; + if #queue > 0 then + session.outgoing_stanza_queue = {}; + for i=1,#queue do + local reply = st.reply(queue[i]); + if reply.attr.to ~= session.full_jid then + reply.attr.type = "error"; + reply:tag("error", error_attr) + :tag("recipient-unavailable", {xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas"}); + core_process_stanza(session, reply); + end + end + end +end + +module:hook("pre-resource-unbind", function (event) + local session, err = event.session, event.error; + if session.smacks then + if not session.resumption_token then + local queue = session.outgoing_stanza_queue; + if #queue > 0 then + session.log("debug", "Destroying session with %d unacked stanzas", #queue); + handle_unacked_stanzas(session); + end + else + session.log("debug", "mod_smacks hibernating session for up to %d seconds", resume_timeout); + local hibernate_time = os_time(); -- Track the time we went into hibernation + session.hibernating = hibernate_time; + local resumption_token = session.resumption_token; + module:fire_event("smacks-hibernation-start", {origin = session, queue = session.outgoing_stanza_queue}); + timer.add_task(resume_timeout, function () + session.log("debug", "mod_smacks hibernation timeout reached..."); + -- We need to check the current resumption token for this resource + -- matches the smacks session this timer is for in case it changed + -- (for example, the client may have bound a new resource and + -- started a new smacks session, or not be using smacks) + local curr_session = full_sessions[session.full_jid]; + if session.destroyed then + session.log("debug", "The session has already been destroyed"); + elseif curr_session and curr_session.resumption_token == resumption_token + -- Check the hibernate time still matches what we think it is, + -- otherwise the session resumed and re-hibernated. + and session.hibernating == hibernate_time then + -- wait longer if the timeout isn't reached because push was enabled for this session + -- session.first_hibernated_push is the starting point for hibernation timeouts of those push enabled clients + -- wait for an additional resume_timeout seconds if no push occured since hibernation at all + local current_time = os_time(); + local timeout_start = math_max(session.hibernating, session.first_hibernated_push or session.hibernating); + if session.push_identifier ~= nil and not session.first_hibernated_push then + session.log("debug", "No push happened since hibernation started, hibernating session for up to %d extra seconds", resume_timeout); + return resume_timeout; + end + if current_time-timeout_start < resume_timeout and session.push_identifier ~= nil then + session.log("debug", "A push happened since hibernation started, hibernating session for up to %d extra seconds", current_time-timeout_start); + return current_time-timeout_start; -- time left to wait + end + session.log("debug", "Destroying session for hibernating too long"); + session_registry.set(session.username, session.resumption_token, nil); + -- save only actual h value and username/host (for security) + old_session_registry.set(session.username, session.resumption_token, { + h = session.handled_stanza_count, + username = session.username, + host = session.host + }); + session.resumption_token = nil; + sessionmanager.destroy_session(session); + else + session.log("debug", "Session resumed before hibernation timeout, all is well") + end + end); + return true; -- Postpone destruction for now + end + end +end); + +local function handle_s2s_destroyed(event) + local session = event.session; + local queue = session.outgoing_stanza_queue; + if queue and #queue > 0 then + session.log("warn", "Destroying session with %d unacked stanzas", #queue); + if s2s_resend then + for i = 1, #queue do + module:send(queue[i]); + end + session.outgoing_stanza_queue = nil; + else + handle_unacked_stanzas(session); + end + end +end + +module:hook("s2sout-destroyed", handle_s2s_destroyed); +module:hook("s2sin-destroyed", handle_s2s_destroyed); + +local function get_session_id(session) + return session.id or (tostring(session):match("[a-f0-9]+$")); +end + +function handle_resume(session, stanza, xmlns_sm) + if session.full_jid then + session.log("warn", "Tried to resume after resource binding"); + session.send(st.stanza("failed", { xmlns = xmlns_sm }) + :tag("unexpected-request", { xmlns = xmlns_errors }) + ); + return true; + end + + local id = stanza.attr.previd; + local original_session = session_registry.get(session.username, id); + if not original_session then + session.log("debug", "Tried to resume non-existent session with id %s", id); + local old_session = old_session_registry.get(session.username, id); + if old_session and session.username == old_session.username + and session.host == old_session.host + and old_session.h then + session.send(st.stanza("failed", { xmlns = xmlns_sm, h = string.format("%d", old_session.h) }) + :tag("item-not-found", { xmlns = xmlns_errors }) + ); + else + session.send(st.stanza("failed", { xmlns = xmlns_sm }) + :tag("item-not-found", { xmlns = xmlns_errors }) + ); + end; + elseif session.username == original_session.username + and session.host == original_session.host then + session.log("debug", "mod_smacks resuming existing session %s...", get_session_id(original_session)); + -- TODO: All this should move to sessionmanager (e.g. session:replace(new_session)) + if original_session.conn then + original_session.log("debug", "mod_smacks closing an old connection for this session"); + local conn = original_session.conn; + c2s_sessions[conn] = nil; + conn:close(); + end + original_session.ip = session.ip; + original_session.conn = session.conn; + original_session.send = session.send; + original_session.close = session.close; + original_session.filter = session.filter; + original_session.filter.session = original_session; + original_session.filters = session.filters; + original_session.stream = session.stream; + original_session.secure = session.secure; + original_session.hibernating = nil; + wrap_session(original_session, true); + -- Inform xmppstream of the new session (passed to its callbacks) + original_session.stream:set_session(original_session); + -- Similar for connlisteners + c2s_sessions[session.conn] = original_session; + + original_session.send(st.stanza("resumed", { xmlns = xmlns_sm, + h = string.format("%d", original_session.handled_stanza_count), previd = id })); + + -- Fake an with the h of the from the client + original_session:dispatch_stanza(st.stanza("a", { xmlns = xmlns_sm, + h = stanza.attr.h })); + + -- Ok, we need to re-send any stanzas that the client didn't see + -- ...they are what is now left in the outgoing stanza queue + local queue = original_session.outgoing_stanza_queue; + original_session.log("debug", "#queue = %d", #queue); + for i=1,#queue do + original_session.send(queue[i]); + end + original_session.log("debug", "#queue = %d -- after send", #queue); + function session.send(stanza) + session.log("warn", "Tried to send stanza on old session migrated by smacks resume (maybe there is a bug?): %s", tostring(stanza)); + return false; + end + module:fire_event("smacks-hibernation-end", {origin = session, resumed = original_session, queue = queue}); + request_ack_if_needed(original_session, true); + else + module:log("warn", "Client %s@%s[%s] tried to resume stream for %s@%s[%s]", + session.username or "?", session.host or "?", session.type, + original_session.username or "?", original_session.host or "?", original_session.type); + session.send(st.stanza("failed", { xmlns = xmlns_sm }) + :tag("not-authorized", { xmlns = xmlns_errors })); + end + return true; +end +module:hook_stanza(xmlns_sm2, "resume", function (session, stanza) return handle_resume(session, stanza, xmlns_sm2); end); +module:hook_stanza(xmlns_sm3, "resume", function (session, stanza) return handle_resume(session, stanza, xmlns_sm3); end); + +local function handle_read_timeout(event) + local session = event.session; + if session.smacks then + if session.awaiting_ack then + if session.awaiting_ack_timer then + session.awaiting_ack_timer:stop(); + end + if session.delayed_ack_timer then + session.delayed_ack_timer:stop(); + session.delayed_ack_timer = nil; + end + return false; -- Kick the session + end + session.log("debug", "Sending (read timeout)"); + session.awaiting_ack = false; + (session.sends2s or session.send)(st.stanza("r", { xmlns = session.smacks })); + session.awaiting_ack = true; + if not session.delayed_ack_timer then + session.delayed_ack_timer = stoppable_timer(delayed_ack_timeout, function() + delayed_ack_function(session); + end); + end + return true; + end +end + +module:hook("s2s-read-timeout", handle_read_timeout); +module:hook("c2s-read-timeout", handle_read_timeout); diff --git a/vnctalk/mod_smacks_offline/mod_smacks_offline.lua b/vnctalk/mod_smacks_offline/mod_smacks_offline.lua new file mode 100644 index 0000000..5cb77d1 --- /dev/null +++ b/vnctalk/mod_smacks_offline/mod_smacks_offline.lua @@ -0,0 +1,4 @@ +-- this module is deprecated, log an error and load the superseding module instead +module:depends"smacks" + +module:log("info", "mod_smacks_offline is no lobger required."); diff --git a/vnctalk/mod_vcard_muc/mod_vcard_muc.lua b/vnctalk/mod_vcard_muc/mod_vcard_muc.lua new file mode 100644 index 0000000..39b0237 --- /dev/null +++ b/vnctalk/mod_vcard_muc/mod_vcard_muc.lua @@ -0,0 +1,174 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- Copyright (C) 2018 Michel Le Bihan +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local st = require "util.stanza" +local jid_split = require "util.jid".split; +local base64 = require"util.encodings".base64; +local sha1 = require"util.hashes".sha1; +local http = require "net.http"; + +local mod_muc = module:depends"muc"; + +local vcards = module:open_store(); + +local avatar_upload_url = module:get_option("avatar_upload_url", ""); +local avatar_upload_user = module:get_option("avatar_upload_user", ""); +local avatar_upload_pass = module:get_option("avatar_upload_pass", ""); + +local avatar_update_store = module:open_store("avatarupdate", "map"); + +local function dumpTable(t, depth) + local result = ""; + if (not depth) then depth = 0 end; + + local prefix = string.rep(" ", depth); + + if (type(t) ~= "table") then + result = result.." "..tostring(t); + else + for key,value in pairs(t) do + result = result..prefix..tostring(key).." => "..dumpTable(value, depth+1).."\n" + end + end + + return result; +end + + +local function putGlobalAvatar(_room_jid, _photo) + local to_url = avatar_upload_url.._room_jid; + module:log("info", "upload target URL: %s", to_url); + local httpRequestOptions = { + method = "PUT", + body = _photo, + headers = { + ["Content-Type"] = "image/png" + } + } + + if avatar_upload_user ~= "" then + httpRequestOptions = { + method = "PUT", + body = _photo, + headers = { + ["Content-Type"] = "image/png", + ["Authorization"] = "Basic "..base64.encode(avatar_upload_user..":"..avatar_upload_pass) + } + } + end + + local function cb(content_, code_, request_, response_) + content, code, request, response = content_, code_, request_, response_; + module:log("info", "response code is: %s", code); + --done(); + end + + http.request(to_url, httpRequestOptions, cb); + + module:log("info", "avatar upload for %s", _room_jid); +end + + +module:add_feature("vcard-temp"); + +local get_room_from_jid = rawget(mod_muc, "get_room_from_jid") or + function (jid) + local rooms = rawget(mod_muc, "rooms"); + return rooms[jid]; + end + +local function broadcast_presence(room_jid, to) + local room = get_room_from_jid(room_jid); + local room_node = jid_split(room_jid); + local vcard = st.deserialize(vcards:get(room_node)); + local photo_hash; + + if vcard then + local photo = vcard:get_child("PHOTO"); + + if photo then + local photo_b64 = photo:get_child_text("BINVAL"); + local photo_raw = photo_b64 and base64.decode(photo_b64); + photo_hash = sha1(photo_raw, true); + end + end + + local presence_vcard = st.presence({to = to, from = room_jid}) + :tag("x", { xmlns = "vcard-temp:x:update" }) + :tag("photo"):text(photo_hash):up(); + + if to == nil then + room:broadcast_message(presence_vcard); + else + module:send(presence_vcard); + end +end + +local function handle_vcard(event) + local session, stanza = event.origin, event.stanza; + + local room_jid = stanza.attr.to; + local room_node = jid_split(room_jid); + local room = get_room_from_jid(room_jid); + + local from = stanza.attr.from; + local from_affiliation = room:get_affiliation(from); + + if stanza.attr.type == "get" then + local vCard; + vCard = st.deserialize(vcards:get(room_node)); + + if vCard then + session.send(st.reply(stanza):add_child(vCard)); -- send vCard! + else + session.send(st.error_reply(stanza, "cancel", "item-not-found")); + end + else + if from_affiliation == "owner" then + if vcards:set(room_node, st.preserialize(stanza.tags[1])) then + session.send(st.reply(stanza):tag("vCard", { xmlns = "vcard-temp" })); + broadcast_presence(room_jid, nil) + local vcrd = st.deserialize(vcards:get(room_node)); + if vcrd then + local photo = vcrd:get_child("PHOTO"); + if photo then + local photo_b64 = photo:get_child_text("BINVAL"); + local photo_raw = photo_b64 and base64.decode(photo_b64); + if room_jid then + local s = os.time(); + avatar_update_store:set(room_node, "updated", s); + putGlobalAvatar(room_jid, photo_raw); + module:log("info", "setting new avatar for %s @ %s", room_jid); + end + + end + end + else + -- TODO unable to write file, file may be locked, etc, what's the correct error? + session.send(st.error_reply(stanza, "wait", "internal-server-error")); + end + else + session.send(st.error_reply(stanza, "auth", "forbidden")); + end + end + return true; +end + + +module:hook("iq/bare/vcard-temp:vCard", handle_vcard); +module:hook("iq/host/vcard-temp:vCard", handle_vcard); + +module:hook("muc-disco#info", function(event) + event.reply:tag("feature", { var = "vcard-temp" }):up(); +end); + +-- module:hook("muc-occupant-session-new", function(event) +-- module:log("info", "skipping vcard photo presence"); +-- broadcast_presence(event.room.jid, event.jid); +-- end) diff --git a/vnctalk/mod_vnc_broadcast.lua b/vnctalk/mod_vnc_broadcast.lua new file mode 100644 index 0000000..f18454f --- /dev/null +++ b/vnctalk/mod_vnc_broadcast.lua @@ -0,0 +1,106 @@ +local is_admin = require "core.usermanager".is_admin; +local allowed_senders = module:get_option_set("broadcast_senders", {}); +local from_address = module:get_option_string("broadcast_from"); +local st = require "util.stanza"; + +local jid_bare = require "util.jid".bare; +local jid = require "util.jid"; +local id = require "util.id" + +local mod_muc = module:depends"muc"; + +local roster_manager = require("core.rostermanager"); + + +function send_bc_message(from, to, bcid, body, title, origtarget, vnctalk_avatarup) +local msgid = id.long(); +local message = ""; + if (vnctalk_avatarup) then + message = st.message({ from = from, to = to, id = msgid, + type = "normal" }):body(body):up():tag("vncTalkBroadcast", { xmlns = "xmpp:vnctalk", id=bcid, title=title, origtarget = origtarget, avatarup = "true"}):up(); + else + message = st.message({ from = from, to = to, id = msgid, + type = "normal" }):body(body):up():tag("vncTalkBroadcast", { xmlns = "xmpp:vnctalk", id=bcid, title=title, origtarget = origtarget}):up(); +-- module:log("info", "built bcmessage: %s", tostring(message)); + end + module:send(message); +end + + +function send_message(event) + local stanza = event.stanza; + local from = jid.bare(stanza.attr.from); + local fnode, fhost = jid.split(from); +-- module:log("info", "event.stanza is %s", stanza:get_child_text("body")); + + local body = stanza:get_child_text("body"); + local vnctalk_broadcast = stanza:get_child("vncTalkBroadcast", "xmpp:vnctalk"); + local vnctalk_avatarup = stanza:get_child("vnctalkAvatarUpdate", "xmpp:vnctalk" ) or false; +-- module:log("info", "vnctalkavatarup %s", vnctalk_avatarup); + local vnctalk_broadcast_childid = vnctalk_broadcast.attr.id; + + if vnctalk_broadcast_childid ~= nil then + module:log("info", "not casting - is already casted message %s", stanza.attr.body); + else + new_id = stanza.attr.id; + local origtarget = stanza.attr.origtarget; + -- module:log("info", "casting broadcast orig target: %s", stanza.attr.origtarget); + local bctitle = vnctalk_broadcast.attr.title; + local bc_to = stanza:get_child("vncTalkBroadcast", "xmpp:vnctalk"):childtags("to", "xmpp:vnctalk")[1]; + local t_receipients = {}; + for ato in vnctalk_broadcast:childtags("to") do + local to = ato:get_text(); +-- module:log("info", "found to: %s", to); + if string.find(to, "@conference") then +-- module:log("info","add affiliates of %s", to); + local muc, host = jid.split(to); +-- module:log("info", "got muc %s from host %s", muc, host); + if prosody.hosts[host] then + local affs = prosody.hosts[host].modules.muc.rooms[to]._affiliations; + for _aff, role in pairs(affs) do +-- module:log("info", "got muc _aff %s from muc %s", _aff, to); + if from ~= _aff then + t_receipients[_aff] = true; + end + end + end + else + t_receipients[to] = true; + end + end + + local target_groups = {}; + + for rgroup in vnctalk_broadcast:childtags("roster") do + local rgrp = rgroup:get_text(); +-- module:log("info", "found targeted roster group: %s", rgrp); + target_groups[rgrp] = true; + end + + local roster = roster_manager.load_roster(fnode, fhost); + for contact, croster in pairs(roster) do + local cgroups = croster.groups; + if cgroups ~= nil then + for a1, a2 in pairs (cgroups) do + if target_groups[a1] then +-- module:log("info","adding roster contact from group: %s ==> %s", a1, contact); + if from ~= contact then + t_receipients[contact] = true; + end + end + end + end + end + + for receipient, _x in pairs(t_receipients) do + module:log("info","sending broadcast message from: %s to: %s with childid %s and body: %s and avatarup: %s", from, receipient, new_id, body, vnctalk_avatarup); + send_bc_message(from, receipient, new_id, body, bctitle, origtarget, vnctalk_avatarup); + end + -- prevent further handling (i.e. storing offline) + -- return 1 + end + +end + +--- execute first +module:hook("message/bare", send_message, 5); diff --git a/vnctalk/mod_vnc_delfile.lua b/vnctalk/mod_vnc_delfile.lua new file mode 100644 index 0000000..3f7e307 --- /dev/null +++ b/vnctalk/mod_vnc_delfile.lua @@ -0,0 +1,123 @@ +--require "socket" +--rawset(_G, "PROXY", false); -- socket module accesses global variable PROXY + +local http = require "net.http" +local json = require "util.json" +-- local ltn12 = require "ltn12" +local jid_bare = require "util.jid".bare; +local jid_split = require "util.jid".split; +local timer = require "util.timer"; +local st = require "util.stanza"; +local time_now = os.time; + +local delAPIURL = module:get_option_string("del_api_url"); + +local moduleIsActive = delAPIURL; + +local function contains(list, x) + if (list) then + for _, v in pairs(list) do + if (v == x) then return true end + end + end + return false +end + +local function starts_with(str, start) + return str:sub(1, #start) == start +end + +local function dumpTable(t, depth) + local result = ""; + if (not depth) then depth = 0 end; + + local prefix = string.rep(" ", depth); + + if (type(t) ~= "table") then + result = result.." "..tostring(t); + else + for key,value in pairs(t) do + result = result..prefix..tostring(key).." => "..dumpTable(value, depth+1).."\n" + end + end + + return result; +end + +local function trim(s) + if s and (type(s) == "string") then + return (s:gsub("^%s*(.-)%s*$", "%1")) + else + return s + end +end + + +local function getPostCallback() + return function(body, code, response) + if code ~= 200 then + module:log("error", "DEL result: HTTP %s", tostring(code)) + -- if body then module:log("error", body); end + end + return false -- close request + end +end + +local function postDEL(_from, _replace) + + if (not moduleIsActive) then return end; + + local delRequest = { + from = _from, + msgid = _replace + }; + + local data = json.encode(delRequest); + + local httpRequestOptions = { + method = "POST", + body = data, + headers = { + ["Connection"] = 'close', + ["Content-Type"] = "application/json; charset=utf-8" + } + } + + module:log("info", "sending delete for message id %s from %s", _replace, _from); + --module:log("info", "sendingHTTP request to %s", delAPIURL); + http.request(delAPIURL, httpRequestOptions, getPostCallback()) +end + + +local function message_handler(event, fromLocal) + local origin, stanza = event.origin, event.stanza; + local message_id = event.stanza.attr.id; + local type = stanza.attr.type or "normal"; + local from = jid_bare(stanza.attr.from); + local to = jid_bare(stanza.attr.to) or from; + + if (to ~= from) + then + + local content = trim(stanza:find("body#")); + local isMessageCorrection = stanza:find("{urn:xmpp:message-correct:0}replace@id"); + -- module:log("info", "got replaceid: %s", isMessageCorrection); + if (isMessageCorrection) then + postDEL(from, isMessageCorrection); + end + end + return; +end + +local function message_handler_from_local(event) + return message_handler(event, true); +end + + +if (moduleIsActive) +then + module:hook("pre-message/bare", message_handler_from_local, 5); + module:hook("pre-message/full", message_handler_from_local, 5); + + module:log("info", "DEL HTTP upload files ACTIVATED"); +end diff --git a/vnctalk/mod_vnc_e2ehints.lua b/vnctalk/mod_vnc_e2ehints.lua new file mode 100644 index 0000000..a515fc5 --- /dev/null +++ b/vnctalk/mod_vnc_e2ehints.lua @@ -0,0 +1,54 @@ +local jid_split = require "util.jid".split; +module:depends"muc"; + + +local function get_e2e(room) + local e2e = room._data.e2e; + if e2e then -- explicitly configured + return e2e; + end + return false; +end + +module:hook("muc-config-form", function(event) + local room, form = event.room, event.form; + local re2e = get_e2e(room); + table.insert(form, { + name = "muc#roomconfig_e2e", + type = "boolean", + label = "if the room is E2E ecnrypted", + value = re2e, + }); + -- value = get_e2e(room), +end); + +module:hook("muc-config-submitted", function(event) + local room, fields, changed = event.room, event.fields, event.changed; + local new = fields["muc#roomconfig_e2e"]; + if new ~= get_e2e(room) then + if new == get_e2e(room) then + room._data.e2e = false; + else + room._data.e2e = new; + end + if type(changed) == "table" then + changed["muc#roomconfig_e2e"] = true; + else + event.changed = true; + end + end +end); + +module:hook("muc-disco#info", function (event) + local room, form, formdata = event.room, event.form, event.formdata; + + local e2e = get_e2e(room); + module:log("info", "got e2e as %s", e2e); +-- if not e2e or e2e == "" then + --return; +-- end + table.insert(form, { + name = "muc#roominfo_e2e", + }); + formdata["muc#roominfo_e2e"] = e2e; +end); diff --git a/vnctalk/mod_vnc_fcm.lua b/vnctalk/mod_vnc_fcm.lua new file mode 100644 index 0000000..117ac2a --- /dev/null +++ b/vnctalk/mod_vnc_fcm.lua @@ -0,0 +1,982 @@ +--require "socket" +--rawset(_G, "PROXY", false); -- socket module accesses global variable PROXY + +local http = require "net.http" +local json = require "util.json" +-- local ltn12 = require "ltn12" +local jid_bare = require "util.jid".bare; +local jid_split = require "util.jid".split; +local timer = require "util.timer"; +local st = require "util.stanza"; +local time_now = os.time; + +local private_storage = module:open_store("private"); +local vcard_storage = module:open_store("vcard"); +local config_store = nil; +local fcm_token_store = module:open_store("fcmtoken"); +local fcm_token_map_store = module:open_store("fcmtoken", "map"); + +local fcmAPIKey = module:get_option_string("fcm_api_key"); +local fcmAPIURL = module:get_option_string("fcm_api_url"); + +local moduleIsActive = fcmAPIURL and fcmAPIKey; + +local isMUC = module:get_host_type() == "component"; +local ThisDomain = module.host; + +local xmlns_sm2 = "urn:xmpp:sm:2"; +local xmlns_sm3 = "urn:xmpp:sm:3"; + +local ThisBroadcast = "broadcast@"..ThisDomain; + +module:add_feature("urn:xmpp:vnctalk:fcm"); + +local inactive_devices = {}; + +local UDomain = ThisDomain; + +if isMUC then + local storage_host = module:get_option_string("storage_host"); + UDomain = storage_host; + module:log("debug", "opening stores for host %s", storage_host); + timer.add_task(3, function () + private_storage = module:open_store(storage_host, "private"); + vcard_storage = module:open_store(storage_host, "vcard"); + fcm_token_store = module:open_store(storage_host, "fcmtoken"); + fcm_token_map_store = module:open_store(storage_host, "fcmtoken", "map"); + if not private_storage or not vcard_storage then + module:log("debug", "private/vcard_store %s/%s not found? - will try again", private_storage, vcard_storage); + return 3; + else + module:log("debug", "private/vcard_store are now %s/%s", private_storage, vcard_storage); + end + end + ); + config_store = module:open_store("config"); +end + +local function contains(list, x) + if (list) then + for _, v in pairs(list) do + if (v == x) then return true end + end + end + return false +end + +local function starts_with(str, start) + return str:sub(1, #start) == start +end + +local function dumpTable(t, depth) + local result = ""; + if (not depth) then depth = 0 end; + + local prefix = string.rep(" ", depth); + + if (type(t) ~= "table") then + result = result.." "..tostring(t); + else + for key,value in pairs(t) do + result = result..prefix..tostring(key).." => "..dumpTable(value, depth+1).."\n" + end + end + + return result; +end + + +local function deserializeDataElement(pdElement, expectedName) + --module:log("debug", "deserializeDataElement("..expectedName..", "..dumpTable(pdElement)..")"); + if (not pdElement) then return nil end + if (type(pdElement) ~= "table") then return nil end + + if (pdElement[1] and pdElement["attr"] and pdElement["attr"]["key"] and (pdElement["attr"]["key"] == expectedName)) + then + local result = json.decode(pdElement[1]); + --module:log("debug", "deserializeDataElement() = '"..dumpTable(result).."'"); + return result; + end + return nil; +end + + +local function trim(s) + if s and (type(s) == "string") then + return (s:gsub("^%s*(.-)%s*$", "%1")) + else + return s + end +end + +local function collapseSpaces(s) + if s and (type(s) == "string") then + return (s:gsub("%s+", " ")) + else + return s + end +end + +local function prettyUsername(userName) + userName = string.upper(string.sub(userName, 1, 1))..string.sub(userName, 2, -1) -- upcase first char in any case + if string.find(userName, "%.") -- contains dot + then + local i = 1 + while true do + local j = string.find(userName, "%.", i) + if j == nil or #userName == i then break end + -- replace dot with space and upcase next char + userName = string.sub(userName, 1, j-1).." "..string.upper(string.sub(userName, j+1, j+1))..string.sub(userName, j+2, -1) + if #userName <= j then break end + i = j + end + end + return trim(collapseSpaces(userName)); +end + + +local function getDisplayName(userName) + local vCard, err = vcard_storage:get(userName); + if vCard then + vCard = st.deserialize(vCard); + end + local name = nil; + if not vCard or err then + module:log("error", "Unable to get vCard for %s : %s ", userName, tostring(err)); + else + name = vCard:find("{vcard-temp}FN#"); + end + if not name then name = prettyUsername(userName) or userName end + module:log("debug", "name for %s : %s", userName, name) + return name; +end + + + +local function removeKeyFromUser(userName, fcmId) + local pd, pfFetchError = private_storage:get(userName); + if (pfFetchError) then + module:log( + "error", + "Fetching private data for '%s' failed : %s", tostring(userName), tostring(pfFetchError) + ); + return 0,{}; + elseif (pd) then + local removed = false; + for key,value in pairs(pd) + do + --module:log("debug", "PDD("..userName..") : "..tostring(key).." => "..tostring(value)); + if ((key == "documents:stanza:io:json") and (type(value) == "table")) then + --module:log("debug", "PDD.stanza("..userName..") : "..json.encode(value)); + for k2,v2 in pairs(value) do + local fcm = deserializeDataElement(v2, "fcm"); + if (fcm) + then + module:log("debug", "removing fcmID %s from private data record: %s", fcmId, json.encode(fcm)); + fcm[fcmId] = nil; + v2[1]=json.encode(fcm); + removed = true; + --module:log("debug", "AFTER PDD.stanza: "..json.encode(v2)); + --module:log("debug", "AFTER PDD: "..json.encode(value)); + --module:log("debug", "removed outdated key"); + break; + end + end + end + end + if (removed) + then + local ok, errmsg = private_storage:set(userName, pd); + if (not ok) + then + module:log("error", "failed to store private data for %s : '%s'", userName, tostring(errmsg)); + end + else + module:log("debug", "unable to remove fcmID %s from %s. Not found in data: %s", fcmId, userName, tostring(pd)); + end + end +end + + +local function getPostCallback(_fcmId, _userName) + return function(body, code, response) + local shortKey = string.sub(_fcmId, 1, 6) + if code ~= 200 then + module:log("error", "FCM result %s@%s : HTTP %s %s", _userName, shortKey, tostring(code), tostring(body)) + -- if body then module:log("error", body); end + else + module:log("debug", "FCM result %s@%s : HTTP %s %s", _userName, shortKey, tostring(code), tostring(body)) + local response2 = json.decode(body) + if response2.results and (type(response2.results) == "table") and response2.results[1] and response2.results[1]["error"] == "NotRegistered" + then + -- module:log("debug", "%s key %s is outdated", _userName, shortKey); + removeKeyFromUser(_userName, _fcmId); + end + if response2.results and (type(response2.results) == "table") and response2.results[1] and response2.results[1]["error"] == "MissingRegistration" + then + -- module:log("debug", "%s key %s is never registered", _userName, shortKey); + removeKeyFromUser(_userName, _fcmId); + end + if response2.results and (type(response2.results) == "table") and response2.results[1] and response2.results[1]["error"] == "InvalidRegistration" + then + -- module:log("debug", "%s key %s is invalid", _userName, shortKey); + removeKeyFromUser(_userName, _fcmId); + end + if response2.results and (type(response2.results) == "table") and response2.results[1] and response2.results[1]["error"] == "InvalidPackageName" + then + -- module:log("debug", "%s key %s is using invalid FCM package name", _userName, shortKey); + removeKeyFromUser(_userName, _fcmId); + end + if response2.results and (type(response2.results) == "table") and response2.results[1] and response2.results[1]["error"] == "error:MessageTooBig" + then + -- module:log("debug", "%s key %s : message too big", _userName, shortKey); + end + if response2.results and (type(response2.results) == "table") and response2.results[1] and response2.results[1]["error"] == "InvalidDataKey" + then + -- module:log("debug", "%s key %s is using invalid data key", _userName, shortKey); + end + if response2.results and (type(response2.results) == "table") and response2.results[1] and response2.results[1]["error"] == "InvalidTtl" + then + --- module:log("debug", "%s key %s is using invalid TTL", _userName, shortKey); + end + end + return false -- close request + --return true -- keep request open + end +end + +local function postFCM(_userName, _fcmId, _source, _body, _type, _senderName, _groupTopic, _attachment_type, _lang, _msgid, _vncTalkConferenceEtype, _vncTalkConferenceId, _nFromJid, _jitsiURL, _jitsiRoom, _isMessageCorrection, _enableSoundOpt) + + if (not moduleIsActive) then return end; + local domainname = ""; + local v_attachment = ""; + if isMUC then + domainname = module:get_option_string("storage_host"); + else + domainname = module.host; + end + + if (_attachment_type ~= "") and (_attachment_type ~=nil) then + v_attachment = _attachment_type; + end + + local lsource = _source; + local ltype = _type; + local signal = nil; + local nfrom = _source; + local nJitsiURL = ""; + local nJitsiRoom = ""; + local replaceMsgId = ""; + + if (_isMessageCorrection ~= nil) then + replaceMsgId = _isMessageCorrection; + end + + if (_vncTalkConferenceId ~= nil) then + if string.match(_vncTalkConferenceId, "@") then + lsource = _vncTalkConferenceId; + end + end + + if (_vncTalkConferenceEtype ~=nil) then + signal = "1"; + ltype = _vncTalkConferenceEtype; + end + + if (_nFromJid ~= nil) then + nfrom = _nFromJid; + end + + if (_jitsiURL ~= nil) then + nJitsiURL = _jitsiURL; + end + + if (_jitsiRoom ~= nil) then + nJitsiRoom = _jitsiRoom; + end + + local stamp = os.time(); + + local fcmRequest = { + to = _fcmId, + enableSoundOpt = _enableSoundOpt, + content_available = true, + priority = "high", + data = { + nType = "local_notification", -- notification type + eType = ltype, -- event type + jid = lsource, -- source jid (room jid or 1:1 chat peer bare jid) + conferenceId = _vncTalkConferenceId, + nfrom = nfrom, + nto = _userName.."@"..domainname, + name = _senderName, -- group chat nick name or single chat peer vCard name", + gt = _groupTopic, -- topic of room iff groupchat + aft = v_attachment, + callSignal = signal, + jitsiURL = nJitsiURL, + jitsiRoom = nJitsiRoom, + t = stamp, + lang = _lang, + msgid = _msgid, + replaceid = replaceMsgId, + body = _body -- notification body/text content + } + }; + + fcmRequest["android"] = { priority = "high", ttl = 345600 }; + fcmRequest["webpush"] = { headers = { Urgency = "high", TTL = 345600 } }; + + local data = json.encode(fcmRequest); + + local httpRequestOptions = { + method = "POST", + body = data, + headers = { + ["Connection"] = 'close', + ["Authorization"] = "key="..fcmAPIKey, + ["Content-Type"] = "application/json; charset=utf-8" + } + } + + module:log("info", "sending notification to %s for token %s - from: %s - msgid: %s - body: %s", _userName.."@"..domainname, _fcmId, _source, _msgid, _body); + -- ssl does not seem to work with net.http, so we proxy it + --http.request("https://fcm.googleapis.com/fcm/send", httpRequestOptions, postCallback) + module:log("info", "sendingHTTP request to %s", fcmAPIURL); + http.request(fcmAPIURL, httpRequestOptions, getPostCallback(_fcmId, _userName)) +end + +local function postFCMIOS(_userName, _fcmId, _source, _body, _type, _senderName, _groupTopic, _attachment_type, _lang, _msgid, _vncTalkConferenceEtype, _vncTalkConferenceId, _nFromJid, _jitsiURL, _jitsiRoom, _isMessageCorrection, _enableSoundOpt) + + if (not moduleIsActive) then return end; + local domainname = ""; + local ntitle = ""; + local nbody = ""; + local v_attachment = ""; + local nJitsiURL = ""; + local nJitsiRoom = ""; + local replaceMsgId = ""; + + if (_isMessageCorrection ~= nil) then + replaceMsgId = _isMessageCorrection; + end + + if isMUC then + domainname = module:get_option_string("storage_host"); + if (_groupTopic ~= "") and (_groupTopic ~= nil) then + ntitle = _groupTopic; + else + ntitle = jid_split(_source); + end + else + domainname = module.host; + if (_groupTopic ~= "") and (_groupTopic ~=nil) then + ntitle = _groupTopic + else + if (_senderName ~= "") and (_senderName ~= nil) then + ntitle = _senderName; + else + ntitle = _source; + end + end + end + + if (_attachment_type ~= "") and (_attachment_type ~=nil) then + v_attachment = _attachment_type; + end + + local _click_action = _type:upper(); + + if (_click_action == "NORMAL") then + _click_action = "CHAT"; + end + + local lsource = _source; + local ltype = _type; + local signal = nil; + local nfrom = _source; + + if (_vncTalkConferenceId ~= nil) then + if string.match(_vncTalkConferenceId, "@") then + lsource = _vncTalkConferenceId; + end + end + + if (_vncTalkConferenceEtype ~=nil) then + signal = "1"; + ltype = _vncTalkConferenceEtype; + end + + if (_nFromJid ~= nil) then + nfrom = _nFromJid; + end + + if (_jitsiURL ~= nil) then + nJitsiURL = _jitsiURL; + end + + if (_jitsiRoom ~= nil) then + nJitsiRoom = _jitsiRoom; + end + module:log("info", "sending IOS sound %s", _enableSoundOpt); + + local fcmRequest = { + to = _fcmId, + priority = "high", + enableSoundOpt = _enableSoundOpt, + notification = { + title = ntitle, + body = _body, + mutable_content = true, + sound = "incoming-call-loop.caff", + thread_id = lsource, + click_action = _click_action + }, + data = { + nType = "local_notification", -- notification type + eType = ltype, -- event type + jid = lsource, -- source jid (room jid or 1:1 chat peer bare jid) + conferenceId = _vncTalkConferenceId, + nfrom = nfrom, + nto = _userName.."@"..domainname, + name = _senderName, -- group chat nick name or single chat peer vCard name", + gt = ntitle, -- topic of room iff groupchat + aft = v_attachment, + callSignal = signal, + jitsiURL = nJitsiURL, + jitsiRoom = nJitsiRoom, + lang = _lang, + msgid = _msgid, + replaceid = replaceMsgId, + body = _body -- notification body/text content + } + }; + + local data = json.encode(fcmRequest); + + local httpRequestOptions = { + method = "POST", + body = data, + headers = { + ["Connection"] = 'close', + ["Authorization"] = "key="..fcmAPIKey, + ["Content-Type"] = "application/json; charset=utf-8" + } + } + + -- module:log("debug", "sending notification to %s for token %s - from: %s - msgid: %s - body: %s", _userName.."@"..domainname, _fcmId, _source, _msgid, string.sub(_body, 20).."..."); + module:log("debug", "sending notification to %s for token %s - from: %s - msgid: %s - body: %s", _userName.."@"..domainname, _fcmId, _source, _msgid, _body.."..."); + -- ssl does not seem to work with net.http, so we proxy it + --http.request("https://fcm.googleapis.com/fcm/send", httpRequestOptions, postCallback) + http.request(fcmAPIURL, httpRequestOptions, getPostCallback(_fcmId, _userName)) +end + + +local function getNotifyOptionsForUser(userName) + -- module:log("info", "in getNotifyOptionsForUser %s", userName ); + local pd, pfFetchError = private_storage:get(userName); + if (pfFetchError) then + module:log( + "error", + "Fetching private data for '%s' failed : %s", tostring(userName), tostring(pfFetchError) + ); + return 0,{}; + else + local tokens; + local empty; + local nType; + local nLang = "en"; + local globalMute = false; + local enableSound; + if (pd) then + for key,value in pairs(pd) + do + -- module:log("debug", "PD("..userName..") : "..tostring(key).." => "..tostring(value)); + module:log("debug", "PD( %s) : %s => %s ", userName,tostring(key),tostring(value)); + if ((key == "documents:stanza:io:json") and (type(value) == "table")) then + --module:log("debug", "PD("..userName..") : "..json.encode(value)); + -- module:log("debug", "PD( %s ) : %s ", userName ,json.encode(value)); + for k2,v2 in pairs(value) do + if (not tokens) then + local fcm = deserializeDataElement(v2, "fcm"); + if (type(fcm) == "table") then + tokens = {}; + for token,x in pairs(fcm) do + -- module:log("debug", "token: %s - x[os]: %s", token, x["os"]); + if (x["os"] ~= nil) then + table.insert(tokens, token.."@"..x["os"]) + end + end + end + end + if (not nType) then + local options = deserializeDataElement(v2, "notifyOptions"); + if (options and (type(options) == "table") and options["type"]) then + nType = tonumber(options["type"]); + module:log("info", "got nType: %s", nType); + end + end + if (not nType) then + local options = deserializeDataElement(v2, "notification"); + module:log("info", "got new NTYPE option %s", options); + if (options) then + nType = tonumber(options); + module:log("info", "got nType: %s", nType); + end + end + if (enableSound == nil) then + local enableSoundOpt = deserializeDataElement(v2, "enabledSound"); + module:log("info", "got new SOUND option %s", enableSoundOpt); + enableSound = enableSoundOpt; + end; + + local pGlobalMute = deserializeDataElement(v2, "globalMute"); + if (pGlobalMute) then + module:log("info", "got pGlobalMute: %s", pGlobalMute); + nType = 0; + tokens = {}; + end + if (not nLang) then + local options = deserializeDataElement(v2, "lang"); + -- module:log("debug", "PDLang: %s => %s", userName, json.encode(options)); + if (options) and (type(options) == "string") then + nLang = options; + end; + -- module:log("debug", "type of options: %s", type(options)); + end + end + break; -- we can stop after the documents:stanza:io:json element + end + end + --module:log("debug", "getNotifyOptionsForUser("..userName..") = "..tostring(tokens).." => "..tostring(nType)); + end + local map_res = fcm_token_store:get(userName); + + -- module:log("debug", "result from map store for %s is: %s", userName, dumpTable(map_res)); + + if (map_res) then + for _device, _tkr in pairs(map_res) do + -- module:log("debug", "got for %s device %s => %s", userName, _device, dumpTable(_tkr)); + -- module:log("debug", "got for %s device %s => token: %s - os: %s", userName, _device, _tkr.token, _tkr.os); + local sessions = prosody.bare_sessions; + local ubjid = userName.."@"..UDomain; + local u_sessions = sessions[ubjid] or nil; + local deviceonline = false; + if (u_sessions) then + local u_sessions_device = u_sessions["sessions"] or nil; + if (u_sessions_device) then + module:log("debug", "devise is %s", _device); + -- module:log("debug", "u_sessions_device %s", dumpTable(u_sessions_device)); + local dsession = u_sessions_device[_device] or nil; + -- module:log("debug", "dsession %s", dumpTable(dsession)); + if (dsession) then + local hibernated = dsession["hibernated"] or nil; + local hibernating = dsession["hibernating"] or nil; + local awaiting_ack = dsession["awaiting_ack"] or nil; + module:log("info", "found session for _device %s - hibernating: %s, hibernated %s", _device, hibernating, hibernated); + if not ((hibernated) or (hibernating)) then + local dev_in_background = inactive_devices[_device] or nil; + if dev_in_background then + if awaiting_ack then + module:log("info", "device %s is still connected, but inactive and awaiting_ack", _device); + else + module:log("info", "device %s is still connected, but inactive", _device); + deviceonline = true; + end + else + if awaiting_ack then + module:log("info", "device %s is connected and active, but awaiting_ack", _device); + else + module:log("info", "device %s is connected and active", _device); + deviceonline = true; + end + end + end + end + end + end + if not(deviceonline) then + local already_added = contains(tokens, _tkr.token.."@".._tkr.os); + module:log("debug","already_added: %s", _tkr.token.."@".._tkr.os); + if not(tokens) then + tokens = {}; + end + if not(already_added) then + table.insert(tokens, _tkr.token.."@".._tkr.os); + end + end + -- module:log("debug","tokens table is now: %s", dumpTable(tokens)); + end + end + + if (not tokens) then tokens = {}; end; + if (not nType) then nType = 2; end; + --module:log("debug", "getNotifyOptionsForUser(%s) = %i, %i tokens", userName, nType, table.getn(tokens)); + -- module:log("debug", "getNotifyOptionsForUser(%s) = %i, %i tokens, lang %s", userName, nType, table.getn(tokens), nLang); + local sessions = prosody.bare_sessions; + local ubjid = userName.."@"..ThisDomain; + for _user, _resdetails in pairs(sessions) do + if (_user == ubjid) then + for _res, _detail1 in pairs(_resdetails["sessions"]) do + local hibernated = _detail1["hibernated"] or _detail1["hibernating"] or nil; + if (hibernated) then + module:log("info", "for _user: %s and _res: %s got hibernated since: %s", _user, _res, hibernated); + else + module:log("info", "for _user: %s and _res: %s got active session?", _user, _res); + --module:log("debug", "for _user: %s and _res: %s got detail: %s", _user, _res, dumpTable(_detail1)); + end + end + -- module:log("debug", "session for _res %s is: %s", _res, dumpTable(_resdetails)); + end + end + -- module:log("debug", "sessions: %s", dumpTable(sessions[ubjid])); + return nType, tokens, nLang, enableSound; + end +end + + + +local function fcm_notify(userName, title, body, type, _senderName, _groupTopic, vnc_attachment_type, msgid, vncTalkConferenceEtype, vncTalkConferenceId, _nFrom, _jitsiURL, _jitsiRoom, _isMessageCorrection) + if (not moduleIsActive) then return end; + local nType, tokens, lang, soundOpt = getNotifyOptionsForUser(userName); + + if (tokens and (#tokens) > 0) and ((nType == 1) or (nType == 2) or vncTalkConferenceEtype) + then + module:log( + "info", "in fcm_notify(%s, %s, %s) => type=%s, %s tokens", + userName, title, body, tostring(nType), tostring((#tokens)) + ); + local sName = _senderName; + local gTopic = _groupTopic; + if (nType == 1) then + sName = "new message"; + gTopic = "new message"; + body = ""; + end + if (_isMessageCorrection ~= nil) then + body = " "; + end; + for i,token in pairs(tokens) do + if (token) + then + local rtoken, ros = jid_split(token); + -- module:log("debug","got rtoken: %s - os: %s ", rtoken, ros); + if (ros == "ios") then + -- module:log("debug", "postFCMIOS with (%s, %s, %s, %s, %s, %s, %s, %s, %s)", userName, rtoken, title, body, type, _senderName, _groupTopic, vnc_attachment_type, lang); + postFCMIOS(userName, rtoken, title, body, type, sName, gTopic, vnc_attachment_type, lang, msgid, vncTalkConferenceEtype, vncTalkConferenceId, _nFrom, _jitsiURL, _jitsiRoom, _isMessageCorrection, soundOpt); + else + -- module:log("debug", "postFCM with (%s, %s, %s, %s, %s, %s, %s, %s, %s)", userName, rtoken, title, body, type, _senderName, _groupTopic, vnc_attachment_type, lang, _nFrom); + postFCM(userName, rtoken, title, body, type, sName, gTopic, vnc_attachment_type, lang, msgid, vncTalkConferenceEtype, vncTalkConferenceId, _nFrom, _jitsiURL, _jitsiRoom, _isMessageCorrection, soundOpt); + end + end + end + else + module:log("debug", "fcm_notify(%s, %s, %s) => type=%s, no tokens", userName, title, body, tostring(nType)); + end +end + +-- build map from room nicknames to real JIDs +local room_nick_jid = {} -- key1=roomJid, key2=occupantNickname, value=occupantJid +local room_afffiliations = {} -- key1=roomJid, key2=occupantNickname, value=occupantJid +module:hook("muc-room-changed", function (event) + + --module:log("debug", "muc-room-changed "..event.room.jid.."\n"..tostring(event.room)); + + --if not room_nick_jid[event.room.jid] + --then + -- module:log("debug", "muc: new room "..tostring(event.room)) + --else + -- module:log("debug", "muc: "..tostring(event.room).." changed") + --end + + room_nick_jid[event.room.jid] = {} -- reset mapping table for the current room + local nick_jid = room_nick_jid[event.room.jid] -- mapping table for the current room + + for nick, odata in pairs(event.room._occupants) + do + local jid = jid_bare(odata["jid"]) + nick_jid[nick]=jid + -- module:log("debug", "muc nick "..nick.." => "..jid.." in "..event.room.jid); + end + + -- copy affiliations table + room_afffiliations[event.room.jid] = {} + for bareJid, role in pairs(event.room._affiliations) + do + room_afffiliations[event.room.jid][bareJid] = role; + -- module:log("debug", "muc role %s %s %s", event.room.jid, bareJid, role); + end +end); + +local function message_handler(event, fromLocal) + --TODO; use stanza:find here + -- module:log("debug", "MH0"); + local origin, stanza = event.origin, event.stanza; + local message_id = event.stanza.attr.id; + -- module:log("debug","message id: %s", event.stanza.attr.id); + local type = stanza.attr.type or "normal"; + local from = jid_bare(stanza.attr.from); + local to = jid_bare(stanza.attr.to) or from; + + local vncTalkConferenceEtype = stanza:find("{xmpp:vnctalk}vncTalkConference/eventType#") or nil; + + local vnctalk_broadcast = stanza:find("{xmpp:vnctalk}vncTalkBroadcast", "xmpp:vnctalk" ) or false; + -- module:log("info", "vnctalk_avatarup broadcast: %s", vnctalk_broadcast); + local vnctalk_avatarup = false; + if (vnctalk_broadcast) then + vnctalk_avatarup = vnctalk_broadcast.attr.avatarup or false; + end + if (to == ThisBroadcast) then + vnctalk_avatarup = true; + end + + module:log("info", "handling message from=%s, to=%s, fromLocal=%s", from, to, fromLocal); + + if ((to ~= from) or ((to == from) and vncTalkConferenceEtype)) + then + + local content = trim(stanza:find("body#")); + local delayedDelivery = stanza:get_child("delay", "urn:xmpp:delay"); + + -- if we have a vncTalk incoming or whiteboard element, it overrides the type + local vncTalkIncomingType = stanza:find("{xmpp:vnctalk}vncTalkConference/conferenceType#"); + + local vncTalkConferenceId = stanza:find("{xmpp:vnctalk}vncTalkConference/conferenceId#") or nil; + local jitsiURL = stanza:find("{xmpp:vnctalk}vncTalkConference/jitsiURL#") or nil; + local jitsiRoom = stanza:find("{xmpp:vnctalk}vncTalkConference/jitsiRoom#") or nil; + -- module:log("debug", "vncTalkIncomingType: %s", vncTalkIncomingType); + local vncTalkWhiteboard = stanza:find("{xmpp:vnctalk}whiteboard"); + local isSentCarbonMessage = stanza:get_child("sent", "urn:xmpp:carbons:2"); + local vnc_attachment_type = stanza:find("{xmpp:vnctalk}attachment/fileType#"); + + local vnctalk_broadcast = stanza:get_child("vncTalkBroadcast", "xmpp:vnctalk"); + local vnctalk_broadcast_childid = nil; + if vnctalk_broadcast then + if starts_with(to, "broadcast") then + -- module:log("message is to broadcast - bailing out"); + return nil; + end + vnctalk_broadcast_childid = vnctalk_broadcast.attr.id; + -- module:log("debug", "this is a broadcast message: %s", tostring(stanza)); + local vnctalk_broadcast_title = vnctalk_broadcast.attr.title; + -- module:log("debug", "this is a broadcast title: %s", vnctalk_broadcast_title); + local vnctalk_broadcast_target = vnctalk_broadcast.attr.origtarget; + -- module:log("debug", "this is a broadcast target: %s", vnctalk_broadcast_target); + local body = stanza:get_child("body"); + -- module:log("debug", "broadcast body: %s", content); + -- module:log("debug", "jid_split(to): %s", jid_split(to)); + if (not(vnctalk_avatarup)) then + fcm_notify(jid_split(to), vnctalk_broadcast_target, content, type, getDisplayName(jid_split(from)), "Broadcast: "..vnctalk_broadcast_title, vnc_attachment_type, message_id, vncTalkConferenceEtype, vncTalkConferenceId, nil, jitsiURL, jitsiRoom); + end + return nil; + end + + local isMessageCorrection = stanza:find("{urn:xmpp:message-correct:0}replace@id") or nil; + if (isMessageCorrection) then + content = " "; + type = "CORRECTION"; + end + -- debug from here + module:log("debug","continue to process message with body: %s", content); + module:log("info","isMessageCorrection: %s", dumpTable(isMessageCorrection)); + local auxType = vncTalkIncomingType or (vncTalkWhiteboard and "whiteboard"); + + if (auxType) + then + content = auxType; + type = auxType; + end; + + local doNotify = content and (content ~= " ") and (content ~= "") and (not(vnctalk_avatarup)) and (delayedDelivery == nil) and (isSentCarbonMessage == nil) and ( + (type == "chat") or (type == "groupchat") or (type == "audio") or (type == "video") or (type == "whiteboard") or (type == "screen") or (type == "CORRECTION") + ); + + if (doNotify) + then + module:log("debug", "536-doNotify: %s -> %s fl=%s, type=%s, content=%s, carbon=%s, isMUC=%s => doNotify=%s", + stanza.attr.from, stanza.attr.to, tostring(fromLocal), type, content, isSentCarbonMessage, tostring(isMUC), tostring(doNotify)); + if (not isMUC) + then + module:log("info", "537-doNotify not isMUC: %s -> %s fl=%s, type=%s, content=%s, carbon=%s, isMUC=%s => doNotify=%s", + stanza.attr.from, stanza.attr.to, tostring(fromLocal), type, content, isSentCarbonMessage, tostring(isMUC), tostring(doNotify)); + --if (fromLocal) + --then + -- module:log("debug", " ignoring message from local client"); + --module:log("debug", "not sending fromLocal from=%s, to=%s, type=%s, content=%s, isSentCarbonMessage=%s", from, to, type, content, isSentCarbonMessage); + local fromuser2, fromdomain2 = jid_split(stanza.attr.from); + local touser2, todomain2 = jid_split(stanza.attr.to); + module:log("info", "fromdomain2: %s <=> ThisDomain: %s", fromdomain2, ThisDomain); + module:log("info", "todomain2: %s <=> ThisDomain: %s", todomain2, ThisDomain); + if (fromLocal and (fromdomain2 == ThisDomain) and ((todomain2 == ThisDomain) or (todomain2 == nil))) and (type ~= "groupchat") then + module:log("info", "checkssa1"); + fcm_notify(jid_split(to), from, content, type, getDisplayName(jid_split(from)), nil, vnc_attachment_type, message_id, vncTalkConferenceEtype, vncTalkConferenceId, nil, jitsiURL, jitsiRoom, isMessageCorrection); + else + module:log("info", "checkssa2"); + if (type ~= "groupchat") and (fromdomain2 ~= ThisDomain) then + fcm_notify(jid_split(to), from, content, type, getDisplayName(jid_split(from)), nil, vnc_attachment_type, message_id, vncTalkConferenceEtype, vncTalkConferenceId, nil, jitsiURL, jitsiRoom, isMessageCorrection); + else + -- notify when not from a local domain + if not (prosody.hosts[fromdomain2]) then + module:log("info", "I do not know about %s", fromdomain2); + end + if not (prosody.hosts[fromdomain2]) and type == "groupchat" then + local rjnode, rjhost, rjres = jid_split(stanza.attr.from); + local rdn, rdh = jid_split(rjres); + local tdn, tdh, tdr = jid_split(stanza.attr.to); + if ((rdn == tdn) and (rdh == tdh)) then + module:log("info", "766 ... rdn=%s, tdn=%s, rdh=%s, tdh=%s", rdn, tdn, rdh, tdh); + else + module:log("info", "541-doNotify IOM: %s -> %s fl=%s, type=%s, content=%s, carbon=%s, isMUC=%s => doNotify=%s, rdn=%s", + stanza.attr.from, stanza.attr.to, tostring(fromLocal), type, content, isSentCarbonMessage, tostring(isMUC), tostring(doNotify), rdn); + + fcm_notify(jid_split(to), from, content, type, getDisplayName(rdn), nil, vnc_attachment_type, message_id, vncTalkConferenceEtype, vncTalkConferenceId, nil, jitsiURL, jitsiRoom, isMessageCorrection); + end + else + module:log("debug", " WTF ignoring message"); + end + end + end + --end + + else -- MUC + module:log("debug", "538-doNotify isMUC: %s -> %s fl=%s, type=%s, content=%s, carbon=%s, isMUC=%s => doNotify=%s", + stanza.attr.from, stanza.attr.to, tostring(fromLocal), type, content, isSentCarbonMessage, tostring(isMUC), tostring(doNotify)); + + local muc, host = jid_split(to); + if prosody.hosts[host] then + module:log("info","muc conf: %s", dumpTable(prosody.hosts[host].modules.muc)); + -- module:log("debug","muc conf: %s", dumpTable(prosody.hosts[host].modules.muc.rooms[to])); + -- module:log("debug","muc affiliations: %s", dumpTable(prosody.hosts[host].modules.muc.rooms[to]._affiliations)); + module.log("info", "processing to and found local muc component: %s", to); + local affs = prosody.hosts[host].modules.muc.rooms[to]._affiliations; + if affs then + for _aff, role in pairs(affs) do + local now = time_now(); + local roomTopic = prosody.hosts[host].modules.muc.rooms[to]._data.subject or nil; + module:log("info", "got muc _aff %s from muc %s", _aff, to); + if from ~= _aff then + module:log("info", " notify MUC affilaite Jid=%s", _aff); + module:log("debug", "muc notifiy from: %s => to: %s", from, to); + local roomJid = to; + fcm_notify(jid_split(_aff), roomJid, content, type, getDisplayName(jid_split(from)), roomTopic, vnc_attachment_type, message_id, vncTalkConferenceEtype, vncTalkConferenceId, from, jitsiURL, jitsiRoom, isMessageCorrection); + end + end + end + end + end + else + module:log("debug", "ignoring from=%s, to=%s, type=%s, content=%s", from, to, type, content); + end + end + + return; +end + +local function iom_muc_handler(event) + -- module:log("debug", "iom muc handler - event: %s", dumpTable(event)); + local origin, stanza = event.origin, event.stanza; + local message_id = event.stanza.attr.id; + -- module:log("debug","message id: %s", event.stanza.attr.id); + local type = stanza.attr.type or "normal"; + local from = jid_bare(stanza.attr.from); + local to = jid_bare(stanza.attr.to) or from; + local fromloc,fromhost = jid_split(from); + local toloc,tohost = jid_split(to); + if (prosody.hosts[tohost] and (prosody.hosts[fromhost] == nil)) then + module:log("debug", "iom message type %s from host %s - to host %s", type, fromhost, tohost); + return message_handler(event, false); + else + return nil; + end +end + +local function message_handler_to_local(event) + return message_handler(event, false); +end + +local function message_handler_from_local(event) + return message_handler(event, true); +end + +local function logCsiEvent(event, active) + local csires = event.origin.resource or nil; + if active + then + inactive_devices[csires] = nil; + module:log("debug", "ACTIVE: %s", tostring(event.origin)); + module:log("debug", "ACTIVE resource %s", csires); +-- module:log("debug", "ACTIVE: %s", dumpTable(event.origin)); + else + module:log("debug", "INACTIVE: %s", tostring(event.origin)); + -- module:log("debug", "INACTIVE: %s", dumpTable(event.origin)); + module:log("debug", "INACTIVE resource %s", csires); + local t = os.time(); + inactive_devices[csires] = t; + end +end + +local function handle_iq(event) + local origin, stanza = event.origin, event.stanza; + local from_node, from_host, from_res = jid_split(stanza.attr.from); + local reply = st.reply(stanza); + local res = false; + if stanza.attr.type == "set" then + local fcm_child = stanza:get_child("add", "xmpp:vnctalk:fcm") or nil; + if (fcm_child) then + local fcm_token = fcm_child:get_child("fcm", "xmpp:vnctalk:fcm") or nil; + if (fcm_token) then + local _device = fcm_token.attr.device or nil; + local _token = fcm_token.attr.token or nil; + local _os = fcm_token.attr.os or nil; + module:log("debug", "going to add token for %s - device %s, token: %s ", from_node, _device, _token); + local do_add_token = (_device) and (_token) and (_os); + if (do_add_token) then + local tk_os = { token = _token , os = _os }; + local _store_fcm = fcm_token_map_store:set(from_node, _device, tk_os); + res = true; + origin.send(st.reply(stanza):tag('add', {xmlns='xmpp:vnctalk:fcm'}):text("ok")); + return true; + -- module:log("debug", "going to add token for ") + end + end + end + end + if res then + return true; + end +end + +if (moduleIsActive) +then + -- Stanszas to local clients + module:hook("message/bare", message_handler_to_local, 2); + module:hook("message/full", iom_muc_handler, 2); + --module:hook("message/full", message_handler_to_local, 2); + -- Stanzas sent by local clients + module:hook("pre-message/bare", message_handler_from_local, 2); + module:hook("pre-message/full", message_handler_from_local, 2); + module:hook("vnc-rest-message", message_handler_from_local, 2); + + module:hook("csi-client-active", function(event) logCsiEvent(event, true); end); + module:hook("csi-client-inactive", function(event) logCsiEvent(event, false); end); + + -- to track new connection + module:hook("resource-bind", function(event) + -- module:log("debug", "resource bind event %s", dumpTable(event.session.resource)); + local resbind = event.session.resource; + inactive_devices[resbind] = nil; + end); + + module:hook_stanza(xmlns_sm3, "resume", function (event) + -- module:log("debug", "resource bind event %s", dumpTable(event.session.resource)); + end); + +-- module:hook("pre-iq/full", handle_iq, 1); + module:hook("pre-iq/bare", handle_iq, 100); +-- module:hook("pre-iq/host", handle_iq, 1); + +-- module:hook("iq/self/urn:xmpp:vnctalk:fcm", handle_iq, -1); +-- module:hook("iq/bare/urn:xmpp:vnctalk:fcm", handle_iq, -1); +-- module:hook("iq/host/urn:xmpp:vnctalk:fcm", handle_iq, -1); + + module:log("info", "FCM notifications ACTIVATED"); +end diff --git a/vnctalk/mod_vnc_fcm_hin.lua b/vnctalk/mod_vnc_fcm_hin.lua new file mode 100644 index 0000000..505cedf --- /dev/null +++ b/vnctalk/mod_vnc_fcm_hin.lua @@ -0,0 +1,932 @@ +--require "socket" +--rawset(_G, "PROXY", false); -- socket module accesses global variable PROXY + +local http = require "net.http" +local json = require "util.json" +-- local ltn12 = require "ltn12" +local jid_bare = require "util.jid".bare; +local jid_split = require "util.jid".split; +local timer = require "util.timer"; +local st = require "util.stanza"; +local time_now = os.time; + +local private_storage = module:open_store("private"); +local vcard_storage = module:open_store("vcard"); +local config_store = nil; +local fcm_token_store = module:open_store("fcmtoken"); +local fcm_token_map_store = module:open_store("fcmtoken", "map"); + +local fcmAPIKey = module:get_option_string("fcm_api_key"); +local fcmAPIURL = module:get_option_string("fcm_api_url"); + +local moduleIsActive = fcmAPIURL and fcmAPIKey; + +local isMUC = module:get_host_type() == "component"; +local ThisDomain = module.host; + +local xmlns_sm2 = "urn:xmpp:sm:2"; +local xmlns_sm3 = "urn:xmpp:sm:3"; + +local ThisBroadcast = "broadcast@"..ThisDomain; + +module:add_feature("urn:xmpp:vnctalk:fcm"); + +local inactive_devices = {}; + +local UDomain = ThisDomain; + +if isMUC then + local storage_host = module:get_option_string("storage_host"); + UDomain = storage_host; + module:log("debug", "opening stores for host %s", storage_host); + timer.add_task(3, function () + private_storage = module:open_store(storage_host, "private"); + vcard_storage = module:open_store(storage_host, "vcard"); + fcm_token_store = module:open_store(storage_host, "fcmtoken"); + fcm_token_map_store = module:open_store(storage_host, "fcmtoken", "map"); + if not private_storage or not vcard_storage then + module:log("debug", "private/vcard_store %s/%s not found? - will try again", private_storage, vcard_storage); + return 3; + else + module:log("debug", "private/vcard_store are now %s/%s", private_storage, vcard_storage); + end + end + ); + config_store = module:open_store("config"); +end + +local function contains(list, x) + if (list) then + for _, v in pairs(list) do + if (v == x) then return true end + end + end + return false +end + +local function starts_with(str, start) + return str:sub(1, #start) == start +end + +local function dumpTable(t, depth) + local result = ""; + if (not depth) then depth = 0 end; + + local prefix = string.rep(" ", depth); + + if (type(t) ~= "table") then + result = result.." "..tostring(t); + else + for key,value in pairs(t) do + result = result..prefix..tostring(key).." => "..dumpTable(value, depth+1).."\n" + end + end + + return result; +end + + +local function deserializeDataElement(pdElement, expectedName) + --module:log("debug", "deserializeDataElement("..expectedName..", "..dumpTable(pdElement)..")"); + if (not pdElement) then return nil end + if (type(pdElement) ~= "table") then return nil end + + if (pdElement[1] and pdElement["attr"] and pdElement["attr"]["key"] and (pdElement["attr"]["key"] == expectedName)) + then + local result = json.decode(pdElement[1]); + --module:log("debug", "deserializeDataElement() = '"..dumpTable(result).."'"); + return result; + end + return nil; +end + + +local function trim(s) + if s and (type(s) == "string") then + return (s:gsub("^%s*(.-)%s*$", "%1")) + else + return s + end +end + +local function collapseSpaces(s) + if s and (type(s) == "string") then + return (s:gsub("%s+", " ")) + else + return s + end +end + +local function prettyUsername(userName) + userName = string.upper(string.sub(userName, 1, 1))..string.sub(userName, 2, -1) -- upcase first char in any case + if string.find(userName, "%.") -- contains dot + then + local i = 1 + while true do + local j = string.find(userName, "%.", i) + if j == nil or #userName == i then break end + -- replace dot with space and upcase next char + userName = string.sub(userName, 1, j-1).." "..string.upper(string.sub(userName, j+1, j+1))..string.sub(userName, j+2, -1) + if #userName <= j then break end + i = j + end + end + return trim(collapseSpaces(userName)); +end + + +local function getDisplayName(userName) + local vCard, err = vcard_storage:get(userName); + if vCard then + vCard = st.deserialize(vCard); + end + local name = nil; + if not vCard or err then + module:log("error", "Unable to get vCard for %s : %s ", userName, tostring(err)); + else + name = vCard:find("{vcard-temp}FN#"); + end + if not name then name = prettyUsername(userName) or userName end + module:log("debug", "name for %s : %s", userName, name) + return name; +end + + + +local function removeKeyFromUser(userName, fcmId) + local pd, pfFetchError = private_storage:get(userName); + if (pfFetchError) then + module:log( + "error", + "Fetching private data for '%s' failed : %s", tostring(userName), tostring(pfFetchError) + ); + return 0,{}; + elseif (pd) then + local removed = false; + for key,value in pairs(pd) + do + --module:log("debug", "PDD("..userName..") : "..tostring(key).." => "..tostring(value)); + if ((key == "documents:stanza:io:json") and (type(value) == "table")) then + --module:log("debug", "PDD.stanza("..userName..") : "..json.encode(value)); + for k2,v2 in pairs(value) do + local fcm = deserializeDataElement(v2, "fcm"); + if (fcm) + then + module:log("debug", "removing fcmID %s from private data record: %s", fcmId, json.encode(fcm)); + fcm[fcmId] = nil; + v2[1]=json.encode(fcm); + removed = true; + --module:log("debug", "AFTER PDD.stanza: "..json.encode(v2)); + --module:log("debug", "AFTER PDD: "..json.encode(value)); + --module:log("debug", "removed outdated key"); + break; + end + end + end + end + if (removed) + then + local ok, errmsg = private_storage:set(userName, pd); + if (not ok) + then + module:log("error", "failed to store private data for %s : '%s'", userName, tostring(errmsg)); + end + else + module:log("debug", "unable to remove fcmID %s from %s. Not found in data: %s", fcmId, userName, tostring(pd)); + end + end +end + + +local function getPostCallback(_fcmId, _userName) + return function(body, code, response) + local shortKey = string.sub(_fcmId, 1, 6) + if code ~= 200 then + module:log("error", "FCM result %s@%s : HTTP %s %s", _userName, shortKey, tostring(code), tostring(body)) + -- if body then module:log("error", body); end + else + module:log("debug", "FCM result %s@%s : HTTP %s %s", _userName, shortKey, tostring(code), tostring(body)) + local response2 = json.decode(body) + if response2.results and (type(response2.results) == "table") and response2.results[1] and response2.results[1]["error"] == "NotRegistered" + then + -- module:log("debug", "%s key %s is outdated", _userName, shortKey); + removeKeyFromUser(_userName, _fcmId); + end + if response2.results and (type(response2.results) == "table") and response2.results[1] and response2.results[1]["error"] == "MissingRegistration" + then + -- module:log("debug", "%s key %s is never registered", _userName, shortKey); + removeKeyFromUser(_userName, _fcmId); + end + if response2.results and (type(response2.results) == "table") and response2.results[1] and response2.results[1]["error"] == "InvalidRegistration" + then + -- module:log("debug", "%s key %s is invalid", _userName, shortKey); + removeKeyFromUser(_userName, _fcmId); + end + if response2.results and (type(response2.results) == "table") and response2.results[1] and response2.results[1]["error"] == "InvalidPackageName" + then + -- module:log("debug", "%s key %s is using invalid FCM package name", _userName, shortKey); + removeKeyFromUser(_userName, _fcmId); + end + if response2.results and (type(response2.results) == "table") and response2.results[1] and response2.results[1]["error"] == "error:MessageTooBig" + then + -- module:log("debug", "%s key %s : message too big", _userName, shortKey); + end + if response2.results and (type(response2.results) == "table") and response2.results[1] and response2.results[1]["error"] == "InvalidDataKey" + then + -- module:log("debug", "%s key %s is using invalid data key", _userName, shortKey); + end + if response2.results and (type(response2.results) == "table") and response2.results[1] and response2.results[1]["error"] == "InvalidTtl" + then + --- module:log("debug", "%s key %s is using invalid TTL", _userName, shortKey); + end + end + return false -- close request + --return true -- keep request open + end +end + +local function postFCM(_userName, _fcmId, _source, _body, _type, _senderName, _groupTopic, _attachment_type, _lang, _msgid, _vncTalkConferenceEtype, _vncTalkConferenceId, _nFromJid, _jitsiURL, _jitsiRoom) + + if (not moduleIsActive) then return end; + local domainname = ""; + local v_attachment = ""; + if isMUC then + domainname = module:get_option_string("storage_host"); + else + domainname = module.host; + end + + if (_attachment_type ~= "") and (_attachment_type ~=nil) then + v_attachment = _attachment_type; + end + + local lsource = _source; + local ltype = _type; + local signal = nil; + local nfrom = _source; + local nJitsiURL = ""; + local nJitsiRoom = ""; + + if (_vncTalkConferenceId ~= nil) then + if string.match(_vncTalkConferenceId, "@") then + lsource = _vncTalkConferenceId; + end + end + + if (_vncTalkConferenceEtype ~=nil) then + signal = "1"; + ltype = _vncTalkConferenceEtype; + end + + if (_nFromJid ~= nil) then + nfrom = _nFromJid; + end + + if (_jitsiURL ~= nil) then + nJitsiURL = _jitsiURL; + end + + if (_jitsiRoom ~= nil) then + nJitsiRoom = _jitsiRoom; + end + + local stamp = os.time(); + + local fcmRequest = { + to = _fcmId, + content_available = true, + priority = "high", + data = { + nType = "local_notification", -- notification type + eType = ltype, -- event type + jid = lsource, -- source jid (room jid or 1:1 chat peer bare jid) + nfrom = nfrom, + nto = _userName.."@"..domainname, + name = _senderName, -- group chat nick name or single chat peer vCard name", + gt = _groupTopic, -- topic of room iff groupchat + aft = v_attachment, + callSignal = signal, + jitsiURL = nJitsiURL, + jitsiRoom = nJitsiRoom, + t = stamp, + lang = _lang, + msgid = _msgid, + body = _body -- notification body/text content + } + }; + + fcmRequest["android"] = { priority = "high", ttl = 345600 }; + fcmRequest["webpush"] = { headers = { Urgency = "high", TTL = 345600 } }; + + local data = json.encode(fcmRequest); + + local httpRequestOptions = { + method = "POST", + body = data, + headers = { + ["Connection"] = 'close', + ["Authorization"] = "key="..fcmAPIKey, + ["Content-Type"] = "application/json; charset=utf-8" + } + } + + module:log("info", "sending notification to %s for token %s - from: %s - msgid: %s - body: %s", _userName.."@"..domainname, _fcmId, _source, _msgid, _body); + -- ssl does not seem to work with net.http, so we proxy it + --http.request("https://fcm.googleapis.com/fcm/send", httpRequestOptions, postCallback) + module:log("info", "sendingHTTP request to %s", fcmAPIURL); + http.request(fcmAPIURL, httpRequestOptions, getPostCallback(_fcmId, _userName)) +end + +local function postFCMIOS(_userName, _fcmId, _source, _body, _type, _senderName, _groupTopic, _attachment_type, _lang, _msgid, _vncTalkConferenceEtype, _vncTalkConferenceId, _nFromJid, _jitsiURL, _jitsiRoom) + + if (not moduleIsActive) then return end; + local domainname = ""; + local ntitle = ""; + local nbody = ""; + local v_attachment = ""; + local nJitsiURL = ""; + local nJitsiRoom = ""; + + if isMUC then + domainname = module:get_option_string("storage_host"); + if (_groupTopic ~= "") and (_groupTopic ~= nil) then + ntitle = _groupTopic; + else + ntitle = jid_split(_source); + end + else + domainname = module.host; + if (_groupTopic ~= "") and (_groupTopic ~=nil) then + ntitle = _groupTopic + else + if (_senderName ~= "") and (_senderName ~= nil) then + ntitle = _senderName; + else + ntitle = _source; + end + end + end + + if (_attachment_type ~= "") and (_attachment_type ~=nil) then + v_attachment = _attachment_type; + end + + local _click_action = _type:upper(); + + if (_click_action == "NORMAL") then + _click_action = "CHAT"; + end + + local lsource = _source; + local ltype = _type; + local signal = nil; + local nfrom = _source; + + if (_vncTalkConferenceId ~= nil) then + if string.match(_vncTalkConferenceId, "@") then + lsource = _vncTalkConferenceId; + end + end + + if (_vncTalkConferenceEtype ~=nil) then + signal = "1"; + ltype = _vncTalkConferenceEtype; + end + + if (_nFromJid ~= nil) then + nfrom = _nFromJid; + end + + if (_jitsiURL ~= nil) then + nJitsiURL = _jitsiURL; + end + + if (_jitsiRoom ~= nil) then + nJitsiRoom = _jitsiRoom; + end + + local fcmRequest = { + to = _fcmId, + priority = "high", + notification = { + title = ntitle, + body = _body, + mutable_content = true, + sound = "incoming-call-loop.caff", + thread_id = lsource, + click_action = _click_action + }, + data = { + nType = "local_notification", -- notification type + eType = ltype, -- event type + jid = lsource, -- source jid (room jid or 1:1 chat peer bare jid) + nfrom = nfrom, + nto = _userName.."@"..domainname, + name = _senderName, -- group chat nick name or single chat peer vCard name", + gt = ntitle, -- topic of room iff groupchat + aft = v_attachment, + callSignal = signal, + jitsiURL = nJitsiURL, + jitsiRoom = nJitsiRoom, + lang = _lang, + msgid = _msgid, + body = _body -- notification body/text content + } + }; + + local data = json.encode(fcmRequest); + + local httpRequestOptions = { + method = "POST", + body = data, + headers = { + ["Connection"] = 'close', + ["Authorization"] = "key="..fcmAPIKey, + ["Content-Type"] = "application/json; charset=utf-8" + } + } + + -- module:log("debug", "sending notification to %s for token %s - from: %s - msgid: %s - body: %s", _userName.."@"..domainname, _fcmId, _source, _msgid, string.sub(_body, 20).."..."); + module:log("debug", "sending notification to %s for token %s - from: %s - msgid: %s - body: %s", _userName.."@"..domainname, _fcmId, _source, _msgid, _body.."..."); + -- ssl does not seem to work with net.http, so we proxy it + --http.request("https://fcm.googleapis.com/fcm/send", httpRequestOptions, postCallback) + http.request(fcmAPIURL, httpRequestOptions, getPostCallback(_fcmId, _userName)) +end + + +local function getNotifyOptionsForUser(userName) + --module:log("debug", "getFCMtokenForUser("..userName..")"); + local pd, pfFetchError = private_storage:get(userName); + if (pfFetchError) then + module:log( + "error", + "Fetching private data for '%s' failed : %s", tostring(userName), tostring(pfFetchError) + ); + return 0,{}; + elseif (pd) then + local tokens; + local nType; + local nLang; + for key,value in pairs(pd) + do + -- module:log("debug", "PD("..userName..") : "..tostring(key).." => "..tostring(value)); + module:log("debug", "PD( %s) : %s => %s ", userName,tostring(key),tostring(value)); + if ((key == "documents:stanza:io:json") and (type(value) == "table")) then + --module:log("debug", "PD("..userName..") : "..json.encode(value)); + -- module:log("debug", "PD( %s ) : %s ", userName ,json.encode(value)); + for k2,v2 in pairs(value) do + if (not tokens) then + local fcm = deserializeDataElement(v2, "fcm"); + if (type(fcm) == "table") then + tokens = {}; + for token,x in pairs(fcm) do + -- module:log("debug", "token: %s - x[os]: %s", token, x["os"]); + if (x["os"] ~= nil) then + table.insert(tokens, token.."@"..x["os"]) + end + end + end + end + if (not nType) then + local options = deserializeDataElement(v2, "notifyOptions"); + if (options and (type(options) == "table") and options["type"]) then + nType = tonumber(options["type"]); + end + end + if (not nLang) then + local options = deserializeDataElement(v2, "lang"); + -- module:log("debug", "PDLang: %s => %s", userName, json.encode(options)); + if (options) and (type(options) == "string") then + nLang = options; + end; + -- module:log("debug", "type of options: %s", type(options)); + end + end + break; -- we can stop after the documents:stanza:io:json element + end + end + --module:log("debug", "getNotifyOptionsForUser("..userName..") = "..tostring(tokens).." => "..tostring(nType)); + + local map_res = fcm_token_store:get(userName); + + -- module:log("debug", "result from map store for %s is: %s", userName, dumpTable(map_res)); + + if (map_res) then + for _device, _tkr in pairs(map_res) do + -- module:log("debug", "got for %s device %s => %s", userName, _device, dumpTable(_tkr)); + -- module:log("debug", "got for %s device %s => token: %s - os: %s", userName, _device, _tkr.token, _tkr.os); + local sessions = prosody.bare_sessions; + local ubjid = userName.."@"..UDomain; + local u_sessions = sessions[ubjid] or nil; + local deviceonline = false; + if (u_sessions) then + local u_sessions_device = u_sessions["sessions"] or nil; + if (u_sessions_device) then + module:log("debug", "devise is %s", _device); + -- module:log("debug", "u_sessions_device %s", dumpTable(u_sessions_device)); + local dsession = u_sessions_device[_device] or nil; + -- module:log("debug", "dsession %s", dumpTable(dsession)); + if (dsession) then + local hibernated = dsession["hibernated"] or nil; + local hibernating = dsession["hibernating"] or nil; + local awaiting_ack = dsession["awaiting_ack"] or nil; + module:log("info", "found session for _device %s - hibernating: %s, hibernated %s", _device, hibernating, hibernated); + if not ((hibernated) or (hibernating)) then + local dev_in_background = inactive_devices[_device] or nil; + if dev_in_background then + if awaiting_ack then + module:log("info", "device %s is still connected, but inactive and awaiting_ack", _device); + else + module:log("info", "device %s is still connected, but inactive", _device); + deviceonline = true; + end + else + if awaiting_ack then + module:log("info", "device %s is connected and active, but awaiting_ack", _device); + else + module:log("info", "device %s is connected and active", _device); + deviceonline = true; + end + end + end + end + end + end + if not(deviceonline) then + local already_added = contains(tokens, _tkr.token.."@".._tkr.os); + module:log("debug","already_added: %s", _tkr.token.."@".._tkr.os); + if not(tokens) then + tokens = {}; + end + if not(already_added) then + table.insert(tokens, _tkr.token.."@".._tkr.os); + end + end + -- module:log("debug","tokens table is now: %s", dumpTable(tokens)); + end + end + + if (not tokens) then tokens = {}; end; + if (not nType) then nType = 0; end; + --module:log("debug", "getNotifyOptionsForUser(%s) = %i, %i tokens", userName, nType, table.getn(tokens)); + -- module:log("debug", "getNotifyOptionsForUser(%s) = %i, %i tokens, lang %s", userName, nType, table.getn(tokens), nLang); + local sessions = prosody.bare_sessions; + local ubjid = userName.."@"..ThisDomain; + for _user, _resdetails in pairs(sessions) do + if (_user == ubjid) then + for _res, _detail1 in pairs(_resdetails["sessions"]) do + local hibernated = _detail1["hibernated"] or _detail1["hibernating"] or nil; + if (hibernated) then + module:log("info", "for _user: %s and _res: %s got hibernated since: %s", _user, _res, hibernated); + else + module:log("info", "for _user: %s and _res: %s got active session?", _user, _res); + --module:log("debug", "for _user: %s and _res: %s got detail: %s", _user, _res, dumpTable(_detail1)); + end + end + -- module:log("debug", "session for _res %s is: %s", _res, dumpTable(_resdetails)); + end + end + -- module:log("debug", "sessions: %s", dumpTable(sessions[ubjid])); + return nType, tokens, nLang; + end +end + + + +local function fcm_notify(userName, title, body, type, _senderName, _groupTopic, vnc_attachment_type, msgid, vncTalkConferenceEtype, vncTalkConferenceId, _nFrom, _jitsiURL, _jitsiRoom) + if (not moduleIsActive) then return end; + local nType, tokens, lang = getNotifyOptionsForUser(userName); + if (tokens and (#tokens) > 0) and ((nType == 1) or (nType == 2) or vncTalkConferenceEtype) + then + module:log( + "info", "in fcm_notify(%s, %s, %s) => type=%s, %s tokens", + userName, title, body, tostring(nType), tostring((#tokens)) + ); + local sName = _senderName; + local gTopic = _groupTopic; + sName = "Neue Nachricht"; + gTopic = "Neue Nachricht"; + + if (lang == "en") then + sName = "new message"; + gTopic = "new message"; + end; + + if (lang == "fr") then + sName = "Nouveau message"; + gTopic = "Nouveau message"; + end; + + body = ""; + for i,token in pairs(tokens) do + if (token) + then + local rtoken, ros = jid_split(token); + -- module:log("debug","got rtoken: %s - os: %s ", rtoken, ros); + if (ros == "ios") then + -- module:log("debug", "postFCMIOS with (%s, %s, %s, %s, %s, %s, %s, %s, %s)", userName, rtoken, title, body, type, _senderName, _groupTopic, vnc_attachment_type, lang); + postFCMIOS(userName, rtoken, title, body, type, sName, gTopic, vnc_attachment_type, lang, msgid, vncTalkConferenceEtype, vncTalkConferenceId, _nFrom, _jitsiURL, _jitsiRoom); + else + -- module:log("debug", "postFCM with (%s, %s, %s, %s, %s, %s, %s, %s, %s)", userName, rtoken, title, body, type, _senderName, _groupTopic, vnc_attachment_type, lang, _nFrom); + postFCM(userName, rtoken, title, body, type, sName, gTopic, vnc_attachment_type, lang, msgid, vncTalkConferenceEtype, vncTalkConferenceId, _nFrom, _jitsiURL, _jitsiRoom); + end + end + end + else + module:log("debug", "fcm_notify(%s, %s, %s) => type=%s, no tokens", userName, title, body, tostring(nType)); + end +end + +-- build map from room nicknames to real JIDs +local room_nick_jid = {} -- key1=roomJid, key2=occupantNickname, value=occupantJid +local room_afffiliations = {} -- key1=roomJid, key2=occupantNickname, value=occupantJid +module:hook("muc-room-changed", function (event) + + --module:log("debug", "muc-room-changed "..event.room.jid.."\n"..tostring(event.room)); + + --if not room_nick_jid[event.room.jid] + --then + -- module:log("debug", "muc: new room "..tostring(event.room)) + --else + -- module:log("debug", "muc: "..tostring(event.room).." changed") + --end + + room_nick_jid[event.room.jid] = {} -- reset mapping table for the current room + local nick_jid = room_nick_jid[event.room.jid] -- mapping table for the current room + + for nick, odata in pairs(event.room._occupants) + do + local jid = jid_bare(odata["jid"]) + nick_jid[nick]=jid + -- module:log("debug", "muc nick "..nick.." => "..jid.." in "..event.room.jid); + end + + -- copy affiliations table + room_afffiliations[event.room.jid] = {} + for bareJid, role in pairs(event.room._affiliations) + do + room_afffiliations[event.room.jid][bareJid] = role; + -- module:log("debug", "muc role %s %s %s", event.room.jid, bareJid, role); + end +end); + +local function message_handler(event, fromLocal) + --TODO; use stanza:find here + -- module:log("debug", "MH0"); + local origin, stanza = event.origin, event.stanza; + local message_id = event.stanza.attr.id; + -- module:log("debug","message id: %s", event.stanza.attr.id); + local type = stanza.attr.type or "normal"; + local from = jid_bare(stanza.attr.from); + local to = jid_bare(stanza.attr.to) or from; + + local vncTalkConferenceEtype = stanza:find("{xmpp:vnctalk}vncTalkConference/eventType#") or nil; + + local vnctalk_broadcast = stanza:find("{xmpp:vnctalk}vncTalkBroadcast", "xmpp:vnctalk" ) or false; + -- module:log("info", "vnctalk_avatarup broadcast: %s", vnctalk_broadcast); + local vnctalk_avatarup = false; + if (vnctalk_broadcast) then + vnctalk_avatarup = vnctalk_broadcast.attr.avatarup or false; + end + if (to == ThisBroadcast) then + vnctalk_avatarup = true; + end + + -- module:log("info", "handling message from=%s, to=%s", from, to); + + if ((to ~= from) or ((to == from) and vncTalkConferenceEtype)) + then + + local content = trim(stanza:find("body#")); + local delayedDelivery = stanza:get_child("delay", "urn:xmpp:delay"); + + -- if we have a vncTalk incoming or whiteboard element, it overrides the type + local vncTalkIncomingType = stanza:find("{xmpp:vnctalk}vncTalkConference/conferenceType#"); + + local vncTalkConferenceId = stanza:find("{xmpp:vnctalk}vncTalkConference/conferenceId#") or nil; + local jitsiURL = stanza:find("{xmpp:vnctalk}vncTalkConference/jitsiURL#") or nil; + local jitsiRoom = stanza:find("{xmpp:vnctalk}vncTalkConference/jitsiRoom#") or nil; + -- module:log("debug", "vncTalkIncomingType: %s", vncTalkIncomingType); + local vncTalkWhiteboard = stanza:find("{xmpp:vnctalk}whiteboard"); + local isSentCarbonMessage = stanza:get_child("sent", "urn:xmpp:carbons:2"); + local vnc_attachment_type = stanza:find("{xmpp:vnctalk}attachment/fileType#"); + + local vnctalk_broadcast = stanza:get_child("vncTalkBroadcast", "xmpp:vnctalk"); + local vnctalk_broadcast_childid = nil; + if vnctalk_broadcast then + if starts_with(to, "broadcast") then + -- module:log("message is to broadcast - bailing out"); + return nil; + end + vnctalk_broadcast_childid = vnctalk_broadcast.attr.id; + -- module:log("debug", "this is a broadcast message: %s", tostring(stanza)); + local vnctalk_broadcast_title = vnctalk_broadcast.attr.title; + -- module:log("debug", "this is a broadcast title: %s", vnctalk_broadcast_title); + local vnctalk_broadcast_target = vnctalk_broadcast.attr.origtarget; + -- module:log("debug", "this is a broadcast target: %s", vnctalk_broadcast_target); + local body = stanza:get_child("body"); + -- module:log("debug", "broadcast body: %s", content); + -- module:log("debug", "jid_split(to): %s", jid_split(to)); + if (not(vnctalk_avatarup)) then + fcm_notify(jid_split(to), vnctalk_broadcast_target, content, type, getDisplayName(jid_split(from)), "Broadcast: "..vnctalk_broadcast_title, vnc_attachment_type, message_id, vncTalkConferenceEtype, vncTalkConferenceId, nil, jitsiURL, jitsiRoom); + end + return nil; + end + --local isMessageCorrection = stanza:find("{urn:xmpp:message-correct:0}replace@id"); + + -- debug from here + module:log("debug","continue to process message with body: %s", content); + + local auxType = vncTalkIncomingType or (vncTalkWhiteboard and "whiteboard"); + + if (auxType) + then + content = auxType; + type = auxType; + end; + + local doNotify = content and (content ~= " ") and (content ~= "") and (not(vnctalk_avatarup)) and (delayedDelivery == nil) and (isSentCarbonMessage == nil) and ( + (type == "chat") or (type == "groupchat") or (type == "audio") or (type == "video") or (type == "whiteboard") or (type == "screen") + ); + + if (doNotify) + then + module:log("debug", "536-doNotify: %s -> %s fl=%s, type=%s, content=%s, carbon=%s, isMUC=%s => doNotify=%s", + stanza.attr.from, stanza.attr.to, tostring(fromLocal), type, content, isSentCarbonMessage, tostring(isMUC), tostring(doNotify)); + if (not isMUC) + then + module:log("debug", "537-doNotify not isMUC: %s -> %s fl=%s, type=%s, content=%s, carbon=%s, isMUC=%s => doNotify=%s", + stanza.attr.from, stanza.attr.to, tostring(fromLocal), type, content, isSentCarbonMessage, tostring(isMUC), tostring(doNotify)); + --if (fromLocal) + --then + -- module:log("debug", " ignoring message from local client"); + --module:log("debug", "not sending fromLocal from=%s, to=%s, type=%s, content=%s, isSentCarbonMessage=%s", from, to, type, content, isSentCarbonMessage); + if fromLocal and (type ~= "groupchat") then + fcm_notify(jid_split(to), from, content, type, getDisplayName(jid_split(from)), nil, vnc_attachment_type, message_id, vncTalkConferenceEtype, vncTalkConferenceId, nil, jitsiURL, jitsiRoom); + else + local fromuser2, fromdomain2 = jid_split(stanza.attr.from); + if (type ~= "groupchat") and (fromdomain2 ~= ThisDomain) then + fcm_notify(jid_split(to), from, content, type, getDisplayName(jid_split(from)), nil, vnc_attachment_type, message_id, vncTalkConferenceEtype, vncTalkConferenceId, nil, jitsiURL, jitsiRoom); + else + -- notify when not from a local domain + if not (prosody.hosts[fromdomain2]) and type == "groupchat" then + local rjnode, rjhost, rjres = jid_split(stanza.attr.from); + local rdn, rdh = jid_split(rjres); + local tdn, tdh, tdr = jid_split(stanza.attr.to); + if ((rdn == tdn) and (rdh == tdh)) then + module:log("info", "766 ... rdn=%s, tdn=%s, rdh=%s, tdh=%s", rdn, tdn, rdh, tdh); + else + module:log("info", "541-doNotify IOM: %s -> %s fl=%s, type=%s, content=%s, carbon=%s, isMUC=%s => doNotify=%s, rdn=%s", + stanza.attr.from, stanza.attr.to, tostring(fromLocal), type, content, isSentCarbonMessage, tostring(isMUC), tostring(doNotify), rdn); + + fcm_notify(jid_split(to), from, content, type, getDisplayName(rdn), nil, vnc_attachment_type, message_id, vncTalkConferenceEtype, vncTalkConferenceId, nil, jitsiURL, jitsiRoom); + end + else + module:log("debug", " ignoring message"); + end + end + end + --end + + else -- MUC + module:log("debug", "538-doNotify isMUC: %s -> %s fl=%s, type=%s, content=%s, carbon=%s, isMUC=%s => doNotify=%s", + stanza.attr.from, stanza.attr.to, tostring(fromLocal), type, content, isSentCarbonMessage, tostring(isMUC), tostring(doNotify)); + + local muc, host = jid_split(to); + if prosody.hosts[host] then + module:log("info","muc conf: %s", dumpTable(prosody.hosts[host].modules.muc)); + -- module:log("debug","muc conf: %s", dumpTable(prosody.hosts[host].modules.muc.rooms[to])); + -- module:log("debug","muc affiliations: %s", dumpTable(prosody.hosts[host].modules.muc.rooms[to]._affiliations)); + module.log("info", "processing to and found local muc component: %s", to); + local affs = prosody.hosts[host].modules.muc.rooms[to]._affiliations; + if affs then + for _aff, role in pairs(affs) do + local now = time_now(); + local roomTopic = prosody.hosts[host].modules.muc.rooms[to]._data.subject or nil; + module:log("info", "got muc _aff %s from muc %s", _aff, to); + if from ~= _aff then + module:log("info", " notify MUC affilaite Jid=%s", _aff); + module:log("debug", "muc notifiy from: %s => to: %s", from, to); + local roomJid = to; + fcm_notify(jid_split(_aff), roomJid, content, type, getDisplayName(jid_split(from)), roomTopic, vnc_attachment_type, message_id, vncTalkConferenceEtype, vncTalkConferenceId, from, jitsiURL, jitsiRoom); + end + end + end + end + end + else + module:log("debug", "ignoring from=%s, to=%s, type=%s, content=%s", from, to, type, content); + end + end + + return; +end + +local function iom_muc_handler(event) + -- module:log("debug", "iom muc handler - event: %s", dumpTable(event)); + local origin, stanza = event.origin, event.stanza; + local message_id = event.stanza.attr.id; + -- module:log("debug","message id: %s", event.stanza.attr.id); + local type = stanza.attr.type or "normal"; + local from = jid_bare(stanza.attr.from); + local to = jid_bare(stanza.attr.to) or from; + local fromloc,fromhost = jid_split(from); + local toloc,tohost = jid_split(to); + if (prosody.hosts[tohost] and (prosody.hosts[fromhost] == nil)) then + module:log("debug", "iom message type %s from host %s - to host %s", type, fromhost, tohost); + return message_handler(event, false); + else + return nil; + end +end + +local function message_handler_to_local(event) + return message_handler(event, false); +end + +local function message_handler_from_local(event) + return message_handler(event, true); +end + +local function logCsiEvent(event, active) + local csires = event.origin.resource or nil; + if active + then + inactive_devices[csires] = nil; + module:log("debug", "ACTIVE: %s", tostring(event.origin)); + module:log("debug", "ACTIVE resource %s", csires); +-- module:log("debug", "ACTIVE: %s", dumpTable(event.origin)); + else + module:log("debug", "INACTIVE: %s", tostring(event.origin)); + -- module:log("debug", "INACTIVE: %s", dumpTable(event.origin)); + module:log("debug", "INACTIVE resource %s", csires); + local t = os.time(); + inactive_devices[csires] = t; + end +end + +local function handle_iq(event) + local origin, stanza = event.origin, event.stanza; + local from_node, from_host, from_res = jid_split(stanza.attr.from); + local reply = st.reply(stanza); + local res = false; + if stanza.attr.type == "set" then + local fcm_child = stanza:get_child("add", "xmpp:vnctalk:fcm") or nil; + if (fcm_child) then + local fcm_token = fcm_child:get_child("fcm", "xmpp:vnctalk:fcm") or nil; + if (fcm_token) then + local _device = fcm_token.attr.device or nil; + local _token = fcm_token.attr.token or nil; + local _os = fcm_token.attr.os or nil; + module:log("debug", "going to add token for %s - device %s, token: %s ", from_node, _device, _token); + local do_add_token = (_device) and (_token) and (_os); + if (do_add_token) then + local tk_os = { token = _token , os = _os }; + local _store_fcm = fcm_token_map_store:set(from_node, _device, tk_os); + res = true; + origin.send(st.reply(stanza):tag('add', {xmlns='xmpp:vnctalk:fcm'}):text("ok")); + return true; + -- module:log("debug", "going to add token for ") + end + end + end + end + if res then + return true; + end +end + +if (moduleIsActive) +then + -- Stanszas to local clients + module:hook("message/bare", message_handler_to_local, 2); + module:hook("message/full", iom_muc_handler, 2); + --module:hook("message/full", message_handler_to_local, 2); + -- Stanzas sent by local clients + module:hook("pre-message/bare", message_handler_from_local, 2); + module:hook("pre-message/full", message_handler_from_local, 2); + + module:hook("csi-client-active", function(event) logCsiEvent(event, true); end); + module:hook("csi-client-inactive", function(event) logCsiEvent(event, false); end); + + -- to track new connection + module:hook("resource-bind", function(event) + -- module:log("debug", "resource bind event %s", dumpTable(event.session.resource)); + local resbind = event.session.resource; + inactive_devices[resbind] = nil; + end); + + module:hook_stanza(xmlns_sm3, "resume", function (event) + -- module:log("debug", "resource bind event %s", dumpTable(event.session.resource)); + end); + +-- module:hook("pre-iq/full", handle_iq, 1); + module:hook("pre-iq/bare", handle_iq, 100); +-- module:hook("pre-iq/host", handle_iq, 1); + +-- module:hook("iq/self/urn:xmpp:vnctalk:fcm", handle_iq, -1); +-- module:hook("iq/bare/urn:xmpp:vnctalk:fcm", handle_iq, -1); +-- module:hook("iq/host/urn:xmpp:vnctalk:fcm", handle_iq, -1); + + module:log("info", "FCM notifications ACTIVATED"); +end diff --git a/vnctalk/mod_vnc_lastactivity.lua b/vnctalk/mod_vnc_lastactivity.lua new file mode 100644 index 0000000..34909ca --- /dev/null +++ b/vnctalk/mod_vnc_lastactivity.lua @@ -0,0 +1,370 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local st = require "util.stanza"; +local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed; +local jid_bare = require "util.jid".bare; +local jid_split = require "util.jid".split; +local base64 = require"util.encodings".base64; +local sha1 = require"util.hashes".sha1; + +local my_host = module:get_host(); +local my_host_type = module:get_host_type(); + +local avt_hash_table = {}; +local remote_act_cache = {}; +local remote_act_sessions = {}; + +module:depends"vcard"; +local vcard_storage = module:open_store("vcard"); +local activity_store = module:open_store("activity", "map"); +local remote_activity_store = module:open_store("remote_activity", "map"); +local remote_avatar_update_store = module:open_store("remote_avatar", "map"); + + +local function dumpTable(t, depth) + local result = ""; + if (not depth) then depth = 0 end; + + local prefix = string.rep(" ", depth); + + if (type(t) ~= "table") then + result = result.." "..tostring(t); + else + for key,value in pairs(t) do + result = result..prefix..tostring(key).." => "..dumpTable(value, depth+1).."\n" + end + end + + return result; +end + + +local function get_vcard(username) + local vcard, err = vcard_storage:get(username); + if vcard then + vcard = st.deserialize(vcard); + end + if not vcard then + vcard = st.stanza("vCard", { xmlns = "vcard-temp" }); + end + return vcard, err; +end + + +local function calculate_avatar_hash(username) + local vcard = get_vcard(username) + local photo = vcard and vcard:get_child("PHOTO") + if photo then + local photo_type = photo:get_child_text("TYPE"); + local photo_b64 = photo:get_child_text("BINVAL"); + local photo_raw = photo_b64 and base64.decode(photo_b64); + if photo_raw and photo_type then + return sha1(photo_raw, true); + end + end + return "" +end + +local function get_avatar_hash(username, host) + if (my_host ~= host) then + return nil + end + local session = nil + if (prosody.hosts[host].sessions ~= nil) then + session = prosody.hosts[host] and prosody.hosts[host].sessions[username] + end + local avt_hash = nil; + if not avt_hash_table[username] then + avt_hash = calculate_avatar_hash(username); + avt_hash_table[username] = avt_hash; +-- -- module:log("info", "DEBUG get_avatar_hash returning result after new calc"); + else +-- -- module:log("info", "DEBUG get_avatar_hash returning result from cache"); + avt_hash = avt_hash_table[username]; + end; + return avt_hash +end + +module:add_feature("jabber:iq:last"); +module:add_feature("jabber:iq:batch"); + +local map = {}; + + +module:hook("authentication-success", function(event) + local session = event.session; + if session.username then + local pers = activity_store:set(session.username, "last", 0); + end +end, 10); + + +module:hook("presence/bare", function(event) + local origin, stanza = event.origin, event.stanza; + local origin, stanza = event.origin, event.stanza; + local typ = stanza.attr.type; + -- module:log("info", "Cache hook, got %s from a %s", stanza:top_tag(), origin.type); + if origin.type == "s2sin" and ( typ == nil or typ == "unavailable" ) then + + local contact_full = stanza.attr.from; + local contact_bare = jid_bare(contact_full); + local contact_sessions = remote_act_sessions[contact_bare] or {}; + + -- module:log("info", "contact_sessions from %s is %s", contact_bare, dumpTable(remote_act_sessions[contact_bare])); + + if typ == "unavailable" then -- set current time in cache + local t = os.time(); + local s = ""; + if contact_sessions[contact_full] then + contact_sessions[contact_full] = nil; + remote_act_sessions[contact_bare] = contact_sessions; + if ((#remote_act_sessions[contact_bare]) == 0 ) then + remote_act_sessions[contact_bare] = nil; + remote_act_cache[contact_bare] = t; + -- module:log("info", "remote_act_cache for %s is now %s", contact_bare, remote_act_cache[contact_bare]); + remote_activity_store:set(contact_bare, "last", t); + end + end + else + -- set 0 (online) in cache + -- module:log("info", "storing online state for %s in remote_act_cache", contact_bare); + remote_act_cache[contact_bare] = 0; + local contact_sessions = remote_act_sessions[contact_bare] or {}; + contact_sessions[contact_full] = 1; + remote_act_sessions[contact_bare] = contact_sessions; + local x_update = stanza:get_child("x", "vcard-temp:x:update") or nil; + if (x_update) then + local x_photo_update = x_update:get_child_text("photo") or nil; +-- module:log("info", "Cache hook1 debug: %s", x_photo_update); + if (x_photo_update) then + module:log("info", "avatarupdate - need to store %s update for %s", x_photo_update, contact_bare); + local t = os.time(); + remote_avatar_update_store:set(contact_bare, "avatar", t); + end + end + + end + end + +end, 10); + +module:hook("pre-presence/bare", function(event) + local stanza = event.stanza; + if not(stanza.attr.to) and stanza.attr.type == "unavailable" then + -- module:log("info", "unavailable hook - event.origin.username: %s", event.origin.username); + local s = os.time(); + local t = stanza:get_child_text("status"); + map[event.origin.username] = {s = s, t = t}; + local lastsession = true; + local luser, lhost = jid_split(stanza.attr.from); + if luser and lhost then + local user_sessions = hosts[lhost] and hosts[lhost].sessions[luser]; + if user_sessions then + lastsession = false; + end + end + if lastsession then + -- module:log("info", "last session gone - storing to activity_store for %s => %s", event.origin.username, s); + local pers = activity_store:set(event.origin.username, "last", s); + end + end +end, 10); + +module:hook("iq/bare/jabber:iq:batch:query", function(event) + local origin, stanza = event.origin, event.stanza; + if stanza.attr.type == "get" then + -- -- module:log("info", "got stanza: %s", dumpTable(stanza)); + local querychild = stanza:get_child("query", "jabber:iq:batch"); + local reply = st.reply(stanza):tag('query', {xmlns='jabber:iq:last'}); + -- :childtags("jid", "jabber:iq:batch"); + -- -- module:log("info", "querychilds: %s", dumpTable(querychild)); + for ajid in querychild:childtags("jid") do + local abjid = ajid:get_text(); + -- module:log("info", "got jid: %s", abjid); + local username = jid_split(abjid); + local luser, lhost = jid_split(abjid); + + local seconds, text = "-1", ""; + local hash = ""; + + -- -- module:log("info", "map is now: %s", dumpTable(map)); + if map[username] then + -- module:log("info", "found map[ %s ] => t: %s", username, map[username].t ); + -- module:log("info", "found map[ %s ] => s: %s", username, map[username].s ); + local mt = tonumber(map[username].s); + seconds = tostring(os.difftime(os.time(), mt)); + text = map[username].s; + end + if luser and lhost then + -- -- module:log("info", "luser: %s ++ lhost %s", luser, lhost); + local sessions_avail = false; + if (hosts[lhost]) then + -- module:log("info", "lhost exists: %s", lhost); + if (hosts[lhost].sessions) then + -- module:log("info", "got session object for lhost %s", lhost); + sessions_avail = true; + end + end + if sessions_avail then + local user_sessions = hosts[lhost] and hosts[lhost].sessions[luser]; + local latest_hibernated_stamp = 0; + local all_hibernated = true; + if user_sessions then + for x,s in pairs(user_sessions.sessions) do + if (s.hibernating) then + -- module:log("info", "user_sessions s.hibernated %s is %s", s, s.hibernating); + if (s.hibernating > latest_hibernated_stamp) then + latest_hibernated_stamp = s.hibernating; + end + else + -- module:log("info", "user_sessions %s is active", s); + all_hibernated = false; + end + end + -- module:log("info","latest_hibernated_stamp is %s", latest_hibernated_stamp); + -- module:log("info","all_hibernated is %s", all_hibernated); + if (latest_hibernated_stamp > 0 and all_hibernated) then + seconds = os.difftime(os.time(), latest_hibernated_stamp) + else + seconds = 0 + end + end + end + if not (prosody.hosts[lhost]) then + local contact_bare = luser.."@"..lhost; + -- module:log("info","getting act for remote %s", contact_bare); + if remote_act_cache[contact_bare] then + if ( remote_act_cache[contact_bare] > 0) then + seconds = tostring(os.difftime(os.time(), remote_act_cache[contact_bare])); + else + seconds = 0; + end + end + end + end + + if (seconds == "-1") then + if (prosody.hosts[lhost]) then + local stored_seconds = activity_store:get(luser, "last") or false; + if stored_seconds then + -- module:log("info", "read persistant lastactivity for %s as %s", luser, dumpTable(stored_seconds)); + local nseconds = tonumber(stored_seconds); + if (nseconds == 0) then + -- module:log("info","using restored for %s @ %s ", luser, lhost); + map[event.origin.username] = {s = "0", t = text}; + else + seconds = tostring(os.difftime(os.time(), nseconds)); + -- module:log("info","using restored for %s @ %s => %s", luser, lhost, nseconds); + map[event.origin.username] = {s = nseconds, t = text}; + end + end + end + end + +-- hash = get_avatar_hash(luser, lhost); +-- reply:add_child():tag("result", {jid=abjid, seconds=seconds, photo=hash}):up(); +-- module:log("info","determined for %s => %s seconds", abjid, seconds); +-- module:log("info","determined for %s => %s seconds", abjid, tostring(seconds)); +-- module:log("info","type jid: %s", type(abjid)); +-- module:log("info","type seconds: %s", type(seconds)); +-- module:log("info"," reply before add: %s", reply); + + -- reply:add_child():tag("result", { jid=abjid; seconds=tostring(seconds); } ):up(); + local rtag = st.stanza("result", { jid=abjid; seconds=tostring(seconds); }); + reply:add_child(rtag); + end +-- module:log("info", "sending reply %s", reply); + origin.send(reply); + end + return true; +end); + +module:hook("iq/bare/jabber:iq:last:query", function(event) + local origin, stanza = event.origin, event.stanza; + if stanza.attr.type == "get" then + local username = jid_split(stanza.attr.to) or origin.username; + local luser, lhost = jid_split(stanza.attr.to); + -- if not stanza.attr.to or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then + if stanza.attr.to then + local seconds, text = "-1", ""; + local hash = ""; + if map[username] then + mseconds = map[username].s or 0; + -- module:log("info", "mseconds: %s", mseconds); + if (mseconds == 0) then + seconds=0; + map[username].s=os.time(); + else + -- module:log("info", "difftime from map for user %s is %s", username, dumpTable(map[username])); + -- module:log("info", "difftime will be called for %s == type %s", map[username].s, type(map[username].s)); + seconds = tostring(os.difftime(os.time(), map[username].s)); + -- module:log("info","using2 for %s @ %s => %s --- stored was %s", luser, lhost, seconds, map[username].s); + -- text = map[username].s; + end + end + if luser and lhost then + local user_sessions = hosts[lhost] and hosts[lhost].sessions[luser]; + if user_sessions then + local latest_hibernated_stamp = 0; + local all_hibernated = true; + for x,s in pairs(user_sessions.sessions) do + if (s.hibernating) then + -- module:log("info", "user_sessions s.hibernated %s is %s", s, s.hibernating); + if (s.hibernating > latest_hibernated_stamp) then + latest_hibernated_stamp = s.hibernating; + end + else + -- module:log("info", "user_sessions %s is active", s); + all_hibernated = false; + end + end + -- module:log("info","latest_hibernated_stamp is %s", latest_hibernated_stamp); + -- module:log("info","all_hibernated is %s", all_hibernated); + if (latest_hibernated_stamp > 0 and all_hibernated) then + seconds = os.difftime(os.time(), latest_hibernated_stamp) + else + seconds = 0 + end + end + end + if (seconds == "-1") then + local stored_seconds = activity_store:get(luser, "last") or -1; + if (stored_seconds ~= 0) then +-- -- module:log("info", "read persistant lastactivity for %s as %s", luser, dumpTable(stored_seconds)); + if (stored_seconds == -1) then + seconds = -1 + else + seconds = tostring(os.difftime(os.time(), stored_seconds)); + end; + -- module:log("info","using4 for %s @ %s => %s", luser, lhost, seconds); + if event.origin.username ~= nil then + map[event.origin.username] = {s = seconds, t = text}; + end + else +-- local now = os.time(); + -- module:log("info", "no stored data for %s @ %s", luser, lhost); + -- local upers = activity_store:set(luser, "last", now); + -- seconds = 0; + end + end + -- module:log("info","sending reply with: seconds=%s text=%s", seconds, text); + origin.send(st.reply(stanza):tag('query', {xmlns='jabber:iq:last', seconds=tostring(seconds)}):text(tostring(text))); + else + origin.send(st.error_reply(stanza, 'auth', 'forbidden')); + end + return true; + end +end); + +module.save = function() + return {map = map}; +end +module.restore = function(data) + map = data.map or {}; +end diff --git a/vnctalk/mod_vnc_muc_automember.lua b/vnctalk/mod_vnc_muc_automember.lua new file mode 100644 index 0000000..6f46a0e --- /dev/null +++ b/vnctalk/mod_vnc_muc_automember.lua @@ -0,0 +1,32 @@ +-- mod_vnc_muc_automember +-- + +-- Copyright (C) 2020 VNC - Virtual Network Consult AG +-- +-- This file is MIT/X11 licensed. +-- +-- A module to automatically add invited users as members +-- + +local st = require"util.stanza" + +local muc_util = module:require "muc/util"; +local valid_affiliations = muc_util.valid_affiliations; + +-- When an invite is sent; add an affiliation for the invitee +module:hook("muc-invite", function(event) + local room = event.room; + local stanza = event.stanza; + local invitee = stanza.attr.to; + local affiliation = room:get_affiliation(invitee); + local invited_unaffiliated = valid_affiliations[affiliation or "none"] <= valid_affiliations.none; + if invited_unaffiliated then + local from = stanza:get_child("x", "http://jabber.org/protocol/muc#user") + :get_child("invite").attr.from; + module:log("debug", "%s invited %s into members only room %s, granting membership", + from, invitee, room.jid); + -- This might fail; ignore for now + room:set_affiliation(true, invitee, "member", "Invited by " .. from); + room:save(); + end +end); diff --git a/vnctalk/mod_vnc_muc_data.lua b/vnctalk/mod_vnc_muc_data.lua new file mode 100644 index 0000000..d7e8ad9 --- /dev/null +++ b/vnctalk/mod_vnc_muc_data.lua @@ -0,0 +1,92 @@ +local jid_split = require "util.jid".split; +local st = require "util.stanza"; +local json = require "util.json"; + + +module:depends"muc"; + +local function dumpTable(t, depth) + local result = ""; + if (not depth) then depth = 0 end; + + local prefix = string.rep(" ", depth); + + if (type(t) ~= "table") then + result = result.." "..tostring(t); + else + for key,value in pairs(t) do + result = result..prefix..tostring(key).." => "..dumpTable(value, depth+1).."\n" + end + end + + return result; +end + +local function get_vdata(room) + local vdata = room._data.vdata; + if vdata then -- explicitly configured + return vdata; + end + return "{ }"; +end + +module:hook("muc-config-form", function(event) + local room, form = event.room, event.form; + local vdata = get_vdata(room); + table.insert(form, { + name = "muc#roomconfig_vdata", + type = "text-single", + label = "muc data", + value = vdata, + }); + -- value = get_e2e(room), +end); + + +local function broadcast_presence(room) + local data = room._data; + local aff = room._affiliations; + module:log("info", "updated config for room %s", room); + module:log("info", "new data: %s", dumpTable(data)); + module:log("info", "new aff: %s", dumpTable(aff)); +end + +module:hook("muc-config-submitted", function(event) + local room, fields, changed = event.room, event.fields, event.changed; + local data = room._data; + local affiliations = room._affiliations; + -- module:log("info", "updated config for room %s", room); + -- module:log("info", "new data: %s", dumpTable(data)); + -- module:log("info", "new aff: %s", dumpTable(affiliations)); + -- module:log("info", "event.fields: %s", dumpTable(fields)); + -- module:log("info", "event.changed: %s", dumpTable(changed)); + + room._data.vdata = fields["muc#roomconfig_vdata"]; + if type(changed) == "table" then + changed["muc#roomconfig_vdata"] = true; + else + event.changed = true; + end + local msg = st.message({type='groupchat', from=room.jid}) + :tag('x', {xmlns='xmpp:vnctalk:update'}) + :tag("affiliations"):text(json.encode(affiliations)):up() + :tag("data"):text(json.encode(data)):up(); + module:log("info","st: %s", tostring(msg)); + room:broadcast_message(msg); + +end, -1); + + +module:hook("muc-disco#info", function (event) + local room, form, formdata = event.room, event.form, event.formdata; + + local vdata = get_vdata(room); + module:log("info", "got disco vdata as %s", vdata); +-- if not e2e or e2e == "" then + --return; +-- end + table.insert(form, { + name = "muc#roominfo_vdata", + }); + formdata["muc#roominfo_vdata"] = vdata; +end); diff --git a/vnctalk/mod_vnc_muc_fcm.lua b/vnctalk/mod_vnc_muc_fcm.lua new file mode 100644 index 0000000..5715c40 --- /dev/null +++ b/vnctalk/mod_vnc_muc_fcm.lua @@ -0,0 +1,695 @@ +-- mod_muc_notifications +-- +-- +-- This file is MIT/X11 licensed. +-- +-- A module to notify non-present members of messages in a group chat +-- + +local http = require "net.http"; +local json = require "util.json"; +-- local ltn12 = require "ltn12" +local jid_bare = require "util.jid".bare; +local jid_split = require "util.jid".split; +local jid = require "util.jid"; +local timer = require "util.timer"; +local id = require"util.id" +local st = require"util.stanza" +local storage_host = module:get_option_string("storage_host"); + +module:log("info","founs storage_host: %s", storage_host); + + +local fcmAPIKey = module:get_option_string("fcm_api_key"); +local fcmAPIURL = module:get_option_string("fcm_api_url"); + +local moduleIsActive = fcmAPIURL and fcmAPIKey; + +local inactive_devices = {}; + +local ThisDomain = module.host; + +local xmlns_sm2 = "urn:xmpp:sm:2"; +local xmlns_sm3 = "urn:xmpp:sm:3"; + + +local fcm_token_store = module:open_store("fcmtoken"); +local fcm_token_map_store = module:open_store("fcmtoken", "map"); +local vcard_storage = module:open_store("vcard"); +local private_storage = module:open_store("private"); + +timer.add_task(3, function () + private_storage = module:open_host_store(storage_host, "private"); + vcard_storage = module:open_host_store(storage_host, "vcard"); + fcm_token_store = module:open_host_store(storage_host, "fcmtoken"); + fcm_token_map_store = module:open_host_store(storage_host, "fcmtoken", "map"); + if not private_storage or not vcard_storage then + module:log("info", "private/vcard_store %s/%s not found? - will try again", private_storage, vcard_storage); + return 3; + else + module:log("info", "private/vcard_store are now %s/%s", private_storage, vcard_storage); + end + end +); + + +-- some helper functions +local function deserializeDataElement(pdElement, expectedName) + --module:log("debug", "deserializeDataElement("..expectedName..", "..dumpTable(pdElement)..")"); + if (not pdElement) then return nil end + if (type(pdElement) ~= "table") then return nil end + + if (pdElement[1] and pdElement["attr"] and pdElement["attr"]["key"] and (pdElement["attr"]["key"] == expectedName)) + then + local result = json.decode(pdElement[1]); + --module:log("debug", "deserializeDataElement() = '"..dumpTable(result).."'"); + return result; + end + return nil; +end + + +local function contains(list, x) + if (list) then + for _, v in pairs(list) do + if (v == x) then return true end + end + end + return false +end + +local function starts_with(str, start) + return str:sub(1, #start) == start +end + +local function trim(s) + if s and (type(s) == "string") then + return (s:gsub("^%s*(.-)%s*$", "%1")) + else + return s + end +end + +local function collapseSpaces(s) + if s and (type(s) == "string") then + return (s:gsub("%s+", " ")) + else + return s + end +end + +local function dumpTable(t, depth) + local result = ""; + if (not depth) then depth = 0 end; + + local prefix = string.rep(" ", depth); + + if (type(t) ~= "table") then + result = result.." "..tostring(t); + else + for key,value in pairs(t) do + result = result..prefix..tostring(key).." => "..dumpTable(value, depth+1).."\n" + end + end + + return result; +end + + +local function prettyUsername(userName) + userName = string.upper(string.sub(userName, 1, 1))..string.sub(userName, 2, -1) -- upcase first char in any case + if string.find(userName, "%.") -- contains dot + then + local i = 1 + while true do + local j = string.find(userName, "%.", i) + if j == nil or #userName == i then break end + -- replace dot with space and upcase next char + userName = string.sub(userName, 1, j-1).." "..string.upper(string.sub(userName, j+1, j+1))..string.sub(userName, j+2, -1) + if #userName <= j then break end + i = j + end + end + return trim(collapseSpaces(userName)); +end + + +local function getDisplayName(userName) + -- module:log("info", "getDisplayName user: %s", userName); + local vCard, err = vcard_storage:get(userName); + if vCard then + vCard = st.deserialize(vCard); + end + local name = nil; + if not vCard or err then + module:log("debug", "Unable to get vCard for %s : %s ", userName, tostring(err)); + else + name = vCard:find("{vcard-temp}FN#"); + end + if not name then name = prettyUsername(userName) or userName end + -- module:log("info", "name for %s : %s", userName, name) + return name; +end + +local function getPostCallback(_fcmId, _userName) + return function(body, code, response) + local shortKey = string.sub(_fcmId, 1, 6) + if code ~= 200 then + module:log("info", "FCM result %s@%s : HTTP %s %s", _userName, shortKey, tostring(code), tostring(body)) + -- if body then module:log("error", body); end + else + module:log("debug", "FCM result %s@%s : HTTP %s %s", _userName, shortKey, tostring(code), tostring(body)) + end + return false -- close request + --return true -- keep request open + end +end + +local function postFCM(_userName, _fcmId, _source, _body, _type, _senderName, _groupTopic, _attachment_type, _lang, _msgid, _vncTalkConferenceEtype, _vncTalkConferenceId, _nFromJid, _jitsiURL, _jitsiRoom, _isMessageCorrection, _enableSoundOpt) + + if (not moduleIsActive) then return end; + local domainname = ""; + local v_attachment = ""; + domainname = module:get_option_string("storage_host"); + + if (_attachment_type ~= "") and (_attachment_type ~=nil) then + v_attachment = _attachment_type; + end + + local lsource = _source; + local ltype = _type; + local signal = nil; + local nfrom = _source; + local nJitsiURL = ""; + local nJitsiRoom = ""; + local replaceMsgId = ""; + + if (_isMessageCorrection ~= nil) then + replaceMsgId = _isMessageCorrection; + end + + + if (_vncTalkConferenceId ~= nil) then + if string.match(_vncTalkConferenceId, "@") then + lsource = _vncTalkConferenceId; + end + end + + if (_vncTalkConferenceEtype ~=nil) then + signal = "1"; + ltype = _vncTalkConferenceEtype; + end + + if (_nFromJid ~= nil) then + nfrom = _nFromJid; + end + + if (_jitsiURL ~= nil) then + nJitsiURL = _jitsiURL; + end + + if (_jitsiRoom ~= nil) then + nJitsiRoom = _jitsiRoom; + end + + local stamp = os.time(); + + module:log("info", "postFCM username %s", _userName); + local uname = ""; + if string.match(_userName, "@") then + uname = _userName; + else + uname = _userName.."@"..domainname; + end + + local fcmRequest = { + to = _fcmId, + enableSoundOpt = _enableSoundOpt, + content_available = true, + priority = "high", + data = { + nType = "local_notification", -- notification type + eType = ltype, -- event type + jid = lsource, -- source jid (room jid or 1:1 chat peer bare jid) + conferenceId = _vncTalkConferenceId, + nfrom = nfrom, + nto = uname, + name = _senderName, -- group chat nick name or single chat peer vCard name", + gt = _groupTopic, -- topic of room iff groupchat + aft = v_attachment, + callSignal = signal, + jitsiURL = nJitsiURL, + jitsiRoom = nJitsiRoom, + t = stamp, + lang = _lang, + msgid = _msgid, + replaceid = replaceMsgId, + body = _body -- notification body/text content + } + }; + + fcmRequest["android"] = { priority = "high", ttl = 345600 }; + fcmRequest["webpush"] = { headers = { Urgency = "high", TTL = 345600 } }; + + local data = json.encode(fcmRequest); + + local httpRequestOptions = { + method = "POST", + body = data, + headers = { + ["Connection"] = 'close', + ["Authorization"] = "key="..fcmAPIKey, + ["Content-Type"] = "application/json; charset=utf-8" + } + } + + -- module:log("info", "sending notification to %s for token %s - from: %s - msgid: %s - body: %s", _userName.."@"..domainname, _fcmId, _source, _msgid, _body); + -- ssl does not seem to work with net.http, so we proxy it + --http.request("https://fcm.googleapis.com/fcm/send", httpRequestOptions, postCallback) + http.request(fcmAPIURL, httpRequestOptions, getPostCallback(_fcmId, _userName)) +end + +local function postFCMIOS(_userName, _fcmId, _source, _body, _type, _senderName, _groupTopic, _attachment_type, _lang, _msgid, _vncTalkConferenceEtype, _vncTalkConferenceId, _nFromJid, _jitsiURL, _jitsiRoom, _isMessageCorrection, _enableSoundOpt) + + if (not moduleIsActive) then return end; + local domainname = ""; + local ntitle = ""; + local nbody = ""; + local v_attachment = ""; + local nJitsiURL = ""; + local nJitsiRoom = ""; + local replaceMsgId = ""; + + if (_isMessageCorrection ~= nil) then + replaceMsgId = _isMessageCorrection; + end + + domainname = module:get_option_string("storage_host"); + if (_groupTopic ~= "") and (_groupTopic ~= nil) then + ntitle = _groupTopic; + else + ntitle = jid_split(_source); + end + + if (_attachment_type ~= "") and (_attachment_type ~=nil) then + v_attachment = _attachment_type; + end + + local _click_action = _type:upper(); + + if (_click_action == "NORMAL") then + _click_action = "CHAT"; + end + + local lsource = _source; + local ltype = _type; + local signal = nil; + local nfrom = _source; + + if (_vncTalkConferenceId ~= nil) then + if string.match(_vncTalkConferenceId, "@") then + lsource = _vncTalkConferenceId; + end + end + + if (_vncTalkConferenceEtype ~=nil) then + signal = "1"; + ltype = _vncTalkConferenceEtype; + end + + if (_nFromJid ~= nil) then + nfrom = _nFromJid; + end + if (_jitsiURL ~= nil) then + nJitsiURL = _jitsiURL; + end + + if (_jitsiRoom ~= nil) then + nJitsiRoom = _jitsiRoom; + end + + local fcmRequest = { + to = _fcmId, + priority = "high", + enableSoundOpt = _enableSoundOpt, + notification = { + title = ntitle, + body = _body, + mutable_content = true, + sound = "incoming-call-loop.caff", + thread_id = lsource, + click_action = _click_action + }, + data = { + nType = "local_notification", -- notification type + eType = ltype, -- event type + jid = lsource, -- source jid (room jid or 1:1 chat peer bare jid) + conferenceId = _vncTalkConferenceId, + nfrom = nfrom, + nto = _userName.."@"..domainname, + name = _senderName, -- group chat nick name or single chat peer vCard name", + gt = ntitle, -- topic of room iff groupchat + aft = v_attachment, + callSignal = signal, + jitsiURL = nJitsiURL, + jitsiRoom = nJitsiRoom, + lang = _lang, + msgid = _msgid, + replaceid = replaceMsgId, + body = _body -- notification body/text content + } + }; + + local data = json.encode(fcmRequest); + + local httpRequestOptions = { + method = "POST", + body = data, + headers = { + ["Connection"] = 'close', + ["Authorization"] = "key="..fcmAPIKey, + ["Content-Type"] = "application/json; charset=utf-8" + } + } + + -- module:log("debug", "sending notification to %s for token %s - from: %s - msgid: %s - body: %s", _userName.."@"..domainname, _fcmId, _source, _msgid, string.sub(_body, 20).."..."); + module:log("debug", "sending notification to %s for token %s - from: %s - msgid: %s - body: %s", _userName.."@"..domainname, _fcmId, _source, _msgid, _body.."..."); + -- ssl does not seem to work with net.http, so we proxy it + --http.request("https://fcm.googleapis.com/fcm/send", httpRequestOptions, postCallback) + module:log("debug", "calling http.request -- fcmAPIURL: %s", fcmAPIURL); + http.request(fcmAPIURL, httpRequestOptions, getPostCallback(_fcmId, _userName)) +end + + +-- Given a stanza, compute if it qualifies as important (notifiable) +-- return true for message stanzas with non-empty body +-- Should probably use something similar to muc-message-is-historic event +local function is_important(stanza) + local body = stanza:find("body#") + return body and #body +end + + +-- get notify options for local users +local function getNotifyOptionsForUser(userName) + --module:log("debug", "getFCMtokenForUser("..userName..")"); + local pd, pfFetchError = private_storage:get(userName); + if (pfFetchError) then + module:log( + "error", + "Fetching private data for '%s' failed : %s", tostring(userName), tostring(pfFetchError) + ); + return 0,{}; + else + local tokens; + local nType; + local isGlobalMute = false; + local nLang = "en"; + local enableSound; + if (pd) then + for key,value in pairs(pd) + do + -- module:log("info", "PD("..userName..") : "..tostring(key).." => "..tostring(value)); + module:log("debug", "PD( %s) : %s => %s ", userName,tostring(key),tostring(value)); + if ((key == "documents:stanza:io:json") and (type(value) == "table")) then + --module:log("debug", "PD("..userName..") : "..json.encode(value)); + -- module:log("debug", "PD( %s ) : %s ", userName ,json.encode(value)); + for k2,v2 in pairs(value) do + if (not tokens) then + local fcm = deserializeDataElement(v2, "fcm"); + if (type(fcm) == "table") then + tokens = {}; + for token,x in pairs(fcm) do + -- module:log("debug", "token: %s - x[os]: %s", token, x["os"]); + if (x["os"] ~= nil) then + table.insert(tokens, token.."@"..x["os"]) + end + end + end + end + if (not nType) then + local options = deserializeDataElement(v2, "notifyOptions"); + if (options and (type(options) == "table") and options["type"]) then + nType = tonumber(options["type"]); + end + end + if (not nType) then + local options = deserializeDataElement(v2, "notification"); + module:log("info", "got new NTYPE option %s", options); + if (options) then + nType = tonumber(options); + module:log("info", "got nType: %s", nType); + end + end + if (enableSound == nil) then + local enableSoundOpt = deserializeDataElement(v2, "enabledSound"); + module:log("info", "got new SOUND option %s", enableSoundOpt); + enableSound = enableSoundOpt; + end; + + local pGlobalMute = deserializeDataElement(v2, "globalMute"); + if (pGlobalMute) then + module:log("info", "got pGlobalMute: %s", pGlobalMute); + nType = 0; + isGlobalMute = true; + end + if (not nLang) then + local options = deserializeDataElement(v2, "lang"); + -- module:log("debug", "PDLang: %s => %s", userName, json.encode(options)); + if (options) and (type(options) == "string") then + nLang = options; + end; + -- module:log("debug", "type of options: %s", type(options)); + end + end + break; -- we can stop after the documents:stanza:io:json element + end + end + end + --module:log("debug", "getNotifyOptionsForUser("..userName..") = "..tostring(tokens).." => "..tostring(nType)); + + local map_res = fcm_token_store:get(userName); + + -- module:log("debug", "result from map store for %s is: %s", userName, dumpTable(map_res)); + + if (map_res) then + for _device, _tkr in pairs(map_res) do + -- module:log("debug", "got for %s device %s => %s", userName, _device, dumpTable(_tkr)); + -- module:log("debug", "got for %s device %s => token: %s - os: %s", userName, _device, _tkr.token, _tkr.os); + local sessions = prosody.bare_sessions; + local ubjid = userName.."@"..storage_host; + local u_sessions = sessions[ubjid] or nil; + local deviceonline = false; + if (u_sessions) then + local u_sessions_device = u_sessions["sessions"] or nil; + if (u_sessions_device) then + -- module:log("info", "devise is %s", _device); + -- module:log("debug", "u_sessions_device %s", dumpTable(u_sessions_device)); + local dsession = u_sessions_device[_device] or nil; + -- module:log("debug", "dsession %s", dumpTable(dsession)); + if (dsession) then + local hibernated = dsession["hibernated"] or nil; + local hibernating = dsession["hibernating"] or nil; + local awaiting_ack = dsession["awaiting_ack"] or nil; + module:log("info", "found session for _device %s - hibernating: %s, hibernated %s", _device, hibernating, hibernated); + if not ((hibernated) or (hibernating)) then + local dev_in_background = inactive_devices[_device] or nil; + if dev_in_background then + if awaiting_ack then + module:log("debug", "device %s is still connected, but inactive and awaiting_ack", _device); + -- module:log("info", "handling1: %s", _tkr.token.."@".._tkr.os); + else + module:log("debug", "device %s is still connected, but inactive", _device); + -- module:log("info", "handling2: %s", _tkr.token.."@".._tkr.os); + if (_tkr.os ~= "ios") then + deviceonline = true; + end + end + else + if awaiting_ack then + module:log("debug", "device %s is connected and active, but awaiting_ack", _device); + -- module:log("info", "handling3: %s", _tkr.token.."@".._tkr.os); + else + module:log("debug", "device %s is connected and active", _device); + -- module:log("info", "handling4: %s", _tkr.token.."@".._tkr.os); + if (_tkr.os ~= "ios") then + deviceonline = true; + end + end + end + end + end + end + end + if not(deviceonline) then + local already_added = contains(tokens, _tkr.token.."@".._tkr.os); + module:log("debug","already_added: %s", _tkr.token.."@".._tkr.os); + if not(tokens) then + tokens = {}; + end + if not(already_added) then + table.insert(tokens, _tkr.token.."@".._tkr.os); + end + end + -- module:log("debug","tokens table is now: %s", dumpTable(tokens)); + end + end + + if (not tokens) then tokens = {}; end; + if (not nType) then nType = 2; end; + local sessions = prosody.bare_sessions; + local ubjid = userName.."@"..ThisDomain; + for _user, _resdetails in pairs(sessions) do + if (_user == ubjid) then + for _res, _detail1 in pairs(_resdetails["sessions"]) do + local hibernated = _detail1["hibernated"] or _detail1["hibernating"] or nil; + if (hibernated) then + module:log("info", "for _user: %s and _res: %s got hibernated since: %s", _user, _res, hibernated); + else + module:log("info", "for _user: %s and _res: %s got active session?", _user, _res); + --module:log("debug", "for _user: %s and _res: %s got detail: %s", _user, _res, dumpTable(_detail1)); + end + end + -- module:log("debug", "session for _res %s is: %s", _res, dumpTable(_resdetails)); + end + end + if (isGlobalMute) then + tokens = {}; + end + -- module:log("debug", "sessions: %s", dumpTable(sessions[ubjid])); + return nType, tokens, nLang, enableSound; + end +end + +local function fcm_notify(userName, title, body, type, _senderName, _groupTopic, vnc_attachment_type, msgid, vncTalkConferenceEtype, vncTalkConferenceId, _nFrom, _jitsiURL, _jitsiRoom, _isMessageCorrection) + if (not moduleIsActive) then module:log("info", "module not active - bailung out"); return end; + module:log("info", "getting notify opts for %s", userName); + local uname, uhost = jid_split(userName); + if (uhost ~= storage_host) then return end; + local nType, tokens, lang, soundOpt = getNotifyOptionsForUser(uname); + if (tokens and (#tokens) > 0) and ((nType == 1) or (nType == 2)) + then + -- module:log( + -- "info", "in fcm_notify(%s, %s, %s) => type=%s, %s tokens", + -- userName, title, body, tostring(nType), tostring((#tokens)) + --); + local sName = _senderName; + local gTopic = _groupTopic; + if (nType == 1) then + sName = "new message"; + -- gTopic = "new message"; + body = ""; + end + for i,token in pairs(tokens) do + if (token) + then + local rtoken, ros = jid_split(token); + -- module:log("debug","got rtoken: %s - os: %s ", rtoken, ros); + if (ros == "ios") then + -- module:log("debug", "postFCMIOS with (%s, %s, %s, %s, %s, %s, %s, %s, %s)", userName, rtoken, title, body, type, _senderName, _groupTopic, vnc_attachment_type, lang); + postFCMIOS(userName, rtoken, title, body, type, sName, gTopic, vnc_attachment_type, lang, msgid, vncTalkConferenceEtype, vncTalkConferenceId, _nFrom, _jitsiURL, _jitsiRoom, _isMessageCorrection, soundOpt); + else + -- module:log("info", "postFCM with (%s, %s, %s, %s, %s, %s, %s, %s, %s)", userName, rtoken, title, body, type, _senderName, _groupTopic, vnc_attachment_type, lang, _nFrom); + postFCM(userName, rtoken, title, body, type, sName, gTopic, vnc_attachment_type, lang, msgid, vncTalkConferenceEtype, vncTalkConferenceId, _nFrom, _jitsiURL, _jitsiRoom, _isMessageCorrection, soundOpt); + end + end + end + else + module:log("debug", "fcm_notify(%s, %s, %s) => type=%s, no tokens", userName, title, body, tostring(nType)); + end +end + + +local function handle_muc_message(event) + -- event.room and event.stanza are available + local room = event.room + local stanza = event.stanza + -- module:log("info", "handling stanza %s", stanza); + -- module:log("info", "room: %s", dumpTable(room)); + + local message_id = event.stanza.attr.id; + -- module:log("info","message id: %s", event.stanza.attr.id); + local type = stanza.attr.type or "normal"; + local from = jid_bare(stanza.attr.from); + local to = jid_bare(stanza.attr.to) or from; + + local vncTalkConferenceEtype = stanza:find("{xmpp:vnctalk}vncTalkConference/eventType#") or nil; + + -- module:log("info", "handling message from=%s, to=%s", stanza.attr.from, to); + + + local content = trim(stanza:find("body#")); + local delayedDelivery = stanza:get_child("delay", "urn:xmpp:delay"); + + -- if we have a vncTalk incoming or whiteboard element, it overrides the type + local vncTalkIncomingType = stanza:find("{xmpp:vnctalk}vncTalkConference/conferenceType#"); + local vncTalkConferenceId = stanza:find("{xmpp:vnctalk}vncTalkConference/conferenceId#") or nil; + local jitsiURL = stanza:find("{xmpp:vnctalk}vncTalkConference/jitsiURL#") or nil; + local jitsiRoom = stanza:find("{xmpp:vnctalk}vncTalkConference/jitsiRoom#") or nil; + local vncTalkWhiteboard = stanza:find("{xmpp:vnctalk}whiteboard"); + local isSentCarbonMessage = stanza:get_child("sent", "urn:xmpp:carbons:2"); + local vnc_attachment_type = stanza:find("{xmpp:vnctalk}attachment/fileType#"); + + local vnctalk_broadcast = stanza:get_child("vncTalkBroadcast", "xmpp:vnctalk"); + local vnctalk_broadcast_childid = nil; + if vnctalk_broadcast then + if starts_with(to, "broadcast") then + -- module:log("message is to broadcast - bailing out"); + return nil; + end + vnctalk_broadcast_childid = vnctalk_broadcast.attr.id; + local vnctalk_broadcast_title = vnctalk_broadcast.attr.title; + local vnctalk_broadcast_target = vnctalk_broadcast.attr.origtarget; + local body = stanza:get_child("body"); + -- module:log("debug", "broadcast body: %s", content); + -- module:log("debug", "jid_split(to): %s", jid_split(to)); + -- fcm_notify(jid_split(to), vnctalk_broadcast_target, content, type, getDisplayName(jid_split(from)), "Broadcast: "..vnctalk_broadcast_title, vnc_attachment_type, message_id, vncTalkConferenceEtype, vncTalkConferenceId); + return nil; + end + + local isMessageCorrection = stanza:find("{urn:xmpp:message-correct:0}replace@id") or nil; + if (isMessageCorrection) then + content = " "; + type = "CORRECTION"; + end + + + local auxType = vncTalkIncomingType or (vncTalkWhiteboard and "whiteboard"); + + if (auxType) + then + content = auxType; + type = auxType; + end; + + local doNotify = content and (content ~= " ") and (content ~= "") and (delayedDelivery == nil) and (isSentCarbonMessage == nil) and ( + (type == "chat") or (type == "groupchat") or (type == "audio") or (type == "video") or (type == "whiteboard") or (type == "screen") or (type == "CORRECTION") + ); + + if (doNotify) then + local sender = ""; + local f2,h2,n2 = jid.split(stanza.attr.from); + if (room._affiliations[n2] ~= nil) then sender = n2; end + -- module:log("info","sender is %s", sender); + local roomTopic = room._data.subject or nil; + for jid, aff in pairs(room._affiliations) do + if aff ~= "outcast" then + -- module:log("info", "found jid: %s as aff: %s", jid, aff); + local roomJid = to; + if (sender ~= jid) then + module:log("info", "from is: %s", from); + fcm_notify(jid, roomJid, content, type, getDisplayName(jid_split(sender)), roomTopic, vnc_attachment_type, message_id, vncTalkConferenceEtype, vncTalkConferenceId, sender, jitsiURL, jitsiRoom, isMessageCorrection); + end + end + end + end + +end + +module:hook("muc-broadcast-message", handle_muc_message, 5) + +module:hook("host-activated", function(host) + module:log("info", "got activated event for host %s", host); +end); + +module:log("debug", "Module loaded") diff --git a/vnctalk/mod_vnc_muc_fcm_hin.lua b/vnctalk/mod_vnc_muc_fcm_hin.lua new file mode 100644 index 0000000..8105ec3 --- /dev/null +++ b/vnctalk/mod_vnc_muc_fcm_hin.lua @@ -0,0 +1,654 @@ +-- mod_muc_notifications +-- +-- +-- This file is MIT/X11 licensed. +-- +-- A module to notify non-present members of messages in a group chat +-- + +local http = require "net.http"; +local json = require "util.json"; +-- local ltn12 = require "ltn12" +local jid_bare = require "util.jid".bare; +local jid_split = require "util.jid".split; +local timer = require "util.timer"; +local id = require"util.id" +local st = require"util.stanza" +local storage_host = module:get_option_string("storage_host"); + +module:log("info","founs storage_host: %s", storage_host); + + +local fcmAPIKey = module:get_option_string("fcm_api_key"); +local fcmAPIURL = module:get_option_string("fcm_api_url"); + +local moduleIsActive = fcmAPIURL and fcmAPIKey; + +local inactive_devices = {}; + +local ThisDomain = module.host; + +local xmlns_sm2 = "urn:xmpp:sm:2"; +local xmlns_sm3 = "urn:xmpp:sm:3"; + + +local fcm_token_store = module:open_store("fcmtoken"); +local fcm_token_map_store = module:open_store("fcmtoken", "map"); +local vcard_storage = module:open_store("vcard"); +local private_storage = module:open_store("private"); + +timer.add_task(3, function () + private_storage = module:open_host_store(storage_host, "private"); + vcard_storage = module:open_host_store(storage_host, "vcard"); + fcm_token_store = module:open_host_store(storage_host, "fcmtoken"); + fcm_token_map_store = module:open_host_store(storage_host, "fcmtoken", "map"); + if not private_storage or not vcard_storage then + module:log("info", "private/vcard_store %s/%s not found? - will try again", private_storage, vcard_storage); + return 3; + else + module:log("info", "private/vcard_store are now %s/%s", private_storage, vcard_storage); + end + end +); + + +-- some helper functions +local function deserializeDataElement(pdElement, expectedName) + --module:log("debug", "deserializeDataElement("..expectedName..", "..dumpTable(pdElement)..")"); + if (not pdElement) then return nil end + if (type(pdElement) ~= "table") then return nil end + + if (pdElement[1] and pdElement["attr"] and pdElement["attr"]["key"] and (pdElement["attr"]["key"] == expectedName)) + then + local result = json.decode(pdElement[1]); + --module:log("debug", "deserializeDataElement() = '"..dumpTable(result).."'"); + return result; + end + return nil; +end + + +local function contains(list, x) + if (list) then + for _, v in pairs(list) do + if (v == x) then return true end + end + end + return false +end + +local function starts_with(str, start) + return str:sub(1, #start) == start +end + +local function trim(s) + if s and (type(s) == "string") then + return (s:gsub("^%s*(.-)%s*$", "%1")) + else + return s + end +end + +local function collapseSpaces(s) + if s and (type(s) == "string") then + return (s:gsub("%s+", " ")) + else + return s + end +end + +local function dumpTable(t, depth) + local result = ""; + if (not depth) then depth = 0 end; + + local prefix = string.rep(" ", depth); + + if (type(t) ~= "table") then + result = result.." "..tostring(t); + else + for key,value in pairs(t) do + result = result..prefix..tostring(key).." => "..dumpTable(value, depth+1).."\n" + end + end + + return result; +end + + +local function prettyUsername(userName) + userName = string.upper(string.sub(userName, 1, 1))..string.sub(userName, 2, -1) -- upcase first char in any case + if string.find(userName, "%.") -- contains dot + then + local i = 1 + while true do + local j = string.find(userName, "%.", i) + if j == nil or #userName == i then break end + -- replace dot with space and upcase next char + userName = string.sub(userName, 1, j-1).." "..string.upper(string.sub(userName, j+1, j+1))..string.sub(userName, j+2, -1) + if #userName <= j then break end + i = j + end + end + return trim(collapseSpaces(userName)); +end + + +local function getDisplayName(userName) + -- module:log("info", "getDisplayName user: %s", userName); + local vCard, err = vcard_storage:get(userName); + if vCard then + vCard = st.deserialize(vCard); + end + local name = nil; + if not vCard or err then + module:log("debug", "Unable to get vCard for %s : %s ", userName, tostring(err)); + else + name = vCard:find("{vcard-temp}FN#"); + end + if not name then name = prettyUsername(userName) or userName end + -- module:log("info", "name for %s : %s", userName, name) + return name; +end + +local function getPostCallback(_fcmId, _userName) + return function(body, code, response) + local shortKey = string.sub(_fcmId, 1, 6) + if code ~= 200 then + module:log("info", "FCM result %s@%s : HTTP %s %s", _userName, shortKey, tostring(code), tostring(body)) + -- if body then module:log("error", body); end + else + module:log("debug", "FCM result %s@%s : HTTP %s %s", _userName, shortKey, tostring(code), tostring(body)) + end + return false -- close request + --return true -- keep request open + end +end + +local function postFCM(_userName, _fcmId, _source, _body, _type, _senderName, _groupTopic, _attachment_type, _lang, _msgid, _vncTalkConferenceEtype, _vncTalkConferenceId, _nFromJid, _jitsiURL, _jitsiRoom) + + if (not moduleIsActive) then return end; + local domainname = ""; + local v_attachment = ""; + domainname = module:get_option_string("storage_host"); + + if (_attachment_type ~= "") and (_attachment_type ~=nil) then + v_attachment = _attachment_type; + end + + local lsource = _source; + local ltype = _type; + local signal = nil; + local nfrom = _source; + local nJitsiURL = ""; + local nJitsiRoom = ""; + + if (_vncTalkConferenceId ~= nil) then + if string.match(_vncTalkConferenceId, "@") then + lsource = _vncTalkConferenceId; + end + end + + if (_vncTalkConferenceEtype ~=nil) then + signal = "1"; + ltype = _vncTalkConferenceEtype; + end + + if (_nFromJid ~= nil) then + nfrom = _nFromJid; + end + + if (_jitsiURL ~= nil) then + nJitsiURL = _jitsiURL; + end + + if (_jitsiRoom ~= nil) then + nJitsiRoom = _jitsiRoom; + end + + local stamp = os.time(); + + module:log("info", "postFCM username %s", _userName); + local uname = ""; + if string.match(_userName, "@") then + uname = _userName; + else + uname = _userName.."@"..domainname; + end + + local fcmRequest = { + to = _fcmId, + content_available = true, + priority = "high", + data = { + nType = "local_notification", -- notification type + eType = ltype, -- event type + jid = lsource, -- source jid (room jid or 1:1 chat peer bare jid) + nfrom = nfrom, + nto = uname, + name = _senderName, -- group chat nick name or single chat peer vCard name", + gt = _groupTopic, -- topic of room iff groupchat + aft = v_attachment, + callSignal = signal, + jitsiURL = nJitsiURL, + jitsiRoom = nJitsiRoom, + t = stamp, + lang = _lang, + msgid = _msgid, + body = _body -- notification body/text content + } + }; + + fcmRequest["android"] = { priority = "high", ttl = 345600 }; + fcmRequest["webpush"] = { headers = { Urgency = "high", TTL = 345600 } }; + + local data = json.encode(fcmRequest); + + local httpRequestOptions = { + method = "POST", + body = data, + headers = { + ["Connection"] = 'close', + ["Authorization"] = "key="..fcmAPIKey, + ["Content-Type"] = "application/json; charset=utf-8" + } + } + + -- module:log("info", "sending notification to %s for token %s - from: %s - msgid: %s - body: %s", _userName.."@"..domainname, _fcmId, _source, _msgid, _body); + -- ssl does not seem to work with net.http, so we proxy it + --http.request("https://fcm.googleapis.com/fcm/send", httpRequestOptions, postCallback) + http.request(fcmAPIURL, httpRequestOptions, getPostCallback(_fcmId, _userName)) +end + +local function postFCMIOS(_userName, _fcmId, _source, _body, _type, _senderName, _groupTopic, _attachment_type, _lang, _msgid, _vncTalkConferenceEtype, _vncTalkConferenceId, _nFromJid, _jitsiURL, _jitsiRoom) + + if (not moduleIsActive) then return end; + local domainname = ""; + local ntitle = ""; + local nbody = ""; + local v_attachment = ""; + local nJitsiURL = ""; + local nJitsiRoom = ""; + + domainname = module:get_option_string("storage_host"); + if (_groupTopic ~= "") and (_groupTopic ~= nil) then + ntitle = _groupTopic; + else + ntitle = jid_split(_source); + end + + if (_attachment_type ~= "") and (_attachment_type ~=nil) then + v_attachment = _attachment_type; + end + + local _click_action = _type:upper(); + + if (_click_action == "NORMAL") then + _click_action = "CHAT"; + end + + local lsource = _source; + local ltype = _type; + local signal = nil; + local nfrom = _source; + + if (_vncTalkConferenceId ~= nil) then + if string.match(_vncTalkConferenceId, "@") then + lsource = _vncTalkConferenceId; + end + end + + if (_vncTalkConferenceEtype ~=nil) then + signal = "1"; + ltype = _vncTalkConferenceEtype; + end + + if (_nFromJid ~= nil) then + nfrom = _nFromJid; + end + if (_jitsiURL ~= nil) then + nJitsiURL = _jitsiURL; + end + + if (_jitsiRoom ~= nil) then + nJitsiRoom = _jitsiRoom; + end + + local fcmRequest = { + to = _fcmId, + priority = "high", + notification = { + title = ntitle, + body = _body, + mutable_content = true, + sound = "incoming-call-loop.caff", + thread_id = lsource, + click_action = _click_action + }, + data = { + nType = "local_notification", -- notification type + eType = ltype, -- event type + jid = lsource, -- source jid (room jid or 1:1 chat peer bare jid) + nfrom = nfrom, + nto = _userName.."@"..domainname, + name = _senderName, -- group chat nick name or single chat peer vCard name", + gt = ntitle, -- topic of room iff groupchat + aft = v_attachment, + callSignal = signal, + jitsiURL = nJitsiURL, + jitsiRoom = nJitsiRoom, + lang = _lang, + msgid = _msgid, + body = _body -- notification body/text content + } + }; + + local data = json.encode(fcmRequest); + + local httpRequestOptions = { + method = "POST", + body = data, + headers = { + ["Connection"] = 'close', + ["Authorization"] = "key="..fcmAPIKey, + ["Content-Type"] = "application/json; charset=utf-8" + } + } + + -- module:log("debug", "sending notification to %s for token %s - from: %s - msgid: %s - body: %s", _userName.."@"..domainname, _fcmId, _source, _msgid, string.sub(_body, 20).."..."); + module:log("debug", "sending notification to %s for token %s - from: %s - msgid: %s - body: %s", _userName.."@"..domainname, _fcmId, _source, _msgid, _body.."..."); + -- ssl does not seem to work with net.http, so we proxy it + --http.request("https://fcm.googleapis.com/fcm/send", httpRequestOptions, postCallback) + module:log("debug", "calling http.request -- fcmAPIURL: %s", fcmAPIURL); + http.request(fcmAPIURL, httpRequestOptions, getPostCallback(_fcmId, _userName)) +end + + +-- Given a stanza, compute if it qualifies as important (notifiable) +-- return true for message stanzas with non-empty body +-- Should probably use something similar to muc-message-is-historic event +local function is_important(stanza) + local body = stanza:find("body#") + return body and #body +end + + +-- get notify options for local users +local function getNotifyOptionsForUser(userName) + --module:log("debug", "getFCMtokenForUser("..userName..")"); + local pd, pfFetchError = private_storage:get(userName); + if (pfFetchError) then + module:log( + "error", + "Fetching private data for '%s' failed : %s", tostring(userName), tostring(pfFetchError) + ); + return 0,{}; + elseif (pd) then + local tokens; + local nType; + local nLang; + for key,value in pairs(pd) + do + -- module:log("info", "PD("..userName..") : "..tostring(key).." => "..tostring(value)); + module:log("debug", "PD( %s) : %s => %s ", userName,tostring(key),tostring(value)); + if ((key == "documents:stanza:io:json") and (type(value) == "table")) then + --module:log("debug", "PD("..userName..") : "..json.encode(value)); + -- module:log("debug", "PD( %s ) : %s ", userName ,json.encode(value)); + for k2,v2 in pairs(value) do + if (not tokens) then + local fcm = deserializeDataElement(v2, "fcm"); + if (type(fcm) == "table") then + tokens = {}; + for token,x in pairs(fcm) do + -- module:log("debug", "token: %s - x[os]: %s", token, x["os"]); + if (x["os"] ~= nil) then + table.insert(tokens, token.."@"..x["os"]) + end + end + end + end + if (not nType) then + local options = deserializeDataElement(v2, "notifyOptions"); + if (options and (type(options) == "table") and options["type"]) then + nType = tonumber(options["type"]); + end + end + if (not nLang) then + local options = deserializeDataElement(v2, "lang"); + -- module:log("debug", "PDLang: %s => %s", userName, json.encode(options)); + if (options) and (type(options) == "string") then + nLang = options; + end; + -- module:log("debug", "type of options: %s", type(options)); + end + end + break; -- we can stop after the documents:stanza:io:json element + end + end + --module:log("debug", "getNotifyOptionsForUser("..userName..") = "..tostring(tokens).." => "..tostring(nType)); + + local map_res = fcm_token_store:get(userName); + + -- module:log("debug", "result from map store for %s is: %s", userName, dumpTable(map_res)); + + if (map_res) then + for _device, _tkr in pairs(map_res) do + -- module:log("debug", "got for %s device %s => %s", userName, _device, dumpTable(_tkr)); + -- module:log("debug", "got for %s device %s => token: %s - os: %s", userName, _device, _tkr.token, _tkr.os); + local sessions = prosody.bare_sessions; + local ubjid = userName.."@"..storage_host; + local u_sessions = sessions[ubjid] or nil; + local deviceonline = false; + if (u_sessions) then + local u_sessions_device = u_sessions["sessions"] or nil; + if (u_sessions_device) then + -- module:log("info", "devise is %s", _device); + -- module:log("debug", "u_sessions_device %s", dumpTable(u_sessions_device)); + local dsession = u_sessions_device[_device] or nil; + -- module:log("debug", "dsession %s", dumpTable(dsession)); + if (dsession) then + local hibernated = dsession["hibernated"] or nil; + local hibernating = dsession["hibernating"] or nil; + local awaiting_ack = dsession["awaiting_ack"] or nil; + module:log("info", "found session for _device %s - hibernating: %s, hibernated %s", _device, hibernating, hibernated); + if not ((hibernated) or (hibernating)) then + local dev_in_background = inactive_devices[_device] or nil; + if dev_in_background then + if awaiting_ack then + module:log("debug", "device %s is still connected, but inactive and awaiting_ack", _device); + -- module:log("info", "handling1: %s", _tkr.token.."@".._tkr.os); + else + module:log("debug", "device %s is still connected, but inactive", _device); + -- module:log("info", "handling2: %s", _tkr.token.."@".._tkr.os); + if (_tkr.os ~= "ios") then + deviceonline = true; + end + end + else + if awaiting_ack then + module:log("debug", "device %s is connected and active, but awaiting_ack", _device); + -- module:log("info", "handling3: %s", _tkr.token.."@".._tkr.os); + else + module:log("debug", "device %s is connected and active", _device); + -- module:log("info", "handling4: %s", _tkr.token.."@".._tkr.os); + if (_tkr.os ~= "ios") then + deviceonline = true; + end + end + end + end + end + end + end + if not(deviceonline) then + local already_added = contains(tokens, _tkr.token.."@".._tkr.os); + module:log("debug","already_added: %s", _tkr.token.."@".._tkr.os); + if not(tokens) then + tokens = {}; + end + if not(already_added) then + table.insert(tokens, _tkr.token.."@".._tkr.os); + end + end + -- module:log("debug","tokens table is now: %s", dumpTable(tokens)); + end + end + + if (not tokens) then tokens = {}; end; + if (not nType) then nType = 0; end; + local sessions = prosody.bare_sessions; + local ubjid = userName.."@"..ThisDomain; + for _user, _resdetails in pairs(sessions) do + if (_user == ubjid) then + for _res, _detail1 in pairs(_resdetails["sessions"]) do + local hibernated = _detail1["hibernated"] or _detail1["hibernating"] or nil; + if (hibernated) then + module:log("info", "for _user: %s and _res: %s got hibernated since: %s", _user, _res, hibernated); + else + module:log("info", "for _user: %s and _res: %s got active session?", _user, _res); + --module:log("debug", "for _user: %s and _res: %s got detail: %s", _user, _res, dumpTable(_detail1)); + end + end + -- module:log("debug", "session for _res %s is: %s", _res, dumpTable(_resdetails)); + end + end + -- module:log("debug", "sessions: %s", dumpTable(sessions[ubjid])); + return nType, tokens, nLang; + end +end + +local function fcm_notify(userName, title, body, type, _senderName, _groupTopic, vnc_attachment_type, msgid, vncTalkConferenceEtype, vncTalkConferenceId, _nFrom) + if (not moduleIsActive) then module:log("info", "module not active - bailung out"); return end; + module:log("info", "getting notify opts for %s", userName); + local uname, uhost = jid_split(userName); + if (uhost ~= storage_host) then return end; + local nType, tokens, lang = getNotifyOptionsForUser(uname); + if (tokens and (#tokens) > 0) and ((nType == 1) or (nType == 2)) + then + -- module:log( + -- "info", "in fcm_notify(%s, %s, %s) => type=%s, %s tokens", + -- userName, title, body, tostring(nType), tostring((#tokens)) + --); + local sName = _senderName; + local gTopic = _groupTopic; + sName = "Neue Nachricht"; + gTopic = "Neue Nachricht"; + + if (lang == "en") then + sName = "new message"; + gTopic = "new message"; + end; + + if (lang == "fr") then + sName = "Nouveau message"; + gTopic = "Nouveau message"; + end; + + body = ""; + + for i,token in pairs(tokens) do + if (token) + then + local rtoken, ros = jid_split(token); + -- module:log("debug","got rtoken: %s - os: %s ", rtoken, ros); + if (ros == "ios") then + -- module:log("debug", "postFCMIOS with (%s, %s, %s, %s, %s, %s, %s, %s, %s)", userName, rtoken, title, body, type, _senderName, _groupTopic, vnc_attachment_type, lang); + postFCMIOS(userName, rtoken, title, body, type, sName, gTopic, vnc_attachment_type, lang, msgid, vncTalkConferenceEtype, vncTalkConferenceId, _nFrom); + else + -- module:log("info", "postFCM with (%s, %s, %s, %s, %s, %s, %s, %s, %s)", userName, rtoken, title, body, type, _senderName, _groupTopic, vnc_attachment_type, lang, _nFrom); + postFCM(userName, rtoken, title, body, type, sName, gTopic, vnc_attachment_type, lang, msgid, vncTalkConferenceEtype, vncTalkConferenceId, _nFrom); + end + end + end + else + module:log("debug", "fcm_notify(%s, %s, %s) => type=%s, no tokens", userName, title, body, tostring(nType)); + end +end + + +local function handle_muc_message(event) + -- event.room and event.stanza are available + local room = event.room + local stanza = event.stanza + -- module:log("info", "handling stanza %s", stanza); + -- module:log("info", "room: %s", dumpTable(room)); + + local message_id = event.stanza.attr.id; + -- module:log("info","message id: %s", event.stanza.attr.id); + local type = stanza.attr.type or "normal"; + local from = jid_bare(stanza.attr.from); + local to = jid_bare(stanza.attr.to) or from; + + local vncTalkConferenceEtype = stanza:find("{xmpp:vnctalk}vncTalkConference/eventType#") or nil; + + -- module:log("info", "handling message from=%s, to=%s", stanza.attr.from, to); + + + local content = trim(stanza:find("body#")); + local delayedDelivery = stanza:get_child("delay", "urn:xmpp:delay"); + + -- if we have a vncTalk incoming or whiteboard element, it overrides the type + local vncTalkIncomingType = stanza:find("{xmpp:vnctalk}vncTalkConference/conferenceType#"); + local vncTalkConferenceId = stanza:find("{xmpp:vnctalk}vncTalkConference/conferenceId#") or nil; + local jitsiURL = stanza:find("{xmpp:vnctalk}vncTalkConference/jitsiURL#") or nil; + local jitsiRoom = stanza:find("{xmpp:vnctalk}vncTalkConference/jitsiRoom#") or nil; + local vncTalkWhiteboard = stanza:find("{xmpp:vnctalk}whiteboard"); + local isSentCarbonMessage = stanza:get_child("sent", "urn:xmpp:carbons:2"); + local vnc_attachment_type = stanza:find("{xmpp:vnctalk}attachment/fileType#"); + + local vnctalk_broadcast = stanza:get_child("vncTalkBroadcast", "xmpp:vnctalk"); + local vnctalk_broadcast_childid = nil; + if vnctalk_broadcast then + if starts_with(to, "broadcast") then + -- module:log("message is to broadcast - bailing out"); + return nil; + end + vnctalk_broadcast_childid = vnctalk_broadcast.attr.id; + local vnctalk_broadcast_title = vnctalk_broadcast.attr.title; + local vnctalk_broadcast_target = vnctalk_broadcast.attr.origtarget; + local body = stanza:get_child("body"); + -- module:log("debug", "broadcast body: %s", content); + -- module:log("debug", "jid_split(to): %s", jid_split(to)); + -- fcm_notify(jid_split(to), vnctalk_broadcast_target, content, type, getDisplayName(jid_split(from)), "Broadcast: "..vnctalk_broadcast_title, vnc_attachment_type, message_id, vncTalkConferenceEtype, vncTalkConferenceId); + return nil; + end + + local auxType = vncTalkIncomingType or (vncTalkWhiteboard and "whiteboard"); + + if (auxType) + then + content = auxType; + type = auxType; + end; + + local doNotify = content and (content ~= " ") and (content ~= "") and (delayedDelivery == nil) and (isSentCarbonMessage == nil) and ( + (type == "chat") or (type == "groupchat") or (type == "audio") or (type == "video") or (type == "whiteboard") or (type == "screen") + ); + + if (doNotify) then + local sender = ""; + for _, occupant in pairs(room._occupants) do + if (stanza.attr.from == occupant.nick) then sender=occupant.bare_jid; end; + end + -- module:log("info","sender is %s", sender); + local roomTopic = room._data.subject or nil; + for jid, aff in pairs(room._affiliations) do + if aff ~= "outcast" then + -- module:log("info", "found jid: %s as aff: %s", jid, aff); + local roomJid = to; + if (sender ~= jid) then + module:log("info", "from is: %s", from); + fcm_notify(jid, roomJid, content, type, getDisplayName(jid_split(sender)), roomTopic, vnc_attachment_type, message_id, vncTalkConferenceEtype, vncTalkConferenceId, from, jitsiURL, jitsiRoom); + end + end + end + end + +end + +module:hook("muc-broadcast-message", handle_muc_message) + +module:hook("host-activated", function(host) + module:log("info", "got activated event for host %s", host); +end); + +module:log("debug", "Module loaded") diff --git a/vnctalk/mod_vnc_muc_hook.lua b/vnctalk/mod_vnc_muc_hook.lua new file mode 100644 index 0000000..715ef60 --- /dev/null +++ b/vnctalk/mod_vnc_muc_hook.lua @@ -0,0 +1,129 @@ +-- mod_muc_notifications +-- +-- Copyright (C) 2019 Marcos de Vera Piquero +-- enhanced by Stefan Sänger +-- This file is MIT/X11 licensed. +-- +-- A module to notify non-present members of messages in a group chat +-- + +local id = require"util.id" +local st = require"util.stanza" + +local use_invite = module:get_option_boolean("muc_notification_invite", false) + +local function dumpTable1(t, depth) + local result = ""; + if (not depth) then depth = 0 end; + + local prefix = string.rep(" ", depth); + + if (type(t) ~= "table") then + result = result.." "..tostring(t); + else + for key,value in pairs(t) do + result = result..prefix..tostring(key).." => "..tostring(value).."\n" + end + end + + return result; +end + +local function dumpTable(t, depth) + local result = ""; + if (not depth) then depth = 0 end; + + local prefix = string.rep(" ", depth); + + if (type(t) ~= "table") then + result = result.." "..tostring(t); + else + for key,value in pairs(t) do + result = result..prefix..tostring(key).." => "..dumpTable(value, depth+1).."\n" + end + end + + return result; +end + +-- Given a stanza, compute if it qualifies as important (notifiable) +-- return true for message stanzas with non-empty body +-- Should probably use something similar to muc-message-is-historic event +local function is_important(stanza) + local body = stanza:find("body#") + return body and #body +end + +local function handle_muc_message(event) + -- event.room and event.stanza are available + local room = event.room + -- module:log("info","room: %s", dumpTable(room._occupants)); + local stanza = event.stanza + local aff_jids = {}; + for jid, aff in pairs(room._affiliations) do + if aff ~= "outcast" then + local is_occupant = false + local sessions = prosody.bare_sessions; + local user_online = sessions[jid] or nil; + local jid_resources = {}; + if (user_online ~= nil) then + local u_sessions = user_online["sessions"] or nil; + for k,v in pairs(u_sessions) do + jid_resources[jid.."/"..tostring(k)] = true; + end +-- module:log("info", "room user %s sessions: \n %s", jid, dumpTable(jid_resources)); + end + + for _, occupant in pairs(room._occupants) do + if occupant.bare_jid == jid then + for k,v in pairs(occupant.sessions) do + module:log("info", "occupant k %s", k); + jid_resources[k] = nil; + end +-- module:log("info", "remaining resources: ", dumpTable(jid_resources)); +-- module:log("info", "remaining resources#: %s", #jid_resources); + if next(jid_resources) == nil then +-- module:log("info", "user %s is occupant with all sessions", jid); + is_occupant = true + break + end + end + end + -- module:log("info", "got jid: %s", jid); + -- local sessions = prosody.bare_sessions; + -- local user_online_sessions = sessions[jid] or nil; + + if not is_occupant and is_important(stanza) and (user_online ~= nil) then + -- send notification to jid + local attrs = { + to = jid, + id = id.short(), + from = room.jid, + } + local not_attrs = { + xmlns = "http://vnc.biz/xmpp/muc#hook", + jid = room.jid, + } + local reason = "You have messages in group chat "..(room:get_name() or room.jid) + local notification = st.message(attrs) + :body(reason):up() + :tag("notification", not_attrs):up() + :tag("no-store", {xmlns = "urn:xmpp:hints"}) + local invite = st.message(attrs):tag("x", {xmlns = "http://jabber.org/protocol/muc#user"}) + :tag("invite", {from = stanza.attr.from}) + :tag("reason"):text(reason):up():up():up() + :tag("notification", not_attrs):up() + :tag("no-store", {xmlns = "urn:xmpp:hints"}) + module:log("debug", "notifying with %s", tostring(use_invite and invite or notification)) + module:send(use_invite and invite or notification) + module:log("debug", "sent notification of MUC message %s", use_invite and invite or notification) + else + module:log("debug", "user %s is not online or already occupant - not sending invite", jid); + end + end + end +end + +module:hook("muc-broadcast-message", handle_muc_message) + +module:log("debug", "Module loaded") diff --git a/vnctalk/mod_vnc_receipts.lua b/vnctalk/mod_vnc_receipts.lua new file mode 100644 index 0000000..6487042 --- /dev/null +++ b/vnctalk/mod_vnc_receipts.lua @@ -0,0 +1,59 @@ +-- Prosody IM +-- Copyright (C) 2008-2017 Matthew Wild +-- Copyright (C) 2008-2017 Waqas Hussain +-- Copyright (C) 2011-2017 Kim Alvefur +-- Copyright (C) 2011-2017 Stefan Sänger +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- +-- store receipts based on mod_mam +-- + +local um = require "core.usermanager"; +local st = require "util.stanza"; +local rsm = require "util.rsm"; +local jid_bare = require "util.jid".bare; +local jid_split = require "util.jid".split; +local jid_prepped_split = require "util.jid".prepped_split; +local host = module.host; + +local is_stanza = st.is_stanza; +local tostring = tostring; +local time_now = os.time; +local m_min = math.min; +local timestamp, timestamp_parse = require "util.datetime".datetime, require "util.datetime".parse; + +local receipt_store = module:open_store("receipts", "archive"); + + +-- Handle messages +local function message_handler(event, c2s) + local origin, stanza = event.origin, event.stanza; + local log = c2s and origin.log or module._log; + local orig_type = stanza.attr.type or "normal"; + local orig_from = stanza.attr.from; + local orig_to = stanza.attr.to or orig_from; + -- Stanza without 'to' are treated as if it was to their own bare jid + local received_child = stanza:get_child("received", "urn:xmpp:receipts"); + if received_child then + local received_test=tostring(received_child); + local received_id = received_child.attr.id; + local receipt_user = jid_split(orig_to); + local receipt_with = jid_bare(orig_from); + local receiptxml = ""; + if jid_bare(orig_from) ~= jid_bare(orig_to) then + -- local receiptok = receipt_store:append(receipt_user, nil, received_id, time_now(), receipt_with); + local receiptok = receipt_store:append(receipt_user, nil, receiptxml, time_now(), receipt_with); + -- log("info", "receipt for id: %s from %s to %s", received_id, jid_bare(orig_from), jid_bare(orig_to)); + else + log("debug", "not storing receipt for id: %s from %s to %s", received_id, jid_bare(orig_from), jid_bare(orig_to)); + end + else + log("debug", "not stored receipt - no receipt-id in message from %s to %s", jid_bare(orig_from), jid_bare(orig_to)); + end +end + +-- Stanzas to local clients +module:hook("message/bare", message_handler, 0); +module:hook("message/full", message_handler, 0); diff --git a/vnctalk/mod_vnc_remotemucinvite.lua b/vnctalk/mod_vnc_remotemucinvite.lua new file mode 100644 index 0000000..48440d6 --- /dev/null +++ b/vnctalk/mod_vnc_remotemucinvite.lua @@ -0,0 +1,72 @@ +-- Prosody IM +-- Copyright (C) 2008-2017 Matthew Wild +-- Copyright (C) 2008-2017 Waqas Hussain +-- Copyright (C) 2011-2017 Kim Alvefur +-- Copyright (C) 2011-2017 Stefan Sänger +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- +-- store groupchat messages from / to remote hosts based on mod_mam +-- + +local um = require "core.usermanager"; +local st = require "util.stanza"; +local rsm = require "util.rsm"; +local jid_bare = require "util.jid".bare; +local jid_split = require "util.jid".split; +local jid_prepped_split = require "util.jid".prepped_split; +local host = module.host; + +module:log("running on host: %s", host); + +local archive_store = "muc_remote_inv"; +local archive = module:open_store(archive_store, "archive"); + + + +local time_now = os.time; +local m_min = math.min; +local timestamp, timestamp_parse = require "util.datetime".datetime, require "util.datetime".parse; + +local function dumpTable(t, depth) + local result = ""; + if (not depth) then depth = 0 end; + + local prefix = string.rep(" ", depth); + + if (type(t) ~= "table") then + result = result.." "..tostring(t); + else + for key,value in pairs(t) do + result = result..prefix..tostring(key).." => "..dumpTable(value, depth+1).."\n" + end + end + + return result; +end + + +local function handle_vnc_invite(event) +-- module:log("info", "seen hooked invite from %s to %s", _to, _invitee); + + -- module:log("info", "hooked event: %s ", dumpTable(event)); + local muc_child = (event:get_child("x", "http://jabber.org/protocol/muc#user") or nil); + local room = event.attr.from or nil; + local to = event.attr.to or nil; + if (to) then + local to_node, to_host = jid_split(to); + local room_node, room_host = jid_split(room); + if not (prosody.hosts[to_host]) then + -- inviting remote user + module:log("info", "hooked room: %s => to: %s", room, to); + local with = to_node.."@"..to_host; + archive:append(room_node, nil, event, time_now(), with); + end + end +end + + + +-- Stanzas to local clients +module:hook("vnc-muc-invitation", handle_vnc_invite, 10); diff --git a/vnctalk/mod_vnc_remotemucstore.lua b/vnctalk/mod_vnc_remotemucstore.lua new file mode 100644 index 0000000..dfb736c --- /dev/null +++ b/vnctalk/mod_vnc_remotemucstore.lua @@ -0,0 +1,166 @@ +-- Prosody IM +-- Copyright (C) 2008-2017 Matthew Wild +-- Copyright (C) 2008-2017 Waqas Hussain +-- Copyright (C) 2011-2017 Kim Alvefur +-- Copyright (C) 2011-2017 Stefan Sänger +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- +-- store groupchat messages from / to remote hosts based on mod_mam +-- + +local um = require "core.usermanager"; +local st = require "util.stanza"; +local rsm = require "util.rsm"; +local jid_bare = require "util.jid".bare; +local jid_split = require "util.jid".split; +local jid_prepped_split = require "util.jid".prepped_split; +local host = module.host; + +module:log("running on host: %s", host); + +local archive_store = "muc_remote"; +local archive = module:open_store(archive_store, "archive"); +local mr_store = module:open_store("muc_remote", "map"); + + +local time_now = os.time; +local m_min = math.min; +local timestamp, timestamp_parse = require "util.datetime".datetime, require "util.datetime".parse; + +local function dumpTable(t, depth) + local result = ""; + if (not depth) then depth = 0 end; + + local prefix = string.rep(" ", depth); + + if (type(t) ~= "table") then + result = result.." "..tostring(t); + else + for key,value in pairs(t) do + result = result..prefix..tostring(key).." => "..dumpTable(value, depth+1).."\n" + end + end + + return result; +end + + +local function presence_handler(event,c2s) + local origin, stanza = event.origin, event.stanza; + local muc_child = (stanza:get_child("x", "http://jabber.org/protocol/muc#user") or nil); + local item_child = nil; + local presence_type = (stanza.attr.type or nil); + local from_node, from_host, from_res = jid_split(stanza.attr.from); + local to_node, to_host = jid_split(stanza.attr.to); + if not (prosody.hosts[from_host]) then + if (muc_child and not(presence_type)) then + item_child = muc_child:get_child("item"); + if (item_child) then +-- module:log("info", "got muc presence from: %s - to: %s, jid: %s || raw: %s", stanza.attr.from, stanza.attr.to, jid_bare(item_child.attr.jid), tostring(stanza)); + if (item_child.attr.jid) then + local fjid = stanza.attr.from; + if (stanza.attr.from == jid_bare(stanza.attr.from)) then + fjid = stanza.attr.from .. "/" .. jid_bare(item_child.attr.jid); + end +-- module:log("info", "got item... from= %s item.attr.jid= %s", fjid, jid_bare(item_child.attr.jid)); + mr_store:set(to_node, fjid, jid_bare(item_child.attr.jid)); + end + if (from_res) then + mr_store:set(to_node,stanza.attr.from, from_res); +-- module:log("info", "got muc presence from_res: %s - to: %s, jid: %s || raw: %s", stanza.attr.from, stanza.attr.to, from_res, tostring(stanza)); + end + end + -- mr_store:set(to_node,stanza.attr.from, ); + end + end +end + +-- Handle messages +local function message_handler(event, c2s) + local origin, stanza = event.origin, event.stanza; + + if stanza.attr.type == "groupchat" then + local from_node, from_host, from_res = jid_split(stanza.attr.from); + local remote_muc = from_node.."@"..from_host; + local to_node, to_host, to_res = jid_split(stanza.attr.to); + -- module:log("info", "seen from_host: %s", from_host); + -- module:log("info", "seen to_res: %s", to_res); + if prosody.hosts[from_host] then + -- message is from a local host, no further action required + module:log("debug", "seen stanza from local host - from: %s", stanza.attr.from); + -- return nil; + else + -- message is from remote host, going to handle... + -- module:log("info", "seen stanza with muc %s@%s - raw: %s - to %s with type %s with id %s", from_node, from_host, stanza.attr.from, stanza.attr.to, stanza.attr.type, stanza.attr.id); + local mkey = stanza.attr.id; + local muc_with = from_node.."@"..from_host; + local content = (stanza and stanza:get_child("body") and stanza:get_child("body")[1] or nil) + -- module:log("info","got content: %s", content); + local delayedDelivery = stanza:get_child("delay", "urn:xmpp:delay"); + if (delayedDelivery) then + -- module:log("info","got delay: %s", delayedDelivery); + end + local muc_subject = (stanza:get_child_text("subject") or nil); + if (muc_subject) then + -- module:log("info","subject for %s: %s", stanza.attr.from, muc_subject); + mr_store:set(to_node, remote_muc, muc_subject); + end + local nick = (stanza:get_child_text("nick", "http://jabber.org/protocol/nick") or nil); + local muc_child = (stanza:get_child("x", "http://jabber.org/protocol/muc#user") or nil); + local item_child = nil; + if (muc_child) then +-- module:log("info","got muc_child: %s", muc_child); + item_child = muc_child:get_child("item"); + if (item_child and nick) then + -- module:log("info","got sender jid nick: %s: %s => %s", stanza.attr.from, nick, item_child.attr.jid); + mr_store:set(to_node,stanza.attr.from, item_child.attr.jid); + end + end + + if (mkey and content and not(delayedDelivery)) then + -- module:log("info", "seen stanza with muc %s@%s - raw: %s - to %s with type %s with id %s", from_node, from_host, stanza.attr.from, stanza.attr.to, stanza.attr.type, stanza.attr.id); + -- module:log("info", "going to store - to: %s, conten: %s", to_node, content); + local storeok = archive:append(to_node, mkey, stanza, time_now(), muc_with); + -- prevent further processing for messages to offline user (to bare jid) + if (to_res == nil) then + -- module:log("info", "going prevent stanza processing to bare from muc - to: %s, conten: %s", to_node, content); + return true; + end + end + end + else + -- module:log("info","seen stanza.attr.type: %s", stanza.attr.type); + local from_node, from_host, from_res = jid_split(stanza.attr.from); + local to_node, to_host, to_res = jid_split(stanza.attr.to); + local muc_child = (stanza:get_child("x", "http://jabber.org/protocol/muc#user") or nil); + local invite_child = nil; + local invite_from = nil; + if (muc_child) then + invite_child = (muc_child:get_child("invite") or nil); + if (invite_child) then + module:log("info", "got invite_child: %s", dumpTable(invite_child)); + invite_from = (invite_child.attr.from or nil); + if (invite_from) then + local invite_from_node, invite_from_host = jid_split(invite_from); + module:log("info","got invite from %s to muc %s for %s", invite_from_node.."@"..invite_from_host, stanza.attr.from, to_node.."@"..to_host ); + local store_key = stanza.attr.from.."/"..invite_from_node.."@"..invite_from_host; + local store_value = invite_from_node.."@"..invite_from_host; + -- module:log("debug","storing: %s -> %s -> %s", to_node, stanza.attr.from.."/"..invite_from_node.."@"..invite_from_host, invite_from_node.."@"..invite_from_host); + mr_store:set(to_node, store_key, store_value); + else + module:log("info","found empty part - check %s", invite_from); + end + end + + + end + end +end + +-- Stanzas to local clients +module:hook("message/bare", message_handler, 0); +module:hook("message/full", message_handler, 0); +module:hook("presence/full", presence_handler, 10); +module:hook("presence/bare", presence_handler, 10); diff --git a/vnctalk/mod_vnc_timestamp.lua b/vnctalk/mod_vnc_timestamp.lua new file mode 100644 index 0000000..46e133f --- /dev/null +++ b/vnctalk/mod_vnc_timestamp.lua @@ -0,0 +1,70 @@ +-- vnc timestamp module +-- based on https://modules.prosody.im/mod_delay.html +-- Copyright (C) 2016-2017 Thilo Molitor +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local add_filter = require "util.filters".add_filter; +local remove_filter = require "util.filters".remove_filter; +local datetime = require "util.datetime"; +local st = require "util.stanza"; +local uuid = require "util.uuid"; + +local xmlns_timestamp = "xmpp:vnctalk:stamp"; + +local function dumpTable(t, depth) + local result = ""; + if (not depth) then depth = 0 end; + + local prefix = string.rep(" ", depth); + + if (type(t) ~= "table") then + result = result.." "..tostring(t); + else + for key,value in pairs(t) do + result = result..prefix..tostring(key).." => "..dumpTable(value, depth+1).."\n" + end + end + + return result; +end + +-- Raise an error if the modules has been loaded as a component in prosody's config +if module:get_host_type() == "component" then + error(module.name.." should NOT be loaded as a component, check out http://prosody.im/doc/components", 0); +end + +local add_delay = function(stanza, session) + if stanza and stanza.name == "message" and stanza:get_child("stamp", xmlns_timestamp) == nil then + -- only add delay tag to chat or groupchat messages (should we add a delay to anything else, too???) + if stanza.attr.type == "chat" or stanza.attr.type == "groupchat" then + if stanza:get_child("body") then + -- session.log("debug", "adding delay to message %s", tostring(stanza)); + local stamp = os.time(); + if (stanza.attr.from and stanza.attr.id) then + -- module:log("info", "vnc-stamp added to stanza %s", dumpTable(stanza)); + local iqid = uuid.generate(); + module:send(st.iq({ id=iqid, to=stanza.attr.from, from=module.host, type="result", t = tostring(stamp) , f=stanza.attr.id})); + -- session:send(st.stanza("t", { xmlns =xmlns_timestamp, t = stamp })); + end + stanza = stanza:tag("stamp", { xmlns = xmlns_timestamp, from = session.host, stamp = tostring(stamp) }); + end; + end + end + return stanza; +end + +module:hook("resource-bind", function(event) + add_filter(event.session, "stanzas/in", add_delay, 1); +end); +module:hook("smacks-hibernation-end", function(event) + -- older smacks module versions send only the "intermediate" session in event.session and no session.resumed one + if event.resumed then + add_filter(event.resumed, "stanzas/in", add_delay, 1); + end +end); +module:hook("pre-resource-unbind", function (event) + remove_filter(event.session, "stanzas/in", add_delay); +end); diff --git a/vnctalk/mod_vnc_track_kicks.lua b/vnctalk/mod_vnc_track_kicks.lua new file mode 100644 index 0000000..0b2e11a --- /dev/null +++ b/vnctalk/mod_vnc_track_kicks.lua @@ -0,0 +1,40 @@ +-- Prosody IM +-- Copyright (C) 2008-2017 Matthew Wild +-- Copyright (C) 2008-2017 Waqas Hussain +-- Copyright (C) 2011-2017 Kim Alvefur +-- Copyright (C) 2011-2017 Stefan Sänger +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- +-- store kicks based on mod_mam +-- + +local um = require "core.usermanager"; +local st = require "util.stanza"; +local rsm = require "util.rsm"; +local jid_bare = require "util.jid".bare; +local jid_split = require "util.jid".split; +local jid_prepped_split = require "util.jid".prepped_split; +local host = module.host; + +local is_stanza = st.is_stanza; +local tostring = tostring; +local time_now = os.time; +local m_min = math.min; +local timestamp, timestamp_parse = require "util.datetime".datetime, require "util.datetime".parse; + +local kick_store = module:open_store("kick", "archive"); + +module:hook("vnc-muc-kick", function (event) +-- module:log("info", "vnc-muc-kick room: %s", event.room); +-- module:log("info", "vnc-muc-kick who: %s", event.who); + local room_node = jid_split(event.room); + local kicktime = tostring(os.time()); + local id, err = kick_store:append(room_node, nil, "kicked or left", time_now(), event.who); + if id then + module:log("info", "stored as id: %s", id); + else + module:log("info", "failed to store: %s", err); + end +end); diff --git a/vnctalk/mod_vnc_vcard_avatar.lua b/vnctalk/mod_vnc_vcard_avatar.lua new file mode 100644 index 0000000..1d212d0 --- /dev/null +++ b/vnctalk/mod_vnc_vcard_avatar.lua @@ -0,0 +1,117 @@ +-- + +local st = require "util.stanza" +local jid = require "util.jid"; +local base64 = require"util.encodings".base64; +local sha1 = require"util.hashes".sha1; +local http = require "net.http"; +local mm = require "core.modulemanager"; + +local my_host = module:get_host(); +local my_host_type = module:get_host_type(); + +local avatar_upload_url = module:get_option("avatar_upload_url", ""); +local avatar_upload_user = module:get_option("avatar_upload_user", ""); +local avatar_upload_pass = module:get_option("avatar_upload_pass", ""); + +local avatar_update_store = module:open_store("avatarupdate", "map"); + + +local function dumpTable(t, depth) + local result = ""; + if (not depth) then depth = 0 end; + + local prefix = string.rep(" ", depth); + + if (type(t) ~= "table") then + result = result.." "..tostring(t); + else + for key,value in pairs(t) do + result = result..prefix..tostring(key).." => "..dumpTable(value, depth+1).."\n" + end + end + + return result; +end + + + +local function putGlobalAvatar(_userName, _host, _photo) + local to_url = avatar_upload_url.._userName.."@".._host; + module:log("info", "upload target URL: %s", to_url); + local httpRequestOptions = { + method = "PUT", + body = _photo, + headers = { + ["Content-Type"] = "image/png" + } + } + if avatar_upload_user ~= "" then + httpRequestOptions = { + method = "PUT", + body = _photo, + headers = { + ["Content-Type"] = "image/png", + ["Authorization"] = "Basic "..base64.encode(avatar_upload_user..":"..avatar_upload_pass) + } + } + end + local function cb(content_, code_, request_, response_) + content, code, request, response = content_, code_, request_, response_; + module:log("info", "response code is: %s", code); + --done(); + end + + http.request(to_url, httpRequestOptions, cb); + + module:log("info", "avatar upload for %s", _userName); +end + +module:depends"vcard"; +local vcard_storage = module:open_store("vcard"); + +local function get_vcard(username) + local vcard, err = vcard_storage:get(username); + if vcard then + vcard = st.deserialize(vcard); + end + if not vcard then + vcard = st.stanza("vCard", { xmlns = "vcard-temp" }); + end + return vcard, err; +end + + +-- vCard -> upload +local function update_avt(session, vcard) + if not vcard then return end + local nickname = vcard:get_child_text("NICKNAME"); + + local photo = vcard:get_child("PHOTO"); + if photo then + local photo_type = photo:get_child_text("TYPE"); + local photo_b64 = photo:get_child_text("BINVAL"); + local photo_raw = photo_b64 and base64.decode(photo_b64); + if photo_raw and photo_type then -- Else invalid data or encoding + + module:log("info", "avatar update for session.username %s", session.username); + module:log("info", "avatar update for session.host %s", session.host); + local s = os.time(); + avatar_update_store:set(session.username, "updated", s); + if avatar_upload_url ~= "" then + putGlobalAvatar(session.username, session.host, photo_raw); + end + end + end +end + +local function handle_vcard(event) + module.log("info","handle_vcard called 1 - handling update from "); + local session, stanza = event.origin, event.stanza; + if not stanza.attr.to and stanza.attr.type == "set" then + module.log("info","handle_vcard called 2 - handling update from "); + return update_avt(session, stanza:get_child("vCard", "vcard-temp")); + end +end + +module:hook("iq/bare/vcard-temp:vCard", handle_vcard, 1); diff --git a/vnctalk/mod_vnc_vcard_fallback.lua b/vnctalk/mod_vnc_vcard_fallback.lua new file mode 100644 index 0000000..cd75222 --- /dev/null +++ b/vnctalk/mod_vnc_vcard_fallback.lua @@ -0,0 +1,106 @@ +local datamanager = require "util.datamanager"; +local usermanager = require "core.usermanager"; +local st = require "util.stanza"; +local host = module.host; +local jid_split = require "util.jid".split; + +local orgname = module:get_option_string("default_vcard_orgname"); +local orgmail = module:get_option_boolean("default_vcard_orgmail"); + +local function trim(s) + if s and (type(s) == "string") then + return (s:gsub("^%s*(.-)%s*$", "%1")) + else + return s + end +end + +local function collapseSpaces(s) + if s and (type(s) == "string") then + return (s:gsub("%s+", " ")) + else + return s + end +end + +local function prettyUsername(userName) + userName = string.upper(string.sub(userName, 1, 1))..string.sub(userName, 2, -1) -- upcase first char in any case + if string.find(userName, "%.") -- contains dot + then + local i = 1 + while true do + local j = string.find(userName, "%.", i) + if j == nil or #userName == i then break end + -- replace dot with space and upcase next char + userName = string.sub(userName, 1, j-1).." "..string.upper(string.sub(userName, j+1, j+1))..string.sub(userName, j+2, -1) + if #userName <= j then break end + i = j + end + end + return trim(collapseSpaces(userName)); +end + + + +module:hook("iq/bare/vcard-temp:vCard", function(event) + local session, stanza = event.origin, event.stanza; + local to = stanza.attr.to; + local from = stanza.attr.from; + + local username = ""; + if (stanza.attr.to == nil) then +-- module:log("info", "query for vcard from stanza.attr.from: %s", stanza.attr.from); + username = jid_split(from); + else +-- module:log("info", "query for vcard for stanza.attr.to: %s", stanza.attr.to); + username = jid_split(to); + end +-- return + if not username then return end + + local vcard = datamanager.load(username, host, "vcard"); + local exists = usermanager.user_exists(username, host); +-- module:log("info", "query for vcard for user: %s host %s", username, host); + module:log("debug", "has %s: %s", "vcard", tostring(vcard)); + module:log("debug", "has %s: %s", "exists", tostring(exists)); +-- data = data or {}; + if vcard then + module:log("debug", "found existing vcard for %s", username); + end + + local firstraw = ""; + local lastraw = ""; + local dotpos = string.find(username, "%."); + if dotpos ~= nil then + firstraw = prettyUsername(string.sub(username, 0, dotpos-1)); + lastraw = prettyUsername(string.sub(username, dotpos+1)); + module:log("info","found raw firstname / lastname from username: %s %s", firstraw, lastraw); + else + firstraw = prettyUsername(username); + module:log("info", "found raw firstname from username: %s", firstraw); + end + + local nickname = prettyUsername(username); + + if not(vcard) and exists then + -- MAYBE + -- first .. " " .. last + -- first, last = name:match("^(%w+) (%w+)$") + local vcard = st.reply(stanza):tag("vCard", { xmlns = "vcard-temp" }) + :tag("VERSION"):text("3.0"):up() + :tag("N") + :tag("FAMILY"):text(lastraw):up() + :tag("GIVEN"):text(firstraw):up() + :up() + :tag("FN"):text(prettyUsername(username) or ""):up() + :tag("NICKNAME"):text(prettyUsername(username)):up() + -- :tag("JABBERID"):text(username.."@"..host):up(); + if orgname then + vcard:tag("ORG"):tag("ORGNAME"):text(orgname):up():up(); + end + session.send(vcard); + return true; + else + module:log("debug", "not modifying vcard"); + end +end, 1); diff --git a/vnctalk/mod_webpresence/README.markdown b/vnctalk/mod_webpresence/README.markdown new file mode 100644 index 0000000..1de4638 --- /dev/null +++ b/vnctalk/mod_webpresence/README.markdown @@ -0,0 +1,65 @@ +--- +labels: +- 'Stage-Stable' +summary: Display your online status in web pages +... + +Introduction +============ + +Quite often you may want to publish your Jabber status to your blog or +website. mod\_webpresence allows you to do exactly this. + +Details +======= + +This module uses Prosody's built-in HTTP server (it does not depend on +mod\_httpserver). It supplies a status icon representative of a user's +online state. + +Installation +============ + +Simply copy mod\_webpresence.lua to your modules directory, the image +files are embedded within it. Then add "webpresence" to your +modules\_enabled list. + +Usage +===== + +Once loaded you can embed the icon into a page using a simple `` +tag, as follows: + + + +Alternatively, it can be used to get status name as plaint text, status +message as plain text or html-code for embedding on web-pages. + +To get status name in plain text you can use something like that link: +`http://prosody.example.com:5280/status/john.smith/text` + +To get status message as plain text you can use something like following +link: `http://prosody.example.com:5280/status/john.smith/message` + +To get html code, containig status name, status image and status message +(if set): `http://prosody.example.com:5280/status/john.smith/html` + +All other + +Compatibility +============= + + ----- ------- + 0.9 Works + 0.8 Works + 0.7 Works + 0.6 Works + ----- ------- + +Todo +==== + +- JSON? +- Display PEP information (maybe a new plugin?) +- More (free) iconsets +- Internal/external image generator (GD, ImageMagick) diff --git a/vnctalk/mod_webpresence/README.wiki b/vnctalk/mod_webpresence/README.wiki new file mode 100644 index 0000000..9f5ab0b --- /dev/null +++ b/vnctalk/mod_webpresence/README.wiki @@ -0,0 +1,43 @@ +#summary Display your online status in web pages +#labels Stage-Stable + += Introduction = + +Quite often you may want to publish your Jabber status to your blog or website. mod_webpresence allows you to do exactly this. + += Details = + +This module uses Prosody's built-in HTTP server (it does not depend on mod_httpserver). It supplies a status icon representative of a user's online state. + += Installation = + +Simply copy mod_webpresence.lua to your modules directory, the image files are embedded within it. Then add "webpresence" to your modules_enabled list. + += Usage = + +Once loaded you can embed the icon into a page using a simple `` tag, as follows: + +{{{}}} + +Alternatively, it can be used to get status name as plaint text, status message as plain text or html-code for embedding on web-pages. + +To get status name in plain text you can use something like that link: {{{http://prosody.example.com:5280/status/john.smith/text}}} + +To get status message as plain text you can use something like following link: {{{http://prosody.example.com:5280/status/john.smith/message}}} + +To get html code, containig status name, status image and status message (if set): +{{{http://prosody.example.com:5280/status/john.smith/html}}} + +All other += Compatibility = +||0.9||Works|| +||0.8||Works|| +||0.7||Works|| +||0.6||Works|| + += Todo = + + * JSON? + * Display PEP information (maybe a new plugin?) + * More (free) iconsets + * Internal/external image generator (GD, ImageMagick) \ No newline at end of file diff --git a/vnctalk/mod_webpresence/icons/status_away.png b/vnctalk/mod_webpresence/icons/status_away.png new file mode 100644 index 0000000000000000000000000000000000000000..0de5c6ab3d35e958b2c2c9fa9ee6e5876312b54b GIT binary patch literal 948 zcmV;l155mgP)*sU#JepF9xjFpi<1dH&*($&0tPbjKK|H0#rd|Dym~9=~^ZBK- zbGzC-yt{5wxNLFa=+nzG+x(``0F-p`vJReOGa7fjv$t};?MarraO%VvcNXui+Z2Ab zc;a$-iMCXj7MgA_iya$jr;+Jt?!*%OIGopiF8Ofe)QK}n06Y?!`l`HGEfrP(JunY_ z-hA|64!S>xumT9vV)(9L$2UQWeX-IA18o4}z^<}w8;jE0HN%H6O@tM|2;`s#b7r8= zL}~^f{FF=A#VfSjSk9W9k)5wRU;d~tReD!A;0c646FQn$sd_VSuNx%j zgRqfWCh{z|P)SKD<KWE7c7kVzzXr!C3B<~Dx5(8b^{$JrjSQPKrq%GQK2KCMN^V(Eo_ z69sgBc#zhk&AfN=5T4Sg-+LI>b@`<6HI7sz@EivvB>=b6zNitieFOPsCi3F)5%$!- z%158J(5hf~=oafYJV{N}6YQ%PM|U$w#|A(ujS)LApodE$e};(HRTQN}bYzh4FaAw^ zLn9kDZf0-8>ud>LU_~g6m$Bi$H6Fi_uezgo^?L7LL!*bj{CdS>dE+Zr=GzSP_tAB2 zg7{>dp>qd#?x9Ik)&XYVU^2iL*9zKue!ae5Q%X@?vsEjyulLnPMoSm_GaS2`N6)au zk!Qx3S@}$Q<3T?AJv0;#7Og(t*&UlbMs>|rL);l?tGqw9qhiUFurwD#%%I7v$#?zp zRr}2ar~TpkYR`9e$Nu-Kds$nys?v{L`#_GpD%Z>wj!ye--@I#HmcrL@+$DHi3p2pKey{piC67(I&w7*W40lyU(UA}K6MAl4+&=mayV7(EwVeII`~vKc@F)NSI^K=n$$(jK%fMr$ux`_Q@w ztvrC6mKGZ=&2IMZjc+>e>c-Ci)aR|+cfYhRFZ_^awM}auHtj_z50J&OVp!IFPM^NP zh7Ipv7!D$lCj6DT^rderD|C5T?!}b_hD~9hZ~)8Dx)7xbP|8Jrf1GHviiU>0^!9e~ z>~m@AcO_lv*+jEpw`Xg?LdXGGAcJKkfh@`76sJzzrnB=$x_iE2b?I-^)L4jQC@sy@ z8Y`A(%0)7Cca2b7JWc&GPL#|87Dtc%z{!(m85l#>)q`(bi@lk^s^Gm)`U`9$H&3 z5R26j3U$!g*}+=>Ab~&%%Ss?J1;DML72KVmkKp@tzR0zrl$ls1hYwfE)vI9<5g8sH zlE%h$l1P?`nea&5R1!ClxQQHUD#`_F9^Q?J(DKpx(ESHmCS!T>+Uw;KiCmFAdmfR$ z#|tEGy8c6P6Y1(Mkag?Vek~#(B8UhdobdPE{>v`^M3H^_i{z)D^%4{}k)BI#*|OC) z`s7n}-X&N>c%!MR?cz6{`Fjs+OVGezo&?`5l&X#8;RPrn|G#RX&08M}EYF(_R#ePZ uFLPymqht2i$cXKxVRv2|?7#Kp;_bh*VwXTL_!Tn%0000`8bWHC5+ah88qqRn1%uG& zQcS>)O*AW4Ao#$6ck%vS9(bOkM1-CC@Ri0pd&=&tEz9Rq??+mg<*upy)XM9hfAsy2U+>&i z+wkexqr=ZOmp_|ougzo%B?uu1u8fpLU^&=$`-y#57AFs$xO#csKZZ}w937ro{^Rb8 z;n8@SX%eFW@GjCTBSpv4X$LxO&$oL=7k)eP+@Y(N=fwaVXs%v(!L`O~eTJ-_U~L7v zUPP%t41#xp>nW!Ge0XrJo?ilvC;sNOnU^;nelS>5Dz7KxeTI77VAJYAj6(2&_Yv;| z?;>`qeCCrIU(OrT-a0!Nz1G^I6NNFFTAmEG&1m_Fx5mtw+ua~GJNp2j(ca}MwQ ze`fDJpPoO@#@rkOJvVB$0wDw;#)!2RYc2n$4Cn_QD5cG&N>*y68$5URT|Uk;v;veu z0f-<1L_tJ|5!RaPoB>*?T`{dRTb%8=L$9tck=8WEYLY}DKn#NGRauwaK)2%&fv6Rm zefeUt|M1iIv*x2yL!Cm$P_?Scw1Rp?pezDK5!i0>cl-9r{o$eam56Y>G5N(C_ZE(2 zI+7S&S>KiMzx`jU=UjYh?Aon+zrU#g%E^fn*C)mo)|2!fd(|t8xC?yHU+2om@KQQ9 za%$JdcTCouZNV+~{U%S69;Xu4MMBpMUt};?CW_0VwBr-clcF`v3p{07*qoM6N<$g4nQ! A4FCWD literal 0 HcmV?d00001 diff --git a/vnctalk/mod_webpresence/icons/status_offline.png b/vnctalk/mod_webpresence/icons/status_offline.png new file mode 100644 index 0000000000000000000000000000000000000000..12db2af7dd86308f5abed00e3930d7f961e1ecc3 GIT binary patch literal 905 zcmV;419tq0P)DbaPEbUMxW_&6gYBRGzO@B5fW zFg`H!@q0C%>wm55hMLJ_n4FwsaBz_J_I5@`N3m_2?(S~l@i?heist5KbX`YEsU04d zzEKsFn*gy`jQRO_rlzLo=;&Z&Wrd!e9;TW3iqqHf*4 z0Yys5M|VH?-j3WD_B@YVE{CeBw6(RNX&S!oBZT1m{2br+sjV}4@y}mBZmp+>RDf{Q zdUSYP_(M^krKJViwh=<$y6zPq%d!Xr3|{2^&P5vP6IbuK{Oq}Gc&q+a;qc$3*X0Xg zS;04!CWOz+!-D)Xn^}oOt)5?g{`KgMRaJH8{o9`iUpz64z-?8jX>#4l%X0bkmRh5( fq&N4zyPo|Isjz3R!I6nX00000NkvXXu0mjf4oJCl literal 0 HcmV?d00001 diff --git a/vnctalk/mod_webpresence/icons/status_online.png b/vnctalk/mod_webpresence/icons/status_online.png new file mode 100644 index 0000000000000000000000000000000000000000..fb257c3144736fd691d81ac82242f7d51f909679 GIT binary patch literal 920 zcmV;J184k+P)vhH8 z7nhD)JG**T`48N8ct_-JduBA-GrtSJ*LNKkX-S-#gD>Ng8!xW^=Fz{}UK=}k#$EyU zy}cun>iPR>;fZ3i&;>#j07zRRmnC-9#xWi2tj)%2(LcLV+qRCKJmU(0+16ZQv8&Lm z8Y)^qL#um`K@HVVQFRq1uMi##GaJse-D*so01(^X@7|T|ntNGj0-+03zxohR=mLSD zd9<0ypC{=U?;ySD-mAT@Kl_v@iPB*!WD7+oC_+JK!ecX36wT|^e_l^wFo9thl&vyL zttEBN&3~X(0<|r;;JPljcp@!Jz^lc$VBeN~#Cl@%?dfBB_cRz#4w|jPsmMbs54XP*)KmE?U-*_*jCwG;dbga3l|qyyuZlsm0=3c6;{*-2+RkF{_wH0Cjj3Dz^v<@`LN&rag)ycKGH+!-`QHe2cY{REu1UALo4Y5@CG#uBi0|F0 z{ZUSxn0q|`O5w4Buv8mV4`i4+4Zk+Hk6a3Uzo~J<{)uzR{6nrnu(-ldf@P|izNq=lxIHpFLiWTEVPV4m}8 zntOR)j+04Pq>?7Nti#ytMR7T4W1BgLoMB^R9O`6)^u=kS(I^1HV34loc5sjYhUH+J z*Q?W#B)e*Zfu1#~R1gV-fKLq{kvHDqflUp(*wck!7<4`T41rK3@BVO-yBFekfXTvw zStYJ&Guez?0dU&!ptrTry0x9p4t_-5&)@UY$zdi&M%c6e0Cyh$k>>h(cFxCG)V(uO zb{CXi)%+(Ath; z8KdUG*(>GS&R-zG^+k+jCnyFk=V=@*TrsPb6m7rd7_IG%O7`5$;qcX1+xmsLteVzM zG3`+q59Eu90{^l6J63m}7(NxdMZS@m`@)YZnQV7aIve)p%;H7WJ$F@ACxzzzcIvl3 c`]].. + tostring( + stanza("html") + :tag("head") + :tag("title"):text("XMPP Status Page for "..jid):up():up() + :tag("body") + :tag("div", { id = jid_hash.."_status", class = "xmpp_status" }) + :tag("img", { id = jid_hash.."_img", class = "xmpp_status_image xmpp_status_"..status, + src = "data:image/png;base64,"..b64(require_resource("status_"..status..".png")) }):up() + :tag("span", { id = jid_hash.."_status_name", class = "xmpp_status_name" }) + :text("\194\160"..status):up() + :tag("span", { id = jid_hash.."_status_message", class = "xmpp_status_message" }) + :text(message and "\194\160"..message.."" or "") + ) + }; + end; + statuses[status].text = function() + return { status_code = 200, headers = { content_type = "text/plain" }, + body = status + }; + end; + statuses[status].message = function() + return { status_code = 200, headers = { content_type = "text/plain" }, + body = (message and message or "") + }; + end; + statuses[status].json = function() + return { status_code = 200, headers = { content_type = "application/json" }, + body = json({ + jid = jid, + show = status, + status = (message and message or "null") + }) + }; + end; + statuses[status].xml = function() + return { status_code = 200, headers = { content_type = "application/xml" }, + body = [[]].. + tostring( + stanza("result") + :tag("jid"):text(jid):up() + :tag("show"):text(status):up() + :tag("status"):text(message) + ) + }; + end + + if ((type == "") or (not statuses[status][type])) then + type = "image" + end; + + return statuses[status][type](); +end + +module:provides("http", { + default_path = "/status"; + route = { + ["GET /*"] = handle_request; + }; +});