diff --git a/.github/workflows/publish-docker-images.yml b/.github/workflows/publish-docker-images.yml index 792b92561..66c91453b 100644 --- a/.github/workflows/publish-docker-images.yml +++ b/.github/workflows/publish-docker-images.yml @@ -48,6 +48,8 @@ jobs: push: true tags: ${{ env.BE_REPO }}:latest,${{ steps.bemeta.outputs.tags }} labels: ${{ steps.bemeta.outputs.labels }} + build-args: | + server_version: {{version}} - name: Backend publish description uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae diff --git a/backend/src/anticheat/index.ts b/backend/src/anticheat/index.ts index 57c52aa34..715b799e5 100644 --- a/backend/src/anticheat/index.ts +++ b/backend/src/anticheat/index.ts @@ -1,10 +1,16 @@ +const hasAnticheatImplemented = process.env["BYPASS_ANTICHEAT"] === "true"; + import { CompletedEvent, KeyStats, } from "@monkeytype/contracts/schemas/results"; +import Logger from "../utils/logger"; export function implemented(): boolean { - return false; + if (hasAnticheatImplemented) { + Logger.warning("BYPASS_ANTICHEAT is enabled! Running without anti-cheat."); + } + return hasAnticheatImplemented; } export function validateResult( @@ -13,6 +19,7 @@ export function validateResult( _uaStringifiedObject: string, _lbOptOut: boolean ): boolean { + Logger.warning("No anticheat module found, result will not be validated."); return true; } @@ -22,5 +29,6 @@ export function validateKeys( _keyDurationStats: KeyStats, _uid: string ): boolean { + Logger.warning("No anticheat module found, key data will not be validated."); return true; } diff --git a/backend/src/constants/base-configuration.ts b/backend/src/constants/base-configuration.ts index 6e5c44a8e..c4c77a770 100644 --- a/backend/src/constants/base-configuration.ts +++ b/backend/src/constants/base-configuration.ts @@ -96,6 +96,7 @@ export const BASE_CONFIGURATION: Configuration = { xpRewardBrackets: [], }, leaderboards: { + minTimeTyping: 2 * 60 * 60, weeklyXp: { enabled: false, expirationTimeInDays: 0, // This should atleast be 15 @@ -548,6 +549,12 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema = { type: "object", label: "Leaderboards", fields: { + minTimeTyping: { + type: "number", + label: "Minimum typing time the user needs to get on a leaderboard", + hint: "Typing time in seconds. Change is only applied after restarting the server.", + min: 0, + }, weeklyXp: { type: "object", label: "Weekly XP", diff --git a/backend/src/dal/leaderboards.ts b/backend/src/dal/leaderboards.ts index da1ac3d2b..76cf52932 100644 --- a/backend/src/dal/leaderboards.ts +++ b/backend/src/dal/leaderboards.ts @@ -3,13 +3,16 @@ import Logger from "../utils/logger"; import { performance } from "perf_hooks"; import { setLeaderboard } from "../utils/prometheus"; import { isDevEnvironment } from "../utils/misc"; -import { getCachedConfiguration } from "../init/configuration"; +import { + getCachedConfiguration, + getLiveConfiguration, +} from "../init/configuration"; import { addLog } from "./logs"; import { Collection, ObjectId } from "mongodb"; import { LeaderboardEntry } from "@monkeytype/contracts/schemas/leaderboards"; import { omit } from "lodash"; -import { DBUser } from "./user"; +import { DBUser, getUsersCollection } from "./user"; import MonkeyError from "../utils/error"; export type DBLeaderboardEntry = LeaderboardEntry & { @@ -269,7 +272,11 @@ export async function update( }; } -async function createIndex(key: string): Promise { +async function createIndex( + key: string, + minTimeTyping: number, + dropIfMismatch = true +): Promise { const index = { [`${key}.wpm`]: -1, [`${key}.acc`]: -1, @@ -293,16 +300,41 @@ async function createIndex(key: string): Promise { $gt: 0, }, timeTyping: { - $gt: isDevEnvironment() ? 0 : 7200, + $gt: minTimeTyping, }, }, }; - await db.collection("users").createIndex(index, partial); + try { + await getUsersCollection().createIndex(index, partial); + } catch (e) { + if (!dropIfMismatch) throw e; + if ( + (e as Error).message.startsWith( + "An existing index has the same name as the requested index" + ) + ) { + Logger.warning(`Index ${key} not matching, dropping and recreating...`); + + const existingIndex = (await getUsersCollection().listIndexes().toArray()) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + .map((it) => it.name as string) + .find((it) => it.startsWith(key)); + + if (existingIndex !== undefined && existingIndex !== null) { + await getUsersCollection().dropIndex(existingIndex); + return createIndex(key, minTimeTyping, false); + } else { + throw e; + } + } + } } export async function createIndicies(): Promise { - await createIndex("lbPersonalBests.time.15.english"); - await createIndex("lbPersonalBests.time.60.english"); + const minTimeTyping = (await getLiveConfiguration()).leaderboards + .minTimeTyping; + await createIndex("lbPersonalBests.time.15.english", minTimeTyping); + await createIndex("lbPersonalBests.time.60.english", minTimeTyping); if (isDevEnvironment()) { Logger.info("Updating leaderboards in dev mode..."); diff --git a/backend/src/init/configuration.ts b/backend/src/init/configuration.ts index 4ac432208..99d316d52 100644 --- a/backend/src/init/configuration.ts +++ b/backend/src/init/configuration.ts @@ -6,10 +6,21 @@ import { identity } from "../utils/misc"; import { BASE_CONFIGURATION } from "../constants/base-configuration"; import { Configuration } from "@monkeytype/contracts/schemas/configuration"; import { addLog } from "../dal/logs"; -import { PartialConfiguration } from "@monkeytype/contracts/configuration"; +import { + PartialConfiguration, + PartialConfigurationSchema, +} from "@monkeytype/contracts/configuration"; import { getErrorMessage } from "../utils/error"; +import { join } from "path"; +import { existsSync, readFileSync } from "fs"; +import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json"; +import { z } from "zod"; const CONFIG_UPDATE_INTERVAL = 10 * 60 * 1000; // 10 Minutes +const SERVER_CONFIG_FILE_PATH = join( + __dirname, + "../backend-configuration.json" +); function mergeConfigurations( baseConfiguration: Configuration, @@ -138,3 +149,20 @@ export async function patchConfiguration( return true; } + +export async function updateFromConfigurationFile(): Promise { + if (existsSync(SERVER_CONFIG_FILE_PATH)) { + Logger.info( + `Reading server configuration from file ${SERVER_CONFIG_FILE_PATH}` + ); + const json = readFileSync(SERVER_CONFIG_FILE_PATH, "utf-8"); + const data = parseJsonWithSchema( + json, + z.object({ + configuration: PartialConfigurationSchema, + }) + ); + + await patchConfiguration(data.configuration); + } +} diff --git a/backend/src/init/email-client.ts b/backend/src/init/email-client.ts index 8d2ebb71f..b98bbd983 100644 --- a/backend/src/init/email-client.ts +++ b/backend/src/init/email-client.ts @@ -44,9 +44,12 @@ export async function init(): Promise { Logger.warning( "No email client configuration provided. Running without email." ); - return; + } else if (process.env["BYPASS_EMAILCLIENT"] === "true") { + Logger.warning("BYPASS_EMAILCLIENT is enabled! Running without email."); + } else { + throw new Error("No email client configuration provided"); } - throw new Error("No email client configuration provided"); + return; } try { diff --git a/backend/src/server.ts b/backend/src/server.ts index 2f4a0a5fe..b4415dcc4 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,7 +1,10 @@ import "dotenv/config"; import * as db from "./init/db"; import jobs from "./jobs"; -import { getLiveConfiguration } from "./init/configuration"; +import { + getLiveConfiguration, + updateFromConfigurationFile, +} from "./init/configuration"; import app from "./app"; import { Server } from "http"; import { version } from "./version"; @@ -30,6 +33,7 @@ async function bootServer(port: number): Promise { Logger.info("Fetching live configuration..."); await getLiveConfiguration(); Logger.success("Live configuration fetched"); + await updateFromConfigurationFile(); Logger.info("Initializing email client..."); await EmailClient.init(); diff --git a/docker/backend-configuration.json b/docker/backend-configuration.json index 1fc3cddb6..4b9cfa0bb 100644 --- a/docker/backend-configuration.json +++ b/docker/backend-configuration.json @@ -11,6 +11,9 @@ }, "dailyLeaderboards": { "enabled": false + }, + "leaderboards":{ + "minTimeTyping": 0 } } } \ No newline at end of file diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index e9b3774c4..ecb9442ed 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -20,9 +20,8 @@ RUN pnpm deploy --filter backend --prod /prod/backend ## target image FROM node:20.16.0-alpine3.19 -##install wget, used by the applyConfig script -RUN apk update --no-cache && \ - apk add --no-cache wget +## get server_version from build-arg, default to UNKNOWN +ARG server_version=UNKNOWN # COPY to target COPY --from=builder /prod/backend/node_modules /app/backend/node_modules @@ -37,10 +36,14 @@ WORKDIR /app/backend/dist ## logs RUN mkdir -p /app/backend/dist/logs -COPY ["docker/backend/entry-point.sh", "docker/backend/applyConfig.sh", "./"] +COPY ["docker/backend/entry-point.sh", "./"] -#run in dev mode (no anticheat) -ENV MODE=dev +RUN echo "${server_version}" > /app/backend/dist/server.version + +#run in prod mode, but don't require anti-cheat or email client +ENV MODE=prod +ENV BYPASS_ANTICHEAT=true +ENV BYPASS_EMAILCLIENT=true EXPOSE 5005 USER node diff --git a/docker/backend/applyConfig.sh b/docker/backend/applyConfig.sh deleted file mode 100755 index f3360b396..000000000 --- a/docker/backend/applyConfig.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/sh -if [ -f backend-configuration.json ]; then - echo "waiting for backend..." - - timeout 30 sh -c 'until nc -z $0 $1; do sleep 1; done' localhost 5005 - - if [ $? -ne 0 ]; then - echo "failed to apply config" - exit 1 - fi - - echo "apply server config" - - wget -qO- --method=PATCH \ - --body-data="`cat backend-configuration.json`" \ - --header='Content-Type:application/json' \ - http://localhost:5005/configuration - - echo "server config applied" -else - echo "skip backend configuration" -fi diff --git a/docker/backend/entry-point.sh b/docker/backend/entry-point.sh index 5e31809b6..86be423ec 100644 --- a/docker/backend/entry-point.sh +++ b/docker/backend/entry-point.sh @@ -1,3 +1,2 @@ #!/bin/sh -./applyConfig.sh & node server.js \ No newline at end of file diff --git a/docs/SELF_HOSTING.md b/docs/SELF_HOSTING.md index 80d4e25ce..c9900af9f 100644 --- a/docs/SELF_HOSTING.md +++ b/docs/SELF_HOSTING.md @@ -162,39 +162,7 @@ Contains your firebase config, only needed if you want to allow users to signup. ### backend-configuration.json -Configuration of the backend. - -If you don't want to update this file manually you can - -- open the backend url in your browser, e.g. `http://localhost:5005/configure/` -- adjust the settings and click `Save Changes` -- click `Export Configuration` -- save the file as `backend-configuration.json`, overwriting the existing one. - -Example output from `http://localhost:5005/configuration`: -```json -{ - "message": "Configuration retrieved", - "data": - { - "maintenance": false, - "results": {}, - .... - } -} -``` - -Example content from `backend-configuration.json`: -``` -{ - "maintenance": false, - "results": {}, - .... -} -``` - -If you have `curl` and `jq` installed you can also run `curl -wO- http://localhost:5005/configuration | jq ".data" > backend-configuration.json` to update the configuration file. - +Configuration of the backend. Check the [default configuration](https://github.com/monkeytypegame/monkeytype/blob/master/backend/src/constants/base-configuration.ts#L8) for possible values. > [!NOTE] > The configuration is applied on container startup only. You have to restart the container for your changes to become active. diff --git a/packages/contracts/src/rate-limit/index.ts b/packages/contracts/src/rate-limit/index.ts index 14a72bc34..927aab9f9 100644 --- a/packages/contracts/src/rate-limit/index.ts +++ b/packages/contracts/src/rate-limit/index.ts @@ -13,7 +13,7 @@ export const limits = { }, adminLimit: { - window: 5000, //5 seconds + window: 5000, max: 1, }, diff --git a/packages/contracts/src/schemas/configuration.ts b/packages/contracts/src/schemas/configuration.ts index 6a6cc3d09..1d4b404f2 100644 --- a/packages/contracts/src/schemas/configuration.ts +++ b/packages/contracts/src/schemas/configuration.ts @@ -111,6 +111,12 @@ export const ConfigurationSchema = z.object({ xpRewardBrackets: z.array(RewardBracketSchema), }), leaderboards: z.object({ + minTimeTyping: z + .number() + .min(0) + .describe( + "Minimum typing time (in seconds) the user needs to get on a leaderboard" + ), weeklyXp: z.object({ enabled: z.boolean(), expirationTimeInDays: z.number().nonnegative(),