commit d6157c6a15bdc4a3967333a706143bb7e2014c58 Author: Matthew Wild Date: Fri Jan 31 13:46:46 2020 +0000 Initial commit diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml new file mode 100644 index 0000000..b0c9353 --- /dev/null +++ b/.github/workflows/build-image.yml @@ -0,0 +1,19 @@ +name: Docker image build + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Build the Docker image + run: >- + docker build . + --file docker/Dockerfile + --tag snikket/snikket:dev + - name: Log into registry + run: echo "${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}" | docker login -u snikket --password-stdin + - name: Push the Docker image + run: docker push snikket/snikket:dev diff --git a/.hg_archival.txt b/.hg_archival.txt new file mode 100644 index 0000000..a10ac1e --- /dev/null +++ b/.hg_archival.txt @@ -0,0 +1,6 @@ +repo: d8acfaad7a59f59c34c81f45af91e895bf15630b +node: 142c7becb8eab82558ebe4be9847ba50adec4032 +branch: default +latesttag: null +latesttagdistance: 194 +changessincelatesttag: 194 diff --git a/.hgignore b/.hgignore new file mode 100644 index 0000000..da48980 --- /dev/null +++ b/.hgignore @@ -0,0 +1,4 @@ +syntax: glob +snikket.conf +snikket.retry +.luacheckcache diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7a542e9 --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ +.PHONY: all docker + +all: docker + +docker: + docker build -t snikket -f docker/Dockerfile . diff --git a/README.md b/README.md new file mode 100644 index 0000000..d375864 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# Snikket builder + +This is the source repository for building [Snikket service](https://snikket.org/service/) +Docker images. + +## Requirements + + - GNU make + - docker (tested on 19.03.5) + - ansible (tested on 2.7 (debian buster)) + +## Building + +Run 'make' + +## Running + +The easiest way is to use docker-compose. Copy the file `snikket.conf.example` to +`snikket.conf` and edit the values in it. Then run: + + docker-compose up -d + +If you need to change port mappings or any other advanced options, you can edit the +docker-compse.yml file. + +Alternatively you can run docker manually with something like the following: + +docker run --env-file=snikket.conf -p 80:5280 -p 443:5281 -p 5222:5222 -p 5269:5269 snikket + +## Development + +Dev images have a few additional features. + +### Local mail server + +Outgoing emails from dev images are captured by a local [MailHog](https://github.com/mailhog/MailHog) +instance and are accessible in a dashboard served on port 8025. The dashboard requires authentication. +The username is 'snikket' and the auto-generated password can be found with the following command: + +``` +docker exec snikket_snikket_1 cat /tmp/mailhog-password +``` + +Replace `snikket_snikket_1` with the name of your running container if it differs. + +MailHog is not included in production images, which require a real SMTP server. diff --git a/ansible/files/bin/create-invite b/ansible/files/bin/create-invite new file mode 100755 index 0000000..1b4c52c --- /dev/null +++ b/ansible/files/bin/create-invite @@ -0,0 +1,19 @@ +#!/bin/bash + +SHOW_QR=0 +if [ "$1" == "--qr" ]; then + SHOW_QR=1; +fi + +URL=$( prosodyctl mod_easy_invite "$SNIKKET_DOMAIN" generate "$@" ) + +echo "" +echo "Your invite link: $URL" +echo "" +if [ "$SHOW_QR" = "1" ]; then + echo "QR code for scanning:" + echo "" + echo "$URL" | qrencode -t ansi + echo "" +fi + diff --git a/ansible/files/certbot.cron b/ansible/files/certbot.cron new file mode 100644 index 0000000..f7e4de0 --- /dev/null +++ b/ansible/files/certbot.cron @@ -0,0 +1,12 @@ +#!/bin/sh + +certbot certonly -n --webroot --webroot-path /var/www \ + --cert-path /etc/ssl/certbot \ + --keep $SNIKKET_CERTBOT_OPTIONS \ + --agree-tos --email "$SNIKKET_ADMIN_EMAIL" --expand \ + --allow-subset-of-names \ + --config-dir /snikket/letsencrypt \ + --domain "$SNIKKET_DOMAIN" --domain "share.$SNIKKET_DOMAIN" \ + --domain "groups.$SNIKKET_DOMAIN" + +prosodyctl --root cert import /snikket/letsencrypt/live diff --git a/ansible/files/msmtp.conf b/ansible/files/msmtp.conf new file mode 100644 index 0000000..65bbd8d --- /dev/null +++ b/ansible/files/msmtp.conf @@ -0,0 +1,7 @@ +account default + +port 1025 +tls off +host localhost +from snikket +auto_from off diff --git a/ansible/files/prosody.cfg.lua b/ansible/files/prosody.cfg.lua new file mode 100644 index 0000000..1d24714 --- /dev/null +++ b/ansible/files/prosody.cfg.lua @@ -0,0 +1,166 @@ +local DOMAIN = assert(ENV_SNIKKET_DOMAIN, "Please set the SNIKKET_DOMAIN environment variable") + +daemonize = false +network_backend = "epoll" + +plugin_paths = { "/etc/prosody/modules" } + +data_path = "/snikket/prosody" + +pidfile = "/var/run/prosody/prosody.pid" + +modules_enabled = { + + -- Generally required + "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 + "disco"; -- Service discovery + + -- Not essential, but recommended + "carbons"; -- Keep multiple clients in sync + "pep"; -- Enables users to publish their avatar, mood, activity, playing music and more + "blocklist"; -- Allow users to block communications with other users + "vcard4"; -- User profiles (stored in PEP) + "vcard_legacy"; -- Conversion between legacy vCard and PEP Avatar, vcard + + -- Nice to have + "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 + "register"; -- Allow users to register on this server using a client and change passwords + "mam"; -- Store messages in an archive and allow users to access it + "csi_simple"; -- Simple Mobile optimizations + "cloud_notify"; -- Push notifications + + -- HTTP modules + "bosh"; -- Enable BOSH clients, aka "Jabber over HTTP" + "websocket"; -- XMPP over WebSockets + "http_acme_challenge"; + "http_libjs"; + + -- Other specific functionality + "limits"; -- Enable bandwidth limiting for XMPP connections + "watchregistrations"; -- Alert admins of registrations + "proxy65"; -- Enables a file transfer proxy service which clients behind NAT can use + "smacks"; + "email"; + "http_altconnect"; + "bookmarks"; + "default_bookmarks"; + "roster_allinall"; + "update_check"; + + -- TODO... + --"groups"; -- Shared roster support + --"server_contact_info"; -- Publish contact information for this service + --"announce"; -- Send announcement to all online users + --"motd"; -- Send a message to users when they log in + "welcome"; -- Welcome users who register accounts + "http_files"; -- Serve static files from a directory over HTTP + "reload_modules"; + "landing_page"; + "invites_page"; + "invites_register"; + "invites_api"; + "easy_invite"; +} + +reload_global_modules = { "http" } + +legacy_ssl_ports = { 5223 } + +allow_registration = true +registration_invite_only = true + +invites_page = ENV_SNIKKET_INVITE_URL or ("https://"..DOMAIN.."/invite?{token}"); + +c2s_require_encryption = true +s2s_require_encryption = true +s2s_secure_auth = true + +archive_expires_after = "1w" -- Remove archived messages after 1 week + +-- Disable IPv6 by default because Docker does not +-- have it enabled by default, and s2s to domains +-- with A+AAAA records breaks (as opposed to just AAAA) +-- TODO: implement happy eyeballs in net.connect +-- https://issues.prosody.im/1246 +use_ipv6 = (ENV_SNIKKET_TWEAK_IPV6 == "1") + +log = { + [ENV_SNIKKET_LOGLEVEL or "info"] = "*stdout" +} + +authentication = "internal_hashed" +authorization = "internal" +storage = "internal" +statistics = "internal" + +certificates = "certs" + +update_check_dns = "_{branch}.update.snikket.net" + +http_host = DOMAIN +http_external_url = "https://"..DOMAIN.."/" + +VirtualHost (DOMAIN) + authentication = "internal_hashed" + + http_files_dir = "/var/www" + http_paths = { + files = "/"; + landing_page = "/"; + invites_page = "/invite"; + invites_register = "/register"; + } + + default_bookmarks = { + { jid = "general@groups."..DOMAIN, name = "General Chat" }; + } + + welcome_message = [[Hi, welcome to Snikket on $host! + +]] +..[[Thanks for joining. We've automatically added you to the "General Chat" group ]] +..[[where you can chat with other members of $host. You'll find it under 'Bookmarks'. + +]] +..[[Snikket is in its early stages right now, so thanks for trying it out, ]] +..[[we hope you like it! + +]]..[[That's all for now, happy chatting!]] + +Component ("groups."..DOMAIN) "muc" + modules_enabled = { + "muc_mam"; + "vcard_muc"; + "muc_defaults"; + } + restrict_room_creation = "local" + muc_room_default_persistent = true + muc_room_default_allow_member_invites = true + + default_mucs = { + { + jid_node = "general"; + affiliations = { + owner = { "admin@"..DOMAIN }; + }; + config = { + name = "General Chat"; + description = "Welcome to "..DOMAIN.." general chat!"; + change_subject = false; + history_length = 30; + members_only = false; + moderated = false; + persistent = true; + public = true; + public_jids = true; + }; + } + } + +Component ("share."..DOMAIN) "http_upload" + diff --git a/ansible/files/supervisor-mailhog.conf b/ansible/files/supervisor-mailhog.conf new file mode 100644 index 0000000..c76a243 --- /dev/null +++ b/ansible/files/supervisor-mailhog.conf @@ -0,0 +1,8 @@ +[program:mailhog] +command=/usr/local/bin/mailhog -auth-file=/etc/mailhog-auth +autorestart=true +stopwaitsecs=30 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +redirect_stderr=true +umask=002 diff --git a/ansible/files/supervisord.conf b/ansible/files/supervisord.conf new file mode 100644 index 0000000..4a2357f --- /dev/null +++ b/ansible/files/supervisord.conf @@ -0,0 +1,25 @@ +[supervisord] +nodaemon=true + +[program:prosody] +command=/usr/bin/lua5.1 /usr/bin/prosody +priority=1000 +autorestart=true +stopwaitsecs=30 +user=prosody +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +redirect_stderr=true +directory=/snikket/prosody +umask=002 +environment=USER="prosody",HOME="/snikket/prosody" + +[program:anacron] +command=/bin/sh -c "/usr/sbin/anacron -d -n && sleep 3600" +startsecs=0 +autorestart=true +stopwaitsecs=30 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +redirect_stderr=true +umask=002 diff --git a/ansible/snikket.yml b/ansible/snikket.yml new file mode 100644 index 0000000..4fb1fb1 --- /dev/null +++ b/ansible/snikket.yml @@ -0,0 +1,13 @@ +--- +# Main playbook + +- hosts: all + become: yes + gather_facts: no + tasks: + - import_tasks: tasks/prosody.yml + - import_tasks: tasks/supervisor.yml + - import_tasks: tasks/cron.yml + - import_tasks: tasks/certs.yml + - import_tasks: tasks/mail.yml + - import_tasks: tasks/scripts.yml diff --git a/ansible/tasks/certs.yml b/ansible/tasks/certs.yml new file mode 100644 index 0000000..e48a0e3 --- /dev/null +++ b/ansible/tasks/certs.yml @@ -0,0 +1,16 @@ +--- + +- name: Install certbot + apt: + name: certbot + state: present + install_recommends: no +- name: Create directory for certs + file: + state: directory + path: /etc/ssl/certbot +- name: Install certbot cron script + copy: + src: ../files/certbot.cron + dest: /etc/cron.daily/certbot + mode: 0550 diff --git a/ansible/tasks/cron.yml b/ansible/tasks/cron.yml new file mode 100644 index 0000000..74029ac --- /dev/null +++ b/ansible/tasks/cron.yml @@ -0,0 +1,15 @@ +--- + +- name: Install anacron + apt: + name: anacron + state: present + install_recommends: no +- name: "Remove system cron scripts" + file: + path: "/etc/cron.daily/{{item}}" + state: absent + loop: + - apt-compat + - dpkg + - passwd diff --git a/ansible/tasks/mail.yml b/ansible/tasks/mail.yml new file mode 100644 index 0000000..128aaeb --- /dev/null +++ b/ansible/tasks/mail.yml @@ -0,0 +1,32 @@ +--- + +- name: Install msmtp-mta + apt: + name: msmtp-mta + state: present + install_recommends: no + +- name: Configure msmtp-mta + copy: + src: ../files/msmtp.conf + dest: /etc/msmtprc + +- name: Download MailHog + get_url: + url: "https://github.com/mailhog/MailHog/releases/download/v1.0.0/MailHog_linux_amd64" + checksum: sha256:ba921e04438e176c474d533447ae64707ffcdd1230f0153f86cb188d348f25c0 + dest: /usr/local/bin/mailhog + mode: 0755 + tags: dev + +- name: Add MailHog authentication + template: + src: "../templates/mailhog-auth" + dest: "/etc/mailhog-auth" + tags: dev + +- name: Add MailHog service + copy: + src: "../files/supervisor-mailhog.conf" + dest: "/etc/supervisor/conf.d/mailhog.conf" + tags: dev diff --git a/ansible/tasks/prosody.yml b/ansible/tasks/prosody.yml new file mode 100644 index 0000000..acc20fb --- /dev/null +++ b/ansible/tasks/prosody.yml @@ -0,0 +1,113 @@ +--- + +- name: "Add Prosody package signing key" + apt_key: + url: "https://packages.prosody.im/debian/pubkey.asc" +- name: "Add Prosody package repo" + apt_repository: + repo: "deb https://packages.prosody.im/debian buster main" +- name: "Install Prosody package" + apt: + name: prosody-trunk + state: present + install_recommends: yes +- name: "Deploy Prosody config" + copy: + src: ../files/prosody.cfg.lua + dest: /etc/prosody/prosody.cfg.lua +- name: "Create Prosody data directory" + file: + state: directory + path: /snikket/prosody + owner: prosody + group: prosody + mode: 0750 +- name: "Create Prosody modules directory" + file: + state: directory + path: /etc/prosody/modules +- name: "Create web root directory" + file: + state: directory + path: /var/www +- name: "FIXME Workaround for Prosody package bug" + file: + path: /etc/prosody/certs + state: directory + owner: prosody + group: adm + mode: 0750 + recurse: yes +- name: "Disable Prosody init script" + service: + name: prosody + enabled: no +- name: "Stop Prosody if running" + service: + name: prosody + state: stopped + +- name: Install Mercurial + apt: + name: mercurial + state: present + install_recommends: no + +- name: Clone prosody-modules + hg: + repo: https://hg.prosody.im/prosody-modules + dest: /usr/local/lib/prosody-modules + revision: default + purge: yes + update: yes +- name: Enable wanted modules + file: + state: link + src: "/usr/local/lib/prosody-modules/{{item}}" + dest: "/etc/prosody/modules/{{item}}" + loop: + - mod_smacks + - mod_cloud_notify + - mod_invite + - mod_block_registrations + - mod_compact_resource + - mod_conversejs + - mod_http_upload + - mod_lastlog + - mod_limit_auth + - mod_password_policy + - mod_password_reset + - mod_roster_allinall + - mod_strict_https + - mod_vcard_muc + - mod_reload_modules + - mod_email + - mod_http_altconnect + - mod_bookmarks + - mod_default_bookmarks + - mod_muc_defaults + + +- name: Install Bootstrap and JS libs + apt: + name: + - libjs-bootstrap4 + - libjs-jquery + install_recommends: no + +- name: Enable wanted modules + file: + state: link + src: "/usr/local/lib/snikket-modules/{{item}}" + dest: "/etc/prosody/modules/{{item}}" + loop: + - mod_landing_page + - mod_invites + - mod_invites_page + - mod_invites_register + - mod_invites_api + - mod_easy_invite + - mod_http_acme_challenge + - mod_http_libjs + - mod_update_check + - mod_authz_internal diff --git a/ansible/tasks/scripts.yml b/ansible/tasks/scripts.yml new file mode 100644 index 0000000..6d11417 --- /dev/null +++ b/ansible/tasks/scripts.yml @@ -0,0 +1,7 @@ +--- + +- name: "Add helper scripts" + copy: + src: "../files/bin/" + dest: "/usr/local/bin/" + mode: 0755 diff --git a/ansible/tasks/supervisor.yml b/ansible/tasks/supervisor.yml new file mode 100644 index 0000000..0765e4f --- /dev/null +++ b/ansible/tasks/supervisor.yml @@ -0,0 +1,11 @@ +--- + +- name: "Install supervisord" + apt: + name: supervisor + state: present + install_recommends: no +- name: "Deploy supervisor config" + copy: + src: "../files/supervisord.conf" + dest: "/etc/supervisor/conf.d/supervisord.conf" diff --git a/ansible/templates/mailhog-auth b/ansible/templates/mailhog-auth new file mode 100644 index 0000000..8c36769 --- /dev/null +++ b/ansible/templates/mailhog-auth @@ -0,0 +1 @@ +snikket:{{ lookup('password', '/tmp/mailhog-password length=15')|password_hash("bcrypt", lookup('password', '/tmp/mailhog-salt length=21 chars=letters,digits')+".") }} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d996b94 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +version: "3.3" + +services: + snikket: + image: snikket:latest + ports: + # HTTP port + - "80:5280" + # HTTPS port + - "443:5281" + # XMPP client connections (STARTTLS and Direct TLS) + - "5222:5222" + - "5223:5223" + # XMPP server-to-server connections + - "5269:5269" + # Mail viewer (dev only) + - "8025:8025" + volumes: + - type: "volume" + source: snikket_data + target: /snikket + env_file: snikket.conf + restart: "unless-stopped" + +volumes: + snikket_data: diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..ee6af5d --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,41 @@ +FROM debian:buster-slim + +ARG BUILD_SERIES=alpha +ARG BUILD_ID=0 + +# Install dependencies + +RUN install -d -m 755 /snikket; + +ADD tools/smtp-url-to-msmtp.lua /usr/local/bin/smtp-url-to-msmtp +RUN chmod 550 /usr/local/bin/smtp-url-to-msmtp + +ADD docker/entrypoint.sh /bin/entrypoint.sh +RUN chmod 550 /bin/entrypoint.sh +ENTRYPOINT ["/bin/sh", "/bin/entrypoint.sh"] + +ADD ansible /opt/ansible + +ADD snikket-modules /usr/local/lib/snikket-modules + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + software-properties-common \ + gpg gpg-agent \ + ansible python-passlib python3-passlib \ + && rm -rf /var/lib/apt/lists/* \ + && ansible-playbook -c local -i localhost, --extra-vars "ansible_python_interpreter=/usr/bin/python2" /opt/ansible/snikket.yml \ + && apt-get remove -y \ + ansible \ + software-properties-common \ + gpg gpg-agent \ + python-passlib python3-passlib \ + mercurial \ + && apt-get autoremove -y \ + && rm -rf /var/cache/* + +ADD www /var/www + +RUN echo "Snikket $BUILD_SERIES.$BUILD_ID" > /var/lib/prosody/prosody.version + +VOLUME ["/snikket"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..0d9f9fe --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +if [ -z "$SNIKKET_DOMAIN" ]; then + echo "Please provide SNIKKET_DOMAIN"; + exit 1; +fi + +if [ -z "$SNIKKET_SMTP_URL" ]; then + SNIKKET_SMTP_URL="smtp://localhost:1025/;no-tls" +fi + +echo "$SNIKKET_SMTP_URL" | smtp-url-to-msmtp > /etc/msmtprc + +echo "from snikket@$SNIKKET_DOMAIN" >> /etc/msmtprc + +unset SNIKKET_SMTP_URL + +exec supervisord -c /etc/supervisor/supervisord.conf diff --git a/snikket-modules/.luacheckrc b/snikket-modules/.luacheckrc new file mode 100644 index 0000000..eb14600 --- /dev/null +++ b/snikket-modules/.luacheckrc @@ -0,0 +1,86 @@ +cache = true +allow_defined_top = true +unused_secondaries = false +max_line_length = 150 +codes = true +ignore = { "411/err", "421/err", "411/ok", "421/ok", "211/_ENV" }; +read_globals = { + "prosody", + "hosts", + "import", + + -- Module instance + "module.name", + "module.host", + "module._log", + "module.event_handlers", + "module.reloading", + "module.saved_state", + "module.global", + "module.path", + + -- Module API + "module.add_extension", + "module.add_feature", + "module.add_identity", + "module.add_item", + "module.add_timer", + "module.broadcast", + "module.context", + "module.depends", + "module.fire_event", + "module.get_directory", + "module.get_host", + "module.get_host_items", + "module.get_host_type", + "module.get_name", + "module.get_option", + "module.get_option_array", + "module.get_option_boolean", + "module.get_option_inherited_set", + "module.get_option_number", + "module.get_option_path", + "module.get_option_set", + "module.get_option_string", + "module.get_status", + "module.handle_items", + "module.hook", + "module.hook_global", + "module.hook_object_event", + "module.hook_tag", + "module.load_resource", + "module.log", + "module.log_status", + "module.measure", + "module.measure_event", + "module.measure_global_event", + "module.measure_object_event", + "module.open_store", + "module.provides", + "module.remove_item", + "module.require", + "module.send", + "module.send_iq", + "module.set_global", + "module.set_status", + "module.shared", + "module.unhook", + "module.unhook_object_event", + "module.wrap_event", + "module.wrap_global", + "module.wrap_object_event", + + -- mod_http API + "module.http_url", +} +globals = { + -- Methods that can be set on module API + "module.unload", + "module.add_host", + "module.load", + "module.add_host", + "module.save", + "module.restore", + "module.command", + "module.environment", +} diff --git a/snikket-modules/mod_authz_internal/mod_authz_internal.lua b/snikket-modules/mod_authz_internal/mod_authz_internal.lua new file mode 100644 index 0000000..17e57ea --- /dev/null +++ b/snikket-modules/mod_authz_internal/mod_authz_internal.lua @@ -0,0 +1,9 @@ +local role_store = module:open_store("roles"); + +function get_user_roles(user) + return role_store:get(user); +end + +function get_jid_roles(jid) --luacheck: ignore 212/jid + return nil; +end diff --git a/snikket-modules/mod_easy_invite/mod_easy_invite.lua b/snikket-modules/mod_easy_invite/mod_easy_invite.lua new file mode 100644 index 0000000..4108e13 --- /dev/null +++ b/snikket-modules/mod_easy_invite/mod_easy_invite.lua @@ -0,0 +1,221 @@ +-- XEP-0401: Easy User Onboarding +local dataforms = require "util.dataforms"; +local datetime = require "util.datetime"; +local jid_bare = require "util.jid".bare; +local jid_split = require "util.jid".split; +local split_jid = require "util.jid".split; +local rostermanager = require "core.rostermanager"; +local st = require "util.stanza"; + +local invite_only = module:get_option_boolean("registration_invite_only", true); +local require_encryption = module:get_option_boolean("c2s_require_encryption", + module:get_option_boolean("require_encryption", false)); + +local new_adhoc = module:require("adhoc").new; + +-- Whether local users can invite other users to create an account on this server +local allow_user_invites = module:get_option_boolean("allow_user_invites", true); + +local invites; +if prosody.shutdown then -- COMPAT hack to detect prosodyctl + invites = module:depends("invites"); +end + +local invite_result_form = dataforms.new({ + title = "Your Invite", + -- TODO instructions = something helpful + { + name = "uri"; + label = "Invite URI"; + -- TODO desc = something helpful + }, + { + name = "url" ; + var = "landing-url"; + label = "Invite landing URL"; + }, + { + name = "expire"; + label = "Token valid until"; + }, + }); + +module:depends("adhoc"); +module:provides("adhoc", new_adhoc("New Invite", "urn:xmpp:invite#invite", + function (_, data) + local username = split_jid(data.from); + local invite = invites.create_contact(username, allow_user_invites); + --TODO: check errors + return { + status = "completed"; + form = { + layout = invite_result_form; + values = { + uri = invite.uri; + url = invite.landing_page; + expire = datetime.datetime(invite.expires); + }; + }; + }; + end, "local_user")); + + +-- TODO +-- module:provides("adhoc", new_adhoc("Create account", "urn:xmpp:invite#create-account", function () end, "admin")); + +-- XEP-0379: Pre-Authenticated Roster Subscription +module:hook("presence/bare", function (event) + local stanza = event.stanza; + if stanza.attr.type ~= "subscribe" then return end + + local preauth = stanza:get_child("preauth", "urn:xmpp:pars:0"); + if not preauth then return end + local token = preauth.attr.token; + if not token then return end + + local username, host = jid_split(stanza.attr.to); + + local invite, err = invites.get(token, username); + + if not invite then + module:log("debug", "Got invalid token, error: %s", err); + return; + end + + local contact = jid_bare(stanza.attr.from); + + module:log("debug", "Approving inbound subscription to %s from %s", username, contact); + if rostermanager.set_contact_pending_in(username, host, contact, stanza) then + if rostermanager.subscribed(username, host, contact) then + invite:use(); + rostermanager.roster_push(username, host, contact); + + -- Send back a subscription request (goal is mutual subscription) + if not rostermanager.is_user_subscribed(username, host, contact) + and not rostermanager.is_contact_pending_out(username, host, contact) then + module:log("debug", "Sending automatic subscription request to %s from %s", contact, username); + if rostermanager.set_contact_pending_out(username, host, contact) then + rostermanager.roster_push(username, host, contact); + module:send(st.presence({type = "subscribe", to = contact })); + else + module:log("warn", "Failed to set contact pending out for %s", username); + end + end + end + end +end, 1); + +-- TODO sender side, magic automatic mutual subscription + +local invite_stream_feature = st.stanza("register", { xmlns = "urn:xmpp:invite" }):up(); +module:hook("stream-features", function(event) + local session, features = event.origin, event.features; + + -- Advertise to unauthorized clients only. + if session.type ~= "c2s_unauthed" or (require_encryption and not session.secure) then + return + end + + features:add_child(invite_stream_feature); +end); + +-- Client is submitting a preauth token to allow registration +module:hook("stanza/iq/urn:xmpp:pars:0:preauth", function(event) + local preauth = event.stanza.tags[1]; + local token = preauth.attr.token; + local validated_invite = invites.get(token); + if not validated_invite then + local reply = st.error_reply(event.stanza, "cancel", "forbidden", "The invite token is invalid or expired"); + event.origin.send(reply); + return true; + end + event.origin.validated_invite = validated_invite; + local reply = st.reply(event.stanza); + event.origin.send(reply); + return true; +end); + +-- Registration attempt - ensure a valid preauth token has been supplied +module:hook("user-registering", function (event) + local validated_invite = event.validated_invite or (event.session and event.session.validated_invite); + if invite_only and not validated_invite then + event.allowed = false; + event.reason = "Registration on this server is through invitation only"; + return; + end +end); + +-- Make a *one-way* subscription. User will see when contact is online, +-- contact will not see when user is online. +function subscribe(host, user_username, contact_username) + local user_jid = user_username.."@"..host; + local contact_jid = contact_username.."@"..host; + -- Update user's roster to say subscription request is pending... + rostermanager.set_contact_pending_out(user_username, host, contact_jid); + -- Update contact's roster to say subscription request is pending... + rostermanager.set_contact_pending_in(contact_username, host, user_jid); + -- Update contact's roster to say subscription request approved... + rostermanager.subscribed(contact_username, host, user_jid); + -- Update user's roster to say subscription request approved... + rostermanager.process_inbound_subscription_approval(user_username, host, contact_jid); +end + +-- Make a mutual subscription between jid1 and jid2. Each JID will see +-- when the other one is online. +function subscribe_both(host, user1, user2) + subscribe(host, user1, user2); + subscribe(host, user2, user1); +end + +-- Registration successful, if there was a preauth token, mark it as used +module:hook("user-registered", function (event) + local validated_invite = event.validated_invite or (event.session and event.session.validated_invite); + if not validated_invite then + return; + end + local inviter_username = validated_invite.inviter; + local contact_username = event.username; + validated_invite:use(); + + if inviter_username then + module:log("debug", "Creating mutual subscription between %s and %s", inviter_username, contact_username); + subscribe_both(module.host, inviter_username, contact_username); + end + + if validated_invite.additional_data then + module:log("debug", "Importing roles from invite"); + local roles = validated_invite.additional_data.roles; + if roles then + module:open_store("roles"):set(contact_username, roles); + end + end +end); + + +local sm = require "core.storagemanager"; +function module.command(arg) + if #arg < 2 or arg[2] ~= "generate" then + print("usage: prosodyctl mod_easy_invite example.net generate"); + return; + end + + local host = arg[1]; + assert(hosts[host], "Host "..tostring(host).." does not exist"); + sm.initialize_host(host); + + table.remove(arg, 1); + table.remove(arg, 1); + + local roles; + if arg[1] == "--admin" then + roles = { ["prosody:admin"] = true }; + elseif arg[1] == "--role" then + roles = { [arg[2]] = true }; + end + + invites = module:context(host):depends("invites"); + module:context(host):depends("invites_page"); + local invite = invites.create_account(nil, { roles = roles }); + print(invite.landing_page or invite.uri); +end + diff --git a/snikket-modules/mod_http_acme_challenge/mod_http_acme_challenge.lua b/snikket-modules/mod_http_acme_challenge/mod_http_acme_challenge.lua new file mode 100644 index 0000000..ee4d220 --- /dev/null +++ b/snikket-modules/mod_http_acme_challenge/mod_http_acme_challenge.lua @@ -0,0 +1,12 @@ +local serve = require "net.http.files".serve; + +module:set_global(); + +local path = module:get_option_string("acme_challenge_path", "/var/www/.well-known/acme-challenge"); + +module:provides("http", { + default_path = "/.well-known/acme-challenge"; + route = { + ["GET /*"] = serve({ path = path }); + } + }); diff --git a/snikket-modules/mod_http_libjs/mod_http_libjs.lua b/snikket-modules/mod_http_libjs/mod_http_libjs.lua new file mode 100644 index 0000000..44c1ac0 --- /dev/null +++ b/snikket-modules/mod_http_libjs/mod_http_libjs.lua @@ -0,0 +1,11 @@ +local mime_map = module:shared("/*/http_files/mime").types or { + css = "text/css", + js = "application/javascript", +}; + +module:provides("http", { + default_path = "/share"; + route = { + ["GET /*"] = require "net.http.files".serve({ path = "/usr/share/javascript", mime_map = mime_map }); + } + }); diff --git a/snikket-modules/mod_invites/mod_invites.lua b/snikket-modules/mod_invites/mod_invites.lua new file mode 100644 index 0000000..8fa8c93 --- /dev/null +++ b/snikket-modules/mod_invites/mod_invites.lua @@ -0,0 +1,134 @@ +local id = require "util.id"; +local url = require "socket.url"; +local jid_node = require "util.jid".node; + +local invite_ttl = module:get_option_number("invite_expiry", 86400 * 7); + +local token_storage = module:open_store("invite_token", "map"); + +local function get_uri(action, jid, token, params) --> string + return url.build({ + scheme = "xmpp", + path = jid, + query = action..";preauth="..token..(params and (";"..params) or ""), + }); +end + +local function create_invite(invite_action, invite_jid, allow_registration, additional_data) + local token = id.medium(); + + local created_at = os.time(); + local expires = created_at + invite_ttl; + + local invite_params = (invite_action == "roster" and allow_registration) and "ibr=y" or nil; + + local invite = { + type = invite_action; + jid = invite_jid; + + token = token; + allow_registration = allow_registration; + additional_data = additional_data; + + uri = get_uri(invite_action, invite_jid, token, invite_params); + + created_at = created_at; + expires = expires; + }; + + module:fire_event("invite-created", invite); + + if allow_registration then + local ok, err = token_storage:set(nil, token, invite); + if not ok then + module:log("warn", "Failed to store account invite: %s", err); + return nil, "internal-server-error"; + end + end + + if invite_action == "roster" then + local username = jid_node(invite_jid); + local ok, err = token_storage:set(username, token, expires); + if not ok then + module:log("warn", "Failed to store subscription invite: %s", err); + return nil, "internal-server-error"; + end + end + + return invite; +end + +-- Create invitation to register an account (optionally restricted to the specified username) +function create_account(account_username, additional_data) --luacheck: ignore 131/create_account + local jid = account_username and (account_username.."@"..module.host) or module.host; + return create_invite("register", jid, true, additional_data); +end + +-- Create invitation to become a contact of a local user +function create_contact(username, allow_registration, additional_data) --luacheck: ignore 131/create_contact + return create_invite("roster", username.."@"..module.host, allow_registration, additional_data); +end + +local valid_invite_methods = {}; +local valid_invite_mt = { __index = valid_invite_methods }; + +function valid_invite_methods:use() + if self.username then + -- Also remove the contact invite if present, on the + -- assumption that they now have a mutual subscription + token_storage:set(self.username, self.token, nil); + end + token_storage:set(nil, self.token, nil); + return true; +end + +-- Get a validated invite (or nil, err). Must call :use() on the +-- returned invite after it is actually successfully used +-- For "roster" invites, the username of the local user (who issued +-- the invite) must be passed. +-- If no username is passed, but the registration is a roster invite +-- from a local user, the "inviter" field of the returned invite will +-- be set to their username. +function get(token, username) + if not token then + return nil, "no-token"; + end + + local valid_until, inviter; + + -- Fetch from host store (account invite) + local token_info = token_storage:get(nil, token); + + if username then -- token being used for subscription + -- Fetch from user store (subscription invite) + valid_until = token_storage:get(username, token); + else -- token being used for account creation + valid_until = token_info and token_info.expires; + if token_info and token_info.type == "roster" then + username = jid_node(token_info.jid); + inviter = username; + end + end + + if not valid_until then + module:log("debug", "Got unknown token: %s", token); + return nil, "token-invalid"; + elseif os.time() > valid_until then + module:log("debug", "Got expired token: %s", token); + return nil, "token-expired"; + end + + return setmetatable({ + token = token; + username = username; + inviter = inviter; + type = token_info and token_info.type or "roster"; + uri = token_info and token_info.uri or get_uri("roster", username.."@"..module.host, token); + additional_data = token_info and token_info.additional_data or nil; + }, valid_invite_mt); +end + +function use(token) --luacheck: ignore 131/use + local invite = get(token); + return invite and invite:use(); +end diff --git a/snikket-modules/mod_invites_api/mod_invites_api.lua b/snikket-modules/mod_invites_api/mod_invites_api.lua new file mode 100644 index 0000000..f6b7074 --- /dev/null +++ b/snikket-modules/mod_invites_api/mod_invites_api.lua @@ -0,0 +1,112 @@ +local http_formdecode = require "net.http".formdecode; + +local api_key_store; +local invites; +-- COMPAT: workaround to avoid executing inside prosodyctl +if prosody.shutdown then + module:depends("http"); + api_key_store = module:open_store("invite_api_keys", "map"); + invites = module:depends("invites"); +end + +local function get_api_user(request, params) + local combined_key; + + local auth_header = request.headers.authorization; + + if not auth_header then + params = params or http_formdecode(request.url.query); + combined_key = params.key; + else + local auth_type, value = auth_header:match("^(%S+)%s(%S+)$"); + if auth_type ~= "Bearer" then + return; + end + combined_key = value; + end + + if not combined_key then + return; + end + + local key_id, key_token = combined_key:match("^([^/]+)/(.+)$"); + + if not key_id then + return; + end + + local api_user = api_key_store:get(nil, key_id); + + if not api_user or api_user.token ~= key_token then + return; + end + + -- TODO: key expiry, rate limiting, etc. + return api_user; +end + +function handle_request(event) + local query_params = http_formdecode(event.request.url.query); + + local api_user = get_api_user(event.request, query_params); + + if not api_user then + return 403; + end + + local invite = invites.create_account(nil, { source = "api/token/"..api_user.id }); + if not invite then + return 500; + end + + event.response.headers.Location = invite.landing_page or invite.uri; + + if query_params.redirect then + return 303; + end + return 201; +end + +if invites then + module:provides("http", { + route = { + ["GET"] = handle_request; + }; + }); +end + +function module.command(arg) + local host = table.remove(arg, 1); + if not prosody.hosts[host] then + print("Error: please supply a valid host"); + return 1; + end + require "core.storagemanager".initialize_host(host); + module.host = host; --luacheck: ignore 122/module + api_key_store = module:open_store("invite_api_keys", "map"); + + local command = table.remove(arg, 1); + if command == "create" then + local id = require "util.id".short(); + local token = require "util.id".long(); + api_key_store:set(nil, id, { + id = id; + token = token; + name = arg[1]; + created_at = os.time(); + }); + print(id.."/"..token); + elseif command == "delete" then + local id = table.remove(arg, 1); + if not api_key_store:get(nil, id) then + print("Error: key not found"); + return 1; + end + api_key_store:set(nil, id, nil); + elseif command == "list" then + local api_key_store_kv = module:open_store("invite_api_keys"); + for key_id, key_info in pairs(api_key_store_kv:get(nil)) do + print(key_id, key_info.name or ""); + end + end +end diff --git a/snikket-modules/mod_invites_page/html/invite.html b/snikket-modules/mod_invites_page/html/invite.html new file mode 100644 index 0000000..38cc712 --- /dev/null +++ b/snikket-modules/mod_invites_page/html/invite.html @@ -0,0 +1,131 @@ + + + + + + Invite to {site_name} | Snikket + + + + + + + + + + + + +
+
+
+

