mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2024-09-20 07:16:17 +08:00
Merge branch 'master' into newtribemerge
This commit is contained in:
commit
d8d8b335ec
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
|
@ -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.-->
|
||||
|
||||
|
|
174
.github/workflows/monkey-ci.yml
vendored
174
.github/workflows/monkey-ci.yml
vendored
|
@ -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
|
||||
|
|
2
.github/workflows/pretty-fix.yml
vendored
2
.github/workflows/pretty-fix.yml
vendored
|
@ -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
|
||||
|
|
17
.github/workflows/semantic-pr-title.yml
vendored
17
.github/workflows/semantic-pr-title.yml
vendored
|
@ -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:
|
||||
|
||||
|
|
|
@ -17,4 +17,5 @@ backend/globalConfig.json
|
|||
frontend/public
|
||||
dist/
|
||||
build/
|
||||
frontend/coverage
|
||||
frontend/coverage
|
||||
pnpm*.yaml
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
422
backend/__tests__/api/controllers/ape-key.spec.ts
Normal file
422
backend/__tests__/api/controllers/ape-key.spec.ts
Normal 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;
|
||||
}
|
338
backend/__tests__/api/controllers/preset.spec.ts
Normal file
338
backend/__tests__/api/controllers/preset.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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 });
|
||||
});
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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":
|
||||
|
|
|
@ -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;
|
|
@ -28,7 +28,7 @@ export function callController<
|
|||
status: 200 as TStatus,
|
||||
body: {
|
||||
message: result.message,
|
||||
data: result.data as TResponse,
|
||||
data: result.data,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
58
backend/src/dal/logs.ts
Normal 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 });
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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] ?? {};
|
||||
}
|
||||
|
||||
|
|
|
@ -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>>;
|
||||
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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}`
|
||||
);
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
9
backend/src/types/types.d.ts
vendored
9
backend/src/types/types.d.ts
vendored
|
@ -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
|
||||
>
|
||||
>;
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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>>;
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
236
bin/release.mjs
Normal 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();
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
15
frontend/__tests__/utils/generate.spec.ts
Normal file
15
frontend/__tests__/utils/generate.spec.ts
Normal 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]+$/);
|
||||
});
|
||||
});
|
133
frontend/__tests__/utils/ip-addresses.spec.ts
Normal file
133
frontend/__tests__/utils/ip-addresses.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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"'
|
||||
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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)),
|
||||
};
|
||||
|
|
11
frontend/src/ts/ape/types/ape-keys.d.ts
vendored
11
frontend/src/ts/ape/types/ape-keys.d.ts
vendored
|
@ -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;
|
||||
};
|
||||
}
|
10
frontend/src/ts/ape/types/presets.d.ts
vendored
10
frontend/src/ts/ape/types/presets.d.ts
vendored
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
|
||||
|
|
19
frontend/src/ts/commandline/lists/keymap-size.ts
Normal file
19
frontend/src/ts/commandline/lists/keymap-size.ts
Normal 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;
|
|
@ -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",
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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, " "),
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
type Config = {
|
||||
backendUrl: string;
|
||||
isDevelopment: boolean;
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue