Merge branch 'develop' into idac

This commit is contained in:
Dniel97 2024-05-03 08:23:31 +02:00
commit 8b3bb08c91
Signed by untrusted user: Dniel97
GPG Key ID: 6180B3C768FB2E08
17 changed files with 1016 additions and 81 deletions

View File

@ -44,11 +44,12 @@ class ShopOwner():
self.permissions = perms self.permissions = perms
class UserSession(): class UserSession():
def __init__(self, usr_id: int = 0, ip: str = "", perms: int = 0, ongeki_ver: int = 7): def __init__(self, usr_id: int = 0, ip: str = "", perms: int = 0, ongeki_ver: int = 7, chunithm_ver: int = -1):
self.user_id = usr_id self.user_id = usr_id
self.current_ip = ip self.current_ip = ip
self.permissions = perms self.permissions = perms
self.ongeki_version = ongeki_ver self.ongeki_version = ongeki_ver
self.chunithm_version = chunithm_ver
class FrontendServlet(): class FrontendServlet():
def __init__(self, cfg: CoreConfig, config_dir: str) -> None: def __init__(self, cfg: CoreConfig, config_dir: str) -> None:
@ -213,7 +214,8 @@ class FE_Base():
sesh.user_id = tk['user_id'] sesh.user_id = tk['user_id']
sesh.current_ip = tk['current_ip'] sesh.current_ip = tk['current_ip']
sesh.permissions = tk['permissions'] sesh.permissions = tk['permissions']
sesh.chunithm_version = tk['chunithm_version']
if sesh.user_id <= 0: if sesh.user_id <= 0:
self.logger.error("User session failed to validate due to an invalid ID!") self.logger.error("User session failed to validate due to an invalid ID!")
return UserSession() return UserSession()
@ -252,12 +254,12 @@ class FE_Base():
if usr_sesh.permissions <= 0 or usr_sesh.permissions > 255: if usr_sesh.permissions <= 0 or usr_sesh.permissions > 255:
self.logger.error(f"User session failed to validate due to an invalid permission value! {usr_sesh.permissions}") self.logger.error(f"User session failed to validate due to an invalid permission value! {usr_sesh.permissions}")
return None return None
return usr_sesh return usr_sesh
def encode_session(self, sesh: UserSession, exp_seconds: int = 86400) -> str: def encode_session(self, sesh: UserSession, exp_seconds: int = 86400) -> str:
try: try:
return jwt.encode({ "user_id": sesh.user_id, "current_ip": sesh.current_ip, "permissions": sesh.permissions, "ongeki_version": sesh.ongeki_version, "exp": int(datetime.now(tz=timezone.utc).timestamp()) + exp_seconds }, b64decode(self.core_config.frontend.secret), algorithm="HS256") return jwt.encode({ "user_id": sesh.user_id, "current_ip": sesh.current_ip, "permissions": sesh.permissions, "ongeki_version": sesh.ongeki_version, "chunithm_version": sesh.chunithm_version, "exp": int(datetime.now(tz=timezone.utc).timestamp()) + exp_seconds }, b64decode(self.core_config.frontend.secret), algorithm="HS256")
except jwt.InvalidKeyError: except jwt.InvalidKeyError:
self.logger.error("Failed to encode User session because the secret is invalid!") self.logger.error("Failed to encode User session because the secret is invalid!")
return "" return ""

View File

@ -12,3 +12,6 @@ uploads:
photos_dir: "" photos_dir: ""
movies: False movies: False
movies_dir: "" movies_dir: ""
crypto:
encrypted_only: False

View File

