mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-01-06 06:01:31 -05:00
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:
committed by
GitHub
parent
7e10d90f5a
commit
087d17abe6
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
};
|
||||
@@ -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({
|
||||
Reference in New Issue
Block a user