Skip to content

Commit

Permalink
(wip) client & api fixes
Browse files Browse the repository at this point in the history
  • Loading branch information
shine00chang committed Nov 24, 2024
1 parent 980ea46 commit f0134b4
Show file tree
Hide file tree
Showing 25 changed files with 2,641 additions and 53 deletions.
10 changes: 10 additions & 0 deletions packages/api-server/src/auth/contest.auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ export async function contestBegan (request: FastifyRequest, reply: FastifyReply
}
}

export async function contestEnded (request: FastifyRequest, reply: FastifyReply) {
const contestId = requestParameter(request, 'contestId')

const contest = await fetchContest({ contestId })
const now = new Date()
if ((new Date(contest.endTime)).getTime() > now.getTime()) {
throw new ForbiddenError('Contest has not ended')
}
}

export async function contestNotBegan (request: FastifyRequest, reply: FastifyReply) {
const contestId = requestParameter(request, 'contestId')

Expand Down
3 changes: 2 additions & 1 deletion packages/api-server/src/hooks/contest.hooks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type FastifyRequest, type FastifyReply } from 'fastify'
import { type FastifyRequest, type FastifyReply } from 'fastify' /*=*/
import { fetchContest } from '../services/contest.services.js'
import { requestParameter } from '../utils/auth.utils.js'
import { contestIdByPath } from '../services/path.services.js'
Expand All @@ -8,6 +8,7 @@ import { contestIdByPath } from '../services/path.services.js'
* - Request.params must have contest ID.
*/
export async function contestInfoHook (request: FastifyRequest, reply: FastifyReply): Promise<void> {

const contestId = requestParameter(request, 'contestId')
try {
requestParameter(request, 'domainId')
Expand Down
78 changes: 70 additions & 8 deletions packages/api-server/src/routes/v1/contest.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from 'http-errors-enhanced'
import {
contestBegan,
contestEnded,
contestNotBegan,
contestPublished,
registeredForContest,
Expand All @@ -44,7 +45,9 @@ import {
removeProblemFromContest,
syncProblemToContest,
updateContest,
publishContest
publishContest,
fetchDomainContests,
fetchPublishedContests
} from '../../services/contest.services.js'
import {
completeTeamInvitation,
Expand Down Expand Up @@ -85,7 +88,8 @@ async function contestProblemRoutes (problemRoutes: FastifyTypeBox): Promise<voi
onRequest: [userAuthHook, problemRoutes.auth([
[hasDomainPrivilege(['contest.read'])], // Domain scope
[hasContestPrivilege(['read'])], // Contest privilege
[registeredForContest, contestBegan] // Regular participant
[registeredForContest, contestBegan], // Regular participant
[contestEnded] // Past contest
]) as any]
},
async (request, reply) => {
Expand All @@ -111,7 +115,8 @@ async function contestProblemRoutes (problemRoutes: FastifyTypeBox): Promise<voi
onRequest: [userAuthHook, problemRoutes.auth([
[hasDomainPrivilege(['contest.read'])], // Domain scope
[hasContestPrivilege(['read'])], // Contest privilege
[registeredForContest, contestBegan] // Regular participant
[registeredForContest, contestBegan], // Regular participant
[contestEnded] // Past contest
]) as any]
},
async (request, reply) => {
Expand Down Expand Up @@ -513,38 +518,62 @@ async function contestTeamRoutes (teamRoutes: FastifyTypeBox): Promise<void> {
}

async function contestRanklistRoutes (ranklistRoutes: FastifyTypeBox): Promise<void> {
ranklistRoutes.addHook('onRequest', contestInfoHook)
ranklistRoutes.get(
'/',
{
schema: {
params: Type.Object({ contestId: Type.String() }),
response: {
200: Type.Array(Type.Intersect([TeamScoreSchema, Type.Object({name: Type.String()})])),
200: Type.Object({
ranklist: Type.Array(Type.Intersect([TeamScoreSchema, Type.Object({name: Type.String()})])),
problems: Type.Array(Type.Object({ id: Type.String(), name: Type.String() }))
}),
400: badRequestSchema,
403: forbiddenSchema,
404: notFoundSchema
}
},
onRequest: [ranklistRoutes.auth([
onRequest: [contestInfoHook, ranklistRoutes.auth([
[contestBegan]
]) as any]
},

async (request, reply) => {
const { contestId } = request.params
const ranklist = await fetchContestRanklist({ contestId })
const _ranklist = await Promise.all(ranklist.map(async team => {
const ranklistResolved = await Promise.all(ranklist.map(async team => {
const { name } = await fetchTeam({ contestId: team.contestId, teamId: team.id });
return { ...team, name };
}));

const { problems } = await fetchContestProblemList({ contestId });

return await reply.status(200).send(_ranklist)
return await reply.status(200).send({ problems, ranklist: ranklistResolved });
}
)
}

export async function contestRoutes (routes: FastifyTypeBox): Promise<void> {

/* get published contests */
routes.get(
'/',
{
schema: {
response: {
200: Type.Array(ContestSchema),
400: badRequestSchema,
401: unauthorizedSchema,
403: forbiddenSchema,
404: notFoundSchema
}
},
},
async (request, reply) => {
const contests = await fetchPublishedContests({limit: 20});
return await reply.status(200).send(contests)
})

routes.get(
'/:contestId',
{
Expand Down Expand Up @@ -596,6 +625,39 @@ export async function contestRoutes (routes: FastifyTypeBox): Promise<void> {
return await reply.status(200).send(status)
})

routes.get(
'/:contestId/submissions',
{
schema: {
params: Type.Object({ contestId: Type.String() }),
response: {
200: Type.Array(SubmissionSchema),
400: badRequestSchema,
401: unauthorizedSchema,
403: forbiddenSchema,
404: notFoundSchema
}
},
onRequest: [contestInfoHook, userAuthHook, routes.auth([
[hasDomainPrivilege(['contest.test'])], // Domain testers
[hasContestPrivilege(['test'])], // Contest tester
[contestRunning, registeredForContest], // Participant
[contestEnded] // Past
]) as any // Users
]
},
async (request, reply) => {

if (request.user == null) {
throw new UnauthorizedError('User not logged in')
}
const { contestId } = request.params
const query = { contestId, userId: request.user.id }
const submissions = await querySubmissions({ query })

return await reply.status(200).send(submissions)
})

routes.post(
'/:contestId/publish',
{
Expand Down
2 changes: 1 addition & 1 deletion packages/api-server/src/routes/v1/session.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { type FastifyTypeBox } from '../../types.js'
import { badRequestSchema, notFoundSchema, unauthorizedSchema } from 'http-errors-enhanced'
import { UserLoginSchema, UserPrivateSessionSchema, UserPublicSessionSchema } from '@argoncs/types'
import { userAuthHook } from '../../hooks/authentication.hooks.js'
import { requestUserProfile, requestSessionToken } from '../../utils/auth.utils.js'
import { requestUserProfile, requestSessionToken } from '../../utils/auth.utils.js'/*=*/

export async function userSessionRoutes (userSessionRoutes: FastifyTypeBox): Promise<void> {
/*
Expand Down
13 changes: 10 additions & 3 deletions packages/api-server/src/services/cache.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { backOff } from 'exponential-backoff'
import { ServiceUnavailableError } from 'http-errors-enhanced'
import { json } from 'typia'

/* Try cache
* If miss:
* Get lock, so caller can set cache, without overlap from another missed caller.
* If locked, another missed caller is getting. await for cache hit, since caller will set.
*/
export async function fetchCacheUntilLockAcquired<T> ({ key }: { key: string }): Promise<T | null> {
let cache = await fetchCache<T>({ key })
if (cache != null) {
Expand Down Expand Up @@ -40,8 +45,9 @@ export async function fetchCache<T> ({ key }: { key: string }): Promise<T | null
if (cache == null || cache === '') {
return null
}
return json.assertParse(cache)
return JSON.parse(cache)
} catch (err) {
console.log(err);
// TODO: Alert cache failure
return null
}
Expand All @@ -50,9 +56,10 @@ export async function fetchCache<T> ({ key }: { key: string }): Promise<T | null
export async function setCache<T> ({ key, data }: { key: string, data: T }): Promise<boolean> {
try {
// Add a random jitter to prevent avalanche
const status = await cacheRedis.setex(key, 1600 + Math.floor(Math.random() * 400), json.assertStringify<T>(data))
const status = await cacheRedis.setex(key, 1600 + Math.floor(Math.random() * 400), JSON.stringify(data))
return Boolean(status)
} catch (err) {
console.log('set cache err: ', err);
return false
}
}
Expand All @@ -61,7 +68,7 @@ export async function deleteCache ({ key }: { key: string }): Promise<void> {
await cacheRedis.del(key)
}

export async function acquireLock ({ key }: { key: string }): Promise<boolean> {
async function acquireLock ({ key }: { key: string }): Promise<boolean> {
const status = await cacheRedis.setnx(`${key}:lock`, 1)
if (status !== 1) {
return false
Expand Down
12 changes: 11 additions & 1 deletion packages/api-server/src/services/contest.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ export async function createContestSeries ({ newContestSeries, domainId }: { new
return { seriesId: id }
}

export async function fetchPublishedContests ({ limit }: { limit: number }): Promise<Contest[]> {
const contests = await contestCollection.find({published: true})
.limit(limit)
.toArray();

return contests;
}

export async function fetchAllContestSeries (): Promise<ContestSeries[]> {
const contestSeries = await contestSeriesCollection.find().toArray()
return contestSeries
Expand All @@ -40,6 +48,7 @@ export async function fetchDomainContestSeries ({ domainId }: { domainId: string
}

export async function fetchContest ({ contestId }: { contestId: string }): Promise<Contest> {

const cache = await fetchCacheUntilLockAcquired<Contest>({ key: `${CONTEST_CACHE_KEY}:${contestId}` })
if (cache != null) {
return cache
Expand All @@ -51,7 +60,8 @@ export async function fetchContest ({ contestId }: { contestId: string }): Promi
throw new NotFoundError('Contest not found')
}

await setCache({ key: `${CONTEST_CACHE_KEY}:${contestId}`, data: contest })
const good = await setCache({ key: `${CONTEST_CACHE_KEY}:${contestId}`, data: contest })
console.log('cache set "', `${CONTEST_CACHE_KEY}:${contestId}`, '": ', good? 'true' : 'false');
return contest
} finally {
await releaseLock({ key: `${CONTEST_CACHE_KEY}:${contestId}` })
Expand Down
32 changes: 0 additions & 32 deletions packages/api-server/src/services/problem.services.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,9 @@
import {
type NewProblem,
type Problem
} from '@argoncs/types' /*=*/
import { NotFoundError } from 'http-errors-enhanced'
import { mongoClient, domainProblemCollection, submissionCollection, uploadSessionCollection } from '@argoncs/common'
import { testcaseExists } from './testcase.services.js'

import { nanoid } from 'nanoid'

/*
export async function createDomainProblem ({ newProblem, domainId }: { newProblem: NewProblem, domainId: string }): Promise<{ problemId: string }> {
const problemId = nanoid()
const problem: Problem = { ...newProblem, id: problemId, domainId }
await domainProblemCollection.insertOne(problem)
return { problemId }
}
*/

/*
export async function updateDomainProblem ({ problemId, domainId, problem }: { problemId: string, domainId: string, problem: Partial<NewProblem> }): Promise<{ modified: boolean }> {
if (problem.testcases != null) {
const testcasesVerifyQueue: Array<Promise<void>> = []
problem.testcases.forEach((testcase) => {
testcasesVerifyQueue.push(testcaseExists({ problemId, filename: testcase.input.name, versionId: testcase.input.versionId }))
testcasesVerifyQueue.push(testcaseExists({ problemId, filename: testcase.output.name, versionId: testcase.output.versionId }))
})
await Promise.all(testcasesVerifyQueue)
}
const { matchedCount, modifiedCount } = await domainProblemCollection.updateOne({ id: problemId, domainId }, { $set: problem })
if (matchedCount === 0) {
throw new NotFoundError('Problem not found')
}
return { modified: modifiedCount > 0 }
}
*/

export async function deleteDomainProblem ({ problemId, domainId }: { problemId: string, domainId: string }): Promise<void> {
const session = mongoClient.startSession()
Expand Down
5 changes: 2 additions & 3 deletions packages/api-server/src/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {

import fs from 'node:fs/promises'
import path from 'node:path'
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';


Expand Down Expand Up @@ -68,7 +67,7 @@ export async function loadFastify (testing = false): Promise<FastifyTypeBox> {
},
})
await app.register(fastifyCors, {
origin: [/\.teamscode\.org$/, /\.argoncs\.io$/, 'http://localhost:3000'],
origin: [/\.teamscode\.org$/, /\.argoncs\.io$/, /localhost/],
allowedHeaders: ['Content-Type', 'Set-Cookie'],
credentials: true
})
Expand Down Expand Up @@ -97,7 +96,7 @@ export async function loadFastify (testing = false): Promise<FastifyTypeBox> {


// Development Clients
const __dirname = dirname(fileURLToPath(import.meta.url));
const __dirname = path.dirname(fileURLToPath(import.meta.url));
app.get('/admin', async function handler (_, reply) {
const fd = await fs.open(path.join(__dirname, '../../src/admin.html'))
const stream = fd.createReadStream()
Expand Down
2 changes: 2 additions & 0 deletions packages/client/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
build
Loading

0 comments on commit f0134b4

Please sign in to comment.