def getScores(req: Request) -> Optional[bytes]: if not all(x in req.args for x in required_params_getScores): printlog(f'get-scores req missing params.') return # Tbh, I don't really care if people request # leaderboards from other peoples accounts, or are # not logged out.. At the moment, there are no checks # that could put anyone's account in danger :P. # XXX: could be a ddos problem? lol if len(req.args['c']) != 32 \ or not req.args['mods'].isnumeric(): return b'-1|false' req.args['mods'] = int(req.args['mods']) res: List[bytes] = [] if req.args['mods'] & Mods.RELAX: table = 'scores_rx' scoring = 'pp' else: table = 'scores_vn' scoring = 'score' if not (bmap := Beatmap.from_md5(req.args['c'])): # Couldn't find in db or at osu! api. # The beatmap must not be submitted. return b'-1|false'
def osuSearchHandler(req: Request) -> Optional[bytes]: if not all(x in req.args for x in required_params_osuSearch): printlog(f'osu-search req missing params.') return if not (p := glob.players.get_from_cred(req.args['u'], req.args['h'])): return
def updateBeatmap(req: Request) -> Optional[bytes]: # XXX: This currently works in updating the map, but # seems to get the checksum something like that wrong? # Will have to look into it :P if not (re := _map_regex.match(unquote(req.uri[10:]))): printlog(f'Requested invalid map update {req.uri}.', Ansi.RED) return b''
def lastFM(req: Request) -> Optional[bytes]: if not all(x in req.args for x in required_params_lastFM): printlog(f'lastfm req missing params.') return if not (p := glob.players.get_from_cred(req.args['us'], req.args['ha'])): return
def add(self, m: Match) -> bool: if m in self.matches: printlog(f'{m} already in matches list!') return False if (free := self.get_free()) is None: printlog(f'Match list is full! Could not add {m}.') return False
def leave_channel(self, c: Channel) -> None: if self not in c: printlog(f'{self} tried to leave {c} but is not in it.') return c.remove(self) # Remove from channels self.channels.remove(c) # Remove from player self.enqueue(packets.channelKick(c.name)) printlog(f'{self} left {c}.')
def add_friend(self, p) -> None: if p.id in self.friends: printlog(f'{self} tried to add {p}, who is already their friend!') return self.friends.add(p.id) glob.db.execute('INSERT INTO friendships ' 'VALUES (%s, %s)', [self.id, p.id]) printlog(f'{self} added {p} to their friends.')
def remove_friend(self, p) -> None: if not p.id in self.friends: printlog(f'{self} tried to remove {p}, who is not their friend!') return self.friends.remove(p.id) glob.db.execute( 'DELETE FROM friendships ' 'WHERE user1 = %s AND user2 = %s', [self.id, p.id]) printlog(f'{self} removed {p} from their friends.')
def read_packet_header(self) -> None: if len(self.data) < 7: # packet is invalid, end connection self.packetID = -1 self._offset += len(self.data) printlog(f'[ERR] Data misread! (len: {len(self.data)})', Ansi.LIGHT_RED) return self.packetID, self.length = struct.unpack('<HxI', self.data[:7]) self._offset += 7 # Read our first 7 bytes for packetid & len
def osuScreenshot(req: Request) -> Optional[bytes]: if not all(x in req.args for x in required_params_screemshot): printlog(f'screenshot req missing params.') return if 'ss' not in req.files: printlog(f'screenshot req missing file.') return if not (p := glob.players.get_from_cred(req.args['u'], req.args['p'])): return
def getReplay(req: Request) -> Optional[bytes]: if not all(x in req.args for x in required_params_getReplay): printlog(f'get-scores req missing params.') return path = f"replays/{req.args['c']}.osr" if not exists(path): return b'' with open(path, 'rb') as f: data = f.read() return data
def restrict(self) -> None: # TODO: reason self.priv &= ~Privileges.Normal glob.db.execute('UPDATE users SET priv = %s WHERE id = %s', [int(self.priv), self.id]) if self in glob.players: # If user is online, notify and log them out. # XXX: If you want to lock the player's # client, you can send -3 rather than -1. self.enqueue(packets.userID(-1)) self.enqueue( packets.notification('Your account has been banned.\n\n' 'If you believe this was a mistake or ' 'have waited >= 2 months, you can appeal ' 'using the appeal form on the website.')) printlog(f'Restricted {self}.', Ansi.CYAN)
def leave_match(self) -> None: if not self.match: printlog(f'{self} tried leaving a match but is not in one?') return for s in self.match.slots: if self == s.player: s.reset() break self.leave_channel(self.match.chat) if all(s.empty() for s in self.match.slots): # Multi is now empty, chat has been removed. # Remove the multi from the channels list. printlog(f'Match {self.match} finished.') glob.matches.remove(self.match) if (lobby := glob.channels.get('#lobby')): lobby.enqueue(packets.disposeMatch(self.match.id))
def from_submission(cls, data_enc: str, iv: str, osu_ver: str, pass_md5: str) -> None: """Create a score object from an osu! submission string.""" aes_key = f'osu!-scoreburgr---------{osu_ver}' cbc = RijndaelCbc( f'osu!-scoreburgr---------{osu_ver}', iv = b64decode(iv).decode('latin_1'), padding = ZeroPadding(32), block_size = 32 ) data = cbc.decrypt(b64decode(data_enc).decode('latin_1')).decode().split(':') if len(data) != 18: printlog('Received an invalid score submission.', Ansi.LIGHT_RED) return None s = cls() if len(map_md5 := data[0]) != 32: return
def remove_spectator(self, p) -> None: self.spectators.remove(p) p.spectating = None c = glob.channels.get(f'#spec_{self.id}') p.leave_channel(c) if not self.spectators: # Remove host from channel, deleting it. self.leave_channel(c) else: fellow = packets.fellowSpectatorLeft(p.id) c_info = packets.channelInfo(*c.basic_info) # new playercount self.enqueue(c_info) for s in self.spectators: s.enqueue(fellow + c_info) self.enqueue(packets.spectatorLeft(p.id)) printlog(f'{p} is no longer spectating {self}.')
def save_to_sql(self) -> None: if any(x is None for x in (self.md5, self.id, self.set_id, self.status, self.artist, self.title, self.version, self.creator, self.last_update, self.frozen, self.mode, self.bpm, self.cs, self.od, self.ar, self.hp, self.diff)): printlog('Tried to save invalid beatmap to SQL!', Ansi.LIGHT_RED) return glob.db.execute( 'REPLACE INTO maps (id, set_id, status, md5, ' 'artist, title, version, creator, last_update, ' 'frozen, mode, bpm, cs, od, ar, hp, diff) VALUES (' '%s, %s, %s, %s, %s, %s, %s, %s, %s, ' '%s, %s, %s, %s, %s, %s, %s, %s)', [ self.id, self.set_id, int(self.status), self.md5, self.artist, self.title, self.version, self.creator, self.last_update, self.frozen, int(self.mode), self.bpm, self.cs, self.od, self.ar, self.hp, self.diff ])
def update_stats(self, gm: GameMode = GameMode.vn_std) -> None: table = 'scores_rx' if gm >= 4 else 'scores_vn' res = glob.db.fetchall( f'SELECT s.pp, s.acc FROM {table} s ' 'LEFT JOIN maps m ON s.map_md5 = m.md5 ' 'WHERE s.userid = %s AND s.game_mode = %s ' 'AND s.status = 2 AND m.status IN (1, 2) ' 'ORDER BY s.pp DESC LIMIT 100', [self.id, gm - (4 if gm >= 4 else 0)]) if not res: return # ? # Update the user's stats ingame, then update db. self.stats[gm].plays += 1 self.stats[gm].pp = sum( round(round(row['pp']) * 0.95**i) for i, row in enumerate(res)) self.stats[gm].acc = sum([row['acc'] for row in res][:50]) / min( 50, len(res)) / 100.0 glob.db.execute( 'UPDATE stats SET pp_{0:sql} = %s, ' 'plays_{0:sql} = plays_{0:sql} + 1, ' 'acc_{0:sql} = %s WHERE id = %s'.format(gm), [self.stats[gm].pp, self.stats[gm].acc, self.id]) # Calculate rank. res = glob.db.fetch( 'SELECT COUNT(*) AS c FROM stats ' 'LEFT JOIN users USING(id) ' f'WHERE pp_{gm:sql} > %s ' 'AND priv & 1', [self.stats[gm].pp]) self.stats[gm].rank = res['c'] + 1 self.enqueue(packets.userStats(self)) printlog(f"Updated {self}'s {gm!r} stats.")
class MatchList(Sequence): """A class to represent all multiplayer matches on the gulag. Attributes ----------- matches: List[Optional[:class:`Match`]] A list of match objects representing the current mp matches. The size of this attr is constant; slots will be None if not in use. """ __slots__ = ('matches', ) def __init__(self): self.matches = [None for _ in range(32)] # Max matches. def __getitem__(self, index: Slice) -> Optional[Match]: return self.matches[index] def __len__(self) -> int: return len(self.matches) def __contains__(self, m: Match) -> bool: return m in self.matches def get_free(self) -> Optional[Match]: # Return first free match. for idx, m in enumerate(self.matches): if not m: return idx def get_by_id(self, mid: int) -> Optional[Match]: for m in self.matches: if m and m.id == mid: return m def add(self, m: Match) -> bool: if m in self.matches: printlog(f'{m} already in matches list!') return False if (free := self.get_free()) is None: printlog(f'Match list is full! Could not add {m}.') return False m.id = free printlog(f'Adding {m} to matches list.') self.matches[free] = m
def join_match(self, m: Match, passwd: str) -> bool: if self.match: printlog(f'{self} tried to join multiple matches?') self.enqueue(packets.matchJoinFail(m)) return False if m.chat: # Match already exists, we're simply joining. if passwd != m.passwd: # eff: could add to if? or self.create_m.. printlog(f'{self} tried to join {m} with incorrect passwd.') self.enqueue(packets.matchJoinFail(m)) return False if (slotID := m.get_free()) is None: printlog(f'{self} tried to join a full match.') self.enqueue(packets.matchJoinFail(m)) return False
def join_channel(self, c: Channel) -> bool: if self in c: printlog(f'{self} tried to double join {c}.') return False if not self.priv & c.read: printlog(f'{self} tried to join {c} but lacks privs.') return False # Lobby can only be interacted with while in mp lobby. if c._name == '#lobby' and not self.in_lobby: return False c.append(self) # Add to channels self.channels.append(c) # Add to player self.enqueue(packets.channelJoin(c.name)) printlog(f'{self} joined {c}.') return True
def dequeue(self) -> bytes: try: return self._queue.get_nowait() except: printlog('Empty queue?')
return cls(**res) @classmethod def from_md5(cls, md5: str, cache_pp: bool = False): # Try to get from sql. if (m := cls.from_md5_sql(md5)): if cache_pp: m.cache_pp() return m # Not in sql, get from osu!api. if glob.config.osu_api_key: return cls.from_md5_osuapi(md5) printlog('Fetching beatmap requires osu!api key.', Ansi.LIGHT_RED) @classmethod def from_md5_sql(cls, md5: str): if not (res := glob.db.fetch( 'SELECT id, set_id, status, ' 'artist, title, version ' 'FROM maps WHERE md5 = %s', [md5])): return res['md5'] = md5 return cls(**res) @classmethod def from_md5_osuapi(cls, md5: str): if not (r := req_get(
def unrestrict(self) -> None: self.priv &= Privileges.Normal glob.db.execute('UPDATE users SET priv = %s WHERE id = %s', [int(self.priv), self.id]) printlog(f'Unrestricted {self}.', Ansi.CYAN)
def submitModularSelector(req: Request) -> Optional[bytes]: if not all(x in req.args for x in required_params_submitModular): printlog(f'submit-modular-selector req missing params.') return b'error: no' # TODO: make some kind of beatmap object. # We currently don't check the map's ranked status. # Parse our score data into a score obj. s: Score = Score.from_submission(req.args['score'], req.args['iv'], req.args['osuver'], req.args['pass']) if not s: printlog('Failed to parse a score - invalid format.', Ansi.YELLOW) return b'error: no' elif not s.player: # Player is not online, return nothing so that their # client will retry submission when they log in. return elif not s.bmap: # Map does not exist, most likely unsubmitted. return b'error: no' elif s.bmap.status == RankedStatus.Pending: # XXX: Perhaps will accept in the future, return b'error: no' # not now though. table = 'scores_rx' if s.mods & Mods.RELAX else 'scores_vn' # Check for score duplicates # TODO: might need to improve? res = glob.db.fetch( f'SELECT 1 FROM {table} WHERE game_mode = %s ' 'AND map_md5 = %s AND userid = %s AND mods = %s ' 'AND score = %s', [s.game_mode, s.bmap.md5, s.player.id, s.mods, s.score]) if res: printlog(f'{s.player} submitted a duplicate score.', Ansi.LIGHT_YELLOW) return b'error: no' if req.args['i']: breakpoint() gm = GameMode(s.game_mode + (4 if s.player.rx and s.game_mode != 3 else 0)) if not s.player.priv & Privileges.Whitelisted: # Get the PP cap for the current context. pp_cap = autorestrict_pp[gm][s.mods & Mods.FLASHLIGHT != 0] if s.pp > pp_cap: printlog( f'{s.player} restricted for submitting {s.pp:.2f} score on gm {s.game_mode}.', Ansi.LIGHT_RED) s.player.restrict() return b'error: ban' if s.status == SubmissionStatus.BEST: # Our score is our best score. # Update any preexisting personal best # records with SubmissionStatus.SUBMITTED. glob.db.execute( f'UPDATE {table} SET status = 1 ' 'WHERE status = 2 and map_md5 = %s ' 'AND userid = %s', [s.bmap.md5, s.player.id]) s.id = glob.db.execute( f'INSERT INTO {table} VALUES (NULL, ' '%s, %s, %s, %s, %s, %s, ' '%s, %s, %s, %s, %s, %s, ' '%s, %s, %s, ' '%s, %s, %s' ')', [ s.bmap.md5, s.score, s.pp, s.acc, s.max_combo, s.mods, s.n300, s.n100, s.n50, s.nmiss, s.ngeki, s.nkatu, int(s.status), s.game_mode, s.play_time, s.client_flags, s.player.id, s.perfect ]) if s.status != SubmissionStatus.FAILED: # All submitted plays should have a replay. # If not, they may be using a score submitter. if 'score' not in req.files or req.files['score'] == b'\r\n': printlog(f'{s.player} submitted a score without a replay!', Ansi.LIGHT_RED) s.player.restrict() else: # Save our replay with open(f'replays/{s.id}.osr', 'wb') as f: f.write(req.files['score']) s.player.stats[gm].tscore += s.score if s.bmap.status in {RankedStatus.Ranked, RankedStatus.Approved}: s.player.stats[gm].rscore += s.score glob.db.execute( 'UPDATE stats SET rscore_{0:sql} = %s, ' 'tscore_{0:sql} = %s WHERE id = %s'.format(gm), [s.player.stats[gm].rscore, s.player.stats[gm].tscore, s.player.id]) if s.status == SubmissionStatus.BEST and s.rank == 1: # Announce the user's #1 score. if announce_chan := glob.channels.get('#announce'): announce_chan.send( glob.bot, f'{s.player.embed} achieved #1 on {s.bmap.embed}.')
printlog(f'screenshot req missing params.') return if 'ss' not in req.files: printlog(f'screenshot req missing file.') return if not (p := glob.players.get_from_cred(req.args['u'], req.args['p'])): return filename = f'{rstring(8)}.png' with open(f'screenshots/{filename}', 'wb+') as f: f.write(req.files['ss']) printlog(f'{p} uploaded {filename}.') return filename.encode() required_params_lastFM = frozenset({'b', 'action', 'us', 'ha'}) @web_handler('lastfm.php') def lastFM(req: Request) -> Optional[bytes]: if not all(x in req.args for x in required_params_lastFM): printlog(f'lastfm req missing params.') return if not (p := glob.players.get_from_cred(req.args['us'], req.args['ha'])): return
def fetch_geoloc(self, ip: str) -> None: if not (res := req_get(f'http://ip-api.com/json/{ip}')): printlog('Failed to get geoloc data: request failed.', Ansi.LIGHT_RED) return
def remove(self, c: Channel) -> None: printlog(f'Removing {c} from channels list.') self.channels.remove(c)
class Score: """A class to represent a score. Attributes ----------- id: :class:`int` The score's unique ID. bmap: Optional[:class:`Beatmap`] A beatmap obj representing the osu map. player: Optional[:class:`Player`] A player obj of the player who submitted the score. pp: :class:`float` The score's performance points. score: :class:`int` The score's osu! score value. max_combo: :class:`int` The maximum combo reached in the score. mods: :class:`int` A bitwise value of the osu! mods used in the score. acc: :class:`float` The accuracy of the score. n300: :class:`int` The number of 300s in the score. n100: :class:`int` The number of 100s in the score (150s if taiko). n50: :class:`int` The number of 50s in the score. nmiss: :class:`int` The number of misses in the score. ngeki: :class:`int` The number of gekis in the score. nkatu: :class:`int` The number of katus in the score. grade: :class:`str` The letter grade in the score. rank: :class:`int` The leaderboard placement of the score. passed: :class:`bool` Whether the score completed the map. perfect: :class:`bool` Whether the score is a full-combo. status: :class:`SubmissionStatus` The submission status of the score. game_mode: :class:`int` The game mode of the score. play_time: :class:`int` A UNIX timestamp of the time of score submission. client_flags: :class:`int` osu!'s old anticheat flags. """ __slots__ = ( 'id', 'map', 'player', 'pp', 'score', 'max_combo', 'mods', 'acc', 'n300', 'n100', 'n50', 'nmiss', 'ngeki', 'nkatu', 'grade', 'rank', 'passed', 'perfect', 'status', 'game_mode', 'play_time', 'client_flags' ) def __init__(self): self.id = 0 self.bmap: Optional[Beatmap] = None self.player: Optional[Player] = None self.pp = 0.0 self.score = 0 self.max_combo = 0 self.mods = Mods.NOMOD self.acc = 0.0 # TODO: perhaps abstract these differently # since they're mode dependant? feels weird.. self.n300 = 0 self.n100 = 0 # n150 for taiko self.n50 = 0 self.nmiss = 0 self.ngeki = 0 self.nkatu = 0 self.grade = Rank.F self.rank = 0 self.passed = False self.perfect = False self.status = SubmissionStatus.FAILED self.game_mode = 0 self.play_time = 0 # osu!'s client 'anticheat'. self.client_flags = ClientFlags.Clean @classmethod def from_submission(cls, data_enc: str, iv: str, osu_ver: str, pass_md5: str) -> None: """Create a score object from an osu! submission string.""" aes_key = f'osu!-scoreburgr---------{osu_ver}' cbc = RijndaelCbc( f'osu!-scoreburgr---------{osu_ver}', iv = b64decode(iv).decode('latin_1'), padding = ZeroPadding(32), block_size = 32 ) data = cbc.decrypt(b64decode(data_enc).decode('latin_1')).decode().split(':') if len(data) != 18: printlog('Received an invalid score submission.', Ansi.LIGHT_RED) return None s = cls() if len(map_md5 := data[0]) != 32: return s.bmap = Beatmap.from_md5(map_md5) # why does osu! make me rstrip lol s.player = glob.players.get_from_cred(data[1].rstrip(), pass_md5) if not s.player: # Return the obj with an empty player to # determine whether the score faield to # be parsed vs. the user could not be found # logged in (we want to not send a reply to # the osu! client if they're simply not logged # in, so that it will retry once they login). return s # XXX: unused idx 2: online score checksum # Perhaps will use to improve security at some point? # Ensure all ints are safe to cast. if not all(i.isnumeric() for i in data[3:11] + [data[13] + data[15]]): printlog('Invalid parameter passed into submit-modular.', Ansi.LIGHT_RED) return (s.n300, s.n100, s.n50, s.ngeki, s.nkatu, s.nmiss, s.score, s.max_combo) = (int(i) for i in data[3:11]) s.perfect = data[11] == '1' s.grade = data[12] # letter grade s.mods = int(data[13]) s.passed = data[14] == 'True' s.game_mode = int(data[15]) s.play_time = int(time()) # (yyMMddHHmmss) s.client_flags = data[17].count(' ') # TODO: use osu!ver? (osuver\s+) # All data read from submission. # Now we can calculate things based on our data. s.calc_accuracy() if s.bmap: # Ignore SR for now. s.pp = s.calc_diff()[0] s.calc_status() s.rank = s.calc_lb_placement() else: s.pp = 0.0 s.status = SubmissionStatus.SUBMITTED if s.passed \ else SubmissionStatus.FAILED return s
def add(self, c: Channel) -> None: # bool ret success? if c in self.channels: printlog(f'{c} already in channels list!') return printlog(f'Adding {c} to channels list.') self.channels.append(c)
slotID = 0 glob.matches.add(m) # add to global matchlist # This will generate an ID. glob.channels.add( Channel(name=f'#multi_{m.id}', topic=f"MID {m.id}'s multiplayer channel.", read=Privileges.Normal, write=Privileges.Normal, auto_join=False, temp=True)) m.chat = glob.channels.get(f'#multi_{m.id}') if not self.join_channel(m.chat): printlog(f'{self} failed to join {m.chat}.') return False if (lobby := glob.channels.get('#lobby')) in self.channels: self.leave_channel(lobby) slot = m.slots[0 if slotID == -1 else slotID] slot.status = SlotStatus.not_ready slot.player = self self.match = m self.enqueue(packets.matchJoinSuccess(m)) m.enqueue(packets.updateMatch(m)) return True