// 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, };