diff --git a/contributing.md b/contributing.md new file mode 100644 index 0000000..5397f70 --- /dev/null +++ b/contributing.md @@ -0,0 +1,8 @@ +# Contributing to ARTEMiS +If you would like to contribute to artemis, either by adding features, games, or fixing bugs, you can do so by forking the repo and submitting a pull request [here](https://gitea.tendokyu.moe/Hay1tsme/artemis/pulls). Please make sure, if you're submitting a PR for a game or game version, that you're following the n-0/y-1 guidelines, or it will be rejected. + +## Adding games +Guide WIP + +## Adding game versions +Guide WIP \ No newline at end of file diff --git a/core/allnet.py b/core/allnet.py index 71eeb33..a02c902 100644 --- a/core/allnet.py +++ b/core/allnet.py @@ -48,49 +48,13 @@ class AllnetServlet: self.logger.error("No games detected!") for _, mod in plugins.items(): - for code in mod.game_codes: - if hasattr(mod, "use_default_title") and mod.use_default_title: - if hasattr(mod, "include_protocol") and mod.include_protocol: - if hasattr(mod, "title_secure") and mod.title_secure: - uri = "https://" - - else: - uri = "http://" + if hasattr(mod.index, "get_allnet_info"): + for code in mod.game_codes: + enabled, uri, host = mod.index.get_allnet_info(code, self.config, self.config_folder) + + if enabled: + self.uri_registry[code] = (uri, host) - else: - uri = "" - - if core_cfg.server.is_develop: - uri += f"{core_cfg.title.hostname}:{core_cfg.title.port}" - - else: - uri += f"{core_cfg.title.hostname}" - - uri += f"/{code}/$v" - - if hasattr(mod, "trailing_slash") and mod.trailing_slash: - uri += "/" - - else: - if hasattr(mod, "uri"): - uri = mod.uri - else: - uri = "" - - if hasattr(mod, "host"): - host = mod.host - - elif hasattr(mod, "use_default_host") and mod.use_default_host: - if core_cfg.server.is_develop: - host = f"{core_cfg.title.hostname}:{core_cfg.title.port}" - - else: - host = f"{core_cfg.title.hostname}" - - else: - host = "" - - self.uri_registry[code] = (uri, host) self.logger.info(f"Allnet serving {len(self.uri_registry)} games on port {core_cfg.allnet.port}") def handle_poweron(self, request: Request, _: Dict): diff --git a/core/data/database.py b/core/data/database.py index 65b01aa..ab4c587 100644 --- a/core/data/database.py +++ b/core/data/database.py @@ -1,12 +1,12 @@ import logging, coloredlogs -from typing import Any, Dict, List +from typing import Optional from sqlalchemy.orm import scoped_session, sessionmaker from sqlalchemy.exc import SQLAlchemyError from sqlalchemy import create_engine from logging.handlers import TimedRotatingFileHandler -from datetime import datetime -import importlib, os, json - +import importlib, os +import secrets, string +import bcrypt from hashlib import sha256 from core.config import CoreConfig @@ -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 = 2 + self.schema_ver_latest = 4 log_fmt_str = "[%(asctime)s] %(levelname)s | Database | %(message)s" log_fmt = logging.Formatter(log_fmt_str) @@ -138,3 +138,61 @@ class Data: return None self.logger.info(f"Successfully migrated {game} to schema version {version}") + + def create_owner(self, email: Optional[str] = None) -> None: + pw = ''.join(secrets.choice(string.ascii_letters + string.digits) for i in range(20)) + hash = bcrypt.hashpw(pw.encode(), bcrypt.gensalt()) + + user_id = self.user.create_user(email=email, permission=255, password=hash) + if user_id is None: + self.logger.error(f"Failed to create owner with email {email}") + return + + card_id = self.card.create_card(user_id, "00000000000000000000") + if card_id is None: + self.logger.error(f"Failed to create card for owner with id {user_id}") + return + + self.logger.warn(f"Successfully created owner with email {email}, access code 00000000000000000000, and password {pw} Make sure to change this password and assign a real card ASAP!") + + def migrate_card(self, old_ac: str, new_ac: str, should_force: bool) -> None: + if old_ac == new_ac: + self.logger.error("Both access codes are the same!") + return + + new_card = self.card.get_card_by_access_code(new_ac) + if new_card is None: + self.card.update_access_code(old_ac, new_ac) + return + + if not should_force: + self.logger.warn(f"Card already exists for access code {new_ac} (id {new_card['id']}). If you wish to continue, rerun with the '--force' flag."\ + f" All exiting data on the target card {new_ac} will be perminently erased and replaced with data from card {old_ac}.") + return + + self.logger.info(f"All exiting data on the target card {new_ac} will be perminently erased and replaced with data from card {old_ac}.") + self.card.delete_card(new_card["id"]) + self.card.update_access_code(old_ac, new_ac) + + hanging_user = self.user.get_user(new_card["user"]) + if hanging_user["password"] is None: + self.logger.info(f"Delete hanging user {hanging_user['id']}") + self.user.delete_user(hanging_user['id']) + + def delete_hanging_users(self) -> None: + """ + Finds and deletes users that have not registered for the webui that have no cards assocated with them. + """ + unreg_users = self.user.get_unregistered_users() + if unreg_users is None: + self.logger.error("Error occoured finding unregistered users") + + for user in unreg_users: + cards = self.card.get_user_cards(user['id']) + if cards is None: + self.logger.error(f"Error getting cards for user {user['id']}") + continue + + if not cards: + self.logger.info(f"Delete hanging user {user['id']}") + self.user.delete_user(user['id']) diff --git a/core/data/schema/base.py b/core/data/schema/base.py index 78f3ab4..955c772 100644 --- a/core/data/schema/base.py +++ b/core/data/schema/base.py @@ -29,6 +29,7 @@ event_log = Table( Column("system", String(255), nullable=False), Column("type", String(255), nullable=False), Column("severity", Integer, nullable=False), + Column("message", String(1000), nullable=False), Column("details", JSON, nullable=False), Column("when_logged", TIMESTAMP, nullable=False, server_default=func.now()), mysql_charset='utf8mb4' @@ -85,7 +86,12 @@ class BaseData(): result = self.execute(sql) if result is None: return None - return result.fetchone()["version"] + + row = result.fetchone() + if row is None: + return None + + return row["version"] def set_schema_ver(self, ver: int, game: str = "CORE") -> Optional[int]: sql = insert(schema_ver).values(game = game, version = ver) @@ -97,12 +103,12 @@ class BaseData(): return None return result.lastrowid - def log_event(self, system: str, type: str, severity: int, details: Dict) -> Optional[int]: - sql = event_log.insert().values(system = system, type = type, severity = severity, details = json.dumps(details)) + def log_event(self, system: str, type: str, severity: int, message: str, details: Dict = {}) -> Optional[int]: + sql = event_log.insert().values(system = system, type = type, severity = severity, message = message, details = json.dumps(details)) result = self.execute(sql) if result is None: - self.logger.error(f"{__name__}: Failed to insert event into event log! system = {system}, type = {type}, severity = {severity}, details = {details}") + self.logger.error(f"{__name__}: Failed to insert event into event log! system = {system}, type = {type}, severity = {severity}, message = {message}") return None return result.lastrowid diff --git a/core/data/schema/card.py b/core/data/schema/card.py index 7c0c945..dc74832 100644 --- a/core/data/schema/card.py +++ b/core/data/schema/card.py @@ -3,6 +3,7 @@ from sqlalchemy import Table, Column, UniqueConstraint from sqlalchemy.types import Integer, String, Boolean, TIMESTAMP from sqlalchemy.sql.schema import ForeignKey from sqlalchemy.sql import func +from sqlalchemy.engine import Row from core.data.schema.base import BaseData, metadata @@ -21,21 +22,44 @@ aime_card = Table( ) class CardData(BaseData): - def get_user_id_from_card(self, access_code: str) -> Optional[int]: - """ - Given a 20 digit access code as a string, get the user id associated with that card - """ + def get_card_by_access_code(self, access_code: str) -> Optional[Row]: sql = aime_card.select(aime_card.c.access_code == access_code) result = self.execute(sql) if result is None: return None + return result.fetchone() - card = result.fetchone() + def get_card_by_id(self, card_id: int) -> Optional[Row]: + sql = aime_card.select(aime_card.c.id == card_id) + + result = self.execute(sql) + if result is None: return None + return result.fetchone() + + def update_access_code(self, old_ac: str, new_ac: str) -> None: + sql = aime_card.update(aime_card.c.access_code == old_ac).values(access_code = new_ac) + + result = self.execute(sql) + if result is None: + self.logger.error(f"Failed to change card access code from {old_ac} to {new_ac}") + + def get_user_id_from_card(self, access_code: str) -> Optional[int]: + """ + Given a 20 digit access code as a string, get the user id associated with that card + """ + card = self.get_card_by_access_code(access_code) if card is None: return None return int(card["user"]) + + def delete_card(self, card_id: int) -> None: + sql = aime_card.delete(aime_card.c.id == card_id) - def get_user_cards(self, aime_id: int) -> Optional[List[Dict]]: + result = self.execute(sql) + if result is None: + self.logger.error(f"Failed to delete card with id {card_id}") + + def get_user_cards(self, aime_id: int) -> Optional[List[Row]]: """ Returns all cards owned by a user """ diff --git a/core/data/schema/user.py b/core/data/schema/user.py index 9e79891..aee07e9 100644 --- a/core/data/schema/user.py +++ b/core/data/schema/user.py @@ -1,14 +1,12 @@ from enum import Enum -from typing import Dict, Optional -from sqlalchemy import Table, Column, and_ +from typing import Optional, List +from sqlalchemy import Table, Column 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.sql import func, select from sqlalchemy.engine import Row +import bcrypt from core.data.schema.base import BaseData, metadata @@ -26,17 +24,6 @@ aime_user = Table( mysql_charset='utf8mb4' ) -frontend_session = Table( - "frontend_session", - 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' -) - class PermissionBits(Enum): PermUser = 1 PermMod = 2 @@ -44,9 +31,6 @@ class PermissionBits(Enum): class UserData(BaseData): 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 = 1 - if id is None: sql = insert(aime_user).values( username=username, @@ -74,52 +58,40 @@ class UserData(BaseData): 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) - + def get_user(self, user_id: int) -> Optional[Row]: + sql = select(aime_user).where(aime_user.c.id == user_id) result = self.execute(sql) if result is None: return False - return True + return result.fetchone() + + def check_password(self, user_id: int, passwd: bytes = None) -> bool: + usr = self.get_user(user_id) + if usr is None: return False - 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 + if usr['password'] is None: + return False - 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 + return bcrypt.checkpw(passwd, usr['password'].encode()) def reset_autoincrement(self, ai_value: int) -> None: # 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 + self.execute(sql) + + def delete_user(self, user_id: int) -> None: + sql = aime_user.delete(aime_user.c.id == user_id) + + result = self.execute(sql) + if result is None: + self.logger.error(f"Failed to delete user with id {user_id}") + + def get_unregistered_users(self) -> List[Row]: + """ + Returns a list of users who have not registered with the webui. They may or may not have cards. + """ + sql = select(aime_user).where(aime_user.c.password == None) + + result = self.execute(sql) + if result is None: + return None + return result.fetchall() \ No newline at end of file diff --git a/core/data/schema/versions/CORE_2_rollback.sql b/core/data/schema/versions/CORE_2_rollback.sql new file mode 100644 index 0000000..8944df0 --- /dev/null +++ b/core/data/schema/versions/CORE_2_rollback.sql @@ -0,0 +1 @@ +ALTER TABLE `event_log` DROP COLUMN `message`; \ No newline at end of file diff --git a/core/data/schema/versions/CORE_3_rollback.sql b/core/data/schema/versions/CORE_3_rollback.sql new file mode 100644 index 0000000..9132cc3 --- /dev/null +++ b/core/data/schema/versions/CORE_3_rollback.sql @@ -0,0 +1,12 @@ +CREATE TABLE `frontend_session` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user` int(11) NOT NULL, + `ip` varchar(15) DEFAULT NULL, + `session_cookie` varchar(32) NOT NULL, + `expires` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `id` (`id`), + UNIQUE KEY `session_cookie` (`session_cookie`), + KEY `user` (`user`), + CONSTRAINT `frontend_session_ibfk_1` FOREIGN KEY (`user`) REFERENCES `aime_user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4; \ No newline at end of file diff --git a/core/data/schema/versions/CORE_3_upgrade.sql b/core/data/schema/versions/CORE_3_upgrade.sql new file mode 100644 index 0000000..cc0e8c6 --- /dev/null +++ b/core/data/schema/versions/CORE_3_upgrade.sql @@ -0,0 +1 @@ +ALTER TABLE `event_log` ADD COLUMN `message` VARCHAR(1000) NOT NULL AFTER `severity`; \ No newline at end of file diff --git a/core/data/schema/versions/CORE_4_upgrade.sql b/core/data/schema/versions/CORE_4_upgrade.sql new file mode 100644 index 0000000..6a04c74 --- /dev/null +++ b/core/data/schema/versions/CORE_4_upgrade.sql @@ -0,0 +1 @@ +DROP TABLE `frontend_session`; \ No newline at end of file diff --git a/core/frontend.py b/core/frontend.py index 780698e..6f95073 100644 --- a/core/frontend.py +++ b/core/frontend.py @@ -4,6 +4,9 @@ from twisted.web import resource from twisted.web.util import redirectTo from twisted.web.http import Request from logging.handlers import TimedRotatingFileHandler +from twisted.web.server import Session +from zope.interface import Interface, Attribute, implementer +from twisted.python.components import registerAdapter import jinja2 import bcrypt @@ -11,6 +14,18 @@ from core.config import CoreConfig from core.data import Data from core.utils import Utils +class IUserSession(Interface): + userId = Attribute("User's ID") + current_ip = Attribute("User's current ip address") + permissions = Attribute("User's permission level") + +@implementer(IUserSession) +class UserSession(object): + def __init__(self, session): + self.userId = 0 + self.current_ip = "0.0.0.0" + self.permissions = 0 + class FrontendServlet(resource.Resource): def getChild(self, name: bytes, request: Request): self.logger.debug(f"{request.getClientIP()} -> {name.decode()}") @@ -38,6 +53,7 @@ class FrontendServlet(resource.Resource): self.logger.setLevel(cfg.frontend.loglevel) coloredlogs.install(level=cfg.frontend.loglevel, logger=self.logger, fmt=log_fmt_str) + registerAdapter(UserSession, Session, IUserSession) fe_game = FE_Game(cfg, self.environment) games = Utils.get_all_titles() @@ -59,8 +75,8 @@ class FrontendServlet(resource.Resource): def render_GET(self, request): self.logger.debug(f"{request.getClientIP()} -> {request.uri.decode()}") - template = self.environment.get_template("core/frontend/index.jinja") - return template.render(server_name=self.config.server.name, title=self.config.server.name, game_list=self.game_list).encode("utf-16") + template = self.environment.get_template("core/frontend/index.jinja") + return template.render(server_name=self.config.server.name, title=self.config.server.name, game_list=self.game_list, sesh=vars(IUserSession(request.getSession()))).encode("utf-16") class FE_Base(resource.Resource): """ @@ -80,6 +96,12 @@ 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() + + sesh = request.getSession() + usr_sesh = IUserSession(sesh) + if usr_sesh.userId > 0: + return redirectTo(b"/user", request) + if uri.startswith("/gate/create"): return self.create_user(request) @@ -92,7 +114,7 @@ class FE_Gate(FE_Base): else: err = 0 template = self.environment.get_template("core/frontend/gate/gate.jinja") - return template.render(title=f"{self.core_config.server.name} | Login Gate", error=err).encode("utf-16") + return template.render(title=f"{self.core_config.server.name} | Login Gate", error=err, sesh=vars(usr_sesh)).encode("utf-16") def render_POST(self, request: Request): uri = request.uri.decode() @@ -100,7 +122,7 @@ class FE_Gate(FE_Base): if uri == "/gate/gate.login": access_code: str = request.args[b"access_code"][0].decode() - passwd: str = request.args[b"passwd"][0] + passwd: bytes = request.args[b"passwd"][0] if passwd == b"": passwd = None @@ -109,20 +131,22 @@ class FE_Gate(FE_Base): return redirectTo(b"/gate?e=1", request) if passwd is None: - sesh = self.data.user.login(uid, ip=ip) + sesh = self.data.user.check_password(uid) 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: + if not self.data.user.check_password(uid, passwd): return redirectTo(b"/gate?e=1", request) - - request.addCookie('session', sesh) + + self.logger.info(f"Successful login of user {uid} at {ip}") + + sesh = request.getSession() + usr_sesh = IUserSession(sesh) + usr_sesh.userId = uid + usr_sesh.current_ip = ip + return redirectTo(b"/user", request) elif uri == "/gate/gate.create": @@ -142,10 +166,8 @@ class FE_Gate(FE_Base): if result is None: return redirectTo(b"/gate?e=3", request) - sesh = self.data.user.login(uid, hashed, ip) - if sesh is None: + if not self.data.user.check_password(uid, passwd.encode()): return redirectTo(b"/gate", request) - request.addCookie('session', sesh) return redirectTo(b"/user", request) @@ -159,14 +181,18 @@ class FE_Gate(FE_Base): ac = request.args[b'ac'][0].decode() template = self.environment.get_template("core/frontend/gate/create.jinja") - return template.render(title=f"{self.core_config.server.name} | Create User", code=ac).encode("utf-16") + return template.render(title=f"{self.core_config.server.name} | Create User", code=ac, sesh={"userId": 0}).encode("utf-16") class FE_User(FE_Base): def render_GET(self, request: Request): template = self.environment.get_template("core/frontend/user/index.jinja") - return template.render().encode("utf-16") - if b'session' not in request.cookies: + + sesh: Session = request.getSession() + usr_sesh = IUserSession(sesh) + if usr_sesh.userId == 0: return redirectTo(b"/gate", request) + + return template.render(title=f"{self.core_config.server.name} | Account", sesh=vars(usr_sesh)).encode("utf-16") class FE_Game(FE_Base): isLeaf = False diff --git a/core/frontend/gate/gate.jinja b/core/frontend/gate/gate.jinja index 760fbab..90abb98 100644 --- a/core/frontend/gate/gate.jinja +++ b/core/frontend/gate/gate.jinja @@ -2,10 +2,23 @@ {% block content %}