Merge branch 'master' into newtribemerge

This commit is contained in:
Miodec 2024-08-05 17:21:55 +02:00
commit d8d8b335ec
200 changed files with 900543 additions and 35530 deletions

View file

@ -12,9 +12,9 @@
- Also please add a screenshot of the theme, it would be extra awesome if you do so!
- [ ] Check if any open issues are related to this PR; if so, be sure to tag them below.
- [ ] Make sure the PR title follows the Conventional Commits standard. (https://www.conventionalcommits.org for more info)
- [ ] Make sure to include your GitHub username inside parentheses at the end of the PR title
- [ ] Make sure to include your GitHub username prefixed with @ inside parentheses at the end of the PR title.
<!-- label(optional scope): pull request title (your_github_username) -->
<!-- label(optional scope): pull request title (@your_github_username) -->
<!-- I know I know they seem boring but please do them, they help us and you will find out it also helps you.-->

View file

@ -1,7 +1,8 @@
name: Monkey CI
env:
NODE_VERSION: "18.20.4"
PNPM_VERSION: "9.6.0"
NODE_VERSION: "20.16.0"
RECAPTCHA_SITE_KEY: "6Lc-V8McAAAAAJ7s6LGNe7MBZnRiwbsbiWts87aj"
permissions:
@ -68,32 +69,45 @@ jobs:
needs: [pre-ci]
if: needs.pre-ci.outputs.should-build-be == 'true' || needs.pre-ci.outputs.should-build-fe == 'true' || needs.pre-ci.outputs.should-build-pkg == 'true' || needs.pre-ci.outputs.assets-json == 'true'
steps:
- name: Checkout package-lock
- name: Checkout pnpm-lock
uses: actions/checkout@v4
with:
sparse-checkout: |
package-lock.json
pnpm-lock.yaml
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Cache node modules
id: cache-npm
id: cache-pnpm
uses: actions/cache@v4
env:
cache-name: cache-node-modules
cache-name: node-modules
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-${{ env.NODE_VERSION }}-build-${{ env.cache-name }}-${{ hashFiles('pnpm-lock.yaml') }}
- if: ${{ steps.cache-npm.outputs.cache-hit != 'true' }}
- if: ${{ steps.cache-pnpm.outputs.cache-hit != 'true' }}
name: Full checkout
uses: actions/checkout@v4
- if: ${{ steps.cache-npm.outputs.cache-hit != 'true' }}
- if: ${{ steps.cache-pnpm.outputs.cache-hit != 'true' }}
name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- if: ${{ steps.cache-pnpm.outputs.cache-hit != 'true' }}
name: Install dependencies
run: npm install --prefer-offline --no-audit
run: pnpm install
check-pretty:
name: check-pretty
@ -102,26 +116,33 @@ jobs:
if: needs.pre-ci.outputs.should-build-be == 'true' || needs.pre-ci.outputs.should-build-fe == 'true' || needs.pre-ci.outputs.should-build-pkg == 'true' || needs.pre-ci.outputs.assets-json == 'true'
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Cache node modules
id: cache-npm
id: cache-pnpm
uses: actions/cache@v4
env:
cache-name: cache-node-modules
cache-name: node-modules
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-${{ env.NODE_VERSION }}-build-${{ env.cache-name }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: npm install --prefer-offline --no-audit
run: pnpm install
- name: Check pretty (backend)
id: check-pretty-be
@ -147,29 +168,36 @@ jobs:
name: ci-be
needs: [pre-ci, prime-cache, check-pretty]
runs-on: ubuntu-latest
if: needs.pre-ci.outputs.should-build-be == 'true'
if: needs.pre-ci.outputs.should-build-be == 'true' || needs.pre-ci.outputs.should-build-pkg == 'true'
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Cache node modules
id: cache-npm
id: cache-pnpm
uses: actions/cache@v4
env:
cache-name: cache-node-modules
cache-name: node-modules
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-${{ env.NODE_VERSION }}-build-${{ env.cache-name }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: npm install --prefer-offline --no-audit
run: pnpm install
- name: Check lint
run: npm run lint-be
@ -184,9 +212,11 @@ jobs:
name: ci-fe
needs: [pre-ci, prime-cache, check-pretty]
runs-on: ubuntu-latest
if: needs.pre-ci.outputs.should-build-fe == 'true'
if: needs.pre-ci.outputs.should-build-fe == 'true' || needs.pre-ci.outputs.should-build-pkg == 'true'
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
@ -196,21 +226,27 @@ jobs:
working-directory: ./frontend/src/ts/constants
run: mv ./firebase-config-example.ts ./firebase-config.ts && cp ./firebase-config.ts ./firebase-config-live.ts
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Cache node modules
id: cache-npm
id: cache-pnpm
uses: actions/cache@v4
env:
cache-name: cache-node-modules
cache-name: node-modules
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-${{ env.NODE_VERSION }}-build-${{ env.cache-name }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: npm install --prefer-offline --no-audit
run: pnpm install
- name: Check lint
run: npm run lint-fe
@ -228,6 +264,7 @@ jobs:
if: needs.pre-ci.outputs.assets-json == 'true'
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
@ -242,26 +279,33 @@ jobs:
- 'frontend/static/themes/*.json'
- 'frontend/static/challenges/*.json'
- 'frontend/static/layouts/*.json'
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Cache node modules
id: cache-npm
id: cache-pnpm
uses: actions/cache@v4
env:
cache-name: cache-node-modules
cache-name: node-modules
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-${{ env.NODE_VERSION }}-build-${{ env.cache-name }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: npm install --prefer-offline --no-audit
run: pnpm install
- name: Lint JSON
run: npm run pr-check-lint-json
@ -284,27 +328,35 @@ jobs:
runs-on: ubuntu-latest
if: needs.pre-ci.outputs.should-build-pkg == 'true'
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Cache node modules
id: cache-npm
id: cache-pnpm
uses: actions/cache@v4
env:
cache-name: cache-node-modules
cache-name: node-modules
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-${{ env.NODE_VERSION }}-build-${{ env.cache-name }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: npm install --prefer-offline --no-audit
run: pnpm install
- name: Check lint
run: npm run lint-pkg

View file

@ -23,7 +23,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "18.20.4"
node-version: "20.16.0"
- name: Install dependencies
run: npm i prettier@2.5.1 --save-dev --save-exact

View file

@ -6,6 +6,7 @@ on:
- opened
- edited
- synchronize
- reopened
permissions:
pull-requests: write
@ -15,7 +16,8 @@ jobs:
name: check
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
- name: Lint and verify PR title
uses: amannn/action-semantic-pull-request@v5
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -34,7 +36,16 @@ jobs:
style
test
requireScope: false
subjectPattern: ^.+ \(@[^ ,]+(, @[^ ,]+)*\)$
subjectPatternError: |
Title "{title}"
didn't match the configured pattern. Please ensure that the title
contains your name so that you can be credited in our changelog.
A correct version would look something like:
feat: add new feature (@github-username)
fix: resolve bug (@github-username)
- uses: marocchino/sticky-pull-request-comment@v2
# When the previous steps fails, the workflow would stop. By adding this
# condition you can continue the execution with the populated error message.
@ -44,7 +55,7 @@ jobs:
message: |
Hey there and thank you for opening this pull request! 👋🏼
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and also include the author name at the end inside round brackets. It looks like your proposed title needs to be adjusted.
Details:

1
.npmrc
View file

@ -1,2 +1,3 @@
engine-strict=true
save-exact=true
save-prefix=''

2
.nvmrc
View file

@ -1 +1 @@
18.20.4
20.16.0

View file

@ -17,4 +17,5 @@ backend/globalConfig.json
frontend/public
dist/
build/
frontend/coverage
frontend/coverage
pnpm*.yaml

View file

@ -1,29 +0,0 @@
{
"hooks": {
"before:init": [
"npx turbo lint test validate-json build --filter @monkeytype/frontend"
],
"before:release": [
"cd frontend && npx firebase deploy -P live --only hosting",
"sh ./bin/purgeCfCache.sh"
]
},
"git": {
"commitMessage": "chore: release v${version}",
"requireCleanWorkingDir": false,
"changelog": "node bin/buildChangelog.mjs"
},
"github": {
"release": true
},
"npm": {
"publish": false,
"ignoreVersion": true
},
"plugins": {
"@csmith/release-it-calver-plugin": {
"format": "yy.ww.minor",
"increment": "calendar.minor"
}
}
}

View file

@ -1,28 +0,0 @@
{
"hooks": {
"before:init": ["npx turbo lint test validate-json build"],
"before:release": [
"sh ./bin/deployBackend.sh",
"cd frontend && npx firebase deploy -P live --only hosting",
"sh ./bin/purgeCfCache.sh"
]
},
"git": {
"commitMessage": "chore: release v${version}",
"requireCleanWorkingDir": false,
"changelog": "node bin/buildChangelog.mjs"
},
"github": {
"release": true
},
"npm": {
"publish": false,
"ignoreVersion": true
},
"plugins": {
"@csmith/release-it-calver-plugin": {
"format": "yy.ww.minor",
"increment": "calendar.minor"
}
}
}

View file

@ -0,0 +1,422 @@
import request from "supertest";
import app from "../../../src/app";
import * as ApeKeyDal from "../../../src/dal/ape-keys";
import { ObjectId } from "mongodb";
import * as Configuration from "../../../src/init/configuration";
import * as UserDal from "../../../src/dal/user";
import _ from "lodash";
const mockApp = request(app);
const configuration = Configuration.getCachedConfiguration();
const uid = new ObjectId().toHexString();
describe("ApeKeyController", () => {
const getUserMock = vi.spyOn(UserDal, "getUser");
beforeEach(async () => {
await enableApeKeysEndpoints(true);
getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: true }));
vi.useFakeTimers();
vi.setSystemTime(1000);
});
afterEach(() => {
getUserMock.mockReset();
vi.useRealTimers();
});
describe("get ape keys", () => {
const getApeKeysMock = vi.spyOn(ApeKeyDal, "getApeKeys");
afterEach(() => {
getApeKeysMock.mockReset();
});
it("should get the users config", async () => {
//GIVEN
const keyOne = apeKeyDb(uid);
const keyTwo = apeKeyDb(uid);
getApeKeysMock.mockResolvedValue([keyOne, keyTwo]);
//WHEN
const { body } = await mockApp
.get("/ape-keys")
.set("authorization", `Uid ${uid}`)
.expect(200);
//THEN
expect(body).toHaveProperty("message", "ApeKeys retrieved");
expect(body.data).toHaveProperty(keyOne._id.toHexString(), {
name: keyOne.name,
enabled: keyOne.enabled,
createdOn: keyOne.createdOn,
modifiedOn: keyOne.modifiedOn,
lastUsedOn: keyOne.lastUsedOn,
});
expect(body.data).toHaveProperty(keyTwo._id.toHexString(), {
name: keyTwo.name,
enabled: keyTwo.enabled,
createdOn: keyTwo.createdOn,
modifiedOn: keyTwo.modifiedOn,
lastUsedOn: keyTwo.lastUsedOn,
});
expect(body.data).keys([keyOne._id, keyTwo._id]);
expect(getApeKeysMock).toHaveBeenCalledWith(uid);
});
it("should fail if apeKeys endpoints are disabled", async () => {
//GIVEN
await enableApeKeysEndpoints(false);
//WHEN
const { body } = await mockApp
.get("/ape-keys")
.set("authorization", `Uid ${uid}`)
.expect(503);
//THEN
expect(body.message).toEqual("ApeKeys are currently disabled.");
});
it("should fail if user has no apeKey permissions", async () => {
//GIVEN
getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false }));
//WHEN
const { body } = await mockApp
.get("/ape-keys")
.set("authorization", `Uid ${uid}`)
.expect(403);
//THEN
expect(body.message).toEqual(
"You have lost access to ape keys, please contact support"
);
});
});
describe("add ape key", () => {
const addApeKeyMock = vi.spyOn(ApeKeyDal, "addApeKey");
const countApeKeysMock = vi.spyOn(ApeKeyDal, "countApeKeysForUser");
beforeEach(() => {
countApeKeysMock.mockResolvedValue(0);
});
afterEach(() => {
addApeKeyMock.mockReset();
countApeKeysMock.mockReset();
});
it("should add ape key", async () => {
//GIVEN
addApeKeyMock.mockResolvedValue("1");
//WHEN
const { body } = await mockApp
.post("/ape-keys")
.set("authorization", `Uid ${uid}`)
.send({ name: "test", enabled: true })
.expect(200);
expect(body.message).toEqual("ApeKey generated");
expect(body.data).keys("apeKey", "apeKeyDetails", "apeKeyId");
expect(body.data.apeKey).not.toBeNull();
expect(body.data.apeKeyDetails).toStrictEqual({
createdOn: 1000,
enabled: true,
lastUsedOn: -1,
modifiedOn: 1000,
name: "test",
});
expect(body.data.apeKeyId).toEqual("1");
expect(addApeKeyMock).toHaveBeenCalledWith(
expect.objectContaining({
createdOn: 1000,
enabled: true,
lastUsedOn: -1,
modifiedOn: 1000,
name: "test",
uid: uid,
useCount: 0,
})
);
});
it("should fail without mandatory properties", async () => {
//WHEN
const { body } = await mockApp
.post("/ape-keys")
.send({})
.set("authorization", `Uid ${uid}`)
.expect(422);
//THEN
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: [`"name" Required`, `"enabled" Required`],
});
});
it("should fail with extra properties", async () => {
//WHEN
const { body } = await mockApp
.post("/ape-keys")
.send({ name: "test", enabled: true, extra: "value" })
.set("authorization", `Uid ${uid}`)
.expect(422);
//THEN
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
});
});
it("should fail if max apeKeys is reached", async () => {
//GIVEN
countApeKeysMock.mockResolvedValue(1);
//WHEN
const { body } = await mockApp
.post("/ape-keys")
.send({ name: "test", enabled: false })
.set("authorization", `Uid ${uid}`)
.expect(409);
//THEN
expect(body.message).toEqual(
"Maximum number of ApeKeys have been generated"
);
});
it("should fail if apeKeys endpoints are disabled", async () => {
//GIVEN
await enableApeKeysEndpoints(false);
//WHEN
const { body } = await mockApp
.post("/ape-keys")
.send({ name: "test", enabled: false })
.set("authorization", `Uid ${uid}`)
.expect(503);
//THEN
expect(body.message).toEqual("ApeKeys are currently disabled.");
});
it("should fail if user has no apeKey permissions", async () => {
//GIVEN
getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false }));
//WHEN
const { body } = await mockApp
.post("/ape-keys")
.send({ name: "test", enabled: false })
.set("authorization", `Uid ${uid}`)
.expect(403);
//THEN
expect(body.message).toEqual(
"You have lost access to ape keys, please contact support"
);
});
});
describe("edit ape key", () => {
const editApeKeyMock = vi.spyOn(ApeKeyDal, "editApeKey");
const apeKeyId = new ObjectId().toHexString();
afterEach(() => {
editApeKeyMock.mockReset();
});
it("should edit ape key", async () => {
//GIVEN
editApeKeyMock.mockResolvedValue();
//WHEN
const { body } = await mockApp
.patch(`/ape-keys/${apeKeyId}`)
.send({ name: "new", enabled: false })
.set("authorization", `Uid ${uid}`)
.expect(200);
//THEN
expect(body.message).toEqual("ApeKey updated");
expect(editApeKeyMock).toHaveBeenCalledWith(uid, apeKeyId, "new", false);
});
it("should edit ape key with single property", async () => {
//GIVEN
editApeKeyMock.mockResolvedValue();
//WHEN
const { body } = await mockApp
.patch(`/ape-keys/${apeKeyId}`)
.send({ name: "new" })
.set("authorization", `Uid ${uid}`)
.expect(200);
//THEN
expect(body.message).toEqual("ApeKey updated");
expect(editApeKeyMock).toHaveBeenCalledWith(
uid,
apeKeyId,
"new",
undefined
);
});
it("should fail with missing path", async () => {
//GIVEN
//WHEN
await mockApp
.patch(`/ape-keys/`)
.set("authorization", `Uid ${uid}`)
.expect(404);
});
it("should fail with extra properties", async () => {
//GIVEN
//WHEN
const { body } = await mockApp
.patch(`/ape-keys/${apeKeyId}`)
.send({ name: "new", extra: "value" })
.set("authorization", `Uid ${uid}`)
.expect(422);
//THEN
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
});
});
it("should fail if apeKeys endpoints are disabled", async () => {
//GIVEN
await enableApeKeysEndpoints(false);
//WHEN
const { body } = await mockApp
.patch(`/ape-keys/${apeKeyId}`)
.send({ name: "test", enabled: false })
.set("authorization", `Uid ${uid}`)
.expect(503);
//THEN
expect(body.message).toEqual("ApeKeys are currently disabled.");
});
it("should fail if user has no apeKey permissions", async () => {
//GIVEN
getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false }));
//WHEN
const { body } = await mockApp
.patch(`/ape-keys/${apeKeyId}`)
.send({ name: "test", enabled: false })
.set("authorization", `Uid ${uid}`)
.expect(403);
//THEN
expect(body.message).toEqual(
"You have lost access to ape keys, please contact support"
);
});
});
describe("delete ape key", () => {
const deleteApeKeyMock = vi.spyOn(ApeKeyDal, "deleteApeKey");
const apeKeyId = new ObjectId().toHexString();
afterEach(() => {
deleteApeKeyMock.mockReset();
});
it("should delete ape key", async () => {
//GIVEN
deleteApeKeyMock.mockResolvedValue();
//WHEN
const { body } = await mockApp
.delete(`/ape-keys/${apeKeyId}`)
.set("authorization", `Uid ${uid}`)
.expect(200);
//THEN
expect(body.message).toEqual("ApeKey deleted");
expect(deleteApeKeyMock).toHaveBeenCalledWith(uid, apeKeyId);
});
it("should fail with missing path", async () => {
//GIVEN
//WHEN
await mockApp
.delete(`/ape-keys/`)
.set("authorization", `Uid ${uid}`)
.expect(404);
});
it("should fail if apeKeys endpoints are disabled", async () => {
//GIVEN
await enableApeKeysEndpoints(false);
//WHEN
const { body } = await mockApp
.delete(`/ape-keys/${apeKeyId}`)
.set("authorization", `Uid ${uid}`)
.expect(503);
//THEN
expect(body.message).toEqual("ApeKeys are currently disabled.");
});
it("should fail if user has no apeKey permissions", async () => {
//GIVEN
getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false }));
//WHEN
const { body } = await mockApp
.delete(`/ape-keys/${apeKeyId}`)
.set("authorization", `Uid ${uid}`)
.expect(403);
//THEN
expect(body.message).toEqual(
"You have lost access to ape keys, please contact support"
);
});
});
});
function apeKeyDb(
uid: string,
data?: Partial<MonkeyTypes.ApeKeyDB>
): MonkeyTypes.ApeKeyDB {
return {
_id: new ObjectId(),
uid,
hash: "hash",
useCount: 1,
name: "name",
enabled: true,
createdOn: Math.random() * Date.now(),
lastUsedOn: Math.random() * Date.now(),
modifiedOn: Math.random() * Date.now(),
...data,
};
}
async function enableApeKeysEndpoints(enabled: boolean): Promise<void> {
const mockConfig = _.merge(await configuration, {
apeKeys: { endpointsEnabled: enabled, maxKeysPerUser: 1 },
});
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
mockConfig
);
}
function user(
uid: string,
data: Partial<MonkeyTypes.DBUser>
): MonkeyTypes.DBUser {
return {
uid,
...data,
} as MonkeyTypes.DBUser;
}