+ Invite to {site_name}
+

+
Powered by
+
+ {inviter?

You have been invited to chat on {site_name} using Snikket, + a secure, privacy-friendly chat app.

} + + {inviter&

You have been invited to chat with {inviter} using Snikket, + a secure, privacy-friendly chat app on {site_name}.

} + +
Get started
+ +

Install the Snikket app on your Android device (iOS coming soon!)

+ + + + + +

After installation the app should automatically open and prompt you to + create an account. If not, simply click the button below.

+ +
App already installed?
+ +
+
+ This button works only if you have the app installed already! +
+
+
Alternatives
+

You can connect to Snikket using any XMPP-compatible software. If the button above does not + work with your app, you may need to register an account manually.

+
+
+
+ + + + + + + + + diff --git a/snikket-modules/mod_invites_page/html/invite_invalid.html b/snikket-modules/mod_invites_page/html/invite_invalid.html new file mode 100644 index 0000000..3e732f5 --- /dev/null +++ b/snikket-modules/mod_invites_page/html/invite_invalid.html @@ -0,0 +1,37 @@ + + + + + + Invite to {site_name} | Snikket + + + + + + + + + + + +
+
+
+

+ Invite to {site_name}
+

+
Powered by
+
+
Invite expired
+ +

Sorry, it looks like this invite code has expired!

