├── .dockerignore ├── .eslintrc.cjs ├── .github ├── assets │ └── wavebreaker_icon.png └── workflows │ ├── docker-image.yml │ └── eslint.yml ├── .gitignore ├── .yarnrc.yml ├── @types ├── authPlugin.d.ts └── global.d.ts ├── Dockerfile ├── README.md ├── RadioSongs └── .put_cgr_files_here ├── config ├── wavebreaker_config.json.example └── wavebreaker_radio_entries.json.example ├── index.ts ├── package.json ├── prisma ├── migrations │ ├── 20230520232916_init │ │ └── migration.sql │ ├── 20230522142147_scoreplaycount │ │ └── migration.sql │ ├── 20230523102606_score_unique_compound │ │ └── migration.sql │ ├── 20230528140741_smallandmediumavatars │ │ └── migration.sql │ ├── 20230528212100_musicbrainz_optionalavatars │ │ └── migration.sql │ ├── 20230529111102_addcascades │ │ └── migration.sql │ ├── 20230530161356_musicbrainz_length │ │ └── migration.sql │ ├── 20230601143722_smallcoverurl │ │ └── migration.sql │ ├── 20230604095842_enabletrgm │ │ └── migration.sql │ ├── 20230608120708_addmistaglock │ │ └── migration.sql │ ├── 20230610204335_addgamemodetags │ │ └── migration.sql │ ├── 20230610204744_uniquesongs │ │ └── migration.sql │ ├── 20230611112343_emptytaglistdefault │ │ └── migration.sql │ ├── 20230830151350_add_skill_points │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── routes ├── api │ ├── README.md │ ├── auth.ts │ ├── rankings.ts │ ├── scores.ts │ ├── server.ts │ ├── shouts.ts │ ├── songs.ts │ └── users.ts └── as1 │ ├── README.md │ ├── accounts.ts │ ├── gameplay.ts │ ├── information.ts │ └── radio.ts ├── scripts ├── cleanSongs.ts ├── coverFixup.ts ├── gameplayTagFixup.ts ├── initLeaderboard.ts ├── makeUserAdmin.ts ├── markMistag.ts ├── refreshMBMetadata.ts └── tagSongByMBID.ts ├── tsconfig.json ├── util ├── authPlugin.ts ├── db.ts ├── discord.ts ├── gamemodeTags.ts ├── musicbrainz.ts ├── rankings.ts ├── schemaTypes.ts └── steam.ts └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | migrations 4 | wavebreaker*.json 5 | .env.development 6 | *.pem 7 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 4 | parser: '@typescript-eslint/parser', 5 | plugins: ['@typescript-eslint'], 6 | ignorePatterns: ["*.js"], 7 | root: true, 8 | }; -------------------------------------------------------------------------------- /.github/assets/wavebreaker_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AudiosurfResearch/Wavebreaker/8ea06c6f4607217be6a5096723be7f7eb5b8bfb1/.github/assets/wavebreaker_icon.png -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # GitHub recommends pinning actions to a commit SHA. 7 | # To get a newer version, you will need to update the SHA. 8 | # You can also reference a tag or branch, but the action may change without warning. 9 | 10 | name: Publish Docker image 11 | 12 | on: 13 | push: 14 | branches: [ "master" ] 15 | 16 | jobs: 17 | push_to_registry: 18 | name: Push Docker image to Docker Hub 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Check out the repo 22 | uses: actions/checkout@v3 23 | 24 | - name: Log in to Docker Hub 25 | uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a 26 | with: 27 | username: ${{ secrets.DOCKER_USERNAME }} 28 | password: ${{ secrets.DOCKER_PASSWORD }} 29 | 30 | - name: Extract metadata (tags, labels) for Docker 31 | id: meta 32 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 33 | with: 34 | images: audiosurfresearch/wavebreaker 35 | 36 | - name: Build and push Docker image 37 | uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 38 | with: 39 | context: . 40 | file: ./Dockerfile 41 | push: true 42 | tags: ${{ steps.meta.outputs.tags }} 43 | labels: ${{ steps.meta.outputs.labels }} 44 | -------------------------------------------------------------------------------- /.github/workflows/eslint.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # ESLint is a tool for identifying and reporting on patterns 6 | # found in ECMAScript/JavaScript code. 7 | # More details at https://github.com/eslint/eslint 8 | # and https://eslint.org 9 | 10 | name: ESLint 11 | 12 | on: 13 | push: 14 | branches: [ "master" ] 15 | pull_request: 16 | # The branches below must be a subset of the branches above 17 | branches: [ "master" ] 18 | schedule: 19 | - cron: '28 17 * * 0' 20 | 21 | jobs: 22 | eslint: 23 | name: Run eslint scanning 24 | runs-on: ubuntu-latest 25 | permissions: 26 | contents: read 27 | security-events: write 28 | actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v3 32 | 33 | - name: Install ESLint 34 | run: | 35 | npm install eslint@8.10.0 36 | npm install @microsoft/eslint-formatter-sarif@2.1.7 37 | 38 | - name: Run ESLint 39 | run: npx eslint . 40 | --config .eslintrc.cjs 41 | --ext .js,.jsx,.ts,.tsx 42 | --format @microsoft/eslint-formatter-sarif 43 | --output-file eslint-results.sarif 44 | continue-on-error: true 45 | 46 | - name: Upload analysis results to GitHub 47 | uses: github/codeql-action/upload-sarif@v2 48 | with: 49 | sarif_file: eslint-results.sarif 50 | wait-for-processing: true 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .DS_Store/ 3 | node_modules/ 4 | .tmp/ 5 | public/dist/ 6 | !public/dist/.gitkeep 7 | cert.pem 8 | key.pem 9 | *.js 10 | .yarn 11 | 12 | # Database stuff 13 | *.db 14 | *.db-journal 15 | #prisma/migrations/ 16 | *.sqlite 17 | 18 | #.env files 19 | *.env* 20 | 21 | # Wavebreaker-specific 22 | wavebreaker_radio_entries.json 23 | wavebreaker_config.json 24 | 25 | # Ignore Quest3D channel groups 26 | *.cgr -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules -------------------------------------------------------------------------------- /@types/authPlugin.d.ts: -------------------------------------------------------------------------------- 1 | import * as fastify from "fastify"; 2 | import * as http from "http"; 3 | 4 | declare module "fastify" { 5 | export interface FastifyInstance< 6 | HttpServer = http.Server, 7 | HttpRequest = http.IncomingMessage, 8 | HttpResponse = http.ServerResponse 9 | > { 10 | authenticate(request: FastifyRequest, reply: FastifyReply): any; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /@types/global.d.ts: -------------------------------------------------------------------------------- 1 | import { User } from "@prisma/client"; 2 | 3 | /* eslint-disable no-var */ 4 | //var is needed to declare a global variable 5 | declare global { 6 | var __basedir: string; 7 | } 8 | 9 | //Give request.user the proper type 10 | declare module "@fastify/jwt" { 11 | interface FastifyJWT { 12 | payload: User; 13 | user: User; 14 | } 15 | } 16 | 17 | type RadioEntry = { 18 | wavebreakerId: number; 19 | title: string; 20 | artist: string; 21 | externalUrl: string; 22 | cgrFileUrl: string; 23 | }; 24 | 25 | export {RadioEntry}; 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:21-alpine 2 | 3 | WORKDIR /usr/src/wavebreaker 4 | 5 | # Install app dependencies 6 | # A wildcard is used to ensure both package.json AND package-lock.json are copied 7 | # where available (npm@5+) 8 | COPY package*.json ./ 9 | COPY yarn.lock ./ 10 | COPY .yarnrc.yml ./ 11 | COPY prisma ./prisma/ 12 | 13 | RUN yarn install 14 | 15 | 16 | # Generate Prisma client for DB stuff 17 | RUN npx prisma generate 18 | 19 | # Bundle app source 20 | COPY . . 21 | 22 | # RUN npm run build 23 | 24 | EXPOSE 5000 25 | CMD [ "npm", "start" ] 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | Wavebreaker logo 4 | 5 | 6 | Wavebreaker, an open-source reimplementation of Audiosurf's online services. 7 | 8 |
9 | 10 | # 11 | 12 | > [!IMPORTANT] 13 | > If you're only interested in playing on Wavebreaker, there is a main public instance of Wavebreaker and its frontend running at https://wavebreaker.arcadian.garden/. Also see the [install guide](https://wavebreaker.arcadian.garden/installguide) for the client mod. 14 | 15 | ### Info 16 | 17 | Backend for Audiosurf server replacement written in TypeScript using Node.js, Fastify and Prisma. Somewhat WIP. 18 | The aim is to replace all of the official server's features. 19 | 20 | > [!NOTE] 21 | > This repo is only for the backend code. Wavebreaker is made of **four parts**.\ 22 | > There's the backend (which you're looking at right now), the [frontend](https://github.com/AudiosurfResearch/Wavebreaker-Frontend) (website to view scores, add rivals, etc), the [client](https://github.com/AudiosurfResearch/Wavebreaker-Hook) (which makes the game connect to Wavebreaker) and the [installer](https://github.com/AudiosurfResearch/Wavebreaker-Installer) as a user-friendly way to set up the client. 23 | 24 | ### Features 25 | 26 | At the moment, Wavebreaker already implements nearly all of the original server's features. This includes: 27 | 28 | - Leaderboards 29 | - Comments/Shouts on songs 30 | - Account system through Steam auth 31 | - Steam friend auto-sync 32 | - Rival/Challenger approach for competing with others instead of just mutual "friends" 33 | - Automatic lookup of **fancy metadata** with proper capitalization and cover art, thanks to integration with the [MusicBrainz](https://musicbrainz.org) API 34 | - **Custom Audiosurf Radio songs[^1]** 35 | 36 | [^1]: Requires RadioBrowser.cgr from AS1 version with manifest ID 2426309927836492358, included with the Wavebreaker client package. Actually making custom Audiosurf Radio songs is really finicky and is done using [Quest3DTamperer](https://github.com/AudiosurfResearch/Quest3DTamperer). 37 | 38 | Currently missing: 39 | 40 | - Dethrone notifications 41 | - Achievements 42 | 43 | ### Config 44 | 45 | The server uses two config files at the root of the project: `wavebreaker_config.json` and `wavebreaker_radio_entries.json`. 46 | Look at the example files in there, they should be self-explanatory. 47 | 48 | You also need to set the DATABASE_URL environment variable to point to a PostgreSQL server. 49 | -------------------------------------------------------------------------------- /RadioSongs/.put_cgr_files_here: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AudiosurfResearch/Wavebreaker/8ea06c6f4607217be6a5096723be7f7eb5b8bfb1/RadioSongs/.put_cgr_files_here -------------------------------------------------------------------------------- /config/wavebreaker_config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "logger": true, 3 | "port": 5000, 4 | "host": "0.0.0.0", 5 | "reverseProxy": true, 6 | "useHttps": false, 7 | "https": { 8 | "key": "./key.pem", 9 | "cert": "./cert.pem", 10 | "passphrase": "your_passphrase_for_dev_cert" 11 | }, 12 | "steam": { 13 | "apiKey": "steam_api_key_here", 14 | "realm": "http://wavebreakerdev.local", 15 | "returnUrl": "http://wavebreakerdev.local/login/steamreturn" 16 | }, 17 | "token_secret": "yuruyaka_ni_kuzure_kowareteku", 18 | "corsOrigin": "http://wavebreakerdev.local", 19 | "webhook_link": "https://discordapp.com/api/webhooks/your_webhook_link_here" 20 | } -------------------------------------------------------------------------------- /config/wavebreaker_radio_entries.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "availableSongs": [ 3 | { 4 | "wavebreakerId": 1, 5 | "artist": "Wavebreaker", 6 | "title": "Example entry 1", 7 | "cgrFileUrl": "http://yourhostname/as/asradio/Test1.cgr", 8 | "externalUrl": "https://github.com/AudiosurfResearch/Wavebreaker" 9 | }, 10 | { 11 | "wavebreakerId": 2, 12 | "artist": "Wavebreaker", 13 | "title": "Example entry 2", 14 | "cgrFileUrl": "http://somecdn/as/asradio/Test2.cgr", 15 | "externalUrl": "https://github.com/AudiosurfResearch/Wavebreaker" 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | //Fastify and plugins 2 | import Fastify from "fastify"; 3 | import formbody from "@fastify/formbody"; 4 | import fastifyStatic from "@fastify/static"; 5 | import httpsRedirect from "fastify-https-redirect"; 6 | import authPlugin from "./util/authPlugin"; 7 | import cors from "@fastify/cors"; 8 | 9 | //Game routes 10 | import accountsRouter from "./routes/as1/accounts"; 11 | import gameplayRouter from "./routes/as1/gameplay"; 12 | import informationRouter from "./routes/as1/information"; 13 | import radioRouter from "./routes/as1/radio"; 14 | 15 | //API routes 16 | import apiAuthRouter from "./routes/api/auth"; 17 | import apiUsersRouter from "./routes/api/users"; 18 | import apiServerRouter from "./routes/api/server"; 19 | import apiScoresRouter from "./routes/api/scores"; 20 | import apiSongsRouter from "./routes/api/songs"; 21 | import apiShoutsRouter from "./routes/api/shouts"; 22 | import apiRankingsRouter from "./routes/api/rankings"; 23 | 24 | //Miscellaneous 25 | import fs from "fs"; 26 | import path from "path"; 27 | import WavebreakerConfig from "./config/wavebreaker_config.json"; 28 | import { Prisma } from "@prisma/client"; 29 | 30 | globalThis.__basedir = __dirname; //Set global variable for the base directory 31 | 32 | //weird hack to select logger based on environment 33 | const logger = { 34 | ...(process.env.NODE_ENV == "development" && { 35 | logger: { 36 | transport: { 37 | target: "pino-pretty", 38 | options: { 39 | translateTime: "HH:MM:ss Z", 40 | ignore: "pid,hostname", 41 | }, 42 | }, 43 | }, 44 | }), 45 | ...(process.env.NODE_ENV != "development" && { 46 | logger: true, 47 | }), 48 | }; 49 | 50 | const fastify = Fastify({ 51 | trustProxy: WavebreakerConfig.reverseProxy, 52 | ...logger, 53 | //For HTTPS in production, please use nginx or whatever 54 | ...(process.env.NODE_ENV == "development" && 55 | WavebreakerConfig.useHttps && { 56 | https: { 57 | key: fs.readFileSync(WavebreakerConfig.https.key), 58 | cert: fs.readFileSync(WavebreakerConfig.https.cert), 59 | passphrase: WavebreakerConfig.https.passphrase, 60 | }, 61 | }), 62 | }); 63 | 64 | fastify.listen( 65 | { port: WavebreakerConfig.port, host: WavebreakerConfig.host }, 66 | (err) => { 67 | if (err) { 68 | fastify.log.error(err); 69 | process.exit(1); 70 | } 71 | } 72 | ); 73 | 74 | if (process.env.NODE_ENV == "development" && WavebreakerConfig.useHttps) 75 | fastify.register(httpsRedirect); //HTTPS redirect for development, PLEASE use nginx or whatever for this in prod, I *beg* you. 76 | 77 | fastify.register(authPlugin); //Register authentication plugin 78 | fastify.register(formbody); //So we can parse requests that use application/x-www-form-urlencoded 79 | fastify.register(fastifyStatic, { 80 | root: path.join(__dirname, "RadioSongs"), 81 | prefix: "/as/asradio/", 82 | }); 83 | fastify.register(cors, { 84 | origin: WavebreakerConfig.corsOrigin, 85 | methods: ["GET", "POST"], 86 | allowedHeaders: ["Content-Type", "Authorization"], 87 | exposedHeaders: ["Content-Type", "Authorization"], 88 | credentials: true, 89 | optionsSuccessStatus: 204, 90 | }); 91 | 92 | fastify.setErrorHandler(function (error, request, reply) { 93 | // Log error 94 | this.log.error(error); 95 | if ( 96 | error instanceof Prisma.PrismaClientKnownRequestError && 97 | error.code === "P2025" 98 | ) { 99 | // Prisma: not found 100 | reply.status(404).send({ error: "Not found" }); 101 | } 102 | 103 | if (error.statusCode === 401) { 104 | reply.code(401).send({ error: "Unauthorized" }); 105 | return; 106 | } 107 | 108 | if (error.code === "FST_ERR_VALIDATION") { 109 | // Fastify validation error 110 | reply.status(400).send({ error: "Request validation failed." }); 111 | } 112 | 113 | reply.status(500).send({ error: "An error has occurred." }); 114 | }); 115 | 116 | //Register game endpoints 117 | fastify.register(accountsRouter); 118 | fastify.register(gameplayRouter); 119 | fastify.register(informationRouter); 120 | fastify.register(radioRouter); 121 | 122 | //Wavebreaker API 123 | fastify.register(apiAuthRouter); 124 | fastify.register(apiUsersRouter); 125 | fastify.register(apiServerRouter); 126 | fastify.register(apiScoresRouter); 127 | fastify.register(apiSongsRouter); 128 | fastify.register(apiShoutsRouter); 129 | fastify.register(apiRankingsRouter); 130 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wavebreaker", 3 | "version": "1.0.0", 4 | "description": "Backend of Wavebreaker, an open-source reimplementation of Audiosurf's game server", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "tsc -p tsconfig.json", 9 | "start": "npm run build && node index.js", 10 | "dev": "npm run build && dotenv -e .env.development -- node index.js", 11 | "migrate:postgres": "dotenv -e .env.development -- npx prisma migrate deploy" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/AudiosurfResearch/Wavebreaker.git" 16 | }, 17 | "keywords": [ 18 | "audiosurf", 19 | "server", 20 | "custom" 21 | ], 22 | "author": "AudiosurfResearch", 23 | "license": "AGPL-3.0", 24 | "bugs": { 25 | "url": "https://github.com/AudiosurfResearch/Wavebreaker/issues" 26 | }, 27 | "homepage": "https://github.com/AudiosurfResearch/Wavebreaker#readme", 28 | "devDependencies": { 29 | "@types/node": "^20.5.9", 30 | "@types/node-steam-openid": "^1.0.3", 31 | "@types/steamapi": "^2.2.2", 32 | "@types/steamid": "^2.0.1", 33 | "@types/xml2js": "^0.4.11", 34 | "@typescript-eslint/eslint-plugin": "^5.59.5", 35 | "@typescript-eslint/parser": "^5.59.5", 36 | "eslint": "^8.48.0", 37 | "pino-pretty": "^10.2.0", 38 | "prisma": "^5.2.0", 39 | "ts-node": "^10.9.1", 40 | "typescript": "^5.2.2" 41 | }, 42 | "dependencies": { 43 | "@fastify/cookie": "^8.3.0", 44 | "@fastify/cors": "^8.3.0", 45 | "@fastify/formbody": "^7.0.1", 46 | "@fastify/jwt": "^6.7.1", 47 | "@fastify/static": "^6.10.1", 48 | "@prisma/client": "^5.2.0", 49 | "@sinclair/typebox": "^0.28.13", 50 | "discord.js": "^14.13.0", 51 | "fastify": "^4.22.0", 52 | "fastify-https-redirect": "^1.0.4", 53 | "ioredis": "^5.3.2", 54 | "musicbrainz-api": "^0.10.3", 55 | "node-steam-openid": "^2.0.0", 56 | "steamapi": "^2.4.2", 57 | "steamid": "^2.0.0", 58 | "xml2js": "^0.6.2" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /prisma/migrations/20230520232916_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" SERIAL NOT NULL, 4 | "username" TEXT NOT NULL, 5 | "steamid64" TEXT NOT NULL, 6 | "steamid32" INTEGER NOT NULL, 7 | "locationid" INTEGER NOT NULL DEFAULT 1, 8 | "accountType" INTEGER NOT NULL DEFAULT 1, 9 | "joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 10 | "avatarUrl" TEXT NOT NULL, 11 | 12 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 13 | ); 14 | 15 | -- CreateTable 16 | CREATE TABLE "Song" ( 17 | "id" SERIAL NOT NULL, 18 | "title" TEXT NOT NULL, 19 | "artist" TEXT NOT NULL, 20 | 21 | CONSTRAINT "Song_pkey" PRIMARY KEY ("id") 22 | ); 23 | 24 | -- CreateTable 25 | CREATE TABLE "Score" ( 26 | "id" SERIAL NOT NULL, 27 | "userId" INTEGER NOT NULL, 28 | "leagueId" INTEGER NOT NULL, 29 | "trackShape" TEXT NOT NULL, 30 | "xstats" TEXT NOT NULL, 31 | "density" INTEGER NOT NULL, 32 | "vehicleId" INTEGER NOT NULL, 33 | "score" INTEGER NOT NULL, 34 | "rideTime" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 35 | "feats" TEXT NOT NULL, 36 | "songLength" INTEGER NOT NULL, 37 | "goldThreshold" INTEGER NOT NULL, 38 | "iss" INTEGER NOT NULL, 39 | "isj" INTEGER NOT NULL, 40 | "songId" INTEGER NOT NULL, 41 | 42 | CONSTRAINT "Score_pkey" PRIMARY KEY ("id") 43 | ); 44 | 45 | -- CreateTable 46 | CREATE TABLE "Shout" ( 47 | "id" SERIAL NOT NULL, 48 | "authorId" INTEGER NOT NULL, 49 | "songId" INTEGER NOT NULL, 50 | "content" TEXT NOT NULL, 51 | "timeCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 52 | 53 | CONSTRAINT "Shout_pkey" PRIMARY KEY ("id") 54 | ); 55 | 56 | -- CreateTable 57 | CREATE TABLE "_friends" ( 58 | "A" INTEGER NOT NULL, 59 | "B" INTEGER NOT NULL 60 | ); 61 | 62 | -- CreateIndex 63 | CREATE UNIQUE INDEX "User_steamid64_key" ON "User"("steamid64"); 64 | 65 | -- CreateIndex 66 | CREATE UNIQUE INDEX "User_steamid32_key" ON "User"("steamid32"); 67 | 68 | -- CreateIndex 69 | CREATE UNIQUE INDEX "_friends_AB_unique" ON "_friends"("A", "B"); 70 | 71 | -- CreateIndex 72 | CREATE INDEX "_friends_B_index" ON "_friends"("B"); 73 | 74 | -- AddForeignKey 75 | ALTER TABLE "Score" ADD CONSTRAINT "Score_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 76 | 77 | -- AddForeignKey 78 | ALTER TABLE "Score" ADD CONSTRAINT "Score_songId_fkey" FOREIGN KEY ("songId") REFERENCES "Song"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 79 | 80 | -- AddForeignKey 81 | ALTER TABLE "Shout" ADD CONSTRAINT "Shout_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 82 | 83 | -- AddForeignKey 84 | ALTER TABLE "Shout" ADD CONSTRAINT "Shout_songId_fkey" FOREIGN KEY ("songId") REFERENCES "Song"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 85 | 86 | -- AddForeignKey 87 | ALTER TABLE "_friends" ADD CONSTRAINT "_friends_A_fkey" FOREIGN KEY ("A") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 88 | 89 | -- AddForeignKey 90 | ALTER TABLE "_friends" ADD CONSTRAINT "_friends_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 91 | -------------------------------------------------------------------------------- /prisma/migrations/20230522142147_scoreplaycount/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Score" ADD COLUMN "playCount" INTEGER NOT NULL DEFAULT 1; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230523102606_score_unique_compound/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[userId,leagueId,songId]` on the table `Score` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- CreateIndex 8 | CREATE UNIQUE INDEX "Score_userId_leagueId_songId_key" ON "Score"("userId", "leagueId", "songId"); 9 | -------------------------------------------------------------------------------- /prisma/migrations/20230528140741_smallandmediumavatars/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "avatarUrlMedium" TEXT NOT NULL DEFAULT '', 3 | ADD COLUMN "avatarUrlSmall" TEXT NOT NULL DEFAULT ''; 4 | -------------------------------------------------------------------------------- /prisma/migrations/20230528212100_musicbrainz_optionalavatars/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Song" ADD COLUMN "coverUrl" TEXT, 3 | ADD COLUMN "mbid" TEXT, 4 | ADD COLUMN "musicbrainzArtist" TEXT, 5 | ADD COLUMN "musicbrainzTitle" TEXT; 6 | 7 | -- AlterTable 8 | ALTER TABLE "User" ALTER COLUMN "avatarUrl" DROP NOT NULL, 9 | ALTER COLUMN "avatarUrlMedium" DROP NOT NULL, 10 | ALTER COLUMN "avatarUrlMedium" DROP DEFAULT, 11 | ALTER COLUMN "avatarUrlSmall" DROP NOT NULL, 12 | ALTER COLUMN "avatarUrlSmall" DROP DEFAULT; 13 | -------------------------------------------------------------------------------- /prisma/migrations/20230529111102_addcascades/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE "Score" DROP CONSTRAINT "Score_songId_fkey"; 3 | 4 | -- DropForeignKey 5 | ALTER TABLE "Score" DROP CONSTRAINT "Score_userId_fkey"; 6 | 7 | -- DropForeignKey 8 | ALTER TABLE "Shout" DROP CONSTRAINT "Shout_authorId_fkey"; 9 | 10 | -- DropForeignKey 11 | ALTER TABLE "Shout" DROP CONSTRAINT "Shout_songId_fkey"; 12 | 13 | -- AddForeignKey 14 | ALTER TABLE "Score" ADD CONSTRAINT "Score_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 15 | 16 | -- AddForeignKey 17 | ALTER TABLE "Score" ADD CONSTRAINT "Score_songId_fkey" FOREIGN KEY ("songId") REFERENCES "Song"("id") ON DELETE CASCADE ON UPDATE CASCADE; 18 | 19 | -- AddForeignKey 20 | ALTER TABLE "Shout" ADD CONSTRAINT "Shout_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 21 | 22 | -- AddForeignKey 23 | ALTER TABLE "Shout" ADD CONSTRAINT "Shout_songId_fkey" FOREIGN KEY ("songId") REFERENCES "Song"("id") ON DELETE CASCADE ON UPDATE CASCADE; 24 | -------------------------------------------------------------------------------- /prisma/migrations/20230530161356_musicbrainz_length/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Song" ADD COLUMN "musicbrainzLength" INTEGER; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230601143722_smallcoverurl/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Song" ADD COLUMN "smallCoverUrl" TEXT; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230604095842_enabletrgm/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateExtension 2 | CREATE EXTENSION IF NOT EXISTS "pg_trgm"; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230608120708_addmistaglock/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Song" ADD COLUMN "mistagLock" BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230610204335_addgamemodetags/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Song" ADD COLUMN "tags" TEXT[]; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230610204744_uniquesongs/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[title,artist,tags]` on the table `Song` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- CreateIndex 8 | CREATE UNIQUE INDEX "Song_title_artist_tags_key" ON "Song"("title", "artist", "tags"); 9 | -------------------------------------------------------------------------------- /prisma/migrations/20230611112343_emptytaglistdefault/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Song" ALTER COLUMN "tags" SET DEFAULT ARRAY[]::TEXT[]; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230830151350_add_skill_points/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `skillPoints` to the `Score` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Score" ADD COLUMN "skillPoints" INTEGER NOT NULL DEFAULT 0; 9 | UPDATE "Score" SET "skillPoints" = ROUND(("score"::float / "goldThreshold") * (("leagueId" + 1) * 100)); -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | previewFeatures = ["postgresqlExtensions", "fullTextSearch"] 7 | } 8 | 9 | datasource db { 10 | provider = "postgres" 11 | url = env("DATABASE_URL") 12 | extensions = [pg_trgm] 13 | } 14 | 15 | model User { 16 | id Int @id @default(autoincrement()) 17 | username String 18 | steamid64 String @unique 19 | steamid32 Int @unique 20 | locationid Int @default(1) 21 | accountType Int @default(1) // 1 = regular, 2 = Moderator, 3 = Wavebreaker Team 22 | scores Score[] 23 | shouts Shout[] 24 | joinedAt DateTime @default(now()) 25 | avatarUrl String? 26 | avatarUrlMedium String? 27 | avatarUrlSmall String? 28 | 29 | //pray this doesn't implode 30 | rivals User[] @relation("friends") 31 | challengers User[] @relation("friends") 32 | } 33 | 34 | model Song { 35 | id Int @id @default(autoincrement()) 36 | title String 37 | artist String 38 | tags String[] @default([]) 39 | mbid String? 40 | musicbrainzTitle String? 41 | musicbrainzArtist String? 42 | musicbrainzLength Int? 43 | mistagLock Boolean @default(false) 44 | coverUrl String? 45 | smallCoverUrl String? 46 | scores Score[] 47 | shouts Shout[] 48 | 49 | @@unique([title, artist, tags]) 50 | } 51 | 52 | model Score { 53 | id Int @id @default(autoincrement()) 54 | userId Int 55 | player User @relation(fields: [userId], references: [id], onDelete: Cascade) 56 | leagueId Int 57 | trackShape String 58 | xstats String 59 | density Int 60 | vehicleId Int 61 | score Int 62 | skillPoints Int 63 | rideTime DateTime @default(now()) //NOTE: When sending to the game, get it as UNIX time, divide by 1000 and Math.floor it 64 | feats String 65 | songLength Int 66 | goldThreshold Int 67 | iss Int 68 | isj Int 69 | playCount Int @default(1) //Total play count on this difficulty 70 | song Song @relation(fields: [songId], references: [id], onDelete: Cascade, onUpdate: Cascade) 71 | songId Int 72 | 73 | @@unique([userId, leagueId, songId]) 74 | } 75 | 76 | model Shout { 77 | id Int @id @default(autoincrement()) 78 | authorId Int 79 | author User @relation(fields: [authorId], references: [id], onDelete: Cascade) 80 | songId Int 81 | song Song @relation(fields: [songId], references: [id], onDelete: Cascade) 82 | content String 83 | timeCreated DateTime @default(now()) 84 | } 85 | -------------------------------------------------------------------------------- /routes/api/README.md: -------------------------------------------------------------------------------- 1 | # /api/ 2 | 3 | This API is intended for any frontends (or other clients who want to get data from the server). -------------------------------------------------------------------------------- /routes/api/auth.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from "fastify"; 2 | import { Prisma, User } from "@prisma/client"; 3 | import { prisma } from "../../util/db"; 4 | import SteamAuth from "node-steam-openid"; 5 | import WavebreakerConfig from "../../config/wavebreaker_config.json"; 6 | 7 | const steam = new SteamAuth({ 8 | realm: WavebreakerConfig.steam.realm, 9 | returnUrl: WavebreakerConfig.steam.returnUrl, 10 | apiKey: WavebreakerConfig.steam.apiKey, 11 | }); 12 | 13 | export default async function routes(fastify: FastifyInstance) { 14 | fastify.get("/api/auth/steam", async (request, reply) => { 15 | fastify.log.info("Redirecting to Steam login"); 16 | const redirectUrl = await steam.getRedirectUrl(); 17 | return reply.redirect(redirectUrl); 18 | }); 19 | 20 | fastify.get( 21 | "/api/auth/verifyToken", 22 | { onRequest: fastify.authenticate }, 23 | async (request, reply) => { 24 | try { 25 | const user: User = await prisma.user.findUniqueOrThrow({ 26 | where: { 27 | id: request.user.id, 28 | }, 29 | }); 30 | return user; 31 | } catch (e) { 32 | if ( 33 | e instanceof Prisma.PrismaClientKnownRequestError && 34 | e.code === "P2025" 35 | ) 36 | reply.status(404).send({ error: "User not found" }); 37 | } 38 | } 39 | ); 40 | 41 | fastify.get("/api/auth/steam/return", async (request, reply) => { 42 | const steamUser = await steam.authenticate(request); 43 | try { 44 | const user: User = await prisma.user.findUniqueOrThrow({ 45 | where: { 46 | steamid64: steamUser.steamid, 47 | }, 48 | }); 49 | 50 | fastify.log.info("Steam login request for user %d", user.id); 51 | const token = fastify.jwt.sign(user); 52 | return { token: token }; 53 | } catch (e) { 54 | if ( 55 | e instanceof Prisma.PrismaClientKnownRequestError && 56 | e.code === "P2025" 57 | ) { 58 | reply.status(404).send({ 59 | error: 60 | "Not registered. Please play Audiosurf on this server to register automatically.", 61 | }); 62 | } else { 63 | throw e; 64 | } 65 | } 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /routes/api/rankings.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from "fastify"; 2 | import { Static, Type } from "@sinclair/typebox"; 3 | import { StringEnum } from "../../util/schemaTypes"; 4 | import { getLeaderboard, getPopularSongs } from "../../util/rankings"; 5 | import { prisma } from "../../util/db"; 6 | 7 | const getSongRankingsQuerySchema = Type.Object( 8 | { 9 | sort: Type.Optional( 10 | Type.Union([StringEnum(["asc", "desc"])], { 11 | default: "desc", 12 | }) 13 | ), 14 | page: Type.Number({ default: 1, minimum: 1 }), 15 | pageSize: Type.Number({ default: 10, minimum: 1, maximum: 100 }), 16 | }, 17 | { additionalProperties: false } 18 | ); 19 | 20 | const getUserRankingsQuerySchema = Type.Object( 21 | { 22 | page: Type.Number({ default: 1, minimum: 1 }), 23 | pageSize: Type.Number({ default: 10, minimum: 1, maximum: 100 }), 24 | }, 25 | { additionalProperties: false } 26 | ); 27 | 28 | type GetSongRankingsQuery = Static; 29 | type GetUserRankingsQuery = Static; 30 | 31 | export default async function routes(fastify: FastifyInstance) { 32 | fastify.get<{ Querystring: GetSongRankingsQuery }>( 33 | "/api/rankings/songs", 34 | { schema: { querystring: getSongRankingsQuerySchema } }, 35 | async (request) => { 36 | return { 37 | songs: await getPopularSongs( 38 | request.query.page, 39 | request.query.pageSize, 40 | request.query.sort 41 | ), 42 | totalCount: await prisma.song.count(), 43 | }; 44 | } 45 | ); 46 | 47 | fastify.get<{ Querystring: GetUserRankingsQuery }>( 48 | "/api/rankings/users", 49 | { schema: { querystring: getUserRankingsQuerySchema } }, 50 | async (request) => { 51 | return { 52 | users: await getLeaderboard(request.query.page, request.query.pageSize), 53 | totalCount: await prisma.user.count(), 54 | }; 55 | } 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /routes/api/scores.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from "fastify"; 2 | import { prisma } from "../../util/db"; 3 | import { Static, Type } from "@sinclair/typebox"; 4 | import { StringEnum } from "../../util/schemaTypes"; 5 | 6 | const getScoresQuerySchema = Type.Object( 7 | { 8 | songId: Type.Optional(Type.Number()), 9 | userId: Type.Optional(Type.Number()), 10 | leagueId: Type.Optional(Type.Number({ minimum: 0, maximum: 2 })), 11 | vehicleId: Type.Optional(Type.Number()), 12 | scoreSort: Type.Optional(StringEnum(["asc", "desc"])), 13 | timeSort: Type.Optional( 14 | Type.Union([StringEnum(["asc", "desc"])], { 15 | default: "desc", 16 | }) 17 | ), 18 | includePlayer: Type.Optional(Type.Boolean({ default: false })), 19 | includeSong: Type.Optional(Type.Boolean({ default: true })), 20 | page: Type.Number({ default: 1, minimum: 1 }), 21 | pageSize: Type.Number({ default: 10, minimum: 1, maximum: 100 }), 22 | }, 23 | { additionalProperties: false } 24 | ); 25 | 26 | type GetScoresQuery = Static; 27 | 28 | export default async function routes(fastify: FastifyInstance) { 29 | fastify.get<{ Querystring: GetScoresQuery }>( 30 | "/api/scores/getScores", 31 | { schema: { querystring: getScoresQuerySchema } }, 32 | async (request, reply) => { 33 | const where = { 34 | ...(request.query.songId && { 35 | songId: request.query.songId, 36 | }), 37 | ...(request.query.userId && { 38 | userId: request.query.userId, 39 | }), 40 | ...(request.query.leagueId > -1 && { 41 | leagueId: request.query.leagueId, 42 | }), 43 | ...(request.query.vehicleId && { 44 | vehicleId: request.query.vehicleId, 45 | }), 46 | }; 47 | 48 | const [count, scores] = await prisma.$transaction([ 49 | prisma.score.count({ 50 | where, 51 | }), 52 | prisma.score.findMany({ 53 | skip: (request.query.page - 1) * request.query.pageSize, 54 | take: request.query.pageSize, 55 | where, 56 | orderBy: { 57 | //TODO: Figure out how to make these two mutually exclusive in the schema. 58 | ...(!request.query.scoreSort && 59 | request.query.timeSort && { 60 | rideTime: request.query.timeSort, 61 | }), 62 | ...(request.query.scoreSort && { 63 | score: request.query.scoreSort, 64 | }), 65 | }, 66 | include: { 67 | ...(request.query.includeSong && { 68 | song: true, 69 | }), 70 | ...(request.query.includePlayer && { 71 | player: true, 72 | }), 73 | }, 74 | }), 75 | ]); 76 | 77 | if (count === 0) { 78 | reply.status(404).send({ error: "No scores found" }); 79 | } else { 80 | return { 81 | scores: scores, 82 | totalCount: count, 83 | }; 84 | } 85 | } 86 | ); 87 | 88 | fastify.get( 89 | "/api/scores/getRivalActivity", 90 | { 91 | onRequest: fastify.authenticate, 92 | }, 93 | async (request, reply) => { 94 | const user = await prisma.user.findUnique({ 95 | where: { 96 | id: request.user.id, 97 | }, 98 | include: { 99 | rivals: true, 100 | }, 101 | }); 102 | const rivalScores = await prisma.score.findMany({ 103 | where: { 104 | userId: { 105 | in: user.rivals.map((rival) => rival.id), 106 | }, 107 | }, 108 | include: { 109 | song: true, 110 | player: true, 111 | }, 112 | orderBy: { 113 | rideTime: "desc", 114 | }, 115 | take: 10, 116 | }); 117 | if (rivalScores.length === 0) { 118 | reply.code(204); 119 | return; 120 | } else return rivalScores; 121 | } 122 | ); 123 | 124 | fastify.get("/api/scores/getRecentActivity", async (request, reply) => { 125 | const recentScores = await prisma.score.findMany({ 126 | include: { 127 | song: true, 128 | player: true, 129 | }, 130 | orderBy: { 131 | rideTime: "desc", 132 | }, 133 | take: 10, 134 | }); 135 | if (recentScores.length === 0) { 136 | reply.code(204); 137 | return; 138 | } else return recentScores; 139 | }); 140 | } 141 | -------------------------------------------------------------------------------- /routes/api/server.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from "fastify"; 2 | import { prisma } from "../../util/db"; 3 | import { Song } from "@prisma/client"; 4 | import fs from "fs"; 5 | import { RadioEntry } from "../../@types/global"; 6 | 7 | type SongWithExternalUrl = Song & { 8 | externalUrl: string; 9 | }; 10 | 11 | export default async function routes(fastify: FastifyInstance) { 12 | fastify.get("/api/server/getStats", async () => { 13 | const userCount = await prisma.user.count(); 14 | const songCount = await prisma.song.count(); 15 | const scoreCount = await prisma.score.count(); 16 | return { 17 | userCount, 18 | songCount, 19 | scoreCount, 20 | }; 21 | }); 22 | 23 | fastify.get( 24 | "/api/server/getRadioSongs", 25 | { onRequest: fastify.authenticate }, 26 | async () => { 27 | const WavebreakerRadioConfig = JSON.parse( 28 | fs.readFileSync( 29 | globalThis.__basedir + "/config/wavebreaker_radio_entries.json", 30 | "utf-8" 31 | ) 32 | ); 33 | if (WavebreakerRadioConfig.availableSongs.length == 0) 34 | return { songs: [] }; 35 | 36 | const radioEntries: RadioEntry[] = WavebreakerRadioConfig.availableSongs; 37 | const ids = radioEntries.map((entry) => entry.wavebreakerId); 38 | 39 | const songs = await prisma.song.findMany({ 40 | where: { 41 | id: { 42 | in: ids, 43 | }, 44 | }, 45 | }); 46 | const songsWithUrls = songs as SongWithExternalUrl[]; 47 | 48 | //Add externalUrl to song 49 | songsWithUrls.forEach((song) => { 50 | const entry = radioEntries.find( 51 | (entry) => entry.wavebreakerId == song.id 52 | ); 53 | if (entry) song.externalUrl = entry.externalUrl; 54 | }); 55 | 56 | return { 57 | songs: songsWithUrls, 58 | }; 59 | } 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /routes/api/shouts.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from "fastify"; 2 | import { prisma } from "../../util/db"; 3 | import { Static, Type } from "@sinclair/typebox"; 4 | import { StringEnum } from "../../util/schemaTypes"; 5 | 6 | const getSongShoutsQuerySchema = Type.Object( 7 | { 8 | songId: Type.Number(), 9 | authorId: Type.Optional(Type.Number()), 10 | timeSort: Type.Optional( 11 | Type.Union([StringEnum(["asc", "desc"])], { 12 | default: "desc", 13 | }) 14 | ), 15 | includeAuthor: Type.Optional(Type.Boolean({ default: true })), 16 | page: Type.Number({ default: 1, minimum: 1 }), 17 | pageSize: Type.Number({ default: 10, minimum: 1, maximum: 100 }), 18 | }, 19 | { additionalProperties: false } 20 | ); 21 | 22 | const deleteShoutBodySchema = Type.Object( 23 | { 24 | id: Type.Number(), 25 | }, 26 | { additionalProperties: false } 27 | ); 28 | 29 | type GetSongShoutsQuery = Static; 30 | type DeleteShoutBody = Static; 31 | 32 | export default async function routes(fastify: FastifyInstance) { 33 | fastify.get<{ Querystring: GetSongShoutsQuery }>( 34 | "/api/shouts/getSongShouts", 35 | { schema: { querystring: getSongShoutsQuerySchema } }, 36 | async (request, reply) => { 37 | const where = { 38 | ...(request.query.songId && { 39 | songId: request.query.songId, 40 | }), 41 | ...(request.query.authorId && { 42 | userId: request.query.authorId, 43 | }), 44 | }; 45 | 46 | const [count, shouts] = await prisma.$transaction([ 47 | prisma.shout.count({ 48 | where, 49 | }), 50 | prisma.shout.findMany({ 51 | skip: (request.query.page - 1) * request.query.pageSize, 52 | take: request.query.pageSize, 53 | where, 54 | orderBy: { 55 | timeCreated: request.query.timeSort, 56 | }, 57 | include: { 58 | author: request.query.includeAuthor, 59 | }, 60 | }), 61 | ]); 62 | 63 | if (count === 0) { 64 | reply.status(204); 65 | return; 66 | } else { 67 | return { 68 | shouts: shouts, 69 | totalCount: count, 70 | }; 71 | } 72 | } 73 | ); 74 | 75 | fastify.post<{ Body: DeleteShoutBody }>( 76 | "/api/shouts/deleteShout", 77 | { 78 | onRequest: fastify.authenticate, 79 | schema: { body: deleteShoutBodySchema }, 80 | }, 81 | async (request, reply) => { 82 | const shout = await prisma.shout.findUnique({ 83 | where: { 84 | id: request.body.id, 85 | }, 86 | }); 87 | 88 | if (!shout) { 89 | reply.status(404).send({ error: "Shout not found" }); 90 | } else { 91 | if ( 92 | shout.authorId === request.user.id || 93 | request.user.accountType > 1 94 | ) { 95 | await prisma.shout.delete({ 96 | where: { 97 | id: request.body.id, 98 | }, 99 | }); 100 | reply.status(204); 101 | } else { 102 | reply.status(403).send({ error: "Insufficient permissions" }); 103 | } 104 | } 105 | } 106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /routes/api/songs.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from "fastify"; 2 | import { prisma } from "../../util/db"; 3 | import { Static, Type } from "@sinclair/typebox"; 4 | import { sendMetadataReport } from "../../util/discord"; 5 | import { tagByMBID } from "../../util/musicbrainz"; 6 | import { Song } from "@prisma/client"; 7 | 8 | const getSongQuerySchema = Type.Object( 9 | { 10 | id: Type.Number(), 11 | includeShouts: Type.Optional(Type.Boolean({ default: false })), 12 | }, 13 | { additionalProperties: false } 14 | ); 15 | 16 | const reportMetadataSchema = Type.Object( 17 | { 18 | id: Type.Number(), 19 | additionalInfo: Type.Optional(Type.String({ maxLength: 150 })), 20 | }, 21 | { additionalProperties: false } 22 | ); 23 | 24 | const applyMBIDSchema = Type.Object( 25 | { 26 | id: Type.Number(), 27 | mbid: Type.String(), 28 | }, 29 | { additionalProperties: false } 30 | ); 31 | 32 | const markMistagSchema = Type.Object( 33 | { 34 | id: Type.Number(), 35 | }, 36 | { additionalProperties: false } 37 | ); 38 | 39 | const searchSongQuerySchema = Type.Object( 40 | { 41 | query: Type.String(), 42 | }, 43 | { additionalProperties: false } 44 | ); 45 | 46 | type GetSongQuery = Static; 47 | type ReportMetadataBody = Static; 48 | type ApplyMBIDBody = Static; 49 | type MarkMistagBody = Static; 50 | type SearchSongQuery = Static; 51 | 52 | export default async function routes(fastify: FastifyInstance) { 53 | fastify.get<{ Querystring: GetSongQuery }>( 54 | "/api/songs/getSong", 55 | { schema: { querystring: getSongQuerySchema } }, 56 | async (request) => { 57 | const song = await prisma.song.findUniqueOrThrow({ 58 | where: { 59 | id: request.query.id, 60 | }, 61 | ...(request.query.includeShouts && { include: { shouts: true } }), 62 | }); 63 | 64 | return song; 65 | } 66 | ); 67 | 68 | fastify.post<{ Body: ReportMetadataBody }>( 69 | "/api/songs/reportMetadata", 70 | { schema: { body: reportMetadataSchema }, onRequest: fastify.authenticate }, 71 | async (request) => { 72 | fastify.log.info( 73 | `Received metadata report for song ID ${request.body.id} from ${request.user.username}` 74 | ); 75 | 76 | const song = await prisma.song.findUniqueOrThrow({ 77 | where: { 78 | id: request.body.id, 79 | }, 80 | }); 81 | sendMetadataReport(request.user, song, request.body.additionalInfo); 82 | } 83 | ); 84 | 85 | fastify.post<{ Body: ApplyMBIDBody }>( 86 | "/api/songs/applyMBID", 87 | { schema: { body: applyMBIDSchema }, onRequest: fastify.authenticate }, 88 | async (request, reply) => { 89 | if (request.user.accountType != 3) { 90 | reply.status(403).send({ error: "Insufficient permissions" }); 91 | } 92 | 93 | fastify.log.info( 94 | `Applying MBID ${request.body.mbid} to song ${request.body.id}, requested by user ${request.user.id}` 95 | ); 96 | 97 | await tagByMBID(request.body.id, request.body.mbid); 98 | return { success: true }; 99 | } 100 | ); 101 | 102 | fastify.post<{ Body: MarkMistagBody }>( 103 | "/api/songs/markMistag", 104 | { schema: { body: markMistagSchema }, onRequest: fastify.authenticate }, 105 | async (request, reply) => { 106 | if (request.user.accountType != 3) { 107 | reply.status(403).send({ error: "Insufficient permissions" }); 108 | } 109 | 110 | fastify.log.info( 111 | `Marking song ${request.body.id} as mistagged, requested by user ${request.user.id}` 112 | ); 113 | 114 | await prisma.song.update({ 115 | where: { 116 | id: request.body.id, 117 | }, 118 | data: { 119 | mistagLock: true, 120 | mbid: null, 121 | musicbrainzLength: null, 122 | musicbrainzArtist: null, 123 | musicbrainzTitle: null, 124 | coverUrl: null, 125 | smallCoverUrl: null, 126 | }, 127 | }); 128 | return { success: true }; 129 | } 130 | ); 131 | 132 | fastify.get<{ Querystring: SearchSongQuery }>( 133 | "/api/songs/searchSongs", 134 | { schema: { querystring: searchSongQuerySchema } }, 135 | async (request) => { 136 | return { 137 | results: await prisma.$queryRaw< 138 | Song[] 139 | >` SELECT * FROM "Song" ORDER BY GREATEST(similarity(concat(artist, title), ${request.query.query}), similarity(concat("musicbrainzArtist", "musicbrainzTitle"), ${request.query.query})) DESC LIMIT 10;`, 140 | }; 141 | } 142 | ); 143 | } 144 | -------------------------------------------------------------------------------- /routes/api/users.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from "fastify"; 2 | import { Prisma, User } from "@prisma/client"; 3 | import { getUserExtended, prisma } from "../../util/db"; 4 | import { Static, Type } from "@sinclair/typebox"; 5 | 6 | const getUserQuerySchema = Type.Object( 7 | { 8 | id: Type.Number(), 9 | getExtendedInfo: Type.Optional(Type.Boolean({ default: false })), 10 | }, 11 | { additionalProperties: false } 12 | ); 13 | 14 | const searchUserQuerySchema = Type.Object( 15 | { 16 | query: Type.String(), 17 | }, 18 | { additionalProperties: false } 19 | ); 20 | 21 | const rivalParamsSchema = Type.Object( 22 | { 23 | id: Type.Number(), 24 | }, 25 | { additionalProperties: false } 26 | ); 27 | 28 | type GetUserQuery = Static; 29 | type SearchUserQuery = Static; 30 | type RivalParams = Static; 31 | 32 | export default async function routes(fastify: FastifyInstance) { 33 | fastify.get<{ Querystring: GetUserQuery }>( 34 | "/api/users/getUser", 35 | { schema: { querystring: getUserQuerySchema } }, 36 | async (request, reply) => { 37 | const id = request.query.id; 38 | try { 39 | const user = await prisma.user.findUniqueOrThrow({ 40 | where: { 41 | id: id, 42 | }, 43 | }); 44 | 45 | if (request.query.getExtendedInfo) { 46 | return await getUserExtended(user); 47 | } 48 | 49 | return user; 50 | } catch (e) { 51 | if ( 52 | e instanceof Prisma.PrismaClientKnownRequestError && 53 | e.code === "P2025" 54 | ) 55 | reply.status(404).send({ error: "User not found" }); 56 | } 57 | } 58 | ); 59 | 60 | fastify.get<{ Querystring: SearchUserQuery }>( 61 | "/api/users/searchUsers", 62 | { schema: { querystring: searchUserQuerySchema } }, 63 | async (request) => { 64 | return { 65 | results: await prisma.$queryRaw< 66 | User[] 67 | >`SELECT * FROM "User" ORDER BY similarity(username, ${request.query.query}) DESC LIMIT 10;`, 68 | }; 69 | } 70 | ); 71 | 72 | fastify.get( 73 | "/api/users/getOwnRivals", 74 | { onRequest: fastify.authenticate }, 75 | async (request) => { 76 | const user = await prisma.user.findUnique({ 77 | where: { 78 | id: request.user.id, 79 | }, 80 | include: { 81 | rivals: true, 82 | challengers: true, 83 | }, 84 | }); 85 | return { rivals: user.rivals, challengers: user.challengers }; 86 | } 87 | ); 88 | 89 | fastify.post<{ Body: RivalParams }>( 90 | "/api/users/addRival", 91 | { 92 | schema: { body: rivalParamsSchema }, 93 | onRequest: fastify.authenticate, 94 | }, 95 | async (request, reply) => { 96 | if (request.user.id == request.body.id) { 97 | reply.status(400).send({ error: "You can't add yourself as a rival!" }); 98 | } else { 99 | await prisma.user.update({ 100 | where: { 101 | id: request.user.id, 102 | }, 103 | data: { 104 | rivals: { 105 | connect: { 106 | id: request.body.id, 107 | }, 108 | }, 109 | }, 110 | }); 111 | reply.status(204).send(); 112 | } 113 | } 114 | ); 115 | 116 | fastify.post<{ Body: RivalParams }>( 117 | "/api/users/removeRival", 118 | { 119 | schema: { body: rivalParamsSchema }, 120 | onRequest: fastify.authenticate, 121 | }, 122 | async (request, reply) => { 123 | if (request.user.id == request.body.id) { 124 | reply.status(400).send({ error: "You can't add yourself as a rival!" }); 125 | } else { 126 | await prisma.user.update({ 127 | where: { 128 | id: request.user.id, 129 | }, 130 | data: { 131 | rivals: { 132 | disconnect: { 133 | id: request.body.id, 134 | }, 135 | }, 136 | }, 137 | }); 138 | reply.status(204).send(); 139 | } 140 | } 141 | ); 142 | 143 | fastify.get<{ Querystring: RivalParams }>( 144 | "/api/users/isRival", 145 | { 146 | schema: { querystring: rivalParamsSchema }, 147 | onRequest: fastify.authenticate, 148 | }, 149 | async (request) => { 150 | const user = await prisma.user.findFirst({ 151 | where: { 152 | id: request.user.id, 153 | rivals: { 154 | some: { 155 | id: request.query.id, 156 | }, 157 | }, 158 | }, 159 | }); 160 | if (user) return { isRival: true }; 161 | else return { isRival: false }; 162 | } 163 | ); 164 | } 165 | -------------------------------------------------------------------------------- /routes/as1/README.md: -------------------------------------------------------------------------------- 1 | # /as/ /as_steamlogin/ 2 | 3 | This is where all the API endpoints that the game needs live. -------------------------------------------------------------------------------- /routes/as1/accounts.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from "fastify"; 2 | import { Prisma, User } from "@prisma/client"; 3 | import { prisma, redis } from "../../util/db"; 4 | import * as SteamUtils from "../../util/steam"; 5 | import xml2js from "xml2js"; 6 | import SteamID from "steamid"; 7 | import { Static, Type } from "@sinclair/typebox"; 8 | 9 | const xmlBuilder = new xml2js.Builder(); 10 | 11 | const steamLoginRequestSchema = Type.Object( 12 | { 13 | steamusername: Type.String(), 14 | snum: Type.Integer(), 15 | s64: Type.String(), 16 | ticket: Type.String(), 17 | wvbrclientversion: Type.String(), 18 | }, 19 | { additionalProperties: false } 20 | ); 21 | type SteamLoginRequest = Static; 22 | 23 | const updateLocationRequestSchema = Type.Object( 24 | { 25 | s64: Type.String(), 26 | ticket: Type.String(), 27 | locationid: Type.Integer(), 28 | }, 29 | { additionalProperties: false } 30 | ); 31 | type UpdateLocationRequest = Static; 32 | 33 | const steamSyncRequestSchema = Type.Object( 34 | { 35 | steamusername: Type.String(), 36 | snum: Type.Integer(), 37 | s64: Type.String(), 38 | ticket: Type.String(), 39 | snums: Type.String(), 40 | achstates: Type.String(), 41 | }, 42 | { additionalProperties: false } 43 | ); 44 | type SteamSyncRequest = Static; 45 | 46 | export default async function routes(fastify: FastifyInstance) { 47 | fastify.post<{ 48 | Body: SteamLoginRequest; 49 | }>( 50 | "/as_steamlogin/game_AttemptLoginSteamVerified.php", 51 | { schema: { body: steamLoginRequestSchema } }, 52 | async (request) => { 53 | const steamTicketResponse = await SteamUtils.verifySteamTicket( 54 | request.body.ticket 55 | ); 56 | const steamId = new SteamID(steamTicketResponse.response.params.steamid); 57 | //const steamUser = await SteamUtils.steamApi.getUserSummary( 58 | // steamId.getSteamID64() 59 | //); 60 | 61 | const profileResponse = await fetch("https://steamcommunity.com/profiles/" + steamId.getSteamID64() + "?xml=1"); 62 | let xmlText = await profileResponse.text(); 63 | let parser = new xml2js.Parser(); 64 | let parsed = await parser.parseStringPromise(xmlText); 65 | 66 | const user: User = await prisma.user.upsert({ 67 | where: { steamid64: steamId.getSteamID64() }, 68 | update: { 69 | username: parsed.profile.steamID[0], 70 | avatarUrl: parsed.profile.avatarFull[0], 71 | avatarUrlMedium: parsed.profile.avatarMedium[0], 72 | avatarUrlSmall: parsed.profile.avatarIcon[0], 73 | }, 74 | create: { 75 | username: parsed.profile.steamID[0], 76 | steamid64: steamId.getSteamID64(), 77 | steamid32: steamId.accountid, 78 | locationid: 1, 79 | avatarUrl: parsed.profile.avatarFull[0], 80 | avatarUrlMedium: parsed.profile.avatarMedium[0], 81 | avatarUrlSmall: parsed.profile.avatarIcon[0], 82 | }, 83 | }); 84 | 85 | //If user isn't stored in Redis yet, we add them to the leaderboard with 0 skill points. 86 | const redisPoints = await redis.zscore("leaderboard", user.id); 87 | if (!redisPoints) await redis.zadd("leaderboard", 0, user.id); 88 | 89 | fastify.log.info("Game auth request for user %d", user.id); 90 | return xmlBuilder.buildObject({ 91 | RESULT: { 92 | $: { 93 | status: "allgood", 94 | }, 95 | userid: user.id, 96 | username: user.username, 97 | locationid: user.locationid, // locationid is the n-th element in the location list you see when registering 98 | steamid: user.steamid32, //SteamID32, not ID64 99 | }, 100 | }); 101 | } 102 | ); 103 | 104 | fastify.post<{ 105 | Body: UpdateLocationRequest; 106 | }>( 107 | "/as_steamlogin/game_UpdateLocationid.php", 108 | { schema: { body: updateLocationRequestSchema } }, 109 | async (request) => { 110 | const steamTicketResponse = await SteamUtils.verifySteamTicket( 111 | request.body.ticket 112 | ); 113 | 114 | await prisma.user.update({ 115 | where: { 116 | steamid64: steamTicketResponse.response.params.steamid, 117 | }, 118 | data: { 119 | locationid: +request.body.locationid, 120 | }, 121 | }); 122 | 123 | return xmlBuilder.buildObject({ 124 | RESULT: { 125 | $: { 126 | status: "success", 127 | }, 128 | }, 129 | }); 130 | } 131 | ); 132 | 133 | fastify.post<{ 134 | Body: SteamSyncRequest; 135 | }>( 136 | "/as_steamlogin/game_SteamSyncSteamVerified.php", 137 | { schema: { body: steamSyncRequestSchema } }, 138 | async (request) => { 139 | const steamTicketResponse = await SteamUtils.verifySteamTicket( 140 | request.body.ticket 141 | ); 142 | 143 | const steamFriendList: number[] = request.body.snums 144 | .split("x") 145 | .map(Number); 146 | 147 | try { 148 | await prisma.user.update({ 149 | where: { 150 | steamid64: steamTicketResponse.response.params.steamid, 151 | }, 152 | data: { 153 | rivals: { 154 | connect: steamFriendList.map((steamid32) => ({ steamid32 })), 155 | }, 156 | }, 157 | }); 158 | } catch (e) { 159 | if ( 160 | e instanceof Prisma.PrismaClientKnownRequestError && 161 | e.code === "P2025" 162 | ) 163 | fastify.log.info("Adding friends: " + e.meta?.cause); 164 | //this is gonna work trust me bro 165 | else throw e; 166 | } 167 | 168 | //Nowhere near close to the response the real server gives 169 | //We do not need to care for this endpoint though, because neither will the client 170 | return xmlBuilder.buildObject({ 171 | RESULT: { 172 | $: { 173 | status: "success", 174 | }, 175 | }, 176 | }); 177 | } 178 | ); 179 | } 180 | -------------------------------------------------------------------------------- /routes/as1/gameplay.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from "fastify"; 2 | import { Prisma, User, Score, Song } from "@prisma/client"; 3 | import { prisma } from "../../util/db"; 4 | import xml2js from "xml2js"; 5 | import * as SteamUtils from "../../util/steam"; 6 | import crypto from "crypto"; 7 | import { addMusicBrainzInfo, tagByMBID } from "../../util/musicbrainz"; 8 | import { removeTagsFromTitle, tagsFromTitle } from "../../util/gamemodeTags"; 9 | import { Static, Type } from "@sinclair/typebox"; 10 | import { calcSkillPoints } from "../../util/rankings"; 11 | 12 | const xmlBuilder = new xml2js.Builder(); 13 | 14 | const fetchSongIdSteamRequestSchema = Type.Object( 15 | { 16 | artist: Type.String(), 17 | song: Type.String(), 18 | uid: Type.Integer(), 19 | league: Type.Integer({ minimum: 0, maximum: 2 }), 20 | ticket: Type.String(), 21 | }, 22 | { additionalProperties: false } 23 | ); 24 | type FetchSongIdSteamRequest = Static; 25 | 26 | const sendRideSteamRequestSchema = Type.Object( 27 | { 28 | steamusername: Type.String(), 29 | snum: Type.Integer(), 30 | artist: Type.String(), 31 | song: Type.String(), 32 | score: Type.Integer(), 33 | vehicle: Type.Integer({ minimum: 0, maximum: 17 }), 34 | league: Type.Integer({ minimum: 0, maximum: 2 }), 35 | locationid: Type.Integer(), 36 | feats: Type.String(), 37 | songlength: Type.Integer(), 38 | trackshape: Type.String(), 39 | density: Type.Integer(), 40 | submitcode: Type.String(), 41 | songid: Type.Integer(), 42 | xstats: Type.String(), 43 | goldthreshold: Type.Integer(), 44 | iss: Type.Integer(), 45 | isj: Type.Integer(), 46 | s64: Type.String(), 47 | ticket: Type.String(), 48 | mbid: Type.Optional(Type.String()), 49 | }, 50 | { additionalProperties: false } 51 | ); 52 | type SendRideSteamRequest = Static; 53 | 54 | const getRidesSteamRequestSchema = Type.Object( 55 | { 56 | uid: Type.Integer(), 57 | songid: Type.Integer(), 58 | league: Type.Integer({ minimum: 0, maximum: 2 }), 59 | locationid: Type.Integer(), 60 | steamusername: Type.String(), 61 | snum: Type.Integer(), 62 | s64: Type.String(), 63 | ticket: Type.String(), 64 | }, 65 | { additionalProperties: false } 66 | ); 67 | type GetRidesSteamRequest = Static; 68 | 69 | type ScoreWithPlayer = Prisma.ScoreGetPayload<{ 70 | include: { player: true }; 71 | }>; 72 | 73 | async function getSongScores( 74 | song: number, 75 | league = -1, 76 | location = 0, 77 | limit = 0, 78 | byPlayers: number[] = [] 79 | ): Promise { 80 | return await prisma.score.findMany({ 81 | where: { 82 | songId: song, 83 | ...(league > -1 && { leagueId: league }), 84 | ...(location > 0 && { 85 | player: { 86 | is: { 87 | locationid: location, 88 | }, 89 | }, 90 | }), 91 | ...(byPlayers.length != 0 && { 92 | player: { 93 | id: { 94 | in: byPlayers, 95 | }, 96 | }, 97 | }), 98 | }, 99 | orderBy: { 100 | score: "desc", 101 | }, 102 | ...(limit > 0 && { take: 11 }), 103 | include: { 104 | player: true, 105 | }, 106 | }); 107 | } 108 | 109 | function constructScoreResponseEntry( 110 | type: number, 111 | score: ScoreWithPlayer 112 | ): object { 113 | return { 114 | $: { 115 | scoretype: type, 116 | }, 117 | league: { 118 | $: { 119 | leagueid: score.leagueId, 120 | }, 121 | ride: { 122 | username: score.player.username, 123 | vehicleid: score.vehicleId, 124 | score: score.score, 125 | ridetime: Math.floor(score.rideTime.getTime() / 1000), 126 | feats: score.feats, 127 | songlength: score.songLength, 128 | trafficcount: score.id, 129 | }, 130 | }, 131 | }; 132 | } 133 | 134 | async function getOrCreateSong(title: string, artist: string): Promise { 135 | //Validation 136 | if ( 137 | artist.toLowerCase() == "unknown artist" || 138 | title.toLowerCase() == "unknown" 139 | ) 140 | throw new Error("Invalid song title or artist."); 141 | 142 | const gamemodeTags: string[] = tagsFromTitle(title); 143 | if (gamemodeTags.length > 0) title = removeTagsFromTitle(title); 144 | 145 | let song: Song = await prisma.song.findFirst({ 146 | where: { 147 | AND: [ 148 | { 149 | OR: [ 150 | { 151 | title: { 152 | equals: title, 153 | }, 154 | }, 155 | { 156 | musicbrainzTitle: { 157 | equals: title, 158 | mode: "insensitive", 159 | }, 160 | }, 161 | ], 162 | }, 163 | { 164 | OR: [ 165 | { 166 | artist: { 167 | equals: artist, 168 | }, 169 | }, 170 | { 171 | musicbrainzArtist: { 172 | equals: artist, 173 | mode: "insensitive", 174 | }, 175 | }, 176 | ], 177 | }, 178 | { 179 | ...(gamemodeTags.length == 0 && { 180 | tags: { 181 | isEmpty: true, 182 | }, 183 | }), 184 | ...(gamemodeTags.length > 0 && { 185 | tags: { 186 | equals: gamemodeTags, 187 | }, 188 | }), 189 | }, 190 | ], 191 | }, 192 | }); 193 | if (!song) { 194 | song = await prisma.song.create({ 195 | data: { 196 | title: title, 197 | artist: artist, 198 | ...(gamemodeTags.length > 0 && { tags: gamemodeTags }), 199 | }, 200 | }); 201 | } 202 | 203 | return song; 204 | } 205 | 206 | export default async function routes(fastify: FastifyInstance) { 207 | fastify.post<{ 208 | Body: FetchSongIdSteamRequest; 209 | }>( 210 | "/as_steamlogin/game_fetchsongid_unicode.php", 211 | { schema: { body: fetchSongIdSteamRequestSchema } }, 212 | async (request) => { 213 | const user: User = await SteamUtils.findUserByTicket(request.body.ticket); 214 | 215 | fastify.log.info( 216 | "User" + user.id + "requesting song ID for " + 217 | request.body.artist + 218 | " - " + 219 | request.body.song 220 | ); 221 | 222 | const song = await getOrCreateSong( 223 | request.body.song, 224 | request.body.artist 225 | ); 226 | 227 | try { 228 | const pb: Score = await prisma.score.findFirstOrThrow({ 229 | where: { 230 | songId: song.id, 231 | userId: request.body.uid, 232 | leagueId: request.body.league, 233 | }, 234 | }); 235 | 236 | return xmlBuilder.buildObject({ 237 | RESULT: { 238 | $: { 239 | status: "allgood", 240 | }, 241 | songid: pb.songId, 242 | pb: pb.score, 243 | }, 244 | }); 245 | } catch (e) { 246 | return xmlBuilder.buildObject({ 247 | RESULT: { 248 | $: { 249 | status: "allgood", 250 | }, 251 | songid: song.id, 252 | pb: 0, 253 | }, 254 | }); 255 | } 256 | } 257 | ); 258 | 259 | fastify.post<{ 260 | Body: SendRideSteamRequest; 261 | }>( 262 | "/as_steamlogin/game_SendRideSteamVerified.php", 263 | { schema: { body: sendRideSteamRequestSchema } }, 264 | async (request) => { 265 | const submissionCodePlaintext = 266 | "oenuthrrprwvqmjwqbxk" + 267 | request.body.score + 268 | request.body.songlength + 269 | request.body.density + 270 | request.body.trackshape + 271 | request.body.vehicle + 272 | "2347nstho4eu" + 273 | request.body.song + 274 | request.body.artist; 275 | const submissionHash = crypto 276 | .createHash("md5") 277 | .update(submissionCodePlaintext) 278 | .digest("hex"); 279 | if (submissionHash != request.body.submitcode) { 280 | fastify.log.error( 281 | `Invalid submit code: ${submissionCodePlaintext} - ${submissionHash} - ${request.body.submitcode}` 282 | ); 283 | throw new Error("Invalid submit code."); 284 | } 285 | 286 | const user: User = await SteamUtils.findUserByTicket(request.body.ticket); 287 | 288 | let song: Song; 289 | if (request.body.mbid) { 290 | fastify.log.info( 291 | `User ${user.id} submitted a ride with MBID, using it for lookup first: ${request.body.mbid}` 292 | ); 293 | song = await prisma.song.findFirst({ 294 | where: { 295 | mbid: request.body.mbid, 296 | tags: { 297 | equals: tagsFromTitle(request.body.song), //Only look for songs with the same modifier tags! 298 | }, 299 | }, 300 | }); 301 | if (!song) { 302 | fastify.log.info( 303 | "Song not found by MBID, looking up by title and artist." 304 | ); 305 | } 306 | } 307 | 308 | if (!song || !request.body.mbid) { 309 | song = await getOrCreateSong(request.body.song, request.body.artist); 310 | } 311 | 312 | if (!song.mbid) { 313 | fastify.log.info( 314 | `Looking up MusicBrainz info for song ${song.id} with length ${ 315 | request.body.songlength * 10 316 | }` 317 | ); 318 | if (request.body.mbid) { 319 | fastify.log.info( 320 | `Using user-provided MBID for song: ${request.body.mbid}` 321 | ); 322 | tagByMBID(song.id, request.body.mbid).catch((e) => { 323 | fastify.log.error(`Failed to tag song by MBID: ${e}\n${e.stack}`); 324 | }); 325 | } else { 326 | addMusicBrainzInfo(song, request.body.songlength * 10).catch((e) => { 327 | fastify.log.error( 328 | `Failed to look up MusicBrainz info: ${e}\n${e.stack}` 329 | ); 330 | }); 331 | } 332 | } 333 | 334 | const prevScore = await prisma.score.findUnique({ 335 | where: { 336 | userId_leagueId_songId: { 337 | songId: song.id, 338 | userId: user.id, 339 | leagueId: request.body.league, 340 | }, 341 | }, 342 | }); 343 | 344 | const scoreComponent = { 345 | trackShape: request.body.trackshape, 346 | xstats: request.body.xstats, 347 | density: request.body.density, 348 | vehicleId: request.body.vehicle, 349 | score: request.body.score, 350 | feats: request.body.feats, 351 | songLength: request.body.songlength, 352 | goldThreshold: request.body.goldthreshold, 353 | skillPoints: calcSkillPoints( 354 | request.body.score, 355 | request.body.goldthreshold, 356 | request.body.league 357 | ), 358 | iss: request.body.iss, 359 | isj: request.body.isj, 360 | }; 361 | const score = await prisma.score.upsert({ 362 | where: { 363 | userId_leagueId_songId: { 364 | songId: song.id, 365 | userId: user.id, 366 | leagueId: request.body.league, 367 | }, 368 | }, 369 | create: { 370 | userId: user.id, 371 | leagueId: request.body.league, 372 | songId: request.body.songid, 373 | ...scoreComponent, 374 | }, 375 | update: { 376 | playCount: { 377 | increment: 1, 378 | }, 379 | ...(prevScore && 380 | prevScore.score < request.body.score && { 381 | rideTime: new Date(), 382 | ...scoreComponent, 383 | }), 384 | }, 385 | }); 386 | 387 | if (!score) throw new Error("Score submission failed."); 388 | 389 | fastify.log.info( 390 | "Play submitted by user %d on song %d in league %d, score: %d\nSubmit code: %s\nPlay #%d", 391 | user.id, 392 | request.body.songid, 393 | request.body.league, 394 | request.body.score, 395 | request.body.submitcode, 396 | score.playCount 397 | ); 398 | fastify.log.info("Song tags: " + song.tags); 399 | 400 | return xmlBuilder.buildObject({ 401 | RESULT: { 402 | $: { 403 | status: "allgood", 404 | }, 405 | songid: song.id, 406 | }, 407 | }); 408 | } 409 | ); 410 | 411 | fastify.post<{ 412 | Body: GetRidesSteamRequest; 413 | }>( 414 | "/as_steamlogin/game_GetRidesSteamVerified.php", 415 | { schema: { body: getRidesSteamRequestSchema } }, 416 | async (request) => { 417 | try { 418 | const user = await SteamUtils.findUserWithRivalsByTicket( 419 | request.body.ticket 420 | ); 421 | 422 | //Global scores 423 | const globalScores: ScoreWithPlayer[] = []; 424 | for (let league = 0; league <= 2; league++) { 425 | globalScores.push( 426 | ...(await getSongScores(request.body.songid, league, 0, 11)) 427 | ); 428 | } 429 | 430 | //Nearby scores 431 | const nearbyScores: ScoreWithPlayer[] = []; 432 | for (let league = 0; league <= 2; league++) { 433 | nearbyScores.push( 434 | ...(await getSongScores( 435 | request.body.songid, 436 | league, 437 | request.body.locationid, 438 | 11 439 | )) 440 | ); 441 | } 442 | 443 | //Rival/Friend scores 444 | //Get the list of IDs of the user's rivals 445 | const rivalIds = user.rivals.map((rival) => rival.id); 446 | rivalIds.push(user.id); //So our own score is included, for easier comparison 447 | 448 | const friendScores: ScoreWithPlayer[] = []; 449 | for (let league = 0; league <= 2; league++) { 450 | friendScores.push( 451 | ...(await getSongScores( 452 | request.body.songid, 453 | league, 454 | request.body.locationid, 455 | 11, 456 | rivalIds 457 | )) 458 | ); 459 | } 460 | 461 | const scoreResponseArray: object[] = []; 462 | for (const score of globalScores) { 463 | scoreResponseArray.push(constructScoreResponseEntry(0, score)); 464 | } 465 | for (const score of nearbyScores) { 466 | scoreResponseArray.push(constructScoreResponseEntry(1, score)); 467 | } 468 | for (const score of friendScores) { 469 | scoreResponseArray.push(constructScoreResponseEntry(2, score)); 470 | } 471 | 472 | return xmlBuilder.buildObject({ 473 | RESULTS: { 474 | scores: scoreResponseArray, 475 | }, 476 | }); 477 | } catch (e) { 478 | fastify.log.error(e); 479 | return e; 480 | } 481 | } 482 | ); 483 | } 484 | -------------------------------------------------------------------------------- /routes/as1/information.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from "fastify"; 2 | import { Prisma, Score, User } from "@prisma/client"; 3 | import xml2js from "xml2js"; 4 | import * as SteamUtils from "../../util/steam"; 5 | import { prisma } from "../../util/db"; 6 | import { Static, Type } from "@sinclair/typebox"; 7 | import { getPopularSongs } from "../../util/rankings"; 8 | 9 | const xmlBuilder = new xml2js.Builder(); 10 | 11 | const fetchTrackShapeRequestSchema = Type.Object( 12 | { 13 | ridd: Type.Integer(), 14 | songid: Type.Integer(), 15 | league: Type.Integer({ minimum: 0, maximum: 3 }), 16 | }, 17 | { additionalProperties: false } 18 | ); 19 | type FetchTrackShapeRequest = Static; 20 | 21 | const fetchShoutsRequestSchema = Type.Object( 22 | { 23 | songid: Type.Array(Type.Integer()), //Oh, Dylan. Why do you pass the song ID twice? 24 | }, 25 | { additionalProperties: false } 26 | ); 27 | type FetchShoutsRequest = Static; 28 | 29 | const sendShoutSteamRequestSchema = Type.Object( 30 | { 31 | s64: Type.String(), 32 | ticket: Type.String(), 33 | songid: Type.Integer(), 34 | shout: Type.String(), 35 | snum: Type.Integer(), 36 | steamusername: Type.String(), 37 | }, 38 | { additionalProperties: false } 39 | ); 40 | type SendShoutSteamRequest = Static; 41 | 42 | const customNewsSteamRequestSchema = Type.Object({ 43 | steamusername: Type.String(), 44 | snum: Type.Integer(), 45 | artist: Type.String(), 46 | song: Type.String(), 47 | vehicle: Type.Integer({ minimum: 0, maximum: 17 }), 48 | userid: Type.Integer(), 49 | league: Type.Integer({ minimum: 0, maximum: 3 }), 50 | songid: Type.Integer(), 51 | songlength: Type.Integer(), 52 | s64: Type.String(), 53 | ticket: Type.String(), 54 | wvbrclientversion: Type.Optional(Type.String()), 55 | }); 56 | type CustomNewsSteamRequest = Static; 57 | 58 | type ShoutWithAuthor = Prisma.ShoutGetPayload<{ 59 | include: { author: true }; 60 | }>; 61 | 62 | async function getShoutsAsString(songId: number) { 63 | let shoutResponse = ""; 64 | const shouts: ShoutWithAuthor[] = await prisma.shout.findMany({ 65 | where: { 66 | songId: songId, 67 | }, 68 | include: { 69 | author: true, 70 | }, 71 | orderBy: { 72 | timeCreated: "desc", 73 | }, 74 | }); 75 | 76 | if (shouts.length == 0) { 77 | return "No shouts found. Shout it out loud!"; 78 | } 79 | 80 | shouts.forEach((shout) => { 81 | shoutResponse += 82 | shout.author.username + 83 | " (at " + 84 | shout.timeCreated.toUTCString() + 85 | "):\n"; 86 | shoutResponse += shout.content + "\n\n"; 87 | }); 88 | return shoutResponse; 89 | } 90 | 91 | export default async function routes(fastify: FastifyInstance) { 92 | fastify.post<{ 93 | Body: FetchTrackShapeRequest; 94 | }>( 95 | "/as/game_fetchtrackshape2.php", 96 | { schema: { body: fetchTrackShapeRequestSchema } }, 97 | async (request) => { 98 | /** 99 | * It doesn't work in a debug environment, because the game requests it over HTTP 100 | * ...and then the weird ass fastify redirect makes it a GET request and strips all parameters. 101 | * TODO: I should probably somehow get the hook to go and replace HTTP with HTTPS. 102 | */ 103 | try { 104 | const score: Score = await prisma.score.findUniqueOrThrow({ 105 | where: { 106 | id: +request.body.ridd, 107 | }, 108 | }); 109 | 110 | return score.trackShape; 111 | } catch (e) { 112 | console.log(e); 113 | return "failed"; 114 | } 115 | } 116 | ); 117 | 118 | fastify.post<{ 119 | Body: FetchShoutsRequest; 120 | }>( 121 | "/as_steamlogin/game_fetchshouts_unicode.php", 122 | { schema: { body: fetchShoutsRequestSchema } }, 123 | async (request) => { 124 | return await getShoutsAsString(+request.body.songid[0]); 125 | } 126 | ); 127 | 128 | fastify.post<{ 129 | Body: SendShoutSteamRequest; 130 | }>( 131 | "/as_steamlogin/game_sendShoutSteamVerified.php", 132 | { schema: { body: sendShoutSteamRequestSchema } }, 133 | async (request) => { 134 | try { 135 | const user: User = await SteamUtils.findUserByTicket( 136 | request.body.ticket 137 | ); 138 | 139 | await prisma.song.update({ 140 | where: { 141 | id: +request.body.songid, 142 | }, 143 | data: { 144 | shouts: { 145 | create: { 146 | authorId: user.id, 147 | content: request.body.shout, 148 | }, 149 | }, 150 | }, 151 | }); 152 | 153 | return await getShoutsAsString(+request.body.songid); 154 | } catch (e) { 155 | console.error(e); 156 | return e; 157 | } 158 | } 159 | ); 160 | 161 | fastify.post<{ 162 | Body: CustomNewsSteamRequest; 163 | }>( 164 | "//as_steamlogin/game_CustomNews.php", 165 | { schema: { body: customNewsSteamRequestSchema } }, 166 | async (request) => { 167 | if (!request.body.wvbrclientversion) { 168 | return xmlBuilder.buildObject({ 169 | RESULTS: { 170 | TEXT: "WARNING\nYOUR CLIENT MOD IS TOO OUTDATED!\nIn the future, you won't be able\nto connect anymore.\n\nPlease re-do the install guide:\nhttps://wavebreaker.arcadian.garden/installguide", 171 | }, 172 | }); 173 | } 174 | 175 | //Placeholder, need to add more news elements to randomly pick 176 | const newsElementDecision = Math.floor(Math.random() * 2); 177 | let newsElement = "Enjoy the ride!"; 178 | switch (newsElementDecision) { 179 | case 0: { 180 | newsElement = "Looking for new songs?\nThese are popular:\n"; 181 | const songs = await getPopularSongs(1, 5); 182 | songs.forEach((song, index) => { 183 | newsElement += song.artist + " - " + song.title; 184 | if (index != songs.length - 1) { 185 | newsElement += "\n"; 186 | } 187 | }); 188 | break; 189 | } 190 | 191 | case 1: { 192 | const user = await prisma.user.findFirst({ 193 | where: { 194 | id: request.body.userid, 195 | }, 196 | include: { 197 | rivals: true, 198 | }, 199 | }); 200 | if (user.rivals.length > 0) { 201 | //Pick random rival 202 | const rival = 203 | user.rivals[Math.floor(Math.random() * user.rivals.length)]; 204 | newsElement = "Recent rival activity of " + rival.username + ":\n"; 205 | const rivalScores = await prisma.score.findMany({ 206 | where: { 207 | player: { 208 | id: rival.id, 209 | }, 210 | }, 211 | orderBy: { 212 | rideTime: "desc", 213 | }, 214 | include: { 215 | song: true, 216 | }, 217 | take: 5, 218 | }); 219 | rivalScores.forEach((score, index) => { 220 | newsElement += score.song.artist + " - " + score.song.title; 221 | if (index != rivalScores.length - 1) { 222 | newsElement += "\n"; 223 | } 224 | }); 225 | } else { 226 | newsElement = 227 | "You have no rivals yet!\nGo add some on the website!\nhttps://wavebreaker.arcadian.garden"; 228 | } 229 | 230 | break; 231 | } 232 | } 233 | 234 | return xmlBuilder.buildObject({ 235 | RESULTS: { 236 | TEXT: "Welcome to Wavebreaker!\n\n" + newsElement, 237 | }, 238 | }); 239 | } 240 | ); 241 | } 242 | -------------------------------------------------------------------------------- /routes/as1/radio.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from "fastify"; 2 | import fs from "fs"; 3 | import { RadioEntry } from "../../@types/global"; 4 | 5 | export default async function routes(fastify: FastifyInstance) { 6 | //idk why this uses POST but it does 7 | fastify.post("/as/asradio/game_asradiolist5.php", async () => { 8 | const WavebreakerRadioConfig = JSON.parse( 9 | fs.readFileSync( 10 | globalThis.__basedir + "/config/wavebreaker_radio_entries.json", 11 | "utf-8" 12 | ) 13 | ); 14 | if (WavebreakerRadioConfig.availableSongs.length == 0) return ""; 15 | 16 | const separator = "-:*x-"; 17 | const radioEntries: RadioEntry[] = WavebreakerRadioConfig.availableSongs; 18 | 19 | //Join every radio entry's properties (and the entries themselves) with the separator 20 | const entriesString = 21 | radioEntries 22 | .map((entry) => { 23 | delete entry.wavebreakerId; //ignore wavebreakerId property - irrelevant for the game and causes issues 24 | return Object.values(entry).join(separator); 25 | }) 26 | .join(separator) + separator; 27 | 28 | return entriesString; 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /scripts/cleanSongs.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | const prisma = new PrismaClient(); 3 | 4 | async function main() { 5 | prisma.song 6 | .deleteMany({ 7 | where: { 8 | scores: { none: {} }, 9 | }, 10 | }) 11 | .then((songs) => { 12 | console.log(songs.count + " songs deleted"); 13 | }); 14 | } 15 | 16 | main() 17 | .then(async () => { 18 | await prisma.$disconnect(); 19 | }) 20 | 21 | .catch(async (e) => { 22 | console.error(e); 23 | 24 | await prisma.$disconnect(); 25 | 26 | process.exit(1); 27 | }); 28 | -------------------------------------------------------------------------------- /scripts/coverFixup.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | const prisma = new PrismaClient(); 3 | 4 | async function main() { 5 | const songs = await prisma.song.findMany({ 6 | where: { 7 | OR: [ 8 | { 9 | AND: [ 10 | { smallCoverUrl: null }, 11 | { 12 | NOT: { 13 | coverUrl: null, 14 | }, 15 | }, 16 | ], 17 | }, 18 | { coverUrl: { endsWith: "_thumb.jpg" } }, 19 | ], 20 | }, 21 | }); 22 | 23 | console.log(`Found ${songs.length} songs to fix`); 24 | 25 | songs.forEach((song) => { 26 | prisma.song 27 | .update({ 28 | where: { id: song.id }, 29 | data: { 30 | smallCoverUrl: song.coverUrl.replace("_thumb500.jpg", "_thumb.jpg"), 31 | coverUrl: song.coverUrl.replace("_thumb.jpg", "_thumb500.jpg"), 32 | }, 33 | }) 34 | .then((song) => { 35 | console.log("Fixed cover URLs on song " + song.id); 36 | }); 37 | }); 38 | } 39 | 40 | main() 41 | .then(async () => { 42 | await prisma.$disconnect(); 43 | }) 44 | 45 | .catch(async (e) => { 46 | console.error(e); 47 | 48 | await prisma.$disconnect(); 49 | 50 | process.exit(1); 51 | }); 52 | -------------------------------------------------------------------------------- /scripts/gameplayTagFixup.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient, Song } from "@prisma/client"; 2 | import { removeTagsFromTitle, tagsFromTitle } from "../util/gamemodeTags"; 3 | const prisma = new PrismaClient(); 4 | 5 | async function main() { 6 | const songs = await prisma.song.findMany({ 7 | where: { 8 | title: { 9 | contains: "[as-", 10 | }, 11 | }, 12 | }); 13 | 14 | console.log("Fixing unapplied tags"); 15 | console.log(`Found ${songs.length} songs to fix`); 16 | 17 | songs.forEach((song) => { 18 | prisma.song 19 | .update({ 20 | where: { id: song.id }, 21 | data: { 22 | title: removeTagsFromTitle(song.title), 23 | tags: tagsFromTitle(song.title), 24 | }, 25 | }) 26 | .then((song) => { 27 | console.log("Fixed tags on song " + song.id); 28 | }); 29 | }); 30 | 31 | const songsNull = await prisma.$queryRawUnsafe('SELECT * FROM "Song" WHERE tags IS NULL;'); 32 | 33 | console.log("Fixing null tags"); 34 | console.log(`Found ${songsNull.length} songs to fix`); 35 | 36 | songsNull.forEach((song) => { 37 | prisma.song 38 | .update({ 39 | where: { id: song.id }, 40 | data: { 41 | tags: [], 42 | }, 43 | }) 44 | .then((song) => { 45 | console.log("Fixed tags on song " + song.id); 46 | }); 47 | }); 48 | } 49 | 50 | main() 51 | .then(async () => { 52 | await prisma.$disconnect(); 53 | }) 54 | 55 | .catch(async (e) => { 56 | console.error(e); 57 | 58 | await prisma.$disconnect(); 59 | 60 | process.exit(1); 61 | }); 62 | -------------------------------------------------------------------------------- /scripts/initLeaderboard.ts: -------------------------------------------------------------------------------- 1 | import Redis from "ioredis"; 2 | import { PrismaClient } from "@prisma/client"; 3 | const redis = new Redis(process.env.REDIS_URL); 4 | const prisma = new PrismaClient(); 5 | 6 | async function main() { 7 | //Get all users and combine all skillPoints across all their scores 8 | const users = await prisma.user.findMany({ 9 | include: { 10 | scores: true, 11 | }, 12 | }); 13 | const usersTotalSkillPoints = users.map((user) => { 14 | const totalPoints = user.scores.reduce((acc, score) => { 15 | return acc + score.skillPoints; 16 | }, 0); 17 | return { ...user, totalSkillPoints: totalPoints }; 18 | }); 19 | //Add users with total skill points to sorted list in Redis 20 | for (const user of usersTotalSkillPoints) { 21 | await redis.zadd("leaderboard", user.totalSkillPoints, user.id); 22 | } 23 | } 24 | 25 | main() 26 | .then(async () => { 27 | await prisma.$disconnect(); 28 | }) 29 | 30 | .catch(async (e) => { 31 | console.error(e); 32 | 33 | await prisma.$disconnect(); 34 | 35 | process.exit(1); 36 | }); 37 | -------------------------------------------------------------------------------- /scripts/makeUserAdmin.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | const prisma = new PrismaClient(); 3 | 4 | async function main() { 5 | const args = process.argv.slice(2); 6 | 7 | const user = await prisma.user.update({ 8 | where: { 9 | id: parseInt(args[0]), 10 | }, 11 | data: { 12 | accountType: 3, 13 | }, 14 | }); 15 | 16 | console.log(user); 17 | } 18 | 19 | main() 20 | .then(async () => { 21 | await prisma.$disconnect(); 22 | }) 23 | 24 | .catch(async (e) => { 25 | console.error(e); 26 | 27 | await prisma.$disconnect(); 28 | 29 | process.exit(1); 30 | }); 31 | -------------------------------------------------------------------------------- /scripts/markMistag.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | const prisma = new PrismaClient(); 3 | 4 | async function main() { 5 | const args = process.argv.slice(2); 6 | 7 | const song = await prisma.song.update({ 8 | where: { 9 | id: parseInt(args[0]), 10 | }, 11 | data: { 12 | mistagLock: true, 13 | mbid: null, 14 | musicbrainzLength: null, 15 | musicbrainzArtist: null, 16 | musicbrainzTitle: null, 17 | coverUrl: null, 18 | smallCoverUrl: null, 19 | }, 20 | }); 21 | 22 | console.log(`Marked ${song.id} as mistagged`); 23 | } 24 | 25 | main() 26 | .then(async () => { 27 | await prisma.$disconnect(); 28 | }) 29 | 30 | .catch(async (e) => { 31 | console.error(e); 32 | 33 | await prisma.$disconnect(); 34 | 35 | process.exit(1); 36 | }); 37 | -------------------------------------------------------------------------------- /scripts/refreshMBMetadata.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { addMusicBrainzInfo } from "../util/musicbrainz"; 3 | const prisma = new PrismaClient(); 4 | 5 | async function main() { 6 | const args = process.argv.slice(2); 7 | 8 | const song = await prisma.song.findUniqueOrThrow({ 9 | where: { 10 | id: parseInt(args[0]), 11 | }, 12 | }); 13 | 14 | console.log("Redoing MusicBrainz lookup for song " + song.id); 15 | await addMusicBrainzInfo(song, song.musicbrainzLength) 16 | } 17 | 18 | main() 19 | .then(async () => { 20 | await prisma.$disconnect(); 21 | }) 22 | 23 | .catch(async (e) => { 24 | console.error(e); 25 | 26 | await prisma.$disconnect(); 27 | 28 | process.exit(1); 29 | }); 30 | -------------------------------------------------------------------------------- /scripts/tagSongByMBID.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { tagByMBID } from "../util/musicbrainz"; 3 | const prisma = new PrismaClient(); 4 | 5 | async function main() { 6 | const args = process.argv.slice(2); 7 | 8 | const id = parseInt(args[0]); 9 | const mbid = args[1]; 10 | 11 | console.log(`Retagging ${id} with MBID ${mbid}`); 12 | tagByMBID(id, mbid); 13 | } 14 | 15 | main() 16 | .then(async () => { 17 | await prisma.$disconnect(); 18 | }) 19 | 20 | .catch(async (e) => { 21 | console.error(e); 22 | 23 | await prisma.$disconnect(); 24 | 25 | process.exit(1); 26 | }); 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "ES2020" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "commonjs" /* Specify what module code is generated. */, 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | "resolveJsonModule": true /* Enable importing .json files. */, 39 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 40 | 41 | /* JavaScript Support */ 42 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 43 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 44 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 45 | 46 | /* Emit */ 47 | //"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 48 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 49 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 50 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 51 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 52 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 53 | // "removeComments": true, /* Disable emitting comments. */ 54 | // "noEmit": true, /* Disable emitting files from a compilation. */ 55 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 56 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 57 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 58 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 61 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 62 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 63 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 64 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 65 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 66 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 67 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 68 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 69 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 70 | 71 | /* Interop Constraints */ 72 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 73 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 74 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 75 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 76 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 77 | 78 | /* Type Checking */ 79 | // "strict": true /* Enable all strict type-checking options. */, 80 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 81 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 82 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 83 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 84 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 85 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 86 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 87 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 88 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 89 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 90 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 91 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 92 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 93 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 94 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 95 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 96 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 97 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 98 | 99 | /* Completeness */ 100 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 101 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 102 | }, 103 | "include": ["**/*.ts"], 104 | "exclude": ["node_modules", "/**", "bin/**"] 105 | } 106 | -------------------------------------------------------------------------------- /util/authPlugin.ts: -------------------------------------------------------------------------------- 1 | import WavebreakerConfig from "../config/wavebreaker_config.json"; 2 | import fastifyJwt from "@fastify/jwt"; 3 | import { FastifyReply, FastifyRequest } from "fastify"; 4 | import fp from "fastify-plugin"; 5 | import cookie from "@fastify/cookie"; 6 | 7 | export default fp(async function (fastify) { 8 | fastify.register(cookie); 9 | 10 | fastify.register(fastifyJwt, { 11 | secret: WavebreakerConfig.token_secret, 12 | cookie: { 13 | cookieName: "Authorization", 14 | signed: false, 15 | }, 16 | }); 17 | 18 | fastify.decorate( 19 | "authenticate", 20 | async function authenticate(request: FastifyRequest, reply: FastifyReply) { 21 | const cookieHeader = request.raw.headers.cookie; 22 | if (cookieHeader) { 23 | request.cookies = fastify.parseCookie(cookieHeader); 24 | } 25 | 26 | try { 27 | await request.jwtVerify(); 28 | } catch (err) { 29 | fastify.log.error("JWT verification failed: " + err); 30 | reply.status(401).send({ error: "Unauthorized" }); 31 | } 32 | } 33 | ); 34 | }); 35 | -------------------------------------------------------------------------------- /util/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient, Song, User } from "@prisma/client"; 2 | import Redis from "ioredis"; 3 | import { getUserRank } from "./rankings"; 4 | 5 | export const redis = new Redis(process.env.REDIS_URL); 6 | 7 | const prismaOrig = new PrismaClient(); 8 | //potentially hacky solution for leaderboard 9 | export const prisma = prismaOrig.$extends({ 10 | name: "leaderboardExt", 11 | query: { 12 | score: { 13 | async upsert({ args, query }) { 14 | //If score already exists, add difference of skillPoints to leaderboard ranking 15 | const score = await prisma.score.findUnique({ where: args.where }); 16 | if (score) { 17 | //create.score and update.score are the same, the latter has a weird secondary type so no comparisons allowed 18 | if (score.score < args.create.score) { 19 | const diff = args.create.skillPoints - score.skillPoints; 20 | await redis.zincrby("leaderboard", diff, score.userId); 21 | } 22 | } else { 23 | await redis.zincrby( 24 | "leaderboard", 25 | args.create.skillPoints, 26 | args.create.userId 27 | ); 28 | } 29 | return query(args); 30 | }, 31 | async delete({ args, query }) { 32 | const score = await prisma.score.findUnique({ where: args.where }); 33 | if (score) { 34 | await redis.zincrby("leaderboard", -score.skillPoints, score.userId); 35 | } 36 | return query(args); 37 | }, 38 | }, 39 | user: { 40 | async delete({ args, query }) { 41 | await redis.zrem("leaderboard", args.where.id); 42 | return query(args); 43 | } 44 | } 45 | }, 46 | /* 47 | result: { 48 | user: { 49 | rank: { 50 | needs: { id: true }, 51 | compute(user) { 52 | return getUserRank(user.id).then((res) => { 53 | return res; 54 | }); 55 | }, 56 | }, 57 | totalSkillPoints: { 58 | needs: { id: true }, 59 | compute(user) { 60 | return redis.zscore("leaderboard", user.id).then((res) => { 61 | return Number(res); 62 | }); 63 | }, 64 | }, 65 | }, 66 | }, 67 | */ 68 | }); 69 | 70 | export async function getUserExtended(user: User): Promise { 71 | const scoreAggregation = await prisma.score.aggregate({ 72 | where: { 73 | userId: user.id, 74 | }, 75 | _sum: { 76 | score: true, 77 | playCount: true, 78 | }, 79 | }); 80 | 81 | //Get user's favorite song (or, rather, song of the score with the most plays) 82 | const favSongScore = await prisma.score.findFirst({ 83 | where: { 84 | userId: user.id, 85 | }, 86 | orderBy: { 87 | playCount: "desc", 88 | }, 89 | include: { 90 | song: true, 91 | }, 92 | }); 93 | 94 | //Get user's most used character 95 | const charGroup = await prisma.score.groupBy({ 96 | by: ["vehicleId"], 97 | where: { 98 | userId: user.id, 99 | }, 100 | _sum: { 101 | playCount: true, 102 | }, 103 | orderBy: { 104 | _sum: { 105 | playCount: "desc", 106 | }, 107 | }, 108 | }); 109 | 110 | return { 111 | ...(await getUserWithRank(user)), 112 | totalScore: scoreAggregation._sum.score ?? 0, 113 | totalPlays: scoreAggregation._sum.playCount ?? 0, 114 | favoriteSong: favSongScore?.song, 115 | ...(charGroup[0] && { favoriteCharacter: charGroup[0].vehicleId }), 116 | }; 117 | } 118 | 119 | export async function getUserWithRank(user: User): Promise { 120 | return { 121 | ...user, 122 | rank: await getUserRank(user.id), 123 | totalSkillPoints: Number(await redis.zscore("leaderboard", user.id)), 124 | }; 125 | } 126 | 127 | export interface UserWithRank extends User { 128 | rank: number; 129 | totalSkillPoints: number; 130 | } 131 | 132 | export interface ExtendedUser extends UserWithRank { 133 | totalScore: number; 134 | totalPlays: number; 135 | favoriteCharacter?: number; 136 | favoriteSong?: Song; 137 | } 138 | -------------------------------------------------------------------------------- /util/discord.ts: -------------------------------------------------------------------------------- 1 | import { WebhookClient, EmbedBuilder } from "discord.js"; 2 | import WavebreakerConfig from "../config/wavebreaker_config.json"; 3 | import { Song, User } from "@prisma/client"; 4 | 5 | export const webhook = new WebhookClient({ 6 | url: WavebreakerConfig.webhookLink, 7 | }); 8 | 9 | export function sendMetadataReport( 10 | user: User, 11 | song: Song, 12 | additionalInfo: string = null 13 | ) { 14 | const embed = new EmbedBuilder() 15 | .setTitle(`Metadata report for song ${song.id} received`) 16 | .setColor(0xffc777) 17 | .addFields([ 18 | { 19 | name: "Song", 20 | value: `${song.artist} - ${song.title}`, 21 | }, 22 | ]); 23 | 24 | if (additionalInfo) { 25 | embed.addFields([{ name: "Additional info", value: additionalInfo }]); 26 | } 27 | if (song.coverUrl) { 28 | embed.setThumbnail(song.coverUrl); 29 | } 30 | 31 | webhook.send({ 32 | username: user.username, 33 | avatarURL: user.avatarUrl, 34 | embeds: [embed], 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /util/gamemodeTags.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-cond-assign */ 2 | //ESLINT IS *POWERLESS* AGAINST THIS. CRY. 3 | 4 | //For your own sanity: do not question anything you are about to see. 5 | //It works. It's beautiful. It's perfect... 6 | const fullTagStringRegex = /(?:\[as-[a-zA-Z0-9]+\]\s*)+$/; 7 | const tagSeparationRegex = /\[as-([a-zA-Z0-9]+)\]/g; 8 | 9 | export function tagsFromTitle(title: string): string[] { 10 | let tagstring; 11 | return Array.from( 12 | (tagstring = title.match(fullTagStringRegex)) ? tagstring[0].matchAll(tagSeparationRegex) : [], 13 | (matchtag) => matchtag[1] 14 | ); 15 | } 16 | 17 | export function removeTagsFromTitle(title: string): string { 18 | return title.slice(0, title.match(fullTagStringRegex).index).trimEnd(); 19 | } 20 | -------------------------------------------------------------------------------- /util/musicbrainz.ts: -------------------------------------------------------------------------------- 1 | import { Song } from "@prisma/client"; 2 | import { 3 | IArtistCredit, 4 | IIsrcSearchResult, 5 | IRecording, 6 | MusicBrainzApi, 7 | } from "musicbrainz-api"; 8 | import { prisma } from "./db"; 9 | 10 | export const mbApi = new MusicBrainzApi({ 11 | appName: "Wavebreaker", 12 | appVersion: "0.0.1", 13 | appContactInfo: "https://github.com/AudiosurfResearch", // Or URL to application home page 14 | }); 15 | 16 | export async function mbSongSearch( 17 | artist: string, 18 | title: string, 19 | length: number 20 | ): Promise { 21 | const search = await mbApi.search("recording", { 22 | query: `recording:${title} AND artist:${artist} AND dur:[${ 23 | length - 6000 24 | } TO ${length + 6000}]`, 25 | }); 26 | 27 | if (search.recordings.length > 0) { 28 | return search.recordings; 29 | } else { 30 | return null; 31 | } 32 | } 33 | 34 | export function mbJoinArtists(artistCredit: IArtistCredit[]): string { 35 | // Join every artist with name + joinphrase, if joinphrase is not empty 36 | // (joinphrase is empty if it's the last artist in the array) 37 | let artistString = ""; 38 | artistCredit.forEach((artist) => { 39 | artistString += artist.name; 40 | if (artist.joinphrase) { 41 | artistString += artist.joinphrase; 42 | } 43 | }); 44 | return artistString; 45 | } 46 | 47 | export async function addMusicBrainzInfo(song: Song, length: number) { 48 | if (song.mistagLock) 49 | throw new Error( 50 | `${song.artist} - ${song.title} is locked because it's prone to mistagging` 51 | ); 52 | 53 | const mbResults = await mbSongSearch(song.artist, song.title, length); 54 | if (mbResults) { 55 | let coverUrl: string = null; 56 | for (const release of mbResults[0].releases) { 57 | const fullRelease = await mbApi.lookupRelease(release.id); 58 | 59 | if (fullRelease["cover-art-archive"].front) { 60 | await fetch( 61 | `https://coverartarchive.org/release/${release.id}/front-500.jpg` 62 | ).then((response) => { 63 | if (response.ok) { 64 | coverUrl = response.url; 65 | } 66 | }); 67 | } 68 | if (coverUrl) break; 69 | } 70 | 71 | await prisma.song.update({ 72 | where: { 73 | id: song.id, 74 | }, 75 | data: { 76 | mbid: mbResults[0].id, 77 | musicbrainzArtist: mbJoinArtists(mbResults[0]["artist-credit"]), 78 | musicbrainzTitle: mbResults[0].title, 79 | musicbrainzLength: mbResults[0].length, 80 | ...(coverUrl && { coverUrl: coverUrl }), 81 | //weird-ish solution but this means i don't have to do two requests to Cover Art Archive 82 | ...(coverUrl && { 83 | smallCoverUrl: coverUrl.replace("_thumb500.jpg", "_thumb.jpg"), 84 | }), 85 | }, 86 | }); 87 | 88 | console.log( 89 | `Found matching MusicBrainz info for ${song.artist} - ${song.title}` 90 | ); 91 | } else { 92 | throw new Error( 93 | `MusicBrainz search for ${song.artist} - ${song.title} failed.` 94 | ); 95 | } 96 | } 97 | 98 | export async function tagByMBID(songId: number, recordingMBID: string) { 99 | const mbRecording = await mbApi.lookupRecording(recordingMBID, [ 100 | "releases", 101 | "artist-credits", 102 | ]); 103 | if (mbRecording) { 104 | let coverUrl: string = null; 105 | for (const release of mbRecording.releases) { 106 | const fullRelease = await mbApi.lookupRelease(release.id); 107 | 108 | if (fullRelease["cover-art-archive"].front) { 109 | await fetch( 110 | `https://coverartarchive.org/release/${release.id}/front-500.jpg` 111 | ).then((response) => { 112 | if (response.ok) { 113 | coverUrl = response.url; 114 | } 115 | }); 116 | } 117 | if (coverUrl) { 118 | console.log("Cover URL found"); 119 | break; 120 | } 121 | } 122 | 123 | await prisma.song.update({ 124 | where: { 125 | id: songId, 126 | }, 127 | data: { 128 | mistagLock: false, 129 | mbid: mbRecording.id, 130 | musicbrainzArtist: mbJoinArtists(mbRecording["artist-credit"]), 131 | musicbrainzTitle: mbRecording.title, 132 | musicbrainzLength: mbRecording.length, 133 | ...(coverUrl && { coverUrl: coverUrl }), 134 | //weird-ish solution but this means i don't have to do two requests to Cover Art Archive 135 | ...(coverUrl && { 136 | smallCoverUrl: coverUrl.replace("_thumb500.jpg", "_thumb.jpg"), 137 | }), 138 | }, 139 | }); 140 | } else { 141 | throw new Error(`Failed to retag ${songId} as ${recordingMBID}.`); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /util/rankings.ts: -------------------------------------------------------------------------------- 1 | /* 2 | TODO: Implement rankings properly, using skill points. 3 | Formula for skill points is Math.round((score / goldThreshold) * multiplier) 4 | The multiplier is 300 for Elite, 200 for Pro and 100 for Casual leagues respectively 5 | Maybe use Redis or something for the ranking stuff as well? 6 | */ 7 | 8 | import { Song } from "@prisma/client"; 9 | import { getUserWithRank, prisma, redis } from "./db"; 10 | 11 | export function calcSkillPoints( 12 | score: number, 13 | goldThreshold: number, 14 | leagueId: number 15 | ): number { 16 | const multiplier = (leagueId + 1) * 100; 17 | return Math.round((score / goldThreshold) * multiplier); 18 | } 19 | 20 | export async function getPopularSongs( 21 | page: number, 22 | pageSize: number, 23 | sort: "asc" | "desc" = "desc" 24 | ): Promise { 25 | return await prisma.song.findMany({ 26 | take: pageSize, 27 | skip: (page - 1) * pageSize, 28 | include: { 29 | _count: { 30 | select: { scores: true }, 31 | }, 32 | }, 33 | orderBy: { 34 | scores: { 35 | _count: sort, 36 | }, 37 | }, 38 | }); 39 | } 40 | 41 | export async function getLeaderboard(page: number, pageSize: number) { 42 | //Get leaderboard from Redis 43 | const leaderboardUsers = ( 44 | await redis.zrevrange( 45 | "leaderboard", 46 | (page - 1) * pageSize, 47 | page * pageSize - 1 48 | ) 49 | ).map(Number); 50 | //Get full users from Prisma 51 | const users = await prisma.user.findMany({ 52 | where: { 53 | id: { 54 | in: leaderboardUsers, 55 | }, 56 | }, 57 | }); 58 | //Order users in the same order as in the leaderboardUsers array 59 | //and turn them into UserWithRank objects 60 | users.sort((a, b) => { 61 | return leaderboardUsers.indexOf(a.id) - leaderboardUsers.indexOf(b.id); 62 | }); 63 | const usersWithRank = await Promise.all(users.map(getUserWithRank)); 64 | return usersWithRank; 65 | } 66 | 67 | export async function getUserRank(userId: number): Promise { 68 | const rank = await redis.zrevrank("leaderboard", userId); 69 | return rank + 1; 70 | } 71 | -------------------------------------------------------------------------------- /util/schemaTypes.ts: -------------------------------------------------------------------------------- 1 | import { Type } from "@sinclair/typebox"; 2 | 3 | export function StringEnum(values: [...T]) { 4 | return Type.Unsafe({ type: "string", enum: values }); 5 | } 6 | -------------------------------------------------------------------------------- /util/steam.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient, User, Prisma } from "@prisma/client"; 2 | import WavebreakerConfig from "../config/wavebreaker_config.json"; 3 | import SteamAPI from "steamapi"; 4 | 5 | const prisma = new PrismaClient(); 6 | 7 | interface SteamTokenValidationResponse { 8 | response: { 9 | params: { 10 | result: string; 11 | steamid: string; 12 | ownersteamid: string; 13 | vacbanned: boolean; 14 | publisherbanned: boolean; 15 | }; 16 | }; 17 | } 18 | 19 | type UserWithRivals = Prisma.UserGetPayload<{ 20 | include: { rivals: true, challengers: true }; 21 | }>; 22 | 23 | export const steamApi = new SteamAPI(WavebreakerConfig.steam.apiKey); 24 | 25 | export async function findUserByTicket(ticket: string): Promise { 26 | const ticketResponse = await verifySteamTicket(ticket); 27 | 28 | const user: User = await prisma.user.findFirstOrThrow({ 29 | where: { 30 | steamid64: ticketResponse.response.params.steamid, 31 | } 32 | }); 33 | return user; 34 | } 35 | 36 | export async function findUserWithRivalsByTicket(ticket: string): Promise { 37 | const ticketResponse = await verifySteamTicket(ticket); 38 | 39 | const user: UserWithRivals = await prisma.user.findFirstOrThrow({ 40 | where: { 41 | steamid64: ticketResponse.response.params.steamid, 42 | }, 43 | include: { 44 | rivals: true, 45 | challengers: true, 46 | } 47 | }); 48 | return user; 49 | } 50 | 51 | export async function verifySteamTicket( 52 | ticket: string 53 | ): Promise { 54 | const apiCheckUrl = 55 | "https://api.steampowered.com/ISteamUserAuth/AuthenticateUserTicket/v1/?key=" + 56 | WavebreakerConfig.steam.apiKey + 57 | "&appid=12900&ticket=" + 58 | ticket; 59 | const response = await fetch(apiCheckUrl); 60 | const jsonData: SteamTokenValidationResponse = await response.json(); 61 | if (jsonData.response.params.result == "OK") return jsonData; 62 | else throw new Error("Ticket validation failed"); 63 | } 64 | --------------------------------------------------------------------------------