mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(api): api/users/get-public-profile (#54729)
Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
committed by
GitHub
parent
02234419a9
commit
a8f7e15dc2
+2
-1
@@ -41,7 +41,7 @@ import { donateRoutes } from './routes/donate';
|
||||
import { emailSubscribtionRoutes } from './routes/email-subscription';
|
||||
import { settingRoutes } from './routes/settings';
|
||||
import { statusRoute } from './routes/status';
|
||||
import { userGetRoutes, userRoutes } from './routes/user';
|
||||
import { userGetRoutes, userRoutes, userPublicGetRoutes } from './routes/user';
|
||||
import {
|
||||
API_LOCATION,
|
||||
COOKIE_DOMAIN,
|
||||
@@ -208,6 +208,7 @@ export const build = async (
|
||||
void fastify.register(donateRoutes);
|
||||
void fastify.register(emailSubscribtionRoutes);
|
||||
void fastify.register(userRoutes);
|
||||
void fastify.register(userPublicGetRoutes);
|
||||
void fastify.register(protectedCertificateRoutes);
|
||||
void fastify.register(unprotectedCertificateRoutes);
|
||||
void fastify.register(userGetRoutes);
|
||||
|
||||
+358
-41
@@ -16,7 +16,7 @@ import {
|
||||
createSuperRequest
|
||||
} from '../../jest.utils';
|
||||
import { JWT_SECRET } from '../utils/env';
|
||||
import { getMsTranscriptApiUrl } from './user';
|
||||
import { getMsTranscriptApiUrl, replacePrivateData } from './user';
|
||||
|
||||
const mockedFetch = jest.fn();
|
||||
jest.spyOn(globalThis, 'fetch').mockImplementation(mockedFetch);
|
||||
@@ -79,7 +79,7 @@ const testUserData: Prisma.userCreateInput = {
|
||||
],
|
||||
savedChallenges: [
|
||||
{
|
||||
id: 'abc123',
|
||||
id: 'a6b0bb188d873cb2c8729495',
|
||||
lastSavedDate: 123,
|
||||
files: [
|
||||
{
|
||||
@@ -110,6 +110,19 @@ const minimalUserData: Prisma.userCreateInput = {
|
||||
unsubscribeId: '1234567890'
|
||||
};
|
||||
|
||||
const lockedProfileUI = {
|
||||
isLocked: true,
|
||||
showAbout: false,
|
||||
showCerts: false,
|
||||
showDonation: false,
|
||||
showHeatMap: false,
|
||||
showLocation: false,
|
||||
showName: false,
|
||||
showPoints: false,
|
||||
showPortfolio: false,
|
||||
showTimeLine: false
|
||||
};
|
||||
|
||||
// These are not part of the schema, but are added to the user object by
|
||||
// get-session-user's handler
|
||||
const computedProperties = {
|
||||
@@ -119,24 +132,23 @@ const computedProperties = {
|
||||
points: 1,
|
||||
// This is the default value if profileUI is missing. If individual properties
|
||||
// are missing from the db, they will be omitted from the response.
|
||||
profileUI: {
|
||||
isLocked: true,
|
||||
showAbout: false,
|
||||
showCerts: false,
|
||||
showDonation: false,
|
||||
showHeatMap: false,
|
||||
showLocation: false,
|
||||
showName: false,
|
||||
showPoints: false,
|
||||
showPortfolio: false,
|
||||
showTimeLine: false
|
||||
}
|
||||
profileUI: lockedProfileUI
|
||||
};
|
||||
|
||||
// The following appears in get-session-user responses, but not
|
||||
// get-public-profile
|
||||
const sessionOnlyData = {
|
||||
currentChallengeId: testUserData.currentChallengeId,
|
||||
email: testUserData.email,
|
||||
emailVerified: testUserData.emailVerified,
|
||||
isEmailVerified: testUserData.emailVerified,
|
||||
sendQuincyEmail: testUserData.sendQuincyEmail,
|
||||
theme: testUserData.theme,
|
||||
keyboardShortcuts: testUserData.keyboardShortcuts,
|
||||
completedChallengeCount: 3,
|
||||
acceptedPrivacyTerms: testUserData.acceptedPrivacyTerms
|
||||
};
|
||||
|
||||
// This is (most of) what we expect to get back from the API. The remaining
|
||||
// properties are 'id' and 'joinDate', which are generated by the database.
|
||||
// We're currently filtering properties with null values, since the old api just
|
||||
// would not return those.
|
||||
const publicUserData = {
|
||||
about: testUserData.about,
|
||||
calendar: { 1520002973: 1, 1520440323: 1 },
|
||||
@@ -177,32 +189,31 @@ const publicUserData = {
|
||||
}
|
||||
],
|
||||
completedExams: testUserData.completedExams,
|
||||
completedSurveys: [],
|
||||
completedSurveys: [], // TODO: add surveys
|
||||
githubProfile: testUserData.githubProfile,
|
||||
is2018DataVisCert: testUserData.is2018DataVisCert,
|
||||
is2018FullStackCert: testUserData.is2018FullStackCert, // TODO: should this be returned? The client doesn't use it at the moment.
|
||||
isApisMicroservicesCert: testUserData.isApisMicroservicesCert,
|
||||
isBackEndCert: testUserData.isBackEndCert,
|
||||
isCheater: testUserData.isCheater,
|
||||
isDonating: testUserData.isDonating,
|
||||
isEmailVerified: testUserData.emailVerified,
|
||||
is2018DataVisCert: testUserData.is2018DataVisCert,
|
||||
is2018FullStackCert: testUserData.is2018FullStackCert,
|
||||
isCollegeAlgebraPyCertV8: testUserData.isCollegeAlgebraPyCertV8,
|
||||
isDataAnalysisPyCertV7: testUserData.isDataAnalysisPyCertV7,
|
||||
isDataVisCert: testUserData.isDataVisCert,
|
||||
isDonating: testUserData.isDonating,
|
||||
isFoundationalCSharpCertV8: testUserData.isFoundationalCSharpCertV8,
|
||||
isFrontEndCert: testUserData.isFrontEndCert,
|
||||
isFullStackCert: testUserData.isFullStackCert,
|
||||
isFrontEndLibsCert: testUserData.isFrontEndLibsCert,
|
||||
isFullStackCert: testUserData.isFullStackCert,
|
||||
isHonest: testUserData.isHonest,
|
||||
isInfosecQaCert: testUserData.isInfosecQaCert,
|
||||
isQaCertV7: testUserData.isQaCertV7,
|
||||
isInfosecCertV7: testUserData.isInfosecCertV7,
|
||||
isInfosecQaCert: testUserData.isInfosecQaCert,
|
||||
isJsAlgoDataStructCert: testUserData.isJsAlgoDataStructCert,
|
||||
isJsAlgoDataStructCertV8: testUserData.isJsAlgoDataStructCertV8,
|
||||
isMachineLearningPyCertV7: testUserData.isMachineLearningPyCertV7,
|
||||
isQaCertV7: testUserData.isQaCertV7,
|
||||
isRelationalDatabaseCertV8: testUserData.isRelationalDatabaseCertV8,
|
||||
isRespWebDesignCert: testUserData.isRespWebDesignCert,
|
||||
isSciCompPyCertV7: testUserData.isSciCompPyCertV7,
|
||||
isDataAnalysisPyCertV7: testUserData.isDataAnalysisPyCertV7,
|
||||
isMachineLearningPyCertV7: testUserData.isMachineLearningPyCertV7,
|
||||
isCollegeAlgebraPyCertV8: testUserData.isCollegeAlgebraPyCertV8,
|
||||
linkedin: testUserData.linkedin,
|
||||
location: testUserData.location,
|
||||
name: testUserData.name,
|
||||
@@ -211,19 +222,20 @@ const publicUserData = {
|
||||
points: 2,
|
||||
portfolio: testUserData.portfolio,
|
||||
profileUI: testUserData.profileUI,
|
||||
savedChallenges: testUserData.savedChallenges,
|
||||
twitter: 'https://twitter.com/foobar',
|
||||
username: testUserData.usernameDisplay, // It defaults to usernameDisplay
|
||||
website: testUserData.website,
|
||||
yearsTopContributor: testUserData.yearsTopContributor,
|
||||
currentChallengeId: testUserData.currentChallengeId,
|
||||
email: testUserData.email,
|
||||
emailVerified: testUserData.emailVerified,
|
||||
sendQuincyEmail: testUserData.sendQuincyEmail,
|
||||
theme: testUserData.theme,
|
||||
twitter: 'https://twitter.com/foobar',
|
||||
keyboardShortcuts: testUserData.keyboardShortcuts,
|
||||
completedChallengeCount: 3,
|
||||
acceptedPrivacyTerms: testUserData.acceptedPrivacyTerms,
|
||||
savedChallenges: testUserData.savedChallenges
|
||||
yearsTopContributor: testUserData.yearsTopContributor
|
||||
};
|
||||
|
||||
// This is (most of) what we expect to get back from the API. The remaining
|
||||
// properties are 'id' and 'joinDate', which are generated by the database.
|
||||
// We're currently filtering properties with null values, since the old api just
|
||||
// would not return those.
|
||||
const sessionUserData = {
|
||||
...sessionOnlyData,
|
||||
...publicUserData
|
||||
};
|
||||
|
||||
const baseProgressData = {
|
||||
@@ -554,7 +566,7 @@ describe('userRoutes', () => {
|
||||
where: { email: testUserData.email }
|
||||
});
|
||||
const publicUser = {
|
||||
...publicUserData,
|
||||
...sessionUserData,
|
||||
id: testUser?.id,
|
||||
joinDate: new ObjectId(testUser?.id).getTimestamp().toISOString()
|
||||
};
|
||||
@@ -1114,6 +1126,167 @@ Thanks and regards,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Public', () => {
|
||||
let superGet: ReturnType<typeof createSuperRequest>;
|
||||
|
||||
beforeEach(() => {
|
||||
superGet = createSuperRequest({ method: 'GET' });
|
||||
});
|
||||
|
||||
describe('/api/users/get-public-profile', () => {
|
||||
const profilelessUsername = 'profileless-user';
|
||||
const lockedUsername = 'locked-user';
|
||||
const publicUsername = 'public-user';
|
||||
const lockedUserProfileUI = {
|
||||
isLocked: true,
|
||||
showAbout: true,
|
||||
showPortfolio: false
|
||||
};
|
||||
const unlockedUserProfileUI = {
|
||||
isLocked: false,
|
||||
showAbout: true,
|
||||
showCerts: true,
|
||||
showDonation: true,
|
||||
showHeatMap: true,
|
||||
showLocation: true,
|
||||
showName: true,
|
||||
showPoints: true,
|
||||
showPortfolio: true,
|
||||
showTimeLine: true
|
||||
};
|
||||
const users = [profilelessUsername, lockedUsername, publicUsername];
|
||||
beforeAll(async () => {
|
||||
await fastifyTestInstance.prisma.user.create({
|
||||
data: {
|
||||
...minimalUserData,
|
||||
email: profilelessUsername,
|
||||
username: profilelessUsername
|
||||
}
|
||||
});
|
||||
await fastifyTestInstance.prisma.user.create({
|
||||
data: {
|
||||
...minimalUserData,
|
||||
email: lockedUsername,
|
||||
username: lockedUsername,
|
||||
profileUI: lockedUserProfileUI
|
||||
}
|
||||
});
|
||||
await fastifyTestInstance.prisma.user.create({
|
||||
data: {
|
||||
...testUserData,
|
||||
email: publicUsername,
|
||||
username: publicUsername,
|
||||
profileUI: unlockedUserProfileUI
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fastifyTestInstance.prisma.user.deleteMany({
|
||||
where: {
|
||||
OR: users.map(username => ({ username }))
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET', () => {
|
||||
test('returns 400 status code if the username param is missing', async () => {
|
||||
const res = await superGet('/api/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=');
|
||||
// TODO(Post-MVP): return something more informative
|
||||
expect(res.body).toStrictEqual({});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
test('returns 404 status code for non-existent user', async () => {
|
||||
const response = await superGet(
|
||||
'/api/users/get-public-profile?username=non-existent'
|
||||
);
|
||||
// TODO(Post-MVP): return something more informative
|
||||
expect(response.body).toStrictEqual({});
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
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}`
|
||||
);
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
entities: {
|
||||
user: {
|
||||
[lockedUsername]: {
|
||||
isLocked: true,
|
||||
profileUI: lockedUserProfileUI,
|
||||
username: lockedUsername
|
||||
}
|
||||
}
|
||||
},
|
||||
result: lockedUsername
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
test('returns 200 status code locked profile if the profile is missing', async () => {
|
||||
const response = await superGet(
|
||||
`/api/users/get-public-profile?username=${profilelessUsername}`
|
||||
);
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
entities: {
|
||||
user: {
|
||||
[profilelessUsername]: {
|
||||
isLocked: true,
|
||||
profileUI: lockedProfileUI,
|
||||
username: profilelessUsername
|
||||
}
|
||||
}
|
||||
},
|
||||
result: profilelessUsername
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
// TODO: create a list of public properties like the api-server and use that
|
||||
// to restrict the output of this and get-session-user.
|
||||
test('returns 200 status code with public user object', async () => {
|
||||
const testUser =
|
||||
await fastifyTestInstance.prisma.user.findFirstOrThrow({
|
||||
where: { email: publicUsername }
|
||||
});
|
||||
const response = await superGet(
|
||||
`/api/users/get-public-profile?username=${publicUsername}`
|
||||
);
|
||||
|
||||
// TODO: create a fixture for this without 'completedSurveys', ideally
|
||||
// it should contain the entire body.
|
||||
const publicUser = {
|
||||
// TODO(Post-MVP, maybe): return completedSurveys?
|
||||
..._.omit(publicUserData, 'completedSurveys'),
|
||||
username: publicUsername,
|
||||
joinDate: new ObjectId(testUser.id).getTimestamp().toISOString(),
|
||||
profileUI: unlockedUserProfileUI
|
||||
};
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
entities: {
|
||||
user: {
|
||||
[publicUsername]: publicUser
|
||||
}
|
||||
},
|
||||
result: publicUsername
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Microsoft helpers', () => {
|
||||
@@ -1143,3 +1316,147 @@ describe('Microsoft helpers', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('get-public-profile helpers', () => {
|
||||
describe('replacePrivateData', () => {
|
||||
const user = {
|
||||
about: 'about',
|
||||
calendar: { 1: 1, 2: 1 } as const,
|
||||
completedChallenges: [
|
||||
{ id: '123', completedDate: 123, files: [] },
|
||||
{ id: '456', completedDate: 456, challengeType: 7, files: [] }
|
||||
],
|
||||
id: '5f5b1b3b1c9d440000d9e3b4',
|
||||
isDonating: false,
|
||||
location: 'location',
|
||||
joinDate: 'joinDate',
|
||||
name: 'name',
|
||||
points: 2,
|
||||
portfolio: [
|
||||
{
|
||||
id: '789',
|
||||
title: 'portfolio',
|
||||
url: 'url',
|
||||
image: 'image',
|
||||
description: 'description'
|
||||
}
|
||||
],
|
||||
profileUI: {
|
||||
isLocked: false,
|
||||
showAbout: true,
|
||||
showCerts: true,
|
||||
showDonation: true,
|
||||
showHeatMap: true,
|
||||
showLocation: true,
|
||||
showName: true,
|
||||
showPoints: true,
|
||||
showPortfolio: true,
|
||||
showTimeLine: true
|
||||
}
|
||||
};
|
||||
|
||||
test(`returns "" for 'about' if showAbout is not true`, () => {
|
||||
const userWithoutAbout = {
|
||||
...user,
|
||||
profileUI: { ...user.profileUI, showAbout: false }
|
||||
};
|
||||
expect(replacePrivateData(userWithoutAbout)).toMatchObject({
|
||||
about: ''
|
||||
});
|
||||
});
|
||||
|
||||
test('returns {} for calendar if showHeatMap is not true', () => {
|
||||
const userWithoutHeatMap = {
|
||||
...user,
|
||||
profileUI: { ...user.profileUI, showHeatMap: false }
|
||||
};
|
||||
expect(replacePrivateData(userWithoutHeatMap).calendar).toEqual({});
|
||||
});
|
||||
|
||||
test(`returns [] for completeChallenges if showTimeLine is not true`, () => {
|
||||
const userWithoutTimeLine = {
|
||||
...user,
|
||||
profileUI: { ...user.profileUI, showTimeLine: false }
|
||||
};
|
||||
expect(replacePrivateData(userWithoutTimeLine)).toMatchObject({
|
||||
completedChallenges: []
|
||||
});
|
||||
});
|
||||
|
||||
test('omits certifications from completedChallenges if showCerts is not true', () => {
|
||||
const userWithoutCerts = {
|
||||
...user,
|
||||
profileUI: { ...user.profileUI, showCerts: false }
|
||||
};
|
||||
expect(replacePrivateData(userWithoutCerts)).toMatchObject({
|
||||
completedChallenges: [{ id: '123', completedDate: 123, files: [] }]
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null for isDonating if showDonation is not true', () => {
|
||||
const userWithoutDonation = {
|
||||
...user,
|
||||
profileUI: { ...user.profileUI, showDonation: false }
|
||||
};
|
||||
expect(replacePrivateData(userWithoutDonation)).toMatchObject({
|
||||
isDonating: null
|
||||
});
|
||||
});
|
||||
|
||||
test('returns "" for joinDate if showAbout is not true', () => {
|
||||
const userWithoutAbout = {
|
||||
...user,
|
||||
profileUI: { ...user.profileUI, showAbout: false }
|
||||
};
|
||||
expect(replacePrivateData(userWithoutAbout)).toMatchObject({
|
||||
joinDate: ''
|
||||
});
|
||||
});
|
||||
|
||||
test(`returns "" for 'location' if showLocation is not true`, () => {
|
||||
const userWithoutLocation = {
|
||||
...user,
|
||||
profileUI: { ...user.profileUI, showLocation: false }
|
||||
};
|
||||
expect(replacePrivateData(userWithoutLocation)).toMatchObject({
|
||||
location: ''
|
||||
});
|
||||
});
|
||||
|
||||
test(`returns "" for 'name' if showName is not true`, () => {
|
||||
const userWithoutName = {
|
||||
...user,
|
||||
profileUI: { ...user.profileUI, showName: false }
|
||||
};
|
||||
expect(replacePrivateData(userWithoutName)).toMatchObject({
|
||||
name: ''
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null for points if showPoints is not true', () => {
|
||||
const userWithoutPoints = {
|
||||
...user,
|
||||
profileUI: { ...user.profileUI, showPoints: false }
|
||||
};
|
||||
expect(replacePrivateData(userWithoutPoints)).toMatchObject({
|
||||
points: null
|
||||
});
|
||||
});
|
||||
|
||||
test('returns [] for portfolio if showPortfolio is not true', () => {
|
||||
const userWithoutPortfolio = {
|
||||
...user,
|
||||
profileUI: { ...user.profileUI, showPortfolio: false }
|
||||
};
|
||||
expect(replacePrivateData(userWithoutPortfolio)).toMatchObject({
|
||||
portfolio: []
|
||||
});
|
||||
});
|
||||
|
||||
test('returns the expected public user object if all showX flags are true', () => {
|
||||
expect(replacePrivateData(user)).toEqual(
|
||||
_.omit(user, ['id', 'profileUI'])
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+183
-1
@@ -1,4 +1,5 @@
|
||||
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
import { Portfolio } from '@prisma/client';
|
||||
import { ObjectId } from 'mongodb';
|
||||
import _ from 'lodash';
|
||||
|
||||
@@ -12,9 +13,11 @@ import {
|
||||
normalizeProfileUI,
|
||||
normalizeTwitter,
|
||||
removeNulls,
|
||||
normalizeSurveys
|
||||
normalizeSurveys,
|
||||
NormalizedChallenge
|
||||
} from '../utils/normalize';
|
||||
import {
|
||||
Calendar,
|
||||
getCalendar,
|
||||
getPoints,
|
||||
type ProgressTimestamp
|
||||
@@ -23,6 +26,7 @@ import { encodeUserToken } from '../utils/tokens';
|
||||
import { trimTags } from '../utils/validation';
|
||||
import { generateReportEmail } from '../utils/email-templates';
|
||||
import { createResetProperties } from '../utils/create-user';
|
||||
import { challengeTypes } from '../../../shared/config/challenge-types';
|
||||
|
||||
// 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
|
||||
@@ -542,6 +546,8 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
|
||||
void res.code(500);
|
||||
return { user: {}, result: '' };
|
||||
}
|
||||
// TODO: DRY this (the creation of the response body) and
|
||||
// get-public-profile's response body creation.
|
||||
|
||||
const encodedToken = userToken
|
||||
? encodeUserToken(userToken.id)
|
||||
@@ -606,3 +612,179 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
type ProfileUI = Partial<{
|
||||
isLocked: boolean;
|
||||
showAbout: boolean;
|
||||
showCerts: boolean;
|
||||
showDonation: boolean;
|
||||
showHeatMap: boolean;
|
||||
showLocation: boolean;
|
||||
showName: boolean;
|
||||
showPoints: boolean;
|
||||
showPortfolio: boolean;
|
||||
showTimeLine: boolean;
|
||||
}>;
|
||||
|
||||
type RawUser = {
|
||||
about: string;
|
||||
completedChallenges: NormalizedChallenge[];
|
||||
calendar: Calendar;
|
||||
id: string;
|
||||
isDonating: boolean;
|
||||
joinDate: string;
|
||||
location: string;
|
||||
name: string;
|
||||
points: number;
|
||||
portfolio: Portfolio[];
|
||||
profileUI: ProfileUI;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an object with the properties that are shared with the public.
|
||||
* @param user The raw user object.
|
||||
* @returns The shared user object.
|
||||
*/
|
||||
export const replacePrivateData = (user: RawUser) => {
|
||||
const {
|
||||
showAbout,
|
||||
showHeatMap,
|
||||
showCerts,
|
||||
showDonation,
|
||||
showLocation,
|
||||
showName,
|
||||
showPoints,
|
||||
showPortfolio,
|
||||
showTimeLine
|
||||
} = user.profileUI;
|
||||
|
||||
return {
|
||||
about: showAbout ? user.about : '',
|
||||
calendar: showHeatMap ? user.calendar : {},
|
||||
completedChallenges: showTimeLine
|
||||
? showCerts
|
||||
? user.completedChallenges
|
||||
: user.completedChallenges.filter(
|
||||
c => c.challengeType !== challengeTypes.step
|
||||
)
|
||||
: [],
|
||||
isDonating: showDonation ? user.isDonating : null,
|
||||
joinDate: showAbout ? user.joinDate : '',
|
||||
location: showLocation ? user.location : '',
|
||||
name: showName ? user.name : '',
|
||||
points: showPoints ? user.points : null,
|
||||
portfolio: showPortfolio ? user.portfolio : []
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Plugin containing public GET routes for user account management. They are kept
|
||||
* separate because they do not require CSRF protection or authorization.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
||||
* @param done Callback to signal that the logic has completed.
|
||||
*/
|
||||
export const userPublicGetRoutes: FastifyPluginCallbackTypebox = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
fastify.get(
|
||||
'/api/users/get-public-profile',
|
||||
{
|
||||
schema: schemas.getPublicProfile
|
||||
},
|
||||
async (req, reply) => {
|
||||
// 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) {
|
||||
void reply.code(404);
|
||||
return reply.send({});
|
||||
}
|
||||
|
||||
const flags = _.pick<typeof user, NullableFlag>(user, nullableFlags);
|
||||
const rest = _.omit<typeof user, NullableFlag>(user, nullableFlags);
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
@@ -8,13 +8,13 @@ const ajv = new Ajv({ strictTypes: false });
|
||||
const isSchemaSecure = ajv.compile(secureSchema);
|
||||
|
||||
// These schemas will fail the tests, so can only be checked by hand.
|
||||
const ignoredSchemas = ['getSessionUser'];
|
||||
const ignoredSchemas = ['getSessionUser', 'getPublicProfile'];
|
||||
|
||||
describe('Schemas do not use obviously dangerous validation', () => {
|
||||
Object.entries(schemas)
|
||||
.filter(([schema]) => !ignoredSchemas.includes(schema))
|
||||
.forEach(([name, schema]) => {
|
||||
describe(`schema ${name} is okay`, () => {
|
||||
describe(`schema ${name}`, () => {
|
||||
if ('body' in schema) {
|
||||
test('body is secure', () => {
|
||||
expect(isSchemaSecure(schema.body)).toBeTruthy();
|
||||
@@ -27,6 +27,12 @@ describe('Schemas do not use obviously dangerous validation', () => {
|
||||
});
|
||||
}
|
||||
|
||||
test('should use querystring instead of query', () => {
|
||||
// if query is used then req.query is unknown, but if querystring is
|
||||
// used then req.query has the expected type
|
||||
expect('query' in schema).toBeFalsy();
|
||||
});
|
||||
|
||||
if ('params' in schema) {
|
||||
test('params is secure', () => {
|
||||
expect(isSchemaSecure(schema.params)).toBeTruthy();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { getPublicProfile } from './schemas/api/users/get-public-profile';
|
||||
export { certSlug } from './schemas/certificate/cert-slug';
|
||||
export { certificateVerify } from './schemas/certificate/certificate-verify';
|
||||
export { backendChallengeCompleted } from './schemas/challenge/backend-challenge-completed';
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
import { profileUI, examResults, savedChallenge } from '../../types';
|
||||
|
||||
export const getPublicProfile = {
|
||||
querystring: Type.Object({
|
||||
username: Type.String({ minLength: 1 })
|
||||
}),
|
||||
response: {
|
||||
200: Type.Object({
|
||||
entities: Type.Object({
|
||||
user: Type.Record(
|
||||
Type.String(),
|
||||
Type.Union([
|
||||
Type.Object({
|
||||
isLocked: Type.Boolean(),
|
||||
profileUI,
|
||||
username: Type.String()
|
||||
}),
|
||||
Type.Object({
|
||||
about: Type.String(),
|
||||
calendar: Type.Record(Type.Number(), Type.Literal(1)),
|
||||
completedChallenges: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
completedDate: Type.Number(),
|
||||
solution: Type.Optional(Type.String()),
|
||||
githubLink: Type.Optional(Type.String()),
|
||||
challengeType: Type.Optional(Type.Number()),
|
||||
files: Type.Array(
|
||||
Type.Object({
|
||||
contents: Type.String(),
|
||||
key: Type.String(),
|
||||
ext: Type.String(),
|
||||
name: Type.String(),
|
||||
path: Type.Optional(Type.String())
|
||||
})
|
||||
),
|
||||
isManuallyApproved: Type.Optional(Type.Boolean())
|
||||
})
|
||||
),
|
||||
completedExams: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
completedDate: Type.Number(),
|
||||
challengeType: Type.Optional(Type.Number()),
|
||||
examResults
|
||||
})
|
||||
),
|
||||
// TODO(Post-MVP): return completedSurveys? Presumably not, since why
|
||||
// would this need to be public.
|
||||
githubProfile: Type.Optional(Type.String()),
|
||||
is2018DataVisCert: Type.Boolean(),
|
||||
is2018FullStackCert: Type.Boolean(),
|
||||
isApisMicroservicesCert: Type.Boolean(),
|
||||
isBackEndCert: Type.Boolean(),
|
||||
isCheater: Type.Boolean(),
|
||||
isCollegeAlgebraPyCertV8: Type.Boolean(),
|
||||
isDataAnalysisPyCertV7: Type.Boolean(),
|
||||
isDataVisCert: Type.Boolean(),
|
||||
// TODO(Post-MVP): isDonating should be boolean.
|
||||
isDonating: Type.Union([Type.Boolean(), Type.Null()]),
|
||||
isFoundationalCSharpCertV8: Type.Boolean(),
|
||||
isFrontEndCert: Type.Boolean(),
|
||||
isFrontEndLibsCert: Type.Boolean(),
|
||||
isFullStackCert: Type.Boolean(),
|
||||
isHonest: Type.Boolean(),
|
||||
isInfosecCertV7: Type.Boolean(),
|
||||
isInfosecQaCert: Type.Boolean(),
|
||||
isJsAlgoDataStructCert: Type.Boolean(),
|
||||
isJsAlgoDataStructCertV8: Type.Boolean(),
|
||||
isMachineLearningPyCertV7: Type.Boolean(),
|
||||
isQaCertV7: Type.Boolean(),
|
||||
isRelationalDatabaseCertV8: Type.Boolean(),
|
||||
isRespWebDesignCert: Type.Boolean(),
|
||||
isSciCompPyCertV7: Type.Boolean(),
|
||||
linkedin: Type.Optional(Type.String()),
|
||||
location: Type.String(),
|
||||
name: Type.String(),
|
||||
partiallyCompletedChallenges: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
completedDate: Type.Number()
|
||||
})
|
||||
),
|
||||
picture: Type.String(),
|
||||
// TODO(Post-MVP): points should be a number
|
||||
points: Type.Union([Type.Number(), Type.Null()]),
|
||||
portfolio: Type.Array(
|
||||
Type.Object({
|
||||
description: Type.String(),
|
||||
id: Type.String(),
|
||||
image: Type.String(),
|
||||
title: Type.String(),
|
||||
url: Type.String()
|
||||
})
|
||||
),
|
||||
profileUI,
|
||||
twitter: Type.Optional(Type.String()),
|
||||
website: Type.Optional(Type.String()),
|
||||
yearsTopContributor: Type.Array(Type.String()), // TODO(Post-MVP): convert to number?
|
||||
joinDate: Type.String(),
|
||||
savedChallenges: Type.Array(savedChallenge),
|
||||
username: Type.String(),
|
||||
msUsername: Type.Optional(Type.String())
|
||||
})
|
||||
])
|
||||
)
|
||||
}),
|
||||
result: Type.String()
|
||||
}),
|
||||
// We can't simply have Type.Object({}), even though that's correct, because
|
||||
// TypeScript will then accept all responses (since every object can be
|
||||
// assigned to {})
|
||||
400: Type.Object({ entities: Type.Optional(Type.Never()) }),
|
||||
404: Type.Object({ entities: Type.Optional(Type.Never()) })
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
import { saveChallengeBody } from '../types';
|
||||
import { savedChallenge } from '../types';
|
||||
|
||||
export const modernChallengeCompleted = {
|
||||
body: Type.Object({
|
||||
@@ -22,12 +22,7 @@ export const modernChallengeCompleted = {
|
||||
completedDate: Type.Number(),
|
||||
points: Type.Number(),
|
||||
alreadyCompleted: Type.Boolean(),
|
||||
savedChallenges: Type.Array(
|
||||
Type.Intersect([
|
||||
saveChallengeBody,
|
||||
Type.Object({ lastSavedDate: Type.Number() })
|
||||
])
|
||||
)
|
||||
savedChallenges: Type.Array(savedChallenge)
|
||||
}),
|
||||
400: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
import { saveChallengeBody } from '../types';
|
||||
import { file, savedChallenge } from '../types';
|
||||
|
||||
export const saveChallenge = {
|
||||
body: saveChallengeBody,
|
||||
body: Type.Object({
|
||||
id: Type.String({
|
||||
format: 'objectid',
|
||||
maxLength: 24,
|
||||
minLength: 24
|
||||
}),
|
||||
files: Type.Array(file)
|
||||
}),
|
||||
response: {
|
||||
200: Type.Object({
|
||||
savedChallenges: Type.Array(
|
||||
Type.Intersect([
|
||||
saveChallengeBody,
|
||||
Type.Object({ lastSavedDate: Type.Number() })
|
||||
])
|
||||
)
|
||||
savedChallenges: Type.Array(savedChallenge)
|
||||
}),
|
||||
403: Type.Literal('That challenge type is not saveable.'),
|
||||
500: Type.Object({
|
||||
|
||||
@@ -36,13 +36,13 @@ export const file = Type.Object({
|
||||
history: Type.Array(Type.String())
|
||||
});
|
||||
|
||||
export const saveChallengeBody = Type.Object({
|
||||
id: Type.String({
|
||||
format: 'objectid',
|
||||
maxLength: 24,
|
||||
minLength: 24
|
||||
}),
|
||||
files: Type.Array(file)
|
||||
// This is only used for serialization, so should not use format. Reason being,
|
||||
// the serializer's job is simply to create JSON strings, not to validate the
|
||||
// data.
|
||||
export const savedChallenge = Type.Object({
|
||||
id: Type.String(),
|
||||
files: Type.Array(file),
|
||||
lastSavedDate: Type.Number()
|
||||
});
|
||||
|
||||
export const examResults = Type.Object({
|
||||
@@ -57,3 +57,16 @@ export const examResults = Type.Object({
|
||||
export const surveyTitles = Type.Union([
|
||||
Type.Literal('Foundational C# with Microsoft Survey')
|
||||
]);
|
||||
|
||||
export const profileUI = Type.Object({
|
||||
isLocked: Type.Optional(Type.Boolean()),
|
||||
showAbout: Type.Optional(Type.Boolean()),
|
||||
showCerts: Type.Optional(Type.Boolean()),
|
||||
showDonation: Type.Optional(Type.Boolean()),
|
||||
showHeatMap: Type.Optional(Type.Boolean()),
|
||||
showLocation: Type.Optional(Type.Boolean()),
|
||||
showName: Type.Optional(Type.Boolean()),
|
||||
showPoints: Type.Optional(Type.Boolean()),
|
||||
showPortfolio: Type.Optional(Type.Boolean()),
|
||||
showTimeLine: Type.Optional(Type.Boolean())
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
import { examResults, saveChallengeBody } from '../types';
|
||||
import { examResults, profileUI, savedChallenge } from '../types';
|
||||
|
||||
export const getSessionUser = {
|
||||
response: {
|
||||
@@ -87,18 +87,7 @@ export const getSessionUser = {
|
||||
url: Type.String()
|
||||
})
|
||||
),
|
||||
profileUI: Type.Object({
|
||||
isLocked: Type.Optional(Type.Boolean()),
|
||||
showAbout: Type.Optional(Type.Boolean()),
|
||||
showCerts: Type.Optional(Type.Boolean()),
|
||||
showDonation: Type.Optional(Type.Boolean()),
|
||||
showHeatMap: Type.Optional(Type.Boolean()),
|
||||
showLocation: Type.Optional(Type.Boolean()),
|
||||
showName: Type.Optional(Type.Boolean()),
|
||||
showPoints: Type.Optional(Type.Boolean()),
|
||||
showPortfolio: Type.Optional(Type.Boolean()),
|
||||
showTimeLine: Type.Optional(Type.Boolean())
|
||||
}),
|
||||
profileUI: Type.Optional(profileUI),
|
||||
sendQuincyEmail: Type.Boolean(),
|
||||
theme: Type.String(),
|
||||
twitter: Type.Optional(Type.String()),
|
||||
@@ -106,12 +95,7 @@ export const getSessionUser = {
|
||||
yearsTopContributor: Type.Array(Type.String()), // TODO(Post-MVP): convert to number?
|
||||
isEmailVerified: Type.Boolean(),
|
||||
joinDate: Type.String(),
|
||||
savedChallenges: Type.Array(
|
||||
Type.Intersect([
|
||||
saveChallengeBody,
|
||||
Type.Object({ lastSavedDate: Type.Number() })
|
||||
])
|
||||
),
|
||||
savedChallenges: Type.Optional(Type.Array(savedChallenge)),
|
||||
username: Type.String(),
|
||||
userToken: Type.Optional(Type.String()),
|
||||
completedSurveys: Type.Array(
|
||||
|
||||
@@ -9,11 +9,16 @@ import {
|
||||
import _ from 'lodash';
|
||||
|
||||
type NullToUndefined<T> = T extends null ? undefined : T;
|
||||
type NullToFalse<T> = T extends null ? false : T;
|
||||
|
||||
type NoNullProperties<T> = {
|
||||
[P in keyof T]: NullToUndefined<T[P]>;
|
||||
};
|
||||
|
||||
type DefaultToFalse<T> = {
|
||||
[P in keyof T]: NullToFalse<T[P]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a Twitter handle or URL to a URL.
|
||||
*
|
||||
@@ -78,7 +83,7 @@ type NormalizedFile = {
|
||||
path?: string;
|
||||
};
|
||||
|
||||
type NormalizedChallenge = {
|
||||
export type NormalizedChallenge = {
|
||||
challengeType?: number;
|
||||
completedDate: number;
|
||||
files: NormalizedFile[];
|
||||
@@ -142,11 +147,5 @@ export const normalizeSurveys = (
|
||||
*/
|
||||
export const normalizeFlags = <T extends Record<string, boolean | null>>(
|
||||
flags: T
|
||||
): { [K in keyof T]: boolean } => {
|
||||
const out = {} as { [K in keyof T]: boolean };
|
||||
for (const key in flags) {
|
||||
const v = flags[key];
|
||||
out[key] = typeof v === 'boolean' ? v : false;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
): DefaultToFalse<T> =>
|
||||
_.mapValues(flags, flag => flag ?? false) as DefaultToFalse<T>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type ProgressTimestamp = number | { timestamp: number } | null;
|
||||
|
||||
export type Calendar = Record<number, 1>;
|
||||
/**
|
||||
* Converts a ProgressTimestamp array to a object with keys based on the timestamps.
|
||||
*
|
||||
@@ -8,8 +8,8 @@ export type ProgressTimestamp = number | { timestamp: number } | null;
|
||||
*/
|
||||
export const getCalendar = (
|
||||
progressTimestamps: ProgressTimestamp[] | null
|
||||
): Record<string, 1> => {
|
||||
const calendar: Record<string, 1> = {};
|
||||
): Calendar => {
|
||||
const calendar: Calendar = {};
|
||||
|
||||
progressTimestamps?.forEach(progress => {
|
||||
if (progress === null) return;
|
||||
|
||||
Reference in New Issue
Block a user