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 = ""
+ encrypted_mass = encryptAES(("vid=" + device_id + "&song_id=-1&mode=1&dummy=").encode("utf-8"))
+ href = f"/ranking_detail.php?{encrypted_mass}"
+ html += f'''
+
+ Total Score
+
+ '''
+ for index, song in enumerate(SONG_LIST):
+ encrypted_mass = encryptAES(("vid=" + device_id + "&song_id=" + str(index) + "&mode=3&dummy=").encode("utf-8"))
+ song_name = song.get("name_en", "Unknown")
+ href = f"/ranking_detail.php?{encrypted_mass}"
+ html += f'''
+
+ {song_name}
+
+ '''
+ 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"}
+
+
+
{username}
+
+
+
{user_score}
+
+ """
+
+ html += """
+
+ """
+ # Loop leaderboard
+ for rank, (username, data) in enumerate(sorted_players, start=1):
+ html += f"""
+
+
#{rank}
+
+
+
{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"}
+
+
+
{username}
+
+
+
{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}
+
+
+
{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"""
+
+
+
+
{user_name}
+
+
+
Level {user_data['lvl']}
+
+ """
+ html += player_element
+
+ page_name = ["Special", "Normal", "Master", "God"]
+ 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"""
+
+ """
+ else:
+ encrypted_mass = encryptAES(f"vid={device_id}&title_id={num}&page_id={page_id}&dummy=".encode("utf-8"))
+ titles_html += f"""
+
+
+
+ """
+ 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:
+
+ New Title:
+
+
+ """
+ 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 += """
+
+ Prev Page
+
+ """.format(page - 1)
+
+ if spawn_next_page:
+ buttons_html += """
+
+ Next Page
+
+ """.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 += """
+
+ Prev Page
+
+ """.format(page - 1)
+
+ if spawn_next_page:
+ buttons_html += """
+
+ Next Page
+
+ """.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"""
+
+
+
+ Would you like to purchase this song?
+
+
{song.get("name_en")} - {song.get("author_en")}
+
Difficulty Levels: {difficulty_levels}
+
+
+
+
{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.
+
+ Go Back
+
+
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.
+
+
+
+ Buy
+
+
+
300
+
+
+
+
+ Go Back
+
+ """
+
+ elif cnt_type == "2":
+ avatar = next((item for item in AVATAR_LIST if item.get("id") == cnt_id), None)
+ if avatar:
+ html = f"""
+
+
+
+ Would you like to purchase this avatar?
+
+
{avatar.get("name")}
+
Effect: {avatar.get("effect")}
+
+
+
+
{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"""
+
+
+
+ Would you like to purchase this item?
+
+
{item.get("name")}
+
Effect: {item.get("effect")}
+
+
+
+
{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"""
+
+
+ """
+
+ 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