Repository: freeCodeCamp/freeCodeCamp
Pull request #59283: feat(api): copy /api endpoints
Author: login: ojeytonwilliams
Labels: name: status: blocked
description: In a transient & temporary hold.
name: platform: api
description: Server application that needs familiarity with Express, Fastify, MongoDB etc.
Created: 2025-03-14T09:46:07Z | Merged: 2025-04-04T11:55:44Z

Checklist:

<!-- Please follow this checklist and put an x in each of the boxes, like this: [x]. It will ensure that our team takes your pull request seriously. -->

- [x] I have read and followed the [contribution guidelines](https://contribute.freecodecamp.org).
- [x] I have read and followed the [how to open a pull request guide](https://contribute.freecodecamp.org/how-to-open-a-pull-request/).
- [x] My pull request targets the `main` branch of freeCodeCamp.
- [x] I have tested these changes either locally on my machine, or Gitpod.

<!--If your pull request closes a GitHub issue, replace the XXXXX below with the issue number.-->

This creates new endpoints: GET /users/get-public-profile and GET /users/exists. They should behave identically to /api /users/get-public-profile and /api/users/exists, respectively.

Once this is live, we'll need two more deployments to complete the move. One to update the web client to use the new endpoints and one more to deprecate the old endpoints.

@freeCodeCamp/mobile does the mobile app use either of these endpoints?

<!-- Feel free to add any additional description of changes below this line -->



Diff:
diff --git a/api-server/src/server/boot/randomAPIs.js b/api-server/src/server/boot/randomAPIs.js
index dd52e2aae8b700..a8db1d98a71d5d 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 aeb6edf1b07a5a..7cc94a85338b06 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 2cb878081bb4f6..6d3a00982d5e66 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 4439f54e619da5..f8eb290354a472 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 62f4da7b4f560e..75bc45aad09d1e 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 8afb7f295ed752..6d9ef398cd0f9d 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 0416d3ff28ec98..8655c824ee5a3e 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 f4305e0cb097d0..06ff7f90a4dc1e 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({


Reviews:
user: login: Nirajn2311
body: Nope we don't use either of the endpoints in the mobile app
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-02T14:17:31Z
user: login: moT01
body: Hopefully this isn't overkill, here are my findings...

### **Old API:**

| **Req**                                    | **Res**                                                                                                                                                        | **Notes**                             |
|--------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------|
| /api/users/exists?username=developmentuser | { "exists": true }                                                                                                                                             | Correct                               |
| /users/exists?username=developmentuser     | { "exists": true }                                                                                                                                             | Correct                               |
| /api/users/exists?username=fakeuser        | { "exists": false }                                                                                                                                            | Correct                               |
| /users/exists?username=fakeuser            | { "exists": false }                                                                                                                                            | Correct                               |
| /api/users/exists?email=foo@bar.com        | { "exists": true }                                                                                                                                             | Correct                               |
| /users/exists?email=foo@bar.com            | {     "type": "danger",     "message": "Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists." } | Should return `{ "exists": true }` ?  |
| /api/users/exists?email=fake@user.com      | { "exists": false }                                                                                                                                            | Correct                               |
| /users/exists?email=fake@user.com          | {     "type": "danger",     "message": "Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists." } | Should return `{ "exists": false }` ? |
| /api/users/exists?test=info                | {     "type": "danger",     "message": "Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists." } | Correct |
| /users/exists?test=info                    | {     "type": "danger",     "message": "Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists." } | Correct |

^ The old endpoint allows query by email, the new endpoint does not.

### **New API:**

| **Req**                                    | **Res**             | **Notes**                                     |
|--------------------------------------------|---------------------|-----------------------------------------------|
| /api/users/exists?username=developmentuser | { "exists": true }  | Correct                                       |
| /users/exists?username=developmentuser     | { "exists": true }  | Correct                                       |
| /api/users/exists?username=fakeuser        | { "exists": false } | Correct                                       |
| /users/exists?username=fakeuser            | { "exists": false } | Correct                                       |
| /api/users/exists?email=foo@bar.com        | { "exists": true }  | Correct - but only by coincidence?            |
| /users/exists?email=foo@bar.com            | { "exists": true }  | Correct - but only by coincidence?            |
| /api/users/exists?email=fake@user.com      | { "exists": true }  | Should return `{ "exists": false }` ?         |
| /users/exists?email=fake@user.com          | { "exists": true }  | Should return `{ "exists": false }` ?         |
| /api/users/exists?test=info                | { "exists": true }  | Should return error? |
| /users/exists?test=info                    | { "exists": true }  | Should return error? |

^ Doesn't return errors when invalid query param - always returns `{ "exists": true }` - doesn't hand email params which the old endpoint on the old API does.

Hopefully that isn't too confusing. A couple other thoughts I had:

Should we keep tests for both (all) endpoints for now? 
Do we plan to get rid of the old endpoints at some point? Or just keep them around as deprecated?
No need to answer those.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T21:50:15Z
user: login: moT01
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T17:24:41Z
user: login: moT01
body: LGTM 🎉 I thought you said you wanted to take a look at this before it went in @raisedadead, but I don't recall.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-03T17:26:24Z
user: login: raisedadead
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-04T11:11:17Z

Review comments:
diff_hunk: @@ -385,52 +385,61 @@ 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 });
+        console.log(res);
+        console.log(res.body);
+
path: api/src/routes/public/user.test.ts
user: login: moT01
body: ```suggestion
```
created_at: 2025-04-03T17:24:41Z
author_association: MEMBER

PR comments:
user: login: ojeytonwilliams
created_at: 2025-04-03T07:39:03Z
body: Thanks for going through this so carefully, Tom.

> The old endpoint allows query by email, the new endpoint does not.

Yep, that's right. The client only ever queries by `username`, so the new api only makes use of that query param. I didn't bother supporting `email` in the old api's `/users` copy for the same reason.

> Do we plan to get rid of the old endpoints at some point? Or just keep them around as deprecated?

We typically "deprecate" them. However it's not a true deprecation (i.e. it's not a warning, the functionality is turned off), so simply deleting them seems fine to me.

> Should we keep tests for both (all) endpoints for now? 

We could, but we're about to deprecate/delete them, so I'm not sure we need to. That said, it's not hard to copy the tests, too, so I could do that if you like.

 
author_association: CONTRIBUTOR
user: login: moT01
created_at: 2025-04-03T13:20:44Z
body: I went over so much cause I saw some unexpected responses. So do we want to go with it like this? The only thing I would say to maybe add is some validation on the new API. `if !req.query.username -> return error` - but if we don't need that, I'm fine with it. No, I don't think we need to duplicate the tests.
author_association: MEMBER
user: login: ojeytonwilliams
created_at: 2025-04-03T13:53:01Z
body: Sure, I'll reject other query params.
author_association: CONTRIBUTOR