@ -811,6 +811,12 @@ class ChuniBase:
upsert = data["upsertUserAll"] upsert = data["upsertUserAll"]
user_id = data["userId"] user_id = data["userId"]
if int(user_id) & 0x1000000000001 == 0x1000000000001:
place_id = int(user_id) & 0xFFFC00000000
self.logger.info("Guest play from place ID %d, ignoring.", place_id)
return {"returnCode": "1"}
if "userData" in upsert: if "userData" in upsert:
try: try:
upsert["userData"][0]["userName"] = self.read_wtf8( upsert["userData"][0]["userName"] = self.read_wtf8(

View File

@ -1,5 +1,5 @@
from typing import List from typing import List
from starlette.routing import Route from starlette.routing import Route, Mount
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import Response, RedirectResponse from starlette.responses import Response, RedirectResponse
from os import path from os import path
@ -29,7 +29,13 @@ class ChuniFrontend(FE_Base):
def get_routes(self) -> List[Route]: def get_routes(self) -> List[Route]:
return [ return [
Route("/", self.render_GET, methods=['GET']), Route("/", self.render_GET, methods=['GET']),
Route("/rating", self.render_GET_rating, methods=['GET']),
Mount("/playlog", routes=[
Route("/", self.render_GET_playlog, methods=['GET']),
Route("/{index}", self.render_GET_playlog, methods=['GET']),
]),
Route("/update.name", self.update_name, methods=['POST']), Route("/update.name", self.update_name, methods=['POST']),
Route("/version.change", self.version_change, methods=['POST']),
] ]
async def render_GET(self, request: Request) -> bytes: async def render_GET(self, request: Request) -> bytes:
@ -39,27 +45,165 @@ class ChuniFrontend(FE_Base):
usr_sesh = self.validate_session(request) usr_sesh = self.validate_session(request)
if not usr_sesh: if not usr_sesh:
usr_sesh = UserSession() usr_sesh = UserSession()
return Response(template.render( if usr_sesh.user_id > 0:
title=f"{self.core_config.server.name} | {self.nav_name}", versions = await self.data.profile.get_all_profile_versions(usr_sesh.user_id)
game_list=self.environment.globals["game_list"], profile = []
sesh=vars(usr_sesh) if versions:
), media_type="text/html; charset=utf-8") # chunithm_version is -1 means it is not initialized yet, select a default version from existing.
if usr_sesh.chunithm_version < 0:
usr_sesh.chunithm_version = versions[0]
profile = await self.data.profile.get_profile_data(usr_sesh.user_id, usr_sesh.chunithm_version)
resp = Response(template.render(
title=f"{self.core_config.server.name} | {self.nav_name}",
game_list=self.environment.globals["game_list"],
sesh=vars(usr_sesh),
user_id=usr_sesh.user_id,
profile=profile,
version_list=ChuniConstants.VERSION_NAMES,
versions=versions,
cur_version=usr_sesh.chunithm_version
), media_type="text/html; charset=utf-8")
if usr_sesh.chunithm_version >= 0:
encoded_sesh = self.encode_session(usr_sesh)
resp.set_cookie("DIANA_SESH", encoded_sesh)
return resp
else:
return RedirectResponse("/gate/", 303)
async def render_GET_rating(self, request: Request) -> bytes:
template = self.environment.get_template(
"titles/chuni/templates/chuni_rating.jinja"
)
usr_sesh = self.validate_session(request)
if not usr_sesh:
usr_sesh = UserSession()
if usr_sesh.user_id > 0:
if usr_sesh.chunithm_version < 0:
return RedirectResponse("/game/chuni/", 303)
profile = await self.data.profile.get_profile_data(usr_sesh.user_id, usr_sesh.chunithm_version)
rating = await self.data.profile.get_profile_rating(usr_sesh.user_id, usr_sesh.chunithm_version)
hot_list=[]
base_list=[]
if profile and rating:
song_records = []
for song in rating:
music_chart = await self.data.static.get_music_chart(usr_sesh.chunithm_version, song.musicId, song.difficultId)
if music_chart:
if (song.score < 800000):
song_rating = 0
elif (song.score >= 800000 and song.score < 900000):
song_rating = music_chart.level / 2 - 5
elif (song.score >= 900000 and song.score < 925000):
song_rating = music_chart.level - 5
elif (song.score >= 925000 and song.score < 975000):
song_rating = music_chart.level - 3
elif (song.score >= 975000 and song.score < 1000000):
song_rating = (song.score - 975000) / 2500 * 0.1 + music_chart.level
elif (song.score >= 1000000 and song.score < 1005000):
song_rating = (song.score - 1000000) / 1000 * 0.1 + 1 + music_chart.level
elif (song.score >= 1005000 and song.score < 1007500):
song_rating = (song.score - 1005000) / 500 * 0.1 + 1.5 + music_chart.level
elif (song.score >= 1007500 and song.score < 1009000):
song_rating = (song.score - 1007500) / 100 * 0.01 + 2 + music_chart.level
elif (song.score >= 1009000):
song_rating = 2.15 + music_chart.level
song_rating = int(song_rating * 10 ** 2) / 10 ** 2
song_records.append({
"difficultId": song.difficultId,
"musicId": song.musicId,
"title": music_chart.title,
"level": music_chart.level,
"score": song.score,
"type": song.type,
"song_rating": song_rating,
})
hot_list = [obj for obj in song_records if obj["type"] == "userRatingBaseHotList"]
base_list = [obj for obj in song_records if obj["type"] == "userRatingBaseList"]
return Response(template.render(
title=f"{self.core_config.server.name} | {self.nav_name}",
game_list=self.environment.globals["game_list"],
sesh=vars(usr_sesh),
profile=profile,
hot_list=hot_list,
base_list=base_list,
), media_type="text/html; charset=utf-8")
else:
return RedirectResponse("/gate/", 303)
async def render_GET_playlog(self, request: Request) -> bytes:
template = self.environment.get_template(
"titles/chuni/templates/chuni_playlog.jinja"
)
usr_sesh = self.validate_session(request)
if not usr_sesh:
usr_sesh = UserSession()
if usr_sesh.user_id > 0:
if usr_sesh.chunithm_version < 0:
return RedirectResponse("/game/chuni/", 303)
path_index = request.path_params.get('index')
if not path_index or int(path_index) < 1:
index = 0
else:
index = int(path_index) - 1 # 0 and 1 are 1st page
user_id = usr_sesh.user_id
playlog_count = await self.data.score.get_user_playlogs_count(user_id)
if playlog_count < index * 20 :
return Response(template.render(
title=f"{self.core_config.server.name} | {self.nav_name}",
game_list=self.environment.globals["game_list"],
sesh=vars(usr_sesh),
playlog_count=0
), media_type="text/html; charset=utf-8")
playlog = await self.data.score.get_playlogs_limited(user_id, index, 20)
playlog_with_title = []
for record in playlog:
music_chart = await self.data.static.get_music_chart(usr_sesh.chunithm_version, record.musicId, record.level)
if music_chart:
difficultyNum=music_chart.level
artist=music_chart.artist
title=music_chart.title
else:
difficultyNum=0
artist="unknown"
title="musicid: " + str(record.musicId)
playlog_with_title.append({
"raw": record,
"title": title,
"difficultyNum": difficultyNum,
"artist": artist,
})
return Response(template.render(
title=f"{self.core_config.server.name} | {self.nav_name}",
game_list=self.environment.globals["game_list"],
sesh=vars(usr_sesh),
user_id=usr_sesh.user_id,
playlog=playlog_with_title,
playlog_count=playlog_count
), media_type="text/html; charset=utf-8")
else:
return RedirectResponse("/gate/", 303)
async def update_name(self, request: Request) -> bytes: async def update_name(self, request: Request) -> bytes:
usr_sesh = self.validate_session(request) usr_sesh = self.validate_session(request)
if not usr_sesh: if not usr_sesh:
return RedirectResponse("/gate/", 303) return RedirectResponse("/gate/", 303)
new_name: str = request.query_params.get('new_name', '') form_data = await request.form()
new_name: str = form_data.get("new_name")
new_name_full = "" new_name_full = ""
if not new_name: if not new_name:
return RedirectResponse("/gate/?e=4", 303) return RedirectResponse("/gate/?e=4", 303)
if len(new_name) > 8: if len(new_name) > 8:
return RedirectResponse("/gate/?e=8", 303) return RedirectResponse("/gate/?e=8", 303)
for x in new_name: # FIXME: This will let some invalid characters through atm for x in new_name: # FIXME: This will let some invalid characters through atm
o = ord(x) o = ord(x)
try: try:
@ -72,12 +216,31 @@ class ChuniFrontend(FE_Base):
return RedirectResponse("/gate/?e=4", 303) return RedirectResponse("/gate/?e=4", 303)
else: else:
new_name_full += x new_name_full += x
except Exception as e: except Exception as e:
self.logger.error(f"Something went wrong parsing character {o:04X} - {e}") self.logger.error(f"Something went wrong parsing character {o:04X} - {e}")
return RedirectResponse("/gate/?e=4", 303) return RedirectResponse("/gate/?e=4", 303)
if not await self.data.profile.update_name(usr_sesh, new_name_full): if not await self.data.profile.update_name(usr_sesh.user_id, new_name_full):
return RedirectResponse("/gate/?e=999", 303) return RedirectResponse("/gate/?e=999", 303)
return RedirectResponse("/gate/?s=1", 303) return RedirectResponse("/game/chuni/?s=1", 303)
async def version_change(self, request: Request):
usr_sesh = self.validate_session(request)
if not usr_sesh:
usr_sesh = UserSession()
if usr_sesh.user_id > 0:
form_data = await request.form()
chunithm_version = form_data.get("version")
self.logger.info(f"version change to: {chunithm_version}")
if(chunithm_version.isdigit()):
usr_sesh.chunithm_version=int(chunithm_version)
encoded_sesh = self.encode_session(usr_sesh)
self.logger.info(f"Created session with JWT {encoded_sesh}")
resp = RedirectResponse("/game/chuni/", 303)
resp.set_cookie("DIANA_SESH", encoded_sesh)
return resp
else:
return RedirectResponse("/gate/", 303)

View File

@ -757,3 +757,26 @@ class ChuniProfileData(BaseData):
return return
return result.lastrowid return result.lastrowid
async def get_profile_rating(self, aime_id: int, version: int) -> Optional[List[Row]]:
sql = select(rating).where(and_(
rating.c.user == aime_id,
rating.c.version <= version,
))
result = await self.execute(sql)
if result is None:
self.logger.warning(f"Rating of user {aime_id}, version {version} was None")
return None
return result.fetchall()
async def get_all_profile_versions(self, aime_id: int) -> Optional[List[Row]]:
sql = select([profile.c.version]).where(profile.c.user == aime_id)
result = await self.execute(sql)
if result is None:
self.logger.warning(f"user {aime_id}, has no profile")
return None
else:
versions_raw = result.fetchall()
versions = [row[0] for row in versions_raw]
return sorted(versions, reverse=True)

View File

@ -190,6 +190,23 @@ class ChuniScoreData(BaseData):
return None return None
return result.fetchall() return result.fetchall()
async def get_playlogs_limited(self, aime_id: int, index: int, count: int) -> Optional[Row]:
sql = select(playlog).where(playlog.c.user == aime_id).order_by(playlog.c.id.desc()).limit(count).offset(index * count)
result = await self.execute(sql)
if result is None:
self.logger.warning(f" aime_id {aime_id} has no playlog ")
return None
return result.fetchall()
async def get_user_playlogs_count(self, aime_id: int) -> Optional[Row]:
sql = select(func.count()).where(playlog.c.user == aime_id)
result = await self.execute(sql)
if result is None:
self.logger.warning(f" aime_id {aime_id} has no playlog ")
return None
return result.scalar()
async def put_playlog(self, aime_id: int, playlog_data: Dict, version: int) -> Optional[int]: async def put_playlog(self, aime_id: int, playlog_data: Dict, version: int) -> Optional[int]:
# Calculate the ROM version that should be inserted into the DB, based on the version of the ggame being inserted # Calculate the ROM version that should be inserted into the DB, based on the version of the ggame being inserted
# We only need from Version 10 (Plost) and back, as newer versions include romVersion in their upsert # We only need from Version 10 (Plost) and back, as newer versions include romVersion in their upsert

View File

@ -0,0 +1,24 @@
<div class="chuni-header">
<h1>Chunithm</h1>
<ul class="chuni-navi">
<li><a class="nav-link" href="/game/chuni">PROFILE</a></li>
<li><a class="nav-link" href="/game/chuni/rating">RATING</a></li>
<li><a class="nav-link" href="/game/chuni/playlog">RECORD</a></li>
<li><a class="nav-link" href="/game/chuni/musics">MUSICS</a></li>
<li><a class="nav-link" href="/game/chuni/userbox">USER BOX</a></li>
</ul>
</div>
<script>
$(document).ready(function () {
var currentPath = window.location.pathname;
if (currentPath === '/game/chuni/') {
$('.nav-link[href="/game/chuni"]').addClass('active');
} else if (currentPath.startsWith('/game/chuni/playlog')) {
$('.nav-link[href="/game/chuni/playlog"]').addClass('active');
} else if (currentPath.startsWith('/game/chuni/rating')) {
$('.nav-link[href="/game/chuni/rating"]').addClass('active');
} else if (currentPath.startsWith('/game/chuni/musics')) {
$('.nav-link[href="/game/chuni/musics"]').addClass('active');
}
});
</script>

View File

@ -1,43 +1,150 @@
{% extends "core/templates/index.jinja" %} {% extends "core/templates/index.jinja" %}
{% block content %} {% block content %}
<h1>Chunithm</h1> <style>
{% if profile is defined and profile is not none and profile.id > 0 %} {% include 'titles/chuni/templates/css/chuni_style.css' %}
<script type="text/javascript"> </style>
function toggle_new_name_form() { <div class="container">
let frm = document.getElementById("new_name_form"); {% include 'titles/chuni/templates/chuni_header.jinja' %}
let btn = document.getElementById("btn_toggle_form"); {% if profile is defined and profile is not none and profile.id > 0 %}
<div class="row">
if (frm.style['display'] != "") { <div class="col-lg-8 m-auto mt-3">
frm.style['display'] = ""; <div class="card bg-card rounded">
frm.style['max-height'] = ""; <table class="table-large table-rowdistinct">
btn.innerText = "Cancel"; <caption align="top">OVERVIEW</caption>
} else { <tr>
frm.style['display'] = "none"; <th>{{ profile.userName }}</th>
frm.style['max-height'] = "0px"; <th>
btn.innerText = "Edit"; <button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#name_change">Edit</button>
} </th>
} </tr>
</script> <tr>
<h3>Profile for {{ profile.userName }}&nbsp;<button onclick="toggle_new_name_form()" class="btn btn-secondary" id="btn_toggle_form">Edit</button></h3> <td>version:</td>
{% if error is defined %} <td>
{% include "core/templates/widgets/err_banner.jinja" %} <select name="version" id="version" onChange="changeVersion(this)">
{% endif %} {% for ver in versions %}
{% if success is defined and success == 1 %} {% if ver == cur_version %}
<div style="background-color: #00AA00; padding: 20px; margin-bottom: 10px; width: 15%;"> <option value="{{ ver }}" selected>{{ version_list[ver] }}</option>
Update successful {% else %}
</div> <option value="{{ ver }}">{{ version_list[ver] }}</option>
{% endif %} {% endif %}
<form style="max-width: 33%; display: none; max-height: 0px;" action="/game/chuni/update.name" method="post" id="new_name_form"> {% endfor %}
<div class="mb-3"> </select>
<label for="new_name" class="form-label">New Trainer Name</label> {% if versions | length > 1 %}
<input type="text" class="form-control" id="new_name" name="new_name" aria-describedby="new_name_help" maxlength="14"> <p style="margin-block-end: 0;">You have {{ versions | length }} versions.</p>
<div id="new_name_help" class="form-text">Must be 14 characters or less</div> {% endif %}
</td>
</tr>
<tr>
<td>Level:</td>
<td>{{ profile.level }}</td>
</tr>
<tr>
<td>Rating:</td>
<td>
<span class="{% if profile.playerRating >= 1600 %}rainbow{% elif profile.playerRating < 1600 and profile.playerRating >= 1525 %}platinum{% elif profile.playerRating < 1525 and profile.playerRating >=1500 %}platinum{% endif %}">
{{ profile.playerRating|float/100 }}
</span>
<span>
(highest: {{ profile.highestRating|float/100 }})
</span>
</td>
</tr>
<tr>
<td>Over Power:</td>
<td>{{ profile.overPowerPoint|float/100 }}({{ profile.overPowerRate|float/100 }})</td>
</tr>
<tr>
<td>Current Point:</td>
<td>{{ profile.point }}</td>
</tr>
<tr>
<td>Total Point:</td>
<td>{{ profile.totalPoint }}</td>
</tr>
<tr>
<td>Play Counts:</td>
<td>{{ profile.playCount }}</td>
</tr>
<tr>
<td>Last Play Date:</td>
<td>{{ profile.lastPlayDate }}</td>
</tr>
</table>
</div>
</div>
<div class="col-lg-8 m-auto mt-3">
<div class="card bg-card rounded">
<table class="table-large table-rowdistinct">
<caption align="top">SCORE</caption>
<tr>
<td>Total High Score:</td>
<td>{{ profile.totalHiScore }}</td>
</tr>
<tr>
<td>Total Basic High Score:</td>
<td>{{ profile.totalBasicHighScore }}</td>
</tr>
<tr>
<td>Total Advanced High Score:</td>
<td>{{ profile.totalAdvancedHighScore }}</td>
</tr>
<tr>
<td>Total Expert High Score:</td>
<td>{{ profile.totalExpertHighScore }}</td>
</tr>
<tr>
<td>Total Master High Score:</td>
<td>{{ profile.totalMasterHighScore }}</td>
</tr>
<tr>
<td>Total Ultima High Score :</td>
<td>{{ profile.totalUltimaHighScore }}</td>
</tr>
</table>
</div>
</div>
</div> </div>
<button type="submit" class="btn btn-primary">Submit</button> {% if error is defined %}
</form> {% include "core/templates/widgets/err_banner.jinja" %}
{% elif sesh is defined and sesh is not none and sesh.user_id > 0 %} {% endif %}
No profile information found for this account. {% elif sesh is defined and sesh is not none and sesh.user_id > 0 %}
{% else %} No profile information found for this account.
Login to view profile information. {% else %}
{% endif %} Login to view profile information.
{% endif %}
</div>
<div class="modal fade" id="name_change" tabindex="-1" aria-labelledby="name_change_label" data-bs-theme="dark"
aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Name change</h5>
</div>
<div class="modal-body">
<form id="new_name_form" action="/game/chuni/update.name" method="post" style="outline: 0;">
<label class="form-label" for="new_name">new name:</label>
<input class="form-control" aria-describedby="newNameHelp" form="new_name_form" id="new_name"
name="new_name" maxlength="14" type="text" required>
<div id="newNameHelp" class="form-text">name must be full-width character string.
</div>
</form>
</div>
<div class="modal-footer">
<input type=submit class="btn btn-primary" type="button" form="new_name_form">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<script>
function changeVersion(sel) {
$.post("/game/chuni/version.change", { version: sel.value })
.done(function (data) {
location.reload();
})
.fail(function () {
alert("Failed to update version.");
});
}
</script>
{% endblock content %} {% endblock content %}

View File

@ -0,0 +1,184 @@
{% extends "core/templates/index.jinja" %}
{% block content %}
<style>
{% include 'titles/chuni/templates/css/chuni_style.css' %}
</style>
<div class="container">
{% include 'titles/chuni/templates/chuni_header.jinja' %}
{% if playlog is defined and playlog is not none %}
<div class="row">
<h4 style="text-align: center;">Playlog counts: {{ playlog_count }}</h4>
{% set rankName = ['D', 'C', 'B', 'BB', 'BBB', 'A', 'AA', 'AAA', 'S', 'S+', 'SS', 'SS+', 'SSS', 'SSS+'] %}
{% set difficultyName = ['normal', 'hard', 'expert', 'master', 'ultimate'] %}
{% for record in playlog %}
<div class="col-lg-6 mt-3">
<div class="card bg-card rounded card-hover">
<div class="card-header row">
<div class="col-8 scrolling-text">
<h5 class="card-text"> {{ record.title }} </h5>
<br>
<h6 class="card-text"> {{ record.artist }} </h6>
</div>
<div class="col-4">
<h6 class="card-text">{{ record.raw.userPlayDate }}</h6>
<h6 class="card-text">TRACK {{ record.raw.track }}</h6>
</div>
</div>
<div class="card-body row">
<div class="col-3" style="text-align: center;">
<h4 class="card-text">{{ record.raw.score }}</h4>
<h2>{{ rankName[record.raw.rank] }}</h2>
<h6
class="{% if record.raw.level == 0 %}normal{% elif record.raw.level == 1 %}advanced{% elif record.raw.level == 2 %}expert{% elif record.raw.level == 3 %}master{% elif record.raw.level == 4 %}ultima{% endif %}">
{{ difficultyName[record.raw.level] }}&nbsp&nbsp{{ record.difficultyNum }}
</h6>
</div>
<div class="col-6" style="text-align: center;">
<table class="table-small table-rowdistinc">
<tr>
<td>JUSTICE CRITIAL</td>
<td>
{{ record.raw.judgeCritical + record.raw.judgeHeaven }}
</td>
</tr>
<tr>
<td>JUSTICE</td>
<td>
{{ record.raw.judgeJustice }}
</td>
</tr>
<tr>
<td>ATTACK</td>
<td>
{{ record.raw.judgeAttack }}
</td>
</tr>
<tr>
<td>MISS</td>
<td>
{{ record.raw.judgeGuilty }}
</td>
</tr>
</table>
</div>
<div class="col-3" style="text-align: center;">
{%if record.raw.isFullCombo == 1 %}
<h6>FULL COMBO</h6>
{% endif %}
{%if record.raw.isAllJustice == 1 %}
<h6>ALL JUSTICE</h6>
{% endif %}
{%if record.raw.isNewRecord == 1 %}
<h6>NEW RECORD</h6>
{% endif %}
{%if record.raw.fullChainKind > 0 %}
<h6>FULL CHAIN</h6>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% set playlog_pages = playlog_count // 20 + 1 %}
{% elif sesh is defined and sesh is not none and sesh.user_id > 0 %}
No Playlog information found for this account.
{% else %}
Login to view profile information.
{% endif %}
</div>
<footer class="navbar-fixed-bottom">
<nav aria-label="Playlog page navigation">
<ul class="pagination justify-content-center mt-3">
<li class="page-item"><a id="prev_page" class="page-link" href="#">Previous</a></li>
<li class="page-item"><a id="first_page" class="page-link" href="/game/chuni/playlog/">1</a></li>
<li class="page-item"><a id="prev_3_page" class="page-link" href="">...</a></li>
<li class="page-item"><a id="front_page" class="page-link" href="">2</a></li>
<li class="page-item"><a id="cur_page" class="page-link active" href="">3</a></li>
<li class="page-item"><a id="back_page" class="page-link" href="">4</a></li>
<li class="page-item"><a id="next_3_page" class="page-link" href="">...</a></li>
<li class="page-item"><a id="last_page" class="page-link" href="/game/chuni/playlog/{{ playlog_pages }}">{{
playlog_pages }}</a></li>
<li class="page-item"><a id="next_page" class="page-link" href="#">Next</a></li>
&nbsp
</ul>
</nav>
<div class="row">
<div class="col-5"></div>
<div class="col-2">
<div class="input-group rounded">
<input id="page_input" type="text" class="form-control" placeholder="go to page">
<span class="input-group-btn">
<button id="go_button" class="btn btn-light" type="button">
Go!
</button>
</span>
</div>
</div>
<div class="col-5"></div>
</div>
</footer>
<script>
$(document).ready(function () {
$('.scrolling-text p, .scrolling-text h1, .scrolling-text h2, .scrolling-text h3, .scrolling-text h4, .scrolling-text h5, .scrolling-text h6').each(function () {
var parentWidth = $(this).parent().width();
var elementWidth = $(this).outerWidth();
var elementWidthWithPadding = $(this).outerWidth(true);
if (elementWidthWithPadding > parentWidth) {
$(this).addClass('scrolling');
}
});
var currentUrl = window.location.pathname;
var currentPage = parseInt(currentUrl.split('/').pop());
var rootUrl = '/game/chuni/playlog/';
var playlogPages = {{ playlog_pages }};
if (Number.isNaN(currentPage)) {
currentPage = 1;
}
$('#cur_page').text(currentPage);
$('#prev_page').attr('href', rootUrl + (currentPage - 1))
$('#next_page').attr('href', rootUrl + (currentPage + 1))
$('#front_page').attr('href', rootUrl + (currentPage - 1))
$('#front_page').text(currentPage - 1);
$('#back_page').attr('href', rootUrl + (currentPage + 1))
$('#back_page').text(currentPage + 1);
$('#prev_3_page').attr('href', rootUrl + (currentPage - 3))
$('#next_3_page').attr('href', rootUrl + (currentPage + 3))
if ((currentPage - 1) < 3) {
$('#prev_3_page').hide();
if ((currentPage - 1) < 2) {
$('#front_page').hide();
if (currentPage === 1) {
$('#first_page').hide();
$('#prev_page').addClass('disabled');
}
}
}
if ((playlogPages - currentPage) < 3) {
$('#next_3_page').hide();
if ((playlogPages - currentPage) < 2) {
$('#back_page').hide();
if (currentPage === playlogPages) {
$('#last_page').hide();
$('#next_page').addClass('disabled');
}
}
}
$('#go_button').click(function () {
var pageNumber = parseInt($('#page_input').val());
if (!Number.isNaN(pageNumber) && pageNumber <= playlogPages && pageNumber >= 0) {
var url = '/game/chuni/playlog/' + pageNumber;
window.location.href = url;
} else {
$('#page_input').val('');
$('#page_input').attr('placeholder', 'invalid input!');
}
});
});
</script>
{% endblock content %}

View File

@ -0,0 +1,79 @@
{% extends "core/templates/index.jinja" %}
{% block content %}
<style>
{% include 'titles/chuni/templates/css/chuni_style.css' %}
</style>
<div class="container">
{% include 'titles/chuni/templates/chuni_header.jinja' %}
{% if profile is defined and profile is not none and profile.id > 0 %}
<h4 style="text-align: center;">Rating: {{ profile.playerRating|float/100 }}&nbsp&nbsp&nbsp&nbspPlayer Counts: {{
profile.playCount }}</h4>
<div class="row">
{% if hot_list %}
<div class="col-lg-6 mt-3">
<div class="card bg-card rounded">
<table class="table-large table-rowdistinct">
<caption align="top">Recent 10</caption>
<tr>
<th>Music</th>
<th>Difficulty</th>
<th>Score</th>
<th>Rating</th>
</tr>
{% for row in hot_list %}
<tr>
<td>{{ row.title }}</td>
<td
class="{% if row.difficultId == 0 %}basic{% elif row.difficultId == 1 %}{% elif row.difficultId == 2 %}expert{% elif row.difficultId == 3 %}master{% else %}{% endif %}">
{{ row.level }}
</td>
<td>{{ row.score }}</td>
<td class="{% if row.song_rating >= 16 %}rainbow{% endif %}">
{{ row.song_rating }}
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
{% else %}
<p>No r10 found</p>
{% endif %}
{% if base_list %}
<div class="col-lg-6 mt-3">
<div class="card bg-card rounded">
<table class="table-large table-rowdistinct">
<caption align="top">Best 30</caption>
<tr>
<th>Music</th>
<th>Difficulty</th>
<th>Score</th>
<th>Rating</th>
</tr>
{% for row in base_list %}
<tr>
<td>{{ row.title }}</td>
<td
class="{% if row.difficultId == 0 %}normal{% elif row.difficultId == 1 %}hard{% elif row.difficultId == 2 %}expert{% elif row.difficultId == 3 %}master{% else %}{% endif %}">
{{ row.level }}
</td>
<td>{{ row.score }}</td>
<td class="{% if row.song_rating >= 16 %}rainbow{% endif %}">
{{ row.song_rating }}
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
{% else %}
<p>No b30 found</p>
{% endif %}
</div>
{% elif sesh is defined and sesh is not none and sesh.user_id > 0 %}
No profile information found for this account.
{% else %}
Login to view profile information.
{% endif %}
</div>
{% endblock content %}

View File

@ -0,0 +1,195 @@
.chuni-header {
text-align: center;
}
ul.chuni-navi {
list-style-type: none;
padding: 0;
overflow: hidden;
background-color: #333;
text-align: center;
display: inline-block;
}
ul.chuni-navi li {
display: inline-block;
}
ul.chuni-navi li a {
display: block;
color: white;
text-align: center;
padding: 14px 16px;
text-decoration: none;
}
ul.chuni-navi li a:hover:not(.active) {
background-color: #111;
}
ul.chuni-navi li a.active {
background-color: #4CAF50;
}
ul.chuni-navi li.right {
float: right;
}
@media screen and (max-width: 600px) {
ul.chuni-navi li.right,
ul.chuni-navi li {
float: none;
display: block;
text-align: center;
}
}
table {
border-collapse: collapse;
border-spacing: 0;
border-collapse: separate;
overflow: hidden;
background-color: #555555;
}
th, td {
text-align: left;
border: none;
}
th {
color: white;
}
.table-rowdistinct tr:nth-child(even) {
background-color: #303030;
}
.table-rowdistinct tr:nth-child(odd) {
background-color: #555555;
}
caption {
text-align: center;
color: white;
font-size: 18px;
font-weight: bold;
}
.table-large {
margin: 16px;
}
.table-large th,
.table-large td {
padding: 8px;
}
.table-small {
width: 100%;
margin: 4px;
}
.table-small th,
.table-small td {
padding: 2px;
}
.bg-card {
background-color: #555555;
}
.card-hover {
transition: all 0.2s ease-in-out;
}
.card-hover:hover {
transform: scale(1.02);
}
.normal {
color: #28a745;
font-weight: bold;
}
.hard {
color: #ffc107;
font-weight: bold;
}
.expert {
color: #dc3545;
font-weight: bold;
}
.master {
color: #dd09e8;
font-weight: bold;
}
.ultimate {
color: #000000;
font-weight: bold;
}
.score {
color: #ffffff;
font-weight: bold;
}
.rainbow {
background: linear-gradient(to right, red, yellow, lime, aqua, blue, fuchsia) 0 / 5em;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: bold;
}
.platinum {
color: #FFFF00;
font-weight: bold;
}
.gold {
color: #FFFF00;
font-weight: bold;
}
.scrolling-text {
overflow: hidden;
}
.scrolling-text p {
white-space: nowrap;
display: inline-block;
}
.scrolling-text h6 {
white-space: nowrap;
display: inline-block;
}
.scrolling-text h5 {
white-space: nowrap;
display: inline-block;
}
.scrolling {
animation: scroll 10s linear infinite;
}
@keyframes scroll {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(-100%);
}
}

View File

@ -238,8 +238,10 @@ class Mai2Base:
user_id = data["userId"] user_id = data["userId"]
upsert = data["upsertUserAll"] upsert = data["upsertUserAll"]
if int(user_id) & 1000000000001 == 1000000000001: if int(user_id) & 0x1000000000001 == 0x1000000000001:
self.logger.info("Guest play, ignoring.") place_id = int(user_id) & 0xFFFC00000000
self.logger.info("Guest play from place ID %d, ignoring.", place_id)
return {"returnCode": 1, "apiName": "UpsertUserAllApi"} return {"returnCode": 1, "apiName": "UpsertUserAllApi"}
if "userData" in upsert and len(upsert["userData"]) > 0: if "userData" in upsert and len(upsert["userData"]) > 0:

View File

@ -1,3 +1,5 @@
from typing import Dict
from core.config import CoreConfig from core.config import CoreConfig
@ -70,8 +72,32 @@ class Mai2UploadsConfig:
) )
class Mai2CryptoConfig:
def __init__(self, parent_config: "Mai2Config") -> None:
self.__config = parent_config
@property
def keys(self) -> Dict[int, list[str]]:
"""
in the form of:
internal_version: [key, iv, salt]
key and iv are hex strings
salt is a normal UTF-8 string
"""
return CoreConfig.get_config_field(
self.__config, "mai2", "crypto", "keys", default={}
)
@property
def encrypted_only(self) -> bool:
return CoreConfig.get_config_field(
self.__config, "mai2", "crypto", "encrypted_only", default=False
)
class Mai2Config(dict): class Mai2Config(dict):
def __init__(self) -> None: def __init__(self) -> None:
self.server = Mai2ServerConfig(self) self.server = Mai2ServerConfig(self)
self.deliver = Mai2DeliverConfig(self) self.deliver = Mai2DeliverConfig(self)
self.uploads = Mai2UploadsConfig(self) self.uploads = Mai2UploadsConfig(self)
self.crypto = Mai2CryptoConfig(self)

