feat(api): copy /api endpoints (#59283)

Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>
Co-authored-by: Mrugesh Mohapatra <1884376+raisedadead@users.noreply.github.com>
This commit is contained in:
Oliver Eyton-Williams
2025-04-04 13:55:43 +02:00
committed by GitHub
parent 7e10d90f5a
commit 087d17abe6
8 changed files with 206 additions and 29 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 });
});

View File

@@ -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)) {

View File

@@ -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';

View File

@@ -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")
})
}
};

View File

@@ -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({