diff --git a/.github/workflows/aws-cd.yml b/.github/workflows/aws-cd.yml index 9f992a6ac..c6104d2a1 100644 --- a/.github/workflows/aws-cd.yml +++ b/.github/workflows/aws-cd.yml @@ -35,7 +35,7 @@ jobs: # 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 infrastructure/prod/Dockerfile.server . + docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG --build-arg=\"COMMIT=$IMAGE_TAG\" --build-arg=\"BUILD_TIMESTAMP=$(date +%s)\" --build-arg=\"COMMIT_MESSAGE=$(git --no-pager show -s --format=%s)\" -f infrastructure/prod/Dockerfile.server . docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT @@ -49,7 +49,7 @@ jobs: # 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 infrastructure/prod/Dockerfile.app . + docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG --build-arg=\"COMMIT=$IMAGE_TAG\" --build-arg=\"BUILD_TIMESTAMP=$(date +%s)\" --build-arg=\"COMMIT_MESSAGE=$(git --no-pager show -s --format=%s)\" -f infrastructure/prod/Dockerfile.app . docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT @@ -57,7 +57,7 @@ jobs: run: | # - create new revision for task definition with latest image. # - redeploy ECS services with the latest revision. - ./infrastructure/prod/redeploy.sh staging + ./infrastructure/prod/redeploy.sh staging latest-main both - name: Logout of Amazon ECR if: always() diff --git a/infrastructure/prod/Dockerfile.app b/infrastructure/prod/Dockerfile.app index 48deb0d53..82e2c6a08 100644 --- a/infrastructure/prod/Dockerfile.app +++ b/infrastructure/prod/Dockerfile.app @@ -22,6 +22,14 @@ COPY packages/frontend-v2 packages/frontend-v2 COPY packages/api-client packages/api-client COPY packages/common packages/common +ARG COMMIT +ARG BUILD_TIMESTAMP +ARG COMMIT_MESSAGE + +ENV NEXT_PUBLIC_COMMIT_HASH $COMMIT +ENV NEXT_PUBLIC_COMMIT_MESSAGE $COMMIT_MESSAGE +ENV NEXT_PUBLIC_BUILD_TIMESTAMP $BUILD_TIMESTAMP + RUN yarn packages/api-client build RUN yarn packages/common build RUN yarn packages/frontend-v2 build diff --git a/infrastructure/prod/Dockerfile.server b/infrastructure/prod/Dockerfile.server index 325d54ffc..2e77d6c50 100644 --- a/infrastructure/prod/Dockerfile.server +++ b/infrastructure/prod/Dockerfile.server @@ -22,7 +22,14 @@ RUN yarn packages/api-v2 build FROM node:16-alpine AS runner WORKDIR /server +ARG COMMIT +ARG COMMIT_MESSAGE +ARG BUILD_TIMESTAMP + ENV NODE_ENV production +ENV COMMIT_HASH $COMMIT +ENV COMMIT_MESSAGE $COMMIT_MESSAGE +ENV BUILD_TIMESTAMP $BUILD_TIMESTAMP RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nestjs diff --git a/infrastructure/prod/README.md b/infrastructure/prod/README.md new file mode 100644 index 000000000..248e309f8 --- /dev/null +++ b/infrastructure/prod/README.md @@ -0,0 +1,36 @@ +# How to do a Manual Deploy (to staging or prod). + +Note: All of the `yarn` scripts in this guide are in the root `package.json`. + +**This will deploy the current files as they are in your local repo**. They will be tagged with the local HEAD commit. + +If you don't want this, you'll need to run modified calls rather than the package.json scripts directly. + +## Prereqs: + +- Install Docker +- Install the AWS CLI +- Have an AWS Access Key ID and AWS Secret Access Key pair. +- Run `aws configure`, and input those keys along with us-east-1 as the region. You can set the Default Output Format to json. + +## Steps: + +1. Run `yarn frontend:build` and/or `yarn backend:build` depending on what you want to build. +2. Run `yarn frontend:run` and/or `yarn backend:run` to test that the image runs (the backend won't be able to connect to the database which is expected, and the frontend will not connect to the backend). +3. You may have to run `yarn ecr:docker:auth` to reauthenticate Docker with AWS ECR. (There is no harm in running this command unnecessarily, so if you're unsure you can just run it). +4. If the images run successfully, run `yarn frontend:push` and/or `yarn backend:push` to push the built images to AWS ECR. +5. Run `./infrastructure/prod/redeploy.sh local-head ` + + `` is either `prod` or `staging`. + `` is one of `frontend`, `backend`, or `both`. + +# How to Manually Redeploy a Previously Deployed Commit + +If you know that a commit already has an image pushed to ECR (ex. the commit is in main, or you already pushed the image using the above steps) and you want to deploy that particular image to staging or prod, you can use the redeploy.sh script. + +1. Find the commit's hash (looks like this: 1445f9533c4ec9a7324c721f09fc5ccec1542d8d). +2. Run `./infrastructure/prod/redeploy.sh ` + + `` is either `prod` or `staging`. + `` is the hash of the commit you just found. + `` is one of `frontend`, `backend`, or `both`. diff --git a/infrastructure/prod/entrypoint.server.sh b/infrastructure/prod/entrypoint.server.sh index a2d9d4b22..0b36af281 100644 --- a/infrastructure/prod/entrypoint.server.sh +++ b/infrastructure/prod/entrypoint.server.sh @@ -1,6 +1,7 @@ #!/bin/sh cd packages/api-v2 +echo "Running on commit: $COMMIT_HASH" yarn typeorm migration:run exec "$@" diff --git a/infrastructure/prod/get-ecr-image-name.sh b/infrastructure/prod/get-ecr-image-name.sh new file mode 100755 index 000000000..a8f946ff7 --- /dev/null +++ b/infrastructure/prod/get-ecr-image-name.sh @@ -0,0 +1,34 @@ +if [[ $# != 2 ]]; then + echo "Usage: get-ecr-image-name.sh " + exit 1 +fi + +# Accept a few different formats for the commit hash. +if [[ ${#1} = 40 ]]; then + ECR_IMAGE_COMMIT_HASH=$1 +elif [[ $1 = "latest-main" ]]; then + ECR_IMAGE_COMMIT_HASH=$(git ls-remote https://github.com/sandboxnu/graduatenu.git main | awk '{ print $1 }') +elif [[ $1 = "local-head" ]]; then + ECR_IMAGE_COMMIT_HASH=$(git rev-parse HEAD) +else + echo "ERROR: Invalid commit '$1'" + exit 1 +fi + +if [[ $2 = "frontend" ]]; then + REPO="graduatenu-node" +elif [[ $2 = "backend" ]]; then + REPO="graduatenu-rails" +else + echo "Please choose a service to create an image name for: 'frontend' or 'backend'" + exit 1 +fi + +AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) +AWS_DEFAULT_REGION="us-east-1" + +ECR_REGISTRY="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com" + +ECR_IMAGE_NAME="${ECR_REGISTRY}/${REPO}:${ECR_IMAGE_COMMIT_HASH}" + +echo $ECR_IMAGE_NAME diff --git a/infrastructure/prod/redeploy.sh b/infrastructure/prod/redeploy.sh index e0932d44f..e3c135af7 100755 --- a/infrastructure/prod/redeploy.sh +++ b/infrastructure/prod/redeploy.sh @@ -1,35 +1,65 @@ #!/bin/bash +if [[ $# != 3 ]]; then + echo "Usage: redeploy.sh " + exit 1 +fi + if [[ ! " prod staging " =~ " $1 " ]]; then echo "Please provide environment to use: prod or staging" exit 1 fi +# Accept a few different formats for the commit hash. +if [[ ${#2} = 40 ]]; then + ECR_IMAGE_COMMIT_HASH=$2 +elif [[ $2 = "latest-main" ]]; then + ECR_IMAGE_COMMIT_HASH=$(git ls-remote https://github.com/sandboxnu/graduatenu.git main | awk '{ print $1 }') +elif [[ $2 = "local-head" ]]; then + ECR_IMAGE_COMMIT_HASH=$(git rev-parse HEAD) +else + echo "ERROR: Invalid commit '$2'" + exit 1 +fi + +# Only deploy the service specified. +if [[ $3 = "frontend" ]]; then + DEPLOY_INDEXES=(1) +elif [[ $3 = "backend" ]]; then + DEPLOY_INDEXES=(0) +elif [[ $3 = "both" ]]; then + DEPLOY_INDEXES=(0 1) +else + echo "Please choose a service to deploy: 'frontend', 'backend', or 'both'" + exit 1 +fi + +echo "Deploying $3 repo(s) to $1 with commit "$2" ($ECR_IMAGE_COMMIT_HASH)" + AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) AWS_DEFAULT_REGION="us-east-1" REPOS=( "graduatenu-rails" "graduatenu-node" ) -LATEST_HASH=$(git ls-remote https://github.com/sandboxnu/graduatenu.git main | awk '{ print $1 }') ECS_CLUSTER="$1-graduatenu" TASK_FAMILIES=( "${ECS_CLUSTER}-api" "${ECS_CLUSTER}-web" ) SERVICES=( "${ECS_CLUSTER}-api" "${ECS_CLUSTER}-web" ) + # Disable aws from sending stdout to less export AWS_PAGER="" -echo "Redeploying services for cluster: ${ECS_CLUSTER} with last pushed image" - - -for i in "${!REPOS[@]}"; do +for service_index in "${!DEPLOY_INDEXES[@]}"; do + echo "Deploying ${REPOS[DEPLOY_INDEXES[service_index]]}..." + i=${DEPLOY_INDEXES[service_index]} # Last pushed image should always be tagged with the latest commit hash on main - ECR_IMAGE="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${REPOS[$i]}:${LATEST_HASH}" + ECR_IMAGE="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${REPOS[$i]}:${ECR_IMAGE_COMMIT_HASH}" TASK_FAMILY="${TASK_FAMILIES[$i]}" SERVICE="${SERVICES[$i]}" # fetch template for task definition TASK_DEFINITION=$(aws ecs describe-task-definition --task-definition "$TASK_FAMILY" --region "$AWS_DEFAULT_REGION") # update the template's image to use the latest ECR_IMAGE - NEW_TASK_DEFINTIION=$(echo $TASK_DEFINITION | jq --arg IMAGE "$ECR_IMAGE" '.taskDefinition | .containerDefinitions[0].image = $IMAGE | del(.taskDefinitionArn) | del(.revision) | del(.status) | del(.requiresAttributes) | del(.compatibilities) | del(.registeredAt) | del(.registeredBy)') + NEW_TASK_DEFINITION=$(echo $TASK_DEFINITION | jq --arg IMAGE "$ECR_IMAGE" '.taskDefinition | .containerDefinitions[0].image = $IMAGE | del(.taskDefinitionArn) | del(.revision) | del(.status) | del(.requiresAttributes) | del(.compatibilities) | del(.registeredAt) | del(.registeredBy)') # register the new revision for the task definition - NEW_TASK_INFO=$(aws ecs register-task-definition --region "$AWS_DEFAULT_REGION" --cli-input-json "$NEW_TASK_DEFINTIION") + NEW_TASK_INFO=$(aws ecs register-task-definition --region "$AWS_DEFAULT_REGION" --cli-input-json "$NEW_TASK_DEFINITION") NEW_REVISION=$(echo $NEW_TASK_INFO | jq '.taskDefinition.revision') # update the service to replace tasks with the latest revision using the latest image aws ecs update-service --cluster ${ECS_CLUSTER} \ diff --git a/package.json b/package.json index b855887a1..f65242b5d 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,13 @@ "backend:docker:build": "docker compose -f infrastructure/develop/docker-compose.api.yml build", "backend:docker:run": "docker compose -f infrastructure/develop/docker-compose.api.yml up -d", "backend:docker:down": "docker compose -f infrastructure/develop/docker-compose.api.yml down", + "frontend:build": "docker build --platform linux/amd64 --build-arg=\"COMMIT=$(git rev-parse HEAD)\" --build-arg=\"BUILD_TIMESTAMP=$(date +%s)\" --build-arg=\"COMMIT_MESSAGE=$(git --no-pager show -s --format=%s)\" -t $(sh ./infrastructure/prod/get-ecr-image-name.sh local-head frontend) -f ./infrastructure/prod/Dockerfile.app .", + "frontend:run": "docker run -p 4000:3000 $(sh ./infrastructure/prod/get-ecr-image-name.sh local-head frontend)", + "frontend:push": "docker push $(sh ./infrastructure/prod/get-ecr-image-name.sh local-head frontend)", + "backend:build": "docker build --platform linux/amd64 --build-arg=\"COMMIT=$(git rev-parse HEAD)\" --build-arg=\"BUILD_TIMESTAMP=$(date +%s)\" --build-arg=\"COMMIT_MESSAGE=$(git --no-pager show -s --format=%s)\" -t $(sh ./infrastructure/prod/get-ecr-image-name.sh local-head backend) -f ./infrastructure/prod/Dockerfile.server .", + "backend:run": "docker run -p 4001:3001 $(sh ./infrastructure/prod/get-ecr-image-name.sh local-head backend)", + "backend:push": "docker push $(sh ./infrastructure/prod/get-ecr-image-name.sh local-head backend)", + "ecr:docker:auth": "aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin $(aws sts get-caller-identity --query Account --output text).dkr.ecr.us-east-1.amazonaws.com", "lint": "eslint packages/ --ext .ts,.tsx .", "tsc": "yarn workspaces foreach -v --exclude . run tsc", "g:babel": "cd $INIT_CWD && babel", diff --git a/packages/api-client/index.ts b/packages/api-client/index.ts index bd3bb8ed8..239ac3088 100644 --- a/packages/api-client/index.ts +++ b/packages/api-client/index.ts @@ -21,6 +21,7 @@ import { ResetPasswordDto, courseToString, NUPathEnum, + GetMetaInfoResponse, } from "@graduate/common"; import { ClassConstructor, plainToInstance } from "class-transformer"; @@ -103,6 +104,11 @@ class APIClient { getSupportedMajors: (): Promise => this.req("GET", `/majors/supportedMajors`, GetSupportedMajorsResponse), }; + + meta = { + getInfo: (): Promise => + this.req("GET", "/meta/info", GetMetaInfoResponse), + }; } /** diff --git a/packages/api-v2/src/app.module.ts b/packages/api-v2/src/app.module.ts index 69b3118d4..6ca2109b1 100644 --- a/packages/api-v2/src/app.module.ts +++ b/packages/api-v2/src/app.module.ts @@ -10,6 +10,7 @@ import { LoggingInterceptor } from "./interceptors/logging.interceptor"; import { MajorModule } from "./major/major.module"; import { EmailModule } from "./email/email.module"; import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler"; +import { MetaModule } from "./meta/meta.module"; @Module({ imports: [ @@ -29,6 +30,7 @@ import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler"; PlanModule, MajorModule, EmailModule, + MetaModule, ], providers: [ { diff --git a/packages/api-v2/src/auth/auth.controller.ts b/packages/api-v2/src/auth/auth.controller.ts index 4bdcdb161..56720b7ca 100644 --- a/packages/api-v2/src/auth/auth.controller.ts +++ b/packages/api-v2/src/auth/auth.controller.ts @@ -22,15 +22,16 @@ import { weakPasswordError, } from "@graduate/common"; import { Response } from "express"; -import EmailConfirmationService from "src/emailConfirmation/emailConfirmation.service"; +import EmailConfirmationService from "../../src/emailConfirmation/emailConfirmation.service"; import { EmailAlreadyExists, EmailNotConfirmed, NoSuchEmail, WeakPassword, -} from "src/student/student.errors"; +} from "../../src/student/student.errors"; import { BadToken, InvalidPayload, TokenExpiredError } from "./auth.errors"; import { Throttle } from "@nestjs/throttler"; +import { COOKIE_DOMAIN } from "../../src/constants"; @Controller("auth") export class AuthController { @@ -61,11 +62,13 @@ export class AuthController { const { accessToken } = student; const isSecure = process.env.NODE_ENV !== "development"; + // Store JWT token in a cookie response.cookie("auth_cookie", accessToken, { httpOnly: true, sameSite: "strict", secure: isSecure, + domain: COOKIE_DOMAIN, }); if (process.env.NODE_ENV !== "testing") { await this.emailConfirmationService.sendVerificationLink( @@ -90,11 +93,13 @@ export class AuthController { const { accessToken } = student; const isSecure = process.env.NODE_ENV !== "development"; + // Store JWT token in a cookie response.cookie("auth_cookie", accessToken, { httpOnly: true, sameSite: "strict", secure: isSecure, + domain: COOKIE_DOMAIN, }); return student; @@ -152,10 +157,12 @@ export class AuthController { @Res({ passthrough: true }) response: Response ): Promise { const isSecure = process.env.NODE_ENV !== "development"; + response.clearCookie("auth_cookie", { httpOnly: true, sameSite: "strict", secure: isSecure, + domain: COOKIE_DOMAIN, }); } } diff --git a/packages/api-v2/src/auth/auth.module.ts b/packages/api-v2/src/auth/auth.module.ts index 74bfc207a..069b98758 100644 --- a/packages/api-v2/src/auth/auth.module.ts +++ b/packages/api-v2/src/auth/auth.module.ts @@ -5,7 +5,7 @@ import { StudentModule } from "../student/student.module"; import { AuthController } from "./auth.controller"; import { AuthService } from "./auth.service"; import { JwtStrategy } from "./jwt.strategy"; -import { EmailModule } from "src/email/email.module"; +import { EmailModule } from "../../src/email/email.module"; @Module({ imports: [ diff --git a/packages/api-v2/src/auth/auth.service.ts b/packages/api-v2/src/auth/auth.service.ts index e53113ed0..73d58dd6e 100644 --- a/packages/api-v2/src/auth/auth.service.ts +++ b/packages/api-v2/src/auth/auth.service.ts @@ -15,10 +15,10 @@ import { EmailNotConfirmed, NoSuchEmail, WeakPassword, -} from "src/student/student.errors"; +} from "../../src/student/student.errors"; import { ConfigService } from "@nestjs/config"; -import { EnvironmentVariables } from "src/environment-variables"; -import EmailService from "src/email/email.service"; +import { EnvironmentVariables } from "../../src/environment-variables"; +import EmailService from "../../src/email/email.service"; import { BadToken, InvalidPayload, TokenExpiredError } from "./auth.errors"; @Injectable() diff --git a/packages/api-v2/src/auth/interfaces/authenticated-request.ts b/packages/api-v2/src/auth/interfaces/authenticated-request.ts index 76b7c3b20..8c95bffde 100644 --- a/packages/api-v2/src/auth/interfaces/authenticated-request.ts +++ b/packages/api-v2/src/auth/interfaces/authenticated-request.ts @@ -1,4 +1,4 @@ -import { Student } from "src/student/entities/student.entity"; +import { Student } from "../../../src/student/entities/student.entity"; /** Represents an authenticated request using the JwtAuthGuard. */ export interface AuthenticatedRequest extends Request { diff --git a/packages/api-v2/src/constants.ts b/packages/api-v2/src/constants.ts new file mode 100644 index 000000000..4af358ff8 --- /dev/null +++ b/packages/api-v2/src/constants.ts @@ -0,0 +1,9 @@ +/** + * The root Domain on which all cookies should be set. (See: + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#define_where_cookies_are_sent) + * + * In production, this should be set to "graduatenu.com" which allows + * api.graduatenu.com to set cookies on every other *.graduatenu.com domain. + */ +export const COOKIE_DOMAIN = + process.env.NODE_ENV === "production" ? "graduatenu.com" : "localhost"; diff --git a/packages/api-v2/src/email/email.module.ts b/packages/api-v2/src/email/email.module.ts index 29317a561..20d0fc188 100644 --- a/packages/api-v2/src/email/email.module.ts +++ b/packages/api-v2/src/email/email.module.ts @@ -3,8 +3,8 @@ import { ConfigModule } from "@nestjs/config"; import EmailService from "./email.service"; import { JwtModule } from "@nestjs/jwt"; import EmailConfirmationService from "../emailConfirmation/emailConfirmation.service"; -import { StudentModule } from "src/student/student.module"; -import { EmailConfirmationController } from "src/emailConfirmation/emailConfirmation.controller"; +import { StudentModule } from "../../src/student/student.module"; +import { EmailConfirmationController } from "../../src/emailConfirmation/emailConfirmation.controller"; @Module({ imports: [ diff --git a/packages/api-v2/src/email/email.service.ts b/packages/api-v2/src/email/email.service.ts index 25190cf54..fd98e8fc3 100644 --- a/packages/api-v2/src/email/email.service.ts +++ b/packages/api-v2/src/email/email.service.ts @@ -2,7 +2,7 @@ import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { createTransport } from "nodemailer"; import Mail from "nodemailer/lib/mailer"; -import { EnvironmentVariables } from "src/environment-variables"; +import { EnvironmentVariables } from "../../src/environment-variables"; @Injectable() export default class EmailService { diff --git a/packages/api-v2/src/emailConfirmation/emailConfirmation.controller.ts b/packages/api-v2/src/emailConfirmation/emailConfirmation.controller.ts index 6486c7c2a..612e04de3 100644 --- a/packages/api-v2/src/emailConfirmation/emailConfirmation.controller.ts +++ b/packages/api-v2/src/emailConfirmation/emailConfirmation.controller.ts @@ -12,8 +12,8 @@ import { emailAlreadyConfirmed, unableToSendEmail, } from "@graduate/common"; -import { AuthenticatedRequest } from "src/auth/interfaces/authenticated-request"; -import { JwtAuthGuard } from "src/guards/jwt-auth.guard"; +import { AuthenticatedRequest } from "../../src/auth/interfaces/authenticated-request"; +import { JwtAuthGuard } from "../../src/guards/jwt-auth.guard"; import EmailConfirmationService from "./emailConfirmation.service"; import { EmailAlreadyConfirmed, diff --git a/packages/api-v2/src/emailConfirmation/emailConfirmation.service.ts b/packages/api-v2/src/emailConfirmation/emailConfirmation.service.ts index af0dec3c0..ddc978f9d 100644 --- a/packages/api-v2/src/emailConfirmation/emailConfirmation.service.ts +++ b/packages/api-v2/src/emailConfirmation/emailConfirmation.service.ts @@ -1,8 +1,8 @@ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { JwtService } from "@nestjs/jwt"; -import { EnvironmentVariables } from "src/environment-variables"; -import { StudentService } from "src/student/student.service"; +import { EnvironmentVariables } from "../../src/environment-variables"; +import { StudentService } from "../../src/student/student.service"; import { UpdateResult } from "typeorm"; import EmailService from "../email/email.service"; import { diff --git a/packages/api-v2/src/graduate-logger.ts b/packages/api-v2/src/graduate-logger.ts index 4b548dad9..cc61cb089 100644 --- a/packages/api-v2/src/graduate-logger.ts +++ b/packages/api-v2/src/graduate-logger.ts @@ -1,5 +1,5 @@ import { ConsoleLogger, LogLevel } from "@nestjs/common"; -import { deepFilter } from "src/utils"; +import { deepFilter } from "../src/utils"; const DENYLIST = ["password", "passwordConfirm"]; diff --git a/packages/api-v2/src/guards/emailConfirmation.guard.ts b/packages/api-v2/src/guards/emailConfirmation.guard.ts index 18c81cbe7..7d59aba06 100644 --- a/packages/api-v2/src/guards/emailConfirmation.guard.ts +++ b/packages/api-v2/src/guards/emailConfirmation.guard.ts @@ -6,8 +6,8 @@ import { UnauthorizedException, Logger, } from "@nestjs/common"; -import { AuthenticatedRequest } from "src/auth/interfaces/authenticated-request"; -import { formatServiceCtx } from "src/utils"; +import { AuthenticatedRequest } from "../../src/auth/interfaces/authenticated-request"; +import { formatServiceCtx } from "../../src/utils"; @Injectable() export class EmailConfirmationGuard implements CanActivate { diff --git a/packages/api-v2/src/main.ts b/packages/api-v2/src/main.ts index a5f64f2a9..d250908c3 100644 --- a/packages/api-v2/src/main.ts +++ b/packages/api-v2/src/main.ts @@ -6,7 +6,7 @@ import { } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { NestFactory, Reflector } from "@nestjs/core"; -import { GraduateLogger } from "src/graduate-logger"; +import { GraduateLogger } from "../src/graduate-logger"; import { AppModule } from "./app.module"; import { EnvironmentVariables } from "./environment-variables"; import * as cookieParser from "cookie-parser"; @@ -20,8 +20,9 @@ async function bootstrap() { const app = await NestFactory.create(AppModule, { logger: graduateLogger, cors: { - origin: - "https://graduatenu-frontend-v2-git-christina-move-fro-b625a5-sandboxneu.vercel.app", + origin: "https://frontend-staging.graduatenu.com", + credentials: true, + methods: ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH"], }, }); diff --git a/packages/api-v2/src/meta/meta.controller.ts b/packages/api-v2/src/meta/meta.controller.ts new file mode 100644 index 000000000..20fec45e3 --- /dev/null +++ b/packages/api-v2/src/meta/meta.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from "@nestjs/common"; +import { MetaService } from "./meta.service"; +import { type MetaInfo } from "@graduate/common"; + +@Controller("meta") +export class MetaController { + constructor(private readonly metaService: MetaService) {} + + @Get("/info") + getMetaInfo(): MetaInfo { + return this.metaService.getMetaInfo(); + } +} diff --git a/packages/api-v2/src/meta/meta.module.ts b/packages/api-v2/src/meta/meta.module.ts new file mode 100644 index 000000000..5d0433160 --- /dev/null +++ b/packages/api-v2/src/meta/meta.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { MetaService } from "./meta.service"; +import { MetaController } from "./meta.controller"; + +@Module({ + controllers: [MetaController], + providers: [MetaService], + exports: [MetaService], +}) +export class MetaModule {} diff --git a/packages/api-v2/src/meta/meta.service.ts b/packages/api-v2/src/meta/meta.service.ts new file mode 100644 index 000000000..b1b0bfe90 --- /dev/null +++ b/packages/api-v2/src/meta/meta.service.ts @@ -0,0 +1,17 @@ +import { type MetaInfo } from "@graduate/common"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class MetaService { + getMetaInfo(): MetaInfo { + return { + commit: process.env.COMMIT_HASH ?? false, + commitMessage: process.env.COMMIT_MESSAGE ?? false, + build_timestamp: + process.env.BUILD_TIMESTAMP !== undefined + ? Number(process.env.BUILD_TIMESTAMP) + : false, + environment: process.env.NODE_ENV ?? false, + }; + } +} diff --git a/packages/common/src/api-response-types.ts b/packages/common/src/api-response-types.ts index 859146677..7cb2c1dee 100644 --- a/packages/common/src/api-response-types.ts +++ b/packages/common/src/api-response-types.ts @@ -3,6 +3,8 @@ import { Schedule2, SupportedMajors, ScheduleCourse2, + MetaInfo, + Maybe, } from "./types"; /** Types our API responds with. */ @@ -52,3 +54,10 @@ export class GetSupportedMajorsResponse { // { year => { majorName => {concentrations, minRequiredConcentrations} }} supportedMajors: SupportedMajors; } + +export class GetMetaInfoResponse implements MetaInfo { + commit: Maybe; + commitMessage: Maybe; + build_timestamp: Maybe; + environment: Maybe; +} diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index 87dc173fa..983f5aa3d 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -483,3 +483,12 @@ export const Err = (err: E): Result => ({ err, type: ResultType.Err, }); + +export type Maybe = T | false; + +export interface MetaInfo { + commit: Maybe; + commitMessage: Maybe; + build_timestamp: Maybe; + environment: Maybe; +} diff --git a/packages/frontend-v2/components/Header/GraduateHeaders.tsx b/packages/frontend-v2/components/Header/GraduateHeaders.tsx index a5aef3d4f..c38f5ef2c 100644 --- a/packages/frontend-v2/components/Header/GraduateHeaders.tsx +++ b/packages/frontend-v2/components/Header/GraduateHeaders.tsx @@ -3,6 +3,7 @@ import { Logo } from "./Logo"; import { GraduateButtonLink } from "../Link"; import { UserDropdown } from "./UserDropdown"; import { Flex, Icon, IconProps, Link as ChakraLink } from "@chakra-ui/react"; +import { MetaInfoWidget } from "../MetaInfo/MetaInfo"; export const GraduatePreAuthHeader: React.FC = () => { return ( @@ -31,6 +32,7 @@ const GraduateHeader: React.FC = ({ rightContent }) => { + Feedback diff --git a/packages/frontend-v2/components/MetaInfo/MetaInfo.tsx b/packages/frontend-v2/components/MetaInfo/MetaInfo.tsx new file mode 100644 index 000000000..4dc9a498e --- /dev/null +++ b/packages/frontend-v2/components/MetaInfo/MetaInfo.tsx @@ -0,0 +1,203 @@ +import { Box, Flex, Icon, Link, Text, Tooltip } from "@chakra-ui/react"; +import { API } from "@graduate/api-client"; +import { Maybe } from "@graduate/common"; +import { useState } from "react"; +import useSWR from "swr"; + +// adapted from https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site +/** + * @param current The current time as a JS timestamp (milliseconds) + * @param previous The previous time as a JS timestamp (milliseconds) + * @returns Textual relative time difference + */ +function timeDifference(current: number, previous: number): string { + const msPerMinute = 60 * 1000; + const msPerHour = msPerMinute * 60; + const msPerDay = msPerHour * 24; + const msPerMonth = msPerDay * 30; + const msPerYear = msPerDay * 365; + + const elapsed = current - previous; + + if (elapsed < msPerMinute) { + return Math.round(elapsed / 1000) + " second(s) ago"; + } else if (elapsed < msPerHour) { + return Math.round(elapsed / msPerMinute) + " minute(s) ago"; + } else if (elapsed < msPerDay) { + return Math.round(elapsed / msPerHour) + " hour(s) ago"; + } else if (elapsed < msPerMonth) { + return "~" + Math.round(elapsed / msPerDay) + " day(s) ago"; + } else if (elapsed < msPerYear) { + return "~" + Math.round(elapsed / msPerMonth) + " month(s) ago"; + } else { + return "~" + Math.round(elapsed / msPerYear) + " year(s) ago"; + } +} + +/** + * @param timestamp A JS timestamp (milliseconds) + * @returns Formats the given timestamp as a string time + */ +function formatBuildTime(timestamp: number): string { + return new Date(timestamp).toLocaleDateString("en-US", { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "numeric", + }); +} + +/** A clickable dev widget for the header at the top of all pages. */ +export const MetaInfoWidget: React.FC = () => { + const [showDevInfo, setShowDevInfo] = useState(false); + + return ( + + setShowDevInfo((state) => !state)} + transition="background 0.15s ease" + userSelect="none" + > + + + + {process.env.NODE_ENV === "development" && Dev} + + { + + + + } + + ); +}; + +/** The contents of the meta info widget which displays docker build info. */ +export const MetaInfo: React.FC = () => { + const { data, error } = useSWR("/meta/info", API.meta.getInfo); + + // When we update Next versions we can use an instrumentation.ts file to create this information + // const relativeRunTime = timeDifference(Date.now(), Number(process.env.NEXT_PUBLIC_NEXT_RUN_TIMESTAMP ?? 0) * 1000) + + return ( + <> + + Docker Build Info + + Frontend + + + + Backend + + {error && Error Reaching Backend} + {data !== undefined ? ( + + ) : ( + !error && Loading Backend Info... + )} + + ); +}; + +/** Display the given optional commit hash and message. */ +const CommitText: React.FC<{ + commitHash: Maybe; + commitMessage: Maybe; +}> = ({ commitHash, commitMessage }) => { + if (commitHash !== false) { + const shortHash = commitHash.slice(0, 7); + const commitLink = `https://github.com/sandboxnu/graduatenu/commit/${commitHash}`; + return ( + + Commit:{" "} + + + {`${ + commitMessage !== false ? commitMessage : "" + } (${shortHash})`} + + + + ); + } else { + return Commit: {""}; + } +}; + +/** Displays the given optional build time timestamp. */ +const BuildTime: React.FC<{ buildTime: Maybe }> = ({ + buildTime, +}) => { + if (buildTime !== false) { + const numericTime = Number(buildTime) * 1000; + return ( + + Image Built: {timeDifference(Date.now(), numericTime)} + + ); + } else { + return Built: {""}; + } +}; + +/** A Docker meta info section. */ +export const MetaInfoSection: React.FC<{ + environment: Maybe; + commitHash: Maybe; + buildTime: Maybe | Maybe; + commitMessage: Maybe; +}> = ({ environment, commitHash, buildTime, commitMessage }) => { + return ( + <> + + Environment: {environment !== false ? environment : ""} + + + + + ); +};