From e6f6a56f4b054f495a4e3120ffa531c316684d06 Mon Sep 17 00:00:00 2001 From: UnitedAirforce Date: Tue, 17 Jun 2025 20:03:03 +0800 Subject: [PATCH] New server open source --- 7002.py | 56 ++ README.md | 35 +- api/crypt.py | 38 + api/database.py | 128 ++++ api/misc.py | 127 ++++ api/ranking.py | 498 +++++++++++++ api/shop.py | 408 +++++++++++ api/templates.py | 58 ++ api/user.py | 825 ++++++++++++++++++++++ config.env | 18 + config.py | 63 ++ 7001.py => old_server/7001.py | 0 config_old.py => old_server/config_old.py | 0 requirements.txt | 2 +- 14 files changed, 2241 insertions(+), 15 deletions(-) create mode 100644 7002.py create mode 100644 api/crypt.py create mode 100644 api/database.py create mode 100644 api/misc.py create mode 100644 api/ranking.py create mode 100644 api/shop.py create mode 100644 api/templates.py create mode 100644 api/user.py create mode 100644 config.env create mode 100644 config.py rename 7001.py => old_server/7001.py (100%) rename config_old.py => old_server/config_old.py (100%) diff --git a/7002.py b/7002.py new file mode 100644 index 0000000..6e47f69 --- /dev/null +++ b/7002.py @@ -0,0 +1,56 @@ +from starlette.applications import Starlette +from starlette.responses import FileResponse, Response +from starlette.routing import Route +import os + +# stupid loading sequence +from api.templates import init_templates +init_templates() + +from api.database import database, init_db +from api.misc import get_4max_version_string + +from api.user import routes as user_routes +from api.ranking import routes as rank_routes +from api.shop import routes as shop_routes + +from config import HOST, PORT, DEBUG, SSL_CERT, SSL_KEY, ROOT_FOLDER, ACTUAL_HOST, ACTUAL_PORT + +if (os.path.isfile('./files/dlc_4max.html')): + get_4max_version_string() + +allowed_folders = ["files"] + +async def serve_file(request): + path = request.path_params['path'] + for folder in allowed_folders: + if path.startswith(folder): + file_path = os.path.join(ROOT_FOLDER, path) + if os.path.isfile(file_path): + return FileResponse(file_path) + + return Response("", status_code=404) + +routes = [] + +routes = routes + user_routes + rank_routes + shop_routes + +routes.append(Route("/{path:path}", serve_file)) + +app = Starlette(debug=DEBUG, routes=routes) + +@app.on_event("startup") +async def startup(): + await database.connect() + await init_db() + +@app.on_event("shutdown") +async def shutdown(): + await database.disconnect() + +if __name__ == "__main__": + import uvicorn + ssl_context = (SSL_CERT, SSL_KEY) if SSL_CERT and SSL_KEY else None + uvicorn.run(app, host=ACTUAL_HOST, port=ACTUAL_PORT, ssl_certfile=SSL_CERT, ssl_keyfile=SSL_KEY) + +# Made By Tony 2025.5.10 \ No newline at end of file diff --git a/README.md b/README.md index b64f34c..50d6b34 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A small local server for `Groove Coaster 2: Original Style`, implemented with `Python` and `Flask`. -一个基于`Python`和`Flask`的微型`Groove Coaster 2: Original Style`本地服务器。 +一个基于`Python`和~~`Flask`~~`Starlette`的微型`Groove Coaster 2: Original Style`本地服务器。
English @@ -14,7 +14,9 @@ This project is for game preservation purposes only. Creative liberty and conven You shall bare all the responsibility for any potential consequences as a result of running this server. If you do not agree to these requirements, you are not allowed to replicate or run this program. -It is designed as a **local** server, as Flask face issues with high concurrency. There is an optimized, `async` server, but the code is not open source. Only vetted server owners that will not violate the license terms will be given access. Contact the repo owner for more information. +~~It is designed as a **local** server, as Flask face issues with high concurrency. There is an optimized, `async` server, but the code is not open source. Only vetted server owners that will not violate the license terms will be given access. Contact the repo owner for more information.~~ + +The async server is now open source under the GPLv2 license. It has superceded the old server in terms of functionality and performance. Inspiration: [Lost-MSth/Arcaea-server](https://github.com/Lost-MSth/Arcaea-server) @@ -65,7 +67,7 @@ The server owner must install the update on their instance. They can download it - Python -- Flask +- ~~Flask~~ `Starlette` - Crypto (pycryptodome) @@ -99,13 +101,13 @@ Note that MAC uses `python3`. Code examples in this document will use the defaul Open command on Windows (MAC open terminal). Type `ipconfig` (MAC `ifconfig`), and obtain your IPV4 address. This assumes that you are connected to a WIFI, and it should start with 192 or 172. -Open the `config_old.py` of the private server, and change the `IP` accordingly. +Open the `config.env` of the private server, and change the `IP` accordingly. Type `cmd` in the file directory on the top of the file explorer, and press enter. A command prompt will be opened for that directory. Type `pip install -r requirements.txt` to install all the dependencies. -Type `python 7001.py` to start the server. If an error pops up, resolve it now – did you install all the dependencies? Is the IP correct? +Type `python 7002.py` to start the server. If an error pops up, resolve it now – did you install all the dependencies? Is the IP correct? ### Android (Harder) @@ -125,7 +127,7 @@ Use `pip install ...` -to install `rust`, `flask`, `passlib`, `pycryptodome`, `requests`. +to install `rust`, `starlette`, `passlib`, `pycryptodome`, `requests`. If ssl errors pop up, you might need to ``pkg up ssl -y``. @@ -135,7 +137,7 @@ change `config.py`'s `IP` to `127.0.0.1` (this is `loopback`. Feel free to use y `cd storage/shared/.... (server location on android file system)` -`python 7001.py` to start the server. +`python 7002.py` to start the server.
@@ -263,7 +265,9 @@ Note that this data is for analytics only, and the functionality to embed this d 你应对因运行本服务器而产生的任何潜在后果承担全部责任。如果您不同意这些要求,则不允许您复制或运行该程序。 -此服务器仅为**本地**运行设计,鉴于Flask糟糕的并发性能。一个高效,`异步`的服务器可供使用,不过代码并非开源。只有经过审核,不会违反许可条款的服务器所有者才能获得访问权限。请联系repo所有者了解更多信息。 +~~此服务器仅为**本地**运行设计,鉴于Flask糟糕的并发性能。一个高效,`异步`的服务器可供使用,不过代码并非开源。只有经过审核,不会违反许可条款的服务器所有者才能获得访问权限。请联系repo所有者了解更多信息。~~ + +基于`Starlette`的异步服务器已经在功能和性能上超越了老服务器,现在以`GPLv2`许可证开源。 灵感: [Lost-MSth/Arcaea-server](https://github.com/Lost-MSth/Arcaea-server) @@ -313,7 +317,7 @@ Note that this data is for analytics only, and the functionality to embed this d - Python -- Flask +- ~~Flask~~`Starlette` - Crypto (pycryptodome) @@ -348,11 +352,11 @@ PC/MAC安装 `python`,安装 `pip`。 PC打开 `cmd` 输入 `ipconfig`。MAC 打开 `terminal` 输入 `ifconfig`。获得你的`IPV4`,一串为192或172开头的数字。 -PC用文本编辑器打开服务器文件夹的 `config.py`,将`IPV4`填写至`IP`。`PORT`(端口)也可以更改。 +PC用文本编辑器打开服务器文件夹的 `config.env`,将`IPV4`填写至`IP`。`PORT`(端口)也可以更改。 文件管理器上方的文件夹路径清空,输入 `cmd`。命令行窗口会弹出。 -输入 `python 7001.py`来开启服务器。如果出现错误,就解决他们吧。检查依赖项是否安装,网络配置是否正确。 +输入 `python 7002.py`来开启服务器。如果出现错误,就解决他们吧。检查依赖项是否安装,网络配置是否正确。 ### 安卓(稍难) @@ -372,7 +376,7 @@ PC用文本编辑器打开服务器文件夹的 `config.py`,将`IPV4`填写至 `pip install ...` -来安装 `rust`, `flask`, `bcrypt`, `pycryptodome`, `requests`. +来安装 `rust`, `starlette`, `bcrypt`, `pycryptodome`, `requests`. 如果出现ssl问题,可能需要``pkg up ssl -y``. @@ -382,7 +386,7 @@ PC用文本编辑器打开服务器文件夹的 `config.py`,将`IPV4`填写至 `cd storage/shared/.... (服务器在安卓文件系统的位置)` -`python 7001.py` 来运行服务器。 +`python 7002.py` 来运行服务器。 @@ -516,8 +520,11 @@ server/ │ │ └─ title │ ├─ web/ (found in common.zip) │ │ └─ webpage assets -├─ 7001.py (main script) +├─ 7002.py (main script) +├─ api/ (API scripts) +│ ├─ config/ (various configuration files) ├─ getCrypt.py (debug purpose only) +├─ old_server (Flask old server (depricated)) └─ config.py (configuration script) diff --git a/api/crypt.py b/api/crypt.py new file mode 100644 index 0000000..d794e7c --- /dev/null +++ b/api/crypt.py @@ -0,0 +1,38 @@ +from Crypto.Cipher import AES +import re +import urllib.parse +from starlette.requests import Request + +# Found in: aesManager::initialize() +# Used for: Crypting parameter bytes sent by client +# Credit: https://github.com/Walter-o/gcm-downloader +AES_CBC_KEY = b"oLxvgCJjMzYijWIldgKLpUx5qhUhguP1" + +# Found in: aesManager::decryptCBC() and aesManager::encryptCBC() +# Used for: Crypting parameter bytes sent by client +# Credit: https://github.com/Walter-o/gcm-downloader +AES_CBC_IV = b"6NrjyFU04IO9j9Yo" + +# Decrypt AES encrypted data, takes in a hex string +# Credit: https://github.com/Walter-o/gcm-downloader +def decryptAES(data, key=AES_CBC_KEY, iv=AES_CBC_IV): + return AES.new(key, AES.MODE_CBC, iv).decrypt(bytes.fromhex(data)) + +# Encrypt data with AES, takes in a bytes object +# Credit: https://github.com/Walter-o/gcm-downloader +def encryptAES(data, key=AES_CBC_KEY, iv=AES_CBC_IV): + while len(data) % 16 != 0: + data += b"\x00" + encryptedData = AES.new(key, AES.MODE_CBC, iv).encrypt(data) + return encryptedData.hex() + +async def decrypt_fields(request: Request): + url = str(request.url) + match = re.search(r'\?(.*)', url) + if match: + original_field = match.group(1) + decrypted_fields = urllib.parse.parse_qs(decryptAES(match.group(1))[:-1]) + + return decrypted_fields, original_field + else: + return None, None \ No newline at end of file diff --git a/api/database.py b/api/database.py new file mode 100644 index 0000000..a8e4e25 --- /dev/null +++ b/api/database.py @@ -0,0 +1,128 @@ +from starlette.responses import JSONResponse, Response +from starlette.requests import Request + +import sqlalchemy +from sqlalchemy import Table, Column, Integer, String, DateTime, ForeignKey +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy import select, update + +import os +import databases +import datetime + +DB_NAME = "player.db" +DB_PATH = os.path.join(os.getcwd(), DB_NAME) +DATABASE_URL = f"sqlite+aiosqlite:///{DB_PATH}" + +database = databases.Database(DATABASE_URL) +metadata = sqlalchemy.MetaData() + +user = Table( + "user", + metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("username", String(20), unique=True, nullable=False), + Column("password_hash", String(255), nullable=False), + Column("device_id", String(512)), + Column("data", String, nullable=True), + Column("save_id", String, nullable=True), + Column("crc", Integer, nullable=True), + Column("timestamp", DateTime, default=datetime.datetime.utcnow), + Column("coin_mp", Integer, default=1), +) + +daily_reward = Table( + "daily_reward", + metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("device_id", String(512)), + Column("timestamp", DateTime, default=datetime.datetime.utcnow), + Column("my_stage", String), + Column("my_avatar", String), + Column("item", String), + Column("day", Integer), + Column("coin", Integer), + Column("lvl", Integer), + Column("title", Integer), + Column("avatar", Integer), +) + +result = Table( + "result", + metadata, + Column("rid", Integer, primary_key=True, autoincrement=True), + Column("vid", String(512), nullable=False), + Column("tid", String(512), nullable=False), + Column("sid", String(512), nullable=False), + Column("stts", String(64)), + Column("id", String(8)), + Column("mode", String(4)), + Column("avatar", String(4)), + Column("score", String(16)), + Column("high_score", String(128)), + Column("play_rslt", String(128)), + Column("item", String(16)), + Column("os", String(16)), + Column("os_ver", String(16)), + Column("ver", String(16)), + Column("mike", String(8)), + +) + +whitelist = Table( + "whitelist", + metadata, + Column("id", String(512), primary_key=True) +) +blacklist = Table( + "blacklist", + metadata, + Column("id", String(512), primary_key=True), + Column("reason", String(256)) +) + +async def init_db(): + + if not os.path.exists(DB_PATH): + print("[DB] Creating new database:", DB_PATH) + + engine = create_async_engine(DATABASE_URL, echo=False) + + async with engine.begin() as conn: + await conn.run_sync(metadata.create_all) + + await engine.dispose() + print("[DB] Database initialized successfully.") + +async def get_user_data(uid, data_field): + query = select(user.c[data_field]).where(user.c.device_id == uid[b'vid'][0].decode()) + async with database.transaction(): + result = await database.fetch_one(query) + return result[data_field] if result else None + +async def set_user_data(uid, data_field, new_data): + query = ( + update(user) + .where(user.c.device_id == uid[b'vid'][0].decode()) + .values({data_field: new_data}) + ) + async with database.transaction(): + await database.execute(query) + +async def check_whitelist(uid): + query = select(whitelist.c.id).where(whitelist.c.id == uid[b'vid'][0].decode()) + async with database.transaction(): + result = await database.fetch_one(query) + return result is not None + +async def check_blacklist(uid): + device_id = uid[b'vid'][0].decode() + user_data = await get_user_data(uid, "username") + username = user_data[0] if user_data else None + + query = select(blacklist.c.id).where( + (blacklist.c.id == device_id) | (blacklist.c.id == username) + ) + async with database.transaction(): + result = await database.fetch_one(query) + return result is None \ No newline at end of file diff --git a/api/misc.py b/api/misc.py new file mode 100644 index 0000000..e07c1ec --- /dev/null +++ b/api/misc.py @@ -0,0 +1,127 @@ +import requests +import json +import binascii +import bcrypt +import re +import xml.etree.ElementTree as ET +from config import MODEL, TUNEFILE, SKIN + +FMAX_VER = None +FMAX_RES = None + +def get_4max_version_string(): + url = "https://studio.code.org/v3/sources/3-aKHy16Y5XaAPXQHI95RnFOKlyYT2O95ia2HN2jKIs/main.json" + global FMAX_VER + try: + with open("./files/4max_ver.txt", 'r') as file: + FMAX_VER = file.read().strip() + except Exception as e: + print(f"An unexpected error occurred when loading files/4max_ver.txt: {e}") + + def fetch(): + global FMAX_RES + try: + response = requests.get(url) + if 200 <= response.status_code <= 207: + try: + FMAX_RES = json.loads(json.loads(response.text)['source']) + except (json.JSONDecodeError, KeyError): + FMAX_RES = 500 + else: + FMAX_RES = response.status_code + except requests.RequestException: + FMAX_RES = 400 + + fetch() + +def parse_res(res): + parsed_data = [] + if isinstance(res, int): + return "Failed to fetch version info: Error " + str(res) + + for item in res: + if item.get("isOpen"): + version = item.get("version", "Unknown Version") + changelog = "
".join(item.get("changeLog", {}).get("en", [])) + parsed_data.append(f"Version: {version}

Changelog:
{changelog}

") + return "".join(parsed_data) + +def crc32_decimal(data): + crc32_hex = binascii.crc32(data.encode()) + return int(crc32_hex & 0xFFFFFFFF) + +def hash_password(password): + salt = bcrypt.gensalt() + hashed_password = bcrypt.hashpw(password.encode('utf-8'), salt) + return hashed_password + +def verify_password(password, hashed_password): + if type(hashed_password) == str: + hashed_password = hashed_password.encode('utf-8') + return bcrypt.checkpw(password.encode('utf-8'), hashed_password) + +def is_alphanumeric(username): + pattern = r"^[a-zA-Z0-9]+$" + return bool(re.match(pattern, username)) + +def get_model_pak(host): + mid = ET.Element("model_pak") + rid = ET.Element("date") + uid = ET.Element("url") + rid.text = MODEL + uid.text = host + "files/gc2/model" + MODEL + ".pak" + mid.append(rid) + mid.append(uid) + return mid + +def get_tune_pak(host): + mid = ET.Element("tuneFile_pak") + rid = ET.Element("date") + uid = ET.Element("url") + rid.text = TUNEFILE + uid.text = host + "files/gc2/tuneFile" + TUNEFILE + ".pak" + mid.append(rid) + mid.append(uid) + return mid + +def get_skin_pak(host): + mid = ET.Element("skin_pak") + rid = ET.Element("date") + uid = ET.Element("url") + rid.text = SKIN + uid.text = host + "files/gc2/skin" + SKIN + ".pak" + mid.append(rid) + mid.append(uid) + return mid + +def get_m4a_path(host): + mid = ET.Element("m4a_path") + mid.text = host + "files/gc2/audio/" + return mid + +def get_stage_path(host): + mid = ET.Element("stage_path") + mid.text = host + "files/gc2/stage/" + return mid + +def get_stage_zero(): + sid = ET.Element("my_stage") + did = ET.Element("stage_id") + cid = ET.Element("ac_mode") + did.text = "0" + cid.text = "0" + sid.append(did) + sid.append(cid) + return sid + +def inform_page(text, mode): + if mode == 0: + mode = "/files/web/ttl_taitoid.png" + elif mode == 1: + mode = "/files/web/ttl_information.png" + elif mode == 2: + mode = "/files/web/ttl_buy.png" + elif mode == 3: + mode = "/files/web/ttl_title.png" + with open("files/inform.html", "r") as file: + return file.read().format(text=text, img=mode) \ No newline at end of file diff --git a/api/ranking.py b/api/ranking.py new file mode 100644 index 0000000..fac94ca --- /dev/null +++ b/api/ranking.py @@ -0,0 +1,498 @@ +from starlette.responses import HTMLResponse +from starlette.requests import Request +from starlette.routing import Route +import os +from sqlalchemy import select, update + +from config import AUTHORIZATION_NEEDED + +from api.database import database, user, result, daily_reward, check_blacklist, check_whitelist +from api.crypt import decrypt_fields, encryptAES +from api.templates import EXP_UNLOCKED_SONGS, TITLE_LISTS, SONG_LIST +from api.misc import inform_page + +async def ranking(request: Request): + decrypted_fields, _ = await decrypt_fields(request) + if not decrypted_fields: + return HTMLResponse("""

Invalid request data

""", status_code=400) + + should_serve = True + if AUTHORIZATION_NEEDED: + should_serve = await check_whitelist(decrypted_fields) and not await check_blacklist(decrypted_fields) + + if should_serve: + device_id = decrypted_fields[b'vid'][0].decode() + + html = "" + + file_path = os.path.join("files", "ranking.html") + try: + with open(file_path, "r", encoding="utf-8") as file: + html_content = file.read().format(text=html) + except FileNotFoundError: + return HTMLResponse("""

Ranking file not found

""", status_code=500) + + return HTMLResponse(html_content) + else: + return HTMLResponse("""

Access denied

""", status_code=403) + +async def ranking_detail(request: Request): + decrypted_fields, _ = await decrypt_fields(request) + if not decrypted_fields: + return HTMLResponse("""

Invalid request data

""", status_code=400) + + should_serve = True + if AUTHORIZATION_NEEDED: + should_serve = await check_whitelist(decrypted_fields) and not await check_blacklist(decrypted_fields) + + if should_serve: + device_id = decrypted_fields[b'vid'][0].decode() + song_id = int(decrypted_fields[b'song_id'][0].decode()) + mode = int(decrypted_fields[b'mode'][0].decode()) + button_labels = [] + difficulty_levels = [] + song_name = "" + if (song_id == -1): + song_name = "Total Score" + difficulty_levels = [] + button_labels = ["All", "Mobile", "Arcade"] + else: + song_name = SONG_LIST[song_id]["name_en"] + difficulty_levels = SONG_LIST[song_id]["difficulty_levels"] + button_labels = ["Easy", "Normal", "Hard"] + + html = f"""
{song_name}
""" + + button_modes = [1, 2, 3] + print(len(difficulty_levels)) + + if (len(difficulty_levels) == 6): + button_labels.extend(["AC-Easy", "AC-Normal", "AC-Hard"]) + button_modes.extend([11, 12, 13]) + + if song_id > 615: + button_modes = [x for x in button_modes if x not in [1, 2, 3]] + button_labels = [x for x in button_labels if x not in ["Easy", "Normal", "Hard"]] + + row_start = '
' + row_end = '
' + row_content = [] + + for i, (label, mode_value) in enumerate(zip(button_labels, button_modes)): + if mode_value == mode: + row_content.append(f"""
{label}
""") + else: + encrypted_mass = encryptAES(("vid=" + device_id + "&song_id=" + str(song_id) + "&mode=" + str(mode_value) + "&dummy=").encode("utf-8")) + row_content.append(f"""{label}""") + + if len(row_content) == 3: + html += row_start + ''.join(row_content) + row_end + row_content = [] # Reset row content + + play_results = None + user_result = None + device_result = None + + if (song_id == -1): + # Filter out the mobile/AC modes + if (mode == 1): + exclude = [] + elif mode == 2: + exclude = [11, 12, 13] + else: + exclude = [1, 2, 3] + + query = select(result.c.vid, result.c.sid, result.c.mode, result.c.avatar, result.c.score) + play_results = await database.fetch_all(query) + + query = select(daily_reward.c.device_id, daily_reward.c.title, daily_reward.c.avatar) + device_results_raw = await database.fetch_all(query) + device_results = {row["device_id"]: {"title": row["title"], "avatar": row["avatar"]} for row in device_results_raw} + + query = select(user.c.id, user.c.username, user.c.device_id) + user_results_raw = await database.fetch_all(query) + user_results = {row["id"]: {"username": row["username"], "device_id": row["device_id"]} for row in user_results_raw} + + query = select(user).where(user.c.device_id == device_id) + cur_user = await database.fetch_one(query) + + player_scores = {} + + filtered_play_results = [play for play in play_results if int(play[2]) not in exclude] + + for play in filtered_play_results: + did = play[0] + sid = play[1] + avatar = play[3] + score = play[4] + username, title = None, None + + if sid: + sid = int(sid) + if sid in user_results: + username = user_results[sid]["username"] + did = user_results[sid]["device_id"] + else: # Guest + username = f"Guest({did[-6:]})" + + # title is device-specific + title = device_results.get(did, {}).get("title", "1") + + if username in player_scores: + player_scores[username]["score"] += int(score) + player_scores[username]["avatar"] = avatar # But avatar is based on latest play submission + player_scores[username]["title"] = title + else: + player_scores[username] = {"score": int(score), "avatar": avatar, "title": title} + + sorted_players = sorted(player_scores.items(), key=lambda x: x[1]["score"], reverse=True) + + username = cur_user[1] if cur_user else f"Guest({device_id[-6:]})" + + player_rank = None + user_score = 0 + avatar = "1" + title = "1" + + for rank, (player_name, data) in enumerate(sorted_players, start=1): + if player_name == username: + player_rank = rank + user_score = data["score"] + avatar = data["avatar"] + title = data["title"] + break + + if player_rank is None: + device_data = next((device for device in device_results if device[1] == device_id), None) + if device_data: + avatar = device_data["avatar"] + title = device_data["title"] + + html += f""" +
+ You
{"#" + str(player_rank) if player_rank else "N/A"}
+ Player Avatar +
+
{username}
+ Player Title +
+
{user_score}
+
+ """ + + html += """ +
+ """ + # Loop leaderboard + for rank, (username, data) in enumerate(sorted_players, start=1): + html += f""" +
+
#{rank}
+ Avatar +
+
{username}
+
Title
+
+
{data['score']}
+
+ """ + + else: + query = select(result).where( + (result.c.id == song_id) & (result.c.mode == mode) + ).order_by(result.c.score.desc()) + play_results = await database.fetch_all(query) + + query = select(user).where(user.c.device_id == device_id) + user_result = await database.fetch_one(query) + + query = select(daily_reward).where(daily_reward.c.device_id == device_id) + device_result = await database.fetch_one(query) + + user_id = user_result[0] if user_result else None + username = user_result[1] if user_result else f"Guest({device_id[-6:]})" + play_record = None + + if user_id: + play_record = next((record for record in play_results if int(record[3]) == user_id), None) + + if not play_record: + play_record = next((record for record in play_results if record[1] == device_id and record[3] is None), None) + + player_rank = None + avatar_index = str(play_record[7]) if play_record else "1" + user_score = play_record[8] if play_record else 0 + for rank, result_obj in enumerate(play_results, start=1): + if user_result and int(result_obj[3]) == user_id: + player_rank = rank + break + elif result_obj[1] == device_id and result_obj[3] is None: + player_rank = rank + break + + html += f""" +
+ You
{"#" + str(player_rank) if player_rank else "N/A"}
+ Player Avatar +
+
{username}
+ Player Title +
+
{user_score}
+
+ """ + + html += """ +
+ """ + + for rank, record in enumerate(play_results, start=1): + username = f"Guest({record[1][-6:]})" + device_info = None + if record[3]: + query = select(user.c.username).where(user.c.id == record[3]) + user_data = await database.fetch_one(query) + if user_data: + username = user_data["username"] + + query = select(daily_reward.c.title).where(daily_reward.c.device_id == record[1]) + device_title = await database.fetch_one(query) + if device_title: + device_info = device_title["title"] + else: + device_info = "1" + + avatar_id = record[7] if record[7] else 1 + avatar_url = f"/files/image/icon/avatar/{avatar_id}.png" + + score = record[8] + + html += f""" +
+
#{rank}
+ Avatar +
+
{username}
+
Title
+
+
{score}
+
+ """ + + html += "
" + + encrypted_mass = encryptAES(("vid=" + device_id + "&dummy=").encode("utf-8")) + html += f""" + + Go Back + + """ + + file_path = os.path.join("files", "ranking.html") + try: + with open(file_path, "r", encoding="utf-8") as file: + html_content = file.read().format(text=html) + except FileNotFoundError: + return HTMLResponse("""

Ranking file not found

""", status_code=500) + + return HTMLResponse(html_content) + else: + return HTMLResponse("""

Access denied

""", status_code=403) + +async def status(request: Request): + decrypted_fields, _ = await decrypt_fields(request) + if not decrypted_fields: + return HTMLResponse("""

Invalid request data

""", status_code=400) + + should_serve = True + if AUTHORIZATION_NEEDED: + should_serve = await check_whitelist(decrypted_fields) and not await check_blacklist(decrypted_fields) + + if should_serve: + device_id = decrypted_fields[b'vid'][0].decode() + set_title = int(decrypted_fields[b'set_title'][0].decode()) if b'set_title' in decrypted_fields else None + page_id = int(decrypted_fields[b'page_id'][0].decode()) if b'page_id' in decrypted_fields else 0 + + if set_title: + update_query = ( + update(daily_reward) + .where(daily_reward.c.device_id == device_id) + .values(title=set_title) + ) + await database.execute(update_query) + + query = select(daily_reward).where(daily_reward.c.device_id == device_id) + user_data = await database.fetch_one(query) + + user_name = f"Guest({device_id[-6:]})" + if user_data: + query = select(user.c.username).where(user.c.device_id == device_id) + user_result = await database.fetch_one(query) + if user_result: + user_name = user_result["username"] + + html = "" + if user_data: + player_element = f""" +
+ Player Avatar +
+
{user_name}
+ Player Title +
+
Level {user_data['lvl']}
+
+ """ + html += player_element + + page_name = ["Special", "Normal", "Master", "God"] + buttons_html = '
' + for i, name in enumerate(page_name): + if i == page_id: + buttons_html += f""" +
{name}
+ """ + else: + encrypted_mass = encryptAES(f"vid={device_id}&page_id={i}&dummy=".encode("utf-8")) + buttons_html += f""" + {name} + """ + buttons_html += '
' + html += f"
{buttons_html}
" + + selected_titles = TITLE_LISTS.get(page_id, []) + titles_html = '
' + for index, num in enumerate(selected_titles): + if index % 2 == 0: + if index != 0: + titles_html += '
' + titles_html += '
' + + if num == user_data["title"]: + titles_html += f""" + Title {num} + """ + else: + encrypted_mass = encryptAES(f"vid={device_id}&title_id={num}&page_id={page_id}&dummy=".encode("utf-8")) + titles_html += f""" + + Title {num} + + """ + titles_html += '
' + html += titles_html + + file_path = os.path.join("files", "status.html") + try: + with open(file_path, "r", encoding="utf-8") as file: + html_content = file.read().format(text=html) + except FileNotFoundError: + return HTMLResponse("""

Status file not found

""", status_code=500) + + return HTMLResponse(html_content) + + else: + return HTMLResponse("""

Access denied

""", status_code=403) + +async def set_title(request: Request): + decrypted_fields, _ = await decrypt_fields(request) + if not decrypted_fields: + return HTMLResponse("""

Invalid request data

""", status_code=400) + + should_serve = True + if AUTHORIZATION_NEEDED: + should_serve = await check_whitelist(decrypted_fields) and not await check_blacklist(decrypted_fields) + + if should_serve: + device_id = decrypted_fields[b'vid'][0].decode() + page_id = decrypted_fields[b'page_id'][0].decode() + title_id = decrypted_fields[b'title_id'][0].decode() + current_title = 1 + + query = select(daily_reward.c.title).where(daily_reward.c.device_id == device_id) + row = await database.fetch_one(query) + if row: + current_title = row["title"] + + confirm_url = encryptAES( + f"vid={device_id}&page_id={page_id}&set_title={title_id}&dummy=".encode("utf-8") + ) + go_back_url = encryptAES( + f"vid={device_id}&page_id={page_id}&dummy=".encode("utf-8") + ) + + html = f""" +

Would you like to change your title?
Current Title:

+ Current Title +

New Title:

+ New Title +
+ Confirm + Go back +
+ """ + return HTMLResponse(inform_page(html, 1)) + + else: + return HTMLResponse("""

Access denied

""", status_code=403) + +async def mission(request: Request): + decrypted_fields, _ = await decrypt_fields(request) + if not decrypted_fields: + return HTMLResponse("""

Invalid request data

""", status_code=400) + + should_serve = True + if AUTHORIZATION_NEEDED: + should_serve = await check_whitelist(decrypted_fields) and not await check_blacklist(decrypted_fields) + + if should_serve: + html = f"""
Play Music to level up and unlock free songs!
Songs can only be unlocked when you play online.
""" + + for song in EXP_UNLOCKED_SONGS: + song_id = song["id"] + level_required = song["lvl"] + song_name = SONG_LIST[song_id]["name_en"] if song_id < len(SONG_LIST) else "Unknown Song" + + html += f""" +
+
Level {level_required}
+
{song_name}
+
+ """ + + html += "
" + file_path = os.path.join("files", "mission.html") + try: + with open(file_path, "r", encoding="utf-8") as file: + html_content = file.read().format(text=html) + except FileNotFoundError: + return HTMLResponse("""

Mission file not found

""", status_code=500) + + return HTMLResponse(html_content) + else: + return HTMLResponse("""

Access denied

""", status_code=403) + +routes = [ + Route('/ranking.php', ranking, methods=['GET']), + Route('/ranking_detail.php', ranking_detail, methods=['GET']), + Route('/set_title.php', set_title, methods=['GET']), + Route('/mission.php', mission, methods=['GET']), + Route('/status.php', status, methods=['GET']), +] \ No newline at end of file diff --git a/api/shop.py b/api/shop.py new file mode 100644 index 0000000..930f468 --- /dev/null +++ b/api/shop.py @@ -0,0 +1,408 @@ +from starlette.responses import Response, FileResponse, HTMLResponse +from starlette.requests import Request +from starlette.routing import Route +import os +import json +import math +from sqlalchemy import select, update +import xml.etree.ElementTree as ET + +from config import START_COIN, AUTHORIZATION_NEEDED, STAGE_PRICE, START_COIN, AVATAR_PRICE, ITEM_PRICE + +from api.crypt import decrypt_fields +from api.misc import inform_page, parse_res, FMAX_VER, FMAX_RES +from api.database import database, daily_reward, check_blacklist, check_whitelist +from api.templates import START_AVATARS, START_STAGES, EXCLUDE_STAGE_EXP, SONG_LIST, AVATAR_LIST, ITEM_LIST + +async def web_shop(request: Request): + decrypted_fields, _ = await decrypt_fields(request) + if not decrypted_fields: + return HTMLResponse("""

Invalid request data

""", status_code=400) + + should_serve = True + if AUTHORIZATION_NEEDED: + should_serve = await check_whitelist(decrypted_fields) and not await check_blacklist(decrypted_fields) + + if should_serve: + cnt_type = decrypted_fields[b'cnt_type'][0].decode() + device_id = decrypted_fields[b'vid'][0].decode() + if (decrypted_fields.get(b'page')): + page = int(decrypted_fields.get(b'page')[0].decode()) + else: + page = 0 + + inc = 0 + fmax_inc = 0 + buttons_html = "" + spawn_prev_page = False + spawn_next_page = False + + query = select(daily_reward.c.my_stage, daily_reward.c.my_avatar, daily_reward.c.coin).where(daily_reward.c.device_id == device_id) + result = await database.fetch_one(query) + + my_stage = set(json.loads(result["my_stage"])) if result and result["my_stage"] else START_STAGES + my_avatar = set(json.loads(result["my_avatar"])) if result and result["my_avatar"] else START_AVATARS + coin = result["coin"] if result and result["coin"] else START_COIN + + if cnt_type == "1": + low_range = 100 + up_range = 616 + + if page < 0 or page > math.ceil(up_range - low_range) / 80: + return HTMLResponse("""

Invalid page number

""", status_code=400) + + if page > 0: + spawn_prev_page = True + if page < math.ceil(up_range - low_range) / 80 - 1: + spawn_next_page = True + + low_range = low_range + page * 80 + up_range = min(up_range, low_range + 80) + + if 700 not in my_stage and os.path.isfile('./files/dlc_4max.html'): + buttons_html += """ + + +
+ """ + fmax_inc = 1 + elif 700 in my_stage and os.path.isfile('./files/dlc_4max.html'): + buttons_html += """ + + +
+ """ + + for i in range(low_range, up_range): + if i not in my_stage and i not in EXCLUDE_STAGE_EXP: + buttons_html += f""" + + """ + inc += 1 + if inc % 4 == 0: + buttons_html += "
" + + if spawn_prev_page: + buttons_html += """
+ + """.format(page - 1) + + if spawn_next_page: + buttons_html += """
+ + """.format(page + 1) + + elif cnt_type == "2": + low_range = 15 + up_range = 173 if FMAX_VER == 0 else 267 + + if page < 0 or page > math.ceil(up_range - low_range) / 80: + return HTMLResponse("""

Invalid page number

""", status_code=400) + + if page > 0: + spawn_prev_page = True + if page < math.ceil(up_range - low_range) / 80 - 1: + spawn_next_page = True + + low_range = low_range + page * 80 + up_range = min(up_range, low_range + 80) + + for i in range(low_range, up_range): + if i not in my_avatar and i not in EXCLUDE_STAGE_EXP: + buttons_html += f""" + + """ + inc += 1 + if inc % 4 == 0: + buttons_html += "
" + + if spawn_prev_page: + buttons_html += """
+ + """.format(page - 1) + + if spawn_next_page: + buttons_html += """
+ + """.format(page + 1) + + elif cnt_type == "3": + for i in range(1, 11): + buttons_html += f""" + + """ + if i % 4 == 0: + buttons_html += "
" + + if inc == 0 and fmax_inc == 0 and cnt_type != "3": + buttons_html += """
Everything has been purchased!
""" + + html_path = f"files/web_shop_{cnt_type}.html" + try: + with open(html_path, "r", encoding="utf-8") as file: + html_content = file.read().format(text=buttons_html, coin=coin) + except FileNotFoundError: + return HTMLResponse("""

Shop template not found

""", status_code=500) + + return HTMLResponse(html_content) + else: + return HTMLResponse("""

Access denied

""", status_code=403) + +async def web_shop_detail(request: Request): + decrypted_fields, _ = await decrypt_fields(request) + if not decrypted_fields: + return HTMLResponse("""

Invalid request data

""", status_code=400) + + should_serve = True + if AUTHORIZATION_NEEDED: + should_serve = await check_whitelist(decrypted_fields) and not await check_blacklist(decrypted_fields) + + if should_serve: + cnt_type = decrypted_fields[b'cnt_type'][0].decode() + cnt_id = int(decrypted_fields[b'cnt_id'][0].decode()) + device_id = decrypted_fields[b'vid'][0].decode() + + query = select(daily_reward.c.coin).where(daily_reward.c.device_id == device_id) + result = await database.fetch_one(query) + coin = result["coin"] if result and result["coin"] else 0 + html = "" + + if cnt_type == "1": + if cnt_id > -1: + song = SONG_LIST[cnt_id] + difficulty_levels = "/".join(map(str, song.get("difficulty_levels", []))) + song_stage_price = STAGE_PRICE * 2 if len(song["difficulty_levels"]) == 6 else STAGE_PRICE + html = f""" +
+ Item Image +
+

Would you like to purchase this song?

+
+

{song.get("name_en")} - {song.get("author_en")}

+

Difficulty Levels: {difficulty_levels}

+
+
+ Coin Icon + {song_stage_price} +
+ """ + elif cnt_id == -2: + log = parse_res(FMAX_RES) + html = f""" +
+

You have unlocked the GC4MAX expansion!

+

Please report bugs/missing tracks to Discord: #AnTcfgss, or QQ 3421587952.

+
+ This server has version {FMAX_VER}. +

Update log:

+

{log}


+

+ """ + elif cnt_id == -1: + html = f""" +
+

Experience the arcade with the GC4MAX expansion! This DLC unlocks 320+ exclusive songs for your 2OS experience.

+

Note that these songs don't have mobile difficulties. A short placeholder is used, and GCoin reward is not available for playing them. You must clear the Normal difficulty to unlock AC content.

+

Due to technical limitations, Extra level charts cannot be ported as of now. After purchasing, you will have access to support information and update logs.

+
+ + +

+ + """ + + elif cnt_type == "2": + avatar = next((item for item in AVATAR_LIST if item.get("id") == cnt_id), None) + if avatar: + html = f""" +
+ Item Image +
+

Would you like to purchase this avatar?

+
+

{avatar.get("name")}

+

Effect: {avatar.get("effect")}

+
+
+ Coin Icon + {AVATAR_PRICE} +
+ """ + else: + html = "

Avatar not found.

" + + elif cnt_type == "3": + item = next((item for item in ITEM_LIST if item.get("id") == cnt_id), None) + if item: + html = f""" +
+ Item Image +
+

Would you like to purchase this item?

+
+

{item.get("name")}

+

Effect: {item.get("effect")}

+
+
+ Coin Icon + {ITEM_PRICE} +
+ """ + else: + html = "

Item not found.

" + + if cnt_type == "1" and cnt_id < 0: + source_html = f"files/dlc_4max.html" + else: + source_html = f"files/web_shop_detail.html" + html += f""" +
+
+ Buy
+ Go Back +
+ """ + + try: + with open(source_html, "r", encoding="utf-8") as file: + html_content = file.read().format(text=html, coin=coin) + except FileNotFoundError: + return HTMLResponse("""

Shop detail template not found

""", status_code=500) + + return HTMLResponse(html_content) + else: + return HTMLResponse("""

Access denied

""", status_code=403) + +async def buy_by_coin(request: Request): + decrypted_fields, _ = await decrypt_fields(request) + if not decrypted_fields: + return Response("""1coin_error.php""", media_type="application/xml") + + should_serve = True + if AUTHORIZATION_NEEDED: + should_serve = await check_whitelist(decrypted_fields) and not await check_blacklist(decrypted_fields) + + if should_serve: + cnt_type = decrypted_fields[b'cnt_type'][0].decode() + cnt_id = int(decrypted_fields[b'cnt_id'][0].decode()) + num = int(decrypted_fields[b'num'][0].decode()) + device_id = decrypted_fields[b'vid'][0].decode() + + fail_url = """1coin_error.php""" + + query = select(daily_reward.c.my_stage, daily_reward.c.my_avatar, daily_reward.c.coin, daily_reward.c.item).where(daily_reward.c.device_id == device_id) + result = await database.fetch_one(query) + + if not result: + return Response(fail_url, media_type="application/xml") + + my_stage = set(json.loads(result["my_stage"])) if result["my_stage"] else set() + my_avatar = set(json.loads(result["my_avatar"])) if result["my_avatar"] else set() + coin = int(result["coin"]) if result["coin"] else 0 + item = json.loads(result["item"]) if result["item"] else [] + + if cnt_type == "1": + if cnt_id == -1: + song_stage_price = 300 + if coin < song_stage_price: + return Response(fail_url, media_type="application/xml") + + for i in range(616, 950): + my_stage.add(i) + coin -= song_stage_price + else: + song_stage_price = STAGE_PRICE * 2 if len(SONG_LIST[cnt_id]["difficulty_levels"]) == 6 else STAGE_PRICE + if coin < song_stage_price or cnt_id in my_stage: + return Response(fail_url, media_type="application/xml") + + coin -= song_stage_price + my_stage.add(cnt_id) + + elif cnt_type == "2": + if coin < AVATAR_PRICE or cnt_id in my_avatar: + return Response(fail_url, media_type="application/xml") + + coin -= AVATAR_PRICE + my_avatar.add(cnt_id) + + elif cnt_type == "3": + if coin < ITEM_PRICE: + return Response(fail_url, media_type="application/xml") + + coin -= ITEM_PRICE + item.append(cnt_id) + + else: + return Response(fail_url, media_type="application/xml") + + update_query = ( + update(daily_reward) + .where(daily_reward.c.device_id == device_id) + .values( + my_stage=json.dumps(list(my_stage)), + my_avatar=json.dumps(list(my_avatar)), + coin=coin, + item=json.dumps(item) + ) + ) + await database.execute(update_query) + + response = ET.Element("response") + ET.SubElement(response, "code").text = "0" + ET.SubElement(response, "result_url").text = "web_shop_result.php" + ET.SubElement(response, "cnt_type").text = cnt_type + ET.SubElement(response, "cnt_id").text = str(cnt_id) + ET.SubElement(response, "num").text = str(num) + + if cnt_type == "1": + ET.SubElement(response, "stage_id").text = str(cnt_id) + + response_string = ET.tostring(response, encoding="utf-8", method="xml").decode("utf-8") + return Response(response_string, media_type="application/xml") + else: + return Response("""1coin_error.php""", media_type="application/xml") + +async def web_shop_result(request: Request): + decrypted_fields, _ = await decrypt_fields(request) + cnt_type = decrypted_fields[b'cnt_type'][0].decode() + return HTMLResponse(inform_page(f"""SUCCESS:
Purchase successful.
Please close this page and the reward will arrive shortly.
If it took too long, try restarting the game.
Go Back""", 2)) + +async def coin_error(request: Request): + return HTMLResponse(inform_page(f"""FAILED:
Either you don't have enough coin,
or there were a duplicate order, and the reward will arrive shortly.""", 2)) + + +routes = [ + Route('/web_shop.php', web_shop, methods=['GET', 'POST']), + Route('/web_shop_detail.php', web_shop_detail, methods=['GET', 'POST']), + Route('/buy_by_coin.php', buy_by_coin, methods=['GET']), + Route('/web_shop_result.php', web_shop_result, methods=['GET']), + Route('/coin_error.php', coin_error, methods=['GET']), +] \ No newline at end of file diff --git a/api/templates.py b/api/templates.py new file mode 100644 index 0000000..8d09c5e --- /dev/null +++ b/api/templates.py @@ -0,0 +1,58 @@ +import json +import os + +SONG_LIST = [] +AVATAR_LIST = [] +ITEM_LIST = [] +EXP_UNLOCKED_SONGS = [] + +START_STAGES = [7,23,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,88,89,90,91,92,93,94,95,96,97,98,99,214] +# 214 is tutorial song. + +START_AVATARS = [] + +EXCLUDE_STAGE_EXP = [121,134,166,167,168,169,170,213,214,215,225,277,397] # 134 and 170 unoccupied dummy tracks (filled with Departure -Remix-), +#121 (and 93-96 lady gaga songs) removed (can be enabled by patching stageParam:isAvailable, or change the last byte before next song's name - 1 from 01 to 03 in stage_param.dat. +# Rest are exp unlocked songs. +EXCLUDE_AVATAR_EXP = [28,29] + +SPECIAL_TITLES = [1, 2, 4431, 4432, 4601, 4602, 4611, 4612, 4621, 4622, 4631, 4632, 5111, 5112, 5121, 5122, 5131, 5132, 10001, 10002, 20001, 20002, 20003, 20004, 20005, 20006, 30001, 30002, 40001, 40002, 50001, 50002, 60001, 60002, 70001, 70002, 80001, 80002, 90001, 90002, 100001, 100002, 110001, 110002, 120001, 120002, 130001, 130002, 140001, 140002, 140003, 140004, 150001, 150002, 150003, 150004, 160001, 160002, 160003, 160004, 170001, 170002, 170003, 170004, 180001, 180002, 180003, 180004, 190001, 190002, 190003, 190004, 200001, 200002, 200003, 200004, 210001, 210002, 210003, 210004, 210005, 210006, 210007, 210008, 210009, 210010, 210011, 210012, 210013, 210014, 240001, 240002, 240003, 240004, 240005, 240006, 240007, 240008, 240009, 240010, 240011, 240012] + +GOD_TITLES = [220001, 220002, 220003, 220004, 220005, 220006, 220007, 220008, 220009, 220010, 220011, 220012, 220013, 220014, 220015, 220016, 220017, 220018, 220019, 220020, 220021, 220022, 220023, 220024, 220025, 220026, 220027, 220028, 220029, 220030, 220031, 220032, 220033, 220034, 220035, 220036, 220037, 220038, 220039, 220040, 220041, 220042, 220043, 220044, 220045, 220046, 220047, 220048, 220049, 220050, 220051, 220052, 220053, 220054, 220055, 220056, 220057, 220058, 220059, 220060, 220061, 220062, 220063, 220064, 220065, 220066, 220067, 220068, 220069, 220070, 220071, 220072, 220073, 220074, 220075, 220076, 220077, 220078, 220079, 220080, 220081, 220082, 220083, 220084, 220085, 220086, 220087, 220088, 220089, 220090, 220091, 220092, 220093, 220094, 220095, 220096, 220097, 220098, 220099, 220100, 220101, 220102] + +MASTER_TITLES = [12, 22, 32, 42, 52, 62, 72, 82, 92, 102, 112, 122, 132, 142, 152, 162, 172, 182, 192, 202, 212, 222, 232, 242, 252, 262, 272, 282, 292, 302, 312, 322, 332, 342, 352, 362, 372, 382, 392, 402, 412, 422, 432, 442, 452, 462, 472, 482, 492, 502, 512, 522, 532, 542, 552, 562, 572, 582, 592, 602, 612, 622, 632, 642, 652, 662, 672, 682, 692, 702, 712, 722, 732, 742, 752, 762, 772, 782, 792, 802, 812, 822, 832, 842, 852, 862, 872, 882, 892, 902, 912, 922, 972, 982, 992, 1002, 1012, 1022, 1032, 1042, 1052, 1062, 1072, 1082, 1092, 1102, 1112, 1122, 1132, 1142, 1152, 1162, 1172, 1182, 1192, 1202, 1222, 1232, 1242, 1252, 1262, 1272, 1282, 1292, 1302, 1312, 1322, 1332, 1342, 1352, 1362, 1372, 1382, 1392, 1402, 1412, 1422, 1432, 1442, 1452, 1462, 1472, 1482, 1492, 1502, 1512, 1522, 1532, 1542, 1552, 1562, 1572, 1582, 1592, 1602, 1612, 1622, 1632, 1642, 1652, 1662, 1672, 1682, 1692, 1702, 1712, 1722, 1732, 1742, 1752, 1762, 1772, 1782, 1792, 1802, 1812, 1822, 1832, 1842, 1852, 1862, 1872, 1882, 1892, 1902, 1912, 1922, 1932, 1942, 1952, 1962, 1972, 1982, 1992, 2002, 2012, 2022, 2032, 2042, 2052, 2062, 2072, 2082, 2092, 2102, 2112, 2122, 2132, 2152, 2162, 2172, 2182, 2192, 2202, 2212, 2222, 2232, 2242, 2252, 2262, 2272, 2282, 2292, 2302, 2312, 2322, 2332, 2342, 2352, 2362, 2372, 2382, 2392, 2402, 2412, 2422, 2432, 2442, 2452, 2462, 2472, 2482, 2492, 2502, 2512, 2522, 2532, 2542, 2552, 2562, 2572, 2582, 2592, 2602, 2612, 2622, 2632, 2642, 2652, 2662, 2672, 2682, 2692, 2702, 2712, 2722, 2732, 2742, 2752, 2762, 2782, 2792, 2802, 2812, 2822, 2832, 2842, 2852, 2862, 2872, 2882, 2892, 2902, 2912, 2922, 2932, 2942, 2952, 2962, 2972, 2982, 2992, 3002, 3012, 3022, 3032, 3042, 3052, 3062, 3072, 3082, 3092, 3102, 3112, 3122, 3132, 3142, 3152, 3162, 3172, 3182, 3192, 3202, 3212, 3222, 3232, 3242, 3252, 3262, 3272, 3282, 3292, 3302, 3312, 3322, 3332, 3342, 3352, 3362, 3372, 3382, 3392, 3402, 3412, 3422, 3432, 3442, 3452, 3462, 3472, 3482, 3492, 3502, 3512, 3522, 3532, 3542, 3552, 3562, 3572, 3582, 3592, 3602, 3612, 3622, 3632, 3642, 3652, 3662, 3672, 3682, 3692, 3702, 3712, 3722, 3732, 3742, 3752, 3762, 3772, 3782, 3792, 3802, 3812, 3822, 3832, 3842, 3852, 3862, 3872, 3882, 3892, 3902, 3912, 3922, 3932, 3942, 3952, 3962, 3982, 3992, 4002, 4012, 4022, 4032, 4042, 4052, 4062, 4072, 4082, 4092, 4102, 4112, 4122, 4132, 4142, 4152, 4162, 4172, 4182, 4192, 4202, 4212, 4222, 4232, 4242, 4252, 4262, 4272, 4282, 4292, 4302, 4312, 4322, 4332, 4342, 4352, 4362, 4372, 4382, 4392, 4402, 4412, 4422, 4442, 4452, 4462, 4472, 4482, 4492, 4502, 4512, 4522, 4532, 4542, 4552, 4562, 4572, 4582, 4592, 4642, 4652, 4662, 4672, 4682, 4692, 4702, 4712, 4722, 4732, 4742, 4752, 4762, 4772, 4782, 4792, 4802, 4812, 4822, 4832, 4842, 4862, 4872, 4882, 4892, 4902, 4912, 4922, 4932, 4942, 4952, 4962, 4972, 4982, 4992, 5002, 5012, 5022, 5032, 5042, 5052, 5062, 5072, 5082, 5092, 5102, 5142, 5152, 5162, 5172, 5182, 5192, 5202, 5212, 5222, 5232, 5242, 5252, 5262, 5272, 5282, 5292, 5302, 5312, 5322, 5332, 5342, 5352, 5362, 5372, 5382, 5392, 5402, 5412, 5422, 5432, 5442, 5452, 5462, 5472, 5482, 5492, 5502, 5512, 5522, 5532, 5542, 5552, 5562, 5572, 5582, 5592, 5602, 5612, 5622, 5632, 5642, 5652, 5662, 5672, 5682, 5692, 5702, 5712, 5722, 5732, 5742, 5752, 5762, 5772, 5782, 5792, 5802, 5812, 5822, 5832, 5842, 5852, 5862, 5872, 5882, 5892, 5902, 5912, 5922, 5932, 5942, 5952, 5962, 5972, 5982, 5992, 6002, 6012, 6022, 6032, 6042, 6052, 6062, 6072, 6082, 6092, 6102, 6112, 6122, 6132, 6142, 6152] + +NORMAL_TITLES = [11, 21, 31, 41, 51, 61, 71, 81, 91, 101, 111, 121, 131, 141, 151, 161, 171, 181, 191, 201, 211, 221, 231, 241, 251, 261, 271, 281, 291, 301, 311, 321, 331, 341, 351, 361, 371, 381, 391, 401, 411, 421, 431, 441, 451, 461, 471, 481, 491, 501, 511, 521, 531, 541, 551, 561, 571, 581, 591, 601, 611, 621, 631, 641, 651, 661, 671, 681, 691, 701, 711, 721, 731, 741, 751, 761, 771, 781, 791, 801, 811, 821, 831, 841, 851, 861, 871, 881, 891, 901, 911, 921, 971, 981, 991, 1001, 1011, 1021, 1031, 1041, 1051, 1061, 1071, 1081, 1091, 1101, 1111, 1121, 1131, 1141, 1151, 1161, 1171, 1181, 1191, 1201, 1221, 1231, 1241, 1251, 1261, 1271, 1281, 1291, 1301, 1311, 1321, 1331, 1341, 1351, 1361, 1371, 1381, 1391, 1401, 1411, 1421, 1431, 1441, 1451, 1461, 1471, 1481, 1491, 1501, 1511, 1521, 1531, 1541, 1551, 1561, 1571, 1581, 1591, 1601, 1611, 1621, 1631, 1641, 1651, 1661, 1671, 1681, 1691, 1701, 1711, 1721, 1731, 1741, 1751, 1761, 1771, 1781, 1791, 1801, 1811, 1821, 1831, 1841, 1851, 1861, 1871, 1881, 1891, 1901, 1911, 1921, 1931, 1941, 1951, 1961, 1971, 1981, 1991, 2001, 2011, 2021, 2031, 2041, 2051, 2061, 2071, 2081, 2091, 2101, 2111, 2121, 2131, 2151, 2161, 2171, 2181, 2191, 2201, 2211, 2221, 2231, 2241, 2251, 2261, 2271, 2281, 2291, 2301, 2311, 2321, 2331, 2341, 2351, 2361, 2371, 2381, 2391, 2401, 2411, 2421, 2431, 2441, 2451, 2461, 2471, 2481, 2491, 2501, 2511, 2521, 2531, 2541, 2551, 2561, 2571, 2581, 2591, 2601, 2611, 2621, 2631, 2641, 2651, 2661, 2671, 2681, 2691, 2701, 2711, 2721, 2731, 2741, 2751, 2761, 2781, 2791, 2801, 2811, 2821, 2831, 2841, 2851, 2861, 2871, 2881, 2891, 2901, 2911, 2921, 2931, 2941, 2951, 2961, 2971, 2981, 2991, 3001, 3011, 3021, 3031, 3041, 3051, 3061, 3071, 3081, 3091, 3101, 3111, 3121, 3131, 3141, 3151, 3161, 3171, 3181, 3191, 3201, 3211, 3221, 3231, 3241, 3251, 3261, 3271, 3281, 3291, 3301, 3311, 3321, 3331, 3341, 3351, 3361, 3371, 3381, 3391, 3401, 3411, 3421, 3431, 3441, 3451, 3461, 3471, 3481, 3491, 3501, 3511, 3521, 3531, 3541, 3551, 3561, 3571, 3581, 3591, 3601, 3611, 3621, 3631, 3641, 3651, 3661, 3671, 3681, 3691, 3701, 3711, 3721, 3731, 3741, 3751, 3761, 3771, 3781, 3791, 3801, 3811, 3821, 3831, 3841, 3851, 3861, 3871, 3881, 3891, 3901, 3911, 3921, 3931, 3941, 3951, 3961, 3981, 3991, 4001, 4011, 4021, 4031, 4041, 4051, 4061, 4071, 4081, 4091, 4101, 4111, 4121, 4131, 4141, 4151, 4161, 4171, 4181, 4191, 4201, 4211, 4221, 4231, 4241, 4251, 4261, 4271, 4281, 4291, 4301, 4311, 4321, 4331, 4341, 4351, 4361, 4371, 4381, 4391, 4401, 4411, 4421, 4441, 4451, 4461, 4471, 4481, 4491, 4501, 4511, 4521, 4531, 4541, 4551, 4561, 4571, 4581, 4591, 4641, 4651, 4661, 4671, 4681, 4691, 4701, 4711, 4721, 4731, 4741, 4751, 4761, 4771, 4781, 4791, 4801, 4811, 4821, 4831, 4841, 4861, 4871, 4881, 4891, 4901, 4911, 4921, 4931, 4941, 4951, 4961, 4971, 4981, 4991, 5001, 5011, 5021, 5031, 5041, 5051, 5061, 5071, 5081, 5091, 5101, 5141, 5151, 5161, 5171, 5181, 5191, 5201, 5211, 5221, 5231, 5241, 5251, 5261, 5271, 5281, 5291, 5301, 5311, 5321, 5331, 5341, 5351, 5361, 5371, 5381, 5391, 5401, 5411, 5421, 5431, 5441, 5451, 5461, 5471, 5481, 5491, 5501, 5511, 5521, 5531, 5541, 5551, 5561, 5571, 5581, 5591, 5601, 5611, 5621, 5631, 5641, 5651, 5661, 5671, 5681, 5691, 5701, 5711, 5721, 5731, 5741, 5751, 5761, 5771, 5781, 5791, 5801, 5811, 5821, 5831, 5841, 5851, 5861, 5871, 5881, 5891, 5901, 5911, 5921, 5931, 5941, 5951, 5961, 5971, 5981, 5991, 6001, 6011, 6021, 6031, 6041, 6051, 6061, 6071, 6081, 6091, 6101, 6111, 6121, 6131, 6141, 6151] + +TITLE_LISTS = { + 0: SPECIAL_TITLES, + 1: NORMAL_TITLES, + 2: MASTER_TITLES, + 3: GOD_TITLES, +} + +def init_templates(): + global SONG_LIST, AVATAR_LIST, ITEM_LIST, EXP_UNLOCKED_SONGS + + base_path = 'api/config/' + print("[TEMPLATES] Initializing templates...") + + try: + with open(os.path.join(base_path, 'song_list.json'), 'r', encoding='utf-8') as f: + SONG_LIST = json.load(f) + + with open(os.path.join(base_path, 'avatar_list.json'), 'r', encoding='utf-8') as f: + AVATAR_LIST = json.load(f) + + with open(os.path.join(base_path, 'item_list.json'), 'r', encoding='utf-8') as f: + ITEM_LIST = json.load(f) + + with open(os.path.join(base_path, 'exp_unlocked_songs.json'), 'r', encoding='utf-8') as f: + EXP_UNLOCKED_SONGS = json.load(f) + + print("[TEMPLATES] Templates initialized successfully.") + + except FileNotFoundError as e: + print(f"Error: {e}") + except json.JSONDecodeError as e: + print(f"Error decoding JSON: {e}") \ No newline at end of file diff --git a/api/user.py b/api/user.py new file mode 100644 index 0000000..91ea0d6 --- /dev/null +++ b/api/user.py @@ -0,0 +1,825 @@ +from starlette.responses import Response, FileResponse, HTMLResponse +from starlette.requests import Request +from starlette.routing import Route +import os +from datetime import datetime +import json +import secrets +from sqlalchemy import select, update, insert +import xml.etree.ElementTree as ET + +from config import ROOT_FOLDER, START_COIN, COIN_REWARD, AUTHORIZATION_NEEDED, HOST, PORT + +from api.misc import is_alphanumeric, inform_page, verify_password, hash_password, crc32_decimal, get_model_pak, get_tune_pak, get_skin_pak, get_m4a_path, get_stage_path, get_stage_zero +from api.database import database, user, daily_reward, result, get_user_data, set_user_data, check_blacklist, check_whitelist +from api.crypt import decrypt_fields +from api.templates import START_AVATARS, START_STAGES, EXP_UNLOCKED_SONGS + +async def info(request: Request): + file_path = os.path.join(ROOT_FOLDER, "files/history.html") + return FileResponse(file_path) + +async def history(request: Request): + file_path = os.path.join(ROOT_FOLDER, "files/history.html") + return FileResponse(file_path) + +async def delete_account(request): + # This only tricks the client to clear its local data for now + return Response( + """0""", + media_type="application/xml" + ) + +async def tier(request: Request): + file_path = os.path.join(ROOT_FOLDER, "files/tier.xml") + return FileResponse(file_path) + +async def reg(request: Request): + return Response("", status_code=200) + +async def name_reset(request: Request): + form = await request.form() + username = form.get("username") + password = form.get("password") + + if not username or not password: + return HTMLResponse(inform_page("FAILED:
Missing username or password.", 0)) + + if len(username) < 6 or len(username) > 20: + return HTMLResponse(inform_page("FAILED:
Username must be between 6 and 20 characters long.", 0)) + + if not is_alphanumeric(username): + return HTMLResponse(inform_page("FAILED:
Username must consist entirely of alphanumeric characters.", 0)) + + if username == password: + return HTMLResponse(inform_page("FAILED:
Username cannot be the same as password.", 0)) + + decrypted_fields, _ = await decrypt_fields(request) + if not decrypted_fields: + return HTMLResponse(inform_page("FAILED:
Invalid request data.", 0)) + + if not await check_blacklist(decrypted_fields): + return HTMLResponse(inform_page("FAILED:
Your account is banned and you are not allowed to perform this action.", 0)) + + user_exist = await get_user_data(decrypted_fields, "username") + if user_exist: + + query = select(user.c.id).where(user.c.username == username) + existing_user = await database.fetch_one(query) + if existing_user: + return HTMLResponse(inform_page("FAILED:
Another user already has this name.", 0)) + + password_hash = await get_user_data(decrypted_fields, "password_hash") + if password_hash: + if verify_password(password, password_hash): + await set_user_data(decrypted_fields, "username", username) + return HTMLResponse(inform_page("SUCCESS:
Username updated.", 0)) + else: + return HTMLResponse(inform_page("FAILED:
Password is not correct.
Please try again.", 0)) + else: + return HTMLResponse(inform_page("FAILED:
User has no password hash.
This should not happen.", 0)) + else: + return HTMLResponse(inform_page("FAILED:
User does not exist.
This should not happen.", 0)) + + +async def password_reset(request: Request): + form = await request.form() + old_password = form.get("old") + new_password = form.get("new") + + if not old_password or not new_password: + return HTMLResponse(inform_page("FAILED:
Missing old or new password.", 0)) + + decrypted_fields, _ = await decrypt_fields(request) + if not decrypted_fields: + return HTMLResponse(inform_page("FAILED:
Invalid request data.", 0)) + + username = await get_user_data(decrypted_fields, "username") + if username: + if username == new_password: + return HTMLResponse(inform_page("FAILED:
Username cannot be the same as password.", 0)) + if len(new_password) < 6: + return HTMLResponse(inform_page("FAILED:
Password must have 6 or more characters.", 0)) + + old_hash = await get_user_data(decrypted_fields, "password_hash") + print("hash type", type(old_hash)) + if old_hash: + if verify_password(old_password, old_hash): + hashed_new_password = hash_password(new_password) + await set_user_data(decrypted_fields, "password_hash", hashed_new_password) + return HTMLResponse(inform_page("SUCCESS:
Password updated.", 0)) + else: + return HTMLResponse(inform_page("FAILED:
Old password is not correct.
Please try again.", 0)) + else: + return HTMLResponse(inform_page("FAILED:
User has no password hash.
This should not happen.", 0)) + else: + return HTMLResponse(inform_page("FAILED:
User does not exist.
This should not happen.", 0)) + + +async def coin_mp(request: Request): + form = await request.form() + mp = int(form.get("coin_mp")) + + if not mp: + return HTMLResponse(inform_page("FAILED:
Missing multiplier.", 0)) + + if mp < 0 or mp > 5: + return HTMLResponse(inform_page("FAILED:
Multiplier not acceptable.", 0)) + + decrypted_fields, _ = await decrypt_fields(request) + if not decrypted_fields: + return HTMLResponse(inform_page("FAILED:
Invalid request data.", 0)) + + user_exist = await get_user_data(decrypted_fields, "username") + if user_exist: + await set_user_data(decrypted_fields, "coin_mp", mp) + return HTMLResponse(inform_page("SUCCESS:
Coin multiplier set to " + str(mp) + ".", 0)) + else: + return HTMLResponse(inform_page("FAILED:
User does not exist.", 0)) + + +async def save_migration(request: Request): + form = await request.form() + save_id = form.get("save_id") + + if not save_id: + return HTMLResponse(inform_page("FAILED:
Missing save_id.", 0)) + + if len(save_id) != 24 or not all(c in '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' for c in save_id): + return HTMLResponse(inform_page("FAILED:
Save ID not acceptable format.", 0)) + + decrypted_fields, _ = await decrypt_fields(request) + if not decrypted_fields: + return HTMLResponse(inform_page("FAILED:
Invalid request data.", 0)) + + user_exist = await get_user_data(decrypted_fields, "username") + if user_exist: + query = select(user.c.data, user.c.crc).where(user.c.save_id == save_id) + existing_save_data = await database.fetch_one(query) + + if existing_save_data: + query = update(user).where(user.c.device_id == decrypted_fields[b'vid'][0].decode()).values( + data=existing_save_data["data"], + crc=existing_save_data["crc"], + timestamp=datetime.now() + ) + await database.execute(query) + return HTMLResponse(inform_page("SUCCESS:
Save migration was applied. If this was done by mistake, press the Save button now.", 0)) + else: + return HTMLResponse(inform_page("FAILED:
Save ID is not associated with a save file.", 0)) + + else: + return HTMLResponse(inform_page("FAILED:
User does not exist.", 0)) + + +async def register(request: Request): + form = await request.form() + username = form.get("username") + password = form.get("password") + + if not username or not password: + return HTMLResponse(inform_page("FAILED:
Missing username or password.", 0)) + + if username == password: + return HTMLResponse(inform_page("FAILED:
Username cannot be the same as password.", 0)) + + if len(username) < 6 or len(username) > 20: + return HTMLResponse(inform_page("FAILED:
Username must be between 6 and 20
characters long.", 0)) + + if len(password) < 6: + return HTMLResponse(inform_page("FAILED:
Password must have
6 or above characters.", 0)) + + if not is_alphanumeric(username): + return HTMLResponse(inform_page("FAILED:
Username must consist entirely of
alphanumeric characters.", 0)) + + decrypted_fields, _ = await decrypt_fields(request) + if not decrypted_fields: + return HTMLResponse(inform_page("FAILED:
Invalid request data.", 0)) + + query = select(user.c.id).where(user.c.username == username) + existing_user = await database.fetch_one(query) + if existing_user: + return HTMLResponse(inform_page("FAILED:
Another user already has this name.", 0)) + + insert_query = insert(user).values( + username=username, + password_hash=hash_password(password), + device_id=decrypted_fields[b'vid'][0].decode(), + data="", + crc=0, + coin_mp=1, + ) + await database.execute(insert_query) + + return HTMLResponse(inform_page("SUCCESS:
Account is registered.
You can now backup/restore your save file.
You can only log into one device at a time.", 0)) + +async def logout(request: Request): + decrypted_fields, _ = await decrypt_fields(request) + if not decrypted_fields: + return HTMLResponse(inform_page("FAILED:
Invalid request data.", 0)) + + if not await check_blacklist(decrypted_fields): + return HTMLResponse(inform_page("FAILED:
Your account is banned and you are
not allowed to perform this action.", 0)) + + await set_user_data(decrypted_fields, "device_id", "") + return HTMLResponse(inform_page("Logout success.", 0)) + +async def login(request: Request): + form = await request.form() + username = form.get("username") + password = form.get("password") + + if not username or not password: + return HTMLResponse(inform_page("FAILED:
Missing username or password.", 0)) + + decrypted_fields, _ = await decrypt_fields(request) + if not decrypted_fields: + return HTMLResponse(inform_page("FAILED:
Invalid request data.", 0)) + + query = select(user.c.id).where(user.c.username == username) + user_record = await database.fetch_one(query) + if user_record: + user_id = user_record[0] + + query = select(user.c.password_hash).where(user.c.id == user_id) + password_hash_record = await database.fetch_one(query) + if password_hash_record and verify_password(password, password_hash_record[0]): + update_query = ( + update(user) + .where(user.c.id == user_id) + .values(device_id=decrypted_fields[b'vid'][0].decode()) + ) + await database.execute(update_query) + return HTMLResponse(inform_page("SUCCESS:
You are logged in.", 0)) + else: + return HTMLResponse(inform_page("FAILED:
Username or password incorrect.", 0)) + else: + return HTMLResponse(inform_page("FAILED:
Username or password incorrect.", 0)) + +async def load(request: Request): + decrypted_fields, _ = await decrypt_fields(request) + if not decrypted_fields: + return Response("""10この機能を使用するには、まずアカウントを登録する必要があります。You need to register an account first before this feature can be used.Vous devez d'abord créer un compte avant de pouvoir utiliser cette fonctionnalité.È necessario registrare un account prima di poter utilizzare questa funzione.""", media_type="application/xml") + + data = await get_user_data(decrypted_fields, "data") + if data and data != "": + crc = await get_user_data(decrypted_fields, "crc") + timestamp = await get_user_data(decrypted_fields, "timestamp") + xml_data = f"""0 + {data} + {crc} + {timestamp} + """ + return Response(xml_data, media_type="application/xml") + else: + return Response( """12セーブデータが無いか、セーブデータが破損しているため、ロードできませんでした。Unable to load; either no save data exists, or the save data is corrupted.Chargement impossible : les données de sauvegarde sont absentes ou corrompues.Impossibile caricare. Non esistono dati salvati o quelli esistenti sono danneggiati.""", media_type="application/xml") + +async def save(request: Request): + decrypted_fields, _ = await decrypt_fields(request) + if not decrypted_fields: + return Response("""10""", media_type="application/xml") + + data = await request.body() + data = data.decode("utf-8") + + username = await get_user_data(decrypted_fields, "username") + if username: + crc = crc32_decimal(data) + formatted_time = datetime.now() + is_save_id_unique = False + while not is_save_id_unique: + save_id = ''.join(secrets.choice('abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ') for _ in range(24)) + + check_query = select(user.c.id).where(user.c.save_id == save_id) + existing_user = await database.fetch_one(check_query) + if not existing_user: + is_save_id_unique = True + + update_query = ( + update(user) + .where(user.c.device_id == decrypted_fields[b'vid'][0].decode()) + .values(data=data, crc=crc, save_id=save_id, timestamp=formatted_time) + ) + await database.execute(update_query) + + return Response("""0""", media_type="application/xml") + else: + return Response("""10""", media_type="application/xml") + +async def start(request: Request): + decrypted_fields, _ = await decrypt_fields(request) + if not decrypted_fields: + return Response("""10Invalid request data.Invalid request data.""", media_type="application/xml" + ) + + file_path = os.path.join(ROOT_FOLDER, "files/start.xml") + try: + tree = ET.parse(file_path) + root = tree.getroot() + except Exception as e: + return Response(f"""500Error parsing XML: {str(e)}""", media_type="application/xml") + + username = await get_user_data(decrypted_fields, "username") + user_id = await get_user_data(decrypted_fields, "id") + + should_serve = True + if AUTHORIZATION_NEEDED: + should_serve = await check_whitelist(decrypted_fields) and not await check_blacklist(decrypted_fields) + + if not should_serve: + return Response("""403Access denied.""", media_type="application/xml") + + host_string = "http://" + HOST + ":" + str(PORT) + "/" + device_id = decrypted_fields[b'vid'][0].decode() + + for generator in [get_model_pak, get_tune_pak, get_skin_pak, get_m4a_path, get_stage_path]: + try: + root.append(generator(host_string)) + except Exception as e: + return Response(f"""500Error generating XML element: {str(e)}""", media_type="application/xml") + + daily_reward_elem = root.find(".//login_bonus") + if daily_reward_elem is None: + return Response("""500Missing element in XML.""", media_type="application/xml") + + last_count_elem = daily_reward_elem.find("last_count") + if last_count_elem is None or not last_count_elem.text.isdigit(): + return Response("""500Invalid or missing last_count in XML.""", media_type="application/xml") + last_count = int(last_count_elem.text) + now_count = 1 + + query = select(daily_reward.c.day, daily_reward.c.timestamp).where(daily_reward.c.device_id == device_id) + row = await database.fetch_one(query) + + if row: + current_day = row["day"] + last_timestamp = row["timestamp"] + current_date = datetime.now() + + if (current_date.date() - last_timestamp.date()).days >= 1: + now_count = current_day + 1 + if now_count > last_count: + now_count = 1 + else: + now_count = current_day + + now_count_elem = daily_reward_elem.find("now_count") + if now_count_elem is None: + now_count_elem = ET.Element("now_count") + daily_reward_elem.append(now_count_elem) + now_count_elem.text = str(now_count) + + query = select(daily_reward.c.my_avatar, daily_reward.c.my_stage, daily_reward.c.coin).where(daily_reward.c.device_id == device_id) + result_obj = await database.fetch_one(query) + + if result_obj: + my_avatar = set(json.loads(result_obj[0])) if result_obj[0] else START_AVATARS + my_stage = set(json.loads(result_obj[1])) if result_obj[1] else START_STAGES + coin = result_obj[2] if result_obj[2] is not None else START_COIN + else: + my_avatar = START_AVATARS + my_stage = START_STAGES + coin = START_COIN + + coin_elem = ET.Element("my_coin") + coin_elem.text = str(coin) + root.append(coin_elem) + + for avatar_id in my_avatar: + avatar_elem = ET.Element("my_avatar") + avatar_elem.text = str(avatar_id) + root.append(avatar_elem) + + for stage_id in my_stage: + stage_elem = ET.Element("my_stage") + stage_id_elem = ET.Element("stage_id") + stage_id_elem.text = str(stage_id) + stage_elem.append(stage_id_elem) + + ac_mode_elem = ET.Element("ac_mode") + ac_mode_elem.text = "1" + stage_elem.append(ac_mode_elem) + root.append(stage_elem) + + if username: + tid = ET.Element("taito_id") + tid.text = username + root.append(tid) + + sid_elem = ET.Element("sid") + sid_elem.text = str(user_id) + root.append(sid_elem) + + try: + sid = get_stage_zero() + root.append(sid) + except Exception as e: + return Response(f"""500Error retrieving stage zero: {str(e)}""", media_type="application/xml") + + xml_response = ET.tostring(root, encoding='unicode') + return Response(xml_response, media_type="application/xml") + +async def sync(request: Request): + decrypted_fields, _ = await decrypt_fields(request) + if not decrypted_fields: + return Response( + """10Invalid request data.""", + media_type="application/xml" + ) + + device_id = decrypted_fields[b'vid'][0].decode() + file_path = os.path.join(ROOT_FOLDER, "files/sync.xml") + try: + tree = ET.parse(file_path) + root = tree.getroot() + except Exception as e: + return Response( + f"""500Error parsing XML: {str(e)}""", + media_type="application/xml" + ) + + username = await get_user_data(decrypted_fields, "username") + should_serve = True + if AUTHORIZATION_NEEDED: + should_serve = await check_whitelist(decrypted_fields) and not await check_blacklist(decrypted_fields) + + if not should_serve: + return Response( + """403Access denied.""", + media_type="application/xml" + ) + + host_string = "http://" + HOST + ":" + str(PORT) + "/" + root.append(get_model_pak(host_string)) + root.append(get_tune_pak(host_string)) + root.append(get_skin_pak(host_string)) + root.append(get_m4a_path(host_string)) + root.append(get_stage_path(host_string)) + + query = select(daily_reward.c.my_avatar, daily_reward.c.my_stage, daily_reward.c.coin, daily_reward.c.item).where(daily_reward.c.device_id == device_id) + result_obj = await database.fetch_one(query) + + if result_obj: + my_avatar = set(json.loads(result_obj[0])) if result_obj[0] else START_AVATARS + my_stage = set(json.loads(result_obj[1])) if result_obj[1] else START_STAGES + coin = result_obj[2] if result_obj[2] is not None else START_COIN + items = json.loads(result_obj[3]) if result_obj[3] else [] + else: + my_avatar = START_AVATARS + my_stage = START_STAGES + coin = START_COIN + items = [] + + coin_elem = ET.Element("my_coin") + coin_elem.text = str(coin) + root.append(coin_elem) + + for item in items: + item_elem = ET.Element("add_item") + item_id_elem = ET.Element("id") + item_id_elem.text = str(item) + item_elem.append(item_id_elem) + item_num_elem = ET.Element("num") + item_num_elem.text = "9" + item_elem.append(item_num_elem) + root.append(item_elem) + + if items: + update_query = ( + update(daily_reward) + .where(daily_reward.c.device_id == device_id) + .values(item="[]") + ) + await database.execute(update_query) + + for avatar_id in my_avatar: + avatar_elem = ET.Element("my_avatar") + avatar_elem.text = str(avatar_id) + root.append(avatar_elem) + + for stage_id in my_stage: + stage_elem = ET.Element("my_stage") + stage_id_elem = ET.Element("stage_id") + stage_id_elem.text = str(stage_id) + stage_elem.append(stage_id_elem) + + ac_mode_elem = ET.Element("ac_mode") + ac_mode_elem.text = "1" + stage_elem.append(ac_mode_elem) + root.append(stage_elem) + + if username: + tid = ET.Element("taito_id") + tid.text = username + root.append(tid) + + sid = get_stage_zero() + root.append(sid) + + kid = ET.Element("friend_num") + kid.text = "9" + root.append(kid) + + xml_response = ET.tostring(root, encoding='unicode') + return Response(xml_response, media_type="application/xml") + +async def ttag(request: Request): + decrypted_fields, original_field = await decrypt_fields(request) + if not decrypted_fields: + return HTMLResponse("""

Invalid request data

""", status_code=400) + + username = await get_user_data(decrypted_fields, "username") + if username: + gcoin_mp = await get_user_data(decrypted_fields, "coin_mp") + savefile_id = await get_user_data(decrypted_fields, "save_id") + with open("files/profile.html", "r") as file: + html_content = file.read().format( + pid=original_field, + user=username, + gcoin_mp_0='selected' if gcoin_mp == 0 else '', + gcoin_mp_1='selected' if gcoin_mp == 1 else '', + gcoin_mp_2='selected' if gcoin_mp == 2 else '', + gcoin_mp_3='selected' if gcoin_mp == 3 else '', + gcoin_mp_4='selected' if gcoin_mp == 4 else '', + gcoin_mp_5='selected' if gcoin_mp == 5 else '', + savefile_id=savefile_id + ) + else: + with open("files/register.html", "r") as file: + html_content = file.read().format(pid=original_field) + + return HTMLResponse(html_content) + +async def bonus(request: Request): + decrypted_fields, _ = await decrypt_fields(request) + if not decrypted_fields: + return Response("""10Invalid request data.""", media_type="application/xml") + + device_id = decrypted_fields[b'vid'][0].decode() + file_path = os.path.join(ROOT_FOLDER, "files/start.xml") + + try: + tree = ET.parse(file_path) + root = tree.getroot() + except Exception as e: + return Response(f"""500Error parsing XML: {str(e)}""", media_type="application/xml") + + daily_reward_elem = root.find(".//login_bonus") + last_count_elem = daily_reward_elem.find("last_count") + if last_count_elem is None or not last_count_elem.text.isdigit(): + return Response("""500Invalid or missing last_count in XML.""", media_type="application/xml") + last_count = int(last_count_elem.text) + + query = select(daily_reward.c.day, daily_reward.c.timestamp, daily_reward.c.my_avatar, daily_reward.c.my_stage).where(daily_reward.c.device_id == device_id) + row = await database.fetch_one(query) + + time = datetime.now() + + if row: + current_day = row["day"] + last_timestamp = row["timestamp"] + my_avatar = set(json.loads(row["my_avatar"])) if row["my_avatar"] else set() + my_stage = set(json.loads(row["my_stage"])) if row["my_stage"] else set() + + if (time.date() - last_timestamp.date()).days >= 1: + current_day += 1 + if current_day > last_count: + current_day = 1 + reward_elem = daily_reward_elem.find(f".//reward[count='{current_day}']") + if reward_elem is not None: + cnt_type = int(reward_elem.find("cnt_type").text) + cnt_id = int(reward_elem.find("cnt_id").text) + + if cnt_type == 1: + stages = set(json.loads(my_stage)) if my_stage else set() + if cnt_id not in stages: + stages.add(cnt_id) + my_stage = json.dumps(list(stages)) + update_query = ( + update(daily_reward) + .where(daily_reward.c.device_id == device_id) + .values(timestamp=time, day=current_day, my_stage=my_stage) + ) + await database.execute(update_query) + + elif cnt_type == 2: + avatars = set(json.loads(my_avatar)) if my_avatar else set() + if cnt_id not in avatars: + avatars.add(cnt_id) + my_avatar = json.dumps(list(avatars)) + update_query = ( + update(daily_reward) + .where(daily_reward.c.device_id == device_id) + .values(timestamp=time, day=current_day, my_avatar=my_avatar) + ) + await database.execute(update_query) + + else: + update_query = ( + update(daily_reward) + .where(daily_reward.c.device_id == device_id) + .values(timestamp=time, day=current_day) + ) + await database.execute(update_query) + else: + update_query = ( + update(daily_reward) + .where(daily_reward.c.device_id == device_id) + .values(timestamp=time, day=current_day) + ) + await database.execute(update_query) + + xml_response = "0" + else: + xml_response = "1" + else: + insert_query = insert(daily_reward).values( + device_id=device_id, + day=1, + timestamp=time, + my_avatar=json.dumps(START_AVATARS), + my_stage=json.dumps(START_STAGES), + coin=START_COIN, + item="[]", + lvl=1, + title=1, + avatar=1 + ) + await database.execute(insert_query) + xml_response = "0" + + return Response(xml_response, media_type="application/xml") + +async def result_request(request: Request): + decrypted_fields, _ = await decrypt_fields(request) + if not decrypted_fields: + return Response("""10Invalid request data.""", media_type="application/xml") + + should_serve = True + if AUTHORIZATION_NEEDED: + should_serve = await check_whitelist(decrypted_fields) and not await check_blacklist(decrypted_fields) + + if not should_serve: + return Response("""403Access denied.""", media_type="application/xml") + + device_id = decrypted_fields[b'vid'][0].decode() + file_path = os.path.join(ROOT_FOLDER, "files/result.xml") + try: + tree = ET.parse(file_path) + root = tree.getroot() + except Exception as e: + return Response(f"""500Error parsing XML: {str(e)}""", media_type="application/xml") + + vid = decrypted_fields[b'vid'][0].decode() + stts = decrypted_fields[b'stts'][0].decode() + track_id = decrypted_fields[b'id'][0].decode() + mode = decrypted_fields[b'mode'][0].decode() + avatar = decrypted_fields[b'avatar'][0].decode() + score = int(decrypted_fields[b'score'][0].decode()) + high_score = decrypted_fields[b'high_score'][0].decode() + play_rslt = decrypted_fields[b'play_rslt'][0].decode() + item = decrypted_fields[b'item'][0].decode() + device_os = decrypted_fields[b'os'][0].decode() + os_ver = decrypted_fields[b'os_ver'][0].decode() + tid = decrypted_fields[b'tid'][0].decode() + ver = decrypted_fields[b'ver'][0].decode() + mike = decrypted_fields[b'mike'][0].decode() + + if int(track_id) not in range(616, 1024) or int(mode) not in range(0, 4): + query = select(daily_reward.c.coin).where(daily_reward.c.device_id == device_id) + row = await database.fetch_one(query) + query = select(user.c.coin_mp).where(user.c.device_id == device_id) + coin_mp_row = await database.fetch_one(query) + current_coin = row["coin"] if row and row["coin"] else START_COIN + updated_coin = current_coin + COIN_REWARD * coin_mp_row["coin_mp"] + + update_query = ( + update(daily_reward) + .where(daily_reward.c.device_id == device_id) + .values(coin=updated_coin) + ) + await database.execute(update_query) + + query = select(user.c.id).where(user.c.device_id == vid) + user_row = await database.fetch_one(query) + sid = user_row["id"] if user_row else "" + + do_insert = False + do_update_sid = False + do_update_vid = False + last_row_id = 0 + + if sid: + query = select(result.c.rid, result.c.score).where( + (result.c.id == track_id) & + (result.c.mode == mode) & + (result.c.sid == sid) + ).order_by(result.c.score.desc()) + records = await database.fetch_all(query) + if records: + last_row_id = records[0]["rid"] + if score > int(records[0]["score"]): + do_update_sid = True + else: + do_insert = True + else: + query = select(result.c.rid, result.c.score).where( + (result.c.id == track_id) & + (result.c.mode == mode) & + (result.c.sid == "") & + (result.c.vid == vid) + ).order_by(result.c.score.desc()) + records = await database.fetch_all(query) + if records: + last_row_id = records[0]["rid"] + if score > records[0]["score"]: + do_update_vid = True + else: + do_insert = True + + if do_insert: + insert_query = insert(result).values( + vid=vid, stts=stts, id=track_id, mode=mode, avatar=avatar, + score=score, high_score=high_score, play_rslt=play_rslt, item=item, + os=device_os, os_ver=os_ver, tid=tid, sid=sid, ver=ver, mike=mike + ) + result_obj = await database.execute(insert_query) + last_row_id = result_obj + elif do_update_sid: + update_query = ( + update(result) + .where((result.c.sid == sid) & (result.c.id == track_id) & (result.c.mode == mode)) + .values( + stts=stts, avatar=avatar, score=score, high_score=high_score, + play_rslt=play_rslt, item=item, os=device_os, os_ver=os_ver, + tid=tid, ver=ver, mike=mike, vid=vid + ) + ) + await database.execute(update_query) + elif do_update_vid: + update_query = ( + update(result) + .where((result.c.vid == vid) & (result.c.id == track_id) & (result.c.mode == mode)) + .values( + stts=stts, avatar=avatar, score=score, high_score=high_score, + play_rslt=play_rslt, item=item, os=device_os, os_ver=os_ver, + sid=sid, ver=ver, mike=mike + ) + ) + await database.execute(update_query) + + query = select(daily_reward.c.my_stage).where(daily_reward.c.device_id == device_id) + row = await database.fetch_one(query) + my_stage = set(json.loads(row["my_stage"])) if row and row["my_stage"] else set(START_STAGES) + + current_exp = int(stts.split(",")[0]) + for song in EXP_UNLOCKED_SONGS: + if song["lvl"] <= current_exp: + my_stage.add(song["id"]) + + my_stage = sorted(my_stage) + update_query = ( + update(daily_reward) + .where(daily_reward.c.device_id == device_id) + .values(lvl=current_exp, avatar=int(avatar), my_stage=json.dumps(my_stage)) + ) + await database.execute(update_query) + + query = select(result.c.rid, result.c.score).where( + (result.c.id == track_id) & (result.c.mode == mode) + ).order_by(result.c.score.desc()) + records = await database.fetch_all(query) + + rank = None + for idx, record in enumerate(records, start=1): + if record["rid"] == last_row_id: + rank = idx + break + + after_element = root.find('.//after') + after_element.text = str(rank) + xml_response = ET.tostring(tree.getroot(), encoding='unicode') + return Response(xml_response, media_type="application/xml") + + +routes = [ + Route('/info.php', info, methods=['GET']), + Route('/history.php', history, methods=['GET']), + Route('/delete_account.php', delete_account, methods=['GET']), + Route('/confirm_tier.php', tier, methods=['GET']), + Route('/gcm/php/register.php', reg, methods=['GET']), + Route('/name_reset/', name_reset, methods=['POST']), + Route('/password_reset/', password_reset, methods=['POST']), + Route('/coin_mp/', coin_mp, methods=['POST']), + Route('/save_migration/', save_migration, methods=['POST']), + Route('/register/', register, methods=['POST']), + Route('/logout/', logout, methods=['POST']), + Route('/login/', login, methods=['POST']), + Route('/load.php', load, methods=['GET']), + Route('/save.php', save, methods=['POST']), + Route('/start.php', start, methods=['GET']), + Route('/sync.php', sync, methods=['GET', 'POST']), + Route('/ttag.php', ttag, methods=['GET']), + Route('/login_bonus.php', bonus, methods=['GET']), + Route('/result.php', result_request, methods=['GET']), +] \ No newline at end of file diff --git a/config.env b/config.env new file mode 100644 index 0000000..30846c7 --- /dev/null +++ b/config.env @@ -0,0 +1,18 @@ +HOST = 192.168.0.106 +PORT = 9068 +ACTUAL_HOST = 192.168.0.106 +ACTUAL_PORT = 9068 + +MODEL = 202504125800 +TUNEFILE = 202504125800 +SKIN = 202404191149 + +STAGE_PRICE = 1 +AVATAR_PRICE = 1 +ITEM_PRICE = 2 +COIN_REWARD = 1 +START_COIN = 10 + +AUTHORIZATION_NEEDED = False + +DEBUG = True \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..56f47f5 --- /dev/null +++ b/config.py @@ -0,0 +1,63 @@ +from starlette.config import Config +import os +''' +Do not change the name of this file. +不要改动这个文件的名称。 +''' + +config = Config("config.env") + +''' +IP and port of the server. +服务器的IP和端口。 +''' + +HOST = config("HOST", default="192.168.0.106") +PORT = int(config("PORT", default=9070)) + +ACTUAL_HOST = config("ACTUAL_HOST", default="192.168.0.106") +ACTUAL_PORT = int(config("ACTUAL_PORT", default=9070)) + +''' +Datecode of the 3 pak files. +三个pak文件的时间戳。 +''' + +MODEL = config("MODEL", cast=str, default="202504125800") +TUNEFILE = config("TUNEFILE", cast=str, default="202504125800") +SKIN = config("SKIN", cast=str, default="202404191149") + +''' +Groove Coin-related settings. +GCoin相关设定。 +''' + +STAGE_PRICE = config("STAGE_PRICE", cast=int, default=1) +AVATAR_PRICE = config("AVATAR_PRICE", cast=int, default=1) +ITEM_PRICE = config("ITEM_PRICE", cast=int, default=2) +COIN_REWARD = config("COIN_REWARD", cast=int, default=1) +START_COIN = config("START_COIN", cast=int, default=10) + +''' +Only the whitelisted playerID can use the service. Blacklist has priority over whitelist. +只有白名单的玩家ID才能使用服务。黑名单优先于白名单。 +''' + +AUTHORIZATION_NEEDED = config("AUTHORIZATION_NEEDED", cast=bool, default=False) + +''' +SSL证书路径 - 留空则使用HTTP +SSL certificate path. If left blank, use HTTP. +''' + +SSL_CERT = config("SSL_CERT", default=None) +SSL_KEY = config("SSL_KEY", default=None) + +''' +Flask default debug +Flask内置Debug +''' + +DEBUG = config("DEBUG", cast=bool, default=False) + +ROOT_FOLDER = os.path.dirname(os.path.abspath(__file__)) diff --git a/7001.py b/old_server/7001.py similarity index 100% rename from 7001.py rename to old_server/7001.py diff --git a/config_old.py b/old_server/config_old.py similarity index 100% rename from config_old.py rename to old_server/config_old.py diff --git a/requirements.txt b/requirements.txt index b0f9e45..9fb40bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -flask +starlette bcrypt pycryptodome requests \ No newline at end of file