diff --git a/Dockerfile b/Dockerfile index 126a06f..faa0d1a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,11 @@ RUN useradd -d /home/matemat -m matemat RUN apt-get update -qy RUN apt-get install -y --no-install-recommends python3-dev python3-pip python3-coverage python3-setuptools build-essential RUN pip3 install wheel pycodestyle mypy +ADD . /home/matemat +RUN chown matemat:matemat -R /home/matemat +RUN pip3 install -r /home/matemat/requirements.txt WORKDIR /home/matemat USER matemat +CMD python3 -m matemat +EXPOSE 8080/tcp diff --git a/matemat/__main__.py b/matemat/__main__.py index 9654b64..e0b549d 100644 --- a/matemat/__main__.py +++ b/matemat/__main__.py @@ -13,4 +13,4 @@ if __name__ == '__main__': port = int(sys.argv[1]) # Start the web server - MatematWebserver(port=port).start() + MatematWebserver(port=port, webroot='./static').start() diff --git a/matemat/db/facade.py b/matemat/db/facade.py index 4bd27cd..8813e06 100644 --- a/matemat/db/facade.py +++ b/matemat/db/facade.py @@ -73,14 +73,28 @@ class MatematDatabase(object): users: List[User] = [] with self.db.transaction(exclusive=False) as c: for row in c.execute(''' - SELECT user_id, username, email, is_admin, is_member + SELECT user_id, username, email, is_admin, is_member, balance FROM users '''): # Decompose each row and put the values into a User object - user_id, username, email, is_admin, is_member = row - users.append(User(user_id, username, email, is_admin, is_member)) + user_id, username, email, is_admin, is_member, balance = row + users.append(User(user_id, username, balance, email, is_admin, is_member)) return users + def get_user(self, uid: int) -> User: + """ + Return a user identified by its user ID + :param uid: The user's ID + """ + with self.db.transaction(exclusive=False) as c: + c.execute('SELECT user_id, username, email, is_admin, is_member, balance FROM users WHERE user_id = ?', + [uid]) + row = c.fetchone() + if row is None: + raise ValueError(f'No user with user ID {uid} exists.') + user_id, username, email, is_admin, is_member, balance = row + return User(user_id, username, balance, email, is_admin, is_member) + def create_user(self, username: str, password: str, @@ -119,7 +133,7 @@ class MatematDatabase(object): # Fetch the new user's rowid. c.execute('SELECT last_insert_rowid()') user_id = int(c.fetchone()[0]) - return User(user_id, username, email, admin, member) + return User(user_id, username, 0, email, admin, member) def login(self, username: str, password: Optional[str] = None, touchkey: Optional[str] = None) -> User: """ @@ -136,21 +150,21 @@ class MatematDatabase(object): raise ValueError('Exactly one of password and touchkey must be provided') with self.db.transaction(exclusive=False) as c: c.execute(''' - SELECT user_id, username, email, password, touchkey, is_admin, is_member + SELECT user_id, username, email, password, touchkey, is_admin, is_member, balance FROM users WHERE username = ? ''', [username]) row = c.fetchone() if row is None: raise AuthenticationError('User does not exist') - user_id, username, email, pwhash, tkhash, admin, member = row + user_id, username, email, pwhash, tkhash, admin, member, balance = row if password is not None and crypt.crypt(password, pwhash) != pwhash: raise AuthenticationError('Password mismatch') elif touchkey is not None and tkhash is not None and crypt.crypt(touchkey, tkhash) != tkhash: raise AuthenticationError('Touchkey mismatch') elif touchkey is not None and tkhash is None: raise AuthenticationError('Touchkey not set') - return User(user_id, username, email, admin, member) + return User(user_id, username, balance, email, admin, member) def change_password(self, user: User, oldpass: str, newpass: str, verify_password: bool = True) -> None: """ @@ -221,14 +235,18 @@ class MatematDatabase(object): with self.db.transaction() as c: c.execute(''' UPDATE users SET + username = :username, email = :email, + balance = :balance, is_admin = :is_admin, is_member = :is_member, lastchange = STRFTIME('%s', 'now') WHERE user_id = :user_id ''', { 'user_id': user.id, + 'username': user.name, 'email': user.email, + 'balance': user.balance, 'is_admin': user.is_admin, 'is_member': user.is_member }) @@ -261,13 +279,30 @@ class MatematDatabase(object): products: List[Product] = [] with self.db.transaction(exclusive=False) as c: for row in c.execute(''' - SELECT product_id, name, price_member, price_non_member + SELECT product_id, name, price_member, price_non_member, stock FROM products '''): - product_id, name, price_member, price_external = row - products.append(Product(product_id, name, price_member, price_external)) + product_id, name, price_member, price_external, stock = row + products.append(Product(product_id, name, price_member, price_external, stock)) return products + def get_product(self, pid: int) -> Product: + """ + Return a product identified by its product ID + :param pid: The products's ID + """ + with self.db.transaction(exclusive=False) as c: + c.execute(''' + SELECT product_id, name, price_member, price_non_member, stock + FROM products + WHERE product_id = ?''', + [pid]) + row = c.fetchone() + if row is None: + raise ValueError(f'No product with product ID {pid} exists.') + product_id, name, price_member, price_non_member, stock = row + return Product(product_id, name, price_member, price_non_member, stock) + def create_product(self, name: str, price_member: int, price_non_member: int) -> Product: """ Creates a new product. @@ -292,7 +327,7 @@ class MatematDatabase(object): }) c.execute('SELECT last_insert_rowid()') product_id = int(c.fetchone()[0]) - return Product(product_id, name, price_member, price_non_member) + return Product(product_id, name, price_member, price_non_member, 0) def change_product(self, product: Product) -> None: with self.db.transaction() as c: @@ -301,13 +336,15 @@ class MatematDatabase(object): SET name = :name, price_member = :price_member, - price_non_member = :price_non_member + price_non_member = :price_non_member, + stock = :stock WHERE product_id = :product_id ''', { 'product_id': product.id, 'name': product.name, 'price_member': product.price_member, - 'price_non_member': product.price_non_member + 'price_non_member': product.price_non_member, + 'stock': product.stock }) affected = c.execute('SELECT changes()').fetchone()[0] if affected != 1: diff --git a/matemat/db/wrapper.py b/matemat/db/wrapper.py index 509dade..fece3af 100644 --- a/matemat/db/wrapper.py +++ b/matemat/db/wrapper.py @@ -47,7 +47,7 @@ class DatabaseWrapper(object): SCHEMA = ''' CREATE TABLE users ( user_id INTEGER PRIMARY KEY, - username TEXT NOT NULL, + username TEXT UNIQUE NOT NULL, email TEXT DEFAULT NULL, password TEXT NOT NULL, touchkey TEXT DEFAULT NULL, diff --git a/matemat/primitives/Product.py b/matemat/primitives/Product.py index d2cb043..6208466 100644 --- a/matemat/primitives/Product.py +++ b/matemat/primitives/Product.py @@ -8,11 +8,13 @@ class Product(object): product_id: int, name: str, price_member: int, - price_non_member: int) -> None: + price_non_member: int, + stock: int) -> None: self._product_id: int = product_id self._name: str = name self._price_member: int = price_member self._price_non_member: int = price_non_member + self._stock: int = stock def __eq__(self, other: Any) -> bool: if other is None or not isinstance(other, Product): @@ -49,3 +51,11 @@ class Product(object): @price_non_member.setter def price_non_member(self, price: int) -> None: self._price_non_member = price + + @property + def stock(self) -> int: + return self._stock + + @stock.setter + def stock(self, stock: int) -> None: + self._stock = stock diff --git a/matemat/primitives/User.py b/matemat/primitives/User.py index e49e52b..04b014a 100644 --- a/matemat/primitives/User.py +++ b/matemat/primitives/User.py @@ -7,6 +7,7 @@ class User(object): def __init__(self, user_id: int, username: str, + balance: int, email: Optional[str] = None, admin: bool = False, member: bool = True) -> None: @@ -15,6 +16,7 @@ class User(object): self._email: Optional[str] = email self._admin: bool = admin self._member: bool = member + self._balance: int = balance def __eq__(self, other: Any) -> bool: if other is None or not isinstance(other, User): @@ -33,6 +35,10 @@ class User(object): def name(self) -> str: return self._username + @name.setter + def name(self, value): + self._username = value + @property def email(self) -> Optional[str]: return self._email @@ -56,3 +62,11 @@ class User(object): @is_member.setter def is_member(self, member: bool) -> None: self._member = member + + @property + def balance(self) -> int: + return self._balance + + @balance.setter + def balance(self, balance: int) -> None: + self._balance = balance diff --git a/matemat/webserver/httpd.py b/matemat/webserver/httpd.py index b703f72..4bc2c78 100644 --- a/matemat/webserver/httpd.py +++ b/matemat/webserver/httpd.py @@ -278,7 +278,9 @@ class HttpHandler(BaseHTTPRequestHandler): except ValueError: self.send_response(400, 'Bad Request') self.end_headers() - except BaseException: + except BaseException as e: + print(e) + traceback.print_tb(e.__traceback__) # Generic error handling self.send_response(500, 'Internal Server Error') self.end_headers() diff --git a/matemat/webserver/pagelets/__init__.py b/matemat/webserver/pagelets/__init__.py index 9b926d6..c336f46 100644 --- a/matemat/webserver/pagelets/__init__.py +++ b/matemat/webserver/pagelets/__init__.py @@ -8,3 +8,8 @@ from .main import main_page from .login import login_page from .logout import logout from .touchkey import touchkey_page +from .buy import buy +from .deposit import deposit +from .admin import admin +from .moduser import moduser +from .modproduct import modproduct diff --git a/matemat/webserver/pagelets/admin.py b/matemat/webserver/pagelets/admin.py new file mode 100644 index 0000000..39ae4d1 --- /dev/null +++ b/matemat/webserver/pagelets/admin.py @@ -0,0 +1,119 @@ + +from typing import Any, Dict, Optional, Tuple, Union + +from jinja2 import Environment, FileSystemLoader + +import os + +from matemat.webserver import pagelet, RequestArguments +from matemat.db import MatematDatabase +from matemat.primitives import User +from matemat.exceptions import DatabaseConsistencyError + + +@pagelet('/admin') +def admin(method: str, + path: str, + args: RequestArguments, + session_vars: Dict[str, Any], + headers: Dict[str, str]) \ + -> Tuple[int, Optional[Union[str, bytes]]]: + env = Environment(loader=FileSystemLoader('templates')) + if 'authentication_level' not in session_vars or 'authenticated_user' not in session_vars: + headers['Location'] = '/login' + return 301, None + authlevel: int = session_vars['authentication_level'] + uid: int = session_vars['authenticated_user'] + if authlevel < 2: + return 403, None + + with MatematDatabase('test.db') as db: + user = db.get_user(uid) + if method == 'POST' and 'change' in args: + handle_change(args, user, db) + elif method == 'POST' and 'adminchange' in args and user.is_admin: + handle_admin_change(args, db) + + users = db.list_users() + products = db.list_products() + template = env.get_template('admin.html') + return 200, template.render(user=user, authlevel=authlevel, users=users, products=products) + + +def handle_change(args: RequestArguments, user: User, db: MatematDatabase) -> None: + try: + change = str(args.change) + + if change == 'account': + username = str(args.username) + email = str(args.email) + if len(email) == 0: + email = None + oldname = user.name + oldmail = user.email + try: + user.name = username + user.email = email + db.change_user(user) + except DatabaseConsistencyError: + user.name = oldname + user.email = oldmail + + elif change == 'password': + oldpass = str(args.oldpass) + newpass = str(args.newpass) + newpass2 = str(args.newpass2) + if newpass != newpass2: + raise ValueError('New passwords don\'t match') + db.change_password(user, oldpass, newpass) + + elif change == 'touchkey': + oldpass = str(args.oldpass) + touchkey = str(args.touchkey) + if len(touchkey) == 0: + touchkey = None + db.change_touchkey(user, oldpass, touchkey) + + elif change == 'avatar': + avatar = bytes(args.avatar) + os.makedirs('./static/img/thumbnails/users/', exist_ok=True) + with open(f'./static/img/thumbnails/users/{user.id}.png', 'wb') as f: + f.write(avatar) + + except UnicodeDecodeError: + raise ValueError('an argument not a string') + + +def handle_admin_change(args: RequestArguments, db: MatematDatabase): + try: + change = str(args.adminchange) + + if change == 'newuser': + username = str(args.username) + email = str(args.email) + if len(email) == 0: + email = None + password = str(args.password) + is_member = 'ismember' in args + is_admin = 'isadmin' in args + db.create_user(username, password, email, member=is_member, admin=is_admin) + + elif change == 'newproduct': + name = str(args.name) + price_member = int(str(args.pricemember)) + price_non_member = int(str(args.pricenonmember)) + newproduct = db.create_product(name, price_member, price_non_member) + if 'image' in args: + image = bytes(args.image) + os.makedirs('./static/img/thumbnails/products/', exist_ok=True) + with open(f'./static/img/thumbnails/products/{newproduct.id}.png', 'wb') as f: + f.write(image) + + elif change == 'restock': + productid = int(str(args.productid)) + amount = int(str(args.amount)) + product = db.get_product(productid) + db.restock(product, amount) + + except UnicodeDecodeError: + raise ValueError('an argument not a string') diff --git a/matemat/webserver/pagelets/buy.py b/matemat/webserver/pagelets/buy.py new file mode 100644 index 0000000..8428aca --- /dev/null +++ b/matemat/webserver/pagelets/buy.py @@ -0,0 +1,30 @@ +from typing import Any, Dict, Optional, Tuple, Union + +from matemat.webserver import pagelet, RequestArguments +from matemat.primitives import User +from matemat.db import MatematDatabase + + +@pagelet('/buy') +def buy(method: str, + path: str, + args: RequestArguments, + session_vars: Dict[str, Any], + headers: Dict[str, str]) \ + -> Tuple[int, Optional[Union[str, bytes]]]: + if 'authenticated_user' not in session_vars: + headers['Location'] = '/' + return 301, None + with MatematDatabase('test.db') as db: + uid: int = session_vars['authenticated_user'] + user = db.get_user(uid) + if 'n' in args: + n = int(str(args.n)) + else: + n = 1 + if 'pid' in args: + pid = int(str(args.pid)) + product = db.get_product(pid) + db.increment_consumption(user, product, n) + headers['Location'] = '/' + return 301, None diff --git a/matemat/webserver/pagelets/deposit.py b/matemat/webserver/pagelets/deposit.py new file mode 100644 index 0000000..36ad4b6 --- /dev/null +++ b/matemat/webserver/pagelets/deposit.py @@ -0,0 +1,25 @@ +from typing import Any, Dict, Optional, Tuple, Union + +from matemat.webserver import pagelet, RequestArguments +from matemat.primitives import User +from matemat.db import MatematDatabase + + +@pagelet('/deposit') +def deposit(method: str, + path: str, + args: RequestArguments, + session_vars: Dict[str, Any], + headers: Dict[str, str]) \ + -> Tuple[int, Optional[Union[str, bytes]]]: + if 'authenticated_user' not in session_vars: + headers['Location'] = '/' + return 301, None + with MatematDatabase('test.db') as db: + uid: int = session_vars['authenticated_user'] + user = db.get_user(uid) + if 'n' in args: + n = int(str(args.n)) + db.deposit(user, n) + headers['Location'] = '/' + return 301, None diff --git a/matemat/webserver/pagelets/login.py b/matemat/webserver/pagelets/login.py index 7d0cc2d..bc0e579 100644 --- a/matemat/webserver/pagelets/login.py +++ b/matemat/webserver/pagelets/login.py @@ -1,6 +1,8 @@ from typing import Any, Dict, Optional, Tuple, Union +from jinja2 import Environment, FileSystemLoader + from matemat.exceptions import AuthenticationError from matemat.webserver import pagelet, RequestArguments from matemat.primitives import User @@ -14,42 +16,22 @@ def login_page(method: str, session_vars: Dict[str, Any], headers: Dict[str, str])\ -> Tuple[int, Optional[Union[str, bytes]]]: - if 'user' in session_vars: + if 'authenticated_user' in session_vars: headers['Location'] = '/' - return 301, None + return 301, bytes() + env = Environment(loader=FileSystemLoader('templates')) if method == 'GET': - data = ''' - - - - Matemat - - - -

