diff --git a/api-server/src/server/boot/randomAPIs.js b/api-server/src/server/boot/randomAPIs.js index dd52e2aae8b..a8db1d98a71 100644 --- a/api-server/src/server/boot/randomAPIs.js +++ b/api-server/src/server/boot/randomAPIs.js @@ -17,6 +17,9 @@ module.exports = function (app) { router.get('/ue/:unsubscribeId', unsubscribeById); router.get('/resubscribe/:unsubscribeId', resubscribe); router.get('/api/users/get-public-profile', blockUserAgent, getPublicProfile); + router.get('/users/get-public-profile', blockUserAgent, getPublicProfile); + const getUserExists = createGetUserExists(app); + router.get('/users/exists', getUserExists); app.use(router); @@ -168,6 +171,17 @@ module.exports = function (app) { }); } + function createGetUserExists(app) { + const User = app.models.User; + return function getUserExists(req, res) { + const username = req.query.username.toLowerCase(); + + User.doesExist(username, null).then(exists => { + res.send({ exists }); + }); + }; + } + function prepUserForPublish(user, profileUI) { const { about, diff --git a/api-server/src/server/middlewares/request-authorization.js b/api-server/src/server/middlewares/request-authorization.js index aeb6edf1b07..7cc94a85338 100644 --- a/api-server/src/server/middlewares/request-authorization.js +++ b/api-server/src/server/middlewares/request-authorization.js @@ -13,8 +13,10 @@ import { getUserById as _getUserById } from '../utils/user-stats'; const authRE = /^\/auth\//; const confirmEmailRE = /^\/confirm-email$/; const newsShortLinksRE = /^\/n\/|^\/p\//; -const publicUserRE = /^\/api\/users\/get-public-profile$/; -const publicUsernameRE = /^\/api\/users\/exists$/; +const publicApiUserRE = /^\/api\/users\/get-public-profile$/; +const publicUserRE = /^\/users\/get-public-profile$/; +const publicApiUsernameRE = /^\/api\/users\/exists$/; +const publicUsernameRE = /^\/users\/exists$/; const resubscribeRE = /^\/resubscribe\//; const showCertRE = /^\/certificate\/showCert\//; // note: signin may not have a trailing slash @@ -32,7 +34,9 @@ const _pathsAllowedREs = [ authRE, confirmEmailRE, newsShortLinksRE, + publicApiUserRE, publicUserRE, + publicApiUsernameRE, publicUsernameRE, resubscribeRE, showCertRE, diff --git a/api-server/src/server/middlewares/request-authorization.test.js b/api-server/src/server/middlewares/request-authorization.test.js index 2cb878081bb..6d3a00982d5 100644 --- a/api-server/src/server/middlewares/request-authorization.test.js +++ b/api-server/src/server/middlewares/request-authorization.test.js @@ -39,8 +39,10 @@ describe('request-authorization', () => { const authRE = /^\/auth\//; const confirmEmailRE = /^\/confirm-email$/; const newsShortLinksRE = /^\/n\/|^\/p\//; - const publicUserRE = /^\/api\/users\/get-public-profile$/; - const publicUsernameRE = /^\/api\/users\/exists$/; + const publicApiUserRE = /^\/api\/users\/get-public-profile$/; + const publicUserRE = /^\/users\/get-public-profile$/; + const publicApiUsernameRE = /^\/api\/users\/exists$/; + const publicUsernameRE = /^\/users\/exists$/; const resubscribeRE = /^\/resubscribe\//; const showCertRE = /^\/certificate\/showCert\//; // note: signin may not have a trailing slash @@ -53,7 +55,9 @@ describe('request-authorization', () => { authRE, confirmEmailRE, newsShortLinksRE, + publicApiUserRE, publicUserRE, + publicApiUsernameRE, publicUsernameRE, resubscribeRE, showCertRE, diff --git a/api/src/routes/public/user.test.ts b/api/src/routes/public/user.test.ts index 4439f54e619..f8eb290354a 100644 --- a/api/src/routes/public/user.test.ts +++ b/api/src/routes/public/user.test.ts @@ -222,7 +222,7 @@ describe('userRoutes', () => { superGet = createSuperRequest({ method: 'GET' }); }); - describe('/api/users/get-public-profile', () => { + describe('/users/get-public-profile', () => { const profilelessUsername = 'profileless-user'; const lockedUsername = 'locked-user'; const publicUsername = 'public-user'; @@ -281,7 +281,7 @@ describe('userRoutes', () => { describe('GET', () => { test('returns 400 status code if the user agent is blocked', async () => { const response = await superGet( - '/api/users/get-public-profile?username=public-user' + '/users/get-public-profile?username=public-user' ).set('User-Agent', 'curl'); expect(response.text).toBe( @@ -291,14 +291,14 @@ describe('userRoutes', () => { }); test('returns 400 status code if the username param is missing', async () => { - const res = await superGet('/api/users/get-public-profile'); + const res = await superGet('/users/get-public-profile'); // TODO(Post-MVP): return something more informative expect(res.body).toStrictEqual({}); expect(res.statusCode).toBe(400); }); test('returns 400 status code if the username param is empty', async () => { - const res = await superGet('/api/users/get-public-profile?username='); + const res = await superGet('/users/get-public-profile?username='); // TODO(Post-MVP): return something more informative expect(res.body).toStrictEqual({}); expect(res.statusCode).toBe(400); @@ -306,7 +306,7 @@ describe('userRoutes', () => { test('returns 404 status code for non-existent user', async () => { const response = await superGet( - '/api/users/get-public-profile?username=non-existent' + '/users/get-public-profile?username=non-existent' ); // TODO(Post-MVP): return something more informative expect(response.body).toStrictEqual({}); @@ -315,7 +315,7 @@ describe('userRoutes', () => { test('returns 200 status code with a locked profile if the profile is private', async () => { const response = await superGet( - `/api/users/get-public-profile?username=${lockedUsername}` + `/users/get-public-profile?username=${lockedUsername}` ); expect(response.body).toStrictEqual({ @@ -335,7 +335,7 @@ describe('userRoutes', () => { test('returns 200 status code locked profile if the profile is missing', async () => { const response = await superGet( - `/api/users/get-public-profile?username=${profilelessUsername}` + `/users/get-public-profile?username=${profilelessUsername}` ); expect(response.body).toStrictEqual({ @@ -360,7 +360,7 @@ describe('userRoutes', () => { where: { email: publicUsername } }); const response = await superGet( - `/api/users/get-public-profile?username=${publicUsername}` + `/users/get-public-profile?username=${publicUsername}` ); // TODO: create a fixture for this without 'completedSurveys', ideally @@ -385,52 +385,58 @@ describe('userRoutes', () => { }); }); }); - describe('GET /api/users/exists', () => { + describe('GET /users/exists', () => { beforeAll(async () => { await fastifyTestInstance.prisma.user.create({ data: minimalUserData }); }); - it('should return { exists: true } with a 400 status code if the username param is missing or empty', async () => { - const res = await superGet('/api/users/exists'); + it('should reject with a 400 status code if the username param is missing or empty', async () => { + const res = await superGet('/users/exists'); - expect(res.body).toStrictEqual({ exists: true }); + expect(res.body).toStrictEqual({ + type: 'danger', + message: 'username parameter is required' + }); expect(res.statusCode).toBe(400); - const res2 = await superGet('/api/users/exists?username='); + const res2 = await superGet('/users/exists?username='); - expect(res2.body).toStrictEqual({ exists: true }); + expect(res2.body).toStrictEqual({ + type: 'danger', + message: 'username parameter is required' + }); expect(res2.statusCode).toBe(400); }); it('should return { exists: true } if the username exists', async () => { - const res = await superGet('/api/users/exists?username=testuser'); + const res = await superGet('/users/exists?username=testuser'); expect(res.body).toStrictEqual({ exists: true }); expect(res.statusCode).toBe(200); }); it('should ignore case when checking for username existence', async () => { - const res = await superGet('/api/users/exists?username=TeStUsEr'); + const res = await superGet('/users/exists?username=TeStUsEr'); expect(res.body).toStrictEqual({ exists: true }); expect(res.statusCode).toBe(200); }); it('should return { exists: false } if the username does not exist', async () => { - const res = await superGet('/api/users/exists?username=nonexistent'); + const res = await superGet('/users/exists?username=nonexistent'); expect(res.body).toStrictEqual({ exists: false }); expect(res.statusCode).toBe(200); }); it('should return { exists: true } if the username is restricted (ignoring case)', async () => { - const res = await superGet('/api/users/exists?username=pRofIle'); + const res = await superGet('/users/exists?username=pRofIle'); expect(res.body).toStrictEqual({ exists: true }); - const res2 = await superGet('/api/users/exists?username=flAnge'); + const res2 = await superGet('/users/exists?username=flAnge'); expect(res2.body).toStrictEqual({ exists: true }); }); diff --git a/api/src/routes/public/user.ts b/api/src/routes/public/user.ts index 62f4da7b4f5..75bc45aad09 100644 --- a/api/src/routes/public/user.ts +++ b/api/src/routes/public/user.ts @@ -212,6 +212,118 @@ export const userPublicGetRoutes: FastifyPluginCallbackTypebox = ( } ); + fastify.get( + '/users/get-public-profile', + { + schema: schemas.getPublicProfile, + onRequest: (req, reply, done) => { + const userAgent = req.headers['user-agent']; + + if ( + userAgent && + blockedUserAgentParts.some(ua => userAgent.toLowerCase().includes(ua)) + ) { + void reply.code(400); + void reply.send( + 'This endpoint is no longer available outside of the freeCodeCamp ecosystem' + ); + } + done(); + } + }, + async (req, reply) => { + const logger = fastify.log.child({ req }); + logger.info({ username: req.query.username }); + // TODO(Post-MVP): look for duplicates unless we can make username unique in the db. + const user = await fastify.prisma.user.findFirst({ + where: { username: req.query.username } + // TODO: only select desired fields, then stop 'omit'ing the undesired + // ones. + }); + + if (!user) { + logger.warn('User not found'); + void reply.code(404); + return reply.send({}); + } + + const [flags, rest] = splitUser(user); + + const publicUser = _.omit(rest, [ + 'currentChallengeId', + 'email', + 'emailVerified', + 'sendQuincyEmail', + 'theme', + // keyboardShortcuts is included in flags. + // 'keyboardShortcuts', + 'acceptedPrivacyTerms', + 'progressTimestamps', + 'unsubscribeId', + 'donationEmails', + 'externalId', + 'usernameDisplay', + 'isBanned' + ]); + + const normalizedProfileUI = normalizeProfileUI(user.profileUI); + + void reply.code(200); + if (normalizedProfileUI.isLocked) { + // TODO(Post-MVP): just return isLocked: true and either a null user + // or no user at all. (see other TODO in the else branch below) + return reply.send({ + entities: { + user: { + [user.username]: { + isLocked: true, + profileUI: normalizedProfileUI, + username: user.username + } + } + }, + result: user.username + }); + } else { + const progressTimestamps = user.progressTimestamps as + | ProgressTimestamp[] + | null; + const sharedUser = replacePrivateData({ + ...user, + calendar: getCalendar(progressTimestamps), + completedChallenges: normalizeChallenges(user.completedChallenges), + location: user.location ?? '', + joinDate: new ObjectId(user.id).getTimestamp().toISOString(), + name: user.name ?? '', + points: getPoints(progressTimestamps), + profileUI: normalizedProfileUI + }); + + const returnedUser = { + ...removeNulls(publicUser), + ...normalizeFlags(flags), + ...sharedUser, + profileUI: normalizedProfileUI, + // TODO: should this always be returned? Shouldn't some privacy + // setting control it? Same applies to website, githubProfile, + // and linkedin. + twitter: normalizeTwitter(user.twitter), + yearsTopContributor: user.yearsTopContributor + }; + return reply.send({ + // TODO(Post-MVP): just return a user object (i.e. returnedUser) and + // isLocked: false. The there should be no need for Type.Union in the + // schema. Alternatively, have the user object be nullable and don't + // bother with isLocked. + entities: { + user: { [user.username]: returnedUser } + }, + result: user.username + }); + } + } + ); + fastify.get( '/api/users/exists', { @@ -224,11 +336,46 @@ export const userPublicGetRoutes: FastifyPluginCallbackTypebox = ( if (req.validationError) { logger.warn({ validationError: req.validationError }); void reply.code(400); - // TODO(Post-MVP): return a message telling the requester that their - // request was malformed. + return await reply.send({ + type: 'danger', + message: 'username parameter is required' + }); + } + + const username = req.query.username.toLowerCase(); + + if (isRestricted(username)) { + logger.info({ username }, 'Restricted username'); return await reply.send({ exists: true }); } + const exists = + (await fastify.prisma.user.count({ + where: { username } + })) > 0; + + await reply.send({ exists }); + } + ); + + fastify.get( + '/users/exists', + { + schema: schemas.userExists, + attachValidation: true + }, + async (req, reply) => { + const logger = fastify.log.child({ req }); + + if (req.validationError) { + logger.warn({ validationError: req.validationError }); + void reply.code(400); + return await reply.send({ + type: 'danger', + message: 'username parameter is required' + }); + } + const username = req.query.username.toLowerCase(); if (isRestricted(username)) { diff --git a/api/src/schemas.ts b/api/src/schemas.ts index 8afb7f295ed..6d9ef398cd0 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -1,5 +1,5 @@ -export { getPublicProfile } from './schemas/api/users/get-public-profile'; -export { userExists } from './schemas/api/users/exists'; +export { getPublicProfile } from './schemas/users/get-public-profile'; +export { userExists } from './schemas/users/exists'; export { certSlug } from './schemas/certificate/cert-slug'; export { certificateVerify } from './schemas/certificate/certificate-verify'; export { backendChallengeCompleted } from './schemas/challenge/backend-challenge-completed'; diff --git a/api/src/schemas/api/users/exists.ts b/api/src/schemas/users/exists.ts similarity index 62% rename from api/src/schemas/api/users/exists.ts rename to api/src/schemas/users/exists.ts index 0416d3ff28e..8655c824ee5 100644 --- a/api/src/schemas/api/users/exists.ts +++ b/api/src/schemas/users/exists.ts @@ -9,7 +9,9 @@ export const userExists = { exists: Type.Boolean() }), 400: Type.Object({ - exists: Type.Literal(true) + type: Type.Literal('danger'), + message: Type.Literal('username parameter is required') + // message: Type.Literal("'username' parameter is required") }) } }; diff --git a/api/src/schemas/api/users/get-public-profile.ts b/api/src/schemas/users/get-public-profile.ts similarity index 98% rename from api/src/schemas/api/users/get-public-profile.ts rename to api/src/schemas/users/get-public-profile.ts index f4305e0cb09..06ff7f90a4d 100644 --- a/api/src/schemas/api/users/get-public-profile.ts +++ b/api/src/schemas/users/get-public-profile.ts @@ -1,5 +1,5 @@ import { Type } from '@fastify/type-provider-typebox'; -import { profileUI, examResults, savedChallenge } from '../../types'; +import { profileUI, examResults, savedChallenge } from '../types'; export const getPublicProfile = { querystring: Type.Object({