View file

@ -0,0 +1,338 @@
import request from "supertest";
import app from "../../../src/app";
import * as PresetDal from "../../../src/dal/preset";
import { ObjectId } from "mongodb";
const mockApp = request(app);
describe("PresetController", () => {
describe("get presets", () => {
const getPresetsMock = vi.spyOn(PresetDal, "getPresets");
afterEach(() => {
getPresetsMock.mockReset();
});
it("should get the users presets", async () => {
//GIVEN
const presetOne = {
_id: new ObjectId(),
uid: "123456789",
name: "test1",
config: { language: "english" },
};
const presetTwo = {
_id: new ObjectId(),
uid: "123456789",
name: "test2",
config: { language: "polish" },
};
getPresetsMock.mockResolvedValue([presetOne, presetTwo]);
//WHEN
const { body } = await mockApp
.get("/presets")
.set("authorization", "Uid 123456789")
.expect(200);
//THEN
expect(body).toStrictEqual({
message: "Presets retrieved",
data: [
{
_id: presetOne._id.toHexString(),
name: "test1",
config: { language: "english" },
},
{
_id: presetTwo._id.toHexString(),
name: "test2",
config: { language: "polish" },
},
],
});
expect(getPresetsMock).toHaveBeenCalledWith("123456789");
});
it("should return empty array if user has no presets", async () => {
//GIVEN
getPresetsMock.mockResolvedValue([]);
//WHEN
const { body } = await mockApp
.get("/presets")
.set("authorization", "Uid 123456789")
.expect(200);
//THEN
expect(body).toStrictEqual({
message: "Presets retrieved",
data: [],
});
expect(getPresetsMock).toHaveBeenCalledWith("123456789");
});
});
describe("add preset", () => {
const addPresetMock = vi.spyOn(PresetDal, "addPreset");
afterEach(() => {
addPresetMock.mockReset();
});
it("should add the users preset", async () => {
//GIVEN
addPresetMock.mockResolvedValue({ presetId: "1" });
//WHEN
const { body } = await mockApp
.post("/presets")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({
name: "new",
config: {
language: "english",
tags: ["one", "two"],
},
})
.expect(200);
//THEN
expect(body).toStrictEqual({
message: "Preset created",
data: { presetId: "1" },
});
expect(addPresetMock).toHaveBeenCalledWith("123456789", {
name: "new",
config: { language: "english", tags: ["one", "two"] },
});
});
it("should not fail with emtpy config", async () => {
//GIVEN
addPresetMock.mockResolvedValue({ presetId: "1" });
//WHEN
const { body } = await mockApp
.post("/presets")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({ name: "new", config: {} })
.expect(200);
//THEN
expect(body).toStrictEqual({
message: "Preset created",
data: { presetId: "1" },
});
expect(addPresetMock).toHaveBeenCalledWith("123456789", {
name: "new",
config: {},
});
});
it("should fail with missing mandatory properties", async () => {
//WHEN
const { body } = await mockApp
.post("/presets")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({})
.expect(422);
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: [`"name" Required`, `"config" Required`],
});
expect(addPresetMock).not.toHaveBeenCalled();
});
it("should not fail with invalid preset", async () => {
//WHEN
const { body } = await mockApp
.post("/presets")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({
_id: "1",
name: "update",
extra: "extra",
config: {
extra: "extra",
autoSwitchTheme: "yes",
confidenceMode: "pretty",
},
})
.expect(422);
//THEN
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: [
`"config.autoSwitchTheme" Expected boolean, received string`,
`"config.confidenceMode" Invalid enum value. Expected 'off' | 'on' | 'max', received 'pretty'`,
`"config" Unrecognized key(s) in object: 'extra'`,
`Unrecognized key(s) in object: '_id', 'extra'`,
],
});
expect(addPresetMock).not.toHaveBeenCalled();
});
});
describe("update preset", () => {
const editPresetMock = vi.spyOn(PresetDal, "editPreset");
afterEach(() => {
editPresetMock.mockReset();
});
it("should update the users preset", async () => {
//GIVEN
editPresetMock.mockResolvedValue({} as any);
//WHEN
const { body } = await mockApp
.patch("/presets")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({
_id: "1",
name: "new",
config: {
language: "english",
tags: ["one", "two"],
},
})
.expect(200);
//THEN
expect(body).toStrictEqual({
message: "Preset updated",
data: null,
});
expect(editPresetMock).toHaveBeenCalledWith("123456789", {
_id: "1",
name: "new",
config: { language: "english", tags: ["one", "two"] },
});
});
it("should not fail with emtpy config", async () => {
//GIVEN
editPresetMock.mockResolvedValue({} as any);
//WHEN
const { body } = await mockApp
.patch("/presets")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({ _id: "1", name: "new", config: {} })
.expect(200);
//THEN
expect(body).toStrictEqual({
message: "Preset updated",
data: null,
});
expect(editPresetMock).toHaveBeenCalledWith("123456789", {
_id: "1",
name: "new",
config: {},
});
});
it("should fail with missing mandatory properties", async () => {
//WHEN
const { body } = await mockApp
.patch("/presets")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({})
.expect(422);
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: [
`"_id" Required`,
`"name" Required`,
`"config" Required`,
],
});
expect(editPresetMock).not.toHaveBeenCalled();
});
it("should not fail with invalid preset", async () => {
//WHEN
const { body } = await mockApp
.patch("/presets")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({
_id: "1",
name: "update",
extra: "extra",
config: {
extra: "extra",
autoSwitchTheme: "yes",
confidenceMode: "pretty",
},
})
.expect(422);
//THEN
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: [
`"config.autoSwitchTheme" Expected boolean, received string`,
`"config.confidenceMode" Invalid enum value. Expected 'off' | 'on' | 'max', received 'pretty'`,
`"config" Unrecognized key(s) in object: 'extra'`,
`Unrecognized key(s) in object: 'extra'`,
],
});
expect(editPresetMock).not.toHaveBeenCalled();
});
});
describe("delete config", () => {
const deletePresetMock = vi.spyOn(PresetDal, "removePreset");
afterEach(() => {
deletePresetMock.mockReset();
});
it("should delete the users preset", async () => {
//GIVEN
deletePresetMock.mockResolvedValue();
//WHEN
const { body } = await mockApp
.delete("/presets/1")
.set("authorization", "Uid 123456789")
.expect(200);
//THEN
expect(body).toStrictEqual({
message: "Preset deleted",
data: null,
});
expect(deletePresetMock).toHaveBeenCalledWith("123456789", "1");
});
it("should fail without preset _id", async () => {
//GIVEN
deletePresetMock.mockResolvedValue();
//WHEN
await mockApp
.delete("/presets/")
.set("authorization", "Uid 123456789")
.expect(404);
expect(deletePresetMock).not.toHaveBeenCalled();
});
});
});

View file