Matemat

- {msg} -
- Username:
- Password:
- -
- - - ''' - return 200, data.format(msg=str(args.msg) if 'msg' in args else '') + template = env.get_template('login.html') + return 200, template.render() elif method == 'POST': with MatematDatabase('test.db') as db: try: user: User = db.login(str(args.username), str(args.password)) except AuthenticationError: - headers['Location'] = '/login?msg=Username%20or%20password%20wrong.%20Please%20try%20again.' + headers['Location'] = '/login' return 301, bytes() - session_vars['user'] = user + session_vars['authenticated_user'] = user.id + session_vars['authentication_level'] = 2 headers['Location'] = '/' - return 301, bytes() + return 301, None return 405, None diff --git a/matemat/webserver/pagelets/logout.py b/matemat/webserver/pagelets/logout.py index beb86a3..362a698 100644 --- a/matemat/webserver/pagelets/logout.py +++ b/matemat/webserver/pagelets/logout.py @@ -11,7 +11,8 @@ def logout(method: str, session_vars: Dict[str, Any], headers: Dict[str, str])\ -> Tuple[int, Optional[Union[str, bytes]]]: - if 'user' in session_vars: - del session_vars['user'] + if 'authenticated_user' in session_vars: + del session_vars['authenticated_user'] + session_vars['authentication_level'] = 0 headers['Location'] = '/' return 301, None diff --git a/matemat/webserver/pagelets/main.py b/matemat/webserver/pagelets/main.py index e22c872..fa813bd 100644 --- a/matemat/webserver/pagelets/main.py +++ b/matemat/webserver/pagelets/main.py @@ -1,8 +1,9 @@ from typing import Any, Dict, Optional, Tuple, Union +from jinja2 import Environment, FileSystemLoader + from matemat.webserver import pagelet, RequestArguments -from matemat.primitives import User from matemat.db import MatematDatabase @@ -13,45 +14,16 @@ def main_page(method: str, session_vars: Dict[str, Any], headers: Dict[str, str])\ -> Tuple[int, Optional[Union[str, bytes]]]: - data = ''' - - - - Matemat - - - -

