Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules
.git
coverage
.nyc_output
npm-debug.log*
pnpm-debug.log*
22 changes: 0 additions & 22 deletions .github/workflows/code_reviewer.yml

This file was deleted.

4 changes: 2 additions & 2 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ COPY . /resources-api
# Set working directory for future use
WORKDIR /resources-api

RUN npm install pnpm -g
RUN npm install -g pnpm@10.33.2
# Install the dependencies from package.json
RUN pnpm install
RUN pnpm install --frozen-lockfile
RUN pnpm lint
RUN pnpm lint:fix

Expand Down
14 changes: 14 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"version": "1.0.0",
"description": "TopCoder Challenge Resources V5 API",
"main": "app.js",
"packageManager": "pnpm@10.33.2",
"scripts": {
"start": "node app.js",
"start:dev": "nodemon app.js",
Expand Down Expand Up @@ -61,6 +62,19 @@
"prisma": {
"schema": "./prisma/schema.prisma"
},
"pnpm": {
"onlyBuiltDependencies": [
"@prisma/client",
"@prisma/engines",
"prisma"
],
"ignoredBuiltDependencies": [
"@scarf/scarf",
"aws-sdk",
"core-js",
"dtrace-provider"
]
},
"engines": {
"node": ">=18 <23"
},
Expand Down
11 changes: 11 additions & 0 deletions prisma/challenge-schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,16 @@ datasource db {
model Challenge {
id String @id @default(uuid())
numOfRegistrants Int @default(0)
userWhitelist ChallengeUserWhitelist[]
}

model ChallengeUserWhitelist {
challengeId String
userId String

challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade)

@@id([challengeId, userId])
@@index([challengeId])
@@index([userId])
}
85 changes: 79 additions & 6 deletions src/common/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -231,28 +231,98 @@ async function getMemberDetailsById (memberId) {
}

/**
* Fetch challenge information from the challenge database and optionally from the Challenge API.
* Uses the Prisma challenge client to ensure the challenge exists before pulling additional details.
* Fetch challenge information by challenge id.
*
* When includeDetails is false, the challenge database is used for a lightweight
* existence check and a local NotFoundError is raised if the record is missing.
* When includeDetails is true, Challenge API remains authoritative for the full
* payload and its error contract, including the 404 response shape exposed by
* resource create/delete flows.
*
* @param {String} challengeId the challenge id
* @param {Object} [options] optional parameters
* @param {Boolean} [options.includeDetails=false] whether to fetch full challenge details from the API
* @returns {Promise<Object>} the challenge record or detailed Challenge API payload
* @throws {NotFoundError} when includeDetails is false and the challenge database record is missing
*/
async function getChallengeById (challengeId, options = {}) {
const { includeDetails = false } = options

if (includeDetails) {
const response = await getRequest(`${config.CHALLENGE_API_URL}/${challengeId}`)
return _.get(response, 'body', null)
}

const challengeRecord = await prismaChallenge.challenge.findUnique({ where: { id: challengeId } })

if (!challengeRecord) {
throw new errors.NotFoundError(`Challenge ID ${challengeId} not found`)
}

if (!includeDetails) {
return challengeRecord
return challengeRecord
}

/**
* Determine whether challenge whitelist checks apply for a request.
* Interactive users, including admins and anonymous callers, must be evaluated;
* M2M callers are allowed to bypass this user-facing access control.
*
* @param {Object} currentUser the user who performs the operation
* @returns {Boolean} true when whitelist rules should be applied
*/
function shouldApplyChallengeWhitelist (currentUser) {
return !_.get(currentUser, 'isMachine', false)
}

/**
* Filter challenge ids by the current challenge user whitelist state.
* Challenges with no whitelist rows stay visible. Evaluation failures fail
* closed and return an empty list for interactive callers.
*
* @param {Object} currentUser the user who performs the operation
* @param {Array<String>} challengeIds challenge ids to filter
* @returns {Promise<Array<String>>} challenge ids visible to the caller
*/
async function filterChallengeIdsByWhitelist (currentUser, challengeIds) {
const ids = _.uniq((challengeIds || []).map(id => _.toString(id).trim()).filter(Boolean))
if (ids.length === 0 || !shouldApplyChallengeWhitelist(currentUser)) {
return ids
}

const userId = _.toString(_.get(currentUser, 'userId', '')).trim()

try {
const rows = await prismaChallenge.challengeUserWhitelist.findMany({
where: { challengeId: { in: ids } },
select: { challengeId: true, userId: true }
})
const restrictedIds = new Set(rows.map(row => row.challengeId))
const allowedRestrictedIds = new Set(
rows
.filter(row => userId && _.toString(row.userId) === userId)
.map(row => row.challengeId)
)

return ids.filter(id => !restrictedIds.has(id) || allowedRestrictedIds.has(id))
} catch (err) {
logger.warn(`filterChallengeIdsByWhitelist failed: ${err.message}`)
return []
}
}

const response = await getRequest(`${config.CHALLENGE_API_URL}/${challengeId}`)
return _.get(response, 'body', null)
/**
* Ensure an interactive caller is allowed by the challenge whitelist.
*
* @param {Object} currentUser the user who performs the operation
* @param {String} challengeId the challenge id to evaluate
* @returns {Promise<void>}
* @throws {ForbiddenError} when the whitelist blocks the caller or evaluation fails
*/
async function ensureChallengeWhitelistAccess (currentUser, challengeId) {
const visibleIds = await filterChallengeIdsByWhitelist(currentUser, [challengeId])
if (!visibleIds.includes(challengeId)) {
throw new errors.ForbiddenError(`You don't have access to view this challenge`)
}
}

async function getMemberDetailsByHandleFromV3Members (handle) {
Expand Down Expand Up @@ -573,6 +643,9 @@ module.exports = {
setResHeaders,
getAllPages,
checkChallengeGroupAccess,
shouldApplyChallengeWhitelist,
filterChallengeIdsByWhitelist,
ensureChallengeWhitelistAccess,
checkAgreedTerms,
postRequest,
advanceChallengePhase,
Expand Down
4 changes: 2 additions & 2 deletions src/controllers/ResourceController.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ async function updatePhaseChangeNotifications (req, res) {
* @param {Object} res the response
*/
async function listChallengesByMember (req, res) {
const result = await service.listChallengesByMember(req.params.memberId, req.query)
const result = await service.listChallengesByMember(req.params.memberId, req.query, req.authUser)
helper.setResHeaders(req, res, result)
res.send(result.data)
}
Expand All @@ -63,7 +63,7 @@ async function listChallengesByMember (req, res) {
* @param {Object} res the response
*/
async function getResourceCount (req, res) {
const result = await service.getResourceCount(req.query.challengeId, req.query.roleId)
const result = await service.getResourceCount(req.query.challengeId, req.query.roleId, req.authUser)
res.send(result)
}

Expand Down
Loading
Loading