Skip to content

Commit

Permalink
enable leaderboards with user-generated cache and tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
fcaps authored and Brutus5000 committed Nov 19, 2023
1 parent a09eb7f commit f6c513a
Show file tree
Hide file tree
Showing 15 changed files with 503 additions and 57 deletions.
4 changes: 2 additions & 2 deletions .env.faf-stack
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ OAUTH_URL=http://faf-ory-hydra:4444
# you can omit this env and it will fallback to OAUTH_URL if you know what you are doing.
OAUTH_PUBLIC_URL=http://localhost:4444

# unsing the "production" wordpress because the faf-local-stack is just an empty instance without any news etc.
WP_URL=https://direct.faforever.com
# unsing the "xyz" wordpress because the faf-local-stack is just an empty instance without any news etc.
WP_URL=https://direct.faforever.xyz

OAUTH_CLIENT_ID=faf-website
OAUTH_CLIENT_SECRET=banana
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public/styles/css/*

#Ignore environment
.env
.env.faf.xyz

public/js/*.js

Expand Down
18 changes: 13 additions & 5 deletions express.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ const session = require('express-session');
const FileStore = require('session-file-store')(session);
const bodyParser = require('body-parser');
const flash = require('connect-flash');
const fs = require('fs');
const setupCronJobs = require('./scripts/cron-jobs');
const middleware = require('./routes/middleware');
const app = express();
const newsRouter = require('./routes/views/news');
const staticMarkdownRouter = require('./routes/views/staticMarkdownRouter');
const leaderboardRouter = require('./routes/views/leaderboardRouter');
const authRouter = require('./routes/views/auth');

app.locals.clanInvitations = {};
Expand Down Expand Up @@ -71,19 +71,27 @@ function loggedIn(req, res, next) {
}
}

app.use('/news', newsRouter)
app.use('/', staticMarkdownRouter)
//Start and listen on port



// --- R O U T E S ---
// when the website is asked to render "/pageName" it will come here and see what are the "instructions" to render said page. If the page isn't here, then the website won't render it properly.

app.use('/', authRouter)
app.use('/', staticMarkdownRouter)
app.use('/news', newsRouter)
app.use('/leaderboards', leaderboardRouter)

// --- UNPROTECTED ROUTES ---
const appGetRouteArray = [
// This first '' is the home/index page
'', 'newshub', 'campaign-missions', 'scfa-vs-faf', 'donation', 'tutorials-guides', 'ai', 'patchnotes', 'faf-teams', 'contribution', 'content-creators', 'tournaments', 'training', 'leaderboards', 'play', 'clans',];
'', 'newshub', 'campaign-missions', 'scfa-vs-faf', 'donation', 'tutorials-guides', 'ai', 'patchnotes', 'faf-teams', 'contribution', 'content-creators', 'tournaments', 'training', 'play', 'clans',];

//Renders every page written above
appGetRouteArray.forEach(page => app.get(`/${page}`, (req, res) => {
// disabled due https://github.com/FAForever/website/issues/445
if (['leaderboards', 'clans'].includes(page)) {
if (page === 'clans') {
return res.status(503).render('errors/503-known-issue')
}
res.render(page);
Expand Down
61 changes: 61 additions & 0 deletions lib/LeaderboardRepository.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
class LeaderboardRepository {
constructor(javaApiClient, monthsInThePast = 12) {
this.javaApiClient = javaApiClient
this.monthsInThePast = monthsInThePast
}

getUpdateTimeForApiEntries() {
const date = new Date();
date.setMonth(date.getMonth() - this.monthsInThePast);

return date.toISOString()
}

async fetchLeaderboard(id) {
const updateTime = this.getUpdateTimeForApiEntries()

let response = await this.javaApiClient.get(`/data/leaderboardRating?include=player&sort=-rating&filter=leaderboard.id==${id};updateTime=ge=${updateTime}&page[size]=9999`);

if (response.status !== 200) {
throw new Error('LeaderboardRepository::fetchLeaderboard failed with response status "' + response.status + '"')
}

return this.mapResponse(JSON.parse(response.data))
}

mapResponse(data) {
if (typeof data !== 'object' || data === null) {
throw new Error('LeaderboardRepository::mapResponse malformed response, not an object')
}

if (!data.hasOwnProperty('data')) {
throw new Error('LeaderboardRepository::mapResponse malformed response, expected "data"')
}

if (data.data.length === 0) {
console.log('[info] leaderboard empty')

return []
}

if (!data.hasOwnProperty('included')) {
throw new Error('LeaderboardRepository::mapResponse malformed response, expected "included"')
}

let leaderboardData = []

data.data.forEach((item, index) => {
leaderboardData.push({
rating: item.attributes.rating,
totalgames: item.attributes.totalGames,
wonGames: item.attributes.wonGames,
date: item.attributes.updateTime,
label: data.included[index].attributes.login,
})
})

return leaderboardData
}
}

module.exports = LeaderboardRepository
36 changes: 36 additions & 0 deletions lib/LeaderboardService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
class LeaderboardService {
constructor(cacheService, lockService, leaderboardRepository, lockTimeout = 3000) {
this.lockTimeout = lockTimeout
this.cacheService = cacheService
this.lockService = lockService
this.leaderboardRepository = leaderboardRepository
}

async getLeaderboard(id) {

if (typeof (id) !== 'number') {
throw new Error('LeaderboardService:getLeaderboard id must be a number')
}

const cacheKey = 'leaderboard-' + id

if (this.cacheService.has(cacheKey)) {
return this.cacheService.get(cacheKey)
}

if (this.lockService.locked) {
await this.lockService.lock(() => {
}, this.lockTimeout)
return this.getLeaderboard(id)
}

await this.lockService.lock(async () => {
const result = await this.leaderboardRepository.fetchLeaderboard(id)
this.cacheService.set(cacheKey, result);
})

return this.getLeaderboard(id)
}
}

module.exports = LeaderboardService
23 changes: 23 additions & 0 deletions lib/LeaderboardServiceFactory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
const LeaderboardService = require("./LeaderboardService");
const LeaderboardRepository = require("./LeaderboardRepository");
const {LockService} = require("./LockService");
const NodeCache = require("node-cache");
const {Axios} = require("axios");

const leaderboardLock = new LockService()
const cacheService = new NodeCache(
{
stdTTL: 300, // use 5 min for all caches if not changed with ttl
checkperiod: 600 // cleanup memory every 10 min
}
);

module.exports = (javaApiBaseURL, token) => {
const config = {
baseURL: javaApiBaseURL,
headers: {Authorization: `Bearer ${token}`}
};
const javaApiClient = new Axios(config)

return new LeaderboardService(cacheService, leaderboardLock, new LeaderboardRepository(javaApiClient))
}
70 changes: 70 additions & 0 deletions lib/LockService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
class LockoutTimeoutError extends Error {
}

class LockService {
constructor() {
this.queue = [];
this.locked = false;
}

async lock(callback, timeLimitMS = 500) {
let timeoutHandle;
const lockHandler = {}

const timeoutPromise = new Promise((resolve, reject) => {
lockHandler.resolve = resolve
lockHandler.reject = reject

timeoutHandle = setTimeout(
() => reject(new LockoutTimeoutError('LockService timeout reached')),
timeLimitMS
);
});

const asyncPromise = new Promise((resolve, reject) => {
if (this.locked) {
lockHandler.resolve = resolve
lockHandler.reject = reject

this.queue.push(lockHandler);
} else {
this.locked = true;
resolve();
}
});

await Promise.race([asyncPromise, timeoutPromise]).then(async () => {
clearTimeout(timeoutHandle);
try {
if (callback[Symbol.toStringTag] === 'AsyncFunction') {
await callback()
return
}

callback()
} finally {
this.release()
}
}).catch(e => {
let index = this.queue.indexOf(lockHandler);

if (index !== -1) {
this.queue.splice(index, 1);
}

throw e
})
}

release() {
if (this.queue.length > 0) {
const queueItem = this.queue.shift();
queueItem.resolve();
} else {
this.locked = false;
}
}
}

module.exports.LockService = LockService
module.exports.LockoutTimeoutError = LockoutTimeoutError
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"express-session": "^1.17.3",
"express-validator": "7.0.1",
"moment": "^2.29.4",
"node-cache": "^5.1.2",
"node-fetch": "^2.6.7",
"npm-check": "^6.0.1",
"passport": "^0.6.0",
Expand Down
40 changes: 25 additions & 15 deletions public/js/app/leaderboards.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ let currentDate = new Date(minusTimeFilter).toISOString();

async function leaderboardOneJSON(leaderboardFile) {
//Check which category is active
const response = await fetch(`js/app/members/${leaderboardFile}.json`);
const response = await fetch(`leaderboards/${leaderboardFile}.json`);
currentLeaderboard = leaderboardFile;
const data = await response.json();
return await data;
Expand All @@ -37,6 +37,10 @@ function leaderboardUpdate() {
//determines the current page, whether to add or substract the missing players in case we pressed next or previous then it will add or substract players
let playerIndex = pageNumber * 100;
let next100Players = (1 + pageNumber) * 100;

if (next100Players > playerList.length) {
next100Players = playerList.length
}

// Function to add player first second and third background
if (playerIndex === 0) {
Expand All @@ -52,14 +56,14 @@ function leaderboardUpdate() {
if (playerIndex < 0) {
playerIndex = 0;
}
let rating = playerList[playerIndex][1].rating;
let winRate = playerList[playerIndex][1].wonGames / playerList[playerIndex][1].totalgames * 100;
let rating = playerList[playerIndex].rating;
let winRate = playerList[playerIndex].wonGames / playerList[playerIndex].totalgames * 100;
insertPlayer.insertAdjacentHTML('beforebegin', `<div class="newLeaderboardContainer leaderboardDelete column12 leaderboardPlayer${playerIndex}">
<div class="column1">
<h3>${playerIndex + 1}</h3>
</div>
<div class="column4">
<h3>${playerList[playerIndex][0].label}</h3>
<h3>${playerList[playerIndex].label}</h3>
</div>
<div class="column2">
<h3>${rating.toFixed(0)}</h3>
Expand All @@ -68,7 +72,7 @@ function leaderboardUpdate() {
<h3>${winRate.toFixed(1)}%</h3>
</div>
<div class="column3">
<h3>${playerList[playerIndex][1].totalgames}</h3>
<h3>${playerList[playerIndex].totalgames}</h3>
</div>
</div>`
);
Expand Down Expand Up @@ -115,15 +119,15 @@ function timeCheck(timeSelected) {
playerList.push(timedOutPlayers[i]);
}
// Sort players by their rating
playerList.sort((playerA, playerB) => playerB[1].rating - playerA[1].rating);
playerList.sort((playerA, playerB) => playerB.rating - playerA.rating);

//clean slate
timedOutPlayers = [];

//kick all the players that dont meet the time filter
for (let i = 0; i < playerList.length; i++) {

if (currentDate > playerList[i][1].date) {
if (currentDate > playerList[i].date) {

timedOutPlayers.push(playerList[i]);
playerList.splice(i, 1);
Expand Down Expand Up @@ -183,16 +187,16 @@ function findPlayer(playerName) {
leaderboardOneJSON(currentLeaderboard)
.then(() => {
//input from the searchbar becomes playerName and then searchPlayer is their index number
let searchPlayer = playerList.findIndex(element => element[0].label.toLowerCase() === playerName.toLowerCase());
let searchPlayer = playerList.findIndex(element => element.label.toLowerCase() === playerName.toLowerCase());

let rating = playerList[searchPlayer][1].rating;
let winRate = playerList[searchPlayer][1].wonGames / playerList[searchPlayer][1].totalgames * 100;
let rating = playerList[searchPlayer].rating;
let winRate = playerList[searchPlayer].wonGames / playerList[searchPlayer].totalgames * 100;
insertSearch.insertAdjacentHTML('beforebegin', `<div class="newLeaderboardContainer leaderboardDeleteSearch column12">
<div class="column1">
<h3>${searchPlayer + 1}</h3>
</div>
<div class="column4">
<h3>${playerList[searchPlayer][0].label} ${currentLeaderboard} </h3>
<h3>${playerList[searchPlayer].label} ${currentLeaderboard} </h3>
</div>
<div class="column2">
<h3>${rating.toFixed(0)}</h3>
Expand All @@ -201,7 +205,7 @@ function findPlayer(playerName) {
<h3>${winRate.toFixed(1)}%</h3>
</div>
<div class="column3">
<h3>${playerList[searchPlayer][1].totalgames}</h3>
<h3>${playerList[searchPlayer].totalgames}</h3>
</div>
</div>`);

Expand All @@ -212,6 +216,12 @@ function findPlayer(playerName) {
});
}

function selectPlayer(name) {
const element = document.getElementById('input')
element.value = name
element.dispatchEvent(new KeyboardEvent('keyup', {'key': 'Enter'}));
}

//Gets called from the HTML search input form
function pressEnter(event) {
let inputText = event.target.value;
Expand All @@ -220,17 +230,17 @@ function pressEnter(event) {
document.querySelectorAll('.removeOldSearch').forEach(element => element.remove());
} else {
let regex = `^${inputText.toLowerCase()}`;
let searchName = playerList.filter(element => element[0].label.toLowerCase().match(regex));
let searchName = playerList.filter(element => element.label.toLowerCase().match(regex));

document.querySelectorAll('.removeOldSearch').forEach(element => element.remove());
for (let player of searchName.slice(0, 5)) {
document.querySelector('#placeMe').insertAdjacentHTML('afterend', `<li class="removeOldSearch"> ${player[0].label} </li>`);
document.querySelector('#placeMe').insertAdjacentHTML('afterend', `<li class="removeOldSearch" style="cursor: pointer" onclick="selectPlayer('${player.label}')">${player.label}</li>`);
}

if (event.key === 'Enter') {
document.querySelector('#searchResults').classList.remove('appearWhenSearching');
document.querySelector('#clearSearch').classList.remove('appearWhenSearching');
findPlayer(inputText);
findPlayer(inputText.trim());
}
}
document.querySelector('#errorLog').innerText = '';
Expand Down
Loading

0 comments on commit f6c513a

Please sign in to comment.