import os from datetime import datetime, UTC from io import BytesIO from shutil import copyfile import magic from PIL import Image from bottle import get, post, abort, redirect, request, FormsDict from matemat.db import MatematDatabase from matemat.db.primitives import User, ReceiptPreference from matemat.exceptions import AuthenticationError, DatabaseConsistencyError from matemat.util.currency_format import parse_chf from matemat.webserver import session, template from matemat.webserver.config import get_app_config, get_stock_provider from matemat.webserver.template import Notification @get('/settings') @post('/settings') def settings(): """ The settings panel, shows a user's own settings. """ config = get_app_config() session_id: str = session.start() # If no user is logged in, redirect to the login page if not session.has(session_id, 'authentication_level') or not session.has(session_id, 'authenticated_user'): redirect('/login') authlevel: int = session.get(session_id, 'authentication_level') uid: int = session.get(session_id, 'authenticated_user') # Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via touchkey or token (1) if authlevel < 2: abort(403) # Connect to the database with MatematDatabase(config['DatabaseFile']) as db: # Fetch the authenticated user user = db.get_user(uid) # If the POST request contains a "change" parameter, delegate the change handling to the function below if 'change' in request.params: handle_change(request.params, request.files, user, db) user = db.get_user(uid) tokens = db.list_tokens(uid) # Render the "Settings" page now = str(int(datetime.now(UTC).timestamp())) return template.render('settings.html', authuser=user, authlevel=authlevel, tokens=tokens, receipt_preference_class=ReceiptPreference, now=now, setupname=config['InstanceName'], config_smtp_enabled=config['SmtpSendReceipts']) def handle_change(args: FormsDict, files: FormsDict, user: User, db: MatematDatabase) -> None: """ Write the changes requested by a user for its own account to the database. :param args: The FormsDict object passed to the pagelet. :param user: The user to edit. :param db: The database facade where changes are written to. """ config = get_app_config() try: # Read the type of change requested by the user, then switch over it change = str(args.change) # The user requested a modification of its general account information (username, email) if change == 'account': # Username and email must be set in the request arguments if 'username' not in args or 'email' not in args: return username = str(args.username) email = str(args.email) logout_after_purchase = 'logout_after_purchase' in args # An empty e-mail field should be interpreted as NULL if len(email) == 0: email = None try: receipt_pref = ReceiptPreference(int(str(args.receipt_pref))) except ValueError: return # Attempt to update username, e-mail and receipt preference try: db.change_user(user, agent=None, name=username, email=email, receipt_pref=receipt_pref, logout_after_purchase=logout_after_purchase) except DatabaseConsistencyError: return # The user requested a password change elif change == 'password': # The old password and 2x the new password must be present if 'oldpass' not in args or 'newpass' not in args or 'newpass2' not in args: return # Read the passwords from the request arguments oldpass = str(args.oldpass) newpass = str(args.newpass) newpass2 = str(args.newpass2) # The two instances of the new password must match if newpass != newpass2: raise ValueError('New passwords don\'t match') # Write the new password to the database try: db.change_password(user, oldpass, newpass) except AuthenticationError: raise ValueError('Old password doesn\'t match') # The user requested a touchkey change elif change == 'touchkey': # The touchkey must be present if 'touchkey' not in args: return # Read the touchkey from the request arguments touchkey = str(args.touchkey) # An empty touchkey field should set the touchkey to NULL (disable touchkey login) if len(touchkey) == 0: touchkey = None # Write the new touchkey to the database db.change_touchkey(user, '', touchkey, verify_password=False) # The user added a new token elif change == 'addtoken': if 'token' not in args: return token = str(args.token) if len(token) < 6: return name = None if 'name' not in args or len(args.name) == 0 else str(args.name) try: tokobj = db.add_token(user, token, name) Notification.success(f'Token {tokobj.name} created successfully') except DatabaseConsistencyError: Notification.error('Token already exists', decay=True) elif change == 'deltoken': try: tokid = int(str(request.params.token)) token = db.get_token(tokid) except Exception as e: Notification.error('Token not found', decay=True) return if token.user_id != user.id: Notification.error('Token not found', decay=True) return try: db.delete_token(token) except DatabaseConsistencyError: Notification.error(f'Failed to delete token {token.name}', decay=True) Notification.success(f'Token {token.name} removed', decay=True) # The user requested an avatar change elif change == 'avatar': # The new avatar field must be present if 'avatar' not in files: return # Read the raw image data from the request avatar = files.avatar.file.read() # Only process the image, if its size is more than zero. Zero size means no new image was uploaded if len(avatar) == 0: return # Detect the MIME type filemagic: magic.FileMagic = magic.detect_from_content(avatar) if not filemagic.mime_type.startswith('image/'): return # Create the absolute path of the upload directory abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/users/') os.makedirs(abspath, exist_ok=True) try: # Parse the image data image: Image = Image.open(BytesIO(avatar)) # Resize the image to 150x150 image.thumbnail((150, 150), Image.LANCZOS) # Write the image to the file image.save(os.path.join(abspath, f'{user.id}.png'), 'PNG') except OSError: return except UnicodeDecodeError: raise ValueError('an argument not a string')