"""Small WSGI contact endpoint. Run behind HTTPS and the Nginx rate limit.""" import base64 import binascii import hashlib import hmac import json import logging import os import re import secrets import time import urllib.error import urllib.parse import urllib.request LOG = logging.getLogger(__name__) ORIGIN = os.environ.get('CONTACT_ORIGIN', 'https://dilibworld.com').rstrip('/') SECRET = os.environ.get('CONTACT_FORM_SECRET', '').encode() SENDER = os.environ.get('GRAPH_SENDER', 'info@dilibworld.com') RECIPIENT = os.environ.get('CONTACT_RECIPIENT', 'info@dilibworld.com') FIELDS = { 'de': ('Vorname', 'Nachname', 'Unternehmen', 'E-Mail', 'Telefon', 'Thema', 'Nachricht'), 'en': ('First name', 'Last name', 'Company', 'Email', 'Phone', 'Topic', 'Message'), } USED = {} def reply(start_response, status, data): body = json.dumps(data).encode() start_response(status, [('Content-Type', 'application/json; charset=utf-8'), ('Content-Length', str(len(body))), ('Cache-Control', 'no-store'), ('X-Content-Type-Options', 'nosniff')]) return [body] def token(): value = f'{int(time.time())}:{secrets.token_urlsafe(18)}' signature = hmac.new(SECRET, value.encode(), hashlib.sha256).hexdigest() return base64.urlsafe_b64encode(f'{value}:{signature}'.encode()).decode() def valid_token(value): try: raw = base64.urlsafe_b64decode(value.encode()).decode() timestamp, nonce, signature = raw.split(':') signed = f'{timestamp}:{nonce}' age = time.time() - int(timestamp) digest = hmac.new(SECRET, signed.encode(), hashlib.sha256).hexdigest() if not (3 <= age <= 3600 and hmac.compare_digest(digest, signature)): return False now = time.time() for key, expiry in list(USED.items()): if expiry < now: del USED[key] if signature in USED: return False USED[signature] = now + 3600 return True except (ValueError, UnicodeError, TypeError, binascii.Error): return False def graph_send(values, lang): tenant = os.environ['GRAPH_TENANT_ID'] app_id = os.environ['GRAPH_CLIENT_ID'] client_secret = os.environ['GRAPH_CLIENT_SECRET'] form = urllib.parse.urlencode({'client_id': app_id, 'client_secret': client_secret, 'scope': 'https://graph.microsoft.com/.default', 'grant_type': 'client_credentials'}).encode() url = f'https://login.microsoftonline.com/{urllib.parse.quote(tenant, safe="")}/oauth2/v2.0/token' request = urllib.request.Request(url, form, {'Content-Type': 'application/x-www-form-urlencoded'}) with urllib.request.urlopen(request, timeout=10) as response: access_token = json.load(response)['access_token'] labels = FIELDS[lang] content = '\n'.join(f'{label}: {values.get(label, "")}' for label in labels) email = values[labels[3]] message = {'message': { 'subject': 'Kontaktanfrage über dilibworld.com', 'body': {'contentType': 'Text', 'content': content}, 'toRecipients': [{'emailAddress': {'address': RECIPIENT}}], 'replyTo': [{'emailAddress': {'address': email}}], }, 'saveToSentItems': True} endpoint = 'https://graph.microsoft.com/v1.0/users/' + urllib.parse.quote(SENDER, safe='@') + '/sendMail' request = urllib.request.Request(endpoint, json.dumps(message).encode(), {'Content-Type': 'application/json', 'Authorization': f'Bearer {access_token}'}) with urllib.request.urlopen(request, timeout=15) as response: if response.status != 202: raise RuntimeError('Graph did not accept the message') def application(environ, start_response): if environ.get('PATH_INFO') != '/api/contact': return reply(start_response, '404 Not Found', {'error': 'not_found'}) if not SECRET or not all(os.environ.get(key) for key in ('GRAPH_TENANT_ID', 'GRAPH_CLIENT_ID', 'GRAPH_CLIENT_SECRET')): LOG.error('Contact service configuration incomplete') return reply(start_response, '503 Service Unavailable', {'error': 'unavailable'}) method = environ.get('REQUEST_METHOD') if method == 'GET': return reply(start_response, '200 OK', {'token': token()}) if method != 'POST': return reply(start_response, '405 Method Not Allowed', {'error': 'method'}) if environ.get('HTTP_ORIGIN') != ORIGIN or environ.get('CONTENT_TYPE', '').split(';')[0] != 'application/json': return reply(start_response, '403 Forbidden', {'error': 'origin'}) try: length = int(environ.get('CONTENT_LENGTH', '0')) if not 0 < length <= 12000: raise ValueError('length') data = json.loads(environ['wsgi.input'].read(length)) lang = data.get('lang') if lang not in FIELDS or not isinstance(data.get('website'), str) or not isinstance(data.get('token'), str): raise ValueError('fields') if data['website']: return reply(start_response, '200 OK', {'ok': True}) if not valid_token(data['token']): raise ValueError('token') labels = FIELDS[lang] values = {key: data.get(key, '') for key in labels} if any(not isinstance(v, str) or len(v) > (5000 if key == labels[6] else 200) for key, v in values.items()): raise ValueError('field length') if not all(values[key].strip() for key in (labels[0], labels[1], labels[3], labels[6])): raise ValueError('required') if not re.fullmatch(r'[^\s@\r\n]+@[^\s@\r\n]+\.[^\s@\r\n]+', values[labels[3]]) or len(values[labels[3]]) > 254: raise ValueError('email') if data.get('privacy') not in ('akzeptiert', 'accepted'): raise ValueError('privacy') graph_send(values, lang) except (ValueError, TypeError, KeyError, json.JSONDecodeError): return reply(start_response, '400 Bad Request', {'error': 'invalid'}) except (urllib.error.URLError, TimeoutError, RuntimeError): LOG.exception('Contact delivery failed') return reply(start_response, '502 Bad Gateway', {'error': 'delivery'}) return reply(start_response, '200 OK', {'ok': True})