mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00: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
@@ -1,6 +1,3 @@
|
|||||||
'scope: docs':
|
|
||||||
- docs/**/*
|
|
||||||
|
|
||||||
'scope: curriculum':
|
'scope: curriculum':
|
||||||
- curriculum/challenges/**/*
|
- curriculum/challenges/**/*
|
||||||
|
|
||||||
@@ -8,7 +5,6 @@
|
|||||||
- client/**/*
|
- client/**/*
|
||||||
|
|
||||||
'platform: api':
|
'platform: api':
|
||||||
- api-server/**/*
|
|
||||||
- api/**/*
|
- api/**/*
|
||||||
|
|
||||||
'scope: tools/scripts':
|
'scope: tools/scripts':
|
||||||
@@ -18,8 +14,6 @@
|
|||||||
- e2e/**/*
|
- e2e/**/*
|
||||||
|
|
||||||
'scope: i18n':
|
'scope: i18n':
|
||||||
- any: ['curriculum/challenges/**/*', '!curriculum/challenges/english/**/*']
|
|
||||||
- docs/i18n/**/*
|
|
||||||
- client/i18n/**/*
|
- client/i18n/**/*
|
||||||
- config/crowdin/**/*
|
- config/crowdin/**/*
|
||||||
- shared/config/i18n/**/*
|
- shared/config/i18n/**/*
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: CD - Deploy API (Legacy) & Clients
|
name: CD - Deploy - Clients
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -33,98 +33,9 @@ jobs:
|
|||||||
;;
|
;;
|
||||||
esac
|
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:
|
client:
|
||||||
name: Clients - [${{ needs.setup-jobs.outputs.tgt_env_short }}] [${{ matrix.lang-name-short }}]
|
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
|
runs-on: ubuntu-22.04
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ jobs:
|
|||||||
name: webpack-stats
|
name: webpack-stats
|
||||||
path: client/public/stats.json
|
path: client/public/stats.json
|
||||||
|
|
||||||
build-new-api:
|
build-api:
|
||||||
name: Build New Api (Container)
|
name: Build API (Container)
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -91,108 +91,16 @@ jobs:
|
|||||||
- name: Save Image
|
- name: Save Image
|
||||||
run: docker save fcc-api > api-artifact.tar
|
run: docker save fcc-api > api-artifact.tar
|
||||||
|
|
||||||
- name: Upload Api Artifact
|
- name: Upload API Artifact
|
||||||
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
|
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
|
||||||
with:
|
with:
|
||||||
name: api-artifact
|
name: api-artifact
|
||||||
path: api-artifact.tar
|
path: api-artifact.tar
|
||||||
|
|
||||||
playwright-run-old-api:
|
playwright-run-api:
|
||||||
name: E2E Test
|
name: Run Playwright Tests
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs: build-client
|
needs: [build-client, build-api]
|
||||||
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]
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -215,7 +123,7 @@ jobs:
|
|||||||
tar -xf client-artifact/client-artifact.tar
|
tar -xf client-artifact/client-artifact.tar
|
||||||
rm client-artifact/client-artifact.tar
|
rm client-artifact/client-artifact.tar
|
||||||
|
|
||||||
- name: Load Api Image
|
- name: Load API Image
|
||||||
run: |
|
run: |
|
||||||
docker load < api-artifact/api-artifact.tar
|
docker load < api-artifact/api-artifact.tar
|
||||||
rm api-artifact/api-artifact.tar
|
rm api-artifact/api-artifact.tar
|
||||||
@@ -261,4 +169,4 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: playwright-report-${{ matrix.browsers }}
|
name: playwright-report-${{ matrix.browsers }}
|
||||||
path: playwright/reporter
|
path: playwright/reporter
|
||||||
retention-days: 30
|
retention-days: 7
|
||||||
|
|||||||
@@ -11,23 +11,12 @@ concurrency:
|
|||||||
cancel-in-progress: ${{ !contains(github.ref, 'main') && !contains(github.ref, 'prod-') }}
|
cancel-in-progress: ${{ !contains(github.ref, 'main') && !contains(github.ref, 'prod-') }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
do-everything:
|
build-client:
|
||||||
name: Build & Test
|
name: Build Client
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [20]
|
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:
|
steps:
|
||||||
- name: Checkout Source Files
|
- name: Checkout Source Files
|
||||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||||
@@ -47,8 +36,7 @@ jobs:
|
|||||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
# cypress-io/github-action caches the store, so we should not cache it
|
cache: pnpm
|
||||||
# here.
|
|
||||||
|
|
||||||
- name: Set freeCodeCamp Environment Variables
|
- name: Set freeCodeCamp Environment Variables
|
||||||
run: |
|
run: |
|
||||||
@@ -60,32 +48,124 @@ jobs:
|
|||||||
- name: Install and Build
|
- name: Install and Build
|
||||||
run: |
|
run: |
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run create:shared
|
pnpm run build
|
||||||
pnpm run build:curriculum
|
|
||||||
pnpm run build:server
|
|
||||||
|
|
||||||
- name: Seed Database with Certified User
|
|
||||||
run: pnpm run seed:certified-user
|
|
||||||
|
|
||||||
- name: Move serve.json to Public Folder
|
- name: Move serve.json to Public Folder
|
||||||
run: cp client-config/serve.json client/public/serve.json
|
run: cp client-config/serve.json client/public/serve.json
|
||||||
|
|
||||||
# start-ci uses pm2, so it needs to be installed globally
|
- name: Tar Files
|
||||||
- name: Install pm2
|
run: tar -cf client-artifact.tar client/public
|
||||||
run: npm i -g pm2
|
|
||||||
|
- 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
|
- name: Install playwright dependencies
|
||||||
run: npx playwright install --with-deps
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
- name: Run playwright tests
|
- name: Install and Build
|
||||||
run: |
|
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
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
with:
|
with:
|
||||||
name: playwright-report-${{ matrix.browsers }}
|
name: playwright-report-${{ matrix.browsers }}
|
||||||
path: playwright/reporter
|
path: playwright/reporter
|
||||||
retention-days: 30
|
retention-days: 7
|
||||||
|
|||||||
+1
-3
@@ -63,7 +63,6 @@ $RECYCLE.BIN/
|
|||||||
# Icon must end with two \r
|
# Icon must end with two \r
|
||||||
Icon
|
Icon
|
||||||
|
|
||||||
|
|
||||||
# Thumbnails
|
# Thumbnails
|
||||||
._*
|
._*
|
||||||
|
|
||||||
@@ -172,7 +171,7 @@ utils/slugs.test.js
|
|||||||
### vim ###
|
### vim ###
|
||||||
# Swap
|
# Swap
|
||||||
[._]*.s[a-v][a-z]
|
[._]*.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]
|
[._]*.sw[a-p]
|
||||||
[._]s[a-rt-v][a-z]
|
[._]s[a-rt-v][a-z]
|
||||||
[._]ss[a-gi-z]
|
[._]ss[a-gi-z]
|
||||||
@@ -198,7 +197,6 @@ tags
|
|||||||
curriculum/curricula.json
|
curriculum/curricula.json
|
||||||
|
|
||||||
### Additional Folders ###
|
### Additional Folders ###
|
||||||
api-server/lib/*
|
|
||||||
curriculum/dist
|
curriculum/dist
|
||||||
curriculum/build
|
curriculum/build
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
**/.cache
|
**/.cache
|
||||||
**/*fixtures*
|
**/*fixtures*
|
||||||
api-server/lib
|
|
||||||
client/**/trending.json
|
client/**/trending.json
|
||||||
client/**/search-bar.json
|
client/**/search-bar.json
|
||||||
client/config/*.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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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