chore(api-server): bye-bye you served us well (#60520)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Mrugesh Mohapatra
2025-05-27 09:56:46 +05:30
committed by GitHub
parent a90e2757ac
commit 16e461385e
139 changed files with 140 additions and 14498 deletions

6
.github/labeler.yml vendored
View File

@@ -1,6 +1,3 @@
'scope: docs':
- docs/**/*
'scope: curriculum':
- curriculum/challenges/**/*
@@ -8,7 +5,6 @@
- client/**/*
'platform: api':
- api-server/**/*
- api/**/*
'scope: tools/scripts':
@@ -18,8 +14,6 @@
- e2e/**/*
'scope: i18n':
- any: ['curriculum/challenges/**/*', '!curriculum/challenges/english/**/*']
- docs/i18n/**/*
- client/i18n/**/*
- config/crowdin/**/*
- shared/config/i18n/**/*

View File

@@ -1,4 +1,4 @@
name: CD - Deploy API (Legacy) & Clients
name: CD - Deploy - Clients
on:
workflow_dispatch:
@@ -33,98 +33,9 @@ jobs:
;;
esac
api:
name: API (Legacy) - [${{ needs.setup-jobs.outputs.tgt_env_short }}]
needs: [setup-jobs]
runs-on: ubuntu-22.04
permissions:
deployments: write
contents: read
environment:
name: ${{ needs.setup-jobs.outputs.tgt_env_short }}-api-legacy
env:
TS_USERNAME: ${{ secrets.TS_USERNAME }}
TS_MACHINE_NAME_PREFIX: ${{ secrets.TS_MACHINE_NAME_PREFIX }}
steps:
- name: Setup and connect to Tailscale network
uses: tailscale/github-action@v3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
hostname: gha-${{needs.setup-jobs.outputs.tgt_env_short}}-api-legacy-ci-${{ github.run_id }}
tags: tag:ci
version: latest
- name: Configure SSH & Check Connection
run: |
mkdir -p ~/.ssh
echo "Host *
UserKnownHostsFile=/dev/null
StrictHostKeyChecking no" > ~/.ssh/config
chmod 644 ~/.ssh/config
sleep 10
for i in {1..3}; do
TS_MACHINE_NAME=${TS_MACHINE_NAME_PREFIX}-api-${i}
tailscale status | grep -q "$TS_MACHINE_NAME" || { echo "Machine not found"; exit 1; }
sleep 1
MACHINE_IP=$(tailscale ip -4 $TS_MACHINE_NAME)
ssh $TS_USERNAME@$MACHINE_IP "uptime"
done
- name: Deploy API
# [NOTE] Use backslashes for expansion on remote machine, example: \$NVM_DIR, etc.
run: |
GIT_SOURCE_BRANCH=${{ needs.setup-jobs.outputs.tgt_env_branch }}
for i in {1..3}; do
TS_MACHINE_NAME=${TS_MACHINE_NAME_PREFIX}-api-${i}
REMOTE_SCRIPT="
set -e
echo -e '\nLOG:Deploying API (Legacy) to $TS_MACHINE_NAME...'
echo -e '\nLOG:Environment setup...'
cd /home/$TS_USERNAME/freeCodeCamp
export NVM_DIR=\$HOME/.nvm && [ -s "\$NVM_DIR/nvm.sh" ] && source "\$NVM_DIR/nvm.sh"
echo -e '\nLOG:Checking available Node.js versions...'
nvm ls | grep 'default'
echo -e '\nLOG:Checking Node.js version...'
node --version
echo -e '\nLOG:Stopping all PM2 services...'
pm2 stop all
echo -e '\nLOG:Git operations...'
git status
git clean -f
git fetch --all --prune
git checkout -f $GIT_SOURCE_BRANCH
git reset --hard origin/$GIT_SOURCE_BRANCH
git status
echo -e '\nLOG:Building...'
npm i -g pnpm@10
pnpm clean:packages
pnpm clean:server
pnpm install
pnpm prebuild
pnpm build:curriculum
pnpm build:server
echo -e '\nLOG:Build completed.'
echo -e '\nLOG:Starting PM2 reload...'
pm2 reload './api-server/ecosystem.config.js'
echo -e '\nLOG:PM2 reload completed.'
pm2 ls
pm2 save
echo -e '\nLOG:Finished deployment.'
"
MACHINE_IP=$(tailscale ip -4 $TS_MACHINE_NAME)
ssh $TS_USERNAME@$MACHINE_IP "$REMOTE_SCRIPT"
done
client:
name: Clients - [${{ needs.setup-jobs.outputs.tgt_env_short }}] [${{ matrix.lang-name-short }}]
needs: [setup-jobs, api]
needs: [setup-jobs]
runs-on: ubuntu-22.04
strategy:
fail-fast: false

View File

@@ -72,8 +72,8 @@ jobs:
name: webpack-stats
path: client/public/stats.json
build-new-api:
name: Build New Api (Container)
build-api:
name: Build API (Container)
runs-on: ubuntu-22.04
steps:
@@ -91,108 +91,16 @@ jobs:
- name: Save Image
run: docker save fcc-api > api-artifact.tar
- name: Upload Api Artifact
- name: Upload API Artifact
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: api-artifact
path: api-artifact.tar
playwright-run-old-api:
name: E2E Test
playwright-run-api:
name: Run Playwright Tests
runs-on: ubuntu-22.04
needs: build-client
strategy:
fail-fast: false
matrix:
# Not Mobile Safari until we can get it working. Webkit and Mobile
# Chrome both work, so hopefully there are no Mobile Safari specific
# bugs.
browsers: [chromium, firefox, webkit, Mobile Chrome]
node-version: [20]
services:
mongodb:
image: mongo:8.0
ports:
- 27017:27017
# We need mailhog to catch any emails the api tries to send.
mailhog:
image: mailhog/mailhog
ports:
- 1025:1025 # SMTP server (listens for emails)
- 8025:8025 # HTTP server (so we can make requests to the api)
steps:
- name: Set Action Environment Variables
run: |
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV
- name: Checkout Source Files
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
submodules: 'recursive'
- uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4
with:
name: client-artifact
- name: Unpack Client Artifact
run: |
tar -xf client-artifact.tar
rm client-artifact.tar
- name: Setup pnpm
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d #v3.0.0
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version: ${{ matrix.node-version }}
- name: Set freeCodeCamp Environment Variables
run: |
cp sample.env .env
- name: Install and Build
run: |
pnpm install
pnpm run create:shared
pnpm run build:curriculum
pnpm run build:server
- name: Seed Database with Certified User
run: pnpm run seed:certified-user
# start-ci uses pm2, so it needs to be installed globally
- name: Install pm2
run: npm i -g pm2
- name: Install playwright dependencies
run: npx playwright install --with-deps
- name: Run playwright tests
run: |
pnpm run start-ci &
sleep 10
npx playwright test --project="${{ matrix.browsers }}" --grep-invert 'third-party-donation.spec.ts'
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report-old-api-${{ matrix.browsers }}
path: playwright/reporter
retention-days: 30
- name: Upload screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: screenshots-old-api-${{ matrix.browsers }}
path: playwright/test-results
retention-days: 14
playwright-run-new-api:
name: Run Playwright Tests (with new Api)
runs-on: ubuntu-22.04
needs: [build-client, build-new-api]
needs: [build-client, build-api]
strategy:
fail-fast: false
matrix:
@@ -215,7 +123,7 @@ jobs:
tar -xf client-artifact/client-artifact.tar
rm client-artifact/client-artifact.tar
- name: Load Api Image
- name: Load API Image
run: |
docker load < api-artifact/api-artifact.tar
rm api-artifact/api-artifact.tar
@@ -261,4 +169,4 @@ jobs:
with:
name: playwright-report-${{ matrix.browsers }}
path: playwright/reporter
retention-days: 30
retention-days: 7

View File

@@ -11,23 +11,12 @@ concurrency:
cancel-in-progress: ${{ !contains(github.ref, 'main') && !contains(github.ref, 'prod-') }}
jobs:
do-everything:
name: Build & Test
build-client:
name: Build Client
runs-on: ubuntu-22.04
strategy:
matrix:
node-version: [20]
services:
mongodb:
image: mongo:8.0
ports:
- 27017:27017
# We need mailhog to catch any emails the api tries to send.
mailhog:
image: mailhog/mailhog
ports:
- 1025:1025
steps:
- name: Checkout Source Files
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
@@ -47,8 +36,7 @@ jobs:
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version: ${{ matrix.node-version }}
# cypress-io/github-action caches the store, so we should not cache it
# here.
cache: pnpm
- name: Set freeCodeCamp Environment Variables
run: |
@@ -60,32 +48,124 @@ jobs:
- name: Install and Build
run: |
pnpm install
pnpm run create:shared
pnpm run build:curriculum
pnpm run build:server
- name: Seed Database with Certified User
run: pnpm run seed:certified-user
pnpm run build
- name: Move serve.json to Public Folder
run: cp client-config/serve.json client/public/serve.json
# start-ci uses pm2, so it needs to be installed globally
- name: Install pm2
run: npm i -g pm2
- name: Tar Files
run: tar -cf client-artifact.tar client/public
- name: Upload Client Artifact
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: client-artifact
path: client-artifact.tar
build-api:
name: Build API (Container)
runs-on: ubuntu-22.04
steps:
- name: Checkout Source Files
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
submodules: 'recursive'
- name: Create Image
run: |
docker build \
-t fcc-api \
-f docker/api/Dockerfile .
- name: Save Image
run: docker save fcc-api > api-artifact.tar
- name: Upload API Artifact
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: api-artifact
path: api-artifact.tar
playwright-run-api:
name: Run Playwright 3rd Party Donation Tests
runs-on: ubuntu-22.04
needs: [build-client, build-api]
strategy:
fail-fast: false
matrix:
browsers: [chromium]
node-version: [20]
services:
mongodb:
image: mongo:8.0
ports:
- 27017:27017
mailhog:
image: mailhog/mailhog
ports:
- 1025:1025
steps:
- name: Set Action Environment Variables
run: |
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV
- name: Checkout Source Files
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4
- name: Unpack Client Artifact
run: |
tar -xf client-artifact/client-artifact.tar
rm client-artifact/client-artifact.tar
- name: Load API Image
run: |
docker load < api-artifact/api-artifact.tar
rm api-artifact/api-artifact.tar
- name: Setup pnpm
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d #v3.0.0
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version: ${{ matrix.node-version }}
- name: Install Dependencies
run: pnpm install
- name: Set freeCodeCamp Environment Variables (needed by api)
run: |
sed '/STRIPE/d; /PAYPAL/d; /PATREON/d;' sample.env > .env
echo 'STRIPE_PUBLIC_KEY=${{ secrets.STRIPE_PUBLIC_KEY }}' >> .env
echo 'PAYPAL_CLIENT_ID=${{ secrets.PAYPAL_CLIENT_ID }}' >> .env
echo 'PATREON_CLIENT_ID=${{ secrets.PATREON_CLIENT_ID }}' >> .env
- name: Install playwright dependencies
run: npx playwright install --with-deps
- name: Run playwright tests
- name: Install and Build
run: |
pnpm run start-ci &
pnpm install
pnpm run create:shared
pnpm run build:curriculum
- name: Start apps
run: |
docker compose up -d
pnpm run serve:client-ci &
sleep 10
npx playwright test third-party-donation.spec.ts --project=${{ matrix.browsers }}
- name: Seed Database with Certified User
run: pnpm run seed:certified-user
- name: Run playwright tests
run: npx playwright test third-party-donation.spec.ts --project=${{ matrix.browsers }}
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report-${{ matrix.browsers }}
path: playwright/reporter
retention-days: 30
retention-days: 7

4
.gitignore vendored
View File

@@ -63,7 +63,6 @@ $RECYCLE.BIN/
# Icon must end with two \r
Icon
# Thumbnails
._*
@@ -172,7 +171,7 @@ utils/slugs.test.js
### vim ###
# Swap
[._]*.s[a-v][a-z]
!*.svg # comment out if you don't need vector files
!*.svg # comment out if you don't need vector files
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
@@ -198,7 +197,6 @@ tags
curriculum/curricula.json
### Additional Folders ###
api-server/lib/*
curriculum/dist
curriculum/build

View File

@@ -1,6 +1,5 @@
**/.cache
**/*fixtures*
api-server/lib
client/**/trending.json
client/**/search-bar.json
client/config/*.json

View File

@@ -1,16 +0,0 @@
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": 10
}
}
]
],
"plugins": [
"@babel/plugin-proposal-object-rest-spread",
"@babel/plugin-proposal-optional-chaining"
]
}

51
api-server/.gitignore vendored
View File

@@ -1,51 +0,0 @@
lib-cov
*~
*.seed
*.log
*.csv
*.dat
*.out
*.pid
*.gz
*.swp
.floo
.flooignore
builtAssets/
pm2.js
*.env
pids
logs
results
tmp
.ds_store
.vscode
npm-debug.log
node_modules
compiled
.idea
*.iml
.DS_Store
Thumbs.db
bower_components
main.css
bundle.js
coverage
.remote-sync.json
.tern-project
server/*.bundle.js
public/js/bundle*
seed/unpacked
*.map
// revision manifest
server/rev-manifest.json
server/manifests/*
!server/manifests/README.md
webpack-bundle-stats.html
server/rev-manifest.json
google-credentials.json
.vs/*

View File

@@ -1,8 +0,0 @@
exports.allowedOrigins = [
'https://www.freecodecamp.dev',
'https://www.freecodecamp.org',
'https://beta.freecodecamp.dev',
'https://beta.freecodecamp.org',
'https://chinese.freecodecamp.dev',
'https://chinese.freecodecamp.org'
];

View File

@@ -1,96 +0,0 @@
const {
MONGODB,
MONGOHQ_URL,
SESSION_SECRET,
COOKIE_SECRET,
JWT_SECRET,
AUTH0_CLIENT_ID,
AUTH0_CLIENT_SECRET,
AUTH0_DOMAIN,
FACEBOOK_ID,
FACEBOOK_SECRET,
GITHUB_ID,
GITHUB_SECRET,
GOOGLE_ID,
GOOGLE_SECRET,
LINKEDIN_ID,
LINKEDIN_SECRET,
TWITTER_KEY,
TWITTER_SECRET,
TWITTER_TOKEN,
TWITTER_TOKEN_SECRET,
SENTRY_DSN,
STRIPE_PUBLIC_KEY,
STRIPE_SECRET_KEY
} = process.env;
module.exports = {
db: MONGODB || MONGOHQ_URL,
cookieSecret: COOKIE_SECRET,
jwtSecret: JWT_SECRET,
sessionSecret: SESSION_SECRET,
auth0: {
clientID: AUTH0_CLIENT_ID,
clientSecret: AUTH0_CLIENT_SECRET,
domain: AUTH0_DOMAIN
},
facebook: {
clientID: FACEBOOK_ID,
clientSecret: FACEBOOK_SECRET,
callbackURL: '/auth/facebook/callback',
passReqToCallback: true
},
github: {
clientID: GITHUB_ID,
clientSecret: GITHUB_SECRET,
callbackURL: '/auth/github/callback',
passReqToCallback: true
},
twitter: {
consumerKey: TWITTER_KEY,
consumerSecret: TWITTER_SECRET,
token: TWITTER_TOKEN,
tokenSecret: TWITTER_TOKEN_SECRET,
callbackURL: '/auth/twitter/callback',
passReqToCallback: true
},
google: {
clientID: GOOGLE_ID,
clientSecret: GOOGLE_SECRET,
callbackURL: '/auth/google/callback',
passReqToCallback: true
},
linkedin: {
clientID: LINKEDIN_ID,
clientSecret: LINKEDIN_SECRET,
callbackURL: '/auth/linkedin/callback',
profileFields: ['public-profile-url'],
scope: ['r_basicprofile', 'r_emailaddress'],
passReqToCallback: true
},
sentry: {
dsn: SENTRY_DSN
},
stripe: {
public: STRIPE_PUBLIC_KEY,
secret: STRIPE_SECRET_KEY
}
};

View File

@@ -1,35 +0,0 @@
const fs = require('fs');
const path = require('path');
const dotenv = require('dotenv');
const filePath = path.resolve(__dirname, '..', '.env');
let env = {};
try {
env = dotenv.parse(fs.readFileSync(filePath));
} catch (e) {
console.log(
"If you're setting the env vars in the shell, it should be fine (you can probably ignore the error)."
);
console.log(e);
}
// without this, loopback cannot find strong-error-handler. Node can, so we know
// there's no _real_ issue, but loopback is not able to find it.
const loopbackModuleResolutionHack = path.resolve(
__dirname,
'../node_modules/.pnpm/node_modules'
);
module.exports = {
apps: [
{
script: `./lib/production-start.js`,
cwd: __dirname,
env: { ...env, NODE_PATH: loopbackModuleResolutionHack },
max_memory_restart: '600M',
instances: 'max',
exec_mode: 'cluster',
name: env.DEPLOYMENT_ENV === 'staging' ? 'dev' : 'org'
}
]
};

View File

@@ -1,89 +0,0 @@
{
"name": "@freecodecamp/api-server",
"version": "0.0.1",
"description": "The freeCodeCamp.org open-source codebase and curriculum",
"license": "BSD-3-Clause",
"private": true,
"engines": {
"node": ">=16",
"pnpm": ">=10"
},
"repository": {
"type": "git",
"url": "git+https://github.com/freeCodeCamp/freeCodeCamp.git"
},
"bugs": {
"url": "https://github.com/freeCodeCamp/freeCodeCamp/issues"
},
"homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme",
"author": "freeCodeCamp <team@freecodecamp.org>",
"main": "none",
"scripts": {
"babel-dev-server": "babel-node --inspect=0.0.0.0 ./src/server/index.js",
"prebuild": "pnpm common-setup",
"build": "babel src --out-dir lib --ignore '/**/*.test.js' --copy-files --no-copy-ignored",
"common-setup": "pnpm -w run create:shared",
"predevelop": "pnpm common-setup",
"develop": "node src/development-start.js",
"start": "DEBUG=fcc* node lib/production-start.js"
},
"dependencies": {
"@freecodecamp/loopback-component-passport": "1.2.0",
"@sentry/node": "7.37.1",
"@sentry/tracing": "7.37.1",
"accepts": "1.3.8",
"body-parser": "1.20.0",
"compression": "1.7.4",
"connect-mongo": "3.2.0",
"cookie-parser": "1.4.6",
"cors": "2.8.5",
"csurf": "1.11.0",
"date-fns": "1.30.1",
"debug": "2.2.0",
"dedent": "0.7.0",
"dotenv": "6.2.0",
"express-flash": "0.0.2",
"express-rate-limit": "^6.7.0",
"express-session": "1.17.3",
"express-validator": "6.14.1",
"helmet": "3.23.3",
"helmet-csp": "2.10.0",
"joi": "17.9.2",
"joi-objectid": "3.0.1",
"jsonwebtoken": "8.5.1",
"lodash": "4.17.21",
"loopback": "3.28.0",
"loopback-boot": "2.28.0",
"loopback-connector-mongodb": "5.6.0",
"method-override": "3.0.0",
"moment": "2.29.3",
"moment-timezone": "0.5.33",
"mongodb": "3.6.9",
"morgan": "1.10.0",
"nanoid": "3.3.4",
"no-profanity": "^1.4.2",
"node-fetch": "^2.6.7",
"nodemailer-ses-transport": "1.5.1",
"passport": "0.4.1",
"passport-auth0": "1.4.2",
"passport-local": "1.0.0",
"passport-mock-strategy": "2.0.0",
"query-string": "6.14.0",
"rate-limit-mongo": "^2.3.2",
"rx": "4.1.0",
"stripe": "8.205.0",
"strong-error-handler": "3.5.0",
"uuid": "3.4.0",
"validator": "13.7.0"
},
"devDependencies": {
"@babel/cli": "7.17.10",
"@babel/core": "7.18.0",
"@babel/node": "7.17.10",
"@babel/plugin-proposal-object-rest-spread": "7.18.0",
"@babel/plugin-proposal-optional-chaining": "7.17.12",
"@babel/preset-env": "7.18.0",
"loopback-component-explorer": "6.4.0",
"nodemon": "2.0.16"
}
}

View File

@@ -1,7 +0,0 @@
// 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

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

View File

@@ -1,94 +0,0 @@
import debug from 'debug';
import { Observable } from 'rx';
import {
createUserUpdatesFromProfile,
getSocialProvider
} from '../../server/utils/auth';
import { observeMethod, observeQuery } from '../../server/utils/rx';
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

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

View File

@@ -1,158 +0,0 @@
import dedent from 'dedent';
import { Observable } from 'rx';
// import debug from 'debug';
import { isEmail } from 'validator';
import { wrapHandledError } from '../../server/utils/create-handled-error.js';
import { observeMethod, observeQuery } from '../../server/utils/rx';
// 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 initializeUserIdent(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

