Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

enable leaderboards with user-generated cache and token #467

Merged
merged 2 commits into from
Nov 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
11 changes: 6 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,20 @@ function loggedIn(req, res, next) {
}
}

app.use('/news', newsRouter)
app.use('/', staticMarkdownRouter)
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, mutexService, leaderboardRepository, lockTimeout = 3000) {
this.lockTimeout = lockTimeout
this.cacheService = cacheService
this.mutexService = mutexService
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.mutexService.locked) {
await this.mutexService.acquire(() => {
}, this.lockTimeout)
return this.getLeaderboard(id)
}

await this.mutexService.acquire(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 {MutexService} = require("./MutexService");
const NodeCache = require("node-cache");
const {Axios} = require("axios");

const leaderboardMutex = new MutexService()
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, leaderboardMutex, new LeaderboardRepository(javaApiClient))
}
70 changes: 70 additions & 0 deletions lib/MutexService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
class AcquireTimeoutError extends Error {
}

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

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

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

timeoutHandle = setTimeout(
() => reject(new AcquireTimeoutError('MutexService 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.MutexService = MutexService
module.exports.AcquireTimeoutError = AcquireTimeoutError
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