View File

@ -121,8 +121,10 @@ class Mai2DX(Mai2Base):
user_id = data["userId"] user_id = data["userId"]
upsert = data["upsertUserAll"] upsert = data["upsertUserAll"]
if int(user_id) & 1000000000001 == 1000000000001: if int(user_id) & 0x1000000000001 == 0x1000000000001:
self.logger.info("Guest play, ignoring.") place_id = int(user_id) & 0xFFFC00000000
self.logger.info("Guest play from place ID %d, ignoring.", place_id)
return {"returnCode": 1, "apiName": "UpsertUserAllApi"} return {"returnCode": 1, "apiName": "UpsertUserAllApi"}
if "userData" in upsert and len(upsert["userData"]) > 0: if "userData" in upsert and len(upsert["userData"]) > 0:

View File

@ -6,9 +6,13 @@ import inflection
import yaml import yaml
import logging, coloredlogs import logging, coloredlogs
import zlib import zlib
import string
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
from os import path, mkdir from os import path, mkdir
from typing import Tuple, List, Dict from typing import Tuple, List, Dict
from Crypto.Hash import MD5
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from core.config import CoreConfig from core.config import CoreConfig
from core.utils import Utils from core.utils import Utils
@ -32,6 +36,7 @@ class Mai2Servlet(BaseServlet):
def __init__(self, core_cfg: CoreConfig, cfg_dir: str) -> None: def __init__(self, core_cfg: CoreConfig, cfg_dir: str) -> None:
super().__init__(core_cfg, cfg_dir) super().__init__(core_cfg, cfg_dir)
self.game_cfg = Mai2Config() self.game_cfg = Mai2Config()
self.hash_table: Dict[int, Dict[str, str]] = {}
if path.exists(f"{cfg_dir}/{Mai2Constants.CONFIG_NAME}"): if path.exists(f"{cfg_dir}/{Mai2Constants.CONFIG_NAME}"):
self.game_cfg.update( self.game_cfg.update(
yaml.safe_load(open(f"{cfg_dir}/{Mai2Constants.CONFIG_NAME}")) yaml.safe_load(open(f"{cfg_dir}/{Mai2Constants.CONFIG_NAME}"))
@ -86,6 +91,37 @@ class Mai2Servlet(BaseServlet):
level=self.game_cfg.server.loglevel, logger=self.logger, fmt=log_fmt_str level=self.game_cfg.server.loglevel, logger=self.logger, fmt=log_fmt_str
) )
self.logger.initted = True self.logger.initted = True
for version, keys in self.game_cfg.crypto.keys.items():
if version < Mai2Constants.VER_MAIMAI_DX:
continue
if len(keys) < 3:
continue
self.hash_table[version] = {}
method_list = [
method
for method in dir(self.versions[version])
if not method.startswith("__")
]
for method in method_list:
# handle_method_api_request -> HandleMethodApiRequest
# remove the first 6 chars and the final 7 chars to get the canonical
# endpoint name.
method_fixed = inflection.camelize(method)[6:-7]
hash = MD5.new((method_fixed + keys[2]).encode())
# truncate unused bytes like the game does
hashed_name = hash.hexdigest()
self.hash_table[version][hashed_name] = method_fixed
self.logger.debug(
"Hashed v%s method %s with %s to get %s",
version, method_fixed, keys[2], hashed_name
)
@classmethod @classmethod
def is_game_enabled( def is_game_enabled(
@ -234,7 +270,7 @@ class Mai2Servlet(BaseServlet):
self.logger.error(f"Error handling v{version} method {endpoint} - {e}") self.logger.error(f"Error handling v{version} method {endpoint} - {e}")
return Response(zlib.compress(b'{"returnCode": "0"}')) return Response(zlib.compress(b'{"returnCode": "0"}'))
if resp == None: if resp is None:
resp = {"returnCode": 1} resp = {"returnCode": 1}
self.logger.debug(f"Response {resp}") self.logger.debug(f"Response {resp}")
@ -252,6 +288,8 @@ class Mai2Servlet(BaseServlet):
req_raw = await request.body() req_raw = await request.body()
internal_ver = 0 internal_ver = 0
client_ip = Utils.get_ip_addr(request) client_ip = Utils.get_ip_addr(request)
encrypted = False
if version < 105: # 1.0 if version < 105: # 1.0
internal_ver = Mai2Constants.VER_MAIMAI_DX internal_ver = Mai2Constants.VER_MAIMAI_DX
elif version >= 105 and version < 110: # PLUS elif version >= 105 and version < 110: # PLUS
@ -271,19 +309,54 @@ class Mai2Servlet(BaseServlet):
elif version >= 140: # BUDDiES elif version >= 140: # BUDDiES
internal_ver = Mai2Constants.VER_MAIMAI_DX_BUDDIES internal_ver = Mai2Constants.VER_MAIMAI_DX_BUDDIES
if all(c in string.hexdigits for c in endpoint) and len(endpoint) == 32:
# If we get a 32 character long hex string, it's a hash and we're
# dealing with an encrypted request. False positives shouldn't happen
# as long as requests are suffixed with `Api`.
if internal_ver not in self.hash_table:
self.logger.error(
"v%s does not support encryption or no keys entered",
version,
)
return Response(zlib.compress(b'{"stat": "0"}'))
elif endpoint.lower() not in self.hash_table[internal_ver]:
self.logger.error(
"No hash found for v%s endpoint %s",
version, endpoint
)
return Response(zlib.compress(b'{"stat": "0"}'))
endpoint = self.hash_table[internal_ver][endpoint.lower()]
try:
crypt = AES.new(
bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][0]),
AES.MODE_CBC,
bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][1]),
)
req_raw = crypt.decrypt(req_raw)
except Exception as e:
self.logger.error(
"Failed to decrypt v%s request to %s",
version, endpoint,
exc_info=e,
)
return Response(zlib.compress(b'{"stat": "0"}'))
encrypted = True
if ( if (
request.headers.get("Mai-Encoding") is not None not encrypted
or request.headers.get("X-Mai-Encoding") is not None and self.game_cfg.crypto.encrypted_only
and version >= 110
): ):
# The has is some flavor of MD5 of the endpoint with a constant bolted onto the end of it. self.logger.error(
# See cake.dll's Obfuscator function for details. Hopefully most DLL edits will remove "Unencrypted v%s %s request, but config is set to encrypted only: %r",
# these two(?) headers to not cause issues, but given the general quality of SEGA data... version, endpoint, req_raw
enc_ver = request.headers.get("Mai-Encoding")
if enc_ver is None:
enc_ver = request.headers.get("X-Mai-Encoding")
self.logger.debug(
f"Encryption v{enc_ver} - User-Agent: {request.headers.get('User-Agent')}"
) )
return Response(zlib.compress(b'{"stat": "0"}'))
try: try:
unzip = zlib.decompress(req_raw) unzip = zlib.decompress(req_raw)
@ -320,12 +393,26 @@ class Mai2Servlet(BaseServlet):
self.logger.error(f"Error handling v{version} method {endpoint} - {e}") self.logger.error(f"Error handling v{version} method {endpoint} - {e}")
return Response(zlib.compress(b'{"stat": "0"}')) return Response(zlib.compress(b'{"stat": "0"}'))
if resp == None: if resp is None:
resp = {"returnCode": 1} resp = {"returnCode": 1}
self.logger.debug(f"Response {resp}") self.logger.debug(f"Response {resp}")
return Response(zlib.compress(json.dumps(resp, ensure_ascii=False).encode("utf-8"))) zipped = zlib.compress(json.dumps(resp, ensure_ascii=False).encode("utf-8"))
if not encrypted or version < 110:
return Response(zipped)
padded = pad(zipped, 16)
crypt = AES.new(
bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][0]),
AES.MODE_CBC,
bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][1]),
)
return Response(crypt.encrypt(padded))
async def handle_old_srv(self, request: Request) -> bytes: async def handle_old_srv(self, request: Request) -> bytes:
endpoint = request.path_params.get('endpoint') endpoint = request.path_params.get('endpoint')

