diff --git a/core/allnet.py b/core/allnet.py index 2fd6db2..6610c23 100644 --- a/core/allnet.py +++ b/core/allnet.py @@ -176,18 +176,18 @@ class AllnetServlet: else AllnetJapanRegionId.AICHI.value ) resp.region_name0 = ( - arcade["country"] - if arcade["country"] is not None - else AllnetCountryCode.JAPAN.value - ) - resp.region_name1 = ( arcade["state"] if arcade["state"] is not None else AllnetJapanRegionId.AICHI.name ) + resp.region_name1 = ( + arcade["country"] + if arcade["country"] is not None + else AllnetCountryCode.JAPAN.value + ) resp.region_name2 = arcade["city"] if arcade["city"] is not None else "" - resp.client_timezone = ( - arcade["timezone"] if arcade["timezone"] is not None else "+0900" + resp.client_timezone = ( # lmao + arcade["timezone"] if arcade["timezone"] is not None else "+0900" if req.format_ver == 3 else "+09:00" ) if req.game_id not in self.uri_registry: @@ -295,7 +295,6 @@ class AllnetServlet: return self.to_dfi(res_str)""" return res_str - def handle_dlorder_ini(self, request: Request, match: Dict) -> bytes: if "file" not in match: diff --git a/core/data/schema/arcade.py b/core/data/schema/arcade.py index 31d48f6..ce7c30a 100644 --- a/core/data/schema/arcade.py +++ b/core/data/schema/arcade.py @@ -218,9 +218,16 @@ class ArcadeData(BaseData): return True - def find_arcade_by_name(self, name: str) -> List[Row]: + def get_arcade_by_name(self, name: str) -> Optional[List[Row]]: sql = arcade.select(or_(arcade.c.name.like(f"%{name}%"), arcade.c.nickname.like(f"%{name}%"))) result = self.execute(sql) if result is None: - return False + return None + return result.fetchall() + + def get_arcades_by_ip(self, ip: str) -> Optional[List[Row]]: + sql = arcade.select().where(arcade.c.ip == ip) + result = self.execute(sql) + if result is None: + return None return result.fetchall() diff --git a/core/frontend.py b/core/frontend.py index 7b028ee..0ee2211 100644 --- a/core/frontend.py +++ b/core/frontend.py @@ -21,6 +21,7 @@ class IUserSession(Interface): userId = Attribute("User's ID") current_ip = Attribute("User's current ip address") permissions = Attribute("User's permission level") + ongeki_version = Attribute("User's selected Ongeki Version") class PermissionOffset(Enum): USER = 0 # Regular user @@ -36,6 +37,7 @@ class UserSession(object): self.userId = 0 self.current_ip = "0.0.0.0" self.permissions = 0 + self.ongeki_version = 7 class FrontendServlet(resource.Resource): @@ -304,9 +306,9 @@ class FE_System(FE_Base): def render_GET(self, request: Request): uri = request.uri.decode() template = self.environment.get_template("core/frontend/sys/index.jinja") - usrlist = [] - aclist = [] - cablist = [] + usrlist: List[Dict] = [] + aclist: List[Dict] = [] + cablist: List[Dict] = [] sesh: Session = request.getSession() usr_sesh = IUserSession(sesh) @@ -339,6 +341,7 @@ class FE_System(FE_Base): ac_id_search = uri_parse.get("arcadeId") ac_name_search = uri_parse.get("arcadeName") ac_user_search = uri_parse.get("arcadeUser") + ac_ip_search = uri_parse.get("arcadeIp") if ac_id_search is not None: u = self.data.arcade.get_arcade(ac_id_search[0]) @@ -346,14 +349,22 @@ class FE_System(FE_Base): aclist.append(u._asdict()) elif ac_name_search is not None: - ul = self.data.arcade.find_arcade_by_name(ac_name_search[0]) - for u in ul: - aclist.append(u._asdict()) + ul = self.data.arcade.get_arcade_by_name(ac_name_search[0]) + if ul is not None: + for u in ul: + aclist.append(u._asdict()) elif ac_user_search is not None: ul = self.data.arcade.get_arcades_managed_by_user(ac_user_search[0]) - for u in ul: - aclist.append(u._asdict()) + if ul is not None: + for u in ul: + aclist.append(u._asdict()) + + elif ac_ip_search is not None: + ul = self.data.arcade.get_arcades_by_ip(ac_ip_search[0]) + if ul is not None: + for u in ul: + aclist.append(u._asdict()) elif uri.startswith("/sys/lookup.cab?"): uri_parse = parse.parse_qs(uri.replace("/sys/lookup.cab?", "")) # lop off the first bit diff --git a/core/frontend/index.jinja b/core/frontend/index.jinja index 7e4a1ca..3dacbe5 100644 --- a/core/frontend/index.jinja +++ b/core/frontend/index.jinja @@ -4,6 +4,7 @@ {{ title }} + diff --git a/core/frontend/sys/index.jinja b/core/frontend/sys/index.jinja index 2da821e..120051a 100644 --- a/core/frontend/sys/index.jinja +++ b/core/frontend/sys/index.jinja @@ -8,8 +8,8 @@

User Search

- - + +
OR
@@ -18,8 +18,8 @@
OR
- - + +

@@ -30,20 +30,25 @@

Arcade Search

-
- - -
- OR
OR +
+ + +
+ OR
+ OR +
+ + +

@@ -52,13 +57,13 @@

Machine Search

- - + +
OR
- - + +
OR
@@ -75,19 +80,19 @@ {% if sesh.permissions >= 2 %} {% endif %} {% if sesh.permissions >= 4 %} {% endif %} diff --git a/example_config/ongeki.yaml b/example_config/ongeki.yaml index 90233b3..e4088c0 100644 --- a/example_config/ongeki.yaml +++ b/example_config/ongeki.yaml @@ -35,3 +35,6 @@ version: card_maker: 1.30.01 7: card_maker: 1.35.03 + +crypto: + encrypted_only: False \ No newline at end of file diff --git a/titles/chuni/read.py b/titles/chuni/read.py index 0051e5b..7100ae6 100644 --- a/titles/chuni/read.py +++ b/titles/chuni/read.py @@ -169,8 +169,10 @@ class ChuniReader(BaseReader): fumen_path = MusicFumenData.find("file").find("path") if fumen_path is not None: - chart_id = MusicFumenData.find("type").find("id").text - if chart_id == "4": + chart_type = MusicFumenData.find("type") + chart_id = chart_type.find("id").text + chart_diff = chart_type.find("str").text + if chart_diff == "WorldsEnd" and (chart_id == "4" or chart_id == "5"): # 4 in SDBT, 5 in SDHD level = float(xml_root.find("starDifType").text) we_chara = ( xml_root.find("worldsEndTagName") diff --git a/titles/ongeki/__init__.py b/titles/ongeki/__init__.py index b887ba6..e4e0ce2 100644 --- a/titles/ongeki/__init__.py +++ b/titles/ongeki/__init__.py @@ -2,9 +2,11 @@ from titles.ongeki.index import OngekiServlet from titles.ongeki.const import OngekiConstants from titles.ongeki.database import OngekiData from titles.ongeki.read import OngekiReader +from titles.ongeki.frontend import OngekiFrontend index = OngekiServlet database = OngekiData reader = OngekiReader +frontend = OngekiFrontend game_codes = [OngekiConstants.GAME_CODE] current_schema_version = 5 diff --git a/titles/ongeki/base.py b/titles/ongeki/base.py index ace1d12..ccf24f5 100644 --- a/titles/ongeki/base.py +++ b/titles/ongeki/base.py @@ -978,35 +978,38 @@ class OngekiBase: """ Added in Bright """ - rival_list = self.data.profile.get_rivals(data["userId"]) - if rival_list is None or len(rival_list) < 1: + + rival_list = [] + user_rivals = self.data.profile.get_rivals(data["userId"]) + for rival in user_rivals: + tmp = {} + tmp["rivalUserId"] = rival[0] + rival_list.append(tmp) + + if user_rivals is None or len(rival_list) < 1: return { "userId": data["userId"], "length": 0, "userRivalList": [], } - return { "userId": data["userId"], "length": len(rival_list), - "userRivalList": rival_list._asdict(), + "userRivalList": rival_list, } - def handle_get_user_rival_data_api_reqiest(self, data: Dict) -> Dict: + def handle_get_user_rival_data_api_request(self, data: Dict) -> Dict: """ Added in Bright """ rivals = [] - for rival in data["userRivalList"]: name = self.data.profile.get_profile_name( rival["rivalUserId"], self.version ) if name is None: continue - - rivals.append({"rivalUserId": rival["rival"], "rivalUserName": name}) - + rivals.append({"rivalUserId": rival["rivalUserId"], "rivalUserName": name}) return { "userId": data["userId"], "length": len(rivals), @@ -1027,7 +1030,6 @@ class OngekiBase: for song in music["userMusicList"]: song["userRivalMusicDetailList"] = song["userMusicDetailList"] song.pop("userMusicDetailList") - return { "userId": data["userId"], "rivalUserId": rival_id, diff --git a/titles/ongeki/config.py b/titles/ongeki/config.py index c20b1ed..2321af6 100644 --- a/titles/ongeki/config.py +++ b/titles/ongeki/config.py @@ -48,9 +48,30 @@ class OngekiCardMakerVersionConfig: self.__config, "ongeki", "version", default={} ).get(version) +class OngekiCryptoConfig: + def __init__(self, parent_config: "OngekiConfig") -> None: + self.__config = parent_config + + @property + def keys(self) -> Dict: + """ + in the form of: + internal_version: [key, iv] + all values are hex strings + """ + return CoreConfig.get_config_field( + self.__config, "ongeki", "crypto", "keys", default={} + ) + + @property + def encrypted_only(self) -> bool: + return CoreConfig.get_config_field( + self.__config, "ongeki", "crypto", "encrypted_only", default=False + ) class OngekiConfig(dict): def __init__(self) -> None: self.server = OngekiServerConfig(self) self.gachas = OngekiGachaConfig(self) self.version = OngekiCardMakerVersionConfig(self) + self.crypto = OngekiCryptoConfig(self) diff --git a/titles/ongeki/frontend.py b/titles/ongeki/frontend.py new file mode 100644 index 0000000..987776f --- /dev/null +++ b/titles/ongeki/frontend.py @@ -0,0 +1,87 @@ +import yaml +import jinja2 +from twisted.web.http import Request +from os import path +from twisted.web.util import redirectTo +from twisted.web.server import Session + +from core.frontend import FE_Base, IUserSession +from core.config import CoreConfig + +from titles.ongeki.config import OngekiConfig +from titles.ongeki.const import OngekiConstants +from titles.ongeki.database import OngekiData +from titles.ongeki.base import OngekiBase + + +class OngekiFrontend(FE_Base): + def __init__( + self, cfg: CoreConfig, environment: jinja2.Environment, cfg_dir: str + ) -> None: + super().__init__(cfg, environment) + self.data = OngekiData(cfg) + self.game_cfg = OngekiConfig() + if path.exists(f"{cfg_dir}/{OngekiConstants.CONFIG_NAME}"): + self.game_cfg.update( + yaml.safe_load(open(f"{cfg_dir}/{OngekiConstants.CONFIG_NAME}")) + ) + self.nav_name = "O.N.G.E.K.I." + self.version_list = OngekiConstants.VERSION_NAMES + + def render_GET(self, request: Request) -> bytes: + template = self.environment.get_template( + "titles/ongeki/frontend/ongeki_index.jinja" + ) + sesh: Session = request.getSession() + usr_sesh = IUserSession(sesh) + self.version = usr_sesh.ongeki_version + if getattr(usr_sesh, "userId", 0) != 0: + profile_data =self.data.profile.get_profile_data(usr_sesh.userId, self.version) + rival_list = self.data.profile.get_rivals(usr_sesh.userId) + rival_data = { + "userRivalList": rival_list, + "userId": usr_sesh.userId + } + rival_info = OngekiBase.handle_get_user_rival_data_api_request(self, rival_data) + + return template.render( + data=self.data.profile, + title=f"{self.core_config.server.name} | {self.nav_name}", + game_list=self.environment.globals["game_list"], + gachas=self.game_cfg.gachas.enabled_gachas, + profile_data=profile_data, + rival_info=rival_info["userRivalDataList"], + version_list=self.version_list, + version=self.version, + sesh=vars(usr_sesh) + ).encode("utf-16") + else: + return redirectTo(b"/gate/", request) + + def render_POST(self, request: Request): + uri = request.uri.decode() + sesh: Session = request.getSession() + usr_sesh = IUserSession(sesh) + if hasattr(usr_sesh, "userId"): + if uri == "/game/ongeki/rival.add": + rival_id = request.args[b"rivalUserId"][0].decode() + self.data.profile.put_rival(usr_sesh.userId, rival_id) + # self.logger.info(f"{usr_sesh.userId} added a rival") + return redirectTo(b"/game/ongeki/", request) + + elif uri == "/game/ongeki/rival.delete": + rival_id = request.args[b"rivalUserId"][0].decode() + self.data.profile.delete_rival(usr_sesh.userId, rival_id) + # self.logger.info(f"{response}") + return redirectTo(b"/game/ongeki/", request) + + elif uri == "/game/ongeki/version.change": + ongeki_version=request.args[b"version"][0].decode() + if(ongeki_version.isdigit()): + usr_sesh.ongeki_version=int(ongeki_version) + return redirectTo(b"/game/ongeki/", request) + + else: + return b"Something went wrong" + else: + return b"User is not logged in" diff --git a/titles/ongeki/frontend/js/ongeki_scripts.js b/titles/ongeki/frontend/js/ongeki_scripts.js new file mode 100644 index 0000000..6de309b --- /dev/null +++ b/titles/ongeki/frontend/js/ongeki_scripts.js @@ -0,0 +1,24 @@ +function deleteRival(rivalUserId){ + + $(document).ready(function () { + $.post("/game/ongeki/rival.delete", + { + rivalUserId + }, + function(data,status){ + window.location.replace("/game/ongeki/") + }) + }); +} +function changeVersion(sel){ + + $(document).ready(function () { + $.post("/game/ongeki/version.change", + { + version: sel.value + }, + function(data,status){ + window.location.replace("/game/ongeki/") + }) + }); +} diff --git a/titles/ongeki/frontend/ongeki_index.jinja b/titles/ongeki/frontend/ongeki_index.jinja new file mode 100644 index 0000000..b7b5a90 --- /dev/null +++ b/titles/ongeki/frontend/ongeki_index.jinja @@ -0,0 +1,83 @@ +{% extends "core/frontend/index.jinja" %} +{% block content %} + +{% if sesh is defined and sesh["userId"] > 0 %} +
+
+
+ +
+
+

Profile

+

Version: + +

+
+
+
+
+

Name: {{ profile_data.userName if profile_data.userName is defined else "Profile not found" }}

+
+
+

ID: {{ profile_data.user if profile_data.user is defined else 'Profile not found' }}

+
+
+
+
+

Rivals

+
+
+ + + + + + + + + + {% for rival in rival_info%} + + + + + + {% endfor %} + +
IDNameDelete
{{rival.rivalUserId}}{{rival.rivalUserName}}
+
+ +
+ + +{% else %} +

Not Currently Logged In

+{% endif %} +{% endblock content %} \ No newline at end of file diff --git a/titles/ongeki/index.py b/titles/ongeki/index.py index af206e9..a89e8c2 100644 --- a/titles/ongeki/index.py +++ b/titles/ongeki/index.py @@ -7,6 +7,10 @@ import logging import coloredlogs import zlib from logging.handlers import TimedRotatingFileHandler +from Crypto.Cipher import AES +from Crypto.Util.Padding import pad +from Crypto.Protocol.KDF import PBKDF2 +from Crypto.Hash import SHA1 from os import path from typing import Tuple @@ -28,6 +32,7 @@ class OngekiServlet: def __init__(self, core_cfg: CoreConfig, cfg_dir: str) -> None: self.core_cfg = core_cfg self.game_cfg = OngekiConfig() + self.hash_table: Dict[Dict[str, str]] = {} if path.exists(f"{cfg_dir}/{OngekiConstants.CONFIG_NAME}"): self.game_cfg.update( yaml.safe_load(open(f"{cfg_dir}/{OngekiConstants.CONFIG_NAME}")) @@ -45,27 +50,60 @@ class OngekiServlet: ] self.logger = logging.getLogger("ongeki") - log_fmt_str = "[%(asctime)s] Ongeki | %(levelname)s | %(message)s" - log_fmt = logging.Formatter(log_fmt_str) - fileHandler = TimedRotatingFileHandler( - "{0}/{1}.log".format(self.core_cfg.server.log_dir, "ongeki"), - encoding="utf8", - when="d", - backupCount=10, - ) - fileHandler.setFormatter(log_fmt) + if not hasattr(self.logger, "inited"): + log_fmt_str = "[%(asctime)s] Ongeki | %(levelname)s | %(message)s" + log_fmt = logging.Formatter(log_fmt_str) + fileHandler = TimedRotatingFileHandler( + "{0}/{1}.log".format(self.core_cfg.server.log_dir, "ongeki"), + encoding="utf8", + when="d", + backupCount=10, + ) - consoleHandler = logging.StreamHandler() - consoleHandler.setFormatter(log_fmt) + fileHandler.setFormatter(log_fmt) - self.logger.addHandler(fileHandler) - self.logger.addHandler(consoleHandler) + consoleHandler = logging.StreamHandler() + consoleHandler.setFormatter(log_fmt) - self.logger.setLevel(self.game_cfg.server.loglevel) - coloredlogs.install( - level=self.game_cfg.server.loglevel, logger=self.logger, fmt=log_fmt_str - ) + self.logger.addHandler(fileHandler) + self.logger.addHandler(consoleHandler) + + self.logger.setLevel(self.game_cfg.server.loglevel) + coloredlogs.install( + level=self.game_cfg.server.loglevel, logger=self.logger, fmt=log_fmt_str + ) + self.logger.inited = True + + for version, keys in self.game_cfg.crypto.keys.items(): + if len(keys) < 3: + continue + + self.hash_table[version] = {} + + method_list = [ + method + for method in dir(self.versions[version]) + if not method.startswith("__") + ] + for method in method_list: + method_fixed = inflection.camelize(method)[6:-7] + # number of iterations is 64 on Bright Memory + iter_count = 64 + hash = PBKDF2( + method_fixed, + bytes.fromhex(keys[2]), + 128, + count=iter_count, + hmac_hash_module=SHA1, + ) + + hashed_name = hash.hex()[:32] # truncate unused bytes like the game does + self.hash_table[version][hashed_name] = method_fixed + + self.logger.debug( + f"Hashed v{version} method {method_fixed} with {bytes.fromhex(keys[2])} to get {hash.hex()[:32]}" + ) @classmethod def get_allnet_info( @@ -100,6 +138,7 @@ class OngekiServlet: req_raw = request.content.getvalue() url_split = url_path.split("/") + encrtped = False internal_ver = 0 endpoint = url_split[len(url_split) - 1] client_ip = Utils.get_ip_addr(request) @@ -125,8 +164,45 @@ class OngekiServlet: # If we get a 32 character long hex string, it's a hash and we're # doing encrypted. The likelyhood of false positives is low but # technically not 0 - self.logger.error("Encryption not supported at this time") - return b"" + if internal_ver not in self.hash_table: + self.logger.error( + f"v{version} does not support encryption or no keys entered" + ) + return zlib.compress(b'{"stat": "0"}') + + elif endpoint.lower() not in self.hash_table[internal_ver]: + self.logger.error( + f"No hash found for v{version} endpoint {endpoint}" + ) + return zlib.compress(b'{"stat": "0"}') + + endpoint = self.hash_table[internal_ver][endpoint.lower()] + + try: + crypt = AES.new( + bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][0]), + AES.MODE_CBC, + bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][1]), + ) + + req_raw = crypt.decrypt(req_raw) + + except Exception as e: + self.logger.error( + f"Failed to decrypt v{version} request to {endpoint} -> {e}" + ) + return zlib.compress(b'{"stat": "0"}') + + encrtped = True + + if ( + not encrtped + and self.game_cfg.crypto.encrypted_only + ): + self.logger.error( + f"Unencrypted v{version} {endpoint} request, but config is set to encrypted only: {req_raw}" + ) + return zlib.compress(b'{"stat": "0"}') try: unzip = zlib.decompress(req_raw) @@ -163,4 +239,17 @@ class OngekiServlet: self.logger.debug(f"Response {resp}") - return zlib.compress(json.dumps(resp, ensure_ascii=False).encode("utf-8")) + zipped = zlib.compress(json.dumps(resp, ensure_ascii=False).encode("utf-8")) + + if not encrtped: + return zipped + + padded = pad(zipped, 16) + + crypt = AES.new( + bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][0]), + AES.MODE_CBC, + bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][1]), + ) + + return crypt.encrypt(padded) \ No newline at end of file diff --git a/titles/ongeki/schema/profile.py b/titles/ongeki/schema/profile.py index f6eeef2..6071bad 100644 --- a/titles/ongeki/schema/profile.py +++ b/titles/ongeki/schema/profile.py @@ -3,7 +3,7 @@ from sqlalchemy import Table, Column, UniqueConstraint, PrimaryKeyConstraint, an from sqlalchemy.types import Integer, String, TIMESTAMP, Boolean, JSON, BigInteger from sqlalchemy.engine.base import Connection from sqlalchemy.schema import ForeignKey -from sqlalchemy.sql import func, select +from sqlalchemy.sql import func, select, delete from sqlalchemy.engine import Row from sqlalchemy.dialects.mysql import insert @@ -269,7 +269,7 @@ class OngekiProfileData(BaseData): return None return row["userName"] - + def get_profile_preview(self, aime_id: int, version: int) -> Optional[Row]: sql = ( select([profile, option]) @@ -499,7 +499,7 @@ class OngekiProfileData(BaseData): def put_rival(self, aime_id: int, rival_id: int) -> Optional[int]: sql = insert(rival).values(user=aime_id, rivalUserId=rival_id) - conflict = sql.on_duplicate_key_update(rival=rival_id) + conflict = sql.on_duplicate_key_update(rivalUserId=rival_id) result = self.execute(conflict) if result is None: @@ -508,3 +508,10 @@ class OngekiProfileData(BaseData): ) return None return result.lastrowid + def delete_rival(self, aime_id: int, rival_id: int) -> Optional[int]: + sql = delete(rival).where(rival.c.user==aime_id, rival.c.rivalUserId==rival_id) + result = self.execute(sql) + if result is None: + self.logger.error(f"delete_rival: failed to delete! aime_id: {aime_id}, rival_id: {rival_id}") + else: + return result.rowcount \ No newline at end of file