diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e074083 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.csv binary +*.txt binary +*.json binary \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e34dfec..f946002 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM python:3.9.15-slim-bullseye -RUN apt update && apt install default-libmysqlclient-dev build-essential libtk nodejs npm -y +RUN apt update && apt install default-libmysqlclient-dev build-essential libtk nodejs npm pkg-config -y WORKDIR /app COPY requirements.txt requirements.txt @@ -12,10 +12,11 @@ RUN chmod +x entrypoint.sh COPY index.py index.py COPY dbutils.py dbutils.py +COPY read.py read.py ADD core core ADD titles titles ADD config config -ADD log log +ADD logs logs ADD cert cert -ENTRYPOINT [ "/app/entrypoint.sh" ] \ No newline at end of file +ENTRYPOINT [ "/app/entrypoint.sh" ] diff --git a/changelog.md b/changelog.md index 40d2e48..3fc6125 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,94 @@ # Changelog Documenting updates to ARTEMiS, to be updated every time the master branch is pushed to. +## 20240108 +### System ++ Change how the underlying system handles URLs + + This can now allow for things like version-specific, or even keychip-specific URLs + + Specific changes to games are noted below ++ Fix docker files [#60](https://gitea.tendokyu.moe/Hay1tsme/artemis/pulls/60) (Thanks Rylie!) ++ Fix support for python 3.8 - 3.10 + +### Aimedb ++ Add support for SegaAuth key in games that support it (for now only Chunithm) + + This is a JWT that is sent to games, by Aimedb, that the games send to their game server, to verify that the access code the game is sending to the server was obtained via aimedb. + + Requires a base64-encoded secret to be set in the `core.yaml` + +### Chunithm ++ Fix Air support ++ Add saving for userRecentPlayerList ++ Add support for SegaAuthKey ++ Fix a bug arising if a user set their name to be 'true' or 'false' ++ Add support for Sun+ [#78](https://gitea.tendokyu.moe/Hay1tsme/artemis/pulls/78) (Thanks EmmyHeart!) ++ Add `matching` section to `chuni.yaml` ++ ~~Change `udpHolePunchUri` and `reflectorUri` to be STUN and TURN servers~~ Reverted ++ Imrpove `GetGameSetting` request handling for different versions ++ Fix issue where songs would not always return all scores [#92](https://gitea.tendokyu.moe/Hay1tsme/artemis/pulls/92) (Thanks Kumubou!) + +### maimai DX ++ Fix user charges failing to save + +### maimai ++ Made it functional + +### CXB ++ Improvements to request dispatching ++ Add support for non-omnimix music lists + + +### IDZ ++ Fix news urls in accordance with the system change to URLs + +### Initial D THE ARCADE ++ Added support for Initial D THE ARCADE S2 + + Story mode progress added + + Bunta Challenge/Touhou Project modes added + + Time Trials added + + Leaderboards added, but doesn't refresh sometimes + + Theory of Street mode added (with CPUs) + + Play Stamp/Timetrial events added + + Frontend to download profile added + + Importer to import profiles added + +### ONGEKI ++ Now supports HTTPS on a per-version basis ++ Merg PR [#61](https://gitea.tendokyu.moe/Hay1tsme/artemis/pulls/61) (Thanks phantomlan!) + + Add Ranking Event Support + + Add reward list support + + Add version segregation to Event Ranking, Tech Challenge, and Music Ranking + + Now stores ClientTestmode and ClientSetting data ++ Fix mission points not adding correctly [#68](https://gitea.tendokyu.moe/Hay1tsme/artemis/pulls/68) (Thanks phantomlan!) ++ Fix tech challenge [#70](https://gitea.tendokyu.moe/Hay1tsme/artemis/pulls/70) (Thanks phantomlan!) + +### SAO ++ Change endpoint in accordance with the system change to URLs ++ Update request header class to be more accurate ++ Encrypted requests are now supported ++ Change to using handler classes instead of raw structs for simplicity + +### Wacca ++ Fix a server error causing a seperate error that casued issues ++ Add better error printing ++ Add better request validation ++ Fix HousingStartV2 ++ Fix Lily's housing/get handler + +## 20231107 +### CXB ++ Hotfix `render_POST` sometimes failing to read the request body on large requests + +## 20231106 +### CXB ++ Hotfix `render_POST` function signature signature ++ Hotfix `handle_action_addenergy_request` hard failing if `get_energy` returns None + +## 20231015 +### maimai DX ++ Added support for FESTiVAL PLUS + +### Card Maker ++ Added support for maimai DX FESTiVAL PLUS + ## 20230716 ### General + Docker files added (#19) diff --git a/core/adb_handlers/__init__.py b/core/adb_handlers/__init__.py new file mode 100644 index 0000000..0c96baf --- /dev/null +++ b/core/adb_handlers/__init__.py @@ -0,0 +1,6 @@ +from .base import ADBBaseRequest, ADBBaseResponse, ADBHeader, ADBHeaderException, PortalRegStatus, LogStatus, ADBStatus +from .base import CompanyCodes, ReaderFwVer, CMD_CODE_GOODBYE, HEADER_SIZE +from .lookup import ADBLookupRequest, ADBLookupResponse, ADBLookupExResponse +from .campaign import ADBCampaignClearRequest, ADBCampaignClearResponse, ADBCampaignResponse, ADBOldCampaignRequest, ADBOldCampaignResponse +from .felica import ADBFelicaLookupRequest, ADBFelicaLookupResponse, ADBFelicaLookup2Request, ADBFelicaLookup2Response +from .log import ADBLogExRequest, ADBLogRequest, ADBStatusLogRequest, ADBLogExResponse diff --git a/core/adb_handlers/base.py b/core/adb_handlers/base.py new file mode 100644 index 0000000..0f208dd --- /dev/null +++ b/core/adb_handlers/base.py @@ -0,0 +1,170 @@ +import struct +from construct import Struct, Int16ul, Int32ul, PaddedString +from enum import Enum +import re +from typing import Union, Final + +class LogStatus(Enum): + NONE = 0 + START = 1 + CONTINUE = 2 + END = 3 + OTHER = 4 + +class PortalRegStatus(Enum): + NO_REG = 0 + PORTAL = 1 + SEGA_ID = 2 + +class ADBStatus(Enum): + UNKNOWN = 0 + GOOD = 1 + BAD_AMIE_ID = 2 + ALREADY_REG = 3 + BAN_SYS_USER = 4 + BAN_SYS = 5 + BAN_USER = 6 + BAN_GEN = 7 + LOCK_SYS_USER = 8 + LOCK_SYS = 9 + LOCK_USER = 10 + +class CompanyCodes(Enum): + NONE = 0 + SEGA = 1 + BAMCO = 2 + KONAMI = 3 + TAITO = 4 + +class ReaderFwVer(Enum): # Newer readers use a singly byte value + NONE = 0 + TN32_10 = 1 + TN32_12 = 2 + OTHER = 9 + + def __str__(self) -> str: + if self == self.TN32_10: + return "TN32MSEC003S F/W Ver1.0" + elif self == self.TN32_12: + return "TN32MSEC003S F/W Ver1.2" + elif self == self.NONE: + return "Not Specified" + elif self == self.OTHER: + return "Unknown/Other" + else: + raise ValueError(f"Bad ReaderFwVer value {self.value}") + + @classmethod + def from_byte(self, byte: bytes) -> Union["ReaderFwVer", int]: + try: + i = int.from_bytes(byte, 'little') + try: + return ReaderFwVer(i) + except ValueError: + return i + except TypeError: + return 0 + +class ADBHeaderException(Exception): + pass + +HEADER_SIZE: Final[int] = 0x20 +CMD_CODE_GOODBYE: Final[int] = 0x66 + +# everything is LE +class ADBHeader: + def __init__(self, magic: int, protocol_ver: int, cmd: int, length: int, status: int, game_id: Union[str, bytes], store_id: int, keychip_id: Union[str, bytes]) -> None: + self.magic = magic # u16 + self.protocol_ver = protocol_ver # u16 + self.cmd = cmd # u16 + self.length = length # u16 + try: + self.status = ADBStatus(status) # u16 + except ValueError as e: + raise ADBHeaderException(f"Status is incorrect! {e}") + self.game_id = game_id # 4 char + \x00 + self.store_id = store_id # u32 + self.keychip_id = keychip_id# 11 char + \x00 + + if type(self.game_id) == bytes: + self.game_id = self.game_id.decode() + + if type(self.keychip_id) == bytes: + self.keychip_id = self.keychip_id.decode() + + self.game_id = self.game_id.replace("\0", "") + self.keychip_id = self.keychip_id.replace("\0", "") + if self.cmd != CMD_CODE_GOODBYE: # Games for some reason send no data with goodbye + self.validate() + + @classmethod + def from_data(cls, data: bytes) -> "ADBHeader": + magic, protocol_ver, cmd, length, status, game_id, store_id, keychip_id = struct.unpack_from("<5H6sI12s", data) + head = cls(magic, protocol_ver, cmd, length, status, game_id, store_id, keychip_id) + + if head.length != len(data): + raise ADBHeaderException(f"Length is incorrect! Expect {head.length}, got {len(data)}") + + return head + + def validate(self) -> bool: + if self.magic != 0xa13e: + raise ADBHeaderException(f"Magic {self.magic} != 0xa13e") + + if self.protocol_ver < 0x1000: + raise ADBHeaderException(f"Protocol version {hex(self.protocol_ver)} is invalid!") + + if re.fullmatch(r"^S[0-9A-Z]{3}[P]?$", self.game_id) is None: + raise ADBHeaderException(f"Game ID {self.game_id} is invalid!") + + if self.store_id == 0: + raise ADBHeaderException(f"Store ID cannot be 0!") + + if re.fullmatch(r"^A[0-9]{2}[E|X][0-9]{2}[A-HJ-NP-Z][0-9]{4}$", self.keychip_id) is None: + raise ADBHeaderException(f"Keychip ID {self.keychip_id} is invalid!") + + return True + + def make(self) -> bytes: + resp_struct = Struct( + "magic" / Int16ul, + "unknown" / Int16ul, + "response_code" / Int16ul, + "length" / Int16ul, + "status" / Int16ul, + "game_id" / PaddedString(6, 'utf_8'), + "store_id" / Int32ul, + "keychip_id" / PaddedString(12, 'utf_8'), + ) + + return resp_struct.build(dict( + magic=self.magic, + unknown=self.protocol_ver, + response_code=self.cmd, + length=self.length, + status=self.status.value, + game_id = self.game_id, + store_id = self.store_id, + keychip_id = self.keychip_id, + )) + +class ADBBaseRequest: + def __init__(self, data: bytes) -> None: + self.head = ADBHeader.from_data(data) + +class ADBBaseResponse: + def __init__(self, code: int = 0, length: int = 0x20, status: int = 1, game_id: str = "SXXX", store_id: int = 1, keychip_id: str = "A69E01A8888", protocol_ver: int = 0x3087) -> None: + self.head = ADBHeader(0xa13e, protocol_ver, code, length, status, game_id, store_id, keychip_id) + + @classmethod + def from_req(cls, req: ADBHeader, cmd: int, length: int = 0x20, status: int = 1) -> "ADBBaseResponse": + return cls(cmd, length, status, req.game_id, req.store_id, req.keychip_id, req.protocol_ver) + + def append_padding(self, data: bytes): + """Appends 0s to the end of the data until it's at the correct size""" + padding_size = self.head.length - len(data) + data += bytes(padding_size) + return data + + def make(self) -> bytes: + return self.head.make() diff --git a/core/adb_handlers/campaign.py b/core/adb_handlers/campaign.py new file mode 100644 index 0000000..a1a372e --- /dev/null +++ b/core/adb_handlers/campaign.py @@ -0,0 +1,132 @@ +from construct import Struct, Int16ul, Padding, Bytes, Int32ul, Int32sl + +from .base import * + +class Campaign: + def __init__(self) -> None: + self.id = 0 + self.name = "" + self.announce_date = 0 + self.start_date = 0 + self.end_date = 0 + self.distrib_start_date = 0 + self.distrib_end_date = 0 + + def make(self) -> bytes: + name_padding = bytes(128 - len(self.name)) + return Struct( + "id" / Int32ul, + "name" / Bytes(128), + "announce_date" / Int32ul, + "start_date" / Int32ul, + "end_date" / Int32ul, + "distrib_start_date" / Int32ul, + "distrib_end_date" / Int32ul, + Padding(8), + ).build(dict( + id = self.id, + name = self.name.encode() + name_padding, + announce_date = self.announce_date, + start_date = self.start_date, + end_date = self.end_date, + distrib_start_date = self.distrib_start_date, + distrib_end_date = self.distrib_end_date, + )) + +class CampaignClear: + def __init__(self) -> None: + self.id = 0 + self.entry_flag = 0 + self.clear_flag = 0 + + def make(self) -> bytes: + return Struct( + "id" / Int32ul, + "entry_flag" / Int32ul, + "clear_flag" / Int32ul, + Padding(4), + ).build(dict( + id = self.id, + entry_flag = self.entry_flag, + clear_flag = self.clear_flag, + )) + +class ADBCampaignResponse(ADBBaseResponse): + def __init__(self, game_id: str = "SXXX", store_id: int = 1, keychip_id: str = "A69E01A8888", code: int = 0x0C, length: int = 0x200, status: int = 1) -> None: + super().__init__(code, length, status, game_id, store_id, keychip_id) + self.campaigns = [Campaign(), Campaign(), Campaign()] + + @classmethod + def from_req(cls, req: ADBHeader) -> "ADBCampaignResponse": + c = cls(req.game_id, req.store_id, req.keychip_id) + c.head.protocol_ver = req.protocol_ver + return c + + def make(self) -> bytes: + body = b"" + + for c in self.campaigns: + body += c.make() + + self.head.length = HEADER_SIZE + len(body) + return self.head.make() + body + +class ADBOldCampaignRequest(ADBBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + self.campaign_id = struct.unpack_from(" None: + super().__init__(code, length, status, game_id, store_id, keychip_id) + self.info0 = 0 + self.info1 = 0 + self.info2 = 0 + self.info3 = 0 + + @classmethod + def from_req(cls, req: ADBHeader) -> "ADBCampaignResponse": + c = cls(req.game_id, req.store_id, req.keychip_id) + c.head.protocol_ver = req.protocol_ver + return c + + def make(self) -> bytes: + resp_struct = Struct( + "info0" / Int32sl, + "info1" / Int32sl, + "info2" / Int32sl, + "info3" / Int32sl, + ).build( + info0 = self.info0, + info1 = self.info1, + info2 = self.info2, + info3 = self.info3, + ) + + self.head.length = HEADER_SIZE + len(resp_struct) + return self.head.make() + resp_struct + +class ADBCampaignClearRequest(ADBBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + self.aime_id = struct.unpack_from(" None: + super().__init__(code, length, status, game_id, store_id, keychip_id) + self.campaign_clear_status = [CampaignClear(), CampaignClear(), CampaignClear()] + + @classmethod + def from_req(cls, req: ADBHeader) -> "ADBCampaignResponse": + c = cls(req.game_id, req.store_id, req.keychip_id) + c.head.protocol_ver = req.protocol_ver + return c + + def make(self) -> bytes: + body = b"" + + for c in self.campaign_clear_status: + body += c.make() + + self.head.length = HEADER_SIZE + len(body) + return self.head.make() + body diff --git a/core/adb_handlers/felica.py b/core/adb_handlers/felica.py new file mode 100644 index 0000000..479e84d --- /dev/null +++ b/core/adb_handlers/felica.py @@ -0,0 +1,85 @@ +from construct import Struct, Int32sl, Padding, Int8ub, Int16sl +from typing import Union +from .base import * + +class ADBFelicaLookupRequest(ADBBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + idm, pmm = struct.unpack_from(">QQ", data, 0x20) + self.idm = hex(idm)[2:].upper() + self.pmm = hex(pmm)[2:].upper() + +class ADBFelicaLookupResponse(ADBBaseResponse): + def __init__(self, access_code: str = None, game_id: str = "SXXX", store_id: int = 1, keychip_id: str = "A69E01A8888", code: int = 0x03, length: int = 0x30, status: int = 1) -> None: + super().__init__(code, length, status, game_id, store_id, keychip_id) + self.access_code = access_code if access_code is not None else "00000000000000000000" + + @classmethod + def from_req(cls, req: ADBHeader, access_code: str = None) -> "ADBFelicaLookupResponse": + c = cls(access_code, req.game_id, req.store_id, req.keychip_id) + c.head.protocol_ver = req.protocol_ver + return c + + def make(self) -> bytes: + resp_struct = Struct( + "felica_idx" / Int32ul, + "access_code" / Int8ub[10], + Padding(2) + ).build(dict( + felica_idx = 0, + access_code = bytes.fromhex(self.access_code) + )) + + self.head.length = HEADER_SIZE + len(resp_struct) + + return self.head.make() + resp_struct + +class ADBFelicaLookup2Request(ADBBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + self.random = struct.unpack_from("<16s", data, 0x20)[0] + idm, pmm = struct.unpack_from(">QQ", data, 0x30) + self.card_key_ver, self.write_ct, self.maca, company, fw_ver, self.dfc = struct.unpack_from("<16s16sQccH", data, 0x40) + self.idm = hex(idm)[2:].upper() + self.pmm = hex(pmm)[2:].upper() + self.company = CompanyCodes(int.from_bytes(company, 'little')) + self.fw_ver = ReaderFwVer.from_byte(fw_ver) + +class ADBFelicaLookup2Response(ADBBaseResponse): + def __init__(self, user_id: Union[int, None] = None, access_code: Union[str, None] = None, game_id: str = "SXXX", store_id: int = 1, keychip_id: str = "A69E01A8888", code: int = 0x12, length: int = 0x130, status: int = 1) -> None: + super().__init__(code, length, status, game_id, store_id, keychip_id) + self.user_id = user_id if user_id is not None else -1 + self.access_code = access_code if access_code is not None else "00000000000000000000" + self.company = CompanyCodes.SEGA + self.portal_status = PortalRegStatus.NO_REG + self.auth_key = [0] * 256 + + @classmethod + def from_req(cls, req: ADBHeader, user_id: Union[int, None] = None, access_code: Union[str, None] = None) -> "ADBFelicaLookup2Response": + c = cls(user_id, access_code, req.game_id, req.store_id, req.keychip_id) + c.head.protocol_ver = req.protocol_ver + return c + + def make(self) -> bytes: + resp_struct = Struct( + "user_id" / Int32sl, + "relation1" / Int32sl, + "relation2" / Int32sl, + "access_code" / Int8ub[10], + "portal_status" / Int8ub, + "company_code" / Int8ub, + Padding(8), + "auth_key" / Int8ub[256], + ).build(dict( + user_id = self.user_id, + relation1 = -1, # Unsupported + relation2 = -1, # Unsupported + access_code = bytes.fromhex(self.access_code), + portal_status = self.portal_status.value, + company_code = self.company.value, + auth_key = self.auth_key + )) + + self.head.length = HEADER_SIZE + len(resp_struct) + + return self.head.make() + resp_struct diff --git a/core/adb_handlers/log.py b/core/adb_handlers/log.py new file mode 100644 index 0000000..28fbdf3 --- /dev/null +++ b/core/adb_handlers/log.py @@ -0,0 +1,56 @@ +from construct import Struct, Padding, Int8sl +from typing import Final, List + +from .base import * +NUM_LOGS: Final[int] = 20 +NUM_LEN_LOG_EX: Final[int] = 48 + +class AmLogEx: + def __init__(self, data: bytes) -> None: + self.aime_id, status, self.user_id, self.credit_ct, self.bet_ct, self.won_ct, self.local_time, \ + self.tseq, self.place_id = struct.unpack(" None: + super().__init__(data) + self.aime_id, status = struct.unpack_from(" None: + super().__init__(data) + self.aime_id, status, self.user_id, self.credit_ct, self.bet_ct, self.won_ct = struct.unpack_from(" None: + super().__init__(data) + self.logs: List[AmLogEx] = [] + + for x in range(NUM_LOGS): + self.logs.append(AmLogEx(data[0x20 + (NUM_LEN_LOG_EX * x): 0x50 + (NUM_LEN_LOG_EX * x)])) + + self.num_logs = struct.unpack_from(" None: + super().__init__(code, length, status, game_id, store_id, keychip_id, protocol_ver) + + @classmethod + def from_req(cls, req: ADBHeader) -> "ADBLogExResponse": + c = cls(req.game_id, req.store_id, req.keychip_id, req.protocol_ver) + return c + + def make(self) -> bytes: + resp_struct = Struct( + "log_result" / Int8sl[NUM_LOGS], + Padding(12) + ) + + body = resp_struct.build(dict( + log_result = [1] * NUM_LOGS + )) + + self.head.length = HEADER_SIZE + len(body) + return self.head.make() + body diff --git a/core/adb_handlers/lookup.py b/core/adb_handlers/lookup.py new file mode 100644 index 0000000..0640493 --- /dev/null +++ b/core/adb_handlers/lookup.py @@ -0,0 +1,82 @@ +from construct import Struct, Int32sl, Padding, Int8sl +from typing import Union + +from .base import * + +class ADBLookupException(Exception): + pass + +class ADBLookupRequest(ADBBaseRequest): + def __init__(self, data: bytes) -> None: + super().__init__(data) + self.access_code = data[0x20:0x2A].hex() + company_code, fw_version, self.serial_number = struct.unpack_from(" None: + super().__init__(code, length, status, game_id, store_id, keychip_id) + self.user_id = user_id if user_id is not None else -1 + self.portal_reg = PortalRegStatus.NO_REG + + @classmethod + def from_req(cls, req: ADBHeader, user_id: Union[int, None]) -> "ADBLookupResponse": + c = cls(user_id, req.game_id, req.store_id, req.keychip_id) + c.head.protocol_ver = req.protocol_ver + return c + + def make(self): + resp_struct = Struct( + "user_id" / Int32sl, + "portal_reg" / Int8sl, + Padding(11) + ) + + body = resp_struct.build(dict( + user_id = self.user_id, + portal_reg = self.portal_reg.value + )) + + self.head.length = HEADER_SIZE + len(body) + return self.head.make() + body + +class ADBLookupExResponse(ADBBaseResponse): + def __init__(self, user_id: Union[int, None], game_id: str = "SXXX", store_id: int = 1, keychip_id: str = "A69E01A8888", + code: int = 0x10, length: int = 0x130, status: int = 1) -> None: + super().__init__(code, length, status, game_id, store_id, keychip_id) + self.user_id = user_id if user_id is not None else -1 + self.portal_reg = PortalRegStatus.NO_REG + self.auth_key = [0] * 256 + + @classmethod + def from_req(cls, req: ADBHeader, user_id: Union[int, None]) -> "ADBLookupExResponse": + c = cls(user_id, req.game_id, req.store_id, req.keychip_id) + c.head.protocol_ver = req.protocol_ver + return c + + def make(self): + resp_struct = Struct( + "user_id" / Int32sl, + "portal_reg" / Int8sl, + Padding(3), + "auth_key" / Int8sl[256], + "relation1" / Int32sl, + "relation2" / Int32sl, + ) + + body = resp_struct.build(dict( + user_id = self.user_id, + portal_reg = self.portal_reg.value, + auth_key = self.auth_key, + relation1 = -1, + relation2 = -1 + )) + + self.head.length = HEADER_SIZE + len(body) + return self.head.make() + body diff --git a/core/aimedb.py b/core/aimedb.py index 39d6373..e65c2c7 100644 --- a/core/aimedb.py +++ b/core/aimedb.py @@ -2,27 +2,18 @@ from twisted.internet.protocol import Factory, Protocol import logging, coloredlogs from Crypto.Cipher import AES import struct -from typing import Dict, Any +from typing import Dict, Tuple, Callable, Union +from typing_extensions import Final from logging.handlers import TimedRotatingFileHandler from core.config import CoreConfig +from core.utils import create_sega_auth_key from core.data import Data +from .adb_handlers import * class AimedbProtocol(Protocol): - AIMEDB_RESPONSE_CODES = { - "felica_lookup": 0x03, - "lookup": 0x06, - "log": 0x0A, - "campaign": 0x0C, - "touch": 0x0E, - "lookup2": 0x10, - "felica_lookup2": 0x12, - "log2": 0x14, - "hello": 0x65, - } - - request_list: Dict[int, Any] = {} + request_list: Dict[int, Tuple[Callable[[bytes, int], Union[ADBBaseResponse, bytes]], int, str]] = {} def __init__(self, core_cfg: CoreConfig) -> None: self.logger = logging.getLogger("aimedb") @@ -32,16 +23,27 @@ class AimedbProtocol(Protocol): self.logger.error("!!!KEY NOT SET!!!") exit(1) - self.request_list[0x01] = self.handle_felica_lookup - self.request_list[0x04] = self.handle_lookup - self.request_list[0x05] = self.handle_register - self.request_list[0x09] = self.handle_log - self.request_list[0x0B] = self.handle_campaign - self.request_list[0x0D] = self.handle_touch - self.request_list[0x0F] = self.handle_lookup2 - self.request_list[0x11] = self.handle_felica_lookup2 - self.request_list[0x13] = self.handle_log2 - self.request_list[0x64] = self.handle_hello + self.register_handler(0x01, 0x03, self.handle_felica_lookup, 'felica_lookup') + self.register_handler(0x02, 0x03, self.handle_felica_register, 'felica_register') + + self.register_handler(0x04, 0x06, self.handle_lookup, 'lookup') + self.register_handler(0x05, 0x06, self.handle_register, 'register') + + self.register_handler(0x07, 0x08, self.handle_status_log, 'status_log') + self.register_handler(0x09, 0x0A, self.handle_log, 'aime_log') + + self.register_handler(0x0B, 0x0C, self.handle_campaign, 'campaign') + self.register_handler(0x0D, 0x0E, self.handle_campaign_clear, 'campaign_clear') + + self.register_handler(0x0F, 0x10, self.handle_lookup_ex, 'lookup_ex') + self.register_handler(0x11, 0x12, self.handle_felica_lookup_ex, 'felica_lookup_ex') + + self.register_handler(0x13, 0x14, self.handle_log_ex, 'aime_log_ex') + self.register_handler(0x64, 0x65, self.handle_hello, 'hello') + self.register_handler(0x66, 0, self.handle_goodbye, 'goodbye') + + def register_handler(self, cmd: int, resp:int, handler: Callable[[bytes, int], Union[ADBBaseResponse, bytes]], name: str) -> None: + self.request_list[cmd] = (handler, resp, name) def append_padding(self, data: bytes): """Appends 0s to the end of the data until it's at the correct size""" @@ -63,202 +65,269 @@ class AimedbProtocol(Protocol): try: decrypted = cipher.decrypt(data) - except Exception: - self.logger.error(f"Failed to decrypt {data.hex()}") + + except Exception as e: + self.logger.error(f"Failed to decrypt {data.hex()} because {e}") return None self.logger.debug(f"{self.transport.getPeer().host} wrote {decrypted.hex()}") - if not decrypted[1] == 0xA1 and not decrypted[0] == 0x3E: - self.logger.error(f"Bad magic") - return None + try: + head = ADBHeader.from_data(decrypted) + + except ADBHeaderException as e: + self.logger.error(f"Error parsing ADB header: {e}") + try: + encrypted = cipher.encrypt(ADBBaseResponse().make()) + self.transport.write(encrypted) - req_code = decrypted[4] - - if req_code == 0x66: - self.logger.info(f"goodbye from {self.transport.getPeer().host}") - self.transport.loseConnection() + except Exception as e: + self.logger.error(f"Failed to encrypt default response because {e}") + return - try: - resp = self.request_list[req_code](decrypted) - encrypted = cipher.encrypt(resp) - self.logger.debug(f"Response {resp.hex()}") + if head.keychip_id == "ABCD1234567" or head.store_id == 0xfff0: + self.logger.warning(f"Request from uninitialized AMLib: {vars(head)}") + + handler, resp_code, name = self.request_list.get(head.cmd, (self.handle_default, None, 'default')) + + if resp_code is None: + self.logger.warning(f"No handler for cmd {hex(head.cmd)}") + + elif resp_code > 0: + self.logger.info(f"{name} from {head.keychip_id} ({head.game_id}) @ {self.transport.getPeer().host}") + + resp = handler(decrypted, resp_code) + + if type(resp) == ADBBaseResponse or issubclass(type(resp), ADBBaseResponse): + resp_bytes = resp.make() + if len(resp_bytes) != resp.head.length: + resp_bytes = self.append_padding(resp_bytes) + + elif type(resp) == bytes: + resp_bytes = resp + + elif resp is None: # Nothing to send, probably a goodbye + return + + else: + raise TypeError(f"Unsupported type returned by ADB handler for {name}: {type(resp)}") + + try: + encrypted = cipher.encrypt(resp_bytes) + self.logger.debug(f"Response {resp_bytes.hex()}") self.transport.write(encrypted) - except KeyError: - self.logger.error(f"Unknown command code {hex(req_code)}") - return None + except Exception as e: + self.logger.error(f"Failed to encrypt {resp_bytes.hex()} because {e}") + + def handle_default(self, data: bytes, resp_code: int, length: int = 0x20) -> ADBBaseResponse: + req = ADBHeader.from_data(data) + return ADBBaseResponse(resp_code, length, 1, req.game_id, req.store_id, req.keychip_id, req.protocol_ver) - except ValueError as e: - self.logger.error(f"Failed to encrypt {resp.hex()} because {e}") - return None + def handle_hello(self, data: bytes, resp_code: int) -> ADBBaseResponse: + return self.handle_default(data, resp_code) - def handle_campaign(self, data: bytes) -> bytes: - self.logger.info(f"campaign from {self.transport.getPeer().host}") - ret = struct.pack( - "<5H", - 0xA13E, - 0x3087, - self.AIMEDB_RESPONSE_CODES["campaign"], - 0x0200, - 0x0001, - ) - return self.append_padding(ret) + def handle_campaign(self, data: bytes, resp_code: int) -> ADBBaseResponse: + h = ADBHeader.from_data(data) + if h.protocol_ver >= 0x3030: + req = h + resp = ADBCampaignResponse.from_req(req) - def handle_hello(self, data: bytes) -> bytes: - self.logger.info(f"hello from {self.transport.getPeer().host}") - ret = struct.pack( - "<5H", 0xA13E, 0x3087, self.AIMEDB_RESPONSE_CODES["hello"], 0x0020, 0x0001 - ) - return self.append_padding(ret) - - def handle_lookup(self, data: bytes) -> bytes: - luid = data[0x20:0x2A].hex() - user_id = self.data.card.get_user_id_from_card(access_code=luid) - - if user_id is None: - user_id = -1 - - self.logger.info( - f"lookup from {self.transport.getPeer().host}: luid {luid} -> user_id {user_id}" - ) - - ret = struct.pack( - "<5H", 0xA13E, 0x3087, self.AIMEDB_RESPONSE_CODES["lookup"], 0x0130, 0x0001 - ) - ret += bytes(0x20 - len(ret)) - - if user_id is None: - ret += struct.pack(" bytes: - self.logger.info(f"lookup2") + def handle_lookup(self, data: bytes, resp_code: int) -> ADBBaseResponse: + req = ADBLookupRequest(data) + user_id = self.data.card.get_user_id_from_card(req.access_code) + is_banned = self.data.card.get_card_banned(req.access_code) + is_locked = self.data.card.get_card_locked(req.access_code) - ret = bytearray(self.handle_lookup(data)) - ret[4] = self.AIMEDB_RESPONSE_CODES["lookup2"] - - return bytes(ret) - - def handle_felica_lookup(self, data: bytes) -> bytes: - idm = data[0x20:0x28].hex() - pmm = data[0x28:0x30].hex() - access_code = self.data.card.to_access_code(idm) + if is_banned and is_locked: + ret.head.status = ADBStatus.BAN_SYS_USER + elif is_banned: + ret.head.status = ADBStatus.BAN_SYS + elif is_locked: + ret.head.status = ADBStatus.LOCK_USER + ret = ADBLookupResponse.from_req(req.head, user_id) + self.logger.info( - f"felica_lookup from {self.transport.getPeer().host}: idm {idm} pmm {pmm} -> access_code {access_code}" + f"access_code {req.access_code} -> user_id {ret.user_id}" + ) + return ret + + def handle_lookup_ex(self, data: bytes, resp_code: int) -> ADBBaseResponse: + req = ADBLookupRequest(data) + user_id = self.data.card.get_user_id_from_card(req.access_code) + + is_banned = self.data.card.get_card_banned(req.access_code) + is_locked = self.data.card.get_card_locked(req.access_code) + + ret = ADBLookupExResponse.from_req(req.head, user_id) + if is_banned and is_locked: + ret.head.status = ADBStatus.BAN_SYS_USER + elif is_banned: + ret.head.status = ADBStatus.BAN_SYS + elif is_locked: + ret.head.status = ADBStatus.LOCK_USER + + self.logger.info( + f"access_code {req.access_code} -> user_id {ret.user_id}" ) - ret = struct.pack( - "<5H", - 0xA13E, - 0x3087, - self.AIMEDB_RESPONSE_CODES["felica_lookup"], - 0x0030, - 0x0001, + if user_id and user_id > 0 and self.config.aimedb.id_secret: + auth_key = create_sega_auth_key(user_id, req.head.game_id, req.head.store_id, req.head.keychip_id, self.config.aimedb.id_secret, self.config.aimedb.id_lifetime_seconds) + if auth_key is not None: + auth_key_extra_len = 256 - len(auth_key) + auth_key_full = auth_key.encode() + (b"\0" * auth_key_extra_len) + self.logger.debug(f"Generated auth token {auth_key}") + ret.auth_key = auth_key_full + + return ret + + def handle_felica_lookup(self, data: bytes, resp_code: int) -> bytes: + """ + On official, I think a card has to be registered for this to actually work, but + I'm making the executive decision to not implement that and just kick back our + faux generated access code. The real felica IDm -> access code conversion is done + on the ADB server, which we do not and will not ever have access to. Because we can + assure that all IDms will be unique, this basic 0-padded hex -> int conversion will + be fine. + """ + req = ADBFelicaLookupRequest(data) + ac = self.data.card.to_access_code(req.idm) + self.logger.info( + f"idm {req.idm} ipm {req.pmm} -> access_code {ac}" ) - ret += bytes(26) - ret += bytes.fromhex(access_code) + return ADBFelicaLookupResponse.from_req(req.head, ac) - return self.append_padding(ret) + def handle_felica_register(self, data: bytes, resp_code: int) -> bytes: + """ + I've never seen this used. + """ + req = ADBFelicaLookupRequest(data) + ac = self.data.card.to_access_code(req.idm) + + if self.config.server.allow_user_registration: + user_id = self.data.user.create_user() - def handle_felica_lookup2(self, data: bytes) -> bytes: - idm = data[0x30:0x38].hex() - pmm = data[0x38:0x40].hex() - access_code = self.data.card.to_access_code(idm) + if user_id is None: + self.logger.error("Failed to register user!") + user_id = -1 + + else: + card_id = self.data.card.create_card(user_id, ac) + + if card_id is None: + self.logger.error("Failed to register card!") + user_id = -1 + + self.logger.info( + f"Register access code {ac} (IDm: {req.idm} PMm: {req.pmm}) -> user_id {user_id}" + ) + + else: + self.logger.info( + f"Registration blocked!: access code {ac} (IDm: {req.idm} PMm: {req.pmm})" + ) + + return ADBFelicaLookupResponse.from_req(req.head, ac) + + def handle_felica_lookup_ex(self, data: bytes, resp_code: int) -> bytes: + req = ADBFelicaLookup2Request(data) + access_code = self.data.card.to_access_code(req.idm) user_id = self.data.card.get_user_id_from_card(access_code=access_code) if user_id is None: user_id = -1 self.logger.info( - f"felica_lookup2 from {self.transport.getPeer().host}: idm {idm} ipm {pmm} -> access_code {access_code} user_id {user_id}" + f"idm {req.idm} ipm {req.pmm} -> access_code {access_code} user_id {user_id}" ) - ret = struct.pack( - "<5H", - 0xA13E, - 0x3087, - self.AIMEDB_RESPONSE_CODES["felica_lookup2"], - 0x0140, - 0x0001, - ) - ret += bytes(22) - ret += struct.pack(" 0 and self.config.aimedb.id_secret: + auth_key = create_sega_auth_key(user_id, req.head.game_id, req.head.store_id, req.head.keychip_id, self.config.aimedb.id_secret, self.config.aimedb.id_lifetime_seconds) + if auth_key is not None: + auth_key_extra_len = 256 - len(auth_key) + auth_key_full = auth_key.encode() + (b"\0" * auth_key_extra_len) + self.logger.debug(f"Generated auth token {auth_key}") + resp.auth_key = auth_key_full - def handle_touch(self, data: bytes) -> bytes: - self.logger.info(f"touch from {self.transport.getPeer().host}") - ret = struct.pack( - "<5H", 0xA13E, 0x3087, self.AIMEDB_RESPONSE_CODES["touch"], 0x0050, 0x0001 - ) - ret += bytes(5) - ret += struct.pack("<3H", 0x6F, 0, 1) + return resp - return self.append_padding(ret) + def handle_campaign_clear(self, data: bytes, resp_code: int) -> ADBBaseResponse: + req = ADBCampaignClearRequest(data) + + resp = ADBCampaignClearResponse.from_req(req.head) + + # We don't support campaign stuff + return resp + + def handle_register(self, data: bytes, resp_code: int) -> bytes: + req = ADBLookupRequest(data) + user_id = -1 - def handle_register(self, data: bytes) -> bytes: - luid = data[0x20:0x2A].hex() if self.config.server.allow_user_registration: user_id = self.data.user.create_user() if user_id is None: - user_id = -1 self.logger.error("Failed to register user!") + user_id = -1 else: - card_id = self.data.card.create_card(user_id, luid) + card_id = self.data.card.create_card(user_id, req.access_code) if card_id is None: - user_id = -1 self.logger.error("Failed to register card!") + user_id = -1 self.logger.info( - f"register from {self.transport.getPeer().host}: luid {luid} -> user_id {user_id}" + f"Register access code {req.access_code} -> user_id {user_id}" ) else: self.logger.info( - f"register from {self.transport.getPeer().host} blocked!: luid {luid}" + f"Registration blocked!: access code {req.access_code}" ) - user_id = -1 - ret = struct.pack( - "<5H", - 0xA13E, - 0x3087, - self.AIMEDB_RESPONSE_CODES["lookup"], - 0x0030, - 0x0001 if user_id > -1 else 0, - ) - ret += bytes(0x20 - len(ret)) - ret += struct.pack(" bytes: - # TODO: Save aimedb logs - self.logger.info(f"log from {self.transport.getPeer().host}") - ret = struct.pack( - "<5H", 0xA13E, 0x3087, self.AIMEDB_RESPONSE_CODES["log"], 0x0020, 0x0001 - ) - return self.append_padding(ret) + # TODO: Save these in some capacity, as deemed relevant + def handle_status_log(self, data: bytes, resp_code: int) -> bytes: + req = ADBStatusLogRequest(data) + self.logger.info(f"User {req.aime_id} logged {req.status.name} event") + return ADBBaseResponse(resp_code, 0x20, 1, req.head.game_id, req.head.store_id, req.head.keychip_id, req.head.protocol_ver) - def handle_log2(self, data: bytes) -> bytes: - self.logger.info(f"log2 from {self.transport.getPeer().host}") - ret = struct.pack( - "<5H", 0xA13E, 0x3087, self.AIMEDB_RESPONSE_CODES["log2"], 0x0040, 0x0001 - ) - ret += bytes(22) - ret += struct.pack("H", 1) + def handle_log(self, data: bytes, resp_code: int) -> bytes: + req = ADBLogRequest(data) + self.logger.info(f"User {req.aime_id} logged {req.status.name} event, credit_ct: {req.credit_ct} bet_ct: {req.bet_ct} won_ct: {req.won_ct}") + return ADBBaseResponse(resp_code, 0x20, 1, req.head.game_id, req.head.store_id, req.head.keychip_id, req.head.protocol_ver) - return self.append_padding(ret) + def handle_log_ex(self, data: bytes, resp_code: int) -> bytes: + req = ADBLogExRequest(data) + strs = [] + self.logger.info(f"Recieved {req.num_logs} or {len(req.logs)} logs") + + for x in range(req.num_logs): + self.logger.debug(f"User {req.logs[x].aime_id} logged {req.logs[x].status.name} event, credit_ct: {req.logs[x].credit_ct} bet_ct: {req.logs[x].bet_ct} won_ct: {req.logs[x].won_ct}") + return ADBLogExResponse.from_req(req.head) + def handle_goodbye(self, data: bytes, resp_code: int) -> None: + self.logger.info(f"goodbye from {self.transport.getPeer().host}") + self.transport.loseConnection() + return class AimedbFactory(Factory): protocol = AimedbProtocol diff --git a/core/allnet.py b/core/allnet.py index 9ad5949..e83aae0 100644 --- a/core/allnet.py +++ b/core/allnet.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Any, Optional, Tuple, Union +from typing import Dict, List, Any, Optional, Tuple, Union, Final import logging, coloredlogs from logging.handlers import TimedRotatingFileHandler from twisted.web.http import Request @@ -6,18 +6,88 @@ from datetime import datetime import pytz import base64 import zlib +import json +from enum import Enum from Crypto.PublicKey import RSA from Crypto.Hash import SHA from Crypto.Signature import PKCS1_v1_5 from time import strptime from os import path import urllib.parse +import math -from core.config import CoreConfig -from core.utils import Utils -from core.data import Data -from core.const import * +from .config import CoreConfig +from .utils import Utils +from .data import Data +from .const import * +from .title import TitleServlet +BILLING_DT_FORMAT: Final[str] = "%Y%m%d%H%M%S" + +class DLIMG_TYPE(Enum): + app = 0 + opt = 1 + +class ALLNET_STAT(Enum): + ok = 0 + bad_game = -1 + bad_machine = -2 + bad_shop = -3 + +class DLI_STATUS(Enum): + START = 0 + GET_DOWNLOAD_CONFIGURATION = 1 + WAIT_DOWNLOAD = 2 + DOWNLOADING = 3 + + NOT_SPECIFY_DLI = 100 + ONLY_POST_REPORT = 101 + STOPPED_BY_APP_RELEASE = 102 + STOPPED_BY_OPT_RELEASE = 103 + + DOWNLOAD_COMPLETE_RECENTLY = 110 + + DOWNLOAD_COMPLETE_WAIT_RELEASE_TIME = 120 + DOWNLOAD_COMPLETE_BUT_NOT_SYNC_SERVER = 121 + DOWNLOAD_COMPLETE_BUT_NOT_FIRST_RESUME = 122 + DOWNLOAD_COMPLETE_BUT_NOT_FIRST_LAUNCH = 123 + DOWNLOAD_COMPLETE_WAIT_UPDATE = 124 + + DOWNLOAD_COMPLETE_AND_ALREADY_UPDATE = 130 + + ERROR_AUTH_FAILURE = 200 + + ERROR_GET_DLI_HTTP = 300 + ERROR_GET_DLI = 301 + ERROR_PARSE_DLI = 302 + ERROR_INVALID_GAME_ID = 303 + ERROR_INVALID_IMAGE_LIST = 304 + ERROR_GET_DLI_APP = 305 + + ERROR_GET_BOOT_ID = 400 + ERROR_ACCESS_SERVER = 401 + ERROR_NO_IMAGE = 402 + ERROR_ACCESS_IMAGE = 403 + + ERROR_DOWNLOAD_APP = 500 + ERROR_DOWNLOAD_OPT = 501 + + ERROR_DISK_FULL = 600 + ERROR_UNINSTALL = 601 + ERROR_INSTALL_APP = 602 + ERROR_INSTALL_OPT = 603 + + ERROR_GET_DLI_INTERNAL = 900 + ERROR_ICF = 901 + ERROR_CHECK_RELEASE_INTERNAL = 902 + UNKNOWN = 999 # Not the actual enum val but it needs to be here as a catch-all + + @classmethod + def from_int(cls, num: int) -> "DLI_STATUS": + try: + return cls(num) + except ValueError: + return cls.UNKNOWN class AllnetServlet: def __init__(self, core_cfg: CoreConfig, cfg_folder: str): @@ -25,7 +95,6 @@ class AllnetServlet: self.config = core_cfg self.config_folder = cfg_folder self.data = Data(core_cfg) - self.uri_registry: Dict[str, Tuple[str, str]] = {} self.logger = logging.getLogger("allnet") if not hasattr(self.logger, "initialized"): @@ -56,24 +125,22 @@ class AllnetServlet: if len(plugins) == 0: self.logger.error("No games detected!") - for _, mod in plugins.items(): - if hasattr(mod, "index") and 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) - self.logger.info( - f"Serving {len(self.uri_registry)} game codes port {core_cfg.allnet.port}" + f"Serving {len(TitleServlet.title_registry)} game codes port {core_cfg.allnet.port}" ) def handle_poweron(self, request: Request, _: Dict): request_ip = Utils.get_ip_addr(request) + pragma_header = request.getHeader('Pragma') + is_dfi = pragma_header is not None and pragma_header == "DFI" + try: - req_dict = self.allnet_req_to_dict(request.content.getvalue()) + if is_dfi: + req_urlencode = self.from_dfi(request.content.getvalue()) + else: + req_urlencode = request.content.getvalue().decode() + + req_dict = self.allnet_req_to_dict(req_urlencode) if req_dict is None: raise AllnetRequestException() @@ -97,33 +164,7 @@ class AllnetServlet: else: resp = AllnetPowerOnResponse() - self.logger.debug(f"Allnet request: {vars(req)}") - if req.game_id not in self.uri_registry: - if not self.config.server.is_develop: - msg = f"Unrecognised game {req.game_id} attempted allnet auth from {request_ip}." - self.data.base.log_event( - "allnet", "ALLNET_AUTH_UNKNOWN_GAME", logging.WARN, msg - ) - self.logger.warn(msg) - - resp.stat = -1 - resp_dict = {k: v for k, v in vars(resp).items() if v is not None} - return (urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n").encode("utf-8") - - else: - self.logger.info( - f"Allowed unknown game {req.game_id} v{req.ver} to authenticate from {request_ip} due to 'is_develop' being enabled. S/N: {req.serial}" - ) - resp.uri = f"http://{self.config.title.hostname}:{self.config.title.port}/{req.game_id}/{req.ver.replace('.', '')}/" - resp.host = f"{self.config.title.hostname}:{self.config.title.port}" - - resp_dict = {k: v for k, v in vars(resp).items() if v is not None} - resp_str = urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) - - self.logger.debug(f"Allnet response: {resp_str}") - return (resp_str + "\n").encode("utf-8") - - resp.uri, resp.host = self.uri_registry[req.game_id] + self.logger.debug(f"Allnet request: {vars(req)}") machine = self.data.arcade.get_machine(req.serial) if machine is None and not self.config.server.allow_unregistered_serials: @@ -131,14 +172,38 @@ class AllnetServlet: self.data.base.log_event( "allnet", "ALLNET_AUTH_UNKNOWN_SERIAL", logging.WARN, msg ) - self.logger.warn(msg) + self.logger.warning(msg) - resp.stat = -2 + resp.stat = ALLNET_STAT.bad_machine.value resp_dict = {k: v for k, v in vars(resp).items() if v is not None} return (urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n").encode("utf-8") if machine is not None: arcade = self.data.arcade.get_arcade(machine["arcade"]) + if self.config.server.check_arcade_ip: + if arcade["ip"] and arcade["ip"] is not None and arcade["ip"] != req.ip: + msg = f"Serial {req.serial} attempted allnet auth from bad IP {req.ip} (expected {arcade['ip']})." + self.data.base.log_event( + "allnet", "ALLNET_AUTH_BAD_IP", logging.ERROR, msg + ) + self.logger.warning(msg) + + resp.stat = ALLNET_STAT.bad_shop.value + resp_dict = {k: v for k, v in vars(resp).items() if v is not None} + return (urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n").encode("utf-8") + + elif (not arcade["ip"] or arcade["ip"] is None) and self.config.server.strict_ip_checking: + msg = f"Serial {req.serial} attempted allnet auth from bad IP {req.ip}, but arcade {arcade['id']} has no IP set! (strict checking enabled)." + self.data.base.log_event( + "allnet", "ALLNET_AUTH_NO_SHOP_IP", logging.ERROR, msg + ) + self.logger.warning(msg) + + resp.stat = ALLNET_STAT.bad_shop.value + resp_dict = {k: v for k, v in vars(resp).items() if v is not None} + return (urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n").encode("utf-8") + + country = ( arcade["country"] if machine["country"] is None else machine["country"] ) @@ -156,23 +221,48 @@ 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_name2 = arcade["city"] if arcade["city"] is not None else "" - resp.client_timezone = ( - arcade["timezone"] if arcade["timezone"] is not None else "+0900" + 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 = ( # 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 TitleServlet.title_registry: + if not self.config.server.is_develop: + msg = f"Unrecognised game {req.game_id} attempted allnet auth from {request_ip}." + self.data.base.log_event( + "allnet", "ALLNET_AUTH_UNKNOWN_GAME", logging.WARN, msg + ) + self.logger.warning(msg) + resp.stat = ALLNET_STAT.bad_game.value + resp_dict = {k: v for k, v in vars(resp).items() if v is not None} + return (urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + "\n").encode("utf-8") + + else: + self.logger.info( + f"Allowed unknown game {req.game_id} v{req.ver} to authenticate from {request_ip} due to 'is_develop' being enabled. S/N: {req.serial}" + ) + resp.uri = f"http://{self.config.title.hostname}:{self.config.title.port}/{req.game_id}/{req.ver.replace('.', '')}/" + resp.host = f"{self.config.title.hostname}:{self.config.title.port}" + + resp_dict = {k: v for k, v in vars(resp).items() if v is not None} + resp_str = urllib.parse.unquote(urllib.parse.urlencode(resp_dict)) + + self.logger.debug(f"Allnet response: {resp_str}") + return (resp_str + "\n").encode("utf-8") + + int_ver = req.ver.replace(".", "") - resp.uri = resp.uri.replace("$v", int_ver) - resp.host = resp.host.replace("$v", int_ver) + resp.uri, resp.host = TitleServlet.title_registry[req.game_id].get_allnet_info(req.game_id, int(int_ver), req.serial) msg = f"{req.serial} authenticated from {request_ip}: {req.game_id} v{req.ver}" self.data.base.log_event("allnet", "ALLNET_AUTH_SUCCESS", logging.INFO, msg) @@ -183,12 +273,24 @@ class AllnetServlet: self.logger.debug(f"Allnet response: {resp_dict}") resp_str += "\n" + """if is_dfi: + request.responseHeaders.addRawHeader('Pragma', 'DFI') + return self.to_dfi(resp_str)""" + return resp_str.encode("utf-8") def handle_dlorder(self, request: Request, _: Dict): request_ip = Utils.get_ip_addr(request) + pragma_header = request.getHeader('Pragma') + is_dfi = pragma_header is not None and pragma_header == "DFI" + try: - req_dict = self.allnet_req_to_dict(request.content.getvalue()) + if is_dfi: + req_urlencode = self.from_dfi(request.content.getvalue()) + else: + req_urlencode = request.content.getvalue().decode() + + req_dict = self.allnet_req_to_dict(req_urlencode) if req_dict is None: raise AllnetRequestException() @@ -230,7 +332,12 @@ class AllnetServlet: self.logger.debug(f"Sending download uri {resp.uri}") self.data.base.log_event("allnet", "DLORDER_REQ_SUCCESS", logging.INFO, f"{Utils.get_ip_addr(request)} requested DL Order for {req.serial} {req.game_id} v{req.ver}") - return urllib.parse.unquote(urllib.parse.urlencode(vars(resp))) + "\n" + res_str = urllib.parse.unquote(urllib.parse.urlencode(vars(resp))) + "\n" + """if is_dfi: + request.responseHeaders.addRawHeader('Pragma', 'DFI') + return self.to_dfi(res_str)""" + + return res_str def handle_dlorder_ini(self, request: Request, match: Dict) -> bytes: if "file" not in match: @@ -241,6 +348,7 @@ class AllnetServlet: if path.exists(f"{self.config.allnet.update_cfg_folder}/{req_file}"): self.logger.info(f"Request for DL INI file {req_file} from {Utils.get_ip_addr(request)} successful") self.data.base.log_event("allnet", "DLORDER_INI_SENT", logging.INFO, f"{Utils.get_ip_addr(request)} successfully recieved {req_file}") + return open( f"{self.config.allnet.update_cfg_folder}/{req_file}", "rb" ).read() @@ -249,10 +357,38 @@ class AllnetServlet: return b"" def handle_dlorder_report(self, request: Request, match: Dict) -> bytes: - self.logger.info( - f"DLI Report from {Utils.get_ip_addr(request)}: {request.content.getvalue()}" - ) - return b"" + req_raw = request.content.getvalue() + client_ip = Utils.get_ip_addr(request) + try: + req_dict: Dict = json.loads(req_raw) + except Exception as e: + self.logger.warning(f"Failed to parse DL Report: {e}") + return "NG" + + dl_data_type = DLIMG_TYPE.app + dl_data = req_dict.get("appimage", {}) + + if dl_data is None or not dl_data: + dl_data_type = DLIMG_TYPE.opt + dl_data = req_dict.get("optimage", {}) + + if dl_data is None or not dl_data: + self.logger.warning(f"Failed to parse DL Report: Invalid format - contains neither appimage nor optimage") + return "NG" + + rep = DLReport(dl_data, dl_data_type) + + if not rep.validate(): + self.logger.warning(f"Failed to parse DL Report: Invalid format - {rep.err}") + return "NG" + + msg = f"{rep.serial} @ {client_ip} reported {rep.rep_type.name} download state {rep.rf_state.name} for {rep.gd} v{rep.dav}:"\ + f" {rep.tdsc}/{rep.tsc} segments downloaded for working files {rep.wfl} with {rep.dfl if rep.dfl else 'none'} complete." + + self.data.base.log_event("allnet", "DL_REPORT", logging.INFO, msg, dl_data) + self.logger.info(msg) + + return "OK" def handle_loaderstaterecorder(self, request: Request, match: Dict) -> bytes: req_data = request.content.getvalue() @@ -276,8 +412,16 @@ class AllnetServlet: return "OK".encode() def handle_billing_request(self, request: Request, _: Dict): - req_dict = self.billing_req_to_dict(request.content.getvalue()) + req_raw = request.content.getvalue() + + if request.getHeader('Content-Type') == "application/octet-stream": + req_unzip = zlib.decompressobj(-zlib.MAX_WBITS).decompress(req_raw) + else: + req_unzip = req_raw + + req_dict = self.billing_req_to_dict(req_unzip) request_ip = Utils.get_ip_addr(request) + if req_dict is None: self.logger.error(f"Failed to parse request {request.content.getvalue()}") return b"" @@ -287,45 +431,61 @@ class AllnetServlet: rsa = RSA.import_key(open(self.config.billing.signing_key, "rb").read()) signer = PKCS1_v1_5.new(rsa) digest = SHA.new() - + traces: List[TraceData] = [] try: - kc_playlimit = int(req_dict[0]["playlimit"]) - kc_nearfull = int(req_dict[0]["nearfull"]) - kc_billigtype = int(req_dict[0]["billingtype"]) - kc_playcount = int(req_dict[0]["playcnt"]) - kc_serial: str = req_dict[0]["keychipid"] - kc_game: str = req_dict[0]["gameid"] - kc_date = strptime(req_dict[0]["date"], "%Y%m%d%H%M%S") - kc_serial_bytes = kc_serial.encode() - + req = BillingInfo(req_dict[0]) except KeyError as e: - return f"result=5&linelimit=&message={e} field is missing".encode() + self.logger.error(f"Billing request failed to parse: {e}") + return f"result=5&linelimit=&message=field is missing or formatting is incorrect\r\n".encode() - machine = self.data.arcade.get_machine(kc_serial) + for x in range(1, len(req_dict)): + if not req_dict[x]: + continue + + try: + tmp = TraceData(req_dict[x]) + if tmp.trace_type == TraceDataType.CHARGE: + tmp = TraceDataCharge(req_dict[x]) + elif tmp.trace_type == TraceDataType.EVENT: + tmp = TraceDataEvent(req_dict[x]) + elif tmp.trace_type == TraceDataType.CREDIT: + tmp = TraceDataCredit(req_dict[x]) + + traces.append(tmp) + + except KeyError as e: + self.logger.warn(f"Tracelog failed to parse: {e}") + + kc_serial_bytes = req.keychipid.encode() + + + machine = self.data.arcade.get_machine(req.keychipid) if machine is None and not self.config.server.allow_unregistered_serials: - msg = f"Unrecognised serial {kc_serial} attempted billing checkin from {request_ip} for game {kc_game}." + msg = f"Unrecognised serial {req.keychipid} attempted billing checkin from {request_ip} for {req.gameid} v{req.gamever}." self.data.base.log_event( "allnet", "BILLING_CHECKIN_NG_SERIAL", logging.WARN, msg ) - self.logger.warn(msg) + self.logger.warning(msg) - resp = BillingResponse("", "", "", "") - resp.result = "1" - return self.dict_to_http_form_string([vars(resp)]) + return f"result=1&requestno={req.requestno}&message=Keychip Serial bad\r\n".encode() msg = ( - f"Billing checkin from {request_ip}: game {kc_game} keychip {kc_serial} playcount " - f"{kc_playcount} billing_type {kc_billigtype} nearfull {kc_nearfull} playlimit {kc_playlimit}" + f"Billing checkin from {request_ip}: game {req.gameid} ver {req.gamever} keychip {req.keychipid} playcount " + f"{req.playcnt} billing_type {req.billingtype.name} nearfull {req.nearfull} playlimit {req.playlimit}" ) self.logger.info(msg) self.data.base.log_event("billing", "BILLING_CHECKIN_OK", logging.INFO, msg) + if req.traceleft > 0: + self.logger.warn(f"{req.traceleft} unsent tracelogs") + kc_playlimit = req.playlimit + kc_nearfull = req.nearfull - while kc_playcount > kc_playlimit: + while req.playcnt > req.playlimit: kc_playlimit += 1024 kc_nearfull += 1024 playlimit = kc_playlimit - nearfull = kc_nearfull + (kc_billigtype * 0x00010000) + nearfull = kc_nearfull + (req.billingtype.value * 0x00010000) digest.update(playlimit.to_bytes(4, "little") + kc_serial_bytes) playlimit_sig = signer.sign(digest).hex() @@ -336,13 +496,16 @@ class AllnetServlet: # TODO: playhistory - resp = BillingResponse(playlimit, playlimit_sig, nearfull, nearfull_sig) + #resp = BillingResponse(playlimit, playlimit_sig, nearfull, nearfull_sig) + resp = BillingResponse(playlimit, playlimit_sig, nearfull, nearfull_sig, req.requestno, req.protocolver) - resp_str = self.dict_to_http_form_string([vars(resp)]) - if resp_str is None: - self.logger.error(f"Failed to parse response {vars(resp)}") + resp_str = urllib.parse.unquote(urllib.parse.urlencode(vars(resp))) + "\r\n" self.logger.debug(f"response {vars(resp)}") + if req.traceleft > 0: + self.logger.info(f"Requesting 20 more of {req.traceleft} unsent tracelogs") + return f"result=6&waittime=0&linelimit=20\r\n".encode() + return resp_str.encode("utf-8") def handle_naomitest(self, request: Request, _: Dict) -> bytes: @@ -354,9 +517,7 @@ class AllnetServlet: Parses an billing request string into a python dictionary """ try: - decomp = zlib.decompressobj(-zlib.MAX_WBITS) - unzipped = decomp.decompress(data) - sections = unzipped.decode("ascii").split("\r\n") + sections = data.decode("ascii").split("\r\n") ret = [] for x in sections: @@ -372,9 +533,7 @@ class AllnetServlet: Parses an allnet request string into a python dictionary """ try: - zipped = base64.b64decode(data) - unzipped = zlib.decompress(zipped) - sections = unzipped.decode("utf-8").split("\r\n") + sections = data.split("\r\n") ret = [] for x in sections: @@ -385,35 +544,15 @@ class AllnetServlet: self.logger.error(f"allnet_req_to_dict: {e} while parsing {data}") return None - def dict_to_http_form_string( - self, - data: List[Dict[str, Any]], - crlf: bool = True, - trailing_newline: bool = True, - ) -> Optional[str]: - """ - Takes a python dictionary and parses it into an allnet response string - """ - try: - urlencode = "" - for item in data: - for k, v in item.items(): - if k is None or v is None: - continue - urlencode += f"{k}={v}&" - if crlf: - urlencode = urlencode[:-1] + "\r\n" - else: - urlencode = urlencode[:-1] + "\n" - if not trailing_newline: - if crlf: - urlencode = urlencode[:-2] - else: - urlencode = urlencode[:-1] - return urlencode - except Exception as e: - self.logger.error(f"dict_to_http_form_string: {e} while parsing {data}") - return None + def from_dfi(self, data: bytes) -> str: + zipped = base64.b64decode(data) + unzipped = zlib.decompress(zipped) + return unzipped.decode("utf-8") + + def to_dfi(self, data: str) -> bytes: + unzipped = data.encode('utf-8') + zipped = zlib.compress(unzipped) + return base64.b64encode(zipped) class AllnetPowerOnRequest: @@ -436,7 +575,7 @@ class AllnetPowerOnResponse: self.stat = 1 self.uri = "" self.host = "" - self.place_id = "123" + self.place_id = "0123" self.name = "ARTEMiS" self.nickname = "ARTEMiS" self.region0 = "1" @@ -499,6 +638,122 @@ class AllnetDownloadOrderResponse: self.serial = serial self.uri = uri +class TraceDataType(Enum): + CHARGE = 0 + EVENT = 1 + CREDIT = 2 + +class BillingType(Enum): + A = 1 + B = 0 + +class float5: + def __init__(self, n: str = "0") -> None: + nf = float(n) + if nf > 999.9 or nf < 0: + raise ValueError('float5 must be between 0.000 and 999.9 inclusive') + + return nf + + @classmethod + def to_str(cls, f: float): + return f"%.{2 - int(math.log10(f))+1}f" % f + +class BillingInfo: + def __init__(self, data: Dict) -> None: + try: + self.keychipid = str(data.get("keychipid", None)) + self.functype = int(data.get("functype", None)) + self.gameid = str(data.get("gameid", None)) + self.gamever = float(data.get("gamever", None)) + self.boardid = str(data.get("boardid", None)) + self.tenpoip = str(data.get("tenpoip", None)) + self.libalibver = float(data.get("libalibver", None)) + self.datamax = int(data.get("datamax", None)) + self.billingtype = BillingType(int(data.get("billingtype", None))) + self.protocolver = float(data.get("protocolver", None)) + self.operatingfix = bool(data.get("operatingfix", None)) + self.traceleft = int(data.get("traceleft", None)) + self.requestno = int(data.get("requestno", None)) + self.datesync = bool(data.get("datesync", None)) + self.timezone = str(data.get("timezone", None)) + self.date = datetime.strptime(data.get("date", None), BILLING_DT_FORMAT) + self.crcerrcnt = int(data.get("crcerrcnt", None)) + self.memrepair = bool(data.get("memrepair", None)) + self.playcnt = int(data.get("playcnt", None)) + self.playlimit = int(data.get("playlimit", None)) + self.nearfull = int(data.get("nearfull", None)) + except Exception as e: + raise KeyError(e) + +class TraceData: + def __init__(self, data: Dict) -> None: + try: + self.crc_err_flg = bool(data.get("cs", None)) + self.record_number = int(data.get("rn", None)) + self.seq_number = int(data.get("sn", None)) + self.trace_type = TraceDataType(int(data.get("tt", None))) + self.date_sync_flg = bool(data.get("ds", None)) + + dt = data.get("dt", None) + if dt is None: + raise KeyError("dt not present") + if dt == "20000000000000": # Not sure what causes it to send like this... + self.date = datetime(2000, 1, 1, 0, 0, 0, 0) + else: + self.date = datetime.strptime(data.get("dt", None), BILLING_DT_FORMAT) + + self.keychip = str(data.get("kn", None)) + self.lib_ver = float(data.get("alib", 0)) + except Exception as e: + raise KeyError(e) + +class TraceDataCharge(TraceData): + def __init__(self, data: Dict) -> None: + super().__init__(data) + try: + self.game_id = str(data.get("gi", None)) # these seem optional...? + self.game_version = float(data.get("gv", 0)) + self.board_serial = str(data.get("bn", None)) + self.shop_ip = str(data.get("ti", None)) + self.play_count = int(data.get("pc", None)) + self.play_limit = int(data.get("pl", None)) + self.product_code = int(data.get("ic", None)) + self.product_count = int(data.get("in", None)) + self.func_type = int(data.get("kk", None)) + self.player_number = int(data.get("playerno", None)) + except Exception as e: + raise KeyError(e) + +class TraceDataEvent(TraceData): + def __init__(self, data: Dict) -> None: + super().__init__(data) + try: + self.message = str(data.get("me", None)) + except Exception as e: + raise KeyError(e) + +class TraceDataCredit(TraceData): + def __init__(self, data: Dict) -> None: + super().__init__(data) + try: + self.chute_type = int(data.get("cct", None)) + self.service_type = int(data.get("cst", None)) + self.operation_type = int(data.get("cop", None)) + self.coin_rate0 = int(data.get("cr0", None)) + self.coin_rate1 = int(data.get("cr1", None)) + self.bonus_addition = int(data.get("cba", None)) + self.credit_rate = int(data.get("ccr", None)) + self.credit0 = int(data.get("cc0", None)) + self.credit1 = int(data.get("cc1", None)) + self.credit2 = int(data.get("cc2", None)) + self.credit3 = int(data.get("cc3", None)) + self.credit4 = int(data.get("cc4", None)) + self.credit5 = int(data.get("cc5", None)) + self.credit6 = int(data.get("cc6", None)) + self.credit7 = int(data.get("cc7", None)) + except Exception as e: + raise KeyError(e) class BillingResponse: def __init__( @@ -507,20 +762,22 @@ class BillingResponse: playlimit_sig: str = "", nearfull: str = "", nearfull_sig: str = "", + request_num: int = 1, + protocol_ver: float = 1.000, playhistory: str = "000000/0:000000/0:000000/0", ) -> None: - self.result = "0" - self.waitime = "100" - self.linelimit = "1" - self.message = "" + self.result = 0 + self.requestno = request_num + self.traceerase = 1 + self.fixinterval = 120 + self.fixlogcnt = 100 self.playlimit = playlimit self.playlimitsig = playlimit_sig - self.protocolver = "1.000" + self.playhistory = playhistory self.nearfull = nearfull self.nearfullsig = nearfull_sig - self.fixlogincnt = "0" - self.fixinterval = "5" - self.playhistory = playhistory + self.linelimit = 100 + self.protocolver = float5.to_str(protocol_ver) # playhistory -> YYYYMM/C:... # YYYY -> 4 digit year, MM -> 2 digit month, C -> Playcount during that period @@ -529,3 +786,66 @@ class AllnetRequestException(Exception): def __init__(self, message="") -> None: self.message = message super().__init__(self.message) + +class DLReport: + def __init__(self, data: Dict, report_type: DLIMG_TYPE) -> None: + self.serial = data.get("serial") + self.dfl = data.get("dfl") + self.wfl = data.get("wfl") + self.tsc = data.get("tsc") + self.tdsc = data.get("tdsc") + self.at = data.get("at") + self.ot = data.get("ot") + self.rt = data.get("rt") + self.as_ = data.get("as") + self.rf_state = DLI_STATUS.from_int(data.get("rf_state")) + self.gd = data.get("gd") + self.dav = data.get("dav") + self.wdav = data.get("wdav") # app only + self.dov = data.get("dov") + self.wdov = data.get("wdov") # app only + self.rep_type = report_type + self.err = "" + + def validate(self) -> bool: + if self.serial is None: + self.err = "serial not provided" + return False + + if self.tsc is None: + self.err = "tsc not provided" + return False + + if self.tdsc is None: + self.err = "tdsc not provided" + return False + + if self.as_ is None: + self.err = "as not provided" + return False + + if self.rf_state is None: + self.err = "rf_state not provided" + return False + + if self.gd is None: + self.err = "gd not provided" + return False + + if self.dav is None: + self.err = "dav not provided" + return False + + if self.dov is None: + self.err = "dov not provided" + return False + + if (self.wdav is None or self.wdov is None) and self.rep_type == DLIMG_TYPE.app: + self.err = "wdav or wdov not provided in app image" + return False + + if (self.wdav is not None or self.wdov is not None) and self.rep_type == DLIMG_TYPE.opt: + self.err = "wdav or wdov provided in opt image" + return False + + return True diff --git a/core/config.py b/core/config.py index 8b85353..68db052 100644 --- a/core/config.py +++ b/core/config.py @@ -36,6 +36,12 @@ class ServerConfig: self.__config, "core", "server", "is_develop", default=True ) + @property + def is_using_proxy(self) -> bool: + return CoreConfig.get_config_field( + self.__config, "core", "server", "is_using_proxy", default=False + ) + @property def threading(self) -> bool: return CoreConfig.get_config_field( @@ -48,6 +54,18 @@ class ServerConfig: self.__config, "core", "server", "log_dir", default="logs" ) + @property + def check_arcade_ip(self) -> bool: + return CoreConfig.get_config_field( + self.__config, "core", "server", "check_arcade_ip", default=False + ) + + @property + def strict_ip_checking(self) -> bool: + return CoreConfig.get_config_field( + self.__config, "core", "server", "strict_ip_checking", default=False + ) + class TitleConfig: def __init__(self, parent_config: "CoreConfig") -> None: @@ -72,6 +90,36 @@ class TitleConfig: return CoreConfig.get_config_field( self.__config, "core", "title", "port", default=8080 ) + + @property + def port_ssl(self) -> int: + return CoreConfig.get_config_field( + self.__config, "core", "title", "port_ssl", default=0 + ) + + @property + def ssl_key(self) -> str: + return CoreConfig.get_config_field( + self.__config, "core", "title", "ssl_key", default="cert/title.key" + ) + + @property + def ssl_cert(self) -> str: + return CoreConfig.get_config_field( + self.__config, "core", "title", "ssl_cert", default="cert/title.pem" + ) + + @property + def reboot_start_time(self) -> str: + return CoreConfig.get_config_field( + self.__config, "core", "title", "reboot_start_time", default="" + ) + + @property + def reboot_end_time(self) -> str: + return CoreConfig.get_config_field( + self.__config, "core", "title", "reboot_end_time", default="" + ) class DatabaseConfig: @@ -138,6 +186,12 @@ class DatabaseConfig: default=10000, ) + @property + def enable_memcached(self) -> bool: + return CoreConfig.get_config_field( + self.__config, "core", "database", "enable_memcached", default=True + ) + @property def memcached_host(self) -> str: return CoreConfig.get_config_field( @@ -188,6 +242,12 @@ class AllnetConfig: self.__config, "core", "allnet", "port", default=80 ) + @property + def ip_check(self) -> bool: + return CoreConfig.get_config_field( + self.__config, "core", "allnet", "ip_check", default=False + ) + @property def allow_online_updates(self) -> int: return CoreConfig.get_config_field( @@ -254,6 +314,18 @@ class AimedbConfig: self.__config, "core", "aimedb", "key", default="" ) + @property + def id_secret(self) -> str: + return CoreConfig.get_config_field( + self.__config, "core", "aimedb", "id_secret", default="" + ) + + @property + def id_lifetime_seconds(self) -> int: + return CoreConfig.get_config_field( + self.__config, "core", "aimedb", "id_lifetime_seconds", default=86400 + ) + class MuchaConfig: def __init__(self, parent_config: "CoreConfig") -> None: diff --git a/core/data/cache.py b/core/data/cache.py index cabf597..1490826 100644 --- a/core/data/cache.py +++ b/core/data/cache.py @@ -17,7 +17,7 @@ except ModuleNotFoundError: def cached(lifetime: int = 10, extra_key: Any = None) -> Callable: def _cached(func: Callable) -> Callable: - if has_mc: + if has_mc and (cfg and cfg.database.enable_memcached): hostname = "127.0.0.1" if cfg: hostname = cfg.database.memcached_host diff --git a/core/data/database.py b/core/data/database.py index 9fb2606..e39d864 100644 --- a/core/data/database.py +++ b/core/data/database.py @@ -15,7 +15,7 @@ from core.utils import Utils class Data: - current_schema_version = 4 + current_schema_version = 6 engine = None session = None user = None @@ -163,7 +163,7 @@ class Data: version = mod.current_schema_version else: - self.logger.warn( + self.logger.warning( f"current_schema_version not found for {folder}" ) @@ -171,7 +171,7 @@ class Data: version = self.current_schema_version if version is None: - self.logger.warn( + self.logger.warning( f"Could not determine latest version for {game}, please specify --version" ) @@ -254,7 +254,7 @@ class Data: self.logger.error(f"Failed to create card for owner with id {user_id}") return - self.logger.warn( + self.logger.warning( 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!" ) @@ -269,7 +269,7 @@ class Data: return if not should_force: - self.logger.warn( + self.logger.warning( 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}." ) @@ -307,7 +307,7 @@ class Data: def autoupgrade(self) -> None: all_game_versions = self.base.get_all_schema_vers() if all_game_versions is None: - self.logger.warn("Failed to get schema versions") + self.logger.warning("Failed to get schema versions") return all_games = Utils.get_all_titles() diff --git a/core/data/schema/arcade.py b/core/data/schema/arcade.py index e1d9b1f..2fb8e43 100644 --- a/core/data/schema/arcade.py +++ b/core/data/schema/arcade.py @@ -1,9 +1,10 @@ -from typing import Optional, Dict -from sqlalchemy import Table, Column +from typing import Optional, Dict, List +from sqlalchemy import Table, Column, and_, or_ from sqlalchemy.sql.schema import ForeignKey, PrimaryKeyConstraint -from sqlalchemy.types import Integer, String, Boolean +from sqlalchemy.types import Integer, String, Boolean, JSON from sqlalchemy.sql import func, select from sqlalchemy.dialects.mysql import insert +from sqlalchemy.engine import Row import re from core.data.schema.base import BaseData, metadata @@ -21,6 +22,7 @@ arcade = Table( Column("city", String(255)), Column("region_id", Integer), Column("timezone", String(255)), + Column("ip", String(39)), mysql_charset="utf8mb4", ) @@ -39,7 +41,9 @@ machine = Table( Column("country", String(3)), # overwrites if not null Column("timezone", String(255)), Column("ota_enable", Boolean), + Column("memo", String(255)), Column("is_cab", Boolean), + Column("data", JSON), mysql_charset="utf8mb4", ) @@ -65,7 +69,7 @@ arcade_owner = Table( class ArcadeData(BaseData): - def get_machine(self, serial: str = None, id: int = None) -> Optional[Dict]: + def get_machine(self, serial: str = None, id: int = None) -> Optional[Row]: if serial is not None: serial = serial.replace("-", "") if len(serial) == 11: @@ -130,12 +134,19 @@ class ArcadeData(BaseData): f"Failed to update board id for machine {machine_id} -> {boardid}" ) - def get_arcade(self, id: int) -> Optional[Dict]: + def get_arcade(self, id: int) -> Optional[Row]: sql = arcade.select(arcade.c.id == id) result = self.execute(sql) if result is None: return None return result.fetchone() + + def get_arcade_machines(self, id: int) -> Optional[List[Row]]: + sql = machine.select(machine.c.arcade == id) + result = self.execute(sql) + if result is None: + return None + return result.fetchall() def put_arcade( self, @@ -165,7 +176,21 @@ class ArcadeData(BaseData): return None return result.lastrowid - def get_arcade_owners(self, arcade_id: int) -> Optional[Dict]: + def get_arcades_managed_by_user(self, user_id: int) -> Optional[List[Row]]: + sql = select(arcade).join(arcade_owner, arcade_owner.c.arcade == arcade.c.id).where(arcade_owner.c.user == user_id) + result = self.execute(sql) + if result is None: + return False + return result.fetchall() + + def get_manager_permissions(self, user_id: int, arcade_id: int) -> Optional[int]: + sql = select(arcade_owner.c.permissions).where(and_(arcade_owner.c.user == user_id, arcade_owner.c.arcade == arcade_id)) + result = self.execute(sql) + if result is None: + return False + return result.fetchone() + + def get_arcade_owners(self, arcade_id: int) -> Optional[Row]: sql = select(arcade_owner).where(arcade_owner.c.arcade == arcade_id) result = self.execute(sql) @@ -187,33 +212,21 @@ class ArcadeData(BaseData): return f"{platform_code}{platform_rev:02d}A{serial_num:04d}{append:04d}" # 0x41 = A, 0x52 = R def validate_keychip_format(self, serial: str) -> bool: - serial = serial.replace("-", "") - if len(serial) != 11 or len(serial) != 15: - self.logger.error( - f"Serial validate failed: Incorrect length for {serial} (len {len(serial)})" - ) + if re.fullmatch(r"^A[0-9]{2}[E|X][-]?[0-9]{2}[A-HJ-NP-Z][0-9]{4}([0-9]{4})?$", serial) is None: return False - - platform_code = serial[:4] - platform_rev = serial[4:6] - const_a = serial[6] - num = serial[7:11] - append = serial[11:15] - - if re.match("A[7|6]\d[E|X][0|1][0|1|2]A\d{4,8}", serial) is None: - self.logger.error(f"Serial validate failed: {serial} failed regex") - return False - - if len(append) != 0 or len(append) != 4: - self.logger.error( - f"Serial validate failed: {serial} had malformed append {append}" - ) - return False - - if len(num) != 4: - self.logger.error( - f"Serial validate failed: {serial} had malformed number {num}" - ) - return False - + return True + + 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 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/data/schema/base.py b/core/data/schema/base.py index a53392f..ef980e5 100644 --- a/core/data/schema/base.py +++ b/core/data/schema/base.py @@ -157,6 +157,8 @@ class BaseData: def fix_bools(self, data: Dict) -> Dict: for k, v in data.items(): + if k == "userName" or k == "teamName": + continue if type(v) == str and v.lower() == "true": data[k] = True elif type(v) == str and v.lower() == "false": diff --git a/core/data/schema/card.py b/core/data/schema/card.py index d8f5fc0..a95684e 100644 --- a/core/data/schema/card.py +++ b/core/data/schema/card.py @@ -64,6 +64,27 @@ class CardData(BaseData): return int(card["user"]) + def get_card_banned(self, access_code: str) -> Optional[bool]: + """ + Given a 20 digit access code as a string, check if the card is banned + """ + card = self.get_card_by_access_code(access_code) + if card is None: + return None + if card["is_banned"]: + return True + return False + def get_card_locked(self, access_code: str) -> Optional[bool]: + """ + Given a 20 digit access code as a string, check if the card is locked + """ + card = self.get_card_by_access_code(access_code) + if card is None: + return None + if card["is_locked"]: + return True + return False + def delete_card(self, card_id: int) -> None: sql = aime_card.delete(aime_card.c.id == card_id) diff --git a/core/data/schema/user.py b/core/data/schema/user.py index 6a95005..221ba81 100644 --- a/core/data/schema/user.py +++ b/core/data/schema/user.py @@ -107,3 +107,17 @@ class UserData(BaseData): if result is None: return None return result.fetchall() + + def find_user_by_email(self, email: str) -> Row: + sql = select(aime_user).where(aime_user.c.email == email) + result = self.execute(sql) + if result is None: + return False + return result.fetchone() + + def find_user_by_username(self, username: str) -> List[Row]: + sql = aime_user.select(aime_user.c.username.like(f"%{username}%")) + result = self.execute(sql) + if result is None: + return False + return result.fetchall() diff --git a/core/data/schema/versions/CORE_4_rollback.sql b/core/data/schema/versions/CORE_4_rollback.sql new file mode 100644 index 0000000..4464915 --- /dev/null +++ b/core/data/schema/versions/CORE_4_rollback.sql @@ -0,0 +1,3 @@ +ALTER TABLE machine DROP COLUMN memo; +ALTER TABLE machine DROP COLUMN is_blacklisted; +ALTER TABLE machine DROP COLUMN `data`; diff --git a/core/data/schema/versions/CORE_5_rollback.sql b/core/data/schema/versions/CORE_5_rollback.sql new file mode 100644 index 0000000..2462fee --- /dev/null +++ b/core/data/schema/versions/CORE_5_rollback.sql @@ -0,0 +1 @@ +ALTER TABLE arcade DROP COLUMN 'ip'; \ No newline at end of file diff --git a/core/data/schema/versions/CORE_5_upgrade.sql b/core/data/schema/versions/CORE_5_upgrade.sql new file mode 100644 index 0000000..8e88b00 --- /dev/null +++ b/core/data/schema/versions/CORE_5_upgrade.sql @@ -0,0 +1,3 @@ +ALTER TABLE machine ADD memo varchar(255) NULL; +ALTER TABLE machine ADD is_blacklisted tinyint(1) NULL; +ALTER TABLE machine ADD `data` longtext NULL; diff --git a/core/data/schema/versions/CORE_6_upgrade.sql b/core/data/schema/versions/CORE_6_upgrade.sql new file mode 100644 index 0000000..013728c --- /dev/null +++ b/core/data/schema/versions/CORE_6_upgrade.sql @@ -0,0 +1 @@ +ALTER TABLE arcade ADD ip varchar(39) NULL; \ No newline at end of file diff --git a/core/data/schema/versions/SBZV_5_rollback.sql b/core/data/schema/versions/SBZV_5_rollback.sql new file mode 100644 index 0000000..851d357 --- /dev/null +++ b/core/data/schema/versions/SBZV_5_rollback.sql @@ -0,0 +1,2 @@ +ALTER TABLE diva_profile + DROP skn_eqp; \ No newline at end of file diff --git a/core/data/schema/versions/SBZV_6_upgrade.sql b/core/data/schema/versions/SBZV_6_upgrade.sql new file mode 100644 index 0000000..d417506 --- /dev/null +++ b/core/data/schema/versions/SBZV_6_upgrade.sql @@ -0,0 +1,2 @@ +ALTER TABLE diva_profile + ADD skn_eqp INT NOT NULL DEFAULT 0; \ No newline at end of file diff --git a/core/data/schema/versions/SDBT_4_rollback.sql b/core/data/schema/versions/SDBT_4_rollback.sql new file mode 100644 index 0000000..81366ea --- /dev/null +++ b/core/data/schema/versions/SDBT_4_rollback.sql @@ -0,0 +1,12 @@ +SET FOREIGN_KEY_CHECKS = 0; + +ALTER TABLE chuni_score_playlog + CHANGE COLUMN isClear isClear TINYINT(1) NULL DEFAULT NULL; + +ALTER TABLE chuni_score_best + CHANGE COLUMN isSuccess isSuccess TINYINT(1) NULL DEFAULT NULL ; + +ALTER TABLE chuni_score_playlog + DROP COLUMN ticketId; + +SET FOREIGN_KEY_CHECKS = 1; \ No newline at end of file diff --git a/core/data/schema/versions/SDBT_5_upgrade.sql b/core/data/schema/versions/SDBT_5_upgrade.sql new file mode 100644 index 0000000..81a094a --- /dev/null +++ b/core/data/schema/versions/SDBT_5_upgrade.sql @@ -0,0 +1,12 @@ +SET FOREIGN_KEY_CHECKS = 0; + +ALTER TABLE chuni_score_playlog + CHANGE COLUMN isClear isClear TINYINT(6) NULL DEFAULT NULL; + +ALTER TABLE chuni_score_best + CHANGE COLUMN isSuccess isSuccess INT(11) NULL DEFAULT NULL ; + +ALTER TABLE chuni_score_playlog + ADD COLUMN ticketId INT(11) NULL AFTER machineType; + +SET FOREIGN_KEY_CHECKS = 1; \ No newline at end of file diff --git a/core/data/schema/versions/SDDT_5_rollback.sql b/core/data/schema/versions/SDDT_5_rollback.sql new file mode 100644 index 0000000..007716c --- /dev/null +++ b/core/data/schema/versions/SDDT_5_rollback.sql @@ -0,0 +1,22 @@ +SET FOREIGN_KEY_CHECKS=0; + +ALTER TABLE ongeki_user_event_point DROP COLUMN version; +ALTER TABLE ongeki_user_event_point DROP COLUMN rank; +ALTER TABLE ongeki_user_event_point DROP COLUMN type; +ALTER TABLE ongeki_user_event_point DROP COLUMN date; + +ALTER TABLE ongeki_user_tech_event DROP COLUMN version; + +ALTER TABLE ongeki_user_mission_point DROP COLUMN version; + +ALTER TABLE ongeki_static_events DROP COLUMN endDate; + +DROP TABLE ongeki_tech_event_ranking; +DROP TABLE ongeki_static_music_ranking_list; +DROP TABLE ongeki_static_rewards; +DROP TABLE ongeki_static_present_list; +DROP TABLE ongeki_static_tech_music; +DROP TABLE ongeki_static_client_testmode; +DROP TABLE ongeki_static_game_point; + +SET FOREIGN_KEY_CHECKS=1; diff --git a/core/data/schema/versions/SDDT_6_upgrade.sql b/core/data/schema/versions/SDDT_6_upgrade.sql new file mode 100644 index 0000000..82d5336 --- /dev/null +++ b/core/data/schema/versions/SDDT_6_upgrade.sql @@ -0,0 +1,98 @@ +SET FOREIGN_KEY_CHECKS=0; + +ALTER TABLE ongeki_user_event_point ADD COLUMN version INTEGER NOT NULL; +ALTER TABLE ongeki_user_event_point ADD COLUMN rank INTEGER; +ALTER TABLE ongeki_user_event_point ADD COLUMN type INTEGER NOT NULL; +ALTER TABLE ongeki_user_event_point ADD COLUMN date VARCHAR(25); + +ALTER TABLE ongeki_user_tech_event ADD COLUMN version INTEGER NOT NULL; + +ALTER TABLE ongeki_user_mission_point ADD COLUMN version INTEGER NOT NULL; + +ALTER TABLE ongeki_static_events ADD COLUMN endDate TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; + +CREATE TABLE ongeki_tech_event_ranking ( + id INT PRIMARY KEY NOT NULL AUTO_INCREMENT, + user INT NOT NULL, + version INT NOT NULL, + date VARCHAR(25), + eventId INT NOT NULL, + rank INT, + totalPlatinumScore INT NOT NULL, + totalTechScore INT NOT NULL, + UNIQUE KEY ongeki_tech_event_ranking_uk (user, eventId), + CONSTRAINT ongeki_tech_event_ranking_ibfk1 FOREIGN KEY (user) REFERENCES aime_user(id) ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE ongeki_static_music_ranking_list ( + id INT PRIMARY KEY NOT NULL AUTO_INCREMENT, + version INT NOT NULL, + musicId INT NOT NULL, + point INT NOT NULL, + userName VARCHAR(255), + UNIQUE KEY ongeki_static_music_ranking_list_uk (version, musicId) +); + +CREATE TABLE ongeki_static_rewards ( + id INT PRIMARY KEY NOT NULL AUTO_INCREMENT, + version INT NOT NULL, + rewardId INT NOT NULL, + rewardName VARCHAR(255) NOT NULL, + itemKind INT NOT NULL, + itemId INT NOT NULL, + UNIQUE KEY ongeki_tech_event_ranking_uk (version, rewardId) +); + +CREATE TABLE ongeki_static_present_list ( + id INT PRIMARY KEY NOT NULL AUTO_INCREMENT, + version INT NOT NULL, + presentId INT NOT NULL, + presentName VARCHAR(255) NOT NULL, + rewardId INT NOT NULL, + stock INT NOT NULL, + message VARCHAR(255), + startDate VARCHAR(25) NOT NULL, + endDate VARCHAR(25) NOT NULL, + UNIQUE KEY ongeki_static_present_list_uk (version, presentId, rewardId) +); + +CREATE TABLE ongeki_static_tech_music ( + id INT PRIMARY KEY NOT NULL AUTO_INCREMENT, + version INT NOT NULL, + eventId INT NOT NULL, + musicId INT NOT NULL, + level INT NOT NULL, + UNIQUE KEY ongeki_static_tech_music_uk (version, musicId, eventId) +); + +CREATE TABLE ongeki_static_client_testmode ( + id INT PRIMARY KEY NOT NULL AUTO_INCREMENT, + regionId INT NOT NULL, + placeId INT NOT NULL, + clientId VARCHAR(11) NOT NULL, + updateDate TIMESTAMP NOT NULL, + isDelivery BOOLEAN NOT NULL, + groupId INT NOT NULL, + groupRole INT NOT NULL, + continueMode INT NOT NULL, + selectMusicTime INT NOT NULL, + advertiseVolume INT NOT NULL, + eventMode INT NOT NULL, + eventMusicNum INT NOT NULL, + patternGp INT NOT NULL, + limitGp INT NOT NULL, + maxLeverMovable INT NOT NULL, + minLeverMovable INT NOT NULL, + UNIQUE KEY ongeki_static_client_testmode_uk (clientId) +); + +CREATE TABLE ongeki_static_game_point ( + id INT PRIMARY KEY NOT NULL AUTO_INCREMENT, + type INT NOT NULL, + cost INT NOT NULL, + startDate VARCHAR(25) NOT NULL DEFAULT "2000-01-01 05:00:00.0", + endDate VARCHAR(25) NOT NULL DEFAULT "2099-01-01 05:00:00.0", + UNIQUE KEY ongeki_static_game_point_uk (type) +); + +SET FOREIGN_KEY_CHECKS=1; diff --git a/core/data/schema/versions/SDEZ_7_rollback.sql b/core/data/schema/versions/SDEZ_7_rollback.sql new file mode 100644 index 0000000..6bb9270 --- /dev/null +++ b/core/data/schema/versions/SDEZ_7_rollback.sql @@ -0,0 +1,10 @@ +ALTER TABLE mai2_profile_detail + DROP COLUMN mapStock; + +ALTER TABLE mai2_profile_extend + DROP COLUMN selectResultScoreViewType; + +ALTER TABLE mai2_profile_option + DROP COLUMN outFrameType, + DROP COLUMN touchVolume, + DROP COLUMN breakSlideVolume; diff --git a/core/data/schema/versions/SDEZ_8_upgrade.sql b/core/data/schema/versions/SDEZ_8_upgrade.sql new file mode 100644 index 0000000..c782d81 --- /dev/null +++ b/core/data/schema/versions/SDEZ_8_upgrade.sql @@ -0,0 +1,10 @@ +ALTER TABLE mai2_profile_detail + ADD mapStock INT NULL AFTER playCount; + +ALTER TABLE mai2_profile_extend + ADD selectResultScoreViewType INT NULL AFTER selectResultDetails; + +ALTER TABLE mai2_profile_option + ADD outFrameType INT NULL AFTER dispCenter, + ADD touchVolume INT NULL AFTER slideVolume, + ADD breakSlideVolume INT NULL AFTER slideVolume; diff --git a/core/frontend.py b/core/frontend.py index f01be50..0ee2211 100644 --- a/core/frontend.py +++ b/core/frontend.py @@ -9,6 +9,9 @@ from zope.interface import Interface, Attribute, implementer from twisted.python.components import registerAdapter import jinja2 import bcrypt +import re +from enum import Enum +from urllib import parse from core import CoreConfig, Utils from core.data import Data @@ -18,7 +21,15 @@ 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 + USERMOD = 1 # Can moderate other users + ACMOD = 2 # Can add arcades and cabs + SYSADMIN = 3 # Can change settings + # 4 - 6 reserved for future use + OWNER = 7 # Can do anything @implementer(IUserSession) class UserSession(object): @@ -26,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): @@ -80,6 +92,9 @@ class FrontendServlet(resource.Resource): self.environment.globals["game_list"] = self.game_list self.putChild(b"gate", FE_Gate(cfg, self.environment)) self.putChild(b"user", FE_User(cfg, self.environment)) + self.putChild(b"sys", FE_System(cfg, self.environment)) + self.putChild(b"arcade", FE_Arcade(cfg, self.environment)) + self.putChild(b"cab", FE_Machine(cfg, self.environment)) self.putChild(b"game", fe_game) self.logger.info( @@ -154,6 +169,7 @@ class FE_Gate(FE_Base): passwd = None uid = self.data.card.get_user_id_from_card(access_code) + user = self.data.user.get_user(uid) if uid is None: return redirectTo(b"/gate?e=1", request) @@ -175,6 +191,7 @@ class FE_Gate(FE_Base): usr_sesh = IUserSession(sesh) usr_sesh.userId = uid usr_sesh.current_ip = ip + usr_sesh.permissions = user['permissions'] return redirectTo(b"/user", request) @@ -192,7 +209,7 @@ class FE_Gate(FE_Base): hashed = bcrypt.hashpw(passwd, salt) result = self.data.user.create_user( - uid, username, email, hashed.decode(), 1 + uid, username, email.lower(), hashed.decode(), 1 ) if result is None: return redirectTo(b"/gate?e=3", request) @@ -210,17 +227,29 @@ class FE_Gate(FE_Base): return redirectTo(b"/gate?e=2", request) ac = request.args[b"ac"][0].decode() + card = self.data.card.get_card_by_access_code(ac) + if card is None: + return redirectTo(b"/gate?e=1", request) + + user = self.data.user.get_user(card['user']) + if user is None: + self.logger.warning(f"Card {ac} exists with no/invalid associated user ID {card['user']}") + return redirectTo(b"/gate?e=0", request) + + if user['password'] is not None: + return redirectTo(b"/gate?e=1", request) template = self.environment.get_template("core/frontend/gate/create.jinja") return template.render( title=f"{self.core_config.server.name} | Create User", code=ac, - sesh={"userId": 0}, + sesh={"userId": 0, "permissions": 0}, ).encode("utf-16") class FE_User(FE_Base): def render_GET(self, request: Request): + uri = request.uri.decode() template = self.environment.get_template("core/frontend/user/index.jinja") sesh: Session = request.getSession() @@ -228,9 +257,26 @@ class FE_User(FE_Base): if usr_sesh.userId == 0: return redirectTo(b"/gate", request) - cards = self.data.card.get_user_cards(usr_sesh.userId) - user = self.data.user.get_user(usr_sesh.userId) + m = re.match("\/user\/(\d*)", uri) + + if m is not None: + usrid = m.group(1) + if usr_sesh.permissions < 1 << PermissionOffset.USERMOD.value or not usrid == usr_sesh.userId: + return redirectTo(b"/user", request) + + else: + usrid = usr_sesh.userId + + user = self.data.user.get_user(usrid) + if user is None: + return redirectTo(b"/user", request) + + cards = self.data.card.get_user_cards(usrid) + arcades = self.data.arcade.get_arcades_managed_by_user(usrid) + card_data = [] + arcade_data = [] + for c in cards: if c['is_locked']: status = 'Locked' @@ -240,9 +286,113 @@ class FE_User(FE_Base): status = 'Active' card_data.append({'access_code': c['access_code'], 'status': status}) + + for a in arcades: + arcade_data.append({'id': a['id'], 'name': a['name']}) return template.render( - title=f"{self.core_config.server.name} | Account", sesh=vars(usr_sesh), cards=card_data, username=user['username'] + title=f"{self.core_config.server.name} | Account", + sesh=vars(usr_sesh), + cards=card_data, + username=user['username'], + arcades=arcade_data + ).encode("utf-16") + + def render_POST(self, request: Request): + pass + + +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: List[Dict] = [] + aclist: List[Dict] = [] + cablist: List[Dict] = [] + + sesh: Session = request.getSession() + usr_sesh = IUserSession(sesh) + if usr_sesh.userId == 0 or usr_sesh.permissions < 1 << PermissionOffset.USERMOD.value: + return redirectTo(b"/gate", request) + + if uri.startswith("/sys/lookup.user?"): + uri_parse = parse.parse_qs(uri.replace("/sys/lookup.user?", "")) # lop off the first bit + uid_search = uri_parse.get("usrId") + email_search = uri_parse.get("usrEmail") + uname_search = uri_parse.get("usrName") + + if uid_search is not None: + u = self.data.user.get_user(uid_search[0]) + if u is not None: + usrlist.append(u._asdict()) + + elif email_search is not None: + u = self.data.user.find_user_by_email(email_search[0]) + if u is not None: + usrlist.append(u._asdict()) + + elif uname_search is not None: + ul = self.data.user.find_user_by_username(uname_search[0]) + for u in ul: + usrlist.append(u._asdict()) + + elif uri.startswith("/sys/lookup.arcade?"): + uri_parse = parse.parse_qs(uri.replace("/sys/lookup.arcade?", "")) # lop off the first bit + 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]) + if u is not None: + aclist.append(u._asdict()) + + elif ac_name_search is not None: + 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]) + 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 + cab_id_search = uri_parse.get("cabId") + cab_serial_search = uri_parse.get("cabSerial") + cab_acid_search = uri_parse.get("cabAcId") + + if cab_id_search is not None: + u = self.data.arcade.get_machine(id=cab_id_search[0]) + if u is not None: + cablist.append(u._asdict()) + + elif cab_serial_search is not None: + u = self.data.arcade.get_machine(serial=cab_serial_search[0]) + if u is not None: + cablist.append(u._asdict()) + + elif cab_acid_search is not None: + ul = self.data.arcade.get_arcade_machines(cab_acid_search[0]) + for u in ul: + cablist.append(u._asdict()) + + return template.render( + title=f"{self.core_config.server.name} | System", + sesh=vars(usr_sesh), + usrlist=usrlist, + aclist=aclist, + cablist=cablist, ).encode("utf-16") @@ -257,3 +407,54 @@ class FE_Game(FE_Base): def render_GET(self, request: Request) -> bytes: return redirectTo(b"/user", request) + + +class FE_Arcade(FE_Base): + def render_GET(self, request: Request): + uri = request.uri.decode() + template = self.environment.get_template("core/frontend/arcade/index.jinja") + managed = [] + + sesh: Session = request.getSession() + usr_sesh = IUserSession(sesh) + if usr_sesh.userId == 0: + return redirectTo(b"/gate", request) + + m = re.match("\/arcade\/(\d*)", uri) + + if m is not None: + arcadeid = m.group(1) + perms = self.data.arcade.get_manager_permissions(usr_sesh.userId, arcadeid) + arcade = self.data.arcade.get_arcade(arcadeid) + + if perms is None: + perms = 0 + + else: + return redirectTo(b"/user", request) + + return template.render( + title=f"{self.core_config.server.name} | Arcade", + sesh=vars(usr_sesh), + error=0, + perms=perms, + arcade=arcade._asdict() + ).encode("utf-16") + + +class FE_Machine(FE_Base): + def render_GET(self, request: Request): + uri = request.uri.decode() + template = self.environment.get_template("core/frontend/machine/index.jinja") + + 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} | Machine", + sesh=vars(usr_sesh), + arcade={}, + error=0, + ).encode("utf-16") \ No newline at end of file diff --git a/core/frontend/arcade/index.jinja b/core/frontend/arcade/index.jinja new file mode 100644 index 0000000..20a1f46 --- /dev/null +++ b/core/frontend/arcade/index.jinja @@ -0,0 +1,4 @@ +{% extends "core/frontend/index.jinja" %} +{% block content %} +

