mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2025-12-19 18:18:27 -05:00
chore(api-server): bye-bye you served us well (#60520)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
committed by
GitHub
parent
a90e2757ac
commit
16e461385e
6
.github/labeler.yml
vendored
6
.github/labeler.yml
vendored
@@ -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/**/*
|
||||
|
||||
93
.github/workflows/deploy-legacy.yml
vendored
93
.github/workflows/deploy-legacy.yml
vendored
@@ -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
|
||||
|
||||
108
.github/workflows/e2e-playwright.yml
vendored
108
.github/workflows/e2e-playwright.yml
vendored
@@ -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
|
||||
|
||||
136
.github/workflows/e2e-third-party.yml
vendored
136
.github/workflows/e2e-third-party.yml
vendored
@@ -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
4
.gitignore
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
**/.cache
|
||||
**/*fixtures*
|
||||
api-server/lib
|
||||
client/**/trending.json
|
||||
client/**/search-bar.json
|
||||
client/config/*.json
|
||||
|
||||
@@ -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
51
api-server/.gitignore
vendored
@@ -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/*
|
||||
@@ -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'
|
||||
];
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,3 +0,0 @@
|
||||
& {
|
||||
@import './app/index.less';
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "userCredential",
|
||||
"plural": "userCredentials",
|
||||
"base": "UserCredential",
|
||||
"properties": {},
|
||||
"validations": [],
|
||||
"relations": {
|
||||
"user": {
|
||||
"type": "belongsTo",
|
||||
"model": "user",
|
||||
"foreignKey": "userId"
|
||||
}
|
||||
},
|
||||
"acls": [],
|
||||
"methods": {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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('');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
|
||||
export const alertTypes = _.keyBy(
|
||||
['success', 'info', 'warning', 'danger'],
|
||||
_.identity
|
||||
);
|
||||
|
||||
export const normalizeAlertType = alertType => alertTypes[alertType] || 'info';
|
||||
@@ -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']);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = function mountRestApi(app) {
|
||||
const restApi = app.loopback.rest();
|
||||
const restApiRoot = app.get('restApiRoot');
|
||||
app.use(restApiRoot, restApi);
|
||||
};
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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/');
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: []
|
||||
}
|
||||
];
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
host: process.env.HOST || 'localhost'
|
||||
};
|
||||
@@ -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;
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"db": {
|
||||
"name": "db",
|
||||
"connector": "mongodb",
|
||||
"allowExtendedOperators": true
|
||||
},
|
||||
"mail": {
|
||||
"name": "mail",
|
||||
"connector": "mail"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import cookieParser from 'cookie-parser';
|
||||
|
||||
const cookieSecret = process.env.COOKIE_SECRET;
|
||||
export default cookieParser.bind(cookieParser, cookieSecret);
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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 you’re the owner of this account contact
|
||||
team@freecodecamp.org for details.
|
||||
`
|
||||
);
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 })
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"name": "AuthToken",
|
||||
"base": "AccessToken",
|
||||
"idInjection": true,
|
||||
"options": {
|
||||
"validateUpsert": true
|
||||
},
|
||||
"properties": {},
|
||||
"validations": [],
|
||||
"relations": {},
|
||||
"acls": [],
|
||||
"methods": {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export function createCookieConfig(req) {
|
||||
return {
|
||||
signed: !!req.signedCookies,
|
||||
domain: process.env.COOKIE_DOMAIN || 'localhost'
|
||||
};
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user