123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491 |
- // Note to potential API users:
- // - If you want to do batch requests, it's probably better to just ask for
- // the data instead.
- // - API is subject to change. Message us if you're using it so we avoid
- // breaking it in the future.
- import dayjs from 'dayjs';
- import express from 'express';
- import relativeTime from 'dayjs/plugin/relativeTime.js';
- dayjs.extend(relativeTime);
- import Config from './util/config.js';
- import bancho from './bancho.js';
- import countries from './countries.js';
- import db from './database.js';
- import {get_user_rank, get_active_users} from './elo.js';
- import {init_lobby, get_new_title} from './ranked.js';
- import {announce_new_room} from './discord_updates.js';
- import {recover_sql_query_data, stars_to_color} from './util/helpers.js';
- const USER_NOT_FOUND = new Error('User not found. Have you played a game in a ranked lobby yet?');
- USER_NOT_FOUND.http_code = 404;
- const RULESET_NOT_FOUND = new Error('Ruleset not found. Must be one of "osu", "taiko", "catch" or "mania".');
- RULESET_NOT_FOUND.http_code = 404;
- function mods_to_flags(mod_list) {
- let flags = 0;
- for (const mod of mod_list) {
- if (mod == 'NM') flags |= 0;
- if (mod == 'EZ') flags |= 2;
- if (mod == 'HD') flags |= 8;
- if (mod == 'HR') flags |= 16;
- if (mod == 'SD') flags |= 32;
- if (mod == 'DT') flags |= 64;
- if (mod == 'NC') flags |= 64 | 512;
- if (mod == 'HT') flags |= 256;
- if (mod == 'FL') flags |= 1024;
- if (mod == 'FI') flags |= 1048576;
- if (mod == 'CO') flags |= 33554432;
- if (mod == 'MR') flags |= 1073741824;
- }
- if ((flags & 64) && (flags & 256)) {
- throw new Error('Invalid mod combination');
- }
- if ((flags & 2) && (flags & 16)) {
- throw new Error('Invalid mod combination');
- }
- return flags;
- }
- function flags_to_mods(flags) {
- const mods = [];
- if (flags & 2) mods.push('EZ');
- if (flags & 8) mods.push('HD');
- if (flags & 16) mods.push('HR');
- if (flags & 32) mods.push('SD');
- if ((flags & 64) && !(flags & 512)) mods.push('DT');
- if (flags & 512) mods.push('NC');
- if (flags & 256) mods.push('HT');
- if (flags & 1024) mods.push('FL');
- if (flags & 1048576) mods.push('FI');
- if (flags & 33554432) mods.push('CO');
- if (flags & 1073741824) mods.push('MR');
- return mods;
- }
- function validate_lobby_settings(settings) {
- if (!['random', 'pp', 'elo'].includes(settings.map_selection_algo)) {
- throw new Error('Invalid map selection');
- }
- if (!['leaderboarded', 'collection'].includes(settings.map_pool)) {
- throw new Error('Invalid map pool');
- }
- if (settings.map_pool == 'collection' && isNaN(parseInt(settings.collection_id, 10))) {
- throw new Error('Invalid collection url');
- }
- settings.freemod = !settings.mod_list.includes('NM');
- settings.mods = mods_to_flags(settings.mod_list);
- settings.mod_list = flags_to_mods(settings.mods);
- // Unflag NC - we use the flags for pp search, and DT is used instead
- settings.mods = settings.mods & ~(512);
- settings.min_stars = 0;
- settings.max_stars = 11;
- settings.filter_query = '1';
- for (const filter of settings.filters) {
- const valid = ['pp', 'sr', 'length', 'ar', 'cs', 'od', 'bpm'];
- if (!valid.includes(filter.name)) {
- throw new Error(`Invalid filter '${filter.name}'`);
- }
- if (filter.name == 'cs' && (settings.ruleset == 1 || settings.ruleset == 3)) {
- throw new Error(`CS illegal for taiko/mania`);
- }
- filter.min = parseFloat(filter.min, 10);
- filter.max = parseFloat(filter.max, 10);
- if (isNaN(filter.min)) {
- throw new Error(`Invalid minimum value for filter '${filter.name}'`);
- }
- if (isNaN(filter.max)) {
- throw new Error(`Invalid maximum value for filter '${filter.name}'`);
- }
- if (filter.name == 'sr') {
- filter.name = 'stars';
- settings.min_stars = filter.min;
- settings.max_stars = filter.max;
- }
- settings.filter_query += ` AND ${filter.name} >= ${filter.min} AND ${filter.name} <= ${filter.max}`;
- }
- if (settings.ruleset == 3) {
- let key_query = '0';
- for (const key of settings.key_count) {
- const key_int = parseInt(key, 10);
- if (isNaN(key_int)) {
- throw new Error('Invalid key count');
- }
- key_query += ` OR cs = ${key_int}`;
- }
- if (key_query == '0') {
- throw new Error('You must select one or more key counts');
- }
- settings.filter_query += ` AND (${key_query})`;
- }
- }
- function get_mode(req) {
- const ruleset = req.subdomains[0] || 'osu';
- if (ruleset == 'osu') {
- return 0;
- } else if (ruleset == 'taiko') {
- return 1;
- } else if (ruleset == 'catch') {
- return 2;
- } else if (ruleset == 'mania') {
- return 3;
- } else {
- throw RULESET_NOT_FOUND;
- }
- }
- async function get_leaderboard_page(mode, page_num) {
- const PLAYERS_PER_PAGE = 20;
- // Fix user-provided page number
- const active_users = get_active_users(mode);
- const nb_pages = Math.ceil(active_users / PLAYERS_PER_PAGE);
- if (page_num <= 0 || isNaN(page_num)) {
- page_num = 1;
- }
- if (page_num > nb_pages) {
- page_num = nb_pages;
- }
- const offset = (page_num - 1) * PLAYERS_PER_PAGE;
- const res = db.prepare(`
- SELECT user.user_id, country_code, username, elo FROM user
- INNER JOIN rating ON user.user_id = rating.user_id
- WHERE s3_scores >= ? AND mode = ?
- ORDER BY elo DESC LIMIT ? OFFSET ?`,
- ).all(Config.games_needed_for_rank, mode, PLAYERS_PER_PAGE, offset);
- const data = {
- nb_ranked_players: active_users,
- players: [],
- page: page_num,
- max_pages: nb_pages,
- };
- // Players
- let ranking = offset + 1;
- for (const user of res) {
- data.players.push({
- user_id: user.user_id,
- country_code: user.country_code,
- country_name: countries[user.country_code] || 'Unknown country',
- username: user.username,
- ranking: ranking,
- elo: Math.round(user.elo),
- });
- ranking++;
- }
- return data;
- }
- async function get_user_profile(user_id, mode) {
- const user = db.prepare(`SELECT user_id, country_code, username FROM user WHERE user_id = ?`).get(user_id);
- if (!user) {
- throw USER_NOT_FOUND;
- }
- const ranks = [{}, {}, {}, {}];
- ranks[mode] = get_user_rank(user_id, mode);
- return {
- username: user.username,
- country_code: user.country_code,
- country_name: countries[user.country_code] || 'Unknown country',
- user_id: user.user_id,
- ranks: ranks,
- };
- }
- async function get_user_matches(user_id, mode, page_num) {
- const total_scores = db.prepare(
- `SELECT total_scores AS nb FROM rating WHERE mode = ? AND user_id = ?`,
- ).get(mode, user_id);
- if (total_scores.nb == 0) {
- return {
- matches: [],
- page: 1,
- max_pages: 1,
- };
- }
- // Fix user-provided page number
- const MATCHES_PER_PAGE = 20;
- const nb_pages = Math.ceil(total_scores.nb / MATCHES_PER_PAGE);
- if (page_num <= 0 || isNaN(page_num)) {
- page_num = 1;
- }
- if (page_num > nb_pages) {
- page_num = nb_pages;
- }
- const data = {
- matches: [],
- page: page_num,
- max_pages: nb_pages,
- };
- const offset = (page_num - 1) * MATCHES_PER_PAGE;
- const scores = db.prepare(`
- SELECT beatmap_id, game_id, created_at, placement, elo_diff FROM score
- WHERE user_id = ? AND mode = ?
- ORDER BY created_at DESC LIMIT ? OFFSET ?`,
- ).all(user_id, mode, MATCHES_PER_PAGE, offset);
- for (const score of scores) {
- data.matches.push({
- map: db.prepare('SELECT * FROM map WHERE map_id = ?').get(score.beatmap_id),
- placement: score.placement,
- nb_players: db.prepare('SELECT MAX(placement) AS cnt FROM score WHERE game_id = ?').get(score.game_id).cnt,
- time: dayjs(score.created_at).fromNow(),
- tms: Math.round(score.created_at / 1000),
- elo_diff: score.elo_diff,
- });
- }
- return data;
- }
- async function register_routes(app) {
- app.all('/api/leaderboard/:pageNum/', async (req, http_res) => {
- try {
- const data = await get_leaderboard_page(get_mode(req), parseInt(req.params.pageNum, 10));
- http_res.set('Cache-control', 'public, s-maxage=10');
- http_res.json(data);
- } catch (err) {
- http_res.status(err.http_code || 503).json({error: err.message});
- }
- });
- app.all('/api/user/:userId/', async (req, http_res) => {
- try {
- const data = await get_user_profile(parseInt(req.params.userId, 10), get_mode(req));
- http_res.set('Cache-control', 'public, s-maxage=10');
- http_res.json(data);
- } catch (err) {
- http_res.status(err.http_code || 503).json({error: err.message});
- }
- });
- app.all('/api/user/:userId/matches/:pageNum/', async (req, http_res) => {
- try {
- const data = await get_user_matches(
- parseInt(req.params.userId, 10),
- get_mode(req),
- parseInt(req.params.pageNum, 10),
- );
- http_res.set('Cache-control', 'public, s-maxage=10');
- http_res.json(data);
- } catch (err) {
- http_res.status(err.http_code || 503).json({error: err.message});
- }
- });
- app.all('/api/lobbies/', async (req, http_res) => {
- const mode = get_mode(req);
- const lobbies = [];
- for (const lobby of bancho.joined_lobbies) {
- if (lobby.passworded) continue;
- if (lobby.data.ruleset != mode) continue;
- if (!lobby.data.creator_id) continue; // lobby not initialized yet
- lobbies.push({
- bancho_id: lobby.invite_id,
- nb_players: lobby.players.length,
- name: lobby.name,
- creator_name: lobby.data.creator,
- creator_id: lobby.data.creator_id,
- map: lobby.map,
- map_selection_algo: lobby.data.map_selection_algo,
- map_pool: lobby.data.map_pool,
- collection_id: lobby.data.collection_id,
- collection_name: lobby.data.collection?.name,
- mod_list: lobby.data.mod_list,
- freemod: lobby.data.freemod,
- color: stars_to_color(lobby.map ? lobby.map.stars : ((lobby.data.min_stars + lobby.data.max_stars) / 2)),
- ...recover_sql_query_data(lobby.data.filter_query), // XXX: Cache this
- });
- }
- const closed_lobbies = db.prepare(`
- SELECT * FROM match
- WHERE ruleset = ? AND reopened_as IS NULL AND end_time IS NOT NULL
- ORDER BY end_time DESC LIMIT 6`,
- ).all(mode);
- for (const lobby of closed_lobbies) {
- lobby.data = JSON.parse(lobby.data);
- if (!lobby.data.creator_id) continue; // lobby failed to initialize
- lobbies.push({
- end_time: lobby.end_time,
- match_id: lobby.match_id,
- name: get_new_title(lobby),
- creator_name: lobby.data.creator,
- creator_id: lobby.data.creator_id,
- map_selection_algo: lobby.data.map_selection_algo,
- map_pool: lobby.data.map_pool,
- collection_id: lobby.data.collection_id,
- collection_name: lobby.data.collection?.name,
- mod_list: lobby.data.mod_list,
- freemod: lobby.data.freemod,
- color: stars_to_color((lobby.data.min_stars + lobby.data.max_stars) / 2),
- ...recover_sql_query_data(lobby.data.filter_query), // XXX: Cache this
- });
- }
- http_res.json(lobbies);
- });
- app.post('/api/create-lobby/', express.json(), async (req, http_res) => {
- if (!req.signedCookies.user) {
- http_res.status(403).json({error: 'You need to be authenticated to create a lobby.'});
- return;
- }
- for (const lobby of bancho.joined_lobbies) {
- if (lobby.data.creator_id == req.signedCookies.user) {
- http_res.status(401).json({error: 'You already have a lobby open.'});
- return;
- }
- }
- let lobby_data = {};
- if (req.body.old_match_id) {
- try {
- lobby_data = JSON.parse(db.prepare('SELECT data FROM match WHERE match_id = ?').get(req.body.old_match_id).data);
- } catch (err) {
- http_res.status(401).json({error: 'Invalid match ID.'});
- console.error(err);
- return;
- }
- } else {
- req.body.ruleset = get_mode(req);
- try {
- validate_lobby_settings(req.body);
- } catch (err) {
- console.log(err);
- return http_res.status(400).json({error: 'Invalid lobby settings', details: err.message});
- }
- lobby_data.ruleset = req.body.ruleset;
- lobby_data.title = req.body.title;
- lobby_data.map_selection_algo = req.body.map_selection_algo;
- lobby_data.map_pool = req.body.map_pool;
- if (lobby_data.map_pool == 'collection') {
- lobby_data.collection_id = req.body.collection_id;
- }
- lobby_data.mods = req.body.mods;
- lobby_data.mod_list = req.body.mod_list;
- lobby_data.freemod = req.body.freemod;
- lobby_data.filter_query = req.body.filter_query;
- lobby_data.min_stars = req.body.min_stars;
- lobby_data.max_stars = req.body.max_stars;
- // TODO: make this customizable
- lobby_data.nb_non_repeating = 100;
- // TODO: make this customizable
- lobby_data.pp_closeness = 50;
- }
- let user = db.prepare(`SELECT username FROM user WHERE user_id = ?`).get(req.signedCookies.user);
- if (!user) {
- // User has never played in a ranked lobby.
- // But we still can create a lobby for them :)
- user = {
- username: 'New user',
- };
- }
- lobby_data.creator = user.username;
- lobby_data.creator_id = req.signedCookies.user;
- let lobby = null;
- if (req.body.match_id) {
- try {
- console.info(`Joining lobby of ${lobby_data.creator}...`);
- lobby = await bancho.join(`#mp_${req.body.match_id}`);
- } catch (err) {
- http_res.status(400).json({error: `Failed to join the lobby`, details: err.message});
- return;
- }
- } else {
- try {
- console.info(`Creating lobby for ${lobby_data.creator}...`);
- lobby = await bancho.make(Config.IS_PRODUCTION ? `New o!RL lobby` : `test lobby`);
- await lobby.send(`!mp addref #${req.signedCookies.user}`);
- } catch (err) {
- http_res.status(400).json({error: 'Could not create the lobby', details: err.message});
- return;
- }
- }
- try {
- await init_lobby(lobby, lobby_data, true);
- } catch (err) {
- http_res.status(503).json({error: 'An error occurred while creating the lobby', details: err.message});
- return;
- }
- http_res.cookie('last_match', lobby.id, {
- domain: Config.domain_name,
- maxAge: 34560000000,
- httpOnly: false,
- secure: Config.IS_PRODUCTION,
- signed: true,
- sameSite: 'Strict',
- });
- http_res.status(200).json({
- success: true,
- lobby: {
- bancho_id: lobby.invite_id,
- tournament_id: lobby.id,
- nb_players: lobby.players.length,
- name: lobby.name,
- creator_name: lobby.data.creator,
- creator_id: lobby.data.creator_id,
- min_stars: lobby.data.min_stars,
- max_stars: lobby.data.max_stars,
- map: lobby.map,
- map_selection_algo: lobby.data.map_selection_algo,
- map_pool: lobby.data.map_pool,
- collection_id: lobby.data.collection_id,
- collection_name: lobby.data.collection?.name,
- mod_list: lobby.data.mod_list,
- freemod: lobby.data.freemod,
- ...recover_sql_query_data(lobby.data.filter_query), // XXX: Cache this
- },
- });
- db.prepare('UPDATE token SET last_match = ? WHERE osu_id = ?').run(lobby.id, req.signedCookies.user);
- announce_new_room(lobby);
- });
- }
- export {
- register_routes, get_user_matches, get_leaderboard_page,
- };
|