diff --git a/core/__init__.py b/core/__init__.py index c72ba0a..717de33 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -4,3 +4,4 @@ from core.aimedb import AimedbFactory from core.title import TitleServlet from core.utils import Utils from core.mucha import MuchaServlet +from core.frontend import FrontendServlet \ No newline at end of file diff --git a/core/data/database.py b/core/data/database.py index 963d016..70fc3e0 100644 --- a/core/data/database.py +++ b/core/data/database.py @@ -31,7 +31,7 @@ class Data: self.arcade = ArcadeData(self.config, self.session) self.card = CardData(self.config, self.session) self.base = BaseData(self.config, self.session) - self.schema_ver_latest = 1 + self.schema_ver_latest = 2 log_fmt_str = "[%(asctime)s] %(levelname)s | Database | %(message)s" log_fmt = logging.Formatter(log_fmt_str) diff --git a/core/data/schema/user.py b/core/data/schema/user.py index 7d76bbe..9e79891 100644 --- a/core/data/schema/user.py +++ b/core/data/schema/user.py @@ -1,9 +1,14 @@ from enum import Enum from typing import Dict, Optional -from sqlalchemy import Table, Column +from sqlalchemy import Table, Column, and_ from sqlalchemy.types import Integer, String, TIMESTAMP from sqlalchemy.sql.schema import ForeignKey from sqlalchemy.sql import func +from sqlalchemy.dialects.mysql import insert +from sqlalchemy.sql import func, select, Delete +from uuid import uuid4 +from datetime import datetime, timedelta +from sqlalchemy.engine import Row from core.data.schema.base import BaseData, metadata @@ -26,6 +31,7 @@ frontend_session = Table( metadata, Column("id", Integer, primary_key=True, unique=True), Column("user", ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False), + Column("ip", String(15)), Column('session_cookie', String(32), nullable=False, unique=True), Column("expires", TIMESTAMP, nullable=False), mysql_charset='utf8mb4' @@ -37,21 +43,83 @@ class PermissionBits(Enum): PermSysAdmin = 4 class UserData(BaseData): - def create_user(self, username: str = None, email: str = None, password: str = None) -> Optional[int]: - + def create_user(self, id: int = None, username: str = None, email: str = None, password: str = None, permission: int = 1) -> Optional[int]: if email is None: - permission = None - else: - permission = 0 + permission = 1 - sql = aime_user.insert().values(username=username, email=email, password=password, permissions=permission) + if id is None: + sql = insert(aime_user).values( + username=username, + email=email, + password=password, + permissions=permission + ) + else: + sql = insert(aime_user).values( + id=id, + username=username, + email=email, + password=password, + permissions=permission + ) + + conflict = sql.on_duplicate_key_update( + username=username, + email=email, + password=password, + permissions=permission + ) - result = self.execute(sql) + result = self.execute(conflict) if result is None: return None return result.lastrowid + def login(self, user_id: int, passwd: bytes = None, ip: str = "0.0.0.0") -> Optional[str]: + sql = select(aime_user).where(and_(aime_user.c.id == user_id, aime_user.c.password == passwd)) + + result = self.execute(sql) + if result is None: return None + + usr = result.fetchone() + if usr is None: return None + + return self.create_session(user_id, ip) + + def check_session(self, cookie: str, ip: str = "0.0.0.0") -> Optional[Row]: + sql = select(frontend_session).where( + and_( + frontend_session.c.session_cookie == cookie, + frontend_session.c.ip == ip + ) + ) + + result = self.execute(sql) + if result is None: return None + return result.fetchone() + + def delete_session(self, session_id: int) -> bool: + sql = Delete(frontend_session).where(frontend_session.c.id == session_id) + + result = self.execute(sql) + if result is None: return False + return True + + def create_session(self, user_id: int, ip: str = "0.0.0.0", expires: datetime = datetime.now() + timedelta(days=1)) -> Optional[str]: + cookie = uuid4().hex + + sql = insert(frontend_session).values( + user = user_id, + ip = ip, + session_cookie = cookie, + expires = expires + ) + + result = self.execute(sql) + if result is None: + return None + return cookie + def reset_autoincrement(self, ai_value: int) -> None: - # Didn't feel like learning how to do this the right way - # if somebody wants a free PR go nuts I guess + # ALTER TABLE isn't in sqlalchemy so we do this the ugly way sql = f"ALTER TABLE aime_user AUTO_INCREMENT={ai_value}" self.execute(sql) \ No newline at end of file diff --git a/core/data/schema/versions/CORE_1_rollback.sql b/core/data/schema/versions/CORE_1_rollback.sql new file mode 100644 index 0000000..8a1144b --- /dev/null +++ b/core/data/schema/versions/CORE_1_rollback.sql @@ -0,0 +1,2 @@ +ALTER TABLE `frontend_session` +DROP COLUMN `ip`; \ No newline at end of file diff --git a/core/data/schema/versions/CORE_2_upgrade.sql b/core/data/schema/versions/CORE_2_upgrade.sql new file mode 100644 index 0000000..44deb6d --- /dev/null +++ b/core/data/schema/versions/CORE_2_upgrade.sql @@ -0,0 +1,2 @@ +ALTER TABLE `frontend_session` +ADD `ip` CHAR(15); \ No newline at end of file diff --git a/core/frontend.py b/core/frontend.py new file mode 100644 index 0000000..554df13 --- /dev/null +++ b/core/frontend.py @@ -0,0 +1,176 @@ +import logging, coloredlogs +from typing import Any, Dict +from twisted.web import resource +from twisted.web.util import redirectTo +from twisted.web.http import Request +from logging.handlers import TimedRotatingFileHandler +import jinja2 +import bcrypt + +from core.config import CoreConfig +from core.data import Data +from core.utils import Utils + +class FrontendServlet(resource.Resource): + children: Dict[str, Any] = {} + def getChild(self, name: bytes, request: Request): + self.logger.debug(f"{request.getClientIP()} -> {name.decode()}") + if name == b'': + return self + return resource.Resource.getChild(self, name, request) + + def __init__(self, cfg: CoreConfig, config_dir: str) -> None: + self.config = cfg + log_fmt_str = "[%(asctime)s] Frontend | %(levelname)s | %(message)s" + log_fmt = logging.Formatter(log_fmt_str) + self.logger = logging.getLogger("frontend") + self.environment = jinja2.Environment(loader=jinja2.FileSystemLoader("core/frontend")) + + fileHandler = TimedRotatingFileHandler("{0}/{1}.log".format(self.config.server.log_dir, "frontend"), when="d", backupCount=10) + fileHandler.setFormatter(log_fmt) + + consoleHandler = logging.StreamHandler() + consoleHandler.setFormatter(log_fmt) + + self.logger.addHandler(fileHandler) + self.logger.addHandler(consoleHandler) + + self.logger.setLevel(cfg.frontend.loglevel) + coloredlogs.install(level=cfg.frontend.loglevel, logger=self.logger, fmt=log_fmt_str) + + fe_game = FE_Game(cfg, self.environment) + games = Utils.get_all_titles() + for game_dir, game_mod in games.items(): + if hasattr(game_mod, "frontend"): + try: + fe_game.putChild(game_dir.encode(), game_mod.frontend(cfg, self.environment, config_dir)) + except: + raise + + self.putChild(b"gate", FE_Gate(cfg, self.environment)) + self.putChild(b"user", FE_User(cfg, self.environment)) + self.putChild(b"game", fe_game) + + self.logger.info(f"Ready on port {self.config.frontend.port} serving {len(fe_game.children)} games") + + def render_GET(self, request): + self.logger.debug(f"{request.getClientIP()} -> {request.uri.decode()}") + template = self.environment.get_template("index.jinja") + return template.render(server_name=self.config.server.name, title=self.config.server.name).encode("utf-16") + +class FE_Base(resource.Resource): + """ + A Generic skeleton class that all frontend handlers should inherit from + Initializes the environment, data, logger, config, and sets isLeaf to true + It is expected that game implementations of this class overwrite many of these + """ + isLeaf = True + def __init__(self, cfg: CoreConfig, environment: jinja2.Environment, cfg_dir: str = None) -> None: + self.core_config = cfg + self.data = Data(cfg) + self.logger = logging.getLogger('frontend') + self.environment = environment + +class FE_Gate(FE_Base): + def render_GET(self, request: Request): + self.logger.debug(f"{request.getClientIP()} -> {request.uri.decode()}") + uri: str = request.uri.decode() + if uri.startswith("/gate/create"): + return self.create_user(request) + + if b'e' in request.args: + try: + err = int(request.args[b'e'][0].decode()) + except: + err = 0 + + else: err = 0 + + template = self.environment.get_template("gate/gate.jinja") + return template.render(title=f"{self.core_config.server.name} | Login Gate", error=err).encode("utf-16") + + def render_POST(self, request: Request): + uri = request.uri.decode() + ip = request.getClientAddress().host + + if uri == "/gate/gate.login": + access_code: str = request.args[b"access_code"][0].decode() + passwd: str = request.args[b"passwd"][0] + if passwd == b"": + passwd = None + + uid = self.data.card.get_user_id_from_card(access_code) + if uid is None: + return redirectTo(b"/gate?e=1", request) + + if passwd is None: + sesh = self.data.user.login(uid, ip=ip) + + if sesh is not None: + return redirectTo(f"/gate/create?ac={access_code}".encode(), request) + return redirectTo(b"/gate?e=1", request) + + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(passwd, salt) + sesh = self.data.user.login(uid, hashed, ip) + + if sesh is None: + return redirectTo(b"/gate?e=1", request) + + request.addCookie('session', sesh) + return redirectTo(b"/user", request) + + elif uri == "/gate/gate.create": + access_code: str = request.args[b"access_code"][0].decode() + username: str = request.args[b"username"][0] + email: str = request.args[b"email"][0].decode() + passwd: str = request.args[b"passwd"][0] + + uid = self.data.card.get_user_id_from_card(access_code) + if uid is None: + return redirectTo(b"/gate?e=1", request) + + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(passwd, salt) + + result = self.data.user.create_user(uid, username, email, hashed.decode(), 1) + if result is None: + return redirectTo(b"/gate?e=3", request) + + sesh = self.data.user.login(uid, hashed, ip) + if sesh is None: + return redirectTo(b"/gate", request) + request.addCookie('session', sesh) + + return redirectTo(b"/user", request) + + else: + return b"" + + def create_user(self, request: Request): + if b'ac' not in request.args or len(request.args[b'ac'][0].decode()) != 20: + return redirectTo(b"/gate?e=2", request) + + ac = request.args[b'ac'][0].decode() + + template = self.environment.get_template("gate/create.jinja") + return template.render(title=f"{self.core_config.server.name} | Create User", code=ac).encode("utf-16") + +class FE_User(FE_Base): + def render_GET(self, request: Request): + template = self.environment.get_template("user/index.jinja") + return template.render().encode("utf-16") + if b'session' not in request.cookies: + return redirectTo(b"/gate", request) + +class FE_Game(FE_Base): + isLeaf = False + children: Dict[str, Any] = {} + + def getChild(self, name: bytes, request: Request): + if name == b'': + return self + return resource.Resource.getChild(self, name, request) + + def render_GET(self, request: Request) -> bytes: + return redirectTo(b"/user", request) \ No newline at end of file diff --git a/core/frontend/gate/create.jinja b/core/frontend/gate/create.jinja new file mode 100644 index 0000000..f5b78ae --- /dev/null +++ b/core/frontend/gate/create.jinja @@ -0,0 +1,24 @@ +{% extends "index.jinja" %} +{% block content %} +

Create User

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

+ +
+{% endblock content %} \ No newline at end of file diff --git a/core/frontend/gate/gate.jinja b/core/frontend/gate/gate.jinja new file mode 100644 index 0000000..85b3ea5 --- /dev/null +++ b/core/frontend/gate/gate.jinja @@ -0,0 +1,17 @@ +{% extends "index.jinja" %} +{% block content %} +

Gate

+{% include "widgets/err_banner.jinja" %} +
+
+
+ +
+
+
+ +
+

+ +
+{% endblock content %} \ No newline at end of file diff --git a/core/frontend/index.jinja b/core/frontend/index.jinja new file mode 100644 index 0000000..c70688b --- /dev/null +++ b/core/frontend/index.jinja @@ -0,0 +1,88 @@ + + + + {{ title }} + + + + + + {% include "widgets/topbar.jinja" %} + {% block content %} +

{{ server_name }}

+ {% endblock content %} + + \ No newline at end of file diff --git a/core/frontend/user/index.jinja b/core/frontend/user/index.jinja new file mode 100644 index 0000000..dc62294 --- /dev/null +++ b/core/frontend/user/index.jinja @@ -0,0 +1,4 @@ +{% extends "index.jinja" %} +{% block content %} +

testing

+{% endblock content %} \ No newline at end of file diff --git a/core/frontend/widgets/err_banner.jinja b/core/frontend/widgets/err_banner.jinja new file mode 100644 index 0000000..23af92d --- /dev/null +++ b/core/frontend/widgets/err_banner.jinja @@ -0,0 +1,14 @@ +{% if error > 0 %} +
+

Error

+{% if error == 1 %} +Card not registered, or wrong password +{% elif error == 2 %} +Missing or malformed access code +{% elif error == 3 %} +Failed to create user +{% else %} +An unknown error occoured +{% endif %} +
+{% endif %} \ No newline at end of file diff --git a/core/frontend/widgets/topbar.jinja b/core/frontend/widgets/topbar.jinja new file mode 100644 index 0000000..5dbba5d --- /dev/null +++ b/core/frontend/widgets/topbar.jinja @@ -0,0 +1,6 @@ +
+ Navigation +
+
+ +
\ No newline at end of file diff --git a/index.py b/index.py index 0049427..5344ce1 100644 --- a/index.py +++ b/index.py @@ -95,6 +95,7 @@ if __name__ == "__main__": allnet_server_str = f"tcp:{cfg.allnet.port}:interface={cfg.server.listen_address}" title_server_str = f"tcp:{cfg.title.port}:interface={cfg.server.listen_address}" adb_server_str = f"tcp:{cfg.aimedb.port}:interface={cfg.server.listen_address}" + frontend_server_str = f"tcp:{cfg.frontend.port}:interface={cfg.server.listen_address}" billing_server_str = f"tcp:{cfg.billing.port}:interface={cfg.server.listen_address}" if cfg.server.is_develop: @@ -106,6 +107,9 @@ if __name__ == "__main__": endpoints.serverFromString(reactor, allnet_server_str).listen(server.Site(dispatcher)) endpoints.serverFromString(reactor, adb_server_str).listen(AimedbFactory(cfg)) + if cfg.frontend.enable: + endpoints.serverFromString(reactor, frontend_server_str).listen(server.Site(FrontendServlet(cfg, args.config))) + if cfg.billing.port > 0: endpoints.serverFromString(reactor, billing_server_str).listen(server.Site(dispatcher)) diff --git a/requirements.txt b/requirements.txt index 40dbf84..70b4215 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,5 @@ coloredlogs pylibmc wacky Routes +bcrypt +jinja2 diff --git a/requirements_win.txt b/requirements_win.txt index 89527fe..d36e884 100644 --- a/requirements_win.txt +++ b/requirements_win.txt @@ -12,3 +12,5 @@ inflection coloredlogs wacky Routes +bcrypt +jinja2 diff --git a/titles/wacca/__init__.py b/titles/wacca/__init__.py index c0351da..41d8dc2 100644 --- a/titles/wacca/__init__.py +++ b/titles/wacca/__init__.py @@ -2,10 +2,12 @@ from titles.wacca.const import WaccaConstants from titles.wacca.index import WaccaServlet from titles.wacca.read import WaccaReader from titles.wacca.database import WaccaData +from titles.wacca.frontend import WaccaFrontend index = WaccaServlet database = WaccaData reader = WaccaReader +frontend = WaccaFrontend use_default_title = True include_protocol = True diff --git a/titles/wacca/frontend.py b/titles/wacca/frontend.py new file mode 100644 index 0000000..a61d5f4 --- /dev/null +++ b/titles/wacca/frontend.py @@ -0,0 +1,18 @@ +import yaml +import jinja2 +from twisted.web.http import Request + +from core.frontend import FE_Base +from core.config import CoreConfig +from titles.wacca.database import WaccaData +from titles.wacca.config import WaccaConfig + +class WaccaFrontend(FE_Base): + def __init__(self, cfg: CoreConfig, environment: jinja2.Environment, cfg_dir: str) -> None: + super().__init__(cfg, environment) + self.data = WaccaData(cfg) + self.game_cfg = WaccaConfig() + self.game_cfg.update(yaml.safe_load(open(f"{cfg_dir}/wacca.yaml"))) + + def render_GET(self, request: Request) -> bytes: + return b""