Initial commit

This commit is contained in:
Matthew Wild 2020-01-31 13:46:46 +00:00
commit d6157c6a15
61 changed files with 2185 additions and 0 deletions

19
.github/workflows/build-image.yml vendored Normal file
View File

@ -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

6
.hg_archival.txt Normal file
View File

@ -0,0 +1,6 @@
repo: d8acfaad7a59f59c34c81f45af91e895bf15630b
node: 142c7becb8eab82558ebe4be9847ba50adec4032
branch: default
latesttag: null
latesttagdistance: 194
changessincelatesttag: 194

4
.hgignore Normal file
View File

@ -0,0 +1,4 @@
syntax: glob
snikket.conf
snikket.retry
.luacheckcache

6
Makefile Normal file
View File

@ -0,0 +1,6 @@
.PHONY: all docker
all: docker
docker:
docker build -t snikket -f docker/Dockerfile .

46
README.md Normal file
View File

@ -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.

19
ansible/files/bin/create-invite Executable file
View File

@ -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

View File

@ -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

7
ansible/files/msmtp.conf Normal file
View File

@ -0,0 +1,7 @@
account default
port 1025
tls off
host localhost
from snikket
auto_from off

View File

@ -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"

View File

@ -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

View File

@ -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

13
ansible/snikket.yml Normal file
View File

@ -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

16
ansible/tasks/certs.yml Normal file
View File

@ -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

15
ansible/tasks/cron.yml Normal file
View File

@ -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

32
ansible/tasks/mail.yml Normal file
View File

@ -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

113
ansible/tasks/prosody.yml Normal file
View File

@ -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

View File

@ -0,0 +1,7 @@
---
- name: "Add helper scripts"
copy:
src: "../files/bin/"
dest: "/usr/local/bin/"
mode: 0755

View File

@ -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"

View File

@ -0,0 +1 @@
snikket:{{ lookup('password', '/tmp/mailhog-password length=15')|password_hash("bcrypt", lookup('password', '/tmp/mailhog-salt length=21 chars=letters,digits')+".") }}

26
docker-compose.yml Normal file
View File

@ -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:

41
docker/Dockerfile Normal file
View File

@ -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"]

18
docker/entrypoint.sh Executable file
View File

@ -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

View File

@ -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",
}

View File

@ -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

View File

@ -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

View File

@ -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 });
}
});

View File

@ -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 });
}
});

View File

@ -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

View File

@ -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

View File

@ -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">&times;</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>

View File

@ -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>

View File

@ -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;
};
});

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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;
};
});

View File

@ -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>

View File

@ -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}

View File

@ -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>

View File

@ -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;
};
});

View File

@ -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);

5
snikket.conf.example Normal file
View File

@ -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

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
www/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
www/background.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

9
www/browserconfig.xml Normal file
View File

@ -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>

BIN
www/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
www/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
www/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

1
www/illus-bug.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 40 KiB

1
www/illus-empty.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
www/mstile-150x150.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

17
www/qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

33
www/safari-pinned-tab.svg Normal file
View File

@ -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

18
www/site.webmanifest Normal file
View File

@ -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"
}

1
www/snikket-logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.2 KiB

41
www/snikket.css Normal file
View File

@ -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;
}