Matemat

- {user} - - - - ''' - try: - with MatematDatabase('test.db') as db: - if 'user' in session_vars: - user: User = session_vars['user'] - products = db.list_products() - plist = '\n'.join([f'
  • {p.name} ' + - f'{p.price_member//100 if user.is_member else p.price_non_member//100}' + - f'.{p.price_member%100 if user.is_member else p.price_non_member%100}' - for p in products]) - uname = f'{user.name} (Logout)' - data = data.format(user=uname, list=plist) - else: - users = db.list_users() - ulist = '\n'.join([f'
  • {u.name}' for u in users]) - ulist = ulist + '
  • Password login' - data = data.format(user='', list=ulist) - return 200, data - except BaseException as e: - import traceback - traceback.print_tb(e.__traceback__) - return 500, None + env = Environment(loader=FileSystemLoader('templates')) + with MatematDatabase('test.db') as db: + if 'authenticated_user' in session_vars: + uid: int = session_vars['authenticated_user'] + authlevel: int = session_vars['authentication_level'] + user = db.get_user(uid) + products = db.list_products() + template = env.get_template('productlist.html') + return 200, template.render(user=user, products=products, authlevel=authlevel) + else: + users = db.list_users() + template = env.get_template('userlist.html') + return 200, template.render(users=users) diff --git a/matemat/webserver/pagelets/modproduct.py b/matemat/webserver/pagelets/modproduct.py new file mode 100644 index 0000000..96f5471 --- /dev/null +++ b/matemat/webserver/pagelets/modproduct.py @@ -0,0 +1,84 @@ +from typing import Any, Dict, Optional, Tuple, Union + +from jinja2 import Environment, FileSystemLoader + +import os + +from matemat.webserver import pagelet, RequestArguments +from matemat.db import MatematDatabase +from matemat.primitives import Product +from matemat.exceptions import DatabaseConsistencyError + + +@pagelet('/modproduct') +def modproduct(method: str, + path: str, + args: RequestArguments, + session_vars: Dict[str, Any], + headers: Dict[str, str]) \ + -> Tuple[int, Optional[Union[str, bytes]]]: + env = Environment(loader=FileSystemLoader('templates')) + if 'authentication_level' not in session_vars or 'authenticated_user' not in session_vars: + headers['Location'] = '/login' + return 301, None + authlevel: int = session_vars['authentication_level'] + auth_uid: int = session_vars['authenticated_user'] + if authlevel < 2: + return 403, None + + with MatematDatabase('test.db') as db: + authuser = db.get_user(auth_uid) + if not authuser.is_admin: + return 403, None + if 'productid' not in args: + return 400, None + + modproduct_id = int(str(args.productid)) + product = db.get_product(modproduct_id) + + if 'change' in args: + handle_change(args, product, db) + if str(args.change) == 'del': + headers['Location'] = '/admin' + return 301, None + + template = env.get_template('modproduct.html') + return 200, template.render(product=product, authlevel=authlevel) + + +def handle_change(args: RequestArguments, product: Product, db: MatematDatabase) -> None: + change = str(args.change) + + if change == 'del': + db.delete_product(product) + try: + os.remove(f'./static/img/thumbnails/products/{product.id}.png') + except FileNotFoundError: + pass + + elif change == 'update': + name = str(args.name) + price_member = int(str(args.pricemember)) + price_non_member = int(str(args.pricenonmember)) + stock = int(str(args.stock)) + oldname = product.name + oldprice_member = product.price_member + oldprice_non_member = product.price_non_member + oldstock = product.stock + product.name = name + product.price_member = price_member + product.price_non_member = price_non_member + product.stock = stock + try: + db.change_product(product) + except DatabaseConsistencyError: + product.name = oldname + product.email = oldprice_member + product.is_member = oldprice_non_member + product.stock = oldstock + if 'image' in args: + image = bytes(args.image) + if len(image) > 0: + os.makedirs('./static/img/thumbnails/products/', exist_ok=True) + with open(f'./static/img/thumbnails/products/{product.id}.png', 'wb') as f: + f.write(image) diff --git a/matemat/webserver/pagelets/moduser.py b/matemat/webserver/pagelets/moduser.py new file mode 100644 index 0000000..ac40736 --- /dev/null +++ b/matemat/webserver/pagelets/moduser.py @@ -0,0 +1,92 @@ +from typing import Any, Dict, Optional, Tuple, Union + +from jinja2 import Environment, FileSystemLoader + +import os + +from matemat.webserver import pagelet, RequestArguments +from matemat.db import MatematDatabase +from matemat.primitives import User +from matemat.exceptions import DatabaseConsistencyError + + +@pagelet('/moduser') +def moduser(method: str, + path: str, + args: RequestArguments, + session_vars: Dict[str, Any], + headers: Dict[str, str]) \ + -> Tuple[int, Optional[Union[str, bytes]]]: + env = Environment(loader=FileSystemLoader('templates')) + if 'authentication_level' not in session_vars or 'authenticated_user' not in session_vars: + headers['Location'] = '/login' + return 301, None + authlevel: int = session_vars['authentication_level'] + auth_uid: int = session_vars['authenticated_user'] + if authlevel < 2: + return 403, None + + with MatematDatabase('test.db') as db: + authuser = db.get_user(auth_uid) + if not authuser.is_admin: + return 403, None + if 'userid' not in args: + return 400, None + + moduser_id = int(str(args.userid)) + user = db.get_user(moduser_id) + + if 'change' in args: + handle_change(args, user, db) + if str(args.change) == 'del': + headers['Location'] = '/admin' + return 301, None + + template = env.get_template('moduser.html') + return 200, template.render(user=user, authlevel=authlevel) + + +def handle_change(args: RequestArguments, user: User, db: MatematDatabase) -> None: + change = str(args.change) + + if change == 'del': + db.delete_user(user) + try: + os.remove(f'./static/img/thumbnails/users/{user.id}.png') + except FileNotFoundError: + pass + + elif change == 'update': + username = str(args.username) + email = str(args.email) + password = str(args.password) + balance = int(str(args.balance)) + if len(email) == 0: + email = None + oldname = user.name + oldmail = user.email + oldmember = user.is_member + oldadmin = user.is_admin + oldbalance = user.balance + user.name = username + user.email = email + user.is_member = 'ismember' in args + user.is_admin = 'isadmin' in args + user.balance = balance + try: + db.change_user(user) + except DatabaseConsistencyError: + user.name = oldname + user.email = oldmail + user.is_member = oldmember + user.is_admin = oldadmin + user.balance = oldbalance + if len(password) > 0: + db.change_password(user, '', password, verify_password=False) + if 'avatar' in args: + avatar = bytes(args.avatar) + if len(avatar) > 0: + os.makedirs('./static/img/thumbnails/products/', exist_ok=True) + with open(f'./static/img/thumbnails/products/{user.id}.png', 'wb') as f: + f.write(avatar) + diff --git a/matemat/webserver/pagelets/touchkey.py b/matemat/webserver/pagelets/touchkey.py index 4de8009..7a5290c 100644 --- a/matemat/webserver/pagelets/touchkey.py +++ b/matemat/webserver/pagelets/touchkey.py @@ -1,6 +1,8 @@ from typing import Any, Dict, Optional, Tuple, Union +from jinja2 import Environment, FileSystemLoader + from matemat.exceptions import AuthenticationError from matemat.webserver import pagelet, RequestArguments from matemat.primitives import User @@ -14,41 +16,22 @@ def touchkey_page(method: str, session_vars: Dict[str, Any], headers: Dict[str, str])\ -> Tuple[int, Optional[Union[str, bytes]]]: - if 'user' in session_vars: + if 'authenticated_user' in session_vars: headers['Location'] = '/' return 301, bytes() + env = Environment(loader=FileSystemLoader('templates')) if method == 'GET': - data = ''' - - - - Matemat - - - -

    Matemat

    -
    -
    - Touchkey:
    - -
    - - - ''' - return 200, data.format(username=str(args.username) if 'username' in args else '') + template = env.get_template('touchkey.html') + return 200, template.render(username=str(args.username), uid=int(str(args.uid))) elif method == 'POST': with MatematDatabase('test.db') as db: try: user: User = db.login(str(args.username), touchkey=str(args.touchkey)) except AuthenticationError: - headers['Location'] = f'/touchkey?username={args["username"]}&msg=Please%20try%20again.' + headers['Location'] = f'/touchkey?uid={str(args.uid)}&username={str(args.username)}&fail=1' return 301, bytes() - session_vars['user'] = user + session_vars['authenticated_user'] = user.id + session_vars['authentication_level'] = 1 headers['Location'] = '/' return 301, None return 405, None diff --git a/matemat/webserver/util.py b/matemat/webserver/util.py index 2bc2244..6e19d7b 100644 --- a/matemat/webserver/util.py +++ b/matemat/webserver/util.py @@ -46,8 +46,11 @@ def _parse_multipart(body: bytes, boundary: str) -> List[RequestArgument]: # Add header to hdr dict hk, hv = head.decode('utf-8').split(':') hdr[hk.strip()] = hv.strip() - # At least Content-Type and Content-Disposition must be present - if 'Content-Type' not in hdr or 'Content-Disposition' not in hdr: + # No content type set - set broadest possible type + if 'Content-Type' not in hdr: + hdr['Content-Type'] = 'application/octet-stream' + # At least Content-Disposition must be present + if 'Content-Disposition' not in hdr: raise ValueError('Missing Content-Type or Content-Disposition header') # Extract Content-Disposition header value and its arguments cd, *cdargs = hdr['Content-Disposition'].split(';') diff --git a/requirements.txt b/requirements.txt index b8ab4ea..7663204 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ apsw +jinja2 diff --git a/static/img/thumbnails/products/1.png b/static/img/thumbnails/products/1.png new file mode 100644 index 0000000..f7ceac2 Binary files /dev/null and b/static/img/thumbnails/products/1.png differ diff --git a/static/img/thumbnails/products/2.png b/static/img/thumbnails/products/2.png new file mode 100644 index 0000000..43aebfb Binary files /dev/null and b/static/img/thumbnails/products/2.png differ diff --git a/static/img/thumbnails/users/1.png b/static/img/thumbnails/users/1.png new file mode 100644 index 0000000..9237924 Binary files /dev/null and b/static/img/thumbnails/users/1.png differ diff --git a/static/img/thumbnails/users/2.png b/static/img/thumbnails/users/2.png new file mode 100644 index 0000000..1343867 Binary files /dev/null and b/static/img/thumbnails/users/2.png differ diff --git a/static/js/touchkey.js b/static/js/touchkey.js new file mode 100644 index 0000000..ed18a48 --- /dev/null +++ b/static/js/touchkey.js @@ -0,0 +1,127 @@ + +HTMLCollection.prototype.forEach = Array.prototype.forEach; +HTMLCollection.prototype.slice = Array.prototype.slice; + +initTouchkey = (keepPattern, svgid, formid, formfieldid) => { + + let svg = document.getElementById(svgid); + let form; + if (formid !== null) { + form = document.getElementById(formid); + } + let formfield = document.getElementById(formfieldid); + + let currentStroke = null; + let strokeId = 0; + let doneMap = {}; + let enteredKey = ''; + + let drawLine = (fromX, fromY, toX, toY) => { + let line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + let id = 'l-' + (strokeId++); + let idAttr = document.createAttribute('id'); + let classAttr = document.createAttribute('class'); + let x1attr = document.createAttribute('x1'); + let y1attr = document.createAttribute('y1'); + let x2attr = document.createAttribute('x2'); + let y2attr = document.createAttribute('y2'); + let styleAttr = document.createAttribute('style'); + idAttr.value = id; + classAttr.value = 'l'; + x1attr.value = fromX; + y1attr.value = fromY; + x2attr.value = toX; + y2attr.value = toY; + styleAttr.value = 'stroke: grey; stroke-width: 5%; stroke-linecap: round'; + line.setAttributeNode(idAttr); + line.setAttributeNode(classAttr); + line.setAttributeNode(x1attr); + line.setAttributeNode(y1attr); + line.setAttributeNode(x2attr); + line.setAttributeNode(y2attr); + line.setAttributeNode(styleAttr); + svg.appendChild(line); + return id; + }; + + let endPath = () => { + svg.removeChild(svg.getElementById(currentStroke)); + currentStroke = null; + }; + + let clearTouchkey = () => { + doneMap = {}; + enteredKey = ''; + svg.getElementsByClassName('l').slice().reverse().forEach((line) => { + svg.removeChild(line); + }); + }; + + svg.onmousedown = (ev) => { + clearTouchkey(); + let svgrect = svg.getBoundingClientRect(); + let minId = ''; + let minDist = Infinity; + let minx = 0; + let miny = 0; + doneMap = {}; + document.getElementsByClassName('c').forEach((circle) => { + let x = parseFloat(circle.getAttribute('cx')) / 100.0 * svgrect.width; + let y = parseFloat(circle.getAttribute('cy')) / 100.0 * svgrect.height; + let dist = Math.pow(ev.offsetX - x, 2) + Math.pow(ev.offsetY - y, 2); + if (dist < minDist) { + minDist = dist; + minId = circle.id; + minx = x; + miny = y; + } + }); + currentStroke = drawLine(minx, miny, ev.offsetX, ev.offsetY); + doneMap[minId] = 1; + enteredKey += minId; + }; + + svg.onmouseup = (ev) => { + endPath(); + formfield.value = enteredKey; + if (keepPattern !== true) { + clearTouchkey(); + } + if (formid !== null) { + form.submit(); + } + }; + + svg.onmousemove = (ev) => { + if (currentStroke != null) { + let svgrect = svg.getBoundingClientRect(); + let minId = ''; + let minDist = Infinity; + let minx = 0; + let miny = 0; + document.getElementsByClassName('c').forEach((circle) => { + let x = parseFloat(circle.getAttribute('cx')) / 100.0 * svgrect.width; + let y = parseFloat(circle.getAttribute('cy')) / 100.0 * svgrect.height; + let dist = Math.pow(ev.offsetX - x, 2) + Math.pow(ev.offsetY - y, 2); + if (dist < minDist) { + minDist = dist; + minId = circle.id; + minx = x; + miny = y; + } + }); + if (minDist < 2000 && !(minId in doneMap)) { + let line = svg.getElementById(currentStroke); + line.setAttribute('x2', minx); + line.setAttribute('y2', miny); + currentStroke = drawLine(minx, miny, ev.offsetX, ev.offsetY); + doneMap[minId] = 1; + enteredKey += minId; + } + let line = svg.getElementById(currentStroke); + line.setAttribute('x2', ev.offsetX); + line.setAttribute('y2', ev.offsetY); + } + }; + +}; diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..9f63d6f --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block header %} +{% if user.is_admin %} +

    Administration

    +{% else %} +

    Settings

    +{% endif %} +{{ super() }} +{% endblock %} + +{% block main %} + +{% include "admin_all.html" %} + +{% if user.is_admin %} +{% include "admin_restricted.html" %} +{% endif %} + +{{ super() }} + +{% endblock %} diff --git a/templates/admin_all.html b/templates/admin_all.html new file mode 100644 index 0000000..b8f8d51 --- /dev/null +++ b/templates/admin_all.html @@ -0,0 +1,71 @@ +
    +

    My Account

    + +
    + +
    + + +
    + + +
    + + +
    + + +
    +
    + +
    +

    Avatar

    + +
    + Avatar of {{ user.name }}
    + + +
    + + +
    +
    + +
    +

    Password

    + +
    + +
    + + +
    + + +
    + + +
    +
    + +
    +

    Touchkey

    + +
    + +
    + + Draw a new touchkey (leave empty to disable): +
    + {% include "touchkey.svg" %} +
    + + + +
    + + + +
    diff --git a/templates/admin_restricted.html b/templates/admin_restricted.html new file mode 100644 index 0000000..9fddbbd --- /dev/null +++ b/templates/admin_restricted.html @@ -0,0 +1,90 @@ +
    +

    Create New User

    + +
    + +
    + + +
    + + +
    + + +
    + + +
    + + +
    +
    + +
    +

    Modify User

    + +
    + +
    + + +
    +
    + +
    +

    Create New Product

    + +
    + +
    + + +
    + + +
    + + +
    + + +
    +
    + +
    +

    Restock Product

    + +
    + +
    + + +
    + + +
    +
    + +
    +

    Modify Product

    + +
    + +
    + + +
    +
    \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..ea08742 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,36 @@ + + + + {% block head %} + Matemat + {% endblock %} + + +
    + {% block header %} + + Home + {% if authlevel|default(0) > 1 %} + {% if user.is_admin %} + Administration + {% else %} + Settings + {% endif %} + {% endif %} + {% endblock %} +
    +
    + {% block main %}{% endblock %} +
    + + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..32b26db --- /dev/null +++ b/templates/login.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + + +{% block header %} +

    Welcome

    +{{ super() }} +{% endblock %} + +{% block main %} + +
    + +
    + + +
    + + +
    + +{{ super() }} + +{% endblock %} diff --git a/templates/modproduct.html b/templates/modproduct.html new file mode 100644 index 0000000..d7d3fc5 --- /dev/null +++ b/templates/modproduct.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} + +{% block header %} +

    Administration

    +{{ super() }} +{% endblock %} + +{% block main %} + +
    +

    Modify {{ product.name }}

    + +
    + +
    + + +
    + + +
    + + +
    + +
    +
    + +
    + + +
    + +
    +
    + +
    + +
    + +{{ super() }} + +{% endblock %} diff --git a/templates/moduser.html b/templates/moduser.html new file mode 100644 index 0000000..e76ab68 --- /dev/null +++ b/templates/moduser.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} + +{% block header %} +

    Administration

    +{{ super() }} +{% endblock %} + +{% block main %} + +
    +

    Modify {{ user.name }}

    + +
    + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + +
    +
    + +
    + + +
    + +
    +
    + +
    + +
    + +{{ super() }} + +{% endblock %} diff --git a/templates/productlist.html b/templates/productlist.html new file mode 100644 index 0000000..acb198e --- /dev/null +++ b/templates/productlist.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block header %} +

    Welcome, {{ user.name }}

    + +{{ super() }} + +{% endblock %} + +{% block main %} + +Your balance: {{ user.balance }} + +Deposit CHF 1 +Deposit CHF 10 + +{% for product in products %} + +{% endfor %} +Logout + +{{ super() }} + +{% endblock %} diff --git a/templates/touchkey.html b/templates/touchkey.html new file mode 100644 index 0000000..83e7f08 --- /dev/null +++ b/templates/touchkey.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} + +{% block head %} +{{ super() }} + +{% endblock %} + +{% block header %} +

    Welcome, {{ username }}

    +{{ super() }} +{% endblock %} + +{% block main %} +{% include "touchkey.svg" %} + +
    + + + +
    +Cancel + + + + +{{ super() }} + +{% endblock %} diff --git a/templates/touchkey.svg b/templates/touchkey.svg new file mode 100644 index 0000000..9ca53a1 --- /dev/null +++ b/templates/touchkey.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/templates/userlist.html b/templates/userlist.html new file mode 100644 index 0000000..64a3f82 --- /dev/null +++ b/templates/userlist.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} + +{% block header %} +

    Welcome

    +{{ super() }} +{% endblock %} + +{% block main %} +{% for user in users %} + +{% endfor %} +Password-based login +{{ super() }} +{% endblock %} diff --git a/testing/Dockerfile b/testing/Dockerfile new file mode 100644 index 0000000..126a06f --- /dev/null +++ b/testing/Dockerfile @@ -0,0 +1,10 @@ + +FROM debian:buster + +RUN useradd -d /home/matemat -m matemat +RUN apt-get update -qy +RUN apt-get install -y --no-install-recommends python3-dev python3-pip python3-coverage python3-setuptools build-essential +RUN pip3 install wheel pycodestyle mypy + +WORKDIR /home/matemat +USER matemat diff --git a/touchkey.html b/touchkey.html new file mode 100644 index 0000000..5d5b427 --- /dev/null +++ b/touchkey.html @@ -0,0 +1,147 @@ + + + + + + + +

    Welcome, {{username}}

    + + + + + + + + + + + + + + + + + + + + + + + + + + +