diff --git a/.dockerignore b/.dockerignore index 6acb02cf..56fe445d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,10 +3,10 @@ .husky .next node_modules -public # Exclude tests cypress +cypress.config.ts # Exclude configuration files .dockerignore @@ -16,15 +16,9 @@ cypress .prettier* *docker-compose* *Dockerfile* -*.config.js -tsconfig.json # Exlude documentation docs *.md npm-debug.log - -# Environment variables -.env -.env*.local diff --git a/.github/workflows/e2e_tests_preview.yml b/.github/workflows/e2e_tests_preview.yml index 952b819c..cd3452bb 100644 --- a/.github/workflows/e2e_tests_preview.yml +++ b/.github/workflows/e2e_tests_preview.yml @@ -1,97 +1,127 @@ name: Cypress E2E Tests (Preview Deployment) on: - workflow_dispatch: - inputs: - run_deploy: - description: 'Run deploy-preview job' - required: false - type: boolean - default: false + pull-request: + types: + - ready_for_review + +env: + AWS_REGION: ca-central-1 # set this to your preferred AWS region, e.g. us-west-1 + ECR_REPOSITORY: harp-video # set this to your Amazon ECR repository name + ECS_SERVICE: harp-video-website-staging-deployment # set this to your Amazon ECS service name + ECS_CLUSTER: HarpVideoDeployment # set this to your Amazon ECS cluster name + ECS_TASK_DEFINITION: amazon-ecs-task-definition.json # set this to the path to your Amazon ECS task definition + # file, e.g. .aws/task-definition.json + CONTAINER_NAME: harp-video # set this to the name of the container in the + # containerDefinitions section of your task definition + +permissions: + id-token: write # This is required for requesting the JWT + contents: read # This is required for actions/checkout jobs: deploy-preview: - name: Deploy Preview + name: Deploy runs-on: ubuntu-latest - if: ${{ inputs.run_deploy }} - env: - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + environment: preview + steps: - - uses: actions/checkout@v4 - - name: Install Vercel CLI - run: npm install --global vercel@latest - - name: Pull Vercel Environment Information - run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }} - - name: Write .env - run: | - echo "${{ secrets.ENV_PREVIEW }}" > .env - - name: Build Project Artifacts - run: vercel build --token=${{ secrets.VERCEL_TOKEN }} - - name: Deploy Project Artifacts to Vercel - run: | - url="$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }})" - vercel alias --token=${{ secrets.VERCEL_TOKEN }} set "$url" ubco-capstone-team-3.vercel.app + - name: Checkout + uses: actions/checkout@v4 + + - name: Create env File + run: | + echo "${{ secrets.STAGING_ENV_FILE }}" > .env + + - name: configure aws credentials + uses: aws-actions/configure-aws-credentials@v3 + with: + role-to-assume: arn:aws:iam::932748244514:role/GithubActionRole + role-session-name: GithubActionRole + aws-region: ${{ env.AWS_REGION }} + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@62f4f872db3836360b72999f4b87f1ff13310f3a + - name: Build, tag, and push image to Amazon ECR + id: build-image + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + IMAGE_TAG: ${{ github.sha }} + run: | + # Build a docker container and + # push it to ECR so that it can + # be deployed to ECS. + docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG -f Dockerfile.prod . + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT + - name: Fill in the new image ID in the Amazon ECS task definition + id: task-def + uses: aws-actions/amazon-ecs-render-task-definition@c804dfbdd57f713b6c079302a4c01db7017a36fc + with: + task-definition: ${{ env.ECS_TASK_DEFINITION }} + container-name: ${{ env.CONTAINER_NAME }} + image: ${{ steps.build-image.outputs.image }} + - name: Deploy Amazon ECS task definition + uses: aws-actions/amazon-ecs-deploy-task-definition@df9643053eda01f169e64a0e60233aacca83799a + with: + task-definition: ${{ steps.task-def.outputs.task-definition }} + service: ${{ env.ECS_SERVICE }} + cluster: ${{ env.ECS_CLUSTER }} + wait-for-service-stability: true setup-db: - name: Set up database + name: Migrate Database runs-on: ubuntu-latest - if: ${{ inputs.run_deploy }} - needs: [ 'deploy-preview' ] - continue-on-error: true + needs: ['deploy-preview'] steps: - - uses: actions/checkout@v4 - - name: Write environment variables - run: | - echo "${{ secrets.ENV_PREVIEW }}" > .env - - name: Migrate Database - run: npx prisma migrate deploy &> prisma_migrate.log - - + - uses: actions/checkout@v4 + - name: Write environment variables + run: | + echo "${{ secrets.STAGING_ENV_FILE }}" > .env + - name: Install Packages + run: | + npm ci + - name: Generate Prisma + run: | + npx prisma generate + - name: Migrate Database + run: npx prisma migrate deploy cypress-e2e: - name: Run cypress E2E tests on preview deployment + name: Run Cypress E2E Tests on Preview Deployment runs-on: ubuntu-latest - if: always() - needs: [ deploy-preview, setup-db ] - outputs: - PR_ID: ${{ steps.save-cypress-outputs.outputs.PR_ID }} + needs: [ deploy-preview, setup-db] steps: - uses: actions/checkout@v4 - name: Write environment variables run: | - echo "${{ secrets.ENV_PREVIEW }}" > .env - - name: Cypress Run E2E Tests - uses: cypress-io/github-action@v6 - env: - GITHUB_TOKEN: ${{ secrets.PERSONAL_GITHUB_ACCESS_TOKEN }} - with: - wait-on: ${{ secrets.CYPRESS_BASE_URL }} - browser: chrome - headed: true - config: baseUrl=${{ secrets.CYPRESS_BASE_URL }} + echo "${{ secrets.STAGING_ENV_FILE }}" > .env + - name: Install Packages + run: | + npm ci + - name: Generate Prisma + run: | + npx prisma generate + - name: Run Cypress Tests + run: | + npx cypress run - name: Upload screenshots - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: cypress-screenshots path: cypress/screenshots if-no-files-found: ignore - name: Upload videos - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: cypress-videos path: cypress/videos if-no-files-found: ignore - - name: Save cypress outputs - if: always() - id: save-cypress-outputs - run: | - echo "PR_ID=$CYPRESS_PULL_REQUEST_ID" >> $GITHUB_OUTPUT - - name: Upload test results + - name: Upload Test Results if: always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: e2e-test-results path: cypress/reports/e2e/e2e*.json @@ -104,7 +134,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Download test results - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: e2e-test-results path: cypress/reports @@ -116,9 +146,7 @@ jobs: npx mochawesome-json-to-md@0.7.2 -p cypress/reports/test_results.json -o cypress/reports/test_results.md --reportTitle="E2E Test Results" - name: Create report and post on PR uses: peter-evans/create-or-update-comment@v3 - env: - PR_ID: ${{ needs.cypress-e2e.outputs.PR_ID }} with: - issue-number: ${{ env.PR_ID }} + issue-number: ${{ github.event.number }} body-path: cypress/reports/test_results.md token: ${{ secrets.PERSONAL_GITHUB_ACCESS_TOKEN }} diff --git a/.github/workflows/local_tests.yml b/.github/workflows/local_tests.yml index 532dfae8..bcfbd654 100644 --- a/.github/workflows/local_tests.yml +++ b/.github/workflows/local_tests.yml @@ -20,7 +20,7 @@ jobs: run: npm ci - name: Write environment variables run: | - echo "${{ secrets.ENV_TEST_LOCAL }}" > .env + echo "${{ secrets.STAGING_ENV_FILE }}" > .env echo "" > cypress.config.json - name: ESLint run: npm run lint @@ -40,7 +40,7 @@ jobs: node-version: '18.18.0' - name: Write environment variables run: | - echo "${{ secrets.ENV_TEST_LOCAL }}" > .env + echo "${{ secrets.STAGING_ENV_FILE }}" > .env echo "" > cypress.config.json - name: Run Component Tests uses: cypress-io/github-action@v6 @@ -88,98 +88,3 @@ jobs: issue-number: ${{ env.PR_ID }} body-path: cypress/reports/test_results.md token: ${{ secrets.PERSONAL_GITHUB_ACCESS_TOKEN }} - - migrate-db-e2e: - name: Set up database - runs-on: ubuntu-latest - needs: [ run-lint ] - continue-on-error: true - steps: - - uses: actions/checkout@v4 - - name: Write environment variables - run: | - echo "${{ secrets.ENV_TEST_LOCAL }}" > .env - - name: Migrate Database - run: npx prisma migrate deploy &> prisma_migrate.log - - cypress-e2e: - name: Cypress Run E2E Tests on GitHub machine - runs-on: ubuntu-latest - needs: [ migrate-db-e2e ] - outputs: - PR_ID: ${{ steps.save-cypress-outputs.outputs.PR_ID }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 - with: - node-version: '18.18.0' - - name: Write environment variables - run: | - echo "${{ secrets.ENV_TEST_LOCAL }}" > .env - echo "" > cypress.config.json - - name: Run E2E Tests - uses: cypress-io/github-action@v6 - env: - CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} - GITHUB_TOKEN: ${{ secrets.PERSONAL_GITHUB_ACCESS_TOKEN }} - CYPRESS_BASE_URL: ${{ secrets.CYPRESS_BASE_URL }} - with: - build: npm run build - start: npm run start - install: true - browser: chrome - headed: true - wait-on: ${{ env.CYPRESS_BASE_URL }} - config: baseUrl=${{ env.CYPRESS_BASE_URL }} - - name: Save cypress video - if: failure() - uses: actions/upload-artifact@v3 - with: - name: cypress-videos - path: cypress/videos - if-no-files-found: ignore - - name: Save cypress screenshots - if: failure() - uses: actions/upload-artifact@v3 - with: - name: cypress-screenshots - path: cypress/screenshots - if-no-files-found: ignore - - name: Save cypress results - id: save-cypress-outputs - run: | - echo "PR_ID=$CYPRESS_PULL_REQUEST_ID" >> $GITHUB_OUTPUT - - name: Upload test results - if: always() - uses: actions/upload-artifact@v3 - with: - name: e2e-test-results - path: cypress/reports/e2e/e2e*.json - - e2e-results-report: - name: Report E2E test results - runs-on: ubuntu-latest - if: always() - needs: [ cypress-e2e ] - steps: - - uses: actions/checkout@v4 - - name: Download test results - uses: actions/download-artifact@v3 - with: - name: e2e-test-results - path: cypress/reports - - name: Merge reports to 1 report file - run: | - npx mochawesome-merge cypress/reports/*.json > cypress/reports/test_results.json - - name: Convert report file to markdown - run: | - npx mochawesome-json-to-md@0.7.2 -p cypress/reports/test_results.json -o cypress/reports/test_results.md --reportTitle="E2E Test Results (Local)" - - name: Create report and post on PR - uses: peter-evans/create-or-update-comment@v3 - continue-on-error: true - env: - PR_ID: ${{ needs.cypress-e2e.outputs.PR_ID || github.event.number }} - with: - issue-number: ${{ env.PR_ID }} - body-path: cypress/reports/test_results.md - token: ${{ secrets.PERSONAL_GITHUB_ACCESS_TOKEN }} diff --git a/.gitignore b/.gitignore index fb26e499..79e04669 100644 --- a/.gitignore +++ b/.gitignore @@ -26,8 +26,7 @@ yarn-debug.log* yarn-error.log* # local env files -.env*.local -.env +.env* cypress.env.json # vercel diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 00000000..bf767589 --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,61 @@ +FROM node:20-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package*.json ./ +RUN npm ci + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry during the build. +# ENV NEXT_TELEMETRY_DISABLED 1 + +# Generate prisma client +RUN npx prisma generate + +RUN npm run build + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV production +# Uncomment the following line in case you want to disable telemetry during runtime. +# ENV NEXT_TELEMETRY_DISABLED 1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Set the correct permission for prerender cache +RUN mkdir .next +RUN chown nextjs:nodejs .next + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT 3000 +# set hostname to localhost +ENV HOSTNAME "0.0.0.0" + +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/next-config-js/output +CMD ["node", "server.js"] diff --git a/amazon-ecs-task-definition.json b/amazon-ecs-task-definition.json new file mode 100644 index 00000000..815765cf --- /dev/null +++ b/amazon-ecs-task-definition.json @@ -0,0 +1,47 @@ +{ + "family": "harp-video", + "containerDefinitions": [ + { + "name": "harp-video", + "image": "932748244514.dkr.ecr.ca-central-1.amazonaws.com/harp-video:latest", + "cpu": 0, + "portMappings": [ + { + "name": "harp-video-3000-tcp", + "containerPort": 3000, + "hostPort": 3000, + "protocol": "tcp", + "appProtocol": "http" + } + ], + "essential": true, + "environment": [], + "environmentFiles": [], + "mountPoints": [], + "volumesFrom": [], + "ulimits": [], + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-create-group": "true", + "awslogs-group": "/ecs/", + "awslogs-region": "ca-central-1", + "awslogs-stream-prefix": "ecs" + }, + "secretOptions": [] + } + } + ], + "taskRoleArn": "arn:aws:iam::932748244514:role/ecsTaskExecutionRole", + "executionRoleArn": "arn:aws:iam::932748244514:role/ecsTaskExecutionRole", + "networkMode": "awsvpc", + "requiresCompatibilities": [ + "FARGATE" + ], + "cpu": "1024", + "memory": "3072", + "runtimePlatform": { + "cpuArchitecture": "X86_64", + "operatingSystemFamily": "LINUX" + } +} diff --git a/cypress.config.ts b/cypress.config.ts index e8d9dbd9..ede7387f 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -13,19 +13,23 @@ import loadInSubmissionBoxes from './cypress/tasks/loadInSubmissionBoxes' import loadOutSubmissionBoxes from './cypress/tasks/loadOutSubmissionBoxes' import submitVideoToSubmissionBox from './cypress/tasks/submitVideoToSubmissionBox' import createRequestSubmissionForUser from './cypress/tasks/createRequestSubmissionForUser' +import getVerificationToken from 'cypress/tasks/getVerificationToken' require('dotenv').config() +console.log(process.env.NEXT_PUBLIC_CYPRESS_BASE_URL) + export default defineConfig({ e2e: { - projectId: process.env.CYPRESS_PROJECT_ID, - baseUrl: process.env.CYPRESS_BASE_URL ?? 'http://localhost:3000', + projectId: process.env.NEXT_PUBLIC_CYPRESS_PROJECT_ID, + baseUrl: process.env.NEXT_PUBLIC_CYPRESS_BASE_URL ?? 'http://localhost:3000', setupNodeEvents(on, config) { // implement node event listeners here on('task', { clearDB, createOneVideoAndRetrieveVideoId, getUserId, + getVerificationToken, createUser, getSubmissionBoxes, getSubmissionBoxManagers, diff --git a/cypress/e2e/app/api/dashboard/myboxes.cy.ts b/cypress/e2e/app/api/dashboard/myboxes.cy.ts index e5ce5a97..400bfe4c 100644 --- a/cypress/e2e/app/api/dashboard/myboxes.cy.ts +++ b/cypress/e2e/app/api/dashboard/myboxes.cy.ts @@ -22,14 +22,11 @@ describe('Test my submission boxes API', () => { const email = 'noSubmission@user.com' const password = 'noSubmission1' - cy.visit('/signup') - cy.get('[data-cy="email"]').type(email) - cy.get('[data-cy="password"]').type(password) - cy.get('[data-cy="passwordConfirmation"]').type(password) - cy.get('[data-cy="submit"]').click() - cy.url().should('contain', 'login') + // Sign up + cy.task('createUser', { email, password }) // Login + cy.visit('/login') cy.get('[data-cy=email]').type(email) cy.get('[data-cy=password]').type(password) cy.get('[data-cy=submit]').click() diff --git a/cypress/e2e/app/api/dashboard/requestedsubmissions.cy.ts b/cypress/e2e/app/api/dashboard/requestedsubmissions.cy.ts index 492811af..39b495bf 100644 --- a/cypress/e2e/app/api/dashboard/requestedsubmissions.cy.ts +++ b/cypress/e2e/app/api/dashboard/requestedsubmissions.cy.ts @@ -24,14 +24,11 @@ describe('Test requested submission API', () => { const email = 'noSubmission' + uuidv4() + '@user.com' const password = 'noSubmission1' - cy.visit('/signup') - cy.get('[data-cy="email"]').type(email) - cy.get('[data-cy="password"]').type(password) - cy.get('[data-cy="passwordConfirmation"]').type(password) - cy.get('[data-cy="submit"]').click() - cy.url().should('contain', 'login') + // Sign up + cy.task('createUser', { email, password }) // Login + cy.visit('/login') cy.get('[data-cy=email]').type(email) cy.get('[data-cy=password]').type(password) cy.get('[data-cy=submit]').click() diff --git a/cypress/e2e/app/api/submission-box/create.cy.ts b/cypress/e2e/app/api/submission-box/create.cy.ts index 28b37737..b82a625d 100644 --- a/cypress/e2e/app/api/submission-box/create.cy.ts +++ b/cypress/e2e/app/api/submission-box/create.cy.ts @@ -28,14 +28,10 @@ describe('Test submission box creation API', () => { const password = 'Password1' // Sign up - cy.visit('/signup') - cy.get('[data-cy="email"]').type(email) - cy.get('[data-cy="password"]').type(password) - cy.get('[data-cy="passwordConfirmation"]').type(password) - cy.get('[data-cy="submit"]').click() - cy.url().should('contain', 'login') + cy.task('createUser', { email, password }) // Login + cy.visit('/login') cy.get('[data-cy=email]').type(email) cy.get('[data-cy=password]').type(password) cy.get('[data-cy=submit]').click() diff --git a/cypress/e2e/app/dashboard/myboxes.cy.ts b/cypress/e2e/app/dashboard/myboxes.cy.ts index 5772f4a9..67380f31 100644 --- a/cypress/e2e/app/dashboard/myboxes.cy.ts +++ b/cypress/e2e/app/dashboard/myboxes.cy.ts @@ -10,17 +10,11 @@ describe('Dashboard My Submission Boxes Tests', () => { const email = 'noSubmissions@box.com' const password = 'noSubmissions1' - cy.visit('/signup') - // Sign up - cy.visit('/signup') - cy.get('[data-cy="email"]').type(email) - cy.get('[data-cy="password"]').type(password) - cy.get('[data-cy="passwordConfirmation"]').type(password) - cy.get('[data-cy="submit"]').click() - cy.url({ timeout: TIMEOUT.EXTRA_LONG }).should('contain', 'login') + cy.task('createUser', { email, password }) // Login + cy.visit('/login') cy.get('[data-cy=email]').type(email) cy.get('[data-cy=password]').type(password) cy.get('[data-cy=submit]').click() diff --git a/cypress/e2e/app/dashboard/requestedsubmissions.cy.ts b/cypress/e2e/app/dashboard/requestedsubmissions.cy.ts index f6ac1ccf..d409e999 100644 --- a/cypress/e2e/app/dashboard/requestedsubmissions.cy.ts +++ b/cypress/e2e/app/dashboard/requestedsubmissions.cy.ts @@ -11,17 +11,11 @@ describe('Dashboard Requested Submission Boxes Tests', () => { const email = 'noSubmissions@box.com' const password = 'noSubmissions1' - cy.visit('/signup') - // Sign up - cy.visit('/signup') - cy.get('[data-cy="email"]').type(email) - cy.get('[data-cy="password"]').type(password) - cy.get('[data-cy="passwordConfirmation"]').type(password) - cy.get('[data-cy="submit"]').click() - cy.url({ timeout: TIMEOUT.EXTRA_LONG }).should('contain', 'login') + cy.task('createUser', { email, password }) // Login + cy.visit('/login') cy.get('[data-cy=email]').type(email) cy.get('[data-cy=password]').type(password) cy.get('[data-cy=submit]').click() diff --git a/cypress/e2e/app/dashboard/searchBar.cy.ts b/cypress/e2e/app/dashboard/searchBar.cy.ts index 5f238f3f..c918cdf9 100644 --- a/cypress/e2e/app/dashboard/searchBar.cy.ts +++ b/cypress/e2e/app/dashboard/searchBar.cy.ts @@ -11,14 +11,10 @@ describe('Dashboard Search Bar', () => { const password = 'Password1' // Sign up - cy.visit('/signup') - cy.get('[data-cy="email"]').type(email) - cy.get('[data-cy="password"]').type(password) - cy.get('[data-cy="passwordConfirmation"]').type(password) - cy.get('[data-cy="submit"]').click() - cy.url().should('contain', 'login') + cy.task('createUser', { email, password }) // Login + cy.visit('/login') cy.get('[data-cy=email]').type(email) cy.get('[data-cy=password]').type(password) cy.get('[data-cy=submit]').click() diff --git a/cypress/e2e/app/dashboard/videoTabs.cy.ts b/cypress/e2e/app/dashboard/videoTabs.cy.ts index 7d2c7da8..2d4f9a82 100644 --- a/cypress/e2e/app/dashboard/videoTabs.cy.ts +++ b/cypress/e2e/app/dashboard/videoTabs.cy.ts @@ -19,14 +19,10 @@ describe('Dashboard Recent Videos Tests', () => { cy.visit('/signup') // Sign up - cy.visit('/signup') - cy.get('[data-cy="email"]').type(email) - cy.get('[data-cy="password"]').type(password) - cy.get('[data-cy="passwordConfirmation"]').type(password) - cy.get('[data-cy="submit"]').click() - cy.url({ timeout: TIMEOUT.EXTRA_LONG }).should('contain', 'login') + cy.task('createUser', { email, password }) // Login + cy.visit('/login') cy.get('[data-cy=email]').type(email) cy.get('[data-cy=password]').type(password) cy.get('[data-cy=submit]').click() @@ -48,12 +44,7 @@ describe('Dashboard Recent Videos Tests', () => { const password = 'randomPasswordCool1' // Sign up - cy.visit('/signup') - cy.get('[data-cy="email"]').type(email) - cy.get('[data-cy="password"]').type(password) - cy.get('[data-cy="passwordConfirmation"]').type(password) - cy.get('[data-cy="submit"]').click() - cy.url({ timeout: TIMEOUT.EXTRA_LONG }).should('contain', 'login') + cy.task('createUser', { email, password }) // Create submission box and submit video const videoTitle = 'Test Video Title ' + uuidv4() @@ -66,6 +57,7 @@ describe('Dashboard Recent Videos Tests', () => { }) // Login + cy.visit('/login') cy.get('[data-cy=email]').type(email) cy.get('[data-cy=password]').type(password) cy.get('[data-cy=submit]').click() @@ -100,12 +92,7 @@ describe('Dashboard Recent Videos Tests', () => { const password = 'randomPasswordCool1' // Sign up - cy.visit('/signup') - cy.get('[data-cy="email"]').type(email) - cy.get('[data-cy="password"]').type(password) - cy.get('[data-cy="passwordConfirmation"]').type(password) - cy.get('[data-cy="submit"]').click() - cy.url({ timeout: TIMEOUT.EXTRA_LONG }).should('contain', 'login') + cy.task('createUser', { email, password }) // Create submission box and submit video const videoTitle = 'Test Video Title ' + uuidv4() @@ -119,6 +106,7 @@ describe('Dashboard Recent Videos Tests', () => { }) // Login + cy.visit('/login') cy.get('[data-cy=email]').type(email) cy.get('[data-cy=password]').type(password) cy.get('[data-cy=submit]').click() diff --git a/cypress/e2e/app/login.cy.ts b/cypress/e2e/app/login.cy.ts index 1e3d03f9..3eee16d7 100644 --- a/cypress/e2e/app/login.cy.ts +++ b/cypress/e2e/app/login.cy.ts @@ -46,37 +46,25 @@ describe('Login tests', () => { }) }) - // TODO: fix flaky test - it.skip('Should allow user to create an account, login, and logout', () => { + it('Should allow user to create an account, login, and logout', () => { // User data - const userEmail = `test-${ uuidv4() }@test.com` + const email = `test-${ uuidv4() }@test.com` const password = 'P@ssw0rd' - // Create user - cy.visit('/signup') - cy.get('[data-cy="email"]').type(userEmail) - cy.get('[data-cy="password"]').type(password) - cy.get('[data-cy="passwordConfirmation"]').type(password) - cy.get('[data-cy="submit"]').click() - - // We shouldn't be on the signup page anymore - cy.url({ timeout: TIMEOUT.LONG }).should('include', '/login') + // Sign up + cy.task('createUser', { email, password }) // We should be able to log in - cy.get('[data-cy="email"]').type(userEmail) + cy.visit('/login') + cy.get('[data-cy="email"]').type(email) cy.get('[data-cy="password"]').type(password) cy.get('[data-cy="submit"]').click() // We shouldn't be on the login page anymore cy.url({ timeout: TIMEOUT.EXTRA_EXTRA_LONG }).should('include', '/dashboard') - // TODO: Fix this assert to make it less flaky. It works locally, but it does not work on our github action. - // cy.get('[data-cy="dashboard-message"]', { timeout: TIMEOUT.EXTRA_EXTRA_LONG }).should( - // 'contain', - // `Welcome to the dashboard, ${userEmail}!` - // ) // We should be able to log out - cy.get('[data-cy="sign-out-button"]').click() + cy.get('[data-cy="sign-out-button"]').click({ force: true }) cy.title().should('eq', 'Harp: A Secure Platform for Anonymous Video Submission') cy.get('[data-cy="sign-up-button"]').contains('Sign Up') diff --git a/cypress/e2e/app/submission-box/create/full-process.cy.ts b/cypress/e2e/app/submission-box/create/full-process.cy.ts index ca1905c0..bf6ae4c2 100644 --- a/cypress/e2e/app/submission-box/create/full-process.cy.ts +++ b/cypress/e2e/app/submission-box/create/full-process.cy.ts @@ -1,21 +1,17 @@ import { v4 as uuidv4 } from 'uuid' +import { DELAY } from '../../../../utils/constants' describe('Test full submission box creation', () => { - // TODO: This will be fixed in another PR - it.skip('should work', () => { + it('should be able to go through the full creation path', () => { cy.task('clearDB') const email = 'user@example.com' const password = 'Password1' // Sign up - cy.visit('/signup') - cy.get('[data-cy="email"]').type(email) - cy.get('[data-cy="password"]').type(password) - cy.get('[data-cy="passwordConfirmation"]').type(password) - cy.get('[data-cy="submit"]').click() - cy.url().should('contain', 'login') + cy.task('createUser', { email, password }) // Login + cy.visit('/login') cy.get('[data-cy=email]').type(email) cy.get('[data-cy=password]').type(password) cy.get('[data-cy=submit]').click() @@ -43,14 +39,14 @@ describe('Test full submission box creation', () => { cy.get('[data-cy=submission-box-title]').type(sb.title) cy.get('[data-cy=description]').type(sb.description) cy.get('.data-cy-date-time-picker').type(sb.closesAt.replaceAll(' ', '')) - cy.get('[data-cy=next]').click() + cy.get('[data-cy=Next]').click() cy.get('[data-cy=title]').should('contain', 'Request Submissions') cy.wrap(sb.requestedEmails).each((email: string) => { cy.get('[data-cy=email]').type(email).type('{enter}') }) - cy.get('[data-cy=next]').click() + cy.get('[data-cy=Next]').click() cy.get('[data-cy=title]').should('contain', 'Review & Create') @@ -61,7 +57,9 @@ describe('Test full submission box creation', () => { cy.get('[data-cy=requested-emails]').should('contain', email) }) - cy.get('[data-cy=next]').click() + cy.get('[data-cy=Create]').click() + + cy.wait(DELAY.MEDIUM) cy.task('getSubmissionBoxes').then((submissionBoxes: any) => { assert(Array.isArray(submissionBoxes)) diff --git a/cypress/e2e/app/submission-box/create/request-submissions.cy.ts b/cypress/e2e/app/submission-box/create/request-submissions.cy.ts index 0025b0f0..70971659 100644 --- a/cypress/e2e/app/submission-box/create/request-submissions.cy.ts +++ b/cypress/e2e/app/submission-box/create/request-submissions.cy.ts @@ -2,7 +2,7 @@ import { TIMEOUT } from '../../../../utils/constants' import { v4 as uuidv4 } from 'uuid' describe('Submission box request submissions tests', () => { - before(() => { + beforeEach(() => { cy.task('clearDB') }) @@ -10,25 +10,19 @@ describe('Submission box request submissions tests', () => { context('Logged in', () => { beforeEach(() => { - cy.session('testuser', () => { - const email = 'user' + uuidv4() + '@example.com' - currentUserEmail = email - const password = 'Password1' - - // Sign up - cy.visit('/signup') - cy.get('[data-cy="email"]').type(email) - cy.get('[data-cy="password"]').type(password) - cy.get('[data-cy="passwordConfirmation"]').type(password) - cy.get('[data-cy="submit"]').click() - cy.url().should('contain', 'login') - - // Login - cy.get('[data-cy=email]').type(email) - cy.get('[data-cy=password]').type(password) - cy.get('[data-cy=submit]').click() - cy.url().should('not.contain', 'login') - }) + const email = 'user' + uuidv4() + '@example.com' + currentUserEmail = email + const password = 'Password1' + + /// Sign up + cy.task('createUser', { email, password }) + + // Login + cy.visit('/login') + cy.get('[data-cy=email]').type(email) + cy.get('[data-cy=password]').type(password) + cy.get('[data-cy=submit]').click() + cy.url().should('not.contain', 'login') cy.visit('/dashboard') cy.get('[data-cy="Create new"]').click() diff --git a/cypress/e2e/app/submission-box/create/review-and-create.cy.ts b/cypress/e2e/app/submission-box/create/review-and-create.cy.ts index c5a0edcc..2c5ae4bf 100644 --- a/cypress/e2e/app/submission-box/create/review-and-create.cy.ts +++ b/cypress/e2e/app/submission-box/create/review-and-create.cy.ts @@ -13,14 +13,10 @@ describe('Submission box review and create tests', () => { const password = 'Password1' // Sign up - cy.visit('/signup') - cy.get('[data-cy="email"]').type(email) - cy.get('[data-cy="password"]').type(password) - cy.get('[data-cy="passwordConfirmation"]').type(password) - cy.get('[data-cy="submit"]').click() - cy.url().should('contain', 'login') + cy.task('createUser', { email, password }) // Login + cy.visit('/login') cy.get('[data-cy=email]').type(email) cy.get('[data-cy=password]').type(password) cy.get('[data-cy=submit]').click() diff --git a/cypress/e2e/app/submission-box/create/settings.cy.ts b/cypress/e2e/app/submission-box/create/settings.cy.ts index ea776888..bf0bec3a 100644 --- a/cypress/e2e/app/submission-box/create/settings.cy.ts +++ b/cypress/e2e/app/submission-box/create/settings.cy.ts @@ -13,14 +13,10 @@ describe('Submission box settings tests', () => { const password = 'Password1' // Sign up - cy.visit('/signup') - cy.get('[data-cy="email"]').type(email) - cy.get('[data-cy="password"]').type(password) - cy.get('[data-cy="passwordConfirmation"]').type(password) - cy.get('[data-cy="submit"]').click() - cy.url().should('contain', 'login') + cy.task('createUser', { email, password }) // Login + cy.visit('/login') cy.get('[data-cy=email]').type(email) cy.get('[data-cy=password]').type(password) cy.get('[data-cy=submit]').click() diff --git a/cypress/e2e/app/verify-email.cy.ts b/cypress/e2e/app/verify-email.cy.ts new file mode 100644 index 00000000..80dc56d9 --- /dev/null +++ b/cypress/e2e/app/verify-email.cy.ts @@ -0,0 +1,57 @@ +import { TIMEOUT } from '../../utils/constants' + +describe('Test email verification', () => { + // Using a real email address, else I think Amazon might be upset about our emails bouncing + const email = 'no-reply@harpvideo.ca' + const password = 'Password1' + + beforeEach(() => { + cy.task('clearDB') + + // Sign up + cy.task('createUser', { email, password, verifyEmail: false }) + + // Login + cy.visit('/login') + cy.get('[data-cy=email]').type(email) + cy.get('[data-cy=password]').type(password) + cy.get('[data-cy=submit]').click() + + cy.url().should('not.contain', '/login') + }) + + it('should prompt user to verify after signup', () => { + cy.url().should('contain', '/verify-email') + }) + + const pages = [ + '/dashboard', + '/submission-box/create', + '/video/upload', + ] + it('should block user from pages if email is not verified', () => { + cy.wrap(pages).each((page: string) => { + cy.visit(page) + cy.url().should('contain', '/verify-email') + }) + }) + + it('should allow user verify email', () => { + cy.get('[data-cy=verify-email-button]').should('be.visible').click() + cy.get('[data-cy=verify-email-button]', { timeout: TIMEOUT.EXTRA_LONG }).should('contain.text', 'Email Sent!') + + cy.task('getVerificationToken', email).then((token) => { + cy.visit(`/verify-email/${ token }`) + cy.url().should('match', /\/verify-email\/[a-zA-Z0-9_-]+$/) + cy.get('body').should('contain', 'Email verified!') + cy.get('[data-cy=dashboard-button]').click() + cy.url().should('contain', '/dashboard') + + // Test that user can access pages + cy.wrap(pages).each((page: string) => { + cy.visit(page) + cy.url().should('not.contain', '/verify-email') + }) + }) + }) +}) diff --git a/cypress/e2e/app/video/edit.cy.ts b/cypress/e2e/app/video/edit.cy.ts index 8e2c6f11..b9d3599c 100644 --- a/cypress/e2e/app/video/edit.cy.ts +++ b/cypress/e2e/app/video/edit.cy.ts @@ -27,14 +27,10 @@ describe('Test video editing page', () => { const email = 'user' + uuidv4() + '@example.com' const password = 'Password1' // Sign up - cy.visit('/signup') - cy.get('[data-cy="email"]').type(email) - cy.get('[data-cy="password"]').type(password) - cy.get('[data-cy="passwordConfirmation"]').type(password) - cy.get('[data-cy="submit"]').click() - cy.url().should('contain', 'login') + cy.task('createUser', { email, password }) // Login + cy.visit('/login') cy.get('[data-cy=email]').type(email) cy.get('[data-cy=password]').type(password) cy.get('[data-cy=submit]').click() diff --git a/cypress/e2e/app/video/upload.cy.ts b/cypress/e2e/app/video/upload.cy.ts index cad2b11c..d23d2ef6 100644 --- a/cypress/e2e/app/video/upload.cy.ts +++ b/cypress/e2e/app/video/upload.cy.ts @@ -36,7 +36,7 @@ describe('Test Video Upload and Streaming Processing Pipeline', () => { /* Upload video from "file system" */ cy.visit('/video/upload') cy.get('[data-cy=test-input]').selectFile('public/videos/lemons.mp4', { force: true }) - cy.get('[data-cy=loading-circle-blur-background]', { timeout: TIMEOUT.LONG }).should('be.visible') + cy.get('[data-cy=loading-circle-blur-background]', { timeout: TIMEOUT.EXTRA_EXTRA_LONG }).should('be.visible') /* Check if the url changes and displays the loading icon */ cy.url({ timeout: TIMEOUT.LONG }).should('contain', 'video/edit/') diff --git a/cypress/tasks/clearDB.ts b/cypress/tasks/clearDB.ts index cf20ee41..07bcb19f 100644 --- a/cypress/tasks/clearDB.ts +++ b/cypress/tasks/clearDB.ts @@ -1,6 +1,7 @@ import prisma from '@/lib/prisma' export default async function clearDB() { + await prisma.requestedEmailVerification.deleteMany({}) await prisma.submittedVideo.deleteMany({}) await prisma.requestedSubmission.deleteMany({}) await prisma.submissionBoxManager.deleteMany({}) diff --git a/cypress/tasks/createUser.ts b/cypress/tasks/createUser.ts index 3727a500..077f7f8d 100644 --- a/cypress/tasks/createUser.ts +++ b/cypress/tasks/createUser.ts @@ -1,12 +1,13 @@ import prisma from '@/lib/prisma' import { hash } from 'bcrypt' -export default async function createUser(user: { email: string; password: string }) { +export default async function createUser(user: { email: string; password: string; verifyEmail: boolean | undefined }) { const hashedPassword = await hash(user.password, 10) return await prisma.user.create({ data: { email: user.email, password: hashedPassword, + emailVerified: (user.verifyEmail ?? true) ? new Date() : null, }, }) } diff --git a/cypress/tasks/getVerificationToken.ts b/cypress/tasks/getVerificationToken.ts new file mode 100644 index 00000000..e53fa25a --- /dev/null +++ b/cypress/tasks/getVerificationToken.ts @@ -0,0 +1,15 @@ +import prisma from '@/lib/prisma' + +export default async function getVerificationToken(userEmail: string): Promise { + return ( + await prisma.requestedEmailVerification.findUniqueOrThrow({ + where: { + userId: (await prisma.user.findUniqueOrThrow({ + where: { email: userEmail }, + select: { id: true }, + })).id, + }, + select: { token: true }, + }) + ).token +} diff --git a/cypress/tasks/loadInSubmissionBoxes.ts b/cypress/tasks/loadInSubmissionBoxes.ts index c56f06bf..432ece4b 100644 --- a/cypress/tasks/loadInSubmissionBoxes.ts +++ b/cypress/tasks/loadInSubmissionBoxes.ts @@ -9,6 +9,7 @@ export default async function loadInSubmissionBoxes() { data: { email, password: hashedPassword, + emailVerified: new Date(), accounts: { create: { type: 'bcrypt', diff --git a/cypress/tasks/loadOutSubmissionBoxes.ts b/cypress/tasks/loadOutSubmissionBoxes.ts index ea8f859e..c9272d31 100644 --- a/cypress/tasks/loadOutSubmissionBoxes.ts +++ b/cypress/tasks/loadOutSubmissionBoxes.ts @@ -15,6 +15,7 @@ export default async function loadInSubmissionBoxes(props: LoadInSubmissionBoxes data: { email, password: hashedPassword, + emailVerified: new Date(), accounts: { create: { type: 'bcrypt', diff --git a/cypress/tasks/populateDB.ts b/cypress/tasks/populateDB.ts index 585c5257..d762c961 100644 --- a/cypress/tasks/populateDB.ts +++ b/cypress/tasks/populateDB.ts @@ -8,6 +8,7 @@ export default async function populateDB() { data: { email: MOCKUSER.email, password: hashedPassword, + emailVerified: new Date(), accounts: { create: { type: 'bcrypt', diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 00000000..d7c25fa3 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,8 @@ +version: '3' + +services: + harp-video-production: + build: + dockerfile: Dockerfile.prod + ports: + - '3000:3000' diff --git a/docs/github/deploy_to_ecs_example.yml b/docs/github/deploy_to_ecs_example.yml new file mode 100644 index 00000000..9ecb10a5 --- /dev/null +++ b/docs/github/deploy_to_ecs_example.yml @@ -0,0 +1,83 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# GitHub recommends pinning actions to a commit SHA. +# To get a newer version, you will need to update the SHA. +# You can also reference a tag or branch, but the action may change without warning. + +# This file was taken off of the Github docs and is the example for how to deploy our application to ECS + +name: Deploy to Amazon ECS Staging + +on: + push: + +env: + AWS_REGION: ca-central-1 # set this to your preferred AWS region, e.g. us-west-1 + ECR_REPOSITORY: harp-video # set this to your Amazon ECR repository name + ECS_SERVICE: harp-video-website-staging-deployment # set this to your Amazon ECS service name + ECS_CLUSTER: HarpVideoDeployment # set this to your Amazon ECS cluster name + ECS_TASK_DEFINITION: amazon-ecs-task-definition.json # set this to the path to your Amazon ECS task definition + # file, e.g. .aws/task-definition.json + CONTAINER_NAME: harp-video # set this to the name of the container in the + # containerDefinitions section of your task definition + +permissions: + id-token: write # This is required for requesting the JWT + contents: read # This is required for actions/checkout + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + environment: production + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Create env File + run: | + echo "${{ secrets.STAGING_ENV_FILE }}" > .env + + - name: configure aws credentials + uses: aws-actions/configure-aws-credentials@v3 + with: + role-to-assume: arn:aws:iam::932748244514:role/GithubActionRole + role-session-name: GithubActionRole + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@62f4f872db3836360b72999f4b87f1ff13310f3a + + - name: Build, tag, and push image to Amazon ECR + id: build-image + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + IMAGE_TAG: ${{ github.sha }} + run: | + # Build a docker container and + # push it to ECR so that it can + # be deployed to ECS. + docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG -f Dockerfile.prod . + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT + + - name: Fill in the new image ID in the Amazon ECS task definition + id: task-def + uses: aws-actions/amazon-ecs-render-task-definition@c804dfbdd57f713b6c079302a4c01db7017a36fc + with: + task-definition: ${{ env.ECS_TASK_DEFINITION }} + container-name: ${{ env.CONTAINER_NAME }} + image: ${{ steps.build-image.outputs.image }} + + - name: Deploy Amazon ECS task definition + uses: aws-actions/amazon-ecs-deploy-task-definition@df9643053eda01f169e64a0e60233aacca83799a + with: + task-definition: ${{ steps.task-def.outputs.task-definition }} + service: ${{ env.ECS_SERVICE }} + cluster: ${{ env.ECS_CLUSTER }} + wait-for-service-stability: true diff --git a/docs/guides/build_production_locally.md b/docs/guides/build_production_locally.md new file mode 100644 index 00000000..5ec30541 --- /dev/null +++ b/docs/guides/build_production_locally.md @@ -0,0 +1,37 @@ +# Guide to Building Production Locally + +## Environment File + +- Depending on where you want to run the container, you will need to change what is in your `.env` file. +- If you want to run the image locally, just use the `.env` file for local development. +- Note that you may need to change the URL for the database because they are not running in the same docker container. + +## Commands + +1. Building the Container + - Run the following command from the root of the repo + - `docker build -t harp-video -f Dockerfile.prod .` + +2. Running the Container + - Once the container is built, run the following command + - `docker run -p 3000:3000 -t harp-video` + +## Pushing Build Container to AWS + +- If desired, the built container can also be pushed to AWS +- To do so, run the following commands + +1. Login: `aws ecr get-login-password --region --profile | docker login --username AWS --password-stdin ` + - The `` tag is the AWS region where the container registry is located + - The `` tag is a CLI profile you have configured locally (see + documentation [here](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html)) + - The `` tag has the format `aws_account_id.dkr.ecr.region.amazonaws.com` and is the uri for the Elastic + Container Repository (ECR) + +2. Taging the Image: `docker tag ` + - The `` tag is the tag of the docker image you build locally. This will be `harp-video` if you used the + commands above. + - The `` tag is the uri for the ECR repository (use the same one you used above) + +3. Pushing the image `docker push ` + - Again, the `` tag is the same as in the two previous commands. diff --git a/docs/weekly logs/personal/erin_hiebert_personal_log.md b/docs/weekly logs/personal/erin_hiebert_personal_log.md index b01a06cc..03cacad2 100644 --- a/docs/weekly logs/personal/erin_hiebert_personal_log.md +++ b/docs/weekly logs/personal/erin_hiebert_personal_log.md @@ -180,4 +180,24 @@ ### Additional Context +- NA + +## Week 2 (15/01/2024 - 21/01/2024) + +![](imgs/term-2-week-2-erin-tasks.png) + +### Week Tasks + +- Create mocks for submission detail pages #304 (Completed) +- Implement submission detail page for receiving submission boxes #305 (In Review) +- Detail page for submission boxes #303 (In Progress) + +### Week Goals + +- This week I pivoted from AWS faceblurring to creation of a detail page for our submission boxes +- Mocks were made for both receiving boxes and user's requested submission boxes. That is a user is able to view all videos submitted to their submission boxes from other users AND requested submission boxes will show the video that the user has submitted to it. +- At present, I finished coding and testing the receiving submission boxes detail page(s) + +### Additional Context + - NA \ No newline at end of file diff --git a/docs/weekly logs/personal/imgs/seth-akins-term-2-week-2-tasks.png b/docs/weekly logs/personal/imgs/seth-akins-term-2-week-2-tasks.png new file mode 100644 index 00000000..9f992cbd Binary files /dev/null and b/docs/weekly logs/personal/imgs/seth-akins-term-2-week-2-tasks.png differ diff --git a/docs/weekly logs/personal/imgs/teresa-saller-tasks-w2-t2.png b/docs/weekly logs/personal/imgs/teresa-saller-tasks-w2-t2.png new file mode 100644 index 00000000..fc2e2d65 Binary files /dev/null and b/docs/weekly logs/personal/imgs/teresa-saller-tasks-w2-t2.png differ diff --git a/docs/weekly logs/personal/imgs/term-2-week-2-erin-tasks.png b/docs/weekly logs/personal/imgs/term-2-week-2-erin-tasks.png new file mode 100644 index 00000000..63a7edb5 Binary files /dev/null and b/docs/weekly logs/personal/imgs/term-2-week-2-erin-tasks.png differ diff --git a/docs/weekly logs/personal/imgs/term-2-week-2-k-phan-tasks.png b/docs/weekly logs/personal/imgs/term-2-week-2-k-phan-tasks.png new file mode 100644 index 00000000..a25c97f3 Binary files /dev/null and b/docs/weekly logs/personal/imgs/term-2-week-2-k-phan-tasks.png differ diff --git a/docs/weekly logs/personal/imgs/week-2-t2-tasks-justin.png b/docs/weekly logs/personal/imgs/week-2-t2-tasks-justin.png new file mode 100644 index 00000000..49514303 Binary files /dev/null and b/docs/weekly logs/personal/imgs/week-2-t2-tasks-justin.png differ diff --git a/docs/weekly logs/personal/justin_schoenit_personal_log.md b/docs/weekly logs/personal/justin_schoenit_personal_log.md index 038320fe..d91a42fb 100644 --- a/docs/weekly logs/personal/justin_schoenit_personal_log.md +++ b/docs/weekly logs/personal/justin_schoenit_personal_log.md @@ -1,5 +1,28 @@ # Weekly Individual Log - Justin Schoenit +## Week 2 (Jan 15 - 21) + +### Tasks + +![](imgs/week-2-t2-tasks-justin.png) + +### My Contributions this week + +- Write tests for email verification +- Bug fix email verification + - Use middleware.ts file for checking that a user's email is verified before allowing them into most of the website + - Automatically verify the emails of users who log in with Google + +### Goals for the coming week + +- Send email notifications to users when they're requested to a submission box + - This requires changes in the way redirects are handled + - Write tests for this + +### Additional Context + +N/A + ## Week 1 (Jan 8 - 14) ### Tasks diff --git a/docs/weekly logs/personal/k_phan_personal_log.md b/docs/weekly logs/personal/k_phan_personal_log.md index ff7bcaf8..c6bdf293 100644 --- a/docs/weekly logs/personal/k_phan_personal_log.md +++ b/docs/weekly logs/personal/k_phan_personal_log.md @@ -1,5 +1,21 @@ # K Phan +## Term 2 Week 2 (January 15, 2024 - January 21, 2024) + +### Week's Goals +- Finish detailed page for video (COMPLETE, NOT TESTED) +- Finish edit video page + backend (COMPLETE, NOT TESTED) +- Test new features (NOT COMPLETED) + +### Tasks Worked On +- Detailed page for video (https://github.com/COSC-499-W2023/year-long-project-team-3/pull/312) +- Helped with renaming files before uploading to S3 +- Asked other teams on how to blur face with Recoknition (Shout out to Jan from Team 9) + +### Teamformation Report +![](./imgs/term-2-week-2-k-phan-tasks.png) + + ## Term 2 Week 1 (December 04, 2023 - January 14, 2024) ### Week's Goals diff --git a/docs/weekly logs/personal/seth_akins_personal_log.md b/docs/weekly logs/personal/seth_akins_personal_log.md index 586a421b..89bc54d5 100644 --- a/docs/weekly logs/personal/seth_akins_personal_log.md +++ b/docs/weekly logs/personal/seth_akins_personal_log.md @@ -272,8 +272,49 @@ will have to reconsider how we do that moving forwards. - None :( #### In-Progress Tasks + - [Attempt to Fix Deployment Issues](https://github.com/COSC-499-W2023/year-long-project-team-3/issues/278) - [Port Authentication to AuthJs](https://github.com/COSC-499-W2023/year-long-project-team-3/issues/294) ### Additional Context + - I ran into many issues moving the deployment this week, so hopefully next week goes better. + +## Week 2 (15/01/2024 - 21/01/2024) + +![](imgs/seth-akins-term-2-week-2-tasks.png) + +### Goals + +- This week I continued working on the migration from Vercel to AWS. I fixed the issues with the Docker production + build, set up a container registry on AWS, and uploaded the production image of our app to AWS. +- I also set up the Elastic Container Service (ECS) on AWS, which runs the production image that we build as a container + on AWS. To do this, I had to set up a cluster of containers, a task which defines the image to run and the settings it + requires, and a service which runs the task. +- Additionally, to make the website accessible via browser, I had to set up an Elastic Load Balancer on AWS. This + forwards traffic from the URL of the load balancer to the docker container running on ECS. +- I also started fixing our currently broken GithHub Actions by changing the End-to-End test action to run on the AWS + container instead of on the GitHub machine. This action also automatically builds and the code into a production + docker container, and then pushes the image to the AWS container repository I set up. The action required setting + up credentials for our repo on AWS and the action itself using the OpenID Connect protocol. The deploying works, now + I am just fixing the Cypress tests to run on the AWS hosted container instead of the on the machine running the GitHub + action. +- Lastly, I added some documentation of how you can manually build and upload an image of our docker container to AWS. + +### Tasks + +#### Completed Tasks + +- [Create Dockerfile to Build NextJS Application for Deployment](https://github.com/COSC-499-W2023/year-long-project-team-3/issues/279) +- [Add Docs On Manually Pushing a Container to ECS](https://github.com/COSC-499-W2023/year-long-project-team-3/issues/308) +- [Setup Staging Deployment on AWS](https://github.com/COSC-499-W2023/year-long-project-team-3/issues/310) + +#### In-Progress Tasks + +- [Attempt to Fix Deployment Issues](https://github.com/COSC-499-W2023/year-long-project-team-3/issues/278) +- [Fix Staging Deployment](https://github.com/COSC-499-W2023/year-long-project-team-3/issues/307) + +### Additional Context + +- Although the number of lines of code written this week was low, I put in a lot of time to work as all the AWS setup + took substantial time and required extensive research and documentation reading. diff --git a/docs/weekly logs/personal/teresa_saller_personal_log.md b/docs/weekly logs/personal/teresa_saller_personal_log.md index b89464eb..0fa95ead 100644 --- a/docs/weekly logs/personal/teresa_saller_personal_log.md +++ b/docs/weekly logs/personal/teresa_saller_personal_log.md @@ -243,4 +243,39 @@ Researching ways to implement face-blurring using AWS, and another small UI impr My goal for this week is to submit my UI improvement for review, and to put at least 6h into researching face-blurring. -### Additional Context \ No newline at end of file +### Additional Context + + +## Teresa Saller - Term 2: Week 2 (2024/01/15 - 2024/01/21) + +![teresa-saller-tasks-w2-t2.png](imgs/teresa-saller-tasks-w1-t2.png) + +### Tasks + +Completed: +- [Research AWS Rekognition for face blurring #289](https://github.com/COSC-499-W2023/year-long-project-team-3/issues/289) +- [Add infrastructure for face blurring #302](https://github.com/orgs/COSC-499-W2023/projects/34/views/2?pane=issue&itemId=50057131) + +In progress: +- [Add Avatar and Dropdown instead of Sign Out Button #295](https://github.com/COSC-499-W2023/year-long-project-team-3/pull/295) + +We decided to focus more heavily on core features this week to be ready for peer-review. As part of this, I spent 20h+ +this week researching AWS Rekognition and setting up infrastructure for faceblurring. As part of this: +- I set up the repo recommended by the client: https://github.com/aws-samples/rekognition-video-people-blurring-cdk +- I ran into and fixed the following errors: + - `[ERROR] 2024-01-16T20:51:13.718Z 27912bcc-8966-4c3c-8899-468f1b5ae336 Lambda role does not have permission to call DetectFaces in Amazon Rekognition.` + - needed to change region to us-west-2 + - `Error: fork/exec /lambda-entrypoint.sh: exec format error from cloud formation template` + - needed to build the image for amd64 architecture, not arm64 which was the default for me since I am on an M2 +- I brainstormed how to link the rekognition faceblurring pipeline to our existing streaming pipeline with @Hedgemon4 and we decided to use a lambda to transfer files between the rekognition output and streaming input bucket. This lambda also generates the metadata file containing the database id needed in the streaming pipeline. It does so using the video title. + +### Goals + +My goal for this week is to help my team reach the goals we set for the peer-review deadline. If there is time, I would +like to implement logic to give the user a choice of whether they want their face blurred. Currently, we are always blurring +it. + +### Additional Context + +My code contributions for this week may look small, but that is because I spent so much time getting the pipeline to work. +None of what I did can really be tested easily, and this is the reason why I have not added any tests this week. diff --git a/docs/weekly logs/team/imgs/burnup-week-2-s2.png b/docs/weekly logs/team/imgs/burnup-week-2-s2.png new file mode 100644 index 00000000..86e45c1d Binary files /dev/null and b/docs/weekly logs/team/imgs/burnup-week-2-s2.png differ diff --git a/docs/weekly logs/team/imgs/completed-week-2-s2.png b/docs/weekly logs/team/imgs/completed-week-2-s2.png new file mode 100644 index 00000000..425d656a Binary files /dev/null and b/docs/weekly logs/team/imgs/completed-week-2-s2.png differ diff --git a/docs/weekly logs/team/imgs/cypress-tests-week-2-s2.png b/docs/weekly logs/team/imgs/cypress-tests-week-2-s2.png new file mode 100644 index 00000000..167ba487 Binary files /dev/null and b/docs/weekly logs/team/imgs/cypress-tests-week-2-s2.png differ diff --git a/docs/weekly logs/team/imgs/in-progress-week-2-s2.png b/docs/weekly logs/team/imgs/in-progress-week-2-s2.png new file mode 100644 index 00000000..2894a227 Binary files /dev/null and b/docs/weekly logs/team/imgs/in-progress-week-2-s2.png differ diff --git a/docs/weekly logs/team/imgs/milestone-progress-week-2-s2.png b/docs/weekly logs/team/imgs/milestone-progress-week-2-s2.png new file mode 100644 index 00000000..1bb31bb8 Binary files /dev/null and b/docs/weekly logs/team/imgs/milestone-progress-week-2-s2.png differ diff --git a/docs/weekly logs/team/week_2_sem_2_team_log.md b/docs/weekly logs/team/week_2_sem_2_team_log.md new file mode 100644 index 00000000..464e6a62 --- /dev/null +++ b/docs/weekly logs/team/week_2_sem_2_team_log.md @@ -0,0 +1,54 @@ +# Weekly Team Log + +## Team 3 - Week 2 Term 2 (2024/01/15 - 2024/01/21) + +### Milestone Goals + +- Video processing features (API calls to AWS) - Teresa +- Filter/search for videos by title, date, and person that submitted - K +- Invite links for submission boxes (including emails) - Justin +- Modifying submission boxes (update/delete), and allowing multiple submissions to the same box - Erin +- Dashboard Page for recently viewed/sent videos, and relevant submission boxes - K +- Detail/edit page for videos - K +- Detail/edit page for submission boxes - Erin +- Email notifications: reminders for submission boxes (if they have been opened or close soon), submission sent/received, and comments received - Justin +- Dockerize deployment of application (NextJS application and poll worker) - Seth +- Fix issues with GitHub actions, migrate test deployment to use docker, and add an automatic build of the container when a branch is merged. - Seth + +### Burn-up Chart + +![](imgs/burnup-week-2-s2.png) + +### Usernames + +- @Hedgemon4 - Seth Akins +- @SecondFeline - Erin Hiebert +- @ketphan02 - K Phan +- @te-sa - Teresa Saller +- @justino599 - Justin Schoenit + +### Completed Tasks + +![](imgs/completed-week-2-s2.png) + +### In-progress Tasks + +![](imgs/in-progress-week-2-s2.png) + +### Test Report + +Failing tests are because we are deployed on Vercel, and we don't have access to an IAM Role for AWS, so we can't access AWS related stuff for the tests. We plan on deploying on AWS via a docker image, which will mean we don't need the IAM Role. +Failing tests are also caused by the migration procedures. We are currently finish 1/3 steps out of migration, this pipeline will not be failing next week, finger crossed. + +![](imgs/cypress-tests-week-2-s2.png) + +### Milestone Progress + +![](imgs/milestone-progress-week-2-s2.png) + +### Additional Context + +- For this and the upcoming weeks, much of the tasks has required work, research, and configuration on AWS which is not + tracked by GitHub. This has been documented in the logs of the individuals it concerns. +- Also, the AWS tasks cannot easily be tested by our application. We can only really look at the input and output + results we see from AWS. As such, we really only have end to end tests for it. diff --git a/lambdas/transferBlurredVideosToStreamingPipeline.py b/lambdas/transferBlurredVideosToStreamingPipeline.py new file mode 100644 index 00000000..9f90ff0f --- /dev/null +++ b/lambdas/transferBlurredVideosToStreamingPipeline.py @@ -0,0 +1,57 @@ +import boto3 +import botocore +import json +import os +import re +import logging + +class PatternNotFoundException(Exception): + pass + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +s3 = boto3.resource('s3') + +def lambda_handler(event, context): + logger.info("New files uploaded to the source bucket.") + + key = event['Records'][0]['s3']['object']['key'] + + source_bucket = event['Records'][0]['s3']['bucket']['name'] + destination_bucket = os.environ['destination_bucket'] + + source = {'Bucket': source_bucket, 'Key': key} + + print(f"Source Bucket: {source_bucket}, Key: {key}, Destination Bucket: {destination_bucket}") + + # Define a regular expression pattern to capture the substring between the second underscore and the .mp4 + pattern = r'_(.*?)_(.*?)\.mp4' + + # Use re.search to find the match in the filename + match = re.search(pattern, key) + + # Check if a match is found, and extract the desired string + if match: + extracted_string = match.group(2) + logger.info(extracted_string) + else: + raise PatternNotFoundException("Pattern not found in the filename.") + + try: + response = s3.meta.client.copy(source, destination_bucket, key) + logger.info("File copied to the destination bucket successfully!") + + # Create json from video name, deposit json once video has been successfully uploaded + metadata_file = {'videoId': extracted_string, 'srcVideo': key} + logger.info(metadata_file) + upload_byte_stream = bytes(json.dumps(metadata_file), 'utf-8') + s3.meta.client.put_object(Bucket=destination_bucket, Key=extracted_string + '.json', Body=upload_byte_stream) + logger.info("Metadata added to destination bucket successfully!") + except botocore.exceptions.ClientError as error: + logger.error("There was an error copying the file to the destination bucket") + print('Error Message: {}'.format(error)) + + except botocore.exceptions.ParamValidationError as error: + logger.error("Missing required parameters while calling the API.") + print('Error Message: {}'.format(error)) diff --git a/next.config.js b/next.config.js index 878cc910..3b757f7f 100644 --- a/next.config.js +++ b/next.config.js @@ -1,58 +1,28 @@ /** @type {import("next").NextConfig} */ const nextConfig = () => { - const getEnvironmentVariables = () => { - const environmentVariables = [ - 'DATABASE_URL', - 'GOOGLE_CLIENT_ID', - 'GOOGLE_CLIENT_SECRET', - 'NEXT_PUBLIC_BASE_URL', - 'NEXTAUTH_URL', - 'NEXTAUTH_SECRET', - 'CYPRESS_PROJECT_ID', - 'AWS_UPLOAD_BUCKET', - 'AWS_UPLOAD_REGION', - 'AWS_ACCESS_KEY_ID', - 'AWS_SECRET_ACCESS_KEY', - 'NEXT_IMAGE_WHITELIST_HOSTNAMES', - ] + const environmentVariables = [ + 'NEXT_PUBLIC_BASE_URL', + 'NEXT_IMAGE_WHITELIST_HOSTNAMES', + 'DATABASE_URL', + 'NEXT_PUBLIC_GOOGLE_CLIENT_ID', + 'NEXT_PUBLIC_GOOGLE_CLIENT_SECRET', + 'NEXT_PUBLIC_NEXTAUTH_SECRET', + 'AWS_UPLOAD_BUCKET', + 'AWS_UPLOAD_REGION', + ] - // Check if all environment variables are set - const missingVariables = environmentVariables.filter((variable) => !process.env[variable]) - if (missingVariables.length > 0) { - const errorMessage = `The environment variables are missing: ${ missingVariables.join(', ') }` - throw new ReferenceError(errorMessage) - } - - // Return environment variables - return { - appBaseUrl: process.env.NEXT_PUBLIC_BASE_URL, - googleClientId: process.env.GOOGLE_CLIENT_ID, - googleClientSecret: process.env.GOOGLE_CLIENT_SECRET, - cypressBaseUrl: process.env.CYPRESS_BASE_URL, - cypressProjectId: process.env.CYPRESS_PROJECT_ID, - nextAuthSecret: process.env.NEXTAUTH_SECRET, - awsUploadBucket: process.env.AWS_UPLOAD_BUCKET, - awsUploadRegion: process.env.AWS_UPLOAD_REGION, - awsAccessKeyId: process.env.AWS_ACCESS_KEY_ID, - awsSecretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, - awsSessionToken: process.env.AWS_SESSION_TOKEN, - } + // Check if all environment variables are set + const missingVariables = environmentVariables.filter((variable) => !process.env[variable]) + if (missingVariables.length > 0) { + const errorMessage = `The environment variables are missing: ${ missingVariables.join(', ') }` + throw new ReferenceError(errorMessage) } - const imageWhitelistHostnames = process.env.NEXT_IMAGE_WHITELIST_HOSTNAMES.split(' ') - return { - env: getEnvironmentVariables(), images: { - remotePatterns: imageWhitelistHostnames.map((imageWhitelistHostname) => { - return { - protocol: 'https', - hostname: imageWhitelistHostname, - port: '', - pathname: '**', - } - }), + domains: process.env.NEXT_IMAGE_WHITELIST_HOSTNAMES.split(' '), }, + output: 'standalone', } } diff --git a/package.json b/package.json index 2bcf6b11..42994d95 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "dependencies": { "@aws-sdk/client-cloudfront": "^3.457.0", "@aws-sdk/client-s3": "^3.456.0", - "@aws-sdk/client-ses": "^3.489.0", + "@aws-sdk/client-ses": "^3.489.0", "@aws-sdk/lib-storage": "^3.456.0", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", diff --git a/prisma/migrations/20240122034613_add_email_verification/migration.sql b/prisma/migrations/20240122034613_add_email_verification/migration.sql new file mode 100644 index 00000000..ebdd7aab --- /dev/null +++ b/prisma/migrations/20240122034613_add_email_verification/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "RequestedEmailVerification" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "userId" TEXT NOT NULL, + + CONSTRAINT "RequestedEmailVerification_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "RequestedEmailVerification_token_key" ON "RequestedEmailVerification"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "RequestedEmailVerification_userId_key" ON "RequestedEmailVerification"("userId"); + +-- AddForeignKey +ALTER TABLE "RequestedEmailVerification" ADD CONSTRAINT "RequestedEmailVerification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e725ef48..ce47e58d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,17 +9,18 @@ datasource db { } model User { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - email String @unique - emailVerified DateTime? - password String? - accounts Account[] - ownedVideos Video[] - whitelistedVideos VideoWhitelistedUser[] - requestedSubmissions RequestedSubmission[] - managedSubmissionBoxes SubmissionBoxManager[] + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + email String @unique + emailVerified DateTime? + password String? + accounts Account[] + ownedVideos Video[] + whitelistedVideos VideoWhitelistedUser[] + requestedSubmissions RequestedSubmission[] + managedSubmissionBoxes SubmissionBoxManager[] + requestedEmailVerification RequestedEmailVerification? @@index([email]) } @@ -136,3 +137,12 @@ model SubmissionBoxManager { @@id([userId, submissionBoxId]) } + +model RequestedEmailVerification { + id String @id @default(cuid()) + token String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + userId String @unique + user User @relation(fields: [userId], references: [id]) +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 913b1747..8138df9d 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -17,7 +17,6 @@ export default function LoginPage() { useEffect(() => { if (status === 'authenticated') { setIsLoginPageVisible(false) - // router.push('/dashboard') if (callbackUrl) { const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000' if (callbackUrl.startsWith(baseUrl)) { diff --git a/src/app/api/submission-box/myboxes/route.ts b/src/app/api/submission-box/myboxes/route.ts index e3583a7f..e80ba633 100644 --- a/src/app/api/submission-box/myboxes/route.ts +++ b/src/app/api/submission-box/myboxes/route.ts @@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import logger from '@/utils/logger' import prisma from '@/lib/prisma' -import { SubmissionBox } from '@prisma/client' +import type { SubmissionBox } from '@prisma/client' export async function GET(_: NextRequest): Promise { try { diff --git a/src/app/api/verify-email/[id]/route.ts b/src/app/api/verify-email/[id]/route.ts new file mode 100644 index 00000000..848fb1a8 --- /dev/null +++ b/src/app/api/verify-email/[id]/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import prisma from '@/lib/prisma' +import logger from '@/utils/logger' +import { isDateWithinLast24Hours } from '@/utils/verification' + +export async function GET(req: NextRequest) { + const session = await getServerSession() + if (!session || !session.user?.email) { + return NextResponse.json({ error: 'You must be signed in to upload a video' }, { status: 401 }) + } + + const verificationToken = req.nextUrl.pathname.split('/').pop() + if (!verificationToken) { + logger.error('Email verification API called with invalid path') + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }) + } + + try { + const requestedEmailVerification = await prisma.requestedEmailVerification.findUnique({ + where: { + token: verificationToken, + }, + }) + if (!requestedEmailVerification) { + logger.error('User submitted an email validation token that was not in the database') + return NextResponse.json({ error: 'Invalid verification link' }, { status: 400 }) + } else if (!isDateWithinLast24Hours(requestedEmailVerification.updatedAt)) { + return NextResponse.json({ error: 'Verification link expired' }, { status: 410 }) + } + + // Set user's email as verified + await prisma.user.update({ + where: { + email: session.user.email, + }, + data: { + emailVerified: new Date(), + }, + }) + + // Remove verification token + await prisma.requestedEmailVerification.delete({ + where: { + id: requestedEmailVerification.id, + }, + }) + + return NextResponse.json({ message: 'User email verified' }, { status: 200 }) + } catch (e) { + logger.error('Unknown error occurred while validating email: ' + e) + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }) + } +} diff --git a/src/app/api/verify-email/is-verified/route.ts b/src/app/api/verify-email/is-verified/route.ts new file mode 100644 index 00000000..793d31da --- /dev/null +++ b/src/app/api/verify-email/is-verified/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import prisma from '@/lib/prisma' + +/** + * Is the currently logged-in user's email verified? + * + * @return `{ isVerified: boolean }` + */ +export async function GET(req: NextRequest) { + const session = await getServerSession() + if (!session || !session.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const user = await prisma.user.findUniqueOrThrow({ + where: { + email: session.user.email, + }, + select: { + emailVerified: true, + createdAt: true, + accounts: { + select: { + provider: true, + }, + }, + }, + }) + + if (!!user.emailVerified) { + return NextResponse.json({ isVerified: true }, { status: 200 }) + } else if (user.accounts.some(account => account.provider === 'google')) { + // Update user record to show that their email is verified if they log in with google + await prisma.user.update({ + where: { + email: session.user.email, + }, + data: { + emailVerified: user.createdAt, + }, + }) + return NextResponse.json({ isVerified: true }, { status: 200 }) + } else { + return NextResponse.json({ isVerified: false }, { status: 200 }) + } + } catch (e) { + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }) + } +} diff --git a/src/app/api/verify-email/send-email/route.ts b/src/app/api/verify-email/send-email/route.ts new file mode 100644 index 00000000..140d2b6c --- /dev/null +++ b/src/app/api/verify-email/send-email/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { sendEmailVerificationEmail } from '@/utils/emails/emailVerification' +import logger from '@/utils/logger' + +export async function GET(req: NextRequest) { + const session = await getServerSession() + if (!session || !session.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const succeeded = await sendEmailVerificationEmail(session.user.email) + if (succeeded) { + return NextResponse.json({ message: 'Email sent' }, { status: 200 }) + } else { + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }) + } + } catch (e) { + logger.error('Email verification had an unexpected error') + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }) + } +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index a79401d5..df77fd4f 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -61,18 +61,18 @@ export default function DashboardPage() { useEffect(() => { if (isVideoTabSelected) { - const filteredVideos = tempVideos.filter( + const filteredVideos = tempVideos?.filter( (video) => video.title.toLowerCase().includes(searchTerm.trim().toLowerCase()) || video.description?.toLowerCase().includes(searchTerm.trim().toLowerCase()) - ) + ) ?? [] setDisplayVideos(filteredVideos) } else { - const filteredSubmissionBoxes = tempSubmissionBoxes.filter( + const filteredSubmissionBoxes = tempSubmissionBoxes?.filter( (submissionBox) => submissionBox.title.toLowerCase().includes(searchTerm.trim().toLowerCase()) || submissionBox.description?.toLowerCase().includes(searchTerm.trim().toLowerCase()) - ) + ) ?? [] setSubmissionBoxes(filteredSubmissionBoxes) } }, [isVideoTabSelected, searchTerm, tempSubmissionBoxes, tempVideos]) diff --git a/src/app/page.tsx b/src/app/page.tsx index b871d9f3..1cb36488 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,7 +3,7 @@ import { Box, Typography } from '@mui/material' import Logo from '@/components/Logo' import Header from '@/components/Header' -import LandingPageButton from 'src/components/LandingPageButton' +import LandingPageButton from '@/components/LandingPageButton' import { type SessionContextValue, useSession } from 'next-auth/react' import { useRouter } from 'next/navigation' diff --git a/src/app/verify-email/[id]/page.tsx b/src/app/verify-email/[id]/page.tsx new file mode 100644 index 00000000..f75e77b7 --- /dev/null +++ b/src/app/verify-email/[id]/page.tsx @@ -0,0 +1,89 @@ +'use client' + +import Header from '@/components/Header' +import { useSession } from 'next-auth/react' +import { usePathname, useRouter } from 'next/navigation' +import { Box, Button, Typography } from '@mui/material' +import React, { useEffect, useState } from 'react' +import PageLoadProgress from '@/components/PageLoadProgress' + +type PageStatus = 'loading' | 'success' | 'error' + +export default function VerifyEmail() { + const session = useSession() + const router = useRouter() + const emailVerificationId = usePathname()?.split('/').pop() + const [pageMsg, setPageMsg] = useState('') + const [pageStatus, setPageStatus] = useState('loading' as PageStatus) + const [buttonText, setButtonText] = useState('Resend Email') + + // Hook is being called twice for some reason, so this stops that + let callCount = 0 + useEffect(() => { + if (callCount === 0) { + callCount++ + fetch(`/api/verify-email/${ emailVerificationId }`).then(async (res) => { + if (res.status === 200) { + setPageMsg('Email verified!') + setPageStatus('success') + } else if (res.status === 400) { + const error = await res.json() + setPageMsg(error.error ?? 'Invalid verification link.') + setPageStatus('error') + } else { + setPageMsg('There was an issue while verifying your email.') + setPageStatus('error') + } + }) + } + }, [callCount, emailVerificationId]) + + const resendEmail = () => { + fetch('/api/verify-email/send-email').then((res) => { + if (res.status === 200) { + setButtonText('Email Sent!') + } else { + setButtonText('Failed to send. Try again') + } + }).catch(() => { + setButtonText('Failed to send. Try again') + }) + } + + return ( + <> +
+ {/* Main Body */} + + {pageStatus === 'loading' ? ( + + ) : ( + <> + + {pageMsg} + + + {pageStatus === 'error' && ( + + )} + + )} + + + ) +} diff --git a/src/app/verify-email/page.tsx b/src/app/verify-email/page.tsx new file mode 100644 index 00000000..d26254da --- /dev/null +++ b/src/app/verify-email/page.tsx @@ -0,0 +1,47 @@ +'use client' + +import { Box, Button, Typography } from '@mui/material' +import { useState } from 'react' +import Header from '@/components/Header' +import { useSession } from 'next-auth/react' + +export default function VerifyEmail() { + const session = useSession() + const [buttonText, setButtonText] = useState('Send Verification Email') + + const resendEmail = () => { + setButtonText('Sending...') + fetch('/api/verify-email/send-email').then((res) => { + if (res.status === 200) { + setButtonText('Email Sent!') + } else { + setButtonText('Failed to send. Try again') + } + }).catch(() => { + setButtonText('Failed to send. Try again') + }) + } + + return ( + <> +
+ + It looks like your email isn't verified! + + + + ) +} diff --git a/src/app/video/submit/[videoId]/page.tsx b/src/app/video/submit/[videoId]/page.tsx index 087c64d4..282edec7 100644 --- a/src/app/video/submit/[videoId]/page.tsx +++ b/src/app/video/submit/[videoId]/page.tsx @@ -6,7 +6,7 @@ import { usePathname, useRouter } from 'next/navigation' import Header from '@/components/Header' import { Box, Select, Typography, Chip, MenuItem } from '@mui/material' import ProgressDots from '@/components/ProgressDots' -import { SubmissionBox, Video } from '@prisma/client' +import type { SubmissionBox, Video } from '@prisma/client' import Image from 'next/image' import TextField from '@mui/material/TextField' import HighlightOffIcon from '@mui/icons-material/HighlightOff' diff --git a/src/app/video/upload/page.tsx b/src/app/video/upload/page.tsx index 7b111186..eba103c1 100644 --- a/src/app/video/upload/page.tsx +++ b/src/app/video/upload/page.tsx @@ -9,7 +9,7 @@ import { useSession } from 'next-auth/react' import Header from '@/components/Header' import { useRouter } from 'next/navigation' import ProgressDots from '@/components/ProgressDots' -import PageLoadProgressBlurBackground from 'src/components/PageLoadProgressBlurBackround' +import PageLoadProgressBlurBackground from '@/components/PageLoadProgressBlurBackround' import logger from '@/utils/logger' import BackButton from '@/components/BackButton' diff --git a/src/components/SignUpForm/index.tsx b/src/components/SignUpForm/index.tsx index 23f6bbfd..bcc3bb1b 100644 --- a/src/components/SignUpForm/index.tsx +++ b/src/components/SignUpForm/index.tsx @@ -17,7 +17,7 @@ import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '@/lib/constants' import { ObjectSchema } from 'yup' import { IconButton, InputAdornment } from '@mui/material' import { Visibility as VisibilityIconOn, VisibilityOff as VisibilityIconOff } from '@mui/icons-material' -import HorizontalSeparator from 'src/components/HorizontalSeparator' +import HorizontalSeparator from '@/components/HorizontalSeparator' import GoogleSigninButton from '@/components/GoogleSigninButton' export type SignUpFormInputsData = { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 76d9d4fa..14b5f25f 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -20,7 +20,6 @@ export const authOptions: NextAuthOptions = { child.error(msg) }, }, - secret: process.env.nextAuthSecret, pages: { signIn: '/login', }, @@ -28,10 +27,11 @@ export const authOptions: NextAuthOptions = { session: { strategy: 'jwt', }, + secret: process.env.NEXT_PUBLIC_NEXTAUTH_SECRET, providers: [ GoogleProvider({ - clientId: process.env.googleClientId as string, - clientSecret: process.env.googleClientSecret as string, + clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID as string, + clientSecret: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_SECRET as string, profile(profile) { return { id: profile.sub, diff --git a/src/middleware.ts b/src/middleware.ts index ee9bcf19..769cf260 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,17 +3,39 @@ import { NextRequest, NextResponse } from 'next/server' import logger from '@/utils/logger' export default withAuth( - function middleware(request: NextRequest) { + async function middleware(request: NextRequest) { + // Check if we should redirect when a user's email isn't verified + const cookies = request.cookies + const isLoggedIn = cookies.has('next-auth.session-token') + const protectedPages = ['/dashboard', '/submission-box', '/video'] + if ( + isLoggedIn && + protectedPages.some(page => request.nextUrl.pathname.startsWith(page)) + ) { + const res = await fetch(process.env.NEXT_PUBLIC_BASE_URL + '/api/verify-email/is-verified', { + method: 'GET', + headers: { + 'cookie': cookies.toString(), + }, + }) + const { isVerified } = await res.json() + if (!isVerified) { + const url = request.nextUrl.clone() + url.pathname = '/verify-email' + return NextResponse.redirect(url) + } + } + if (request.nextUrl.pathname.startsWith('/api')) { logger.info(`API request: ${ request.nextUrl.pathname }`) - return NextResponse.next() } return NextResponse.next() }, { + secret: process.env.NEXT_PUBLIC_NEXTAUTH_SECRET, callbacks: { authorized: ({ req, token }) => { - const protectedPages = ['/dashboard', '/submission-box', '/video', '/api/video'] + const protectedPages = ['/dashboard', '/submission-box', '/video'] return !(token === null && protectedPages.some((page) => req.nextUrl.pathname.startsWith(page))) }, }, diff --git a/src/utils/emails/emailVerification.ts b/src/utils/emails/emailVerification.ts index 68fd29e6..723a2fb7 100644 --- a/src/utils/emails/emailVerification.ts +++ b/src/utils/emails/emailVerification.ts @@ -1,13 +1,49 @@ import { sendEmail } from '@/utils/emails/sendEmail' import { Message } from '@aws-sdk/client-ses' -import { User } from '@prisma/client' - -export async function sendEmailVerificationEmail(user: User) { - let message = getVerificationMessage('https://example.com') - return sendEmail(user.email, message) +import * as process from 'process' +import prisma from '@/lib/prisma' +import { v4 as uuidv4 } from 'uuid' +import logger from '@/utils/logger' + +export async function sendEmailVerificationEmail(email: string): Promise { + const user = await prisma.user.findUniqueOrThrow({ + where: { + email, + }, + select: { + id: true, + email: true, + }, + }) + + // Keep trying to generate token in case we generate one that isn't unique + for (let i = 0; i < 10; i++) { + try { + const verificationToken = await prisma.requestedEmailVerification.upsert({ + where: { + userId: user.id, + }, + create: { + userId: user.id, + token: uuidv4(), + }, + update: { + token: uuidv4(), + }, + }) + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://harpvideo.ca' + let message = getVerificationMessage(baseUrl + '/verify-email/' + verificationToken.token) + const res = await sendEmail(email, message) + return res.$metadata.httpStatusCode === 200 + } catch (e) { + logger.error('Failed to generate unique token for database: ' + e) + } + } + return false } function getVerificationMessage(link: string): Message { + // noinspection all return { Body: { Html: { diff --git a/src/utils/emails/sendEmail.ts b/src/utils/emails/sendEmail.ts index 14f81b21..9ad87d93 100644 --- a/src/utils/emails/sendEmail.ts +++ b/src/utils/emails/sendEmail.ts @@ -2,12 +2,7 @@ import {Message, SendEmailCommand, SESClient} from '@aws-sdk/client-ses' export async function sendEmails(emailAddresses: string[], message: Message) { const ses = new SESClient({ - region: process.env.awsUploadRegion, - credentials: { - accessKeyId: process.env.awsAccessKeyId as string, - secretAccessKey: process.env.awsSecretAccessKey as string, - sessionToken: process.env.awsSessionToken as string, - }, + region: process.env.AWS_UPLOAD_REGION, }) return ses.send(new SendEmailCommand({ Destination: { diff --git a/src/utils/sendVideo.ts b/src/utils/sendVideo.ts index ff72c655..ed200064 100644 --- a/src/utils/sendVideo.ts +++ b/src/utils/sendVideo.ts @@ -39,18 +39,13 @@ export default async function sendVideo(rawVideo: File, owner: User): Promise