@@ -1,23 +0,0 @@
{
"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

@@ -1,29 +0,0 @@
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

@@ -1,9 +0,0 @@
import { Observable } from 'rx';
export default function initializeBlock(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

@@ -1,53 +0,0 @@
{
"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

@@ -1,135 +0,0 @@
{
"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"
},
"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

@@ -1,937 +0,0 @@
/**
*
* Any ref to fixCompletedChallengesItem should be removed post
* a db migration to fix all completedChallenges
*
*/
import debugFactory from 'debug';
import dedent from 'dedent';
import _ from 'lodash';
import moment from 'moment';
import { customAlphabet } from 'nanoid';
import { Observable } from 'rx';
import uuid from 'uuid/v4';
import { isEmail } from 'validator';
import { isProfane } from 'no-profanity';
import { blocklistedUsernames } from '../../../../shared/config/constants';
import { wrapHandledError } from '../../server/utils/create-handled-error.js';
import {
setAccessTokenToResponse,
removeCookies
} from '../../server/utils/getSetAccessToken';
import { saveUser, observeMethod } from '../../server/utils/rx.js';
import { getEmailSender } from '../../server/utils/url-utils';
import {
fixCompletedChallengeItem,
getEncodedEmail,
getWaitMessage,
renderEmailChangeEmail,
renderSignUpEmail,
renderSignInEmail
} from '../utils';
const log = debugFactory('fcc:models:user');
const BROWNIEPOINTS_TIMEOUT = [1, 'hour'];
const nanoidCharSet =
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const nanoid = customAlphabet(nanoidCharSet, 21);
const createEmailError = redirectTo =>
wrapHandledError(new Error('email format is invalid'), {
type: 'info',
message: 'Please check to make sure the email is a valid email address.',
redirectTo
});
function destroyAll(id, Model) {
return Observable.fromNodeCallback(Model.destroyAll, Model)({ userId: id });
}
export function ensureLowerCaseString(maybeString) {
return (maybeString && maybeString.toLowerCase()) || '';
}
function buildCompletedChallengesUpdate(completedChallenges, project) {
const key = Object.keys(project)[0];
const solutions = project[key];
const solutionKeys = Object.keys(solutions);
const currentCompletedChallenges = [
...completedChallenges.map(fixCompletedChallengeItem)
];
const currentCompletedProjects = currentCompletedChallenges.filter(({ id }) =>
solutionKeys.includes(id)
);
const now = Date.now();
const update = solutionKeys.reduce((update, currentId) => {
const indexOfCurrentId = _.findIndex(update, ({ id }) => id === currentId);
const isCurrentlyCompleted = indexOfCurrentId !== -1;
if (isCurrentlyCompleted) {
update[indexOfCurrentId] = {
..._.find(update, ({ id }) => id === currentId),
solution: solutions[currentId]
};
}
if (!isCurrentlyCompleted) {
return [
...update,
{
id: currentId,
solution: solutions[currentId],
challengeType: 3,
completedDate: now
}
];
}
return update;
}, currentCompletedProjects);
const updatedExisting = _.uniqBy(
[...update, ...currentCompletedChallenges],
'id'
);
return {
updated: updatedExisting,
isNewCompletionCount: updatedExisting.length - completedChallenges.length
};
}
function isTheSame(val1, val2) {
return val1 === val2;
}
function getAboutProfile({
username,
usernameDisplay,
githubProfile: github,
progressTimestamps = [],
bio
}) {
return {
username: usernameDisplay || username,
github,
browniePoints: progressTimestamps.length,
bio
};
}
function nextTick(fn) {
return process.nextTick(fn);
}
const getRandomNumber = () => Math.random();
function populateRequiredFields(user) {
user.usernameDisplay = user.username.trim();
user.username = user.usernameDisplay.toLowerCase();
user.email =
typeof user.email === 'string'
? user.email.trim().toLowerCase()
: user.email;
if (!user.progressTimestamps) {
user.progressTimestamps = [];
}
if (user.progressTimestamps.length === 0) {
user.progressTimestamps.push(Date.now());
}
if (!user.externalId) {
user.externalId = uuid();
}
if (!user.unsubscribeId) {
user.unsubscribeId = nanoid();
}
return;
}
export default function initializeUser(User) {
// set salt factor for passwords
User.settings.saltWorkFactor = 5;
// set user.rand to random number
User.definition.rawProperties.rand.default = getRandomNumber;
User.definition.properties.rand.default = getRandomNumber;
// increase user accessToken ttl to 900 days
User.settings.ttl = 900 * 24 * 60 * 60 * 1000;
// Sets ttl to 900 days for mobile login created access tokens
User.settings.maxTTL = 900 * 24 * 60 * 60 * 1000;
// username should not be in blocklist
User.validatesExclusionOf('username', {
in: blocklistedUsernames,
message: 'is not available'
});
// username should be unique
User.validatesUniquenessOf('username');
User.settings.emailVerificationRequired = false;
User.on('dataSourceAttached', () => {
User.findOne$ = Observable.fromNodeCallback(User.findOne, User);
User.count$ = Observable.fromNodeCallback(User.count, User);
User.create$ = Observable.fromNodeCallback(User.create.bind(User));
User.prototype.createAccessToken$ = Observable.fromNodeCallback(
User.prototype.createAccessToken
);
});
User.observe('before save', function (ctx) {
const beforeCreate = Observable.of(ctx)
.filter(({ isNewInstance }) => isNewInstance)
// User.create
.map(({ instance }) => instance)
.flatMap(user => {
// note(berks): we now require all new users to supply an email
// this was not always the case
if (typeof user.email !== 'string' || !isEmail(user.email)) {
throw createEmailError();
}
// assign random username to new users
user.username = 'fcc' + uuid();
populateRequiredFields(user);
return Observable.fromPromise(User.doesExist(null, user.email)).do(
exists => {
if (exists) {
throw wrapHandledError(new Error('user already exists'), {
redirectTo: `${process.env.API_LOCATION}/signin`,
message: dedent`
The ${user.email} email address is already associated with an account.
Try signing in with it here instead.
`
});
}
}
);
})
.ignoreElements();
const updateOrSave = Observable.of(ctx)
// not new
.filter(({ isNewInstance }) => !isNewInstance)
.map(({ instance }) => instance)
// is update or save user
.filter(Boolean)
.do(user => {
// Some old accounts will not have emails associated with them
// we verify only if the email field is populated
if (user.email && !isEmail(user.email)) {
throw createEmailError();
}
populateRequiredFields(user);
})
.ignoreElements();
return Observable.merge(beforeCreate, updateOrSave).toPromise();
});
// remove lingering user identities before deleting user
User.observe('before delete', function (ctx, next) {
const UserIdentity = User.app.models.UserIdentity;
const UserCredential = User.app.models.UserCredential;
log('removing user', ctx.where);
var id = ctx.where && ctx.where.id ? ctx.where.id : null;
if (!id) {
return next();
}
return Observable.combineLatest(
destroyAll(id, UserIdentity),
destroyAll(id, UserCredential),
function (identData, credData) {
return {
identData: identData,
credData: credData
};
}
).subscribe(
function (data) {
log('deleted', data);
},
function (err) {
log('error deleting user %s stuff', id, err);
next(err);
},
function () {
log('user stuff deleted for user %s', id);
next();
}
);
});
log('setting up user hooks');
// overwrite lb confirm
User.confirm = function (uid, token, redirectTo) {
return this.findById(uid).then(user => {
if (!user) {
throw wrapHandledError(new Error(`User not found: ${uid}`), {
// standard oops
type: 'info',
redirectTo
});
}
if (user.verificationToken !== token) {
throw wrapHandledError(new Error(`Invalid token: ${token}`), {
type: 'info',
message: dedent`
Looks like you have clicked an invalid link.
Please sign in and request a fresh one.
`,
redirectTo
});
}
return new Promise((resolve, reject) =>
user.updateAttributes(
{
email: user.newEmail,
emailVerified: true,
emailVerifyTTL: null,
newEmail: null,
verificationToken: null
},
err => {
if (err) {
return reject(err);
}
return resolve();
}
)
);
});
};
User.prototype.loginByRequest = function loginByRequest(req, res) {
const {
query: { emailChange }
} = req;
const createToken = this.createAccessToken$().do(accessToken => {
if (accessToken && accessToken.id) {
setAccessTokenToResponse({ accessToken }, req, res);
}
});
let data = {
emailVerified: true,
emailAuthLinkTTL: null,
emailVerifyTTL: null
};
if (emailChange && this.newEmail) {
data = {
...data,
email: this.newEmail,
newEmail: null
};
}
const updateUser = new Promise((resolve, reject) =>
this.updateAttributes(data, err => {
if (err) {
return reject(err);
}
return resolve();
})
);
return Observable.combineLatest(
createToken,
Observable.fromPromise(updateUser),
req.logIn(this),
accessToken => accessToken
);
};
User.prototype.mobileLoginByRequest = function mobileLoginByRequest(
req,
res
) {
return new Promise((resolve, reject) =>
this.createAccessToken({}, (err, accessToken) => {
if (err) {
return reject(err);
}
setAccessTokenToResponse({ accessToken }, req, res);
return resolve(accessToken);
})
);
};
User.afterRemote('logout', function ({ req, res }, result, next) {
removeCookies(req, res);
next();
});
User.doesExist = function doesExist(username, email) {
if (!username && (!email || !isEmail(email))) {
return Promise.resolve(false);
}
log('check if username is available');
// check to see if username is on blocklist
if (
username &&
(blocklistedUsernames.includes(username) || isProfane(username))
) {
return Promise.resolve(true);
}
var where = {};
if (username) {
where.username = username.toLowerCase();
} else {
where.email = email ? email.toLowerCase() : email;
}
log('where', where);
return User.count(where).then(count => count > 0);
};
User.about = function about(username, cb) {
if (!username) {
// Zalgo!!
return nextTick(() => {
cb(null, {});
});
}
return User.findOne({ where: { username } }, (err, user) => {
if (err) {
return cb(err);
}
if (!user || user.username !== username) {
return cb(null, {});
}
const aboutUser = getAboutProfile(user);
return cb(null, aboutUser);
});
};
User.remoteMethod('about', {
description: 'get public info about user',
accepts: [
{
arg: 'username',
type: 'string'
}
],
returns: [
{
arg: 'about',
type: 'object'
}
],
http: {
path: '/about',
verb: 'get'
}
});
User.prototype.createAuthToken = function createAuthToken({ ttl } = {}) {
return Observable.fromNodeCallback(
this.authTokens.create.bind(this.authTokens)
)({ ttl });
};
User.prototype.createDonation = function createDonation(donation = {}) {
return Observable.fromNodeCallback(
this.donations.create.bind(this.donations)
)(donation).do(() =>
this.updateAttributes({
isDonating: true,
donationEmails: [...(this.donationEmails || []), donation.email]
})
);
};
function requestCompletedChallenges() {
return this.getCompletedChallenges$();
}
User.prototype.requestCompletedChallenges = requestCompletedChallenges;
function requestAuthEmail(isSignUp, newEmail) {
return Observable.defer(() => {
const messageOrNull = getWaitMessage(this.emailAuthLinkTTL);
if (messageOrNull) {
throw wrapHandledError(new Error('request is throttled'), {
type: 'info',
message: messageOrNull
});
}
// create a temporary access token with ttl for 15 minutes
return this.createAuthToken({ ttl: 15 * 60 * 1000 });
})
.flatMap(token => {
let renderAuthEmail = renderSignInEmail;
let subject = 'Your sign in link for freeCodeCamp.org';
if (isSignUp) {
renderAuthEmail = renderSignUpEmail;
subject = 'Your sign in link for your new freeCodeCamp.org account';
}
if (newEmail) {
renderAuthEmail = renderEmailChangeEmail;
subject = dedent`
Please confirm your updated email address for freeCodeCamp.org
`;
}
const { id: loginToken, created: emailAuthLinkTTL } = token;
const loginEmail = getEncodedEmail(newEmail ? newEmail : null);
const host = process.env.API_LOCATION;
const mailOptions = {
type: 'email',
to: newEmail ? newEmail : this.email,
from: getEmailSender(),
subject,
text: renderAuthEmail({
host,
loginEmail,
loginToken,
emailChange: !!newEmail
})
};
const userUpdate = new Promise((resolve, reject) =>
this.updateAttributes({ emailAuthLinkTTL }, err => {
if (err) {
return reject(err);
}
return resolve();
})
);
return Observable.forkJoin(
User.email.send$(mailOptions),
Observable.fromPromise(userUpdate)
);
})
.map({
type: 'info',
message: dedent`Check your email and click the link we sent you to confirm your new email address.`
});
}
User.prototype.requestAuthEmail = requestAuthEmail;
/**
* @param {String} requestedEmail
*/
function requestUpdateEmail(requestedEmail) {
const newEmail = ensureLowerCaseString(requestedEmail);
const currentEmail = ensureLowerCaseString(this.email);
const isOwnEmail = isTheSame(newEmail, currentEmail);
const isResendUpdateToSameEmail = isTheSame(
newEmail,
ensureLowerCaseString(this.newEmail)
);
const isLinkSentWithinLimit = getWaitMessage(this.emailVerifyTTL);
const isVerifiedEmail = this.emailVerified;
if (isOwnEmail && isVerifiedEmail) {
// email is already associated and verified with this account
throw wrapHandledError(new Error('email is already verified'), {
type: 'info',
message: `
${newEmail} is already associated with this account.
You can update a new email address instead.`
});
}
if (isResendUpdateToSameEmail && isLinkSentWithinLimit) {
// trying to update with the same newEmail and
// confirmation email is still valid
throw wrapHandledError(new Error(), {
type: 'info',
message: dedent`
We have already sent an email confirmation request to ${newEmail}.
${isLinkSentWithinLimit}`
});
}
if (!isEmail('' + newEmail)) {
throw createEmailError();
}
// newEmail is not associated with this user, and
// this attempt to change email is the first or
// previous attempts have expired
if (
!isOwnEmail ||
(isOwnEmail && !isVerifiedEmail) ||
(isResendUpdateToSameEmail && !isLinkSentWithinLimit)
) {
const updateConfig = {
newEmail,
emailVerified: false,
emailVerifyTTL: new Date()
};
// defer prevents the promise from firing prematurely (before subscribe)
return Observable.defer(() => User.doesExist(null, newEmail))
.do(exists => {
if (exists && !isOwnEmail) {
// newEmail is not associated with this account,
// but is associated with different account
throw wrapHandledError(new Error('email already in use'), {
type: 'info',
message: `${newEmail} is already associated with another account.`
});
}
})
.flatMap(() => {
const updatePromise = new Promise((resolve, reject) =>
this.updateAttributes(updateConfig, err => {
if (err) {
return reject(err);
}
return resolve();
})
);
return Observable.forkJoin(
Observable.fromPromise(updatePromise),
this.requestAuthEmail(false, newEmail),
(_, message) => message
);
});
} else {
return 'Something unexpected happened while updating your email.';
}
}
User.prototype.requestUpdateEmail = requestUpdateEmail;
User.prototype.requestUpdateFlags = async function requestUpdateFlags(
values
) {
const flagsToCheck = Object.keys(values);
const valuesToCheck = _.pick({ ...this }, flagsToCheck);
const flagsToUpdate = flagsToCheck.filter(
flag => !isTheSame(values[flag], valuesToCheck[flag])
);
if (!flagsToUpdate.length) {
return Observable.of(
dedent`
No property in
${JSON.stringify(flagsToCheck, null, 2)}
will introduce a change in this user.
`
).map(() => dedent`Your settings have not been updated.`);
}
const userUpdateData = flagsToUpdate.reduce((data, currentFlag) => {
data[currentFlag] = values[currentFlag];
return data;
}, {});
log(userUpdateData);
const userUpdate = new Promise((resolve, reject) =>
this.updateAttributes(userUpdateData, err => {
if (err) {
return reject(err);
}
return resolve();
})
);
return Observable.fromPromise(userUpdate).map(
() => dedent`
We have successfully updated your account.
`
);
};
User.prototype.updateMyPortfolio = function updateMyPortfolio(
portfolioItem,
deleteRequest
) {
const currentPortfolio = this.portfolio.slice(0);
const pIndex = _.findIndex(
currentPortfolio,
p => p.id === portfolioItem.id
);
let updatedPortfolio = [];
if (deleteRequest) {
updatedPortfolio = currentPortfolio.filter(
p => p.id !== portfolioItem.id
);
} else if (pIndex === -1) {
updatedPortfolio = currentPortfolio.concat([portfolioItem]);
} else {
updatedPortfolio = [...currentPortfolio];
updatedPortfolio[pIndex] = { ...portfolioItem };
}
const userUpdate = new Promise((resolve, reject) =>
this.updateAttribute('portfolio', updatedPortfolio, err => {
if (err) {
return reject(err);
}
return resolve();
})
);
return Observable.fromPromise(userUpdate).map(
() => dedent`
Your portfolio has been updated.
`
);
};
User.prototype.updateMyProjects = function updateMyProjects(project) {
const updateData = { $set: {} };
return this.getCompletedChallenges$()
.flatMap(() => {
const { updated, isNewCompletionCount } =
buildCompletedChallengesUpdate(this.completedChallenges, project);
updateData.$set.completedChallenges = updated;
if (isNewCompletionCount) {
let points = [];
// give points a length of isNewCompletionCount
points[isNewCompletionCount - 1] = true;
updateData.$push = {};
updateData.$push.progressTimestamps = {
$each: points.map(() => Date.now())
};
}
const updatePromise = new Promise((resolve, reject) =>
this.updateAttributes(updateData, err => {
if (err) {
return reject(err);
}
return resolve();
})
);
return Observable.fromPromise(updatePromise);
})
.map(
() => dedent`
Your projects have been updated.
`
);
};
User.prototype.updateMyProfileUI = function updateMyProfileUI(profileUI) {
const newProfileUI = {
...this.profileUI,
...profileUI
};
const profileUIUpdate = new Promise((resolve, reject) =>
this.updateAttribute('profileUI', newProfileUI, err => {
if (err) {
return reject(err);
}
return resolve();
})
);
return Observable.fromPromise(profileUIUpdate).map(
() => dedent`
Your privacy settings have been updated.
`
);
};
User.giveBrowniePoints = function giveBrowniePoints(
receiver,
giver,
data = {},
dev = false,
cb
) {
const findUser = observeMethod(User, 'findOne');
if (!receiver) {
return nextTick(() => {
cb(new TypeError(`receiver should be a string but got ${receiver}`));
});
}
if (!giver) {
return nextTick(() => {
cb(new TypeError(`giver should be a string but got ${giver}`));
});
}
let temp = moment();
const browniePoints = temp.subtract
.apply(temp, BROWNIEPOINTS_TIMEOUT)
.valueOf();
const user$ = findUser({ where: { username: receiver } });
return (
user$
.tapOnNext(user => {
if (!user) {
throw new Error(`could not find receiver for ${receiver}`);
}
})
.flatMap(({ progressTimestamps = [] }) => {
return Observable.from(progressTimestamps);
})
// filter out non objects
.filter(timestamp => !!timestamp || typeof timestamp === 'object')
// filter out timestamps older than one hour
.filter(({ timestamp = 0 }) => {
return timestamp >= browniePoints;
})
// filter out brownie points given by giver
.filter(browniePoint => {
return browniePoint.giver === giver;
})
// no results means this is the first brownie point given by giver
// so return -1 to indicate receiver should receive point
.first({ defaultValue: -1 })
.flatMap(browniePointsFromGiver => {
if (browniePointsFromGiver === -1) {
return user$.flatMap(user => {
user.progressTimestamps.push({
giver,
timestamp: Date.now(),
...data
});
return saveUser(user);
});
}
return Observable.throw(
new Error(`${giver} already gave ${receiver} points`)
);
})
.subscribe(
user => {
return cb(
null,
getAboutProfile(user),
dev ? { giver, receiver, data } : null
);
},
e => cb(e, null, dev ? { giver, receiver, data } : null),
() => {
log('brownie points assigned completed');
}
)
);
};
User.remoteMethod('giveBrowniePoints', {
description: 'Give this user brownie points',
accepts: [
{
arg: 'receiver',
type: 'string',
required: true
},
{
arg: 'giver',
type: 'string',
required: true
},
{
arg: 'data',
type: 'object'
},
{
arg: 'debug',
type: 'boolean'
}
],
returns: [
{
arg: 'about',
type: 'object'
},
{
arg: 'debug',
type: 'object'
}
],
http: {
path: '/give-brownie-points',
verb: 'POST'
}
});
User.prototype.getPoints$ = function getPoints$() {
if (
Array.isArray(this.progressTimestamps) &&
this.progressTimestamps.length
) {
return Observable.of(this.progressTimestamps);
}
const id = this.getId();
const filter = {
where: { id },
fields: { progressTimestamps: true }
};
return this.constructor.findOne$(filter).map(user => {
this.progressTimestamps = user.progressTimestamps;
return user.progressTimestamps;
});
};
User.prototype.getCompletedChallenges$ = function getCompletedChallenges$() {
if (
Array.isArray(this.completedChallenges) &&
this.completedChallenges.length
) {
return Observable.of(this.completedChallenges);
}
const id = this.getId();
const filter = {
where: { id },
fields: { completedChallenges: true }
};
return this.constructor.findOne$(filter).map(user => {
this.completedChallenges = user.completedChallenges;
return user.completedChallenges;
});
};
User.prototype.getSavedChallenges$ = function getSavedChallenges$() {
if (Array.isArray(this.savedChallenges) && this.savedChallenges.length) {
return Observable.of(this.savedChallenges);
}
const id = this.getId();
const filter = {
where: { id },
fields: { savedChallenges: true }
};
return this.constructor.findOne$(filter).map(user => {
this.savedChallenges = user.savedChallenges;
return user.savedChallenges;
});
};
User.prototype.getPartiallyCompletedChallenges$ =
function getPartiallyCompletedChallenges$() {
if (
Array.isArray(this.partiallyCompletedChallenges) &&
this.partiallyCompletedChallenges.length
) {
return Observable.of(this.partiallyCompletedChallenges);
}
const id = this.getId();
const filter = {
where: { id },
fields: { partiallyCompletedChallenges: true }
};
return this.constructor.findOne$(filter).map(user => {
this.partiallyCompletedChallenges = user.partiallyCompletedChallenges;
return user.partiallyCompletedChallenges;
});
};
User.prototype.getCompletedExams$ = function getCompletedExams$() {
if (Array.isArray(this.completedExams) && this.completedExams.length) {
return Observable.of(this.completedExams);
}
const id = this.getId();
const filter = {
where: { id },
fields: { completedExams: true }
};
return this.constructor.findOne$(filter).map(user => {
this.completedExams = user.completedExams;
return user.completedExams;
});
};
User.getMessages = messages => Promise.resolve(messages);
User.remoteMethod('getMessages', {
http: {
verb: 'get',
path: '/get-messages'
},
accepts: {
arg: 'messages',
type: 'object',
http: ctx => ctx.req.flash()
},
returns: [
{
arg: 'messages',
type: 'object',
root: true
}
]
});
}

View File

@@ -1,520 +0,0 @@
{
"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
},
"usernameDisplay": {
"type": "string"
},
"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
},
"isClassroomAccount": {
"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
},
"needsModeration": {
"type": "boolean",
"description": "Camper has challenges needing to be moderated",
"default": false,
"index": {
"mongodb": {
"unique": true,
"background": true
}
}
},
"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
},
"isRelationalDatabaseCertV8": {
"type": "boolean",
"description": "Camper is relational database certified",
"default": false
},
"isCollegeAlgebraPyCertV8": {
"type": "boolean",
"description": "Camper is college algebra with Python certified",
"default": false
},
"isFoundationalCSharpCertV8": {
"type": "boolean",
"description": "Camper is foundational C# certified",
"default": false
},
"isJsAlgoDataStructCertV8": {
"type": "boolean",
"description": "Camper is javascript algorithms and data structures certified (2023)",
"default": false
},
"completedChallenges": {
"type": [
{
"completedDate": "number",
"id": "string",
"solution": "string",
"githubLink": "string",
"challengeType": "number",
"isManuallyApproved": "boolean",
"files": {
"type": [
{
"contents": {
"type": "string",
"default": ""
},
"ext": {
"type": "string"
},
"path": {
"type": "string"
},
"name": {
"type": "string"
},
"key": {
"type": "string"
}
}
],
"default": []
}
}
],
"default": []
},
"partiallyCompletedChallenges": {
"type": [
{
"completedDate": "number",
"id": "string"
}
],
"default": []
},
"savedChallenges": {
"type": [
{
"lastSavedDate": "number",
"id": "string",
"challengeType": "number",
"files": {
"type": [
{
"contents": {
"type": "string",
"default": ""
},
"ext": {
"type": "string"
},
"path": {
"type": "string"
},
"name": {
"type": "string"
},
"key": {
"type": "string"
}
}
],
"default": []
}
}
],
"default": []
},
"completedExams": {
"type": [
{
"completedDate": "number",
"id": "string",
"challengeType": "number",
"examResults": {
"type": {
"numberOfCorrectAnswers": "number",
"numberOfQuestionsInExam": "number",
"percentCorrect": "number",
"passingPercent": "number",
"passed": "boolean",
"examTimeInSeconds": "number"
}
}
}
],
"default": []
},
"portfolio": {
"type": "array",
"default": []
},
"yearsTopContributor": {
"type": "array",
"default": []
},
"rand": {
"type": "number",
"index": true
},
"timezone": {
"type": "string"
},
"theme": {
"type": "string",
"default": "default"
},
"keyboardShortcuts": {
"type": "boolean",
"default": false
},
"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"
},
"userTokens": {
"type": "hasMany",
"model": "UserToken",
"foreignKey": "userId"
},
"msUsernames": {
"type": "hasMany",
"model": "MsUsername",
"foreignKey": "userId"
},
"surveys": {
"type": "hasMany",
"model": "Survey",
"foreignKey": "userId"
}
},
"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

