Bladeren bron

Use cookies for auth, and use subdomains for rulesets

Clément Wolf 7 maanden geleden
bovenliggende
commit
2cc0c2e302
11 gewijzigde bestanden met toevoegingen van 235 en 211 verwijderingen
  1. 13 3
      RUNNING.md
  2. 6 3
      commands.js
  3. 1 1
      config.json.example
  4. 3 3
      discord_interactions.js
  5. 5 9
      glicko.js
  6. 21 0
      nginx.cfg
  7. 5 7
      public/index.html
  8. 63 98
      public/scripts.js
  9. 6 2
      util/helpers.js
  10. 86 64
      website.js
  11. 26 21
      website_api.js

+ 13 - 3
RUNNING.md

@@ -5,6 +5,7 @@ Getting this bot to run all of its features is a long process, so for your conve
 * [Node.JS LTS](https://nodejs.org/en/)
 * [Yarn](https://yarnpkg.com/)
 * [Rust](https://doc.rust-lang.org/cargo/getting-started/installation.html) (for calculating pp)
+* [Nginx](https://www.nginx.com/)
 * On Windows, [Visual Studio 2013/2015/2017/2019 with C++ Option](https://docs.microsoft.com/en-us/visualstudio/install/install-visual-studio?view=vs-2022)
 
 # Installation (basic)
@@ -15,6 +16,17 @@ Getting this bot to run all of its features is a long process, so for your conve
 
 * [Create a new osu! API application](https://osu.ppy.sh/home/account/edit#new-oauth-application), then add the client ID and secret to `osu_v2api_client_id` and `osu_v2api_client_secret` in `config.json`.
 
+* Put [nginx.cfg](nginx.cfg) into nginx's /sites-enabled/ directory.
+
+* Add these lines to your hosts file (/etc/hosts on linux):
+
+```
+127.0.0.1 osu.osudev.local
+127.0.0.1 taiko.osudev.local
+127.0.0.1 catch.osudev.local
+127.0.0.1 mania.osudev.local
+```
+
 That's it! You should be able to run the website with `yarn start`.
 
 # Installation (osu!)
@@ -37,9 +49,7 @@ Try running the bot with `yarn start` and see if it connects successfully. You s
 
 * [Create a new Discord application](https://discord.com/developers/applications) and add the bot id and token to `config.json`
 
-* Create a Discord server with 4 channels and 10 roles, and add all of the relevant IDs to `config.json` by using Right Click -> Copy ID on the server name, channels, and roles
-
-Template for convenience: https://discord.new/VRCmSa5gcVvN
+* Create a Discord server with a welcome channel and a verified role, and add their IDs to `config.json` by using Right Click -> Copy ID on the server name, channel, and role
 
 * Invite the bot to your Discord server ([invite generator](https://discordapi.com/permissions.html))
 

+ 6 - 3
commands.js

@@ -3,6 +3,7 @@ import bancho from './bancho.js';
 import db from './database.js';
 import {get_user_ranks} from './glicko.js';
 import Config from './util/config.js';
+import {gen_url} from './util/helpers.js';
 
 
 async function reply(user, lobby, message) {
@@ -45,7 +46,8 @@ async function rank_command(msg, match, lobby) {
     }
   }
   const fancy_elo = rank_info.elo == '???' ? '???' : Math.round(rank_info.elo);
-  await reply(msg.from, lobby, `[${Config.website_base_url}/u/${user_id}/ ${requested_username}] | Rank: ${rank_info.text} (#${rank_info.rank_nb}) | Elo: ${fancy_elo} | Games played: ${rank_info.total_scores}`);
+  const user_page = gen_url(lobby.data.ruleset, `/u/${user_id}/`);
+  await reply(msg.from, lobby, `[${user_page} ${requested_username}] | Rank: ${rank_info.text} (#${rank_info.rank_nb}) | Elo: ${fancy_elo} | Games played: ${rank_info.total_scores}`);
 }
 
 async function start_command(msg, match, lobby) {
@@ -82,10 +84,11 @@ async function wait_command(msg, match, lobby) {
 }
 
 async function about_command(msg, match, lobby) {
+  const faq_page = gen_url(lobby.data.ruleset, `/faq/`);
   if (lobby) {
-    await lobby.send(`In this lobby, you get a rank based on how well you score against other players. More info: ${Config.website_base_url}/faq/`);
+    await lobby.send(`In this lobby, you get a rank based on how well you score against other players. More info: ${faq_page}`);
   } else {
-    await bancho.privmsg(msg.from, `${Config.website_base_url}/faq/`);
+    await bancho.privmsg(msg.from, faq_page);
   }
 }
 

+ 1 - 1
config.json.example

@@ -19,7 +19,7 @@
     "discord_linked_account_role_id": "909777665223966750",
 
     "HOST_WEBSITE": true,
-    "website_base_url": "https://127.0.0.1:3001",
+    "domain_name": "osudev.local",
     
     "IS_PRODUCTION": false,
     "ENABLE_SENTRY": false,

+ 3 - 3
discord_interactions.js

@@ -4,7 +4,7 @@ import {Client, Intents, MessageActionRow, MessageButton} from 'discord.js';
 
 import db from './database.js';
 import {sync_discord_info} from './discord_updates.js';
-import {capture_sentry_exception} from './util/helpers.js';
+import {capture_sentry_exception, gen_url} from './util/helpers.js';
 import Config from './util/config.js';
 
 const client = new Client({intents: [Intents.FLAGS.GUILDS]});
@@ -37,7 +37,7 @@ async function on_interaction(interaction) {
 
       const res = db.prepare(`SELECT * FROM user WHERE discord_user_id = ?`).get(target.id);
       if (res) {
-        await interaction.reply(`${Config.website_base_url}/u/${res.user_id}`);
+        await interaction.reply(gen_url(0, `/u/${res.user_id}`));
       } else {
         await interaction.reply({
           content: 'That user hasn\'t linked their osu! account yet.',
@@ -111,7 +111,7 @@ async function on_link_osu_account_press(interaction) {
     components: [
       new MessageActionRow().addComponents([
         new MessageButton({
-          url: `https://osu.ppy.sh/oauth/authorize?client_id=${Config.osu_v2api_client_id}&response_type=code&scope=identify&state=${ephemeral_token}&redirect_uri=${Config.website_base_url}/auth`,
+          url: `https://osu.ppy.sh/oauth/authorize?client_id=${Config.osu_v2api_client_id}&response_type=code&scope=identify&state=${ephemeral_token}&redirect_uri=${gen_url(0, '/auth')}`,
           label: 'Verify using osu!web',
           style: 'LINK',
         }),

+ 5 - 9
glicko.js

@@ -6,6 +6,7 @@ import bancho from './bancho.js';
 import db from './database.js';
 import Config from './util/config.js';
 import {get_user_by_id} from './user.js';
+import {gen_url} from './util/helpers.js';
 
 
 const RANK_DIVISIONS = [
@@ -192,15 +193,10 @@ async function save_game_and_update_rating(lobby, game) {
     const new_rank_text = get_rank_text(ratio);
 
     if (old_rank_text != new_rank_text) {
-      if (division_to_index(new_rank_text) > division_to_index(old_rank_text)) {
-        rank_changes.push(`${player.username} [${Config.website_base_url}/u/${player.user_id}/ ▲ ${new_rank_text} ]`);
-      } else {
-        rank_changes.push(`${player.username} [${Config.website_base_url}/u/${player.user_id}/ ▼ ${new_rank_text} ]`);
-      }
-
-      db.prepare(
-          `UPDATE rating SET division = ? WHERE user_id = ? AND mode = ?`,
-      ).run(new_rank_text, player.user_id, game.mode_int);
+      const user_page = gen_url(game.mode_int, `/u/${player.user_id}/`);
+      const direction = (division_to_index(new_rank_text) > division_to_index(old_rank_text)) ? '▲' : '▼';
+      rank_changes.push(`${player.username} [${user_page} ${direction} ${new_rank_text} ]`);
+      db.prepare(`UPDATE rating SET division = ? WHERE user_id = ? AND mode = ?`).run(new_rank_text, player.user_id, game.mode_int);
     }
   }
 

+ 21 - 0
nginx.cfg

@@ -0,0 +1,21 @@
+server {
+    listen 80;
+    server_name osu.osudev.local taiko.osudev.local catch.osudev.local mania.osudev.local;
+    root /path/to/the/repository/osu-ranked-lobbies/public;
+
+    location / {
+        rewrite ^/$ /faq/ redirect;
+        try_files $uri @proxy;
+        expires max;
+        access_log off;
+    }
+
+    location @proxy {
+        proxy_pass http://localhost:3001;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header Host $host;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_intercept_errors on;
+        error_page 404 =200 /index.html;
+    }
+}

+ 5 - 7
public/index.html

@@ -38,27 +38,25 @@
               <img class="h-8" src="/images/mode-osu.png" />
             </button>
             <div id="rulesets-dropdown" class="absolute hidden z-10 mt-4 -ml-12 bg-zinc-700 rounded-md">
-              <button class="choose-ruleset flex p-2 w-36 opacity-75 hover:opacity-100 hover:bg-zinc-900" data-ruleset="0">
+              <button class="choose-ruleset flex p-2 w-36 opacity-75 hover:opacity-100 hover:bg-zinc-900" data-ruleset="osu">
                 <img class="h-8" src="/images/mode-osu.png" />
                 <span class="px-2 leading-7">osu!</span>
               </button>
-              <button class="choose-ruleset flex p-2 w-36 opacity-75 hover:opacity-100 hover:bg-zinc-900" data-ruleset="1">
+              <button class="choose-ruleset flex p-2 w-36 opacity-75 hover:opacity-100 hover:bg-zinc-900" data-ruleset="taiko">
                 <img class="h-8" src="/images/mode-taiko.png" />
                 <span class="px-2 leading-7">osu!taiko</span>
               </button>
-              <button class="choose-ruleset flex p-2 w-36 opacity-75 hover:opacity-100 hover:bg-zinc-900" data-ruleset="2">
+              <button class="choose-ruleset flex p-2 w-36 opacity-75 hover:opacity-100 hover:bg-zinc-900" data-ruleset="catch">
                 <img class="h-8" src="/images/mode-catch.png" />
                 <span class="px-2 leading-7">osu!catch</span>
               </button>
-              <button class="choose-ruleset flex p-2 w-36 opacity-75 hover:opacity-100 hover:bg-zinc-900" data-ruleset="3">
+              <button class="choose-ruleset flex p-2 w-36 opacity-75 hover:opacity-100 hover:bg-zinc-900" data-ruleset="mania">
                 <img class="h-8" src="/images/mode-mania.png" />
                 <span class="px-2 leading-7">osu!mania</span>
               </button>
             </div>
           </div>
 
-          <input type="hidden" name="authenticated_osu_id" id="authenticated_osu_id" value="{{ user_id }}">
-          <input type="hidden" name="token" id="token" value="{{ token }}">
           <a class="login-btn p-2 opacity-75 hover:opacity-100" href="/osu_login">
             <img class="h-8 rounded-full" src="/images/login.png" />
           </a>
@@ -495,6 +493,6 @@
       </div>
     </template>
 
-    <script src="/scripts.js?v=3.2" type="module"></script>
+    <script src="/scripts.js?v=3.3" type="module"></script>
   </body>
 </html>

+ 63 - 98
public/scripts.js

@@ -1,36 +1,49 @@
-const rulesets = ['osu', 'taiko', 'catch', 'mania'];
-let selected_ruleset = parseInt(localStorage.getItem('selected_ruleset') || '0', 10);
-let logged_user_id = localStorage.getItem('user_id');
-let last_match_id = localStorage.getItem('last_match_id');
-let token = localStorage.getItem('token');
-
-function finish_authentication() {
-  const token_val = document.querySelector('#token').value;
-  if (token_val != '{{ token }}') {
-    token = token_val;
-    localStorage.setItem('token', token);
-  }
+const domain_name = 'kiwec.net'; // TODO set this in build step?
 
-  const id = document.querySelector('#authenticated_osu_id').value;
-  if (id != '{{ user_id }}') {
-    logged_user_id = id;
-    localStorage.setItem('user_id', logged_user_id);
-  }
+// Migrate from old authentication method
+const legacy_token = localStorage.getItem('token');
+if (legacy_token != null) {
+  const last_match_id = localStorage.getItem('last_match_id') || '';
 
-  update_header_profile();
-}
-finish_authentication();
+  localStorage.removeItem('token');
+  localStorage.removeItem('user_id');
+  localStorage.removeItem('last_match_id');
 
-function update_selected_ruleset(name) {
-  if (name == 'std') name = 'osu';
-  if (name == 'fruits') name = 'catch';
-  if (!rulesets.includes(name)) name = 'osu';
+  document.cookie = `login_to=${location.href}; path=/; domain=${domain_name}; secure; SameSite=Strict; Max-Age=30000`;
+  document.location = '/auth_migrate?token=' + legacy_token + '&last_match_id=' + last_match_id;
+}
 
-  localStorage.setItem('selected_ruleset', rulesets.indexOf(name));
-  selected_ruleset = rulesets.indexOf(name);
+// Grab info from cookies
+let logged_user_id = null;
+let last_match_id = null;
+const cookies = document.cookies.split(';');
+for (const cookie of cookies) {
+  const foo = cookie.trim().split('=');
+  const key = foo[0];
+  let value = foo[1];
+  if (value.substr(0, 2) == 's:') {
+    value = value.slice(2, value.lastIndexOf('.'));
+  }
+
+  if (key == 'user') {
+    logged_user_id = parseInt(value, 10);
+  } else if (key == 'last_match') {
+    last_match_id = parseInt(value, 10);
+  } else if (key == 'login_to' && value != '') {
+    document.cookie = `login_to=; path=/; domain=${domain_name}; secure; SameSite=Strict; Max-Age=0`;
+    document.location = value;
+  }
+}
 
-  document.querySelector('#toggle-rulesets-dropdown-btn img').src = `/images/mode-${rulesets[selected_ruleset]}.png`;
+// Get current ruleset from hostname (eg. 'mania.kiwec.net')
+const rulesets = ['osu', 'taiko', 'catch', 'mania'];
+const subdomain = location.hostname.split('.')[0];
+let selected_ruleset = rulesets.indexOf(name);
+if (selected_ruleset == -1) {
+  console.warn('No ruleset found for subdomain "' + subdomain + '"! Defaulting to standard.');
+  selected_ruleset = 0;
 }
+document.querySelector('#toggle-rulesets-dropdown-btn img').src = `/images/mode-${rulesets[selected_ruleset]}.png`;
 
 function update_header_highlights() {
   const header_links = document.querySelectorAll('header a');
@@ -45,16 +58,15 @@ function update_header_highlights() {
 
 function update_header_profile() {
   const a = document.querySelector('.login-btn');
-  if (token) {
-    a.href = `/u/${logged_user_id}/${rulesets[selected_ruleset]}/`;
+  if (logged_user_id) {
+    a.href = `/u/${logged_user_id}/`;
     a.querySelector('img').src = `https://s.ppy.sh/a/${logged_user_id}`;
   } else {
-    a.href = '/osu_login';
+    a.href = '/me/';
     a.querySelector('img').src = `/images/login.png`;
   }
 }
 
-
 // Returns the color of a given star rating, matching osu!web's color scheme.
 function stars_to_color(sr) {
   if (sr <= 0.1) {
@@ -93,27 +105,15 @@ document.addEventListener('click', (event) => {
 document.querySelectorAll('.choose-ruleset').forEach((btn) => {
   btn.addEventListener('click', function(event) {
     event.preventDefault();
-    update_selected_ruleset(rulesets[parseInt(this.dataset.ruleset, 10)]);
 
-    const url = location.pathname;
-    const new_url = url.replaceAll(/\/(osu|taiko|catch|mania)/g, '/' + rulesets[selected_ruleset]);
-
-    if (url == new_url) {
-      location.reload();
-    } else {
-      window.history.pushState({}, '', new_url);
-      route(new_url);
-    }
+    const subdomains = location.hostname.split('.');
+    subdomains[0] = this.dataset.ruleset;
+    location.hostname = subdomains.join('.');
   });
 });
 
 
 function click_listener(evt) {
-  if (this.pathname == '/osu_login') {
-    localStorage.setItem('redirect_to', location.pathname);
-    return true;
-  }
-
   // Intercept clicks that don't lead to an external domain
   if (this.host == location.host && this.target != '_blank') {
     evt.preventDefault();
@@ -277,7 +277,7 @@ function render_closed_lobby(lobby, user_has_lobby_open) {
       window.history.pushState({}, '', `/reopen-lobby/${lobby.match_id}`);
       route(`/reopen-lobby/${lobby.match_id}`);
     } else {
-      localStorage.setItem('redirect_to', `/reopen-lobby/${lobby.match_id}`);
+      document.cookie = `login_to=${location.origin}/reopen-lobby/${lobby.match_id}; path=/; domain=${domain_name}; secure; SameSite=Strict; Max-Age=30000`;
       document.location = '/osu_login';
     }
   });
@@ -316,6 +316,7 @@ function render_open_lobby(lobby) {
     evt.stopPropagation();
 
     if (!token) {
+      document.cookie = `login_to=${location.origin}/lobbies/; path=/; domain=${domain_name}; secure; SameSite=Strict; Max-Age=30000`;
       document.location = '/osu_login';
       return;
     }
@@ -356,7 +357,7 @@ async function render_lobbies() {
   const template = document.querySelector('#lobbies-template').content.cloneNode(true);
 
   document.querySelector('main').appendChild(document.querySelector('#loading-template').content.cloneNode(true));
-  const json = await get(`/api/lobbies/${rulesets[selected_ruleset]}/`);
+  const json = await get(`/api/lobbies/`);
   document.querySelector('main .loading-placeholder').remove();
 
   if (json.open.length == 0) {
@@ -395,7 +396,7 @@ async function render_lobbies() {
       window.history.pushState({}, '', '/create-lobby/');
       route('/create-lobby/');
     } else {
-      localStorage.setItem('redirect_to', '/create-lobby/');
+      document.cookie = `login_to=${location.origin}/create-lobby/; path=/; domain=${domain_name}; secure; SameSite=Strict; Max-Age=30000`;
       document.location = '/osu_login';
     }
   });
@@ -421,12 +422,12 @@ function get_country_flag_html(country_code) {
   return twemoji.parse(emoji);
 }
 
-async function render_leaderboard(ruleset, page_num) {
+async function render_leaderboard(page_num) {
   document.title = 'Leaderboard - o!RL';
   const template = document.querySelector('#leaderboard-template').content.cloneNode(true);
 
   document.querySelector('main').appendChild(document.querySelector('#loading-template').content.cloneNode(true));
-  const json = await get(`/api/leaderboard/${ruleset}/${page_num}`);
+  const json = await get(`/api/leaderboard/${page_num}`);
   document.querySelector('main .loading-placeholder').remove();
 
   const lboard = template.querySelector('.leaderboard tbody');
@@ -441,7 +442,7 @@ async function render_leaderboard(ruleset, page_num) {
   }
 
   const pagi_div = template.querySelector('.pagination');
-  render_pagination(pagi_div, json.page, json.max_pages, (num) => `/leaderboard/${ruleset}/page-${num}/`);
+  render_pagination(pagi_div, json.page, json.max_pages, (num) => `/leaderboard/page-${num}/`);
 
   document.querySelector('main').appendChild(template);
 }
@@ -474,15 +475,11 @@ async function render_user(user_id, page_num) {
     'The One': 'the-one',
   };
 
-  const ruleset = rulesets[selected_ruleset];
   const template = document.querySelector('#user-template').content.cloneNode(true);
   template.querySelector('.heading-left img').src = `https://s.ppy.sh/a/${json.user_id}`;
   template.querySelector('.heading-right h1').innerHTML = get_country_flag_html(json.country_code) + ' ' + json.username;
   template.querySelector('.heading-right h1').classList.add(division_to_class[user_info.text]);
   template.querySelector('.heading-right .subheading').href = `https://osu.ppy.sh/users/${json.user_id}`;
-  template.querySelectorAll('.user-modes a').forEach((div) => {
-    div.href = `/u/${json.user_id}/${div.querySelector('img').getAttribute('ruleset')}`;
-  });
 
   const blocks = template.querySelectorAll('.user-focus-block');
   if (user_info.is_ranked) {
@@ -497,7 +494,7 @@ async function render_user(user_id, page_num) {
   document.querySelector('main').appendChild(template);
 
   document.querySelector('main').appendChild(document.querySelector('#loading-template').content.cloneNode(true));
-  const matches_json = await get(`/api/user/${user_id}/${ruleset}/matches/${page_num}`);
+  const matches_json = await get(`/api/user/${user_id}/matches/${page_num}`);
   document.querySelector('main .loading-placeholder').remove();
 
   const tbody = document.querySelector('.match-history tbody');
@@ -518,7 +515,7 @@ async function render_user(user_id, page_num) {
   }
 
   const pagi_div = document.querySelector('.pagination');
-  render_pagination(pagi_div, matches_json.page, matches_json.max_pages, (num) => `/u/${user_id}/${ruleset}/page-${num}/`);
+  render_pagination(pagi_div, matches_json.page, matches_json.max_pages, (num) => `/u/${user_id}/page-${num}/`);
 }
 
 
@@ -526,12 +523,6 @@ async function route(new_url) {
   console.info('Loading ' + new_url);
   update_header_highlights();
   update_header_profile();
-  update_selected_ruleset(rulesets[selected_ruleset]);
-
-  if (new_url == '/osu_login') {
-    document.location = '/osu_login';
-    return;
-  }
 
   let m;
   if (m = new_url.match(/\/create-lobby\//)) {
@@ -687,7 +678,6 @@ async function route(new_url) {
         document.querySelector('.lobby-creation-success').hidden = false;
 
         last_match_id = json_res.lobby.tournament_id;
-        localStorage.setItem('last_match_id', last_match_id);
       } catch (err) {
         document.querySelector('.lobby-creation-error .error-msg').innerText = err.message;
         document.querySelector('.lobby-creation-spinner').hidden = true;
@@ -711,7 +701,7 @@ async function route(new_url) {
           window.history.pushState({}, '', `/reopen-lobby/${last_match_id}`);
           route(`/reopen-lobby/${last_match_id}`);
         } else {
-          localStorage.setItem('redirect_to', `/reopen-lobby/${last_match_id}`);
+          document.cookie = `login_to=${location.origin}/reopen-lobby/${last_match_id}; path=/; domain=${domain_name}; secure; SameSite=Strict; Max-Age=30000`;
           document.location = '/osu_login';
         }
       });
@@ -734,7 +724,6 @@ async function route(new_url) {
       evt.stopPropagation();
 
       const lobby_settings = {
-        ruleset: selected_ruleset,
         title: document.querySelector('input[name="title"]').value,
         map_selection_algo: document.querySelector('main input[name="map-selection-type"]:checked').value,
         map_pool: document.querySelector('main input[name="map-pool"]:checked').value,
@@ -820,7 +809,6 @@ async function route(new_url) {
         document.querySelector('.lobby-creation-success').hidden = false;
 
         last_match_id = json_res.lobby.tournament_id;
-        localStorage.setItem('last_match_id', last_match_id);
       } catch (err) {
         document.querySelector('.lobby-creation-error .error-msg').innerText = err.message;
         document.querySelector('.lobby-creation-spinner').hidden = true;
@@ -853,47 +841,23 @@ async function route(new_url) {
   } else if (m = new_url.match(/\/lobbies\//)) {
     document.querySelector('main').innerHTML = '';
     await render_lobbies();
-  } else if (m = new_url.match(/\/leaderboard\/(\w+)\/(page-(\d+)\/)?/)) {
-    const ruleset = m[1];
-    update_selected_ruleset(ruleset);
-
-    const page_num = m[3] || 1;
+  } else if (m = new_url.match(/\/leaderboard\/(page-(\d+)\/)?/)) {
+    const page_num = m[2] || 1;
     document.querySelector('main').innerHTML = '';
-    await render_leaderboard(ruleset, page_num);
-  } else if (m = new_url.match(/\/u\/(\d+)\/(\w+)\/page-(\d+)\/?/)) {
-    const ruleset = m[2];
-    update_selected_ruleset(ruleset);
-
+    await render_leaderboard(page_num);
+  } else if (m = new_url.match(/\/u\/(\d+)\/page-(\d+)\/?/)) {
     const user_id = m[1];
-    const page_num = m[3] || 1;
+    const page_num = m[2] || 1;
     document.querySelector('main').innerHTML = '';
     await render_user(user_id, page_num);
-  } else if (m = new_url.match(/\/u\/(\d+)\/(\w+)\/?/)) {
-    const ruleset = m[2];
-    update_selected_ruleset(ruleset);
-
+  } else if (m = new_url.match(/\/u\/(\d+)\/?/)) {
     const user_id = m[1];
     document.querySelector('main').innerHTML = '';
     await render_user(user_id, 1);
-  } else if (m = new_url.match(/\/leaderboard\/(page-(\d+)\/)?/)) {
-    const page_num = m[2] || 1;
-    new_url = `/leaderboard/${rulesets[selected_ruleset]}/page-${page_num}/`;
-    window.history.replaceState({}, 'osu! ranked lobbies', new_url);
-    route(new_url);
-  } else if (m = new_url.match(/\/u\/(\d+)\/?/)) {
-    new_url = `/u/${m[1]}/${rulesets[selected_ruleset]}/`;
-    window.history.replaceState({}, 'osu! ranked lobbies', new_url);
-    route(new_url);
   } else {
     const main = document.querySelector('main');
     if (main.innerHTML.indexOf('{{ error }}') != -1) {
       main.innerHTML = 'Page not found.';
-    } else if (main.innerHTML.indexOf('Logged in successfully.') != -1) {
-      const redirect_to = localStorage.getItem('redirect_to');
-      localStorage.removeItem('redirect_to');
-      document.querySelector('main').innerHTML = '';
-      route(redirect_to);
-      return;
     }
   }
 
@@ -913,4 +877,5 @@ async function route(new_url) {
 
 
 // Load pages and hijack browser browsing
+update_header_profile();
 route(location.pathname);

+ 6 - 2
util/helpers.js

@@ -17,13 +17,17 @@ export function capture_sentry_exception(err) {
   }
 }
 
+export function gen_url(mode, path) {
+  const ruleset = ['osu', 'taiko', 'catch', 'mania'][mode || 0];
+  const scheme = Config.IS_PRODUCTION ? 'https://' : 'http://';
+  return `${scheme}${ruleset}.${Config.domain_name}${path}`;
+}
+
 export function random_from(arr) {
   return arr[Math.floor((Math.random() * arr.length))];
 }
 
 export const render_error = async (req, error, code, data = {}) => {
   data.error = error;
-  data.user_id = req.user_id;
-  data.token = req.token;
   return await Mustache.render(base, data);
 };

+ 86 - 64
website.js

@@ -1,9 +1,11 @@
+import cookieParser from 'cookie-parser';
 import express from 'express';
 import fs from 'fs';
 import fetch from 'node-fetch';
 import morgan from 'morgan';
 import Sentry from '@sentry/node';
 import crypto from 'crypto';
+import url from 'node:url';
 
 import bancho from './bancho.js';
 import db from './database.js';
@@ -11,10 +13,12 @@ import {sync_discord_info} from './discord_updates.js';
 import {get_user_ranks} from './glicko.js';
 import {get_user_by_id} from './user.js';
 import Config from './util/config.js';
-import {render_error} from './util/helpers.js';
+import {render_error, gen_url} from './util/helpers.js';
 import {register_routes as register_api_routes} from './website_api.js';
 
 
+const auth_page = gen_url(0, '/auth');
+
 async function listen() {
   const app = express();
 
@@ -25,18 +29,7 @@ async function listen() {
   app.use(morgan(':status :method :url :response-time ms (:remote-addr)'));
   app.enable('trust proxy');
   app.set('trust proxy', () => true);
-  app.use(express.static('public'));
-
-  // Auth middleware
-  app.use(express.json(), async function(req, res, next) {
-    if (req.body && req.body.token) {
-      const info = db.prepare(`SELECT osu_id FROM token WHERE token = ?`).get(req.body.token);
-      req.user_id = info?.osu_id;
-      req.token = req.body?.token;
-    }
-
-    next();
-  });
+  app.use(cookieParser());
 
   await register_api_routes(app);
 
@@ -46,30 +39,60 @@ async function listen() {
 
   // Convenience redirect so we only have to generate the oauth URL here.
   app.get('/osu_login', (req, http_res) => {
-    if (!Config.IS_PRODUCTION) {
-      http_res.redirect('/auth');
-      return;
+    http_res.redirect(`https://osu.ppy.sh/oauth/authorize?client_id=${Config.osu_v2api_client_id}&response_type=code&state=login&scope=identify&redirect_uri=${auth_page}`);
+  });
+
+  // Convenience URL that redirects you to your profile or the login page
+  app.get('/me/', (req, http_res) => {
+    if (req.signedCookies.user) {
+      return http_res.redirect(`${req.protocol}://${req.hostname}/u/${req.signedCookies.user}`);
+    } else {
+      http_res.cookie('login_to', `${req.protocol}://${req.hostname}/me/`, {
+        domain: Config.domain_name,
+        maxAge: 30000,
+        httpOnly: false,
+        secure: Config.IS_PRODUCTION,
+        signed: false,
+        sameSite: 'Strict',
+      });
+      return http_res.redirect('/osu_login');
     }
+  });
 
-    http_res.redirect(`https://osu.ppy.sh/oauth/authorize?client_id=${Config.osu_v2api_client_id}&response_type=code&state=login&scope=identify&redirect_uri=${Config.website_base_url}/auth`);
+  app.get('/auth_migrate', (req, http_res) => {
+    try {
+      const info = db.prepare(`SELECT osu_id FROM token WHERE token = ?`).get(req.query.token);
+      http_res.cookie('user', info.osu_id, {
+        domain: Config.domain_name,
+        maxAge: 34560000000,
+        httpOnly: false,
+        secure: Config.IS_PRODUCTION,
+        signed: true,
+        sameSite: 'Strict',
+      });
+
+      if (req.query.last_match_id) {
+        http_res.cookie('last_match', req.query.last_match_id, {
+          domain: Config.domain_name,
+          maxAge: 34560000000,
+          httpOnly: false,
+          secure: Config.IS_PRODUCTION,
+          signed: true,
+          sameSite: 'Strict',
+        });
+      }
+    } catch {
+      // If token was invalid, just ignore
+    }
+
+    // Nginx will serve index.html on 404
+    // Frontend will redirect to the correct page using the login_to cookie
+    http_res.status(404).send('');
   });
 
   app.get('/auth', async (req, http_res) => {
     let res;
 
-    // Since OAuth is a pain in localhost, always authenticate outside of production.
-    if (!Config.IS_PRODUCTION) {
-      const new_auth_token = crypto.randomBytes(20).toString('hex');
-      db.prepare(`INSERT INTO token (token, created_at, osu_id, last_match) VALUES (?, ?, ?, 106542789)`).run(new_auth_token, Date.now(), Config.osu_id);
-      await get_user_by_id(Config.osu_id, true); // init user if needed
-
-      req.user_id = Config.osu_id;
-      req.token = new_auth_token;
-      http_res.send(await render_error(req, 'Account linked!', 200, {title: 'Account Linked - o!RL'}));
-      http_res.end();
-      return;
-    }
-
     if (!req.query.code) {
       http_res.status(403).send(await render_error(req, 'No auth code provided.', 403));
       return;
@@ -85,7 +108,7 @@ async function listen() {
             client_secret: Config.osu_v2api_client_secret,
             code: req.query.code,
             grant_type: 'authorization_code',
-            redirect_uri: Config.website_base_url + '/auth',
+            redirect_uri: auth_page,
           }),
           headers: {
             'Accept': 'application/json',
@@ -140,19 +163,18 @@ async function listen() {
       // Initialize the user in the database if needed
       await get_user_by_id(user_profile.id, true);
 
-      let user_token = db.prepare(`SELECT token FROM token WHERE osu_id = ?`).get(user_profile.id);
-      if (!user_token) {
-        const new_auth_token = crypto.randomBytes(20).toString('hex');
-        db.prepare(`INSERT INTO token (token, created_at, osu_id) VALUES (?, ?, ?)`).run(new_auth_token, Date.now(), user_profile.id);
-        user_token = {
-          token: new_auth_token,
-        };
-      }
-
-      req.user_id = user_profile.id;
-      req.token = user_token.token;
-      http_res.send(await render_error(req, 'Logged in successfully.', 200, {title: 'Logged in - o!RL'}));
-      http_res.end();
+      http_res.cookie('user', user_profile.id, {
+        domain: Config.domain_name,
+        maxAge: 34560000000,
+        httpOnly: false,
+        secure: Config.IS_PRODUCTION,
+        signed: true,
+        sameSite: 'Strict',
+      });
+
+      // Nginx will serve index.html on 404
+      // Frontend will redirect to the correct page using the login_to cookie
+      http_res.status(404).send('');
     } else {
       // Get discord user id from ephemeral token
       const ephemeral_token = req.query.state;
@@ -184,7 +206,7 @@ async function listen() {
   });
 
   app.post('/get-invite/:banchoId', async (req, http_res) => {
-    if (!req.user_id) {
+    if (!req.signedCookies.user) {
       http_res.status(403).json({error: 'You need to be authenticated to get an invite.'});
       return;
     }
@@ -201,24 +223,29 @@ async function listen() {
       return;
     }
 
-    const user = await get_user_by_id(req.user_id, false);
+    const user = await get_user_by_id(req.signedCookies.user, false);
     await bancho.privmsg(user.username, `${user.username}, here's your invite: [http://osump://${inviting_lobby.invite_id}/ ${inviting_lobby.name}]`);
     http_res.status(200).json({success: true});
   });
 
-  // In production, we let expressjs return a blank page of status 404, so
-  // that nginx serves the index.html page directly. During development
-  // however, it's useful to serve that page since it avoids having to run a
-  // proxy on the development machine.
-  if (!Config.IS_PRODUCTION) {
-    app.get('*', async (req, http_res) => {
-      http_res.set('Cache-control', 'public, max-age=14400');
-      http_res.send(fs.readFileSync('public/index.html', 'utf-8'));
-    });
-  }
+  // Legacy URLs
+  app.get('/leaderboard/:ruleset/*', (req, res, next) => {
+    if (req.params.ruleset.indexOf('page-') != -1) {
+      return next();
+    }
+    const new_path = req.path.replace(`/${req.params.ruleset}/`, '/');
+    return res.redirect(`https://${req.params.ruleset}.kiwec.net${new_path}`);
+  });
+  app.get('/u/:userId/:ruleset/*', (req, res, next) => {
+    if (req.params.ruleset.indexOf('page-') != -1) {
+      return next();
+    }
+    const new_path = req.path.replace(`/${req.params.ruleset}/`, '/');
+    return res.redirect(`https://${req.params.ruleset}.kiwec.net${new_path}`);
+  });
 
   // Dirty hack to handle Discord embeds nicely
-  app.get('/u/:userId', async (req, http_res) => {
+  app.get('/u/:userId', async (req, http_res, next) => {
     if (req.get('User-Agent').indexOf('Discordbot') != -1) {
       const user = await get_user_by_id(req.params.userId, false);
       if (!user) {
@@ -252,12 +279,7 @@ async function listen() {
       return;
     }
 
-    if (Config.IS_PRODUCTION) {
-      http_res.status(404).send('');
-    } else {
-      http_res.set('Cache-control', 'public, max-age=14400');
-      http_res.send(fs.readFileSync('public/index.html', 'utf-8'));
-    }
+    return next();
   });
 
   if (Config.ENABLE_SENTRY) {
@@ -265,7 +287,7 @@ async function listen() {
   }
 
   app.listen(3001, () => {
-    console.log(`Listening on :${3001}`);
+    console.log(`Listening on :${3001}. Access via ${gen_url(0, '/')}`);
   });
 }
 

+ 26 - 21
website_api.js

@@ -68,10 +68,6 @@ function flags_to_mods(flags) {
 
 
 function validate_lobby_settings(settings) {
-  settings.ruleset = parseInt(settings.ruleset, 10);
-  if (![0, 1, 2, 3].includes(settings.ruleset)) {
-    throw new Error('Invalid ruleset');
-  }
   if (!['random', 'pp', 'elo'].includes(settings.map_selection_algo)) {
     throw new Error('Invalid map selection');
   }
@@ -138,7 +134,8 @@ function validate_lobby_settings(settings) {
 }
 
 
-function ruleset_to_mode(ruleset) {
+function get_mode(req) {
+  const ruleset = req.subdomains[0];
   if (ruleset == 'osu') {
     return 0;
   } else if (ruleset == 'taiko') {
@@ -153,10 +150,9 @@ function ruleset_to_mode(ruleset) {
 }
 
 
-async function get_leaderboard_page(ruleset, page_num) {
+async function get_leaderboard_page(mode, page_num) {
   const PLAYERS_PER_PAGE = 20;
 
-  const mode = ruleset_to_mode(ruleset);
   const total_players = db.prepare(
       `SELECT COUNT(*) AS nb FROM rating WHERE sig < ? AND mode = ?`,
   ).get(Config.ranked_sig_cutoff, mode);
@@ -218,8 +214,7 @@ async function get_user_profile(user_id) {
   };
 }
 
-async function get_user_matches(user_id, ruleset, page_num) {
-  const mode = ruleset_to_mode(ruleset);
+async function get_user_matches(user_id, mode, page_num) {
   const total_scores = db.prepare(
       `SELECT COUNT(*) AS nb FROM score WHERE mode = ? AND user_id = ?`,
   ).get(mode, user_id);
@@ -313,9 +308,9 @@ function recover_sql_query_data(filter_query) {
 }
 
 async function register_routes(app) {
-  app.all('/api/leaderboard/:ruleset/:pageNum/', async (req, http_res) => {
+  app.all('/api/leaderboard/:pageNum/', async (req, http_res) => {
     try {
-      const data = await get_leaderboard_page(req.params.ruleset, parseInt(req.params.pageNum, 10));
+      const data = await get_leaderboard_page(get_mode(req), parseInt(req.params.pageNum, 10));
       http_res.set('Cache-control', 'public, max-age=60');
       http_res.json(data);
     } catch (err) {
@@ -333,11 +328,11 @@ async function register_routes(app) {
     }
   });
 
-  app.all('/api/user/:userId/:ruleset/matches/:pageNum/', async (req, http_res) => {
+  app.all('/api/user/:userId/matches/:pageNum/', async (req, http_res) => {
     try {
       const data = await get_user_matches(
           parseInt(req.params.userId, 10),
-          req.params.ruleset,
+          get_mode(req),
           parseInt(req.params.pageNum, 10),
       );
       http_res.set('Cache-control', 'public, max-age=60');
@@ -347,8 +342,8 @@ async function register_routes(app) {
     }
   });
 
-  app.all('/api/lobbies/:ruleset/', async (req, http_res) => {
-    const mode = ruleset_to_mode(req.params.ruleset);
+  app.all('/api/lobbies/', async (req, http_res) => {
+    const mode = get_mode(req);
 
     const lobbies = {
       open: [],
@@ -409,13 +404,13 @@ async function register_routes(app) {
   });
 
   app.post('/api/create-lobby/', express.json(), async (req, http_res) => {
-    if (!req.user_id) {
+    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.user_id) {
+      if (lobby.data.creator_id == req.signedCookies.user) {
         http_res.status(401).json({error: 'You already have a lobby open.'});
         return;
       }
@@ -431,6 +426,7 @@ async function register_routes(app) {
         return;
       }
     } else {
+      req.body.ruleset = get_mode(req);
       validate_lobby_settings(req.body);
 
       lobby_data.ruleset = req.body.ruleset;
@@ -454,7 +450,7 @@ async function register_routes(app) {
       lobby_data.pp_closeness = 50;
     }
 
-    let user = db.prepare(`SELECT username FROM user WHERE user_id = ?`).get(req.user_id);
+    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 :)
@@ -463,7 +459,7 @@ async function register_routes(app) {
       };
     }
     lobby_data.creator = user.username;
-    lobby_data.creator_id = req.user_id;
+    lobby_data.creator_id = req.signedCookies.user;
 
     let lobby = null;
     if (req.body.match_id) {
@@ -478,7 +474,7 @@ async function register_routes(app) {
       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.user_id}`);
+        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;
@@ -492,6 +488,15 @@ async function register_routes(app) {
       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: {
@@ -514,7 +519,7 @@ async function register_routes(app) {
       },
     });
 
-    db.prepare('UPDATE token SET last_match = ? WHERE osu_id = ?').run(lobby.id, req.user_id);
+    db.prepare('UPDATE token SET last_match = ? WHERE osu_id = ?').run(lobby.id, req.signedCookies.user);
   });
 }