Skip to content

Commit

Permalink
chore(self hosting): run selfhosted backend in prod mode (@fehmer) (#…
Browse files Browse the repository at this point in the history
…6326)

Co-authored-by: Miodec <[email protected]>
  • Loading branch information
fehmer and Miodec authored Mar 3, 2025
1 parent 7d7118f commit f80dde4
Show file tree
Hide file tree
Showing 14 changed files with 116 additions and 75 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/publish-docker-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion backend/src/anticheat/index.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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;
}

Expand All @@ -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;
}
7 changes: 7 additions & 0 deletions backend/src/constants/base-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -548,6 +549,12 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema<Configuration> = {
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",
Expand Down
46 changes: 39 additions & 7 deletions backend/src/dal/leaderboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand Down Expand Up @@ -269,7 +272,11 @@ export async function update(
};
}

async function createIndex(key: string): Promise<void> {
async function createIndex(
key: string,
minTimeTyping: number,
dropIfMismatch = true
): Promise<void> {
const index = {
[`${key}.wpm`]: -1,
[`${key}.acc`]: -1,
Expand All @@ -293,16 +300,41 @@ async function createIndex(key: string): Promise<void> {
$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<void> {
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...");
Expand Down
30 changes: 29 additions & 1 deletion backend/src/init/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -138,3 +149,20 @@ export async function patchConfiguration(

return true;
}

export async function updateFromConfigurationFile(): Promise<void> {
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);
}
}
7 changes: 5 additions & 2 deletions backend/src/init/email-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,12 @@ export async function init(): Promise<void> {
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 {
Expand Down
6 changes: 5 additions & 1 deletion backend/src/server.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -30,6 +33,7 @@ async function bootServer(port: number): Promise<Server> {
Logger.info("Fetching live configuration...");
await getLiveConfiguration();
Logger.success("Live configuration fetched");
await updateFromConfigurationFile();

Logger.info("Initializing email client...");
await EmailClient.init();
Expand Down
3 changes: 3 additions & 0 deletions docker/backend-configuration.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
},
"dailyLeaderboards": {
"enabled": false
},
"leaderboards":{
"minTimeTyping": 0
}
}
}
15 changes: 9 additions & 6 deletions docker/backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
22 changes: 0 additions & 22 deletions docker/backend/applyConfig.sh

This file was deleted.

1 change: 0 additions & 1 deletion docker/backend/entry-point.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
#!/bin/sh
./applyConfig.sh &
node server.js
34 changes: 1 addition & 33 deletions docs/SELF_HOSTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/contracts/src/rate-limit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const limits = {
},

adminLimit: {
window: 5000, //5 seconds
window: 5000,
max: 1,
},

Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/schemas/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down

0 comments on commit f80dde4

Please sign in to comment.