diff --git a/api/src/routes/helpers/is-restricted.ts b/api/src/routes/helpers/is-restricted.ts new file mode 100644 index 00000000000..6e843a64718 --- /dev/null +++ b/api/src/routes/helpers/is-restricted.ts @@ -0,0 +1,12 @@ +import { isProfane } from 'no-profanity'; + +import { blocklistedUsernames } from '../../../../shared/config/constants'; + +/** + * Checks if a username is restricted (i.e. It's profane or reserved). + * @param username - The username to check. + * @returns True if the username is restricted, false otherwise. + */ +export const isRestricted = (username: string): boolean => { + return isProfane(username) || blocklistedUsernames.includes(username); +}; diff --git a/api/src/routes/settings.ts b/api/src/routes/settings.ts index 1a0290e0285..44d975c844c 100644 --- a/api/src/routes/settings.ts +++ b/api/src/routes/settings.ts @@ -14,13 +14,12 @@ import type { } from 'fastify'; import { ResolveFastifyReplyType } from 'fastify/types/type-provider'; import { differenceInMinutes } from 'date-fns'; -import { isProfane } from 'no-profanity'; -import { blocklistedUsernames } from '../../../shared/config/constants'; import { isValidUsername } from '../../../shared/utils/validate'; import * as schemas from '../schemas'; import { createAuthToken } from '../utils/tokens'; import { API_LOCATION } from '../utils/env'; +import { isRestricted } from './helpers/is-restricted'; type WaitMesssageArgs = { sentAt: Date | null; @@ -401,9 +400,6 @@ ${isLinkSentWithinLimitTTL}` }); } - const isUserNameProfane = isProfane(newUsername); - const onBlocklist = blocklistedUsernames.includes(newUsername); - const usernameTaken = newUsername === oldUsername ? false @@ -411,7 +407,7 @@ ${isLinkSentWithinLimitTTL}` where: { username: newUsername } }); - if (usernameTaken || isUserNameProfane || onBlocklist) { + if (usernameTaken || isRestricted(newUsername)) { void reply.code(400); return reply.send({ message: 'flash.username-taken', diff --git a/api/src/routes/user.test.ts b/api/src/routes/user.test.ts index deaaa579e91..bcf9dfac8d8 100644 --- a/api/src/routes/user.test.ts +++ b/api/src/routes/user.test.ts @@ -1286,6 +1286,56 @@ Thanks and regards, }); }); }); + describe('GET /api/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'); + + expect(res.body).toStrictEqual({ exists: true }); + expect(res.statusCode).toBe(400); + + const res2 = await superGet('/api/users/exists?username='); + + expect(res2.body).toStrictEqual({ exists: true }); + expect(res2.statusCode).toBe(400); + }); + + it('should return { exists: true } if the username exists', async () => { + const res = await superGet('/api/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'); + + 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'); + + 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'); + + expect(res.body).toStrictEqual({ exists: true }); + + const res2 = await superGet('/api/users/exists?username=flAnge'); + + expect(res2.body).toStrictEqual({ exists: true }); + }); + }); }); }); diff --git a/api/src/routes/user.ts b/api/src/routes/user.ts index 9004df1c884..9e9d4a9e992 100644 --- a/api/src/routes/user.ts +++ b/api/src/routes/user.ts @@ -27,6 +27,7 @@ import { trimTags } from '../utils/validation'; import { generateReportEmail } from '../utils/email-templates'; import { createResetProperties } from '../utils/create-user'; import { challengeTypes } from '../../../shared/config/challenge-types'; +import { isRestricted } from './helpers/is-restricted'; // user flags that the api-server returns as false if they're missing in the // user document. Since Prisma returns null for missing fields, we need to @@ -786,5 +787,32 @@ export const userPublicGetRoutes: FastifyPluginCallbackTypebox = ( } ); + fastify.get( + '/api/users/exists', + { + schema: schemas.userExists, + attachValidation: true + }, + async (req, reply) => { + if (req.validationError) { + void reply.code(400); + // TODO(Post-MVP): return a message telling the requester that their + // request was malformed. + return await reply.send({ exists: true }); + } + + const username = req.query.username.toLowerCase(); + + if (isRestricted(username)) return await reply.send({ exists: true }); + + const exists = + (await fastify.prisma.user.count({ + where: { username } + })) > 0; + + await reply.send({ exists }); + } + ); + done(); }; diff --git a/api/src/schemas.ts b/api/src/schemas.ts index 6a12c08d4aa..d322e502fc1 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -1,4 +1,5 @@ export { getPublicProfile } from './schemas/api/users/get-public-profile'; +export { userExists } from './schemas/api/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/api/users/exists.ts new file mode 100644 index 00000000000..0416d3ff28e --- /dev/null +++ b/api/src/schemas/api/users/exists.ts @@ -0,0 +1,15 @@ +import { Type } from '@fastify/type-provider-typebox'; + +export const userExists = { + querystring: Type.Object({ + username: Type.String({ minLength: 1 }) + }), + response: { + 200: Type.Object({ + exists: Type.Boolean() + }), + 400: Type.Object({ + exists: Type.Literal(true) + }) + } +};