Skip to content
This repository was archived by the owner on Feb 4, 2025. It is now read-only.

Commit 110885c

Browse files
committed
add leaderboard caching and pagination
1 parent 5b39e41 commit 110885c

File tree

16 files changed

+211
-85
lines changed

16 files changed

+211
-85
lines changed

app.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
const path = require('path')
22
const express = require('express')
33

4+
require('./server/leaderboard')
5+
46
const app = express()
57

68
app.use(express.raw({
@@ -11,5 +13,12 @@ app.use('/api/v1', require('./server/api'))
1113

1214
const staticPath = path.join(__dirname, '/build')
1315
app.use(express.static(staticPath, { extensions: ['html'] }))
16+
app.use((req, res, next) => {
17+
if (req.method !== 'GET') {
18+
next()
19+
return
20+
}
21+
res.sendFile(path.join(staticPath, 'index.html'))
22+
})
1423

1524
module.exports = app

config/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@ module.exports = {
1212
eligible: 0,
1313
ineligible: 1
1414
},
15-
loginTimeout: 10 * 60 * 1000
15+
loginTimeout: 10 * 60 * 1000,
16+
leaderboardUpdateInterval: 10 * 1000
1617
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
exports.up = function (pgm) {
2+
pgm.addColumns('solves', {
3+
createdat: { type: 'timestamp', notNull: true }
4+
})
5+
}
6+
7+
exports.down = function (pgm) {
8+
pgm.dropColumns('solves', ['createdat'])
9+
}

server/api/index.js

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,21 +29,23 @@ const routeValidators = routes.map((route) => {
2929
return ret
3030
})
3131

32+
const makeSendResponse = (res) => (responseKind, data = null) => {
33+
const response = responseList[responseKind]
34+
if (response === undefined) {
35+
throw new Error(`unknown response ${responseKind}`)
36+
}
37+
res.set('content-type', 'application/json')
38+
res.status(response.status)
39+
res.send(JSON.stringify({
40+
kind: responseKind,
41+
message: response.message,
42+
data
43+
}))
44+
}
45+
3246
routes.forEach((route, i) => {
3347
router[route.method](route.path, async (req, res) => {
34-
const sendResponse = (responseKind, data = null) => {
35-
const response = responseList[responseKind]
36-
if (response === undefined) {
37-
throw new Error(`unknown response ${responseKind}`)
38-
}
39-
res.set('content-type', 'application/json')
40-
res.status(response.status)
41-
res.send(JSON.stringify({
42-
kind: responseKind,
43-
message: response.message,
44-
data
45-
}))
46-
}
48+
const sendResponse = makeSendResponse(res)
4749

4850
if (req.body instanceof Buffer) {
4951
try {
@@ -100,4 +102,8 @@ routes.forEach((route, i) => {
100102
})
101103
})
102104

105+
router.use((req, res) => {
106+
makeSendResponse(res)(responses.badEndpoint)
107+
})
108+
103109
module.exports = router

server/api/leaderboard.js

Lines changed: 24 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,34 @@
1-
const db = require('../database')
2-
const challenges = require('../challenges')
31
const { responses } = require('../responses')
4-
5-
const util = require('../util')
2+
const cache = require('../cache')
63

74
module.exports = {
85
method: 'get',
96
path: '/leaderboard',
107
requireAuth: false,
11-
handler: async ({ req }) => {
12-
const solveAmount = {}
13-
const challengeValues = {}
14-
const userSolves = {}
15-
const userScores = []
16-
17-
const solves = await db.solves.getAllSolves()
18-
const users = await db.users.getAllUsers()
19-
20-
for (let i = 0; i < solves.length; i++) {
21-
// Accumulate in solveAmount
22-
if (!(solves[i].challengeid in solveAmount)) {
23-
solveAmount[solves[i].challengeid] = 1
24-
} else {
25-
solveAmount[solves[i].challengeid] += 1
26-
}
27-
// Store which challenges each user solved for later
28-
if (!(solves[i].userid in userSolves)) {
29-
userSolves[solves[i].userid] = [solves[i].challengeid]
30-
} else {
31-
userSolves[solves[i].userid].push(solves[i].challengeid)
32-
}
33-
}
34-
35-
const allChallenges = challenges.getAllChallenges()
36-
37-
for (let i = 0; i < allChallenges.length; i++) {
38-
const challenge = allChallenges[i]
39-
if (!(challenge.id in solveAmount)) {
40-
// There are currently no solves
41-
challengeValues[challenge.id] = util.scores.getScore('dynamic', challenge.points.min, challenge.points.max, 0)
42-
} else {
43-
challengeValues[challenge.id] = util.scores.getScore('dynamic', challenge.points.min, challenge.points.max, solveAmount[challenge.id])
44-
}
45-
}
46-
47-
for (let i = 0; i < users.length; i++) {
48-
if (!(users[i].userid in userSolves)) {
49-
// The user has not solved anything
50-
userScores.push([users[i].name, 0])
51-
} else {
52-
let currScore = 0
53-
for (let j = 0; j < userSolves[users[i].userid].length; j++) {
54-
// Add the score for the specific solve loaded fr om the challengeValues array using ids
55-
currScore += challengeValues[userSolves[users[i].userid][j]]
8+
schema: {
9+
query: {
10+
type: 'object',
11+
properties: {
12+
limit: {
13+
type: 'string'
14+
},
15+
offset: {
16+
type: 'string'
5617
}
57-
userScores.push([users[i].name, currScore])
58-
}
18+
},
19+
required: ['limit', 'offset']
5920
}
60-
61-
const sortedUsers = userScores.sort((a, b) => b[1] - a[1])
62-
63-
return [responses.goodLeaderboard, sortedUsers]
21+
},
22+
handler: async ({ req }) => {
23+
const limit = parseInt(req.query.limit)
24+
const offset = parseInt(req.query.offset)
25+
if (limit < 0 || offset < 0) {
26+
return responses.badBody
27+
}
28+
const result = await cache.leaderboard.getRange({
29+
start: offset,
30+
end: offset + limit
31+
})
32+
return [responses.goodLeaderboard, result]
6433
}
6534
}

server/api/submitflag.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ module.exports = {
3838
if (submittedFlag === challenge.flag) {
3939
const solved = await db.solves.getSolvesByUserIdAndChallId({ userid: uuid, challengeid: challengeid })
4040
if (solved === undefined) {
41-
db.solves.newSolve({ id: uuidv4(), challengeid: challengeid, userid: uuid })
41+
db.solves.newSolve({ id: uuidv4(), challengeid: challengeid, userid: uuid, createdat: new Date() })
4242
return responses.goodFlag
4343
} else {
4444
return responses.alreadySolved

server/auth/token.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const tokenKinds = {
1414
const tokenExpiries = {
1515
[tokenKinds.auth]: Infinity,
1616
[tokenKinds.team]: Infinity,
17-
[tokenKinds.verify]: 1000 * 60 * 10
17+
[tokenKinds.verify]: config.loginTimeout
1818
}
1919

2020
const timeNow = () => Math.floor(Date.now() / 1000)

server/cache/client.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const redis = require('redis')
2+
const config = require('../../config')
3+
4+
const client = redis.createClient({
5+
url: config.redisUrl
6+
})
7+
8+
module.exports = client

server/cache/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
module.exports = {
2-
login: require('./login')
2+
login: require('./login'),
3+
leaderboard: require('./leaderboard')
34
}

server/cache/leaderboard.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
const { promisify } = require('util')
2+
const client = require('./client')
3+
4+
const redisEval = promisify(client.eval.bind(client))
5+
const redisLrange = promisify(client.lrange.bind(client))
6+
7+
const setLeaderboard = async (leaderboard) => {
8+
if (leaderboard.length === 0) {
9+
return
10+
}
11+
await redisEval(
12+
'redis.call("DEL", KEYS[1]); redis.call("RPUSH", KEYS[1], unpack(cjson.decode(ARGV[1])))',
13+
1,
14+
'leaderboard',
15+
JSON.stringify(leaderboard.flat())
16+
)
17+
}
18+
19+
const getRange = async ({ start, end }) => {
20+
let redisResult = []
21+
try {
22+
redisResult = await redisLrange('leaderboard', start * 3, end * 3 - 1)
23+
} catch (e) {}
24+
const result = []
25+
for (let i = 0; i < redisResult.length / 3; i++) {
26+
result.push([redisResult[i], redisResult[i + 1], parseInt(redisResult[i + 2])])
27+
}
28+
return result
29+
}
30+
31+
module.exports = {
32+
setLeaderboard,
33+
getRange
34+
}

server/cache/login.js

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
const { promisify } = require('util')
2-
const redis = require('redis')
32
const config = require('../../config')
4-
5-
const client = redis.createClient({
6-
url: config.redisUrl
7-
})
3+
const client = require('./client')
84

95
const prefixes = {
106
login: 'l'

server/database/solves.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,19 @@ const db = require('./db')
22

33
const ret = {
44
getAllSolves: () => {
5-
return db.query('SELECT * FROM solves')
5+
return db.query('SELECT * FROM solves ORDER BY createdat DESC')
66
.then(res => res.rows)
77
},
88
getSolvesByUserId: ({ userid }) => {
9-
return db.query('SELECT * FROM solves WHERE userid = $1', [userid])
9+
return db.query('SELECT * FROM solves WHERE userid = $1 ORDER BY createdat DESC', [userid])
1010
.then(res => res.rows)
1111
},
1212
getSolvesByUserIdAndChallId: ({ userid, challengeid }) => {
13-
return db.query('SELECT * FROM solves WHERE userid = $1 AND challengeid = $2', [userid, challengeid])
13+
return db.query('SELECT * FROM solves WHERE userid = $1 AND challengeid = $2 ORDER BY createdat DESC', [userid, challengeid])
1414
.then(res => res.rows[0])
1515
},
16-
newSolve: ({ id, userid, challengeid }) => {
17-
return db.query('INSERT INTO solves (id, challengeid, userid) VALUES ($1, $2, $3) RETURNING *', [id, challengeid, userid])
16+
newSolve: ({ id, userid, challengeid, createdat }) => {
17+
return db.query('INSERT INTO solves (id, challengeid, userid, createdat) VALUES ($1, $2, $3, $4) RETURNING *', [id, challengeid, userid, createdat])
1818
.then(res => res.rows[0])
1919
},
2020
removeSolvesByUserId: ({ userid }) => {

server/leaderboard/calculate.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
const { workerData, parentPort } = require('worker_threads')
2+
const util = require('../util')
3+
4+
const { solves, users, allChallenges } = workerData
5+
6+
const solveAmount = new Map()
7+
const challengeValues = new Map()
8+
const userSolves = new Map()
9+
const userLastSolves = new Map()
10+
const userScores = []
11+
12+
for (let i = 0; i < solves.length; i++) {
13+
// Accumulate in solveAmount
14+
const challId = solves[i].challengeid
15+
const userId = solves[i].userid
16+
if (!(challId in solveAmount)) {
17+
solveAmount.set(challId, 1)
18+
} else {
19+
solveAmount.set(challId, solveAmount.get(challId) + 1)
20+
}
21+
// Store which challenges each user solved for later
22+
if (!(userId in userSolves)) {
23+
userSolves.set(userId, [challId])
24+
userLastSolves.set(userId, solves[i].createdat)
25+
} else {
26+
userSolves.get(solves[i].userid).push(challId)
27+
}
28+
}
29+
30+
for (let i = 0; i < allChallenges.length; i++) {
31+
const challenge = allChallenges[i]
32+
if (!(challenge.id in solveAmount)) {
33+
// There are currently no solves
34+
challengeValues.set(challenge.id, util.scores.getScore('dynamic', challenge.points.min, challenge.points.max, 0))
35+
} else {
36+
challengeValues.set(challenge.id, util.scores.getScore('dynamic', challenge.points.min, challenge.points.max, solveAmount.get(challenge.id)))
37+
}
38+
}
39+
40+
for (let i = 0; i < users.length; i++) {
41+
const userId = users[i].userid
42+
let currScore = 0
43+
const solvedChalls = userSolves.get(userId)
44+
for (let j = 0; j < solvedChalls.length; j++) {
45+
// Add the score for the specific solve loaded fr om the challengeValues array using ids
46+
currScore += challengeValues.get(solvedChalls[j])
47+
}
48+
userScores.push([userId, users[i].name, currScore, userLastSolves.get(userId)])
49+
}
50+
51+
const sortedUsers = userScores.sort((a, b) => {
52+
const scoreCompare = b[2] - a[2]
53+
if (scoreCompare !== 0) {
54+
return scoreCompare
55+
}
56+
return a[3] - b[3]
57+
}).map((user) => user.slice(0, 3))
58+
59+
parentPort.postMessage(sortedUsers)

server/leaderboard/index.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
const path = require('path')
2+
const { Worker } = require('worker_threads')
3+
const database = require('../database')
4+
const challenges = require('../challenges')
5+
const cache = require('../cache')
6+
const config = require('../../config')
7+
8+
const fetchData = async () => {
9+
const [solves, users] = await Promise.all([
10+
database.solves.getAllSolves(),
11+
database.users.getAllUsers()
12+
])
13+
return {
14+
solves,
15+
users,
16+
allChallenges: challenges.getAllChallenges()
17+
}
18+
}
19+
20+
const runUpdate = async () => {
21+
const worker = new Worker(path.join(__dirname, 'calculate.js'), {
22+
workerData: await fetchData()
23+
})
24+
worker.once('message', (data) => {
25+
cache.leaderboard.setLeaderboard(data)
26+
})
27+
}
28+
29+
setInterval(runUpdate, config.leaderboardUpdateInterval)
30+
runUpdate()

server/responses/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ const responseList = {
6363
status: 400,
6464
message: 'The request JSON body is malformed.'
6565
},
66+
badEndpoint: {
67+
status: 404,
68+
message: 'The request endpoint could not be found.'
69+
},
6670
errorInternal: {
6771
status: 500,
6872
message: 'An internal error occurred.'

0 commit comments

Comments
 (0)