Repository: codicocodes/speedtyper.dev Branch: main Commit: 7778555e37a4 Files: 217 Total size: 304.8 KB Directory structure: gitextract_8g1dy2zt/ ├── .github/ │ └── workflows/ │ └── webapp-linting-and-unit-tests.yaml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md └── packages/ ├── back-nest/ │ ├── .eslintrc.js │ ├── .gitignore │ ├── .prettierrc │ ├── Dockerfile │ ├── README.md │ ├── docker-compose.yml │ ├── nest-cli.json │ ├── package.json │ ├── scripts/ │ │ ├── seed-local.sh │ │ └── seed-production.sh │ ├── src/ │ │ ├── app.module.ts │ │ ├── auth/ │ │ │ ├── auth.module.ts │ │ │ └── github/ │ │ │ ├── github.controller.ts │ │ │ ├── github.guard.ts │ │ │ └── github.strategy.ts │ │ ├── challenges/ │ │ │ ├── challenges.module.ts │ │ │ ├── commands/ │ │ │ │ ├── calculate-language-runner.ts │ │ │ │ ├── challenge-import-runner.ts │ │ │ │ ├── reformat-challenges-runner.ts │ │ │ │ └── unsynced-file-import-runner.ts │ │ │ ├── entities/ │ │ │ │ ├── challenge.entity.ts │ │ │ │ ├── language.dto.ts │ │ │ │ └── unsynced-file.entity.ts │ │ │ ├── languages.controller.ts │ │ │ └── services/ │ │ │ ├── challenge.service.ts │ │ │ ├── literal.service.ts │ │ │ ├── parser.service.ts │ │ │ ├── tests/ │ │ │ │ └── parser.service.spec.ts │ │ │ ├── ts-parser.factory.ts │ │ │ ├── unsynced-file-filterer.ts │ │ │ ├── unsynced-file-importer.ts │ │ │ └── unsynced-file.service.ts │ │ ├── commands.ts │ │ ├── config/ │ │ │ ├── cors.ts │ │ │ └── postgres.ts │ │ ├── connectors/ │ │ │ └── github/ │ │ │ ├── github.module.ts │ │ │ ├── schemas/ │ │ │ │ ├── github-blob.dto.ts │ │ │ │ ├── github-repository.dto.ts │ │ │ │ └── github-tree.dto.ts │ │ │ └── services/ │ │ │ └── github-api.ts │ │ ├── database.module.ts │ │ ├── filters/ │ │ │ └── exception.filter.ts │ │ ├── main.ts │ │ ├── middlewares/ │ │ │ └── guest-user.ts │ │ ├── projects/ │ │ │ ├── commands/ │ │ │ │ ├── import-untracked-projects-runner.ts │ │ │ │ └── sync-untracked-projects-runner.ts │ │ │ ├── entities/ │ │ │ │ ├── project.entity.ts │ │ │ │ └── untracked-project.entity.ts │ │ │ ├── project.controller.ts │ │ │ ├── projects.module.ts │ │ │ └── services/ │ │ │ ├── project.service.ts │ │ │ ├── projects-from-file-reader.ts │ │ │ └── untracked-projects.service.ts │ │ ├── races/ │ │ │ ├── entities/ │ │ │ │ └── race-settings.dto.ts │ │ │ ├── race.controllers.ts │ │ │ ├── race.exceptions.ts │ │ │ ├── race.gateway.ts │ │ │ ├── races.module.ts │ │ │ └── services/ │ │ │ ├── add-keystroke.service.ts │ │ │ ├── countdown.service.ts │ │ │ ├── keystroke-validator.service.ts │ │ │ ├── locker.service.ts │ │ │ ├── progress.service.ts │ │ │ ├── race-events.service.ts │ │ │ ├── race-manager.service.ts │ │ │ ├── race-player.service.ts │ │ │ ├── race.service.ts │ │ │ ├── results-handler.service.ts │ │ │ ├── session-state.service.ts │ │ │ └── tests/ │ │ │ └── race-player.service.spec.ts │ │ ├── results/ │ │ │ ├── entities/ │ │ │ │ ├── leaderboard-result.dto.ts │ │ │ │ └── result.entity.ts │ │ │ ├── errors.ts │ │ │ ├── results.controller.ts │ │ │ ├── results.module.ts │ │ │ └── services/ │ │ │ ├── result-calculation.service.ts │ │ │ ├── result-factory.service.ts │ │ │ └── results.service.ts │ │ ├── seeder/ │ │ │ ├── commands/ │ │ │ │ └── challenge.seeder.ts │ │ │ └── seeder.module.ts │ │ ├── sessions/ │ │ │ ├── session.adapter.ts │ │ │ ├── session.entity.ts │ │ │ ├── session.middleware.ts │ │ │ └── types.d.ts │ │ ├── tracking/ │ │ │ ├── entities/ │ │ │ │ └── event.entity.ts │ │ │ ├── tracking.module.ts │ │ │ └── tracking.service.ts │ │ ├── users/ │ │ │ ├── controllers/ │ │ │ │ └── user.controller.ts │ │ │ ├── entities/ │ │ │ │ ├── upsertGithubUserDTO.ts │ │ │ │ └── user.entity.ts │ │ │ ├── services/ │ │ │ │ └── user.service.ts │ │ │ ├── users.module.ts │ │ │ └── utils/ │ │ │ └── generateRandomUsername.ts │ │ └── utils/ │ │ └── validateDTO.ts │ ├── tracked-projects.txt │ ├── tsconfig.build.json │ └── tsconfig.json └── webapp-next/ ├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── README.md ├── Socket.ts ├── assets/ │ └── icons/ │ ├── BattleIcon.tsx │ ├── CopyIcon.tsx │ ├── CrossIcon.tsx │ ├── CrownIcon.tsx │ ├── DiscordLogo.tsx │ ├── DownArrowIcon.tsx │ ├── GithubLogo.tsx │ ├── InfoIcon.tsx │ ├── KogWheel.tsx │ ├── LinkIcon.tsx │ ├── OnlineIcon.tsx │ ├── PlayIcon.tsx │ ├── ProfileIcon.tsx │ ├── ReloadIcon.tsx │ ├── RightArrowIcon.tsx │ ├── TerminalIcon.tsx │ ├── TwitchLogo.tsx │ ├── UserGroupIcon.tsx │ ├── WarningIcon.tsx │ ├── YoutubeLogo.tsx │ └── index.tsx ├── common/ │ ├── api/ │ │ ├── auth.ts │ │ ├── races.ts │ │ ├── types.ts │ │ └── user.ts │ ├── components/ │ │ ├── Avatar.tsx │ │ ├── BattleMatcher.tsx │ │ ├── Button.tsx │ │ ├── Footer/ │ │ │ └── YoutubeLink.tsx │ │ ├── Footer.tsx │ │ ├── Layout.tsx │ │ ├── NewNavbar.tsx │ │ ├── Overlay.tsx │ │ ├── buttons/ │ │ │ ├── GithubLoginButton.tsx │ │ │ └── ModalCloseButton.tsx │ │ ├── modals/ │ │ │ ├── GithubLoginModal.tsx │ │ │ ├── GithubModal.tsx │ │ │ ├── Modal.tsx │ │ │ ├── ProfileModal.tsx │ │ │ └── SettingsModal.tsx │ │ └── overlays/ │ │ ├── GithubLoginOverlay.tsx │ │ └── SettingsOverlay.tsx │ ├── github/ │ │ └── stargazers.ts │ ├── hooks/ │ │ ├── useIsPlaying.ts │ │ └── useSocket.ts │ ├── services/ │ │ └── Socket.ts │ ├── state/ │ │ └── user-store.ts │ └── utils/ │ ├── clipboard.ts │ ├── cpmToWPM.ts │ ├── getServerUrl.ts │ ├── router.ts │ └── toHumanReadableTime.ts ├── components/ │ ├── Countdown.tsx │ ├── Navbar.tsx │ └── Stream.tsx ├── hooks/ │ ├── useKeyMap.ts │ └── useTotalSeconds.ts ├── modules/ │ └── play2/ │ ├── components/ │ │ ├── CodeArea.tsx │ │ ├── HiddenCodeInput.tsx │ │ ├── IncorrectChars.tsx │ │ ├── NextChar.tsx │ │ ├── RaceSettings.tsx │ │ ├── ResultsChart.tsx │ │ ├── SmoothCaret.tsx │ │ ├── TweetResult.tsx │ │ ├── TypedChars.tsx │ │ ├── UntypedChars.tsx │ │ ├── leaderboard/ │ │ │ ├── Leaderboard.tsx │ │ │ └── LeaderboardButton.tsx │ │ ├── play-footer/ │ │ │ ├── ChallengeSource/ │ │ │ │ ├── ChallengeSource.tsx │ │ │ │ └── index.ts │ │ │ └── PlayFooter/ │ │ │ ├── PlayFooter.tsx │ │ │ └── index.ts │ │ ├── play-header/ │ │ │ └── PlayHeader/ │ │ │ ├── PlayHeader.tsx │ │ │ └── index.tsx │ │ └── race-settings/ │ │ └── LanguageSelector.tsx │ ├── containers/ │ │ ├── CodeTypingContainer.tsx │ │ └── ResultsContainer.tsx │ ├── hooks/ │ │ ├── useChallenge.ts │ │ ├── useEndGame.ts │ │ ├── useFocusRef.ts │ │ ├── useGame.ts │ │ ├── useGameIdQueryParam.ts │ │ ├── useIsCompleted.ts │ │ ├── useNodeRect.ts │ │ └── useResetStateOnUnmount.ts │ ├── services/ │ │ └── Game.ts │ └── state/ │ ├── code-store.ts │ ├── connection-store.ts │ ├── game-store.ts │ ├── settings-store.ts │ └── trends-store.ts ├── next.config.js ├── package.json ├── pages/ │ ├── 404.tsx │ ├── _app.tsx │ ├── _document.tsx │ ├── index.tsx │ └── results/ │ └── [id].tsx ├── postcss.config.js ├── public/ │ └── robots.txt ├── styles/ │ └── globals.css ├── tailwind.config.js ├── tsconfig.json └── utils/ ├── calculateAccuracy.ts ├── cpmToWpm.ts ├── getTimeDifference.ts ├── humanize.ts └── stripIndentation.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/webapp-linting-and-unit-tests.yaml ================================================ name: Frontent linting and unit tests on: push: branches: - main pull_request: env: NODE_VERSION: 16 jobs: linting-and-tests: name: Webapp linting and unit tests runs-on: ubuntu-latest steps: - name: Install NodeJS uses: actions/setup-node@v2 with: node-version: ${{ env.NODE_VERSION }} - name: Code Checkout uses: actions/checkout@v2 - name: Install webapp dependencies run: yarn --cwd ./packages/webapp-next install --frozen-lockfile - name: Build webapp run: yarn --cwd ./packages/webapp-next build - name: Webapp code linting run: yarn --cwd ./packages/webapp-next lint --quiet - name: Webapp unit test run: echo "no tests to run" ================================================ FILE: .gitignore ================================================ # Notes .mind # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* # Deploys .netlify # Compiled binary addons (https://nodejs.org/api/addons.html) dist/ artifacts/ tmp/ # Dependency directories node_modules/ jspm_packages/ # TypeScript v1 declaration files typings/ # TypeScript cache *.tsbuildinfo # Yarn Integrity file .yarn-integrity # dotenv environment variables file .env # Local Netlify folder .netlify # development .idea ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing *This is a work in progress.* ### **Table of Contents** - [Required](#required) - [Running Speedtyper.dev](#running-speedtyperdev) - [Backend](#backend) - [Frontend](#frontend) ## Required |Prerequisite |Link | |-------------------------------------------|-----------------------------------------------------------------------| |Git |[🔗](https://git-scm.com/downloads) | |Node 20 |[🔗](https://nodejs.org/en/) | | Yarn |[🔗](https://classic.yarnpkg.com/lang/en/docs/install/#windows-stable)| |PostgreSQL | | |build-essential (or equivalent for your OS)| | | Docker (Optional) |[🔗](https://www.docker.com/) | ## Running Speedtyper.dev ### Backend 1. Install dependencies: ``` make install-backend-dependencies ``` 1. Copy over path of env file: ``` cp ./packages/back-nest/.env.development ./packages/back-nest/.env ``` 1. Generate [Github Access Token (classic)](https://github.com/settings/tokens) with `public_repo` permissions and update `GITHUB_ACCESS_TOKEN` variable in `./packages/back-nest/.env` with the token value. It is used to download seed data from GitHub. 1. Start Docker Compose in the background: ``` make run-dev-db ``` 1. Seed the db with example challenges: ``` make run-seed-codesources ``` 1. Run the backend: ``` make run-backend-dev ``` ### Frontend 1. Install dependencies: ``` make install-webapp-dependencies ``` 1. Run the frontend: ``` make run-webapp-dev ``` ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2022 codico Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ # backend install-backend-dependencies: yarn --cwd ./packages/back-nest run-backend-dev: yarn --cwd ./packages/back-nest start:dev run-dev-db: docker compose -f ./packages/back-nest/docker-compose.yml up -d run-seed-codesources: yarn --cwd ./packages/back-nest command seed-challenges # webapp install-webapp-dependencies: yarn --cwd ./packages/webapp-next run-webapp-dev: yarn --cwd ./packages/webapp-next dev ================================================ FILE: README.md ================================================
Speedtyper

speedtyper.dev

Typing competitions for programmers 🧑‍💻👩‍💻👨‍💻

GitHub stars