@@ -1,80 +0,0 @@
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

@@ -1,9 +0,0 @@
{
"aboutUrl": "https://www.freecodecamp.org/about",
"defaultProfileImage": "https://cdn.freecodecamp.org/platform/universal/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

@@ -1,13 +0,0 @@
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

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

View File

@@ -1,34 +0,0 @@
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',
'isManuallyApproved',
'examResults'
]);
export const fixSavedChallengeItem = obj =>
pick(obj, ['id', 'lastSavedDate', 'files']);
export const fixPartiallyCompletedChallengeItem = obj =>
pick(obj, ['id', 'completedDate']);
export const fixCompletedExamItem = obj =>
pick(obj, ['id', 'completedDate', 'challengeType', 'examResults']);
export const fixCompletedSurveyItem = obj => pick(obj, ['title', 'responses']);

View File

@@ -1,21 +0,0 @@
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
const createDebugger = require('debug');
const nodemon = require('nodemon');
const log = createDebugger('fcc:start:development');
nodemon({
ext: 'js json',
// --silent squashes an ELIFECYCLE error when the server exits
exec: 'pnpm run --silent babel-dev-server',
watch: path.resolve(__dirname, './server'),
spawn: true,
env: {
DEBUG: `fcc*,${process.env.DEBUG}`
}
});
nodemon.on('restart', function nodemonRestart(files) {
log('App restarted due to: ', files);
});

View File

@@ -1,31 +0,0 @@
// this ensures node understands the future
const createDebugger = require('debug');
const _ = require('lodash');
const log = createDebugger('fcc:server:production-start');
const startTime = Date.now();
// force logger to always output
// this may be brittle
log.enabled = true;
// this is where server starts booting up
const app = require('./server');
let timeoutHandler;
let killTime = 15;
const onConnect = _.once(() => {
log('db connected in: %s', Date.now() - startTime);
if (timeoutHandler) {
clearTimeout(timeoutHandler);
}
app.start();
});
timeoutHandler = setTimeout(() => {
const message = `db did not connect after ${killTime}s -- crashing hard`;
// purposely shutdown server
// pm2 should restart this in production
throw new Error(message);
}, killTime * 1000);
app.dataSources.db.on('connected', onConnect);

View File

@@ -1,15 +0,0 @@
import { Observable } from 'rx';
export default function extendEmail(app) {
const { AccessToken, Email } = app.models;
Email.send$ = Observable.fromNodeCallback(Email.send, Email);
AccessToken.findOne$ = Observable.fromNodeCallback(
AccessToken.findOne.bind(AccessToken)
);
AccessToken.prototype.validate$ = Observable.fromNodeCallback(
AccessToken.prototype.validate
);
AccessToken.prototype.destroy$ = Observable.fromNodeCallback(
AccessToken.prototype.destroy
);
}

View File

@@ -1,5 +0,0 @@
module.exports = function increaseListers(app) {
// increase loopback database ODM max listeners
// this is a EventEmitter method
app.dataSources.db.setMaxListeners(32);
};

View File

@@ -1,246 +0,0 @@
import dedent from 'dedent';
import { check } from 'express-validator';
import jwt from 'jsonwebtoken';
import passport from 'passport';
import fetch from 'node-fetch';
import { isEmail } from 'validator';
import { jwtSecret } from '../../../config/secrets';
import { decodeEmail } from '../../common/utils';
import {
createPassportCallbackAuthenticator,
devSaveResponseAuthCookies,
devLoginRedirect
} from '../component-passport';
import { wrapHandledError } from '../utils/create-handled-error.js';
import { removeCookies } from '../utils/getSetAccessToken';
import {
ifUserRedirectTo,
ifNoUserRedirectHome,
ifNotMobileRedirect
} from '../utils/middleware';
import { getRedirectParams } from '../utils/redirection';
import { createDeleteUserToken } from '../middlewares/user-token';
const passwordlessGetValidators = [
check('email')
.isBase64()
.withMessage('Email should be a base64 encoded string.'),
check('token')
.exists()
.withMessage('Token should exist.')
// based on strongloop/loopback/common/models/access-token.js#L15
.isLength({ min: 64, max: 64 })
.withMessage('Token is not the right length.')
];
module.exports = function enableAuthentication(app) {
// enable loopback access control authentication. see:
// loopback.io/doc/en/lb2/Authentication-authorization-and-permissions.html
app.enableAuth();
const ifNotMobile = ifNotMobileRedirect();
const ifUserRedirect = ifUserRedirectTo();
const ifNoUserRedirect = ifNoUserRedirectHome();
const devSaveAuthCookies = devSaveResponseAuthCookies();
const devLoginSuccessRedirect = devLoginRedirect();
const api = app.loopback.Router();
const deleteUserToken = createDeleteUserToken(app);
// Use a local mock strategy for signing in if we are in dev mode.
// Otherwise we use auth0 login. We use a string for 'true' because values
// set in the env file will always be strings and never boolean.
if (process.env.LOCAL_MOCK_AUTH === 'true') {
api.get(
'/signin',
passport.authenticate('devlogin'),
devSaveAuthCookies,
devLoginSuccessRedirect
);
} else {
api.get('/signin', ifUserRedirect, (req, res, next) => {
const { returnTo, origin, pathPrefix } = getRedirectParams(req);
const state = jwt.sign({ returnTo, origin, pathPrefix }, jwtSecret);
return passport.authenticate('auth0-login', { state })(req, res, next);
});
api.get(
'/auth/auth0/callback',
createPassportCallbackAuthenticator('auth0-login', { provider: 'auth0' })
);
}
api.get('/signout', deleteUserToken, (req, res) => {
const { origin, returnTo } = getRedirectParams(req);
req.logout();
req.session.destroy(err => {
if (err) {
throw wrapHandledError(new Error('could not destroy session'), {
type: 'info',
message: 'We could not log you out, please try again in a moment.',
redirectTo: origin
});
}
removeCookies(req, res);
res.redirect(returnTo);
});
});
api.get(
'/confirm-email',
ifNoUserRedirect,
passwordlessGetValidators,
createGetPasswordlessAuth(app)
);
api.get('/mobile-login', ifNotMobile, ifUserRedirect, mobileLogin(app));
app.use(api);
};
const defaultErrorMsg = dedent`
Oops, something is not right,
please request a fresh link to sign in / sign up.
`;
function createGetPasswordlessAuth(app) {
const {
models: { AuthToken, User }
} = app;
return function getPasswordlessAuth(req, res, next) {
const {
query: { email: encodedEmail, token: authTokenId, emailChange } = {}
} = req;
const { origin } = getRedirectParams(req);
const email = decodeEmail(encodedEmail);
if (!isEmail(email)) {
return next(
wrapHandledError(new TypeError('decoded email is invalid'), {
type: 'info',
message: 'The email encoded in the link is incorrectly formatted',
redirectTo: `${origin}/signin`
})
);
}
// first find
return (
AuthToken.findOne$({ where: { id: authTokenId } })
.flatMap(authToken => {
if (!authToken) {
throw wrapHandledError(
new Error(`no token found for id: ${authTokenId}`),
{
type: 'info',
message: defaultErrorMsg,
redirectTo: `${origin}/signin`
}
);
}
// find user then validate and destroy email validation token
// finally return user instance
return User.findOne$({ where: { id: authToken.userId } }).flatMap(
user => {
if (!user) {
throw wrapHandledError(
new Error(`no user found for token: ${authTokenId}`),
{
type: 'info',
message: defaultErrorMsg,
redirectTo: `${origin}/signin`
}
);
}
if (user.email !== email) {
if (!emailChange || (emailChange && user.newEmail !== email)) {
throw wrapHandledError(
new Error('user email does not match'),
{
type: 'info',
message: defaultErrorMsg,
redirectTo: `${origin}/signin`
}
);
}
}
return authToken
.validate$()
.map(isValid => {
if (!isValid) {
throw wrapHandledError(new Error('token is invalid'), {
type: 'info',
message: `
Looks like the link you clicked has expired,
please request a fresh link, to sign in.
`,
redirectTo: `${origin}/signin`
});
}
return authToken.destroy$();
})
.map(() => user);
}
);
})
// at this point token has been validated and destroyed
// update user and log them in
.map(user => user.loginByRequest(req, res))
.do(() => {
if (emailChange) {
req.flash('success', 'flash.email-valid');
} else {
req.flash('success', 'flash.signin-success');
}
return res.redirectWithFlash(`${origin}/learn`);
})
.subscribe(() => {}, next)
);
};
}
function mobileLogin(app) {
const {
models: { User }
} = app;
return async function getPasswordlessAuth(req, res, next) {
try {
const auth0Res = await fetch(
`https://${process.env.AUTH0_DOMAIN}/userinfo`,
{
headers: { Authorization: req.headers.authorization }
}
);
if (!auth0Res.ok) {
return next(
wrapHandledError(new Error('Invalid Auth0 token'), {
type: 'danger',
message: 'We could not log you in, please try again in a moment.',
status: auth0Res.status
})
);
}
const { email } = await auth0Res.json();
if (typeof email !== 'string' || !isEmail(email)) {
return next(
wrapHandledError(new TypeError('decoded email is invalid'), {
type: 'danger',
message: 'The email is incorrectly formatted',
status: 400
})
);
}
User.findOne$({ where: { email } })
.do(async user => {
if (!user) {
user = await User.create({ email });
}
await user.mobileLoginByRequest(req, res);
res.end();
})
.subscribe(() => {}, next);
} catch (err) {
next(err);
}
};
}

View File

