website_api.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  1. // Note to potential API users:
  2. // - If you want to do batch requests, it's probably better to just ask for
  3. // the data instead.
  4. // - API is subject to change. Message us if you're using it so we avoid
  5. // breaking it in the future.
  6. import dayjs from 'dayjs';
  7. import express from 'express';
  8. import relativeTime from 'dayjs/plugin/relativeTime.js';
  9. dayjs.extend(relativeTime);
  10. import Config from './util/config.js';
  11. import bancho from './bancho.js';
  12. import countries from './countries.js';
  13. import db from './database.js';
  14. import {get_user_rank, get_active_users} from './elo.js';
  15. import {init_lobby, get_new_title} from './ranked.js';
  16. import {announce_new_room} from './discord_updates.js';
  17. import {recover_sql_query_data, stars_to_color} from './util/helpers.js';
  18. const USER_NOT_FOUND = new Error('User not found. Have you played a game in a ranked lobby yet?');
  19. USER_NOT_FOUND.http_code = 404;
  20. const RULESET_NOT_FOUND = new Error('Ruleset not found. Must be one of "osu", "taiko", "catch" or "mania".');
  21. RULESET_NOT_FOUND.http_code = 404;
  22. function mods_to_flags(mod_list) {
  23. let flags = 0;
  24. for (const mod of mod_list) {
  25. if (mod == 'NM') flags |= 0;
  26. if (mod == 'EZ') flags |= 2;
  27. if (mod == 'HD') flags |= 8;
  28. if (mod == 'HR') flags |= 16;
  29. if (mod == 'SD') flags |= 32;
  30. if (mod == 'DT') flags |= 64;
  31. if (mod == 'NC') flags |= 64 | 512;
  32. if (mod == 'HT') flags |= 256;
  33. if (mod == 'FL') flags |= 1024;
  34. if (mod == 'FI') flags |= 1048576;
  35. if (mod == 'CO') flags |= 33554432;
  36. if (mod == 'MR') flags |= 1073741824;
  37. }
  38. if ((flags & 64) && (flags & 256)) {
  39. throw new Error('Invalid mod combination');
  40. }
  41. if ((flags & 2) && (flags & 16)) {
  42. throw new Error('Invalid mod combination');
  43. }
  44. return flags;
  45. }
  46. function flags_to_mods(flags) {
  47. const mods = [];
  48. if (flags & 2) mods.push('EZ');
  49. if (flags & 8) mods.push('HD');
  50. if (flags & 16) mods.push('HR');
  51. if (flags & 32) mods.push('SD');
  52. if ((flags & 64) && !(flags & 512)) mods.push('DT');
  53. if (flags & 512) mods.push('NC');
  54. if (flags & 256) mods.push('HT');
  55. if (flags & 1024) mods.push('FL');
  56. if (flags & 1048576) mods.push('FI');
  57. if (flags & 33554432) mods.push('CO');
  58. if (flags & 1073741824) mods.push('MR');
  59. return mods;
  60. }
  61. function validate_lobby_settings(settings) {
  62. if (!['random', 'pp', 'elo'].includes(settings.map_selection_algo)) {
  63. throw new Error('Invalid map selection');
  64. }
  65. if (!['leaderboarded', 'collection'].includes(settings.map_pool)) {
  66. throw new Error('Invalid map pool');
  67. }
  68. if (settings.map_pool == 'collection' && isNaN(parseInt(settings.collection_id, 10))) {
  69. throw new Error('Invalid collection url');
  70. }
  71. settings.freemod = !settings.mod_list.includes('NM');
  72. settings.mods = mods_to_flags(settings.mod_list);
  73. settings.mod_list = flags_to_mods(settings.mods);
  74. // Unflag NC - we use the flags for pp search, and DT is used instead
  75. settings.mods = settings.mods & ~(512);
  76. settings.min_stars = 0;
  77. settings.max_stars = 11;
  78. settings.filter_query = '1';
  79. for (const filter of settings.filters) {
  80. const valid = ['pp', 'sr', 'length', 'ar', 'cs', 'od', 'bpm'];
  81. if (!valid.includes(filter.name)) {
  82. throw new Error(`Invalid filter '${filter.name}'`);
  83. }
  84. if (filter.name == 'cs' && (settings.ruleset == 1 || settings.ruleset == 3)) {
  85. throw new Error(`CS illegal for taiko/mania`);
  86. }
  87. filter.min = parseFloat(filter.min, 10);
  88. filter.max = parseFloat(filter.max, 10);
  89. if (isNaN(filter.min)) {
  90. throw new Error(`Invalid minimum value for filter '${filter.name}'`);
  91. }
  92. if (isNaN(filter.max)) {
  93. throw new Error(`Invalid maximum value for filter '${filter.name}'`);
  94. }
  95. if (filter.name == 'sr') {
  96. filter.name = 'stars';
  97. settings.min_stars = filter.min;
  98. settings.max_stars = filter.max;
  99. }
  100. settings.filter_query += ` AND ${filter.name} >= ${filter.min} AND ${filter.name} <= ${filter.max}`;
  101. }
  102. if (settings.ruleset == 3) {
  103. let key_query = '0';
  104. for (const key of settings.key_count) {
  105. const key_int = parseInt(key, 10);
  106. if (isNaN(key_int)) {
  107. throw new Error('Invalid key count');
  108. }
  109. key_query += ` OR cs = ${key_int}`;
  110. }
  111. if (key_query == '0') {
  112. throw new Error('You must select one or more key counts');
  113. }
  114. settings.filter_query += ` AND (${key_query})`;
  115. }
  116. }
  117. function get_mode(req) {
  118. const ruleset = req.subdomains[0] || 'osu';
  119. if (ruleset == 'osu') {
  120. return 0;
  121. } else if (ruleset == 'taiko') {
  122. return 1;
  123. } else if (ruleset == 'catch') {
  124. return 2;
  125. } else if (ruleset == 'mania') {
  126. return 3;
  127. } else {
  128. throw RULESET_NOT_FOUND;
  129. }
  130. }
  131. async function get_leaderboard_page(mode, page_num) {
  132. const PLAYERS_PER_PAGE = 20;
  133. // Fix user-provided page number
  134. const active_users = get_active_users(mode);
  135. const nb_pages = Math.ceil(active_users / PLAYERS_PER_PAGE);
  136. if (page_num <= 0 || isNaN(page_num)) {
  137. page_num = 1;
  138. }
  139. if (page_num > nb_pages) {
  140. page_num = nb_pages;
  141. }
  142. const offset = (page_num - 1) * PLAYERS_PER_PAGE;
  143. const res = db.prepare(`
  144. SELECT user.user_id, country_code, username, elo FROM user
  145. INNER JOIN rating ON user.user_id = rating.user_id
  146. WHERE s3_scores >= ? AND mode = ?
  147. ORDER BY elo DESC LIMIT ? OFFSET ?`,
  148. ).all(Config.games_needed_for_rank, mode, PLAYERS_PER_PAGE, offset);
  149. const data = {
  150. nb_ranked_players: active_users,
  151. players: [],
  152. page: page_num,
  153. max_pages: nb_pages,
  154. };
  155. // Players
  156. let ranking = offset + 1;
  157. for (const user of res) {
  158. data.players.push({
  159. user_id: user.user_id,
  160. country_code: user.country_code,
  161. country_name: countries[user.country_code] || 'Unknown country',
  162. username: user.username,
  163. ranking: ranking,
  164. elo: Math.round(user.elo),
  165. });
  166. ranking++;
  167. }
  168. return data;
  169. }
  170. async function get_user_profile(user_id, mode) {
  171. const user = db.prepare(`SELECT user_id, country_code, username FROM user WHERE user_id = ?`).get(user_id);
  172. if (!user) {
  173. throw USER_NOT_FOUND;
  174. }
  175. const ranks = [{}, {}, {}, {}];
  176. ranks[mode] = get_user_rank(user_id, mode);
  177. return {
  178. username: user.username,
  179. country_code: user.country_code,
  180. country_name: countries[user.country_code] || 'Unknown country',
  181. user_id: user.user_id,
  182. ranks: ranks,
  183. };
  184. }
  185. async function get_user_matches(user_id, mode, page_num) {
  186. const total_scores = db.prepare(
  187. `SELECT total_scores AS nb FROM rating WHERE mode = ? AND user_id = ?`,
  188. ).get(mode, user_id);
  189. if (total_scores.nb == 0) {
  190. return {
  191. matches: [],
  192. page: 1,
  193. max_pages: 1,
  194. };
  195. }
  196. // Fix user-provided page number
  197. const MATCHES_PER_PAGE = 20;
  198. const nb_pages = Math.ceil(total_scores.nb / MATCHES_PER_PAGE);
  199. if (page_num <= 0 || isNaN(page_num)) {
  200. page_num = 1;
  201. }
  202. if (page_num > nb_pages) {
  203. page_num = nb_pages;
  204. }
  205. const data = {
  206. matches: [],
  207. page: page_num,
  208. max_pages: nb_pages,
  209. };
  210. const offset = (page_num - 1) * MATCHES_PER_PAGE;
  211. const scores = db.prepare(`
  212. SELECT beatmap_id, game_id, created_at, placement, elo_diff FROM score
  213. WHERE user_id = ? AND mode = ?
  214. ORDER BY created_at DESC LIMIT ? OFFSET ?`,
  215. ).all(user_id, mode, MATCHES_PER_PAGE, offset);
  216. for (const score of scores) {
  217. data.matches.push({
  218. map: db.prepare('SELECT * FROM map WHERE map_id = ?').get(score.beatmap_id),
  219. placement: score.placement,
  220. nb_players: db.prepare('SELECT MAX(placement) AS cnt FROM score WHERE game_id = ?').get(score.game_id).cnt,
  221. time: dayjs(score.created_at).fromNow(),
  222. tms: Math.round(score.created_at / 1000),
  223. elo_diff: score.elo_diff,
  224. });
  225. }
  226. return data;
  227. }
  228. async function register_routes(app) {
  229. app.all('/api/leaderboard/:pageNum/', async (req, http_res) => {
  230. try {
  231. const data = await get_leaderboard_page(get_mode(req), parseInt(req.params.pageNum, 10));
  232. http_res.set('Cache-control', 'public, s-maxage=10');
  233. http_res.json(data);
  234. } catch (err) {
  235. http_res.status(err.http_code || 503).json({error: err.message});
  236. }
  237. });
  238. app.all('/api/user/:userId/', async (req, http_res) => {
  239. try {
  240. const data = await get_user_profile(parseInt(req.params.userId, 10), get_mode(req));
  241. http_res.set('Cache-control', 'public, s-maxage=10');
  242. http_res.json(data);
  243. } catch (err) {
  244. http_res.status(err.http_code || 503).json({error: err.message});
  245. }
  246. });
  247. app.all('/api/user/:userId/matches/:pageNum/', async (req, http_res) => {
  248. try {
  249. const data = await get_user_matches(
  250. parseInt(req.params.userId, 10),
  251. get_mode(req),
  252. parseInt(req.params.pageNum, 10),
  253. );
  254. http_res.set('Cache-control', 'public, s-maxage=10');
  255. http_res.json(data);
  256. } catch (err) {
  257. http_res.status(err.http_code || 503).json({error: err.message});
  258. }
  259. });
  260. app.all('/api/lobbies/', async (req, http_res) => {
  261. const mode = get_mode(req);
  262. const lobbies = [];
  263. for (const lobby of bancho.joined_lobbies) {
  264. if (lobby.passworded) continue;
  265. if (lobby.data.ruleset != mode) continue;
  266. if (!lobby.data.creator_id) continue; // lobby not initialized yet
  267. lobbies.push({
  268. bancho_id: lobby.invite_id,
  269. nb_players: lobby.players.length,
  270. name: lobby.name,
  271. creator_name: lobby.data.creator,
  272. creator_id: lobby.data.creator_id,
  273. map: lobby.map,
  274. map_selection_algo: lobby.data.map_selection_algo,
  275. map_pool: lobby.data.map_pool,
  276. collection_id: lobby.data.collection_id,
  277. collection_name: lobby.data.collection?.name,
  278. mod_list: lobby.data.mod_list,
  279. freemod: lobby.data.freemod,
  280. color: stars_to_color(lobby.map ? lobby.map.stars : ((lobby.data.min_stars + lobby.data.max_stars) / 2)),
  281. ...recover_sql_query_data(lobby.data.filter_query), // XXX: Cache this
  282. });
  283. }
  284. const closed_lobbies = db.prepare(`
  285. SELECT * FROM match
  286. WHERE ruleset = ? AND reopened_as IS NULL AND end_time IS NOT NULL
  287. ORDER BY end_time DESC LIMIT 6`,
  288. ).all(mode);
  289. for (const lobby of closed_lobbies) {
  290. lobby.data = JSON.parse(lobby.data);
  291. if (!lobby.data.creator_id) continue; // lobby failed to initialize
  292. lobbies.push({
  293. end_time: lobby.end_time,
  294. match_id: lobby.match_id,
  295. name: get_new_title(lobby),
  296. creator_name: lobby.data.creator,
  297. creator_id: lobby.data.creator_id,
  298. map_selection_algo: lobby.data.map_selection_algo,
  299. map_pool: lobby.data.map_pool,
  300. collection_id: lobby.data.collection_id,
  301. collection_name: lobby.data.collection?.name,
  302. mod_list: lobby.data.mod_list,
  303. freemod: lobby.data.freemod,
  304. color: stars_to_color((lobby.data.min_stars + lobby.data.max_stars) / 2),
  305. ...recover_sql_query_data(lobby.data.filter_query), // XXX: Cache this
  306. });
  307. }
  308. http_res.json(lobbies);
  309. });
  310. app.post('/api/create-lobby/', express.json(), async (req, http_res) => {
  311. if (!req.signedCookies.user) {
  312. http_res.status(403).json({error: 'You need to be authenticated to create a lobby.'});
  313. return;
  314. }
  315. for (const lobby of bancho.joined_lobbies) {
  316. if (lobby.data.creator_id == req.signedCookies.user) {
  317. http_res.status(401).json({error: 'You already have a lobby open.'});
  318. return;
  319. }
  320. }
  321. let lobby_data = {};
  322. if (req.body.old_match_id) {
  323. try {
  324. lobby_data = JSON.parse(db.prepare('SELECT data FROM match WHERE match_id = ?').get(req.body.old_match_id).data);
  325. } catch (err) {
  326. http_res.status(401).json({error: 'Invalid match ID.'});
  327. console.error(err);
  328. return;
  329. }
  330. } else {
  331. req.body.ruleset = get_mode(req);
  332. try {
  333. validate_lobby_settings(req.body);
  334. } catch (err) {
  335. console.log(err);
  336. return http_res.status(400).json({error: 'Invalid lobby settings', details: err.message});
  337. }
  338. lobby_data.ruleset = req.body.ruleset;
  339. lobby_data.title = req.body.title;
  340. lobby_data.map_selection_algo = req.body.map_selection_algo;
  341. lobby_data.map_pool = req.body.map_pool;
  342. if (lobby_data.map_pool == 'collection') {
  343. lobby_data.collection_id = req.body.collection_id;
  344. }
  345. lobby_data.mods = req.body.mods;
  346. lobby_data.mod_list = req.body.mod_list;
  347. lobby_data.freemod = req.body.freemod;
  348. lobby_data.filter_query = req.body.filter_query;
  349. lobby_data.min_stars = req.body.min_stars;
  350. lobby_data.max_stars = req.body.max_stars;
  351. // TODO: make this customizable
  352. lobby_data.nb_non_repeating = 100;
  353. // TODO: make this customizable
  354. lobby_data.pp_closeness = 50;
  355. }
  356. let user = db.prepare(`SELECT username FROM user WHERE user_id = ?`).get(req.signedCookies.user);
  357. if (!user) {
  358. // User has never played in a ranked lobby.
  359. // But we still can create a lobby for them :)
  360. user = {
  361. username: 'New user',
  362. };
  363. }
  364. lobby_data.creator = user.username;
  365. lobby_data.creator_id = req.signedCookies.user;
  366. let lobby = null;
  367. if (req.body.match_id) {
  368. try {
  369. console.info(`Joining lobby of ${lobby_data.creator}...`);
  370. lobby = await bancho.join(`#mp_${req.body.match_id}`);
  371. } catch (err) {
  372. http_res.status(400).json({error: `Failed to join the lobby`, details: err.message});
  373. return;
  374. }
  375. } else {
  376. try {
  377. console.info(`Creating lobby for ${lobby_data.creator}...`);
  378. lobby = await bancho.make(Config.IS_PRODUCTION ? `New o!RL lobby` : `test lobby`);
  379. await lobby.send(`!mp addref #${req.signedCookies.user}`);
  380. } catch (err) {
  381. http_res.status(400).json({error: 'Could not create the lobby', details: err.message});
  382. return;
  383. }
  384. }
  385. try {
  386. await init_lobby(lobby, lobby_data, true);
  387. } catch (err) {
  388. http_res.status(503).json({error: 'An error occurred while creating the lobby', details: err.message});
  389. return;
  390. }
  391. http_res.cookie('last_match', lobby.id, {
  392. domain: Config.domain_name,
  393. maxAge: 34560000000,
  394. httpOnly: false,
  395. secure: Config.IS_PRODUCTION,
  396. signed: true,
  397. sameSite: 'Strict',
  398. });
  399. http_res.status(200).json({
  400. success: true,
  401. lobby: {
  402. bancho_id: lobby.invite_id,
  403. tournament_id: lobby.id,
  404. nb_players: lobby.players.length,
  405. name: lobby.name,
  406. creator_name: lobby.data.creator,
  407. creator_id: lobby.data.creator_id,
  408. min_stars: lobby.data.min_stars,
  409. max_stars: lobby.data.max_stars,
  410. map: lobby.map,
  411. map_selection_algo: lobby.data.map_selection_algo,
  412. map_pool: lobby.data.map_pool,
  413. collection_id: lobby.data.collection_id,
  414. collection_name: lobby.data.collection?.name,
  415. mod_list: lobby.data.mod_list,
  416. freemod: lobby.data.freemod,
  417. ...recover_sql_query_data(lobby.data.filter_query), // XXX: Cache this
  418. },
  419. });
  420. db.prepare('UPDATE token SET last_match = ? WHERE osu_id = ?').run(lobby.id, req.signedCookies.user);
  421. announce_new_room(lobby);
  422. });
  423. }
  424. export {
  425. register_routes, get_user_matches, get_leaderboard_page,
  426. };