### **Table of Contents** - [Features](#features-🎉) - [Contribute](#contribute-👷) - [Community](#community-☕) - [License](#license-📜) - [Project Contributors](#project-contributors⭐) ## Features 🎉 - ✍️ [**Practice**](https://speedtyper.dev/play?mode=private) - type code snippets from real open source projects - 🏎️ [**Battle**](https://speedtyper.dev/play?mode=private) - play with your friends in real time with the private race mode - 🏅 [**Compete**](https://speedtyper.dev) - get on the global leaderboard ## Contribute 👷 - 🦄 **Pull requests are very appreciated!** - 📚 Read the [contributor introduction (wip)](https://github.com/codicocodes/speedtyper.dev/blob/main/CONTRIBUTING.md) - 🐛 If you encounter a bug, please [open an issue](https://github.com/codicocodes/speedtyper.dev/issues/new) - 🗨️ If you want to make a large change, please [open an issue](https://github.com/codicocodes/speedtyper.dev/issues/new) so we can discuss it! ## Community ☕ SpeedTyper Discord Twitch Stream ## License 📜 speedtyper.dev is open source software licensed as [MIT](https://github.com/codicocodes/speedtyper.dev/blob/main/LICENSE). The [logo](https://github.com/codicocodes/speedtyper.dev/blob/main/packages/webapp/public/images/logo.png) is made by [astrocanyounaut](https://www.twitch.tv/astrocanyounaut) 🧑‍🚀 and is not licensed under MIT. ## Project Contributors⭐ ================================================ FILE: packages/back-nest/.eslintrc.js ================================================ module.exports = { parser: '@typescript-eslint/parser', parserOptions: { project: 'tsconfig.json', tsconfigRootDir : __dirname, sourceType: 'module', }, plugins: ['@typescript-eslint/eslint-plugin'], extends: [ 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', ], root: true, env: { node: true, jest: true, }, ignorePatterns: ['.eslintrc.js', 'node_modules', 'dist'], rules: { '@typescript-eslint/interface-name-prefix': 'off', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', }, }; ================================================ FILE: packages/back-nest/.gitignore ================================================ # compiled output /dist /node_modules # Logs logs *.log npm-debug.log* pnpm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* # OS .DS_Store # Tests /coverage /.nyc_output # IDEs and editors /.idea .project .classpath .c9/ *.launch .settings/ *.sublime-workspace # IDE - VSCode .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json ================================================ FILE: packages/back-nest/.prettierrc ================================================ { "singleQuote": true, "trailingComma": "all" } ================================================ FILE: packages/back-nest/Dockerfile ================================================ FROM node:20 # Create app directory RUN mkdir -p /app WORKDIR /app # Install app dependencies COPY package.json /app COPY yarn.lock /app RUN yarn install --frozen-lockfile # Bundle app source COPY . /app RUN yarn build EXPOSE 80 CMD [ "node", "dist/main.js" ] ================================================ FILE: packages/back-nest/README.md ================================================ ## Seed challenge data ### Seed test challenges `yarn command seed-challenges` ### Seed production challenges Requires configuring a personal `GITHUB_ACCESS_TOKEN` in your .env file `yarn command import-projects` `yarn command sync-projects` `yarn command import-files` `yarn command import-challenges` ================================================ FILE: packages/back-nest/docker-compose.yml ================================================ # Use postgres/example user/password credentials version: "3.1" services: db: image: postgres restart: always ports: - 5432:5432 environment: POSTGRES_USERNAME: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: speedtyper adminer: image: adminer restart: always ports: - 8080:8080 ================================================ FILE: packages/back-nest/nest-cli.json ================================================ { "$schema": "https://json.schemastore.org/nest-cli", "collection": "@nestjs/schematics", "sourceRoot": "src" } ================================================ FILE: packages/back-nest/package.json ================================================ { "name": "back-nest", "version": "0.0.1", "description": "", "author": "", "private": true, "license": "UNLICENSED", "scripts": { "command": "TS_NODE_PROJECT=./tsconfig.json ts-node -r tsconfig-paths/register ./src/commands.ts", "prebuild": "rimraf dist", "build": "nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "yarn start:prod", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { "@nestjs/axios": "^0.1.0", "@nestjs/common": "^9.0.0", "@nestjs/config": "^2.2.0", "@nestjs/core": "^9.0.0", "@nestjs/passport": "^9.0.0", "@nestjs/platform-express": "^9.0.0", "@nestjs/platform-socket.io": "^9.1.4", "@nestjs/typeorm": "^10.0.1", "@nestjs/websockets": "^9.1.4", "@sentry/node": "^7.37.2", "@types/passport-github": "^1.1.7", "class-transformer": "^0.5.1", "class-validator": "^0.13.2", "connect-typeorm": "^2.0.0", "express-session": "^1.17.3", "express-socket.io-session": "^1.3.5", "nest-commander": "^3.1.0", "passport": "^0.6.0", "passport-github": "^1.1.0", "pg": "^8.8.0", "pg-query-stream": "^4.3.0", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0", "socket.io": "^4.5.2", "tree-sitter": "^0.20.0", "tree-sitter-c": "^0.19.0", "tree-sitter-c-sharp": "^0.19.0", "tree-sitter-cpp": "^0.19.0", "tree-sitter-css": "^0.19.0", "tree-sitter-go": "^0.19.1", "tree-sitter-java": "^0.19.1", "tree-sitter-javascript": "^0.19.0", "tree-sitter-lua": "^1.6.2", "tree-sitter-ocaml": "^0.19.0", "tree-sitter-php": "^0.19.0", "tree-sitter-python": "^0.19.0", "tree-sitter-ruby": "^0.19.0", "tree-sitter-rust": "^0.19.1", "tree-sitter-scala": "^0.19.0", "tree-sitter-typescript": "^0.19.0", "typeorm": "^0.3.10", "unique-names-generator": "^4.7.1" }, "devDependencies": { "@nestjs/cli": "^9.0.0", "@nestjs/schematics": "^9.0.0", "@nestjs/testing": "^9.0.0", "@types/express": "^4.17.13", "@types/express-session": "^1.17.5", "@types/express-socket.io-session": "^1.3.6", "@types/jest": "28.1.8", "@types/node": "^20.0.0", "@types/supertest": "^2.0.11", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", "eslint": "^8.0.1", "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^4.0.0", "jest": "28.1.3", "prettier": "^2.3.2", "source-map-support": "^0.5.20", "supertest": "^6.1.3", "ts-jest": "28.0.8", "ts-loader": "^9.2.3", "ts-node": "^10.0.0", "tsconfig-paths": "4.1.0", "typescript": "^4.7.4" }, "jest": { "moduleFileExtensions": [ "node", "js", "json", "ts" ], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, "collectCoverageFrom": [ "**/*.(t|j)s" ], "coverageDirectory": "../coverage", "testEnvironment": "node", "moduleNameMapper": { "^src/(.*)$": "/$1" } } } ================================================ FILE: packages/back-nest/scripts/seed-local.sh ================================================ #!/bin/bash yarn command import-projects && yarn command sync-projects && yarn command import-files && yarn command import-challenges ================================================ FILE: packages/back-nest/scripts/seed-production.sh ================================================ #!/bin/bash railway run ./seed-local.sh ================================================ FILE: packages/back-nest/src/app.module.ts ================================================ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { GithubConnectorModule } from './connectors/github/github.module'; import { ProjectsModule } from './projects/projects.module'; import { ChallengesModule } from './challenges/challenges.module'; import { UsersModule } from './users/users.module'; import { PostgresModule } from './database.module'; import { RacesModule } from './races/races.module'; import { SeederModule } from './seeder/seeder.module'; import { ResultsModule } from './results/results.module'; import { AuthModule } from './auth/auth.module'; @Module({ imports: [ ChallengesModule, ConfigModule.forRoot(), GithubConnectorModule, PostgresModule, ProjectsModule, RacesModule, ResultsModule, SeederModule, UsersModule, AuthModule, ], controllers: [], providers: [], }) export class AppModule {} ================================================ FILE: packages/back-nest/src/auth/auth.module.ts ================================================ import { Module } from '@nestjs/common'; import { PassportModule } from '@nestjs/passport'; import { ConfigModule } from '@nestjs/config'; import { AuthController, GithubAuthController, } from './github/github.controller'; import { GithubStrategy } from './github/github.strategy'; import { UsersModule } from 'src/users/users.module'; import { RacesModule } from 'src/races/races.module'; @Module({ imports: [ PassportModule.register({ // session: true, }), ConfigModule, UsersModule, RacesModule, ], controllers: [GithubAuthController, AuthController], providers: [GithubStrategy], }) export class AuthModule {} ================================================ FILE: packages/back-nest/src/auth/github/github.controller.ts ================================================ import { Controller, Delete, Get, HttpException, Req, Res, UseGuards, } from '@nestjs/common'; import { Request, Response } from 'express'; import { cookieName } from 'src/sessions/session.middleware'; import { User } from 'src/users/entities/user.entity'; import { GithubOauthGuard } from './github.guard'; @Controller('auth') export class AuthController { @Delete() async logout(@Req() request: Request, @Res() response: Response) { await new Promise((resolve, reject) => request.session?.destroy((err) => { console.log('session destroyed', { err }); if (err) { return reject(err); } return resolve(); }), ); response.clearCookie(cookieName); return response.send({ ok: true, }); } } @Controller('auth/github') export class GithubAuthController { @Get() @UseGuards(GithubOauthGuard) async githubLogin() { // NOTE: the GithubOauthGuard initiates the authentication flow } @Get('callback') @UseGuards(GithubOauthGuard) async githubCallback( @Req() request: Request, @Res({ passthrough: true }) response: Response, ) { if (!request.session) { throw new HttpException('Internal server error', 500); } request.session.user = request.user as User; const next = process.env.NODE_ENV === 'production' ? 'https://www.speedtyper.dev' : 'http://localhost:3001'; response.redirect(next); } } ================================================ FILE: packages/back-nest/src/auth/github/github.guard.ts ================================================ import { ExecutionContext, Injectable } from '@nestjs/common'; import { AuthGuard, IAuthModuleOptions } from '@nestjs/passport'; import { Request } from 'express'; @Injectable() export class GithubOauthGuard extends AuthGuard('github') { getAuthenticateOptions(context: ExecutionContext): IAuthModuleOptions { const request = context.switchToHttp().getRequest(); return { state: this.getState(request), session: true, }; } getState(request: Request) { const { next } = request.query as Record; const queryParams = next ? { next, } : {}; const state = new URLSearchParams(queryParams).toString(); return state; } } ================================================ FILE: packages/back-nest/src/auth/github/github.strategy.ts ================================================ import { PassportStrategy } from '@nestjs/passport'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Profile, Strategy } from 'passport-github'; import { UserService } from 'src/users/services/user.service'; import { UpsertGithubUserDTO } from 'src/users/entities/upsertGithubUserDTO'; @Injectable() export class GithubStrategy extends PassportStrategy(Strategy, 'github') { constructor(cfg: ConfigService, private userService: UserService) { const BASE_URL = process.env.NODE_ENV === 'production' ? 'https://v3.speedtyper.dev' : 'http://localhost:1337'; super({ clientID: cfg.get('GITHUB_CLIENT_ID'), clientSecret: cfg.get('GITHUB_CLIENT_SECRET'), callbackURL: `${BASE_URL}/api/auth/github/callback`, scope: ['public_profile'], }); } async validate( _accessToken: string, _refreshToken: string, profile: Profile, ) { const upsertUserDTO = UpsertGithubUserDTO.fromGithubProfile(profile); const user = await this.userService.upsertGithubUser( upsertUserDTO.toUser(), ); return user; } } ================================================ FILE: packages/back-nest/src/challenges/challenges.module.ts ================================================ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { GithubConnectorModule } from 'src/connectors/github/github.module'; import { ProjectsModule } from 'src/projects/projects.module'; import { CalculateLanguageRunner } from './commands/calculate-language-runner'; import { ChallengeImportRunner } from './commands/challenge-import-runner'; import { ReformatChallengesRunner } from './commands/reformat-challenges-runner'; import { UnsyncedFileImportRunner } from './commands/unsynced-file-import-runner'; import { Challenge } from './entities/challenge.entity'; import { UnsyncedFile } from './entities/unsynced-file.entity'; import { LanguageController } from './languages.controller'; import { ChallengeService } from './services/challenge.service'; import { LiteralService } from './services/literal.service'; import { ParserService } from './services/parser.service'; import { UnsyncedFileFilterer } from './services/unsynced-file-filterer'; import { UnsyncedFileImporter } from './services/unsynced-file-importer'; import { UnsyncedFileService } from './services/unsynced-file.service'; @Module({ imports: [ TypeOrmModule.forFeature([UnsyncedFile, Challenge]), GithubConnectorModule, ProjectsModule, ], controllers: [LanguageController], providers: [ ParserService, ChallengeService, LiteralService, ChallengeImportRunner, UnsyncedFileFilterer, UnsyncedFileImporter, UnsyncedFileImportRunner, UnsyncedFileService, CalculateLanguageRunner, ReformatChallengesRunner, ], exports: [ChallengeService, LiteralService], }) export class ChallengesModule {} ================================================ FILE: packages/back-nest/src/challenges/commands/calculate-language-runner.ts ================================================ import { InjectRepository } from '@nestjs/typeorm'; import { Command, CommandRunner } from 'nest-commander'; import { Repository } from 'typeorm'; import { Challenge } from '../entities/challenge.entity'; @Command({ name: 'calculate-language', arguments: '', options: {}, }) export class CalculateLanguageRunner extends CommandRunner { constructor( @InjectRepository(Challenge) private repository: Repository, ) { super(); } async run(): Promise { const stream = await this.repository .createQueryBuilder('ch') .select('id, path') .where('ch.language IS NULL') .stream(); const updatesByLanguage: Record = {}; for await (const { id, path } of stream) { const dotSplitted = path.split('.'); const language = dotSplitted[dotSplitted.length - 1]; if (!updatesByLanguage[language]) { updatesByLanguage[language] = []; } updatesByLanguage[language].push(id); } await Promise.all( Object.entries(updatesByLanguage).map(async ([language, ids]) => { await this.repository.update(ids, { language }); }), ); } } ================================================ FILE: packages/back-nest/src/challenges/commands/challenge-import-runner.ts ================================================ import { Command, CommandRunner } from 'nest-commander'; import { GithubAPI } from 'src/connectors/github/services/github-api'; import { Challenge } from '../entities/challenge.entity'; import { UnsyncedFile } from '../entities/unsynced-file.entity'; import { ChallengeService } from '../services/challenge.service'; import { ParserService } from '../services/parser.service'; import { UnsyncedFileService } from '../services/unsynced-file.service'; @Command({ name: 'import-challenges', arguments: '', options: {}, }) export class ChallengeImportRunner extends CommandRunner { constructor( private api: GithubAPI, private unsynced: UnsyncedFileService, private parserService: ParserService, private challengeService: ChallengeService, ) { super(); } async run(): Promise { let filesSynced = 0; const files = await this.unsynced.findAllWithProject(); for (const file of files) { const challenges = await this.syncChallengesFromFile(file); filesSynced++; console.info( `[challenge-import]: ${filesSynced}/${files.length} synced. Challenges added=${challenges.length}`, ); } } private async syncChallengesFromFile(file: UnsyncedFile) { const blob = await this.api.fetchBlob( file.project.fullName, file.currentSha, ); const nodes = this.parseNodesFromContent(file.path, blob.content); const challenges = nodes.map((node) => Challenge.fromTSNode(file.project, file, node), ); await this.challengeService.upsert(challenges); await this.unsynced.remove([file]); return challenges; } private parseNodesFromContent(path: string, base64Content: string) { const fileExtension = path.split('.').pop(); const parser = this.parserService.getParser(fileExtension); const content = Buffer.from(base64Content, 'base64').toString(); const nodes = parser.parseTrackedNodes(content); return nodes; } } ================================================ FILE: packages/back-nest/src/challenges/commands/reformat-challenges-runner.ts ================================================ import { InjectRepository } from '@nestjs/typeorm'; import { Command, CommandRunner } from 'nest-commander'; import { Repository } from 'typeorm'; import { Challenge } from '../entities/challenge.entity'; import { getFormattedText } from '../services/parser.service'; @Command({ name: 'reformat-challenges', arguments: '', options: {}, }) export class ReformatChallengesRunner extends CommandRunner { constructor( @InjectRepository(Challenge) private repository: Repository, ) { super(); } async run(): Promise { const stream = await this.repository .createQueryBuilder('ch') .select('id, content') .stream(); const pendingUpdates = []; for await (const { id, content } of stream) { const formattedContent = getFormattedText(content); if (formattedContent !== content) { pendingUpdates.push( this.repository.update({ id }, { content: formattedContent }), ); } } await Promise.all(pendingUpdates); console.log(`Reformatted ${pendingUpdates.length} challenges`); } } ================================================ FILE: packages/back-nest/src/challenges/commands/unsynced-file-import-runner.ts ================================================ import { Command, CommandRunner } from 'nest-commander'; import { ProjectService } from 'src/projects/services/project.service'; import { UnsyncedFileImporter } from '../services/unsynced-file-importer'; @Command({ name: 'import-files', arguments: '', options: {}, }) export class UnsyncedFileImportRunner extends CommandRunner { constructor( private projectService: ProjectService, private importer: UnsyncedFileImporter, ) { super(); } async run(): Promise { const projects = await this.projectService.findAll(); for (const project of projects) { // Only sync unsynced projects for now if (!project.syncedSha) { const sha = await this.importer.import(project); await this.projectService.updateSyncedSha(project.id, sha); console.info(`[FileImport]: Imported files for ${project.fullName}`); } } } } ================================================ FILE: packages/back-nest/src/challenges/entities/challenge.entity.ts ================================================ import TSParser from 'tree-sitter'; import { Project } from 'src/projects/entities/project.entity'; import { Entity, PrimaryGeneratedColumn, ManyToOne, Column, OneToMany, } from 'typeorm'; import { UnsyncedFile } from './unsynced-file.entity'; import { GithubAPI } from 'src/connectors/github/services/github-api'; import { Result } from 'src/results/entities/result.entity'; import { getFormattedText } from '../services/parser.service'; @Entity() export class Challenge { @PrimaryGeneratedColumn('uuid') id: string; @Column({ select: false }) sha: string; @Column({ select: false }) treeSha: string; @Column({ nullable: false }) language: string; @Column() path: string; @Column({ unique: true }) url: string; @Column({ unique: true }) content: string; @ManyToOne(() => Project, (project) => project.files) project: Project; @OneToMany(() => Result, (result) => result.user) results: Result[]; static fromTSNode( project: Project, file: UnsyncedFile, node: TSParser.SyntaxNode, ) { const challenge = new Challenge(); challenge.path = file.path; challenge.sha = file.currentSha; challenge.treeSha = file.currentTreeSha; challenge.project = project; challenge.content = getFormattedText(node.text); challenge.url = GithubAPI.getBlobPermaLink( project.fullName, file.currentTreeSha, file.path, // NOTE: row is 0 indexed, while #L is 1 indexed node.startPosition.row + 1, node.endPosition.row + 1, ); const dotSplitPath = file.path.split('.'); challenge.language = dotSplitPath[dotSplitPath.length - 1]; return challenge; } static getStrippedCode(code: string) { const strippedCode = code .split('\n') .map((subText) => subText.trimStart()) .join('\n'); return strippedCode; } } ================================================ FILE: packages/back-nest/src/challenges/entities/language.dto.ts ================================================ import { IsString } from 'class-validator'; export class LanguageDTO { @IsString() language: string; @IsString() name: string; } ================================================ FILE: packages/back-nest/src/challenges/entities/unsynced-file.entity.ts ================================================ import { GithubNode } from 'src/connectors/github/schemas/github-tree.dto'; import { Project } from 'src/projects/entities/project.entity'; import { Entity, PrimaryGeneratedColumn, ManyToOne, Column, Index, } from 'typeorm'; @Entity() @Index(['path', 'project'], { unique: true }) export class UnsyncedFile { @PrimaryGeneratedColumn('uuid') id: string; @Column() path: string; @Column() currentSha: string; @Column() currentTreeSha: string; @Column({ nullable: true }) syncedSha?: string; @ManyToOne(() => Project, (project) => project.files) project: Project; static fromGithubNode(project: Project, treeSha: string, node: GithubNode) { const file = new UnsyncedFile(); file.path = node.path; file.currentSha = node.sha; file.currentTreeSha = treeSha; file.project = project; return file; } } ================================================ FILE: packages/back-nest/src/challenges/languages.controller.ts ================================================ import { Controller, Get } from '@nestjs/common'; import { LanguageDTO } from './entities/language.dto'; import { ChallengeService } from './services/challenge.service'; @Controller('languages') export class LanguageController { constructor(private service: ChallengeService) {} @Get() getLeaderboard(): Promise { return this.service.getLanguages(); } } ================================================ FILE: packages/back-nest/src/challenges/services/challenge.service.ts ================================================ import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Challenge } from '../entities/challenge.entity'; import { LanguageDTO } from '../entities/language.dto'; @Injectable() export class ChallengeService { private static UpsertOptions = { conflictPaths: ['content'], skipUpdateIfNoValuesChanged: true, }; constructor( @InjectRepository(Challenge) private challengeRepository: Repository, ) {} async upsert(challenges: Challenge[]): Promise { await this.challengeRepository.upsert( challenges, ChallengeService.UpsertOptions, ); } async getRandom(language?: string): Promise { let query = this.challengeRepository .createQueryBuilder('challenge') .leftJoinAndSelect('challenge.project', 'project'); if (language) { query = query.where('challenge.language = :language', { language, }); } const randomChallenge = await query.orderBy('RANDOM()').getOne(); if (!randomChallenge) throw new BadRequestException(`No challenges for language: ${language}`); return randomChallenge; } async getLanguages(): Promise { const selectedLanguages = await this.challengeRepository .createQueryBuilder() .select('language') .distinct() .execute(); const languages = selectedLanguages.map( ({ language }: { language: string }) => ({ language, name: this.getLanguageName(language), }), ); languages.sort((a, b) => a.name.localeCompare(b.name)); return languages; } private getLanguageName(language: string): string { const allLanguages = { js: 'JavaScript', ts: 'TypeScript', rs: 'Rust', c: 'C', java: 'Java', cpp: 'C++', go: 'Go', lua: 'Lua', php: 'PHP', py: 'Python', rb: 'Ruby', cs: 'C-Sharp', scala: 'Scala', }; return allLanguages[language]; } } ================================================ FILE: packages/back-nest/src/challenges/services/literal.service.ts ================================================ import { Injectable } from '@nestjs/common'; @Injectable() export class LiteralService { calculateLiterals(code: string) { const literals = code .substring(0) .split(/[.\-=/_\:\;\,\}\{\)\(\"\'\]\[\/\#\?\>\<\&\*]/) .flatMap((r) => { return r.split(/[\n\r\s\t]+/); }) .filter(Boolean); return literals; } } ================================================ FILE: packages/back-nest/src/challenges/services/parser.service.ts ================================================ import * as TSParser from 'tree-sitter'; import { Injectable } from '@nestjs/common'; import { getTSLanguageParser } from './ts-parser.factory'; // TODO: Chars like ♡ should be filtered out @Injectable() export class ParserService { getParser(language: string) { const tsParser = getTSLanguageParser(language); return new Parser(tsParser); } } export enum NodeTypes { ClassDeclaration = 'class_declaration', ClassDefinition = 'class_definition', FunctionDeclaration = 'function_declaration', FunctionDefinition = 'function_definition', FunctionItem = 'function_item', MethodDeclaration = 'method_declaration', Module = 'module', Call = 'call', UsingDirective = 'using_directive', NamespaceDeclaration = 'namespace_declaration', } export class Parser { private MAX_NODE_LENGTH = 300; private MIN_NODE_LENGTH = 100; private MAX_NUM_LINES = 11; private MAX_LINE_LENGTH = 55; constructor(private ts: TSParser) {} parseTrackedNodes(content: string) { const root = this.ts.parse(content).rootNode; return this.filterNodes(root); } private filterNodes(root: TSParser.SyntaxNode) { const nodes = root.children .filter((n) => this.filterValidNodeTypes(n)) .filter((n) => this.filterLongNodes(n)) .filter((n) => this.filterShortNodes(n)) .filter((n) => this.filterTooLongLines(n)) .filter((n) => this.filterTooManyLines(n)); return nodes; } private filterValidNodeTypes(node: TSParser.SyntaxNode) { switch (node.type) { case NodeTypes.ClassDeclaration: case NodeTypes.ClassDefinition: case NodeTypes.FunctionDeclaration: case NodeTypes.FunctionDefinition: case NodeTypes.FunctionItem: case NodeTypes.MethodDeclaration: case NodeTypes.Module: case NodeTypes.Call: case NodeTypes.UsingDirective: case NodeTypes.NamespaceDeclaration: // We want method declarations if they are on the root node (i.e. golang) return true; default: console.log(node.type); return false; } } private filterLongNodes(node: TSParser.SyntaxNode) { return this.MAX_NODE_LENGTH > node.text.length; } private filterShortNodes(node: TSParser.SyntaxNode) { return node.text.length > this.MIN_NODE_LENGTH; } private filterTooManyLines(node: TSParser.SyntaxNode) { const lines = node.text.split('\n'); return lines.length <= this.MAX_NUM_LINES; } private filterTooLongLines(node: TSParser.SyntaxNode) { for (const line of node.text.split('\n')) { if (line.length > this.MAX_LINE_LENGTH) { return false; } } return true; } } export function removeDuplicateNewLines(rawText: string) { const newLine = '\n'; const duplicateNewLine = '\n\n'; let newRawText = rawText; let prevRawText = rawText; do { prevRawText = newRawText; newRawText = newRawText.replaceAll(duplicateNewLine, newLine); } while (newRawText !== prevRawText); return newRawText; } export function replaceTabsWithSpaces(rawText: string) { const tab = '\t'; const spaces = ' '; return rawText.replaceAll(tab, spaces); } export function removeTrailingSpaces(rawText: string) { return rawText .split('\n') .map((line) => line.trimEnd()) .join('\n'); } export function dedupeInnerSpaces(rawText: string) { const innerSpaces = /(?<=\S+)\s+(?=\S+)/g; const space = ' '; return rawText .split('\n') .map((line) => line.replaceAll(innerSpaces, space)) .join('\n'); } export function getFormattedText(rawText: string) { rawText = replaceTabsWithSpaces(rawText); rawText = removeTrailingSpaces(rawText); rawText = removeDuplicateNewLines(rawText); rawText = dedupeInnerSpaces(rawText); return rawText; } ================================================ FILE: packages/back-nest/src/challenges/services/tests/parser.service.spec.ts ================================================ import { getFormattedText } from '../parser.service'; const dubbleNewLineInput = `func newGRPCProxyCommand() *cobra.Command { lpc := &cobra.Command{ Use: "grpc-proxy ", Short: "grpc-proxy related command", } lpc.AddCommand(newGRPCProxyStartCommand()) return lpc }`; const trippleNewLineInput = `func newGRPCProxyCommand() *cobra.Command { lpc := &cobra.Command{ Use: "grpc-proxy ", Short: "grpc-proxy related command", } lpc.AddCommand(newGRPCProxyStartCommand()) return lpc }`; const inputWithTabs = `func newGRPCProxyCommand() *cobra.Command { \tlpc := &cobra.Command{ \t\tUse: "grpc-proxy ", \t\tShort: "grpc-proxy related command", \t} \tlpc.AddCommand(newGRPCProxyStartCommand()) \treturn lpc }`; const inputWithEmptyLineWithSpaces = `func newGRPCProxyCommand() *cobra.Command { lpc := &cobra.Command{ Use: "grpc-proxy ", Short: "grpc-proxy related command", } lpc.AddCommand(newGRPCProxyStartCommand()) return lpc }`; const inputWithTrailingSpaces = `func newGRPCProxyCommand() *cobra.Command { lpc := &cobra.Command{ Use: "grpc-proxy ", Short: "grpc-proxy related command", } lpc.AddCommand(newGRPCProxyStartCommand()) return lpc }`; const inputWithStructAlignment = `func newGRPCProxyCommand() *cobra.Command { lpc := &cobra.Command{ Use: "grpc-proxy ", Short: "grpc-proxy related command", } lpc.AddCommand(newGRPCProxyStartCommand()) return lpc }`; const output = `func newGRPCProxyCommand() *cobra.Command { lpc := &cobra.Command{ Use: "grpc-proxy ", Short: "grpc-proxy related command", } lpc.AddCommand(newGRPCProxyStartCommand()) return lpc }`; describe('getFormattedText', () => { it('should remove double newlines', () => { const parsed = getFormattedText(dubbleNewLineInput); expect(parsed).toEqual(output); }); it('should remove tripple newlines', () => { const parsed = getFormattedText(trippleNewLineInput); expect(parsed).toEqual(output); }); it('should replace tabs with spaces', () => { const parsed = getFormattedText(inputWithTabs); expect(parsed).toEqual(output); }); it('should return the same if called twice', () => { const firstParsed = getFormattedText(inputWithTabs); const parsed = getFormattedText(firstParsed); expect(parsed).toEqual(output); }); it('should remove trailing spaces', () => { const parsed = getFormattedText(inputWithTrailingSpaces); expect(parsed).toEqual(output); }); it('should remove empty line with spaces', () => { const parsed = getFormattedText(inputWithEmptyLineWithSpaces); expect(parsed).toEqual(output); }); it('should dedupe multiple interior spaces', () => { const parsed = getFormattedText(inputWithStructAlignment); expect(parsed).toEqual(output); }); }); ================================================ FILE: packages/back-nest/src/challenges/services/ts-parser.factory.ts ================================================ import * as TSParser from 'tree-sitter'; import * as js from 'tree-sitter-javascript'; import * as ts from 'tree-sitter-typescript/typescript'; import * as java from 'tree-sitter-java'; import * as c from 'tree-sitter-c'; import * as cpp from 'tree-sitter-cpp'; import * as lua from 'tree-sitter-lua'; import * as php from 'tree-sitter-php'; import * as py from 'tree-sitter-python'; import * as rb from 'tree-sitter-ruby'; import * as cs from 'tree-sitter-c-sharp'; import * as go from 'tree-sitter-go'; import * as rs from 'tree-sitter-rust'; import * as scala from 'tree-sitter-scala'; const languageParserMap: { [key: string]: any } = { js, ts, rs, c, java, cpp, go, lua, php, py, rb, cs, scala, }; export const getSupportFileExtensions = () => { return Object.keys(languageParserMap).map((ext) => `.${ext}`); }; export class InvalidLanguage extends Error { constructor(language: string) { super(`Error getting parser for language='${language}'`); Object.setPrototypeOf(this, InvalidLanguage.prototype); } } export const getTSLanguageParser = (language: string) => { const langParser = languageParserMap[language]; if (!langParser) throw new InvalidLanguage(language); const parser = new TSParser(); parser.setLanguage(langParser); return parser; }; ================================================ FILE: packages/back-nest/src/challenges/services/unsynced-file-filterer.ts ================================================ import { Injectable } from '@nestjs/common'; import { GithubNode } from 'src/connectors/github/schemas/github-tree.dto'; import { getSupportFileExtensions } from './ts-parser.factory'; @Injectable() export class UnsyncedFileFilterer { filter(nodes: GithubNode[]) { return nodes .filter(isBlobNode) .filter(hasTrackedFileExt) .filter(isNotExcludedPath); } } function isBlobNode(node: GithubNode) { return node.type === 'blob'; } function hasTrackedFileExt(node: GithubNode) { const trackedFileExtensions = getSupportFileExtensions(); for (const includedExt of trackedFileExtensions) { if (node.path.endsWith(includedExt)) { // ends with tracked file extension return true; } } // untracked file extension return false; } function isNotExcludedPath(node: GithubNode) { const excludedSubStrings = [ '.ci', '.jenkins', '.build', '.idea', '.devcontainer', 'migrations', 'benchmarks', 'build-tools', 'conventions', 'licenses', 'requirements', '.svg', 'docs', '.github', 'example', 'types', 'test', '.pb.', '.proto', 'doc', ]; for (const excludeStr of excludedSubStrings) { if (node.path.includes(excludeStr)) { // is excluded path return false; } } // is not excluded path return true; } ================================================ FILE: packages/back-nest/src/challenges/services/unsynced-file-importer.ts ================================================ import { Injectable } from '@nestjs/common'; import { GithubAPI } from 'src/connectors/github/services/github-api'; import { Project } from 'src/projects/entities/project.entity'; import { UnsyncedFile } from '../entities/unsynced-file.entity'; import { UnsyncedFileFilterer } from './unsynced-file-filterer'; import { UnsyncedFileService } from './unsynced-file.service'; @Injectable() export class UnsyncedFileImporter { constructor( private api: GithubAPI, private filterer: UnsyncedFileFilterer, private svc: UnsyncedFileService, ) {} async import(project: Project) { const root = await this.api.fetchTree( project.fullName, project.defaultBranch, ); const nodes = this.filterer.filter(root.tree); const files = nodes.map((node) => UnsyncedFile.fromGithubNode(project, root.sha, node), ); await this.svc.bulkUpsert(files); return root.sha; } } ================================================ FILE: packages/back-nest/src/challenges/services/unsynced-file.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { UnsyncedFile } from '../entities/unsynced-file.entity'; @Injectable() export class UnsyncedFileService { private static UpsertOptions = { conflictPaths: ['path', 'project'], skipUpdateIfNoValuesChanged: true, }; constructor( @InjectRepository(UnsyncedFile) private filesRepository: Repository, ) {} async bulkUpsert(files: UnsyncedFile[]): Promise { await this.filesRepository.upsert(files, UnsyncedFileService.UpsertOptions); } async findAllWithProject(): Promise { const files = await this.filesRepository.find({ relations: { project: true, }, }); return files; } async remove(files: UnsyncedFile[]): Promise { await this.filesRepository.remove(files); } } ================================================ FILE: packages/back-nest/src/commands.ts ================================================ import { CommandFactory } from 'nest-commander'; import { AppModule } from './app.module'; async function runCommand() { await CommandFactory.run(AppModule, ['warn', 'error']); } runCommand(); ================================================ FILE: packages/back-nest/src/config/cors.ts ================================================ import { GatewayMetadata } from '@nestjs/websockets'; export const getAllowedOrigins = () => { return process.env.NODE_ENV === 'production' ? ['https://speedtyper.dev', 'https://www.speedtyper.dev'] : ['http://localhost:3001']; }; export const gatewayMetadata: GatewayMetadata = { cors: { origin: getAllowedOrigins(), methods: ['GET', 'POST'], credentials: true, }, }; ================================================ FILE: packages/back-nest/src/config/postgres.ts ================================================ import * as dotenv from 'dotenv'; import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions'; dotenv.config({ path: __dirname + '/../../.env' }); export const pgOptions: Partial = { url: process.env.DATABASE_PRIVATE_URL, extra: { // 120 seconds idle timeout idleTimeoutMillis: 120000, max: 10, }, }; ================================================ FILE: packages/back-nest/src/connectors/github/github.module.ts ================================================ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { GithubAPI } from './services/github-api'; @Module({ imports: [HttpModule, ConfigModule], providers: [GithubAPI], exports: [GithubAPI], }) export class GithubConnectorModule {} ================================================ FILE: packages/back-nest/src/connectors/github/schemas/github-blob.dto.ts ================================================ import { IsEnum, IsNumber, IsString } from 'class-validator'; export enum GithubBlobEncoding { base64 = 'base64', } export class GithubBlob { @IsString() sha: string; @IsString() node_id: string; @IsNumber() size: number; @IsString() url: string; @IsString() content: string; @IsEnum(GithubBlobEncoding) encoding: 'base64'; } ================================================ FILE: packages/back-nest/src/connectors/github/schemas/github-repository.dto.ts ================================================ import { IsNumber, IsString, ValidateIf } from 'class-validator'; export class GithubLicense { @IsString() name: string; } export class GithubOwner { @IsString() login: string; @IsNumber() id: number; @IsString() avatar_url: string; @IsString() html_url: string; } export class GithubRepository { @IsNumber() id: number; @IsString() node_id: string; @IsString() name: string; @IsString() full_name: string; @IsString() html_url: string; @IsString() description: string; @IsString() url: string; @IsString() trees_url: string; @IsString() @ValidateIf((_: any, value: unknown) => value !== null) homepage: string | null; @IsNumber() stargazers_count: number; @IsString() language: string; @IsString() default_branch: string; license: GithubLicense; owner: GithubOwner; } ================================================ FILE: packages/back-nest/src/connectors/github/schemas/github-tree.dto.ts ================================================ import { IsEnum, IsNumber, IsString } from 'class-validator'; export enum GithubNodeType { blob = 'blob', tree = 'tree', } export class GithubNode { @IsString() path: string; @IsString() mode: string; @IsEnum(GithubNodeType) type: GithubNodeType; @IsString() sha: string; @IsNumber() size?: number; @IsString() url: string; } export class GithubTree { @IsString() sha: string; @IsString() url: string; tree: GithubNode[]; } ================================================ FILE: packages/back-nest/src/connectors/github/services/github-api.ts ================================================ import { AxiosResponse } from 'axios'; import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { firstValueFrom } from 'rxjs'; import { validateDTO } from 'src/utils/validateDTO'; import { GithubBlob } from '../schemas/github-blob.dto'; import { GithubRepository } from '../schemas/github-repository.dto'; import { GithubTree } from '../schemas/github-tree.dto'; @Injectable() export class GithubAPI { private static BASE_URL = 'https://api.github.com'; private static REPOSITORIES_URL = `${GithubAPI.BASE_URL}/repos`; private static REPOSITORY_URL = `${GithubAPI.REPOSITORIES_URL}/{fullName}`; private static TREE_URL = `${GithubAPI.REPOSITORY_URL}/git/trees/{sha}?recursive=true`; private static BLOB_URL = `${GithubAPI.REPOSITORY_URL}/git/blobs/{sha}`; private static BLOB_HTML_PERMA_LINK = `https://github.com/{fullName}/blob/{treeSha}/{path}/#L{startLine}-L{endLine}`; private token: string; constructor(private readonly http: HttpService, cfg: ConfigService) { this.token = getGithubAccessToken(cfg); } static getBlobPermaLink( fullName: string, treeSha: string, path: string, startLine: number, endLine: number, ) { const url = GithubAPI.BLOB_HTML_PERMA_LINK.replace('{fullName}', fullName) .replace('{treeSha}', treeSha) .replace('{path}', path) .replace('{startLine}', startLine.toString()) .replace('{endLine}', endLine.toString()); return url; } private async get(url: string) { const resp = await firstValueFrom( this.http.get(url, { headers: { Authorization: `token ${this.token}`, }, }), ); this.logRateLimit(resp); return resp.data; } private logRateLimit(resp: AxiosResponse) { const rateLimitResetSeconds = resp.headers['x-ratelimit-reset']; const resetDate = new Date(parseInt(rateLimitResetSeconds) * 1000); const rateLimitRemaining = resp.headers['x-ratelimit-remaining']; console.log( `GH Rate Limiting. Remaining: ${rateLimitRemaining} Reset: ${resetDate}`, ); } async fetchRepository(fullName: string): Promise { const url = GithubAPI.REPOSITORY_URL.replace('{fullName}', fullName); const rawData = await this.get(url); const repository = await validateDTO(GithubRepository, rawData); return repository; } async fetchTree(fullName: string, sha: string): Promise { const treeUrl = GithubAPI.TREE_URL.replace('{fullName}', fullName).replace( '{sha}', sha, ); const rawData = await this.get(treeUrl); const rootNode = await validateDTO(GithubTree, rawData); return rootNode; } async fetchBlob(fullName: string, sha: string): Promise { const url = GithubAPI.BLOB_URL.replace('{fullName}', fullName).replace( '{sha}', sha, ); const rawData = await this.get(url); const blob = await validateDTO(GithubBlob, rawData); return blob; } } function getGithubAccessToken(cfg: ConfigService) { const token = cfg.get('GITHUB_ACCESS_TOKEN'); if (!token) { throw new Error( `GITHUB_ACCESS_TOKEN is missing from environment variables`, ); } if (!token.startsWith('ghp_')) { throw new Error( `GITHUB_ACCESS_TOKEN is not a valid value. It should start with 'ghp_'`, ); } return token; } ================================================ FILE: packages/back-nest/src/database.module.ts ================================================ import { TypeOrmModule } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { pgOptions } from './config/postgres'; const entities = [__dirname + '/**/*.entity.{ts,js}']; export const PostgresDataSource = new DataSource({ type: 'postgres', synchronize: true, entities, ...pgOptions, }); export const PostgresModule = TypeOrmModule.forRootAsync({ useFactory: () => { return { type: 'postgres', synchronize: true, entities, ...pgOptions, }; }, dataSourceFactory: async () => { return PostgresDataSource.initialize(); }, }); ================================================ FILE: packages/back-nest/src/filters/exception.filter.ts ================================================ import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, } from '@nestjs/common'; import { QueryFailedError } from 'typeorm'; interface ErrorResponse { message: string; status: number; } @Catch() export class AllExceptionsFilter implements ExceptionFilter { catch(exception: any, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); let errorResponse: ErrorResponse; const message = exception.message; if (exception instanceof HttpException) { errorResponse = { status: exception.getStatus(), message }; } else if (exception instanceof QueryFailedError) { errorResponse = { message: 'Internal server error', status: HttpStatus.INTERNAL_SERVER_ERROR, }; } else { errorResponse = { message: 'Internal server error', status: HttpStatus.INTERNAL_SERVER_ERROR, }; } if (errorResponse.status === HttpStatus.INTERNAL_SERVER_ERROR) { console.log(exception); } response.status(errorResponse.status).json({ statusCode: errorResponse.status, message: errorResponse.message, }); } } ================================================ FILE: packages/back-nest/src/main.ts ================================================ import * as Sentry from '@sentry/node'; import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { AppModule } from './app.module'; import { getAllowedOrigins } from './config/cors'; import { guestUserMiddleware } from './middlewares/guest-user'; import { SessionAdapter } from './sessions/session.adapter'; import { getSessionMiddleware } from './sessions/session.middleware'; import { json } from 'express'; import { AllExceptionsFilter } from './filters/exception.filter'; const GLOBAl_API_PREFIX = 'api'; async function runServer() { Sentry.init({ dsn: process.env.SENTRY_DSN, tracesSampleRate: 0, }); const port = process.env.PORT || 1337; const app = await NestFactory.create(AppModule); app.set('trust proxy', 1); const sessionMiddleware = getSessionMiddleware(); app.enableCors({ origin: getAllowedOrigins(), credentials: true, }); app.use(json({ limit: '50mb' })); app.use(sessionMiddleware); app.use(guestUserMiddleware); app.useWebSocketAdapter(new SessionAdapter(app, sessionMiddleware)); app.setGlobalPrefix(GLOBAl_API_PREFIX); app.useGlobalFilters(new AllExceptionsFilter()); app.useGlobalPipes(new ValidationPipe()); await app.listen(port); } runServer(); ================================================ FILE: packages/back-nest/src/middlewares/guest-user.ts ================================================ import { NextFunction, Request, Response } from 'express'; import { User } from 'src/users/entities/user.entity'; export function guestUserMiddleware( req: Request, _: Response, next: NextFunction, ) { if (req.session && !req.session?.user) { req.session.user = User.generateAnonymousUser(); } next(); } ================================================ FILE: packages/back-nest/src/projects/commands/import-untracked-projects-runner.ts ================================================ import { Command, CommandRunner } from 'nest-commander'; import { ProjectService } from '../services/project.service'; import { ProjectsFromFileReader } from '../services/projects-from-file-reader'; import { UntrackedProjectService } from '../services/untracked-projects.service'; @Command({ name: 'import-projects', arguments: '', options: {}, }) export class ImportUntrackedProjectsRunner extends CommandRunner { constructor( private reader: ProjectsFromFileReader, private untracked: UntrackedProjectService, private synced: ProjectService, ) { super(); } async run(): Promise { for await (const project of this.reader.readProjects()) { const syncedProject = await this.synced.findByFullName(project); if (!syncedProject) { await this.untracked.bulkUpsert([project]); console.info(`[ProjectImport]: Imported ${project}`); } } } } ================================================ FILE: packages/back-nest/src/projects/commands/sync-untracked-projects-runner.ts ================================================ import { Command, CommandRunner } from 'nest-commander'; import { GithubAPI } from 'src/connectors/github/services/github-api'; import { Project } from '../entities/project.entity'; import { ProjectService } from '../services/project.service'; import { UntrackedProjectService } from '../services/untracked-projects.service'; @Command({ name: 'sync-projects', arguments: '', options: {}, }) export class SyncUntrackedProjectsRunner extends CommandRunner { constructor( private untracked: UntrackedProjectService, private api: GithubAPI, private synced: ProjectService, ) { super(); } async run(): Promise { const untracked = await this.untracked.findAll(); for (const untrackedProject of untracked) { const repository = await this.api.fetchRepository( untrackedProject.fullName, ); const project = Project.fromGithubRepository( untrackedProject, repository, ); await this.synced.bulkUpsert([project]); await this.untracked.remove([untrackedProject]); console.info(`[ProjectSync]: Synced ${project.fullName}`); } } } ================================================ FILE: packages/back-nest/src/projects/entities/project.entity.ts ================================================ import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm'; import { GithubRepository } from 'src/connectors/github/schemas/github-repository.dto'; import { UntrackedProject } from './untracked-project.entity'; import { UnsyncedFile } from 'src/challenges/entities/unsynced-file.entity'; @Entity() export class Project { @PrimaryGeneratedColumn('uuid') id: string; @Column({ unique: true }) fullName: string; @Column({ unique: true }) htmlUrl: string; @Column() language: string; @Column() stars: number; @Column() licenseName: string; @Column() ownerAvatar: string; @Column() defaultBranch: string; @OneToMany(() => UnsyncedFile, (file) => file.project) files: File[]; @Column({ nullable: true }) syncedSha?: string; static fromGithubRepository( tracked: UntrackedProject, repo: GithubRepository, ) { const project = new Project(); project.fullName = tracked.fullName; project.htmlUrl = repo.html_url; project.stars = repo.stargazers_count; project.language = repo.language; project.licenseName = repo.license?.name ?? 'Other'; project.ownerAvatar = repo.owner.avatar_url; project.defaultBranch = repo.default_branch; return project; } } ================================================ FILE: packages/back-nest/src/projects/entities/untracked-project.entity.ts ================================================ import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; @Entity() export class UntrackedProject { @PrimaryGeneratedColumn('uuid') id: string; @Column({ unique: true }) fullName: string; } ================================================ FILE: packages/back-nest/src/projects/project.controller.ts ================================================ import { Controller, Get } from '@nestjs/common'; import { ProjectService } from './services/project.service'; @Controller('projects') export class ProjectController { constructor(private projectService: ProjectService) {} @Get('languages') getLeaderboard(): Promise { return this.projectService.getLanguages(); } } ================================================ FILE: packages/back-nest/src/projects/projects.module.ts ================================================ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { GithubConnectorModule } from 'src/connectors/github/github.module'; import { ImportUntrackedProjectsRunner } from './commands/import-untracked-projects-runner'; import { SyncUntrackedProjectsRunner } from './commands/sync-untracked-projects-runner'; import { Project } from './entities/project.entity'; import { UntrackedProject } from './entities/untracked-project.entity'; import { ProjectController } from './project.controller'; import { ProjectService } from './services/project.service'; import { ProjectsFromFileReader } from './services/projects-from-file-reader'; import { UntrackedProjectService } from './services/untracked-projects.service'; @Module({ imports: [ TypeOrmModule.forFeature([Project, UntrackedProject]), GithubConnectorModule, ], providers: [ UntrackedProjectService, ProjectService, ProjectsFromFileReader, ImportUntrackedProjectsRunner, SyncUntrackedProjectsRunner, ], controllers: [ProjectController], exports: [ProjectService], }) export class ProjectsModule {} ================================================ FILE: packages/back-nest/src/projects/services/project.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Project } from '../entities/project.entity'; @Injectable() export class ProjectService { constructor( @InjectRepository(Project) private projectRepository: Repository, ) {} async bulkUpsert(projects: Project[]): Promise { await this.projectRepository.upsert(projects, ['fullName']); } async findByFullName(fullName: string) { const project = await this.projectRepository.findOneBy({ fullName, }); return project; } async updateSyncedSha(id: string, syncedSha: string) { await this.projectRepository.update( { id, }, { syncedSha }, ); } async findAll(): Promise { const projects = await this.projectRepository.find(); return projects; } async getLanguages(): Promise { const selectedLanguages = await this.projectRepository .createQueryBuilder() .select('language') .distinct() .execute(); return selectedLanguages.map((l: any) => l.language); } } ================================================ FILE: packages/back-nest/src/projects/services/projects-from-file-reader.ts ================================================ import { Injectable } from '@nestjs/common'; import { createReadStream } from 'fs'; import { createInterface } from 'readline'; @Injectable() export class ProjectsFromFileReader { private static FILE_PATH = './tracked-projects.txt'; async *readProjects() { const stream = createReadStream(ProjectsFromFileReader.FILE_PATH); const rl = createInterface({ input: stream, crlfDelay: Infinity, }); for await (const line of rl) { const slug = line.trim(); yield validateProjectName(slug); } } } export function validateProjectName(slug: string) { let [owner, repo] = slug.split('/'); owner = owner.trim(); repo = repo.trim(); if (!owner || !repo) { throw new Error(slug); } return [owner, repo].join('/'); } ================================================ FILE: packages/back-nest/src/projects/services/untracked-projects.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { UntrackedProject } from '../entities/untracked-project.entity'; @Injectable() export class UntrackedProjectService { constructor( @InjectRepository(UntrackedProject) private untrackedProjects: Repository, ) {} async bulkUpsert(names: string[]): Promise { const partialProjects = names.map((fullName) => ({ fullName })); await this.untrackedProjects.upsert(partialProjects, ['fullName']); } async remove(untrackedProjects: UntrackedProject[]): Promise { await this.untrackedProjects.remove(untrackedProjects); } async findAll(): Promise { return await this.untrackedProjects.find(); } } ================================================ FILE: packages/back-nest/src/races/entities/race-settings.dto.ts ================================================ import { IsBoolean, IsOptional, IsString } from 'class-validator'; export class RaceSettingsDTO { @IsString() @IsOptional() language: string; @IsBoolean() @IsOptional() isPublic: boolean; } ================================================ FILE: packages/back-nest/src/races/race.controllers.ts ================================================ import { BadRequestException, Controller, Get, Param, Post, Req, } from '@nestjs/common'; import { PublicRace, RaceManager } from './services/race-manager.service'; import { Request } from 'express'; @Controller('races') export class RacesController { constructor(private raceManager: RaceManager) {} @Get() getRaces(): PublicRace[] { return this.raceManager.getPublicRaces(); } @Get('online') getOnlineCount(): { online: number } { const online = this.raceManager.getOnlineCount(); return { online, }; } @Post('online') toggleOnlineState(@Req() request: Request): { isPublic: boolean } { const userId = request.session.user.id; const raceId = request.session.raceId; const race = this.raceManager.getRace(raceId); if (race.owner !== userId) { throw new BadRequestException(); } const isPublic = race.togglePublic(); return { isPublic, }; } @Get(':raceId/status') getRaceStatus( @Req() request: Request, @Param('raceId') raceId: string, ): { ok: boolean } { try { const userId = request.session.user.id; const player = this.raceManager.getPlayer(raceId, userId); return { ok: !!player }; } catch (err) { return { ok: false }; } } } ================================================ FILE: packages/back-nest/src/races/race.exceptions.ts ================================================ import * as Sentry from '@sentry/node'; import { ArgumentsHost, Catch } from '@nestjs/common'; import { BaseWsExceptionFilter } from '@nestjs/websockets'; import { Socket } from 'socket.io'; import { InvalidKeystrokeException } from './services/keystroke-validator.service'; import { RaceEvents } from './services/race-events.service'; import { RaceDoesNotExist } from './services/race-manager.service'; import { SessionState } from './services/session-state.service'; export function getSocketFromArgs(host: ArgumentsHost): Socket { const args = host.getArgs(); for (const arg of args) { if (arg instanceof Socket) { return arg; } } } @Catch(RaceDoesNotExist) export class RaceDoesNotExistFilter extends BaseWsExceptionFilter { raceEvents: RaceEvents; sessionState: SessionState; constructor() { super(); this.raceEvents = new RaceEvents(); this.sessionState = new SessionState(); } async catch(error: RaceDoesNotExist, host: ArgumentsHost) { const socket = getSocketFromArgs(host); this.sessionState.removeRaceID(socket); this.raceEvents.raceDoesNotExist(socket, error.id); } } @Catch(InvalidKeystrokeException) export class InvalidKeystrokeFilter extends BaseWsExceptionFilter { async catch(error: InvalidKeystrokeException) { Sentry.withScope((scope) => { const player = error.race.members[error.userId]; const data = { challengeId: error.race.challenge.id, expected: error.expected, input: error.input, keystroke: error.keystroke, userId: error.userId, }; const typedKeystrokes = player.typedKeyStrokes; const validKeyStrokes = player.validKeyStrokes(); scope.setUser({ id: process.env.NODE_ENV === 'production' ? `${error.race.id}-${error.userId}` : `[local-testing] ${error.race.id}`, }); scope.setExtras({ error: data, typedKeystrokes, validKeyStrokes }); Sentry.captureException(error); }); } } ================================================ FILE: packages/back-nest/src/races/race.gateway.ts ================================================ import { UseFilters, UsePipes, ValidationPipe } from '@nestjs/common'; import { SubscribeMessage, WebSocketGateway, WebSocketServer, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; import { gatewayMetadata } from 'src/config/cors'; import { RaceSettingsDTO } from './entities/race-settings.dto'; import { InvalidKeystrokeFilter, RaceDoesNotExistFilter, } from './race.exceptions'; import { AddKeyStrokeService } from './services/add-keystroke.service'; import { CountdownService } from './services/countdown.service'; import { Locker } from './services/locker.service'; import { RaceEvents } from './services/race-events.service'; import { RaceManager } from './services/race-manager.service'; import { KeystrokeDTO } from './services/race-player.service'; import { SessionState } from './services/session-state.service'; @WebSocketGateway(gatewayMetadata) export class RaceGateway { @WebSocketServer() server: Server; constructor( private raceManager: RaceManager, private session: SessionState, private raceEvents: RaceEvents, private addKeyStrokeService: AddKeyStrokeService, private manageRaceLock: Locker, private countdownService: CountdownService, ) {} afterInit(server: Server) { console.info('[SpeedTyper.dev] Websocket Server Started.'); this.raceEvents.server = server; } handleDisconnect(socket: Socket) { console.info( `Client disconnected: ${socket.request.session.user.username}`, ); const raceId = this.session.getRaceID(socket); const user = this.session.getUser(socket); this.raceManager.leaveRace(user, raceId); this.session.removeRaceID(socket); this.manageRaceLock.release(socket.id); } async handleConnection(socket: Socket) { const userId = this.session.getUser(socket).id; const userIsAlreadyPlaying = this.raceManager.userIsAlreadyPlaying(userId); for (const [sid, s] of this.server.sockets.sockets) { // We need to cleanup other sockets for the same user // Because we can not have several instances of the same user in the same race twice // Consider adding this possibility, but for different races if (sid === socket.id) { console.log('Same socket id, keeping.'); continue; } if (s.request.session.user.id === userId) { console.log( 'Different socket id, same user. Disconnecting previous socket', ); if (userIsAlreadyPlaying) { this.raceManager.leaveRace( s.request.session.user, s.request.session.raceId, ); } s.disconnect(); } if (!this.raceManager.userIsAlreadyPlaying(s.request.session.user.id)) { console.log( 'Disconnecting because socket is not playing: ', s.request.session.user.username, s.request.session.user.id, ); s.disconnect(); continue; } console.log( 'Keeping: ', s.request.session.user.username, s.request.session.user.id, ); } console.info( `Client connected: ${socket.request.session.user.username} - ${socket.id}`, ); } @UseFilters(new RaceDoesNotExistFilter()) @SubscribeMessage('refresh_challenge') async onRefreshChallenge(socket: Socket, settings: RaceSettingsDTO) { this.raceEvents.logConnectedSockets(); const socketID = socket.id; await this.manageRaceLock.runIfOpen(socketID, async () => { const raceId = this.session.getRaceID(socket); if (!raceId) { this.manageRaceLock.release(socket.id); this.onPlay(socket, settings); return; } const user = this.session.getUser(socket); if (this.raceManager.isOwner(user.id, raceId)) { const race = await this.raceManager.refresh(raceId, settings.language); this.raceEvents.updatedRace(socket, race); } }); } @UsePipes(new ValidationPipe()) @SubscribeMessage('play') async onPlay(socket: Socket, settings: RaceSettingsDTO) { const socketID = socket.id; await this.manageRaceLock.runIfOpen(socketID, async () => { const user = this.session.getUser(socket); const raceId = this.session.getRaceID(socket); this.raceManager.leaveRace(user, raceId); const race = await this.raceManager.create(user, settings); this.raceEvents.createdRace(socket, race); this.session.saveRaceID(socket, race.id); }); } @UseFilters(new RaceDoesNotExistFilter(), new InvalidKeystrokeFilter()) @UsePipes(new ValidationPipe()) @SubscribeMessage('key_stroke') async onKeyStroke(socket: Socket, keystroke: KeystrokeDTO) { keystroke.timestamp = new Date().getTime(); this.addKeyStrokeService.validate(socket, keystroke); this.addKeyStrokeService.addKeyStroke(socket, keystroke); } @SubscribeMessage('join') async onJoin(socket: Socket, id: string) { this.manageRaceLock.runIfOpen(socket.id, async () => { const user = this.session.getUser(socket); const raceID = this.session.getRaceID(socket); this.raceManager.leaveRace(user, raceID); const race = this.raceManager.join(user, id); if (!race) { // if there is no race with the ID in the state // we recreate a race for the user // this makes sure that the game does not crash for the user // TODO: we should create a race with the same ID, and even same challenge selected // So that the other people in the race can then join the same room // instead of creating their own through this same functionality // we do however have to reset the progress for all participants as it is only kept in state this.manageRaceLock.release(socket.id); return this.onPlay(socket, { language: undefined, isPublic: false }); } this.raceEvents.joinedRace(socket, race, user); this.session.saveRaceID(socket, id); }); } @SubscribeMessage('start_race') async onStart(socket: Socket) { const user = this.session.getUser(socket); const raceID = this.session.getRaceID(socket); const race = this.raceManager.getRace(raceID); if (race.canStartRace(user.id)) { this.countdownService.countdown(race); } } } ================================================ FILE: packages/back-nest/src/races/races.module.ts ================================================ import { Module } from '@nestjs/common'; import { ChallengesModule } from 'src/challenges/challenges.module'; import { ResultsModule } from 'src/results/results.module'; import { TrackingModule } from 'src/tracking/tracking.module'; import { RacesController } from './race.controllers'; import { RaceGateway } from './race.gateway'; import { AddKeyStrokeService } from './services/add-keystroke.service'; import { CountdownService } from './services/countdown.service'; import { KeyStrokeValidationService } from './services/keystroke-validator.service'; import { Locker } from './services/locker.service'; import { ProgressService } from './services/progress.service'; import { RaceEvents } from './services/race-events.service'; import { RaceManager } from './services/race-manager.service'; import { ResultsHandlerService } from './services/results-handler.service'; import { SessionState } from './services/session-state.service'; @Module({ imports: [ChallengesModule, ResultsModule, TrackingModule], controllers: [RacesController], providers: [ AddKeyStrokeService, KeyStrokeValidationService, ProgressService, RaceEvents, RaceGateway, RaceManager, ResultsHandlerService, SessionState, Locker, CountdownService, ], exports: [RaceManager, RaceEvents, KeyStrokeValidationService], }) export class RacesModule {} ================================================ FILE: packages/back-nest/src/races/services/add-keystroke.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { Socket } from 'socket.io'; import { TrackingService } from 'src/tracking/tracking.service'; import { KeyStrokeValidationService } from './keystroke-validator.service'; import { ProgressService } from './progress.service'; import { RaceEvents } from './race-events.service'; import { RaceManager } from './race-manager.service'; import { KeystrokeDTO } from './race-player.service'; import { ResultsHandlerService } from './results-handler.service'; import { SessionState } from './session-state.service'; @Injectable() export class AddKeyStrokeService { constructor( private manager: RaceManager, private session: SessionState, private validator: KeyStrokeValidationService, private progressService: ProgressService, private trackingService: TrackingService, private events: RaceEvents, private resultHandler: ResultsHandlerService, ) {} validate(socket: Socket, keyStroke: KeystrokeDTO) { const user = this.session.getUser(socket); const raceId = this.session.getRaceID(socket); const player = this.manager.getPlayer(raceId, user.id); this.validator.validateKeyStroke(player, keyStroke); } async addKeyStroke(socket: Socket, keyStroke: KeystrokeDTO) { const user = this.session.getUser(socket); const raceId = this.session.getRaceID(socket); const player = this.manager.getPlayer(raceId, user.id); if (player.hasNotStartedTyping()) { this.trackingService.trackRaceStarted(); } player.addKeyStroke(keyStroke); if (keyStroke.correct) { player.progress = this.progressService.calculateProgress(player); const code = this.manager.getCode(raceId); player.updateLiteral(code, keyStroke); this.events.progressUpdated(socket, raceId, player); } this.syncStartTime(raceId, new Date(keyStroke.timestamp)); const race = this.manager.getRace(raceId); this.resultHandler.handleResult(race, user); } async syncStartTime(raceId: string, timestamp: Date) { const race = this.manager.getRace(raceId); if (!race.isMultiplayer()) { race.startTime = race.startTime ?? timestamp; } } } ================================================ FILE: packages/back-nest/src/races/services/countdown.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { RaceEvents } from './race-events.service'; import { Race } from './race.service'; @Injectable() export class CountdownService { constructor(private raceEvents: RaceEvents) {} async countdown(race: Race) { race.countdown = true; const seconds = 5; for (let i = seconds; i > 0; i--) { const delay = seconds - i; const timeout = setTimeout(() => { this.raceEvents.countdown(race.id, i); }, delay * 1000); race.timeouts.push(timeout); } const timeout = setTimeout(() => { race.start(); this.raceEvents.raceStarted(race); race.timeouts = []; race.countdown = false; }, seconds * 1000); race.timeouts.push(timeout); } } ================================================ FILE: packages/back-nest/src/races/services/keystroke-validator.service.ts ================================================ import { BadRequestException, Injectable } from '@nestjs/common'; import { Challenge } from 'src/challenges/entities/challenge.entity'; import { RaceManager } from './race-manager.service'; import { KeystrokeDTO, RacePlayer } from './race-player.service'; import { Race } from './race.service'; export class InvalidKeystrokeException extends Error { userId: string; keystroke: KeystrokeDTO; input: string; expected: string; race: Race; constructor( userId: string, keystroke: KeystrokeDTO, userInput: string, expectedUserInput: string, race: Race, ) { super('Unexpected keystroke received2'); this.userId = userId; this.keystroke = keystroke; this.input = userInput; this.expected = expectedUserInput; this.race = race; } } export class RaceNotStartedException extends BadRequestException { constructor() { super('Race not started'); } } export function getCurrentInputBeforeKeystroke( player: RacePlayer, keystroke: KeystrokeDTO, ) { const currentInputBeforeKey = player .validKeyStrokes() .filter((stroke) => stroke.index < keystroke.index) .map((stroke) => stroke.key) .join(''); return currentInputBeforeKey; } @Injectable() export class KeyStrokeValidationService { constructor(private raceManager: RaceManager) {} validateKeyStroke(player: RacePlayer, recentKeyStroke: KeystrokeDTO) { this.validateRaceStarted(player.raceId); const currentInputBeforeKey = getCurrentInputBeforeKeystroke( player, recentKeyStroke, ); const userInput = currentInputBeforeKey + recentKeyStroke.key; const expectedInput = this.getStrippedCode(player.raceId, recentKeyStroke); const correct = userInput === expectedInput; if (recentKeyStroke.correct && recentKeyStroke.correct !== correct) { throw new InvalidKeystrokeException( player.id, recentKeyStroke, userInput, expectedInput, this.raceManager.getRace(player.raceId), ); } } validateRaceStarted(raceID: string) { const race = this.raceManager.getRace(raceID); if (!race.startTime && race.isMultiplayer()) { throw new RaceNotStartedException(); } } private getStrippedCode(raceId: string, keystroke: KeystrokeDTO) { const code = this.raceManager.getCode(raceId); const strippedCode = Challenge.getStrippedCode( code.substring(0, keystroke.index), ); return strippedCode; } } ================================================ FILE: packages/back-nest/src/races/services/locker.service.ts ================================================ import { Injectable } from '@nestjs/common'; @Injectable() export class Locker { lockedIDs: Set; constructor() { this.lockedIDs = new Set(); } // this is a global lock function // it locks all run methods called with this lockid // even if they are coming from different classes async runIfOpen(lockID: string, callback: () => Promise): Promise { if (this.lockedIDs.has(lockID)) { return; } this.lockedIDs.add(lockID); try { return await callback(); } finally { this.lockedIDs.delete(lockID); } } release(id: string) { this.lockedIDs.delete(id); } } ================================================ FILE: packages/back-nest/src/races/services/progress.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { Challenge } from 'src/challenges/entities/challenge.entity'; import { RaceManager } from './race-manager.service'; import { RacePlayer } from './race-player.service'; @Injectable() export class ProgressService { constructor(private raceManager: RaceManager) {} calculateProgress(player: RacePlayer) { const currentInput = player.getValidInput(); const code = this.raceManager.getCode(player.raceId); const strippedFullCode = Challenge.getStrippedCode(code); const progress = Math.floor( (currentInput.length / strippedFullCode.length) * 100, ); return progress; } } ================================================ FILE: packages/back-nest/src/races/services/race-events.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { Server, Socket } from 'socket.io'; import { Result } from 'src/results/entities/result.entity'; import { User } from 'src/users/entities/user.entity'; import { RacePlayer } from './race-player.service'; import { Race } from './race.service'; @Injectable() export class RaceEvents { server: Server; getPlayerCount() { return this.server.sockets.sockets.size; } createdRace(socket: Socket, race: Race) { socket.join(race.id); socket.emit('race_joined', race); socket.emit('challenge_selected', race.challenge); } countdown(raceID: string, i: number) { const event = 'countdown'; this.server.to(raceID).emit(event, i); } raceStarted(race: Race) { this.server.to(race.id).emit('race_started', race.startTime); } updatedRace(_: Socket, race: Race) { this.server.to(race.id).emit('race_joined', race); this.server.to(race.id).emit('challenge_selected', race.challenge); } joinedRace(socket: Socket, race: Race, user: User) { socket.join(race.id); socket.emit('race_joined', race); socket.to(race.id).emit('member_joined', race.members[user.id]); } leftRace(race: Race, user: User) { this.server.to(race.id).emit('member_left', { member: user.id, owner: race.owner, }); } progressUpdated(socket: Socket, raceId: string, player: RacePlayer) { socket.to(raceId).emit('progress_updated', player); socket.emit('progress_updated', player); } raceCompleted(raceId: string, result: Result) { this.server.to(raceId).emit('race_completed', result); } raceDoesNotExist(socket: Socket, id: string) { socket.emit('race_does_not_exist', id); } async logConnectedSockets() { const sockets = await this.server.fetchSockets(); console.log('Connected sockets: ', sockets.length); } } ================================================ FILE: packages/back-nest/src/races/services/race-manager.service.ts ================================================ import { BadRequestException, Injectable } from '@nestjs/common'; import { Challenge } from 'src/challenges/entities/challenge.entity'; import { ChallengeService } from 'src/challenges/services/challenge.service'; import { LiteralService } from 'src/challenges/services/literal.service'; import { User } from 'src/users/entities/user.entity'; import { RaceSettingsDTO } from '../entities/race-settings.dto'; import { RaceEvents } from './race-events.service'; import { RacePlayer } from './race-player.service'; import { Race } from './race.service'; export interface PublicRace { id: string; ownerName: string; memberCount: number; } @Injectable() export class RaceManager { private races: Record = {}; constructor( private challengeService: ChallengeService, private literalsService: LiteralService, private raceEvents: RaceEvents, ) {} getOnlineCount(): number { const memberIds = Object.values(this.races) .flatMap((race) => Object.values(race.members)) .map((member) => member.id); const uniqueMemberIds = new Set(memberIds); return uniqueMemberIds.size; } getPublicRaces(): PublicRace[] { const races = Object.values(this.races); const publicRaces = races .filter((race) => race.isPublic) .map((race) => { return race.toPublic(); }); return publicRaces; } syncUser(raceId: string, prevUserId: string, user: User) { const race = this.getRace(raceId); if (race.owner === prevUserId) { race.owner = user.id; } const player = race.members[prevUserId]; player.id = user.id; player.username = user.username; delete race.members[prevUserId]; race.members[user.id] = player; } debugSize(msg: string) { const racesSize = JSON.stringify(this.races).length; console.log(msg, { racesSize, races: Object.keys(this.races).length, players: this.getOnlineCount(), }); } async create(user: User, settings: RaceSettingsDTO): Promise { this.debugSize('create'); const challenge = await this.challengeService.getRandom(settings.language); const literals = this.literalsService.calculateLiterals(challenge.content); const race = new Race(user, challenge, literals); race.isPublic = settings.isPublic; this.races[race.id] = race; return race; } async refresh(id: string, language?: string): Promise { this.debugSize('refresh'); const race = this.getRace(id); const challenge = await this.challengeService.getRandom(language); const literals = this.literalsService.calculateLiterals(challenge.content); race.challenge = challenge; race.literals = literals; race.resetProgress(); return race; } getRace(id: string): Race { const race = this.races[id]; if (!race) throw new RaceDoesNotExist(id); return race; } getPlayer(raceId: string, userId: string): RacePlayer { const race = this.getRace(raceId); return race.getPlayer(userId); } getChallenge(raceId: string): Challenge { const race = this.getRace(raceId); return race.challenge; } // Get the full code string of the currently active challenge for the provided race id getCode(raceId: string): string { return this.getChallenge(raceId).content; } join(user: User, raceId: string): Race | null { const race = this.races[raceId]; // it's important to return null instead of throwing // a RaceDoesNotExist error because the exception filter // sends a race_does_not_exist event back to the client // and the client tries to join the race // in the controller we create a game if no game exists // preventing an infinite loop // TODO: this should be handled better in the future if (!race) return null; race.addMember(user); return race; } leaveRace(user: User, raceId: string) { const race = this.races[raceId]; if (!race) return; race.removeMember(user); if (Object.values(race.members).length === 0) { delete this.races[raceId]; } else if (race.owner === user.id) { race.owner = Object.values(race.members)[0].id; } this.raceEvents.leftRace(race, user); } isOwner(userId: string, raceId: string): boolean { const race = this.races[raceId]; if (!race) throw new RaceDoesNotExist(raceId); return race.owner === userId; } userIsAlreadyPlaying(userId: string): boolean { return Object.values(this.races) .flatMap((race) => Object.keys(race.members)) .includes(userId); } } export class RaceDoesNotExist extends BadRequestException { id: string; constructor(id: string) { super(`Race with id=${id} does not exist`); this.id = id; Object.setPrototypeOf(this, RaceDoesNotExist.prototype); } } ================================================ FILE: packages/back-nest/src/races/services/race-player.service.ts ================================================ import { Exclude, instanceToPlain } from 'class-transformer'; import { IsBoolean, IsNotEmpty, IsNumber, IsString, MaxLength, } from 'class-validator'; import { LiteralService } from 'src/challenges/services/literal.service'; import { User } from 'src/users/entities/user.entity'; export class KeystrokeDTO { @IsString() @IsNotEmpty() @MaxLength(1) key: string; @IsNotEmpty() @IsNumber() timestamp: number; @IsNotEmpty() @IsBoolean() correct: boolean; @IsNotEmpty() @IsNumber() index: number; } export class RacePlayer { id: string; username: string; recentlyTypedLiteral: string; @Exclude() literalOffset: number; @Exclude() literals: string[]; @Exclude() saved: boolean; progress: number; @Exclude() raceId: string; @Exclude() typedKeyStrokes: KeystrokeDTO[]; @Exclude() literalService: LiteralService; toJSON() { return instanceToPlain(this); } reset(literals: string[]) { this.literals = literals; this.literalOffset = 0; this.recentlyTypedLiteral = this.literals[this.literalOffset]; this.progress = 0; this.saved = false; this.typedKeyStrokes = []; } validKeyStrokes() { const keyStrokes = this.typedKeyStrokes; const latestKeyStrokePerIndex = Object.fromEntries( keyStrokes.map((keyStroke) => { return [keyStroke.index, keyStroke]; }), ); const firstIncorrectKeystroke = Object.values(latestKeyStrokePerIndex).find( (keystroke) => !keystroke.correct, ); const validKeyStrokes = Object.values(latestKeyStrokePerIndex) .filter((keyStroke) => keyStroke.correct) .filter((keystroke) => firstIncorrectKeystroke ? keystroke.index < firstIncorrectKeystroke.index : true, ); return validKeyStrokes; } incorrectKeyStrokes() { const incorrectKeyStrokes = this.typedKeyStrokes.filter( (keyStroke) => !keyStroke.correct, ); return incorrectKeyStrokes; } getValidInput() { const validInput = this.validKeyStrokes() .map((keyStroke) => keyStroke.key) .join(''); return validInput; } addKeyStroke(keyStroke: KeystrokeDTO) { keyStroke.timestamp = new Date().getTime(); this.typedKeyStrokes.push(keyStroke); } updateLiteral(code: string, keyStroke: KeystrokeDTO) { const untypedCode = code.substring(keyStroke.index); const nextLiteral = this.literals[this.literalOffset + 1]; const startsWithNextLiteral = this.literalService .calculateLiterals(untypedCode.trimStart()) .join('') .startsWith(nextLiteral); if (startsWithNextLiteral && this.literals.length > 1) { this.literalOffset++; } this.recentlyTypedLiteral = this.literals[this.literalOffset]; } hasNotStartedTyping(): boolean { return this.typedKeyStrokes.length === 0; } hasCompletedRace(): boolean { return this.progress === 100; } static fromUser(raceId: string, user: User, literals: string[]) { const player = new RacePlayer(); player.id = user.id; player.raceId = raceId; player.username = user.username; player.progress = 0; player.literals = literals; player.recentlyTypedLiteral = player.literals[0]; player.literalOffset = 0; player.typedKeyStrokes = []; player.literalService = new LiteralService(); player.saved = false; return player; } } ================================================ FILE: packages/back-nest/src/races/services/race.service.ts ================================================ import { Exclude, instanceToPlain } from 'class-transformer'; import { randomUUID } from 'crypto'; import { Challenge } from 'src/challenges/entities/challenge.entity'; import { User } from 'src/users/entities/user.entity'; import { RacePlayer } from './race-player.service'; export interface PublicRace { id: string; ownerName: string; memberCount: number; } export class Race { id: string; challenge: Challenge; owner: string; members: Record; @Exclude() literals: string[]; @Exclude() timeouts: NodeJS.Timeout[]; startTime?: Date; @Exclude() countdown: boolean; isPublic: boolean; togglePublic(): boolean { this.isPublic = !this.isPublic; return this.isPublic; } toPublic(): PublicRace { const ownerName = this.members[this.owner].username; const memberCount = Object.keys(this.members).length; return { id: this.id, ownerName, memberCount, }; } isMultiplayer(): boolean { return Object.keys(this.members).length > 1; } toJSON() { return instanceToPlain(this); } constructor(owner: User, challenge: Challenge, literals: string[]) { this.id = randomUUID().replaceAll('-', ''); this.members = {}; this.owner = owner.id; this.challenge = challenge; this.literals = literals; this.timeouts = []; this.countdown = false; this.addMember(owner); this.isPublic = false; } start() { this.startTime = new Date(); } canStartRace(userID: string): boolean { return !this.countdown && !this.startTime && this.owner === userID; } getPlayer(id: string) { return this.members[id]; } resetProgress() { Object.values(this.members).forEach((player) => { player.reset(this.literals); }); this.startTime = undefined; for (const timeout of this.timeouts) { clearTimeout(timeout); } this.timeouts = []; this.countdown = false; } addMember(user: User) { this.members[user.id] = RacePlayer.fromUser(this.id, user, this.literals); } removeMember(user: User) { delete this.members[user.id]; } } ================================================ FILE: packages/back-nest/src/races/services/results-handler.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { ResultFactoryService } from 'src/results/services/result-factory.service'; import { ResultService } from 'src/results/services/results.service'; import { TrackingService } from 'src/tracking/tracking.service'; import { User } from 'src/users/entities/user.entity'; import { RaceEvents } from './race-events.service'; import { Race } from './race.service'; @Injectable() export class ResultsHandlerService { constructor( private factory: ResultFactoryService, private events: RaceEvents, private results: ResultService, private tracker: TrackingService, ) {} async handleResult(race: Race, user: User) { const player = race.getPlayer(user.id); if (player.hasCompletedRace()) { if (player.saved) { return; } player.saved = true; let result = this.factory.factory(race, player, user); if (!user.isAnonymous) { result = await this.results.create(result); } result.percentile = await this.results.getResultPercentile(result.cpm); this.tracker.trackRaceCompleted(); this.events.raceCompleted(race.id, result); } } } ================================================ FILE: packages/back-nest/src/races/services/session-state.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { Socket } from 'socket.io'; import { User } from 'src/users/entities/user.entity'; @Injectable() export class SessionState { getUser(socket: Socket): User { return socket.request.session.user; } getRaceID(socket: Socket): string { return socket.request.session.raceId; } saveRaceID(socket: Socket, id: string) { const prevRaceID = socket.request.session.raceId; socket.request.session.raceId = id; socket.request.session.save(() => { socket.leave(prevRaceID); }); } removeRaceID(socket: Socket) { const prevRaceID = socket.request.session.raceId; socket.request.session.raceId = null; socket.request.session.save(() => { socket.leave(prevRaceID); }); } } ================================================ FILE: packages/back-nest/src/races/services/tests/race-player.service.spec.ts ================================================ import { RacePlayer } from '../race-player.service'; describe('[unit] validKeyStrokes()', () => { const player = new RacePlayer(); beforeEach(() => { const typedKeystrokes = [ { correct: true, index: 1, key: 'f', timestamp: 1676334891980 }, { correct: true, index: 2, key: 'u', timestamp: 1676334892242 }, { correct: true, index: 3, key: 'n', timestamp: 1676334892503 }, { correct: true, index: 4, key: 'c', timestamp: 1676334892503 }, ]; player.typedKeyStrokes = typedKeystrokes; }); it('should include all valid keystrokes', () => { const validKeyStrokes = player.validKeyStrokes(); expect(validKeyStrokes).toEqual(player.typedKeyStrokes); }); it('should filter out invalid keystrokes all valid keystrokes', () => { const expectedValidKeystrokes = [...player.typedKeyStrokes]; player.typedKeyStrokes.push({ correct: false, index: 5, key: 'd', timestamp: 1676334892503, }); const validKeyStrokes = player.validKeyStrokes(); expect(validKeyStrokes).toEqual(expectedValidKeystrokes); }); it('should use the latest timestamp for each index', () => { const expectedValidKeystrokes = [player.typedKeyStrokes[0]]; player.typedKeyStrokes.push({ correct: false, index: 2, key: 'd', timestamp: 1676334892503, }); const validKeyStrokes = player.validKeyStrokes(); expect(validKeyStrokes).toEqual(expectedValidKeystrokes); }); }); describe('[functional] validKeyStrokes()', () => { const player = new RacePlayer(); player.typedKeyStrokes = [ { correct: true, index: 1, key: 'f', timestamp: 1676392785787 }, { correct: true, index: 2, key: 'u', timestamp: 1676392785931 }, { correct: true, index: 3, key: 'n', timestamp: 1676392786176 }, { correct: true, index: 4, key: 'c', timestamp: 1676392786253 }, { correct: true, index: 5, key: ' ', timestamp: 1676392786343 }, { correct: true, index: 6, key: 'n', timestamp: 1676392786485 }, { correct: true, index: 7, key: 'e', timestamp: 1676392786572 }, { correct: true, index: 8, key: 'w', timestamp: 1676392786630 }, { correct: true, index: 9, key: 'W', timestamp: 1676392786851 }, { correct: true, index: 10, key: 'a', timestamp: 1676392787083 }, { correct: true, index: 11, key: 't', timestamp: 1676392787162 }, { correct: true, index: 12, key: 'c', timestamp: 1676392787392 }, { correct: true, index: 13, key: 'h', timestamp: 1676392787460 }, { correct: true, index: 14, key: 'e', timestamp: 1676392787566 }, { correct: true, index: 15, key: 'r', timestamp: 1676392787696 }, { correct: true, index: 16, key: 'G', timestamp: 1676392787997 }, { correct: false, index: 17, key: 'B', timestamp: 1676392788000 }, { correct: false, index: 18, key: 'r', timestamp: 1676392788196 }, { correct: true, index: 17, key: 'r', timestamp: 1676392788777 }, { correct: false, index: 18, key: 'u', timestamp: 1676392788952 }, { correct: false, index: 19, key: 'o', timestamp: 1676392788963 }, { correct: false, index: 20, key: 'o', timestamp: 1676392789165 }, { correct: true, index: 18, key: 'o', timestamp: 1676392790150 }, { correct: true, index: 19, key: 'u', timestamp: 1676392790279 }, { correct: true, index: 20, key: 'p', timestamp: 1676392790365 }, { correct: true, index: 21, key: '(', timestamp: 1676392790776 }, { correct: true, index: 22, key: ')', timestamp: 1676392790847 }, { correct: true, index: 23, key: ' ', timestamp: 1676392791108 }, { correct: true, index: 24, key: 'w', timestamp: 1676392791308 }, { correct: true, index: 25, key: 'a', timestamp: 1676392791492 }, { correct: true, index: 26, key: 't', timestamp: 1676392791555 }, { correct: true, index: 27, key: 'c', timestamp: 1676392791771 }, { correct: true, index: 28, key: 'h', timestamp: 1676392791852 }, { correct: true, index: 29, key: 'e', timestamp: 1676392791944 }, { correct: true, index: 30, key: 'r', timestamp: 1676392792046 }, { correct: true, index: 31, key: 'G', timestamp: 1676392792307 }, { correct: true, index: 32, key: 'r', timestamp: 1676392792494 }, { correct: true, index: 33, key: 'o', timestamp: 1676392792554 }, { correct: true, index: 34, key: 'u', timestamp: 1676392792659 }, { correct: true, index: 35, key: 'p', timestamp: 1676392792729 }, { correct: true, index: 36, key: ' ', timestamp: 1676392792882 }, { correct: true, index: 37, key: '{', timestamp: 1676392793137 }, { correct: true, index: 40, key: '\n', timestamp: 1676392793228 }, { correct: true, index: 41, key: 'r', timestamp: 1676392793933 }, { correct: true, index: 42, key: 'e', timestamp: 1676392794021 }, { correct: true, index: 43, key: 't', timestamp: 1676392794141 }, { correct: true, index: 44, key: 'u', timestamp: 1676392794197 }, { correct: true, index: 45, key: 'r', timestamp: 1676392794324 }, { correct: true, index: 46, key: 'n', timestamp: 1676392794454 }, { correct: true, index: 41, key: 'r', timestamp: 1676392795190 }, { correct: true, index: 42, key: 'e', timestamp: 1676392795299 }, { correct: true, index: 43, key: 't', timestamp: 1676392795410 }, { correct: true, index: 44, key: 'u', timestamp: 1676392795468 }, { correct: true, index: 45, key: 'r', timestamp: 1676392795601 }, { correct: true, index: 46, key: 'n', timestamp: 1676392795683 }, { correct: true, index: 47, key: ' ', timestamp: 1676392796151 }, { correct: true, index: 48, key: 'w', timestamp: 1676392796361 }, { correct: true, index: 49, key: 'a', timestamp: 1676392796514 }, { correct: true, index: 50, key: 't', timestamp: 1676392796588 }, { correct: true, index: 51, key: 'c', timestamp: 1676392796827 }, { correct: true, index: 52, key: 'h', timestamp: 1676392796909 }, { correct: true, index: 53, key: 'e', timestamp: 1676392797008 }, { correct: true, index: 54, key: 'r', timestamp: 1676392797116 }, { correct: true, index: 55, key: 'G', timestamp: 1676392797496 }, { correct: true, index: 56, key: 'r', timestamp: 1676392797730 }, { correct: true, index: 57, key: 'o', timestamp: 1676392797808 }, { correct: true, index: 58, key: 'u', timestamp: 1676392797910 }, { correct: true, index: 59, key: 'p', timestamp: 1676392797968 }, { correct: true, index: 60, key: '{', timestamp: 1676392798346 }, { correct: true, index: 65, key: '\n', timestamp: 1676392798497 }, { correct: true, index: 66, key: 'k', timestamp: 1676392800709 }, { correct: true, index: 67, key: 'e', timestamp: 1676392800788 }, { correct: true, index: 68, key: 'y', timestamp: 1676392800894 }, { correct: true, index: 69, key: 'W', timestamp: 1676392801060 }, { correct: true, index: 70, key: 'a', timestamp: 1676392801287 }, { correct: true, index: 71, key: 't', timestamp: 1676392801399 }, { correct: true, index: 72, key: 'c', timestamp: 1676392801635 }, { correct: true, index: 73, key: 'h', timestamp: 1676392801724 }, { correct: true, index: 74, key: 'e', timestamp: 1676392801810 }, { correct: true, index: 75, key: 'r', timestamp: 1676392801933 }, { correct: true, index: 76, key: 's', timestamp: 1676392802024 }, { correct: false, index: 66, key: 'd', timestamp: 1676392804327 }, { correct: false, index: 67, key: 'e', timestamp: 1676392804543 }, { correct: false, index: 68, key: 'y', timestamp: 1676392804700 }, { correct: false, index: 69, key: 'W', timestamp: 1676392804958 }, { correct: false, index: 70, key: 'a', timestamp: 1676392805036 }, { correct: false, index: 71, key: 't', timestamp: 1676392806184 }, { correct: false, index: 72, key: 'c', timestamp: 1676392806433 }, { correct: false, index: 73, key: 'h', timestamp: 1676392806553 }, { correct: false, index: 74, key: 'e', timestamp: 1676392806625 }, { correct: false, index: 75, key: 'r', timestamp: 1676392806753 }, { correct: false, index: 76, key: 's', timestamp: 1676392806850 }, { correct: false, index: 77, key: ':', timestamp: 1676392807263 }, { correct: true, index: 66, key: 'k', timestamp: 1676392808209 }, { correct: true, index: 67, key: 'e', timestamp: 1676392808311 }, { correct: true, index: 68, key: 'y', timestamp: 1676392808401 }, { correct: true, index: 69, key: 'W', timestamp: 1676392808559 }, { correct: true, index: 70, key: 'a', timestamp: 1676392808736 }, { correct: true, index: 71, key: 't', timestamp: 1676392808809 }, { correct: true, index: 72, key: 'c', timestamp: 1676392809050 }, { correct: true, index: 73, key: 'h', timestamp: 1676392809118 }, { correct: true, index: 74, key: 'e', timestamp: 1676392809227 }, { correct: true, index: 75, key: 'r', timestamp: 1676392809328 }, { correct: true, index: 76, key: 's', timestamp: 1676392809396 }, { correct: true, index: 77, key: ':', timestamp: 1676392809639 }, { correct: true, index: 78, key: ' ', timestamp: 1676392809832 }, { correct: true, index: 79, key: 'm', timestamp: 1676392810011 }, { correct: true, index: 80, key: 'a', timestamp: 1676392810089 }, { correct: true, index: 81, key: 'k', timestamp: 1676392810198 }, { correct: true, index: 82, key: 'e', timestamp: 1676392810274 }, { correct: true, index: 83, key: '(', timestamp: 1676392810520 }, { correct: true, index: 84, key: 'w', timestamp: 1676392810915 }, { correct: true, index: 85, key: 'a', timestamp: 1676392811118 }, { correct: true, index: 86, key: 't', timestamp: 1676392811218 }, { correct: true, index: 87, key: 'c', timestamp: 1676392811442 }, { correct: true, index: 88, key: 'h', timestamp: 1676392811538 }, { correct: true, index: 89, key: 'e', timestamp: 1676392811649 }, { correct: true, index: 90, key: 'r', timestamp: 1676392811760 }, { correct: true, index: 91, key: 'S', timestamp: 1676392811955 }, { correct: true, index: 92, key: 'e', timestamp: 1676392812097 }, { correct: true, index: 93, key: 't', timestamp: 1676392812197 }, { correct: true, index: 94, key: 'B', timestamp: 1676392812554 }, { correct: true, index: 95, key: 'y', timestamp: 1676392812704 }, { correct: true, index: 96, key: 'K', timestamp: 1676392812921 }, { correct: true, index: 97, key: 'e', timestamp: 1676392813049 }, { correct: true, index: 98, key: 'y', timestamp: 1676392813137 }, { correct: true, index: 99, key: ')', timestamp: 1676392813359 }, { correct: true, index: 100, key: ',', timestamp: 1676392813650 }, { correct: true, index: 105, key: '\n', timestamp: 1676392813778 }, { correct: true, index: 106, key: 'r', timestamp: 1676392814158 }, { correct: true, index: 107, key: 'a', timestamp: 1676392814246 }, { correct: true, index: 108, key: 'n', timestamp: 1676392814333 }, { correct: true, index: 109, key: 'g', timestamp: 1676392814444 }, { correct: true, index: 110, key: 'e', timestamp: 1676392814518 }, { correct: true, index: 111, key: 's', timestamp: 1676392814609 }, { correct: true, index: 112, key: ':', timestamp: 1676392814749 }, { correct: true, index: 113, key: ' ', timestamp: 1676392815613 }, { correct: true, index: 114, key: ' ', timestamp: 1676392815800 }, { correct: true, index: 115, key: ' ', timestamp: 1676392815972 }, { correct: true, index: 116, key: ' ', timestamp: 1676392816163 }, { correct: true, index: 117, key: ' ', timestamp: 1676392816369 }, { correct: true, index: 118, key: ' ', timestamp: 1676392816606 }, { correct: true, index: 119, key: 'a', timestamp: 1676392817044 }, { correct: true, index: 120, key: 'd', timestamp: 1676392817074 }, { correct: true, index: 121, key: 't', timestamp: 1676392817262 }, { correct: true, index: 122, key: '.', timestamp: 1676392817743 }, { correct: true, index: 123, key: 'N', timestamp: 1676392818028 }, { correct: true, index: 124, key: 'e', timestamp: 1676392818220 }, { correct: true, index: 125, key: 'w', timestamp: 1676392818308 }, { correct: true, index: 126, key: 'I', timestamp: 1676392818497 }, { correct: false, index: 127, key: 'N', timestamp: 1676392818602 }, { correct: false, index: 128, key: 't', timestamp: 1676392818801 }, { correct: false, index: 129, key: 'e', timestamp: 1676392818895 }, { correct: true, index: 127, key: 'n', timestamp: 1676392819553 }, { correct: true, index: 128, key: 't', timestamp: 1676392819712 }, { correct: true, index: 129, key: 'e', timestamp: 1676392819808 }, { correct: true, index: 130, key: 'r', timestamp: 1676392819998 }, { correct: true, index: 131, key: 'v', timestamp: 1676392820243 }, { correct: true, index: 132, key: 'a', timestamp: 1676392820349 }, { correct: true, index: 133, key: 'l', timestamp: 1676392820416 }, { correct: true, index: 134, key: 'T', timestamp: 1676392820902 }, { correct: true, index: 135, key: 'r', timestamp: 1676392821122 }, { correct: true, index: 136, key: 'e', timestamp: 1676392821196 }, { correct: true, index: 137, key: 'e', timestamp: 1676392821343 }, { correct: true, index: 138, key: '(', timestamp: 1676392821550 }, { correct: true, index: 139, key: ')', timestamp: 1676392821785 }, { correct: true, index: 140, key: ',', timestamp: 1676392821968 }, { correct: true, index: 145, key: '\n', timestamp: 1676392822798 }, { correct: true, index: 146, key: 'w', timestamp: 1676392823218 }, { correct: true, index: 147, key: 'a', timestamp: 1676392823403 }, { correct: true, index: 148, key: 't', timestamp: 1676392823537 }, { correct: true, index: 149, key: 'c', timestamp: 1676392823771 }, { correct: true, index: 150, key: 'h', timestamp: 1676392823848 }, { correct: true, index: 151, key: 'e', timestamp: 1676392823954 }, { correct: true, index: 152, key: 'r', timestamp: 1676392824086 }, { correct: true, index: 153, key: 's', timestamp: 1676392824116 }, { correct: true, index: 154, key: ':', timestamp: 1676392824376 }, { correct: true, index: 155, key: ' ', timestamp: 1676392824700 }, { correct: true, index: 156, key: ' ', timestamp: 1676392824882 }, { correct: true, index: 157, key: ' ', timestamp: 1676392825040 }, { correct: true, index: 158, key: ' ', timestamp: 1676392825547 }, { correct: true, index: 159, key: 'm', timestamp: 1676392825845 }, { correct: true, index: 160, key: 'a', timestamp: 1676392825913 }, { correct: true, index: 161, key: 'k', timestamp: 1676392826036 }, { correct: true, index: 162, key: 'e', timestamp: 1676392826111 }, { correct: true, index: 163, key: '(', timestamp: 1676392826422 }, { correct: true, index: 164, key: 'w', timestamp: 1676392826766 }, ]; it('should include the last correct keystroke', () => { const expectedInput = 'func newWatcherGroup() watcherGroup {\nreturn watcherGroup{\nkeyWatchers: make(watcherSetByKey),\nranges: adt.NewIntervalTree(),\nwatchers: make(w'; const validKeyStrokes = player.validKeyStrokes(); const actualInput = validKeyStrokes.map((stroke) => stroke.key).join(''); expect(actualInput).toBe(expectedInput); }); }); ================================================ FILE: packages/back-nest/src/results/entities/leaderboard-result.dto.ts ================================================ export class LeaderBoardResult { username: string; avatarUrl: string; cpm: number; accuracy: number; createdAt: Date; } ================================================ FILE: packages/back-nest/src/results/entities/result.entity.ts ================================================ import { Challenge } from 'src/challenges/entities/challenge.entity'; import { User } from 'src/users/entities/user.entity'; import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, } from 'typeorm'; @Entity('results') export class Result { @PrimaryGeneratedColumn('uuid') id: string; @Column({ nullable: true }) raceId: string; @Column() timeMS: number; @Column() cpm: number; @Column() mistakes: number; @Column() accuracy: number; @Column({ unique: true, nullable: true, default: null }) legacyId: string; @CreateDateColumn({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP(6)', }) public createdAt: Date; @ManyToOne(() => Challenge, (challenge) => challenge.results, { onDelete: 'SET NULL', }) challenge: Challenge; @ManyToOne(() => User, (user) => user.results, { onDelete: 'SET NULL', }) user: User; userId: string; percentile?: number; } ================================================ FILE: packages/back-nest/src/results/errors.ts ================================================ import { BadRequestException, ForbiddenException } from '@nestjs/common'; export class SaveResultAnonymousNotAllowed extends ForbiddenException { constructor() { super('Anonymous users cannot save results'); } } export class SaveResultInvalidUserID extends ForbiddenException { constructor() { super('Users can only save their own results'); } } export class SaveResultRaceNotCompleted extends BadRequestException { constructor() { super('User did not complete the race'); } } export class SaveResultUserNotInRace extends BadRequestException { constructor() { super('User is not playing in the race'); } } ================================================ FILE: packages/back-nest/src/results/results.controller.ts ================================================ import { BadRequestException, Controller, Get, InternalServerErrorException, Param, Req, } from '@nestjs/common'; import { isUUID } from 'class-validator'; import { Request } from 'express'; import { LeaderBoardResult } from './entities/leaderboard-result.dto'; import { ResultService } from './services/results.service'; @Controller('results') export class ResultsController { leaderboardP?: Promise; constructor(private resultsService: ResultService) {} @Get('leaderboard') async getLeaderboard(): Promise { if (this.leaderboardP) { // there is an ongoing promise return this.leaderboardP; } // cache the leaderboard promise so we only hit the DB once per concurrent request this.leaderboardP = this.resultsService.getLeaderboard(); return this.leaderboardP.finally(() => { // reset the promise so new clients don't get a stale leaderboard this.leaderboardP = undefined; }); } @Get('/stats') async getStatsByUser(@Req() request: Request) { if (!request.session?.user) { throw new InternalServerErrorException(); } const startOfToday = new Date(); startOfToday.setUTCHours(0, 0, 0, 0); const startOfTime = new Date('January 1, 1979'); const oneWeekAgo = new Date(); oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); oneWeekAgo.setUTCHours(0, 0, 0, 0); const user = request.session.user; const [cpmAllTime, cpmToday, cpmLastWeek, cpmLast10] = await Promise.all([ this.resultsService.getAverageCPMSince(user.id, startOfTime), this.resultsService.getAverageCPMSince(user.id, startOfToday), this.resultsService.getAverageCPMSince(user.id, oneWeekAgo), this.resultsService.getAverageCPM(user.id, 10), ]); return { cpmLast10, cpmToday, cpmLastWeek, cpmAllTime, }; } @Get(':resultId') getResultByID(@Param('resultId') resultId: string) { if (!isUUID(resultId)) throw new BadRequestException('Invalid resultId'); const result = this.resultsService.getByID(resultId); return result; } } ================================================ FILE: packages/back-nest/src/results/results.module.ts ================================================ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Result } from './entities/result.entity'; import { ResultsController } from './results.controller'; import { ResultCalculationService } from './services/result-calculation.service'; import { ResultFactoryService } from './services/result-factory.service'; import { ResultService } from './services/results.service'; @Module({ imports: [TypeOrmModule.forFeature([Result])], providers: [ResultService, ResultFactoryService, ResultCalculationService], controllers: [ResultsController], exports: [ResultService, ResultFactoryService], }) export class ResultsModule {} ================================================ FILE: packages/back-nest/src/results/services/result-calculation.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { Challenge } from 'src/challenges/entities/challenge.entity'; import { RacePlayer } from 'src/races/services/race-player.service'; import { Race } from 'src/races/services/race.service'; @Injectable() export class ResultCalculationService { getTimeMS(race: Race, player: RacePlayer): number { const firstTimeStampMS = race.startTime.getTime(); const keyStrokes = player.validKeyStrokes(); const lastTimeStampMS = keyStrokes[keyStrokes.length - 1].timestamp; return lastTimeStampMS - firstTimeStampMS; } getCPM(code: string, timeMS: number): number { const timeSeconds = timeMS / 1000; const strippedCode = Challenge.getStrippedCode(code); const cps = strippedCode.length / timeSeconds; const cpm = cps * 60; return Math.floor(cpm); } getMistakesCount(player: RacePlayer): number { return player.incorrectKeyStrokes().length; } getAccuracy(player: RacePlayer): number { const incorrectKeyStrokes = player.incorrectKeyStrokes().length; const validKeyStrokes = player.validKeyStrokes().length; const totalKeySrokes = validKeyStrokes + incorrectKeyStrokes; return Math.floor((validKeyStrokes / totalKeySrokes) * 100); } } ================================================ FILE: packages/back-nest/src/results/services/result-factory.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { RacePlayer } from 'src/races/services/race-player.service'; import { Race } from 'src/races/services/race.service'; import { User } from 'src/users/entities/user.entity'; import { Result } from '../entities/result.entity'; import { ResultCalculationService } from './result-calculation.service'; @Injectable() export class ResultFactoryService { constructor(private resultCalculation: ResultCalculationService) {} factory(race: Race, player: RacePlayer, user: User): Result { const challenge = race.challenge; const result = new Result(); const timeMS = this.resultCalculation.getTimeMS(race, player); const cpm = this.resultCalculation.getCPM(challenge.content, timeMS); const mistakes = this.resultCalculation.getMistakesCount(player); const accuracy = this.resultCalculation.getAccuracy(player); result.raceId = player.raceId; result.user = user; result.challenge = challenge; result.timeMS = timeMS; result.cpm = cpm; result.mistakes = mistakes; result.accuracy = accuracy; return result; } } ================================================ FILE: packages/back-nest/src/results/services/results.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { LeaderBoardResult } from '../entities/leaderboard-result.dto'; import { Result } from '../entities/result.entity'; @Injectable() export class ResultService { constructor( @InjectRepository(Result) private resultsRepository: Repository, ) {} async create(result: Result): Promise { return await this.resultsRepository.save(result); } async upsertByLegacyId(results: Result[]): Promise { await this.resultsRepository.upsert(results, ['legacyId']); } async getByID(id: string) { const result = await this.resultsRepository.findOneOrFail({ where: { id, // filter out legacy results legacyId: null, }, relations: ['user', 'challenge', 'challenge.project'], }); result.percentile = await this.getResultPercentile(result.cpm); return result; } async getLeaderboard(): Promise { const oneDayAgo = new Date(); oneDayAgo.setDate(oneDayAgo.getDate() - 1); const resultsTodayStream = await this.resultsRepository .createQueryBuilder('r') .leftJoinAndSelect('r.user', 'u') .where( `u.banned=false AND r.createdAt BETWEEN '${oneDayAgo.toISOString()}' AND '${new Date().toISOString()}'`, ) .orderBy('r.cpm') .orderBy('r.createdAt', 'DESC') .stream(); const resultsToday: Record = {}; for await (const r of resultsTodayStream) { if (!resultsToday[r.u_id]) { r.racesPlayed = 1; resultsToday[r.u_id] = r; continue; } const prevResult = resultsToday[r.u_id]; if (r.r_cpm > prevResult.r_cpm) { r.racesPlayed = prevResult.racesPlayed; resultsToday[r.u_id] = r; } resultsToday[r.u_id].racesPlayed++; } const results = Object.values(resultsToday) .map((r) => { return { username: r.u_username, avatarUrl: r.u_avatarUrl, cpm: r.r_cpm, accuracy: r.r_accuracy, createdAt: r.r_createdAt, racesPlayed: r.racesPlayed, resultId: r.r_id, }; }) .sort((a, b) => b.cpm - a.cpm); return results; } async getAverageCPM(userId: string, take: number): Promise { const results = await this.resultsRepository.find({ where: { user: { id: userId }, }, order: { createdAt: 'DESC', }, take, }); const total = results.reduce((prev, curr) => { return prev + curr.cpm; }, 0); const average = total / results.length; return parseInt(average.toString(), 10); } async getAverageCPMSince(userId: string, since: Date): Promise { const { avg } = await this.resultsRepository .createQueryBuilder('r') .where('r.userId=:userId AND r.createdAt > :startOfToday', { userId, startOfToday: since.toISOString(), }) .select('AVG(r.cpm)', 'avg') .getRawOne(); return parseInt(avg, 10); } async getResultPercentile(cpm: number): Promise { const { countBetterThan } = await this.resultsRepository .createQueryBuilder('r') .where('r.cpm < :cpm', { cpm, }) .select('COUNT(r.cpm)', 'countBetterThan') .getRawOne(); const totalCount = await this.resultsRepository.count(); const percentile = ( (parseInt(countBetterThan, 10) / totalCount) * 100 ).toFixed(0); return parseInt(percentile, 10); } } ================================================ FILE: packages/back-nest/src/seeder/commands/challenge.seeder.ts ================================================ import { Command, CommandRunner } from 'nest-commander'; import { Challenge } from 'src/challenges/entities/challenge.entity'; import { ChallengeService } from 'src/challenges/services/challenge.service'; import { Project } from 'src/projects/entities/project.entity'; import { ProjectService } from 'src/projects/services/project.service'; @Command({ name: 'seed-challenges', arguments: '', options: {}, }) export class ProjectSeedRunner extends CommandRunner { constructor( private projectService: ProjectService, private challengeService: ChallengeService, ) { super(); } async run(): Promise { const project = this.project_factory(); await this.projectService.bulkUpsert([project]); const challenges = this.challenges_factory(project); await this.challengeService.upsert(challenges); } project_factory() { const project = new Project(); project.id = '98dac57c-516e-485f-872a-4b9f6e1ad566'; project.fullName = 'etcd-io/etcd'; project.htmlUrl = 'https://github.com/etcd-io/etcd'; project.language = 'Go'; project.stars = 41403; project.licenseName = 'Apache License 2.0'; project.ownerAvatar = 'https://avatars.githubusercontent.com/u/41972792?v=4'; project.defaultBranch = 'main'; project.syncedSha = '2d638e0fd2c1d91c9c4323591bb1041b594e28fa'; return project; } challenges_factory(project: Project) { const challenges = []; const firstChallenge = new Challenge(); challenges.push(firstChallenge); firstChallenge.id = 'b4b6eec5-333c-4c77-a648-1b0884ae5ad0'; firstChallenge.sha = 'fa3011cb39ac784a88da3667b729f3a79f5f22c3'; firstChallenge.treeSha = '2d638e0fd2c1d91c9c4323591bb1041b594e28fa'; firstChallenge.path = 'server/etcdserver/api/rafthttp/transport.go'; firstChallenge.language = 'go'; firstChallenge.url = 'https://github.com/etcd-io/etcd/blob/2d638e0fd2c1d91c9c4323591bb1041b594e28fa/server/etcdserver/api/rafthttp/transport.go/#L425'; firstChallenge.content = 'func (t *Transport) Pause() {\n' + '\tt.mu.RLock()\n' + '\tdefer t.mu.RUnlock()\n' + '\tfor _, p := range t.peers {\n' + '\t\tp.(Pausable).Pause()\n' + '\t}\n' + '}'; firstChallenge.project = project; const secondChallenge = new Challenge(); challenges.push(secondChallenge); secondChallenge.id = '8ebf6be1-7f7c-4edf-a622-97b0024636e8'; secondChallenge.sha = '69ecc631471975fcb4d207f85a57baf2b5a79460'; secondChallenge.treeSha = '2d638e0fd2c1d91c9c4323591bb1041b594e28fa'; secondChallenge.language = 'go'; secondChallenge.path = 'client/v3/retry.go'; secondChallenge.url = 'https://github.com/etcd-io/etcd/blob/2d638e0fd2c1d91c9c4323591bb1041b594e28fa/client/v3/retry.go/#L160'; secondChallenge.content = 'func RetryClusterClient(c *Client) pb.ClusterClient {\n' + '\treturn &retryClusterClient{\n' + '\t\tcc: pb.NewClusterClient(c.conn),\n' + '\t}\n' + '}'; secondChallenge.project = project; const thirdChallenge = new Challenge(); challenges.push(thirdChallenge); thirdChallenge.id = '19174a2e-9220-40c8-832a-7effd351a68b'; thirdChallenge.sha = 'ea19cf0181bbedbfc65bce9cfce26eb3558cb9ee'; thirdChallenge.treeSha = '2d638e0fd2c1d91c9c4323591bb1041b594e28fa'; thirdChallenge.path = 'pkg/schedule/schedule.go'; thirdChallenge.language = 'go'; thirdChallenge.url = 'https://github.com/etcd-io/etcd/blob/2d638e0fd2c1d91c9c4323591bb1041b594e28fa/pkg/schedule/schedule.go/#L135'; thirdChallenge.content = 'func (f *fifo) Finished() int {\n' + '\tf.finishCond.L.Lock()\n' + '\tdefer f.finishCond.L.Unlock()\n' + '\treturn f.finished\n' + '}'; thirdChallenge.project = project; return challenges; } } ================================================ FILE: packages/back-nest/src/seeder/seeder.module.ts ================================================ import { Module } from '@nestjs/common'; import { ChallengesModule } from 'src/challenges/challenges.module'; import { ProjectsModule } from 'src/projects/projects.module'; import { ProjectSeedRunner } from './commands/challenge.seeder'; @Module({ imports: [ProjectsModule, ChallengesModule], providers: [ProjectSeedRunner], }) export class SeederModule {} ================================================ FILE: packages/back-nest/src/sessions/session.adapter.ts ================================================ import { IncomingMessage } from 'http'; import { INestApplication } from '@nestjs/common'; import { IoAdapter } from '@nestjs/platform-socket.io'; import { NextFunction } from 'express'; import { Server, Socket } from 'socket.io'; type SocketIOCompatibleMiddleware = ( r: IncomingMessage, object: object, next: NextFunction, ) => void; export function makeSocketIOReadMiddleware( middleware: SocketIOCompatibleMiddleware, ) { return (socket: Socket, next: NextFunction) => { return middleware(socket.request, {}, next); }; } export const denyWithoutUserInSession = ( socket: Socket, next: NextFunction, ) => { if (!socket.request.session?.user) { console.log( 'disconnect because there is no user in the session', socket.id, ); socket.request.session?.destroy(() => { /* **/ }); return socket.disconnect(true); } next(); }; export class SessionAdapter extends IoAdapter { constructor( app: INestApplication, private sessionMiddleware: SocketIOCompatibleMiddleware, ) { super(app); } createIOServer(port: number, opt?: any): any { const server: Server = super.createIOServer(port, opt); server.use(makeSocketIOReadMiddleware(this.sessionMiddleware)); server.use(denyWithoutUserInSession); return server; } } ================================================ FILE: packages/back-nest/src/sessions/session.entity.ts ================================================ import { ISession } from 'connect-typeorm'; import { Column, DeleteDateColumn, Entity, Index, PrimaryColumn, } from 'typeorm'; @Entity({ name: 'sessions' }) export class Session implements ISession { @Index() @Column('bigint') expiredAt: number = Date.now(); @PrimaryColumn('varchar', { length: 255 }) id: string; @Column('text') json: string; @DeleteDateColumn() destroyedAt: Date; } ================================================ FILE: packages/back-nest/src/sessions/session.middleware.ts ================================================ import { TypeormStore } from 'connect-typeorm/out'; import * as session from 'express-session'; import { PostgresDataSource } from 'src/database.module'; import { Session } from './session.entity'; const SESSION_SECRET_MIN_LENGTH = 12; const ONE_DAY = 1000 * 60 * 60 * 24; export const cookieName = 'speedtyper-v2-sid'; export const getSessionMiddleware = () => { const sessionRepository = PostgresDataSource.getRepository(Session); return session({ name: cookieName, store: new TypeormStore({ cleanupLimit: 2, }).connect(sessionRepository), secret: getSessionSecret(), resave: false, saveUninitialized: false, cookie: { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', maxAge: ONE_DAY * 7, ...(process.env.NODE_ENV === 'production' ? { domain: '.speedtyper.dev', } : {}), }, }); }; function getSessionSecret() { const secret = process.env.SESSION_SECRET; if (!secret) throw new Error('SESSION_SECRET is missing from environment variables'); if (secret.length < SESSION_SECRET_MIN_LENGTH) throw new Error( `SESSION_SECRET is not long enough, must be at least ${SESSION_SECRET_MIN_LENGTH} characters long`, ); return secret; } ================================================ FILE: packages/back-nest/src/sessions/types.d.ts ================================================ import { Session, SessionData } from 'express-session'; import { User } from 'src/users/entities/user.entity'; declare module 'express-session' { export interface SessionData { user: User; raceId: string; } } declare module 'http' { interface IncomingMessage { cookieHolder?: string; session: Session & SessionData; } } ================================================ FILE: packages/back-nest/src/tracking/entities/event.entity.ts ================================================ import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; export enum TrackingEventType { LegacyRaceStarted = 'legacy_race_started', RaceStarted = 'race_started', RaceCompleted = 'race_completed', } @Entity() export class TrackingEvent { @PrimaryGeneratedColumn('uuid') id: string; @Column({ unique: true, type: 'enum', enum: TrackingEventType, }) event: TrackingEventType; @Column({ default: 0, }) count: number; } ================================================ FILE: packages/back-nest/src/tracking/tracking.module.ts ================================================ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { TrackingEvent } from './entities/event.entity'; import { TrackingService } from './tracking.service'; @Module({ imports: [TypeOrmModule.forFeature([TrackingEvent])], controllers: [], providers: [TrackingService], exports: [TrackingService], }) export class TrackingModule {} ================================================ FILE: packages/back-nest/src/tracking/tracking.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { TrackingEvent, TrackingEventType } from './entities/event.entity'; @Injectable() export class TrackingService { constructor( @InjectRepository(TrackingEvent) private repository: Repository, ) {} async trackRaceStarted(): Promise { return this.trackRaceEvent(TrackingEventType.RaceStarted); } async trackRaceCompleted(): Promise { return this.trackRaceEvent(TrackingEventType.RaceCompleted); } private async trackRaceEvent( event: TrackingEventType, ): Promise { return await this.repository.manager.transaction(async (transaction) => { let trackingEvent = new TrackingEvent(); trackingEvent.event = event; await transaction.upsert(TrackingEvent, trackingEvent, { conflictPaths: ['event'], skipUpdateIfNoValuesChanged: true, }); trackingEvent = await transaction.findOneBy(TrackingEvent, { event: trackingEvent.event, }); trackingEvent.count++; trackingEvent = await transaction.save(TrackingEvent, trackingEvent); return trackingEvent; }); } } ================================================ FILE: packages/back-nest/src/users/controllers/user.controller.ts ================================================ import { Controller, Get, HttpException, Req } from '@nestjs/common'; import { Request } from 'express'; import { User } from '../entities/user.entity'; @Controller('user') export class UserController { @Get() getCurrentUser(@Req() request: Request): User { if (!request.session?.user) { throw new HttpException('Internal server error', 500); } return request.session.user; } } ================================================ FILE: packages/back-nest/src/users/entities/upsertGithubUserDTO.ts ================================================ import { IsString } from 'class-validator'; import { Profile } from 'passport-github'; import { User } from './user.entity'; export class UpsertGithubUserDTO { @IsString() username: string; @IsString() githubId: string; @IsString() githubUrl: string; @IsString() avatarUrl: string; static fromGithubProfile(profile: Profile) { const user = new UpsertGithubUserDTO(); user.githubId = profile.id; user.username = profile.username; user.githubUrl = profile.profileUrl; user.avatarUrl = profile.photos[0].value; return user; } toUser() { const user = new User(); user.githubId = parseInt(this.githubId); user.username = this.username; user.githubUrl = this.githubUrl; user.avatarUrl = this.avatarUrl; return user; } } ================================================ FILE: packages/back-nest/src/users/entities/user.entity.ts ================================================ import { randomUUID } from 'crypto'; import { Result } from 'src/results/entities/result.entity'; import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn, } from 'typeorm'; import { generateRandomUsername } from '../utils/generateRandomUsername'; @Entity('users') export class User { @PrimaryGeneratedColumn('uuid') id: string; @Column({ unique: true }) username: string; @Column({ unique: true }) githubId: number; @Column({ unique: true }) githubUrl: string; @Column() avatarUrl: string; @Column({ unique: true, nullable: true }) legacyId: string; @Column({ default: false, select: false }) banned: boolean; @CreateDateColumn({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP(6)', }) public createdAt: Date; @OneToMany(() => Result, (result) => result.user) results: Result[]; isAnonymous: boolean; static generateAnonymousUser() { const user = new User(); user.id = randomUUID(); user.username = generateRandomUsername(); user.isAnonymous = true; return user; } } ================================================ FILE: packages/back-nest/src/users/services/user.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from '../entities/user.entity'; @Injectable() export class UserService { constructor( @InjectRepository(User) private userRepository: Repository, ) {} async upsertGithubUser(userData: User): Promise { const currentUser = await this.userRepository.findOneBy({ githubId: userData.githubId, }); userData.id = currentUser?.id; userData.banned = currentUser?.banned || userData.banned || false; const user = await this.userRepository.save(userData); user.isAnonymous = false; return user; } async findByLegacyID(legacyId: string) { const user = await this.userRepository.findOneBy({ legacyId, }); return user; } } ================================================ FILE: packages/back-nest/src/users/users.module.ts ================================================ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { UserController } from './controllers/user.controller'; import { User } from './entities/user.entity'; import { UserService } from './services/user.service'; @Module({ imports: [TypeOrmModule.forFeature([User])], controllers: [UserController], providers: [UserService], exports: [UserService], }) export class UsersModule {} ================================================ FILE: packages/back-nest/src/users/utils/generateRandomUsername.ts ================================================ import { uniqueNamesGenerator } from 'unique-names-generator'; const adjectives2 = [ 'abrupt', 'acidic', 'adorable', 'adventurous', 'aggressive', 'agitated', 'aloof', 'amused', 'annoyed', 'antsy', 'anxious', 'appalling', 'apprehensive', 'arrogant', 'astonishing', 'bitter', 'bland', 'bored', 'brave', 'bright', 'broad', 'bulky', 'burly', 'charming', 'cheeky', 'cheerful', 'clean', 'clear', 'cloudy', 'clueless', 'clumsy', 'colorful', 'colossal', 'confused', 'convincing', 'convoluted', 'cooperative', 'courageous', 'crooked', 'cruel', 'cynical', 'dangerous', 'dashing', 'deceitful', 'defeated', 'defiant', 'delicious', 'delightful', 'depraved', 'depressed', 'despicable', 'determined', 'dilapidated', 'diminutive', 'disgusted', 'distinct', 'distraught', 'distressed', 'disturbed', 'dizzy', 'drab', 'drained', 'dull', 'eager', 'ecstatic', 'elated', 'elegant', 'emaciated', 'embarrassed', 'enchanting', 'encouraging', 'energetic', 'enormous', 'enthusiastic', 'envious', 'exasperated', 'excited', 'exhilarated', 'extensive', 'exuberant', 'fancy', 'fantastic', 'fierce', 'fluttering', 'foolish', 'frantic', 'fresh', 'friendly', 'frightened', 'frothy', 'frustrating', 'funny', 'fuzzy', 'gaudy', 'gentle', 'giddy', 'gigantic', 'glamorous', 'gleaming', 'glorious', 'gorgeous', 'graceful', 'greasy', 'grieving', 'gritty', 'grotesque', 'grubby', 'grumpy', 'handsome', 'happy', 'harebrained', 'healthy', 'helpful', 'helpless', 'high', 'hollow', 'homely', 'horrific', 'huge', 'hungry', 'hurt', 'icy', 'ideal', 'immense', 'impressionable', 'intrigued', 'irate', 'irritable', 'itchy', 'jealous', 'jittery', 'jolly', 'joyous', 'juicy', 'jumpy', 'kind', 'large', 'lazy', 'lethal', 'little', 'lively', 'livid', 'lonely', 'loose', 'lovely', 'lucky', 'ludicrous', 'magnificent', 'mammoth', 'maniacal', 'massive', 'melancholy', 'melted', 'miniature', 'minute', 'mistaken', 'misty', 'moody', 'mortified', 'motionless', 'mysterious', 'narrow', 'nasty', 'naughty', 'nervous', 'nonchalant', 'nonsensical', 'nutritious', 'nutty', 'obedient', 'oblivious', 'obnoxious', 'odd', 'outrageous', 'panicky', 'perfect', 'perplexed', 'petite', 'petty', 'plain', 'pleasant', 'poised', 'pompous', 'precious', 'prickly', 'proud', 'pungent', 'puny', 'quaint', 'quizzical', 'ratty', 'reassured', 'relieved', 'repulsive', 'responsive', 'ripe', 'robust', 'rotten', 'rough', 'round', 'salty', 'sarcastic', 'scant', 'scary', 'scattered', 'scrawny', 'selfish', 'shaky', 'shallow', 'sharp', 'shiny', 'short', 'silky', 'silly', 'skinny', 'slimy', 'slippery', 'small', 'smarmy', 'smiling', 'smoggy', 'smooth', 'smug', 'soggy', 'solid', 'sore', 'sour', 'sparkling', 'spicy', 'splendid', 'spotless', 'square', 'stale', 'steady', 'steep', 'responsive', 'sticky', 'stormy', 'stout', 'strange', 'strong', 'stunning', 'substantial', 'successful', 'succulent', 'superficial', 'superior', 'sweet', 'tart', 'tasty', 'tender', 'tense', 'terrible', 'thankful', 'thick', 'thoughtful', 'thoughtless', 'timely', 'tricky', 'troubled', 'uneven', 'unsightly', 'upset', 'uptight', 'vast', 'vexed', 'victorious', 'virtuous', 'vivacious', 'vivid', 'wacky', 'weary', 'whimsical', 'whopping', 'wicked', 'witty', 'wobbly', 'wonderful', 'worried', 'yummy', 'zany', 'zealous', 'zippy', ]; const technologies = [ 'Bash', 'C', 'C#', 'C++', 'CSS', 'Elm', 'Eno', 'ERB', 'Fennel', 'Golang', 'HTML', 'Java', 'JavaScript', 'Lua', 'Make', 'Markdown', 'OCaml', 'PHP', 'Python', 'Ruby', 'Rust', 'R', 'S-expressions', 'SPARQL', 'SystemRDL', 'Svelte', 'TOML', 'Turtle', 'TypeScript', 'Verilog', 'VHDL', 'Vue', 'YAML', 'WASM', 'Agda', 'Erlang', 'Dockerfile', 'Go', 'Haskell', 'Kotlin', 'Nix', 'Perl', 'Scala', 'Swift', 'Arch', 'Ubuntu', 'Mac', 'Windows', 'GNU', 'Linux', 'BSD', 'Arduino', 'Clojure', 'Blockchain', 'Elixir', 'Angular', 'Vue', 'Svelte', 'React', 'Re-frame', 'Stateless', 'Kernel', 'Context', 'OpenGL', 'MicroServices', 'Monolith', 'Monorepo', 'SQL', 'Firebase', 'MongoDB', 'Postgres', 'MySQL', 'Ionic', 'Phoenix', 'Cordova', 'ReactNative', 'PowerShell', 'Vim', 'VSCode', 'Emacs', 'Cobol', 'Zsh', 'Assembly', 'OpenCV', 'HTTP', 'SSH', 'FTP', 'Tensorflow', 'PyTorch', 'Pandas', 'Unity', 'Unreal', 'Docker', 'Kubernetes', 'Godot', ]; export const generateRandomUsername = () => { const randomName: string = uniqueNamesGenerator({ dictionaries: [adjectives2, technologies], separator: '', length: 2, style: 'capital', }); return randomName; }; ================================================ FILE: packages/back-nest/src/utils/validateDTO.ts ================================================ import { ValidationError } from '@nestjs/common'; import { ClassConstructor, plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; export class ValidationErrorContainer extends TypeError { errors: ValidationError[]; constructor(name: string, errors: ValidationError[]) { const fields = errors.map((err) => err.property).join(', '); super(`Error validating ${name} for fields: ${fields}`); this.errors = errors; Object.setPrototypeOf(this, ValidationErrorContainer.prototype); } } export const validateDTO = async ( dto: ClassConstructor, obj: unknown, ) => { const instance = plainToInstance(dto, obj); const errors = await validate(instance as object); if (errors.length > 0) { throw new ValidationErrorContainer(dto.name, errors); } return instance; }; ================================================ FILE: packages/back-nest/tracked-projects.txt ================================================ etcd-io/etcd rust-lang/cargo rust-lang/rust tiangolo/fastapi pallets/flask encode/starlette apache/zookeeper ClickHouse/ClickHouse rails/rails lodash/lodash TheAlgorithms/Java ggerganov/llama.cpp ggerganov/whisper.cpp vitejs/vite lampepfl/dotty tinygrad/tinygrad ================================================ FILE: packages/back-nest/tsconfig.build.json ================================================ { "extends": "./tsconfig.json", "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] } ================================================ FILE: packages/back-nest/tsconfig.json ================================================ { "ts-node": { "files": true }, "compilerOptions": { "module": "commonjs", "declaration": true, "removeComments": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "target": "es2021", "sourceMap": true, "outDir": "./dist", "baseUrl": "./", "incremental": true, "skipLibCheck": true, "strictNullChecks": false, "noImplicitAny": false, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false } } ================================================ FILE: packages/webapp-next/.eslintrc.json ================================================ { "extends": ["next/core-web-vitals", "prettier"], "plugins": ["prettier"], "rules": { "prettier/prettier": [ "error", { "endOfLine": "auto" } ] } } ================================================ FILE: packages/webapp-next/.gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage # next.js .next/ /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # local env files .env*.local # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts ================================================ FILE: packages/webapp-next/.prettierrc ================================================ { "singleQuote": false, "jsxSingleQuote": false } ================================================ FILE: packages/webapp-next/README.md ================================================ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). ## Getting Started First, run the development server: ```bash npm run dev # or yarn dev ``` Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. ## Learn More To learn more about Next.js, take a look at the following resources: - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! ## Deploy on Vercel The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. ================================================ FILE: packages/webapp-next/Socket.ts ================================================ import io from "socket.io-client"; export default class Socket { socket: SocketIOClient.Socket; constructor(serverUrl: string) { this.socket = io(serverUrl); } disconnect = () => { this.socket.emit("disconnect"); if (this.socket) this.socket.disconnect(); }; subscribe = (event: string, cb: (error: string | null, msg: any) => void) => { if (!this.socket) return true; this.socket.on(event, (msg: any) => { return cb(null, msg); }); }; emit = (event: string, data?: any) => { if (this.socket) this.socket.emit(event, data); }; } ================================================ FILE: packages/webapp-next/assets/icons/BattleIcon.tsx ================================================ export const BattleIcon = () => { // return ( ); }; ================================================ FILE: packages/webapp-next/assets/icons/CopyIcon.tsx ================================================ export const CopyIcon = () => { return ( ); }; ================================================ FILE: packages/webapp-next/assets/icons/CrossIcon.tsx ================================================ export const CrossIcon = () => { // Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. return ( ); }; ================================================ FILE: packages/webapp-next/assets/icons/CrownIcon.tsx ================================================ export const CrownIcon = () => { return ( ); }; ================================================ FILE: packages/webapp-next/assets/icons/DiscordLogo.tsx ================================================ export const DiscordLogo = () => { return ( ); }; ================================================ FILE: packages/webapp-next/assets/icons/DownArrowIcon.tsx ================================================ export const DownArrowIcon = () => { return ( ); }; ================================================ FILE: packages/webapp-next/assets/icons/GithubLogo.tsx ================================================ export const GithubLogo = () => ( ); ================================================ FILE: packages/webapp-next/assets/icons/InfoIcon.tsx ================================================ export const InfoIcon = () => { return ( // Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --> ); }; ================================================ FILE: packages/webapp-next/assets/icons/KogWheel.tsx ================================================ export const KogWheel = () => { // Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. return ( ); }; ================================================ FILE: packages/webapp-next/assets/icons/LinkIcon.tsx ================================================ export const LinkIcon = () => { return ( // Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --> ); }; ================================================ FILE: packages/webapp-next/assets/icons/OnlineIcon.tsx ================================================ export const OnlineIcon = () => { return ( // Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. ); }; ================================================ FILE: packages/webapp-next/assets/icons/PlayIcon.tsx ================================================ export const PlayIcon = () => { return ( ); }; ================================================ FILE: packages/webapp-next/assets/icons/ProfileIcon.tsx ================================================ export const ProfileIcon = () => { return ( ); }; ================================================ FILE: packages/webapp-next/assets/icons/ReloadIcon.tsx ================================================ export const ReloadIcon = () => { return ( // Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. ); }; ================================================ FILE: packages/webapp-next/assets/icons/RightArrowIcon.tsx ================================================ export const RightArrowIcon = () => { return ( ); }; ================================================ FILE: packages/webapp-next/assets/icons/TerminalIcon.tsx ================================================ export const TerminalIcon = () => { // return ( ); }; ================================================ FILE: packages/webapp-next/assets/icons/TwitchLogo.tsx ================================================ export const TwitchLogo = () => { return ( ); }; ================================================ FILE: packages/webapp-next/assets/icons/UserGroupIcon.tsx ================================================ export const UserGroupIcon = () => { // return ( ); }; ================================================ FILE: packages/webapp-next/assets/icons/WarningIcon.tsx ================================================ export const WarningIcon = () => { // return ( ); }; ================================================ FILE: packages/webapp-next/assets/icons/YoutubeLogo.tsx ================================================ export const YoutubeLogo = () => { return ( ); }; ================================================ FILE: packages/webapp-next/assets/icons/index.tsx ================================================ export * from "./CopyIcon"; export * from "./DiscordLogo"; export * from "./DownArrowIcon"; export * from "./GithubLogo"; export * from "./PlayIcon"; export * from "./ProfileIcon"; export * from "./RightArrowIcon"; export * from "./TwitchLogo"; ================================================ FILE: packages/webapp-next/common/api/auth.ts ================================================ import { NextRouter } from "next/router"; import { useCallback } from "react"; import { useGameStore } from "../../modules/play2/state/game-store"; import { useUserStore } from "../state/user-store"; import { getExperimentalServerUrl, getSiteRoot } from "../utils/getServerUrl"; import { fetchUser } from "./user"; export const useGithubAuthFactory = (router: NextRouter, serverUrl: string) => { return useCallback(() => { let nextUrl = getSiteRoot(); if (document !== undefined) { nextUrl = window.location.href; } const authUrl = `${serverUrl}/auth/github?next=${nextUrl}`; router.push(authUrl); }, [router, serverUrl]); }; export const logout = async () => { const serverUrl = getExperimentalServerUrl(); const authUrl = `${serverUrl}/api/auth`; return fetch(authUrl, { method: "DELETE", credentials: "include", }).then(async () => { const prevUserId = useUserStore.getState().id; const user = await fetchUser(); useUserStore.setState((state) => ({ ...state, ...user, avatarUrl: undefined, })); useGameStore.setState((state) => { return { ...state, owner: state.owner === prevUserId ? user.id : state.owner, }; }); useGameStore.getState().game?.reconnect(); }); }; ================================================ FILE: packages/webapp-next/common/api/races.ts ================================================ import { getExperimentalServerUrl } from "../utils/getServerUrl"; const serverUrl = getExperimentalServerUrl(); const RACE_STATUS_API = "/api/races/:id/status"; export const fetchRaceStatus = async (raceId: string) => { const url = serverUrl + RACE_STATUS_API.replace(":id", raceId); return fetch(url, { credentials: "include", }).then((resp) => resp.json()); }; export const ONLINE_COUNT_API = serverUrl + "/api/races/online"; export const fetchOnlineCount = async () => { return fetch(ONLINE_COUNT_API, { credentials: "include", }).then((resp) => resp.json()); }; ================================================ FILE: packages/webapp-next/common/api/types.ts ================================================ import { GetServerSidePropsContext, PreviewData } from "next"; import { ParsedUrlQuery } from "querystring"; export type ServerSideContext = GetServerSidePropsContext< ParsedUrlQuery, PreviewData >; ================================================ FILE: packages/webapp-next/common/api/user.ts ================================================ import { useEffect, useState } from "react"; import { User } from "../state/user-store"; import { getExperimentalServerUrl } from "../utils/getServerUrl"; import { ServerSideContext } from "./types"; const USER_API = "/api/user"; const withCookie = (ctx?: ServerSideContext) => { const cookie = ctx?.req?.headers?.cookie; return cookie ? { cookie } : undefined; }; const withSetHeaders = (resp: Response, ctx?: ServerSideContext) => { const setCookie = resp.headers.get("set-cookie"); if (ctx && setCookie) { ctx.res.setHeader("set-cookie", setCookie); } return resp; }; export const fetchUser = async (context?: ServerSideContext) => { const serverUrl = getExperimentalServerUrl(); const url = serverUrl + USER_API; return fetch(url, { credentials: "include", headers: withCookie(context), }).then((resp) => withSetHeaders(resp, context).json()); }; export const fetchUser2 = async () => { const serverUrl = getExperimentalServerUrl(); const url = serverUrl + USER_API; return fetch(url, { credentials: "include", }).then((resp) => resp.json()); }; export const useUser = () => { const [user, setUser] = useState(null); useEffect(() => { fetchUser2().then((u) => setUser(u)); }, []); return user; }; ================================================ FILE: packages/webapp-next/common/components/Avatar.tsx ================================================ import Image from "next/image"; import { ProfileIcon } from "../../assets/icons"; interface AvatarProps { avatarUrl?: string; username: string; } export const Avatar: React.FC = ({ avatarUrl, username }) => { return (
{username} {avatarUrl ? ( {username} ) : ( )}
); }; ================================================ FILE: packages/webapp-next/common/components/BattleMatcher.tsx ================================================ import { useState } from "react"; import useSWR from "swr"; import { InfoIcon } from "../../assets/icons/InfoIcon"; import Modal from "./modals/Modal"; import { OnlineIcon } from "../../assets/icons/OnlineIcon"; import { UserGroupIcon } from "../../assets/icons/UserGroupIcon"; import { ToggleSelector } from "../../modules/play2/components/RaceSettings"; import { useGameStore, useIsOwner } from "../../modules/play2/state/game-store"; import { closeModals, openPublicRacesModal, toggleRaceIsPublic, useSettingsStore, } from "../../modules/play2/state/settings-store"; import { ONLINE_COUNT_API } from "../api/races"; import { getExperimentalServerUrl } from "../utils/getServerUrl"; import { Overlay } from "./Overlay"; import ModalCloseButton from "./buttons/ModalCloseButton"; export const BattleMatcher: React.FC = () => { const [isOpen, setIsOpen] = useState(false); const openModal = () => setIsOpen(true); const closeModal = () => setIsOpen(false); return (
{isOpen && }
); }; interface BatteListItemProps { race: any; closeModal: () => void; } const BatteListItem: React.FC = ({ race, closeModal }) => { const { ownerName, memberCount } = race; const game = useGameStore((state) => state.game); const myRaceID = useGameStore((state) => state.id); const isMyRace = myRaceID === race.id; const joinRace = () => { // TODO: if no game -> we should forward to page with ?id=race.id closeModal(); game?.join(race.id); }; return ( ); }; interface BattleMatcherModalProps { closeModal: () => void; } export const PlayingNow = () => { const { data } = useSWR(ONLINE_COUNT_API, (...args) => fetch(...args).then((res) => res.json()) ); const isPublic = useSettingsStore((s) => s.raceIsPublic); const isOpen = useSettingsStore((s) => s.publicRacesModalIsOpen); const isOwner = useIsOwner(); return ( <> {isOpen && (

Public races

)} ); }; const BattleMatcherModal = ({ closeModal }: BattleMatcherModalProps) => { return ( ); }; export const BattleMatcherContainer = ({ closeModal, }: { closeModal: () => void; }) => { const baseUrl = getExperimentalServerUrl(); const { data } = useSWR( `${baseUrl}/api/races`, (...args) => fetch(...args).then((res) => res.json()), { refreshInterval: 10000 } ); const availableRaces = data; return (
{availableRaces && availableRaces.length > 0 && (
{availableRaces.map((race: any, i: number) => ( ))}
)}
); }; ================================================ FILE: packages/webapp-next/common/components/Button.tsx ================================================ import React, { ButtonHTMLAttributes } from "react"; type ButtonColor = "primary" | "secondary" | "invisible"; interface ButtonProps extends ButtonHTMLAttributes { color: ButtonColor; leftIcon?: React.ReactElement; rightIcon?: React.ReactElement; text?: string; size?: "sm" | "md" | "lg"; } const Button = ({ color, disabled, onClick, leftIcon, rightIcon, text, title, size = "md", }: ButtonProps) => { const colorStyles = getColorStyles(color); const disabledStyle = disabled ? "cursor-not-allowed opacity-80" : "cursor-pointer"; const buttonSize = () => { switch (size) { case "lg": return "text-xl px-12 py-2"; case "md": return "text-base py-2 px-4"; case "sm": return "text-base py-1 px-2"; } }; return ( ); }; function getColorStyles(color: ButtonColor) { if (color === "invisible") { return "off-white border-none"; } const sharedStyle = "flex items-center text-gray-900 border-gray-200 border rounded"; const style = color === "primary" ? `bg-off-white` : `bg-purple-400 hover:bg-purple-300`; return `${sharedStyle} ${style}`; } export default Button; ================================================ FILE: packages/webapp-next/common/components/Footer/YoutubeLink.tsx ================================================ import getConfig from "next/config"; import { useEffect, useState } from "react"; import { YoutubeLogo } from "../../../assets/icons/YoutubeLogo"; export const useHasClicked = (key: string, value: string): boolean => { const [hasClicked, setHasClicked] = useState(true); useEffect(() => { if (typeof window !== "undefined") { const storedValue = localStorage.getItem(key); setHasClicked(storedValue === value); } }, [key, value]); return hasClicked; }; const YOUTUBE_LINK_STORAGE_KEY = "youtube-link"; export const YoutubeLink = () => { const [hasClickedNow, setHasClickedNow] = useState(false); const youtubeLink = "https://www.youtube.com/watch?v=pNsJS5F-2yg"; const hasClickedPreviously = useHasClicked( YOUTUBE_LINK_STORAGE_KEY, youtubeLink ); const color = hasClickedPreviously || hasClickedNow ? "text-faded-gray" : "text-red-500"; return ( { localStorage.setItem(YOUTUBE_LINK_STORAGE_KEY, youtubeLink); setHasClickedNow(true); }} onMouseDown={(event: any) => { if (event.button === 1) { localStorage.setItem(YOUTUBE_LINK_STORAGE_KEY, youtubeLink); setHasClickedNow(true); } }} >
watch
); }; ================================================ FILE: packages/webapp-next/common/components/Footer.tsx ================================================ import { faCode } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { AnimatePresence, motion } from "framer-motion"; import { useEffect, useState } from "react"; import { DiscordLogo, GithubLogo, TwitchLogo } from "../../assets/icons"; import { getStargazersCount } from "../github/stargazers"; import { useIsPlaying } from "../hooks/useIsPlaying"; import { PlayingNow } from "./BattleMatcher"; import { YoutubeLink } from "./Footer/YoutubeLink"; function useStargazersCount() { const [count, setCount] = useState(0); useEffect(() => { getStargazersCount().then((stargazersCount) => { setCount(stargazersCount); }); }, []); return count; } export function KeybindInfo() { return (
); } export function Footer() { const isPlaying = useIsPlaying(); const stargazersCount = useStargazersCount(); return ( ); } ================================================ FILE: packages/webapp-next/common/components/Layout.tsx ================================================ import { Footer } from "./Footer"; import { navbarFactory } from "./NewNavbar"; interface LayoutProps { children: JSX.Element; } interface ContainerProps { children: JSX.Element; centered: boolean; } export function Container({ children, centered }: ContainerProps) { return (
{children}
); } export function Layout({ children }: LayoutProps) { const Navbar = navbarFactory(); return ( <> {children} <>