@@ -1,569 +0,0 @@
import path from 'path';
import debug from 'debug';
import dedent from 'dedent';
import _ from 'lodash';
import loopback from 'loopback';
import { Observable } from 'rx';
import { isEmail } from 'validator';
import {
completionHours,
certTypes,
certSlugTypeMap,
certTypeTitleMap,
certTypeIdMap,
certIds,
oldDataVizId,
currentCertifications,
upcomingCertifications,
legacyCertifications,
legacyFullStackCertification
} from '../../../../shared/config/certification-settings';
import { reportError } from '../middlewares/sentry-error-handler.js';
import { deprecatedEndpoint } from '../utils/disabled-endpoints';
import { getChallenges } from '../utils/get-curriculum';
import { ifNoUser401 } from '../utils/middleware';
import { observeQuery } from '../utils/rx';
const {
legacyFrontEndChallengeId,
legacyBackEndChallengeId,
legacyDataVisId,
legacyInfosecQaId,
legacyFullStackId,
respWebDesignId,
frontEndDevLibsId,
jsAlgoDataStructId,
dataVis2018Id,
apisMicroservicesId,
qaV7Id,
infosecV7Id,
sciCompPyV7Id,
dataAnalysisPyV7Id,
machineLearningPyV7Id,
relationalDatabaseV8Id,
collegeAlgebraPyV8Id,
foundationalCSharpV8Id,
jsAlgoDataStructV8Id
} = certIds;
const log = debug('fcc:certification');
export default function bootCertificate(app) {
const api = app.loopback.Router();
// TODO: rather than getting all the challenges, then grabbing the certs,
// consider just getting the certs.
const certTypeIds = createCertTypeIds(getChallenges());
const showCert = createShowCert(app);
const verifyCert = createVerifyCert(certTypeIds, app);
api.put('/certificate/verify', ifNoUser401, ifNoCertification404, verifyCert);
api.get('/certificate/showCert/:username/:certSlug', showCert);
api.get('/certificate/verify-can-claim-cert', deprecatedEndpoint);
app.use(api);
}
export function getFallbackFullStackDate(completedChallenges, completedDate) {
var chalIds = [
respWebDesignId,
jsAlgoDataStructId,
frontEndDevLibsId,
dataVis2018Id,
apisMicroservicesId,
legacyInfosecQaId
];
const latestCertDate = completedChallenges
.filter(chal => chalIds.includes(chal.id))
.sort((a, b) => b.completedDate - a.completedDate)[0]?.completedDate;
return latestCertDate ? latestCertDate : completedDate;
}
export function ifNoCertification404(req, res, next) {
const { certSlug } = req.body;
if (!certSlug) return res.status(404).end();
if (
currentCertifications.includes(certSlug) ||
legacyCertifications.includes(certSlug) ||
legacyFullStackCertification.includes(certSlug)
)
return next();
if (
process.env.SHOW_UPCOMING_CHANGES === 'true' &&
upcomingCertifications.includes(certSlug)
) {
return next();
}
res.status(404).end();
}
const renderCertifiedEmail = loopback.template(
path.join(__dirname, '..', 'views', 'emails', 'certified.ejs')
);
function createCertTypeIds(allChallenges) {
return {
// legacy
[certTypes.frontEnd]: getCertById(legacyFrontEndChallengeId, allChallenges),
[certTypes.jsAlgoDataStruct]: getCertById(
jsAlgoDataStructId,
allChallenges
),
[certTypes.backEnd]: getCertById(legacyBackEndChallengeId, allChallenges),
[certTypes.dataVis]: getCertById(legacyDataVisId, allChallenges),
[certTypes.infosecQa]: getCertById(legacyInfosecQaId, allChallenges),
[certTypes.fullStack]: getCertById(legacyFullStackId, allChallenges),
// modern
[certTypes.respWebDesign]: getCertById(respWebDesignId, allChallenges),
[certTypes.jsAlgoDataStructV8]: getCertById(
jsAlgoDataStructV8Id,
allChallenges
),
[certTypes.frontEndDevLibs]: getCertById(frontEndDevLibsId, allChallenges),
[certTypes.dataVis2018]: getCertById(dataVis2018Id, allChallenges),
[certTypes.apisMicroservices]: getCertById(
apisMicroservicesId,
allChallenges
),
[certTypes.qaV7]: getCertById(qaV7Id, allChallenges),
[certTypes.infosecV7]: getCertById(infosecV7Id, allChallenges),
[certTypes.sciCompPyV7]: getCertById(sciCompPyV7Id, allChallenges),
[certTypes.dataAnalysisPyV7]: getCertById(
dataAnalysisPyV7Id,
allChallenges
),
[certTypes.machineLearningPyV7]: getCertById(
machineLearningPyV7Id,
allChallenges
),
[certTypes.relationalDatabaseV8]: getCertById(
relationalDatabaseV8Id,
allChallenges
),
[certTypes.collegeAlgebraPyV8]: getCertById(
collegeAlgebraPyV8Id,
allChallenges
),
[certTypes.foundationalCSharpV8]: getCertById(
foundationalCSharpV8Id,
allChallenges
)
};
}
function hasCompletedTests(ids, completedChallenges = []) {
return _.every(ids, ({ id }) =>
_.find(completedChallenges, ({ id: completedId }) => completedId === id)
);
}
function getCertById(anId, allChallenges) {
return allChallenges
.filter(({ id }) => id === anId)
.map(({ id, tests, name, challengeType }) => ({
id,
tests,
name,
challengeType
}))[0];
}
function sendCertifiedEmail(
{
email = '',
name,
username,
isRespWebDesignCert,
isJsAlgoDataStructCertV8,
isFrontEndLibsCert,
isDataVisCert,
isApisMicroservicesCert,
isQaCertV7,
isInfosecCertV7,
isSciCompPyCertV7,
isDataAnalysisPyCertV7,
isMachineLearningPyCertV7,
isRelationalDatabaseCertV8,
isCollegeAlgebraPyCertV8,
isFoundationalCSharpCertV8
},
send$
) {
if (
!isEmail(email) ||
!isRespWebDesignCert ||
!isJsAlgoDataStructCertV8 ||
!isFrontEndLibsCert ||
!isDataVisCert ||
!isApisMicroservicesCert ||
!isQaCertV7 ||
!isInfosecCertV7 ||
!isSciCompPyCertV7 ||
!isDataAnalysisPyCertV7 ||
!isMachineLearningPyCertV7 ||
!isRelationalDatabaseCertV8 ||
!isCollegeAlgebraPyCertV8 ||
!isFoundationalCSharpCertV8
) {
return Observable.just(false);
}
const notifyUser = {
type: 'email',
to: email,
from: 'quincy@freecodecamp.org',
subject: dedent`
Congratulations on completing all of the
freeCodeCamp certifications!
`,
text: renderCertifiedEmail({
username,
name
})
};
return send$(notifyUser).map(() => true);
}
function getUserIsCertMap(user) {
const {
isRespWebDesignCert = false,
isJsAlgoDataStructCert = false,
isJsAlgoDataStructCertV8 = false,
isFrontEndLibsCert = false,
is2018DataVisCert = false,
isApisMicroservicesCert = false,
isInfosecQaCert = false,
isQaCertV7 = false,
isInfosecCertV7 = false,
isFrontEndCert = false,
isBackEndCert = false,
isDataVisCert = false,
isFullStackCert = false,
isSciCompPyCertV7 = false,
isDataAnalysisPyCertV7 = false,
isMachineLearningPyCertV7 = false,
isRelationalDatabaseCertV8 = false,
isCollegeAlgebraPyCertV8 = false,
isFoundationalCSharpCertV8 = false
} = user;
return {
isRespWebDesignCert,
isJsAlgoDataStructCert,
isJsAlgoDataStructCertV8,
isFrontEndLibsCert,
is2018DataVisCert,
isApisMicroservicesCert,
isInfosecQaCert,
isQaCertV7,
isInfosecCertV7,
isFrontEndCert,
isBackEndCert,
isDataVisCert,
isFullStackCert,
isSciCompPyCertV7,
isDataAnalysisPyCertV7,
isMachineLearningPyCertV7,
isRelationalDatabaseCertV8,
isCollegeAlgebraPyCertV8,
isFoundationalCSharpCertV8
};
}
function createVerifyCert(certTypeIds, app) {
const { Email } = app.models;
return function verifyCert(req, res, next) {
const {
body: { certSlug },
user
} = req;
log(certSlug);
let certType = certSlugTypeMap[certSlug];
log(certType);
return Observable.of(certTypeIds[certType])
.flatMap(challenge => {
const certName = certTypeTitleMap[certType];
if (user[certType]) {
return Observable.just({
type: 'info',
message: 'flash.already-claimed',
variables: { name: certName }
});
}
// certificate doesn't exist or
// connection error
if (!challenge) {
reportError(`Error claiming ${certName}`);
return Observable.just({
type: 'danger',
message: 'flash.wrong-name',
variables: { name: certName }
});
}
const { id, tests, challengeType } = challenge;
if (!hasCompletedTests(tests, user.completedChallenges)) {
return Observable.just({
type: 'info',
message: 'flash.incomplete-steps',
variables: { name: certName }
});
}
const updateData = {
[certType]: true,
completedChallenges: [
...user.completedChallenges,
{
id,
completedDate: new Date(),
challengeType
}
]
};
if (!user.name) {
return Observable.just({
type: 'info',
message: 'flash.name-needed'
});
}
// set here so sendCertifiedEmail works properly
// not used otherwise
user[certType] = true;
const updatePromise = new Promise((resolve, reject) =>
user.updateAttributes(updateData, err => {
if (err) {
return reject(err);
}
return resolve();
})
);
return Observable.combineLatest(
// update user data
Observable.fromPromise(updatePromise),
// sends notification email is user has all 6 certs
// if not it noop
sendCertifiedEmail(user, Email.send$),
(_, pledgeOrMessage) => ({ pledgeOrMessage })
).map(({ pledgeOrMessage }) => {
if (typeof pledgeOrMessage === 'string') {
log(pledgeOrMessage);
}
log('Certificates updated');
return {
type: 'success',
message: 'flash.cert-claim-success',
variables: {
username: user.username,
name: certName
}
};
});
})
.subscribe(message => {
return res.status(200).json({
response: message,
isCertMap: getUserIsCertMap(user),
// send back the completed challenges
// NOTE: we could just send back the latest challenge, but this
// ensures the challenges are synced.
completedChallenges: user.completedChallenges
});
}, next);
};
}
function createShowCert(app) {
const { User } = app.models;
function findUserByUsername$(username, fields) {
return observeQuery(User, 'findOne', {
where: { username },
fields
});
}
return function showCert(req, res, next) {
let { username, certSlug } = req.params;
username = username.toLowerCase();
const certType = certSlugTypeMap[certSlug];
const certId = certTypeIdMap[certType];
const certTitle = certTypeTitleMap[certType];
const completionTime = completionHours[certType] || 300;
return findUserByUsername$(username, {
isBanned: true,
isCheater: true,
isFrontEndCert: true,
isBackEndCert: true,
isFullStackCert: true,
isRespWebDesignCert: true,
isFrontEndLibsCert: true,
isJsAlgoDataStructCert: true,
isJsAlgoDataStructCertV8: true,
isDataVisCert: true,
is2018DataVisCert: true,
isApisMicroservicesCert: true,
isInfosecQaCert: true,
isQaCertV7: true,
isInfosecCertV7: true,
isSciCompPyCertV7: true,
isDataAnalysisPyCertV7: true,
isMachineLearningPyCertV7: true,
isRelationalDatabaseCertV8: true,
isCollegeAlgebraPyCertV8: true,
isFoundationalCSharpCertV8: true,
isHonest: true,
username: true,
name: true,
completedChallenges: true,
profileUI: true
}).subscribe(user => {
if (!user) {
return res.json({
messages: [
{
type: 'info',
message: 'flash.username-not-found',
variables: { username: username }
}
]
});
}
const { isLocked, showCerts, showName, showTimeLine } = user.profileUI;
if (user.isCheater || user.isBanned) {
return res.json({
messages: [
{
type: 'info',
message: 'flash.not-eligible'
}
]
});
}
if (!user.isHonest) {
return res.json({
messages: [
{
type: 'info',
message: 'flash.not-honest',
variables: { username: username }
}
]
});
}
if (isLocked) {
return res.json({
messages: [
{
type: 'info',
message: 'flash.profile-private',
variables: { username: username }
}
]
});
}
// If the user does not have a name, and have set their name to public,
// warn them. Otherwise, fallback to username
if (!user.name && user.showName) {
return res.json({
messages: [
{
type: 'info',
message: 'flash.add-name'
}
]
});
}
if (!showCerts) {
return res.json({
messages: [
{
type: 'info',
message: 'flash.certs-private',
variables: { username: username }
}
]
});
}
if (!showTimeLine) {
return res.json({
messages: [
{
type: 'info',
message: 'flash.timeline-private',
variables: { username: username }
}
]
});
}
if (user[certType]) {
const { completedChallenges = [] } = user;
const certChallenge = _.find(
completedChallenges,
({ id }) => certId === id
);
let { completedDate = new Date() } = certChallenge || {};
// the challenge id has been rotated for isDataVisCert
if (certType === 'isDataVisCert' && !certChallenge) {
let oldDataVisIdChall = _.find(
completedChallenges,
({ id }) => oldDataVizId === id
);
if (oldDataVisIdChall) {
completedDate = oldDataVisIdChall.completedDate || completedDate;
}
}
// if fullcert is not found, return the latest completedDate
if (certType === 'isFullStackCert' && !certChallenge) {
completedDate = getFallbackFullStackDate(
completedChallenges,
completedDate
);
}
const { username, name } = user;
if (!showName) {
return res.json({
certSlug,
certTitle,
username,
date: completedDate,
completionTime
});
}
return res.json({
certSlug,
certTitle,
username,
name,
date: completedDate,
completionTime
});
}
return res.json({
messages: [
{
type: 'info',
message: 'flash.user-not-certified',
variables: { username: username, cert: certTypeTitleMap[certType] }
}
]
});
}, next);
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,189 +0,0 @@
import debug from 'debug';
import Stripe from 'stripe';
import {
donationSubscriptionConfig,
allStripeProductIdsArray
} from '../../../../shared/config/donation-settings';
import keys from '../../../config/secrets';
import {
createStripeCardDonation,
handleStripeCardUpdateSession,
inLastFiveMinutes
} from '../utils/donation';
import { validStripeForm } from '../utils/stripeHelpers';
const log = debug('fcc:boot:donate');
export default function donateBoot(app, done) {
let stripe = false;
const { User } = app.models;
const api = app.loopback.Router();
const hooks = app.loopback.Router();
const donateRouter = app.loopback.Router();
function connectToStripe() {
return new Promise(function () {
// connect to stripe API
stripe = Stripe(keys.stripe.secret);
});
}
async function handleStripeCardDonation(req, res) {
return createStripeCardDonation(req, res, stripe, app).catch(err => {
if (
err.type === 'AlreadyDonatingError' ||
err.type === 'UserActionRequired' ||
err.type === 'PaymentMethodRequired'
) {
return res.status(402).send({ error: err });
}
if (err.type === 'InvalidRequest')
return res.status(400).send({ error: err });
return res.status(500).send({
error: 'Donation failed due to a server error.'
});
});
}
async function createStripeDonation(req, res) {
const { body } = req;
const { amount, duration, email, subscriptionId } = body;
try {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const isSubscriptionActive = subscription.status === 'active';
const productId = subscription.items.data[0].plan.product;
const isStartedRecently = inLastFiveMinutes(
subscription.current_period_start
);
const isProductIdValid = allStripeProductIdsArray.includes(productId);
if (isSubscriptionActive && isProductIdValid && isStartedRecently) {
const [donatingUser] = await User.findOrCreate(
{ where: { email } },
{ email }
);
const donation = {
email,
amount,
duration,
provider: 'stripe',
subscriptionId,
customerId: subscription.customer,
startDate: new Date().toISOString()
};
await donatingUser.createDonation(donation);
return res.status(200).send({ isDonating: true });
} else {
throw new Error('Donation failed due to a server error.');
}
} catch (err) {
return res
.status(500)
.send({ error: 'Donation failed due to a server error.' });
}
}
async function createStripePaymentIntent(req, res) {
const { body } = req;
const { amount, duration, email, name } = body;
if (!validStripeForm(amount, duration, email)) {
return res.status(400).send({
error: 'The donation form had invalid values for this submission.'
});
}
try {
const stripeCustomer = await stripe.customers.create({
email,
name
});
const stripeSubscription = await stripe.subscriptions.create({
customer: stripeCustomer.id,
items: [
{
plan: `${donationSubscriptionConfig.duration[duration]}-donation-${amount}`
}
],
payment_behavior: 'default_incomplete',
payment_settings: { save_default_payment_method: 'on_subscription' },
expand: ['latest_invoice.payment_intent']
});
res.status(200).send({
subscriptionId: stripeSubscription.id,
clientSecret:
stripeSubscription.latest_invoice.payment_intent.client_secret
});
} catch (err) {
return res
.status(500)
.send({ error: 'Donation failed due to a server error.' });
}
}
function addDonation(req, res) {
const { user, body } = req;
if (!user || !body) {
return res
.status(500)
.json({ error: 'User must be signed in for this request.' });
}
return Promise.resolve(req)
.then(
user.updateAttributes({
isDonating: true
})
)
.then(() => res.status(200).json({ isDonating: true }))
.catch(err => {
log(err.message);
return res.status(500).json({
type: 'danger',
message: 'Something went wrong.'
});
});
}
async function handleStripeCardUpdate(req, res, next) {
try {
const sessionIdObj = await handleStripeCardUpdateSession(
req,
app,
stripe
);
return res.status(200).json(sessionIdObj);
} catch (err) {
return next(err);
}
}
const stripeKey = keys.stripe.public;
const secKey = keys.stripe.secret;
const stripeSecretInvalid = !secKey || secKey === 'sk_from_stripe_dashboard';
const stripPublicInvalid =
!stripeKey || stripeKey === 'pk_from_stripe_dashboard';
const stripeInvalid = stripeSecretInvalid || stripPublicInvalid;
if (stripeInvalid) {
if (process.env.FREECODECAMP_NODE_ENV === 'production') {
throw new Error('Donation API keys are required to boot the server!');
}
log('Donation disabled in development unless ALL test keys are provided');
done();
} else {
api.post('/charge-stripe', createStripeDonation);
api.post('/charge-stripe-card', handleStripeCardDonation);
api.post('/create-stripe-payment-intent', createStripePaymentIntent);
api.put('/update-stripe-card', handleStripeCardUpdate);
api.post('/add-donation', addDonation);
donateRouter.use('/donate', api);
donateRouter.use('/hooks', hooks);
app.use(donateRouter);
connectToStripe(stripe).then(done);
done();
}
}

View File

@@ -1,33 +0,0 @@
const createDebugger = require('debug');
const log = createDebugger('fcc:boot:explorer');
module.exports = function mountLoopBackExplorer(app) {
if (process.env.FREECODECAMP_NODE_ENV === 'production') {
return;
}
let explorer;
try {
explorer = require('loopback-component-explorer');
} catch (err) {
// Print the message only when the app was started via `app.listen()`.
// Do not print any message when the project is used as a component.
app.once('started', function () {
log(
'Run `pnpm add loopback-component-explorer` to enable ' +
'the LoopBack explorer'
);
});
return;
}
const restApiRoot = app.get('restApiRoot');
const mountPath = '/explorer';
explorer(app, { basePath: restApiRoot, mountPath });
app.once('started', function () {
const baseUrl = app.get('url').replace(/\/$/, '');
log('Browse your REST API at %s%s', baseUrl, mountPath);
});
};

View File

@@ -1,41 +0,0 @@
import debug from 'debug';
const log = debug('fcc:boot:news');
export default function newsBoot(app) {
const router = app.loopback.Router();
router.get('/n', (req, res) => res.redirect('/news'));
router.get('/n/:shortId', createShortLinkHandler(app));
}
function createShortLinkHandler(app) {
const { Article } = app.models;
return function shortLinkHandler(req, res, next) {
const { shortId } = req.params;
if (!shortId) {
return res.redirect('/news');
}
log('shortId', shortId);
return Article.findOne(
{
where: {
or: [{ shortId }, { slugPart: shortId }]
}
},
(err, article) => {
if (err) {
next(err);
}
if (!article) {
return res.redirect('/news');
}
const { slugPart } = article;
const slug = `/news/${slugPart}`;
return res.redirect(slug);
}
);
};
}

View File

@@ -1,242 +0,0 @@
import { pick } from 'lodash';
import { getRedirectParams } from '../utils/redirection';
import { deprecatedEndpoint } from '../utils/disabled-endpoints';
import {
getProgress,
normaliseUserFields,
publicUserProps
} from '../utils/publicUserProps';
module.exports = function (app) {
const router = app.loopback.Router();
const User = app.models.User;
router.get('/api/github', deprecatedEndpoint);
router.get('/u/:email', unsubscribeDeprecated);
router.get('/unsubscribe/:email', unsubscribeDeprecated);
router.get('/ue/:unsubscribeId', unsubscribeById);
router.get('/resubscribe/:unsubscribeId', resubscribe);
router.get('/users/get-public-profile', blockUserAgent, getPublicProfile);
const getUserExists = createGetUserExists(app);
router.get('/users/exists', getUserExists);
app.use(router);
function unsubscribeDeprecated(req, res) {
req.flash(
'info',
'We are no longer able to process this unsubscription request. ' +
'Please go to your settings to update your email preferences'
);
const { origin } = getRedirectParams(req);
res.redirectWithFlash(origin);
}
function unsubscribeById(req, res, next) {
const { origin } = getRedirectParams(req);
const { unsubscribeId } = req.params;
if (!unsubscribeId) {
req.flash('info', 'We could not find an account to unsubscribe');
return res.redirectWithFlash(origin);
}
return User.find({ where: { unsubscribeId } }, (err, users) => {
if (err || !users.length) {
req.flash('info', 'We could not find an account to unsubscribe');
return res.redirectWithFlash(origin);
}
const updates = users.map(user => {
return new Promise((resolve, reject) =>
user.updateAttributes(
{
sendQuincyEmail: false
},
err => {
if (err) {
reject(err);
} else {
resolve();
}
}
)
);
});
return Promise.all(updates)
.then(() => {
req.flash(
'success',
"We've successfully updated your email preferences."
);
return res.redirectWithFlash(
`${origin}/unsubscribed/${unsubscribeId}`
);
})
.catch(next);
});
}
function resubscribe(req, res, next) {
const { unsubscribeId } = req.params;
const { origin } = getRedirectParams(req);
if (!unsubscribeId) {
req.flash(
'info',
'We were unable to process this request, please check and try again'
);
res.redirect(origin);
}
return User.find({ where: { unsubscribeId } }, (err, users) => {
if (err || !users.length) {
req.flash('info', 'We could not find an account to resubscribe');
return res.redirectWithFlash(origin);
}
const [user] = users;
return new Promise((resolve, reject) =>
user.updateAttributes(
{
sendQuincyEmail: true
},
err => {
if (err) {
reject(err);
} else {
resolve();
}
}
)
)
.then(() => {
req.flash(
'success',
"We've successfully updated your email preferences. Thank you for resubscribing."
);
return res.redirectWithFlash(origin);
})
.catch(next);
});
}
const blockedUserAgentParts = ['python', 'google-apps-script', 'curl'];
function blockUserAgent(req, res, next) {
const userAgent = req.headers['user-agent'];
if (
!userAgent ||
blockedUserAgentParts.some(ua => userAgent.toLowerCase().includes(ua))
) {
return res
.status(400)
.send(
'This endpoint is no longer available outside of the freeCodeCamp ecosystem'
);
}
return next();
}
async function getPublicProfile(req, res) {
const { username } = req.query;
if (!username) {
return res.status(400).json({ error: 'No username provided' });
}
const user = await User.findOne({ where: { username } });
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
const { completedChallenges, progressTimestamps, profileUI } = user;
const allUser = {
...pick(user, publicUserProps),
points: progressTimestamps.length,
completedChallenges,
...getProgress(progressTimestamps),
...normaliseUserFields(user),
joinDate: user.id.getTimestamp()
};
const publicUser = prepUserForPublish(allUser, profileUI);
return res.json({
entities: {
user: {
[user.username]: {
...publicUser
}
}
},
result: user.username
});
}
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,
calendar,
completedChallenges,
isDonating,
joinDate,
location,
name,
points,
portfolio,
username,
yearsTopContributor
} = user;
const {
isLocked = true,
showAbout = false,
showCerts = false,
showDonation = false,
showHeatMap = false,
showLocation = false,
showName = false,
showPoints = false,
showPortfolio = false,
showTimeLine = false
} = profileUI;
if (isLocked) {
return {
isLocked,
profileUI,
username
};
}
return {
...user,
about: showAbout ? about : '',
calendar: showHeatMap ? calendar : {},
completedChallenges: (function () {
if (showTimeLine) {
return showCerts
? completedChallenges
: completedChallenges.filter(
({ challengeType }) => challengeType !== 7
);
} else {
return [];
}
})(),
isDonating: showDonation ? isDonating : null,
joinDate: showAbout ? joinDate : '',
location: showLocation ? location : '',
name: showName ? name : '',
points: showPoints ? points : null,
portfolio: showPortfolio ? portfolio : [],
yearsTopContributor: yearsTopContributor
};
}
};

View File

@@ -1,5 +0,0 @@
module.exports = function mountRestApi(app) {
const restApi = app.loopback.rest();
const restApiRoot = app.get('restApiRoot');
app.use(restApiRoot, restApi);
};

View File

@@ -1,373 +0,0 @@
import debug from 'debug';
import { check } from 'express-validator';
import _ from 'lodash';
import isURL from 'validator/lib/isURL';
import { isValidUsername } from '../../../../shared/utils/validate';
import { alertTypes } from '../../common/utils/flash.js';
import {
deprecatedEndpoint,
temporarilyDisabledEndpoint
} from '../utils/disabled-endpoints';
import { ifNoUser401, createValidatorErrorHandler } from '../utils/middleware';
const log = debug('fcc:boot:settings');
export default function settingsController(app) {
const api = app.loopback.Router();
const updateMyUsername = createUpdateMyUsername(app);
api.put('/update-privacy-terms', ifNoUser401, updatePrivacyTerms);
api.post('/refetch-user-completed-challenges', deprecatedEndpoint);
// Re-enable once we can handle the traffic
// api.post(
// '/update-my-current-challenge',
// ifNoUser401,
// updateMyCurrentChallengeValidators,
// createValidatorErrorHandler(alertTypes.danger),
// updateMyCurrentChallenge
// );
api.post('/update-my-current-challenge', temporarilyDisabledEndpoint);
api.put('/update-my-portfolio', ifNoUser401, updateMyPortfolio);
api.put('/update-my-theme', ifNoUser401, updateMyTheme);
api.put('/update-my-about', ifNoUser401, updateMyAbout);
api.put(
'/update-my-email',
ifNoUser401,
updateMyEmailValidators,
createValidatorErrorHandler(alertTypes.danger),
updateMyEmail
);
api.put('/update-my-profileui', ifNoUser401, updateMyProfileUI);
api.put('/update-my-username', ifNoUser401, updateMyUsername);
api.put('/update-user-flag', ifNoUser401, updateUserFlag);
api.put('/update-my-socials', ifNoUser401, updateMySocials);
api.put(
'/update-my-keyboard-shortcuts',
ifNoUser401,
updateMyKeyboardShortcuts
);
api.put('/update-my-honesty', ifNoUser401, updateMyHonesty);
api.put('/update-my-quincy-email', ifNoUser401, updateMyQuincyEmail);
api.put('/update-my-classroom-mode', ifNoUser401, updateMyClassroomMode);
app.use(api);
}
const standardErrorMessage = {
type: 'danger',
message: 'flash.wrong-updating'
};
const createStandardHandler = (req, res, next, alertMessage) => err => {
if (err) {
res.status(500).json(standardErrorMessage);
return next(err);
}
return res.status(200).json({ type: 'success', message: alertMessage });
};
const createUpdateUserProperties = (buildUpdate, validate, successMessage) => {
return (req, res, next) => {
const { user, body } = req;
const update = buildUpdate(body);
if (validate(update)) {
user.updateAttributes(
update,
createStandardHandler(req, res, next, successMessage)
);
} else {
handleInvalidUpdate(res);
}
};
};
const updateMyEmailValidators = [
check('email').isEmail().withMessage('Email format is invalid.')
];
function updateMyEmail(req, res, next) {
const {
user,
body: { email }
} = req;
return user
.requestUpdateEmail(email)
.subscribe(
message => res.json({ type: message.type, message: message.message }),
next
);
}
// Re-enable once we can handle the traffic
// const updateMyCurrentChallengeValidators = [
// check('currentChallengeId')
// .isMongoId()
// .withMessage('currentChallengeId is not a valid challenge ID')
// ];
// Re-enable once we can handle the traffic
// function updateMyCurrentChallenge(req, res, next) {
// const {
// user,
// body: { currentChallengeId }
// } = req;
// return user.updateAttribute(
// 'currentChallengeId',
// currentChallengeId,
// (err, updatedUser) => {
// if (err) {
// return next(err);
// }
// const { currentChallengeId } = updatedUser;
// return res.status(200).json(currentChallengeId);
// }
// );
// }
function updateMyPortfolio(...args) {
const portfolioKeys = ['id', 'title', 'description', 'url', 'image'];
const buildUpdate = body => {
const portfolio = body?.portfolio?.map(elem => _.pick(elem, portfolioKeys));
return { portfolio };
};
const validate = ({ portfolio }) => portfolio?.every(isPortfolioElement);
const isPortfolioElement = elem =>
Object.values(elem).every(val => typeof val == 'string');
createUpdateUserProperties(
buildUpdate,
validate,
'flash.portfolio-item-updated'
)(...args);
}
// This API is responsible for what campers decide to make public in their profile, and what is private.
function updateMyProfileUI(req, res, next) {
const {
user,
body: { profileUI }
} = req;
const update = {
isLocked: !!profileUI.isLocked,
showAbout: !!profileUI.showAbout,
showCerts: !!profileUI.showCerts,
showDonation: !!profileUI.showDonation,
showHeatMap: !!profileUI.showHeatMap,
showLocation: !!profileUI.showLocation,
showName: !!profileUI.showName,
showPoints: !!profileUI.showPoints,
showPortfolio: !!profileUI.showPortfolio,
showTimeLine: !!profileUI.showTimeLine
};
user.updateAttribute(
'profileUI',
update,
createStandardHandler(req, res, next, 'flash.privacy-updated')
);
}
export function updateMyAbout(req, res, next) {
const {
user,
body: { name, location, about, picture }
} = req;
log(name, location, picture, about);
// prevent dataurls from being stored
const update = isURL(picture, { require_protocol: true })
? { name, location, about, picture }
: { name, location, about, picture: '' };
return user.updateAttributes(
update,
createStandardHandler(req, res, next, 'flash.updated-about-me')
);
}
function createUpdateMyUsername(app) {
const { User } = app.models;
return async function updateMyUsername(req, res, next) {
const { user, body } = req;
const usernameDisplay = body.username.trim();
const username = usernameDisplay.toLowerCase();
if (
username === user.username &&
user.usernameDisplay &&
usernameDisplay === user.usernameDisplay
) {
return res.json({
type: 'info',
message: 'flash.username-used'
});
}
const validation = isValidUsername(username);
if (!validation.valid) {
return res.json({
type: 'info',
message: `Username ${username} ${validation.error}`
});
}
const exists =
username === user.username ? false : await User.doesExist(username);
if (exists) {
return res.json({
type: 'info',
message: 'flash.username-taken'
});
}
return user.updateAttributes({ username, usernameDisplay }, err => {
if (err) {
res.status(500).json(standardErrorMessage);
return next(err);
}
return res.status(200).json({
type: 'success',
message: `flash.username-updated`,
variables: { username: usernameDisplay }
});
});
};
}
const updatePrivacyTerms = (req, res, next) => {
const {
user,
body: { quincyEmails }
} = req;
const update = {
acceptedPrivacyTerms: true,
sendQuincyEmail: !!quincyEmails
};
return user.updateAttributes(
update,
createStandardHandler(req, res, next, 'flash.privacy-updated')
);
};
const allowedSocialsAndDomains = {
githubProfile: 'github.com',
linkedin: 'linkedin.com',
twitter: ['twitter.com', 'x.com'],
website: ''
};
const socialVals = Object.keys(allowedSocialsAndDomains);
export function updateMySocials(...args) {
const buildUpdate = body => _.pick(body, socialVals);
const validate = update => {
// Socials should point to their respective domains
// or be empty strings
return Object.keys(update).every(key => {
const val = update[key];
if (val === '') {
return true;
}
if (key === 'website') {
return isURL(val, { require_protocol: true });
}
const domain = allowedSocialsAndDomains[key];
try {
const url = new URL(val);
const topDomain = url.hostname.split('.').slice(-2);
if (topDomain.length === 2) {
return Array.isArray(domain)
? domain.some(d => topDomain.join('.') === d)
: topDomain.join('.') === domain;
}
return false;
} catch (e) {
return false;
}
});
};
createUpdateUserProperties(
buildUpdate,
validate,
'flash.updated-socials'
)(...args);
}
function updateMyTheme(...args) {
const buildUpdate = body => _.pick(body, 'theme');
const validate = ({ theme }) => theme == 'default' || theme == 'night';
createUpdateUserProperties(
buildUpdate,
validate,
'flash.updated-themes'
)(...args);
}
function updateMyKeyboardShortcuts(...args) {
const buildUpdate = body => _.pick(body, 'keyboardShortcuts');
const validate = ({ keyboardShortcuts }) =>
typeof keyboardShortcuts === 'boolean';
createUpdateUserProperties(
buildUpdate,
validate,
'flash.keyboard-shortcut-updated'
)(...args);
}
function updateMyHonesty(...args) {
const buildUpdate = body => _.pick(body, 'isHonest');
const validate = ({ isHonest }) => isHonest === true;
createUpdateUserProperties(
buildUpdate,
validate,
'buttons.accepted-honesty'
)(...args);
}
function updateMyQuincyEmail(...args) {
const buildUpdate = body => _.pick(body, 'sendQuincyEmail');
const validate = ({ sendQuincyEmail }) =>
typeof sendQuincyEmail === 'boolean';
createUpdateUserProperties(
buildUpdate,
validate,
'flash.subscribe-to-quincy-updated'
)(...args);
}
export function updateMyClassroomMode(...args) {
const buildUpdate = body => _.pick(body, 'isClassroomAccount');
const validate = ({ isClassroomAccount }) =>
typeof isClassroomAccount === 'boolean';
createUpdateUserProperties(
buildUpdate,
validate,
'flash.classroom-mode-updated'
)(...args);
}
function handleInvalidUpdate(res) {
res.status(403).json({
type: 'danger',
message: 'flash.wrong-updating'
});
}
function updateUserFlag(req, res, next) {
const { user, body: update } = req;
const allowedKeys = ['githubProfile', 'linkedin', 'twitter', 'website'];
if (Object.keys(update).every(key => allowedKeys.includes(key))) {
return user.updateAttributes(
update,
createStandardHandler(req, res, next, 'flash.updated-socials')
);
}
return res.status(403).json({
type: 'danger',
message: 'flash.invalid-update-flag'
});
}