View File

@ -293,7 +293,9 @@ class OngekiBase:
async def handle_upsert_user_gplog_api_request(self, data: Dict) -> Dict: async def handle_upsert_user_gplog_api_request(self, data: Dict) -> Dict:
user = data["userId"] user = data["userId"]
if user >= 200000000000000: # Account for guest play
# If playing as guest, the user ID is or(0x1000000000001, (placeId & 65535) << 32)
if user & 0x1000000000001 == 0x1000000000001:
user = None user = None
await self.data.log.put_gp_log( await self.data.log.put_gp_log(
@ -426,9 +428,10 @@ class OngekiBase:
userTechCountList = [] userTechCountList = []
for tc in utcl: for tc in utcl:
tc.pop("id") tmp = tc._asdict()
tc.pop("user") tmp.pop("id")
userTechCountList.append(tc) tmp.pop("user")
userTechCountList.append(tmp)
return { return {
"userId": data["userId"], "userId": data["userId"],
@ -944,6 +947,12 @@ class OngekiBase:
upsert = data["upsertUserAll"] upsert = data["upsertUserAll"]
user_id = data["userId"] user_id = data["userId"]
if user_id & 0x1000000000001 == 0x1000000000001:
place_id = int(user_id) & 0xFFFC00000000
self.logger.info("Guest play from place ID %d, ignoring.", place_id)
return {"returnCode": 1, "apiName": "UpsertUserAllApi"}
# The isNew fields are new as of Red and up. We just won't use them for now. # The isNew fields are new as of Red and up. We just won't use them for now.
if "userData" in upsert and len(upsert["userData"]) > 0: if "userData" in upsert and len(upsert["userData"]) > 0:

View File

@ -129,7 +129,13 @@ tech_count = Table(
class OngekiScoreData(BaseData): class OngekiScoreData(BaseData):
async def get_tech_count(self, aime_id: int) -> Optional[List[Dict]]: async def get_tech_count(self, aime_id: int) -> Optional[List[Dict]]:
return [] sql = select(tech_count).where(tech_count.c.user == aime_id)
result = await self.execute(sql)
if result is None:
return None
return result.fetchall()
async def put_tech_count(self, aime_id: int, tech_count_data: Dict) -> Optional[int]: async def put_tech_count(self, aime_id: int, tech_count_data: Dict) -> Optional[int]:
tech_count_data["user"] = aime_id tech_count_data["user"] = aime_id