+ + +
+
+
+ + + + diff --git a/snikket-modules/mod_invites_page/mod_invites_page.lua b/snikket-modules/mod_invites_page/mod_invites_page.lua new file mode 100644 index 0000000..a298a9f --- /dev/null +++ b/snikket-modules/mod_invites_page/mod_invites_page.lua @@ -0,0 +1,55 @@ +local st = require "util.stanza"; +local url_escape = require "util.http".urlencode; + +local render_html_template = require"util.interpolation".new("%b{}", st.xml_escape, { + urlescape = url_escape; +}); +local render_url = require "util.interpolation".new("%b{}", url_escape, { + urlescape = url_escape; + noscheme = function (url) + return (url:gsub("^[^:]+:", "")); + end; +}); + +local site_name = module:get_option_string("site_name", module.host); + +if prosody.shutdown then + module:depends("http"); +end +local invites = module:depends("invites"); + +-- Point at eg https://github.com/ge0rg/easy-xmpp-invitation +local base_url = module:get_option_string("invites_page", (module.http_url and module:http_url().."?{token}") or nil); + +local function add_landing_url(invite) + if not base_url then return; end + invite.landing_page = render_url(base_url, invite); +end + +module:hook("invite-created", add_landing_url); + + +function serve_invite_page(event) + local invite_page_template = assert(module:load_resource("html/invite.html")):read("*a"); + local invalid_invite_page_template = assert(module:load_resource("html/invite_invalid.html")):read("*a"); + + local invite = invites.get(event.request.url.query); + if not invite then + return render_html_template(invalid_invite_page_template, { site_name = site_name }); + end + + local invite_page = render_html_template(invite_page_template, { + site_name = site_name; + token = invite.token; + uri = invite.uri; + type = invite.type; + jid = invite.jid; + }); + return invite_page; +end + +module:provides("http", { + route = { + ["GET"] = serve_invite_page; + }; +}); diff --git a/snikket-modules/mod_invites_register/html/register.html b/snikket-modules/mod_invites_register/html/register.html new file mode 100644 index 0000000..d93c197 --- /dev/null +++ b/snikket-modules/mod_invites_register/html/register.html @@ -0,0 +1,87 @@ + + + + + + {site_name} | Snikket + + + + + + + + + + + +
+
+
+