{{ arcade.name }}

+{% endblock content %} \ No newline at end of file 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/machine/index.jinja b/core/frontend/machine/index.jinja new file mode 100644 index 0000000..01e90a0 --- /dev/null +++ b/core/frontend/machine/index.jinja @@ -0,0 +1,5 @@ +{% extends "core/frontend/index.jinja" %} +{% block content %} +{% include "core/frontend/widgets/err_banner.jinja" %} +

Machine Management

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

System Management

+ +
+ {% if sesh.permissions >= 2 %} +
+
+

User Search

+
+ + +
+ OR +
+ + +
+ OR +
+ + +
+
+ +
+
+ {% endif %} + {% if sesh.permissions >= 4 %} +
+
+

Arcade Search

+
+ + +
+ OR +
+ + +
+ OR +
+ + +
+ OR +
+ + +
+
+ +
+
+
+
+

Machine Search

+
+ + +
+ OR +
+ + +
+ OR +
+ + +
+
+ +
+
+ {% endif %} +
+
+ {% if sesh.permissions >= 2 %} + + {% endif %} + {% if sesh.permissions >= 4 %} + + {% endif %} +
+
+ +
+{% endblock content %} \ No newline at end of file diff --git a/core/frontend/user/index.jinja b/core/frontend/user/index.jinja index 2911e67..2f76b14 100644 --- a/core/frontend/user/index.jinja +++ b/core/frontend/user/index.jinja @@ -2,11 +2,21 @@ {% block content %}

Management for {{ username }}

Cards

-
    +
      {% for c in cards %} -
    • {{ c.access_code }}: {{ c.status }}
    • +
    • {{ c.access_code }}: {{ c.status }} {% if c.status == 'Active'%}{% elif c.status == 'Locked' %}{% endif %} 
    • {% endfor %}
    + +{% if arcades is defined %} +

    Arcades

    + +{% endif %} +