feat(api): decouple api from curriculum (#40703)

This commit is contained in:
Oliver Eyton-Williams
2021-02-22 07:53:59 +01:00
committed by GitHub
parent f4bbe3f34c
commit c077ffe4b9
172 changed files with 376 additions and 345 deletions

View File

@@ -0,0 +1,7 @@
// The path where to mount the REST API app
exports.restApiRoot = '/api';
//
// The URL where the browser client can access the REST API is available
// Replace with a full url (including hostname) if your client is being
// served from a different server than your REST API.
exports.restApiUrl = exports.restApiRoot;

View File

@@ -0,0 +1 @@
&{ @import "./app/index.less"; }

View File

@@ -0,0 +1,94 @@
import { Observable } from 'rx';
import debug from 'debug';
import { observeMethod, observeQuery } from '../../server/utils/rx';
import {
createUserUpdatesFromProfile,
getSocialProvider
} from '../../server/utils/auth';
const log = debug('fcc:models:UserCredential');
module.exports = function(UserCredential) {
UserCredential.link = function(
userId,
_provider,
authScheme,
profile,
credentials,
options = {},
cb
) {
if (typeof options === 'function' && !cb) {
cb = options;
options = {};
}
const User = UserCredential.app.models.User;
const findCred = observeMethod(UserCredential, 'findOne');
const createCred = observeMethod(UserCredential, 'create');
const provider = getSocialProvider(_provider);
const query = {
where: {
provider: provider,
externalId: profile.id
}
};
// find createCred if they exist
// if not create it
// if yes, update credentials
// also if github
// update profile
// update username
// update picture
log('link query', query);
return findCred(query)
.flatMap(_credentials => {
const modified = new Date();
const updateUser = new Promise((resolve, reject) => {
User.find({ id: userId }, (err, user) => {
if (err) {
return reject(err);
}
return user.updateAttributes(
createUserUpdatesFromProfile(provider, profile),
updateErr => {
if (updateErr) {
return reject(updateErr);
}
return resolve();
}
);
});
});
let updateCredentials;
if (!_credentials) {
updateCredentials = createCred({
provider,
externalId: profile.id,
authScheme,
// we no longer want to keep the profile
// this is information we do not need or use
profile: null,
credentials,
userId,
created: modified,
modified
});
} else {
_credentials.credentials = credentials;
updateCredentials = observeQuery(_credentials, 'updateAttributes', {
profile: null,
credentials,
modified
});
}
return Observable.combineLatest(
Observable.fromPromise(updateUser),
updateCredentials,
(_, credentials) => credentials
);
})
.subscribe(credentials => cb(null, credentials), cb);
};
};

View File

@@ -0,0 +1,16 @@
{
"name": "userCredential",
"plural": "userCredentials",
"base": "UserCredential",
"properties": {},
"validations": [],
"relations": {
"user": {
"type": "belongsTo",
"model": "user",
"foreignKey": "userId"
}
},
"acls": [],
"methods": {}
}

View File

@@ -0,0 +1,158 @@
import { Observable } from 'rx';
// import debug from 'debug';
import dedent from 'dedent';
import { isEmail } from 'validator';
import { observeMethod, observeQuery } from '../../server/utils/rx';
import { wrapHandledError } from '../../server/utils/create-handled-error.js';
// const log = debug('fcc:models:userIdent');
export function ensureLowerCaseEmail(profile) {
return typeof profile?.emails?.[0]?.value === 'string'
? profile.emails[0].value.toLowerCase()
: '';
}
export default function(UserIdent) {
UserIdent.on('dataSourceAttached', () => {
UserIdent.findOne$ = observeMethod(UserIdent, 'findOne');
});
UserIdent.login = function(
_provider,
authScheme,
profile,
credentials,
options,
cb
) {
const User = UserIdent.app.models.User;
const AccessToken = UserIdent.app.models.AccessToken;
options = options || {};
if (typeof options === 'function' && !cb) {
cb = options;
options = {};
}
// get the social provider data and the external id from auth0
profile.id = profile.id || profile.openid;
const auth0IdString = '' + profile.id;
const [provider, socialExtId] = auth0IdString.split('|');
const query = {
where: {
provider: provider,
externalId: socialExtId
},
include: 'user'
};
// get the email from the auth0 (its expected from social providers)
const email = ensureLowerCaseEmail(profile);
if (!isEmail('' + email)) {
throw wrapHandledError(
new Error('invalid or empty email received from auth0'),
{
message: dedent`
${provider} did not return a valid email address.
Please try again with a different account that has an
email associated with it your update your settings on ${provider}, for us to be able to retrieve your email.
`,
type: 'info',
redirectTo: '/'
}
);
}
if (provider === 'email') {
return User.findOne$({ where: { email } })
.flatMap(user => {
return user
? Observable.of(user)
: User.create$({ email }).toPromise();
})
.flatMap(user => {
if (!user) {
throw wrapHandledError(
new Error('could not find or create a user'),
{
message: dedent`
We could not find or create a user with that email address.
`,
type: 'info',
redirectTo: '/'
}
);
}
const createToken = observeQuery(AccessToken, 'create', {
userId: user.id,
created: new Date(),
ttl: user.constructor.settings.ttl
});
const updateUserPromise = new Promise((resolve, reject) =>
user.updateAttributes(
{
emailVerified: true,
emailAuthLinkTTL: null,
emailVerifyTTL: null
},
err => {
if (err) {
return reject(err);
}
return resolve();
}
)
);
return Observable.combineLatest(
Observable.of(user),
createToken,
Observable.fromPromise(updateUserPromise),
(user, token) => ({ user, token })
);
})
.subscribe(({ user, token }) => cb(null, user, null, token), cb);
} else {
return UserIdent.findOne$(query)
.flatMap(identity => {
return identity
? Observable.of(identity.user())
: User.findOne$({ where: { email } }).flatMap(user => {
return user
? Observable.of(user)
: User.create$({ email }).toPromise();
});
})
.flatMap(user => {
const createToken = observeQuery(AccessToken, 'create', {
userId: user.id,
created: new Date(),
ttl: user.constructor.settings.ttl
});
const updateUser = new Promise((resolve, reject) =>
user.updateAttributes(
{
email: email,
emailVerified: true,
emailAuthLinkTTL: null,
emailVerifyTTL: null
},
err => {
if (err) {
return reject(err);
}
return resolve();
}
)
);
return Observable.combineLatest(
Observable.of(user),
createToken,
Observable.fromPromise(updateUser),
(user, token) => ({ user, token })
);
})
.subscribe(({ user, token }) => cb(null, user, null, token), cb);
}
};
}

View File

@@ -0,0 +1,23 @@
{
"name": "userIdentity",
"plural": "userIdentities",
"base": "UserIdentity",
"properties": {},
"validations": [],
"relations": {
"user": {
"type": "belongsTo",
"model": "user",
"foreignKey": "userId"
}
},
"acls": [
{
"accessType": "*",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "DENY"
}
],
"methods": {}
}

View File

@@ -0,0 +1,30 @@
/* global expect */
import { ensureLowerCaseEmail } from './User-Identity';
test('returns lowercase email when one exists', () => {
const profile = {
id: 2,
emails: [{ value: 'Example@Mail.com', name: 'John Doe' }]
};
expect(ensureLowerCaseEmail(profile)).toBe('example@mail.com');
});
test('returns empty string when value is undefined', () => {
const profile = {
id: 4,
emails: []
};
expect(ensureLowerCaseEmail(profile)).toBe('');
});
test('returns empty string when emails is undefined', () => {
const profile = {
id: 5
};
expect(ensureLowerCaseEmail(profile)).toBe('');
});
test('returns empty string when profile is undefined', () => {
let profile;
expect(ensureLowerCaseEmail(profile)).toBe('');
});

View File

@@ -0,0 +1,9 @@
import { Observable } from 'rx';
module.exports = function(Article) {
Article.on('dataSourceAttached', () => {
Article.findOne$ = Observable.fromNodeCallback(Article.findOne, Article);
Article.findById$ = Observable.fromNodeCallback(Article.findById, Article);
Article.find$ = Observable.fromNodeCallback(Article.find, Article);
});
};

View File

@@ -0,0 +1,102 @@
{
"name": "article",
"plural": "articles",
"base": "PersistedModel",
"idInjection": true,
"options": {
"validateUpsert": true
},
"properties": {
"shortId": {
"type": "string",
"required": true
},
"slugPart": {
"type": "string",
"required": true,
"description": "A kebab-case-string created from the title, will have the shortId appended to it"
},
"meta": {
"type": "object",
"default": {},
"description": "A place to keep the referral link and read time"
},
"title": {
"type": "string",
"required": true
},
"author": {
"type": "object",
"required": true
},
"subtitle": {
"type": "string"
},
"featureImage": {
"type": "object"
},
"draft": {
"type": "string",
"required": true
},
"renderableContent": {
"type": "string"
},
"youtubeId": {
"type": "string",
"description": "A youtube video id eg: 'EErY9zXGLNU'"
},
"published": {
"type": "boolean",
"required": true,
"default": false
},
"featured": {
"type": "boolean",
"required": true,
"default": false
},
"underReview": {
"type": "boolean",
"required": true,
"default": false
},
"viewCount": {
"type": "number",
"required": true,
"default": 1
},
"firstPublishedDate": {
"type": "date"
},
"createdDate": {
"type": "date",
"required": true
},
"lastEditedDate": {
"type": "date",
"required": true
},
"history": {
"type": [
"object"
],
"required": true
}
},
"validations": [],
"relations": {
"user": {
"type": "belongsTo",
"model": "user",
"foreignKey": "externalId"
},
"popularity": {
"type": "hasOne",
"model": "popularity",
"foreignKey": "popularityId"
}
},
"acls": [],
"methods": {}
}

View File

@@ -0,0 +1,9 @@
import { Observable } from 'rx';
export default function(Block) {
Block.on('dataSourceAttached', () => {
Block.findOne$ = Observable.fromNodeCallback(Block.findOne, Block);
Block.findById$ = Observable.fromNodeCallback(Block.findById, Block);
Block.find$ = Observable.fromNodeCallback(Block.find, Block);
});
}

View File

@@ -0,0 +1,53 @@
{
"name": "block",
"base": "PersistedModel",
"idInjection": true,
"options": {
"validateUpsert": true
},
"properties": {
"superBlock": {
"type": "string",
"required": true,
"description": "The super block that this block belongs to"
},
"order": {
"type": "number",
"required": true,
"description": "The order in which this block appears"
},
"name": {
"type": "string",
"required": true,
"description": "The name of this block derived from the title, suitable for regex search"
},
"superOrder": {
"type": "number",
"required": true
},
"dashedName": {
"type": "string",
"required": true,
"description": "Generated from the title to be URL friendly"
},
"title": {
"type": "string",
"required": true,
"description": "The title of this block, suitable for display"
},
"time": {
"type": "string",
"required": false
}
},
"validations": [],
"relations": {
"challenges": {
"type": "hasMany",
"model": "challenge",
"foreignKey": "blockId"
}
},
"acls": [],
"methods": {}
}

View File

@@ -0,0 +1,139 @@
{
"name": "challenge",
"base": "PersistedModel",
"idInjection": true,
"trackChanges": false,
"properties": {
"id": {
"type": "string",
"id": true
},
"name": {
"type": "string",
"index": {
"mongodb": {
"unique": true,
"background": true
}
}
},
"title": {
"type": "string"
},
"order": {
"type": "number"
},
"suborder": {
"type": "number"
},
"checksum": {
"type": "number"
},
"isBeta": {
"type": "boolean",
"description": "Show only during dev or on beta site. Completely omitted otherwise"
},
"isComingSoon": {
"type": "boolean",
"description": "Challenge shows in production, but is unreachable and disabled. Is reachable in beta/dev only if isBeta flag is set"
},
"dashedName": {
"type": "string"
},
"superBlock": {
"type": "string",
"description": "Used for ordering challenge blocks in map"
},
"superOrder": {
"type": "number",
"description": "Used to determine super block order, set by prepending two digit number to super block folder name"
},
"block": {
"type": "string"
},
"difficulty": {
"type": "string"
},
"description": {
"type": "string"
},
"tests": {
"type": "array"
},
"head": {
"type": "string",
"description": "Appended to user code",
"default": ""
},
"tail": {
"type": "string",
"description": "Prepended to user code",
"default": ""
},
"helpRoom": {
"type": "string",
"description": "Gitter help chatroom this challenge belongs too. Must be PascalCase",
"default": "Help"
},
"fileName": {
"type": "string",
"description": "Filename challenge comes from. Used in dev mode"
},
"challengeSeed": {
"type": "array"
},
"challengeType": {
"type": "number"
},
"solutions": {
"type": "array",
"default": []
},
"guideUrl": {
"type": "string",
"description": "Used to link to an article in the FCC guide"
},
"required": {
"type": [
{
"type": {
"link": {
"type": "string",
"description": "Used for css files"
},
"src": {
"type": "string",
"description": "Used for script files"
},
"crossDomain": {
"type": "boolean",
"description": "Files coming from freeCodeCamp must mark this true"
}
}
}
],
"default": []
},
"template": {
"type": "string",
"description": "A template to render the compiled challenge source into. This template uses template literal delimiter, i.e. ${ foo }"
}
},
"validations": [],
"relations": {},
"acls": [
{
"accessType": "*",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "DENY"
},
{
"accessType": "READ",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW"
}
],
"methods": {}
}

View File

@@ -0,0 +1,68 @@
{
"name": "nonprofit",
"base": "PersistedModel",
"idInjection": true,
"trackChanges": false,
"properties": {
"id": {
"type": "string",
"id": true
},
"name": {
"type": "string",
"index": {
"mongodb": {
"unique": true,
"background": true
}
}
},
"whatDoesNonprofitDo": {
"type": "string"
},
"websiteLink": {
"type": "string"
},
"endUser": {
"type": "string"
},
"approvedDeliverables": {
"type": "array"
},
"projectDescription": {
"type": "string"
},
"logoUrl": {
"type": "string"
},
"imageUrl": {
"type": "string"
},
"estimatedHours": {
"type": "number"
},
"moneySaved": {
"type": "number"
},
"currentStatus": {
"type": "string"
}
},
"validations": [],
"relations": {},
"acls": [
{
"accessType": "*",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "DENY"
},
{
"accessType": "READ",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW"
}
],
"methods": {}
}

View File

@@ -0,0 +1,55 @@
{
"name": "pledge",
"base": "PersistedModel",
"idInjection": true,
"trackChanges": false,
"properties": {
"nonprofit": {
"type": "string",
"index": true
},
"amount": {
"type": "number"
},
"dateStarted": {
"type": "date",
"defaultFn": "now"
},
"dateEnded": {
"type": "date"
},
"formerUserId": {
"type": "string"
},
"isOrphaned": {
"type": "boolean"
},
"isCompleted": {
"type": "boolean",
"default": "false"
}
},
"validations": [],
"relations": {
"user": {
"type": "hasMany",
"model": "user",
"foreignKey": "userId"
}
},
"acls": [
{
"accessType": "*",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "DENY"
},
{
"accessType": "READ",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW"
}
],
"methods": {}
}

View File

@@ -0,0 +1,15 @@
import { Observable } from 'rx';
module.exports = function(Popularity) {
Popularity.on('dataSourceAttached', () => {
Popularity.findOne$ = Observable.fromNodeCallback(
Popularity.findOne,
Popularity
);
Popularity.findById$ = Observable.fromNodeCallback(
Popularity.findById,
Popularity
);
Popularity.find$ = Observable.fromNodeCallback(Popularity.find, Popularity);
});
};

View File

@@ -0,0 +1,28 @@
{
"name": "popularity",
"plural": "popularities",
"base": "PersistedModel",
"idInjection": true,
"options": {
"validateUpsert": true
},
"properties": {
"events": {
"type": [
"object"
],
"required": true,
"default": []
}
},
"validations": [],
"relations": {
"article": {
"type": "belongsTo",
"model": "article",
"foreignKey": "articleId"
}
},
"acls": [],
"methods": {}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,402 @@
{
"name": "user",
"base": "User",
"strict": "filter",
"idInjection": true,
"emailVerificationRequired": false,
"trackChanges": false,
"properties": {
"email": {
"type": "string",
"index": {
"mongodb": {
"unique": true,
"background": true,
"sparse": true
}
}
},
"newEmail": {
"type": "string"
},
"emailVerifyTTL": {
"type": "date"
},
"emailVerified": {
"type": "boolean",
"default": false
},
"emailAuthLinkTTL": {
"type": "date"
},
"externalId": {
"type": "string",
"description": "A uuid/v4 used to identify user accounts"
},
"unsubscribeId": {
"type": "string",
"description": "An ObjectId used to unsubscribe users from the mailing list(s)"
},
"password": {
"type": "string",
"description": "No longer used for new accounts"
},
"progressTimestamps": {
"type": "array",
"default": []
},
"isBanned": {
"type": "boolean",
"description": "User is banned from posting to camper news",
"default": false
},
"isCheater": {
"type": "boolean",
"description": "Users who are confirmed to have broken academic honesty policy are marked as cheaters",
"default": false
},
"githubProfile": {
"type": "string"
},
"website": {
"type": "string"
},
"_csrf": {
"type": "string"
},
"username": {
"type": "string",
"index": {
"mongodb": {
"unique": true,
"background": true
}
},
"require": true
},
"about": {
"type": "string",
"default": ""
},
"name": {
"type": "string",
"default": ""
},
"location": {
"type": "string",
"default": ""
},
"picture": {
"type": "string",
"default": ""
},
"linkedin": {
"type": "string"
},
"codepen": {
"type": "string"
},
"twitter": {
"type": "string"
},
"acceptedPrivacyTerms": {
"type": "boolean",
"default": false
},
"sendQuincyEmail": {
"type": "boolean",
"default": false
},
"currentChallengeId": {
"type": "string",
"description": "The challenge last visited by the user",
"default": ""
},
"isHonest": {
"type": "boolean",
"description": "Camper has signed academic honesty policy",
"default": false
},
"isFrontEndCert": {
"type": "boolean",
"description": "Camper is front end certified",
"default": false
},
"isDataVisCert": {
"type": "boolean",
"description": "Camper is data visualization certified",
"default": false
},
"isBackEndCert": {
"type": "boolean",
"description": "Campers is back end certified",
"default": false
},
"isFullStackCert": {
"type": "boolean",
"description": "Campers is full stack certified",
"default": false
},
"isRespWebDesignCert": {
"type": "boolean",
"description": "Camper is responsive web design certified",
"default": false
},
"is2018DataVisCert": {
"type": "boolean",
"description": "Camper is data visualization certified (2018)",
"default": false
},
"isFrontEndLibsCert": {
"type": "boolean",
"description": "Camper is front end libraries certified",
"default": false
},
"isJsAlgoDataStructCert": {
"type": "boolean",
"description": "Camper is javascript algorithms and data structures certified",
"default": false
},
"isApisMicroservicesCert": {
"type": "boolean",
"description": "Camper is apis and microservices certified",
"default": false
},
"isInfosecQaCert": {
"type": "boolean",
"description": "Camper is information security and quality assurance certified",
"default": false
},
"isQaCertV7": {
"type": "boolean",
"description": "Camper is quality assurance certified",
"default": false
},
"isInfosecCertV7": {
"type": "boolean",
"description": "Camper is information security certified",
"default": false
},
"is2018FullStackCert": {
"type": "boolean",
"description": "Camper is full stack certified (2018)",
"default": false
},
"isSciCompPyCertV7": {
"type": "boolean",
"description": "Camper is scientific computing with Python certified",
"default": false
},
"isDataAnalysisPyCertV7": {
"type": "boolean",
"description": "Camper is data analysis with Python certified",
"default": false
},
"isMachineLearningPyCertV7": {
"type": "boolean",
"description": "Camper is machine learning with Python certified",
"default": false
},
"completedChallenges": {
"type": [
{
"completedDate": "number",
"id": "string",
"solution": "string",
"githubLink": "string",
"challengeType": "number",
"files": {
"type": [
{
"contents": {
"type": "string",
"default": ""
},
"ext": {
"type": "string"
},
"path": {
"type": "string"
},
"name": {
"type": "string"
},
"key": {
"type": "string"
}
}
],
"default": []
}
}
],
"default": []
},
"portfolio": {
"type": "array",
"default": []
},
"yearsTopContributor": {
"type": "array",
"default": []
},
"rand": {
"type": "number",
"index": true
},
"timezone": {
"type": "string"
},
"theme": {
"type": "string",
"default": "default"
},
"profileUI": {
"type": "object",
"default": {
"isLocked": true,
"showAbout": false,
"showCerts": false,
"showDonation": false,
"showHeatMap": false,
"showLocation": false,
"showName": false,
"showPoints": false,
"showPortfolio": false,
"showTimeLine": false
}
},
"badges": {
"type": {
"coreTeam": {
"type": "array",
"default": []
}
},
"default": {}
},
"donationEmails": {
"type": [
"string"
]
},
"isDonating": {
"type": "boolean",
"description": "Does the camper have an active donation",
"default": false
}
},
"validations": [],
"relations": {
"donations": {
"type": "hasMany",
"foreignKey": "",
"modal": "donation"
},
"credentials": {
"type": "hasMany",
"model": "userCredential",
"foreignKey": ""
},
"identities": {
"type": "hasMany",
"model": "userIdentity",
"foreignKey": ""
},
"pledge": {
"type": "hasOne",
"model": "pledge",
"foreignKey": ""
},
"authTokens": {
"type": "hasMany",
"model": "AuthToken",
"foreignKey": "userId",
"options": {
"disableInclude": true
}
},
"articles": {
"type": "hasMany",
"model": "article",
"foreignKey": "externalId"
}
},
"acls": [
{
"accessType": "*",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "DENY"
},
{
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "DENY",
"property": "create"
},
{
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "DENY",
"property": "login"
},
{
"accessType": "EXECUTE",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "DENY",
"property": "verify"
},
{
"accessType": "EXECUTE",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "DENY",
"property": "resetPassword"
},
{
"accessType": "EXECUTE",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW",
"property": "doesExist"
},
{
"accessType": "EXECUTE",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW",
"property": "about"
},
{
"accessType": "EXECUTE",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW",
"property": "getPublicProfile"
},
{
"accessType": "EXECUTE",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW",
"property": "giveBrowniePoints"
},
{
"accessType": "EXECUTE",
"principalType": "ROLE",
"principalId": "$owner",
"permission": "ALLOW",
"property": "updateTheme"
},
{
"accessType": "EXECUTE",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW",
"property": "getMessages"
}
],
"methods": {}
}

View File

@@ -0,0 +1,80 @@
import path from 'path';
import dedent from 'dedent';
import loopback from 'loopback';
import moment from 'moment';
export const renderSignUpEmail = loopback.template(
path.join(
__dirname,
'..',
'..',
'server',
'views',
'emails',
'user-request-sign-up.ejs'
)
);
export const renderSignInEmail = loopback.template(
path.join(
__dirname,
'..',
'..',
'server',
'views',
'emails',
'user-request-sign-in.ejs'
)
);
export const renderEmailChangeEmail = loopback.template(
path.join(
__dirname,
'..',
'..',
'server',
'views',
'emails',
'user-request-update-email.ejs'
)
);
export function getWaitPeriod(ttl) {
const fiveMinutesAgo = moment().subtract(5, 'minutes');
const lastEmailSentAt = moment(new Date(ttl || null));
const isWaitPeriodOver = ttl
? lastEmailSentAt.isBefore(fiveMinutesAgo)
: true;
if (!isWaitPeriodOver) {
const minutesLeft = 5 - (moment().minutes() - lastEmailSentAt.minutes());
return minutesLeft;
}
return 0;
}
export function getWaitMessage(ttl) {
const minutesLeft = getWaitPeriod(ttl);
if (minutesLeft <= 0) {
return null;
}
const timeToWait = minutesLeft
? `${minutesLeft} minute${minutesLeft > 1 ? 's' : ''}`
: 'a few seconds';
return dedent`
Please wait ${timeToWait} to resend an authentication link.
`;
}
export function getEncodedEmail(email) {
if (!email) {
return null;
}
return Buffer.from(email).toString('base64');
}
export const decodeEmail = email => Buffer.from(email, 'base64').toString();

View File

@@ -0,0 +1,9 @@
{
"aboutUrl": "https://www.freecodecamp.org/about",
"defaultProfileImage": "https://s3.amazonaws.com/freecodecamp/camper-image-placeholder.png",
"donateUrl": "https://www.freecodecamp.org/donate",
"forumUrl": "https://forum.freecodecamp.org",
"githubUrl": "https://github.com/freecodecamp/freecodecamp",
"RSA": "https://forum.freecodecamp.org/t/the-read-search-ask-methodology-for-getting-unstuck/137307",
"homeURL": "https://www.freecodecamp.org"
}

View File

@@ -0,0 +1,13 @@
const emptyProtector = {
blocks: [],
challenges: []
};
// protect against malformed map data
// protect(block: { challenges: [], block: [] }|Void) => block|emptyProtector
export default function protect(block) {
// if no block or block has no challenges or blocks
if (!block || !(block.challenges || block.blocks)) {
return emptyProtector;
}
return block;
}

View File

@@ -0,0 +1,8 @@
import _ from 'lodash';
export const alertTypes = _.keyBy(
['success', 'info', 'warning', 'danger'],
_.identity
);
export const normalizeAlertType = alertType => alertTypes[alertType] || 'info';

View File

@@ -0,0 +1,21 @@
import { pick } from 'lodash';
export {
getEncodedEmail,
decodeEmail,
getWaitMessage,
getWaitPeriod,
renderEmailChangeEmail,
renderSignUpEmail,
renderSignInEmail
} from './auth';
export const fixCompletedChallengeItem = obj =>
pick(obj, [
'id',
'completedDate',
'solution',
'githubLink',
'challengeType',
'files'
]);

View File

@@ -0,0 +1,108 @@
const legacyFrontEndProjects = {
challenges: [
// build-a-personal-portfolio-webpage
'bd7158d8c242eddfaeb5bd13',
// build-a-random-quote-machine
'bd7158d8c442eddfaeb5bd13',
// build-a-25-5-clock
'bd7158d8c442eddfaeb5bd0f',
// build-a-javascript-calculator
'bd7158d8c442eddfaeb5bd17',
// show-the-local-weather
'bd7158d8c442eddfaeb5bd10',
// use-the-twitchtv-json-api
'bd7158d8c442eddfaeb5bd1f',
// stylize-stories-on-camper-news
'bd7158d8c442eddfaeb5bd18',
// build-a-wikipedia-viewer
'bd7158d8c442eddfaeb5bd19',
// build-a-tic-tac-toe-game
'bd7158d8c442eedfaeb5bd1c',
// build-a-simon-game
'bd7158d8c442eddfaeb5bd1c'
],
title: 'Legacy Front End Projects',
superBlock: 'legacy-front-end'
};
const legacyBackEndProjects = {
challenges: [
// timestamp microservice
'bd7158d8c443edefaeb5bdef',
// request-header-parser-microservice
'bd7158d8c443edefaeb5bdff',
// url-shortener-microservice
'bd7158d8c443edefaeb5bd0e',
// image-search-abstraction-layer
'bd7158d8c443edefaeb5bdee',
// file-metadata-microservice
'bd7158d8c443edefaeb5bd0f',
// build-a-voting-app
'bd7158d8c443eddfaeb5bdef',
// build-a-nightlife-coordination-app
'bd7158d8c443eddfaeb5bdff',
// chart-the-stock-market
'bd7158d8c443eddfaeb5bd0e',
// manage-a-book-trading-club
'bd7158d8c443eddfaeb5bd0f',
// build-a-pinterest-clone
'bd7158d8c443eddfaeb5bdee'
],
title: 'Legacy Back End Projects',
superBlock: 'legacy-back-end'
};
const legacyDataVisProjects = {
challenges: [
// build-a-markdown-previewer
'bd7157d8c242eddfaeb5bd13',
// build-a-camper-leaderboard
'bd7156d8c242eddfaeb5bd13',
// build-a-recipe-box
'bd7155d8c242eddfaeb5bd13',
// build-the-game-of-life
'bd7154d8c242eddfaeb5bd13',
// build-a-roguelike-dungeon-crawler-game
'bd7153d8c242eddfaeb5bd13',
// visualize-data-with-a-bar-chart
'bd7168d8c242eddfaeb5bd13',
// visualize-data-with-a-scatterplot-graph
'bd7178d8c242eddfaeb5bd13',
// visualize-data-with-a-heat-map
'bd7188d8c242eddfaeb5bd13',
// show-national-contiguity-with-a-force-directed-graph
'bd7198d8c242eddfaeb5bd13',
// map-data-across-the-globe
'bd7108d8c242eddfaeb5bd13'
],
title: 'Legacy Data Visualization Projects',
superBlock: 'legacy-data-visualization'
};
const legacyInfosecQaProjects = {
challenges: [
// metric-imperial-converter
'587d8249367417b2b2512c41',
// issue-tracker
'587d8249367417b2b2512c42',
// personal-library
'587d824a367417b2b2512c43',
// stock-price-checker
'587d824a367417b2b2512c44',
// anonymous-message-board
'587d824a367417b2b2512c45'
],
title: 'Legacy Information Security and Quality Assurance Projects',
// Keep the settings page "Show Certification" button
// pointing to information-security-and-quality-assurance
superBlock: 'information-security-and-quality-assurance'
};
const legacyProjects = [
legacyFrontEndProjects,
legacyBackEndProjects,
legacyDataVisProjects,
legacyInfosecQaProjects
];
export default legacyProjects;

View File

@@ -0,0 +1,9 @@
export const themes = {
night: 'night',
default: 'default'
};
export const invertTheme = currentTheme =>
!currentTheme || currentTheme === themes.default
? themes.night
: themes.default;