+ Secure communication on {site_name}
+

+
Powered by
+
+

{site_name} is using Snikket - a secure, privacy-friendly chat app.

+ +
Create an account
+ +

Creating an account will allow to communicate with other people using + the Snikket app or compatible software. If you already have the app installed, + we recommend that you continue the account creation process inside the app + by clicking on the button below:

+ +
App already installed?
+ +
+
+ This button works only if you have the app installed already! +
+
+ +
Create an account online
+

If you plan to use a legacy XMPP client, you can register an account online and enter your + credentials into any XMPP-compatible software.

+ + {message&} + +
+
+ +
+
+ +
+ @{domain} +
+
+ Choose a username, this will become the first part of your new chat address. +
+
+
+ +
+ + Enter a secure password that you do not use anywhere else. +
+
+
+ + +
+
+
+
+
+ + + + diff --git a/snikket-modules/mod_invites_register/html/register_error.html b/snikket-modules/mod_invites_register/html/register_error.html new file mode 100644 index 0000000..43995d4 --- /dev/null +++ b/snikket-modules/mod_invites_register/html/register_error.html @@ -0,0 +1,37 @@ + + + + + + Invite to {site_name} | Snikket + + + + + + + + + + + +
+
+
+

+ Invite to {site_name}
+

+
Powered by
+
+
Registration error
+ +

