From ebc53bfeceb1aae66c71352a6517e863975a9ba7 Mon Sep 17 00:00:00 2001 From: purr <204539943+purrfume@users.noreply.github.com> Date: Fri, 4 Apr 2025 21:32:15 +0900 Subject: [PATCH] Add files via upload --- migrations/base.sql | 486 +++++++++++++++ migrations/migrations.sql | 477 +++++++++++++++ scripts/install-nginx-config.sh | 16 + scripts/run-tests.sh | 61 ++ scripts/start_server.sh | 10 + scripts/wait-for-it.sh | 182 ++++++ testing/__init__.py | 0 testing/sample_data/__init__.py | 0 testing/sample_data/sample_beatmap_data.py | 157 +++++ testing/sample_data/vivid_osu_file.osu | 139 +++++ tests/__init__.py | 0 tests/conftest.py | 57 ++ tests/integration/__init__.py | 0 tests/integration/domains/osu_test.py | 243 ++++++++ tests/unit/__init__.py | 0 tests/unit/packets_test.py | 669 +++++++++++++++++++++ tools/enable_geoip_module.sh | 52 ++ tools/generate_cf_dns_records.sh | 21 + tools/local_cert_generator.sh | 13 + tools/migrate_logs.py | 85 +++ tools/migrate_v420/go.mod | 8 + tools/migrate_v420/main.go | 344 +++++++++++ tools/proxy.py | 167 +++++ tools/recalc.py | 279 +++++++++ 24 files changed, 3466 insertions(+) create mode 100644 migrations/base.sql create mode 100644 migrations/migrations.sql create mode 100644 scripts/install-nginx-config.sh create mode 100644 scripts/run-tests.sh create mode 100644 scripts/start_server.sh create mode 100644 scripts/wait-for-it.sh create mode 100644 testing/__init__.py create mode 100644 testing/sample_data/__init__.py create mode 100644 testing/sample_data/sample_beatmap_data.py create mode 100644 testing/sample_data/vivid_osu_file.osu create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/domains/osu_test.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/packets_test.py create mode 100644 tools/enable_geoip_module.sh create mode 100644 tools/generate_cf_dns_records.sh create mode 100644 tools/local_cert_generator.sh create mode 100644 tools/migrate_logs.py create mode 100644 tools/migrate_v420/go.mod create mode 100644 tools/migrate_v420/main.go create mode 100644 tools/proxy.py create mode 100644 tools/recalc.py diff --git a/migrations/base.sql b/migrations/base.sql new file mode 100644 index 0000000..79bba0a --- /dev/null +++ b/migrations/base.sql @@ -0,0 +1,486 @@ +create table achievements +( + id int auto_increment + primary key, + file varchar(128) not null, + name varchar(128) charset utf8 not null, + `desc` varchar(256) charset utf8 not null, + cond varchar(64) not null, + constraint achievements_desc_uindex + unique (`desc`), + constraint achievements_file_uindex + unique (file), + constraint achievements_name_uindex + unique (name) +); + +create table channels +( + id int auto_increment + primary key, + name varchar(32) not null, + topic varchar(256) not null, + read_priv int default 1 not null, + write_priv int default 2 not null, + auto_join tinyint(1) default 0 not null, + constraint channels_name_uindex + unique (name) +); +create index channels_auto_join_index + on channels (auto_join); + +create table clans +( + id int auto_increment + primary key, + name varchar(16) charset utf8 not null, + tag varchar(6) charset utf8 not null, + owner int not null, + created_at datetime not null, + constraint clans_name_uindex + unique (name), + constraint clans_owner_uindex + unique (owner), + constraint clans_tag_uindex + unique (tag) +); + +create table client_hashes +( + userid int not null, + osupath char(32) not null, + adapters char(32) not null, + uninstall_id char(32) not null, + disk_serial char(32) not null, + latest_time datetime not null, + occurrences int default 0 not null, + primary key (userid, osupath, adapters, uninstall_id, disk_serial) +); + +create table comments +( + id int auto_increment + primary key, + target_id int not null comment 'replay, map, or set id', + target_type enum('replay', 'map', 'song') not null, + userid int not null, + time int not null, + comment varchar(80) charset utf8 not null, + colour char(6) null comment 'rgb hex string' +); + +create table favourites +( + userid int not null, + setid int not null, + created_at int default 0 not null, + primary key (userid, setid) +); + +create table ingame_logins +( + id int auto_increment + primary key, + userid int not null, + ip varchar(45) not null comment 'maxlen for ipv6', + osu_ver date not null, + osu_stream varchar(11) not null, + datetime datetime not null +); + +create table relationships +( + user1 int not null, + user2 int not null, + type enum('friend', 'block') not null, + primary key (user1, user2) +); + +create table logs +( + id int auto_increment + primary key, + `from` int not null comment 'both from and to are playerids', + `to` int not null, + `action` varchar(32) not null, + msg varchar(2048) charset utf8 null, + time datetime not null on update CURRENT_TIMESTAMP +); + +create table mail +( + id int auto_increment + primary key, + from_id int not null, + to_id int not null, + msg varchar(2048) charset utf8 not null, + time int null, + `read` tinyint(1) default 0 not null +); + +create table maps +( + server enum('osu!', 'private') default 'osu!' not null, + id int not null, + set_id int not null, + status int not null, + md5 char(32) not null, + artist varchar(128) charset utf8 not null, + title varchar(128) charset utf8 not null, + version varchar(128) charset utf8 not null, + creator varchar(19) charset utf8 not null, + filename varchar(256) charset utf8 not null, + last_update datetime not null, + total_length int not null, + max_combo int not null, + frozen tinyint(1) default 0 not null, + plays int default 0 not null, + passes int default 0 not null, + mode tinyint(1) default 0 not null, + bpm float(12,2) default 0.00 not null, + cs float(4,2) default 0.00 not null, + ar float(4,2) default 0.00 not null, + od float(4,2) default 0.00 not null, + hp float(4,2) default 0.00 not null, + diff float(6,3) default 0.000 not null, + primary key (server, id), + constraint maps_id_uindex + unique (id), + constraint maps_md5_uindex + unique (md5) +); +create index maps_set_id_index + on maps (set_id); +create index maps_status_index + on maps (status); +create index maps_filename_index + on maps (filename); +create index maps_plays_index + on maps (plays); +create index maps_mode_index + on maps (mode); +create index maps_frozen_index + on maps (frozen); + +create table mapsets +( + server enum('osu!', 'private') default 'osu!' not null, + id int not null, + last_osuapi_check datetime default CURRENT_TIMESTAMP not null, + primary key (server, id), + constraint nmapsets_id_uindex + unique (id) +); + +create table map_requests +( + id int auto_increment + primary key, + map_id int not null, + player_id int not null, + datetime datetime not null, + active tinyint(1) not null +); + +create table performance_reports +( + scoreid bigint(20) unsigned not null, + mod_mode enum('vanilla', 'relax', 'autopilot') default 'vanilla' not null, + os varchar(64) not null, + fullscreen tinyint(1) not null, + fps_cap varchar(16) not null, + compatibility tinyint(1) not null, + version varchar(16) not null, + start_time int not null, + end_time int not null, + frame_count int not null, + spike_frames int not null, + aim_rate int not null, + completion tinyint(1) not null, + identifier varchar(128) null comment 'really don''t know much about this yet', + average_frametime int not null, + primary key (scoreid, mod_mode) +); + +create table ratings +( + userid int not null, + map_md5 char(32) not null, + rating tinyint(2) not null, + primary key (userid, map_md5) +); + +create table scores +( + id bigint unsigned auto_increment + primary key, + map_md5 char(32) not null, + score int not null, + pp float(7,3) not null, + acc float(6,3) not null, + max_combo int not null, + mods int not null, + n300 int not null, + n100 int not null, + n50 int not null, + nmiss int not null, + ngeki int not null, + nkatu int not null, + grade varchar(2) default 'N' not null, + status tinyint not null, + mode tinyint not null, + play_time datetime not null, + time_elapsed int not null, + client_flags int not null, + userid int not null, + perfect tinyint(1) not null, + online_checksum char(32) not null +); +create index scores_map_md5_index + on scores (map_md5); +create index scores_score_index + on scores (score); +create index scores_pp_index + on scores (pp); +create index scores_mods_index + on scores (mods); +create index scores_status_index + on scores (status); +create index scores_mode_index + on scores (mode); +create index scores_play_time_index + on scores (play_time); +create index scores_userid_index + on scores (userid); +create index scores_online_checksum_index + on scores (online_checksum); +create index scores_fetch_leaderboard_generic_index + on scores (map_md5, status, mode); + +create table startups +( + id int auto_increment + primary key, + ver_major tinyint not null, + ver_minor tinyint not null, + ver_micro tinyint not null, + datetime datetime not null +); + +create table stats +( + id int auto_increment, + mode tinyint(1) not null, + tscore bigint unsigned default 0 not null, + rscore bigint unsigned default 0 not null, + pp int unsigned default 0 not null, + plays int unsigned default 0 not null, + playtime int unsigned default 0 not null, + acc float(6,3) default 0.000 not null, + max_combo int unsigned default 0 not null, + total_hits int unsigned default 0 not null, + replay_views int unsigned default 0 not null, + xh_count int unsigned default 0 not null, + x_count int unsigned default 0 not null, + sh_count int unsigned default 0 not null, + s_count int unsigned default 0 not null, + a_count int unsigned default 0 not null, + primary key (id, mode) +); +create index stats_mode_index + on stats (mode); +create index stats_pp_index + on stats (pp); +create index stats_tscore_index + on stats (tscore); +create index stats_rscore_index + on stats (rscore); + +create table tourney_pool_maps +( + map_id int not null, + pool_id int not null, + mods int not null, + slot tinyint not null, + primary key (map_id, pool_id) +); +create index tourney_pool_maps_mods_slot_index + on tourney_pool_maps (mods, slot); +create index tourney_pool_maps_tourney_pools_id_fk + on tourney_pool_maps (pool_id); + +create table tourney_pools +( + id int auto_increment + primary key, + name varchar(16) not null, + created_at datetime not null, + created_by int not null +); + +create index tourney_pools_users_id_fk + on tourney_pools (created_by); + +create table user_achievements +( + userid int not null, + achid int not null, + primary key (userid, achid) +); +create index user_achievements_achid_index + on user_achievements (achid); +create index user_achievements_userid_index + on user_achievements (userid); + +create table users +( + id int auto_increment + primary key, + name varchar(32) charset utf8 not null, + safe_name varchar(32) charset utf8 not null, + email varchar(254) not null, + priv int default 1 not null, + pw_bcrypt char(60) not null, + country char(2) default 'xx' not null, + silence_end int default 0 not null, + donor_end int default 0 not null, + creation_time int default 0 not null, + latest_activity int default 0 not null, + clan_id int default 0 not null, + clan_priv tinyint(1) default 0 not null, + preferred_mode int default 0 not null, + play_style int default 0 not null, + custom_badge_name varchar(16) charset utf8 null, + custom_badge_icon varchar(64) null, + userpage_content varchar(2048) charset utf8 null, + api_key char(36) null, + constraint users_api_key_uindex + unique (api_key), + constraint users_email_uindex + unique (email), + constraint users_name_uindex + unique (name), + constraint users_safe_name_uindex + unique (safe_name) +); +create index users_priv_index + on users (priv); +create index users_clan_id_index + on users (clan_id); +create index users_clan_priv_index + on users (clan_priv); +create index users_country_index + on users (country); + +insert into users (id, name, safe_name, priv, country, silence_end, email, pw_bcrypt, creation_time, latest_activity) +values (1, 'BanchoBot', 'banchobot', 1, 'ca', 0, 'bot@akatsuki.pw', + '_______________________my_cool_bcrypt_______________________', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()); + +INSERT INTO stats (id, mode) VALUES (1, 0); # vn!std +INSERT INTO stats (id, mode) VALUES (1, 1); # vn!taiko +INSERT INTO stats (id, mode) VALUES (1, 2); # vn!catch +INSERT INTO stats (id, mode) VALUES (1, 3); # vn!mania +INSERT INTO stats (id, mode) VALUES (1, 4); # rx!std +INSERT INTO stats (id, mode) VALUES (1, 5); # rx!taiko +INSERT INTO stats (id, mode) VALUES (1, 6); # rx!catch +INSERT INTO stats (id, mode) VALUES (1, 8); # ap!std + + +# userid 2 is reserved for ppy in osu!, and the +# client will not allow users to pm this id. +# If you want this, simply remove these two lines. +alter table users auto_increment = 3; +alter table stats auto_increment = 3; + +insert into channels (name, topic, read_priv, write_priv, auto_join) +values ('#osu', 'General discussion.', 1, 2, true), + ('#announce', 'Exemplary performance and public announcements.', 1, 24576, true), + ('#lobby', 'Multiplayer lobby discussion room.', 1, 2, false), + ('#supporter', 'General discussion for supporters.', 48, 48, false), + ('#staff', 'General discussion for staff members.', 28672, 28672, true), + ('#admin', 'General discussion for administrators.', 24576, 24576, true), + ('#dev', 'General discussion for developers.', 16384, 16384, true); + +insert into achievements (id, file, name, `desc`, cond) values (1, 'osu-skill-pass-1', 'Rising Star', 'Can''t go forward without the first steps.', '(score.mods & 1 == 0) and 1 <= score.sr < 2 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (2, 'osu-skill-pass-2', 'Constellation Prize', 'Definitely not a consolation prize. Now things start getting hard!', '(score.mods & 1 == 0) and 2 <= score.sr < 3 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (3, 'osu-skill-pass-3', 'Building Confidence', 'Oh, you''ve SO got this.', '(score.mods & 1 == 0) and 3 <= score.sr < 4 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (4, 'osu-skill-pass-4', 'Insanity Approaches', 'You''re not twitching, you''re just ready.', '(score.mods & 1 == 0) and 4 <= score.sr < 5 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (5, 'osu-skill-pass-5', 'These Clarion Skies', 'Everything seems so clear now.', '(score.mods & 1 == 0) and 5 <= score.sr < 6 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (6, 'osu-skill-pass-6', 'Above and Beyond', 'A cut above the rest.', '(score.mods & 1 == 0) and 6 <= score.sr < 7 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (7, 'osu-skill-pass-7', 'Supremacy', 'All marvel before your prowess.', '(score.mods & 1 == 0) and 7 <= score.sr < 8 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (8, 'osu-skill-pass-8', 'Absolution', 'My god, you''re full of stars!', '(score.mods & 1 == 0) and 8 <= score.sr < 9 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (9, 'osu-skill-pass-9', 'Event Horizon', 'No force dares to pull you under.', '(score.mods & 1 == 0) and 9 <= score.sr < 10 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (10, 'osu-skill-pass-10', 'Phantasm', 'Fevered is your passion, extraordinary is your skill.', '(score.mods & 1 == 0) and 10 <= score.sr < 11 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (11, 'osu-skill-fc-1', 'Totality', 'All the notes. Every single one.', 'score.perfect and 1 <= score.sr < 2 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (12, 'osu-skill-fc-2', 'Business As Usual', 'Two to go, please.', 'score.perfect and 2 <= score.sr < 3 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (13, 'osu-skill-fc-3', 'Building Steam', 'Hey, this isn''t so bad.', 'score.perfect and 3 <= score.sr < 4 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (14, 'osu-skill-fc-4', 'Moving Forward', 'Bet you feel good about that.', 'score.perfect and 4 <= score.sr < 5 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (15, 'osu-skill-fc-5', 'Paradigm Shift', 'Surprisingly difficult.', 'score.perfect and 5 <= score.sr < 6 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (16, 'osu-skill-fc-6', 'Anguish Quelled', 'Don''t choke.', 'score.perfect and 6 <= score.sr < 7 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (17, 'osu-skill-fc-7', 'Never Give Up', 'Excellence is its own reward.', 'score.perfect and 7 <= score.sr < 8 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (18, 'osu-skill-fc-8', 'Aberration', 'They said it couldn''t be done. They were wrong.', 'score.perfect and 8 <= score.sr < 9 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (19, 'osu-skill-fc-9', 'Chosen', 'Reign among the Prometheans, where you belong.', 'score.perfect and 9 <= score.sr < 10 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (20, 'osu-skill-fc-10', 'Unfathomable', 'You have no equal.', 'score.perfect and 10 <= score.sr < 11 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (21, 'osu-combo-500', '500 Combo', '500 big ones! You''re moving up in the world!', '500 <= score.max_combo < 750 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (22, 'osu-combo-750', '750 Combo', '750 notes back to back? Woah.', '750 <= score.max_combo < 1000 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (23, 'osu-combo-1000', '1000 Combo', 'A thousand reasons why you rock at this game.', '1000 <= score.max_combo < 2000 and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (24, 'osu-combo-2000', '2000 Combo', 'Nothing can stop you now.', '2000 <= score.max_combo and mode_vn == 0'); +insert into achievements (id, file, name, `desc`, cond) values (25, 'taiko-skill-pass-1', 'My First Don', 'Marching to the beat of your own drum. Literally.', '(score.mods & 1 == 0) and 1 <= score.sr < 2 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (26, 'taiko-skill-pass-2', 'Katsu Katsu Katsu', 'Hora! Izuko!', '(score.mods & 1 == 0) and 2 <= score.sr < 3 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (27, 'taiko-skill-pass-3', 'Not Even Trying', 'Muzukashii? Not even.', '(score.mods & 1 == 0) and 3 <= score.sr < 4 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (28, 'taiko-skill-pass-4', 'Face Your Demons', 'The first trials are now behind you, but are you a match for the Oni?', '(score.mods & 1 == 0) and 4 <= score.sr < 5 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (29, 'taiko-skill-pass-5', 'The Demon Within', 'No rest for the wicked.', '(score.mods & 1 == 0) and 5 <= score.sr < 6 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (30, 'taiko-skill-pass-6', 'Drumbreaker', 'Too strong.', '(score.mods & 1 == 0) and 6 <= score.sr < 7 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (31, 'taiko-skill-pass-7', 'The Godfather', 'You are the Don of Dons.', '(score.mods & 1 == 0) and 7 <= score.sr < 8 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (32, 'taiko-skill-pass-8', 'Rhythm Incarnate', 'Feel the beat. Become the beat.', '(score.mods & 1 == 0) and 8 <= score.sr < 9 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (33, 'taiko-skill-fc-1', 'Keeping Time', 'Don, then katsu. Don, then katsu..', 'score.perfect and 1 <= score.sr < 2 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (34, 'taiko-skill-fc-2', 'To Your Own Beat', 'Straight and steady.', 'score.perfect and 2 <= score.sr < 3 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (35, 'taiko-skill-fc-3', 'Big Drums', 'Bigger scores to match.', 'score.perfect and 3 <= score.sr < 4 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (36, 'taiko-skill-fc-4', 'Adversity Overcome', 'Difficult? Not for you.', 'score.perfect and 4 <= score.sr < 5 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (37, 'taiko-skill-fc-5', 'Demonslayer', 'An Oni felled forevermore.', 'score.perfect and 5 <= score.sr < 6 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (38, 'taiko-skill-fc-6', 'Rhythm''s Call', 'Heralding true skill.', 'score.perfect and 6 <= score.sr < 7 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (39, 'taiko-skill-fc-7', 'Time Everlasting', 'Not a single beat escapes you.', 'score.perfect and 7 <= score.sr < 8 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (40, 'taiko-skill-fc-8', 'The Drummer''s Throne', 'Percussive brilliance befitting royalty alone.', 'score.perfect and 8 <= score.sr < 9 and mode_vn == 1'); +insert into achievements (id, file, name, `desc`, cond) values (41, 'fruits-skill-pass-1', 'A Slice Of Life', 'Hey, this fruit catching business isn''t bad.', '(score.mods & 1 == 0) and 1 <= score.sr < 2 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (42, 'fruits-skill-pass-2', 'Dashing Ever Forward', 'Fast is how you do it.', '(score.mods & 1 == 0) and 2 <= score.sr < 3 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (43, 'fruits-skill-pass-3', 'Zesty Disposition', 'No scurvy for you, not with that much fruit.', '(score.mods & 1 == 0) and 3 <= score.sr < 4 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (44, 'fruits-skill-pass-4', 'Hyperdash ON!', 'Time and distance is no obstacle to you.', '(score.mods & 1 == 0) and 4 <= score.sr < 5 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (45, 'fruits-skill-pass-5', 'It''s Raining Fruit', 'And you can catch them all.', '(score.mods & 1 == 0) and 5 <= score.sr < 6 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (46, 'fruits-skill-pass-6', 'Fruit Ninja', 'Legendary techniques.', '(score.mods & 1 == 0) and 6 <= score.sr < 7 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (47, 'fruits-skill-pass-7', 'Dreamcatcher', 'No fruit, only dreams now.', '(score.mods & 1 == 0) and 7 <= score.sr < 8 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (48, 'fruits-skill-pass-8', 'Lord of the Catch', 'Your kingdom kneels before you.', '(score.mods & 1 == 0) and 8 <= score.sr < 9 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (49, 'fruits-skill-fc-1', 'Sweet And Sour', 'Apples and oranges, literally.', 'score.perfect and 1 <= score.sr < 2 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (50, 'fruits-skill-fc-2', 'Reaching The Core', 'The seeds of future success.', 'score.perfect and 2 <= score.sr < 3 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (51, 'fruits-skill-fc-3', 'Clean Platter', 'Clean only of failure. It is completely full, otherwise.', 'score.perfect and 3 <= score.sr < 4 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (52, 'fruits-skill-fc-4', 'Between The Rain', 'No umbrella needed.', 'score.perfect and 4 <= score.sr < 5 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (53, 'fruits-skill-fc-5', 'Addicted', 'That was an overdose?', 'score.perfect and 5 <= score.sr < 6 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (54, 'fruits-skill-fc-6', 'Quickening', 'A dash above normal limits.', 'score.perfect and 6 <= score.sr < 7 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (55, 'fruits-skill-fc-7', 'Supersonic', 'Faster than is reasonably necessary.', 'score.perfect and 7 <= score.sr < 8 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (56, 'fruits-skill-fc-8', 'Dashing Scarlet', 'Speed beyond mortal reckoning.', 'score.perfect and 8 <= score.sr < 9 and mode_vn == 2'); +insert into achievements (id, file, name, `desc`, cond) values (57, 'mania-skill-pass-1', 'First Steps', 'It isn''t 9-to-5, but 1-to-9. Keys, that is.', '(score.mods & 1 == 0) and 1 <= score.sr < 2 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (58, 'mania-skill-pass-2', 'No Normal Player', 'Not anymore, at least.', '(score.mods & 1 == 0) and 2 <= score.sr < 3 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (59, 'mania-skill-pass-3', 'Impulse Drive', 'Not quite hyperspeed, but getting close.', '(score.mods & 1 == 0) and 3 <= score.sr < 4 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (60, 'mania-skill-pass-4', 'Hyperspeed', 'Woah.', '(score.mods & 1 == 0) and 4 <= score.sr < 5 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (61, 'mania-skill-pass-5', 'Ever Onwards', 'Another challenge is just around the corner.', '(score.mods & 1 == 0) and 5 <= score.sr < 6 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (62, 'mania-skill-pass-6', 'Another Surpassed', 'Is there no limit to your skills?', '(score.mods & 1 == 0) and 6 <= score.sr < 7 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (63, 'mania-skill-pass-7', 'Extra Credit', 'See me after class.', '(score.mods & 1 == 0) and 7 <= score.sr < 8 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (64, 'mania-skill-pass-8', 'Maniac', 'There''s just no stopping you.', '(score.mods & 1 == 0) and 8 <= score.sr < 9 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (65, 'mania-skill-fc-1', 'Keystruck', 'The beginning of a new story', 'score.perfect and 1 <= score.sr < 2 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (66, 'mania-skill-fc-2', 'Keying In', 'Finding your groove.', 'score.perfect and 2 <= score.sr < 3 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (67, 'mania-skill-fc-3', 'Hyperflow', 'You can *feel* the rhythm.', 'score.perfect and 3 <= score.sr < 4 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (68, 'mania-skill-fc-4', 'Breakthrough', 'Many skills mastered, rolled into one.', 'score.perfect and 4 <= score.sr < 5 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (69, 'mania-skill-fc-5', 'Everything Extra', 'Giving your all is giving everything you have.', 'score.perfect and 5 <= score.sr < 6 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (70, 'mania-skill-fc-6', 'Level Breaker', 'Finesse beyond reason', 'score.perfect and 6 <= score.sr < 7 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (71, 'mania-skill-fc-7', 'Step Up', 'A precipice rarely seen.', 'score.perfect and 7 <= score.sr < 8 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (72, 'mania-skill-fc-8', 'Behind The Veil', 'Supernatural!', 'score.perfect and 8 <= score.sr < 9 and mode_vn == 3'); +insert into achievements (id, file, name, `desc`, cond) values (73, 'all-intro-suddendeath', 'Finality', 'High stakes, no regrets.', 'score.mods == 32'); +insert into achievements (id, file, name, `desc`, cond) values (74, 'all-intro-hidden', 'Blindsight', 'I can see just perfectly', 'score.mods & 8'); +insert into achievements (id, file, name, `desc`, cond) values (75, 'all-intro-perfect', 'Perfectionist', 'Accept nothing but the best.', 'score.mods & 16384'); +insert into achievements (id, file, name, `desc`, cond) values (76, 'all-intro-hardrock', 'Rock Around The Clock', "You can\'t stop the rock.", 'score.mods & 16'); +insert into achievements (id, file, name, `desc`, cond) values (77, 'all-intro-doubletime', 'Time And A Half', "Having a right ol\' time. One and a half of them, almost.", 'score.mods & 64'); +insert into achievements (id, file, name, `desc`, cond) values (78, 'all-intro-flashlight', 'Are You Afraid Of The Dark?', "Harder than it looks, probably because it\'s hard to look.", 'score.mods & 1024'); +insert into achievements (id, file, name, `desc`, cond) values (79, 'all-intro-easy', 'Dial It Right Back', 'Sometimes you just want to take it easy.', 'score.mods & 2'); +insert into achievements (id, file, name, `desc`, cond) values (80, 'all-intro-nofail', 'Risk Averse', 'Safety nets are fun!', 'score.mods & 1'); +insert into achievements (id, file, name, `desc`, cond) values (81, 'all-intro-nightcore', 'Sweet Rave Party', 'Founded in the fine tradition of changing things that were just fine as they were.', 'score.mods & 512'); +insert into achievements (id, file, name, `desc`, cond) values (82, 'all-intro-halftime', 'Slowboat', 'You got there. Eventually.', 'score.mods & 256'); +insert into achievements (id, file, name, `desc`, cond) values (83, 'all-intro-spunout', 'Burned Out', 'One cannot always spin to win.', 'score.mods & 4096'); diff --git a/migrations/migrations.sql b/migrations/migrations.sql new file mode 100644 index 0000000..95794b6 --- /dev/null +++ b/migrations/migrations.sql @@ -0,0 +1,477 @@ +# This file contains any sql updates, along with the +# version they are required from. Touching this without +# at least reading utils/updater.py is certainly a bad idea :) + +# v3.0.6 +alter table users change name_safe safe_name varchar(32) not null; +alter table users drop key users_name_safe_uindex; +alter table users add constraint users_safe_name_uindex unique (safe_name); +alter table users change pw_hash pw_bcrypt char(60) not null; +insert into channels (name, topic, read_priv, write_priv, auto_join) values + ('#supporter', 'General discussion for p2w gamers.', 48, 48, false), + ('#staff', 'General discussion for the cool kids.', 28672, 28672, true), + ('#admin', 'General discussion for the cool.', 24576, 24576, true), + ('#dev', 'General discussion for the.', 16384, 16384, true); + +# v3.0.8 +alter table users modify safe_name varchar(32) charset utf8 not null; +alter table users modify name varchar(32) charset utf8 not null; +alter table mail modify msg varchar(2048) charset utf8 not null; +alter table logs modify msg varchar(2048) charset utf8 not null; +drop table if exists comments; +create table comments +( + id int auto_increment + primary key, + target_id int not null comment 'replay, map, or set id', + target_type enum('replay', 'map', 'song') not null, + userid int not null, + time int not null, + comment varchar(80) charset utf8 not null, + colour char(6) null comment 'rgb hex string' +); + +# v3.0.9 +alter table stats modify tscore_vn_std int unsigned default 0 not null; +alter table stats modify tscore_vn_taiko int unsigned default 0 not null; +alter table stats modify tscore_vn_catch int unsigned default 0 not null; +alter table stats modify tscore_vn_mania int unsigned default 0 not null; +alter table stats modify tscore_rx_std int unsigned default 0 not null; +alter table stats modify tscore_rx_taiko int unsigned default 0 not null; +alter table stats modify tscore_rx_catch int unsigned default 0 not null; +alter table stats modify tscore_ap_std int unsigned default 0 not null; +alter table stats modify rscore_vn_std int unsigned default 0 not null; +alter table stats modify rscore_vn_taiko int unsigned default 0 not null; +alter table stats modify rscore_vn_catch int unsigned default 0 not null; +alter table stats modify rscore_vn_mania int unsigned default 0 not null; +alter table stats modify rscore_rx_std int unsigned default 0 not null; +alter table stats modify rscore_rx_taiko int unsigned default 0 not null; +alter table stats modify rscore_rx_catch int unsigned default 0 not null; +alter table stats modify rscore_ap_std int unsigned default 0 not null; +alter table stats modify pp_vn_std smallint unsigned default 0 not null; +alter table stats modify pp_vn_taiko smallint unsigned default 0 not null; +alter table stats modify pp_vn_catch smallint unsigned default 0 not null; +alter table stats modify pp_vn_mania smallint unsigned default 0 not null; +alter table stats modify pp_rx_std smallint unsigned default 0 not null; +alter table stats modify pp_rx_taiko smallint unsigned default 0 not null; +alter table stats modify pp_rx_catch smallint unsigned default 0 not null; +alter table stats modify pp_ap_std smallint unsigned default 0 not null; +alter table stats modify plays_vn_std int unsigned default 0 not null; +alter table stats modify plays_vn_taiko int unsigned default 0 not null; +alter table stats modify plays_vn_catch int unsigned default 0 not null; +alter table stats modify plays_vn_mania int unsigned default 0 not null; +alter table stats modify plays_rx_std int unsigned default 0 not null; +alter table stats modify plays_rx_taiko int unsigned default 0 not null; +alter table stats modify plays_rx_catch int unsigned default 0 not null; +alter table stats modify plays_ap_std int unsigned default 0 not null; +alter table stats modify playtime_vn_std int unsigned default 0 not null; +alter table stats modify playtime_vn_taiko int unsigned default 0 not null; +alter table stats modify playtime_vn_catch int unsigned default 0 not null; +alter table stats modify playtime_vn_mania int unsigned default 0 not null; +alter table stats modify playtime_rx_std int unsigned default 0 not null; +alter table stats modify playtime_rx_taiko int unsigned default 0 not null; +alter table stats modify playtime_rx_catch int unsigned default 0 not null; +alter table stats modify playtime_ap_std int unsigned default 0 not null; +alter table stats modify maxcombo_vn_std int unsigned default 0 not null; +alter table stats modify maxcombo_vn_taiko int unsigned default 0 not null; +alter table stats modify maxcombo_vn_catch int unsigned default 0 not null; +alter table stats modify maxcombo_vn_mania int unsigned default 0 not null; +alter table stats modify maxcombo_rx_std int unsigned default 0 not null; +alter table stats modify maxcombo_rx_taiko int unsigned default 0 not null; +alter table stats modify maxcombo_rx_catch int unsigned default 0 not null; +alter table stats modify maxcombo_ap_std int unsigned default 0 not null; + +# v3.0.10 +update channels set write_priv = 24576 where name = '#announce'; + +# v3.1.0 +alter table maps modify bpm float(12,2) default 0.00 not null; +alter table stats modify tscore_vn_std bigint unsigned default 0 not null; +alter table stats modify tscore_vn_taiko bigint unsigned default 0 not null; +alter table stats modify tscore_vn_catch bigint unsigned default 0 not null; +alter table stats modify tscore_vn_mania bigint unsigned default 0 not null; +alter table stats modify tscore_rx_std bigint unsigned default 0 not null; +alter table stats modify tscore_rx_taiko bigint unsigned default 0 not null; +alter table stats modify tscore_rx_catch bigint unsigned default 0 not null; +alter table stats modify tscore_ap_std bigint unsigned default 0 not null; +alter table stats modify rscore_vn_std bigint unsigned default 0 not null; +alter table stats modify rscore_vn_taiko bigint unsigned default 0 not null; +alter table stats modify rscore_vn_catch bigint unsigned default 0 not null; +alter table stats modify rscore_vn_mania bigint unsigned default 0 not null; +alter table stats modify rscore_rx_std bigint unsigned default 0 not null; +alter table stats modify rscore_rx_taiko bigint unsigned default 0 not null; +alter table stats modify rscore_rx_catch bigint unsigned default 0 not null; +alter table stats modify rscore_ap_std bigint unsigned default 0 not null; +alter table stats modify pp_vn_std int unsigned default 0 not null; +alter table stats modify pp_vn_taiko int unsigned default 0 not null; +alter table stats modify pp_vn_catch int unsigned default 0 not null; +alter table stats modify pp_vn_mania int unsigned default 0 not null; +alter table stats modify pp_rx_std int unsigned default 0 not null; +alter table stats modify pp_rx_taiko int unsigned default 0 not null; +alter table stats modify pp_rx_catch int unsigned default 0 not null; +alter table stats modify pp_ap_std int unsigned default 0 not null; + +# v3.1.2 +create table clans +( + id int auto_increment + primary key, + name varchar(16) not null, + tag varchar(6) not null, + owner int not null, + created_at datetime not null, + constraint clans_name_uindex + unique (name), + constraint clans_owner_uindex + unique (owner), + constraint clans_tag_uindex + unique (tag) +); +alter table users add clan_id int default 0 not null; +alter table users add clan_rank tinyint(1) default 0 not null; +create table achievements +( + id int auto_increment + primary key, + file varchar(128) not null, + name varchar(128) not null, + `desc` varchar(256) not null, + cond varchar(64) not null, + mode tinyint(1) not null, + constraint achievements_desc_uindex + unique (`desc`), + constraint achievements_file_uindex + unique (file), + constraint achievements_name_uindex + unique (name) +); +create table user_achievements +( + userid int not null, + achid int not null, + primary key (userid, achid) +); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (1, 'osu-skill-pass-1', 'Rising Star', 'Can''t go forward without the first steps.', '(score.mods & 259 == 0) and 2 >= score.sr > 1', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (2, 'osu-skill-pass-2', 'Constellation Prize', 'Definitely not a consolation prize. Now things start getting hard!', '(score.mods & 259 == 0) and 3 >= score.sr > 2', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (3, 'osu-skill-pass-3', 'Building Confidence', 'Oh, you''ve SO got this.', '(score.mods & 259 == 0) and 4 >= score.sr > 3', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (4, 'osu-skill-pass-4', 'Insanity Approaches', 'You''re not twitching, you''re just ready.', '(score.mods & 259 == 0) and 5 >= score.sr > 4', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (5, 'osu-skill-pass-5', 'These Clarion Skies', 'Everything seems so clear now.', '(score.mods & 259 == 0) and 6 >= score.sr > 5', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (6, 'osu-skill-pass-6', 'Above and Beyond', 'A cut above the rest.', '(score.mods & 259 == 0) and 7 >= score.sr > 6', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (7, 'osu-skill-pass-7', 'Supremacy', 'All marvel before your prowess.', '(score.mods & 259 == 0) and 8 >= score.sr > 7', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (8, 'osu-skill-pass-8', 'Absolution', 'My god, you''re full of stars!', '(score.mods & 259 == 0) and 9 >= score.sr > 8', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (9, 'osu-skill-pass-9', 'Event Horizon', 'No force dares to pull you under.', '(score.mods & 259 == 0) and 10 >= score.sr > 9', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (10, 'osu-skill-pass-10', 'Phantasm', 'Fevered is your passion, extraordinary is your skill.', '(score.mods & 259 == 0) and 11 >= score.sr > 10', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (11, 'osu-skill-fc-1', 'Totality', 'All the notes. Every single one.', 'score.perfect and 2 >= score.sr > 1', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (12, 'osu-skill-fc-2', 'Business As Usual', 'Two to go, please.', 'score.perfect and 3 >= score.sr > 2', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (13, 'osu-skill-fc-3', 'Building Steam', 'Hey, this isn''t so bad.', 'score.perfect and 4 >= score.sr > 3', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (14, 'osu-skill-fc-4', 'Moving Forward', 'Bet you feel good about that.', 'score.perfect and 5 >= score.sr > 4', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (15, 'osu-skill-fc-5', 'Paradigm Shift', 'Surprisingly difficult.', 'score.perfect and 6 >= score.sr > 5', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (16, 'osu-skill-fc-6', 'Anguish Quelled', 'Don''t choke.', 'score.perfect and 7 >= score.sr > 6', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (17, 'osu-skill-fc-7', 'Never Give Up', 'Excellence is its own reward.', 'score.perfect and 8 >= score.sr > 7', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (18, 'osu-skill-fc-8', 'Aberration', 'They said it couldn''t be done. They were wrong.', 'score.perfect and 9 >= score.sr > 8', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (19, 'osu-skill-fc-9', 'Chosen', 'Reign among the Prometheans, where you belong.', 'score.perfect and 10 >= score.sr > 9', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (20, 'osu-skill-fc-10', 'Unfathomable', 'You have no equal.', 'score.perfect and 11 >= score.sr > 10', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (21, 'osu-combo-500', '500 Combo', '500 big ones! You''re moving up in the world!', '750 >= score.max_combo > 500', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (22, 'osu-combo-750', '750 Combo', '750 notes back to back? Woah.', '1000 >= score.max_combo > 750', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (23, 'osu-combo-1000', '1000 Combo', 'A thousand reasons why you rock at this game.', '2000 >= score.max_combo > 1000', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (24, 'osu-combo-2000', '2000 Combo', 'Nothing can stop you now.', 'score.max_combo >= 2000', 0); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (25, 'taiko-skill-pass-1', 'My First Don', 'Marching to the beat of your own drum. Literally.', '(score.mods & 259 == 0) and 2 >= score.sr > 1', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (26, 'taiko-skill-pass-2', 'Katsu Katsu Katsu', 'Hora! Izuko!', '(score.mods & 259 == 0) and 3 >= score.sr > 2', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (27, 'taiko-skill-pass-3', 'Not Even Trying', 'Muzukashii? Not even.', '(score.mods & 259 == 0) and 4 >= score.sr > 3', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (28, 'taiko-skill-pass-4', 'Face Your Demons', 'The first trials are now behind you, but are you a match for the Oni?', '(score.mods & 259 == 0) and 5 >= score.sr > 4', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (29, 'taiko-skill-pass-5', 'The Demon Within', 'No rest for the wicked.', '(score.mods & 259 == 0) and 6 >= score.sr > 5', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (30, 'taiko-skill-pass-6', 'Drumbreaker', 'Too strong.', '(score.mods & 259 == 0) and 7 >= score.sr > 6', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (31, 'taiko-skill-pass-7', 'The Godfather', 'You are the Don of Dons.', '(score.mods & 259 == 0) and 8 >= score.sr > 7', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (32, 'taiko-skill-pass-8', 'Rhythm Incarnate', 'Feel the beat. Become the beat.', '(score.mods & 259 == 0) and 9 >= score.sr > 8', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (33, 'taiko-skill-fc-1', 'Keeping Time', 'Don, then katsu. Don, then katsu..', 'score.perfect and 2 >= score.sr > 1', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (34, 'taiko-skill-fc-2', 'To Your Own Beat', 'Straight and steady.', 'score.perfect and 3 >= score.sr > 2', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (35, 'taiko-skill-fc-3', 'Big Drums', 'Bigger scores to match.', 'score.perfect and 4 >= score.sr > 3', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (36, 'taiko-skill-fc-4', 'Adversity Overcome', 'Difficult? Not for you.', 'score.perfect and 5 >= score.sr > 4', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (37, 'taiko-skill-fc-5', 'Demonslayer', 'An Oni felled forevermore.', 'score.perfect and 6 >= score.sr > 5', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (38, 'taiko-skill-fc-6', 'Rhythm''s Call', 'Heralding true skill.', 'score.perfect and 7 >= score.sr > 6', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (39, 'taiko-skill-fc-7', 'Time Everlasting', 'Not a single beat escapes you.', 'score.perfect and 8 >= score.sr > 7', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (40, 'taiko-skill-fc-8', 'The Drummer''s Throne', 'Percussive brilliance befitting royalty alone.', 'score.perfect and 9 >= score.sr > 8', 1); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (41, 'fruits-skill-pass-1', 'A Slice Of Life', 'Hey, this fruit catching business isn''t bad.', '(score.mods & 259 == 0) and 2 >= score.sr > 1', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (42, 'fruits-skill-pass-2', 'Dashing Ever Forward', 'Fast is how you do it.', '(score.mods & 259 == 0) and 3 >= score.sr > 2', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (43, 'fruits-skill-pass-3', 'Zesty Disposition', 'No scurvy for you, not with that much fruit.', '(score.mods & 259 == 0) and 4 >= score.sr > 3', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (44, 'fruits-skill-pass-4', 'Hyperdash ON!', 'Time and distance is no obstacle to you.', '(score.mods & 259 == 0) and 5 >= score.sr > 4', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (45, 'fruits-skill-pass-5', 'It''s Raining Fruit', 'And you can catch them all.', '(score.mods & 259 == 0) and 6 >= score.sr > 5', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (46, 'fruits-skill-pass-6', 'Fruit Ninja', 'Legendary techniques.', '(score.mods & 259 == 0) and 7 >= score.sr > 6', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (47, 'fruits-skill-pass-7', 'Dreamcatcher', 'No fruit, only dreams now.', '(score.mods & 259 == 0) and 8 >= score.sr > 7', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (48, 'fruits-skill-pass-8', 'Lord of the Catch', 'Your kingdom kneels before you.', '(score.mods & 259 == 0) and 9 >= score.sr > 8', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (49, 'fruits-skill-fc-1', 'Sweet And Sour', 'Apples and oranges, literally.', 'score.perfect and 2 >= score.sr > 1', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (50, 'fruits-skill-fc-2', 'Reaching The Core', 'The seeds of future success.', 'score.perfect and 3 >= score.sr > 2', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (51, 'fruits-skill-fc-3', 'Clean Platter', 'Clean only of failure. It is completely full, otherwise.', 'score.perfect and 4 >= score.sr > 3', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (52, 'fruits-skill-fc-4', 'Between The Rain', 'No umbrella needed.', 'score.perfect and 5 >= score.sr > 4', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (53, 'fruits-skill-fc-5', 'Addicted', 'That was an overdose?', 'score.perfect and 6 >= score.sr > 5', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (54, 'fruits-skill-fc-6', 'Quickening', 'A dash above normal limits.', 'score.perfect and 7 >= score.sr > 6', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (55, 'fruits-skill-fc-7', 'Supersonic', 'Faster than is reasonably necessary.', 'score.perfect and 8 >= score.sr > 7', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (56, 'fruits-skill-fc-8', 'Dashing Scarlet', 'Speed beyond mortal reckoning.', 'score.perfect and 9 >= score.sr > 8', 2); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (57, 'mania-skill-pass-1', 'First Steps', 'It isn''t 9-to-5, but 1-to-9. Keys, that is.', '(score.mods & 259 == 0) and 2 >= score.sr > 1', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (58, 'mania-skill-pass-2', 'No Normal Player', 'Not anymore, at least.', '(score.mods & 259 == 0) and 3 >= score.sr > 2', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (59, 'mania-skill-pass-3', 'Impulse Drive', 'Not quite hyperspeed, but getting close.', '(score.mods & 259 == 0) and 4 >= score.sr > 3', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (60, 'mania-skill-pass-4', 'Hyperspeed', 'Woah.', '(score.mods & 259 == 0) and 5 >= score.sr > 4', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (61, 'mania-skill-pass-5', 'Ever Onwards', 'Another challenge is just around the corner.', '(score.mods & 259 == 0) and 6 >= score.sr > 5', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (62, 'mania-skill-pass-6', 'Another Surpassed', 'Is there no limit to your skills?', '(score.mods & 259 == 0) and 7 >= score.sr > 6', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (63, 'mania-skill-pass-7', 'Extra Credit', 'See me after class.', '(score.mods & 259 == 0) and 8 >= score.sr > 7', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (64, 'mania-skill-pass-8', 'Maniac', 'There''s just no stopping you.', '(score.mods & 259 == 0) and 9 >= score.sr > 8', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (65, 'mania-skill-fc-1', 'Keystruck', 'The beginning of a new story', 'score.perfect and (score.mods & 259 == 0) and 2 >= score.sr > 1', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (66, 'mania-skill-fc-2', 'Keying In', 'Finding your groove.', 'score.perfect and 3 >= score.sr > 2', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (67, 'mania-skill-fc-3', 'Hyperflow', 'You can *feel* the rhythm.', 'score.perfect and 4 >= score.sr > 3', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (68, 'mania-skill-fc-4', 'Breakthrough', 'Many skills mastered, rolled into one.', 'score.perfect and 5 >= score.sr > 4', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (69, 'mania-skill-fc-5', 'Everything Extra', 'Giving your all is giving everything you have.', 'score.perfect and 6 >= score.sr > 5', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (70, 'mania-skill-fc-6', 'Level Breaker', 'Finesse beyond reason', 'score.perfect and 7 >= score.sr > 6', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (71, 'mania-skill-fc-7', 'Step Up', 'A precipice rarely seen.', 'score.perfect and 8 >= score.sr > 7', 3); +insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (72, 'mania-skill-fc-8', 'Behind The Veil', 'Supernatural!', 'score.perfect and 9 >= score.sr > 8', 3); + +# v3.1.3 +alter table clans modify name varchar(16) charset utf8 not null; +alter table clans modify tag varchar(6) charset utf8 not null; +alter table achievements modify name varchar(128) charset utf8 not null; +alter table achievements modify `desc` varchar(256) charset utf8 not null; +alter table maps modify artist varchar(128) charset utf8 not null; +alter table maps modify title varchar(128) charset utf8 not null; +alter table maps modify version varchar(128) charset utf8 not null; +alter table maps modify creator varchar(19) charset utf8 not null comment 'not 100%% certain on len'; +alter table tourney_pools drop foreign key tourney_pools_users_id_fk; +alter table tourney_pool_maps drop foreign key tourney_pool_maps_tourney_pools_id_fk; +alter table stats drop foreign key stats_users_id_fk; +alter table ratings drop foreign key ratings_maps_md5_fk; +alter table ratings drop foreign key ratings_users_id_fk; +alter table logs modify `from` int not null comment 'both from and to are playerids'; + +# v3.1.9 +alter table scores_rx modify id bigint(20) unsigned auto_increment; +update scores_rx set id = id + (6148914691236517205 - 1); +select @max_rx := MAX(id) + 1 from scores_rx; +set @s = CONCAT('alter table scores_rx auto_increment = ', @max_rx); +prepare stmt from @s; +execute stmt; +deallocate PREPARE stmt; +alter table scores_ap modify id bigint(20) unsigned auto_increment; +update scores_ap set id = id + (12297829382473034410 - 1); +select @max_ap := MAX(id) + 1 from scores_ap; +set @s = CONCAT('alter table scores_ap auto_increment = ', @max_ap); +prepare stmt from @s; +execute stmt; +deallocate PREPARE stmt; +alter table performance_reports modify scoreid bigint(20) unsigned auto_increment; + +# v3.2.0 +create table map_requests +( + id int auto_increment + primary key, + map_id int not null, + player_id int not null, + datetime datetime not null, + active tinyint(1) not null +); + +# v3.2.1 +update scores_rx set id = id - 3074457345618258603; +update scores_ap set id = id - 6148914691236517206; + +# v3.2.2 +alter table maps add max_combo int not null after total_length; +alter table users change clan_rank clan_priv tinyint(1) default 0 not null; + +# v3.2.3 +alter table users add api_key char(36) default NULL null; +create unique index users_api_key_uindex on users (api_key); + +# v3.2.4 +update achievements set file = replace(file, 'ctb', 'fruits') where mode = 2; + +# v3.2.5 +update achievements set cond = '(score.mods & 1 == 0) and 1 <= score.sr < 2' where file in ('osu-skill-pass-1', 'taiko-skill-pass-1', 'fruits-skill-pass-1', 'mania-skill-pass-1'); +update achievements set cond = '(score.mods & 1 == 0) and 2 <= score.sr < 3' where file in ('osu-skill-pass-2', 'taiko-skill-pass-2', 'fruits-skill-pass-2', 'mania-skill-pass-2'); +update achievements set cond = '(score.mods & 1 == 0) and 3 <= score.sr < 4' where file in ('osu-skill-pass-3', 'taiko-skill-pass-3', 'fruits-skill-pass-3', 'mania-skill-pass-3'); +update achievements set cond = '(score.mods & 1 == 0) and 4 <= score.sr < 5' where file in ('osu-skill-pass-4', 'taiko-skill-pass-4', 'fruits-skill-pass-4', 'mania-skill-pass-4'); +update achievements set cond = '(score.mods & 1 == 0) and 5 <= score.sr < 6' where file in ('osu-skill-pass-5', 'taiko-skill-pass-5', 'fruits-skill-pass-5', 'mania-skill-pass-5'); +update achievements set cond = '(score.mods & 1 == 0) and 6 <= score.sr < 7' where file in ('osu-skill-pass-6', 'taiko-skill-pass-6', 'fruits-skill-pass-6', 'mania-skill-pass-6'); +update achievements set cond = '(score.mods & 1 == 0) and 7 <= score.sr < 8' where file in ('osu-skill-pass-7', 'taiko-skill-pass-7', 'fruits-skill-pass-7', 'mania-skill-pass-7'); +update achievements set cond = '(score.mods & 1 == 0) and 8 <= score.sr < 9' where file in ('osu-skill-pass-8', 'taiko-skill-pass-8', 'fruits-skill-pass-8', 'mania-skill-pass-8'); +update achievements set cond = '(score.mods & 1 == 0) and 9 <= score.sr < 10' where file = 'osu-skill-pass-9'; +update achievements set cond = '(score.mods & 1 == 0) and 10 <= score.sr < 11' where file = 'osu-skill-pass-10'; + +update achievements set cond = 'score.perfect and 1 <= score.sr < 2' where file in ('osu-skill-fc-1', 'taiko-skill-fc-1', 'fruits-skill-fc-1', 'mania-skill-fc-1'); +update achievements set cond = 'score.perfect and 2 <= score.sr < 3' where file in ('osu-skill-fc-2', 'taiko-skill-fc-2', 'fruits-skill-fc-2', 'mania-skill-fc-2'); +update achievements set cond = 'score.perfect and 3 <= score.sr < 4' where file in ('osu-skill-fc-3', 'taiko-skill-fc-3', 'fruits-skill-fc-3', 'mania-skill-fc-3'); +update achievements set cond = 'score.perfect and 4 <= score.sr < 5' where file in ('osu-skill-fc-4', 'taiko-skill-fc-4', 'fruits-skill-fc-4', 'mania-skill-fc-4'); +update achievements set cond = 'score.perfect and 5 <= score.sr < 6' where file in ('osu-skill-fc-5', 'taiko-skill-fc-5', 'fruits-skill-fc-5', 'mania-skill-fc-5'); +update achievements set cond = 'score.perfect and 6 <= score.sr < 7' where file in ('osu-skill-fc-6', 'taiko-skill-fc-6', 'fruits-skill-fc-6', 'mania-skill-fc-6'); +update achievements set cond = 'score.perfect and 7 <= score.sr < 8' where file in ('osu-skill-fc-7', 'taiko-skill-fc-7', 'fruits-skill-fc-7', 'mania-skill-fc-7'); +update achievements set cond = 'score.perfect and 8 <= score.sr < 9' where file in ('osu-skill-fc-8', 'taiko-skill-fc-8', 'fruits-skill-fc-8', 'mania-skill-fc-8'); +update achievements set cond = 'score.perfect and 9 <= score.sr < 10' where file = 'osu-skill-fc-9'; +update achievements set cond = 'score.perfect and 10 <= score.sr < 11' where file = 'osu-skill-fc-10'; + +update achievements set cond = '500 <= score.max_combo < 750' where file = 'osu-combo-500'; +update achievements set cond = '750 <= score.max_combo < 1000' where file = 'osu-combo-750'; +update achievements set cond = '1000 <= score.max_combo < 2000' where file = 'osu-combo-1000'; +update achievements set cond = '2000 <= score.max_combo' where file = 'osu-combo-2000'; + +# v3.2.6 +alter table stats change maxcombo_vn_std max_combo_vn_std int unsigned default 0 not null; +alter table stats change maxcombo_vn_taiko max_combo_vn_taiko int unsigned default 0 not null; +alter table stats change maxcombo_vn_catch max_combo_vn_catch int unsigned default 0 not null; +alter table stats change maxcombo_vn_mania max_combo_vn_mania int unsigned default 0 not null; +alter table stats change maxcombo_rx_std max_combo_rx_std int unsigned default 0 not null; +alter table stats change maxcombo_rx_taiko max_combo_rx_taiko int unsigned default 0 not null; +alter table stats change maxcombo_rx_catch max_combo_rx_catch int unsigned default 0 not null; +alter table stats change maxcombo_ap_std max_combo_ap_std int unsigned default 0 not null; + +# v3.2.7 +drop table if exists user_hashes; + +# v3.3.0 +rename table friendships to relationships; +alter table relationships add type enum('friend', 'block') not null; + +# v3.3.1 +create table ingame_logins +( + id int auto_increment + primary key, + userid int not null, + ip varchar(45) not null comment 'maxlen for ipv6', + osu_ver date not null, + osu_stream varchar(11) not null, + datetime datetime not null +); + +# v3.3.7 +update achievements set cond = CONCAT(cond, ' and mode_vn == 0') where mode = 0; +update achievements set cond = CONCAT(cond, ' and mode_vn == 1') where mode = 1; +update achievements set cond = CONCAT(cond, ' and mode_vn == 2') where mode = 2; +update achievements set cond = CONCAT(cond, ' and mode_vn == 3') where mode = 3; +alter table achievements drop column mode; + +# v3.3.8 +create table mapsets +( + server enum('osu!', 'gulag') default 'osu!' not null, + id int not null, + last_osuapi_check datetime default CURRENT_TIMESTAMP not null, + primary key (server, id), + constraint nmapsets_id_uindex + unique (id) +); + +# v3.4.1 +alter table maps add filename varchar(256) charset utf8 not null after creator; + +# v3.5.2 +alter table scores_vn add online_checksum char(32) not null; +alter table scores_rx add online_checksum char(32) not null; +alter table scores_ap add online_checksum char(32) not null; + +# v4.1.1 +alter table stats add total_hits int unsigned default 0 not null after max_combo; + +# v4.1.2 +alter table stats add replay_views int unsigned default 0 not null after total_hits; + +# v4.1.3 +alter table users add preferred_mode int default 0 not null after latest_activity; +alter table users add play_style int default 0 not null after preferred_mode; +alter table users add custom_badge_name varchar(16) charset utf8 null after play_style; +alter table users add custom_badge_icon varchar(64) null after custom_badge_name; +alter table users add userpage_content varchar(2048) charset utf8 null after custom_badge_icon; + +# v4.2.0 +# please refer to tools/migrate_v420 for further v4.2.0 migrations +update stats set mode = 8 where mode = 7; + +# v4.3.1 +alter table maps change server server enum('osu!', 'private') default 'osu!' not null; +alter table mapsets change server server enum('osu!', 'private') default 'osu!' not null; + +# v4.4.2 +insert into achievements (id, file, name, `desc`, cond) values (73, 'all-intro-suddendeath', 'Finality', 'High stakes, no regrets.', 'score.mods == 32'); +insert into achievements (id, file, name, `desc`, cond) values (74, 'all-intro-hidden', 'Blindsight', 'I can see just perfectly', 'score.mods & 8'); +insert into achievements (id, file, name, `desc`, cond) values (75, 'all-intro-perfect', 'Perfectionist', 'Accept nothing but the best.', 'score.mods & 16384'); +insert into achievements (id, file, name, `desc`, cond) values (76, 'all-intro-hardrock', 'Rock Around The Clock', "You can\'t stop the rock.", 'score.mods & 16'); +insert into achievements (id, file, name, `desc`, cond) values (77, 'all-intro-doubletime', 'Time And A Half', "Having a right ol\' time. One and a half of them, almost.", 'score.mods & 64'); +insert into achievements (id, file, name, `desc`, cond) values (78, 'all-intro-flashlight', 'Are You Afraid Of The Dark?', "Harder than it looks, probably because it\'s hard to look.", 'score.mods & 1024'); +insert into achievements (id, file, name, `desc`, cond) values (79, 'all-intro-easy', 'Dial It Right Back', 'Sometimes you just want to take it easy.', 'score.mods & 2'); +insert into achievements (id, file, name, `desc`, cond) values (80, 'all-intro-nofail', 'Risk Averse', 'Safety nets are fun!', 'score.mods & 1'); +insert into achievements (id, file, name, `desc`, cond) values (81, 'all-intro-nightcore', 'Sweet Rave Party', 'Founded in the fine tradition of changing things that were just fine as they were.', 'score.mods & 512'); +insert into achievements (id, file, name, `desc`, cond) values (82, 'all-intro-halftime', 'Slowboat', 'You got there. Eventually.', 'score.mods & 256'); +insert into achievements (id, file, name, `desc`, cond) values (83, 'all-intro-spunout', 'Burned Out', 'One cannot always spin to win.', 'score.mods & 4096'); + +# v4.4.3 +alter table favourites add created_at int default 0 not null; + +# v4.7.1 +lock tables maps write; +alter table maps drop primary key; +alter table maps add primary key (id); +alter table maps modify column server enum('osu!', 'private') not null default 'osu!' after id; +unlock tables; + +# v5.0.1 +create index channels_auto_join_index + on channels (auto_join); + +create index maps_set_id_index + on maps (set_id); +create index maps_status_index + on maps (status); +create index maps_filename_index + on maps (filename); +create index maps_plays_index + on maps (plays); +create index maps_mode_index + on maps (mode); +create index maps_frozen_index + on maps (frozen); + +create index scores_map_md5_index + on scores (map_md5); +create index scores_score_index + on scores (score); +create index scores_pp_index + on scores (pp); +create index scores_mods_index + on scores (mods); +create index scores_status_index + on scores (status); +create index scores_mode_index + on scores (mode); +create index scores_play_time_index + on scores (play_time); +create index scores_userid_index + on scores (userid); +create index scores_online_checksum_index + on scores (online_checksum); + +create index stats_mode_index + on stats (mode); +create index stats_pp_index + on stats (pp); +create index stats_tscore_index + on stats (tscore); +create index stats_rscore_index + on stats (rscore); + +create index tourney_pool_maps_mods_slot_index + on tourney_pool_maps (mods, slot); + +create index user_achievements_achid_index + on user_achievements (achid); +create index user_achievements_userid_index + on user_achievements (userid); + +create index users_priv_index + on users (priv); +create index users_clan_id_index + on users (clan_id); +create index users_clan_priv_index + on users (clan_priv); +create index users_country_index + on users (country); + +# v5.2.2 +create index scores_fetch_leaderboard_generic_index + on scores (map_md5, status, mode); diff --git a/scripts/install-nginx-config.sh b/scripts/install-nginx-config.sh new file mode 100644 index 0000000..4bee811 --- /dev/null +++ b/scripts/install-nginx-config.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "Sourcing environment from .env file" +set -a +source .env +set +a + +echo "Installing nginx configuration" +envsubst '${APP_PORT},${DOMAIN},${SSL_CERT_PATH},${SSL_KEY_PATH},${DATA_DIRECTORY}' < ext/nginx.conf.example > /etc/nginx/sites-available/bancho.conf +ln -f -s /etc/nginx/sites-available/bancho.conf /etc/nginx/sites-enabled/bancho.conf + +echo "Restarting nginx" +nginx -s reload + +echo "Nginx configuration installed" diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh new file mode 100644 index 0000000..849760c --- /dev/null +++ b/scripts/run-tests.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -eo pipefail + +export DB_HOST=mysql-test +export REDIS_HOST=redis-test + +initDB() { + echo "Initializing database..." + if [[ "$DB_USE_SSL" == "true" ]]; then + EXTRA_PARAMS="--ssl" + else + EXTRA_PARAMS="" + fi + + DB_QUERIES=( + "DROP DATABASE IF EXISTS $DB_NAME" + "CREATE DATABASE $DB_NAME" + ) + + for query in "${DB_QUERIES[@]}" + do + mysql \ + --host=$DB_HOST \ + --port=$DB_PORT \ + --user=root \ + --database=mysql \ + --password=$DB_PASS \ + $EXTRA_PARAMS \ + --execute="$query" + done + + redis-cli -h $REDIS_HOST -p $REDIS_PORT FLUSHALL +} + +execDBStatement() { + if [[ "$DB_USE_SSL" == "true" ]]; then + EXTRA_PARAMS="--ssl" + else + EXTRA_PARAMS="" + fi + + mysql \ + --host=$DB_HOST \ + --port=$DB_PORT \ + --user=root \ + --database=$DB_NAME \ + --password=$DB_PASS \ + $EXTRA_PARAMS \ + --execute="$1" +} + + +initDB + +execDBStatement "source /srv/root/migrations/base.sql" + +# Run tests +echo "Running tests..." +coverage run -m pytest -vv -s tests/ +coverage report --show-missing --fail-under=45 +coverage html diff --git a/scripts/start_server.sh b/scripts/start_server.sh new file mode 100644 index 0000000..1f20c43 --- /dev/null +++ b/scripts/start_server.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euxo pipefail + +# Checking MySQL TCP connection +scripts/wait-for-it.sh --timeout=60 $DB_HOST:$DB_PORT + +# Checking Redis connection +scripts/wait-for-it.sh --timeout=60 $REDIS_HOST:$REDIS_PORT + +python main.py diff --git a/scripts/wait-for-it.sh b/scripts/wait-for-it.sh new file mode 100644 index 0000000..d990e0d --- /dev/null +++ b/scripts/wait-for-it.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# Use this script to test if a given TCP host/port are available + +WAITFORIT_cmdname=${0##*/} + +echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } + +usage() +{ + cat << USAGE >&2 +Usage: + $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] + -h HOST | --host=HOST Host or IP under test + -p PORT | --port=PORT TCP port under test + Alternatively, you specify the host and port as host:port + -s | --strict Only execute subcommand if the test succeeds + -q | --quiet Don't output any status messages + -t TIMEOUT | --timeout=TIMEOUT + Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit 1 +} + +wait_for() +{ + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + else + echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" + fi + WAITFORIT_start_ts=$(date +%s) + while : + do + if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then + nc -z $WAITFORIT_HOST $WAITFORIT_PORT + WAITFORIT_result=$? + else + (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 + WAITFORIT_result=$? + fi + if [[ $WAITFORIT_result -eq 0 ]]; then + WAITFORIT_end_ts=$(date +%s) + echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" + break + fi + sleep 1 + done + return $WAITFORIT_result +} + +wait_for_wrapper() +{ + # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 + if [[ $WAITFORIT_QUIET -eq 1 ]]; then + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + else + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + fi + WAITFORIT_PID=$! + trap "kill -INT -$WAITFORIT_PID" INT + wait $WAITFORIT_PID + WAITFORIT_RESULT=$? + if [[ $WAITFORIT_RESULT -ne 0 ]]; then + echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + fi + return $WAITFORIT_RESULT +} + +# process arguments +while [[ $# -gt 0 ]] +do + case "$1" in + *:* ) + WAITFORIT_hostport=(${1//:/ }) + WAITFORIT_HOST=${WAITFORIT_hostport[0]} + WAITFORIT_PORT=${WAITFORIT_hostport[1]} + shift 1 + ;; + --child) + WAITFORIT_CHILD=1 + shift 1 + ;; + -q | --quiet) + WAITFORIT_QUIET=1 + shift 1 + ;; + -s | --strict) + WAITFORIT_STRICT=1 + shift 1 + ;; + -h) + WAITFORIT_HOST="$2" + if [[ $WAITFORIT_HOST == "" ]]; then break; fi + shift 2 + ;; + --host=*) + WAITFORIT_HOST="${1#*=}" + shift 1 + ;; + -p) + WAITFORIT_PORT="$2" + if [[ $WAITFORIT_PORT == "" ]]; then break; fi + shift 2 + ;; + --port=*) + WAITFORIT_PORT="${1#*=}" + shift 1 + ;; + -t) + WAITFORIT_TIMEOUT="$2" + if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi + shift 2 + ;; + --timeout=*) + WAITFORIT_TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + WAITFORIT_CLI=("$@") + break + ;; + --help) + usage + ;; + *) + echoerr "Unknown argument: $1" + usage + ;; + esac +done + +if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then + echoerr "Error: you need to provide a host and port to test." + usage +fi + +WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} +WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} +WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} +WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} + +# Check to see if timeout is from busybox? +WAITFORIT_TIMEOUT_PATH=$(type -p timeout) +WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) + +WAITFORIT_BUSYTIMEFLAG="" +if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then + WAITFORIT_ISBUSY=1 + # Check if busybox timeout uses -t flag + # (recent Alpine versions don't support -t anymore) + if timeout &>/dev/stdout | grep -q -e '-t '; then + WAITFORIT_BUSYTIMEFLAG="-t" + fi +else + WAITFORIT_ISBUSY=0 +fi + +if [[ $WAITFORIT_CHILD -gt 0 ]]; then + wait_for + WAITFORIT_RESULT=$? + exit $WAITFORIT_RESULT +else + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + wait_for_wrapper + WAITFORIT_RESULT=$? + else + wait_for + WAITFORIT_RESULT=$? + fi +fi + +if [[ $WAITFORIT_CLI != "" ]]; then + if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then + echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" + exit $WAITFORIT_RESULT + fi + exec "${WAITFORIT_CLI[@]}" +else + exit $WAITFORIT_RESULT +fi diff --git a/testing/__init__.py b/testing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/testing/sample_data/__init__.py b/testing/sample_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/testing/sample_data/sample_beatmap_data.py b/testing/sample_data/sample_beatmap_data.py new file mode 100644 index 0000000..5ffab72 --- /dev/null +++ b/testing/sample_data/sample_beatmap_data.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from typing import Any + + +def vivid_osu_file_sample_response() -> bytes: + with open("testing/sample_data/vivid_osu_file.osu", "rb") as f: + return f.read() + + +def vivid_getbeatmaps_sample_response() -> list[dict[str, Any]]: + return [ + { + "beatmapset_id": 141, + "beatmap_id": 313, + "approved": 1, + "total_length": 188, + "hit_length": 159, + "version": "Easy", + "file_md5": "72bdc73c3f17013c5d0ba8443c9045b2", + "diff_size": 3, + "diff_overall": 3, + "diff_approach": 3, + "diff_drain": 3, + "mode": 0, + "approved_date": "2007-11-01T06:09:15Z", + "last_update": "2014-05-18T08:16:40Z", + "artist": "FAIRY FORE", + "artist_unicode": "FAIRY FORE", + "title": "Vivid", + "title_unicode": "Vivid", + "creator": "Hitoshirenu Shourai", + "creator_id": 602, + "bpm": 168, + "source": "", + "tags": "", + "genre_id": 0, + "language_id": 0, + "favourite_count": 336, + "storyboard": 0, + "video": 0, + "download_unavailable": 0, + "playcount": 53584, + "passcount": 15160, + "packs": ["S5", "T79"], + "max_combo": 294, + "difficultyrating": 1.54, + }, + { + "beatmapset_id": 141, + "beatmap_id": 314, + "approved": 1, + "total_length": 185, + "hit_length": 182, + "version": "Hard", + "file_md5": "dd1749b4422a1dab9a2945a6bfccc5ef", + "diff_size": 5, + "diff_overall": 5, + "diff_approach": 5, + "diff_drain": 5, + "mode": 0, + "approved_date": "2007-11-01T06:09:15Z", + "last_update": "2014-05-18T16:45:16Z", + "artist": "FAIRY FORE", + "artist_unicode": "FAIRY FORE", + "title": "Vivid", + "title_unicode": "Vivid", + "creator": "Hitoshirenu Shourai", + "creator_id": 602, + "bpm": 168, + "source": "", + "tags": "", + "genre_id": 0, + "language_id": 0, + "favourite_count": 336, + "storyboard": 0, + "video": 0, + "download_unavailable": 0, + "playcount": 79331, + "passcount": 8523, + "packs": ["S5", "T79"], + "max_combo": 723, + "difficultyrating": 3.75, + }, + { + "beatmapset_id": 141, + "beatmap_id": 315, + "approved": 1, + "total_length": 14, + "hit_length": 14, + "version": "Insane", + "file_md5": "1cf5b2c2edfafd055536d2cefcb89c0e", + "diff_size": 6, + "diff_overall": 7, + "diff_approach": 7, + "diff_drain": 2, + "mode": 0, + "approved_date": "2007-11-01T06:09:15Z", + "last_update": "2014-05-18T15:41:48Z", + "artist": "FAIRY FORE", + "artist_unicode": "FAIRY FORE", + "title": "Vivid", + "title_unicode": "Vivid", + "creator": "Hitoshirenu Shourai", + "creator_id": 602, + "bpm": 168, + "source": "", + "tags": "", + "genre_id": 0, + "language_id": 0, + "favourite_count": 336, + "storyboard": 0, + "video": 0, + "download_unavailable": 0, + "playcount": 1632137, + "passcount": 987366, + "packs": ["S5", "T79"], + "max_combo": 114, + "difficultyrating": 5.23, + }, + { + "beatmapset_id": 141, + "beatmap_id": 316, + "approved": 1, + "total_length": 188, + "hit_length": 159, + "version": "Normal", + "file_md5": "0236aeb3bb5f110d7eacf4045092efac", + "diff_size": 5, + "diff_overall": 5, + "diff_approach": 5, + "diff_drain": 5, + "mode": 0, + "approved_date": "2007-11-01T06:09:15Z", + "last_update": "2014-05-18T16:26:49Z", + "artist": "FAIRY FORE", + "artist_unicode": "FAIRY FORE", + "title": "Vivid", + "title_unicode": "Vivid", + "creator": "Hitoshirenu Shourai", + "creator_id": 602, + "bpm": 168, + "source": "", + "tags": "", + "genre_id": 0, + "language_id": 0, + "favourite_count": 336, + "storyboard": 0, + "video": 0, + "download_unavailable": 0, + "playcount": 49671, + "passcount": 13422, + "packs": ["S5", "T79"], + "max_combo": 478, + "difficultyrating": 2.28, + }, + ] diff --git a/testing/sample_data/vivid_osu_file.osu b/testing/sample_data/vivid_osu_file.osu new file mode 100644 index 0000000..ac23651 --- /dev/null +++ b/testing/sample_data/vivid_osu_file.osu @@ -0,0 +1,139 @@ +osu file format v3 + +[General] +AudioFilename: 01_-_vivid.mp3 +AudioLeadIn: 2000 +AudioHash: f9e55f878282eba37a8da909fcc40994 +PreviewTime: -1 +SampleSet: Normal +EditorBookmarks: 3317,5460,8942,14389,16085,17514,19300,22692,25907,28942,31800,34300,37335 + +[Metadata] +Title:Vivid +Artist:FAIRY FORE +Creator:Hitoshirenu Shourai +Version:Insane + +[Difficulty] +HPDrainRate:2 +CircleSize:6 +OverallDifficulty:7 +SliderMultiplier: 1 +SliderTickRate: 2 + +[Events] +0,0,"Chocobos.jpg" +3,0,0,0,255 + +[TimingPoints] +520,357.142857142857 + +[HitObjects] +256,192,520,1,0, +224,192,609,1,0, +192,192,698,1,0, +208,160,787,1,0, +224,144,877,5,0, +256,144,966,1,0, +288,144,1055,1,0, +320,160,1144,1,0, +320,192,1234,5,0, +304,220,1323,1,0, +288,240,1412,1,0, +256,240,1502,1,0, +224,240,1591,5,0, +192,240,1680,1,0, +160,240,1769,1,0, +128,224,1859,1,0, +128,192,1948,5,0, +144,168,2037,1,0, +160,144,2127,1,0, +177,120,2216,1,0, +192,96,2305,5,0, +224,96,2394,1,0, +256,96,2484,1,0, +288,96,2573,1,0, +320,96,2662,5,0, +339,122,2752,1,0, +352,144,2841,1,0, +373,173,2930,1,0, +384,192,3019,5,0, +373,220,3109,1,0, +360,248,3198,1,0, +337,272,3287,1,0, +320,288,3377,5,0, +288,288,3466,1,0, +256,288,3555,1,0, +224,288,3644,1,0, +192,288,3734,5,0, +136,288,3912,1,0, +96,256,4091,5,0, +72,216,4180,1,0, +72,176,4269,1,0, +80,144,4359,1,0, +96,120,4448,5,0, +128,88,4627,1,0, +160,48,4805,5,0, +192,48,4895,1,0, +224,48,4984,1,0, +256,48,5073,1,0, +288,48,5162,5,0, +352,48,5341,1,0, +416,128,5698,6,0,B|416:128|416:192,1,49 +416,272,6234,5,0, +416,312,6323,1,0, +416,352,6412,1,0, +376,352,6502,1,0, +336,352,6591,5,0, +336,272,6770,1,0, +256,272,6948,5,0, +256,312,7037,1,0, +256,352,7127,1,0, +216,352,7216,1,0, +176,352,7305,5,0, +176,272,7484,1,0, +176,192,7662,5,0, +136,192,7752,1,0, +96,192,7841,1,0, +64,160,7930,1,0, +32,128,8020,5,0, +32,48,8198,1,0, +112,112,8555,6,0,B|112:112|112:48,1,49 +224,32,9091,5,0, +224,64,9180,1,0, +224,96,9270,1,0, +256,96,9359,1,0, +288,96,9448,5,0, +288,160,9627,1,0, +224,160,9805,5,0, +224,192,9895,1,0, +224,224,9984,1,0, +256,224,10073,1,0, +288,224,10162,5,0, +288,288,10341,1,0, +224,288,10520,5,0, +224,320,10609,1,0, +224,352,10698,1,0, +256,352,10787,1,0, +288,352,10877,5,0, +352,352,11055,1,0, +352,288,11234,5,0, +352,256,11323,1,0, +352,224,11412,1,0, +384,224,11502,1,0, +416,224,11591,5,0, +416,288,11769,1,0, +480,256,11948,5,0, +480,224,12037,1,0, +480,192,12127,1,0, +480,160,12216,1,0, +416,160,12305,5,0, +352,160,12484,1,0, +352,96,12662,5,0, +384,96,12752,1,0, +416,96,12841,1,0, +448,96,12930,1,0, +416,32,13019,5,0, +352,32,13198,1,0, +288,32,13377,6,0,B|288:32|176:32,1,98 +192,96,13912,2,0,B|192:96|304:96,1,98 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..371ee10 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator + +import httpx +import pytest +import respx +from asgi_lifespan import LifespanManager +from asgi_lifespan._types import ASGIApp +from fastapi import status + +from app.api.init_api import asgi_app + +# TODO: fixtures for postgres database connection(s) for itests + +# TODO: I believe if we switch to fastapi.TestClient, we +# will no longer need to use the asgi-lifespan dependency. +# (We do not need an asynchronous http client for our tests) + + +@pytest.fixture(autouse=True) +def mock_out_initial_image_downloads(respx_mock: respx.MockRouter) -> None: + # mock out default avatar download + respx_mock.get("https://i.cmyui.xyz/U24XBZw-4wjVME-JaEz3.png").mock( + return_value=httpx.Response( + status_code=status.HTTP_200_OK, + headers={"Content-Type": "image/png"}, + content=b"i am a png file", + ), + ) + # mock out achievement image downloads + respx_mock.get(url__regex=r"https://assets.ppy.sh/medals/client/.+").mock( + return_value=httpx.Response( + status_code=status.HTTP_200_OK, + headers={"Content-Type": "image/png"}, + content=b"i am a png file", + ), + ) + + +@pytest.fixture +async def app() -> AsyncIterator[ASGIApp]: + async with LifespanManager( + asgi_app, + startup_timeout=None, + shutdown_timeout=None, + ) as manager: + yield manager.app + + +@pytest.fixture +async def http_client(app: ASGIApp) -> AsyncIterator[httpx.AsyncClient]: + async with httpx.AsyncClient(app=app, base_url="http://test") as client: + yield client + + +pytest_plugins = [] diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/domains/osu_test.py b/tests/integration/domains/osu_test.py new file mode 100644 index 0000000..ee09269 --- /dev/null +++ b/tests/integration/domains/osu_test.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import hashlib +import secrets +from datetime import datetime +from uuid import UUID + +import httpx +import respx +from fastapi import status +from httpx import AsyncClient + +from app import encryption +from testing.sample_data import sample_beatmap_data + + +async def test_score_submission( + http_client: AsyncClient, + respx_mock: respx.MockRouter, +) -> None: + # ARRANGE + + username = f"test-{secrets.token_hex(4)}" + email_address = f"cmyui-{secrets.token_hex(4)}@akatsuki.pw" + passwd_plaintext = "myPassword321$" + passwd_md5 = hashlib.md5(passwd_plaintext.encode()).hexdigest() + + respx_mock.get("http://ip-api.com/line/").mock( + return_value=httpx.Response( + status_code=status.HTTP_200_OK, + content=b"\n".join((b"success", b"CA", b"43.6485", b"-79.4054")), + ), + ) + response = await http_client.post( + url="/users", + headers={ + "Host": "osu.cmyui.xyz", + "X-Forwarded-For": "127.0.0.1", + "X-Real-IP": "127.0.0.1", + }, + data={ + "user[username]": username, + "user[password]": passwd_plaintext, + "user[user_email]": email_address, + "check": "0", + }, + ) + assert response.status_code == status.HTTP_200_OK + + osu_version = "20230814" + utc_offset = -5 + display_city = 1 + pm_private = 1 + + osu_path_md5 = hashlib.md5(b"lol123").hexdigest() + adapters_str = ".".join(("1", "2", "3")) + "." + adapters_md5 = hashlib.md5(b"lol123").hexdigest() + uninstall_md5 = hashlib.md5(b"lol123").hexdigest() # or uniqueid 1 + disk_signature_md5 = hashlib.md5(b"lol123").hexdigest() # or uniqueid 2 + + client_hashes = ( + ":".join( + ( + osu_path_md5, + adapters_str, + adapters_md5, + # double md5 unique ids on login; single time on score submission + hashlib.md5(uninstall_md5.encode()).hexdigest(), + hashlib.md5(disk_signature_md5.encode()).hexdigest(), + ), + ) + + ":" + ) + + login_data = ( + "\n".join( + ( + username, + passwd_md5, + "|".join( + ( + "b" + osu_version, + str(utc_offset), + str(display_city), + client_hashes, + str(pm_private), + ), + ), + ), + ).encode() + + b"\n" + ) + + response = await http_client.post( + url="/", + headers={ + "Host": "c.cmyui.xyz", + "User-Agent": "osu!", + "CF-Connecting-IP": "127.0.0.1", + }, + content=login_data, + ) + assert response.status_code == status.HTTP_200_OK + + # cho token must be valid uuid + try: + UUID(response.headers["cho-token"]) + except ValueError: + raise AssertionError( + "cho-token is not a valid uuid", + response.headers["cho-token"], + ) + + has_supporter = True + + beatmap_md5 = "1cf5b2c2edfafd055536d2cefcb89c0e" + n300 = 83 + n100 = 14 + n50 = 5 + ngeki = 23 + nkatu = 6 + nmiss = 6 + score = 26810 + max_combo = 52 + perfect = False + grade = "C" + mods = 136 + passed = True + game_mode = 0 + client_time = datetime.now() + + storyboard_md5 = hashlib.md5(b"lol123").hexdigest() + + score_online_checksum = hashlib.md5( + "chickenmcnuggets{0}o15{1}{2}smustard{3}{4}uu{5}{6}{7}{8}{9}{10}{11}Q{12}{13}{15}{14:%y%m%d%H%M%S}{16}{17}".format( + n100 + n300, + n50, + ngeki, + nkatu, + nmiss, + beatmap_md5, + max_combo, + perfect, + username, + score, + grade, + mods, + passed, + game_mode, + client_time, + osu_version, + client_hashes, + storyboard_md5, + # yyMMddHHmmss + ).encode(), + ).hexdigest() + + score_data = [ + beatmap_md5, + username + (" " if has_supporter else ""), + score_online_checksum, + str(n300), + str(n100), + str(n50), + str(ngeki), + str(nkatu), + str(nmiss), + str(score), + str(max_combo), + str(perfect), + str(grade), + str(mods), + str(passed), + str(game_mode), + client_time.strftime("%y%m%d%H%M%S"), + str(osu_version), + "26685362", # TODO what is this? + ] + + iv_b64 = b"N2Q1YWZiNzYzNWFiYWZjZWMyMWMwM2QwMDEzOGRiNDk=" + visual_settings_b64 = b"YHD/rr/lajZIr+ZC6UbFYvCOwTOaEF3qhJCFaZUlQA8=" + + score_data_b64, client_hash_b64 = encryption.encrypt_score_aes_data( + score_data, + client_hashes, + iv_b64=iv_b64, + osu_version=osu_version, + ) + score_time = 13358 + fail_time = 0 + exited_out = False + + # mock out 3rd party calls to get beatmap data + for url in ( + "https://osu.direct/api/get_beatmaps?h=1cf5b2c2edfafd055536d2cefcb89c0e", + "https://osu.direct/api/get_beatmaps?s=141", + ): + respx_mock.get(url).mock( + return_value=httpx.Response( + status_code=status.HTTP_200_OK, + json=sample_beatmap_data.vivid_getbeatmaps_sample_response(), + ), + ) + + respx_mock.get("https://old.ppy.sh/osu/315").mock( + return_value=httpx.Response( + status_code=status.HTTP_200_OK, + content=sample_beatmap_data.vivid_osu_file_sample_response(), + ), + ) + + # ACT + response = await http_client.post( + url="/web/osu-submit-modular-selector.php", + headers={"Host": "osu.cmyui.xyz", "token": "auth-token"}, + data={ + "x": exited_out, + "ft": fail_time, + "fs": visual_settings_b64, + "bmk": beatmap_md5, # (`updated_beatmap_hash` in code) + "sbk": storyboard_md5, + "iv": iv_b64, + "c1": f"{uninstall_md5}|{disk_signature_md5}", + "st": score_time, + "pass": passwd_md5, + "osuver": osu_version, + "s": client_hash_b64, + # score param + "score": score_data_b64, + }, + files={ + # simulate replay data + "score": b"12345" + * 100, + }, + ) + + # ASSERT + assert response.status_code == status.HTTP_200_OK + assert ( + response.read() + == b"beatmapId:315|beatmapSetId:141|beatmapPlaycount:1|beatmapPasscount:1|approvedDate:2014-05-18 15:41:48|\n|chartId:beatmap|chartUrl:https://osu.cmyui.xyz/s/141|chartName:Beatmap Ranking|rankBefore:|rankAfter:1|rankedScoreBefore:|rankedScoreAfter:26810|totalScoreBefore:|totalScoreAfter:26810|maxComboBefore:|maxComboAfter:52|accuracyBefore:|accuracyAfter:81.94|ppBefore:|ppAfter:10.448|onlineScoreId:1|\n|chartId:overall|chartUrl:https://cmyui.xyz/u/3|chartName:Overall Ranking|rankBefore:|rankAfter:1|rankedScoreBefore:|rankedScoreAfter:26810|totalScoreBefore:|totalScoreAfter:26810|maxComboBefore:|maxComboAfter:52|accuracyBefore:|accuracyAfter:81.94|ppBefore:|ppAfter:11|achievements-new:osu-skill-pass-4+Insanity Approaches+You're not twitching, you're just ready./all-intro-hidden+Blindsight+I can see just perfectly" + ) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/packets_test.py b/tests/unit/packets_test.py new file mode 100644 index 0000000..d8fad00 --- /dev/null +++ b/tests/unit/packets_test.py @@ -0,0 +1,669 @@ +from __future__ import annotations + +import pytest + +import app.packets + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + (0, b"\x05\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00"), + (2_147_483_647, b"\x05\x00\x00\x04\x00\x00\x00\xff\xff\xff\x7f"), + ], +) +def test_write_user_id(test_input, expected): + assert app.packets.login_reply(test_input) == expected + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ( + { + "sender": "cmyui", + "msg": "woah woah crazy!!", + "recipient": "jacobian", + "sender_id": 32, + }, + b"\x07\x00\x00(\x00\x00\x00\x0b\x05cmyui\x0b\x11woah woah crazy!!\x0b\x08jacobian \x00\x00\x00", + ), + ( + { + "sender": "", + "msg": "", + "recipient": "", + "sender_id": 0, + }, + b"\x07\x00\x00\x07\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", + ), + ], +) +def test_write_send_message(test_input, expected): + assert app.packets.send_message(**test_input) == expected + + +def test_write_pong(): + assert app.packets.pong() == b"\x08\x00\x00\x00\x00\x00\x00" + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ( + {"old": "cmyui", "new": "abcgamer321"}, + b"\t\x00\x00\x16\x00\x00\x00\x0b\x14cmyui>>>>abcgamer321", + ), + ( + {"old": "", "new": ""}, + b"\t\x00\x00\x06\x00\x00\x00\x0b\x04>>>>", + ), + ], +) +def test_write_change_username(test_input, expected): + assert app.packets.change_username(**test_input) == expected + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ( + { + "user_id": 1001, + "action": 2, # playing + "info_text": "gaming", # TODO: get a realistic one + "map_md5": "60b725f10c9c85c70d97880dfe8191b3", + "mods": 64, + "mode": 0, + "map_id": 1723723, + "ranked_score": 1_238_917_112, + "accuracy": 92.32, + "plays": 3821, + "total_score": 3_812_428_392, + "global_rank": 42, + "pp": 8291, + }, + b"\x0b\x00\x00V\x00\x00\x00\xe9\x03\x00\x00\x02\x0b\x06gaming\x0b 60b725f10c9c85c70d97880dfe8191b3@\x00\x00\x00\x00KM\x1a\x00\xf8_\xd8I\x00\x00\x00\x00\xd6Vl?\xed\x0e\x00\x00h\n=\xe3\x00\x00\x00\x00*\x00\x00\x00c ", + ), + ( + { + "user_id": 0, + "action": 0, + "info_text": "", + "map_md5": "", # TODO: can this even be empty + "mods": 0, + "mode": 0, + "map_id": 0, + "ranked_score": 0, + "accuracy": 0.0, + "plays": 0, + "total_score": 0, + "global_rank": 0, + "pp": 0, + }, + b"\x0b\x00\x00.\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", + ), + ], +) +def test_write_user_stats(test_input, expected): + assert app.packets._user_stats(**test_input) == expected + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + (0, b"\x0c\x00\x00\x05\x00\x00\x00\x00\x00\x00\x00\x00"), + (2_147_483_647, b"\x0c\x00\x00\x05\x00\x00\x00\xff\xff\xff\x7f\x00"), + ], +) +def test_write_logout(test_input, expected): + assert app.packets.logout(test_input) == expected + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + (0, b"\x0d\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00"), + (2_147_483_647, b"\x0d\x00\x00\x04\x00\x00\x00\xff\xff\xff\x7f"), + ], +) +def test_write_spectator_joined(test_input, expected): + assert app.packets.spectator_joined(test_input) == expected + ... + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + (0, b"\x0e\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00"), + (2_147_483_647, b"\x0e\x00\x00\x04\x00\x00\x00\xff\xff\xff\x7f"), + ], +) +def test_write_spectator_left(test_input, expected): + assert app.packets.spectator_left(test_input) == expected + + +@pytest.mark.xfail(reason="need to implement proper writing") +@pytest.mark.parametrize(("test_input", "expected"), [({}, b"")]) +def test_write_spectate_frames(test_input, expected): + assert app.packets.spectate_frames(test_input) == expected + + +def test_write_version_update(): + assert app.packets.version_update() == b"\x13\x00\x00\x00\x00\x00\x00" + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + (0, b"\x16\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00"), + (2_147_483_647, b"\x16\x00\x00\x04\x00\x00\x00\xff\xff\xff\x7f"), + ], +) +def test_write_spectator_cant_spectate(test_input, expected): + assert app.packets.spectator_cant_spectate(test_input) == expected + + +def test_write_get_attention(): + assert app.packets.get_attention() == b"\x17\x00\x00\x00\x00\x00\x00" + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ("waowww", b"\x18\x00\x00\x08\x00\x00\x00\x0b\x06waowww"), + ("", b"\x18\x00\x00\x01\x00\x00\x00\x00"), + ], +) +def test_write_notification(test_input, expected): + assert app.packets.notification(test_input) == expected + + +@pytest.mark.xfail(reason="need to remove bancho.py match object") +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ({"m": None, "send_pw": False}, b""), + ({"m": None, "send_pw": True}, b""), + ], +) +def test_write_update_match(test_input, expected): + assert app.packets.update_match(test_input) == expected + + +@pytest.mark.xfail(reason="need to remove bancho.py match object") +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ({}, b""), + ({}, b""), + ], +) +def test_write_new_match(test_input, expected): + assert app.packets.new_match(test_input) == expected + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + (0, b"\x1c\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00"), + (2_147_483_647, b"\x1c\x00\x00\x04\x00\x00\x00\xff\xff\xff\x7f"), + ], +) +def test_write_dispose_match(test_input, expected): + assert app.packets.dispose_match(test_input) == expected + + +def test_write_toggle_block_non_friend_pm(): + assert app.packets.toggle_block_non_friend_dm() == b'"\x00\x00\x00\x00\x00\x00' + + +@pytest.mark.xfail(reason="need to remove bancho.py match object") +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ({}, b""), + ({}, b""), + ], +) +def test_write_match_join_success(test_input, expected): + assert app.packets.match_join_success(test_input) == expected + + +def test_write_match_join_fail(): + assert app.packets.match_join_fail() == b"%\x00\x00\x00\x00\x00\x00" + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + (0, b"*\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00"), + (2_147_483_647, b"*\x00\x00\x04\x00\x00\x00\xff\xff\xff\x7f"), + ], +) +def test_write_fellow_spectator_joined(test_input, expected): + assert app.packets.fellow_spectator_joined(test_input) == expected + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + (0, b"+\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00"), + (2_147_483_647, b"+\x00\x00\x04\x00\x00\x00\xff\xff\xff\x7f"), + ], +) +def test_write_fellow_spectator_left(test_input, expected): + assert app.packets.fellow_spectator_left(test_input) == expected + + +@pytest.mark.xfail(reason="need to remove bancho.py match object") +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ({}, b""), + ({}, b""), + ], +) +def test_write_match_start(test_input, expected): + assert app.packets.match_start(test_input) == expected + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ( + app.packets.ScoreFrame( + time=38242, # TODO: check if realistic + id=28, # TODO: check if realistic + num300=320, + num100=48, + num50=2, + num_geki=32, + num_katu=8, + num_miss=3, + total_score=492_392, + current_combo=39, + max_combo=122, + perfect=False, + current_hp=245, # TODO: check if realistic + tag_byte=0, + score_v2=False, + # NOTE: this stuff isn't written + # combo_portion=0.0, + # bonus_portion=0.0, + ), + b"0\x00\x00\x1d\x00\x00\x00b\x95\x00\x00\x1c@\x010\x00\x02\x00 \x00\x08\x00\x03\x00h\x83\x07\x00z\x00'\x00\x00\xf5\x00\x00", + ), + ( + app.packets.ScoreFrame( + time=0, + id=0, + num300=0, + num100=0, + num50=0, + num_geki=0, + num_katu=0, + num_miss=0, + total_score=0, + current_combo=0, + max_combo=0, + perfect=False, + current_hp=0, + tag_byte=0, + score_v2=False, + combo_portion=0.0, + bonus_portion=0.0, + ), + b"0\x00\x00\x1d\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", + ), + ], +) +def test_write_match_score_update(test_input, expected): + assert app.packets.match_score_update(test_input) == expected + + +def test_write_match_transfer_host(): + assert app.packets.match_transfer_host() == b"2\x00\x00\x00\x00\x00\x00" + + +def test_write_match_all_players_loaded(): + assert app.packets.match_all_players_loaded() == b"5\x00\x00\x00\x00\x00\x00" + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + (0, b"9\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00"), + (2_147_483_647, b"9\x00\x00\x04\x00\x00\x00\xff\xff\xff\x7f"), + ], +) +def test_write_match_player_failed(test_input, expected): + assert app.packets.match_player_failed(test_input) == expected + + +def test_write_match_complete(): + assert app.packets.match_complete() == b":\x00\x00\x00\x00\x00\x00" + + +def test_write_match_skip(): + assert app.packets.match_skip() == b"=\x00\x00\x00\x00\x00\x00" + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ("#osu", b"@\x00\x00\x06\x00\x00\x00\x0b\x04#osu"), + ("", b"@\x00\x00\x01\x00\x00\x00\x00"), + ], +) +def test_write_channel_join(test_input, expected): + assert app.packets.channel_join(test_input) == expected + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ( + ("#osu", "le topique", 123), + b"A\x00\x00\x14\x00\x00\x00\x0b\x04#osu\x0b\nle topique{\x00", + ), + (("", "", 0), b"A\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00"), + ], +) +def test_write_channel_info(test_input, expected): + assert app.packets.channel_info(*test_input) == expected + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ("#osu", b"B\x00\x00\x06\x00\x00\x00\x0b\x04#osu"), + ("", b"B\x00\x00\x01\x00\x00\x00\x00"), + ], +) +def test_write_channel_kick(test_input, expected): + assert app.packets.channel_kick(test_input) == expected + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ( + ("#osu", "le topique", 123), + b"C\x00\x00\x14\x00\x00\x00\x0b\x04#osu\x0b\nle topique{\x00", + ), + (("", "", 0), b"C\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00"), + ], +) +def test_write_channel_auto_join(test_input, expected): + assert app.packets.channel_auto_join(*test_input) == expected + + +# TODO: test_write_beatmap_info_reply? it's disabled in +# app.packets but perhaps for completion i can keep it in + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + (0, b"G\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00"), + (2_147_483_647, b"G\x00\x00\x04\x00\x00\x00\xff\xff\xff\x7f"), + ], +) +def test_write_bancho_privileges(test_input, expected): + assert app.packets.bancho_privileges(test_input) == expected + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ( + [1, 4, 1001], + b"H\x00\x00\x0e\x00\x00\x00\x03\x00\x01\x00\x00\x00\x04\x00\x00\x00\xe9\x03\x00\x00", + ), + ( + [], + b"H\x00\x00\x02\x00\x00\x00\x00\x00", + ), + ], +) +def test_write_friends_list(test_input, expected): + assert app.packets.friends_list(test_input) == expected + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + (0, b"K\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00"), + (2_147_483_647, b"K\x00\x00\x04\x00\x00\x00\xff\xff\xff\x7f"), + ], +) +def test_write_protocol_version(test_input, expected): + assert app.packets.protocol_version(test_input) == expected + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ( + ("https://icon-url.ca/a.png", "https://onclick-url.ca/a.png"), + b"L\x00\x008\x00\x00\x00\x0b6https://icon-url.ca/a.png|https://onclick-url.ca/a.png", + ), + ( + ("", ""), + b"L\x00\x00\x03\x00\x00\x00\x0b\x01|", + ), + ], +) +def test_write_main_menu_icon(test_input, expected): + assert app.packets.main_menu_icon(*test_input) == expected + + +def test_write_monitor(): + assert app.packets.monitor() == b"P\x00\x00\x00\x00\x00\x00" + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + (0, b"Q\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00"), + (2_147_483_647, b"Q\x00\x00\x04\x00\x00\x00\xff\xff\xff\x7f"), + ], +) +def test_write_match_player_skipped(test_input, expected): + assert app.packets.match_player_skipped(test_input) == expected + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ( + { + "user_id": 1001, + "name": "cmyui", + "utc_offset": -5, + "country_code": 38, + "bancho_privileges": 31, # owner|dev|supporter|mod|player + "mode": 0, + "longitude": 43.768, + "latitude": -79.522, + "global_rank": 42, + }, + b"S\x00\x00\x1a\x00\x00\x00\xe9\x03\x00\x00\x0b\x05cmyui\x13&\x1fo\x12/BD\x0b\x9f\xc2*\x00\x00\x00", + ), + ( + { + "user_id": 0, + "name": "", + "utc_offset": 0, + "country_code": 0, + "bancho_privileges": 0, + "mode": 0, + "longitude": 0.0, + "latitude": 0.0, + "global_rank": 0, + }, + b"S\x00\x00\x14\x00\x00\x00\x00\x00\x00\x00\x00\x18\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", + ), + ], +) +def test_write_user_presence(test_input, expected): + assert app.packets._user_presence(**test_input) == expected + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + (0, b"V\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00"), + (2_147_483_647, b"V\x00\x00\x04\x00\x00\x00\xff\xff\xff\x7f"), + ], +) +def test_write_restart_server(test_input, expected): + assert app.packets.restart_server(test_input) == expected + + +@pytest.mark.xfail(reason="need to remove bancho.py match object") +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ({"p": None, "t_name": "cover"}, b""), + ({"p": None, "t_name": "cover"}, b""), + ], +) +def test_write_match_invite(test_input, expected): + assert app.packets.match_invite(**test_input) == expected + + +def test_channel_info_end(): + assert app.packets.channel_info_end() == b"Y\x00\x00\x00\x00\x00\x00" + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ("newpassword", b"[\x00\x00\x0d\x00\x00\x00\x0b\x0bnewpassword"), + ("", b"[\x00\x00\x01\x00\x00\x00\x00"), + ], +) +def test_write_match_change_password(test_input, expected): + assert app.packets.match_change_password(test_input) == expected + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + (0, b"\\\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00"), + (2_147_483_647, b"\\\x00\x00\x04\x00\x00\x00\xff\xff\xff\x7f"), + ], +) +def test_write_silence_end(test_input, expected): + assert app.packets.silence_end(test_input) == expected + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + (0, b"^\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00"), + (2_147_483_647, b"^\x00\x00\x04\x00\x00\x00\xff\xff\xff\x7f"), + ], +) +def test_write_user_silenced(test_input, expected): + assert app.packets.user_silenced(test_input) == expected + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + (0, b"_\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00"), + (2_147_483_647, b"_\x00\x00\x04\x00\x00\x00\xff\xff\xff\x7f"), + ], +) +def test_write_user_presence_single(test_input, expected): + assert app.packets.user_presence_single(test_input) == expected + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ( + [1, 4, 1001], + b"`\x00\x00\x0e\x00\x00\x00\x03\x00\x01\x00\x00\x00\x04\x00\x00\x00\xe9\x03\x00\x00", + ), + ( + [], + b"`\x00\x00\x02\x00\x00\x00\x00\x00", + ), + ], +) +def test_write_user_presence_bundle(test_input, expected): + assert app.packets.user_presence_bundle(test_input) == expected + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ("cover", b"d\x00\x00\r\x00\x00\x00\x00\x00\x0b\x05cover\x00\x00\x00\x00"), + ("", b"d\x00\x00\x07\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"), + ], +) +def test_write_user_dm_blocked(test_input, expected): + assert app.packets.user_dm_blocked(test_input) == expected + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ("cover", b"e\x00\x00\r\x00\x00\x00\x00\x00\x0b\x05cover\x00\x00\x00\x00"), + ("", b"e\x00\x00\x07\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"), + ], +) +def test_write_target_silenced(test_input, expected): + assert app.packets.target_silenced(test_input) == expected + + +def test_write_version_update_forced(): + assert app.packets.version_update_forced() == b"f\x00\x00\x00\x00\x00\x00" + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + (0, b"g\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00"), + (2_147_483_647, b"g\x00\x00\x04\x00\x00\x00\xff\xff\xff\x7f"), + ], +) +def test_write_switch_server(test_input, expected): + assert app.packets.switch_server(test_input) == expected + + +def test_write_account_restricted(): + assert app.packets.account_restricted() == b"h\x00\x00\x00\x00\x00\x00" + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ("yoyoo rip rtx", b"i\x00\x00\x0f\x00\x00\x00\x0b\ryoyoo rip rtx"), + ("", b"i\x00\x00\x01\x00\x00\x00\x00"), + ], +) +def test_write_rtx(test_input, expected): + assert app.packets.rtx(test_input) == expected + + +def test_write_match_abort(): + assert app.packets.match_abort() == b"j\x00\x00\x00\x00\x00\x00" + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + ( + "61.91.139.24", + b"k\x00\x00\x0e\x00\x00\x00\x0b\x0c61.91.139.24", + ), + ("", b"k\x00\x00\x01\x00\x00\x00\x00"), + ], +) +def test_write_switch_tournament_server(test_input, expected): + assert app.packets.switch_tournament_server(test_input) == expected diff --git a/tools/enable_geoip_module.sh b/tools/enable_geoip_module.sh new file mode 100644 index 0000000..856e1e4 --- /dev/null +++ b/tools/enable_geoip_module.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ensure admin privileges +if (( $EUID != 0 )); then + printf "This script must be run with administrative privileges." + exit +fi + +root_dir=$(pwd) +nginx_version=$(nginx -v 2>&1 | awk -F' ' '{print $3}' | grep -o '[0-9.]*$') + +# download the nginx source and the geoip2 module in a temp folder +mkdir "temp" && cd "temp" +wget http://nginx.org/download/nginx-$nginx_version.tar.gz +tar zxvf nginx-$nginx_version.tar.gz +wget -O ngx_http_geoip2_module.tar.gz https://github.com/leev/ngx_http_geoip2_module/archive/master.tar.gz +tar zxvf ngx_http_geoip2_module.tar.gz + +# install essentials apps to compile software and add ppas +apt update && apt install -y \ + software-properties-common \ + build-essential + +# install maxmind's ppa and the libraries required to build nginx +add-apt-repository ppa:maxmind/ppa -y +apt install -y \ + libmaxminddb0 \ + libmaxminddb-dev \ + mmdb-bin \ + geoipupdate \ + libpcre3 \ + libpcre3-dev \ + zlib1g \ + zlib1g-dev \ + libssl-dev + +# build nginx with the geoip2 module +cd nginx-$nginx_version +./configure --add-dynamic-module=../ngx_http_geoip2_module-master $(nginx -V) --with-compat +make + +# install the new dynamic module in nginx +mkdir -p /etc/nginx/modules-available /etc/nginx/modules-enabled +cp objs/ngx_http_geoip2_module.so /usr/lib/nginx/modules +echo "load_module modules/ngx_http_geoip2_module.so;" > /etc/nginx/modules-available/mod-http-geoip2.conf +rm -f /etc/nginx/modules-enabled/60-mod-http-geoip2.conf +ln -s /etc/nginx/modules-available/mod-http-geoip2.conf /etc/nginx/modules-enabled/60-mod-http-geoip2.conf + +cd "$root_dir" && rm -r temp + +printf "The GeoIP2 module has been installed and enabled." diff --git a/tools/generate_cf_dns_records.sh b/tools/generate_cf_dns_records.sh new file mode 100644 index 0000000..2bc49fc --- /dev/null +++ b/tools/generate_cf_dns_records.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +read -p "What's your server domain? " domain +domain=${domain:-example.com} + +read -p "What's your server IP? " ip +ip=${ip:-0.0.0.0} + +printf '%s\n' \ + "a 1 IN A $ip"\ + "api 1 IN A $ip"\ + "assets 1 IN A $ip"\ + "b 1 IN A $ip"\ + "c 1 IN A $ip"\ + "c4 1 IN A $ip"\ + "ce 1 IN A $ip"\ + "$domain 1 IN A $ip"\ + "i 1 IN A $ip"\ + "osu 1 IN A $ip"\ + "s 1 IN A $ip" >> "cf_records.txt" + +printf "Your Cloudflare DNS records have been generated." diff --git a/tools/local_cert_generator.sh b/tools/local_cert_generator.sh new file mode 100644 index 0000000..c83802f --- /dev/null +++ b/tools/local_cert_generator.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +read -p "What's the name of your server? " name +name=${name=Bancho} +read -p "What's the base domain of your server? " domain +domain=*.${domain=ppy.sh} +read -p "What country is this based in? (ISO country code) " location +location=${location=CA} + +args="/CN=$domain/O=$name/C=$location" +openssl req -subj $args -new -newkey rsa:4096 -sha256 -days 36500 -nodes -x509 -keyout key.pem -out cert.pem +openssl x509 -outform der -in cert.pem -out cert.crt + +printf "Your certificates have been generated." diff --git a/tools/migrate_logs.py b/tools/migrate_logs.py new file mode 100644 index 0000000..49aa685 --- /dev/null +++ b/tools/migrate_logs.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3.11 +from __future__ import annotations + +import asyncio +import os +import re +import sys + +import databases + +sys.path.insert(0, os.path.abspath(os.pardir)) +os.chdir(os.path.abspath(os.pardir)) + +import app.settings + +LOG_REGEX = re.compile( + r"<(.*)\((.*)\)> (?Punrestricted|restricted|unsilenced|silenced|added note) ?(\((.*)\))? ?(\: (?P.*))? ?(?:for (?P.*))?", +) + + +async def main() -> int: + async with databases.Database(app.settings.DB_DSN) as db: + async with ( + db.connection() as select_conn, + db.connection() as update_conn, + ): + # add/adjust new columns, keeping them null until we are finished + print("Creating new columns") + + await update_conn.execute( + "ALTER TABLE `logs` ADD COLUMN `action` VARCHAR(32) null after `to`", + ) + await update_conn.execute( + "ALTER TABLE `logs` MODIFY `msg` VARCHAR(2048) null", + ) # now used as reason + + # get all logs & change + print("Getting all old logs") + for row in await select_conn.fetch_all(f"SELECT * FROM logs"): + note = row["msg"] + + note_match = LOG_REGEX.match(row["msg"]) + if not note_match: + continue + + reason = note_match["reason"] + note = note_match["note"] + + msg = None + if reason: + msg = reason + elif note: + msg = note + + if note: + action = "note" + else: + action = ( + note_match["action"][:-2] + if "silence" not in note_match["reason"] + else note_match["action"][:-1] + ) + + await update_conn.execute( + "UPDATE logs SET action = :action, msg = :msg, time = :time WHERE id = :id", + { + "action": action, + "msg": msg, + "id": row["id"], + "time": row["time"], + }, + ) + + # change action column to not null + await update_conn.execute( + "ALTER TABLE `logs` MODIFY `action` VARCHAR(32) not null", + ) + + print("Finished migrating logs!") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/tools/migrate_v420/go.mod b/tools/migrate_v420/go.mod new file mode 100644 index 0000000..47ef663 --- /dev/null +++ b/tools/migrate_v420/go.mod @@ -0,0 +1,8 @@ +module scoreMigrator + +go 1.17 + +require ( + github.com/go-sql-driver/mysql v1.6.0 + github.com/jmoiron/sqlx v1.3.4 +) diff --git a/tools/migrate_v420/main.go b/tools/migrate_v420/main.go new file mode 100644 index 0000000..e22bc62 --- /dev/null +++ b/tools/migrate_v420/main.go @@ -0,0 +1,344 @@ +package main + +import ( + _ "github.com/go-sql-driver/mysql" + "github.com/jmoiron/sqlx" + + "database/sql" + "sync/atomic" + + "fmt" + "os" + "reflect" + "strings" + "sync" + "time" +) + +/// MIGRATION TOOL INSTRUCTIONS /// +// first install golang from https://go.dev/doc/install + +// next, install the dependencies for running this tool +// $ go get + +// next, configure these parameters +var SQLUsername string = "cmyui" +var SQLPassword string = "lol123" +var SQLDatabase string = "gulag_old" +var SQLHost string = "127.0.0.1" +var SQLPort string = "3306" +var GulagPath string = "/home/cmyui/programming/gulag" // NOTE: no trailing slash! + +// then, build & run the binary. this will create the new +// scores table, move all scores to the new tables, and +// move all existing replays to their new locations. +// NOTE: at the end, you will be prompted to delete the old +// scores tables. you should only do this once you are +// certain the migration ran without any issues. +// NOTE: you may want to back up your gulag/.data/osr folder +// which contains the server's replays, just in case +// there are any issues. +// $ go run . + +var DB *sqlx.DB + +type Score struct { + ID int64 + MapMD5 string `db:"map_md5"` + Score int + PP float32 + Acc float32 + MaxCombo int `db:"max_combo"` + Mods int + N300 int + N100 int + N50 int + Nmiss int + Ngeki int + Nkatu int + Grade string + Status int + Mode int + PlayTime int64 `db:"play_time"` + TimeElapsed int `db:"time_elapsed"` + ClientFlags int `db:"client_flags"` + UserID int64 `db:"userid"` + Perfect int + OnlineChecksum sql.NullString `db:"online_checksum"` +} + +var create_scores = ` +create table scores ( + id bigint unsigned auto_increment + primary key, + map_md5 char(32) not null, + score int not null, + pp float(7,3) not null, + acc float(6,3) not null, + max_combo int not null, + mods int not null, + n300 int not null, + n100 int not null, + n50 int not null, + nmiss int not null, + ngeki int not null, + nkatu int not null, + grade varchar(2) default 'N' not null, + status tinyint not null, + mode tinyint not null, + play_time datetime not null, + time_elapsed int not null, + client_flags int not null, + userid int not null, + perfect tinyint(1) not null, + online_checksum char(32) not null default '' +); +` + +var insert_score = ` +INSERT INTO scores VALUES ( + NULL, + :map_md5, + :score, + :pp, + :acc, + :max_combo, + :mods, + :n300, + :n100, + :n50, + :nmiss, + :ngeki, + :nkatu, + :grade, + :status, + :mode, + FROM_UNIXTIME(:play_time), + :time_elapsed, + :client_flags, + :userid, + :perfect, + :online_checksum +)` + +var replaysMoved int32 + +func recalculate_chunk(chunk []Score, table string, increase int) { + tx := DB.MustBegin() + batch := 1 + + for _, score := range chunk { + score.Mode += increase + + if batch == 0 { + tx = DB.MustBegin() + } + batch++ + + if !score.OnlineChecksum.Valid { + score.OnlineChecksum.String = "" + score.OnlineChecksum.Valid = true + } + + res, err := tx.NamedExec(insert_score, &score) + if err != nil { + fmt.Println(err) + continue + } + + new_id, err := res.LastInsertId() + if err != nil { + fmt.Println(err) + continue + } + + if score.Status != 0 { + // this is a submitted score, move the replay file as well + oldReplayPath := fmt.Sprintf("/tmp/gulag_replays/%d.osr", score.ID) + if _, err := os.Stat(oldReplayPath); os.IsNotExist(err) { + fmt.Printf("Warning: replay file for old ID %d could not be found\n", score.ID) + } else { + newReplayPath := fmt.Sprintf("%s/.data/osr/%d.osr", GulagPath, new_id) + os.Rename(oldReplayPath, newReplayPath) + atomic.AddInt32(&replaysMoved, 1) + } + } + + if batch == 3000 { + batch = 0 + tx.Commit() + } + } + + if batch != 0 { + tx.Commit() + } +} + +func SplitToChunks(slice interface{}, chunkSize int) interface{} { + sliceType := reflect.TypeOf(slice) + sliceVal := reflect.ValueOf(slice) + length := sliceVal.Len() + if sliceType.Kind() != reflect.Slice { + panic("parameter must be []T") + } + n := 0 + if length%chunkSize > 0 { + n = 1 + } + SST := reflect.MakeSlice(reflect.SliceOf(sliceType), 0, length/chunkSize+n) + st, ed := 0, 0 + for st < length { + ed = st + chunkSize + if ed > length { + ed = length + } + SST = reflect.Append(SST, sliceVal.Slice(st, ed)) + st = ed + } + return SST.Interface() +} + +func main() { + // start migration timer + start := time.Now() + + // ensure gulag path exists + if _, err := os.Stat(GulagPath); os.IsNotExist(err) { + panic("Gulag path is invalid") + } + + // connect to the database + dbDSN := fmt.Sprintf("%s:%s@(%s:%s)/%s", SQLUsername, SQLPassword, SQLHost, SQLPort, SQLDatabase) + DB = sqlx.MustConnect("mysql", dbDSN) + + // move replays to temp directory + err := os.Rename(fmt.Sprintf("%s/.data/osr", GulagPath), "/tmp/gulag_replays") + if err != nil { + panic(err) + } + + // create new replay directory in gulag/.data + err = os.Mkdir(fmt.Sprintf("%s/.data/osr", GulagPath), 0755) + if err != nil { + panic(err) + } + + var wg sync.WaitGroup + + // create new scores table + DB.MustExec(create_scores) + + // migrate vn_scores table + vn_scores := []Score{} + vn_rows, err := DB.Queryx(` + SELECT id, map_md5, score, pp, acc, max_combo, mods, n300, n100, + n50, nmiss, ngeki, nkatu, grade, status, mode, UNIX_TIMESTAMP(play_time) AS play_time, + time_elapsed, client_flags, userid, perfect, online_checksum FROM scores_vn`) + if err != nil { + panic(err) + } + + for vn_rows.Next() { + score := Score{} + err := vn_rows.StructScan(&score) + if err != nil { + panic(err) + } + + vn_scores = append(vn_scores, score) + } + + for _, vn_chunk := range SplitToChunks(vn_scores, 10000).([][]Score) { + wg.Add(1) + go func(chunk []Score) { + defer wg.Done() + recalculate_chunk(chunk, "scores_vn", 0) + }(vn_chunk) + } + + // migrate rx_scores table + rx_scores := []Score{} + rx_rows, err := DB.Queryx(` + SELECT id, map_md5, score, pp, acc, max_combo, mods, n300, n100, + n50, nmiss, ngeki, nkatu, grade, status, mode, UNIX_TIMESTAMP(play_time) AS play_time, + time_elapsed, client_flags, userid, perfect, online_checksum FROM scores_rx`) + if err != nil { + panic(err) + } + + for rx_rows.Next() { + score := Score{} + err := rx_rows.StructScan(&score) + if err != nil { + panic(err) + } + + rx_scores = append(rx_scores, score) + } + + for _, rx_chunk := range SplitToChunks(rx_scores, 10000).([][]Score) { + wg.Add(1) + go func(chunk []Score) { + defer wg.Done() + recalculate_chunk(chunk, "scores_rx", 4) + }(rx_chunk) + } + + // migrate ap_scores table + ap_scores := []Score{} + ap_rows, err := DB.Queryx(` + SELECT id, map_md5, score, pp, acc, max_combo, mods, n300, n100, + n50, nmiss, ngeki, nkatu, grade, status, mode, UNIX_TIMESTAMP(play_time) AS play_time, + time_elapsed, client_flags, userid, perfect, online_checksum FROM scores_ap`) + if err != nil { + panic(err) + } + + for ap_rows.Next() { + score := Score{} + err := ap_rows.StructScan(&score) + if err != nil { + panic(err) + } + + ap_scores = append(ap_scores, score) + } + + for _, ap_chunk := range SplitToChunks(ap_scores, 10000).([][]Score) { + wg.Add(1) + go func(chunk []Score) { + defer wg.Done() + recalculate_chunk(chunk, "scores_ap", 8) + }(ap_chunk) + } + + // wait for all migrations to complete + wg.Wait() + + // attempt to remove the temp replays directory + err = os.Remove("/tmp/gulag_replays") + if err != nil { + fmt.Println("There are some replays files for which scores could not be found in the database. They have been left at /tmp/gulag_replays.") + } + + // print elapsed time spent migrating + elapsed := time.Since(start) + fmt.Printf("Score migrator took %s\n", elapsed) + fmt.Printf("Moved %d replays\n", replaysMoved) + + // prompt user to delete the old scores tables if they're certain everything is successful + fmt.Printf("Do you wish to drop the old tables? [only do this if you're certain migrations have been successful] (y/n)\n>> ") + var res string + fmt.Scanln(&res) + res = strings.ToLower(res) + + if res == "y" { + fmt.Println("Dropping old tables") + DB.MustExec("drop table scores_vn") + DB.MustExec("drop table scores_rx") + DB.MustExec("drop table scores_ap") + } else { + fmt.Println("Not dropping old tables") + } +} diff --git a/tools/proxy.py b/tools/proxy.py new file mode 100644 index 0000000..099a9d3 --- /dev/null +++ b/tools/proxy.py @@ -0,0 +1,167 @@ +# like budget wireshark for osu! server stuff +# usage: enable http://localhost:8080 proxy in windows, +# (https://i.cmyui.xyz/DNnqifKHyBSA9X8NEHg.png) +# and run this with `mitmdump -qs tools/proxy.py` +from __future__ import annotations + +domain = "cmyui.xyz" # XXX: put your domain here + +import re +import struct +import sys +from enum import IntEnum +from enum import unique + +from mitmproxy import http + + +@unique +class ServerPackets(IntEnum): + USER_ID = 5 + SEND_MESSAGE = 7 + PONG = 8 + HANDLE_IRC_CHANGE_USERNAME = 9 # unused + HANDLE_IRC_QUIT = 10 + USER_STATS = 11 + USER_LOGOUT = 12 + SPECTATOR_JOINED = 13 + SPECTATOR_LEFT = 14 + SPECTATE_FRAMES = 15 + VERSION_UPDATE = 19 + SPECTATOR_CANT_SPECTATE = 22 + GET_ATTENTION = 23 + NOTIFICATION = 24 + UPDATE_MATCH = 26 + NEW_MATCH = 27 + DISPOSE_MATCH = 28 + TOGGLE_BLOCK_NON_FRIEND_DMS = 34 + MATCH_JOIN_SUCCESS = 36 + MATCH_JOIN_FAIL = 37 + FELLOW_SPECTATOR_JOINED = 42 + FELLOW_SPECTATOR_LEFT = 43 + ALL_PLAYERS_LOADED = 45 + MATCH_START = 46 + MATCH_SCORE_UPDATE = 48 + MATCH_TRANSFER_HOST = 50 + MATCH_ALL_PLAYERS_LOADED = 53 + MATCH_PLAYER_FAILED = 57 + MATCH_COMPLETE = 58 + MATCH_SKIP = 61 + UNAUTHORIZED = 62 # unused + CHANNEL_JOIN_SUCCESS = 64 + CHANNEL_INFO = 65 + CHANNEL_KICK = 66 + CHANNEL_AUTO_JOIN = 67 + BEATMAP_INFO_REPLY = 69 + PRIVILEGES = 71 + FRIENDS_LIST = 72 + PROTOCOL_VERSION = 75 + MAIN_MENU_ICON = 76 + MONITOR = 80 # unused + MATCH_PLAYER_SKIPPED = 81 + USER_PRESENCE = 83 + RESTART = 86 + MATCH_INVITE = 88 + CHANNEL_INFO_END = 89 + MATCH_CHANGE_PASSWORD = 91 + SILENCE_END = 92 + USER_SILENCED = 94 + USER_PRESENCE_SINGLE = 95 + USER_PRESENCE_BUNDLE = 96 + USER_DM_BLOCKED = 100 + TARGET_IS_SILENCED = 101 + VERSION_UPDATE_FORCED = 102 + SWITCH_SERVER = 103 + ACCOUNT_RESTRICTED = 104 + RTX = 105 # unused + MATCH_ABORT = 106 + SWITCH_TOURNAMENT_SERVER = 107 + + def __repr__(self) -> str: + return f"<{self.name} ({self.value})>" + + +BYTE_ORDER_SUFFIXES = ["B", "KB", "MB", "GB"] + + +def fmt_bytes(n: int | float) -> str: + suffix = None + for suffix in BYTE_ORDER_SUFFIXES: + if n < 1024: + break + n /= 1024 + return f"{n:,.2f}{suffix}" + + +DOMAIN_RGX = re.compile( + rf"^(?Posu|c[e4]?|a|s|b|assets)\.(?:ppy\.sh|{re.escape(domain)})$", +) + +PACKET_HEADER_FMT = struct.Struct(" None: + r_match = DOMAIN_RGX.match(flow.request.host) + if not r_match: + return # unrelated request + + body = flow.response.content + if not body: + return # empty resp + + sys.stdout.write(f"\x1b[0;93m[{flow.request.method}] {flow.request.url}\x1b[0m\n") + body_view = memoryview(body) + body_len = len(body) + + if r_match["subdomain"] in ("c", "ce", "c4", "c5", "c6"): + if flow.request.method == "POST": + packet_num = 1 + while body_view: + # read header + _pid, plen = PACKET_HEADER_FMT.unpack_from(body_view) + pid = ServerPackets(_pid) + body_view = body_view[7:] + + # read data + pdata = str(body_view[:plen].tobytes())[2:-1] # remove b'' + body_view = body_view[plen:] + + sys.stdout.write(f"[{packet_num}] \x1b[0;95m{pid!r}\x1b[0m {pdata}\n") + + packet_num += 1 + + if packet_num % 5: # don't build up too much in ram + sys.stdout.flush() + sys.stdout.write("\n") + else: # format varies per request + if ( # todo check host + ( + # jfif, jpe, jpeg, jpg graphics file + body_view[:4] == b"\xff\xd8\xff\xe0" + and body_view[6:11] == b"JFIF\x00" + ) + or ( + # exif digital jpg + body_view[:4] == b"\xff\xd8\xff\xe1" + and body_view[6:11] == b"Exif\x00" + ) + or ( + # spiff still picture jpg + body_view[:4] == b"\xff\xd8\xff\xe8" + and body_view[6:12] == b"SPIFF\x00" + ) + ): + sys.stdout.write(f"[{fmt_bytes(body_len)} jpeg file]\n\n") + elif ( + body_view[:8] == b"\x89PNG\r\n\x1a\n" + and body_view[-8:] == b"\x49END\xae\x42\x60\x82" + ): + sys.stdout.write(f"[{fmt_bytes(body_len)} png file]\n\n") + elif body_view[:6] in (b"GIF87a", b"GIF89a") and body_view[-2:] == b"\x00\x3b": + sys.stdout.write(f"[{fmt_bytes(body_len)} gif file]\n\n") + else: + sys.stdout.write(f"{str(body)[2:-1]}\n\n") # remove b'' + + sys.stdout.flush() diff --git a/tools/recalc.py b/tools/recalc.py new file mode 100644 index 0000000..d7c019d --- /dev/null +++ b/tools/recalc.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3.11 +from __future__ import annotations + +import argparse +import asyncio +import math +import os +import sys +from collections.abc import Awaitable +from collections.abc import Iterator +from collections.abc import Sequence +from dataclasses import dataclass +from dataclasses import field +from pathlib import Path +from typing import Any +from typing import TypeVar + +import databases +from akatsuki_pp_py import Beatmap +from akatsuki_pp_py import Calculator +from redis import asyncio as aioredis + +sys.path.insert(0, os.path.abspath(os.pardir)) +os.chdir(os.path.abspath(os.pardir)) + +try: + import app.settings + import app.state.services + from app.constants.gamemodes import GameMode + from app.constants.mods import Mods + from app.constants.privileges import Privileges + from app.objects.beatmap import ensure_osu_file_is_available +except ModuleNotFoundError: + print("\x1b[;91mMust run from tools/ directory\x1b[m") + raise + +T = TypeVar("T") + + +DEBUG = False +BEATMAPS_PATH = Path.cwd() / ".data/osu" + + +@dataclass +class Context: + database: databases.Database + redis: aioredis.Redis + beatmaps: dict[int, Beatmap] = field(default_factory=dict) + + +def divide_chunks(values: list[T], n: int) -> Iterator[list[T]]: + for i in range(0, len(values), n): + yield values[i : i + n] + + +async def recalculate_score( + score: dict[str, Any], + beatmap_path: Path, + ctx: Context, +) -> None: + beatmap = ctx.beatmaps.get(score["map_id"]) + if beatmap is None: + beatmap = Beatmap(path=str(beatmap_path)) + ctx.beatmaps[score["map_id"]] = beatmap + + calculator = Calculator( + mode=GameMode(score["mode"]).as_vanilla, + mods=score["mods"], + combo=score["max_combo"], + n_geki=score["ngeki"], # Mania 320s + n300=score["n300"], + n_katu=score["nkatu"], # Mania 200s, Catch tiny droplets + n100=score["n100"], + n50=score["n50"], + n_misses=score["nmiss"], + ) + attrs = calculator.performance(beatmap) + + new_pp: float = attrs.pp + if math.isnan(new_pp) or math.isinf(new_pp): + new_pp = 0.0 + + new_pp = min(new_pp, 9999.999) + + await ctx.database.execute( + "UPDATE scores SET pp = :new_pp WHERE id = :id", + {"new_pp": new_pp, "id": score["id"]}, + ) + + if DEBUG: + print( + f"Recalculated score ID {score['id']} ({score['pp']:.3f}pp -> {new_pp:.3f}pp)", + ) + + +async def process_score_chunk( + chunk: list[dict[str, Any]], + ctx: Context, +) -> None: + tasks: list[Awaitable[None]] = [] + for score in chunk: + osu_file_available = await ensure_osu_file_is_available( + score["map_id"], + expected_md5=score["map_md5"], + ) + if osu_file_available: + tasks.append( + recalculate_score( + score, + BEATMAPS_PATH / f"{score['map_id']}.osu", + ctx, + ), + ) + + await asyncio.gather(*tasks) + + +async def recalculate_user( + id: int, + game_mode: GameMode, + ctx: Context, +) -> None: + best_scores = await ctx.database.fetch_all( + "SELECT s.pp, s.acc FROM scores s " + "INNER JOIN maps m ON s.map_md5 = m.md5 " + "WHERE s.userid = :user_id AND s.mode = :mode " + "AND s.status = 2 AND m.status IN (2, 3) " # ranked, approved + "ORDER BY s.pp DESC", + {"user_id": id, "mode": game_mode}, + ) + + total_scores = len(best_scores) + if not total_scores: + return + + # calculate new total weighted accuracy + weighted_acc = sum(row["acc"] * 0.95**i for i, row in enumerate(best_scores)) + bonus_acc = 100.0 / (20 * (1 - 0.95**total_scores)) + acc = (weighted_acc * bonus_acc) / 100 + + # calculate new total weighted pp + weighted_pp = sum(row["pp"] * 0.95**i for i, row in enumerate(best_scores)) + bonus_pp = 416.6667 * (1 - 0.9994**total_scores) + pp = round(weighted_pp + bonus_pp) + + await ctx.database.execute( + "UPDATE stats SET pp = :pp, acc = :acc WHERE id = :id AND mode = :mode", + {"pp": pp, "acc": acc, "id": id, "mode": game_mode}, + ) + + user_info = await ctx.database.fetch_one( + "SELECT country, priv FROM users WHERE id = :id", + {"id": id}, + ) + if user_info is None: + raise Exception(f"Unknown user ID {id}?") + + if user_info["priv"] & Privileges.UNRESTRICTED: + await ctx.redis.zadd( + f"bancho:leaderboard:{game_mode.value}", + {str(id): pp}, + ) + + await ctx.redis.zadd( + f"bancho:leaderboard:{game_mode.value}:{user_info['country']}", + {str(id): pp}, + ) + + if DEBUG: + print(f"Recalculated user ID {id} ({pp:.3f}pp, {acc:.3f}%)") + + +async def process_user_chunk( + chunk: list[int], + game_mode: GameMode, + ctx: Context, +) -> None: + tasks: list[Awaitable[None]] = [] + for id in chunk: + tasks.append(recalculate_user(id, game_mode, ctx)) + + await asyncio.gather(*tasks) + + +async def recalculate_mode_users(mode: GameMode, ctx: Context) -> None: + user_ids = [ + row["id"] for row in await ctx.database.fetch_all("SELECT id FROM users") + ] + + for id_chunk in divide_chunks(user_ids, 100): + await process_user_chunk(id_chunk, mode, ctx) + + +async def recalculate_mode_scores(mode: GameMode, ctx: Context) -> None: + scores = [ + dict(row) + for row in await ctx.database.fetch_all( + """\ + SELECT scores.id, scores.mode, scores.mods, scores.map_md5, + scores.pp, scores.acc, scores.max_combo, + scores.ngeki, scores.n300, scores.nkatu, scores.n100, scores.n50, scores.nmiss, + maps.id as `map_id` + FROM scores + INNER JOIN maps ON scores.map_md5 = maps.md5 + WHERE scores.status = 2 + AND scores.mode = :mode + ORDER BY scores.pp DESC + """, + {"mode": mode}, + ) + ] + + for score_chunk in divide_chunks(scores, 100): + await process_score_chunk(score_chunk, ctx) + + +async def main(argv: Sequence[str] | None = None) -> int: + argv = argv if argv is not None else sys.argv[1:] + + parser = argparse.ArgumentParser( + description="Recalculate performance for scores and/or stats", + ) + + parser.add_argument( + "-d", + "--debug", + help="Enable debug logging", + action="store_true", + ) + parser.add_argument( + "--no-scores", + help="Disable recalculating scores", + action="store_true", + ) + parser.add_argument( + "--no-stats", + help="Disable recalculating user stats", + action="store_true", + ) + + parser.add_argument( + "-m", + "--mode", + nargs=argparse.ONE_OR_MORE, + required=False, + default=["0", "1", "2", "3", "4", "5", "6", "8"], + # would love to do things like "vn!std", but "!" will break interpretation + choices=["0", "1", "2", "3", "4", "5", "6", "8"], + ) + args = parser.parse_args(argv) + + global DEBUG + DEBUG = args.debug + + db = databases.Database(app.settings.DB_DSN) + await db.connect() + + redis = await aioredis.from_url(app.settings.REDIS_DSN) + + ctx = Context(db, redis) + + for mode in args.mode: + mode = GameMode(int(mode)) + + if not args.no_scores: + await recalculate_mode_scores(mode, ctx) + + if not args.no_stats: + await recalculate_mode_users(mode, ctx) + + await app.state.services.http_client.aclose() + await db.disconnect() + await redis.aclose() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main()))