async def from_md5(cls, md5: str, set_id: Optional[int] = None): """Create a beatmap object from sql or osu!api using it's md5.""" # Check if the map is in the cache. if md5 in glob.cache['beatmap']: # Check if our cached result is within timeout. cached = glob.cache['beatmap'][md5] if (time.time() - cached['timeout']) <= 0: # Cache is within timeout. return cached['map'] # Cache is outdated and should be deleted. del glob.cache['beatmap'][md5] # Check if the map is in the unsubmitted cache. if md5 in glob.cache['unsubmitted']: return # Try to get from sql. if not (m := await cls.from_md5_sql(md5)): # Map not found in sql. # If the user has no API key, we cannot make # any further attempts to serve them the map. if not glob.config.osu_api_key: plog('Fetching beatmap requires osu!api key.', Ansi.LRED) return # Try to get from the osu!api. if not (m := await cls.from_md5_osuapi(md5, set_id)): return
async def remove(self, m: Match) -> None: for idx, i in enumerate(self.matches): if m == i: self.matches[idx] = None break if glob.config.debug: plog(f'{m} removed from matches list.')
async def add(self, c: Channel) -> None: if c in self.channels: plog(f'{c} double-added to channels list?') return self.channels.append(c) if glob.config.debug: plog(f'{c} added to channels list.')
async def add(self, p: Player) -> None: if p in self.players: if glob.config.debug: plog(f'{p} double-added to players list?') return self.players.append(p) if glob.config.debug: plog(f'{p} added to players list.')
async def osuScreenshot(conn: AsyncConnection) -> Optional[bytes]: if 'ss' not in conn.files: plog(f'screenshot req missing file.', Ansi.LRED) return pname = unquote(conn.multipart_args['u']) phash = conn.multipart_args['p'] if not (p := await glob.players.get_login(pname, phash)): return
async def add_friend(self, p) -> None: if p.id in self.friends: plog(f'{self} tried to add {p}, who is already their friend!') return self.friends.add(p.id) await glob.db.execute('INSERT INTO friendships ' 'VALUES (%s, %s)', [self.id, p.id]) plog(f'{self} added {p} to their friends.')
async def remove_friend(self, p) -> None: if not p.id in self.friends: plog(f'{self} tried to remove {p}, who is not their friend!') return self.friends.remove(p.id) await glob.db.execute( 'DELETE FROM friendships ' 'WHERE user1 = %s AND user2 = %s', [self.id, p.id]) plog(f'{self} removed {p} from their friends.')
async def _handler(conn: AsyncConnection): # make sure we have all required args if not all(x in conn.args for x in required_args): plog(f'{uri} request missing args.', Ansi.LRED) return # make sure we have all required multipart args if not all(x in conn.multipart_args for x in required_mpargs): plog(f'{uri} request missing mpargs.', Ansi.LRED) return return await cb(conn)
async def add(self, m: Match) -> None: if m in self.matches: plog(f'{m} double-added to matches list?') return if (free := self.get_free()) is not None: # set the id of the match # to our free slot found. m.id = free self.matches[free] = m if glob.config.debug: plog(f'{m} added to matches list.')
async def read_packet_header(self) -> None: ldata = len(self.data) if ldata < 7: # Packet not even minimal legnth. # End the connection immediately. self.current_packet = None self._offset += ldata plog(f'[ERR] Data misread! (len: {len(self.data)})', Ansi.LRED) return packet_id, self.length = struct.unpack('<HxI', self.data[:7]) self.current_packet = BanchoPacket(packet_id) self._offset += 7 # read first 7 bytes for packetid & length
async def fetch_geoloc(self, ip: str) -> None: """Fetch a player's geolocation data based on their ip.""" async with glob.http.get(f'http://ip-api.com/json/{ip}') as resp: if not resp or resp.status != 200: plog('Failed to get geoloc data: request failed.', Ansi.LRED) return res = await resp.json() if 'status' not in res or res['status'] != 'success': plog(f"Failed to get geoloc data: {res['message']}.", Ansi.LRED) return country = res['countryCode'] self.country = (country_codes[country], country) self.location = (res['lon'], res['lat'])
async def restrict(self) -> None: # TODO: reason self.priv &= ~Privileges.Normal await 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(await packets.userID(-1)) self.enqueue(await 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.')) plog(f'Restricted {self}.', Ansi.CYAN)
async def from_submission(cls, data_enc: str, iv: str, osu_ver: str, phash: str) -> None: """Create a score object from an osu! submission string.""" cbc = RijndaelCbc(f'osu!-scoreburgr---------{osu_ver}', iv=base64.b64decode(iv).decode('latin_1'), padding=ZeroPadding(32), block_size=32) data = cbc.decrypt( base64.b64decode(data_enc).decode('latin_1')).decode().split(':') if len(data) != 18: plog('Received an invalid score submission.', Ansi.LRED) return s = cls() if len(map_md5 := data[0]) != 32: return
async def leave_channel(self, c: Channel) -> None: if self not in c: plog(f'{self} tried to leave {c} but is not in it.') return await c.remove(self) # Remove from channels self.channels.remove(c) # Remove from player self.enqueue(await packets.channelKick(c.name)) # Update channel usercounts for all clients that can see. # For instanced channels, enqueue update to only players # in the instance; for normal channels, enqueue to all. targets = c.players if c.instance else glob.players for p in targets: p.enqueue(await packets.channelInfo(*c.basic_info)) if glob.config.debug: plog(f'{self} left {c}.')
async def stats_from_sql(self, mode: GameMode) -> None: """Fetch the player's stats for a specified gamemode.""" res = await glob.db.fetch( 'SELECT tscore_{0:sql} tscore, rscore_{0:sql} rscore, ' 'pp_{0:sql} pp, plays_{0:sql} plays, acc_{0:sql} acc, ' 'playtime_{0:sql} playtime, maxcombo_{0:sql} max_combo ' 'FROM stats WHERE id = %s'.format(mode), [self.id]) if not res: plog(f"Failed to fetch {self}'s {mode!r} user stats.", Ansi.LRED) return # Calculate rank. res['rank'] = await glob.db.fetch( 'SELECT COUNT(*) AS c FROM stats ' 'LEFT JOIN users USING(id) ' f'WHERE pp_{mode:sql} > %s ' 'AND priv & 1', [res['pp']])['c'] self.stats[mode].update(**res)
async def leave_match(self) -> None: if not self.match: if glob.config.debug: plog(f"{self} tried leaving a match they're not in?") return for s in self.match.slots: if self == s.player: s.reset() break await 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. plog(f'Match {self.match} finished.') await glob.matches.remove(self.match) if lobby := glob.channels['#lobby']: lobby.enqueue(await packets.disposeMatch(self.match.id))
async def join_channel(self, c: Channel) -> bool: if self in c: # User already in the channel. if glob.config.debug: plog(f'{self} was double-added to {c}.') return False if not self.priv & c.read: plog(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(await packets.channelJoin(c.name)) # Update channel usercounts for all clients that can see. # For instanced channels, enqueue update to only players # in the instance; for normal channels, enqueue to all. targets = c.players if c.instance else glob.players for p in targets: p.enqueue(await packets.channelInfo(*c.basic_info)) if glob.config.debug: plog(f'{self} joined {c}.') return True
async def remove_spectator(self, p) -> None: self.spectators.remove(p) p.spectating = None c = glob.channels[f'#spec_{self.id}'] await p.leave_channel(c) if not self.spectators: # Remove host from channel, deleting it. await self.leave_channel(c) else: fellow = await packets.fellowSpectatorLeft(p.id) c_info = await packets.channelInfo(*c.basic_info ) # new playercount self.enqueue(c_info) for s in self.spectators: s.enqueue(fellow + c_info) self.enqueue(await packets.spectatorLeft(p.id)) plog(f'{p} is no longer spectating {self}.')
async def save_to_sql(self) -> None: """Save the the object into sql.""" 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)): plog('Tried to save invalid beatmap to SQL!', Ansi.LRED) return await 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 ])
async def join_match(self, m: Match, passwd: str) -> bool: if self.match: plog(f'{self} tried to join multiple matches?') self.enqueue(await packets.matchJoinFail()) 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.. plog(f'{self} tried to join {m} with incorrect passwd.') self.enqueue(await packets.matchJoinFail()) return False if (slotID := m.get_free()) is None: plog(f'{self} tried to join a full match.') self.enqueue(await packets.matchJoinFail()) return False
async def remove(self, p: Player) -> None: self.players.remove(p) if glob.config.debug: plog(f'{p} removed from players list.')
async def dequeue(self) -> Optional[bytes]: """Get data from the queue to send to the client.""" try: return self._queue.get_nowait() except: plog('Empty queue?')
if 'ss' not in conn.files: plog(f'screenshot req missing file.', Ansi.LRED) return pname = unquote(conn.multipart_args['u']) phash = conn.multipart_args['p'] if not (p := await glob.players.get_login(pname, phash)): return filename = f'{rstring(8)}.png' async with aiofiles.open(f'.data/ss/{filename}', 'wb') as f: await f.write(conn.files['ss']) plog(f'{p} uploaded {filename}.') return filename.encode() @web_handler('osu-getfriends.php', required_args=('u', 'h')) async def osuGetFriends(conn: AsyncConnection) -> Optional[bytes]: pname = unquote(conn.args['u']) phash = conn.args['h'] if not (p := await glob.players.get_login(pname, phash)): return return '\n'.join(str(i) for i in p.friends).encode() @web_handler('osu-getbeatmapinfo.php', required_args=('u', 'h'))
async def add(self, m: Match) -> None: if m in self.matches: plog(f'{m} double-added to matches list?') return if (free := self.get_free()) is not None: # set the id of the match # to our free slot found. m.id = free self.matches[free] = m if glob.config.debug: plog(f'{m} added to matches list.') else: plog(f'Match list is full! Could not add {m}.') async def remove(self, m: Match) -> None: for idx, i in enumerate(self.matches): if m == i: self.matches[idx] = None break if glob.config.debug: plog(f'{m} removed from matches list.') class PlayerList(Sequence): """A class to represent all players online on the gulag. Attributes
async def updateBeatmap(conn: AsyncConnection) -> Optional[bytes]: if not (re := regexes.mapfile.match(unquote(conn.path[10:]))): plog(f'Requested invalid map update {conn.path}.', Ansi.LRED) return
class Score: """A class to represent an osu! score. Attributes ----------- id: `int` The score's unique ID. bmap: Optional[`Beatmap`] A beatmap obj representing the osu map. player: Optional[`Player`] A player obj of the player who submitted the score. pp: `float` The score's performance points. score: `int` The score's osu! score value. max_combo: `int` The maximum combo reached in the score. mods: `Mods` A bitwise value of the osu! mods used in the score. acc: `float` The accuracy of the score. n300: `int` The number of 300s in the score. n100: `int` The number of 100s in the score (150s if taiko). n50: `int` The number of 50s in the score. nmiss: `int` The number of misses in the score. ngeki: `int` The number of gekis in the score. nkatu: `int` The number of katus in the score. grade: `str` The letter grade in the score. rank: `int` The leaderboard placement of the score. passed: `bool` Whether the score completed the map. perfect: `bool` Whether the score is a full-combo. status: `SubmissionStatus` The submission status of the score. mode: `GameMode` The game mode of the score. play_time: `int` A UNIX timestamp of the time of score submission. time_elapsed: `int` The total elapsed time of the play (in milliseconds). client_flags: `int` osu!'s old anticheat flags. prev_best: Optional[`Score`] The previous best score before this play was submitted. NOTE: just because a score has a `prev_best` attribute does mean the score is our best score on the map! the `status` value will always be accurate for any score. """ __slots__ = ('id', 'bmap', 'player', 'pp', 'score', 'max_combo', 'mods', 'acc', 'n300', 'n100', 'n50', 'nmiss', 'ngeki', 'nkatu', 'grade', 'rank', 'passed', 'perfect', 'status', 'mode', 'play_time', 'time_elapsed', 'client_flags', 'prev_best') 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.mode = GameMode.vn_std self.play_time = 0 self.time_elapsed = 0 # osu!'s client 'anticheat'. self.client_flags = ClientFlags.Clean self.prev_best = None @classmethod async def from_sql(cls, scoreid: int, sql_table: str): """Create a score object from sql using it's scoreid.""" # XXX: perhaps in the future this should take a gamemode rather # than just the sql table? just faster on the current setup :P res = await glob.db.fetch( 'SELECT id, map_md5, userid, pp, score, ' 'max_combo, mods, acc, n300, n100, n50, ' 'nmiss, ngeki, nkatu, grade, perfect, ' 'status, mode, play_time, ' 'time_elapsed, client_flags ' f'FROM {sql_table} WHERE id = %s', [scoreid], _dict=False) if not res: return s = cls() s.id = res[0] s.bmap = await Beatmap.from_md5(res[1]) s.player = await glob.players.get_by_id(res[2], sql=True) (s.pp, s.score, s.max_combo, s.mods, s.acc, s.n300, s.n100, s.n50, s.nmiss, s.ngeki, s.nkatu, s.grade, s.perfect, s.status, mode_vn, s.play_time, s.time_elapsed, s.client_flags) = res[3:] # fix some types s.passed = s.status != 0 s.status = SubmissionStatus(s.status) s.mods = Mods(s.mods) s.mode = GameMode.from_params(mode_vn, s.mods) s.client_flags = ClientFlags(s.client_flags) if s.bmap: s.rank = await s.calc_lb_placement() return s @classmethod async def from_submission(cls, data_enc: str, iv: str, osu_ver: str, phash: str) -> None: """Create a score object from an osu! submission string.""" cbc = RijndaelCbc(f'osu!-scoreburgr---------{osu_ver}', iv=base64.b64decode(iv).decode('latin_1'), padding=ZeroPadding(32), block_size=32) data = cbc.decrypt( base64.b64decode(data_enc).decode('latin_1')).decode().split(':') if len(data) != 18: plog('Received an invalid score submission.', Ansi.LRED) return s = cls() if len(map_md5 := data[0]) != 32: return pname = data[1].rstrip() # why does osu! make me rstrip lol # Get the map & player for the score. s.bmap = await Beatmap.from_md5(map_md5) s.player = await glob.players.get_login(pname, phash) 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.isdecimal() for i in data[3:11] + [data[13], data[15]]): plog('Invalid parameter passed into submit-modular.', Ansi.LRED) 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' _grade = data[12] # letter grade s.mods = Mods(int(data[13])) s.passed = data[14] == 'True' s.mode = GameMode.from_params(int(data[15]), s.mods) s.play_time = int(time.time()) # (yyMMddHHmmss) s.client_flags = data[17].count(' ') # TODO: use osu!ver? (osuver\s+) s.grade = _grade if s.passed else 'F' # 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 = (await s.calc_diff())[0] await s.calc_status() s.rank = await s.calc_lb_placement() else: s.pp = 0.0 s.status = SubmissionStatus.SUBMITTED if s.passed \ else SubmissionStatus.FAILED return s
async def osuSubmitModularSelector(conn: AsyncConnection) -> Optional[bytes]: mp_args = conn.multipart_args # Parse our score data into a score obj. s = await Score.from_submission(mp_args['score'], mp_args['iv'], mp_args['osuver'], mp_args['pass']) if not s: plog('Failed to parse a score - invalid format.', Ansi.LRED) 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. # attempt to update their stats if their # gm/gm-affecting-mods change at all. if s.mode != s.player.status.mode: s.player.status.mods = s.mods s.player.status.mode = s.mode glob.players.enqueue(await packets.userStats(s.player)) table = s.mode.sql_table # Check for score duplicates # TODO: might need to improve? res = await glob.db.fetch( f'SELECT 1 FROM {table} WHERE mode = %s ' 'AND map_md5 = %s AND userid = %s AND mods = %s ' 'AND score = %s', [s.mode.as_vanilla, s.bmap.md5, s.player.id, int(s.mods), s.score]) if res: plog(f'{s.player} submitted a duplicate score.', Ansi.LYELLOW) return b'error: no' time_elapsed = mp_args['st' if s.passed else 'ft'] if not time_elapsed.isdecimal(): return s.time_elapsed = int(time_elapsed) if 'i' in conn.files: breakpoint() if not s.player.priv & Privileges.Whitelisted: # Get the PP cap for the current context. pp_cap = autorestrict_pp[s.mode][s.mods & Mods.FLASHLIGHT != 0] if s.pp > pp_cap: plog( f'{s.player} restricted for submitting ' f'{s.pp:.2f} score on gm {s.mode!r}.', Ansi.LRED) await 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. await glob.db.execute( f'UPDATE {table} SET status = 1 ' 'WHERE status = 2 AND map_md5 = %s ' 'AND userid = %s AND mode = %s', [s.bmap.md5, s.player.id, s.mode.as_vanilla]) s.id = await 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, %s)', [ s.bmap.md5, s.score, s.pp, s.acc, s.max_combo, int(s.mods), s.n300, s.n100, s.n50, s.nmiss, s.ngeki, s.nkatu, s.grade, int(s.status), s.mode.as_vanilla, s.play_time, s.time_elapsed, 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 conn.files or conn.files['score'] == b'\r\n': plog(f'{s.player} submitted a score without a replay!', Ansi.LRED) await s.player.restrict() else: # TODO: the replay is currently sent from the osu! # client compressed with LZMA; this compression can # be improved pretty decently by serializing it # manually, so we'll probably do that in the future. async with aiofiles.open(f'.data/osr/{s.id}.osr', 'wb') as f: await f.write(conn.files['score']) """ Update the user's & beatmap's stats """ # get the current stats, and take a # shallow copy for the response charts. stats = s.player.gm_stats prev_stats = copy.copy(stats) # update playtime & plays stats.playtime += s.time_elapsed / 1000 stats.plays += 1 s.bmap.plays += 1 if s.passed: s.bmap.passes += 1 # update max combo if s.max_combo > stats.max_combo: stats.max_combo = s.max_combo # update total score stats.tscore += s.score # if this is our (new) best play on # the map, update our ranked score. if s.status == SubmissionStatus.BEST \ and s.bmap.status in (RankedStatus.Ranked, RankedStatus.Approved): # add our new ranked score. additive = s.score if s.prev_best: # we previously had a score, so remove # it's score from our ranked score. additive -= s.prev_best.score stats.rscore += additive # update user with new stats await glob.db.execute( 'UPDATE stats SET rscore_{0:sql} = %s, ' 'tscore_{0:sql} = %s, playtime_{0:sql} = %s, ' 'plays_{0:sql} = %s, maxcombo_{0:sql} = %s ' 'WHERE id = %s'.format(s.mode), [ stats.rscore, stats.tscore, stats.playtime, stats.plays, stats.max_combo, s.player.id ]) # update beatmap with new stats await glob.db.execute( 'UPDATE maps SET plays = %s, ' 'passes = %s WHERE md5 = %s', [s.bmap.plays, s.bmap.passes, s.bmap.md5]) if s.status == SubmissionStatus.BEST and s.rank == 1 \ and (announce_chan := glob.channels['#announce']): # Announce the user's #1 score. prev_n1 = await glob.db.fetch( 'SELECT u.id, name FROM users u ' f'LEFT JOIN {table} s ON u.id = s.userid ' 'WHERE s.map_md5 = %s AND s.mode = %s ' 'AND s.status = 2 ORDER BY pp DESC LIMIT 1, 1', [s.bmap.md5, s.mode.as_vanilla]) performance = f'{s.pp:.2f}pp' if s.pp else f'{s.score}' ann = [ f'\x01ACTION achieved #1 on {s.bmap.embed} {s.mods!r} with {s.acc:.2f}% for {performance}.' ] if prev_n1: # If there was previously a score on the map, add old #1. ann.append( '(Previously: [https://osu.ppy.sh/u/{id} {name}])'.format( **prev_n1)) await announce_chan.send(s.player, ' '.join(ann), to_client=True)
slotID = 0 await glob.matches.add(m) # add to global matchlist # This will generate an ID. await 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, instance=True)) m.chat = glob.channels[f'#multi_{m.id}'] if not await self.join_channel(m.chat): plog(f'{self} failed to join {m.chat}.') return False if (lobby := glob.channels['#lobby']) in self.channels: await 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(await packets.matchJoinSuccess(m)) m.enqueue(await packets.updateMatch(m)) return True
async def unrestrict(self) -> None: self.priv &= Privileges.Normal await glob.db.execute('UPDATE users SET priv = %s WHERE id = %s', [int(self.priv), self.id]) plog(f'Unrestricted {self}.', Ansi.CYAN)
async def remove(self, c: Channel) -> None: self.channels.remove(c) if glob.config.debug: plog(f'{c} removed from channels list.')