{message?Sorry, there was a problem registering your account.}

+ + +
+
+
+ + + + diff --git a/snikket-modules/mod_invites_register/html/register_success.html b/snikket-modules/mod_invites_register/html/register_success.html new file mode 100644 index 0000000..fccc3af --- /dev/null +++ b/snikket-modules/mod_invites_register/html/register_success.html @@ -0,0 +1,79 @@ + + + + + + Invite to {site_name} | Snikket + + + + + + + + + + + + + +
+
+
+

+ {site_name}
+

+
Powered by
+
+
Congratulations!
+ +

You have created an account on {site_name}.

+ +

To start chatting, you need to enter your new account + credentials into your chosen XMPP software.

+ +

As a final reminder, your account details are shown below:

+ + + +

Your password is stored encrypted on the server and will not be accessible after you close this page.

+
+
+
+ + + + diff --git a/snikket-modules/mod_invites_register/mod_invites_register.lua b/snikket-modules/mod_invites_register/mod_invites_register.lua new file mode 100644 index 0000000..536e330 --- /dev/null +++ b/snikket-modules/mod_invites_register/mod_invites_register.lua @@ -0,0 +1,158 @@ +local id = require "util.id"; +local http_formdecode = require "net.http".formdecode; +local usermanager = require "core.usermanager"; +local nodeprep = require "util.encodings".stringprep.nodeprep; +local st = require "util.stanza"; +local url_escape = require "util.http".urlencode; +local render_html_template = require"util.interpolation".new("%b{}", st.xml_escape, { + urlescape = url_escape; +}); + + +local site_name = module:get_option_string("site_name", module.host); + +module:depends("http"); +module:depends("easy_invite"); +local invites = module:depends("invites"); +local invites_page = module:depends("invites_page"); + +function serve_register_page(event) + local register_page_template = assert(module:load_resource("html/register.html")):read("*a"); + + local invite = invites.get(event.request.url.query); + if not invite then + return { + status_code = 303; + headers = { + ["Location"] = invites.module:http_url().."?"..event.request.url.query; + }; + }; + end + + local invite_page = render_html_template(register_page_template, { + site_name = site_name; + token = invite.token; + domain = module.host; + uri = invite.uri; + type = invite.type; + jid = invite.jid; + }); + return invite_page; +end + +function handle_register_form(event) + local request, response = event.request, event.response; + local form_data = http_formdecode(request.body); + local user, password, token = form_data["user"], form_data["password"], form_data["token"]; + + local register_page_template = assert(module:load_resource("html/register.html")):read("*a"); + local error_template = assert(module:load_resource("html/register_error.html")):read("*a"); + local success_template = assert(module:load_resource("html/register_success.html")):read("*a"); + + local invite = invites.get(token); + if not invite then + return { + status_code = 303; + headers = { + ["Location"] = invites_page.module:http_url().."?"..event.request.url.query; + }; + }; + end + + response.headers.content_type = "text/html; charset=utf-8"; + + if not user or #user == 0 or not password or #password == 0 or not token then + return render_html_template(register_page_template, { + site_name = site_name; + token = invite.token; + domain = module.host; + uri = invite.uri; + type = invite.type; + jid = invite.jid; + + msg_class = "alert-warning"; + message = "Please fill in all fields."; + }); + end + + -- Shamelessly copied from mod_register_web. + local prepped_username = nodeprep(user); + + if not prepped_username or #prepped_username == 0 then + return render_html_template(register_page_template, { + site_name = site_name; + token = invite.token; + domain = module.host; + uri = invite.uri; + type = invite.type; + jid = invite.jid; + + msg_class = "alert-warning"; + message = "This username contains invalid characters."; + }); + end + + if usermanager.user_exists(prepped_username, module.host) then + return render_html_template(register_page_template, { + site_name = site_name; + token = invite.token; + domain = module.host; + uri = invite.uri; + type = invite.type; + jid = invite.jid; + + msg_class = "alert-warning"; + message = "This username is already in use."; + }); + end + + local registering = { + validated_invite = invite; + username = prepped_username; + host = module.host; + allowed = true; + }; + + module:fire_event("user-registering", registering); + + if not registering.allowed then + return render_html_template(error_template, { + site_name = site_name; + msg_class = "alert-danger"; + message = registering.reason or "Registration is not allowed."; + }); + end + + local ok, err = usermanager.create_user(prepped_username, password, module.host); + + if ok then + module:fire_event("user-registered", { + username = prepped_username; + host = module.host; + source = "mod_"..module.name; + validated_invite = invite; + }); + + return render_html_template(success_template, { + site_name = site_name; + username = prepped_username; + domain = module.host; + password = password; + }); + else + local err_id = id.short(); + module:log("warn", "Registration failed (%s): %s", err_id, tostring(err)); + return render_html_template(error_template, { + site_name = site_name; + msg_class = "alert-danger"; + message = ("An unknown error has occurred (%s)"):format(err_id); + }); + end +end + +module:provides("http", { + route = { + ["GET"] = serve_register_page; + ["POST"] = handle_register_form; + }; +}); diff --git a/snikket-modules/mod_landing_page/email_templates/invite_email.html b/snikket-modules/mod_landing_page/email_templates/invite_email.html new file mode 100644 index 0000000..fe3d36d --- /dev/null +++ b/snikket-modules/mod_landing_page/email_templates/invite_email.html @@ -0,0 +1,11 @@ + + +TODO! + +Hello from {site_name}! + +You have been invited to join {site_name}. Simply click here to get started: view invite + +If you already have a compatible app installed, you can click here instead: open in app + + diff --git a/snikket-modules/mod_landing_page/email_templates/invite_email.txt b/snikket-modules/mod_landing_page/email_templates/invite_email.txt new file mode 100644 index 0000000..46cf722 --- /dev/null +++ b/snikket-modules/mod_landing_page/email_templates/invite_email.txt @@ -0,0 +1,7 @@ +TODO! + +Hello from {site_name}! + +You have been invited to join {site_name}. Simply click here to get started: {invite_page} + +If you already have a compatible app installed, you can click here instead: {invite_uri} diff --git a/snikket-modules/mod_landing_page/html/index.html b/snikket-modules/mod_landing_page/html/index.html new file mode 100644 index 0000000..db07632 --- /dev/null +++ b/snikket-modules/mod_landing_page/html/index.html @@ -0,0 +1,63 @@ + + + + + + {site_name} | Snikket + + + + + + + + + + + +
+
+
+