View File

@@ -1,6 +0,0 @@
export default function bootStatus(app) {
const api = app.loopback.Router();
api.get('/status/ping', (req, res) => res.json({ msg: 'pong' }));
app.use(api);
}

View File

@@ -1,10 +0,0 @@
module.exports = function (app) {
var router = app.loopback.Router();
router.get('/wiki/*', showForum);
app.use(router);
function showForum(req, res) {
res.redirect('http://forum.freecodecamp.org/');
}
};

View File

@@ -1,535 +0,0 @@
import debugFactory from 'debug';
import dedent from 'dedent';
import { body } from 'express-validator';
import { pick } from 'lodash';
import fetch from 'node-fetch';
import {
fixCompletedChallengeItem,
fixCompletedExamItem,
fixCompletedSurveyItem,
fixPartiallyCompletedChallengeItem,
fixSavedChallengeItem
} from '../../common/utils';
import { removeCookies } from '../utils/getSetAccessToken';
import { ifNoUser401, ifNoUserRedirectHome } from '../utils/middleware';
import {
getProgress,
normaliseUserFields,
userPropsForSession
} from '../utils/publicUserProps';
import { getRedirectParams } from '../utils/redirection';
import { trimTags } from '../utils/validators';
import {
createDeleteUserToken,
encodeUserToken
} from '../middlewares/user-token';
import { createDeleteMsUsername } from '../middlewares/ms-username';
import { validateSurvey, createDeleteUserSurveys } from '../middlewares/survey';
import { deprecatedEndpoint } from '../utils/disabled-endpoints';
const log = debugFactory('fcc:boot:user');
const sendNonUserToHome = ifNoUserRedirectHome();
function bootUser(app) {
const api = app.loopback.Router();
const getSessionUser = createReadSessionUser(app);
const postReportUserProfile = createPostReportUserProfile(app);
const postDeleteAccount = createPostDeleteAccount(app);
const postUserToken = createPostUserToken(app);
const deleteUserToken = createDeleteUserToken(app);
const postMsUsername = createPostMsUsername(app);
const deleteMsUsername = createDeleteMsUsername(app);
const postSubmitSurvey = createPostSubmitSurvey(app);
const deleteUserSurveys = createDeleteUserSurveys(app);
api.get('/account', sendNonUserToHome, deprecatedEndpoint);
api.get('/account/unlink/:social', sendNonUserToHome, getUnlinkSocial);
api.get('/user/get-session-user', getSessionUser);
api.post(
'/account/delete',
ifNoUser401,
deleteUserToken,
deleteMsUsername,
deleteUserSurveys,
postDeleteAccount
);
api.post(
'/account/reset-progress',
ifNoUser401,
deleteUserToken,
deleteMsUsername,
deleteUserSurveys,
postResetProgress
);
api.post(
'/user/report-user/',
ifNoUser401,
body('reportDescription').customSanitizer(trimTags),
postReportUserProfile
);
api.post('/user/user-token', ifNoUser401, postUserToken);
api.delete(
'/user/user-token',
ifNoUser401,
deleteUserToken,
deleteUserTokenResponse
);
api.post('/user/ms-username', ifNoUser401, postMsUsername);
api.delete(
'/user/ms-username',
ifNoUser401,
deleteMsUsername,
deleteMsUsernameResponse
);
api.post(
'/user/submit-survey',
ifNoUser401,
validateSurvey,
postSubmitSurvey
);
app.use(api);
}
function createPostUserToken(app) {
const { UserToken } = app.models;
return async function postUserToken(req, res) {
const ttl = 900 * 24 * 60 * 60 * 1000;
let encodedUserToken;
try {
await UserToken.destroyAll({ userId: req.user.id });
const newUserToken = await UserToken.create({ ttl, userId: req.user.id });
if (!newUserToken?.id) throw new Error();
encodedUserToken = encodeUserToken(newUserToken.id);
} catch (e) {
return res.status(500).send('Error starting project');
}
return res.json({ userToken: encodedUserToken });
};
}
function deleteUserTokenResponse(req, res) {
if (!req.userTokenDeleted) {
return res.status(500).send('Error deleting user token');
}
return res.send({ userToken: null });
}
export const getMsTranscriptApiUrl = msTranscript => {
// example msTranscriptUrl: https://learn.microsoft.com/en-us/users/mot01/transcript/8u6awert43q1plo
const url = new URL(msTranscript);
const transcriptUrlRegex = /\/transcript\/([^/]+)\/?/;
const id = transcriptUrlRegex.exec(url.pathname)?.[1];
return `https://learn.microsoft.com/api/profiles/transcript/share/${
id ?? ''
}`;
};
function createPostMsUsername(app) {
const { MsUsername } = app.models;
return async function postMsUsername(req, res) {
// example msTranscriptUrl: https://learn.microsoft.com/en-us/users/mot01/transcript/8u6awert43q1plo
// the last part is the transcript ID
// the username is irrelevant, and retrieved from the MS API response
const { msTranscriptUrl } = req.body;
if (!msTranscriptUrl) {
return res.status(400).json({
type: 'error',
message: 'flash.ms.transcript.link-err-1'
});
}
const msTranscriptApiUrl = getMsTranscriptApiUrl(msTranscriptUrl);
try {
const msApiRes = await fetch(msTranscriptApiUrl);
if (!msApiRes.ok) {
return res
.status(404)
.json({ type: 'error', message: 'flash.ms.transcript.link-err-2' });
}
const { userName } = await msApiRes.json();
if (!userName) {
return res
.status(500)
.json({ type: 'error', message: 'flash.ms.transcript.link-err-3' });
}
// Don't create if username is used by another fCC account
const usernameUsed = await MsUsername.findOne({
where: { msUsername: userName }
});
if (usernameUsed) {
return res
.status(403)
.json({ type: 'error', message: 'flash.ms.transcript.link-err-4' });
}
await MsUsername.destroyAll({ userId: req.user.id });
const ttl = 900 * 24 * 60 * 60 * 1000;
const newMsUsername = await MsUsername.create({
ttl,
userId: req.user.id,
msUsername: userName
});
if (!newMsUsername?.id) {
return res
.status(500)
.json({ type: 'error', message: 'flash.ms.transcript.link-err-5' });
}
return res.json({ msUsername: userName });
} catch (e) {
log(e);
return res
.status(500)
.json({ type: 'error', message: 'flash.ms.transcript.link-err-6' });
}
};
}
function deleteMsUsernameResponse(req, res) {
if (!req.msUsernameDeleted) {
return res.status(500).json({
type: 'error',
message: 'flash.ms.transcript.unlink-err'
});
}
return res.send({ msUsername: null });
}
function createPostSubmitSurvey(app) {
const { Survey } = app.models;
return async function postSubmitSurvey(req, res) {
const { user, body } = req;
const { surveyResults } = body;
const { id: userId } = user;
const { title } = surveyResults;
const completedSurveys = await Survey.find({
where: { userId }
});
const surveyAlreadyTaken = completedSurveys.some(s => s.title === title);
if (surveyAlreadyTaken) {
return res.status(400).json({
type: 'error',
message: 'flash.survey.err-2'
});
}
try {
const newSurvey = {
...surveyResults,
userId: user.id
};
const createdSurvey = await Survey.create(newSurvey);
if (!createdSurvey) {
throw new Error('Error creating survey');
}
return res.json({
type: 'success',
message: 'flash.survey.success'
});
} catch (e) {
log(e);
return res.status(500).json({
type: 'error',
message: 'flash.survey.err-3'
});
}
};
}
function createReadSessionUser(app) {
const { MsUsername, Survey, UserToken } = app.models;
return async function getSessionUser(req, res, next) {
const queryUser = req.user;
let encodedUserToken;
try {
const userId = queryUser?.id;
const userToken = userId
? await UserToken.findOne({
where: { userId }
})
: null;
encodedUserToken = userToken ? encodeUserToken(userToken.id) : undefined;
} catch (e) {
return next(e);
}
let msUsername;
try {
const userId = queryUser?.id;
const msUser = userId
? await MsUsername.findOne({
where: { userId }
})
: null;
msUsername = msUser ? msUser.msUsername : undefined;
} catch (e) {
return next(e);
}
let completedSurveys;
try {
const userId = queryUser?.id;
completedSurveys = userId
? await Survey.find({
where: { userId }
})
: [];
} catch (e) {
return next(e);
}
if (!queryUser || !queryUser.toJSON().username) {
// TODO: This should return an error status
return res.json({ user: {}, result: '' });
}
try {
const [
completedChallenges,
completedExams,
partiallyCompletedChallenges,
progressTimestamps,
savedChallenges
] = await Promise.all(
[
queryUser.getCompletedChallenges$(),
queryUser.getCompletedExams$(),
queryUser.getPartiallyCompletedChallenges$(),
queryUser.getPoints$(),
queryUser.getSavedChallenges$()
].map(obs => obs.toPromise())
);
const { calendar } = getProgress(progressTimestamps);
const user = {
...queryUser.toJSON(),
calendar,
completedChallenges: completedChallenges.map(fixCompletedChallengeItem),
completedExams: completedExams.map(fixCompletedExamItem),
partiallyCompletedChallenges: partiallyCompletedChallenges.map(
fixPartiallyCompletedChallengeItem
),
savedChallenges: savedChallenges.map(fixSavedChallengeItem)
};
const response = {
user: {
[user.username]: {
...pick(user, userPropsForSession),
username: user.usernameDisplay || user.username,
isEmailVerified: !!user.emailVerified,
...normaliseUserFields(user),
joinDate: user.id.getTimestamp(),
userToken: encodedUserToken,
msUsername,
completedSurveys: completedSurveys.map(fixCompletedSurveyItem)
}
},
result: user.username
};
return res.json(response);
} catch (e) {
// TODO: This should return an error status
return res.json({ user: {}, result: '' });
}
};
}
function getUnlinkSocial(req, res, next) {
const { user } = req;
const { username } = user;
const { origin } = getRedirectParams(req);
let social = req.params.social;
if (!social) {
req.flash('danger', 'No social account found');
return res.redirect('/' + username);
}
social = social.toLowerCase();
const validSocialAccounts = ['twitter', 'linkedin'];
if (validSocialAccounts.indexOf(social) === -1) {
req.flash('danger', 'Invalid social account');
return res.redirect('/' + username);
}
if (!user[social]) {
req.flash('danger', `No ${social} account associated`);
return res.redirect('/' + username);
}
const query = {
where: {
provider: social
}
};
return user.identities(query, function (err, identities) {
if (err) {
return next(err);
}
// assumed user identity is unique by provider
let identity = identities.shift();
if (!identity) {
req.flash('danger', 'No social account found');
return res.redirect('/' + username);
}
return identity.destroy(function (err) {
if (err) {
return next(err);
}
const updateData = { [social]: null };
return user.updateAttributes(updateData, err => {
if (err) {
return next(err);
}
log(`${social} has been unlinked successfully`);
req.flash('info', `You've successfully unlinked your ${social}.`);
return res.redirectWithFlash(`${origin}/${username}`);
});
});
});
}
function postResetProgress(req, res, next) {
const { user } = req;
return user.updateAttributes(
{
progressTimestamps: [Date.now()],
currentChallengeId: '',
isRespWebDesignCert: false,
is2018DataVisCert: false,
isFrontEndLibsCert: false,
isJsAlgoDataStructCert: false,
isApisMicroservicesCert: false,
isInfosecQaCert: false,
isQaCertV7: false,
isInfosecCertV7: false,
is2018FullStackCert: false,
isFrontEndCert: false,
isBackEndCert: false,
isDataVisCert: false,
isFullStackCert: false,
isSciCompPyCertV7: false,
isDataAnalysisPyCertV7: false,
isMachineLearningPyCertV7: false,
isRelationalDatabaseCertV8: false,
isCollegeAlgebraPyCertV8: false,
isFoundationalCSharpCertV8: false,
isJsAlgoDataStructCertV8: false,
completedChallenges: [],
completedExams: [],
savedChallenges: [],
partiallyCompletedChallenges: [],
needsModeration: false
},
function (err) {
if (err) {
return next(err);
}
return res.status(200).json({});
}
);
}
function createPostDeleteAccount(app) {
const { User } = app.models;
return async function postDeleteAccount(req, res, next) {
return User.destroyById(req.user.id, function (err) {
if (err) {
return next(err);
}
req.logout();
removeCookies(req, res);
return res.status(200).json({});
});
};
}
function createPostReportUserProfile(app) {
const { Email } = app.models;
return function postReportUserProfile(req, res, next) {
const { user } = req;
const { username, reportDescription: report } = req.body;
const { origin } = getRedirectParams(req);
log(username);
log(report);
if (!username || !report || report === '') {
return res.json({
type: 'danger',
message: 'flash.provide-username'
});
}
return Email.send$(
{
type: 'email',
to: 'support@freecodecamp.org',
cc: user.email,
from: 'team@freecodecamp.org',
subject: `Abuse Report : Reporting ${username}'s profile.`,
text: dedent(`
Hello Team,\n
This is to report the profile of ${username}.\n
Report Details:\n
${report}\n\n
Reported by:
Username: ${user.username}
Name: ${user.name}
Email: ${user.email}\n
Thanks and regards,
${user.name}
`)
},
err => {
if (err) {
err.redirectTo = `${origin}/${username}`;
return next(err);
}
return res.json({
type: 'info',
message: 'flash.report-sent',
variables: { email: user.email }
});
}
);
};
}
export default bootUser;

View File

@@ -1,19 +0,0 @@
import accepts from 'accepts';
import { getRedirectParams } from '../utils/redirection';
export default function fourOhFour(app) {
app.all('*', function (req, res) {
const accept = accepts(req);
// prioritise returning json
const type = accept.type('json', 'html', 'text');
const { path } = req;
const { origin } = getRedirectParams(req);
if (type === 'json') {
return res.status('404').json({ error: 'path not found' });
} else {
req.flash('danger', `We couldn't find path ${path}`);
return res.redirectWithFlash(`${origin}/404`);
}
});
}

View File

@@ -1,3 +0,0 @@
## Test scripts for the boot directory
These files cannot be co-located with the files under test due to the auto-discovery the loopback-boot employs.

View File

