Initial commit
|
@ -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
|
|
@ -0,0 +1,6 @@
|
|||
repo: d8acfaad7a59f59c34c81f45af91e895bf15630b
|
||||
node: 142c7becb8eab82558ebe4be9847ba50adec4032
|
||||
branch: default
|
||||
latesttag: null
|
||||
latesttagdistance: 194
|
||||
changessincelatesttag: 194
|
|
@ -0,0 +1,4 @@
|
|||
syntax: glob
|
||||
snikket.conf
|
||||
snikket.retry
|
||||
.luacheckcache
|
|
@ -0,0 +1,6 @@
|
|||
.PHONY: all docker
|
||||
|
||||
all: docker
|
||||
|
||||
docker:
|
||||
docker build -t snikket -f docker/Dockerfile .
|
|
@ -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.
|
|
@ -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
|
||||
|
|
@ -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
|
|
@ -0,0 +1,7 @@
|
|||
account default
|
||||
|
||||
port 1025
|
||||
tls off
|
||||
host localhost
|
||||
from snikket
|
||||
auto_from off
|
|
@ -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"
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
|
||||
- name: "Add helper scripts"
|
||||
copy:
|
||||
src: "../files/bin/"
|
||||
dest: "/usr/local/bin/"
|
||||
mode: 0755
|
|
@ -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"
|
|
@ -0,0 +1 @@
|
|||
snikket:{{ lookup('password', '/tmp/mailhog-password length=15')|password_hash("bcrypt", lookup('password', '/tmp/mailhog-salt length=21 chars=letters,digits')+".") }}
|
|
@ -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:
|
|
@ -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"]
|
|
@ -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
|
|
@ -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",
|
||||
}
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
|
@ -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 });
|
||||
}
|
||||
});
|
|
@ -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
|
|
@ -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 "<unknown>");
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,131 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Invite to {site_name} | Snikket</title>
|
||||
<link rel="stylesheet" href="/share/bootstrap4/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/snikket.css">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#fbd308">
|
||||
<meta name="theme-color" content="#fbd308">
|
||||
<style>
|
||||
#install-buttons-container {
|
||||
text-align: center;
|
||||
}
|
||||
#install-buttons-container img {
|
||||
height: 3.5em;
|
||||
margin: 0 auto 0.5em auto;
|
||||
}
|
||||
button {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="background" class="fixed-top overflow-hidden" aria-role="none presentation"></div>
|
||||
<div id="form" class="container col-md-8 col-md-offset-2 col-sm-8 cold-sm-offset-2 col-lg-6 col-lg-offset-3 mt-2 mt-md-5">
|
||||
<div class="card rounded-lg shadow">
|
||||
<h1 class="card-header rounded-lg rounded-lg">
|
||||
Invite to {site_name}<br/>
|
||||
</h1>
|
||||
<div id="powered-by">Powered by <img src="/snikket-logo.svg"></div>
|
||||
<div class="card-body" >
|
||||
{inviter?<p>You have been invited to chat on {site_name} using Snikket,
|
||||
a secure, privacy-friendly chat app.</p>}
|
||||
|
||||
{inviter&<p>You have been invited to chat with {inviter} using Snikket,
|
||||
a secure, privacy-friendly chat app on {site_name}.</p>}
|
||||
|
||||
<h5 class="card-title">Get started</h5>
|
||||
|
||||
<p>Install the Snikket app on your Android device (iOS <a href="https://snikket.org/faq/#is-there-an-ios-app">coming soon!</a>)</p>
|
||||
|
||||
<div id="install-buttons-container" class="container">
|
||||
<a href='https://play.google.com/store/apps/details?id=org.snikket.android&referrer={uri|urlescape}&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'>
|
||||
<img alt='Get it on Google Play' src='https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png'/>
|
||||
</a>
|
||||
<a href="#qr-modal" class="d-none" id="qr-modal-show">
|
||||
<button class="btn btn-info" title="Send this invite to your device"
|
||||
data-toggle="modal" data-target="#qr-modal">Not on mobile?</button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<p>After installation the app should automatically open and prompt you to
|
||||
create an account. If not, simply click the button below.</p>
|
||||
|
||||
<h6 class="text-center">App already installed?</h6>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="{uri}" id="uri-cta"><button class="btn btn-secondary btn-sm">Open the app</button></a><br/>
|
||||
<small class="text-muted">This button works only if you have the app installed already!</small>
|
||||
</div>
|
||||
<br/>
|
||||
<h5>Alternatives</h5>
|
||||
<p>You can connect to Snikket using any XMPP-compatible software. If the button above does not
|
||||
work with your app, you may need to <a href="register?{token}">register an account manually</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" tabindex="-1" role="dialog" id="qr-modal">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Scan invite code</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>You can transfer this invite to your mobile device by scanning a code with your camera. You can use
|
||||
either a QR scanner app or the Snikket app itself.</p>
|
||||
<nav>
|
||||
<div class="nav nav-tabs" id="nav-tab" role="tablist">
|
||||
<a class="nav-item nav-link active" id="qr-tab-scanner" data-toggle="tab" href="#qr-info-url" role="tab" aria-controls="qr-info-url" aria-selected="true">Using a QR code scanner</a>
|
||||
<a class="nav-item nav-link" id="qr-tab-app" data-toggle="tab" href="#qr-info-uri" role="tab" aria-controls="qr-info-uri" aria-selected="false">Using the Snikket app</a>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="tab-content">
|
||||
<div id="qr-info-url" class="tab-pane show active">
|
||||
<p>Use a <em>QR code</em> scanner on your mobile device to scan the code below:</p>
|
||||
<div id="qr-invite-page" class="w-50 p-1 mx-auto"></div>
|
||||
</div>
|
||||
<div id="qr-info-uri" class="tab-pane">
|
||||
<div>
|
||||
<img src="/img/snikket-scan-button-shdw.png" class="d-block w-25 p-1 float-right">
|
||||
<p>Install the Snikket app on your mobile device, open it, and
|
||||
tap the 'Scan' button at the top.</p>
|
||||
<p>Your camera will turn on. Point it at the square code below until it is
|
||||
within the highlighted square on your screen, and wait until the app
|
||||
recognises it.</p>
|
||||
</div>
|
||||
<div id="qr-uri" class="w-50 p-1 mx-auto clearfix"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" data-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/share/jquery/jquery.min.js"></script>
|
||||
<script src="/share/bootstrap4/js/bootstrap.min.js"></script>
|
||||
<script src="/qrcode.min.js"></script>
|
||||
<script type="text/javascript">
|
||||
$('#qr-modal').one('show.bs.modal', function (e) {
|
||||
new QRCode(document.getElementById("qr-uri"), document.getElementById("uri-cta").getAttribute("href"));
|
||||
new QRCode(document.getElementById("qr-invite-page"), document.location.href);
|
||||
});
|
||||
$('#qr-modal-show').addClass("d-md-block");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,37 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Invite to {site_name} | Snikket</title>
|
||||
<link rel="stylesheet" href="/share/bootstrap4/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/snikket.css">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#fbd308">
|
||||
<meta name="theme-color" content="#fbd308">
|
||||
</head>
|
||||
<body>
|
||||
<div id="background" class="fixed-top overflow-hidden" aria-role="none presentation"></div>
|
||||
<div id="form" class="container col-md-8 col-md-offset-2 col-sm-8 cold-sm-offset-2 col-lg-6 col-lg-offset-3 mt-2 mt-md-5">
|
||||
<div class="card rounded-lg shadow">
|
||||
<h1 class="card-header rounded-lg rounded-lg">
|
||||
Invite to {site_name}<br/>
|
||||
</h1>
|
||||
<div id="powered-by">Powered by <img src="/snikket-logo.svg"></div>
|
||||
<div class="card-body" >
|
||||
<h5 class="card-title">Invite expired</h5>
|
||||
|
||||
<p>Sorry, it looks like this invite code has expired!</p>
|
||||
|
||||
<img class="w-100" src="/illus-empty.svg">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/share/jquery/jquery.min.js"></script>
|
||||
<script src="/share/bootstrap4/js/bootstrap.min.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -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;
|
||||
};
|
||||
});
|
|
@ -0,0 +1,87 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{site_name} | Snikket</title>
|
||||
<link rel="stylesheet" href="/share/bootstrap4/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/snikket.css">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#fbd308">
|
||||
<meta name="theme-color" content="#fbd308">
|
||||
</head>
|
||||
<body>
|
||||
<div id="background" class="fixed-top overflow-hidden" aria-role="none presentation"></div>
|
||||
<div id="form" class="container col-md-8 col-md-offset-2 col-sm-8 cold-sm-offset-2 col-lg-6 col-lg-offset-3 mt-2 mt-md-5">
|
||||
<div class="card rounded-lg shadow">
|
||||
<h1 class="card-header rounded-lg rounded-lg">
|
||||
Secure communication on {site_name}<br/>
|
||||
</h1>
|
||||
<div id="powered-by">Powered by <img src="/snikket-logo.svg"></div>
|
||||
<div class="card-body" >
|
||||
<p>{site_name} is using Snikket - a secure, privacy-friendly chat app.</p>
|
||||
|
||||
<h5 class="card-title">Create an account</h5>
|
||||
|
||||
<p>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:</p>
|
||||
|
||||
<h6 class="text-center">App already installed?</h6>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="{uri}"><button class="btn btn-secondary btn-sm">Open the app</button></a><br/>
|
||||
<small class="text-muted">This button works only if you have the app installed already!</small>
|
||||
</div>
|
||||
<br/>
|
||||
|
||||
<h5 class="card-title">Create an account online</h5>
|
||||
<p>If you plan to use a legacy XMPP client, you can register an account online and enter your
|
||||
credentials into any XMPP-compatible software.</p>
|
||||
|
||||
{message&<div class="alert {msg_class?alert-info}" role="alert">
|
||||
{message}
|
||||
</div>}
|
||||
|
||||
<form method="post">
|
||||
<div class="form-group form-row">
|
||||
<label for="user" class="col-md-4 col-lg-12 col-form-label">Username:</label>
|
||||
<div class="col-md-8 col-lg-12">
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text" name="user" class="form-control" aria-describedby="usernameHelp"
|
||||
required autofocus minlength="1" maxlength="30" length="30"
|
||||
>
|
||||
<div class="input-group-append">
|
||||
<span class="input-group-text">@{domain}</span>
|
||||
</div>
|
||||
</div>
|
||||
<small id="usernameHelp" class="d-block form-text text-muted">Choose a username, this will become the first part of your new chat address.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group form-row">
|
||||
<label for="password" class="col-md-4 col-lg-12 col-form-label">Password:</label>
|
||||
<div class="col-md-8 col-lg-12">
|
||||
<input type="password" name="password" class="form-control" aria-describedby="passwordHelp"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
<small id="passwordHelp" class="form-text text-muted">Enter a secure password that you do not use anywhere else.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group form-row">
|
||||
<input type="hidden" name="token" value="{token}">
|
||||
<button type="submit" class="btn btn-primary btn-lg">Submit</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/share/jquery/jquery.min.js"></script>
|
||||
<script src="/share/bootstrap4/js/bootstrap.min.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,37 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Invite to {site_name} | Snikket</title>
|
||||
<link rel="stylesheet" href="/share/bootstrap4/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/snikket.css">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#fbd308">
|
||||
<meta name="theme-color" content="#fbd308">
|
||||
</head>
|
||||
<body>
|
||||
<div id="background" class="fixed-top overflow-hidden" aria-role="none presentation"></div>
|
||||
<div id="form" class="container col-md-8 col-md-offset-2 col-sm-8 cold-sm-offset-2 col-lg-6 col-lg-offset-3 mt-2 mt-md-5">
|
||||
<div class="card rounded-lg shadow">
|
||||
<h1 class="card-header rounded-lg rounded-lg">
|
||||
Invite to {site_name}<br/>
|
||||
</h1>
|
||||
<div id="powered-by">Powered by <img src="/snikket-logo.svg"></div>
|
||||
<div class="card-body" >
|
||||
<h5 class="card-title">Registration error</h5>
|
||||
|
||||
<p>{message?Sorry, there was a problem registering your account.}</p>
|
||||
|
||||
<img class="w-100" src="/illus-bug.svg">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/share/jquery/jquery.min.js"></script>
|
||||
<script src="/share/bootstrap4/js/bootstrap.min.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,79 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Invite to {site_name} | Snikket</title>
|
||||
<link rel="stylesheet" href="/share/bootstrap4/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/snikket.css">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#fbd308">
|
||||
<meta name="theme-color" content="#fbd308">
|
||||
|
||||
<script>
|
||||
function toggle_password(e) {
|
||||
var button = e.target;
|
||||
var input = button.parentNode.parentNode.querySelector("input");
|
||||
switch(input.attributes.type.value) {
|
||||
case "password":
|
||||
input.attributes.type.value = "text";
|
||||
button.innerText = "Hide";
|
||||
break;
|
||||
case "text":
|
||||
input.attributes.type.value = "password";
|
||||
button.innerText = "Show";
|
||||
break;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="background" class="fixed-top overflow-hidden" aria-role="none presentation"></div>
|
||||
<div id="form" class="container col-md-8 col-md-offset-2 col-sm-8 cold-sm-offset-2 col-lg-6 col-lg-offset-3 mt-2 mt-md-5">
|
||||
<div class="card rounded-lg shadow">
|
||||
<h1 class="card-header rounded-lg rounded-lg">
|
||||
{site_name}<br/>
|
||||
</h1>
|
||||
<div id="powered-by">Powered by <img src="/snikket-logo.svg"></div>
|
||||
<div class="card-body" >
|
||||
<h5 class="card-title">Congratulations!</h5>
|
||||
|
||||
<p>You have created an account on {site_name}.</p>
|
||||
|
||||
<p>To start chatting, you need to enter your new account
|
||||
credentials into your chosen XMPP software.</p>
|
||||
|
||||
<p>As a final reminder, your account details are shown below:</p>
|
||||
|
||||
<form class="account-details col-12 col-lg-6 mx-auto">
|
||||
<div class="form-group form-row">
|
||||
<label for="user" class="col-md-4 col-lg-12 col-form-label">Chat address (JID):</label>
|
||||
<div class="col-md-8 col-lg-12">
|
||||
<input type="text" class="form-control-plaintext" readonly value="{username}@{domain}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group form-row">
|
||||
<label for="password" class="col-md-4 col-lg-12 col-form-label">Password:</label>
|
||||
<div class="col-md-8 col-lg-12">
|
||||
<div class="input-group">
|
||||
<input type="password" readonly class="form-control" value="{password}">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="toggle_password(event)">Show</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p>Your password is stored encrypted on the server and will not be accessible after you close this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/share/jquery/jquery.min.js"></script>
|
||||
<script src="/share/bootstrap4/js/bootstrap.min.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -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;
|
||||
};
|
||||
});
|
|
@ -0,0 +1,11 @@
|
|||
<html>
|
||||
<body>
|
||||
TODO!
|
||||
|
||||
Hello from <strong>{site_name}!</strong>
|
||||
|
||||
You have been invited to join {site_name}. Simply click here to get started: <a href="{invite_page}">view invite</a>
|
||||
|
||||
If you already have a compatible app installed, you can click here instead: <a href="{invite_uri}">open in app</a>
|
||||
</body>
|
||||
</html>
|
|
@ -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}
|
|
@ -0,0 +1,63 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{site_name} | Snikket</title>
|
||||
<link rel="stylesheet" href="/share/bootstrap4/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/snikket.css">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#fbd308">
|
||||
<meta name="theme-color" content="#fbd308">
|
||||
</head>
|
||||
<body>
|
||||
<div id="background" class="fixed-top overflow-hidden" aria-role="none presentation"></div>
|
||||
<div id="form" class="container col-md-8 col-md-offset-2 col-sm-8 cold-sm-offset-2 col-lg-6 col-lg-offset-3 mt-2 mt-md-5">
|
||||
<div class="card rounded-lg shadow">
|
||||
<h1 class="card-header rounded-lg rounded-lg">
|
||||
Secure communication on {site_name}<br/>
|
||||
</h1>
|
||||
<div id="powered-by"><a href="https://snikket.org/">Powered by <img src="/snikket-logo.svg"></a></div>
|
||||
<div class="card-body" >
|
||||
<p>{site_name} is using Snikket - a secure, privacy-friendly chat app.</p>
|
||||
|
||||
{allow_email&
|
||||
<h5 class="card-title">Request invitation</h5>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<form action="/invite-request" method="post">
|
||||
<div class="form-group form-row">
|
||||
<label for="user" class="col-md-4 col-lg-12 col-form-label">Email:</label>
|
||||
<div class="col-md-8 col-lg-12">
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="email" name="email" class="form-control" aria-describedby="emailHelp"
|
||||
required autofocus minlength="3" length="30"
|
||||
>
|
||||
</div>
|
||||
<small id="emailHelp" class="d-block form-text text-muted">
|
||||
Enter the email address we should deliver your invitation to.
|
||||
<br/>
|
||||
Your email address will be kept private as per our <a href="privacy/">Privacy Policy</a>.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group form-row float-right">
|
||||
<button type="submit" class="btn btn-primary btn-lg">Submit</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/share/jquery/jquery.min.js"></script>
|
||||
<script src="/share/bootstrap4/js/bootstrap.min.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -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;
|
||||
};
|
||||
});
|
|
@ -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);
|
|
@ -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
|
|
@ -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
|
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 209 KiB |
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/mstile-150x150.png"/>
|
||||
<TileColor>#ffc40d</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
After Width: | Height: | Size: 1.6 KiB |
After Width: | Height: | Size: 2.6 KiB |
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 40 KiB |
After Width: | Height: | Size: 34 KiB |
After Width: | Height: | Size: 7.1 KiB |
After Width: | Height: | Size: 14 KiB |
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="260.000000pt" height="260.000000pt" viewBox="0 0 260.000000 260.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.11, written by Peter Selinger 2001-2013
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,260.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M1169 2496 c-2 -2 -24 -6 -49 -9 -51 -6 -196 -42 -237 -59 -360 -146
|
||||
-607 -401 -723 -748 -49 -143 -62 -230 -62 -386 0 -177 27 -297 109 -498 115
|
||||
-282 109 -516 -18 -660 l-30 -35 38 6 c151 22 316 93 400 172 12 12 25 21 28
|
||||
21 3 0 41 -20 83 -45 181 -104 366 -152 592 -153 133 0 240 16 362 54 84 27
|
||||
228 92 228 103 0 4 -14 15 -31 25 -254 150 -478 469 -534 761 -5 22 -10 47
|
||||
-11 55 -21 97 -21 303 1 409 58 293 200 534 420 716 33 28 81 64 108 80 26 17
|
||||
47 33 47 36 0 3 -39 24 -87 47 -86 40 -217 83 -298 97 -46 7 -329 17 -336 11z
|
||||
m-117 -379 c90 -53 140 -137 140 -237 0 -231 -271 -353 -445 -199 -62 54 -89
|
||||
113 -90 197 -1 125 70 223 193 263 49 16 155 4 202 -24z"/>
|
||||
<path d="M847 2029 c-56 -29 -96 -101 -91 -161 12 -128 153 -196 261 -124 37
|
||||
24 73 83 73 118 0 23 -4 25 -45 24 -75 -2 -108 46 -93 139 2 17 -3 20 -33 21
|
||||
-20 1 -52 -7 -72 -17z"/>
|
||||
<path d="M1885 2218 c-136 -100 -265 -251 -340 -398 -40 -80 -92 -223 -100
|
||||
-281 -4 -24 -8 -46 -10 -49 -2 -3 -6 -35 -9 -71 l-6 -65 33 -1 c125 -3 325
|
||||
-57 461 -124 77 -39 178 -101 204 -126 7 -7 17 -13 20 -13 14 0 178 -166 216
|
||||
-219 39 -53 39 -53 52 -30 18 32 63 173 75 234 71 364 -31 723 -281 997 -64
|
||||
70 -219 198 -238 198 -4 0 -38 -23 -77 -52z"/>
|
||||
<path d="M1418 1245 c-7 -8 17 -171 37 -244 9 -35 28 -93 42 -128 l26 -64 41
|
||||
7 c136 22 368 118 446 182 l31 27 -50 33 c-146 94 -330 161 -491 178 -36 3
|
||||
-68 8 -71 10 -3 2 -8 1 -11 -1z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.8 KiB |
|
@ -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"
|
||||
}
|
After Width: | Height: | Size: 8.2 KiB |
|
@ -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;
|
||||
}
|