+ Secure communication on {site_name}
+

+ +
+

{site_name} is using Snikket - a secure, privacy-friendly chat app.

+ + {allow_email& +
Request invitation
+ +

You may join the Snikket network by creating an account + on {site_name}. Registration is by invitation only, enter + your email address to request an invitation.

+ +
+
+ +
+
+ +
+ + Enter the email address we should deliver your invitation to. +
+ Your email address will be kept private as per our Privacy Policy. +
+
+
+
+ +
+
+ } +
+
+
+ + + + diff --git a/snikket-modules/mod_landing_page/mod_landing_page.lua b/snikket-modules/mod_landing_page/mod_landing_page.lua new file mode 100644 index 0000000..b361940 --- /dev/null +++ b/snikket-modules/mod_landing_page/mod_landing_page.lua @@ -0,0 +1,94 @@ +local http_formdecode = require "net.http".formdecode; +local st = require "util.stanza"; +local render_html_template = require"util.interpolation".new("%b{}", st.xml_escape); +local render_text_template = require"util.interpolation".new("%b{}", function (s) return s; end); +local mime = require "mime"; +local ltn12 = require "ltn12"; + +local site_name = module:get_option_string("site_name", module.host); + +-- Email templates +local email_template_preamble = "Problems viewing this email? View it online at {invite_page}"; +local email_template_text = assert(module:load_resource("email_templates/invite_email.txt")):read("*a"); +local email_template_html = assert(module:load_resource("email_templates/invite_email.html")):read("*a"); + +module:depends("http"); +module:depends("email"); +local invites = module:depends("invites"); + +local landing_page_template = assert(module:load_resource("html/index.html")):read("*a"); + +local landing_page = render_html_template(landing_page_template, { + site_name = site_name; +}); + +local function handle_form(event) + local request, response = event.request, event.response; + local form_data = http_formdecode(request.body); + + local email = form_data["email"]; + + response.headers.content_type = ""; + + local invite = invites.create_account(); + + local email_template_params = { + site_name = site_name; + invite_token = invite.token; + invite_uri = invite.uri; + invite_page = invite.landing_page; + }; + + local email_headers = { + Subject = "Your Snikket invitation"; + ["Content-Type"] = "multipart/mixed"; + }; + + local email_body = { + -- Optional text content prefixed to the entire email (visible even in + -- non-MIME clients) + preamble = render_text_template(email_template_preamble, email_template_params); + + -- Plain text version + [1] = { + headers = { + ["Content-Type"] = 'text/plain; charset="utf-8"'; + }; + body = mime.eol(0, render_text_template(email_template_text, email_template_params)); + }; + + -- HTML version + [2] = { + headers = { + ["content-type"] = 'text/html;charset="utf-8"', + ["content-transfer-encoding"] = "quoted-printable" + }, + body = ltn12.source.chain( + ltn12.source.string(render_html_template(email_template_html, email_template_params)), + ltn12.filter.chain( + mime.encode("quoted-printable", "text"), + mime.wrap() + ) + ); + }; + } + + + module:send_email({ --luacheck: ignore 143/module + to = email; + headers = email_headers; + body = email_body; + }); + + return render_html_template(landing_page_template, { + site_name = site_name; + message = "Ok! Check your inbox :)"; + }); +end + +module:provides("http", { + route = { + ["GET /"] = landing_page; + ["POST /invite-request"] = handle_form; + }; +}); diff --git a/snikket-modules/mod_update_check/mod_update_check.lua b/snikket-modules/mod_update_check/mod_update_check.lua new file mode 100644 index 0000000..719cfe9 --- /dev/null +++ b/snikket-modules/mod_update_check/mod_update_check.lua @@ -0,0 +1,64 @@ +local adns = require "net.adns"; +local r = adns.resolver(); + +local function dns_escape(input) + return (input:gsub("%W", "_")); +end +local render_hostname = require "util.interpolation".new("%b{}", dns_escape); + +local update_dns = module:get_option_string("update_check_dns"); +local check_interval = module:get_option_number("update_check_interval", 86400); + +local version_info = {}; + +do + local version = prosody.version; + local branch, bugfix = version:match("(%S+)%.(%d+)$"); + if branch then + version_info.branch, version_info.level = branch, bugfix; + end +end + +function check_for_updates() + r:lookup(function (records) + local result = {}; + for _, record in ipairs(records) do + local key, val = record.txt:match("(%S+)=(%S+)"); + if key then + result[key] = val; + end + end + module:fire_event("update-check/result", { result = result }); + end, render_hostname(update_dns, version_info), "TXT", "IN"); + return check_interval; +end + +function module.load() + module:add_timer(300, check_for_updates); +end + +module:hook("update-check/result", function (event) + local ver_secure = tonumber(event.result.secure); + local ver_latest = tonumber(event.result.latest); + local ver_installed = tonumber(version_info.level); + + if not ver_installed then + module:log_status("warn", "Unable to determine local version number"); + return; + end + + if ver_secure and ver_installed < ver_secure then + module:log_status("warn", "Security update available!"); + return; + end + + if ver_latest and ver_installed < ver_latest then + module:log_status("info", "Update available!"); + return; + end + + if event.result.support_status == "unsupported" then + module:log_status("warn", "%s is no longer supported", version_info.branch); + return; + end +end); diff --git a/snikket.conf.example b/snikket.conf.example new file mode 100644 index 0000000..e8c8624 --- /dev/null +++ b/snikket.conf.example @@ -0,0 +1,5 @@ +# The domain of your Snikket instance +SNIKKET_DOMAIN=example.com + +# The email address of the primary admin +SNIKKET_ADMIN_EMAIL=admin@example.com diff --git a/tools/smtp-url-to-msmtp.lua b/tools/smtp-url-to-msmtp.lua new file mode 100644 index 0000000..326650c --- /dev/null +++ b/tools/smtp-url-to-msmtp.lua @@ -0,0 +1,40 @@ +#!/usr/bin/lua + +local url = require "socket.url"; + +for smtp_url in io.lines() do + local parsed_url = url.parse(smtp_url); + print("# "..smtp_url); + for k, v in pairs(parsed_url) do print("# "..k.." = "..v) end + print "" + + local protocol = parsed_url.scheme or "smtp"; + local default_port = (protocol == "smtps" and 465) or 25; + + print("account default"); + print(("host %s"):format(parsed_url.host)); + print(("port %d"):format(parsed_url.port or default_port)); + + if parsed_url.params ~= "no-tls" then + local verify_cert = parsed_url.params ~= "insecure"; + + print("tls on"); + if verify_cert then + print("tls_trust_file /etc/ssl/certs/ca-certificates.crt"); + else + print("tls_trust_file"); -- empty disables trust verification + print("tls_certcheck off"); + end + if protocol == "smtps" then + print("tls_starttls off"); + end + end + + if parsed_url.user then + print("auth on"); + print(("user %s"):format(parsed_url.user)); + end + if parsed_url.password then + print(("password %s"):format(parsed_url.password)); + end +end diff --git a/www/android-chrome-192x192.png b/www/android-chrome-192x192.png new file mode 100644 index 0000000..6c01f9d Binary files /dev/null and b/www/android-chrome-192x192.png differ diff --git a/www/android-chrome-256x256.png b/www/android-chrome-256x256.png new file mode 100644 index 0000000..6fad754 Binary files /dev/null and b/www/android-chrome-256x256.png differ diff --git a/www/android-chrome-512x512.png b/www/android-chrome-512x512.png new file mode 100644 index 0000000..60303fb Binary files /dev/null and b/www/android-chrome-512x512.png differ diff --git a/www/apple-touch-icon.png b/www/apple-touch-icon.png new file mode 100644 index 0000000..90227c6 Binary files /dev/null and b/www/apple-touch-icon.png differ diff --git a/www/background.jpg b/www/background.jpg new file mode 100644 index 0000000..3202891 Binary files /dev/null and b/www/background.jpg differ diff --git a/www/browserconfig.xml b/www/browserconfig.xml new file mode 100644 index 0000000..249c5c1 --- /dev/null +++ b/www/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #ffc40d + + + diff --git a/www/favicon-16x16.png b/www/favicon-16x16.png new file mode 100644 index 0000000..ea70a19 Binary files /dev/null and b/www/favicon-16x16.png differ diff --git a/www/favicon-32x32.png b/www/favicon-32x32.png new file mode 100644 index 0000000..ddf5bde Binary files /dev/null and b/www/favicon-32x32.png differ diff --git a/www/favicon.ico b/www/favicon.ico new file mode 100644 index 0000000..6fc595c Binary files /dev/null and b/www/favicon.ico differ diff --git a/www/illus-bug.svg b/www/illus-bug.svg new file mode 100644 index 0000000..83f1b3b --- /dev/null +++ b/www/illus-bug.svg @@ -0,0 +1 @@ +bug fixing \ No newline at end of file diff --git a/www/illus-empty.svg b/www/illus-empty.svg new file mode 100644 index 0000000..7a96302 --- /dev/null +++ b/www/illus-empty.svg @@ -0,0 +1 @@ +empty \ No newline at end of file diff --git a/www/img/snikket-scan-button-shdw.png b/www/img/snikket-scan-button-shdw.png new file mode 100644 index 0000000..f06f6b7 Binary files /dev/null and b/www/img/snikket-scan-button-shdw.png differ diff --git a/www/mstile-150x150.png b/www/mstile-150x150.png new file mode 100644 index 0000000..689b5b3 Binary files /dev/null and b/www/mstile-150x150.png differ diff --git a/www/qrcode.min.js b/www/qrcode.min.js new file mode 100644 index 0000000..2ec2f64 --- /dev/null +++ b/www/qrcode.min.js @@ -0,0 +1,17 @@ +/* +The MIT License (MIT) +--------------------- +Copyright (c) 2012 davidshimjs + +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. +*/ +var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j=0?p.get(q):0}}for(var r=0,m=0;mm;m++)for(var j=0;jm;m++)for(var j=0;j=0;)b^=f.G15<=0;)b^=f.G18<>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;cf;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=[''],h=0;d>h;h++){g.push("");for(var i=0;d>i;i++)g.push('');g.push("")}g.push("
"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}(); diff --git a/www/safari-pinned-tab.svg b/www/safari-pinned-tab.svg new file mode 100644 index 0000000..91745ca --- /dev/null +++ b/www/safari-pinned-tab.svg @@ -0,0 +1,33 @@ + + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + + + + diff --git a/www/site.webmanifest b/www/site.webmanifest new file mode 100644 index 0000000..a3001dc --- /dev/null +++ b/www/site.webmanifest @@ -0,0 +1,18 @@ +{ + "name": "Snikket", + "short_name": "Snikket", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-256x256.png", + "sizes": "256x256", + "type": "image/png" + } + ], + "theme_color": "#fbfdff", + "background_color": "#fbfdff" +} diff --git a/www/snikket-logo.svg b/www/snikket-logo.svg new file mode 100644 index 0000000..8a1e25f --- /dev/null +++ b/www/snikket-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/www/snikket.css b/www/snikket.css new file mode 100644 index 0000000..e10e108 --- /dev/null +++ b/www/snikket.css @@ -0,0 +1,41 @@ +#background { + z-index: -1; + display: block; + width: 100%; + height: 100%; + background: url(/background.jpg) no-repeat center center fixed; + background-size: cover; + filter: blur(10px); + opacity: 0.5; + background-color: #ccc; +} + +#form { + margin-top: 100px; + opacity: 0.8; +} + +#form .card { + border-color: #4f9bcd; + border-width: 1px; + border-radius: 25px; +} + +#form .card h1 { + font-size: 1.8rem; +} + +#powered-by { + text-align: right; + margin-right: 15px; + font-size: 90%; + padding-top: 5px; +} + +#powered-by img { + height:1.5em; +} + +#form .account-details label { + font-weight: bold; +}