@@ -1,94 +0,0 @@
import {
getFallbackFullStackDate,
ifNoCertification404
} from '../boot/certificate';
import { fullStackChallenges } from './fixtures';
export const mockReq = opts => {
const req = {};
return { ...req, ...opts };
};
export const mockRes = opts => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.end = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
res.redirect = jest.fn().mockReturnValue(res);
res.set = jest.fn().mockReturnValue(res);
res.clearCookie = jest.fn().mockReturnValue(res);
res.cookie = jest.fn().mockReturnValue(res);
return { ...res, ...opts };
};
describe('boot/certificate', () => {
describe('getFallbackFullStackDate', () => {
it('should return the date of the latest completed challenge', () => {
expect(getFallbackFullStackDate(fullStackChallenges)).toBe(1685210952511);
});
});
describe('ifNoCertification404', () => {
it('declares a 404 when there is no certSlug in the body', () => {
const req = mockReq({
body: {}
});
const res = mockRes();
const next = jest.fn();
ifNoCertification404(req, res, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(next).not.toHaveBeenCalled();
});
it('declares a 404 for an invalid certSlug in the body', () => {
const req = mockReq({
body: { certSlug: 'not-a-real-certSlug' }
});
const res = mockRes();
const next = jest.fn();
ifNoCertification404(req, res, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(next).not.toHaveBeenCalled();
});
it('calls next for a valid certSlug of a current certification', () => {
const req = mockReq({
body: { certSlug: 'responsive-web-design' }
});
const res = mockRes();
const next = jest.fn();
ifNoCertification404(req, res, next);
expect(next).toHaveBeenCalled();
});
it('calls next for a valid certSlug of a legacy certification', () => {
const req = mockReq({
body: { certSlug: 'legacy-front-end' }
});
const res = mockRes();
const next = jest.fn();
ifNoCertification404(req, res, next);
expect(next).toHaveBeenCalled();
});
it('calls next for a valid certSlug of the legacy full stack certification', () => {
const req = mockReq({
body: { certSlug: 'full-stack' }
});
const res = mockRes();
const next = jest.fn();
ifNoCertification404(req, res, next);
expect(next).toHaveBeenCalled();
});
});
});

View File

@@ -1,447 +0,0 @@
import { find } from 'lodash';
import {
buildUserUpdate,
buildExamUserUpdate,
buildChallengeUrl,
createChallengeUrlResolver,
createRedirectToCurrentChallenge,
getFirstChallenge,
isValidChallengeCompletion
} from '../boot/challenge';
import {
firstChallengeUrl,
requestedChallengeUrl,
mockAllChallenges,
mockChallenge,
mockUser,
mockUser2,
mockGetFirstChallenge,
mockCompletedChallenge,
mockCompletedChallengeNoFiles,
mockCompletedChallenges,
mockFailingExamChallenge,
mockPassingExamChallenge,
mockBetterPassingExamChallenge,
mockWorsePassingExamChallenge
} from './fixtures';
export const mockReq = opts => {
const req = {};
return { ...req, ...opts };
};
export const mockRes = opts => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
res.redirect = jest.fn().mockReturnValue(res);
res.set = jest.fn().mockReturnValue(res);
res.clearCookie = jest.fn().mockReturnValue(res);
res.cookie = jest.fn().mockReturnValue(res);
return { ...res, ...opts };
};
describe('boot/challenge', () => {
xdescribe('backendChallengeCompleted', () => {});
describe('buildUserUpdate', () => {
it('returns an Object with a nested "completedChallenges" property', () => {
const result = buildUserUpdate(
mockUser,
'123abc',
mockCompletedChallenge,
'UTC'
);
expect(result).toHaveProperty('updateData.$push.completedChallenges');
});
it('preserves file contents if the completed challenge is a JS Project', () => {
const jsChallengeId = 'aa2e6f85cab2ab736c9a9b24';
const completedChallenge = {
...mockCompletedChallenge,
completedDate: Date.now(),
id: jsChallengeId
};
const result = buildUserUpdate(
mockUser,
jsChallengeId,
completedChallenge,
'UTC'
);
const newCompletedChallenge = result.updateData.$push.completedChallenges;
expect(newCompletedChallenge).toEqual(completedChallenge);
});
it('preserves the original completed date of a challenge', () => {
const completedChallengeId = 'aaa48de84e1ecc7c742e1124';
const completedChallenge = {
...mockCompletedChallenge,
completedDate: Date.now(),
id: completedChallengeId
};
const originalCompletion = find(
mockCompletedChallenges,
x => x.id === completedChallengeId
).completedDate;
const result = buildUserUpdate(
mockUser,
completedChallengeId,
completedChallenge,
'UTC'
);
const updatedCompletedChallenge =
result.updateData.$set['completedChallenges.2'];
expect(updatedCompletedChallenge.completedDate).toEqual(
originalCompletion
);
});
it('does not attempt to update progressTimestamps for a previously completed challenge', () => {
const completedChallengeId = 'aaa48de84e1ecc7c742e1124';
const completedChallenge = {
...mockCompletedChallenge,
completedDate: Date.now(),
id: completedChallengeId
};
const { updateData } = buildUserUpdate(
mockUser,
completedChallengeId,
completedChallenge,
'UTC'
);
const hasProgressTimestamps =
'$push' in updateData && 'progressTimestamps' in updateData.$push;
expect(hasProgressTimestamps).toBe(false);
});
it('provides a progressTimestamps update for new challenge completion', () => {
expect.assertions(2);
const { updateData } = buildUserUpdate(
mockUser,
'123abc',
mockCompletedChallenge,
'UTC'
);
expect(updateData).toHaveProperty('$push');
expect(updateData.$push).toHaveProperty('progressTimestamps');
});
it('will $push newly completed challenges to the completedChallenges array', () => {
const {
updateData: {
$push: { completedChallenges }
}
} = buildUserUpdate(
mockUser,
'123abc',
mockCompletedChallengeNoFiles,
'UTC'
);
expect(completedChallenges).toEqual(mockCompletedChallengeNoFiles);
});
});
describe('buildExamUserUpdate', () => {
it('should $push exam results to completedExams[]', () => {
const {
updateData: {
$push: { completedExams }
}
} = buildExamUserUpdate(mockUser, mockFailingExamChallenge);
expect(completedExams).toEqual(mockFailingExamChallenge);
});
it('should not add failing exams to completedChallenges[]', () => {
const { alreadyCompleted, addPoint, updateData } = buildExamUserUpdate(
mockUser,
mockFailingExamChallenge
);
expect(updateData).not.toHaveProperty('$push.completedChallenges');
expect(updateData).not.toHaveProperty('$set.completedChallenges');
expect(addPoint).toBe(false);
expect(alreadyCompleted).toBe(false);
});
it('should $push newly passed exams to completedChallenge[]', () => {
const {
alreadyCompleted,
addPoint,
updateData: {
$push: { completedChallenges }
}
} = buildExamUserUpdate(mockUser, mockPassingExamChallenge);
expect(completedChallenges).toEqual(mockPassingExamChallenge);
expect(addPoint).toBe(true);
expect(alreadyCompleted).toBe(false);
});
it('should not update passed exams with worse results in completedChallenge[]', () => {
const { alreadyCompleted, addPoint, updateData } = buildExamUserUpdate(
mockUser2,
mockWorsePassingExamChallenge
);
expect(updateData).not.toHaveProperty('$push.completedChallenges');
expect(updateData).not.toHaveProperty('$set.completedChallenges');
expect(addPoint).toBe(false);
expect(alreadyCompleted).toBe(true);
});
it('should update passed exams with better results in completedChallenge[]', () => {
const {
alreadyCompleted,
addPoint,
completedDate,
updateData: { $set }
} = buildExamUserUpdate(mockUser2, mockBetterPassingExamChallenge);
expect($set['completedChallenges.4'].examResults).toEqual(
mockBetterPassingExamChallenge.examResults
);
expect(addPoint).toBe(false);
expect(alreadyCompleted).toBe(true);
expect(completedDate).toBe(1538052380328);
});
});
describe('buildChallengeUrl', () => {
it('resolves the correct Url for the provided challenge', () => {
const result = buildChallengeUrl(mockChallenge);
expect(result).toEqual(requestedChallengeUrl);
});
});
describe('challengeUrlResolver', () => {
it('resolves to the first challenge url by default', async () => {
const challengeUrlResolver = await createChallengeUrlResolver(
mockAllChallenges,
{
_getFirstChallenge: mockGetFirstChallenge
}
);
return challengeUrlResolver().then(url => {
expect(url).toEqual(firstChallengeUrl);
});
}, 10000);
it('returns the first challenge url if the provided id does not relate to a challenge', async () => {
const challengeUrlResolver = await createChallengeUrlResolver(
mockAllChallenges,
{
_getFirstChallenge: mockGetFirstChallenge
}
);
return challengeUrlResolver('not-a-real-challenge').then(url => {
expect(url).toEqual(firstChallengeUrl);
});
});
it('resolves the correct url for the requested challenge', async () => {
const challengeUrlResolver = await createChallengeUrlResolver(
mockAllChallenges,
{
_getFirstChallenge: mockGetFirstChallenge
}
);
return challengeUrlResolver('123abc').then(url => {
expect(url).toEqual(requestedChallengeUrl);
});
});
});
describe('getFirstChallenge', () => {
it('returns the correct challenge url from the model', async () => {
const result = await getFirstChallenge(mockAllChallenges);
expect(result).toEqual(firstChallengeUrl);
});
it('returns the learn base if no challenges found', async () => {
const result = await getFirstChallenge([]);
expect(result).toEqual('/learn');
});
});
describe('isValidChallengeCompletion', () => {
const validObjectId = '5c716d1801013c3ce3aa23e6';
it('declares a 403 for an invalid id in the body', () => {
expect.assertions(2);
const req = mockReq({
body: { id: 'not-a-real-id' }
});
const res = mockRes();
const next = jest.fn();
isValidChallengeCompletion(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('declares a 403 for an invalid challengeType in the body', () => {
expect.assertions(2);
const req = mockReq({
body: { id: validObjectId, challengeType: 'ponyfoo' }
});
const res = mockRes();
const next = jest.fn();
isValidChallengeCompletion(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('declares a 403 for an invalid solution in the body', () => {
expect.assertions(2);
const req = mockReq({
body: {
id: validObjectId,
challengeType: '1',
solution: 'https://not-a-url'
}
});
const res = mockRes();
const next = jest.fn();
isValidChallengeCompletion(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('calls next if the body is valid', () => {
const req = mockReq({
body: {
id: validObjectId,
challengeType: '1',
solution: 'https://www.freecodecamp.org'
}
});
const res = mockRes();
const next = jest.fn();
isValidChallengeCompletion(req, res, next);
expect(next).toHaveBeenCalled();
});
it('calls next if only the id is provided', () => {
const req = mockReq({
body: {
id: validObjectId
}
});
const res = mockRes();
const next = jest.fn();
isValidChallengeCompletion(req, res, next);
expect(next).toHaveBeenCalled();
});
it('can handle an "int" challengeType', () => {
const req = mockReq({
body: {
id: validObjectId,
challengeType: 1
}
});
const res = mockRes();
const next = jest.fn();
isValidChallengeCompletion(req, res, next);
expect(next).toHaveBeenCalled();
});
});
xdescribe('modernChallengeCompleted', () => {});
xdescribe('projectCompleted', () => {});
describe('redirectToCurrentChallenge', () => {
const mockHomeLocation = 'https://www.example.com';
const mockLearnUrl = `${mockHomeLocation}/learn`;
const mockgetParamsFromReq = () => ({
returnTo: mockLearnUrl,
origin: mockHomeLocation,
pathPrefix: ''
});
const mockNormalizeParams = params => params;
it('redirects to the learn base url for non-users', async () => {
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
() => {},
mockNormalizeParams,
mockgetParamsFromReq
);
const req = mockReq();
const res = mockRes();
const next = jest.fn();
await redirectToCurrentChallenge(req, res, next);
expect(res.redirect).toHaveBeenCalledWith(mockLearnUrl);
});
it('redirects to the url provided by the challengeUrlResolver', async () => {
const challengeUrlResolver = await createChallengeUrlResolver(
mockAllChallenges,
{
_getFirstChallenge: mockGetFirstChallenge
}
);
const expectedUrl = `${mockHomeLocation}${requestedChallengeUrl}`;
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
challengeUrlResolver,
mockNormalizeParams,
mockgetParamsFromReq
);
const req = mockReq({
user: mockUser
});
const res = mockRes();
const next = jest.fn();
await redirectToCurrentChallenge(req, res, next);
expect(res.redirect).toHaveBeenCalledWith(expectedUrl);
});
it('redirects to the first challenge for users without a currentChallengeId', async () => {
const challengeUrlResolver = await createChallengeUrlResolver(
mockAllChallenges,
{
_getFirstChallenge: mockGetFirstChallenge
}
);
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
challengeUrlResolver,
mockNormalizeParams,
mockgetParamsFromReq
);
const req = mockReq({
user: { ...mockUser, currentChallengeId: '' }
});
const res = mockRes();
const next = jest.fn();
await redirectToCurrentChallenge(req, res, next);
const expectedUrl = `${mockHomeLocation}${firstChallengeUrl}`;
expect(res.redirect).toHaveBeenCalledWith(expectedUrl);
});
});
});

View File

@@ -1,266 +0,0 @@
import { isEqual } from 'lodash';
import { isEmail } from 'validator';
export const firstChallengeUrl = '/learn/the/first/challenge';
export const requestedChallengeUrl = '/learn/my/actual/challenge';
export const mockChallenge = {
id: '123abc',
block: 'actual',
superBlock: 'my',
dashedName: 'challenge'
};
export const mockFirstChallenge = {
id: '456def',
block: 'first',
superBlock: 'the',
dashedName: 'challenge',
challengeOrder: 0,
superOrder: 0,
order: 0
};
export const mockCompletedChallenge = {
id: '890xyz',
challengeType: 0,
files: [
{
contents: 'file contents',
key: 'indexfile',
name: 'index',
path: 'index.file',
ext: 'file'
}
],
completedDate: Date.now()
};
export const mockCompletedChallengeNoFiles = {
id: '123abc456def',
challengeType: 0,
completedDate: Date.now()
};
export const mockFailingExamChallenge = {
id: '647e22d18acb466c97ccbef8',
challengeType: 17,
completedDate: Date.now(),
examResults: {
"numberOfCorrectAnswers" : 5,
"numberOfQuestionsInExam" : 10,
"percentCorrect" : 50,
"passingPercent" : 70,
"passed" : false,
"examTimeInSeconds" : 1200
}
}
export const mockPassingExamChallenge = {
id: '647e22d18acb466c97ccbef8',
challengeType: 17,
completedDate: 1538052380328,
examResults: {
"numberOfCorrectAnswers" : 9,
"numberOfQuestionsInExam" : 10,
"percentCorrect" : 90,
"passingPercent" : 70,
"passed" : true,
"examTimeInSeconds" : 1200
}
}
export const mockBetterPassingExamChallenge = {
id: '647e22d18acb466c97ccbef8',
challengeType: 17,
completedDate: Date.now(),
examResults: {
"numberOfCorrectAnswers" : 10,
"numberOfQuestionsInExam" : 10,
"percentCorrect" : 100,
"passingPercent" : 70,
"passed" : true,
"examTimeInSeconds" : 1200
}
}
export const mockWorsePassingExamChallenge = {
id: '647e22d18acb466c97ccbef8',
challengeType: 17,
completedDate: Date.now(),
examResults: {
"numberOfCorrectAnswers" : 8,
"numberOfQuestionsInExam" : 10,
"percentCorrect" : 80,
"passingPercent" : 70,
"passed" : true,
"examTimeInSeconds" : 1200
}
}
export const mockCompletedChallenges = [
{
id: 'bd7123c8c441eddfaeb5bdef',
completedDate: 1538052380328.0
},
{
id: '587d7dbd367417b2b2512bb4',
completedDate: 1547472893032.0,
files: []
},
{
id: 'aaa48de84e1ecc7c742e1124',
completedDate: 1541678430790.0,
files: [
{
contents: "function palindrome(str) {\n const clean = str.replace(/[\\W_]/g, '').toLowerCase()\n const revStr = clean.split('').reverse().join('');\n return clean === revStr;\n}\n\n\n\npalindrome(\"eye\");\n",
ext: 'js',
path: 'index.js',
name: 'index',
key: 'indexjs'
}
]
},
{
id: '5a24c314108439a4d4036164',
completedDate: 1543845124143.0,
files: []
}
];
export const mockUserID = '5c7d892aff9777c8b1c1a95e';
export const createUserMockFn = jest.fn();
export const createDonationMockFn = jest.fn();
export const updateDonationAttr = jest.fn();
export const updateUserAttr = jest.fn();
export const mockUser = {
id: mockUserID,
username: 'camperbot',
currentChallengeId: '123abc',
email: 'donor@freecodecamp.com',
timezone: 'UTC',
completedChallenges: mockCompletedChallenges,
progressTimestamps: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
isDonating: true,
donationEmails: ['donor@freecodecamp.com', 'donor@freecodecamp.com'],
createDonation: donation => {
createDonationMockFn(donation);
return mockObservable;
},
updateAttributes: updateUserAttr
};
export const mockUser2 = JSON.parse(JSON.stringify(mockUser));
mockUser2.completedChallenges.push(mockPassingExamChallenge);
const mockObservable = {
toPromise: () => Promise.resolve('result')
};
export const mockDonation = {
id: '5e5f8eda5ed7be2b54e18718',
email: 'donor@freecodecamp.com',
provider: 'paypal',
amount: 500,
duration: 'month',
startDate: {
_when: '2018-11-01T00:00:00.000Z',
_date: '2018-11-01T00:00:00.000Z'
},
subscriptionId: 'I-BA1ATBNF8T3P',
userId: mockUserID,
updateAttributes: updateDonationAttr
};
export function createNewUserFromEmail(email) {
const newMockUser = mockUser;
newMockUser.email = email;
newMockUser.username = 'camberbot2';
newMockUser.ID = '5c7d892aff9888c8b1c1a95e';
return newMockUser;
}
export const mockApp = {
models: {
Donation: {
findOne(query, cb) {
return isEqual(query, matchSubscriptionIdQuery)
? cb(null, mockDonation)
: cb(Error('No Donation'));
}
},
User: {
findById(id, cb) {
if (id === mockUser.id) {
return cb(null, mockUser);
}
return cb(Error('No user'));
},
findOne(query, cb) {
if (isEqual(query, matchEmailQuery) || isEqual(query, matchUserIdQuery))
return cb(null, mockUser);
return cb(null, null);
},
create(query, cb) {
if (!isEmail(query.email)) return cb(new Error('email not valid'));
else if (query.email === mockUser.email)
return cb(new Error('user exist'));
createUserMockFn();
return Promise.resolve(createNewUserFromEmail(query.email));
}
}
}
};
export const mockAllChallenges = [mockFirstChallenge, mockChallenge];
export const mockGetFirstChallenge = () => firstChallengeUrl;
export const matchEmailQuery = {
where: { email: mockUser.email }
};
export const matchSubscriptionIdQuery = {
where: { subscriptionId: mockDonation.subscriptionId }
};
export const matchUserIdQuery = {
where: { id: mockUser.id }
};
export const fullStackChallenges = [
{
completedDate: 1585210952511,
id: '5a553ca864b52e1d8bceea14',
challengeType: 7,
files: []
},
{
completedDate: 1585210952511,
id: '561add10cb82ac38a17513bc',
challengeType: 7,
files: []
},
{
completedDate: 1588665778679,
id: '561acd10cb82ac38a17513bc',
challengeType: 7,
files: []
},
{
completedDate: 1685210952511,
id: '561abd10cb81ac38a17513bc',
challengeType: 7,
files: []
},
{
completedDate: 1585210952511,
id: '561add10cb82ac38a17523bc',
challengeType: 7,
files: []
},
{
completedDate: 1588665778679,
id: '561add10cb82ac38a17213bc',
challengeType: 7,
files: []
}
];

View File

@@ -1,175 +0,0 @@
import {
updateMyAbout,
updateMySocials,
updateMyClassroomMode
} from '../boot/settings';
export const mockReq = opts => {
const req = {};
return { ...req, ...opts };
};
export const mockRes = opts => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
res.redirect = jest.fn().mockReturnValue(res);
res.set = jest.fn().mockReturnValue(res);
res.clearCookie = jest.fn().mockReturnValue(res);
res.cookie = jest.fn().mockReturnValue(res);
return { ...res, ...opts };
};
describe('boot/settings', () => {
describe('updateMyAbout', () => {
it('allows empty string in any field', () => {
let updateData;
const req = mockReq({
user: {
updateAttributes: (update, cb) => {
updateData = update;
cb();
}
},
body: {
name: '',
location: '',
about: '',
picture: ''
}
});
const res = mockRes();
const next = jest.fn();
updateMyAbout(req, res, next);
expect(res.status).toHaveBeenCalledWith(200);
expect(updateData).toStrictEqual({
name: '',
location: '',
about: '',
picture: ''
});
});
});
describe('updateMySocials', () => {
it('does not allow non-github domain in GitHub social', () => {
const req = mockReq({
user: {},
body: {
githubProfile: 'https://www.almost-github.com'
}
});
const res = mockRes();
const next = jest.fn();
updateMySocials(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
});
it('does not allow non-linkedin domain in LinkedIn social', () => {
const req = mockReq({
user: {},
body: {
linkedin: 'https://www.freecodecamp.org'
}
});
const res = mockRes();
const next = jest.fn();
updateMySocials(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
});
it('does not allow non-twitter domain in Twitter social', () => {
const req = mockReq({
user: {},
body: {
twitter: 'https://www.freecodecamp.org'
}
});
const res = mockRes();
const next = jest.fn();
updateMySocials(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
});
it('allows empty string in any social', () => {
const req = mockReq({
user: {
updateAttributes: (_, cb) => cb()
},
body: {
twitter: '',
linkedin: '',
githubProfile: '',
website: ''
}
});
const res = mockRes();
const next = jest.fn();
updateMySocials(req, res, next);
expect(res.status).toHaveBeenCalledWith(200);
});
it('allows any valid link in website social', () => {
const req = mockReq({
user: {
updateAttributes: (_, cb) => cb()
},
body: {
website: 'https://www.freecodecamp.org'
}
});
const res = mockRes();
const next = jest.fn();
updateMySocials(req, res, next);
expect(res.status).toHaveBeenCalledWith(200);
});
it('allows valid links with sub-domains to pass', () => {
const req = mockReq({
user: {
updateAttributes: (_, cb) => cb()
},
body: {
githubProfile: 'https://www.gist.github.com',
linkedin: 'https://www.linkedin.com/freecodecamp',
twitter: 'https://www.twitter.com/freecodecamp',
website: 'https://www.example.freecodecamp.org'
}
});
const res = mockRes();
const next = jest.fn();
updateMySocials(req, res, next);
expect(res.status).toHaveBeenCalledWith(200);
});
});
describe('updateMyClassroomMode', () => {
it('does not allow invalid classroomMode', () => {
const req = mockReq({
user: {},
body: {
isClassroomAccount: 'invalid'
}
});
const res = mockRes();
const next = jest.fn();
updateMyClassroomMode(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
});
it('allows valid classroomMode', () => {
const req = mockReq({
user: {
updateAttributes: (_, cb) => cb()
},
body: {
isClassroomAccount: true
}
});
const res = mockRes();
const next = jest.fn();
updateMyClassroomMode(req, res, next);
expect(res.status).toHaveBeenCalledWith(200);
});
});
});

View File

@@ -1,180 +0,0 @@
import { PassportConfigurator } from '@freecodecamp/loopback-component-passport';
import dedent from 'dedent';
import passport from 'passport';
import { availableLangs } from '../../../shared/config/i18n';
import { jwtSecret } from '../../config/secrets';
import passportProviders from './passport-providers';
import { setAccessTokenToResponse } from './utils/getSetAccessToken';
import {
getReturnTo,
getPrefixedLandingPath,
getRedirectParams,
haveSamePath
} from './utils/redirection';
import { getUserById } from './utils/user-stats';
const passportOptions = {
emailOptional: true,
profileToUser: null
};
PassportConfigurator.prototype.init = function passportInit(noSession) {
this.app.middleware('session:after', passport.initialize());
if (noSession) {
return;
}
this.app.middleware('session:after', passport.session());
// Serialization and deserialization is only required if passport session is
// enabled
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser(async (id, done) => {
const user = await getUserById(id).catch(done);
return done(null, user);
});
};
export function setupPassport(app) {
const configurator = new PassportConfigurator(app);
configurator.setupModels({
userModel: app.models.user,
userIdentityModel: app.models.userIdentity,
userCredentialModel: app.models.userCredential
});
configurator.init();
Object.keys(passportProviders).map(function (strategy) {
let config = passportProviders[strategy];
config.session = config.session !== false;
config.customCallback = !config.useCustomCallback
? null
: createPassportCallbackAuthenticator(strategy, config);
configurator.configureProvider(strategy, {
...config,
...passportOptions
});
});
}
export const devSaveResponseAuthCookies = () => {
return (req, res, next) => {
const user = req.user;
if (!user) {
return res.redirect('/signin');
}
const { accessToken } = user;
setAccessTokenToResponse({ accessToken }, req, res);
return next();
};
};
export const devLoginRedirect = () => {
return (req, res) => {
// this mirrors the production approach, but only validates the prefix
let { returnTo, origin, pathPrefix } = getRedirectParams(
req,
({ returnTo, origin, pathPrefix }) => {
pathPrefix = availableLangs.client.includes(pathPrefix)
? pathPrefix
: '';
return {
returnTo,
origin,
pathPrefix
};
}
);
// if returnTo has a trailing slash, we need to remove it before comparing
// it to the prefixed landing path
if (returnTo.slice(-1) === '/') {
returnTo = returnTo.slice(0, -1);
}
const redirectBase = getPrefixedLandingPath(origin, pathPrefix);
returnTo += haveSamePath(redirectBase, returnTo) ? '/learn' : '';
return res.redirect(returnTo);
};
};
export const createPassportCallbackAuthenticator =
(strategy, config) => (req, res, next) => {
return passport.authenticate(
strategy,
{ session: false },
(err, user, userInfo) => {
if (err) {
return next(err);
}
const state = req && req.query && req.query.state;
// returnTo, origin and pathPrefix are audited by getReturnTo
let { returnTo, origin, pathPrefix } = getReturnTo(state, jwtSecret);
const redirectBase = getPrefixedLandingPath(origin, pathPrefix);
const { error, error_description } = req.query;
if (error === 'access_denied') {
const blockedByLaw =
error_description === 'Access denied from your location';
// Do not show any error message, instead redirect to the blocked page, with details.
if (blockedByLaw) {
return res.redirectWithFlash(`${redirectBase}/blocked`);
}
req.flash('info', dedent`${error_description}.`);
return res.redirectWithFlash(`${redirectBase}/learn`);
}
if (!user || !userInfo) {
return res.redirect('/signin');
}
const { accessToken } = userInfo;
const { provider } = config;
if (accessToken && accessToken.id) {
if (provider === 'auth0') {
req.flash('success', 'flash.signin-success');
} else if (user.email) {
req.flash(
'info',
dedent`
We are moving away from social authentication for privacy reasons. Next time
we recommend using your email address: ${user.email} to sign in instead.
`
);
}
setAccessTokenToResponse({ accessToken }, req, res);
req.login(user);
}
// TODO: getReturnTo could return a success flag to show a flash message,
// but currently it immediately gets overwritten by a second message. We
// should either change the message if the flag is present or allow
// multiple messages to appear at once.
if (user.acceptedPrivacyTerms) {
// if returnTo has a trailing slash, we need to remove it before comparing
// it to the prefixed landing path
if (returnTo.slice(-1) === '/') {
returnTo = returnTo.slice(0, -1);
}
returnTo += haveSamePath(redirectBase, returnTo) ? '/learn' : '';
return res.redirectWithFlash(returnTo);
} else {
return res.redirectWithFlash(`${redirectBase}/email-sign-up`);
}
}
)(req, res, next);
};

View File

@@ -1,9 +0,0 @@
module.exports = {
host: '127.0.0.1',
sessionSecret: process.env.SESSION_SECRET,
github: {
clientID: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET
}
};

View File

@@ -1,22 +0,0 @@
{
"restApiRoot": "/api",
"host": "127.0.0.1",
"port": 3000,
"legacyExplorer": false,
"remoting": {
"rest": {
"handleErrors": false,
"normalizeHttpPath": false,
"xml": false
},
"json": {
"strict": false,
"limit": "100kb"
},
"urlencoded": {
"extended": true,
"limit": "100kb"
},
"cors": false
}
}

View File

@@ -1,11 +0,0 @@
var globalConfig = require('../common/config.global');
module.exports = {
restApiRoot: globalConfig.restApi,
sessionSecret: process.env.SESSION_SECRET,
github: {
clientID: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET
}
};

View File

@@ -1,3 +0,0 @@
module.exports = {
host: process.env.HOST || 'localhost'
};

View File

@@ -1,30 +0,0 @@
const debug = require('debug')('fcc:server:datasources');
const dsLocal = require('./datasources.production.js');
const ds = {
...dsLocal
};
// use [MailHog](https://github.com/mailhog/MailHog) if no SES keys are found
if (!process.env.SES_ID) {
ds.mail = {
connector: 'mail',
transport: {
type: 'smtp',
host: process.env.MAILHOG_HOST || 'localhost',
secure: false,
port: 1025,
tls: {
rejectUnauthorized: false
}
},
auth: {
user: 'test',
pass: 'test'
}
};
debug(`using MailHog server on port ${ds.mail.transport.port}`);
} else {
debug('using AWS SES to deliver emails');
}
module.exports = ds;

View File

@@ -1,11 +0,0 @@
{
"db": {
"name": "db",
"connector": "mongodb",
"allowExtendedOperators": true
},
"mail": {
"name": "mail",
"connector": "mail"
}
}

View File

@@ -1,21 +0,0 @@
var secrets = require('../../config/secrets');
module.exports = {
db: {
connector: 'mongodb',
protocol: 'mongodb+srv',
connectionTimeout: 10000,
url: secrets.db,
useNewUrlParser: true,
useUnifiedTopology: true,
allowExtendedOperators: true
},
mail: {
connector: 'mail',
transport: {
type: 'ses',
accessKeyId: process.env.SES_ID,
secretAccessKey: process.env.SES_SECRET
}
}
};

View File

@@ -1,116 +0,0 @@
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '../../../.env') });
const Sentry = require('@sentry/node');
// const Tracing = require('@sentry/tracing');
const createDebugger = require('debug');
const _ = require('lodash');
const loopback = require('loopback');
const boot = require('loopback-boot');
const morgan = require('morgan');
const { sentry } = require('../../config/secrets');
const { setupPassport } = require('./component-passport');
const { getRedirectParams } = require('./utils/redirection.js');
const log = createDebugger('fcc:server');
const reqLogFormat = ':date[iso] :status :method :response-time ms - :url';
const app = loopback();
app.set('state namespace', '__fcc__');
app.set('port', process.env.API_PORT || 3000);
app.set('views', path.join(__dirname, 'views'));
app.use(loopback.token());
app.use(
morgan(reqLogFormat, { stream: { write: msg => log(_.split(msg, '\n')[0]) } })
);
app.disable('x-powered-by');
const createLogOnce = () => {
let called = false;
return str => {
if (called) {
return null;
}
called = true;
return log(str);
};
};
const logOnce = createLogOnce();
boot(app, __dirname, err => {
if (err) {
// rethrowing the error here because any error thrown in the boot stage
// is silent
logOnce('The below error was thrown in the boot stage');
throw err;
}
});
setupPassport(app);
const { db } = app.datasources;
db.on(
'connected',
_.once(() => log('db connected'))
);
app.start = _.once(function () {
const server = app.listen(app.get('port'), function () {
app.emit('started');
log(
'freeCodeCamp server listening on port %d in %s',
app.get('port'),
app.get('env')
);
log(`connecting to db at ${db.settings.url}`);
});
process.on('SIGINT', () => {
log('Shutting down server');
server.close(() => {
log('Server is closed');
});
log('closing db connection');
db.disconnect().then(() => {
log('DB connection closed');
// exit process
// this may close kept alive sockets
process.exit(0);
});
});
});
if (process.env.FREECODECAMP_NODE_ENV === 'development') {
app.get('/', (req, res) => {
log('Mounting dev root redirect...');
const { origin } = getRedirectParams(req);
res.redirect(origin);
});
}
if (sentry.dsn === 'dsn_from_sentry_dashboard') {
log('Sentry reporting disabled unless DSN is provided.');
} else {
Sentry.init({
dsn: sentry.dsn
// integrations: [
// new Sentry.Integrations.Http({ tracing: true }),
// new Tracing.Integrations.Express({
// app
// })
// ],
// // Capture 20% of transactions to avoid
// // overwhelming Sentry and remain within
// // the usage quota
// tracesSampleRate: 0.2
});
log('Sentry initialized');
}
module.exports = app;
if (require.main === module) {
app.start();
}

View File

@@ -1,8 +0,0 @@
This folder contains a list of json files representing the name
of revisioned client files. It is empty due to the fact that the
files are generated at runtime and their content is determined by
the content of the files they are derived from.
Since the build process is not exactly the same on every machine,
these files are not tracked in github otherwise conflicts arise when
building on our servers.

View File

@@ -1,59 +0,0 @@
{
"initial:before": {
"./middlewares/sentry-request-handler": {}
},
"initial": {
"compression": {},
"cors": {
"params": {
"origin": true,
"credentials": true,
"maxAge": 86400
}
}
},
"session": {
"./middlewares/sessions.js": {}
},
"auth:before": {
"express-flash": {},
"./middlewares/express-extensions": {},
"./middlewares/cookie-parser": {},
"./middlewares/request-authorization": {}
},
"parse": {
"body-parser#json": {},
"body-parser#urlencoded": {
"params": {
"extended": true
}
},
"method-override": {}
},
"routes:before": {
"helmet#xssFilter": {},
"helmet#noSniff": {},
"helmet#frameguard": {},
"./middlewares/csurf": {},
"./middlewares/csurf-set-cookie": {},
"./middlewares/constant-headers": {},
"./middlewares/csp": {},
"./middlewares/flash-cheaters": {},
"./middlewares/passport-login": {},
"./middlewares/rate-limit": {
"paths": ["/mobile-login"]
}
},
"files": {},
"final:after": {
"./middlewares/sentry-error-handler": {},
"./middlewares/csurf-error-handler": {},
"./middlewares/error-handlers": {},
"strong-error-handler": {
"params": {
"debug": false,
"log": true
}
}
}
}

View File

@@ -1,21 +0,0 @@
import { allowedOrigins } from '../../../config/cors-settings';
export default function constantHeaders() {
return function (req, res, next) {
if (
req.headers &&
req.headers.origin &&
allowedOrigins.includes(req.headers.origin)
) {
res.header('Access-Control-Allow-Origin', req.headers.origin);
} else {
res.header('Access-Control-Allow-Origin', process.env.HOME_LOCATION);
}
res.header('Access-Control-Allow-Credentials', true);
res.header(
'Access-Control-Allow-Headers',
'Origin, X-Requested-With, Content-Type, Accept'
);
next();
};
}

View File

@@ -1,4 +0,0 @@
import cookieParser from 'cookie-parser';
const cookieSecret = process.env.COOKIE_SECRET;
export default cookieParser.bind(cookieParser, cookieSecret);

View File

@@ -1,93 +0,0 @@
import helmet from 'helmet';
let trusted = [
"'self'",
'https://search.freecodecamp.org',
process.env.HOME_LOCATION,
'https://' + process.env.AUTH0_DOMAIN
];
const host = process.env.HOST || 'localhost';
const port = process.env.SYNC_PORT || '3000';
if (process.env.FREECODECAMP_NODE_ENV !== 'production') {
trusted = trusted.concat([`ws://${host}:${port}`, 'http://localhost:8000']);
}
export default function csp() {
return helmet.contentSecurityPolicy({
directives: {
defaultSrc: trusted.concat([
'https://*.cloudflare.com',
'*.cloudflare.com'
]),
connectSrc: trusted.concat([
'https://glitch.com',
'https://*.glitch.com',
'https://*.glitch.me',
'https://*.cloudflare.com',
'https://*.algolia.net'
]),
scriptSrc: [
"'unsafe-eval'",
"'unsafe-inline'",
'*.google-analytics.com',
'*.gstatic.com',
'https://*.cloudflare.com',
'*.cloudflare.com',
'https://*.gitter.im',
'https://*.cdnjs.com',
'*.cdnjs.com',
'https://*.jsdelivr.com',
'*.jsdelivr.com',
'*.twimg.com',
'https://*.twimg.com',
'*.youtube.com',
'*.ytimg.com'
].concat(trusted),
styleSrc: [
"'unsafe-inline'",
'*.gstatic.com',
'*.googleapis.com',
'*.bootstrapcdn.com',
'https://*.bootstrapcdn.com',
'*.cloudflare.com',
'https://*.cloudflare.com',
'https://use.fontawesome.com'
].concat(trusted),
fontSrc: [
'*.cloudflare.com',
'https://*.cloudflare.com',
'*.bootstrapcdn.com',
'*.googleapis.com',
'*.gstatic.com',
'https://*.bootstrapcdn.com',
'https://use.fontawesome.com'
].concat(trusted),
imgSrc: [
// allow all input since we have user submitted images for
// public profile
'*',
'data:'
],
mediaSrc: ['*.bitly.com', '*.amazonaws.com', '*.twitter.com'].concat(
trusted
),
frameSrc: [
'*.gitter.im',
'*.gitter.im https:',
'*.youtube.com',
'*.twitter.com',
'*.ghbtns.com',
'*.freecatphotoapp.com',
'freecodecamp.github.io'
].concat(trusted)
},
// set to true if you only want to report errors
reportOnly: false,
// set to true if you want to set all headers
setAllHeaders: false,
// set to true if you want to force buggy CSP in Safari 5
safari5: false
});
}

View File

@@ -1,12 +0,0 @@
import { csrfOptions } from './csurf.js';
export default function csrfErrorHandler() {
return function (err, req, res, next) {
if (err.code === 'EBADCSRFTOKEN' && req.csrfToken) {
// use the middleware to generate a token. The client sends this back via
// a header
res.cookie('csrf_token', req.csrfToken(), csrfOptions);
}
next(err);
};
}

View File

@@ -1,13 +0,0 @@
import { csrfOptions } from './csurf.js';
export default function setCSRFCookie() {
return function (req, res, next) {
// not all paths require a CSRF token, so the function may not be available.
if (req.csrfToken && !req.cookies.csrf_token) {
// use the middleware to generate a token. The client sends this back via
// a header
res.cookie('csrf_token', req.csrfToken(), csrfOptions);
}
next();
};
}

View File

@@ -1,26 +0,0 @@
import csurf from 'csurf';
export const csrfOptions = {
domain: process.env.COOKIE_DOMAIN,
sameSite: 'strict',
secure: process.env.FREECODECAMP_NODE_ENV === 'production'
};
export default function getCsurf() {
const protection = csurf({
cookie: { ...csrfOptions, httpOnly: true }
});
return function csrf(req, res, next) {
const { path } = req;
if (
/^\/donate\/charge-stripe$|^\/donate\/create-stripe-payment-intent$|^\/coderoad-challenge-completed$/.test(
path
)
) {
next();
} else {
// add the middleware
protection(req, res, next);
}
};
}

View File

@@ -1,68 +0,0 @@
// import { inspect } from 'util';
// import _ from 'lodash/fp';
import accepts from 'accepts';
import { unwrapHandledError } from '../utils/create-handled-error.js';
import { getRedirectParams } from '../utils/redirection';
const errTemplate = (error, req) => {
const { message, stack } = error;
return `
Error: ${message}
Is authenticated user: ${!!req.user}
Headers: ${JSON.stringify(req.headers, null, 2)}
Original request: ${req.originalMethod} ${req.originalUrl}
Stack: ${stack}
// raw
${JSON.stringify(error, null, 2)}
`;
};
const isDev = process.env.FREECODECAMP_NODE_ENV !== 'production';
export default function prodErrorHandler() {
// error handling in production.
return function (err, req, res, _next) {
// response for when req.body is bigger than body-parser's size limit
if (err?.type === 'entity.too.large') {
return res.status('413').send('Request payload is too large');
}
const { origin } = getRedirectParams(req);
const handled = unwrapHandledError(err);
// respect handled error status
let status = handled.status || err.status || res.statusCode;
if (!handled.status && status < 400) {
status = 500;
}
res.status(status);
// parse res type
const accept = accepts(req);
// prioritise returning json
const type = accept.type('json', 'html', 'text');
const redirectTo = handled.redirectTo || `${origin}/`;
const message =
handled.message ||
'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.';
if (isDev) {
console.error(errTemplate(err, req));
}
if (type === 'json') {
return res.json({
type: handled.type || 'danger',
message
});
} else {
if (typeof req.flash === 'function') {
req.flash(handled.type || 'danger', message);
}
return res.redirectWithFlash(redirectTo);
}
};
}

View File

@@ -1,23 +0,0 @@
import qs from 'query-string';
// add rx methods to express
export default function getExpressExtensions() {
return function expressExtensions(req, res, next) {
res.redirectWithFlash = uri => {
const flash = req.flash();
res.redirect(
`${uri}?${qs.stringify(
{ messages: qs.stringify(flash, { arrayFormat: 'index' }) },
{ arrayFormat: 'index' }
)}`
);
};
res.sendFlash = (type, message) => {
if (type && message) {
req.flash(type, message);
}
return res.json(req.flash());
};
next();
};
}

View File

@@ -1,32 +0,0 @@
import dedent from 'dedent';
const ALLOWED_METHODS = ['GET'];
const EXCLUDED_PATHS = [
'/api/flyers/findOne',
'/challenges/current-challenge',
'/challenges/next-challenge',
'/map-aside',
'/signout'
];
export default function flashCheaters() {
return function (req, res, next) {
if (
ALLOWED_METHODS.indexOf(req.method) !== -1 &&
EXCLUDED_PATHS.indexOf(req.path) === -1 &&
req.user &&
req.url !== '/' &&
req.user.isCheater
) {
req.flash(
'danger',
dedent`
Upon review, this account has been flagged for academic
dishonesty. If youre the owner of this account contact
team@freecodecamp.org for details.
`
);
}
return next();
};
}

View File

@@ -1,20 +0,0 @@
import debugFactory from 'debug';
const log = debugFactory('fcc:boot:user');
export function createDeleteMsUsername(app) {
const { MsUsername } = app.models;
return async function deleteMsUsername(req, res, next) {
try {
await MsUsername.destroyAll({ userId: req.user.id });
req.msUsernameDeleted = true;
} catch (e) {
req.msUsernameDeleted = false;
log(
`An error occurred deleting Microsoft username for user with id ${req.user.id}`
);
}
next();
};
}

View File

@@ -1,21 +0,0 @@
import _ from 'lodash';
import { login } from 'passport/lib/http/request';
import { Observable } from 'rx';
// make login polymorphic
// if supplied callback it works as normal
// if called without callback it returns an observable
// login(user, options?, cb?) => Void|Observable
function login$(...args) {
if (_.isFunction(_.last(args))) {
return login.apply(this, args);
}
return Observable.fromNodeCallback(login).apply(this, args);
}
export default function passportLogin() {
return (req, res, next) => {
req.login = req.logIn = login$;
next();
};
}

View File

@@ -1,23 +0,0 @@
import rateLimit from 'express-rate-limit';
import MongoStore from 'rate-limit-mongo';
const url = process.env.MONGODB || process.env.MONGOHQ_URL;
// Rate limit for mobile login
// 10 requests per 15 minute windows
export default function rateLimitMiddleware() {
return rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: req => {
return req.headers['x-forwarded-for'] || 'localhost';
},
store: new MongoStore({
collectionName: 'UserRateLimit',
uri: url,
expireTimeMs: 15 * 60 * 1000
})
});
}

View File

@@ -1,106 +0,0 @@
import { isEmpty } from 'lodash';
import { jwtSecret as _jwtSecret } from '../../../config/secrets';
import { wrapHandledError } from '../utils/create-handled-error';
import {
getAccessTokenFromRequest,
errorTypes
} from '../utils/getSetAccessToken';
import { getRedirectParams } from '../utils/redirection';
import { getUserById as _getUserById } from '../utils/user-stats';
const authRE = /^\/auth\//;
const confirmEmailRE = /^\/confirm-email$/;
const newsShortLinksRE = /^\/n\/|^\/p\//;
const publicUserRE = /^\/users\/get-public-profile$/;
const publicUsernameRE = /^\/users\/exists$/;
const resubscribeRE = /^\/resubscribe\//;
const showCertRE = /^\/certificate\/showCert\//;
// note: signin may not have a trailing slash
const signinRE = /^\/signin/;
const statusRE = /^\/status\/ping$/;
const unsubscribedRE = /^\/unsubscribed\//;
const unsubscribeRE = /^\/u\/|^\/unsubscribe\/|^\/ue\//;
// note: this would be replaced by webhooks later
const donateRE = /^\/donate\/charge-stripe$/;
const paymentIntentRE = /^\/donate\/create-stripe-payment-intent$/;
const submitCoderoadChallengeRE = /^\/coderoad-challenge-completed$/;
const mobileLoginRE = /^\/mobile-login\/?$/;
const _pathsAllowedREs = [
authRE,
confirmEmailRE,
newsShortLinksRE,
publicUserRE,
publicUsernameRE,
resubscribeRE,
showCertRE,
signinRE,
statusRE,
unsubscribedRE,
unsubscribeRE,
donateRE,
paymentIntentRE,
submitCoderoadChallengeRE,
mobileLoginRE
];
export function isAllowedPath(path, pathsAllowedREs = _pathsAllowedREs) {
return pathsAllowedREs.some(re => re.test(path));
}
export default function getRequestAuthorisation({
jwtSecret = _jwtSecret,
getUserById = _getUserById
} = {}) {
return function requestAuthorisation(req, res, next) {
const { origin } = getRedirectParams(req);
const { path } = req;
if (!isAllowedPath(path)) {
const { accessToken, error } = getAccessTokenFromRequest(req, jwtSecret);
if (!accessToken && error === errorTypes.noTokenFound) {
throw wrapHandledError(
new Error('Access token is required for this request'),
{
type: 'info',
redirect: `${origin}/signin`,
message: 'Access token is required for this request',
status: 403
}
);
}
if (!accessToken && error === errorTypes.invalidToken) {
throw wrapHandledError(new Error('Access token is invalid'), {
type: 'info',
redirect: `${origin}/signin`,
message: 'Your access token is invalid',
status: 403
});
}
if (!accessToken && error === errorTypes.expiredToken) {
throw wrapHandledError(new Error('Access token is no longer valid'), {
type: 'info',
redirect: `${origin}/signin`,
message: 'Access token is no longer valid',
status: 403
});
}
if (isEmpty(req.user)) {
const { userId } = accessToken;
return getUserById(userId)
.then(user => {
if (user) {
req.user = user;
}
return;
})
.then(next)
.catch(next);
} else {
return Promise.resolve(next());
}
}
return Promise.resolve(next());
};
}

View File

@@ -1,180 +0,0 @@
import path from 'path';
import jwt from 'jsonwebtoken';
import { config } from 'dotenv';
import { mockReq as mockRequest, mockRes } from '../boot_tests/challenge.test';
import createRequestAuthorization, {
isAllowedPath
} from './request-authorization';
config({ path: path.resolve(__dirname, '../../../../.env') });
const validJWTSecret = 'this is a super secret string';
const invalidJWTSecret = 'This is not correct secret';
const now = new Date(Date.now());
const theBeginningOfTime = new Date(0);
const accessToken = {
id: '123abc',
userId: '456def',
ttl: 60000,
created: now
};
const users = {
'456def': {
username: 'camperbot',
progressTimestamps: [1, 2, 3, 4]
}
};
const mockGetUserById = id =>
id in users ? Promise.resolve(users[id]) : Promise.reject('No user found');
const mockReq = args => {
const mock = mockRequest(args);
mock.header = () => process.env.HOME_LOCATION;
return mock;
};
describe('request-authorization', () => {
describe('isAllowedPath', () => {
const authRE = /^\/auth\//;
const confirmEmailRE = /^\/confirm-email$/;
const newsShortLinksRE = /^\/n\/|^\/p\//;
const publicUserRE = /^\/users\/get-public-profile$/;
const publicUsernameRE = /^\/users\/exists$/;
const resubscribeRE = /^\/resubscribe\//;
const showCertRE = /^\/certificate\/showCert\//;
// note: signin may not have a trailing slash
const signinRE = /^\/signin/;
const statusRE = /^\/status\/ping$/;
const unsubscribedRE = /^\/unsubscribed\//;
const unsubscribeRE = /^\/u\/|^\/unsubscribe\/|^\/ue\//;
const allowedPathsList = [
authRE,
confirmEmailRE,
newsShortLinksRE,
publicUserRE,
publicUsernameRE,
resubscribeRE,
showCertRE,
signinRE,
statusRE,
unsubscribedRE,
unsubscribeRE
];
it('returns a boolean', () => {
const result = isAllowedPath();
expect(typeof result).toBe('boolean');
});
it('returns true for a white listed path', () => {
const resultA = isAllowedPath(
'/auth/auth0/callback?code=yF_mGjswLsef-_RLo',
allowedPathsList
);
const resultB = isAllowedPath(
'/ue/WmjInLerysPrcon6fMb/',
allowedPathsList
);
expect(resultA).toBe(true);
expect(resultB).toBe(true);
});
it('returns false for a non-white-listed path', () => {
const resultA = isAllowedPath('/hax0r-42/no-go', allowedPathsList);
const resultB = isAllowedPath(
'/update-current-challenge',
allowedPathsList
);
expect(resultA).toBe(false);
expect(resultB).toBe(false);
});
});
describe('createRequestAuthorization', () => {
const requestAuthorization = createRequestAuthorization({
jwtSecret: validJWTSecret,
getUserById: mockGetUserById
});
it('is a function', () => {
expect(typeof requestAuthorization).toEqual('function');
});
describe('cookies', () => {
it('throws when no access token is present', () => {
expect.assertions(2);
const req = mockReq({ path: '/some-path/that-needs/auth' });
const res = mockRes();
const next = jest.fn();
expect(() => requestAuthorization(req, res, next)).toThrowError(
'Access token is required for this request'
);
expect(next).not.toHaveBeenCalled();
});
it('throws when the access token is invalid', () => {
expect.assertions(2);
const invalidJWT = jwt.sign({ accessToken }, invalidJWTSecret);
const req = mockReq({
path: '/some-path/that-needs/auth',
// eslint-disable-next-line camelcase
cookie: { jwt_access_token: invalidJWT }
});
const res = mockRes();
const next = jest.fn();
expect(() => requestAuthorization(req, res, next)).toThrowError(
'Access token is invalid'
);
expect(next).not.toHaveBeenCalled();
});
it('throws when the access token has expired', () => {
expect.assertions(2);
const invalidJWT = jwt.sign(
{ accessToken: { ...accessToken, created: theBeginningOfTime } },
validJWTSecret
);
const req = mockReq({
path: '/some-path/that-needs/auth',
// eslint-disable-next-line camelcase
cookie: { jwt_access_token: invalidJWT }
});
const res = mockRes();
const next = jest.fn();
expect(() => requestAuthorization(req, res, next)).toThrowError(
'Access token is no longer valid'
);
expect(next).not.toHaveBeenCalled();
});
it('adds the user to the request object', async () => {
expect.assertions(3);
const validJWT = jwt.sign({ accessToken }, validJWTSecret);
const req = mockReq({
path: '/some-path/that-needs/auth',
// eslint-disable-next-line camelcase
cookie: { jwt_access_token: validJWT }
});
const res = mockRes();
const next = jest.fn();
await requestAuthorization(req, res, next);
expect(next).toHaveBeenCalled();
expect(req).toHaveProperty('user');
expect(req.user).toEqual(users['456def']);
});
it('calls next if request does not require authorization', async () => {
// currently /unsubscribe does not require authorization
const req = mockReq({ path: '/unsubscribe/another/route' });
const res = mockRes();
const next = jest.fn();
await requestAuthorization(req, res, next);
expect(next).toHaveBeenCalled();
});
});
});
});

View File

@@ -1,23 +0,0 @@
import { Handlers, captureException } from '@sentry/node';
import { sentry } from '../../../config/secrets';
import { isHandledError } from '../utils/create-handled-error';
// sends directly to Sentry
export function reportError(err) {
return sentry.dsn === 'dsn_from_sentry_dashboard'
? console.error(err)
: captureException(err);
}
// determines which errors should be reported
export default function sentryErrorHandler() {
return sentry.dsn === 'dsn_from_sentry_dashboard'
? (req, res, next) => next()
: Handlers.errorHandler({
shouldHandleError(err) {
// CSRF errors have status 403, consider ignoring them once csurf is
// no longer rejecting people incorrectly.
return !isHandledError(err) && (!err.status || err.status >= 500);
}
});
}

View File

@@ -1,8 +0,0 @@
import { Handlers } from '@sentry/node';
import { sentry } from '../../../config/secrets';
export default function sentryRequestHandler() {
return sentry.dsn === 'dsn_from_sentry_dashboard'
? (req, res, next) => next()
: Handlers.requestHandler();
}

View File

@@ -1,8 +0,0 @@
import { Handlers } from '@sentry/node';
import { sentry } from '../../../config/secrets';
export default function sentryRequestHandler() {
return sentry.dsn === 'dsn_from_sentry_dashboard'
? (req, res, next) => next()
: Handlers.tracingHandler();
}

View File

@@ -1,20 +0,0 @@
import MongoStoreFactory from 'connect-mongo';
import session from 'express-session';
const MongoStore = MongoStoreFactory(session);
const sessionSecret = process.env.SESSION_SECRET;
const url = process.env.MONGODB || process.env.MONGOHQ_URL;
export default function sessionsMiddleware() {
return session({
// 900 day session cookie
cookie: { maxAge: 900 * 24 * 60 * 60 * 1000 },
// resave forces session to be resaved
// regardless of whether it was modified
// this causes race conditions during parallel req
resave: false,
saveUninitialized: true,
secret: sessionSecret,
store: new MongoStore({ url })
});
}

View File

@@ -1,42 +0,0 @@
import debugFactory from 'debug';
const log = debugFactory('fcc:boot:user');
const allowedTitles = ['Foundational C# with Microsoft Survey'];
export function validateSurvey(req, res, next) {
const { title, responses } = req.body.surveyResults || {
title: '',
responses: []
};
if (
!allowedTitles.includes(title) ||
!Array.isArray(responses) ||
!responses.every(
r => typeof r.question === 'string' && typeof r.response === 'string'
)
) {
return res.status(400).json({
type: 'error',
message: 'flash.survey.err-1'
});
}
next();
}
export function createDeleteUserSurveys(app) {
const { Survey } = app.models;
return async function deleteUserSurveys(req, res, next) {
try {
await Survey.destroyAll({ userId: req.user.id });
req.userSurveysDeleted = true;
} catch (e) {
req.userSurveysDeleted = false;
log(`An error occurred deleting Surveys for user with id ${req.user.id}`);
}
next();
};
}

View File

@@ -1,31 +0,0 @@
import debugFactory from 'debug';
const log = debugFactory('fcc:boot:user');
import jwt from 'jsonwebtoken';
import { jwtSecret } from '../../../config/secrets';
/*
* User tokens for submitting external curriculum are deleted when they sign
* out, reset their account, or delete their account
*/
export function createDeleteUserToken(app) {
const { UserToken } = app.models;
return async function deleteUserToken(req, res, next) {
try {
await UserToken.destroyAll({ userId: req.user.id });
req.userTokenDeleted = true;
} catch (e) {
req.userTokenDeleted = false;
log(
`An error occurred deleting user token for user with id ${req.user.id}`
);
}
next();
};
}
export function encodeUserToken(userToken) {
return jwt.sign({ userToken }, jwtSecret);
}

View File

@@ -1,78 +0,0 @@
{
"_meta": {
"sources": [
"loopback/common/models",
"loopback/server/models",
"../common/models",
"./models"
]
},
"AuthToken": {
"dataSource": "db",
"public": false
},
"AccessToken": {
"dataSource": "db",
"public": false
},
"ACL": {
"dataSource": "db",
"public": false
},
"block": {
"dataSource": "db",
"public": true
},
"challenge": {
"dataSource": "db",
"public": true
},
"Donation": {
"dataSource": "db",
"public": false
},
"Email": {
"dataSource": "mail",
"public": false
},
"Exam": {
"dataSource": "db",
"public": false
},
"Role": {
"dataSource": "db",
"public": false
},
"MsUsername": {
"dataSource": "db",
"public": false
},
"Survey": {
"dataSource": "db",
"public": false
},
"RoleMapping": {
"dataSource": "db",
"public": false
},
"userCredential": {
"dataSource": "db",
"public": true
},
"userIdentity": {
"dataSource": "db",
"public": true
},
"user": {
"dataSource": "db",
"public": true
},
"User": {
"dataSource": "db",
"public": false
},
"UserToken": {
"dataSource": "db",
"public": false
}
}

View File

@@ -1,15 +0,0 @@
import { Observable } from 'rx';
export default function initializeAuthToken(AuthToken) {
AuthToken.on('dataSourceAttached', () => {
AuthToken.findOne$ = Observable.fromNodeCallback(
AuthToken.findOne.bind(AuthToken)
);
AuthToken.prototype.validate$ = Observable.fromNodeCallback(
AuthToken.prototype.validate
);
AuthToken.prototype.destroy$ = Observable.fromNodeCallback(
AuthToken.prototype.destroy
);
});
}

View File

@@ -1,13 +0,0 @@
{
"name": "AuthToken",
"base": "AccessToken",
"idInjection": true,
"options": {
"validateUpsert": true
},
"properties": {},
"validations": [],
"relations": {},
"acls": [],
"methods": {}
}

View File

@@ -1,71 +0,0 @@
import debug from 'debug';
import { Observable } from 'rx';
import { reportError } from '../middlewares/sentry-error-handler.js';
import InMemoryCache from '../utils/in-memory-cache';
const log = debug('fcc:boot:donate');
const fiveMinutes = 1000 * 60 * 5;
export default function initializeDonation(Donation) {
let activeDonationUpdateInterval = null;
const activeDonationCountCacheTTL = fiveMinutes;
const activeDonationCountCache = InMemoryCache(0, reportError);
const activeDonationsQuery$ = () =>
Donation.find$({
// eslint-disable-next-line no-undefined
where: { endDate: undefined }
}).map(instances => instances.length);
function cleanUp() {
if (activeDonationUpdateInterval) {
clearInterval(activeDonationUpdateInterval);
}
return;
}
process.on('exit', cleanUp);
Donation.on('dataSourceAttached', () => {
Donation.find$ = Observable.fromNodeCallback(Donation.find.bind(Donation));
Donation.findOne$ = Observable.fromNodeCallback(
Donation.findOne.bind(Donation)
);
seedTheCache()
.then(setupCacheUpdateInterval)
.catch(err => {
const errMsg = `Error caught seeding the cache: ${err.message}`;
err.message = errMsg;
reportError(err);
});
});
function seedTheCache() {
return new Promise((resolve, reject) =>
Observable.defer(activeDonationsQuery$).subscribe(count => {
log('activeDonor count: %d', count);
activeDonationCountCache.update(() => count);
return resolve();
}, reject)
);
}
function setupCacheUpdateInterval() {
activeDonationUpdateInterval = setInterval(
() =>
Observable.defer(activeDonationsQuery$).subscribe(
count => {
log('activeDonor count: %d', count);
return activeDonationCountCache.update(() => count);
},
err => {
const errMsg = `Error caught updating the cache: ${err.message}`;
err.message = errMsg;
reportError(err);
}
),
activeDonationCountCacheTTL
);
return null;
}
}

View File

@@ -1,71 +0,0 @@
{
"name": "Donation",
"description": "A representation of a donation to freeCodeCamp",
"plural": "donations",
"base": "PersistedModel",
"idInjection": true,
"scopes": {},
"indexes": {},
"options": {
"validateUpsert": true
},
"hidden": [],
"remoting": {},
"http": {},
"properties": {
"email": {
"type": "string",
"required": true,
"description": "The email used to create the donation"
},
"provider": {
"type": "string",
"required": true,
"description": "The payment handler, paypal/stripe etc..."
},
"amount": {
"type": "number",
"required": true,
"description": "The donation amount in cents"
},
"duration": {
"type": "string"
},
"startDate": {
"type": "DateString",
"required": true
},
"endDate": {
"type": "DateString"
},
"subscriptionId": {
"type": "string",
"required": true,
"description": "The donation subscription id returned from the provider"
},
"customerId": {
"type": "string",
"required": true,
"description": "The providers reference for the donor"
}
},
"validations": [
{
"amount": {
"type": "number",
"description": "Amount should be >= $1 (100c)",
"min": 100
},
"facetName": "server"
}
],
"relations": {
"user": {
"type": "belongsTo",
"model": "user",
"foreignKey": "userId"
}
},
"acls": [],
"methods": {}
}

View File

@@ -1,70 +0,0 @@
{
"name": "Exam",
"description": "Exam questions for exam style challenges",
"base": "PersistedModel",
"idInjection": true,
"options": {
"strict": true
},
"properties": {
"numberOfQuestionsInExam": {
"type": "number",
"required": true
},
"passingPercent": {
"type": "number",
"required": true
},
"prerequisites": {
"type": [
{
"id": "string",
"title": "string"
}
]
},
"questions": {
"type": [
{
"id": "string",
"question": "string",
"wrongAnswers": {
"type": [
{
"id": "string",
"answer": "string"
}
],
"required": true
},
"correctAnswers": {
"type": [
{
"id": "string",
"answer": "string"
}
],
"required": true
}
}
],
"required": true,
"itemType": "Question"
},
"title": {
"type": "string",
"required": true
}
},
"validations": [],
"relations": {},
"acls": [
{
"accessType": "*",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "DENY"
}
],
"methods": {}
}

View File

@@ -1,20 +0,0 @@
{
"name": "MsUsername",
"description": "Microsoft account usernames",
"base": "PersistedModel",
"idInjection": true,
"options": {
"validateUpsert": true
},
"properties": {},
"validations": [],
"relations": {
"user": {
"type": "belongsTo",
"model": "user",
"foreignKey": "userId"
}
},
"acls": [],
"methods": {}
}

View File

@@ -1,41 +0,0 @@
{
"name": "Survey",
"description": "Survey responses from campers",
"base": "PersistedModel",
"idInjection": true,
"options": {
"strict": true
},
"properties": {
"title": {
"type": "string",
"required": true
},
"responses": {
"type": [
{
"question": "string",
"response": "string"
}
],
"required": true
}
},
"validations": [],
"relations": {
"user": {
"type": "belongsTo",
"model": "user",
"foreignKey": "userId"
}
},
"acls": [
{
"accessType": "*",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "DENY"
}
],
"methods": {}
}

View File

@@ -1,20 +0,0 @@
{
"name": "UserToken",
"description": "Tokens for submitting curricula through CodeRoad",
"base": "AccessToken",
"idInjection": true,
"options": {
"validateUpsert": true
},
"properties": {},
"validations": [],
"relations": {
"user": {
"type": "belongsTo",
"model": "user",
"foreignKey": "userId"
}
},
"acls": [],
"methods": {}
}

View File

@@ -1,48 +0,0 @@
import { auth0 } from '../../config/secrets';
const { clientID, clientSecret, domain } = auth0;
// These don't seem to be used, can they go?
const successRedirect = `${process.env.HOME_LOCATION}/learn`;
const failureRedirect = `${process.env.HOME_LOCATION}/signin`;
// TODO: can we remove passport-mock-strategy entirely in prod? That would let
// us make passport-mock-strategy a dev dep, as it should be.
const passportProviders = {
devlogin: {
authScheme: 'mock',
provider: 'dev',
module: 'passport-mock-strategy'
},
local: {
provider: 'local',
module: 'passport-local',
usernameField: 'email',
passwordField: 'password',
authPath: '/auth/local',
successRedirect: successRedirect,
failureRedirect: failureRedirect,
session: true,
failureFlash: true
},
'auth0-login': {
provider: 'auth0',
module: 'passport-auth0',
clientID,
clientSecret,
domain,
cookieDomain: process.env.COOKIE_DOMAIN || 'localhost',
callbackURL: `${process.env.API_LOCATION}/auth/auth0/callback`,
authPath: '/auth/auth0',
callbackPath: '/auth/auth0/callback',
useCustomCallback: true,
passReqToCallback: true,
state: false,
successRedirect: successRedirect,
failureRedirect: failureRedirect,
scope: ['openid profile email'],
failureFlash: true
}
};
export default passportProviders;

View File

@@ -1,78 +0,0 @@
import compareDesc from 'date-fns/compare_desc';
import debug from 'debug';
import _ from 'lodash';
import { getLybsynFeed } from './lybsyn';
const log = debug('fcc:rss:news-feed');
const fiveMinutes = 1000 * 60 * 5;
class NewsFeed {
constructor() {
this.state = {
readyState: false,
lybsynFeed: [],
combinedFeed: []
};
this.refreshFeeds();
setInterval(this.refreshFeeds, fiveMinutes);
}
setState = stateUpdater => {
const newState = stateUpdater(this.state);
this.state = _.merge({}, this.state, newState);
return;
};
refreshFeeds = () => {
const currentFeed = this.state.combinedFeed.slice(0);
log('grabbing feeds');
return Promise.all([getLybsynFeed()])
.then(([lybsynFeed]) =>
this.setState(state => ({
...state,
lybsynFeed
}))
)
.then(() => {
log('crossing the streams');
const { lybsynFeed } = this.state;
const combinedFeed = [...lybsynFeed].sort((a, b) => {
return compareDesc(a.isoDate, b.isoDate);
});
this.setState(state => ({
...state,
combinedFeed,
readyState: true
}));
})
.catch(err => {
console.log(err);
this.setState(state => ({
...state,
combinedFeed: currentFeed
}));
});
};
getFeed = () =>
new Promise(resolve => {
let notReadyCount = 0;
function waitForReady() {
log('notReadyCount', notReadyCount);
notReadyCount++;
return this.state.readyState || notReadyCount === 5
? resolve(this.state.combinedFeed)
: setTimeout(waitForReady, 100);
}
log('are we ready?', this.state.readyState);
return this.state.readyState
? resolve(this.state.combinedFeed)
: setTimeout(waitForReady, 100);
});
}
export default NewsFeed;

View File

@@ -1,48 +0,0 @@
import http from 'http';
import _ from 'lodash';
const lybsynFeed = 'http://freecodecamp.libsyn.com/render-type/json';
export function getLybsynFeed() {
return new Promise((resolve, reject) => {
http.get(lybsynFeed, res => {
let raw = '';
res.on('data', chunk => {
raw += chunk;
});
res.on('error', err => reject(err));
res.on('end', () => {
let feed = [];
try {
feed = JSON.parse(raw);
} catch (err) {
return reject(err);
}
const items = feed
.map(item =>
_.pick(item, [
'full_item_url',
'item_title',
'release_date',
'item_body_short'
])
)
/* eslint-disable camelcase */
.map(
({ full_item_url, item_title, release_date, item_body_short }) => ({
title: item_title,
extract: item_body_short,
isoDate: new Date(release_date).toISOString(),
link: full_item_url
})
);
/* eslint-enable camelcase */
return resolve(items);
});
});
});
}

View File

@@ -1,91 +0,0 @@
export const examJson = {
id: 1,
numberOfQuestionsInExam: 1,
passingPercent: 70,
questions: [
{
id: '3bbl2mx2mq',
question: 'Question 1?',
wrongAnswers: [
{ id: 'ex7hii9zup', answer: 'Q1: Wrong Answer 1' },
{ id: 'lmr1ew7m67', answer: 'Q1: Wrong Answer 2' },
{ id: 'qh5sz9qdiq', answer: 'Q1: Wrong Answer 3' },
{ id: 'g489kbwn6a', answer: 'Q1: Wrong Answer 4' },
{ id: '7vu84wl4lc', answer: 'Q1: Wrong Answer 5' },
{ id: 'em59kw6avu', answer: 'Q1: Wrong Answer 6' }
],
correctAnswers: [
{ id: 'dzlokqdc73', answer: 'Q1: Correct Answer 1' },
{ id: 'f5gk39ske9', answer: 'Q1: Correct Answer 2' }
]
},
{
id: 'oqis5gzs0h',
question: 'Question 2?',
wrongAnswers: [
{ id: 'ojhnoxh5r5', answer: 'Q2: Wrong Answer 1' },
{ id: 'onx06if0uh', answer: 'Q2: Wrong Answer 2' },
{ id: 'zbxnsko712', answer: 'Q2: Wrong Answer 3' },
{ id: 'bqv5y68jyp', answer: 'Q2: Wrong Answer 4' },
{ id: 'i5xipitiss', answer: 'Q2: Wrong Answer 5' },
{ id: 'wycrnloajd', answer: 'Q2: Wrong Answer 6' }
],
correctAnswers: [
{ id: 't9ezcsupdl', answer: 'Q1: Correct Answer 1' },
{ id: 'agert35dk0', answer: 'Q1: Correct Answer 2' }
]
}
]
};
// failed
export const userExam1 = {
userExamQuestions: [
{
id: '3bbl2mx2mq',
question: 'Question 1?',
answer: { id: 'dzlokqdc73', answer: 'Q1: Correct Answer 1' }
},
{
id: 'oqis5gzs0h',
question: 'Question 2?',
answer: { id: 'i5xipitiss', answer: 'Q2: Wrong Answer 5' }
}
],
examTimeInSeconds: 20
};
// passed
export const userExam2 = {
userExamQuestions: [
{
id: '3bbl2mx2mq',
question: 'Question 1?',
answer: { id: 'dzlokqdc73', answer: 'Q1: Correct Answer 1' }
},
{
id: 'oqis5gzs0h',
question: 'Question 2?',
answer: { id: 't9ezcsupdl', answer: 'Q1: Correct Answer 1' }
}
],
examTimeInSeconds: 20
};
export const mockResults1 = {
numberOfCorrectAnswers: 1,
numberOfQuestionsInExam: 2,
percentCorrect: 50,
passingPercent: 70,
passed: false,
examTimeInSeconds: 20
};
export const mockResults2 = {
numberOfCorrectAnswers: 2,
numberOfQuestionsInExam: 2,
percentCorrect: 100,
passingPercent: 70,
passed: true,
examTimeInSeconds: 20
};

View File

@@ -1,50 +0,0 @@
const githubRegex = /github/i;
const providerHash = {
facebook: ({ id }) => id,
github: ({ username }) => username,
twitter: ({ username }) => username,
linkedin({ _json }) {
return (_json && _json.publicProfileUrl) || null;
},
google: ({ id }) => id
};
export function getUsernameFromProvider(provider, profile) {
return typeof providerHash[provider] === 'function'
? providerHash[provider](profile)
: null;
}
// createProfileAttributes(provider: String, profile: {}) => Object
export function createUserUpdatesFromProfile(provider, profile) {
if (githubRegex.test(provider)) {
return createProfileAttributesFromGithub(profile);
}
return {
[getSocialProvider(provider)]: getUsernameFromProvider(
getSocialProvider(provider),
profile
)
};
}
// createProfileAttributes(profile) => profileUpdate
function createProfileAttributesFromGithub(profile) {
const {
profileUrl: githubProfile,
username,
_json: { avatar_url: picture, blog: website, location, bio, name } = {}
} = profile;
return {
name,
username: username.toLowerCase(),
location,
bio,
website,
picture,
githubProfile
};
}
export function getSocialProvider(provider) {
return provider.split('-')[0];
}

View File

@@ -1,11 +0,0 @@
import { Observable, helpers } from 'rx';
export default function castToObservable(maybe) {
if (Observable.isObservable(maybe)) {
return maybe;
}
if (helpers.isPromise(maybe)) {
return Observable.fromPromise(maybe);
}
return Observable.of(maybe);
}

View File

@@ -1,21 +0,0 @@
{
"frontEnd": "isFrontEndCert",
"backEnd": "isBackEndCert",
"dataVis": "isDataVisCert",
"respWebDesign": "isRespWebDesignCert",
"frontEndLibs": "isFrontEndLibsCert",
"dataVis2018": "is2018DataVisCert",
"jsAlgoDataStruct": "isJsAlgoDataStructCert",
"apisMicroservices": "isApisMicroservicesCert",
"infosecQa": "isInfosecQaCert",
"qaV7": "isQaCertV7",
"infosecV7": "isInfosecCertV7",
"sciCompPyV7": "isSciCompPyCertV7",
"dataAnalysisPyV7": "isDataAnalysisPyCertV7",
"machineLearningPyV7": "isMachineLearningPyCertV7",
"fullStack": "isFullStackCert",
"relationalDatabaseV8": "isRelationalDatabaseV8",
"collegeAlgebraPyV8": "isCollegeAlgebraPyCertV8",
"foundationalCSharpV8": "isFoundationalCSharpCertV8",
"isJsAlgoDataStructCertV8": "isJsAlgoDataStructCertV8"
}

View File

@@ -1,6 +0,0 @@
export function createCookieConfig(req) {
return {
signed: !!req.signedCookies,
domain: process.env.COOKIE_DOMAIN || 'localhost'
};
}

View File

@@ -1,29 +0,0 @@
const _handledError = Symbol('handledError');
export function isHandledError(err) {
return !!err[_handledError];
}
export function unwrapHandledError(err) {
return err[_handledError] || {};
}
export function wrapHandledError(
err,
{ type, message, redirectTo, status = 200 }
) {
err[_handledError] = { type, message, redirectTo, status };
return err;
}
// for use with express-validator error formatter
export const createValidatorErrorFormatter =
(type, redirectTo) =>
({ msg }) =>
wrapHandledError(new Error(msg), {
type,
message: msg,
redirectTo,
// we default to 400 as these are malformed requests
status: 400
});

Some files were not shown because too many files have changed in this diff Show More