@ -1,18 +1,23 @@
import { ObjectId } from "mongodb";
import * as PresetDal from "../../src/dal/preset";
import _ from "lodash";
import { off } from "process";
describe("PresetDal", () => {
describe("readPreset", () => {
it("should read", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const first = await PresetDal.addPreset(uid, "first", { ads: "sellout" });
const second = await PresetDal.addPreset(uid, "second", {
ads: "result",
const first = await PresetDal.addPreset(uid, {
name: "first",
config: { ads: "sellout" },
});
await PresetDal.addPreset("unknown", "unknown", {});
const second = await PresetDal.addPreset(uid, {
name: "second",
config: {
ads: "result",
},
});
await PresetDal.addPreset("unknown", { name: "unknown", config: {} });
//WHEN
const read = await PresetDal.getPresets(uid);
@ -43,24 +48,27 @@ describe("PresetDal", () => {
//GIVEN
const uid = new ObjectId().toHexString();
for (let i = 0; i < 10; i++) {
await PresetDal.addPreset(uid, "test", {} as any);
await PresetDal.addPreset(uid, { name: "test", config: {} });
}
//WHEN / THEN
expect(() =>
PresetDal.addPreset(uid, "max", {} as any)
PresetDal.addPreset(uid, { name: "max", config: {} })
).rejects.toThrowError("Too many presets");
});
it("should add preset", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
for (let i = 0; i < 9; i++) {
await PresetDal.addPreset(uid, "test", {} as any);
await PresetDal.addPreset(uid, { name: "test", config: {} });
}
//WHEN
const newPreset = await PresetDal.addPreset(uid, "new", {
ads: "sellout",
const newPreset = await PresetDal.addPreset(uid, {
name: "new",
config: {
ads: "sellout",
},
});
//THEN
@ -82,31 +90,44 @@ describe("PresetDal", () => {
describe("editPreset", () => {
it("should not fail if preset is unknown", async () => {
await PresetDal.editPreset(
"uid",
new ObjectId().toHexString(),
"new",
undefined
);
await PresetDal.editPreset("uid", {
_id: new ObjectId().toHexString(),
name: "new",
config: {},
});
});
it("should edit", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const decoyUid = new ObjectId().toHexString();
const first = (
await PresetDal.addPreset(uid, "first", { ads: "sellout" })
await PresetDal.addPreset(uid, {
name: "first",
config: { ads: "sellout" },
})
).presetId;
const second = (
await PresetDal.addPreset(uid, "second", {
ads: "result",
await PresetDal.addPreset(uid, {
name: "second",
config: {
ads: "result",
},
})
).presetId;
const decoy = (
await PresetDal.addPreset(decoyUid, "unknown", { ads: "result" })
await PresetDal.addPreset(decoyUid, {
name: "unknown",
config: { ads: "result" },
})
).presetId;
//WHEN
await PresetDal.editPreset(uid, first, "newName", { ads: "off" });
await PresetDal.editPreset(uid, {
_id: first,
name: "newName",
config: { ads: "off" },
});
//THEN
const read = await PresetDal.getPresets(uid);
@ -143,37 +164,18 @@ describe("PresetDal", () => {
//GIVEN
const uid = new ObjectId().toHexString();
const first = (
await PresetDal.addPreset(uid, "first", { ads: "sellout" })
await PresetDal.addPreset(uid, {
name: "first",
config: { ads: "sellout" },
})
).presetId;
//WHEN undefined
await PresetDal.editPreset(uid, first, "newName", undefined);
expect(await PresetDal.getPresets(uid)).toEqual(
expect.arrayContaining([
expect.objectContaining({
_id: new ObjectId(first),
uid: uid,
name: "newName",
config: { ads: "sellout" },
}),
])
);
//WHEN null
await PresetDal.editPreset(uid, first, "newName", null);
expect(await PresetDal.getPresets(uid)).toEqual(
expect.arrayContaining([
expect.objectContaining({
_id: new ObjectId(first),
uid: uid,
name: "newName",
config: { ads: "sellout" },
}),
])
);
//WHEN empty
await PresetDal.editPreset(uid, first, "newName", {});
await PresetDal.editPreset(uid, {
_id: first,
name: "newName",
config: {},
});
expect(await PresetDal.getPresets(uid)).toEqual(
expect.arrayContaining([
expect.objectContaining({
@ -190,11 +192,18 @@ describe("PresetDal", () => {
const uid = new ObjectId().toHexString();
const decoyUid = new ObjectId().toHexString();
const first = (
await PresetDal.addPreset(uid, "first", { ads: "sellout" })
await PresetDal.addPreset(uid, {
name: "first",
config: { ads: "sellout" },
})
).presetId;
//WHEN
await PresetDal.editPreset(decoyUid, first, "newName", { ads: "off" });
await PresetDal.editPreset(decoyUid, {
_id: first,
name: "newName",
config: { ads: "off" },
});
//THEN
const read = await PresetDal.getPresets(uid);
@ -222,12 +231,20 @@ describe("PresetDal", () => {
//GIVEN
const uid = new ObjectId().toHexString();
const decoyUid = new ObjectId().toHexString();
const first = (await PresetDal.addPreset(uid, "first", {})).presetId;
const first = (
await PresetDal.addPreset(uid, { name: "first", config: {} })
).presetId;
const second = (
await PresetDal.addPreset(uid, "second", { ads: "result" })
await PresetDal.addPreset(uid, {
name: "second",
config: { ads: "result" },
})
).presetId;
const decoy = (
await PresetDal.addPreset(decoyUid, "unknown", { ads: "result" })
await PresetDal.addPreset(decoyUid, {
name: "unknown",
config: { ads: "result" },
})
).presetId;
//WHEN
@ -262,7 +279,10 @@ describe("PresetDal", () => {
const uid = new ObjectId().toHexString();
const decoyUid = new ObjectId().toHexString();
const first = (
await PresetDal.addPreset(uid, "first", { ads: "sellout" })
await PresetDal.addPreset(uid, {
name: "first",
config: { ads: "sellout" },
})
).presetId;
//WHEN
@ -294,10 +314,16 @@ describe("PresetDal", () => {
//GIVEN
const uid = new ObjectId().toHexString();
const decoyUid = new ObjectId().toHexString();
await PresetDal.addPreset(uid, "first", {});
await PresetDal.addPreset(uid, "second", { ads: "result" });
await PresetDal.addPreset(uid, { name: "first", config: {} });
await PresetDal.addPreset(uid, {
name: "second",
config: { ads: "result" },
});
const decoy = (
await PresetDal.addPreset(decoyUid, "unknown", { ads: "result" })
await PresetDal.addPreset(decoyUid, {
name: "unknown",
config: { ads: "result" },
})
).presetId;
//WHEN

View file

@ -621,4 +621,33 @@ describe("Misc Utils", () => {
});
});
});
describe("replaceObjectIds", () => {
it("replaces objecIds with string", () => {
const fromDatabase = {
_id: new ObjectId(),
test: "test",
number: 1,
};
const fromDatabase2 = {
_id: new ObjectId(),
test: "bob",
number: 2,
};
expect(
misc.replaceObjectIds([fromDatabase, fromDatabase2])
).toStrictEqual([
{
_id: fromDatabase._id.toHexString(),
test: "test",
number: 1,
},
{
_id: fromDatabase2._id.toHexString(),
test: "bob",
number: 2,
},
]);
});
});
});

View file

@ -21,7 +21,7 @@ services:
api-server:
container_name: monkeytype-api-server
image: node:18.20.4
image: node:20.16.0
user: "node" ##this works as long as your local user has uid=1000
restart: on-failure
depends_on:
@ -36,7 +36,8 @@ services:
- ../../:/monkeytype
entrypoint: 'bash -c "echo starting, this may take a while... \
&& cd /monkeytype \
&& npm i --prefer-offline --no-audit \
&& npm i -g pnpm \
&& pnpm i \
&& npm run dev-be"'
volumes:

View file

@ -12,18 +12,19 @@
"start": "node ./dist/server.js",
"test": "vitest run",
"test-coverage": "vitest run --coverage",
"dev": "concurrently \"tsx watch --clear-screen=false ./src/server.ts\" \"tsc --preserveWatchOutput --noEmit --watch\" \"npx eslint-watch \"./src/**/*.ts\"\"",
"dev": "concurrently \"tsx watch --clear-screen=false --inspect ./src/server.ts\" \"tsc --preserveWatchOutput --noEmit --watch\" \"npx eslint-watch \"./src/**/*.ts\"\"",
"knip": "knip",
"docker-db-only": "docker compose -f docker/compose.db-only.yml up",
"docker": "docker compose -f docker/compose.yml up",
"gen-docs": "tsx scripts/openapi.ts dist/static/api/openapi.json && redocly build-docs -o dist/static/api/internal.html internal@v2 && redocly bundle -o dist/static/api/public.json public-filter && redocly build-docs -o dist/static/api/public.html public@v2"
},
"engines": {
"node": "18.20.4"
"node": "20.16.0"
},
"dependencies": {
"@date-fns/utc": "1.2.0",
"@monkeytype/contracts": "*",
"@monkeytype/contracts": "workspace:*",
"@ts-rest/core": "3.45.2",
"@ts-rest/express": "3.45.2",
"@ts-rest/open-api": "3.45.2",
"bcrypt": "5.1.1",
@ -57,19 +58,21 @@
"swagger-ui-express": "4.3.0",
"ua-parser-js": "0.7.33",
"uuid": "9.0.1",
"winston": "3.6.0"
"winston": "3.6.0",
"zod": "3.23.8"
},
"devDependencies": {
"@monkeytype/shared-types": "*",
"@monkeytype/typescript-config": "*",
"@monkeytype/eslint-config": "*",
"@monkeytype/eslint-config": "workspace:*",
"@monkeytype/shared-types": "workspace:*",
"@monkeytype/typescript-config": "workspace:*",
"@redocly/cli": "1.18.1",
"@types/bcrypt": "5.0.0",
"@types/bcrypt": "5.0.2",
"@types/cors": "2.8.12",
"@types/cron": "1.7.3",
"@types/express": "4.17.21",
"@types/ioredis": "4.28.10",
"@types/lodash": "4.14.178",
"@types/mjml": "4.7.4",
"@types/mustache": "4.2.2",
"@types/node": "20.14.11",
"@types/node-fetch": "2.6.1",

View file

@ -56,6 +56,16 @@ export function getOpenApi(): OpenAPIObject {
"User specific configurations like test settings, theme or tags.",
"x-displayName": "User configuration",
},
{
name: "presets",
description: "User specific configuration presets.",
"x-displayName": "User presets",
},
{
name: "ape-keys",
description: "Ape keys provide access to certain API endpoints.",
"x-displayName": "Ape Keys",
},
],
},

View file

@ -24,6 +24,8 @@ export async function handleReports(
accept: boolean
): Promise<MonkeyResponse> {
const { reports } = req.body;
// TODO: remove once this gets converted to ts-rest
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
const reportIds = reports.map(({ reportId }) => reportId);
const reportsFromDb = await ReportDAL.getReports(reportIds);

View file

@ -3,29 +3,37 @@ import { randomBytes } from "crypto";
import { hash } from "bcrypt";
import * as ApeKeysDAL from "../../dal/ape-keys";
import MonkeyError from "../../utils/error";
import { MonkeyResponse } from "../../utils/monkey-response";
import { MonkeyResponse2 } from "../../utils/monkey-response";
import { base64UrlEncode } from "../../utils/misc";
import { ObjectId } from "mongodb";
import { ApeKey } from "@monkeytype/shared-types";
import {
AddApeKeyRequest,
AddApeKeyResponse,
ApeKeyParams,
EditApeKeyRequest,
GetApeKeyResponse,
} from "@monkeytype/contracts/ape-keys";
import { ApeKey } from "@monkeytype/contracts/schemas/ape-keys";
function cleanApeKey(apeKey: MonkeyTypes.ApeKeyDB): ApeKey {
return _.omit(apeKey, "hash", "_id", "uid", "useCount");
}
export async function getApeKeys(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request2
): Promise<GetApeKeyResponse> {
const { uid } = req.ctx.decodedToken;
const apeKeys = await ApeKeysDAL.getApeKeys(uid);
const cleanedKeys = _(apeKeys).keyBy("_id").mapValues(cleanApeKey).value();
return new MonkeyResponse("ApeKeys retrieved", cleanedKeys);
return new MonkeyResponse2("ApeKeys retrieved", cleanedKeys);
}
export async function generateApeKey(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request2<undefined, AddApeKeyRequest>
): Promise<AddApeKeyResponse> {
const { name, enabled } = req.body;
const { uid } = req.ctx.decodedToken;
const { maxKeysPerUser, apeKeyBytes, apeKeySaltRounds } =
@ -54,7 +62,7 @@ export async function generateApeKey(
const apeKeyId = await ApeKeysDAL.addApeKey(apeKey);
return new MonkeyResponse("ApeKey generated", {
return new MonkeyResponse2("ApeKey generated", {
apeKey: base64UrlEncode(`${apeKeyId}.${apiKey}`),
apeKeyId,
apeKeyDetails: cleanApeKey(apeKey),
@ -62,24 +70,24 @@ export async function generateApeKey(
}
export async function editApeKey(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request2<undefined, EditApeKeyRequest, ApeKeyParams>
): Promise<MonkeyResponse2> {
const { apeKeyId } = req.params;
const { name, enabled } = req.body;
const { uid } = req.ctx.decodedToken;
await ApeKeysDAL.editApeKey(uid, apeKeyId as string, name, enabled);
await ApeKeysDAL.editApeKey(uid, apeKeyId, name, enabled);
return new MonkeyResponse("ApeKey updated");
return new MonkeyResponse2("ApeKey updated", null);
}
export async function deleteApeKey(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request2<undefined, undefined, ApeKeyParams>
): Promise<MonkeyResponse2> {
const { apeKeyId } = req.params;
const { uid } = req.ctx.decodedToken;
await ApeKeysDAL.deleteApeKey(uid, apeKeyId as string);
await ApeKeysDAL.deleteApeKey(uid, apeKeyId);
return new MonkeyResponse("ApeKey deleted");
return new MonkeyResponse2("ApeKey deleted", null);
}

View file

@ -20,7 +20,7 @@ export async function saveConfig(
await ConfigDAL.saveConfig(uid, config);
return new MonkeyResponse2("Config updated");
return new MonkeyResponse2("Config updated", null);
}
export async function deleteConfig(
@ -29,5 +29,5 @@ export async function deleteConfig(
const { uid } = req.ctx.decodedToken;
await ConfigDAL.deleteConfig(uid);
return new MonkeyResponse2("Config deleted");
return new MonkeyResponse2("Config deleted", null);
}

View file

@ -10,8 +10,11 @@ import { ObjectId } from "mongodb";
import * as LeaderboardDal from "../../dal/leaderboards";
import MonkeyError from "../../utils/error";
import isNumber from "lodash/isNumber";
import { Mode } from "@monkeytype/shared-types/config";
import { PersonalBest, PersonalBests } from "@monkeytype/shared-types/user";
import {
Mode,
PersonalBest,
PersonalBests,
} from "@monkeytype/contracts/schemas/shared";
type GenerateDataOptions = {
firstTestTimestamp: Date;
@ -51,7 +54,7 @@ async function getOrCreateUser(
if (existingUser !== undefined && existingUser !== null) {
return existingUser;
} else if (createUser === false) {
} else if (!createUser) {
throw new MonkeyError(404, `User ${username} does not exist.`);
}
@ -182,8 +185,11 @@ async function updateUser(uid: string): Promise<void> {
])
.toArray();
const timeTyping = stats.reduce((a, c) => a + c["timeTyping"], 0);
const completedTests = stats.reduce((a, c) => a + c["completedTests"], 0);
const timeTyping = stats.reduce((a, c) => (a + c["timeTyping"]) as number, 0);
const completedTests = stats.reduce(
(a, c) => (a + c["completedTests"]) as number,
0
);
//update PBs
const lbPersonalBests: MonkeyTypes.LbPersonalBests = {
@ -200,7 +206,15 @@ async function updateUser(uid: string): Promise<void> {
zen: {},
quote: {},
};
const modes = stats.map((it) => it["_id"]);
const modes = stats.map(
(it) =>
it["_id"] as {
language: string;
mode: "time" | "custom" | "words" | "quote" | "zen";
mode2: `${number}` | "custom" | "zen";
}
);
for (const mode of modes) {
const best = (
await ResultDal.getResultCollection()
@ -253,7 +267,7 @@ async function updateUser(uid: string): Promise<void> {
timeTyping: timeTyping,
completedTests: completedTests,
startedTests: Math.round(completedTests * 1.25),
personalBests: personalBests as PersonalBests,
personalBests: personalBests,
lbPersonalBests: lbPersonalBests,
},
}

View file

@ -1,44 +1,56 @@
import {
AddPresetRequest,
AddPresetResponse,
DeletePresetsParams,
GetPresetResponse,
} from "@monkeytype/contracts/presets";
import * as PresetDAL from "../../dal/preset";
import { MonkeyResponse } from "../../utils/monkey-response";
import { MonkeyResponse2 } from "../../utils/monkey-response";
import { replaceObjectId } from "../../utils/misc";
import { Preset } from "@monkeytype/contracts/schemas/presets";
export async function getPresets(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request2
): Promise<GetPresetResponse> {
const { uid } = req.ctx.decodedToken;
const data = await PresetDAL.getPresets(uid);
return new MonkeyResponse("Preset retrieved", data);
const data = (await PresetDAL.getPresets(uid))
.map((preset) => ({
...preset,
uid: undefined,
}))
.map(replaceObjectId);
return new MonkeyResponse2("Presets retrieved", data);
}
export async function addPreset(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const { name, config } = req.body;
req: MonkeyTypes.Request2<undefined, AddPresetRequest>
): Promise<AddPresetResponse> {
const { uid } = req.ctx.decodedToken;
const data = await PresetDAL.addPreset(uid, name, config);
const data = await PresetDAL.addPreset(uid, req.body);
return new MonkeyResponse("Preset created", data);
return new MonkeyResponse2("Preset created", data);
}
export async function editPreset(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const { _id, name, config } = req.body;
req: MonkeyTypes.Request2<undefined, Preset>
): Promise<MonkeyResponse2> {
const { uid } = req.ctx.decodedToken;
await PresetDAL.editPreset(uid, _id, name, config);
await PresetDAL.editPreset(uid, req.body);
return new MonkeyResponse("Preset updated");
return new MonkeyResponse2("Preset updated", null);
}
export async function removePreset(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request2<undefined, undefined, DeletePresetsParams>
): Promise<MonkeyResponse2> {
const { presetId } = req.params;
const { uid } = req.ctx.decodedToken;
await PresetDAL.removePreset(uid, presetId as string);
await PresetDAL.removePreset(uid, presetId);
return new MonkeyResponse("Preset deleted");
return new MonkeyResponse2("Preset deleted", null);
}

View file

@ -6,9 +6,9 @@ import * as NewQuotesDAL from "../../dal/new-quotes";
import * as QuoteRatingsDAL from "../../dal/quote-ratings";
import MonkeyError from "../../utils/error";
import { verify } from "../../utils/captcha";
import Logger from "../../utils/logger";
import { MonkeyResponse } from "../../utils/monkey-response";
import { ObjectId } from "mongodb";
import { addLog } from "../../dal/logs";
async function verifyCaptcha(captcha: string): Promise<void> {
if (!(await verify(captcha))) {
@ -70,7 +70,7 @@ export async function approveQuote(
}
const data = await NewQuotesDAL.approve(quoteId, editText, editSource, name);
void Logger.logToDb("system_quote_approved", data, uid);
void addLog("system_quote_approved", data, uid);
return new MonkeyResponse(data.message, data.quote);
}

View file

@ -41,6 +41,7 @@ import {
Configuration,
PostResultResponse,
} from "@monkeytype/shared-types";
import { addLog } from "../../dal/logs";
try {
if (!anticheatImplemented()) throw new Error("undefined");
@ -103,7 +104,7 @@ export async function getResults(
limit,
offset,
});
void Logger.logToDb(
void addLog(
"user_results_requested",
{
limit,
@ -130,7 +131,7 @@ export async function deleteAll(
const { uid } = req.ctx.decodedToken;
await ResultDAL.deleteAll(uid);
void Logger.logToDb("user_results_deleted", "", uid);
void addLog("user_results_deleted", "", uid);
return new MonkeyResponse("All results deleted");
}
@ -207,7 +208,7 @@ export async function addResult(
if (req.ctx.configuration.results.objectHashCheckEnabled) {
const serverhash = objectHash(completedEvent);
if (serverhash !== resulthash) {
void Logger.logToDb(
void addLog(
"incorrect_result_hash",
{
serverhash,
@ -308,7 +309,7 @@ export async function addResult(
const earliestPossible = (lastResultTimestamp ?? 0) + testDurationMilis;
const nowNoMilis = Math.floor(Date.now() / 1000) * 1000;
if (lastResultTimestamp && nowNoMilis < earliestPossible - 1000) {
void Logger.logToDb(
void addLog(
"invalid_result_spacing",
{
lastTimestamp: lastResultTimestamp,
@ -378,7 +379,7 @@ export async function addResult(
if (req.ctx.configuration.users.lastHashesCheck.enabled) {
let lastHashes = user.lastReultHashes ?? [];
if (lastHashes.includes(resulthash)) {
void Logger.logToDb(
void addLog(
"duplicate_result",
{
lastHashes,
@ -474,7 +475,7 @@ export async function addResult(
user.banned !== true &&
user.lbOptOut !== true &&
(isDevEnvironment() || (user.timeTyping ?? 0) > 7200) &&
completedEvent.stopOnLetter !== true;
!completedEvent.stopOnLetter;
const selectedBadgeId = user.inventory?.badges?.find((b) => b.selected)?.id;
const isPremium =
@ -593,7 +594,7 @@ export async function addResult(
await UserDAL.incrementTestActivity(user, completedEvent.timestamp);
if (isPb) {
void Logger.logToDb(
void addLog(
"user_new_pb",
`${completedEvent.mode + " " + completedEvent.mode2} ${
completedEvent.wpm

View file

@ -1,7 +1,6 @@
import _ from "lodash";
import * as UserDAL from "../../dal/user";
import MonkeyError from "../../utils/error";
import Logger from "../../utils/logger";
import { MonkeyResponse } from "../../utils/monkey-response";
import * as DiscordUtils from "../../utils/discord";
import {
@ -28,7 +27,7 @@ import * as AuthUtil from "../../utils/auth";
import * as Dates from "date-fns";
import { UTCDateMini } from "@date-fns/utc";
import * as BlocklistDal from "../../dal/blocklist";
import { Mode, Mode2 } from "@monkeytype/shared-types/config";
import { Mode, Mode2 } from "@monkeytype/contracts/schemas/shared";
import {
AllTimeLbs,
CountByYearAndDay,
@ -37,6 +36,7 @@ import {
UserProfile,
UserProfileDetails,
} from "@monkeytype/shared-types";
import { addImportantLog, addLog, deleteUserLogs } from "../../dal/logs";
async function verifyCaptcha(captcha: string): Promise<void> {
if (!(await verify(captcha))) {
@ -68,7 +68,7 @@ export async function createNewUser(
}
await UserDAL.addUser(name, email, uid);
void Logger.logToDb("user_created", `${name} ${email}`, uid);
void addImportantLog("user_created", `${name} ${email}`, uid);
return new MonkeyResponse("User created");
} catch (e) {
@ -206,6 +206,7 @@ export async function deleteUser(
//cleanup database
await Promise.all([
UserDAL.deleteUser(uid),
deleteUserLogs(uid),
deleteAllApeKeys(uid),
deleteAllPresets(uid),
deleteConfig(uid),
@ -219,7 +220,7 @@ export async function deleteUser(
//delete user from
await AuthUtil.deleteUser(uid);
void Logger.logToDb(
void addImportantLog(
"user_deleted",
`${userInfo.email} ${userInfo.name}`,
uid
@ -259,7 +260,7 @@ export async function resetUser(
promises.push(GeorgeQueue.unlinkDiscord(userInfo.discordId, uid));
}
await Promise.all(promises);
void Logger.logToDb("user_reset", `${userInfo.email} ${userInfo.name}`, uid);
void addImportantLog("user_reset", `${userInfo.email} ${userInfo.name}`, uid);
return new MonkeyResponse("User reset");
}
@ -289,7 +290,7 @@ export async function updateName(
}
await UserDAL.updateName(uid, name, user.name);
void Logger.logToDb(
void addImportantLog(
"user_name_updated",
`changed name from ${user.name} to ${name}`,
uid
@ -308,7 +309,7 @@ export async function clearPb(
uid,
req.ctx.configuration.dailyLeaderboards
);
void Logger.logToDb("user_cleared_pbs", "", uid);
void addImportantLog("user_cleared_pbs", "", uid);
return new MonkeyResponse("User's PB cleared");
}
@ -323,7 +324,7 @@ export async function optOutOfLeaderboards(
uid,
req.ctx.configuration.dailyLeaderboards
);
void Logger.logToDb("user_opted_out_of_leaderboards", "", uid);
void addImportantLog("user_opted_out_of_leaderboards", "", uid);
return new MonkeyResponse("User opted out of leaderboards");
}
@ -377,7 +378,7 @@ export async function updateEmail(
}
}
void Logger.logToDb(
void addImportantLog(
"user_email_updated",
`changed email to ${newEmail}`,
uid
@ -461,7 +462,7 @@ export async function getUser(
};
const agentLog = buildAgentLog(req);
void Logger.logToDb("user_data_requested", agentLog, uid);
void addLog("user_data_requested", agentLog, uid);
void UserDAL.logIpAddress(uid, agentLog.ip, userInfo);
let inboxUnreadSize = 0;
@ -556,7 +557,7 @@ export async function linkDiscord(
await UserDAL.linkDiscord(uid, discordId, discordAvatar);
await GeorgeQueue.linkDiscord(discordId, uid);
void Logger.logToDb("user_discord_link", `linked to ${discordId}`, uid);
void addImportantLog("user_discord_link", `linked to ${discordId}`, uid);
return new MonkeyResponse("Discord account linked", {
discordId,
@ -585,7 +586,7 @@ export async function unlinkDiscord(
await GeorgeQueue.unlinkDiscord(discordId, uid);
await UserDAL.unlinkDiscord(uid);
void Logger.logToDb("user_discord_unlinked", discordId, uid);
void addImportantLog("user_discord_unlinked", discordId, uid);
return new MonkeyResponse("Discord account unlinked");
}
@ -957,6 +958,8 @@ export async function setStreakHourOffset(
await UserDAL.setStreakHourOffset(uid, hourOffset);
void addImportantLog("user_streak_hour_offset_set", { hourOffset }, uid);
return new MonkeyResponse("Streak hour offset set");
}
@ -980,6 +983,8 @@ export async function toggleBan(
if (discordIdIsValid) await GeorgeQueue.userBanned(discordId, true);
}
void addImportantLog("user_ban_toggled", { banned: !user.banned }, uid);
return new MonkeyResponse(`Ban toggled`, {
banned: !user.banned,
});
@ -990,6 +995,7 @@ export async function revokeAllTokens(
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
await AuthUtil.revokeTokensByUid(uid);
void addImportantLog("user_tokens_revoked", "", uid);
return new MonkeyResponse("All tokens revoked");
}

View file

@ -1,91 +1,42 @@
import joi from "joi";
import { Router } from "express";
import { authenticateRequest } from "../../middlewares/auth";
import * as ApeKeyController from "../controllers/ape-key";
import { apeKeysContract } from "@monkeytype/contracts/ape-keys";
import { initServer } from "@ts-rest/express";
import * as RateLimit from "../../middlewares/rate-limit";
import * as ApeKeyController from "../controllers/ape-key";
import { callController } from "../ts-rest-adapter";
import { checkUserPermissions } from "../../middlewares/permission";
import { validate } from "../../middlewares/configuration";
import { asyncHandler } from "../../middlewares/utility";
import { validateRequest } from "../../middlewares/validation";
const apeKeyNameSchema = joi
.string()
.regex(/^[0-9a-zA-Z_.-]+$/)
.max(20)
.messages({
"string.pattern.base": "Invalid ApeKey name",
"string.max": "ApeKey name exceeds maximum of 20 characters",
});
const checkIfUserCanManageApeKeys = checkUserPermissions({
criteria: (user) => {
// Must be an exact check
return user.canManageApeKeys !== false;
},
invalidMessage: "You have lost access to ape keys, please contact support",
});
const router = Router();
router.use(
const commonMiddleware = [
validate({
criteria: (configuration) => {
return configuration.apeKeys.endpointsEnabled;
},
invalidMessage: "ApeKeys are currently disabled.",
})
);
router.get(
"/",
authenticateRequest(),
RateLimit.apeKeysGet,
checkIfUserCanManageApeKeys,
asyncHandler(ApeKeyController.getApeKeys)
);
router.post(
"/",
authenticateRequest(),
RateLimit.apeKeysGenerate,
checkIfUserCanManageApeKeys,
validateRequest({
body: {
name: apeKeyNameSchema.required(),
enabled: joi.boolean().required(),
},
}),
asyncHandler(ApeKeyController.generateApeKey)
);
router.patch(
"/:apeKeyId",
authenticateRequest(),
RateLimit.apeKeysUpdate,
checkIfUserCanManageApeKeys,
validateRequest({
params: {
apeKeyId: joi.string().token().required(),
},
body: {
name: apeKeyNameSchema,
enabled: joi.boolean(),
checkUserPermissions({
criteria: (user) => {
return user.canManageApeKeys ?? false;
},
invalidMessage: "You have lost access to ape keys, please contact support",
}),
asyncHandler(ApeKeyController.editApeKey)
);
];
router.delete(
"/:apeKeyId",
authenticateRequest(),
RateLimit.apeKeysDelete,
checkIfUserCanManageApeKeys,
validateRequest({
params: {
apeKeyId: joi.string().token().required(),
},
}),
asyncHandler(ApeKeyController.deleteApeKey)
);
export default router;
const s = initServer();
export default s.router(apeKeysContract, {
get: {
middleware: [...commonMiddleware, RateLimit.apeKeysGet],
handler: async (r) => callController(ApeKeyController.getApeKeys)(r),
},
add: {
middleware: [...commonMiddleware, RateLimit.apeKeysGenerate],
handler: async (r) => callController(ApeKeyController.generateApeKey)(r),
},
save: {
middleware: [...commonMiddleware, RateLimit.apeKeysUpdate],
handler: async (r) => callController(ApeKeyController.editApeKey)(r),
},
delete: {
middleware: [...commonMiddleware, RateLimit.apeKeysDelete],
handler: async (r) => callController(ApeKeyController.deleteApeKey)(r),
},
});

View file

@ -20,7 +20,7 @@ router.use("/v2/internal.json", (req, res) => {
res.sendFile("api/openapi.json", { root });
});
router.use("/v2/public", (req, res) => {
router.use(["/v2/public", "/v2/"], (req, res) => {
res.sendFile("api/public.html", { root });
});

View file

@ -45,12 +45,10 @@ const APP_START_TIME = Date.now();
const API_ROUTE_MAP = {
"/users": users,
"/results": results,
"/presets": presets,
"/psas": psas,
"/public": publicStats,
"/leaderboards": leaderboards,
"/quotes": quotes,
"/ape-keys": apeKeys,
"/admin": admin,
"/webhooks": webhooks,
"/docs": docs,
@ -58,7 +56,9 @@ const API_ROUTE_MAP = {
const s = initServer();
const router = s.router(contract, {
apeKeys,
configs,
presets,
});
export function addApiRoutes(app: Application): void {
@ -81,7 +81,10 @@ function applyTsRestApiRoutes(app: IRouter): void {
createExpressEndpoints(contract, router, app, {
jsonQuery: true,
requestValidationErrorHandler(err, req, res, next) {
if (err.body?.issues === undefined) return next();
if (err.body?.issues === undefined) {
next();
return;
}
const issues = err.body?.issues.map(prettyErrorMessage);
res.status(422).json({
message: "Invalid request data schema",
@ -103,7 +106,7 @@ function applyDevApiRoutes(app: Application): void {
//disable csp to allow assets to load from unsecured http
app.use((req, res, next) => {
res.setHeader("Content-Security-Policy", "");
return next();
next();
});
app.use("/configure", expressStatic(join(__dirname, "../../../private")));
@ -145,6 +148,13 @@ function applyApiRoutes(app: Application): void {
recordClientVersion(clientVersion?.toString() ?? "unknown");
}
if (req.path.startsWith("/docs")) {
res.setHeader(
"Content-Security-Policy",
"default-src 'self';base-uri 'self';block-all-mixed-content;font-src 'self' https: data:;frame-ancestors 'self';img-src 'self' monkeytype.com cdn.redoc.ly data:;object-src 'none';script-src 'self' cdn.redoc.ly 'unsafe-inline'; worker-src blob: data;script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests"
);
}
next();
}
);

View file

@ -1,74 +1,25 @@
import joi from "joi";
import { authenticateRequest } from "../../middlewares/auth";
import * as PresetController from "../controllers/preset";
import { presetsContract } from "@monkeytype/contracts/presets";
import { initServer } from "@ts-rest/express";
import * as RateLimit from "../../middlewares/rate-limit";
import configSchema from "../schemas/config-schema";
import { Router } from "express";
import { asyncHandler } from "../../middlewares/utility";
import { validateRequest } from "../../middlewares/validation";
import * as PresetController from "../controllers/preset";
import { callController } from "../ts-rest-adapter";
const router = Router();
const presetNameSchema = joi
.string()
.required()
.regex(/^[0-9a-zA-Z_-]+$/)
.max(16)
.messages({
"string.pattern.base": "Invalid preset name",
"string.max": "Preset name exceeds maximum of 16 characters",
});
router.get(
"/",
authenticateRequest(),
RateLimit.presetsGet,
asyncHandler(PresetController.getPresets)
);
router.post(
"/",
authenticateRequest(),
RateLimit.presetsAdd,
validateRequest({
body: {
name: presetNameSchema,
config: configSchema.keys({
tags: joi.array().items(joi.string().token().max(50)),
}),
},
}),
asyncHandler(PresetController.addPreset)
);
router.patch(
"/",
authenticateRequest(),
RateLimit.presetsEdit,
validateRequest({
body: {
_id: joi.string().token().required(),
name: presetNameSchema,
config: configSchema
.keys({
tags: joi.array().items(joi.string().token().max(50)),
})
.allow(null),
},
}),
asyncHandler(PresetController.editPreset)
);
router.delete(
"/:presetId",
authenticateRequest(),
RateLimit.presetsRemove,
validateRequest({
params: {
presetId: joi.string().token().required(),
},
}),
asyncHandler(PresetController.removePreset)
);
export default router;
const s = initServer();
export default s.router(presetsContract, {
get: {
middleware: [RateLimit.presetsGet],
handler: async (r) => callController(PresetController.getPresets)(r),
},
add: {
middleware: [RateLimit.presetsAdd],
handler: async (r) => callController(PresetController.addPreset)(r),
},
save: {
middleware: [RateLimit.presetsEdit],
handler: async (r) => callController(PresetController.editPreset)(r),
},
delete: {
middleware: [RateLimit.presetsRemove],
handler: async (r) => callController(PresetController.removePreset)(r),
},
});

View file

@ -75,7 +75,7 @@ const usernameValidation = joi
return helpers.error("string.pattern.base");
}
return value;
return value as string;
})
.messages({
"string.profanity":
@ -217,7 +217,7 @@ router.patch(
RateLimit.userUpdateEmail,
validateRequest({
body: {
newPassword: joi.string().required(),
newPassword: joi.string().min(6).required(),
},
}),
asyncHandler(UserController.updatePassword)
@ -537,7 +537,7 @@ const profileDetailsBase = joi
return helpers.error("string.profanity");
}
return value;
return value as string;
})
.messages({
"string.profanity":

View file

@ -1,152 +0,0 @@
import _ from "lodash";
import joi from "joi";
const CARET_STYLES = [
"off",
"default",
"underline",
"outline",
"block",
"carrot",
"banana",
];
//TODO replaced, still used by presets
const CONFIG_SCHEMA = joi.object({
theme: joi.string().max(50).token(),
themeLight: joi.string().max(50).token(),
themeDark: joi.string().max(50).token(),
autoSwitchTheme: joi.boolean(),
customTheme: joi.boolean(),
customThemeId: joi.string().min(0).max(24).token(),
customThemeColors: joi
.array()
.items(joi.string().pattern(/^#([\da-f]{3}){1,2}$/i))
.length(10),
favThemes: joi.array().items(joi.string().max(50).token()),
showKeyTips: joi.boolean(),
smoothCaret: joi.string().valid("off", "slow", "medium", "fast"),
quickRestart: joi.string().valid("off", "tab", "esc", "enter"),
punctuation: joi.boolean(),
numbers: joi.boolean(),
words: joi.number().min(0),
time: joi.number().min(0),
mode: joi.string().valid("time", "words", "quote", "zen", "custom"),
quoteLength: joi.array().items(joi.number()),
language: joi
.string()
.max(50)
.pattern(/^[a-zA-Z0-9_+]+$/),
fontSize: joi.number().min(0),
freedomMode: joi.boolean(),
difficulty: joi.string().valid("normal", "expert", "master"),
blindMode: joi.boolean(),
quickEnd: joi.boolean(),
caretStyle: joi.string().valid(...CARET_STYLES),
paceCaretStyle: joi.string().valid(...CARET_STYLES),
flipTestColors: joi.boolean(),
layout: joi.string().max(50).token(),
funbox: joi
.string()
.max(100)
.regex(/[\w#]+/),
confidenceMode: joi.string().valid("off", "on", "max"),
indicateTypos: joi.string().valid("off", "below", "replace"),
timerStyle: joi.string().valid("off", "bar", "text", "mini"),
liveSpeedStyle: joi.string().valid("off", "text", "mini"),
liveAccStyle: joi.string().valid("off", "text", "mini"),
liveBurstStyle: joi.string().valid("off", "text", "mini"),
colorfulMode: joi.boolean(),
randomTheme: joi
.string()
.valid("off", "on", "fav", "light", "dark", "custom"),
timerColor: joi.string().valid("black", "sub", "text", "main"),
timerOpacity: joi.number().valid(0.25, 0.5, 0.75, 1),
stopOnError: joi.string().valid("off", "word", "letter"),
showAllLines: joi.boolean(),
keymapMode: joi.string().valid("off", "static", "react", "next"),
keymapStyle: joi
.string()
.valid(
"staggered",
"alice",
"matrix",
"split",
"split_matrix",
"steno",
"steno_matrix"
),
keymapLegendStyle: joi
.string()
.valid("lowercase", "uppercase", "blank", "dynamic"),
keymapLayout: joi
.string()
.regex(/[\w\-_]+/)
.valid()
.max(50),
keymapShowTopRow: joi.string().valid("always", "layout", "never"),
fontFamily: joi
.string()
.max(50)
.regex(/^[a-zA-Z0-9_\-+.]+$/),
smoothLineScroll: joi.boolean(),
alwaysShowDecimalPlaces: joi.boolean(),
alwaysShowWordsHistory: joi.boolean(),
singleListCommandLine: joi.string().valid("manual", "on"),
capsLockWarning: joi.boolean(),
playSoundOnError: joi.string().valid("off", ..._.range(1, 5).map(_.toString)),
playSoundOnClick: joi.alternatives().try(
joi.boolean(), //todo remove soon
joi.string().valid("off", ..._.range(1, 16).map(_.toString))
),
soundVolume: joi.string().valid("0.1", "0.5", "1.0"),
startGraphsAtZero: joi.boolean(),
showOutOfFocusWarning: joi.boolean(),
paceCaret: joi
.string()
.valid("off", "average", "pb", "last", "daily", "custom"),
paceCaretCustomSpeed: joi.number().min(0),
repeatedPace: joi.boolean(),
accountChart: joi
.array()
.items(joi.string().valid("on", "off"))
.min(3)
.max(4)
.optional(), //replace min max with length 4 after a while
minWpm: joi.string().valid("off", "custom"),
minWpmCustomSpeed: joi.number().min(0),
highlightMode: joi
.string()
.valid(
"off",
"letter",
"word",
"next_word",
"next_two_words",
"next_three_words"
),
tapeMode: joi.string().valid("off", "letter", "word"),
typingSpeedUnit: joi.string().valid("wpm", "cpm", "wps", "cps", "wph"),
enableAds: joi.string().valid("off", "on", "max"),
ads: joi.string().valid("off", "result", "on", "sellout"),
hideExtraLetters: joi.boolean(),
strictSpace: joi.boolean(),
minAcc: joi.string().valid("off", "custom"),
minAccCustom: joi.number().min(0),
monkey: joi.boolean(),
repeatQuotes: joi.string().valid("off", "typing"),
oppositeShiftMode: joi.string().valid("off", "on", "keymap"),
customBackground: joi.string().uri().allow(""),
customBackgroundSize: joi.string().valid("cover", "contain", "max"),
customBackgroundFilter: joi.array().items(joi.number()),
customLayoutfluid: joi.string().regex(/^[0-9a-zA-Z_#]+$/),
monkeyPowerLevel: joi.string().valid("off", "1", "2", "3", "4"),
minBurst: joi.string().valid("off", "fixed", "flex"),
minBurstCustomSpeed: joi.number().min(0),
burstHeatmap: joi.boolean(),
britishEnglish: joi.boolean(),
lazyMode: joi.boolean(),
showAverage: joi.string().valid("off", "speed", "acc", "both"),
maxLineWidth: joi.number().min(20).max(1000).allow(0),
});
export default CONFIG_SCHEMA;

View file

@ -28,7 +28,7 @@ export function callController<
status: 200 as TStatus,
body: {
message: result.message,
data: result.data as TResponse,
data: result.data,
},
};

View file

@ -306,6 +306,16 @@ const FunboxList: MonkeyTypes.FunboxMetadata[] = [
frontendFunctions: ["getWord"],
name: "binary",
},
{
canGetPb: false,
difficultyLevel: 1,
properties: ["ignoresLanguage", "ignoresLayout", "noLetters"],
frontendForcedConfig: {
numbers: [false],
},
frontendFunctions: ["getWord", "punctuateWord", "rememberSettings"],
name: "hexadecimal",
},
{
canGetPb: false,
difficultyLevel: 0,

View file

@ -34,8 +34,7 @@ export async function getApeKey(
}
export async function countApeKeysForUser(uid: string): Promise<number> {
const apeKeys = await getApeKeys(uid);
return _.size(apeKeys);
return getApeKeysCollection().countDocuments({ uid });
}
export async function addApeKey(apeKey: MonkeyTypes.ApeKeyDB): Promise<string> {
@ -64,9 +63,11 @@ async function updateApeKey(
export async function editApeKey(
uid: string,
keyId: string,
name: string,
enabled: boolean
name?: string,
enabled?: boolean
): Promise<void> {
//check if there is a change
if (name === undefined && enabled === undefined) return;
const apeKeyUpdates = {
name,
enabled,

View file

@ -5,6 +5,7 @@ import { setLeaderboard } from "../utils/prometheus";
import { isDevEnvironment } from "../utils/misc";
import { getCachedConfiguration } from "../init/configuration";
import { LeaderboardEntry } from "@monkeytype/shared-types";
import { addLog } from "./logs";
export async function get(
mode: string,
@ -235,7 +236,7 @@ export async function update(
const timeToRunIndex = (end2 - start2) / 1000;
const timeToSaveHistogram = (end3 - start3) / 1000; // not sent to prometheus yet
void Logger.logToDb(
void addLog(
`system_lb_update_${language}_${mode}_${mode2}`,
`Aggregate ${timeToRunAggregate}s, loop 0s, insert 0s, index ${timeToRunIndex}s, histogram ${timeToSaveHistogram}`
);

58
backend/src/dal/logs.ts Normal file
View file

@ -0,0 +1,58 @@
import { Collection, ObjectId } from "mongodb";
import * as db from "../init/db";
import Logger from "../utils/logger";
type DbLog = {
_id: ObjectId;
type?: string;
timestamp: number;
uid: string;
important?: boolean;
event: string;
message: string | Record<string, unknown>;
};
export const getLogsCollection = (): Collection<DbLog> =>
db.collection<DbLog>("logs");
async function insertIntoDb(
event: string,
message: string | Record<string, unknown>,
uid = "",
important = false
): Promise<void> {
const dbLog: DbLog = {
_id: new ObjectId(),
timestamp: Date.now(),
uid: uid ?? "",
event: event,
message: message,
important: important,
};
if (!important) delete dbLog.important;
Logger.info(`${event}\t${uid}\t${JSON.stringify(message)}`);
await getLogsCollection().insertOne(dbLog);
}
export async function addLog(
event: string,
message: string | Record<string, unknown>,
uid = ""
): Promise<void> {
await insertIntoDb(event, message, uid);
}
export async function addImportantLog(
event: string,
message: string | Record<string, unknown>,
uid = ""
): Promise<void> {
await insertIntoDb(event, message, uid, true);
}
export async function deleteUserLogs(uid: string): Promise<void> {
await getLogsCollection().deleteMany({ uid });
}

View file

@ -1,14 +1,15 @@
import MonkeyError from "../utils/error";
import * as db from "../init/db";
import { ObjectId, type Filter, Collection, type WithId } from "mongodb";
import {
ConfigPreset,
DBConfigPreset as SharedDBConfigPreset,
} from "@monkeytype/shared-types";
import { Preset } from "@monkeytype/contracts/schemas/presets";
const MAX_PRESETS = 10;
type DBConfigPreset = MonkeyTypes.WithObjectId<SharedDBConfigPreset>;
type DBConfigPreset = MonkeyTypes.WithObjectId<
Preset & {
uid: string;
}
>;
function getPresetKeyFilter(
uid: string,
@ -37,36 +38,32 @@ export async function getPresets(uid: string): Promise<DBConfigPreset[]> {
export async function addPreset(
uid: string,
name: string,
config: ConfigPreset
preset: Omit<Preset, "_id">
): Promise<PresetCreationResult> {
const presets = await getPresets(uid);
if (presets.length >= MAX_PRESETS) {
const presets = await getPresetsCollection().countDocuments({ uid });
if (presets >= MAX_PRESETS) {
throw new MonkeyError(409, "Too many presets");
}
const preset = await getPresetsCollection().insertOne({
const result = await getPresetsCollection().insertOne({
...preset,
_id: new ObjectId(),
uid,
name,
config,
});
return {
presetId: preset.insertedId.toHexString(),
presetId: result.insertedId.toHexString(),
};
}
export async function editPreset(
uid: string,
presetId: string,
name: string,
config: ConfigPreset | null | undefined
): Promise<void> {
export async function editPreset(uid: string, preset: Preset): Promise<void> {
const config = preset.config;
const presetUpdates =
config !== undefined && config !== null && Object.keys(config).length > 0
? { name, config }
: { name };
await getPresetsCollection().updateOne(getPresetKeyFilter(uid, presetId), {
? { name: preset.name, config }
: { name: preset.name };
await getPresetsCollection().updateOne(getPresetKeyFilter(uid, preset._id), {
$set: presetUpdates,
});
}

View file

@ -35,12 +35,21 @@ export async function getSpeedHistogram(
language: string,
mode: string,
mode2: string
): Promise<Record<string, number>> {
const key = `${language}_${mode}_${mode2}`;
): Promise<SpeedHistogram> {
const key = `${language}_${mode}_${mode2}` as keyof PublicSpeedStatsDB;
if (key === "_id") {
throw new MonkeyError(
400,
"Invalid speed histogram key",
"get speed histogram"
);
}
const stats = await db
.collection<PublicSpeedStatsDB>("public")
.findOne({ _id: "speedStatsHistogram" }, { projection: { [key]: 1 } });
return stats?.[key] ?? {};
}

View file

@ -9,7 +9,7 @@ import MonkeyError from "../utils/error";
import * as db from "../init/db";
import { DBResult as SharedDBResult } from "@monkeytype/shared-types";
import { getUser, getTags } from "./user";
import { Mode } from "@monkeytype/shared-types/config";
import { Mode } from "@monkeytype/contracts/schemas/shared";
type DBResult = MonkeyTypes.WithObjectId<SharedDBResult<Mode>>;

View file

@ -10,7 +10,6 @@ import {
type UpdateFilter,
type Filter,
} from "mongodb";
import Logger from "../utils/logger";
import { flattenObjectDeep, isToday, isYesterday } from "../utils/misc";
import { getCachedConfiguration } from "../init/configuration";
import { getDayOfYear } from "date-fns";
@ -28,8 +27,12 @@ import {
UserQuoteRatings,
UserStreak,
} from "@monkeytype/shared-types";
import { Mode, Mode2 } from "@monkeytype/shared-types/config";
import { PersonalBest } from "@monkeytype/shared-types/user";
import {
Mode,
Mode2,
PersonalBest,
} from "@monkeytype/contracts/schemas/shared";
import { addImportantLog } from "./logs";
const SECONDS_PER_HOUR = 3600;
@ -739,10 +742,10 @@ export async function getPersonalBests(
]);
if (mode2 !== undefined) {
return user.personalBests?.[mode]?.[mode2];
return user.personalBests?.[mode]?.[mode2] as PersonalBest;
}
return user.personalBests?.[mode];
return user.personalBests?.[mode] as PersonalBest;
}
export async function getStats(
@ -856,7 +859,7 @@ export async function recordAutoBanEvent(
}
await getUsersCollection().updateOne({ uid }, { $set: updateObj });
void Logger.logToDb(
void addImportantLog(
"user_auto_banned",
{ autoBanTimestamps, banningUser },
uid
@ -967,7 +970,7 @@ export async function updateInbox(
//we don't need to read mails that are going to be deleted because
//Rewards will be claimed on unread mails on deletion
const readSet = [...new Set(mailToRead)].filter(
(it) => deleteSet.includes(it) === false
(it) => !deleteSet.includes(it)
);
const update = await getUsersCollection().updateOne({ uid }, [
@ -989,12 +992,12 @@ export async function updateInbox(
);
const toBeRead = inbox.filter(
(it) => readIds.includes(it.id) && it.read === false
(it) => readIds.includes(it.id) && !it.read
);
//flatMap rewards
const rewards: AllRewards[] = [...toBeRead, ...toBeDeleted]
.filter((it) => it.read === false)
.filter((it) => !it.read)
.reduce((arr, current) => {
return [...arr, ...current.rewards];
}, []);
@ -1074,7 +1077,11 @@ export async function updateStreak(
if (isYesterday(streak.lastResultTimestamp, streak.hourOffset ?? 0)) {
streak.length += 1;
} else if (!isToday(streak.lastResultTimestamp, streak.hourOffset ?? 0)) {
void Logger.logToDb("streak_lost", JSON.parse(JSON.stringify(streak)), uid);
void addImportantLog(
"streak_lost",
JSON.parse(JSON.stringify(streak)),
uid
);
streak.length = 1;
}
@ -1123,7 +1130,7 @@ export async function checkIfUserIsPremium(
): Promise<boolean> {
const premiumFeaturesEnabled = (await getCachedConfiguration(true)).users
.premium.enabled;
if (premiumFeaturesEnabled !== true) {
if (!premiumFeaturesEnabled) {
return false;
}
const user =

View file

@ -1,7 +1,7 @@
{
"swagger": "2.0",
"info": {
"description": "These are the set of `internal` endpoints dedicated to the Monkeytype web client. Authentication for these endpoints requires a user account.",
"description": "These are the set of `internal` endpoints dedicated to the Monkeytype web client. Authentication for these endpoints requires a user account.\nNote: We are currently re-working our APIs. Some endpoints are documented at https://api.monkeytype.com/docs/v2/internal",
"version": "1.0.0",
"title": "Monkeytype",
"termsOfService": "https://monkeytype.com/terms-of-service",
@ -27,18 +27,6 @@
"name": "psas",
"description": "Public service announcements"
},
{
"name": "presets",
"description": "Preset data and related operations"
},
{
"name": "configs",
"description": "User configuration data and related operations"
},
{
"name": "ape-keys",
"description": "ApeKey data and related operations"
},
{
"name": "leaderboards",
"description": "Leaderboard data"
@ -442,232 +430,6 @@
}
}
},
"/presets": {
"get": {
"tags": ["presets"],
"summary": "Gets saved preset configurations",
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
},
"post": {
"tags": ["presets"],
"summary": "Creates a preset configuration",
"parameters": [
{
"in": "body",
"name": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"config": {
"type": "object"
}
}
}
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
},
"patch": {
"tags": ["presets"],
"summary": "Updates an existing preset configuration",
"parameters": [
{
"in": "body",
"name": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"_id": {
"type": "string"
},
"name": {
"type": "string"
},
"config": {
"type": "object"
}
}
}
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/presets/{presetId}": {
"delete": {
"tags": ["presets"],
"summary": "Deletes a preset configuration",
"parameters": [
{
"in": "path",
"name": "presetId",
"required": true,
"type": "string"
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/configs": {
"get": {
"tags": ["configs"],
"summary": "Gets the user's current configuration",
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
},
"patch": {
"tags": ["configs"],
"summary": "Updates a user's configuration",
"parameters": [
{
"in": "body",
"name": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"config": {
"type": "object"
}
}
}
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/ape-keys": {
"get": {
"tags": ["ape-keys"],
"summary": "Gets ApeKeys created by a user",
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
},
"post": {
"tags": ["ape-keys"],
"summary": "Creates an ApeKey",
"parameters": [
{
"in": "body",
"name": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"enabled": {
"type": "boolean"
}
}
}
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/ape-keys/{apeKeyId}": {
"patch": {
"tags": ["ape-keys"],
"summary": "Updates an ApeKey",
"parameters": [
{
"in": "path",
"name": "apeKeyId",
"required": true,
"type": "string"
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
},
"delete": {
"tags": ["ape-keys"],
"summary": "Deletes an ApeKey",
"parameters": [
{
"in": "path",
"name": "apeKeyId",
"required": true,
"type": "string"
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/leaderboards": {
"get": {
"tags": ["leaderboards"],

View file

@ -1,7 +1,7 @@
{
"swagger": "2.0",
"info": {
"description": "Documentation for the public endpoints provided by the Monkeytype API server.\n\nNote that authentication is performed with the Authorization HTTP header in the format `Authorization: ApeKey YOUR_APE_KEY`\n\nThere is a rate limit of `30 requests per minute` across all endpoints with some endpoints being more strict. Rate limit rates are shared across all ape keys.",
"description": "Documentation for the public endpoints provided by the Monkeytype API server.\n\nNote that authentication is performed with the Authorization HTTP header in the format `Authorization: ApeKey YOUR_APE_KEY`\n\nThere is a rate limit of `30 requests per minute` across all endpoints with some endpoints being more strict. Rate limit rates are shared across all ape keys.\n\nNote: We are currently re-working our APIs. Some endpoints are documented at https://api.monkeytype.com/docs/v2/public",
"version": "1.0.0",
"title": "Monkeytype API",
"termsOfService": "https://monkeytype.com/terms-of-service",

View file

@ -5,6 +5,7 @@ import Logger from "../utils/logger";
import { identity } from "../utils/misc";
import { BASE_CONFIGURATION } from "../constants/base-configuration";
import { Configuration } from "@monkeytype/shared-types";
import { addLog } from "../dal/logs";
const CONFIG_UPDATE_INTERVAL = 10 * 60 * 1000; // 10 Minutes
@ -84,7 +85,7 @@ export async function getLiveConfiguration(): Promise<Configuration> {
}); // Seed the base configuration.
}
} catch (error) {
void Logger.logToDb(
void addLog(
"fetch_configuration_failure",
`Could not fetch configuration: ${error.message}`
);
@ -102,7 +103,7 @@ async function pushConfiguration(configuration: Configuration): Promise<void> {
await db.collection("configuration").replaceOne({}, configuration);
serverConfigurationUpdated = true;
} catch (error) {
void Logger.logToDb(
void addLog(
"push_configuration_failure",
`Could not push configuration: ${error.message}`
);
@ -122,7 +123,7 @@ export async function patchConfiguration(
await getLiveConfiguration();
} catch (error) {
void Logger.logToDb(
void addLog(
"patch_configuration_failure",
`Could not patch configuration: ${error.message}`
);

View file

@ -50,7 +50,7 @@ export async function connect(): Promise<void> {
};
mongoClient = new MongoClient(
(DB_URI as string) ?? global.__MONGO_URI__, // Set in tests only
DB_URI ?? global.__MONGO_URI__, // Set in tests only
connectionOptions
);

View file

@ -1,7 +1,7 @@
import { CronJob } from "cron";
import * as db from "../init/db";
import Logger from "../utils/logger";
import { getCachedConfiguration } from "../init/configuration";
import { addLog } from "../dal/logs";
const CRON_SCHEDULE = "0 0 0 * * *";
const LOG_MAX_AGE_DAYS = 30;
@ -13,11 +13,12 @@ async function deleteOldLogs(): Promise<void> {
return;
}
const data = await db
.collection("logs")
.deleteMany({ timestamp: { $lt: Date.now() - LOG_MAX_AGE_MILLISECONDS } });
const data = await db.collection("logs").deleteMany({
timestamp: { $lt: Date.now() - LOG_MAX_AGE_MILLISECONDS },
$or: [{ important: false }, { important: { $exists: false } }],
});
void Logger.logToDb(
void addLog(
"system_logs_deleted",
`${data.deletedCount} logs deleted older than ${LOG_MAX_AGE_DAYS} day(s)`,
undefined

View file

@ -42,9 +42,12 @@ export function withApeRateLimiter(
return (req: MonkeyTypes.Request, res: Response, next: NextFunction) => {
if (req.ctx.decodedToken.type === "ApeKey") {
const rateLimiter = apeRateLimiterOverride ?? apeRateLimiter;
// TODO: bump version?
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return rateLimiter(req, res, next);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return defaultRateLimiter(req, res, next);
};
}

View file

@ -112,7 +112,8 @@ async function _authenticateRequestInternal(
req
);
return next(error);
next(error);
return;
}
recordAuthTime(
token.type,
@ -345,7 +346,8 @@ export function authenticateGithubWebhook(): Handler {
}
}
} catch (e) {
return next(e);
next(e);
return;
}
next();

View file

@ -12,6 +12,7 @@ import {
import { isDevEnvironment } from "../utils/misc";
import { ObjectId } from "mongodb";
import { version } from "../version";
import { addLog } from "../dal/logs";
type DBError = {
_id: ObjectId;
@ -70,7 +71,7 @@ async function errorHandlingMiddleware(
const { uid, errorId } = monkeyResponse.data;
try {
await Logger.logToDb(
await addLog(
"system_error",
`${monkeyResponse.status} ${errorId} ${error.message} ${error.stack}`,
uid
@ -98,13 +99,14 @@ async function errorHandlingMiddleware(
delete monkeyResponse.data.errorId;
}
return handleMonkeyResponse(monkeyResponse, res);
handleMonkeyResponse(monkeyResponse, res);
return;
} catch (e) {
Logger.error("Error handling middleware failed.");
Logger.error(e);
}
return handleMonkeyResponse(
handleMonkeyResponse(
new MonkeyResponse(
"Something went really wrong, please contact support.",
undefined,

View file

@ -8,10 +8,12 @@ import { isDevEnvironment } from "../utils/misc";
const REQUEST_MULTIPLIER = isDevEnvironment() ? 100 : 1;
const getKey = (req: MonkeyTypes.Request, _res: Response): string => {
return ((req.headers["cf-connecting-ip"] as string) ||
return (
(req.headers["cf-connecting-ip"] as string) ||
(req.headers["x-forwarded-for"] as string) ||
(req.ip as string) ||
"255.255.255.255") as string;
"255.255.255.255"
);
};
const getKeyWithUid = (req: MonkeyTypes.Request, _res: Response): string => {
@ -61,7 +63,8 @@ export async function badAuthRateLimiterHandler(
const badAuthEnabled =
req?.ctx?.configuration?.rateLimiting?.badAuthentication?.enabled;
if (!badAuthEnabled) {
return next();
next();
return;
}
try {
@ -75,7 +78,8 @@ export async function badAuthRateLimiterHandler(
);
}
} catch (error) {
return next(error);
next(error);
return;
}
next();

View file

@ -28,7 +28,7 @@ export function asyncHandler(handler: AsyncHandler): RequestHandler {
) => {
try {
const handlerData = await handler(req, res);
return handleMonkeyResponse(handlerData, res);
handleMonkeyResponse(handlerData, res);
} catch (error) {
next(error);
}

View file

@ -109,7 +109,7 @@ export class WeeklyXpLeaderboard {
entry.uid,
xpGained,
JSON.stringify({ ...entry, timeTypedSeconds: totalTimeTypedSeconds })
),
) as Promise<number>,
LaterQueue.scheduleForNextWeek(
"weekly-xp-leaderboard-results",
"weekly-xp"
@ -155,11 +155,16 @@ export class WeeklyXpLeaderboard {
}
const resultsWithRanks: WeeklyXpLeaderboardEntry[] = results.map(
(resultJSON: string, index: number) => ({
...JSON.parse(resultJSON),
rank: minRank + index + 1,
totalXp: parseInt(scores[index] as string, 10),
})
(resultJSON: string, index: number) => {
//TODO parse with zod?
const parsed = JSON.parse(resultJSON) as WeeklyXpLeaderboardEntry;
return {
...parsed,
rank: minRank + index + 1,
totalXp: parseInt(scores[index] as string, 10),
};
}
);
return resultsWithRanks;
@ -193,11 +198,17 @@ export class WeeklyXpLeaderboard {
return null;
}
//TODO parse with zod?
const parsed = JSON.parse(result ?? "null") as Omit<
WeeklyXpLeaderboardEntry,
"rank" | "count" | "totalXp"
>;
return {
rank: rank + 1,
count: count ?? 0,
totalXp: parseInt(totalXp, 10),
...JSON.parse(result ?? "null"),
...parsed,
};
}
}

View file

@ -65,7 +65,10 @@ declare namespace MonkeyTypes {
type LbPersonalBests = {
time: Record<
number,
Record<string, import("@monkeytype/shared-types/user").PersonalBest>
Record<
string,
import("@monkeytype/contracts/schemas/shared").PersonalBest
>
>;
};
@ -73,7 +76,7 @@ declare namespace MonkeyTypes {
_id: ObjectId;
};
type ApeKeyDB = import("@monkeytype/shared-types").ApeKey & {
type ApeKeyDB = import("@monkeytype/contracts/schemas/ape-keys").ApeKey & {
_id: ObjectId;
uid: string;
hash: string;
@ -123,7 +126,7 @@ declare namespace MonkeyTypes {
type DBResult = MonkeyTypes.WithObjectId<
import("@monkeytype/shared-types").DBResult<
import("@monkeytype/shared-types/config").Mode
import("@monkeytype/contracts/schemas/shared").Mode
>
>;

View file

@ -91,7 +91,7 @@ export class DailyLeaderboard {
const resultScore = kogascore(entry.wpm, entry.acc, entry.timestamp);
// @ts-expect-error
const rank = await connection.addResult(
const rank = (await connection.addResult(
2,
leaderboardScoresKey,
leaderboardResultsKey,
@ -100,7 +100,7 @@ export class DailyLeaderboard {
entry.uid,
resultScore,
JSON.stringify(entry)
);
)) as number;
if (
isValidModeRule(
@ -153,10 +153,15 @@ export class DailyLeaderboard {
}
const resultsWithRanks: LbEntryWithRank[] = results.map(
(resultJSON, index) => ({
...JSON.parse(resultJSON),
rank: minRank + index + 1,
})
(resultJSON, index) => {
// TODO: parse with zod?
const parsed = JSON.parse(resultJSON) as LbEntryWithRank;
return {
...parsed,
rank: minRank + index + 1,
};
}
);
if (!premiumFeaturesEnabled) {

View file

@ -1,4 +1,3 @@
import * as db from "../init/db";
import chalk from "chalk";
import {
format,
@ -7,7 +6,6 @@ import {
type Logger as LoggerType,
} from "winston";
import { resolve } from "path";
import { ObjectId } from "mongodb";
const errorColor = chalk.red.bold;
const warningColor = chalk.yellow.bold;
@ -17,14 +15,6 @@ const infoColor = chalk.white;
const logFolderPath = process.env["LOG_FOLDER_PATH"] ?? "./logs";
const maxLogSize = parseInt(process.env["LOG_FILE_MAX_SIZE"] ?? "10485760");
type Log = {
type?: string;
timestamp: number;
uid: string;
event: string;
message: string | Record<string, unknown>;
};
const customLevels = {
error: 0,
warning: 1,
@ -90,33 +80,11 @@ const logger = createLogger({
],
});
const logToDb = async (
event: string,
message: string | Record<string, unknown>,
uid?: string
): Promise<void> => {
const logsCollection = db.collection<Log>("logs");
logger.info(`${event}\t${uid}\t${JSON.stringify(message)}`);
logsCollection
.insertOne({
_id: new ObjectId(),
timestamp: Date.now(),
uid: uid ?? "",
event,
message,
})
.catch((error) => {
logger.error(`Could not log to db: ${error.message}`);
});
};
const Logger = {
error: (message: string): LoggerType => logger.error(message),
warning: (message: string): LoggerType => logger.warning(message),
info: (message: string): LoggerType => logger.info(message),
success: (message: string): LoggerType => logger.log("success", message),
logToDb,
};
export default Logger;

View file

@ -316,7 +316,7 @@ export function isDevEnvironment(): boolean {
/**
* convert database object into api object
* @param data database object with `_id: ObjectId`
* @returns api obkect with `id: string`
* @returns api object with `id: string`
*/
export function replaceObjectId<T extends { _id: ObjectId }>(
data: T
@ -327,3 +327,14 @@ export function replaceObjectId<T extends { _id: ObjectId }>(
} as T & { _id: string };
return result;
}
/**
* convert database objects into api objects
* @param data database objects with `_id: ObjectId`
* @returns api objects with `id: string`
*/
export function replaceObjectIds<T extends { _id: ObjectId }>(
data: T[]
): (T & { _id: string })[] {
return data.map((it) => replaceObjectId(it));
}

View file

@ -35,7 +35,8 @@ export function handleMonkeyResponse(
//@ts-expect-error ignored so that we can see message in swagger stats
res.monkeyMessage = message;
if ([301, 302].includes(status)) {
return res.redirect(data);
res.redirect(data);
return;
}
res.json({ message, data });
@ -45,9 +46,9 @@ export class MonkeyResponse2<T = null>
implements MonkeyResponseType, MonkeyDataAware<T>
{
public message: string;
public data: T | null;
public data: T;
constructor(message: string, data: T | null = null) {
constructor(message: string, data: T) {
this.message = message;
this.data = data;
}

View file

@ -1,8 +1,12 @@
import _ from "lodash";
import FunboxList from "../constants/funbox-list";
import { PersonalBest, PersonalBests } from "@monkeytype/shared-types/user";
import { DBResult } from "@monkeytype/shared-types";
import { Mode, Mode2 } from "@monkeytype/shared-types/config";
import {
Mode,
Mode2,
PersonalBest,
PersonalBests,
} from "@monkeytype/contracts/schemas/shared";
type CheckAndUpdatePbResult = {
isPb: boolean;

View file

@ -1,5 +1,5 @@
import { Result } from "@monkeytype/shared-types";
import { Mode } from "@monkeytype/shared-types/config";
import { Mode } from "@monkeytype/contracts/schemas/shared";
import "dotenv/config";
import { Counter, Histogram, Gauge } from "prom-client";
import { TsRestRequestWithCtx } from "../middlewares/auth";

View file

@ -1,5 +1,5 @@
import { CompletedEvent, DBResult } from "@monkeytype/shared-types";
import { Mode } from "@monkeytype/shared-types/config";
import { Mode } from "@monkeytype/contracts/schemas/shared";
import { ObjectId } from "mongodb";
type Result = MonkeyTypes.WithObjectId<DBResult<Mode>>;

View file

@ -8,6 +8,7 @@ import EmailQueue, {
} from "../queues/email-queue";
import { sendEmail } from "../init/email-client";
import { recordTimeToCompleteJob } from "../utils/prometheus";
import { addLog } from "../dal/logs";
async function jobHandler(job: Job): Promise<void> {
const type: EmailType = job.data.type;
@ -21,7 +22,7 @@ async function jobHandler(job: Job): Promise<void> {
const result = await sendEmail(type, email, ctx);
if (!result.success) {
void Logger.logToDb("error_sending_email", {
void addLog("error_sending_email", {
type,
email,
ctx: JSON.stringify(ctx),

View file

@ -281,8 +281,8 @@ function convertStringToLog(logString) {
body: body || "",
});
} else {
console.log({ hash, shortHash, title, body });
console.warn("skipping line due to invalid format: " + line);
// console.log({ hash, shortHash, title, body });
// console.warn("skipping line due to invalid format: " + line);
}
}
return log;

236
bin/release.mjs Normal file
View file

@ -0,0 +1,236 @@
import { execSync } from "child_process";
import { Octokit } from "@octokit/rest";
import dotenv from "dotenv";
import { readFileSync } from "fs";
import readlineSync from "readline-sync";
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
import { dirname } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
dotenv.config();
const args = process.argv.slice(2);
const isFrontend = args.includes("--fe");
const noDeploy = args.includes("--no-deploy");
const isBackend = args.includes("--be");
const isDryRun = args.includes("--dry");
const runCommand = (command, force) => {
if (isDryRun && !force) {
console.log(`[Dry Run] Command: ${command}`);
return "[Dry Run] Command executed.";
} else {
try {
const output = execSync(command, { stdio: "pipe" }).toString();
return output;
} catch (error) {
console.error(`Error executing command ${command}`);
console.error(error);
process.exit(1);
}
}
};
const checkBranchSync = () => {
console.log("Checking if local master branch is in sync with origin...");
if (isDryRun) {
console.log("[Dry Run] Checking sync...");
} else {
try {
// Fetch the latest changes from the remote repository
runCommand("git fetch origin");
// Get the commit hashes of the local and remote master branches
const localMaster = runCommand("git rev-parse master").trim();
const remoteMaster = runCommand("git rev-parse origin/master").trim();
if (localMaster !== remoteMaster) {
console.error(
"Local master branch is not in sync with origin. Please pull the latest changes before proceeding."
);
process.exit(1);
}
} catch (error) {
console.error("Error checking branch sync status.");
console.error(error);
process.exit(1);
}
}
};
const getCurrentVersion = () => {
console.log("Getting current version...");
const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
return packageJson.version;
};
const incrementVersion = (currentVersion) => {
console.log("Incrementing version...");
const now = new Date();
const year = now.getFullYear().toString().slice(-2);
const start = new Date(now.getFullYear(), 0, 1);
const week = Math.ceil(((now - start) / 86400000 + start.getDay() + 1) / 7);
const [prevYear, prevWeek, minor] = currentVersion.split(".").map(Number);
let newMinor = minor + 1;
if (year != prevYear || week != prevWeek) {
newMinor = 0;
}
const v = `v${year}.${week}.${newMinor}`;
return v;
};
const updatePackage = (newVersion) => {
console.log("Updating package.json...");
if (isDryRun) {
console.log(`[Dry Run] Updated package.json to version ${newVersion}`);
return;
}
const packagePath = path.resolve(__dirname, "../package.json");
// Read the package.json file
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
// Update the version field
packageJson.version = newVersion.replace("v", "");
// Write the updated JSON back to package.json
fs.writeFileSync(
packagePath,
JSON.stringify(packageJson, null, 2) + "\n",
"utf8"
);
console.log(`Updated package.json to version ${newVersion}`);
};
const checkUncommittedChanges = () => {
console.log("Checking uncommitted changes...");
const status = execSync("git status --porcelain").toString().trim();
if (isDryRun) {
console.log("[Dry Run] Checking uncommitted changes...");
} else if (status) {
console.error(
"You have uncommitted changes. Please commit or stash them before proceeding."
);
process.exit(1);
}
};
const buildProject = () => {
console.log("Building project...");
let filter = "";
if (isFrontend && !isBackend) {
filter = "--filter @monkeytype/frontend";
} else if (isBackend && !isFrontend) {
filter = "--filter @monkeytype/backend";
}
runCommand("npx turbo lint test validate-json build " + filter);
};
const deployBackend = () => {
console.log("Deploying backend...");
runCommand("sh ./bin/deployBackend.sh");
};
const deployFrontend = () => {
console.log("Deploying frontend...");
runCommand("cd frontend && npx firebase deploy -P live --only hosting");
};
const purgeCache = () => {
console.log("Purging Cloudflare cache...");
runCommand("sh ./bin/purgeCfCache.sh");
};
const generateChangelog = async () => {
console.log("Generating changelog...");
const changelog = runCommand("node bin/buildChangelog.mjs", true);
return changelog;
};
const createCommitAndTag = (version) => {
console.log("Creating commit and tag... Pushing to Github...");
runCommand(`git add .`);
runCommand(`git commit -m "chore: release ${version}" --no-verify`);
runCommand(`git tag ${version}`);
runCommand(`git push origin master --tags --no-verify`);
};
const createGithubRelease = async (version, changelogContent) => {
console.log("Creating GitHub release...");
if (isDryRun) {
console.log(
`[Dry Run] Sent release request to GitHub for version ${version}`
);
} else {
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
const { owner, repo } = {
owner: "monkeytypegame",
repo: "monkeytype",
};
await octokit.repos.createRelease({
owner,
repo,
tag_name: version,
name: `${version}`,
body: changelogContent,
});
}
};
const main = async () => {
console.log("Starting release process...");
checkBranchSync();
checkUncommittedChanges();
const changelogContent = await generateChangelog();
console.log(changelogContent);
if (!readlineSync.keyInYN("Changelog looks good?")) {
console.log("Exiting.");
process.exit(1);
}
const currentVersion = getCurrentVersion();
const newVersion = incrementVersion(currentVersion);
buildProject();
if (!readlineSync.keyInYN(`Ready to release ${newVersion}?`)) {
console.log("Exiting.");
process.exit(1);
}
if (!noDeploy && (isBackend || (!isFrontend && !isBackend))) {
deployBackend();
}
if (!noDeploy && (isFrontend || (!isFrontend && !isBackend))) {
deployFrontend();
}
if (!noDeploy) purgeCache();
updatePackage(newVersion);
createCommitAndTag(newVersion);
await createGithubRelease(newVersion, changelogContent);
console.log(`Release ${newVersion} completed successfully.`);
process.exit(0);
};
main();

View file

@ -1,23 +1,36 @@
FROM node:20.16.0-alpine3.19 AS builder
FROM node:18.20.4-alpine3.19
##install wget, used by the applyConfig script
RUN apk add wget
WORKDIR /app
#copy
COPY package.json package.json
COPY package-lock.json package-lock.json
COPY pnpm-lock.yaml pnpm-lock.yaml
COPY pnpm-workspace.yaml pnpm-workspace.yaml
COPY turbo.json turbo.json
COPY packages packages
COPY backend backend
#gimme pnpm
RUN npm i -g pnpm
#build
RUN npm ci
RUN pnpm i --frozen-lockfile
RUN npm run build
## remove dev dependencies
RUN npm install --install-strategy=nested --omit=dev --workspace=backend --ignore-scripts
#deploy (install all non-dev dependencies in a single node_module folder)
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 add wget
# COPY to target
COPY --from=builder /prod/backend/node_modules /app/backend/node_modules
COPY --from=builder /prod/backend/dist /app/backend/dist
COPY --from=builder /prod/backend/email-templates /app/backend/email-templates
COPY --from=builder /prod/backend/redis-scripts /app/backend/redis-scripts
## to build directory
WORKDIR /app/backend/dist

View file

@ -1,4 +1,4 @@
FROM node:18.20.4-alpine3.19 AS builder
FROM node:20.16.0-alpine3.19 AS builder
WORKDIR /app
#ENV
@ -7,7 +7,8 @@ ENV RECAPTCHA_SITE_KEY=###RECAPTCHA_SITE_KEY###
#COPY
COPY package.json package.json
COPY package-lock.json package-lock.json
COPY pnpm-lock.yaml pnpm-lock.yaml
COPY pnpm-workspace.yaml pnpm-workspace.yaml
COPY turbo.json turbo.json
COPY packages packages
COPY frontend frontend
@ -15,8 +16,11 @@ COPY frontend frontend
COPY docker/frontend/firebase-config-live.ts frontend/src/ts/constants/firebase-config.ts
COPY docker/frontend/firebase-config-live.ts frontend/src/ts/constants/firebase-config-live.ts
#BUILD
RUN npm ci
#gimme pnpm
RUN npm i -g pnpm
#build
RUN pnpm i --frozen-lockfile
RUN npm run build
# COPY to target

View file

@ -6,7 +6,7 @@
- [**Table of Contents**](#table-of-contents)
- [Prerequisites](#prerequisites)
- [Git](#git)
- [NodeJS and NPM](#nodejs-and-npm)
- [NodeJS and PNPM](#nodejs-and-pnpm)
- [Docker (Recommended but Optional)](#docker-recommended-but-optional)
- [Firebase (optional)](#firebase-optional)
- [Config file](#config-file)
@ -21,7 +21,7 @@
## Prerequisites
This contribution guide is for cases in which you need to test the functionality of your changes, or if you need to take screenshots of your changes. You will need a computer with a stable internet connection, a text editor, Git, and NodeJS with version 18.20.4. There are some additional requirements depending on what you're looking to contribute, such as Firebase for authentication, and Mongo and Docker for the backend. Read the below sections to understand how to set up each of these tools.
This contribution guide is for cases in which you need to test the functionality of your changes, or if you need to take screenshots of your changes. You will need a computer with a stable internet connection, a text editor, Git, and NodeJS with version 20.16.0. There are some additional requirements depending on what you're looking to contribute, such as Firebase for authentication, and Mongo and Docker for the backend. Read the below sections to understand how to set up each of these tools.
### Git
@ -31,14 +31,16 @@ This contribution guide is for cases in which you need to test the functionality
Git is optional but we recommend you utilize it. Monkeytype uses the Git source control management (SCM) system for its version control. Assuming you don't have experience typing commands in the command line, we suggest installing [Sourcetree](https://www.sourcetreeapp.com/). You will be able to utilize the power of Git without needing to remember any cryptic commands. Using a Git client such as Sourcetree won't give you access to the full functionality of Git, but provides an easy-to-understand graphical user interface (GUI). Once you have downloaded Sourcetree, run the installer. While installing Sourcetree, keep your eyes peeled for the option to also install Git with Sourcetree. This is the option you will need to look for in order to install Git. **Make sure to click yes in the installer to install Git with Sourcetree.**
### NodeJS and NPM
### NodeJS and PNPM
Currently, the project is using version `18.20.4 LTS`.
Currently, the project is using version `20.16.0 LTS`.
If you use `nvm` (if you use Windows, use [nvm-windows](https://github.com/coreybutler/nvm-windows)) then you can run `nvm install` and `nvm use` (you might need to specify the exact version eg: `nvm install 18.20.4` then `nvm use 18.20.4`) to use the version of Node.js in the `.nvmrc` file.
If you use `nvm` (if you use Windows, use [nvm-windows](https://github.com/coreybutler/nvm-windows)) then you can run `nvm install` and `nvm use` (you might need to specify the exact version eg: `nvm install 20.16.0` then `nvm use 20.16.0`) to use the version of Node.js in the `.nvmrc` file.
Alternatively, you can navigate to the NodeJS [website](https://nodejs.org/en/) to download it from there.
For package management, we use `pnpm` instead of `npm` or `yarn`. You can install it by running `npm i -g pnpm@9.6.0`. This will install `pnpm` globally on your machine.
### Docker (Recommended but Optional)
You can use docker to run the frontend and backend. This will take care of OS-specific problems but might be a bit more resource-intensive. You can download it from the [Docker website](https://www.docker.com/get-started/#h_installation).
@ -65,7 +67,7 @@ The account system will not let you create an account without a Firebase project
- Click "Generate New Private Key"
- Save as `serviceAccountKey.json` inside the `backend/src/credentials/` directory.
1. Run `npm install -g firebase-tools` to install the Firebase Command Line Interface.
1. Run `pnpm add -g firebase-tools` to install the Firebase Command Line Interface.
1. Run `firebase login` on your terminal to log in to the same Google account you just used to create the project.
1. Within the `frontend` directory, duplicate `.firebaserc_example`, rename the new file to `.firebaserc` and change the project name to the firebase project id you just created.
@ -120,7 +122,7 @@ Its time to run Monkeytype. Just like with the databases, you can run the fronte
### Dependencies (if running manually)
Run `npm i` in the project root to install all dependencies.
Run `pnpm i` in the project root to install all dependencies.
### Both Frontend and Backend

View file

@ -1,4 +1,5 @@
import * as Config from "../../src/ts/config";
import { CustomThemeColors } from "@monkeytype/contracts/schemas/configs";
import { randomBytes } from "crypto";
@ -18,8 +19,8 @@ describe("Config", () => {
expect(Config.setPlaySoundOnClick("invalid" as any)).toBe(false);
});
it("setSoundVolume", () => {
expect(Config.setSoundVolume("0.1")).toBe(true);
expect(Config.setSoundVolume("1.0")).toBe(true);
expect(Config.setSoundVolume(0.1)).toBe(true);
expect(Config.setSoundVolume(1.0)).toBe(true);
expect(Config.setSoundVolume("invalid" as any)).toBe(false);
});
it("setDifficulty", () => {
@ -188,6 +189,22 @@ describe("Config", () => {
expect(Config.setKeymapShowTopRow("never")).toBe(true);
expect(Config.setKeymapShowTopRow("invalid" as any)).toBe(false);
});
it("setKeymapSize", () => {
expect(Config.setKeymapSize(0.5)).toBe(true);
expect(Config.setKeymapSize(2)).toBe(true);
expect(Config.setKeymapSize(3.5)).toBe(true);
expect(Config.setKeymapSize("invalid" as any)).toBe(false);
//invalid values being "auto-fixed"
expect(Config.setKeymapSize(0)).toBe(true);
expect(Config.default.keymapSize).toBe(0.5);
expect(Config.setKeymapSize(4)).toBe(true);
expect(Config.default.keymapSize).toBe(3.5);
expect(Config.setKeymapSize(1.25)).toBe(true);
expect(Config.default.keymapSize).toBe(1.3);
expect(Config.setKeymapSize(1.24)).toBe(true);
expect(Config.default.keymapSize).toBe(1.2);
});
it("setCustomBackgroundSize", () => {
expect(Config.setCustomBackgroundSize("contain")).toBe(true);
expect(Config.setCustomBackgroundSize("cover")).toBe(true);

View file

@ -0,0 +1,15 @@
import * as generate from "../../src/ts/utils/generate";
describe("hexadecimal", () => {
it("should generate a random hexadecimal string", () => {
const hex = generate.getHexadecimal();
expect(hex.length).toSatisfy(
(len: number) => len % 2 === 0,
"The length of the hexadecimal string should be even."
);
expect(hex.length).toBeGreaterThanOrEqual(2);
expect(hex.length).toBeLessThanOrEqual(16);
expect(hex).toMatch(/^[0-9a-f]+$/);
});
});

View file

@ -0,0 +1,133 @@
import * as IpAddresses from "../../src/ts/utils/ip-addresses";
const IP_GENERATE_COUNT = 1000;
describe("IP Addresses", () => {
describe("Compressing IPv6", () => {
it("should compress ipv6 according to the official rules", () => {
const rawIps = [
"0000:0000:0000:0000:0001:0000:0000:0000",
"b70b:ad23:3d4b:23a9:8000:0000:0000:0000",
"ad69:0005:02a4:a8a9:5dae:55f4:d87a:0000",
"0000:0000:0000:0001:0002:0000:0000:0000",
"0000:0000:0000:0000:0000:0000:0000:0000",
"2001:db8:0:0:0:0:2:1",
"2001:db8:0000:1:1:1:1:1",
"9ffd:7895:b4ae:36f6:b50a:8300:0000:0000/88",
];
const compressedIps = [
"::1:0:0:0",
"b70b:ad23:3d4b:23a9:8000::",
"ad69:5:2a4:a8a9:5dae:55f4:d87a:0",
"::1:2:0:0:0",
"::",
"2001:db8::2:1",
"2001:db8:0:1:1:1:1:1",
"9ffd:7895:b4ae:36f6:b50a:8300::/88",
];
for (let i = 0; i < rawIps.length; i++) {
expect(IpAddresses.compressIpv6(rawIps[i] as string)).toEqual(
compressedIps[i]
);
}
});
});
describe("Generating IPv4", () => {
it("should generate valid IPv4 addresses", () => {
// We generate a set number of ip addresses dictated by the constant
for (let i = 0; i < IP_GENERATE_COUNT; i++) {
const ipAddress = IpAddresses.getRandomIPv4address();
const splitIpAddress = ipAddress.split(".");
expect(splitIpAddress.length, "Make sure there are four parts").toEqual(
4
);
for (let j = 0; j < 4; j++) {
const currentNumber = Number(splitIpAddress[j]);
expect(
currentNumber,
"Each part of an IPv4 should be >= 0"
).toBeGreaterThanOrEqual(0);
expect(
currentNumber,
"Each part of an IPv4 should be <= 255"
).toBeLessThanOrEqual(255);
}
}
});
});
describe("Generating IPv6", () => {
it("should generate valid IPv6 addresses", () => {
for (let i = 0; i < IP_GENERATE_COUNT; i++) {
const ipAddress = IpAddresses.getRandomIPv6address();
const splitIpAddress = ipAddress.split(":");
expect(
splitIpAddress.length,
"Make sure there are eight parts"
).toEqual(8);
for (let j = 0; j < 8; j++) {
const currentPart = splitIpAddress[j] as string;
expect(
currentPart.length,
"Each part of an IPv6 should be between 1 and 4 characters"
).toBeGreaterThanOrEqual(1);
expect(
currentPart.length,
"Each part of an IPv6 should be between 1 and 4 characters"
).toBeLessThanOrEqual(4);
const currentNumber = parseInt(currentPart, 16);
expect(
currentNumber,
"Each part of an IPv6 should be a valid hexadecimal number"
).not.toBeNaN();
expect(
currentNumber,
"Each part of an IPv6 should be >= 0"
).toBeGreaterThanOrEqual(0);
expect(
currentNumber,
"Each part of an IPv6 should be <= 65535"
).toBeLessThanOrEqual(65535);
}
}
});
});
describe("Address to CIDR", () => {
it("should convert an IPv4 address to CIDR notation", () => {
const ip = "192.168.1.1";
const cidr = IpAddresses.addressToCIDR(ip);
const ipParts = cidr.split("/");
expect(
ipParts.length,
"There should only be one '/' in the ip addresss"
).toEqual(2);
const maskSize = Number(ipParts[1]);
expect(maskSize).not.toBeNaN();
expect(maskSize).toBeGreaterThanOrEqual(0);
expect(maskSize).toBeLessThanOrEqual(32);
});
it("should convert an IPv6 address to CIDR notation", () => {
const ip = "b70b:ad23:3d4b:23a9:8000:0000:0000:0000";
const cidr = IpAddresses.addressToCIDR(ip);
const ipParts = cidr.split("/");
expect(
ipParts.length,
"There should only be one '/' in the ip addresss"
).toEqual(2);
console.log(cidr);
const maskSize = Number(ipParts[1]);
expect(maskSize).not.toBeNaN();
expect(maskSize).toBeGreaterThanOrEqual(1);
expect(maskSize).toBeLessThanOrEqual(128);
});
});
});

View file

@ -2,7 +2,7 @@ name: monkeytype
services:
frontend:
container_name: monkeytype-frontend
image: node:18.20.4
image: node:20.16.0
user: "node" ##this works as long as your local user has uid=1000
# restart: on-failure
environment:
@ -14,7 +14,8 @@ services:
- ../../:/monkeytype
entrypoint: 'bash -c "echo starting, this may take a while... \
&& cd /monkeytype \
&& npm i --prefer-offline --no-audit \
&& npm i -g pnpm \
&& pnpm i \
&& export SERVER_OPEN=false \
&& npm run dev-fe"'

View file

@ -24,16 +24,16 @@
"docker": "docker compose -f docker/compose.dev.yml up"
},
"engines": {
"node": "18.20.4"
"node": "20.16.0"
},
"browserslist": [
"defaults"
],
"devDependencies": {
"@fortawesome/fontawesome-free": "5.15.4",
"@monkeytype/shared-types": "*",
"@monkeytype/eslint-config": "*",
"@monkeytype/typescript-config": "*",
"@monkeytype/eslint-config": "workspace:*",
"@monkeytype/shared-types": "workspace:*",
"@monkeytype/typescript-config": "workspace:*",
"@types/canvas-confetti": "1.4.3",
"@types/chartjs-plugin-trendline": "1.0.1",
"@types/damerau-levenshtein": "1.0.0",
@ -72,7 +72,8 @@
},
"dependencies": {
"@date-fns/utc": "1.2.0",
"@monkeytype/contracts": "*",
"@monkeytype/contracts": "workspace:*",
"@ts-rest/core": "3.45.2",
"axios": "1.6.4",
"canvas-confetti": "1.5.1",
"chart.js": "3.7.1",
@ -82,7 +83,7 @@
"color-blend": "4.0.0",
"damerau-levenshtein": "1.0.8",
"date-fns": "3.6.0",
"firebase": "10.8.0",
"firebase": "10.12.4",
"hangul-js": "0.2.6",
"howler": "2.2.3",
"html2canvas": "1.4.1",
@ -95,8 +96,9 @@
"slim-select": "2.8.1",
"seedrandom": "3.0.5",
"socket.io-client": "4.7.1",
"stemmer": "2.0.0",
"throttle-debounce": "3.0.1"
"stemmer": "2.0.1",
"throttle-debounce": "5.0.2",
"zod": "3.23.8"
},
"overrides": {
"madge": {

View file

@ -102,7 +102,7 @@ export function getFontawesomeConfig(debug = false): FontawesomeConfig {
throw new Error("unknown icons: " + leftOvers);
}
if (debug === true) {
if (debug) {
console.debug(
"Make sure fontawesome modules are active: ",
Object.entries(modules2)

View file

@ -735,10 +735,11 @@
<span>sound volume</span>
</div>
<div class="text">Change the volume of the sound effects.</div>
<div class="buttons">
<button data-config-value="0.1">quiet</button>
<button data-config-value="0.5">medium</button>
<button data-config-value="1.0">loud</button>
<div class="inputs">
<div class="rangeGroup">
<div class="value">100</div>
<input type="range" min="0" max="1" step="0.1" />
</div>
</div>
</div>
<div class="section fullWidth" data-config-name="playSoundOnClick">
@ -827,8 +828,9 @@
</div>
<div class="text">
Displays a second caret that moves at constant speed. The 'average'
option averages the speed of last 10 results. The 'daily' option takes
the highest speed of the last 24 hours.
option averages the speed of last 10 results. The 'tag pb' option takes
the highest PB of any active tag. The 'daily' option takes the highest
speed of the last 24 hours.
</div>
<div class="inputs">
<div class="inputAndButton">
@ -849,6 +851,7 @@
<button data-config-value="off">off</button>
<button data-config-value="average">avg</button>
<button data-config-value="pb">pb</button>
<button data-config-value="tagPb">tag pb</button>
<button data-config-value="last">last</button>
<button data-config-value="daily">daily</button>
<button data-config-value="custom">custom</button>
@ -1203,6 +1206,19 @@
<button data-config-value="never">never</button>
</div>
</div>
<div class="section" data-config-name="keymapSize">
<div class="groupTitle">
<i class="fas fa-keyboard"></i>
<span>keymap size</span>
</div>
<div class="text">Change the size of the keymap.</div>
<div class="inputs">
<div class="rangeGroup">
<div class="value">1.0</div>
<input type="range" min="0.5" max="3.5" step="0.1" />
</div>
</div>
</div>
<div class="sectionSpacer"></div>
</div>

View file

@ -4,6 +4,44 @@
</div>
</dialog>
<dialog id="lastSignedOutResult" class="modalWrapper hidden">
<div class="modal">
<div class="title">Last signed out result</div>
<div class="question">Would you like to save it?</div>
<div class="divider"></div>
<div class="result">
<div class="group wpm">
<div class="sub">wpm</div>
<div class="val">-</div>
</div>
<div class="group acc">
<div class="sub">accuracy</div>
<div class="val">-</div>
</div>
<div class="group raw">
<div class="sub">raw</div>
<div class="val">-</div>
</div>
<div class="group con">
<div class="sub">consistency</div>
<div class="val">-</div>
</div>
<div class="group chardata">
<div class="sub">characters</div>
<div class="val">-</div>
</div>
<div class="group testType">
<div class="sub">test type</div>
<div class="val">-</div>
</div>
</div>
<div class="buttons">
<button class="save">save</button>
<button class="discard">discard</button>
</div>
</div>
</dialog>
<dialog id="devOptionsModal" class="modalWrapper hidden">
<div class="modal">
<div class="title">Dev options</div>

View file

@ -981,6 +981,45 @@ body.darkMode {
}
}
#lastSignedOutResult {
.modal {
max-width: 600px;
.buttons {
display: flex;
flex-direction: row-reverse;
gap: 0.5rem;
button {
flex-grow: 1;
}
}
.result {
display: grid;
gap: 0.5rem;
grid-template-columns: 1fr 1fr;
}
.divider {
background: var(--sub-alt-color);
width: 100%;
height: 0.25rem;
border-radius: var(--roundness);
}
.group {
.sub {
font-size: 0.75em;
color: var(--sub-color);
}
&.testType {
grid-column: 1;
}
&.wpm,
&.acc {
font-size: 2em;
}
}
}
}
#devOptionsModal {
.modal {
max-width: 400px;
@ -1028,6 +1067,12 @@ body.darkMode {
display: grid;
gap: 1rem;
}
.title {
color: var(--sub-color);
}
.description {
color: var(--text-color);
}
.settings {
display: grid;
gap: 1rem;

View file

@ -70,6 +70,12 @@
}
}
.rangeGroup {
display: grid;
grid-template-columns: auto 1fr;
gap: 1rem;
}
&[data-config-name="autoSwitchThemeInputs"] {
grid-template-areas: unset;
grid-template-columns: 1fr 3fr 1fr 3fr;

View file

@ -917,12 +917,12 @@
opacity: 0;
}
&.half {
&.pending {
--main: var(--bg-color);
--alt: var(--main-color);
outline: 0.2em solid var(--main-color);
}
&.broken {
&.ineligible {
--main: var(--sub-color);
--alt: var(--bg-color);
.fa-slash {

View file

@ -101,10 +101,12 @@ function apeifyClientMethod(
errorMessage = typedError.message;
if (isAxiosError(typedError)) {
const data = typedError.response?.data as { data: TData };
return {
status: typedError.response?.status ?? 500,
message: typedError.message,
...typedError.response?.data,
...data,
};
}
}

View file

@ -5,6 +5,12 @@ import { envConfig } from "../../constants/env-config";
import { getAuthenticatedUser, isAuthenticated } from "../../firebase";
import { EndpointMetadata } from "@monkeytype/contracts/schemas/api";
function timeoutSignal(ms: number): AbortSignal {
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(new Error("request timed out")), ms);
return ctrl.signal;
}
function buildApi(timeout: number): (args: ApiFetcherArgs) => Promise<{
status: number;
body: unknown;
@ -27,12 +33,22 @@ function buildApi(timeout: number): (args: ApiFetcherArgs) => Promise<{
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(request.path, {
signal: AbortSignal.timeout(timeout),
const fetchOptions: RequestInit = {
method: request.method as Method,
headers,
body: request.body,
};
const usePolyfill = AbortSignal?.timeout === undefined;
const response = await fetch(request.path, {
...fetchOptions,
signal: usePolyfill
? timeoutSignal(timeout)
: AbortSignal.timeout(timeout),
});
const body = await response.json();
if (response.status >= 400) {
console.error(`${request.method} ${request.path} failed`, {
@ -47,9 +63,19 @@ function buildApi(timeout: number): (args: ApiFetcherArgs) => Promise<{
headers: response.headers ?? new Headers(),
};
} catch (e: Error | unknown) {
let message = "Unknown error";
if (e instanceof Error) {
if (e.message.includes("timed out")) {
message = "request took too long to complete";
} else {
message = e.message;
}
}
return {
status: 500,
body: { message: e },
body: { message },
headers: new Headers(),
};
}

View file

@ -1,33 +0,0 @@
const BASE_PATH = "/ape-keys";
export default class ApeKeys {
constructor(private httpClient: Ape.HttpClient) {
this.httpClient = httpClient;
}
async get(): Ape.EndpointResponse<Ape.ApeKeys.GetApeKeys> {
return await this.httpClient.get(BASE_PATH);
}
async generate(
name: string,
enabled: boolean
): Ape.EndpointResponse<Ape.ApeKeys.GenerateApeKey> {
const payload = { name, enabled };
return await this.httpClient.post(BASE_PATH, { payload });
}
async update(
apeKeyId: string,
updates: { name?: string; enabled?: boolean }
): Ape.EndpointResponse<null> {
const payload = { ...updates };
const encoded = encodeURIComponent(apeKeyId);
return await this.httpClient.patch(`${BASE_PATH}/${encoded}`, { payload });
}
async delete(apeKeyId: string): Ape.EndpointResponse<null> {
const encoded = encodeURIComponent(apeKeyId);
return await this.httpClient.delete(`${BASE_PATH}/${encoded}`);
}
}

View file

@ -1,23 +1,19 @@
import Leaderboards from "./leaderboards";
import Presets from "./presets";
import Psas from "./psas";
import Quotes from "./quotes";
import Results from "./results";
import Users from "./users";
import ApeKeys from "./ape-keys";
import Public from "./public";
import Configuration from "./configuration";
import Dev from "./dev";
export default {
Leaderboards,
Presets,
Psas,
Public,
Quotes,
Results,
Users,
ApeKeys,
Configuration,
Dev,
};

View file

@ -1,44 +0,0 @@
const BASE_PATH = "/presets";
export default class Presets {
constructor(private httpClient: Ape.HttpClient) {
this.httpClient = httpClient;
}
async get(): Ape.EndpointResponse<Ape.Presets.GetPresets> {
return await this.httpClient.get(BASE_PATH);
}
async add(
presetName: string,
configChanges: MonkeyTypes.ConfigChanges
): Ape.EndpointResponse<Ape.Presets.PostPreset> {
const payload = {
name: presetName,
config: configChanges,
};
return await this.httpClient.post(BASE_PATH, { payload });
}
async edit(
presetId: string,
presetName: string,
configChanges: MonkeyTypes.ConfigChanges
): Ape.EndpointResponse<Ape.Presets.PatchPreset> {
const payload = {
_id: presetId,
name: presetName,
config: configChanges,
};
return await this.httpClient.patch(BASE_PATH, { payload });
}
async delete(
presetId: string
): Ape.EndpointResponse<Ape.Presets.DeltePreset> {
const encoded = encodeURIComponent(presetId);
return await this.httpClient.delete(`${BASE_PATH}/${encoded}`);
}
}

View file

@ -1,5 +1,5 @@
import { DBResult, Result } from "@monkeytype/shared-types";
import { Mode } from "@monkeytype/shared-types/config";
import { Mode } from "@monkeytype/contracts/schemas/shared";
const BASE_PATH = "/results";

View file

@ -6,7 +6,7 @@ import {
UserProfileDetails,
UserTag,
} from "@monkeytype/shared-types";
import { Mode, Mode2 } from "@monkeytype/shared-types/config";
import { Mode, Mode2 } from "@monkeytype/contracts/schemas/shared";
const BASE_PATH = "/users";

View file

@ -3,6 +3,8 @@ import { buildHttpClient } from "./adapters/axios-adapter";
import { envConfig } from "../constants/env-config";
import { buildClient } from "./adapters/ts-rest-adapter";
import { configsContract } from "@monkeytype/contracts/configs";
import { presetsContract } from "@monkeytype/contracts/presets";
import { apeKeysContract } from "@monkeytype/contracts/ape-keys";
const API_PATH = "";
const BASE_URL = envConfig.backendUrl;
@ -18,9 +20,9 @@ const Ape = {
psas: new endpoints.Psas(httpClient),
quotes: new endpoints.Quotes(httpClient),
leaderboards: new endpoints.Leaderboards(httpClient),
presets: new endpoints.Presets(httpClient),
presets: buildClient(presetsContract, BASE_URL, 10_000),
publicStats: new endpoints.Public(httpClient),
apeKeys: new endpoints.ApeKeys(httpClient),
apeKeys: buildClient(apeKeysContract, BASE_URL, 10_000),
configuration: new endpoints.Configuration(httpClient),
dev: new endpoints.Dev(buildHttpClient(API_URL, 240_000)),
};

View file

@ -1,11 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// for some reason when using the dot notaion, the types are not being recognized as used
declare namespace Ape.ApeKeys {
type GetApeKeys = Record<string, import("@monkeytype/shared-types").ApeKey>;
type GenerateApeKey = {
apeKey: string;
apeKeyId: string;
apeKeyDetails: import("@monkeytype/shared-types").ApeKey;
};
}

View file

@ -1,10 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// for some reason when using the dot notaion, the types are not being recognized as used
declare namespace Ape.Presets {
type GetPresets = DBConfigPreset[];
type PostPreset = {
presetId: string;
};
type PatchPreset = null;
type DeltePreset = null;
}

View file

@ -491,9 +491,7 @@ async function runActiveCommand(): Promise<void> {
updateInput(inputModeParams.value as string);
hideCommands();
} else if (command.subgroup) {
CommandlineLists.pushToStack(
command.subgroup as MonkeyTypes.CommandsSubgroup
);
CommandlineLists.pushToStack(command.subgroup);
updateInput("");
await filterSubgroup();
await showCommands();

View file

@ -56,6 +56,7 @@ import KeymapModeCommands from "./lists/keymap-mode";
import KeymapStyleCommands from "./lists/keymap-style";
import KeymapLegendStyleCommands from "./lists/keymap-legend-style";
import KeymapShowTopRowCommands from "./lists/keymap-show-top-row";
import KeymapSizeCommands from "./lists/keymap-size";
import EnableAdsCommands from "./lists/enable-ads";
import MonkeyPowerLevelCommands from "./lists/monkey-power-level";
import BailOutCommands from "./lists/bail-out";
@ -247,9 +248,7 @@ export const commands: MonkeyTypes.CommandsSubgroup = {
icon: "fa-tint",
exec: ({ input }): void => {
if (input === undefined) return;
void UpdateConfig.setCustomLayoutfluid(
input as MonkeyTypes.CustomLayoutFluidSpaces
);
void UpdateConfig.setCustomLayoutfluid(input);
},
},
@ -302,6 +301,7 @@ export const commands: MonkeyTypes.CommandsSubgroup = {
...KeymapModeCommands,
...KeymapStyleCommands,
...KeymapLegendStyleCommands,
...KeymapSizeCommands,
...KeymapLayoutsCommands,
...KeymapShowTopRowCommands,

View file

@ -0,0 +1,19 @@
import Config, * as UpdateConfig from "../../config";
const commands: MonkeyTypes.Command[] = [
{
id: "changeKeymapSize",
display: "Keymap size...",
icon: "fa-keyboard",
alias: "keyboard",
input: true,
defaultValue: (): string => {
return Config.keymapSize.toString();
},
exec: ({ input }): void => {
if (input === undefined || input === "") return;
UpdateConfig.setKeymapSize(parseFloat(input));
},
},
];
export default commands;

View file

@ -24,6 +24,15 @@ const subgroup: MonkeyTypes.CommandsSubgroup = {
TestLogic.restart();
},
},
{
id: "setPaceCaretTagPb",
display: "tag pb",
configValue: "tagPb",
exec: (): void => {
UpdateConfig.setPaceCaret("tagPb");
TestLogic.restart();
},
},
{
id: "setPaceCaretLast",
display: "last",

View file

@ -10,7 +10,7 @@ const subgroup: MonkeyTypes.CommandsSubgroup = {
display: "quiet",
configValue: "0.1",
exec: (): void => {
UpdateConfig.setSoundVolume("0.1");
UpdateConfig.setSoundVolume(0.1);
void SoundController.playClick();
},
},
@ -19,7 +19,7 @@ const subgroup: MonkeyTypes.CommandsSubgroup = {
display: "medium",
configValue: "0.5",
exec: (): void => {
UpdateConfig.setSoundVolume("0.5");
UpdateConfig.setSoundVolume(0.5);
void SoundController.playClick();
},
},
@ -28,7 +28,17 @@ const subgroup: MonkeyTypes.CommandsSubgroup = {
display: "loud",
configValue: "1.0",
exec: (): void => {
UpdateConfig.setSoundVolume("1.0");
UpdateConfig.setSoundVolume(1.0);
void SoundController.playClick();
},
},
{
id: "setSoundVolumeCustom",
display: "custom...",
input: true,
exec: ({ input }): void => {
if (input === undefined || input === "") return;
UpdateConfig.setSoundVolume(parseFloat(input));
void SoundController.playClick();
},
},

View file

@ -56,7 +56,7 @@ function update(): void {
display: `Clear tags`,
icon: "fa-times",
sticky: true,
exec: (): void => {
exec: async (): Promise<void> => {
const snapshot = DB.getSnapshot();
if (!snapshot) return;
@ -67,6 +67,13 @@ function update(): void {
});
DB.setSnapshot(snapshot);
if (
Config.paceCaret === "average" ||
Config.paceCaret === "tagPb" ||
Config.paceCaret === "daily"
) {
await PaceCaret.init();
}
void ModesNotice.update();
TagController.saveActiveToLocalStorage();
},
@ -85,12 +92,15 @@ function update(): void {
},
exec: async (): Promise<void> => {
TagController.toggle(tag._id);
void ModesNotice.update();
if (Config.paceCaret === "average") {
if (
Config.paceCaret === "average" ||
Config.paceCaret === "tagPb" ||
Config.paceCaret === "daily"
) {
await PaceCaret.init();
void ModesNotice.update();
}
void ModesNotice.update();
},
});
}

View file

@ -21,7 +21,7 @@ function update(themes: MonkeyTypes.Theme[]): void {
subgroup.list = [];
const favs: MonkeyTypes.Command[] = [];
themes.forEach((theme) => {
if ((Config.favThemes as string[]).includes(theme.name)) {
if (Config.favThemes.includes(theme.name)) {
favs.push({
id: "changeTheme" + capitalizeFirstLetterOfEachWord(theme.name),
display: theme.name.replace(/_/g, " "),

View file

@ -21,6 +21,8 @@ import * as TribeConfigSyncEvent from "./observables/tribe-config-sync-event";
import { isDevEnvironment, reloadAfter } from "./utils/misc";
import * as ConfigSchemas from "@monkeytype/contracts/schemas/configs";
import { Config } from "@monkeytype/contracts/schemas/configs";
import { roundTo1 } from "./utils/numbers";
import { Mode, ModeSchema } from "@monkeytype/contracts/schemas/shared";
export let localStorageConfig: Config;
@ -130,18 +132,8 @@ export function setPunctuation(punc: boolean, nosave?: boolean): boolean {
return true;
}
<<<<<<< HEAD
export function setMode(mode: ConfigTypes.Mode, nosave?: boolean,
tribeOverride = false): boolean {
if (
!isConfigValueValid("mode", mode, [
["time", "words", "quote", "zen", "custom"],
])
) {
=======
export function setMode(mode: ConfigSchemas.Mode, nosave?: boolean): boolean {
if (!isConfigValueValid("mode", mode, ConfigSchemas.ModeSchema)) {
>>>>>>> master
export function setMode(mode: Mode, nosave?: boolean, tribeOverride = false): boolean {
if (!isConfigValueValid("mode", mode, ModeSchema)) {
return false;
}
if (!TribeState.canChangeConfig(tribeOverride)) return false;
@ -216,6 +208,11 @@ export function setSoundVolume(
val: ConfigSchemas.SoundVolume,
nosave?: boolean
): boolean {
if (val < 0 || val > 1) {
Notifications.add("Sound volume must be between 0 and 1", 0);
val = 0.5;
}
if (
!isConfigValueValid("sound volume", val, ConfigSchemas.SoundVolumeSchema)
) {
@ -539,8 +536,11 @@ export function setPaceCaret(
}
if (document.readyState === "complete") {
if (val === "pb" && !isAuthenticated()) {
Notifications.add("PB pace caret is unavailable without an account", 0);
if ((val === "pb" || val === "tagPb") && !isAuthenticated()) {
Notifications.add(
`Pace caret "pb" and "tag pb" are unavailable without an account`,
0
);
return false;
}
}
@ -1885,6 +1885,38 @@ export function setKeymapShowTopRow(
return true;
}
export function setKeymapSize(
keymapSize: ConfigSchemas.KeymapSize,
nosave?: boolean
): boolean {
//auto-fix values to avoid validation errors
if (keymapSize < 0.5) keymapSize = 0.5;
if (keymapSize > 3.5) keymapSize = 3.5;
keymapSize = roundTo1(keymapSize);
if (
!isConfigValueValid(
"keymap size",
keymapSize,
ConfigSchemas.KeymapSizeSchema
)
) {
return false;
}
config.keymapSize = keymapSize;
$("#keymap").css("zoom", keymapSize);
saveToLocalStorage("keymapSize", nosave);
ConfigEvent.dispatch("keymapSize", config.keymapSize, nosave);
// trigger a resize event to update the layout - handled in ui.ts:108
$(window).trigger("resize");
return true;
}
export function setLayout(
layout: ConfigSchemas.Layout,
nosave?: boolean
@ -2028,10 +2060,7 @@ export async function setCustomLayoutfluid(
return false;
}
const customLayoutfluid = trimmed.replace(
/ /g,
"#"
) as ConfigSchemas.CustomLayoutFluid;
const customLayoutfluid = trimmed.replace(/ /g, "#");
config.customLayoutfluid = customLayoutfluid;
saveToLocalStorage("customLayoutfluid", nosave);
@ -2241,6 +2270,7 @@ export async function apply(
setKeymapLegendStyle(configObj.keymapLegendStyle, true);
setKeymapLayout(configObj.keymapLayout, true);
setKeymapShowTopRow(configObj.keymapShowTopRow, true);
setKeymapSize(configObj.keymapSize, true);
setFontFamily(configObj.fontFamily, true);
setSmoothCaret(configObj.smoothCaret, true);
setSmoothLineScroll(configObj.smoothLineScroll, true);
@ -2394,6 +2424,10 @@ function replaceLegacyValues(
configObj.liveAccStyle = val;
}
if (typeof configObj.soundVolume === "string") {
configObj.soundVolume = parseFloat(configObj.soundVolume);
}
return configObj;
}

View file

@ -59,6 +59,7 @@ export default {
keymapLegendStyle: "lowercase",
keymapLayout: "overrideSync",
keymapShowTopRow: "layout",
keymapSize: 1,
fontFamily: "Roboto_Mono",
smoothLineScroll: false,
alwaysShowDecimalPlaces: false,
@ -67,7 +68,7 @@ export default {
capsLockWarning: true,
playSoundOnError: "off",
playSoundOnClick: "off",
soundVolume: "0.5",
soundVolume: 0.5,
startGraphsAtZero: true,
showOutOfFocusWarning: true,
paceCaret: "off",

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
type Config = {
backendUrl: string;
isDevelopment: boolean;

View file

@ -15,6 +15,7 @@ import * as LoginPage from "../pages/login";
import * as ResultFilters from "../elements/account/result-filters";
import * as TagController from "./tag-controller";
import * as RegisterCaptchaModal from "../modals/register-captcha";
import * as LastSignedOutResultModal from "../modals/last-signed-out-result";
import * as URLHandler from "../utils/url-handler";
import * as Account from "../pages/account";
import * as Alerts from "../elements/alerts";
@ -45,8 +46,6 @@ import * as ConnectionState from "../states/connection";
import { navigate } from "./route-controller";
import { getHtmlByUserFlags } from "./user-flag-controller";
let signedOutThisSession = false;
export const gmailProvider = new GoogleAuthProvider();
export const githubProvider = new GithubAuthProvider();
@ -209,20 +208,9 @@ export async function loadUser(user: UserType): Promise<void> {
// showFavouriteThemesAtTheTop();
if (TestLogic.notSignedInLastResult !== null && !signedOutThisSession) {
if (TestLogic.notSignedInLastResult !== null) {
TestLogic.setNotSignedInUid(user.uid);
const response = await Ape.results.save(TestLogic.notSignedInLastResult);
if (response.status !== 200) {
return Notifications.add(
"Failed to save last result: " + response.message,
-1
);
}
TestLogic.clearNotSignedInResult();
Notifications.add("Last test result saved", 1);
LastSignedOutResultModal.show();
}
}
@ -491,7 +479,7 @@ async function signUp(): Promise<void> {
});
return;
}
await RegisterCaptchaModal.show();
RegisterCaptchaModal.show();
const captchaToken = await RegisterCaptchaModal.promise;
if (captchaToken === undefined || captchaToken === "") {
Notifications.add("Please complete the captcha", -1);
@ -649,7 +637,6 @@ $("header .signInOut").on("click", () => {
}
if (isAuthenticated()) {
signOut();
signedOutThisSession = true;
} else {
navigate("/login");
}

View file

@ -36,5 +36,6 @@ export function getResponse(id: string): string {
}
//@ts-expect-error
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return grecaptcha.getResponse(captchas[id]);
}

View file

@ -17,8 +17,8 @@ import {
import {
Config as ConfigType,
Difficulty,
Mode,
} from "@monkeytype/shared-types/config";
} from "@monkeytype/contracts/schemas/configs";
import { Mode } from "@monkeytype/contracts/schemas/shared";
let challengeLoading = false;
@ -55,9 +55,7 @@ export function verify(result: Result<Mode>): string | null {
for (const requirementType in TestState.activeChallenge.requirements) {
if (!requirementsMet) return null;
const requirementValue =
TestState.activeChallenge.requirements[
requirementType as keyof typeof TestState.activeChallenge.requirements
];
TestState.activeChallenge.requirements[requirementType];
if (requirementValue === undefined) {
throw new Error("Requirement value is undefined");

View file

@ -95,6 +95,7 @@ class ChartWithUpdateColors<
id: DatasetIds extends never ? never : "x" | DatasetIds
): DatasetIds extends never ? never : CartesianScaleOptions {
//@ts-expect-error
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return this.options.scales[id];
}
}

View file

@ -750,7 +750,7 @@ function handleChar(
document.querySelectorAll<HTMLElement>("#words .word")[
TestUI.currentWordElementIndex - 1
]?.offsetTop ?? 0
) as number;
);
if (!Config.showAllLines) TestUI.lineJump(currentTop);
} else {
TestInput.input.current = TestInput.input.current.slice(0, -1);

Some files were not shown because too many files have changed in this diff Show more