├── .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 |
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 |
--------------------------------------------------------------------------------