diff --git a/Dockerfile b/Dockerfile
index 4657575753dd61cfdf5457aeae04baea430512f1..3f16218e32ade3d05b6c261e308532b932acfa95 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,13 +1,12 @@
-FROM python:3-alpine3.9
+# Use Debian-based image for faster build
+# https://pythonspeed.com/articles/alpine-docker-python/
+FROM python:3-slim-buster
WORKDIR /usr/src/app
COPY app/requirements.txt .
-RUN apk add --no-cache openssl \
- && apk add --no-cache --virtual build-deps build-base libffi-dev openssl-dev \
- && pip install --no-cache-dir -r requirements.txt \
- && apk del build-deps
+RUN pip install --no-cache-dir -r requirements.txt
COPY app/ .
diff --git a/README.md b/README.md
index d5bdf930a62e9309ce02938ef1631c4d2467e55c..a6207abfa1d7ad29c078cd851733885c9dbdb4cd 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,11 @@ values via Nextcloud.
- POSTFIXADMIN_SSH_PASSWORD=${POSTFIXADMIN_SSH_PASSWORD}
- DOMAIN=${DOMAIN}
- CREATE_ACCOUNT_PASSWORD=${CREATE_ACCOUNT_PASSWORD}
+ - SMTP_HOST=${SMTP_HOST}
+ - SMTP_FROM=${SMTP_FROM}
+ - SMTP_PASSWORD=${SMTP_PASSWORD}
+ volumes:
+ - /mnt/repo-base/volumes/create-account:/data
networks:
- serverbase
depends_on:
diff --git a/app/create_account.py b/app/create_account.py
new file mode 100644
index 0000000000000000000000000000000000000000..204d45c9faf22c5456eec51a4a45acd2c061f517
--- /dev/null
+++ b/app/create_account.py
@@ -0,0 +1,93 @@
+#!/usr/bin/python3
+
+import os
+import sys
+import json
+import shlex
+from threading import Thread
+import logging
+import requests
+from requests.auth import HTTPBasicAuth
+import paramiko
+from pyramid.response import Response
+from pyramid.request import Request
+
+ch = logging.StreamHandler(sys.stdout)
+ch.setLevel(logging.DEBUG)
+logging.getLogger('create-account').addHandler(ch)
+paramiko.util.get_logger("paramiko").addHandler(ch)
+
+
+def create_account(request: Request) -> Response:
+ if request.params['auth'] != os.environ['CREATE_ACCOUNT_PASSWORD']:
+ data = json.dumps({'success': False, 'message': 'no_auth'})
+ return respond(401, data)
+
+ print(f'Creating account for {request.params["target_email"]}, displayname='
+ f'{request.params["displayname"]}, fallback email={request.params["fallback_email"]}')
+
+ return create_account_perform(request.params['target_email'], request.params['password'],
+ request.params['password_confirm'], request.params['displayname'],
+ request.params['email_quota'], request.params['fallback_email'],
+ request.params['nextcloud_quota'])
+
+
+def create_account_perform(target_email: str, password: str, password_confirm: str,
+ displayname: str, email_quota: str, fallback_email: str,
+ nextcloud_quota: str) -> Response:
+ target_username = target_email.split('@')[0]
+ with open('forbidden_usernames') as f:
+ if target_username in f.read().splitlines():
+ data = json.dumps({'success': False, 'message': 'username_forbidden'})
+ return respond(403, data)
+ # create account via postfixadmin ssh
+ with paramiko.SSHClient() as ssh:
+ try:
+ ssh.set_missing_host_key_policy(paramiko.MissingHostKeyPolicy)
+ ssh.connect(hostname='postfixadmin', username='pfexec', password=os.environ['POSTFIXADMIN_SSH_PASSWORD'])
+
+ stdin, stdout, stderr = ssh.exec_command(
+ f'/postfixadmin/scripts/postfixadmin-cli mailbox view {shlex.quote(target_email)}')
+ if b"error: the email is not valid!" not in stderr.read().lower():
+ data = json.dumps({'success': False, 'message': 'username_taken'})
+ return respond(403, data)
+
+ stdin, stdout, stderr = ssh.exec_command(
+ f'/postfixadmin/scripts/postfixadmin-cli mailbox add {shlex.quote(target_email)} ' +
+ f'--password {shlex.quote(password)} --password2 {shlex.quote(password_confirm)} ' +
+ f'--name {shlex.quote(displayname)} --quota {shlex.quote(email_quota)} --active 1 ' +
+ f'--welcome-mail 0 --email-other {shlex.quote(fallback_email)}')
+ print(stdout.read())
+ print(stderr.read())
+ except (ssh.SSHException, ssh.AuthenticationException, ssh.socket.error, ssh.BadHostKeyException) as e:
+ print(e)
+ data = json.dumps({'success': False, 'message': 'internal_error'})
+ return respond(500, data)
+
+ # Run Nextcloud API call in seperate thread. Needed because the API calls take 50+ seconds
+ # each for unknown reasons. Instead we just run them in the background.
+ Thread(target=call_nextcloud_api, args=(target_email, password, fallback_email, nextcloud_quota)).start()
+
+ return respond(200, json.dumps({'success': True}))
+
+
+def respond(status: int, message: str) -> Response:
+ print(f'sending response status {status}, message {message}')
+ return Response(status=status, body=message)
+
+
+def call_nextcloud_api(target_email: str, password: str, fallback_email: str, nextcloud_quota: str):
+ # Nextcloud only creates external accounts after the first login. We login through the API
+ # to trigger the account creation.
+ headers = {'OCS-APIRequest': 'true'}
+ auth = HTTPBasicAuth(target_email, password)
+ url = f'https://{os.environ["DOMAIN"]}/ocs/v1.php/cloud/users/{target_email}'
+ requests.put(url, headers=headers, auth=auth)
+
+ # Edit nextcloud account, set quota and email
+ auth = HTTPBasicAuth(os.environ['NEXTCLOUD_ADMIN_USER'], os.environ['NEXTCLOUD_ADMIN_PASSWORD'])
+ url = f'https://{os.environ["DOMAIN"]}/ocs/v1.php/cloud/users/{target_email.lower()}'
+ r1 = requests.put(url, data={'key': 'email', 'value': fallback_email}, headers=headers, auth=auth)
+ r2 = requests.put(url, data={'key': 'quota', 'value': nextcloud_quota}, headers=headers, auth=auth)
+ print(r1.text)
+ print(r2.text)
diff --git a/app/delete-account.html b/app/delete-account.html
new file mode 100644
index 0000000000000000000000000000000000000000..b3e78e2fc7e560a67f030daf319bee9ad46f014f
--- /dev/null
+++ b/app/delete-account.html
@@ -0,0 +1,14 @@
+
+
+ Delete /e/ account
+
+
+
+
+
diff --git a/app/delete_account.py b/app/delete_account.py
new file mode 100644
index 0000000000000000000000000000000000000000..e8979b9e1aaff662165b65c5cd4688516226f57d
--- /dev/null
+++ b/app/delete_account.py
@@ -0,0 +1,120 @@
+#!/usr/bin/python3
+
+import os
+import time
+import shlex
+import random
+import string
+import sqlite3
+from sqlite3 import Error, Connection
+import smtplib
+from xml.dom import minidom
+import requests
+from requests.auth import HTTPBasicAuth
+import paramiko
+from pyramid.response import Response, FileResponse
+from pyramid.request import Request
+
+
+SQLITE_DATABASE = r"/data/database.sqlite"
+CONFIRMATION_LINK_VALIDITY_DURATION_SECONDS = 24 * 60 * 60
+
+
+def create_connection() -> Connection:
+ try:
+ conn = sqlite3.connect(SQLITE_DATABASE)
+ conn.execute("""CREATE TABLE IF NOT EXISTS confirm_delete_codes (
+ id INTEGER PRIMARY KEY,
+ user_email TEXT NOT NULL,
+ confirmation_code TEXT NOT NULL,
+ creation_timestamp INTEGER NOT NULL
+ ); """)
+ return conn
+ except Error as e:
+ print(e)
+
+
+def delete_account(request: Request) -> Response:
+ return FileResponse(path='delete-account.html', content_type='text/html')
+
+
+def delete_account_form(request: Request) -> Response:
+ email = request.params['email']
+ password = request.params['password']
+ url = f'https://{os.environ["DOMAIN"]}/ocs/v1.php/cloud/users/{email}'
+ r = requests.get(url, headers={'OCS-APIRequest': 'true'}, auth=HTTPBasicAuth(email, password))
+ xmldoc = minidom.parseString(r.text)
+ if xmldoc.getElementsByTagName('statuscode')[0].lastChild.nodeValue == '100':
+ confirmation_code = ''.join(random.choice(string.ascii_lowercase) for i in range(20))
+ conn = create_connection()
+ conn.execute(
+ "INSERT INTO confirm_delete_codes(user_email, confirmation_code, creation_timestamp) VALUES (?, ?, ?)",
+ (email, confirmation_code, int(time.time())))
+ conn.commit()
+ conn.close()
+ confirmation_link = f'https://{os.environ["DOMAIN"]}/confirm-delete-account?email={email}&confirmation_code={confirmation_code}'
+ print('confirmation_link=' + confirmation_link)
+ fallback_email = xmldoc.getElementsByTagName('email')[0].lastChild.nodeValue
+ return send_confirmation_email(fallback_email, confirmation_link)
+ else:
+ return Response(status=403, body="Invalid email or password")
+
+
+def send_confirmation_email(email: str, confirmation_link: str):
+ sender = os.environ['SMTP_FROM']
+ message = f"""From: {sender}
+ To: {email}
+ Subject: /e/ account deletion
+
+ Please click the following link to delete your account. If you do not want to delete your account,
+ ignore this email.
+
+ {confirmation_link}
+ """
+ try:
+ smtp = smtplib.SMTP(os.environ['SMTP_HOST'])
+ smtp.connect(os.environ['SMTP_HOST'])
+ smtp.ehlo()
+ smtp.starttls()
+ smtp.ehlo()
+ smtp.login(user=sender, password=os.environ['SMTP_PASSWORD'])
+ smtp.sendmail(sender, [email], message)
+ smtp.quit()
+ return Response(status=200, body=f'Sent confirmation email to {email}')
+ except smtplib.SMTPException as e:
+ print(e)
+ return Response(status=500, body=f'Failed to send confirmation email')
+
+
+def confirm_delete_account(request: Request) -> Response:
+ email = request.params['email']
+ confirmation_code = request.params['confirmation_code']
+ conn = create_connection()
+ c = conn.execute(
+ "SELECT EXISTS(SELECT 1 FROM confirm_delete_codes WHERE user_email=? AND confirmation_code=? AND creation_timestamp>?)",
+ (email, confirmation_code, int(time.time()) - CONFIRMATION_LINK_VALIDITY_DURATION_SECONDS))
+ exists = c.fetchone()[0]
+ conn.close()
+ if exists == 1:
+ auth = HTTPBasicAuth(os.environ['NEXTCLOUD_ADMIN_USER'], os.environ['NEXTCLOUD_ADMIN_PASSWORD'])
+ url = f'https://{os.environ["DOMAIN"]}/ocs/v1.php/cloud/users/{email.lower()}'
+ r1 = requests.delete(url, headers={'OCS-APIRequest': 'true'}, auth=auth)
+ print(r1.text)
+ xmldoc = minidom.parseString(r1.text)
+ if xmldoc.getElementsByTagName('statuscode')[0].lastChild.nodeValue != '100':
+ return Response(status=500, body="Internal server error")
+ with paramiko.SSHClient() as ssh:
+ try:
+ ssh.set_missing_host_key_policy(paramiko.MissingHostKeyPolicy)
+ ssh.connect(hostname='postfixadmin', username='pfexec', password=os.environ['POSTFIXADMIN_SSH_PASSWORD'])
+
+ stdin, stdout, stderr = ssh.exec_command(
+ f'/postfixadmin/scripts/postfixadmin-cli mailbox delete {shlex.quote(email)}')
+ print(stdout.read())
+ print(stderr.read())
+ except (ssh.SSHException, ssh.AuthenticationException, ssh.socket.error, ssh.BadHostKeyException) as e:
+ print(e)
+ return Response(status=500, body="Internal server error")
+ return Response(status=200, body="Account successfully deleted")
+ else:
+ return Response(status=403, body="Invalid email or confirmation code")
diff --git a/app/main.py b/app/main.py
index 78f477c35db055fb73dd58be1cf2a5016230d94f..d4aa1dcb3f2f15d9c09fd09341efe07e0d1e7f8c 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,116 +1,28 @@
#!/usr/bin/python3
import time
-from http.server import BaseHTTPRequestHandler, HTTPServer
-import urllib.parse
-import paramiko
-import requests
-from requests.auth import HTTPBasicAuth
-import os
-import logging
-import sys
-import json
-import shlex
-from threading import Thread
-
-ch = logging.StreamHandler(sys.stdout)
-ch.setLevel(logging.DEBUG)
-logger = logging.getLogger('create-account').addHandler(ch)
-paramiko.util.get_logger("paramiko").addHandler(ch)
+from wsgiref.simple_server import make_server
+from pyramid.config import Configurator
+from create_account import create_account
+from delete_account import delete_account, delete_account_form, confirm_delete_account
HOST_NAME = '0.0.0.0'
PORT_NUMBER = 9000
-class MyHandler(BaseHTTPRequestHandler):
- def do_PUT(self):
- if self.path == '/create-account' or self.path == '/create-account/':
- length = int(self.headers['Content-Length'])
- query = self.rfile.read(length)
- postvars = dict(urllib.parse.parse_qsl(query.decode()))
- if postvars['auth'] != os.environ['CREATE_ACCOUNT_PASSWORD']:
- data = json.dumps({'success': False, 'message': 'no_auth'})
- self.respond(401, data)
- return
-
- print(f'Creating account for {postvars["target_email"]}, displayname={postvars["displayname"]}, fallback email={postvars["fallback_email"]}')
-
- self.create_account(postvars['target_email'], postvars['password'], postvars['password_confirm'],
- postvars['displayname'], postvars['email_quota'], postvars['fallback_email'],
- postvars['nextcloud_quota'])
-
- def create_account(self, target_email: str, password: str, password_confirm: str, displayname: str, email_quota: str,
- fallback_email: str, nextcloud_quota: str):
- target_username = target_email.split('@')[0]
- with open('forbidden_usernames') as f:
- if target_username in f.read().splitlines():
- data = json.dumps({'success': False, 'message': 'username_forbidden'})
- self.respond(403, data)
- return
- # create account via postfixadmin ssh
- with paramiko.SSHClient() as ssh:
- try:
- ssh.set_missing_host_key_policy(paramiko.MissingHostKeyPolicy)
- ssh.connect(hostname='postfixadmin', username='pfexec', password=os.environ['POSTFIXADMIN_SSH_PASSWORD'])
-
- stdin, stdout, stderr = ssh.exec_command(
- f'/postfixadmin/scripts/postfixadmin-cli mailbox view {shlex.quote(target_email)}')
- if b"error: the email is not valid!" not in stderr.read().lower():
- data = json.dumps({'success': False, 'message': 'username_taken'})
- self.respond(403, data)
- return
-
- stdin, stdout, stderr = ssh.exec_command(
- f'/postfixadmin/scripts/postfixadmin-cli mailbox add {shlex.quote(target_email)} ' +
- f'--password {shlex.quote(password)} --password2 {shlex.quote(password_confirm)} ' +
- f'--name {shlex.quote(displayname)} --quota {shlex.quote(email_quota)} --active 1 ' +
- f'--welcome-mail 0 --email-other {shlex.quote(fallback_email)}')
- print(stdout.read())
- print(stderr.read())
- except (ssh.SSHException, ssh.AuthenticationException, ssh.socket.error, ssh.BadHostKeyException) as e:
- print(e)
- data = json.dumps({'success': False, 'message': 'internal_error'})
- self.respond(500, data)
- return
-
- self.respond(200, json.dumps({'success': True}))
-
- # Run Nextcloud API call in seperate thread. Needed because the API calls take 50+ seconds
- # each for unknown reasons. Instead we just run them in the background.
- Thread(target=self.call_nextcloud_api, args=(target_email, password, fallback_email, nextcloud_quota)).start()
-
- def respond(self, status: int, message: str):
- print(f'sending response status {status}, message {message}')
- self.send_response(status)
- self.end_headers()
- self.wfile.write(message.encode())
-
- def call_nextcloud_api(self, target_email: str, password: str, fallback_email: str, nextcloud_quota: str):
- # Nextcloud only creates external accounts after the first login. We login through the API
- # to trigger the account creation.
- headers = {'OCS-APIRequest': 'true'}
- auth = HTTPBasicAuth(target_email, password)
- url = f'https://{os.environ["DOMAIN"]}/ocs/v1.php/cloud/users/' + target_email
- r1 = requests.put(url, headers=headers, auth=auth)
-
- # Edit nextcloud account, set quota and email
- auth = HTTPBasicAuth(os.environ['NEXTCLOUD_ADMIN_USER'], os.environ['NEXTCLOUD_ADMIN_PASSWORD'])
- url = f'https://{os.environ["DOMAIN"]}/ocs/v1.php/cloud/users/{target_email.lower()}'
- r1 = requests.put(url, data={'key': 'email', 'value': fallback_email}, headers=headers, auth=auth)
- r2 = requests.put(url, data={'key': 'quota', 'value': nextcloud_quota}, headers=headers, auth=auth)
- print(r1.text)
- print(f'status code: {r1.status_code}')
- print(r2.text)
- print(f'status code: {r2.status_code}')
-
-
+# https://gitlab.e.foundation/e/infra/ecloud-selfhosting/issues/64#note_26118
+# https://docs.pylonsproject.org/projects/pyramid/en/latest/quick_tour.html
if __name__ == '__main__':
- server_class = HTTPServer
- httpd = server_class((HOST_NAME, PORT_NUMBER), MyHandler)
- print(time.asctime(), 'Server Starts - %s:%s' % (HOST_NAME, PORT_NUMBER))
- try:
- httpd.serve_forever()
- except (SystemExit, KeyboardInterrupt):
- pass
- httpd.server_close()
- print(time.asctime(), 'Server Stops - %s:%s' % (HOST_NAME, PORT_NUMBER))
+ with Configurator() as config:
+ config.add_route('create-account', '/create-account', request_method='PUT')
+ config.add_view(create_account, route_name='create-account')
+ config.add_route('delete-account', '/delete-account')
+ config.add_view(delete_account, route_name='delete-account')
+ config.add_route('delete-account-form', '/delete-account-form', request_method='POST')
+ config.add_view(delete_account_form, route_name='delete-account-form')
+ config.add_route('confirm-delete-account', '/confirm-delete-account')
+ config.add_view(confirm_delete_account, route_name='confirm-delete-account')
+ app = config.make_wsgi_app()
+ server = make_server(HOST_NAME, PORT_NUMBER, app)
+ print(time.asctime(), 'Server started - %s:%s' % (HOST_NAME, PORT_NUMBER))
+ server.serve_forever()
diff --git a/app/requirements.txt b/app/requirements.txt
index 505dd5710f00fbff35f0c3b742274214b42ddc42..c2684e2bc6470a3ebdcc8fb81ec1e3edc0efe5f0 100644
--- a/app/requirements.txt
+++ b/app/requirements.txt
@@ -1,2 +1,3 @@
-paramiko>2.4, <2.5
-requests>2.19, < 2.20
\ No newline at end of file
+paramiko==2.7.1
+requests==2.22.0
+pyramid==1.10.4