diff --git a/.env b/.env new file mode 100644 index 0000000000..9d97a5b762 --- /dev/null +++ b/.env @@ -0,0 +1,10 @@ +## Shared Environment Variables for the project +GOOGLE_APPLICATION_CREDENTIALS=./secrets/gcp_credentials.json +GCLOUD_PROJECT=cs3219-ay2425s1-project-g10 +GCLOUD_REGION=us-central1 +GCLOUD_ZONE=us-central1-c +GCLOUD_REPOSITORY_ID=cs3219-ay2425s1-project-g10 + +# Terraform backend variables +TF_BACKEND_BUCKET_NAME=${GCLOUD_PROJECT}-tfstate + diff --git a/.github/.keep b/.github/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.github/actions/container_setup/action.yml b/.github/actions/container_setup/action.yml new file mode 100644 index 0000000000..aade29ae97 --- /dev/null +++ b/.github/actions/container_setup/action.yml @@ -0,0 +1,17 @@ +name: Setup Action +description: Set up environment, authenticate, and configure git for workflows under the kimyongbeom/peerprep-actions-runner container. + +runs: + using: composite + steps: + - name: Configure Git + shell: bash + run: | + git config --global --add safe.directory "$PWD" + + - name: Decrypt Secret Files + shell: bash + run: | + mkdir -p ${XDG_CONFIG_HOME:-$HOME/.config}/sops/age + echo $AGE_SECRET_KEY > ${XDG_CONFIG_HOME:-$HOME/.config}/sops/age/keys.txt + /bin/bash ./scripts/secret.sh decrypt \ No newline at end of file diff --git a/.github/workflows/cleanup_branch.yml b/.github/workflows/cleanup_branch.yml new file mode 100644 index 0000000000..b8aba26a7a --- /dev/null +++ b/.github/workflows/cleanup_branch.yml @@ -0,0 +1,53 @@ +# This is a basic workflow to help you get started with Actions + +name: Branch Cleanup + +# Controls when the workflow will run +on: + # On push @ branch + pull_request: + types: [closed] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + cleanup_infra: + # Run only if PR is merged + if: github.event.pull_request.merged == true + # The type of runner that the job will run on + runs-on: ubuntu-latest + + container: + image: kimyongbeom/peerprep-actions-runner:latest + env: + AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + options: --privileged + + concurrency: + # current branch + group: ${{ github.event.pull_request.head.ref }} + + permissions: write-all + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + - uses: ./.github/actions/container_setup + + - name: Destroy Global Infrastructure + shell: bash + run: cd docker_registry && make destroy + + - name: Destroy Backend + shell: bash + run: cd backend && make destroy + + - name: Destroy Frontend + shell: bash + run: . $NVM_DIR/nvm.sh && cd frontend && make destroy \ No newline at end of file diff --git a/.github/workflows/on_pr.yml b/.github/workflows/on_pr.yml new file mode 100644 index 0000000000..0077a8b2a0 --- /dev/null +++ b/.github/workflows/on_pr.yml @@ -0,0 +1,37 @@ +# This is a basic workflow to help you get started with Actions + +name: Checks on PRs + +# Controls when the workflow will run +on: + # Triggers the workflow on push or pull request events but only for the "main" branch + pull_request: + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + test: + # The type of runner that the job will run on + runs-on: ubuntu-latest + container: + image: kimyongbeom/peerprep-actions-runner:latest + env: + AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + options: --privileged + concurrency: + # current branch + group: ${{ github.head_ref }} + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/container_setup + + # Runs a single command using the runners shell + - name: Run a one-line script + run: echo Hello, world! diff --git a/.github/workflows/on_push.yml b/.github/workflows/on_push.yml new file mode 100644 index 0000000000..2ee4817a87 --- /dev/null +++ b/.github/workflows/on_push.yml @@ -0,0 +1,64 @@ +# This is a basic workflow to help you get started with Actions + +name: Actions on Push + +# Controls when the workflow will run +on: + # On push @ branch + push: + # When branch is created + create: + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + deploy: + # The type of runner that the job will run on + runs-on: ubuntu-latest + container: + image: kimyongbeom/peerprep-actions-runner:latest + env: + AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + options: --privileged + concurrency: + # current branch + group: ${{ github.ref_name }} + + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/container_setup + + - name: Find Changed Files + uses: dorny/paths-filter@v3 + id: changes + with: + filters: | + global_infra: + - 'tf/**' + frontend: + - 'frontend/**' + backend: + - 'backend/**' + + - name: Deploy Global Infrastructure + shell: bash + if: ${{ github.event_name }} == 'create' || ${{ steps.changes.outputs.global_infra }} == 'true' + run: cd docker_registry && make deploy + + - name: Deploy Backend + if: ${{ github.event_name }} == 'create' || ${{ steps.changes.outputs.backend }} == 'true' + shell: bash + run: cd backend && make deploy + + - name: Deploy Frontend + if: ${{ github.event_name }} == 'create' || ${{ steps.changes.outputs.frontend }} == 'true' + shell: bash + run: . $NVM_DIR/nvm.sh && cd frontend && make deploy + diff --git a/.github/workflows/pr_setup.yml b/.github/workflows/pr_setup.yml new file mode 100644 index 0000000000..00b94535c8 --- /dev/null +++ b/.github/workflows/pr_setup.yml @@ -0,0 +1,53 @@ +# This is a basic workflow to help you get started with Actions + +name: Setup Actions on new Pull Request + +# Controls when the workflow will run +on: + # Triggers the workflow on push or pull request events but only for the "main" branch + pull_request: + types: [opened, synchronize] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + test: + # The type of runner that the job will run on + runs-on: ubuntu-latest + container: + image: kimyongbeom/peerprep-actions-runner:latest + env: + AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + options: --privileged + concurrency: + # current branch + group: ${{ github.head_ref }} + + permissions: + contents: read + pull-requests: write + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/container_setup + - name: Get all deployment URLs + id: urls + run: | + echo frontend=$(cd frontend && make url) >> $GITHUB_OUTPUT + echo backend=$(cd backend && make url) >> $GITHUB_OUTPUT + + + - name: Add a comment to the pull request + uses: peter-evans/create-or-update-comment@v3 + with: + token: ${{ secrets.GITHUB_TOKEN }} + issue-number: ${{ github.event.pull_request.number }} + body: | + Frontend URL: ${{ steps.urls.outputs.frontend }} + Backend URL: ${{ steps.urls.outputs.backend }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..df09198a98 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +/google-cloud-sdk/ +/secrets/* +# Encrypted secrets +!*.enc + +# https://github.com/github/gitignore/blob/main/Terraform.gitignore +# Local .terraform directories +**/.terraform/* + +# .tfstate files +*.tfstate +*.tfstate.* + +# Crash log files +crash.log +crash.*.log + +# Exclude all .tfvars files, which are likely to contain sensitive data, such as +# password, private keys, and other secrets. These should not be part of version +# control as they are data points which are potentially sensitive and subject +# to change depending on the environment. +*.tfvars +*.tfvars.json + +# Ignore override files as they are usually used to override resources locally and so +# are not checked in +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Ignore transient lock info files created by terraform apply +.terraform.tfstate.lock.info + +# Include override files you do wish to add to version control using negated pattern +# !example_override.tf + +# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan +# example: *tfplan* + +# Ignore CLI configuration files +.terraformrc +terraform.rc +node_modules diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000000..049cc3ae99 --- /dev/null +++ b/.sops.yaml @@ -0,0 +1,11 @@ +## KEYS: +# Yongbeom: age1c0fqjnprp4pzk8kx5y23mlkreu5z34v3tkrrwrcmf56cmu3zaf0q30sqfv +# Bhanuka: age1kdym6a2z8trwn3efwj4kw2eypsh2pucl4vk3faqmqglhgx4ek47s3mt3kk +# GH Actions Runner: age1g2yz8vzyyzmdsht3398da6nu3pgl54rkw5wuveqh5wfn23r2hy0qu6d4dw + +creation_rules: + # Note: must be comma separated + - age: >- + age1c0fqjnprp4pzk8kx5y23mlkreu5z34v3tkrrwrcmf56cmu3zaf0q30sqfv, + age1g2yz8vzyyzmdsht3398da6nu3pgl54rkw5wuveqh5wfn23r2hy0qu6d4dw, + age1kdym6a2z8trwn3efwj4kw2eypsh2pucl4vk3faqmqglhgx4ek47s3mt3kk \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..9791c162f3 --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +SHELL := /bin/bash +MAKEFLAGS += --no-print-directory + +.PHONY: help deploy_tf_backend destroy_tf_backend + + + +help: ## Display this help text + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[$$()% 0-9a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + + +urls: ## Get the URL endpoints of all deployed resources. + @echo "FRONTEND_URL: $$(cd frontend && $(MAKE) url)" + @echo "BACKEND_URL: $$(cd backend && $(MAKE) url)" + +encrypt: ## Encrypt the secrets file + ./scripts/secret.sh encrypt + +decrypt: ## Decrypt the secrets file + ./scripts/secret.sh decrypt + + +## Repo-wide +deploy: ## Deploy all infrastructure and code + $(MAKE) -C docker_registry deploy + $(MAKE) -C backend deploy + $(MAKE) -C frontend deploy + +# Recommended to destroy services in the reverse order of deployment. +destroy: ## Destroy all infrastructure and code + $(MAKE) -C frontend destroy + $(MAKE) -C backend destroy + $(MAKE) -C docker_registry destroy + +## +## Terraform backend +## + +deploy_tf_backend: ## Deploy the OpenTofu/Terraform backend to GCP + . ./source.sh && \ + unset TF_WORKSPACE && \ + cd tf_backend && \ + tofu init && \ + tofu apply -auto-approve + +destroy_tf_backend: ## Destroy the OpenTofu/Terraform backend on GCP + . ./source.sh && \ + unset TF_WORKSPACE && \ + cd tf_backend && \ + tofu destroy -auto-approve \ No newline at end of file diff --git a/README.md b/README.md index 259f7bba2e..2848edcbd4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,131 @@ +[![Review Assignment Due Date](https://classroom.github.com/assets/deadline-readme-button-22041afd0340ce965d47ae6ef1cefeee28c7c493a6346c4f15d667ab976d596c.svg)](https://classroom.github.com/a/bzPrOe11) # CS3219 Project (PeerPrep) - AY2425S1 -## Group: Gxx +## Group: G10 -### Note: -- You can choose to develop individual microservices within separate folders within this repository **OR** use individual repositories (all public) for each microservice. -- In the latter scenario, you should enable sub-modules on this GitHub classroom repository to manage the development/deployment **AND** add your mentor to the individual repositories as a collaborator. -- The teaching team should be given access to the repositories as we may require viewing the history of the repository in case of any disputes or disagreements. +## Dependencies +| Thing | Version | +| ------------------------------------------------ | ------- | +| [gcloud](https://cloud.google.com/sdk/gcloud) | | +| [OpenTofu](https://github.com/opentofu/opentofu) | 1.8[^1] | +| [sops](https://github.com/getsops/sops) | 3.9 | +| [age](https://github.com/FiloSottile/age) | 1 | +| Make | 4 | +| Bash | 5 | + +## Developer Guide + +Here are the scripts relevant in deploying the project. + +## Repository Structure + +Except the directories `.github`, `ci`, `scripts`, `secrets`, `tf_backend`, all directories are services. + +Each service has: +- A `tf` directory, which contains the Terraform configuration for the service. +- A `source.sh` file, which sources the base directory's `source.sh` file and adds on any additional environment variables required by the service. +- A `Makefile` which contains the commands to deploy, test and destroy the service. + +Here are the relevant make targets for each service. For some services, the `Makefile` may omit some of these targets (e.g. `docker_registry` service has no code to build or deploy.) + +```sh +Usage: make [target] +Targets: + help Show this help + local Run the code locally + deploy Deploy the code and infrastructure + destroy Destroy the infrastructure + test Run the tests + code_build Build the code + code_deploy Deploy the code + infra_deploy Deploy the infrastructure + infra_destroy Destroy the infrastructure + url Get the service URL +``` + +## Developing +### Code and Infrastructure Deployment + +There are two ways to deploy the project to the cloud. Note that every branch creates its own separate deployment copy. + +First, you can simply push to your feature branch, and the CI/CD pipeline will deploy a copy of the infrastructure and the code to the cloud. + +To deploy all infrastructure and code on the command line, you can run the following command: +```bash +# Decrypt secrets +make decrypt # You need your age key in the sops file, contact Yongbeom for how to do this. + +# In the base directory, +make deploy + +# This is equivalent to running +make deploy # in docker_registry +make deploy # in frontend +make deploy # in backend +``` + +To destroy your deployment, you can do: +```bash +# Decrypt secrets +make decrypt # You need your age key in the sops file, contact Yongbeom for how to do this. + +# In the base directory, +make destroy + +# This is equivalent to running +make destroy # in docker_registry +make destroy # in frontend +make destroy # in backend +``` + + +### Code Deployment + +Once you have deployed the infrastructure, you do not need to do so again, you can simply update our deployment by deploying code. + +To deploy the code, you can do: +```bash +# deploy frontend code +make code_deploy # in frontend directory + +# deploy backend code +make code_deploy # in backend directory +``` + +### Local Deployment + +Alternatively, you can deploy the code locally. + +Run the following commands (in separate terminals) to deploy the frontend and backend code locally. +```bash +# deploy frontend code +make local + +# deploy backend code +make local +``` + +## Non-Service Directories +All direct subdirectories of the project base directory (with the following exceptions) are microservices. + +Additional information about some directories is provided below. +### `scripts` +Contains scripts for setting up the project. + +Usage: +``` +# Generate a new age key for secret encryption. +./scripts/generate_age_key.sh +# Encrypt all secret files +./scripts/secret.sh encrypt +# Decrypt all secret files +./scripts/secret.sh decrypt +``` +### `secrets` +Contains secrets for the project. + +### `tf_backend` +Contains the Terraform configuration for the Terraform state backend. +For this project, the terraform backend is stored in a Google Cloud Storage bucket. + + +[^1]: OpenTofu 1.8.0 introduces [static variable evaluation](https://opentofu.org/blog/opentofu-1-8-0/), which we use for the project. \ No newline at end of file diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000000..77c9570c63 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,2 @@ +/node_modules/ +.gitignore diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000000..5de9178342 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,142 @@ +# https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored + +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# https://github.com/github/gitignore/blob/main/Node.gitignore + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000000..5f5554280f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine +ARG PORT +ENV PORT=$PORT + +COPY --link package.json yarn.lock ./ + +RUN yarn + +COPY --link . . + +EXPOSE ${PORT} + +CMD ["yarn", "run", "start"] \ No newline at end of file diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000000..ad4560c27c --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,48 @@ +SHELL=/bin/bash + +help: ## Show this help + @echo "Usage: make [target]" + @echo "Targets:" + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +local: code_build ## Run the code locally + . ./source.sh && \ + docker run -p $$EXPOSED_PORT:$$EXPOSED_PORT $$DOCKER_IMAGE_NAME + echo "Service is running on http://localhost:$$EXPOSED_PORT" + +deploy: infra_deploy code_deploy ## Deploy the code and infrastructure +destroy: infra_destroy ## Destroy the infrastructure + +code_build: ## Build the code + . ./source.sh && \ + docker build -t $$DOCKER_IMAGE_NAME --build-arg="PORT=$$EXPOSED_PORT" . + +code_deploy: code_build infra_deploy ## Deploy the code + . ./source.sh && \ + docker push $$DOCKER_IMAGE_NAME && \ + gcloud run deploy $$CLOUD_RUN_SERVICE_NAME --project $$GCLOUD_PROJECT --image $$DOCKER_IMAGE_NAME --port $$EXPOSED_PORT --region $$GCLOUD_REGION --allow-unauthenticated + +infra_deploy: ## Deploy the infrastructure + . ./source.sh && \ + cd tf && \ + tofu init && \ + tofu workspace select -or-create $$TERRAFORM_WORKSPACE && \ + tofu apply -auto-approve + +infra_destroy: ## Destroy the infrastructure + . ./source.sh && \ + cd tf && \ + tofu init && \ + tofu workspace select -or-create $$TERRAFORM_WORKSPACE && \ + tofu destroy -auto-approve + +url: ## Get the service URL + @ . ./source.sh && \ + cd tf && \ + tofu init &> /dev/null && \ + tofu workspace select -or-create $$TERRAFORM_WORKSPACE &> /dev/null && \ + tofu output -raw service_url + +local_url: ## Get the local service URL + @ . ./source.sh && \ + echo http://localhost:$$EXPOSED_PORT \ No newline at end of file diff --git a/backend/index.ts b/backend/index.ts new file mode 100644 index 0000000000..ad1974014e --- /dev/null +++ b/backend/index.ts @@ -0,0 +1,15 @@ +import express from 'express'; +import cors from 'cors'; + +const PORT = process.env.PORT ?? 8080; + +const app = express(); +app.use(cors()); + +app.get('/hello', (req, res) => { + res.send('Hello World!'); +}); + +app.listen(PORT, () => { + console.log('Server is running on localhost:' + PORT); +}) diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000000..e5f39ddd2a --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,1108 @@ +{ + "name": "backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "backend", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "cors": "^2.8.5", + "express": "^4.21.0", + "ts-node": "^10.9.2", + "typescript": "^5.6.2" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.5", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.5.tgz", + "integrity": "sha512-y6W03tvrACO72aijJ5uF02FRq5cgDR9lUxddQ8vyF+GvmjJQqbzDcJngEjURc+ZsG31VI3hODNZJ2URj86pzmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.5.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.5.tgz", + "integrity": "sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/@types/qs": { + "version": "6.9.16", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.16.tgz", + "integrity": "sha512-7i+zxXdPD0T4cKDuxCUXJ4wHcsJLwENa6Z3dCu8cfCK743OGy5Nu1RmAGqDPsoTDINVEcdXKRvR/zre+P2Ku1A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", + "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.6.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.10", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "license": "ISC" + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", + "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000000..b80a37c31c --- /dev/null +++ b/backend/package.json @@ -0,0 +1,19 @@ +{ + "name": "backend", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "cors": "^2.8.5", + "express": "^4.21.0", + "ts-node": "^10.9.2", + "typescript": "^5.6.2" + }, + "scripts": { + "start": "ts-node index.ts" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/cors": "^2.8.17" + } +} diff --git a/backend/source.sh b/backend/source.sh new file mode 100644 index 0000000000..ced24d1c8a --- /dev/null +++ b/backend/source.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Get global environment variables for project +source ../source.sh + +set -o allexport + +SERVICE_NAME=backend + +TF_VAR_service_name=${SERVICE_NAME} +DOCKER_IMAGE_NAME=${GCLOUD_REPOSITORY_URL}/${SERVICE_NAME}:$(git rev-parse HEAD) + +CLOUD_RUN_SERVICE_NAME=$(echo ${GCLOUD_PROJECT}-${SERVICE_NAME}-${ENV} | head -c 49) # Max length is 50 characters +TF_VAR_cloud_run_service_name=${CLOUD_RUN_SERVICE_NAME} + +EXPOSED_PORT=8080 + +set +o allexport diff --git a/backend/tf/.terraform.lock.hcl b/backend/tf/.terraform.lock.hcl new file mode 100644 index 0000000000..18d634b18d --- /dev/null +++ b/backend/tf/.terraform.lock.hcl @@ -0,0 +1,20 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/google" { + version = "6.3.0" + constraints = "6.3.0" + hashes = [ + "h1:2Yamc2+FSclN0at2L04+vAaL90JDNUH0e3aj2+gyTmA=", + "zh:1ce4d9270a11a99a842f4516e4a24e85b7bd14147c736bbd0ac9711feb880148", + "zh:2643cf1dacf4464688ca5d6f7c530ea941224a878cf19717b231bf0b5edc97b9", + "zh:559e23c32ab605189f2752fd261b120aa0a0604f12c063d856f1399048b856db", + "zh:5e422235813c6c34a12cede5ee535c377bee0d9de731ea7ab4abb2fc2d8e30af", + "zh:66d0b21cf6af354b0d0ee7b6aee7ad5ca1f3160a91b4ba0d21fd78db8eeca74f", + "zh:7c73c86100e26c6eb43026eec9cfa83b95a6872a0142bd221c1d4a65741180f4", + "zh:bc36f1ecd2dac775f5216097a1028fbcc63604234f446ce6220463f4b4a1fac8", + "zh:cdbd0883eaaad0e8caf79252acc3997fcebba9767db67a6b873008fb8e4945f9", + "zh:f0760ed8f0f622f88c40686cca0f4a7862cb41d67b2c1ddcf16b01418a4c8abc", + "zh:fae891937838784869a0cbfdf0c1d2bad08fba63c1e2a9fd43c269b2940925b6", + ] +} diff --git a/backend/tf/cloud_run.tf b/backend/tf/cloud_run.tf new file mode 100644 index 0000000000..9d9fc35aa3 --- /dev/null +++ b/backend/tf/cloud_run.tf @@ -0,0 +1,31 @@ +variable "cloud_run_service_name" { + description = "The name of the Cloud Run service" + type = string +} + +resource "google_cloud_run_v2_service" "service" { + name = var.cloud_run_service_name + location = var.region + deletion_protection = false + + template { + containers { + image = "nginx:alpine" + ports { + container_port = 80 + } + } + } +} + +resource "google_cloud_run_v2_service_iam_member" "noauth" { + location = google_cloud_run_v2_service.service.location + name = google_cloud_run_v2_service.service.name + role = "roles/run.invoker" + member = "allUsers" +} + + +output "service_url" { + value = google_cloud_run_v2_service.service.uri +} \ No newline at end of file diff --git a/backend/tf/provider.tf b/backend/tf/provider.tf new file mode 100644 index 0000000000..9fb2693d6d --- /dev/null +++ b/backend/tf/provider.tf @@ -0,0 +1,45 @@ +variable "backend_gcs_bucket" { + type = string + description = "The name of the bucket for storing Terraform state" +} + +variable "service_name" { + type = string + description = "Name of the service. This name should be unique within the project." +} + +variable "project" { + type = string + description = "Name of the project. This value should be shared within the entire repository." +} + +variable "region" { + type = string +} + +terraform { + required_providers { + google = { + source = "hashicorp/google" + version = "6.3.0" + } + } + + backend "gcs" { + bucket = var.backend_gcs_bucket + prefix = "terraform/state/${var.service_name}" + } +} + +provider "google" { + # Configuration options + project = var.project + + region = var.region + + default_labels = { + "managed-by" = "opentofu" + "project" = var.project + # "environment" = "production" # TODO: This depends on the environment/branch. + } +} \ No newline at end of file diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000000..3f3166a018 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,110 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "commonjs", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} diff --git a/backend/yarn.lock b/backend/yarn.lock new file mode 100644 index 0000000000..0be0c537a7 --- /dev/null +++ b/backend/yarn.lock @@ -0,0 +1,683 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@cspotcode/source-map-support@^0.8.0": + version "0.8.1" + resolved "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz" + integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== + dependencies: + "@jridgewell/trace-mapping" "0.3.9" + +"@jridgewell/resolve-uri@^3.0.3": + version "3.1.2" + resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/sourcemap-codec@^1.4.10": + version "1.5.0" + resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz" + integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== + +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + +"@tsconfig/node10@^1.0.7": + version "1.0.11" + resolved "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz" + integrity sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw== + +"@tsconfig/node12@^1.0.7": + version "1.0.11" + resolved "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz" + integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== + +"@tsconfig/node14@^1.0.0": + version "1.0.3" + resolved "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz" + integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== + +"@tsconfig/node16@^1.0.2": + version "1.0.4" + resolved "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz" + integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== + +"@types/body-parser@*": + version "1.19.5" + resolved "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz" + integrity sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg== + dependencies: + "@types/connect" "*" + "@types/node" "*" + +"@types/connect@*": + version "3.4.38" + resolved "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz" + integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== + dependencies: + "@types/node" "*" + +"@types/cors@^2.8.17": + version "2.8.17" + resolved "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz" + integrity sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA== + dependencies: + "@types/node" "*" + +"@types/express-serve-static-core@^4.17.33": + version "4.19.5" + resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.5.tgz" + integrity sha512-y6W03tvrACO72aijJ5uF02FRq5cgDR9lUxddQ8vyF+GvmjJQqbzDcJngEjURc+ZsG31VI3hODNZJ2URj86pzmg== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + "@types/send" "*" + +"@types/express@^4.17.21": + version "4.17.21" + resolved "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz" + integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^4.17.33" + "@types/qs" "*" + "@types/serve-static" "*" + +"@types/http-errors@*": + version "2.0.4" + resolved "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz" + integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA== + +"@types/mime@^1": + version "1.3.5" + resolved "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz" + integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w== + +"@types/node@*": + version "22.5.5" + resolved "https://registry.npmjs.org/@types/node/-/node-22.5.5.tgz" + integrity sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA== + dependencies: + undici-types "~6.19.2" + +"@types/qs@*": + version "6.9.16" + resolved "https://registry.npmjs.org/@types/qs/-/qs-6.9.16.tgz" + integrity sha512-7i+zxXdPD0T4cKDuxCUXJ4wHcsJLwENa6Z3dCu8cfCK743OGy5Nu1RmAGqDPsoTDINVEcdXKRvR/zre+P2Ku1A== + +"@types/range-parser@*": + version "1.2.7" + resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz" + integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== + +"@types/send@*": + version "0.17.4" + resolved "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz" + integrity sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA== + dependencies: + "@types/mime" "^1" + "@types/node" "*" + +"@types/serve-static@*": + version "1.15.7" + resolved "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz" + integrity sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw== + dependencies: + "@types/http-errors" "*" + "@types/node" "*" + "@types/send" "*" + +accepts@~1.3.8: + version "1.3.8" + resolved "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + +acorn-walk@^8.1.1: + version "8.3.4" + resolved "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz" + integrity sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g== + dependencies: + acorn "^8.11.0" + +acorn@^8.11.0, acorn@^8.4.1: + version "8.12.1" + resolved "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz" + integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg== + +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + +array-flatten@1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz" + integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== + +body-parser@1.20.3: + version "1.20.3" + resolved "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz" + integrity sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g== + dependencies: + bytes "3.1.2" + content-type "~1.0.5" + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + http-errors "2.0.0" + iconv-lite "0.4.24" + on-finished "2.4.1" + qs "6.13.0" + raw-body "2.5.2" + type-is "~1.6.18" + unpipe "1.0.0" + +bytes@3.1.2: + version "3.1.2" + resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +call-bind@^1.0.7: + version "1.0.7" + resolved "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + +content-disposition@0.5.4: + version "0.5.4" + resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz" + integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== + dependencies: + safe-buffer "5.2.1" + +content-type@~1.0.4, content-type@~1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +cookie-signature@1.0.6: + version "1.0.6" + resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz" + integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== + +cookie@0.6.0: + version "0.6.0" + resolved "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz" + integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== + +cors@^2.8.5: + version "2.8.5" + resolved "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + +debug@2.6.9: + version "2.6.9" + resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + +depd@2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +destroy@1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + +diff@^4.0.1: + version "4.0.2" + resolved "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz" + integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== + +encodeurl@~2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz" + integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + +etag@~1.8.1: + version "1.8.1" + resolved "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz" + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + +express@^4.21.0: + version "4.21.0" + resolved "https://registry.npmjs.org/express/-/express-4.21.0.tgz" + integrity sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng== + dependencies: + accepts "~1.3.8" + array-flatten "1.1.1" + body-parser "1.20.3" + content-disposition "0.5.4" + content-type "~1.0.4" + cookie "0.6.0" + cookie-signature "1.0.6" + debug "2.6.9" + depd "2.0.0" + encodeurl "~2.0.0" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "1.3.1" + fresh "0.5.2" + http-errors "2.0.0" + merge-descriptors "1.0.3" + methods "~1.1.2" + on-finished "2.4.1" + parseurl "~1.3.3" + path-to-regexp "0.1.10" + proxy-addr "~2.0.7" + qs "6.13.0" + range-parser "~1.2.1" + safe-buffer "5.2.1" + send "0.19.0" + serve-static "1.16.2" + setprototypeof "1.2.0" + statuses "2.0.1" + type-is "~1.6.18" + utils-merge "1.0.1" + vary "~1.1.2" + +finalhandler@1.3.1: + version "1.3.1" + resolved "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz" + integrity sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ== + dependencies: + debug "2.6.9" + encodeurl "~2.0.0" + escape-html "~1.0.3" + on-finished "2.4.1" + parseurl "~1.3.3" + statuses "2.0.1" + unpipe "~1.0.0" + +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + +fresh@0.5.2: + version "0.5.2" + resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz" + integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: + version "1.2.4" + resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz" + integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + has-proto "^1.0.1" + has-symbols "^1.0.3" + hasown "^2.0.0" + +gopd@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz" + integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== + dependencies: + get-intrinsic "^1.1.3" + +has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-proto@^1.0.1: + version "1.0.3" + resolved "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz" + integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== + +has-symbols@^1.0.3: + version "1.0.3" + resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + +hasown@^2.0.0: + version "2.0.2" + resolved "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + +iconv-lite@0.4.24: + version "0.4.24" + resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +inherits@2.0.4: + version "2.0.4" + resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ipaddr.js@1.9.1: + version "1.9.1" + resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + +make-error@^1.1.1: + version "1.3.6" + resolved "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz" + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + +merge-descriptors@1.0.3: + version "1.0.3" + resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz" + integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== + +methods@~1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz" + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@~2.1.24, mime-types@~2.1.34: + version "2.1.35" + resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mime@1.6.0: + version "1.6.0" + resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== + +ms@2.1.3: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + +object-assign@^4: + version "4.1.1" + resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-inspect@^1.13.1: + version "1.13.2" + resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz" + integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g== + +on-finished@2.4.1: + version "2.4.1" + resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +path-to-regexp@0.1.10: + version "0.1.10" + resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz" + integrity sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w== + +proxy-addr@~2.0.7: + version "2.0.7" + resolved "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz" + integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + +qs@6.13.0: + version "6.13.0" + resolved "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz" + integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== + dependencies: + side-channel "^1.0.6" + +range-parser@~1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raw-body@2.5.2: + version "2.5.2" + resolved "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz" + integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + +safe-buffer@5.2.1: + version "5.2.1" + resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +"safer-buffer@>= 2.1.2 < 3": + version "2.1.2" + resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +send@0.19.0: + version "0.19.0" + resolved "https://registry.npmjs.org/send/-/send-0.19.0.tgz" + integrity sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw== + dependencies: + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "2.0.0" + mime "1.6.0" + ms "2.1.3" + on-finished "2.4.1" + range-parser "~1.2.1" + statuses "2.0.1" + +serve-static@1.16.2: + version "1.16.2" + resolved "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz" + integrity sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw== + dependencies: + encodeurl "~2.0.0" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "0.19.0" + +set-function-length@^1.2.1: + version "1.2.2" + resolved "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +side-channel@^1.0.6: + version "1.0.6" + resolved "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz" + integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + object-inspect "^1.13.1" + +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + +ts-node@^10.9.2: + version "10.9.2" + resolved "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz" + integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== + dependencies: + "@cspotcode/source-map-support" "^0.8.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.1" + yn "3.1.1" + +type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +typescript@^5.6.2, typescript@>=2.7: + version "5.6.2" + resolved "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz" + integrity sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw== + +undici-types@~6.19.2: + version "6.19.8" + resolved "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz" + integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== + +unpipe@~1.0.0, unpipe@1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +utils-merge@1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz" + integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + +v8-compile-cache-lib@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz" + integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== + +vary@^1, vary@~1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + +yn@3.1.1: + version "3.1.1" + resolved "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== diff --git a/ci/actions_runner_image/Dockerfile b/ci/actions_runner_image/Dockerfile new file mode 100644 index 0000000000..d4da0bd8ab --- /dev/null +++ b/ci/actions_runner_image/Dockerfile @@ -0,0 +1,55 @@ +# Start from Ubuntu 22.04 base image +FROM ubuntu:22.04 + +# Set arguments for versioning +ARG SOPS_VERSION=3.9.0 +ARG NODE_VERSION=20 + +# Install necessary packages and dependencies +RUN apt-get update && apt-get upgrade -y && \ + apt-get install -y \ + curl \ + apt-transport-https \ + ca-certificates \ + gnupg \ + age \ + build-essential + +# Install Docker +RUN curl -fsSL https://get.docker.com -o get-docker.sh && \ + sh get-docker.sh && \ + rm -f get-docker.sh + +# Install SOPS +RUN curl -LO https://github.com/getsops/sops/releases/download/v${SOPS_VERSION}/sops-v${SOPS_VERSION}.linux.amd64 && \ + mv sops-v${SOPS_VERSION}.linux.amd64 /usr/local/bin/sops && \ + chmod +x /usr/local/bin/sops + +# Create the directory for age keys +RUN mkdir -p /root/.config/sops/age + +# Install OpenTofu +RUN curl --proto '=https' --tlsv1.2 -fsSL https://get.opentofu.org/install-opentofu.sh -o install-opentofu.sh && \ + chmod +x install-opentofu.sh && \ + ./install-opentofu.sh --install-method deb && \ + rm -f install-opentofu.sh + +# Install Google Cloud SDK +RUN apt-get install -y apt-transport-https ca-certificates gnupg && \ + curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | \ + gpg --dearmor | \ + tee /usr/share/keyrings/cloud.google.gpg > /dev/null && \ + echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | \ + tee /etc/apt/sources.list.d/google-cloud-sdk.list && \ + apt-get update && \ + apt-get install -y google-cloud-sdk + +# Install npm, yarn, and Node.js +ENV NVM_DIR=/root/.nvm +RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash && \ + . "$NVM_DIR/nvm.sh" && nvm install ${NODE_VERSION} && \ + npm install -g yarn + + +# Default command +CMD ["bash"] diff --git a/ci/actions_runner_image/Makefile b/ci/actions_runner_image/Makefile new file mode 100644 index 0000000000..cc157bde83 --- /dev/null +++ b/ci/actions_runner_image/Makefile @@ -0,0 +1,17 @@ +.PHONY: all build push + +all: build push + +build: + docker build -t kimyongbeom/peerprep-actions-runner:latest . + +push: + docker push kimyongbeom/peerprep-actions-runner:latest + +run: + docker run -it \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v $(shell git rev-parse --show-toplevel):/home \ + --privileged \ + -e AGE_SECRET_KEY=$$(tail -n1 $${XDG_CONFIG_HOME:-$$HOME/.config}/sops/age/keys.txt) \ + kimyongbeom/peerprep-actions-runner:latest \ No newline at end of file diff --git a/docker_registry/Makefile b/docker_registry/Makefile new file mode 100644 index 0000000000..07d4b92d0a --- /dev/null +++ b/docker_registry/Makefile @@ -0,0 +1,41 @@ +SHELL=/bin/bash + +BACKEND_CLOUD_SERVICE_URL=$(shell $(MAKE) -C ../backend -s url | head -n 2) +BACKEND_LOCAL_SERVICE_URL=$(shell $(MAKE) -C ../backend -s local_url) + +help: ## Show this help + @echo "Usage: make [target]" + @echo "Targets:" + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +local: ## Run the code locally + export VITE_BACKEND_SERVICE_URL="$(BACKEND_LOCAL_SERVICE_URL)" && \ + yarn run dev + +deploy: infra_deploy ## Deploy all +destroy: infra_destroy ## Destroy all + + +test: ## Run the tests + @echo $(BACKEND_CLOUD_SERVICE_URL) + +infra_deploy: ## Deploy the infrastructure + . ./source.sh && \ + cd tf && \ + tofu init && \ + tofu workspace select -or-create $$TERRAFORM_WORKSPACE && \ + tofu apply -auto-approve + +infra_destroy: ## Destroy the infrastructure + . ./source.sh && \ + cd tf && \ + tofu init && \ + tofu workspace select -or-create $$TERRAFORM_WORKSPACE && \ + tofu destroy -auto-approve + +url: ## Get the service URL + @. ./source.sh && \ + cd tf && \ + tofu init &> /dev/null && \ + tofu workspace select -or-create $$TERRAFORM_WORKSPACE &> /dev/null && \ + tofu output -raw frontend_bucket_website_url \ No newline at end of file diff --git a/docker_registry/source.sh b/docker_registry/source.sh new file mode 100644 index 0000000000..bfe40c56ef --- /dev/null +++ b/docker_registry/source.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +set -a + +# Set the environment variables +. ../source.sh + +set +a \ No newline at end of file diff --git a/docker_registry/tf/.terraform.lock.hcl b/docker_registry/tf/.terraform.lock.hcl new file mode 100644 index 0000000000..18d634b18d --- /dev/null +++ b/docker_registry/tf/.terraform.lock.hcl @@ -0,0 +1,20 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/google" { + version = "6.3.0" + constraints = "6.3.0" + hashes = [ + "h1:2Yamc2+FSclN0at2L04+vAaL90JDNUH0e3aj2+gyTmA=", + "zh:1ce4d9270a11a99a842f4516e4a24e85b7bd14147c736bbd0ac9711feb880148", + "zh:2643cf1dacf4464688ca5d6f7c530ea941224a878cf19717b231bf0b5edc97b9", + "zh:559e23c32ab605189f2752fd261b120aa0a0604f12c063d856f1399048b856db", + "zh:5e422235813c6c34a12cede5ee535c377bee0d9de731ea7ab4abb2fc2d8e30af", + "zh:66d0b21cf6af354b0d0ee7b6aee7ad5ca1f3160a91b4ba0d21fd78db8eeca74f", + "zh:7c73c86100e26c6eb43026eec9cfa83b95a6872a0142bd221c1d4a65741180f4", + "zh:bc36f1ecd2dac775f5216097a1028fbcc63604234f446ce6220463f4b4a1fac8", + "zh:cdbd0883eaaad0e8caf79252acc3997fcebba9767db67a6b873008fb8e4945f9", + "zh:f0760ed8f0f622f88c40686cca0f4a7862cb41d67b2c1ddcf16b01418a4c8abc", + "zh:fae891937838784869a0cbfdf0c1d2bad08fba63c1e2a9fd43c269b2940925b6", + ] +} diff --git a/docker_registry/tf/provider.tf b/docker_registry/tf/provider.tf new file mode 100644 index 0000000000..471ace3745 --- /dev/null +++ b/docker_registry/tf/provider.tf @@ -0,0 +1,42 @@ +variable "project" { + type = string +} + +variable "region" { + type = string +} + +variable "zone" { + type = string +} + +variable "backend_gcs_bucket" { + type = string +} + +terraform { + required_providers { + google = { + source = "hashicorp/google" + version = "6.3.0" + } + } + + backend "gcs" { + bucket = var.backend_gcs_bucket + prefix = "terraform/state/global" + } +} + +provider "google" { + # Configuration options + project = var.project + region = var.region + zone = var.zone + + default_labels = { + "managed-by" = "opentofu" + "project" = var.project + "environment" = "terraform" + } +} \ No newline at end of file diff --git a/docker_registry/tf/registry.tf b/docker_registry/tf/registry.tf new file mode 100644 index 0000000000..999d5b9101 --- /dev/null +++ b/docker_registry/tf/registry.tf @@ -0,0 +1,15 @@ +resource "google_artifact_registry_repository" "repo" { + repository_id = terraform.workspace + description = "Docker Repository for project ${var.project}." + format = "DOCKER" + + docker_config { + # immutable_tags = true + immutable_tags = false # This sucks, but it fixes some issues with the actions. FIXME: Enable this without triggering errors due to commit hash not being updated with unstaged commits. + } + +} + +output "repository_url" { + value = "${google_artifact_registry_repository.repo.location}-docker.pkg.dev/${google_artifact_registry_repository.repo.project}/${google_artifact_registry_repository.repo.repository_id}" +} \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000000..a547bf36d8 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/Makefile b/frontend/Makefile new file mode 100644 index 0000000000..a4a3a21449 --- /dev/null +++ b/frontend/Makefile @@ -0,0 +1,57 @@ +SHELL=/bin/bash + +BACKEND_CLOUD_SERVICE_URL=$(shell $(MAKE) -C ../backend -s url | head -n 2) +BACKEND_LOCAL_SERVICE_URL=$(shell $(MAKE) -C ../backend -s local_url) + +help: ## Show this help + @echo "Usage: make [target]" + @echo "Targets:" + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +local: ## Run the code locally + export VITE_BACKEND_SERVICE_URL="$(BACKEND_LOCAL_SERVICE_URL)" && \ + yarn run dev + +deploy: infra_deploy code_deploy ## Deploy the code and infrastructure +destroy: infra_destroy ## Destroy the infrastructure + + +test: ## Run the tests + @echo $(BACKEND_CLOUD_SERVICE_URL) + +code_build: ## Build the code + @if [[ "$(BACKEND_CLOUD_SERVICE_URL)" == *"No outputs found"* ]] ; then \ + echo "Backend service URL not found, please go to the backend service and deploy it first."; \ + exit 1; \ + fi + export VITE_BACKEND_SERVICE_URL=$(BACKEND_CLOUD_SERVICE_URL) && \ + yarn && \ + yarn run build + +code_deploy: code_build ## Deploy the code + . ./source.sh && \ + bucket_url=$$(cd tf && tofu output -raw frontend_bucket_url) && \ + gsutil -m rm $$bucket_url/'**' || true && \ + gsutil -m cp -r dist/** $$bucket_url && \ + cd tf && tofu output + +infra_deploy: ## Deploy the infrastructure + . ./source.sh && \ + cd tf && \ + tofu init && \ + tofu workspace select -or-create $$TERRAFORM_WORKSPACE && \ + tofu apply -auto-approve + +infra_destroy: ## Destroy the infrastructure + . ./source.sh && \ + cd tf && \ + tofu init && \ + tofu workspace select -or-create $$TERRAFORM_WORKSPACE && \ + tofu destroy -auto-approve + +url: ## Get the service URL + @. ./source.sh && \ + cd tf && \ + tofu init &> /dev/null && \ + tofu workspace select -or-create $$TERRAFORM_WORKSPACE &> /dev/null && \ + tofu output -raw frontend_bucket_website_url \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000000..74872fd4af --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,50 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default tseslint.config({ + languageOptions: { + // other options... + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, +}) +``` + +- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` +- Optionally add `...tseslint.configs.stylisticTypeChecked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: + +```js +// eslint.config.js +import react from 'eslint-plugin-react' + +export default tseslint.config({ + // Set the react version + settings: { react: { version: '18.3' } }, + plugins: { + // Add the react plugin + react, + }, + rules: { + // other rules... + // Enable its recommended rules + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + }, +}) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000000..092408a9f0 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000000..bbd293dfe3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + PeerPrep + + +
+ + + diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts new file mode 100644 index 0000000000..7e897022cf --- /dev/null +++ b/frontend/jest.config.ts @@ -0,0 +1,14 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, + setupFilesAfterEnv: ['/src/setupTests.ts'], + testMatch: ['**/__tests__/**/*.ts?(x)', '**/?(*.)+(spec|test).ts?(x)'], + transform: { + '^.+\\.(ts|tsx)$': ['ts-jest', { + tsconfig: 'tsconfig.app.json' // Point to tsconfig.app.json + }], + }, + }; \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000000..561c54fe0c --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,39 @@ +{ + "name": "frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "test": "jest" + }, + "dependencies": { + "@testing-library/react": "^16.0.1", + "@types/testing-library__react": "^10.2.0", + "axios": "^1.7.7", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@eslint/js": "^9.9.0", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.5.0", + "@types/jest": "^29.5.13", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "eslint": "^9.9.0", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.9", + "globals": "^15.9.0", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", + "typescript": "^5.5.3", + "typescript-eslint": "^8.0.1", + "vite": "^5.4.1" + } +} diff --git a/frontend/public/code.svg b/frontend/public/code.svg new file mode 100644 index 0000000000..bb4ffcd5f4 --- /dev/null +++ b/frontend/public/code.svg @@ -0,0 +1,15 @@ + + + + code_fill + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000000..e7b8dfb1b2 --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/source.sh b/frontend/source.sh new file mode 100644 index 0000000000..6ce2d80b92 --- /dev/null +++ b/frontend/source.sh @@ -0,0 +1,6 @@ +#!/bin/bash +PWD="$(git rev-parse --show-toplevel)"/frontend +# Get global environment variables for project +source $PWD/../source.sh + +export TF_VAR_service_name=frontend diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000000..10f0d9b0b9 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,133 @@ +#root { + max-width: 1080px; + margin: 0 auto; + padding: 5rem; + text-align: center; +} + +/* Global Styling */ +body { + background-color: white; + color: black; +} + +h1 { + font-size: 32px; + color: black; +} + +h2 { + font-size: 24px; + color: black; +} + + +/* General Form Styles */ +.question-form input, +.question-form textarea, +.question-form select { + width: 100%; + padding: 10px; + margin-top: 5px; + border: 1px solid #ccc; + border-radius: 4px; + background-color: #f4f4f4; + /* Light background */ + color: #333; + /* Dark text color for contrast */ + font-size: 16px; +} + + + +/* Input Fields Focus State */ +.question-form input:focus, +.question-form textarea:focus, +.question-form select:focus { + outline: none; + border: 2px solid #4CAF50; + /* Green border on focus */ + background-color: #fff; + /* White background on focus */ +} + +/* Submit Button */ +button.submit-btn { + background-color: #4CAF50; + color: white; + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + margin-top: 10px; +} + +button.submit-btn:hover { + background-color: #45a049; +} + +/* Error Message */ +.error-message { + color: red; + font-size: 14px; + margin-bottom: 15px; + text-align: center; +} + +/* Table Styles */ +.question-table th, +.question-table td { + padding: 12px; + text-align: left; + border-bottom: 1px solid #ddd; +} + +.question-table th { + background-color: #f2f2f2; + color: #333; +} + +.question-table tr:hover { + background-color: #f1f1f1; +} + +/* Force the table to have fixed layout */ +.question-table { + width: 100%; + table-layout: fixed; /* Equal width and height for table cells */ + border-collapse: collapse; /* no gaps between table cells */ +} + +/* Style table cells */ +.question-table th, .question-table td { + border: 1px solid #ccc; /* Apply border to all cells */ + padding: 8px; /* Add padding for better readability */ + vertical-align: top; /* Ensure text starts at the top of each cell */ + text-align: left; /* Align text to the left for better readability */ +} + +/* Description cell clipping */ +.description-cell { + display: -webkit-box; + -webkit-line-clamp: 10; /* Limit description to 10 lines */ + line-clamp: 10; /* Standard property for compatibility */ + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + white-space: normal; + border: none; +} + +/* Adjust spacing and alignment for category checkboxes */ +.category-group label { + display: inline-block; /* label and input stay on same line */ + margin-right: 15px; /* spacing between each checkbox and label */ + margin-bottom: 5px; /* Control vertical spacing */ + font-size: 16px; /* Adjust font size to match rest of the form */ +} + +.category-group input { + margin-right: 5px; /* Space between checkbox and category text */ + vertical-align: middle; /* Align checkbox vertically with the text */ +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000000..f20dca68e1 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import QuestionManagement from './views/QuestionManagement'; +import './App.css'; + +const App: React.FC = () => { + return ( +
+
+

PeerPrep

+
+
+ +
+
+

© Group X

+
+
+ ); +}; + +export default App; \ No newline at end of file diff --git a/frontend/src/__test__/api/questionApi.test.ts b/frontend/src/__test__/api/questionApi.test.ts new file mode 100644 index 0000000000..dc48a627b0 --- /dev/null +++ b/frontend/src/__test__/api/questionApi.test.ts @@ -0,0 +1,69 @@ +import axios from 'axios'; +import { Question } from '../../models/Question'; +import * as api from '../../api/questionApi'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('questionApi', () => { + const mockValidQuestion: Question = { + id: 1, + title: 'Test Question', + description: 'This is a test question', + categories: ['algorithms'], + complexity: 'medium' + }; + + const mockInvalidQuestion: any = { + id: 2, + title: '', // Invalid: empty title + description: 'Invalid question', + categories: [], // Invalid: empty categories + complexity: 'invalid' // Invalid: incorrect complexity + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('fetchQuestions', () => { + it('should return valid questions when API call is successful', async () => { + mockedAxios.get.mockResolvedValue({ data: [mockValidQuestion] }); + + const result = await api.fetchQuestions(); + expect(result).toEqual([mockValidQuestion]); + expect(mockedAxios.get).toHaveBeenCalledWith(expect.any(String)); + }); + + it('should filter out invalid questions and log warnings', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + mockedAxios.get.mockResolvedValue({ data: [mockValidQuestion, mockInvalidQuestion] }); + + const result = await api.fetchQuestions(); + expect(result).toEqual([mockValidQuestion]); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Received 2 questions, but only 1 are valid.")); + consoleSpy.mockRestore(); + }); + + it('should throw an ApiError when API call fails', async () => { + mockedAxios.get.mockRejectedValue(new Error('Network error')); + + await expect(api.fetchQuestions()).rejects.toThrow('API error'); + }); + }); + + describe('deleteQuestion', () => { + it('should delete a question when API call is successful', async () => { + mockedAxios.delete.mockResolvedValue({ data: {} }); + + await api.deleteQuestion(1); + expect(mockedAxios.delete).toHaveBeenCalledWith(expect.stringContaining('/1')); + }); + + it('should throw an ApiError when API call fails', async () => { + mockedAxios.delete.mockRejectedValue(new Error('Network error')); + + await expect(api.deleteQuestion(1)).rejects.toThrow('API error'); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/__test__/controllers/QuestionController.test.tsx b/frontend/src/__test__/controllers/QuestionController.test.tsx new file mode 100644 index 0000000000..2dbd47d1e6 --- /dev/null +++ b/frontend/src/__test__/controllers/QuestionController.test.tsx @@ -0,0 +1,138 @@ +import QuestionController from '../../controllers/QuestionController'; +import * as api from '../../api/questionApi'; +import { Question } from '../../models/Question'; + +jest.mock('../../api/questionApi'); + +describe('QuestionController', () => { + const mockValidQuestion: Question = { + id: 1, + title: 'Test Question', + description: 'This is a test question', + categories: ['algorithms'], + complexity: 'medium' + }; + + const mockInvalidQuestion: any = { + id: 2, + title: '', // Invalid: empty title + description: 'Invalid question', + categories: [], // Invalid: empty categories + complexity: 'invalid' // Invalid: incorrect complexity + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('validateQuestion', () => { + it('should return null for valid question data', () => { + const result = QuestionController.validateQuestion(mockValidQuestion); + expect(result).toBeNull(); + }); + + it('should return error message for invalid title', () => { + const result = QuestionController.validateQuestion({ ...mockValidQuestion, title: '' }); + expect(result).toBe("Title is required and cannot be empty."); + }); + + it('should return error message for invalid description', () => { + const result = QuestionController.validateQuestion({ ...mockValidQuestion, description: '' }); + expect(result).toBe("Description is required and cannot be empty."); + }); + + it('should return error message for empty categories', () => { + const result = QuestionController.validateQuestion({ ...mockValidQuestion, categories: [] }); + expect(result).toBe("At least one category is required."); + }); + + it('should return error message for invalid complexity', () => { + const result = QuestionController.validateQuestion({ ...mockValidQuestion, complexity: 'invalid' as any }); + expect(result).toBe("Complexity must be either 'easy', 'medium', or 'hard'."); + }); + }); + + describe('createQuestion', () => { + const newValidQuestion = { + title: 'New Question', + description: 'This is a new question', + categories: ['data-structures'], + complexity: 'easy' as const + }; + + it('should create a question when data is valid and API call is successful', async () => { + (api.createQuestion as jest.Mock).mockResolvedValue({ id: 2, ...newValidQuestion }); + + const result = await QuestionController.createQuestion(newValidQuestion); + expect(result).toEqual({ id: 2, ...newValidQuestion }); + expect(api.createQuestion).toHaveBeenCalledWith(newValidQuestion); + }); + + it('should throw an error when question data is invalid', async () => { + await expect(QuestionController.createQuestion(mockInvalidQuestion)).rejects.toThrow('Invalid question data:'); + expect(api.createQuestion).not.toHaveBeenCalled(); + }); + + it('should throw an error when API call fails', async () => { + (api.createQuestion as jest.Mock).mockRejectedValue(new Error('API error')); + + await expect(QuestionController.createQuestion(newValidQuestion)).rejects.toThrow('Failed to create question. Please check your input and try again.'); + expect(api.createQuestion).toHaveBeenCalledWith(newValidQuestion); + }); + }); + + describe('updateQuestion', () => { + const updatedValidQuestion = { + title: 'Updated Question', + description: 'This is an updated question', + categories: ['algorithms', 'dynamic-programming'], + complexity: 'hard' as const + }; + + it('should update a question when data is valid and API call is successful', async () => { + (api.updateQuestion as jest.Mock).mockResolvedValue({ id: 1, ...updatedValidQuestion }); + + const result = await QuestionController.updateQuestion(1, updatedValidQuestion); + expect(result).toEqual({ id: 1, ...updatedValidQuestion }); + expect(api.updateQuestion).toHaveBeenCalledWith(1, updatedValidQuestion); + }); + + it('should throw an error when question ID is invalid', async () => { + await expect(QuestionController.updateQuestion(0, updatedValidQuestion)).rejects.toThrow('Invalid question ID.'); + expect(api.updateQuestion).not.toHaveBeenCalled(); + }); + + it('should throw an error when question data is invalid', async () => { + await expect(QuestionController.updateQuestion(1, mockInvalidQuestion)).rejects.toThrow('Invalid question data:'); + expect(api.updateQuestion).not.toHaveBeenCalled(); + }); + + it('should throw an error when API call fails', async () => { + (api.updateQuestion as jest.Mock).mockRejectedValue(new Error('API error')); + + await expect(QuestionController.updateQuestion(1, updatedValidQuestion)).rejects.toThrow('Failed to update question. Please check your input and try again.'); + expect(api.updateQuestion).toHaveBeenCalledWith(1, updatedValidQuestion); + }); + }); + + describe('deleteQuestion', () => { + it('should delete a question when ID is valid and API call is successful', async () => { + (api.deleteQuestion as jest.Mock).mockResolvedValue(undefined); + + await QuestionController.deleteQuestion(1); + expect(api.deleteQuestion).toHaveBeenCalledWith(1); + }); + + it('should throw an error when question ID is invalid', async () => { + await expect(QuestionController.deleteQuestion(0)).rejects.toThrow('Invalid question ID.'); + expect(api.deleteQuestion).not.toHaveBeenCalled(); + }); + + it('should throw an error when API call fails', async () => { + (api.deleteQuestion as jest.Mock).mockRejectedValue(new Error('API error')); + + await expect(QuestionController.deleteQuestion(1)).rejects.toThrow('Failed to delete question. Please try again later.'); + expect(api.deleteQuestion).toHaveBeenCalledWith(1); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/__test__/models/QuestionModel.test.tsx b/frontend/src/__test__/models/QuestionModel.test.tsx new file mode 100644 index 0000000000..836f88b1cb --- /dev/null +++ b/frontend/src/__test__/models/QuestionModel.test.tsx @@ -0,0 +1,40 @@ +import { Question } from '../../models/Question'; + +describe('Question Model', () => { + test('should have the correct structure', () => { + const question: Question = { + id: 1, + title: 'Sample Question', + description: 'This is a sample question', + categories: ['algorithms', 'data-structures'], + complexity: 'medium' + }; + + expect(question).toHaveProperty('id'); + expect(question).toHaveProperty('title'); + expect(question).toHaveProperty('description'); + expect(question).toHaveProperty('categories'); + expect(question).toHaveProperty('complexity'); + + expect(typeof question.id).toBe('number'); + expect(typeof question.title).toBe('string'); + expect(typeof question.description).toBe('string'); + expect(Array.isArray(question.categories)).toBeTruthy(); + expect(typeof question.complexity).toBe('string'); + }); + + test('should accept valid complexity values', () => { + const validComplexities = ['easy', 'medium', 'hard']; + + validComplexities.forEach(complexity => { + const question: Question = { + id: 1, + title: 'Test', + description: 'Test', + categories: [], + complexity: complexity as 'easy' | 'medium' | 'hard' + }; + expect(question.complexity).toBe(complexity); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/__test__/views/QuestionForm.test.tsx b/frontend/src/__test__/views/QuestionForm.test.tsx new file mode 100644 index 0000000000..e89fad4f27 --- /dev/null +++ b/frontend/src/__test__/views/QuestionForm.test.tsx @@ -0,0 +1,34 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import QuestionForm from '../../views/QuestionForm'; + +describe('QuestionForm', () => { + const mockOnSubmit = jest.fn(); + + test('renders form fields correctly', () => { + render(); + expect(screen.getByPlaceholderText('Question Title')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Question Description')).toBeInTheDocument(); + expect(screen.getByText('Categories:')).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + expect(screen.getByText('Submit')).toBeInTheDocument(); + }); + + test('calls onSubmit with form data when submitted', () => { + render(); + + fireEvent.change(screen.getByPlaceholderText('Question Title'), { target: { value: 'Test Title' } }); + fireEvent.change(screen.getByPlaceholderText('Question Description'), { target: { value: 'Test Description' } }); + fireEvent.click(screen.getByLabelText('graphs')); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'hard' } }); + + fireEvent.click(screen.getByText('Submit')); + + expect(mockOnSubmit).toHaveBeenCalledWith({ + title: 'Test Title', + description: 'Test Description', + categories: ['graphs'], + complexity: 'hard' + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/__test__/views/QuestionList.test.tsx b/frontend/src/__test__/views/QuestionList.test.tsx new file mode 100644 index 0000000000..95254b2c21 --- /dev/null +++ b/frontend/src/__test__/views/QuestionList.test.tsx @@ -0,0 +1,41 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import QuestionList from '../../views/QuestionList'; + +describe('QuestionList', () => { + const mockQuestions = [ + { id: 1, title: 'Question 1', description: 'Description 1', categories: ['algorithms'], complexity: 'easy' }, + { id: 2, title: 'Question 2', description: 'Description 2', categories: ['data-structures', 'graphs'], complexity: 'medium' }, + ]; + const mockOnDelete = jest.fn(); + const mockOnEdit = jest.fn(); + + test('renders questions correctly', () => { + render(); + expect(screen.getByText('Question 1')).toBeInTheDocument(); + expect(screen.getByText('Question 2')).toBeInTheDocument(); + expect(screen.getByText('algorithms')).toBeInTheDocument(); + expect(screen.getByText('data-structures, graphs')).toBeInTheDocument(); + expect(screen.getByText('easy')).toBeInTheDocument(); + expect(screen.getByText('medium')).toBeInTheDocument(); + }); + + test('calls onDelete when delete button is clicked', () => { + render(); + const deleteButtons = screen.getAllByText('Delete'); + fireEvent.click(deleteButtons[0]); + expect(mockOnDelete).toHaveBeenCalledWith(1); + }); + + test('calls onEdit when edit button is clicked', () => { + render(); + const editButtons = screen.getAllByText('Edit'); + fireEvent.click(editButtons[1]); + expect(mockOnEdit).toHaveBeenCalledWith(mockQuestions[1]); + }); + + test('renders empty message when no questions', () => { + render(); + expect(screen.getByText('No questions available.')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/src/__test__/views/QuestionManagement.test.tsx b/frontend/src/__test__/views/QuestionManagement.test.tsx new file mode 100644 index 0000000000..e2ae1b2a36 --- /dev/null +++ b/frontend/src/__test__/views/QuestionManagement.test.tsx @@ -0,0 +1,95 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import QuestionManagement from '../../views/QuestionManagement'; +import QuestionController from '../../controllers/QuestionController'; + +jest.mock('../../controllers/QuestionController'); + +describe('QuestionManagement', () => { + const mockQuestions = [ + { id: 1, title: 'Test Question', description: 'Test Description', categories: ['algorithms'], complexity: 'easy' } + ]; + + beforeEach(() => { + (QuestionController.fetchQuestions as jest.Mock).mockResolvedValue(mockQuestions); + }); + + test('renders question management form and list', async () => { + render(); + expect(screen.getByText('Question Management')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Question Title')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('Test Question')).toBeInTheDocument(); + }); + }); + + test('handles form submission for new question', async () => { + const newQuestion = { id: 2, title: 'New Question', description: 'New Description', categories: ['data-structures'], complexity: 'medium' }; + (QuestionController.createQuestion as jest.Mock).mockResolvedValue(newQuestion); + + render(); + + fireEvent.change(screen.getByPlaceholderText('Question Title'), { target: { value: 'New Question' } }); + fireEvent.change(screen.getByPlaceholderText('Question Description'), { target: { value: 'New Description' } }); + fireEvent.click(screen.getByLabelText('data-structures')); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'medium' } }); + + fireEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(QuestionController.createQuestion).toHaveBeenCalledWith({ + title: 'New Question', + description: 'New Description', + categories: ['data-structures'], + complexity: 'medium' + }); + }); + }); + + test('handles question deletion', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Test Question')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Delete')); + + await waitFor(() => { + expect(QuestionController.deleteQuestion).toHaveBeenCalledWith(1); + }); + }); + + test('handles question editing', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Test Question')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Edit')); + + expect(screen.getByDisplayValue('Test Question')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Test Description')).toBeInTheDocument(); + + fireEvent.change(screen.getByDisplayValue('Test Question'), { target: { value: 'Updated Question' } }); + fireEvent.click(screen.getByText('Update')); + + await waitFor(() => { + expect(QuestionController.updateQuestion).toHaveBeenCalledWith(1, expect.objectContaining({ + title: 'Updated Question', + description: 'Test Description', + categories: ['algorithms'], + complexity: 'easy' + })); + }); + }); + + test('displays error message on API failure', async () => { + (QuestionController.fetchQuestions as jest.Mock).mockRejectedValue(new Error('API Error')); + + render(); + + await waitFor(() => { + expect(screen.getByText('Failed to fetch questions. Please try again.')).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/api/questionApi.ts b/frontend/src/api/questionApi.ts new file mode 100644 index 0000000000..98b0ac104e --- /dev/null +++ b/frontend/src/api/questionApi.ts @@ -0,0 +1,84 @@ +import axios, { AxiosError } from 'axios'; +import { Question } from '../models/Question'; + +const API_URL = 'http://localhost:8080/api/questions'; + +class ApiError extends Error { + constructor(message: string, public statusCode?: number) { + super(message); + this.name = 'ApiError'; + } +} + +const handleApiError = (error: unknown): never => { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + if (axiosError.response) { + throw new ApiError(`API error: ${axiosError.response.statusText}`, axiosError.response.status); + } else if (axiosError.request) { + throw new ApiError('API error: No response received from the server'); + } else { + throw new ApiError(`API error: ${axiosError.message}`); + } + } else { + throw new ApiError('API error: An unexpected error occurred'); + } +}; + +const validateQuestionData = (data: any): data is Question => { + return ( + typeof data === 'object' && + data !== null && + typeof data.id === 'number' && + typeof data.title === 'string' && + typeof data.description === 'string' && + Array.isArray(data.categories) && + data.categories.every((cat: any) => typeof cat === 'string') && + ['easy', 'medium', 'hard'].includes(data.complexity) + ); +}; + +export const fetchQuestions = async (): Promise => { + try { + const response = await axios.get(API_URL); + const validQuestions = response.data.filter(validateQuestionData); + if (validQuestions.length !== response.data.length) { + console.warn(`Received ${response.data.length} questions, but only ${validQuestions.length} are valid.`); + } + return validQuestions; + } catch (error) { + return handleApiError(error); + } +}; + +export const createQuestion = async (questionData: Omit): Promise => { + try { + const response = await axios.post(API_URL, questionData); + if (!validateQuestionData(response.data)) { + throw new Error('Invalid question data received from server'); + } + return response.data; + } catch (error) { + return handleApiError(error); + } +}; + +export const updateQuestion = async (id: number, questionData: Omit): Promise => { + try { + const response = await axios.put(`${API_URL}/${id}`, questionData); + if (!validateQuestionData(response.data)) { + throw new Error('Invalid question data received from server'); + } + return response.data; + } catch (error) { + return handleApiError(error); + } +}; + +export const deleteQuestion = async (id: number): Promise => { + try { + await axios.delete(`${API_URL}/${id}`); + } catch (error) { + return handleApiError(error); + } +}; \ No newline at end of file diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000000..6c87de9bb3 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/controllers/QuestionController.tsx b/frontend/src/controllers/QuestionController.tsx new file mode 100644 index 0000000000..eb99b07f42 --- /dev/null +++ b/frontend/src/controllers/QuestionController.tsx @@ -0,0 +1,80 @@ +import * as api from '../api/questionApi'; +import { Question } from '../models/Question'; + +class QuestionController { + static validateQuestion(question: Partial): string | null { + if (!question.title || question.title.trim().length === 0) { + return "Title is required and cannot be empty."; + } + if (!question.description || question.description.trim().length === 0) { + return "Description is required and cannot be empty."; + } + if (!question.categories || question.categories.length === 0) { + return "At least one category is required."; + } + if (!question.complexity || !['easy', 'medium', 'hard'].includes(question.complexity)) { + return "Complexity must be either 'easy', 'medium', or 'hard'."; + } + return null; + } + + static async fetchQuestions(): Promise { + try { + const questions = await api.fetchQuestions(); + return questions.filter(question => { + const validationResult = this.validateQuestion(question); + if (validationResult !== null) { + console.warn(`Invalid question data received: ${validationResult}`, question); + return false; + } + return true; + }); + } catch (error) { + console.error('Error fetching questions:', error); + throw new Error('Failed to fetch questions. Please try again later.'); + } + } + + static async createQuestion(questionData: Omit): Promise { + const error = this.validateQuestion(questionData); + if (error) { + throw new Error(`Invalid question data: ${error}`); + } + try { + return await api.createQuestion(questionData); + } catch (error) { + console.error('Error creating question:', error); + throw new Error('Failed to create question. Please check your input and try again.'); + } + } + + static async updateQuestion(id: number, questionData: Omit): Promise { + if (!id || typeof id !== 'number' || id <= 0) { + throw new Error('Invalid question ID.'); + } + const error = this.validateQuestion(questionData); + if (error) { + throw new Error(`Invalid question data: ${error}`); + } + try { + return await api.updateQuestion(id, questionData); + } catch (error) { + console.error('Error updating question:', error); + throw new Error('Failed to update question. Please check your input and try again.'); + } + } + + static async deleteQuestion(id: number): Promise { + if (!id || typeof id !== 'number' || id <= 0) { + throw new Error('Invalid question ID.'); + } + try { + await api.deleteQuestion(id); + } catch (error) { + console.error('Error deleting question:', error); + throw new Error('Failed to delete question. Please try again later.'); + } + } +} + +export default QuestionController; \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000000..6119ad9a8f --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,68 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/frontend/src/jest.setup.ts b/frontend/src/jest.setup.ts new file mode 100644 index 0000000000..331666cea8 --- /dev/null +++ b/frontend/src/jest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000000..6f4ac9bcca --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App.tsx' +import './index.css' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/models/Question.tsx b/frontend/src/models/Question.tsx new file mode 100644 index 0000000000..5cbab6a6bc --- /dev/null +++ b/frontend/src/models/Question.tsx @@ -0,0 +1,7 @@ +export interface Question { + id: number; + title: string; + description: string; + categories: string[]; + complexity: string; + } \ No newline at end of file diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts new file mode 100644 index 0000000000..331666cea8 --- /dev/null +++ b/frontend/src/setupTests.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; \ No newline at end of file diff --git a/frontend/src/views/QuestionForm.tsx b/frontend/src/views/QuestionForm.tsx new file mode 100644 index 0000000000..fe9fba756a --- /dev/null +++ b/frontend/src/views/QuestionForm.tsx @@ -0,0 +1,107 @@ +import React, { useState, useEffect } from 'react'; +import { Question } from '../models/Question'; + +interface QuestionFormProps { + onSubmit: (formData: Omit) => void; + initialData: Partial | null; +} + +const QuestionForm: React.FC = ({ onSubmit, initialData }) => { + const [formData, setFormData] = useState>({ + title: '', + description: '', + categories: [], + complexity: '' + }); + + useEffect(() => { + if (initialData) { + setFormData({ + title: initialData.title || '', + description: initialData.description || '', + categories: initialData.categories || [], + complexity: initialData.complexity || '' + }); + } + }, [initialData]); + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData({ ...formData, [name]: value }); + }; + + const handleCategoryChange = (e: React.ChangeEvent) => { + const { value, checked } = e.target; + setFormData(prevData => ({ + ...prevData, + categories: checked + ? [...prevData.categories, value] + : prevData.categories.filter(cat => cat !== value) + })); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(formData); + setFormData({ title: '', description: '', categories: [], complexity: '' }); + }; + + return ( +
+
+ +
+ +
+