Introduce user & global archiving controls; add unregister function

- WARNING to operators: You MUST run fixup_2.sql in order to use this and newer
  versions of the bridge.
- Archiving and full chat history fetches are now a configuration setting (and
  default to off).
- Users now have to enable archiving manually by talking to the admin user, and
  are warned about the potential privacy implications.
- Users can now completely remove themselves from the bridge, deleting all data.
- Bridge administrators can now be specified by adding an entry to the
  'administrators' SQL table with your JID. These can force-unregister specific
  users.
This commit is contained in:
eta 2020-09-30 14:01:52 +01:00
parent 7e306420e6
commit d664cb800c
5 changed files with 208 additions and 37 deletions

View file

@ -23,20 +23,21 @@ additional caveats: take a look at the requirements list.
## What works? ## What works?
- Sending private messages/DMs both ways - Sending private messages/DMs both ways
- *Basic* support for MUCs - Support for MUCs
- Magically populating your roster using [XEP-0144: Roster Item Exchange](https://xmpp.org/extensions/xep-0144.html) - Magically populating your roster using [XEP-0144: Roster Item Exchange](https://xmpp.org/extensions/xep-0144.html)
- Downloading/decrypting media from WhatsApp and uploading it to your XEP-0363 server - Downloading/decrypting media from WhatsApp and uploading it to your XEP-0363 server
- Avatars - Avatars
- Read receipts - Read receipts
- Status text - Status text
- Typing notifications / chat state - Typing notifications / chat state
- [XEP-0313: Message Archive Management](https://xmpp.org/extensions/xep-0313.html) in MUCs *only when enabled in configuration*
- Fetching your entire message history from WhatsApp and making it available via MAM *only when enabled in configuration*
- Users joining and leaving MUCs, and the topic changing (partial, requires XMPP-side rejoin)
- Uploading images to WhatsApp natively
## What doesn't yet? ## What doesn't yet?
- [XEP-0313: Message Archive Management](https://xmpp.org/extensions/xep-0313.html) in MUCs (DMs should be done by your server) - Uploading non-image media to WhatsApp (currently, it just comes through as a link)
- Support for users joining and leaving MUCs
- Support for the topic changing in MUCs
- Uploading media to WhatsApp (currently, it just comes through as a link)
- Probably other stuff - Probably other stuff
## What you'll need ## What you'll need
@ -96,6 +97,27 @@ A few things to note here:
- The `shared_secret` is the same as the `component_secret`. - The `shared_secret` is the same as the `component_secret`.
- The `upload_component_name` is the name of the XEP-0363 HTTP Upload component. - The `upload_component_name` is the name of the XEP-0363 HTTP Upload component.
#### Enabling archiving and full history fetches
If you want to be able to use the MAM and full history fetch features, you'll need to run some additional commands in the above `sqlite3` window.
To let users use MAM:
```
sqlite> UPDATE configuration SET allow_archiving = true;
```
To let users fetch their WhatsApp history:
```
sqlite> UPDATE configuration SET allow_history_fetches = true;
```
**WARNING:** These options are NOT recommended for people wishing to run a public instance of the bridge. (In fact, if you're doing that, come talk to us in the support MUC first, as there are various things you probably want to be made aware of.)
Note that users must still enable archiving manually via talking to the admin user and executing the `enable-archiving` command (and similarly for history fetches, which use the `full-history-fetch` command).
### Step 3: run the bridge ### Step 3: run the bridge
You can build the Docker image yourself from the `Dockerfile` in the repo, or you can just You can build the Docker image yourself from the `Dockerfile` in the repo, or you can just

54
db.lisp
View file

@ -175,6 +175,60 @@
(with-bound-columns (xid) get-stmt (with-bound-columns (xid) get-stmt
xid)))) xid))))
(defun user-archiving-enabled-p (uid)
"Returns a generalized boolean for whether the user with ID UID has archiving enabled or not."
(with-prepared-statements
((get-stmt "SELECT enable_archiving FROM users WHERE id = ?"))
(bind-parameters get-stmt uid)
(when (sqlite:step-statement get-stmt)
(with-bound-columns (ena) get-stmt
(not (eql ena 0))))))
(defun user-set-archiving-state (uid enabled)
"Set the user's archiving state for the user with ID UID to ENABLED (either T or NIL)."
(let ((ena (if enabled 1 0)))
(with-prepared-statements
((set-stmt "UPDATE users SET enable_archiving = ? WHERE id = ?"))
(bind-parameters set-stmt ena uid)
(sqlite:step-statement set-stmt))))
(defun jid-admin-p (jid)
"Returns a generalized boolean for whether the JID is a bridge administrator."
(with-prepared-statements
((get-stmt "SELECT id FROM administrators WHERE jid = ?"))
(bind-parameters get-stmt jid)
(when (sqlite:step-statement get-stmt)
t)))
(defun db-unregister-user (uid)
"Unregister the user with ID UID."
(with-prepared-statements
((remove-user-stmt "DELETE FROM users WHERE id = ?")
(remove-contacts-stmt "DELETE FROM user_contacts WHERE user_id = ?")
(remove-messages-stmt "DELETE FROM user_messages WHERE user_id = ?")
(remove-chats-stmt "DELETE FROM user_chats WHERE user_id = ?")
(get-chats-stmt "SELECT id FROM user_chats WHERE user_id = ?")
(remove-chat-members-stmt "DELETE FROM user_chat_members WHERE chat_id = ?")
(remove-chat-joined-stmt "DELETE FROM user_chat_joined WHERE chat_id = ?")
(remove-chat-history-stmt "DELETE FROM user_chat_history WHERE user_id = ?"))
(with-transaction ()
(bind-parameters get-chats-stmt uid)
(loop
while (sqlite:step-statement get-chats-stmt)
do (with-bound-columns (chatid) get-chats-stmt
(loop
for stmt in (list remove-chat-members-stmt remove-chat-joined-stmt remove-chat-history-stmt)
do (progn
(sqlite:reset-statement stmt)
(bind-parameters stmt chatid)
(sqlite:step-statement stmt)))))
(loop
for stmt in (list remove-chats-stmt remove-messages-stmt remove-contacts-stmt remove-user-stmt)
do (progn
(sqlite:reset-statement stmt)
(bind-parameters stmt uid)
(sqlite:step-statement stmt))))))
(defun get-chat-history-ts (uid chat-id xmpp-id) (defun get-chat-history-ts (uid chat-id xmpp-id)
"Look up the UNIX timestamp for the given UID, CHAT-ID and XMPP-ID." "Look up the UNIX timestamp for the given UID, CHAT-ID and XMPP-ID."
(with-prepared-statements (with-prepared-statements

21
fixup_2.sql Normal file
View file

@ -0,0 +1,21 @@
BEGIN;
ALTER TABLE configuration ADD COLUMN allow_archiving BOOL NOT NULL DEFAULT false;
ALTER TABLE configuration ADD COLUMN allow_history_fetches BOOL NOT NULL DEFAULT false;
ALTER TABLE users ADD COLUMN enable_archiving BOOL NOT NULL DEFAULT false;
CREATE TABLE administrators (
id INTEGER PRIMARY KEY,
jid VARCHAR UNIQUE NOT NULL
);
CREATE TABLE user_chat_history (
id INTEGER PRIMARY KEY,
user_id INT NOT NULL REFERENCES users,
chat_id INT NOT NULL REFERENCES user_chats,
user_from VARCHAR NOT NULL,
ts_unix INT NOT NULL,
xmpp_id VARCHAR NOT NULL,
orig_id VARCHAR,
body VARCHAR NOT NULL,
oob_url VARCHAR
);
CREATE UNIQUE INDEX user_chat_history_unique ON user_chat_history (user_id, chat_id, xmpp_id);
COMMIT;

View file

@ -4,24 +4,28 @@ CREATE TABLE configuration (
port INT NOT NULL, port INT NOT NULL,
component_name VARCHAR NOT NULL, component_name VARCHAR NOT NULL,
shared_secret VARCHAR NOT NULL, shared_secret VARCHAR NOT NULL,
upload_component_name VARCHAR NOT NULL upload_component_name VARCHAR NOT NULL,
allow_archiving BOOL NOT NULL DEFAULT false,
allow_history_fetches BOOL NOT NULL DEFAULT false
); );
CREATE TABLE users ( CREATE TABLE users (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
jid VARCHAR UNIQUE NOT NULL, jid VARCHAR UNIQUE NOT NULL,
session_data VARCHAR session_data VARCHAR,
enable_archiving BOOL NOT NULL DEFAULT false
); );
CREATE TABLE user_contacts ( CREATE TABLE user_contacts (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
user_id INT NOT NULL REFERENCES users, user_id INT NOT NULL REFERENCES users,
wa_jid VARCHAR UNIQUE NOT NULL, wa_jid VARCHAR NOT NULL,
subscription_state VARCHAR NOT NULL DEFAULT 'none', subscription_state VARCHAR NOT NULL DEFAULT 'none',
avatar_url VARCHAR, avatar_url VARCHAR,
name VARCHAR, name VARCHAR,
notify VARCHAR, notify VARCHAR,
status VARCHAR status VARCHAR,
UNIQUE(user_id, wa_jid)
); );
CREATE TABLE user_messages ( CREATE TABLE user_messages (
@ -74,3 +78,8 @@ CREATE TABLE user_chat_history (
); );
CREATE UNIQUE INDEX user_chat_history_unique ON user_chat_history (user_id, chat_id, xmpp_id); CREATE UNIQUE INDEX user_chat_history_unique ON user_chat_history (user_id, chat_id, xmpp_id);
CREATE TABLE administrators (
id INTEGER PRIMARY KEY,
jid VARCHAR UNIQUE NOT NULL
);

View file

@ -2,6 +2,11 @@
(defparameter +version+ "0.0.1") (defparameter +version+ "0.0.1")
(defvar *archiving-enabled* nil
"T if archiving user messages is enabled, NIL otherwise")
(defvar *history-fetches-enabled* nil
"T if full history fetches are enabled, NIL otherwise")
(defclass whatsxmpp-component (xmpp-component) (defclass whatsxmpp-component (xmpp-component)
((whatsapps ((whatsapps
:initform (make-hash-table :test 'equal) :initform (make-hash-table :test 'equal)
@ -94,14 +99,22 @@
"** whatsxmpp, version ~A, a theta.eu.org project ** "** whatsxmpp, version ~A, a theta.eu.org project **
Commands: Commands:
- register: set up the bridge - register: set up the bridge
- unregister: log out of the bridge and wipe all bridge data (!)
- connect: manually connect to WhatsApp - connect: manually connect to WhatsApp
- stop: disconnect from WhatsApp, and disable automatic reconnections - stop: disconnect from WhatsApp, and disable automatic reconnections
- status: get your current status - status: get your current status
- getroster: trigger an XEP-0144 roster item exchange (in some clients, this'll pop up a window asking to add contacts to your roster) - getroster: trigger an XEP-0144 roster item exchange (in some clients, this'll pop up a window asking to add contacts to your roster)
- help: view this help text - help: view this help text
- refresh-chats: force the bridge to update member lists + subject for all of your group chats" - refresh-chats: force the bridge to update member lists + subject for all of your group chats
- enable-archiving: enable storage of *unencrypted* WhatsApp messages by the bridge
- disable-archiving: disable the above archiving setting
- full-history-fetch: fetch ALL historic messages from WhatsApp and store them in the bridge's archive"
+version+)) +version+))
(defparameter *admin-privileged-help-text*
"You may execute the following additional administrator commands:
- force-unregister USER: forcibly remove USER from the bridge")
(defparameter *reconnect-every-secs* 5 (defparameter *reconnect-every-secs* 5
"Interval between calls to WA-RESETUP-USERS.") "Interval between calls to WA-RESETUP-USERS.")
@ -377,7 +390,7 @@ WhatsXMPP represents users as u440123456789 and groups as g1234-5678."
for x-msg in messages for x-msg in messages
do (progn do (progn
(deliver-xmpp-message comp x-msg) (deliver-xmpp-message comp x-msg)
(when (uiop:string-prefix-p "g" (conversation x-msg)) (when (and *archiving-enabled* (user-archiving-enabled-p uid) (uiop:string-prefix-p "g" (conversation x-msg)))
(insert-xmpp-message x-msg)) (insert-xmpp-message x-msg))
(when (orig-id x-msg) (when (orig-id x-msg)
(insert-user-message uid (xmpp-id x-msg) (orig-id x-msg)))))))) (insert-user-message uid (xmpp-id x-msg) (orig-id x-msg))))))))
@ -658,6 +671,7 @@ Returns three values: avatar data (as two values), and a generalized boolean spe
(let* ((new-from (concatenate 'string orig-to "/" muc-resource)) (let* ((new-from (concatenate 'string orig-to "/" muc-resource))
(group-localpart (nth-value 1 (parse-jid orig-to))) (group-localpart (nth-value 1 (parse-jid orig-to)))
(recipients (get-user-chat-joined (get-user-id jid) group-localpart))) (recipients (get-user-chat-joined (get-user-id jid) group-localpart)))
(when (and *archiving-enabled* (user-archiving-enabled-p (get-user-id jid)))
;; FIXME: You can break the database's UNIQUE constraint here. ;; FIXME: You can break the database's UNIQUE constraint here.
(insert-xmpp-message (make-instance 'xmpp-message (insert-xmpp-message (make-instance 'xmpp-message
:conversation group-localpart :conversation group-localpart
@ -665,7 +679,7 @@ Returns three values: avatar data (as two values), and a generalized boolean spe
:from muc-resource :from muc-resource
:timestamp (local-time:now) :timestamp (local-time:now)
:xmpp-id orig-id :xmpp-id orig-id
:body orig-body)) :body orig-body)))
(loop (loop
for recip in recipients for recip in recipients
do (with-message (comp recip :from new-from :id orig-id :type "groupchat") do (with-message (comp recip :from new-from :id orig-id :type "groupchat")
@ -872,6 +886,18 @@ Returns three values: avatar data (as two values), and a generalized boolean spe
(admin-msg comp jid "(will retry)") (admin-msg comp jid "(will retry)")
(setf stored-conn nil))))))))) (setf stored-conn nil)))))))))
(defun unregister-user (comp uid)
"Unregister the user with id UID from the bridge."
(with-component-data-lock (comp)
(let* ((user-jid (get-user-jid uid))
(current-conn (gethash user-jid (component-whatsapps comp))))
(db-unregister-user uid)
(when current-conn
(ignore-errors
(whatscl::close-connection current-conn)))
(remhash user-jid (component-whatsapps comp))
(admin-msg comp user-jid "You have been unregistered from the bridge, and all data has been deleted. Have a nice day!"))))
(defun start-user-registration (comp jid) (defun start-user-registration (comp jid)
"Register the JID as wanting to use the bridge COMP." "Register the JID as wanting to use the bridge COMP."
(with-component-data-lock (comp) (with-component-data-lock (comp)
@ -941,10 +967,31 @@ Returns three values: avatar data (as two values), and a generalized boolean spe
(and (not uid) (equal body "register")) (and (not uid) (equal body "register"))
(and uid (equal body "register -force"))) (and uid (equal body "register -force")))
(start-user-registration comp stripped)) (start-user-registration comp stripped))
((equal body "unregister")
(reply "Are you sure? Unregistering will delete ALL information the bridge has about you, and is not reversible. If you still want to continue, execute the `unregister -force` command."))
((equal body "unregister -force")
(if uid
(unregister-user comp uid)
(reply "You're not registered with the bridge.")))
((equal body "help") ((equal body "help")
(reply *admin-help-text*)) (progn
(reply *admin-help-text*)
(when (jid-admin-p stripped)
(reply *admin-privileged-help-text*))))
((not uid) ((not uid)
(reply "You're not registered with this bridge. Try `register` or `help`.")) (reply "You're not registered with this bridge. Try `register` or `help`."))
((equal body "enable-archiving")
(reply "Are you sure you want to enable archiving? Doing this will store your WhatsApp messages, *unencrypted*, on the server of the bridge operator, where they can be easily read! If you still want to continue, execute the `enable-archiving -force` command."))
((equal body "enable-archiving -force")
(if *archiving-enabled*
(progn
(user-set-archiving-state uid t)
(reply "Archiving enabled."))
(reply "Archiving cannot be enabled on this bridge. Please contact the bridge administrator.")))
((equal body "disable-archiving")
(progn
(user-set-archiving-state uid nil)
(reply "Archiving disabled. WARNING: Message history is not cleared. To do that, unregister from the bridge entirely (deleting all data), and re-register.")))
((equal body "getroster") ((equal body "getroster")
(progn (progn
(do-roster-exchange comp stripped uid) (do-roster-exchange comp stripped uid)
@ -960,6 +1007,7 @@ Returns three values: avatar data (as two values), and a generalized boolean spe
(when conn (when conn
(whatscl::close-connection conn)))) (whatscl::close-connection conn))))
((equal body "full-history-fetch") ((equal body "full-history-fetch")
(if *history-fetches-enabled*
(let ((conn (gethash stripped (component-whatsapps comp)))) (let ((conn (gethash stripped (component-whatsapps comp))))
(if conn (if conn
(let ((chats (get-user-groupchats uid))) (let ((chats (get-user-groupchats uid)))
@ -974,7 +1022,8 @@ Returns three values: avatar data (as two values), and a generalized boolean spe
(do-chat-history-request comp conn stripped uid (whatsxmpp-localpart-to-wa-jid localpart))) (do-chat-history-request comp conn stripped uid (whatsxmpp-localpart-to-wa-jid localpart)))
(sleep 0.1)))) (sleep 0.1))))
:name "whatsxmpp chat history fetcher")) :name "whatsxmpp chat history fetcher"))
(reply "You're not connected to WhatsApp.")))) (reply "You're not connected to WhatsApp.")))
(reply "Full history fetching is not enabled for this bridge. Please contact the bridge administrator.")))
((equal body "refresh-chats") ((equal body "refresh-chats")
(let ((conn (gethash stripped (component-whatsapps comp)))) (let ((conn (gethash stripped (component-whatsapps comp))))
(if conn (if conn
@ -993,6 +1042,16 @@ Returns three values: avatar data (as two values), and a generalized boolean spe
(reply (format nil "Refreshing metadata for ~A..." localpart-to-use)) (reply (format nil "Refreshing metadata for ~A..." localpart-to-use))
(request-wa-chat-metadata comp conn stripped localpart-to-use)) (request-wa-chat-metadata comp conn stripped localpart-to-use))
(reply "You're not connected to WhatsApp.")))) (reply "You're not connected to WhatsApp."))))
((uiop:string-prefix-p "force-unregister " body)
(let ((jid-to-use (subseq body #.(length "force-unregister "))))
(if (jid-admin-p stripped)
(let ((uid-to-use (get-user-id jid-to-use)))
(if uid-to-use
(progn
(unregister-user comp uid-to-use)
(reply "User unregistered and data deleted."))
(reply "That user couldn't be found.")))
(reply "You are not a bridge administrator."))))
(t (t
(reply "Unknown command. Try `help` for a list of supported commands.")))))) (reply "Unknown command. Try `help` for a list of supported commands."))))))
@ -1363,13 +1422,19 @@ Returns three values: avatar data (as two values), and a generalized boolean spe
"Initialise the whatsxmpp bridge." "Initialise the whatsxmpp bridge."
(connect-database) (connect-database)
(with-prepared-statement (with-prepared-statement
(config "SELECT server, port, component_name, shared_secret, upload_component_name FROM configuration WHERE rev = 1") (config "SELECT server, port, component_name, shared_secret, upload_component_name, allow_archiving, allow_history_fetches FROM configuration WHERE rev = 1")
(assert (sqlite:step-statement config) () "No configuration in database!") (assert (sqlite:step-statement config) () "No configuration in database!")
(destructuring-bind (server port component-name shared-secret upload-name) (destructuring-bind (server port component-name shared-secret upload-name allow-archiving allow-history-fetches)
(column-values config) (column-values config)
(let* ((comp (make-component server port shared-secret component-name)) (let* ((comp (make-component server port shared-secret component-name))
(ret (change-class comp 'whatsxmpp-component (ret (change-class comp 'whatsxmpp-component
:upload-component-name upload-name))) :upload-component-name upload-name)))
(setf *archiving-enabled* (not (eql allow-archiving 0)))
(when *archiving-enabled*
(format *debug-io* "~&[!] WARNING: Archiving of user messages is enabled. If you're running a public bridge, this has potential privacy implications!~%"))
(setf *history-fetches-enabled* (not (eql allow-history-fetches 0)))
(when *history-fetches-enabled*
(format *debug-io* "~&[!] WARNING: Full history fetches are enabled. Letting any user initiate a full history fetch is a potential Denial of Service vector!~%"))
(on :text-message ret (lambda (&rest args) (on :text-message ret (lambda (&rest args)
(apply #'whatsxmpp-message-handler ret args))) (apply #'whatsxmpp-message-handler ret args)))
(on :message-marker ret (lambda (&rest args) (on :message-marker ret (lambda (&rest args)