This repository has been archived on 2022-02-23. You can view files and clone it, but cannot push or open issues or pull requests.
lets/handlers/submitModularHandler.pyx

554 lines
22 KiB
Cython

import base64
import collections
import json
import sys
import threading
import traceback
from urllib.parse import urlencode
import requests
import tornado.gen
import tornado.web
import math
import secret.achievements.utils
from common.constants import gameModes
from common.constants import mods
from common.log import logUtils as log
from common.ripple import userUtils
from common.ripple import scoreUtils
from common.web import requestsManager
from constants import exceptions
from constants import rankedStatuses
from constants.exceptions import ppCalcException
from helpers import aeshelper
from helpers import replayHelper
from helpers import leaderboardHelper
from objects import beatmap
from objects import glob
from objects import score
from objects import scoreboard
from objects import relaxboard
from objects import rxscore
from helpers.generalHelper import zingonify
from objects.charts import BeatmapChart, OverallChart
from common import generalUtils
MODULE_NAME = "submit_modular"
class handler(requestsManager.asyncRequestHandler):
"""
Handler for /web/osu-submit-modular.php
"""
@tornado.web.asynchronous
@tornado.gen.engine
#@sentry.captureTornado
def asyncPost(self):
newCharts = self.request.uri == "/web/osu-submit-modular-selector.php"
try:
# Resend the score in case of unhandled exceptions
keepSending = True
# Get request ip
ip = self.getRequestIP()
# Print arguments
if glob.debug:
requestsManager.printArguments(self)
# Check arguments
if not requestsManager.checkArguments(self.request.arguments, ["score", "iv", "pass"]):
raise exceptions.invalidArgumentsException(MODULE_NAME)
# TODO: Maintenance check
# Get parameters and IP
scoreDataEnc = self.get_argument("score")
iv = self.get_argument("iv")
password = self.get_argument("pass")
ip = self.getRequestIP()
# Get bmk and bml (notepad hack check)
if "bmk" in self.request.arguments and "bml" in self.request.arguments:
bmk = self.get_argument("bmk")
bml = self.get_argument("bml")
else:
bmk = None
bml = None
# Get right AES Key
if "osuver" in self.request.arguments:
aeskey = "osu!-scoreburgr---------{}".format(self.get_argument("osuver"))
else:
aeskey = "h89f2-890h2h89b34g-h80g134n90133"
# Get score data
log.debug("Decrypting score data...")
scoreData = aeshelper.decryptRinjdael(aeskey, iv, scoreDataEnc, True).split(":")
username = scoreData[1].strip()
# Login and ban check
userID = userUtils.getID(username)
# User exists check
if userID == 0:
raise exceptions.loginFailedException(MODULE_NAME, userID)
# Bancho session/username-pass combo check
if not userUtils.checkLogin(userID, password, ip):
raise exceptions.loginFailedException(MODULE_NAME, username)
# 2FA Check
if userUtils.check2FA(userID, ip):
raise exceptions.need2FAException(MODULE_NAME, userID, ip)
# Generic bancho session check
#if not userUtils.checkBanchoSession(userID):
# TODO: Ban (see except exceptions.noBanchoSessionException block)
# raise exceptions.noBanchoSessionException(MODULE_NAME, username, ip)
# Ban check
if userUtils.isBanned(userID):
raise exceptions.userBannedException(MODULE_NAME, username)
# Data length check
if len(scoreData) < 16:
raise exceptions.invalidArgumentsException(MODULE_NAME)
# Get restricted
restricted = userUtils.isRestricted(userID)
# Get variables for relax
used_mods = int(scoreData[13])
isRelaxing = used_mods & 128
# Create score object and set its data
log.info("[{}] {} has submitted a score on {}...".format("RELAX" if isRelaxing else "VANILLA", username, scoreData[0]))
s = rxscore.score() if isRelaxing else score.score()
s.setDataFromScoreData(scoreData)
if s.completed == -1:
# Duplicated score
log.warning("Duplicated score detected, this is normal right after restarting the server")
return
# Set score stuff missing in score data
s.playerUserID = userID
# Get beatmap info
beatmapInfo = beatmap.beatmap()
beatmapInfo.setDataFromDB(s.fileMd5)
# Make sure the beatmap is submitted and updated
if beatmapInfo.rankedStatus == rankedStatuses.NOT_SUBMITTED or beatmapInfo.rankedStatus == rankedStatuses.NEED_UPDATE or beatmapInfo.rankedStatus == rankedStatuses.UNKNOWN:
log.debug("Beatmap is not submitted/outdated/unknown. Score submission aborted.")
return
# increment user playtime
length = 0
if s.passed:
length = userUtils.getBeatmapTime(beatmapInfo.beatmapID)
else:
length = math.ceil(int(self.get_argument("ft")) / 1000)
userUtils.incrementPlaytime(userID, s.gameMode, length)
# Calculate PP
midPPCalcException = None
try:
s.calculatePP()
except Exception as e:
# Intercept ALL exceptions and bypass them.
# We want to save scores even in case PP calc fails
# due to some rippoppai bugs.
# I know this is bad, but who cares since I'll rewrite
# the scores server again.
log.error("Caught an exception in pp calculation, re-raising after saving score in db")
s.pp = 0
midPPCalcException = e
# Restrict obvious cheaters™
if restricted == False:
if isRelaxing: # Relax
rxGods = [7340, 2137, 6868, 1215, 15066, 14522, 1325, 5798, 21610, 1254, 15070, 3445, 17157, 14791, 14728] # Yea yea it's a bad way of doing it, kill yourself - cmyui osu gaming
"""
CTBLIST = []
TAIKOLIST = []
"""
if (s.pp >= 1200 and s.gameMode == gameModes.STD and used_mods & 1024) and userID not in rxGods:
userUtils.restrict(userID)
userUtils.appendNotes(userID, "Restricted due to too high pp gain with FLASHLIGHT ({}pp)".format(s.pp))
log.warning("**{}** ({}) has been restricted due to too high pp gain with FLASHLIGHT **({}pp)**".format(username, userID, s.pp), "cm")
elif (s.pp >= 2000 and s.gameMode == gameModes.STD) and userID not in rxGods:
userUtils.restrict(userID)
userUtils.appendNotes(userID, "Restricted due to too high pp gain ({}pp)".format(s.pp))
log.warning("**{}** ({}) has been restricted due to too high pp gain **({}pp)**".format(username, userID, s.pp), "cm")
"""
elif (s.pp >= 10000 and s.gameMode == gameModes.TAIKO) and userID not in TAIKOLIST:
userUtils.restrict(userID)
userUtils.appendNotes(userID, "Restricted due to too high pp gain ({}pp)".format(s.pp))
log.warning("**{}** ({}) has been restricted due to too high pp gain **({}pp)**".format(username, userID, s.pp), "cm")
elif s.pp >= 10000 and (s.gameMode == gameModes.CTB) and userID not in CTBLIST:
userUtils.restrict(userID)
userUtils.appendNotes(userID, "Restricted due to too high pp gain ({}pp)".format(s.pp))
log.warning("**{}** ({}) has been restricted due to too high pp gain **({}pp)**".format(username, userID, s.pp), "cm")
"""
else: # Vanilla
if (s.pp >= 500 and s.gameMode == gameModes.STD and used_mods & 1024):
userUtils.restrict(userID)
userUtils.appendNotes(userID, "Restricted due to too high pp gain with FLASHLIGHT ({}pp)".format(s.pp))
log.warning("**{}** ({}) has been restricted due to too high pp gain with FLASHLIGHT **({}pp)**".format(username, userID, s.pp), "cm")
elif (s.pp >= 700 and s.gameMode == gameModes.STD):
userUtils.restrict(userID)
userUtils.appendNotes(userID, "Restricted due to too high pp gain ({}pp)".format(s.pp))
log.warning("**{}** ({}) has been restricted due to too high pp gain **({}pp)**".format(username, userID, s.pp), "cm")
"""
elif (s.pp >= 10000 and s.gameMode == gameModes.TAIKO):
userUtils.restrict(userID)
userUtils.appendNotes(userID, "Restricted due to too high pp gain ({}pp)".format(s.pp))
log.warning("**{}** ({}) has been restricted due to too high pp gain **({}pp)**".format(username, userID, s.pp), "cm")
elif s.pp >= 10000 and (s.gameMode == gameModes.CTB):
userUtils.restrict(userID)
userUtils.appendNotes(userID, "Restricted due to too high pp gain ({}pp)".format(s.pp))
log.warning("**{}** ({}) has been restricted due to too high pp gain **({}pp)**".format(username, userID, s.pp), "cm")
"""
# Check notepad hack
if bmk is None and bml is None:
# No bmk and bml params passed, edited or super old client
#log.warning("{} ({}) most likely submitted a score from an edited client or a super old client".format(username, userID), "cm")
pass
elif bmk != bml and not restricted:
# bmk and bml passed and they are different, restrict the user
userUtils.restrict(userID)
userUtils.appendNotes(userID, "Restricted due to notepad hack")
log.warning("**{}** ({}) has been restricted due to notepad hack".format(username, userID), "cm")
return
# Right before submitting the score, get the personal best score object (we need it for charts)
if s.passed and s.oldPersonalBest > 0:
oldPersonalBestRank = glob.personalBestCache.get(userID, s.fileMd5)
if oldPersonalBestRank == 0:
oldScoreboard = scoreboard.scoreboard(username, s.gameMode, beatmapInfo, False)
oldScoreboard.setPersonalBestRank()
oldPersonalBestRank = max(oldScoreboard.personalBestRank, 0)
oldPersonalBest = score.score(s.oldPersonalBest, oldPersonalBestRank)
else:
oldPersonalBestRank = 0
oldPersonalBest = None
# Save score in db
s.saveScoreInDB()
# Client anti-cheat flags
haxFlags = scoreData[17].count(' ') # 4 is normal, 0 is irregular but inconsistent.
if haxFlags != 4 and haxFlags != 0 and s.completed > 1 and restricted == False:
flagsReadable = generalUtils.calculateFlags(haxFlags, used_mods, s.gameMode)
userUtils.appendNotes(userID, "-- has received clientside flags: {} [{}] (cheated score id: {})".format(haxFlags, flagsReadable, s.scoreID))
log.warning("**{}** ({}) has received clientside anti cheat flags.\n\nFlags: {}.\n[{}]\n\nScore ID: {scoreID}\nReplay: https://akatsuki.pw/web/replays/{scoreID}".format(username, userID, haxFlags, flagsReadable, scoreID=s.scoreID), "cm")
if s.score < 0 or s.score > (2 ** 63) - 1:
userUtils.ban(userID)
userUtils.appendNotes(userID, "Banned due to negative score.")
if s.passed: # just incase :)!
if (s.score - (s.c300 * 300 + s.c100 * 100 + s.c50 * 50)) < 0 and not isRelaxing:
#userUtils.ban(userID)
#userUtils.appendNotes(userID, "Banned due to score being less than no-combo value.")
log.cmyui("{} has submitted a score where score is less than no-combo value. (scoreID: {}, score: {}, pp:{})".format(username, s.scoreID, s.score, s.pp), discord="cm")
if s.fullCombo and s.cMiss > 0:
log.cmyui("{} has submitted a score with 'fullCombo' flag, but has > 0 misses. (scoreID: {}, score: {}, pp:{})".format(username, s.scoreID, s.score, s.pp), discord="cm")
# Make sure the score is not memed
if s.gameMode == gameModes.MANIA and s.score > 1000000:
userUtils.ban(userID)
userUtils.appendNotes(userID, "Banned due to mania score > 1000000.")
# Ci metto la faccia, ci metto la testa e ci metto il mio cuore
if ((s.mods & mods.DOUBLETIME) > 0 and (s.mods & mods.HALFTIME) > 0) \
or ((s.mods & mods.HARDROCK) > 0 and (s.mods & mods.EASY) > 0) \
or ((s.mods & mods.SUDDENDEATH) > 0 and (s.mods & mods.NOFAIL) > 0)\
or ((s.mods & mods.RELAX) > 0 and (s.mods & mods.RELAX2) > 0):
userUtils.ban(userID)
userUtils.appendNotes(userID, "Impossible mod combination ({}).".format(s.mods))
# NOTE: Process logging was removed from the client starting from 20180322
# Save replay for all passed scores
# Make sure the score has an id as well (duplicated?, query error?)
if s.passed and s.scoreID > 0:
if "score" in self.request.files:
# Save the replay if it was provided
log.debug("Saving replay ({})...".format(s.scoreID))
replay = self.request.files["score"][0]["body"]
with open(".data/replays/replay_{}.osr".format(s.scoreID), "wb") as f:
f.write(replay)
# Send to cono ALL passed replays, even non high-scores
if glob.conf.config["cono"]["enable"]:
if isRelaxing:
threading.Thread(target=lambda: glob.redis.publish(
"cono:analyze", json.dumps({
"score_id": s.scoreID,
"beatmap_id": beatmapInfo.beatmapID,
"user_id": s.playerUserID,
"game_mode": s.gameMode,
"pp": s.pp,
"replay_data": base64.b64encode(
replayHelper.rxbuildFullReplay(
s.scoreID,
rawReplay=self.request.files["score"][0]["body"]
)
).decode(),
})
)).start()
else:
# We run this in a separate thread to avoid slowing down scores submission,
# as cono needs a full replay
threading.Thread(target=lambda: glob.redis.publish(
"cono:analyze", json.dumps({
"score_id": s.scoreID,
"beatmap_id": beatmapInfo.beatmapID,
"user_id": s.playerUserID,
"game_mode": s.gameMode,
"pp": s.pp,
"replay_data": base64.b64encode(
replayHelper.buildFullReplay(
s.scoreID,
rawReplay=self.request.files["score"][0]["body"]
)
).decode(),
})
)).start()
else:
# Restrict if no replay was provided
if not restricted:
userUtils.restrict(userID)
userUtils.appendNotes(userID, "Restricted due to missing replay while submitting a score.")
log.warning("**{}** ({}) has been restricted due to not submitting a replay on map {}.".format(
username, userID, s.fileMd5
), "cm")
# Update beatmap playcount (and passcount)
beatmap.incrementPlaycount(s.fileMd5, s.passed)
# Let the api know of this score
if s.scoreID:
glob.redis.publish("api:score_submission", s.scoreID)
# Re-raise pp calc exception after saving score, cake, replay etc
# so Sentry can track it without breaking score submission
if midPPCalcException is not None:
raise ppCalcException(midPPCalcException)
# If there was no exception, update stats and build score submitted panel
# Get "before" stats for ranking panel (only if passed)
if s.passed:
# Get stats and rank
if isRelaxing:
oldUserData = glob.userStatsCache.rxget(userID, s.gameMode)
oldRank = userUtils.rxgetGameRank(userID, s.gameMode)
else:
oldUserData = glob.userStatsCache.get(userID, s.gameMode)
oldRank = userUtils.getGameRank(userID, s.gameMode)
# Always update users stats (total/ranked score, playcount, level, acc and pp)
# even if not passed
log.debug("[{}] Updating {}'s stats...".format("RELAX" if isRelaxing else "VANILLA", username))
if isRelaxing:
userUtils.rxupdateStats(userID, s)
else:
userUtils.updateStats(userID, s)
# Get "after" stats for ranking panel
# and to determine if we should update the leaderboard
# (only if we passed that song)
if s.passed:
# Get new stats
if isRelaxing:
newUserData = userUtils.getRelaxStats(userID, s.gameMode)
glob.userStatsCache.rxupdate(userID, s.gameMode, newUserData)
else:
newUserData = userUtils.getUserStats(userID, s.gameMode)
glob.userStatsCache.update(userID, s.gameMode, newUserData)
# Update leaderboard (global and country) if score/pp has changed
if s.completed == 3 and newUserData["pp"] != oldUserData["pp"]:
if isRelaxing:
leaderboardHelper.rxupdate(userID, newUserData["pp"], s.gameMode)
leaderboardHelper.rxupdateCountry(userID, newUserData["pp"], s.gameMode)
else:
leaderboardHelper.update(userID, newUserData["pp"], s.gameMode)
leaderboardHelper.updateCountry(userID, newUserData["pp"], s.gameMode)
# TODO: Update total hits and max combo
# Update latest activity
userUtils.updateLatestActivity(userID)
# IP log
userUtils.IPLog(userID, ip)
# Score submission and stats update done
log.debug("Score submission and user stats update done!")
# Score has been submitted, do not retry sending the score if
# there are exceptions while building the ranking panel
keepSending = False
# At the end, check achievements
if s.passed:
new_achievements = secret.achievements.utils.unlock_achievements(s, beatmapInfo, newUserData)
# Output ranking panel only if we passed the song
# and we got valid beatmap info from db
if beatmapInfo is not None and beatmapInfo != False and s.passed:
log.debug("Started building ranking panel.")
if isRelaxing: # Relax
# Trigger bancho stats cache update
glob.redis.publish("peppy:update_rxcached_stats", userID)
newScoreboard = relaxboard.scoreboard(username, s.gameMode, beatmapInfo, False)
newScoreboard.setPersonalBestRank()
personalBestID = newScoreboard.getPersonalBestID()
assert personalBestID is not None
currentPersonalBest = rxscore.score(personalBestID, newScoreboard.personalBestRank)
# Get rank info (current rank, pp/score to next rank, user who is 1 rank above us)
rankInfo = leaderboardHelper.rxgetRankInfo(userID, s.gameMode)
else: # Vanilla
# Trigger bancho stats cache update
glob.redis.publish("peppy:update_cached_stats", userID)
newScoreboard = scoreboard.scoreboard(username, s.gameMode, beatmapInfo, False)
newScoreboard.setPersonalBestRank()
personalBestID = newScoreboard.getPersonalBestID()
assert personalBestID is not None
currentPersonalBest = score.score(personalBestID, newScoreboard.personalBestRank)
# Get rank info (current rank, pp/score to next rank, user who is 1 rank above us)
rankInfo = leaderboardHelper.getRankInfo(userID, s.gameMode)
if newCharts:
log.debug("Using new charts")
dicts = [
collections.OrderedDict([
("beatmapId", beatmapInfo.beatmapID),
("beatmapSetId", beatmapInfo.beatmapSetID),
("beatmapPlaycount", beatmapInfo.playcount + 1),
("beatmapPasscount", beatmapInfo.passcount + (s.completed == 3)),
("approvedDate", "")
]),
BeatmapChart(
oldPersonalBest if s.completed == 3 else currentPersonalBest,
currentPersonalBest if s.completed == 3 else s,
beatmapInfo.beatmapID,
),
OverallChart(
userID, oldUserData, newUserData, beatmapInfo, s, new_achievements, oldRank, rankInfo["currentRank"]
)
]
else:
log.debug("Using old charts")
dicts = [
collections.OrderedDict([
("beatmapId", beatmapInfo.beatmapID),
("beatmapSetId", beatmapInfo.beatmapSetID),
("beatmapPlaycount", beatmapInfo.playcount),
("beatmapPasscount", beatmapInfo.passcount),
("approvedDate", "")
]),
collections.OrderedDict([
("chartId", "overall"),
("chartName", "Overall Ranking"),
("chartEndDate", ""),
("beatmapRankingBefore", oldPersonalBestRank),
("beatmapRankingAfter", newScoreboard.personalBestRank),
("rankedScoreBefore", oldUserData["rankedScore"]),
("rankedScoreAfter", newUserData["rankedScore"]),
("totalScoreBefore", oldUserData["totalScore"]),
("totalScoreAfter", newUserData["totalScore"]),
("playCountBefore", newUserData["playcount"]),
("accuracyBefore", float(oldUserData["accuracy"])/100),
("accuracyAfter", float(newUserData["accuracy"])/100),
("rankBefore", oldRank),
("rankAfter", rankInfo["currentRank"]),
("toNextRank", rankInfo["difference"]),
("toNextRankUser", rankInfo["nextUsername"]),
("achievements", ""),
("achievements-new", secret.achievements.utils.achievements_response(new_achievements)),
("onlineScoreId", s.scoreID)
])
]
output = "\n".join(zingonify(x) for x in dicts)
log.debug("Generated output for online ranking screen!")
log.debug(output)
# Send message to #announce if we're rank #1
if newScoreboard.personalBestRank == 1 and s.completed == 3 and not restricted:
annmsg = "[{}] [https://akatsuki.pw/u/{} {}] achieved rank #1 on [https://osu.ppy.sh/b/{} {}] ({})".format(
"RELAX" if isRelaxing else "VANILLA",
userID,
username.encode().decode("ASCII", "ignore"),
beatmapInfo.beatmapID,
beatmapInfo.songName.encode().decode("ASCII", "ignore"),
gameModes.getGamemodeFull(s.gameMode)
)
params = urlencode({"k": glob.conf.config["server"]["apikey"], "to": "#announce", "msg": annmsg})
requests.get("{}/api/v1/fokabotMessage?{}".format(glob.conf.config["server"]["banchourl"], params))
scoreUtils.newFirst(s.scoreID, userID, s.fileMd5, s.gameMode, isRelaxing)
# Write message to client
self.write(output)
else:
# No ranking panel, send just "ok"
self.write("ok")
# Send username change request to bancho if needed
# (key is deleted bancho-side)
newUsername = glob.redis.get("ripple:change_username_pending:{}".format(userID))
if newUsername is not None:
log.debug("Sending username change request for user {} to Bancho".format(userID))
glob.redis.publish("peppy:change_username", json.dumps({
"userID": userID,
"newUsername": newUsername.decode("utf-8")
}))
# Datadog stats
glob.dog.increment(glob.DATADOG_PREFIX+".submitted_scores")
except exceptions.invalidArgumentsException:
pass
except exceptions.loginFailedException:
self.write("error: pass")
except exceptions.need2FAException:
# Send error pass to notify the user
# resend the score at regular intervals
# for users with memy connection
self.set_status(408)
self.write("error: 2fa")
except exceptions.userBannedException:
self.write("error: ban")
except exceptions.noBanchoSessionException:
# We don't have an active bancho session.
# Don't ban the user but tell the client to send the score again.
# Once we are sure that this error doesn't get triggered when it
# shouldn't (eg: bancho restart), we'll ban users that submit
# scores without an active bancho session.
# We only log through schiavo atm (see exceptions.py).
self.set_status(408)
self.write("error: pass")
except:
# Try except block to avoid more errors
try:
log.error("Unknown error in {}!\n```{}\n{}```".format(MODULE_NAME, sys.exc_info(), traceback.format_exc()))
if glob.sentry:
yield tornado.gen.Task(self.captureException, exc_info=True)
except:
pass
# Every other exception returns a 408 error (timeout)
# This avoids lost scores due to score server crash
# because the client will send the score again after some time.
if keepSending:
self.set_status(408)