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
083c18b8b9
|
@ -2,3 +2,4 @@ backend/dist
|
|||
backend/__migration__
|
||||
docker
|
||||
backend/scripts
|
||||
**/vitest.config.js
|
||||
|
|
85
.github/workflows/monkey-ci.yml
vendored
85
.github/workflows/monkey-ci.yml
vendored
|
@ -33,8 +33,13 @@ jobs:
|
|||
assets-json: ${{ steps.export-changes.outputs.assets-json }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
- name: Full checkout
|
||||
uses: actions/checkout@v4
|
||||
# paths filter doesn't need checkout on pr
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
- name: Detect changes
|
||||
uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
|
@ -94,6 +99,7 @@ jobs:
|
|||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-${{ env.NODE_VERSION }}-build-${{ env.cache-name }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
lookup-only: true
|
||||
|
||||
- if: ${{ steps.cache-pnpm.outputs.cache-hit != 'true' }}
|
||||
name: Full checkout
|
||||
|
@ -127,42 +133,37 @@ jobs:
|
|||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
- name: Install prettier
|
||||
run: pnpm add -g prettier@2.5.1
|
||||
|
||||
- name: Cache node modules
|
||||
id: cache-pnpm
|
||||
uses: actions/cache@v4
|
||||
env:
|
||||
cache-name: node-modules
|
||||
- name: Get changed files
|
||||
if: github.event_name == 'pull_request'
|
||||
id: get-changed-files
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-${{ env.NODE_VERSION }}-build-${{ env.cache-name }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
script: |
|
||||
const changedFiles = await github.paginate(
|
||||
github.rest.pulls.listFiles,
|
||||
{
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
}
|
||||
);
|
||||
return changedFiles.filter(file=> file.status !== "removed").map(file => file.filename).join(' ');
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Check pretty (changed files)
|
||||
if: github.event_name == 'pull_request'
|
||||
id: check-pretty
|
||||
run: |
|
||||
CHANGED_FILES=$(echo ${{ steps.get-changed-files.outputs.result }})
|
||||
if [ -n "$CHANGED_FILES" ]; then
|
||||
pnpm prettier --check $CHANGED_FILES
|
||||
fi
|
||||
|
||||
- name: Check pretty (backend)
|
||||
id: check-pretty-be
|
||||
if: needs.pre-ci.outputs.should-build-be == 'true'
|
||||
run: npm run pretty-check-be
|
||||
|
||||
- name: Check pretty (frontend)
|
||||
id: check-pretty-fe
|
||||
if: needs.pre-ci.outputs.should-build-fe == 'true'
|
||||
run: npm run pretty-check-fe
|
||||
|
||||
- name: Check pretty (packages)
|
||||
id: check-pretty-pkg
|
||||
if: needs.pre-ci.outputs.should-build-pkg == 'true'
|
||||
run: npm run pretty-check-pkg
|
||||
|
||||
- name: Check pretty (assets)
|
||||
id: check-pretty-assets
|
||||
if: needs.pre-ci.outputs.assets-json == 'true'
|
||||
run: npm run pretty-check-assets
|
||||
- name: Check pretty (all files)
|
||||
if: github.event_name == 'push'
|
||||
run: pnpm prettier --check .
|
||||
|
||||
ci-be:
|
||||
name: ci-be
|
||||
|
@ -171,6 +172,10 @@ jobs:
|
|||
if: needs.pre-ci.outputs.should-build-be == 'true' || needs.pre-ci.outputs.should-build-pkg == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: |
|
||||
backend
|
||||
packages
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
@ -216,6 +221,10 @@ jobs:
|
|||
steps:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: |
|
||||
frontend
|
||||
packages
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
@ -264,6 +273,10 @@ jobs:
|
|||
if: needs.pre-ci.outputs.assets-json == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: |
|
||||
frontend
|
||||
packages
|
||||
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
|
@ -330,6 +343,9 @@ jobs:
|
|||
steps:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: |
|
||||
packages
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
@ -374,7 +390,6 @@ jobs:
|
|||
needs: [ci-be, ci-fe, ci-assets, ci-pkg]
|
||||
if: ${{ always() && contains(needs.*.result, 'failure') && github.ref != 'refs/heads/master' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Save the PR number in an artifact
|
||||
shell: bash
|
||||
env:
|
||||
|
|
18
README.md
18
README.md
|
@ -1,9 +1,23 @@
|
|||
[![](https://github.com/monkeytypegame/monkeytype/blob/master/frontend/static/images/githubbanner2.png?raw=true)](https://monkeytype.com/)
|
||||
<br />
|
||||
|
||||
![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white)
|
||||
![SASS](https://img.shields.io/badge/SASS-hotpink.svg?style=for-the-badge&logo=SASS&logoColor=white)
|
||||
![ChartJs](https://img.shields.io/badge/Chart.js-FF6384?style=for-the-badge&logo=chartdotjs&logoColor=white)
|
||||
![Eslint](https://img.shields.io/badge/eslint-4B32C3?style=for-the-badge&logo=eslint&logoColor=white)
|
||||
![Express](https://img.shields.io/badge/-Express-373737?style=for-the-badge&logo=Express&logoColor=white)
|
||||
![Firebase](https://img.shields.io/badge/firebase-ffca28?style=for-the-badge&logo=firebase&logoColor=black)
|
||||
![Fontawesome](https://img.shields.io/badge/fontawesome-538DD7?style=for-the-badge&logo=fontawesome&logoColor=white)
|
||||
![HTML5](https://img.shields.io/badge/html5-%23E34F26.svg?style=for-the-badge&logo=html5&logoColor=white)
|
||||
![JQuery](https://img.shields.io/badge/jQuery-0769AD?style=for-the-badge&logo=jquery&logoColor=white)
|
||||
![MongoDB](https://img.shields.io/badge/-MongoDB-13aa52?style=for-the-badge&logo=mongodb&logoColor=white)
|
||||
![PNPM](https://img.shields.io/badge/pnpm-F69220?style=for-the-badge&logo=pnpm&logoColor=white)
|
||||
![Redis](https://img.shields.io/badge/Redis-DC382D?style=for-the-badge&logo=redis&logoColor=white)
|
||||
![SASS](https://img.shields.io/badge/SASS-hotpink.svg?style=for-the-badge&logo=SASS&logoColor=white)
|
||||
![TsRest](https://img.shields.io/badge/-TSREST-9333ea?style=for-the-badge&logoColor=white&logo=data:image/svg%2bxml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhLS0gQ3JlYXRlZCB3aXRoIElua3NjYXBlIChodHRwOi8vd3d3Lmlua3NjYXBlLm9yZy8pIC0tPgoKPHN2ZwogICB3aWR0aD0iMjAuMzA2Nzc4bW0iCiAgIGhlaWdodD0iMTIuMDgzMjMzbW0iCiAgIHZpZXdCb3g9IjAgMCAyMC4zMDY3NzggMTIuMDgzMjMzIgogICB2ZXJzaW9uPSIxLjEiCiAgIGlkPSJzdmcxIgogICB4bWxuczppbmtzY2FwZT0iaHR0cDovL3d3dy5pbmtzY2FwZS5vcmcvbmFtZXNwYWNlcy9pbmtzY2FwZSIKICAgeG1sbnM6c29kaXBvZGk9Imh0dHA6Ly9zb2RpcG9kaS5zb3VyY2Vmb3JnZS5uZXQvRFREL3NvZGlwb2RpLTAuZHRkIgogICB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxzb2RpcG9kaTpuYW1lZHZpZXcKICAgICBpZD0ibmFtZWR2aWV3MSIKICAgICBwYWdlY29sb3I9IiM1MDUwNTAiCiAgICAgYm9yZGVyY29sb3I9IiNmZmZmZmYiCiAgICAgYm9yZGVyb3BhY2l0eT0iMSIKICAgICBpbmtzY2FwZTpzaG93cGFnZXNoYWRvdz0iMCIKICAgICBpbmtzY2FwZTpwYWdlb3BhY2l0eT0iMCIKICAgICBpbmtzY2FwZTpwYWdlY2hlY2tlcmJvYXJkPSIxIgogICAgIGlua3NjYXBlOmRlc2tjb2xvcj0iI2QxZDFkMSIKICAgICBpbmtzY2FwZTpkb2N1bWVudC11bml0cz0ibW0iIC8+CiAgPGRlZnMKICAgICBpZD0iZGVmczEiIC8+CiAgPGcKICAgICBpbmtzY2FwZTpsYWJlbD0iTGF5ZXIgMSIKICAgICBpbmtzY2FwZTpncm91cG1vZGU9ImxheWVyIgogICAgIGlkPSJsYXllcjEiCiAgICAgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTMuODE5ODA1NCwtMi4yMTQ3MTkzKSI+CiAgICA8cGF0aAogICAgICAgZD0ibSAxNS40NTgwMzUsOC45NzMzOTUzIDguNjMzMjUsMC4wNDQ4NyAwLjAwOSwtMS42NjgxOTggLTguNjMzMjIsLTAuMDQ0ODUgeiBtIDAuMDI2MywtNS4wNTYxMDggOC42MzMyNSwwLjA0NDg1IDAuMDA5LC0xLjcwMjU2OCAtOC42MzMyNSwtMC4wNDQ4NSB6IG0gLTAuMDQ0OCw4LjYzMzI0NzcgOC42MzMyMywwLjA0NDg1IC0wLjAwOSwxLjcwMjU2NyAtOC42MzMyNSwtMC4wNDQ4NSB6IgogICAgICAgZmlsbD0iI2ZmZmZmZiIKICAgICAgIGlkPSJwYXRoMSIKICAgICAgIHN0eWxlPSJzdHJva2Utd2lkdGg6MC4yNjQ1ODMiIC8+CiAgICA8cGF0aAogICAgICAgZD0ibSAxMS4xMTE3MjUsMTAuMjg2NjI4IGMgMS42NTEsLTAuNjE5MTI0NyAyLjU5Njg4LC0xLjk2MDU2MjcgMi41OTY4OCwtMy44MDA3Mzk3IDAsLTIuNjQ4NDc5IC0xLjkyNjE2LC00LjI0Nzg4NSAtNS4wNzMzNzk2LC00LjI0Nzg4NSBoIC00LjgxNTQyIHYgMS43MDI1OTQgaCA0Ljc0NjYzIGMgMi4wODA5Mzk2LDAgMy4xNjQ0MDk2LDAuOTI4Njg3IDMuMTY0NDA5NiwyLjU0NTI5MSAwLDEuNTk5NDA2IC0xLjA4MzQ3LDIuNTQ1MjkyIC0zLjE2NDQwOTYsMi41NDUyOTIgaCAtNC43NDY2MyB2IDUuMjQ1MzYzNyBoIDEuOTYwNTYgdiAtMy41NzcxNjYgaCAyLjg1NDg2IGMgMC4yMDYzNywwIDAuNDI5OTUsMCAwLjYxOTEyLC0wLjAxNzIgbCAyLjUyODA5OTYsMy41OTQzNjQgaCAyLjEzMjU0IHoiCiAgICAgICBmaWxsPSIjZmZmZmZmIgogICAgICAgaWQ9InBhdGgyIgogICAgICAgc3R5bGU9InN0cm9rZS13aWR0aDowLjI2NDU4MyIgLz4KICA8L2c+Cjwvc3ZnPgo=)
|
||||
![Turborepo](https://img.shields.io/badge/-Turborepo-EF4444?style=for-the-badge&logo=turborepo&logoColor=white)
|
||||
![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white)
|
||||
![Vite](https://img.shields.io/badge/Vite-646CFF?style=for-the-badge&logo=Vite&logoColor=white)
|
||||
![Vitest](https://img.shields.io/badge/vitest-6E9F18?style=for-the-badge&logo=vitest&logoColor=white)
|
||||
![Zod](https://img.shields.io/badge/-Zod-3E67B1?style=for-the-badge&logo=zod&logoColor=white)
|
||||
|
||||
# About
|
||||
|
||||
|
|
429
backend/__tests__/api/controllers/admin.spec.ts
Normal file
429
backend/__tests__/api/controllers/admin.spec.ts
Normal file
|
@ -0,0 +1,429 @@
|
|||
import request, { Test as SuperTest } from "supertest";
|
||||
import app from "../../../src/app";
|
||||
import { ObjectId } from "mongodb";
|
||||
import * as Configuration from "../../../src/init/configuration";
|
||||
import * as AdminUuidDal from "../../../src/dal/admin-uids";
|
||||
import * as UserDal from "../../../src/dal/user";
|
||||
import * as ReportDal from "../../../src/dal/report";
|
||||
import GeorgeQueue from "../../../src/queues/george-queue";
|
||||
import * as AuthUtil from "../../../src/utils/auth";
|
||||
import _ from "lodash";
|
||||
|
||||
const mockApp = request(app);
|
||||
const configuration = Configuration.getCachedConfiguration();
|
||||
const uid = new ObjectId().toHexString();
|
||||
|
||||
describe("ApeKeyController", () => {
|
||||
const isAdminMock = vi.spyOn(AdminUuidDal, "isAdmin");
|
||||
|
||||
beforeEach(async () => {
|
||||
isAdminMock.mockReset();
|
||||
await enableAdminEndpoints(true);
|
||||
isAdminMock.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe("check for admin", () => {
|
||||
it("should succeed if user is admin", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/admin")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "OK",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(isAdminMock).toHaveBeenCalledWith(uid);
|
||||
});
|
||||
it("should fail if user is no admin", async () => {
|
||||
await expectFailForNonAdmin(
|
||||
mockApp.get("/admin").set("authorization", `Uid ${uid}`)
|
||||
);
|
||||
});
|
||||
it("should fail if admin endpoints are disabled", async () => {
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp.get("/admin").set("authorization", `Uid ${uid}`)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggle ban", () => {
|
||||
const userBannedMock = vi.spyOn(UserDal, "setBanned");
|
||||
const georgeBannedMock = vi.spyOn(GeorgeQueue, "userBanned");
|
||||
const getUserMock = vi.spyOn(UserDal, "getPartialUser");
|
||||
|
||||
beforeEach(() => {
|
||||
[userBannedMock, georgeBannedMock, getUserMock].forEach((it) =>
|
||||
it.mockReset()
|
||||
);
|
||||
});
|
||||
|
||||
it("should ban user with discordId", async () => {
|
||||
//GIVEN
|
||||
const victimUid = new ObjectId().toHexString();
|
||||
getUserMock.mockResolvedValue({
|
||||
banned: false,
|
||||
discordId: "discordId",
|
||||
} as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/toggleBan")
|
||||
.send({ uid: victimUid })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Ban toggled",
|
||||
data: { banned: true },
|
||||
});
|
||||
|
||||
expect(getUserMock).toHaveBeenCalledWith(victimUid, "toggle ban", [
|
||||
"banned",
|
||||
"discordId",
|
||||
]);
|
||||
expect(userBannedMock).toHaveBeenCalledWith(victimUid, true);
|
||||
expect(georgeBannedMock).toHaveBeenCalledWith("discordId", true);
|
||||
});
|
||||
it("should unban user without discordId", async () => {
|
||||
//GIVEN
|
||||
const victimUid = new ObjectId().toHexString();
|
||||
getUserMock.mockResolvedValue({
|
||||
banned: true,
|
||||
} as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/toggleBan")
|
||||
.send({ uid: victimUid })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Ban toggled",
|
||||
data: { banned: false },
|
||||
});
|
||||
|
||||
expect(getUserMock).toHaveBeenCalledWith(victimUid, "toggle ban", [
|
||||
"banned",
|
||||
"discordId",
|
||||
]);
|
||||
expect(userBannedMock).toHaveBeenCalledWith(victimUid, false);
|
||||
expect(georgeBannedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should fail without mandatory properties", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/toggleBan")
|
||||
.send({})
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ['"uid" Required'],
|
||||
});
|
||||
});
|
||||
it("should fail with unknown properties", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/toggleBan")
|
||||
.send({ uid: new ObjectId().toHexString(), extra: "value" })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
it("should fail if user is no admin", async () => {
|
||||
await expectFailForNonAdmin(
|
||||
mockApp
|
||||
.post("/admin/toggleBan")
|
||||
.send({ uid: new ObjectId().toHexString() })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
);
|
||||
});
|
||||
it("should fail if admin endpoints are disabled", async () => {
|
||||
//GIVEN
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp
|
||||
.post("/admin/toggleBan")
|
||||
.send({ uid: new ObjectId().toHexString() })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("accept reports", () => {
|
||||
const getReportsMock = vi.spyOn(ReportDal, "getReports");
|
||||
const deleteReportsMock = vi.spyOn(ReportDal, "deleteReports");
|
||||
const addToInboxMock = vi.spyOn(UserDal, "addToInbox");
|
||||
|
||||
beforeEach(() => {
|
||||
[getReportsMock, deleteReportsMock, addToInboxMock].forEach((it) =>
|
||||
it.mockReset()
|
||||
);
|
||||
});
|
||||
|
||||
it("should accept reports", async () => {
|
||||
//GIVEN
|
||||
const reportOne = {
|
||||
id: "1",
|
||||
reason: "one",
|
||||
} as any as MonkeyTypes.Report;
|
||||
const reportTwo = {
|
||||
id: "2",
|
||||
reason: "two",
|
||||
} as any as MonkeyTypes.Report;
|
||||
getReportsMock.mockResolvedValue([reportOne, reportTwo]);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/report/accept")
|
||||
.send({
|
||||
reports: [{ reportId: reportOne.id }, { reportId: reportTwo.id }],
|
||||
})
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Reports removed and users notified.",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(addToInboxMock).toBeCalledTimes(2);
|
||||
expect(deleteReportsMock).toHaveBeenCalledWith(["1", "2"]);
|
||||
});
|
||||
it("should fail wihtout mandatory properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/report/accept")
|
||||
.send({})
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ['"reports" Required'],
|
||||
});
|
||||
});
|
||||
it("should fail with empty reports", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/report/accept")
|
||||
.send({ reports: [] })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
'"reports" Array must contain at least 1 element(s)',
|
||||
],
|
||||
});
|
||||
});
|
||||
it("should fail with unknown properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/report/accept")
|
||||
.send({ reports: [{ reportId: "1", extra2: "value" }], extra: "value" })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
`"reports.0" Unrecognized key(s) in object: 'extra2'`,
|
||||
"Unrecognized key(s) in object: 'extra'",
|
||||
],
|
||||
});
|
||||
});
|
||||
it("should fail if user is no admin", async () => {
|
||||
await expectFailForNonAdmin(
|
||||
mockApp
|
||||
.post("/admin/report/accept")
|
||||
.send({ reports: [] })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
);
|
||||
});
|
||||
it("should fail if admin endpoints are disabled", async () => {
|
||||
//GIVEN
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp
|
||||
.post("/admin/report/accept")
|
||||
.send({ reports: [] })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("reject reports", () => {
|
||||
const getReportsMock = vi.spyOn(ReportDal, "getReports");
|
||||
const deleteReportsMock = vi.spyOn(ReportDal, "deleteReports");
|
||||
const addToInboxMock = vi.spyOn(UserDal, "addToInbox");
|
||||
|
||||
beforeEach(() => {
|
||||
[getReportsMock, deleteReportsMock, addToInboxMock].forEach((it) =>
|
||||
it.mockReset()
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject reports", async () => {
|
||||
//GIVEN
|
||||
const reportOne = {
|
||||
id: "1",
|
||||
reason: "one",
|
||||
} as any as MonkeyTypes.Report;
|
||||
const reportTwo = {
|
||||
id: "2",
|
||||
reason: "two",
|
||||
} as any as MonkeyTypes.Report;
|
||||
getReportsMock.mockResolvedValue([reportOne, reportTwo]);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/report/reject")
|
||||
.send({
|
||||
reports: [
|
||||
{ reportId: reportOne.id, reason: "test" },
|
||||
{ reportId: reportTwo.id },
|
||||
],
|
||||
})
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Reports removed and users notified.",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(addToInboxMock).toHaveBeenCalledTimes(2);
|
||||
expect(deleteReportsMock).toHaveBeenCalledWith(["1", "2"]);
|
||||
});
|
||||
it("should fail wihtout mandatory properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/report/reject")
|
||||
.send({})
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ['"reports" Required'],
|
||||
});
|
||||
});
|
||||
it("should fail with empty reports", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/report/reject")
|
||||
.send({ reports: [] })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
'"reports" Array must contain at least 1 element(s)',
|
||||
],
|
||||
});
|
||||
});
|
||||
it("should fail with unknown properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/report/reject")
|
||||
.send({ reports: [{ reportId: "1", extra2: "value" }], extra: "value" })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
`"reports.0" Unrecognized key(s) in object: 'extra2'`,
|
||||
"Unrecognized key(s) in object: 'extra'",
|
||||
],
|
||||
});
|
||||
});
|
||||
it("should fail if user is no admin", async () => {
|
||||
await expectFailForNonAdmin(
|
||||
mockApp
|
||||
.post("/admin/report/reject")
|
||||
.send({ reports: [] })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
);
|
||||
});
|
||||
it("should fail if admin endpoints are disabled", async () => {
|
||||
//GIVEN
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp
|
||||
.post("/admin/report/reject")
|
||||
.send({ reports: [] })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("send forgot password email", () => {
|
||||
const sendForgotPasswordEmailMock = vi.spyOn(
|
||||
AuthUtil,
|
||||
"sendForgotPasswordEmail"
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
sendForgotPasswordEmailMock.mockReset();
|
||||
});
|
||||
|
||||
it("should send forgot password link", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/admin/sendForgotPasswordEmail")
|
||||
.send({ email: "meowdec@example.com" })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Password reset request email sent.",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(sendForgotPasswordEmailMock).toHaveBeenCalledWith(
|
||||
"meowdec@example.com"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function expectFailForNonAdmin(call: SuperTest): Promise<void> {
|
||||
isAdminMock.mockResolvedValue(false);
|
||||
const { body } = await call.expect(403);
|
||||
expect(body.message).toEqual("You don't have permission to do this.");
|
||||
}
|
||||
async function expectFailForDisabledEndpoint(call: SuperTest): Promise<void> {
|
||||
await enableAdminEndpoints(false);
|
||||
const { body } = await call.expect(503);
|
||||
expect(body.message).toEqual("Admin endpoints are currently disabled.");
|
||||
}
|
||||
});
|
||||
async function enableAdminEndpoints(enabled: boolean): Promise<void> {
|
||||
const mockConfig = _.merge(await configuration, {
|
||||
admin: { endpointsEnabled: enabled },
|
||||
});
|
||||
|
||||
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
|
||||
mockConfig
|
||||
);
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import request from "supertest";
|
||||
import request, { Test as SuperTest } from "supertest";
|
||||
import app from "../../../src/app";
|
||||
import * as ApeKeyDal from "../../../src/dal/ape-keys";
|
||||
import { ObjectId } from "mongodb";
|
||||
|
@ -65,31 +65,13 @@ describe("ApeKeyController", () => {
|
|||
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.");
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp.get("/ape-keys").set("authorization", `Uid ${uid}`)
|
||||
);
|
||||
});
|
||||
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"
|
||||
await expectFailForNoPermissions(
|
||||
mockApp.get("/ape-keys").set("authorization", `Uid ${uid}`)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -190,33 +172,19 @@ describe("ApeKeyController", () => {
|
|||
);
|
||||
});
|
||||
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.");
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp
|
||||
.post("/ape-keys")
|
||||
.send({ name: "test", enabled: false })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
);
|
||||
});
|
||||
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"
|
||||
await expectFailForNoPermissions(
|
||||
mockApp
|
||||
.post("/ape-keys")
|
||||
.send({ name: "test", enabled: false })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -290,33 +258,19 @@ describe("ApeKeyController", () => {
|
|||
});
|
||||
});
|
||||
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.");
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp
|
||||
.patch(`/ape-keys/${apeKeyId}`)
|
||||
.send({ name: "test", enabled: false })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
);
|
||||
});
|
||||
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"
|
||||
await expectFailForNoPermissions(
|
||||
mockApp
|
||||
.patch(`/ape-keys/${apeKeyId}`)
|
||||
.send({ name: "test", enabled: false })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -352,35 +306,33 @@ describe("ApeKeyController", () => {
|
|||
.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.");
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp
|
||||
.delete(`/ape-keys/${apeKeyId}`)
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
);
|
||||
});
|
||||
|
||||
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"
|
||||
await expectFailForNoPermissions(
|
||||
mockApp
|
||||
.delete(`/ape-keys/${apeKeyId}`)
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
);
|
||||
});
|
||||
});
|
||||
async function expectFailForNoPermissions(call: SuperTest): Promise<void> {
|
||||
getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false }));
|
||||
const { body } = await call.expect(403);
|
||||
expect(body.message).toEqual(
|
||||
"You have lost access to ape keys, please contact support"
|
||||
);
|
||||
}
|
||||
async function expectFailForDisabledEndpoint(call: SuperTest): Promise<void> {
|
||||
await enableApeKeysEndpoints(false);
|
||||
const { body } = await call.expect(503);
|
||||
expect(body.message).toEqual("ApeKeys are currently disabled.");
|
||||
}
|
||||
});
|
||||
|
||||
function apeKeyDb(
|
||||
|
|
80
backend/__tests__/api/controllers/psa.spec.ts
Normal file
80
backend/__tests__/api/controllers/psa.spec.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
import request from "supertest";
|
||||
import app from "../../../src/app";
|
||||
import * as PsaDal from "../../../src/dal/psa";
|
||||
import * as Prometheus from "../../../src/utils/prometheus";
|
||||
import { ObjectId } from "mongodb";
|
||||
const mockApp = request(app);
|
||||
|
||||
describe("Psa Controller", () => {
|
||||
describe("get psa", () => {
|
||||
const getPsaMock = vi.spyOn(PsaDal, "get");
|
||||
const recordClientVersionMock = vi.spyOn(Prometheus, "recordClientVersion");
|
||||
|
||||
afterEach(() => {
|
||||
getPsaMock.mockReset();
|
||||
recordClientVersionMock.mockReset();
|
||||
});
|
||||
|
||||
it("get psas without authorization", async () => {
|
||||
//GIVEN
|
||||
const psaOne: PsaDal.DBPSA = {
|
||||
_id: new ObjectId(),
|
||||
message: "test2",
|
||||
date: 1000,
|
||||
level: 1,
|
||||
sticky: true,
|
||||
};
|
||||
const psaTwo: PsaDal.DBPSA = {
|
||||
_id: new ObjectId(),
|
||||
message: "test2",
|
||||
date: 2000,
|
||||
level: 2,
|
||||
sticky: false,
|
||||
};
|
||||
getPsaMock.mockResolvedValue([psaOne, psaTwo]);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp.get("/psas").expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "PSAs retrieved",
|
||||
data: [
|
||||
{
|
||||
_id: psaOne._id.toHexString(),
|
||||
date: 1000,
|
||||
level: 1,
|
||||
message: "test2",
|
||||
sticky: true,
|
||||
},
|
||||
{
|
||||
_id: psaTwo._id.toHexString(),
|
||||
date: 2000,
|
||||
level: 2,
|
||||
message: "test2",
|
||||
sticky: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(recordClientVersionMock).toHaveBeenCalledWith("unknown");
|
||||
});
|
||||
it("get psas with authorization", async () => {
|
||||
await mockApp
|
||||
.get("/psas")
|
||||
.set("authorization", `Uid 123456789`)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it("get psas records x-client-version", async () => {
|
||||
await mockApp.get("/psas").set("x-client-version", "1.0").expect(200);
|
||||
|
||||
expect(recordClientVersionMock).toHaveBeenCalledWith("1.0");
|
||||
});
|
||||
it("get psas records client-version", async () => {
|
||||
await mockApp.get("/psas").set("client-version", "2.0").expect(200);
|
||||
|
||||
expect(recordClientVersionMock).toHaveBeenCalledWith("2.0");
|
||||
});
|
||||
});
|
||||
});
|
29
backend/__tests__/dal/admin-uids.spec.ts
Normal file
29
backend/__tests__/dal/admin-uids.spec.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { ObjectId } from "mongodb";
|
||||
import * as AdminUidsDal from "../../src/dal/admin-uids";
|
||||
|
||||
describe("AdminUidsDal", () => {
|
||||
describe("isAdmin", () => {
|
||||
it("should return true for existing admin user", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
await AdminUidsDal.getCollection().insertOne({
|
||||
_id: new ObjectId(),
|
||||
uid: uid,
|
||||
});
|
||||
|
||||
//WHEN / THEN
|
||||
expect(await AdminUidsDal.isAdmin(uid)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for non-existing admin user", async () => {
|
||||
//GIVEN
|
||||
await AdminUidsDal.getCollection().insertOne({
|
||||
_id: new ObjectId(),
|
||||
uid: "admin",
|
||||
});
|
||||
|
||||
//WHEN / THEN
|
||||
expect(await AdminUidsDal.isAdmin("regularUser")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,5 +1,6 @@
|
|||
import * as MongoDbMock from "vitest-mongodb";
|
||||
export async function setup({ provide }): Promise<void> {
|
||||
export async function setup(): Promise<void> {
|
||||
process.env.TZ = "UTC";
|
||||
await MongoDbMock.setup({
|
||||
serverOptions: {
|
||||
binary: {
|
||||
|
|
|
@ -649,5 +649,8 @@ describe("Misc Utils", () => {
|
|||
},
|
||||
]);
|
||||
});
|
||||
it("handles undefined", () => {
|
||||
expect(misc.replaceObjectIds(undefined as any)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
"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"
|
||||
"gen-docs": "tsx scripts/openapi.ts dist/static/api/openapi.json && openapi-recursive-tagging dist/static/api/openapi.json dist/static/api/openapi-tagged.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": "20.16.0"
|
||||
|
@ -47,7 +47,7 @@
|
|||
"mustache": "4.2.0",
|
||||
"node-fetch": "2.6.7",
|
||||
"nodemailer": "6.9.9",
|
||||
"nodemon": "3.0.1",
|
||||
"nodemon": "3.1.4",
|
||||
"object-hash": "3.0.0",
|
||||
"path": "0.12.7",
|
||||
"prom-client": "14.0.1",
|
||||
|
@ -57,7 +57,7 @@
|
|||
"swagger-stats": "0.99.5",
|
||||
"swagger-ui-express": "4.3.0",
|
||||
"ua-parser-js": "0.7.33",
|
||||
"uuid": "9.0.1",
|
||||
"uuid": "10.0.0",
|
||||
"winston": "3.6.0",
|
||||
"zod": "3.23.8"
|
||||
},
|
||||
|
@ -84,16 +84,17 @@
|
|||
"@types/swagger-stats": "0.95.11",
|
||||
"@types/swagger-ui-express": "4.1.3",
|
||||
"@types/ua-parser-js": "0.7.36",
|
||||
"@types/uuid": "8.3.4",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@vitest/coverage-v8": "1.6.0",
|
||||
"concurrently": "8.2.2",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-watch": "8.0.0",
|
||||
"ioredis-mock": "7.4.0",
|
||||
"openapi-recursive-tagging": "0.0.6",
|
||||
"readline-sync": "1.4.10",
|
||||
"supertest": "6.2.3",
|
||||
"tsx": "4.16.2",
|
||||
"typescript": "5.5.3",
|
||||
"typescript": "5.5.4",
|
||||
"vitest": "1.6.0",
|
||||
"vitest-mongodb": "1.0.0"
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ apis:
|
|||
internal@v2:
|
||||
root: dist/static/api/openapi.json
|
||||
public-filter:
|
||||
root: dist/static/api/openapi.json
|
||||
root: dist/static/api/openapi-tagged.json
|
||||
decorators:
|
||||
filter-in:
|
||||
property: x-public
|
||||
|
|
|
@ -66,6 +66,17 @@ export function getOpenApi(): OpenAPIObject {
|
|||
description: "Ape keys provide access to certain API endpoints.",
|
||||
"x-displayName": "Ape Keys",
|
||||
},
|
||||
{
|
||||
name: "psas",
|
||||
description: "Public service announcements.",
|
||||
"x-displayName": "PSAs",
|
||||
},
|
||||
{
|
||||
name: "admin",
|
||||
description:
|
||||
"Various administrative endpoints. Require user to have admin permissions.",
|
||||
"x-displayName": "Admin",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
|
|
@ -1,31 +1,75 @@
|
|||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { MonkeyResponse2 } from "../../utils/monkey-response";
|
||||
import { buildMonkeyMail } from "../../utils/monkey-mail";
|
||||
import * as UserDAL from "../../dal/user";
|
||||
import * as ReportDAL from "../../dal/report";
|
||||
import GeorgeQueue from "../../queues/george-queue";
|
||||
import { sendForgotPasswordEmail as authSendForgotPasswordEmail } from "../../utils/auth";
|
||||
import {
|
||||
AcceptReportsRequest,
|
||||
RejectReportsRequest,
|
||||
SendForgotPasswordEmailRequest,
|
||||
ToggleBanRequest,
|
||||
ToggleBanResponse,
|
||||
} from "@monkeytype/contracts/admin";
|
||||
import MonkeyError from "../../utils/error";
|
||||
import { Configuration } from "@monkeytype/shared-types";
|
||||
import { addImportantLog } from "../../dal/logs";
|
||||
|
||||
export async function test(): Promise<MonkeyResponse> {
|
||||
return new MonkeyResponse("OK");
|
||||
export async function test(
|
||||
_req: MonkeyTypes.Request2
|
||||
): Promise<MonkeyResponse2> {
|
||||
return new MonkeyResponse2("OK", null);
|
||||
}
|
||||
|
||||
export async function toggleBan(
|
||||
req: MonkeyTypes.Request2<undefined, ToggleBanRequest>
|
||||
): Promise<ToggleBanResponse> {
|
||||
const { uid } = req.body;
|
||||
|
||||
const user = await UserDAL.getPartialUser(uid, "toggle ban", [
|
||||
"banned",
|
||||
"discordId",
|
||||
]);
|
||||
const discordId = user.discordId;
|
||||
const discordIdIsValid = discordId !== undefined && discordId !== "";
|
||||
|
||||
await UserDAL.setBanned(uid, !user.banned);
|
||||
if (discordIdIsValid) await GeorgeQueue.userBanned(discordId, !user.banned);
|
||||
|
||||
void addImportantLog("user_ban_toggled", { banned: !user.banned }, uid);
|
||||
|
||||
return new MonkeyResponse2(`Ban toggled`, {
|
||||
banned: !user.banned,
|
||||
});
|
||||
}
|
||||
|
||||
export async function acceptReports(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
return handleReports(req, true);
|
||||
req: MonkeyTypes.Request2<undefined, AcceptReportsRequest>
|
||||
): Promise<MonkeyResponse2> {
|
||||
await handleReports(
|
||||
req.body.reports.map((it) => ({ ...it })),
|
||||
true,
|
||||
req.ctx.configuration.users.inbox
|
||||
);
|
||||
return new MonkeyResponse2("Reports removed and users notified.", null);
|
||||
}
|
||||
|
||||
export async function rejectReports(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
return handleReports(req, false);
|
||||
req: MonkeyTypes.Request2<undefined, RejectReportsRequest>
|
||||
): Promise<MonkeyResponse2> {
|
||||
await handleReports(
|
||||
req.body.reports.map((it) => ({ ...it })),
|
||||
false,
|
||||
req.ctx.configuration.users.inbox
|
||||
);
|
||||
return new MonkeyResponse2("Reports removed and users notified.", null);
|
||||
}
|
||||
|
||||
export async function handleReports(
|
||||
req: MonkeyTypes.Request,
|
||||
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
|
||||
reports: { reportId: string; reason?: string }[],
|
||||
accept: boolean,
|
||||
inboxConfig: Configuration["users"]["inbox"]
|
||||
): Promise<void> {
|
||||
const reportIds = reports.map(({ reportId }) => reportId);
|
||||
|
||||
const reportsFromDb = await ReportDAL.getReports(reportIds);
|
||||
|
@ -37,10 +81,9 @@ export async function handleReports(
|
|||
);
|
||||
|
||||
if (missingReportIds.length > 0) {
|
||||
return new MonkeyResponse(
|
||||
`Reports not found for some IDs`,
|
||||
missingReportIds,
|
||||
404
|
||||
throw new MonkeyError(
|
||||
404,
|
||||
`Reports not found for some IDs ${missingReportIds.join(",")}`
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -50,11 +93,7 @@ export async function handleReports(
|
|||
try {
|
||||
const report = reportById.get(reportId);
|
||||
if (!report) {
|
||||
return new MonkeyResponse(
|
||||
`Report not found for ID: ${reportId}`,
|
||||
null,
|
||||
404
|
||||
);
|
||||
throw new MonkeyError(404, `Report not found for ID: ${reportId}`);
|
||||
}
|
||||
|
||||
let mailBody = "";
|
||||
|
@ -75,14 +114,17 @@ export async function handleReports(
|
|||
subject: mailSubject,
|
||||
body: mailBody,
|
||||
});
|
||||
await UserDAL.addToInbox(
|
||||
report.uid,
|
||||
[mail],
|
||||
req.ctx.configuration.users.inbox
|
||||
);
|
||||
await UserDAL.addToInbox(report.uid, [mail], inboxConfig);
|
||||
} catch (e) {
|
||||
return new MonkeyResponse(e.message, null, e.status);
|
||||
throw new MonkeyError(e.status, e.message);
|
||||
}
|
||||
}
|
||||
return new MonkeyResponse("Reports removed and users notified.");
|
||||
}
|
||||
|
||||
export async function sendForgotPasswordEmail(
|
||||
req: MonkeyTypes.Request2<undefined, SendForgotPasswordEmailRequest>
|
||||
): Promise<MonkeyResponse2> {
|
||||
const { email } = req.body;
|
||||
await authSendForgotPasswordEmail(email);
|
||||
return new MonkeyResponse2("Password reset request email sent.", null);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
import { GetPsaResponse } from "@monkeytype/contracts/psas";
|
||||
import * as PsaDAL from "../../dal/psa";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { MonkeyResponse2 } from "../../utils/monkey-response";
|
||||
import { replaceObjectIds } from "../../utils/misc";
|
||||
|
||||
export async function getPsas(): Promise<MonkeyResponse> {
|
||||
export async function getPsas(
|
||||
_req: MonkeyTypes.Request2
|
||||
): Promise<GetPsaResponse> {
|
||||
const data = await PsaDAL.get();
|
||||
return new MonkeyResponse("PSAs retrieved", data);
|
||||
return new MonkeyResponse2("PSAs retrieved", replaceObjectIds(data));
|
||||
}
|
||||
|
|
|
@ -330,8 +330,7 @@ export async function addResult(
|
|||
completedEvent.wpm > 130 &&
|
||||
completedEvent.testDuration < 122 &&
|
||||
(user.verified === false || user.verified === undefined) &&
|
||||
user.lbOptOut !== true &&
|
||||
user.banned !== true //no need to check again if user is already banned
|
||||
user.lbOptOut !== true
|
||||
) {
|
||||
if (!completedEvent.keySpacingStats || !completedEvent.keyDurationStats) {
|
||||
const status = MonkeyStatusCodes.MISSING_KEY_DATA;
|
||||
|
|
|
@ -37,10 +37,21 @@ import {
|
|||
UserProfileDetails,
|
||||
} from "@monkeytype/shared-types";
|
||||
import { addImportantLog, addLog, deleteUserLogs } from "../../dal/logs";
|
||||
import { sendForgotPasswordEmail as authSendForgotPasswordEmail } from "../../utils/auth";
|
||||
|
||||
async function verifyCaptcha(captcha: string): Promise<void> {
|
||||
if (!(await verify(captcha))) {
|
||||
throw new MonkeyError(422, "Captcha check failed");
|
||||
let verified = false;
|
||||
try {
|
||||
verified = await verify(captcha);
|
||||
} catch (e) {
|
||||
//fetch to recaptcha api can sometimes fail
|
||||
throw new MonkeyError(
|
||||
422,
|
||||
"Request to the Captcha API failed, please try again later"
|
||||
);
|
||||
}
|
||||
if (!verified) {
|
||||
throw new MonkeyError(422, "Captcha challenge failed");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -86,11 +97,15 @@ export async function sendVerificationEmail(
|
|||
await admin
|
||||
.auth()
|
||||
.getUser(uid)
|
||||
.catch((e) => {
|
||||
.catch((e: unknown) => {
|
||||
throw new MonkeyError(
|
||||
500, // this should never happen, but it does. it mightve been caused by auth token cache, will see if disabling cache fixes it
|
||||
"Auth user not found, even though the token got decoded",
|
||||
JSON.stringify({ uid, email, stack: e.stack }),
|
||||
JSON.stringify({
|
||||
uid,
|
||||
email,
|
||||
stack: e instanceof Error ? e.stack : JSON.stringify(e),
|
||||
}),
|
||||
uid
|
||||
);
|
||||
})
|
||||
|
@ -159,29 +174,7 @@ export async function sendForgotPasswordEmail(
|
|||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
const { email } = req.body;
|
||||
|
||||
try {
|
||||
const uid = (await FirebaseAdmin().auth().getUserByEmail(email)).uid;
|
||||
const userInfo = await UserDAL.getPartialUser(
|
||||
uid,
|
||||
"request forgot password email",
|
||||
["name"]
|
||||
);
|
||||
|
||||
const link = await FirebaseAdmin()
|
||||
.auth()
|
||||
.generatePasswordResetLink(email, {
|
||||
url: isDevEnvironment()
|
||||
? "http://localhost:3000"
|
||||
: "https://monkeytype.com",
|
||||
});
|
||||
|
||||
await emailQueue.sendForgotPasswordEmail(email, userInfo.name, link);
|
||||
} catch {
|
||||
return new MonkeyResponse(
|
||||
"Password reset request received. If the email is valid, you will receive an email shortly."
|
||||
);
|
||||
}
|
||||
await authSendForgotPasswordEmail(email);
|
||||
return new MonkeyResponse(
|
||||
"Password reset request received. If the email is valid, you will receive an email shortly."
|
||||
);
|
||||
|
|
|
@ -1,107 +1,46 @@
|
|||
// import joi from "joi";
|
||||
import { Router } from "express";
|
||||
import { authenticateRequest } from "../../middlewares/auth";
|
||||
import * as AdminController from "../controllers/admin";
|
||||
import { adminLimit } from "../../middlewares/rate-limit";
|
||||
import { sendForgotPasswordEmail, toggleBan } from "../controllers/user";
|
||||
import joi from "joi";
|
||||
import * as AdminController from "../controllers/admin";
|
||||
|
||||
import { adminContract } from "@monkeytype/contracts/admin";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import { validate } from "../../middlewares/configuration";
|
||||
import { checkIfUserIsAdmin } from "../../middlewares/permission";
|
||||
import { asyncHandler } from "../../middlewares/utility";
|
||||
import { validateRequest } from "../../middlewares/validation";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const router = Router();
|
||||
const commonMiddleware = [
|
||||
adminLimit,
|
||||
|
||||
router.use(
|
||||
validate({
|
||||
criteria: (configuration) => {
|
||||
return configuration.admin.endpointsEnabled;
|
||||
},
|
||||
invalidMessage: "Admin endpoints are currently disabled.",
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/",
|
||||
adminLimit,
|
||||
authenticateRequest({
|
||||
noCache: true,
|
||||
}),
|
||||
checkIfUserIsAdmin(),
|
||||
asyncHandler(AdminController.test)
|
||||
);
|
||||
];
|
||||
|
||||
router.post(
|
||||
"/toggleBan",
|
||||
adminLimit,
|
||||
authenticateRequest({
|
||||
noCache: true,
|
||||
}),
|
||||
checkIfUserIsAdmin(),
|
||||
validateRequest({
|
||||
body: {
|
||||
uid: joi.string().required().token(),
|
||||
},
|
||||
}),
|
||||
asyncHandler(toggleBan)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/report/accept",
|
||||
authenticateRequest({
|
||||
noCache: true,
|
||||
}),
|
||||
checkIfUserIsAdmin(),
|
||||
validateRequest({
|
||||
body: {
|
||||
reports: joi
|
||||
.array()
|
||||
.items(
|
||||
joi.object({
|
||||
reportId: joi.string().required(),
|
||||
})
|
||||
)
|
||||
.required(),
|
||||
},
|
||||
}),
|
||||
asyncHandler(AdminController.acceptReports)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/report/reject",
|
||||
authenticateRequest({
|
||||
noCache: true,
|
||||
}),
|
||||
checkIfUserIsAdmin(),
|
||||
validateRequest({
|
||||
body: {
|
||||
reports: joi
|
||||
.array()
|
||||
.items(
|
||||
joi.object({
|
||||
reportId: joi.string().required(),
|
||||
reason: joi.string().optional(),
|
||||
})
|
||||
)
|
||||
.required(),
|
||||
},
|
||||
}),
|
||||
asyncHandler(AdminController.rejectReports)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/sendForgotPasswordEmail",
|
||||
adminLimit,
|
||||
authenticateRequest({
|
||||
noCache: true,
|
||||
}),
|
||||
checkIfUserIsAdmin(),
|
||||
validateRequest({
|
||||
body: {
|
||||
email: joi.string().email().required(),
|
||||
},
|
||||
}),
|
||||
asyncHandler(sendForgotPasswordEmail)
|
||||
);
|
||||
|
||||
export default router;
|
||||
const s = initServer();
|
||||
export default s.router(adminContract, {
|
||||
test: {
|
||||
middleware: commonMiddleware,
|
||||
handler: async (r) => callController(AdminController.test)(r),
|
||||
},
|
||||
toggleBan: {
|
||||
middleware: commonMiddleware,
|
||||
handler: async (r) => callController(AdminController.toggleBan)(r),
|
||||
},
|
||||
acceptReports: {
|
||||
middleware: commonMiddleware,
|
||||
handler: async (r) => callController(AdminController.acceptReports)(r),
|
||||
},
|
||||
rejectReports: {
|
||||
middleware: commonMiddleware,
|
||||
handler: async (r) => callController(AdminController.rejectReports)(r),
|
||||
},
|
||||
sendForgotPasswordEmail: {
|
||||
middleware: commonMiddleware,
|
||||
handler: async (r) =>
|
||||
callController(AdminController.sendForgotPasswordEmail)(r),
|
||||
},
|
||||
});
|
||||
|
|
|
@ -45,20 +45,20 @@ const APP_START_TIME = Date.now();
|
|||
const API_ROUTE_MAP = {
|
||||
"/users": users,
|
||||
"/results": results,
|
||||
"/psas": psas,
|
||||
"/public": publicStats,
|
||||
"/leaderboards": leaderboards,
|
||||
"/quotes": quotes,
|
||||
"/admin": admin,
|
||||
"/webhooks": webhooks,
|
||||
"/docs": docs,
|
||||
};
|
||||
|
||||
const s = initServer();
|
||||
const router = s.router(contract, {
|
||||
admin,
|
||||
apeKeys,
|
||||
configs,
|
||||
presets,
|
||||
psas,
|
||||
});
|
||||
|
||||
export function addApiRoutes(app: Application): void {
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import { Router } from "express";
|
||||
import * as PsaController from "../controllers/psa";
|
||||
import { psasContract } from "@monkeytype/contracts/psas";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as RateLimit from "../../middlewares/rate-limit";
|
||||
import { asyncHandler } from "../../middlewares/utility";
|
||||
import * as PsaController from "../controllers/psa";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", RateLimit.psaGet, asyncHandler(PsaController.getPsas));
|
||||
|
||||
export default router;
|
||||
const s = initServer();
|
||||
export default s.router(psasContract, {
|
||||
get: {
|
||||
middleware: [RateLimit.psaGet],
|
||||
handler: async (r) => callController(PsaController.getPsas)(r),
|
||||
},
|
||||
});
|
||||
|
|
|
@ -7,6 +7,8 @@ export function callController<
|
|||
TBody,
|
||||
TParams,
|
||||
TResponse,
|
||||
//ignoring as it might be used in the future
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
|
||||
TStatus = 200
|
||||
>(
|
||||
handler: Handler<TQuery, TBody, TParams, TResponse>
|
||||
|
|
|
@ -312,7 +312,6 @@ export const profanities = [
|
|||
"feces",
|
||||
"felcher",
|
||||
"ficken",
|
||||
"fitt",
|
||||
"flikker",
|
||||
"foreskin",
|
||||
"fotze",
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
import { Collection, WithId } from "mongodb";
|
||||
import * as db from "../init/db";
|
||||
|
||||
export const getCollection = (): Collection<WithId<{ uid: string }>> =>
|
||||
db.collection("admin-uids");
|
||||
|
||||
export async function isAdmin(uid: string): Promise<boolean> {
|
||||
const doc = await db.collection("admin-uids").findOne({ uid });
|
||||
const doc = await getCollection().findOne({ uid });
|
||||
if (doc) {
|
||||
return true;
|
||||
} else {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { PSA } from "@monkeytype/shared-types";
|
||||
import { PSA } from "@monkeytype/contracts/schemas/psas";
|
||||
import * as db from "../init/db";
|
||||
|
||||
type DBPSA = MonkeyTypes.WithObjectId<PSA>;
|
||||
export type DBPSA = MonkeyTypes.WithObjectId<PSA>;
|
||||
|
||||
export async function get(): Promise<DBPSA[]> {
|
||||
return await db.collection<DBPSA>("psa").find().toArray();
|
||||
|
|
|
@ -1004,12 +1004,12 @@ export async function updateInbox(
|
|||
|
||||
const xpGain = rewards
|
||||
.filter((it) => it.type === "xp")
|
||||
.map((it) => it.item as number)
|
||||
.map((it) => it.item)
|
||||
.reduce((s, a) => s + a, 0);
|
||||
|
||||
const badgesToClaim = rewards
|
||||
.filter((it) => it.type === "badge")
|
||||
.map((it) => it.item as Badge);
|
||||
.map((it) => it.item);
|
||||
|
||||
if (inventory === null)
|
||||
inventory = {
|
||||
|
|
|
@ -23,10 +23,6 @@
|
|||
"name": "users",
|
||||
"description": "User data and related operations"
|
||||
},
|
||||
{
|
||||
"name": "psas",
|
||||
"description": "Public service announcements"
|
||||
},
|
||||
{
|
||||
"name": "leaderboards",
|
||||
"description": "Leaderboard data"
|
||||
|
@ -416,20 +412,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/psas": {
|
||||
"get": {
|
||||
"tags": ["psas"],
|
||||
"summary": "Gets the latest public service announcements",
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/leaderboards": {
|
||||
"get": {
|
||||
"tags": ["leaderboards"],
|
||||
|
|
|
@ -82,10 +82,10 @@ type MailResult = {
|
|||
message: string;
|
||||
};
|
||||
|
||||
export async function sendEmail<M extends EmailType>(
|
||||
export async function sendEmail(
|
||||
templateName: EmailType,
|
||||
to: string,
|
||||
data: EmailTaskContexts[M]
|
||||
data: EmailTaskContexts[EmailType]
|
||||
): Promise<MailResult> {
|
||||
if (!isInitialized()) {
|
||||
return {
|
||||
|
|
|
@ -19,7 +19,7 @@ type ValidationHandlingOptions = {
|
|||
};
|
||||
|
||||
type ValidationSchemaOptions = {
|
||||
[schema in keyof ValidationSchema]?: ValidationSchemaOption;
|
||||
[_schema in keyof ValidationSchema]?: ValidationSchemaOption;
|
||||
} & ValidationHandlingOptions;
|
||||
|
||||
const VALIDATION_SCHEMA_DEFAULT_OPTIONS: ValidationSchemaOptions = {
|
||||
|
|
|
@ -182,6 +182,7 @@ export class WeeklyXpLeaderboard {
|
|||
const { weeklyXpLeaderboardScoresKey, weeklyXpLeaderboardResultsKey } =
|
||||
this.getThisWeeksXpLeaderboardKeys();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
connection.set;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error
|
||||
|
|
|
@ -6,6 +6,9 @@ import {
|
|||
setTokenCacheSize,
|
||||
} from "./prometheus";
|
||||
import { type DecodedIdToken, UserRecord } from "firebase-admin/auth";
|
||||
import { isDevEnvironment } from "./misc";
|
||||
import emailQueue from "../queues/email-queue";
|
||||
import * as UserDAL from "../dal/user";
|
||||
|
||||
const tokenCache = new LRUCache<string, DecodedIdToken>({
|
||||
max: 20000,
|
||||
|
@ -82,3 +85,28 @@ export async function revokeTokensByUid(uid: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendForgotPasswordEmail(email: string): Promise<void> {
|
||||
try {
|
||||
const uid = (await FirebaseAdmin().auth().getUserByEmail(email)).uid;
|
||||
const { name } = await UserDAL.getPartialUser(
|
||||
uid,
|
||||
"request forgot password email",
|
||||
["name"]
|
||||
);
|
||||
|
||||
const link = await FirebaseAdmin()
|
||||
.auth()
|
||||
.generatePasswordResetLink(email, {
|
||||
url: isDevEnvironment()
|
||||
? "http://localhost:3000"
|
||||
: "https://monkeytype.com",
|
||||
});
|
||||
|
||||
await emailQueue.sendForgotPasswordEmail(email, name, link);
|
||||
} catch (err) {
|
||||
if (err.errorInfo?.code !== "auth/user-not-found") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -286,6 +286,7 @@ export function formatSeconds(
|
|||
|
||||
export function intersect<T>(a: T[], b: T[], removeDuplicates = false): T[] {
|
||||
let t;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
if (b.length > a.length) (t = b), (b = a), (a = t); // indexOf to loop over shorter
|
||||
const filtered = a.filter(function (e) {
|
||||
return b.includes(e);
|
||||
|
@ -336,5 +337,6 @@ export function replaceObjectId<T extends { _id: ObjectId }>(
|
|||
export function replaceObjectIds<T extends { _id: ObjectId }>(
|
||||
data: T[]
|
||||
): (T & { _id: string })[] {
|
||||
if (data === undefined) return data;
|
||||
return data.map((it) => replaceObjectId(it));
|
||||
}
|
||||
|
|
3
frontend/__tests__/global-setup.ts
Normal file
3
frontend/__tests__/global-setup.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export const setup = () => {
|
||||
process.env.TZ = "UTC";
|
||||
};
|
|
@ -46,7 +46,7 @@
|
|||
"@types/throttle-debounce": "2.1.0",
|
||||
"@vitest/coverage-v8": "1.6.0",
|
||||
"ajv": "8.12.0",
|
||||
"autoprefixer": "10.4.14",
|
||||
"autoprefixer": "10.4.20",
|
||||
"concurrently": "8.2.2",
|
||||
"dotenv": "16.4.5",
|
||||
"eslint": "8.57.0",
|
||||
|
@ -60,7 +60,7 @@
|
|||
"postcss": "8.4.31",
|
||||
"sass": "1.70.0",
|
||||
"subset-font": "2.3.0",
|
||||
"typescript": "5.3.3",
|
||||
"typescript": "5.5.4",
|
||||
"vite": "5.1.7",
|
||||
"vite-bundle-visualizer": "1.0.1",
|
||||
"vite-plugin-checker": "0.6.4",
|
||||
|
@ -99,10 +99,5 @@
|
|||
"stemmer": "2.0.1",
|
||||
"throttle-debounce": "5.0.2",
|
||||
"zod": "3.23.8"
|
||||
},
|
||||
"overrides": {
|
||||
"madge": {
|
||||
"@typescript-eslint/typescript-estree": "7.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -227,6 +227,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
&[data-config-name="paceCaret"] {
|
||||
.buttons {
|
||||
grid-template-columns: repeat(auto-fit, minmax(6rem, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
&.discordIntegration {
|
||||
.info {
|
||||
grid-area: buttons;
|
||||
|
|
|
@ -306,6 +306,14 @@
|
|||
// }
|
||||
}
|
||||
|
||||
&.hideExtraLetters {
|
||||
.word {
|
||||
& letter.extra {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.flipped {
|
||||
--correct-letter-color: var(--sub-color);
|
||||
--untyped-letter-color: var(--text-color);
|
||||
|
|
|
@ -3,7 +3,6 @@ import { Method } from "axios";
|
|||
import { getIdToken } from "firebase/auth";
|
||||
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();
|
||||
|
@ -17,20 +16,14 @@ function buildApi(timeout: number): (args: ApiFetcherArgs) => Promise<{
|
|||
headers: Headers;
|
||||
}> {
|
||||
return async (request: ApiFetcherArgs) => {
|
||||
const isPublicEndpoint =
|
||||
(request.route.metadata as EndpointMetadata | undefined)
|
||||
?.authenticationOptions?.isPublic ?? false;
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
...request.headers,
|
||||
"X-Client-Version": envConfig.clientVersion,
|
||||
};
|
||||
if (!isPublicEndpoint) {
|
||||
const token = isAuthenticated()
|
||||
? await getIdToken(getAuthenticatedUser())
|
||||
: "";
|
||||
|
||||
if (isAuthenticated()) {
|
||||
const token = await getIdToken(getAuthenticatedUser());
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import Leaderboards from "./leaderboards";
|
||||
import Psas from "./psas";
|
||||
import Quotes from "./quotes";
|
||||
import Results from "./results";
|
||||
import Users from "./users";
|
||||
|
@ -9,7 +8,6 @@ import Dev from "./dev";
|
|||
|
||||
export default {
|
||||
Leaderboards,
|
||||
Psas,
|
||||
Public,
|
||||
Quotes,
|
||||
Results,
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
import { PSA } from "@monkeytype/shared-types";
|
||||
|
||||
const BASE_PATH = "/psas";
|
||||
|
||||
export default class Psas {
|
||||
constructor(private httpClient: Ape.HttpClient) {
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
async get(): Ape.EndpointResponse<PSA[]> {
|
||||
return await this.httpClient.get(BASE_PATH);
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@ 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";
|
||||
import { psasContract } from "@monkeytype/contracts/psas";
|
||||
|
||||
const API_PATH = "";
|
||||
const BASE_URL = envConfig.backendUrl;
|
||||
|
@ -17,7 +18,7 @@ const Ape = {
|
|||
users: new endpoints.Users(httpClient),
|
||||
configs: buildClient(configsContract, BASE_URL, 10_000),
|
||||
results: new endpoints.Results(httpClient),
|
||||
psas: new endpoints.Psas(httpClient),
|
||||
psas: buildClient(psasContract, BASE_URL, 10_000),
|
||||
quotes: new endpoints.Quotes(httpClient),
|
||||
leaderboards: new endpoints.Leaderboards(httpClient),
|
||||
presets: buildClient(presetsContract, BASE_URL, 10_000),
|
||||
|
|
|
@ -112,7 +112,7 @@ layoutsPromise
|
|||
updateLayoutsCommands(layouts);
|
||||
updateKeymapLayoutsCommands(layouts);
|
||||
})
|
||||
.catch((e) => {
|
||||
.catch((e: unknown) => {
|
||||
console.error(
|
||||
Misc.createErrorMessage(e, "Failed to update layouts commands")
|
||||
);
|
||||
|
@ -123,7 +123,7 @@ languagesPromise
|
|||
.then((languages) => {
|
||||
updateLanguagesCommands(languages);
|
||||
})
|
||||
.catch((e) => {
|
||||
.catch((e: unknown) => {
|
||||
console.error(
|
||||
Misc.createErrorMessage(e, "Failed to update language commands")
|
||||
);
|
||||
|
@ -139,7 +139,7 @@ funboxPromise
|
|||
};
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
.catch((e: unknown) => {
|
||||
console.error(
|
||||
Misc.createErrorMessage(e, "Failed to update funbox commands")
|
||||
);
|
||||
|
@ -150,7 +150,7 @@ fontsPromise
|
|||
.then((fonts) => {
|
||||
updateFontFamilyCommands(fonts);
|
||||
})
|
||||
.catch((e) => {
|
||||
.catch((e: unknown) => {
|
||||
console.error(
|
||||
Misc.createErrorMessage(e, "Failed to update fonts commands")
|
||||
);
|
||||
|
@ -161,7 +161,7 @@ themesPromise
|
|||
.then((themes) => {
|
||||
updateThemesCommands(themes);
|
||||
})
|
||||
.catch((e) => {
|
||||
.catch((e: unknown) => {
|
||||
console.error(
|
||||
Misc.createErrorMessage(e, "Failed to update themes commands")
|
||||
);
|
||||
|
@ -172,7 +172,7 @@ challengesPromise
|
|||
.then((challenges) => {
|
||||
updateLoadChallengeCommands(challenges);
|
||||
})
|
||||
.catch((e) => {
|
||||
.catch((e: unknown) => {
|
||||
console.error(
|
||||
Misc.createErrorMessage(e, "Failed to update challenges commands")
|
||||
);
|
||||
|
@ -438,7 +438,7 @@ export const commands: MonkeyTypes.CommandsSubgroup = {
|
|||
.then(() => {
|
||||
Notifications.add("Copied to clipboard", 1);
|
||||
})
|
||||
.catch((e) => {
|
||||
.catch((e: unknown) => {
|
||||
Notifications.add("Failed to copy to clipboard: " + e, -1);
|
||||
});
|
||||
},
|
||||
|
|
|
@ -45,6 +45,7 @@ const commands: MonkeyTypes.Command[] = [
|
|||
alias: "navigate go to stats",
|
||||
icon: "fa-user",
|
||||
exec: (): void => {
|
||||
//todo probably base this on some state instead of the dom
|
||||
$("header nav .textButton.view-account").hasClass("hidden")
|
||||
? navigate("/login")
|
||||
: navigate("/account");
|
||||
|
|
|
@ -18,7 +18,7 @@ import {
|
|||
} from "./test/funbox/funbox-validation";
|
||||
import * as TribeState from "./tribe/tribe-state";
|
||||
import * as TribeConfigSyncEvent from "./observables/tribe-config-sync-event";
|
||||
import { isDevEnvironment, reloadAfter } from "./utils/misc";
|
||||
import { isDevEnvironment, reloadAfter, typedKeys } from "./utils/misc";
|
||||
import * as ConfigSchemas from "@monkeytype/contracts/schemas/configs";
|
||||
import { Config } from "@monkeytype/contracts/schemas/configs";
|
||||
import { roundTo1 } from "./utils/numbers";
|
||||
|
@ -55,7 +55,8 @@ function saveToLocalStorage(
|
|||
const localToSaveStringified = JSON.stringify(localToSave);
|
||||
window.localStorage.setItem("config", localToSaveStringified);
|
||||
if (!noDbCheck) {
|
||||
(configToSend[key] as typeof config[typeof key]) = config[key];
|
||||
//@ts-expect-error this is fine
|
||||
configToSend[key] = config[key];
|
||||
saveToDatabase();
|
||||
}
|
||||
ConfigEvent.dispatch("saveToLocalStorage", localToSaveStringified);
|
||||
|
@ -2301,12 +2302,13 @@ function replaceLegacyValues(
|
|||
|
||||
export function getConfigChanges(): MonkeyTypes.PresetConfig {
|
||||
const configChanges = {} as MonkeyTypes.PresetConfig;
|
||||
(Object.keys(config) as (keyof Config)[])
|
||||
typedKeys(config)
|
||||
.filter((key) => {
|
||||
return config[key] !== DefaultConfig[key];
|
||||
})
|
||||
.forEach((key) => {
|
||||
(configChanges[key] as typeof config[typeof key]) = config[key];
|
||||
//@ts-expect-error this is fine
|
||||
configChanges[key] = config[key];
|
||||
});
|
||||
return configChanges;
|
||||
}
|
||||
|
|
|
@ -45,6 +45,8 @@ import {
|
|||
import * as ConnectionState from "../states/connection";
|
||||
import { navigate } from "./route-controller";
|
||||
import { getHtmlByUserFlags } from "./user-flag-controller";
|
||||
import { FirebaseError } from "firebase/app";
|
||||
import * as PSA from "../elements/psa";
|
||||
|
||||
export const gmailProvider = new GoogleAuthProvider();
|
||||
export const githubProvider = new GithubAuthProvider();
|
||||
|
@ -85,30 +87,33 @@ async function getDataAndInit(): Promise<boolean> {
|
|||
await LoadingPage.showBar();
|
||||
await DB.initSnapshot();
|
||||
} catch (error) {
|
||||
const e = error as { message: string; responseCode: number };
|
||||
console.error(error);
|
||||
AccountButton.loading(false);
|
||||
if (e.responseCode === 429) {
|
||||
Notifications.add(
|
||||
"Doing so will save you bandwidth, make the next test be ready faster and will not sign you out (which could mean your new personal best would not save to your account).",
|
||||
0,
|
||||
{
|
||||
duration: 0,
|
||||
}
|
||||
);
|
||||
Notifications.add(
|
||||
"You will run into this error if you refresh the website to restart the test. It is NOT recommended to do that. Instead, use tab + enter or just tab (with quick tab mode enabled) to restart the test.",
|
||||
0,
|
||||
{
|
||||
duration: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
const msg = e.message || "Unknown error";
|
||||
Notifications.add("Failed to get user data: " + msg, -1);
|
||||
console.error(e);
|
||||
|
||||
LoginPage.enableInputs();
|
||||
$("header nav .account").css("opacity", 1);
|
||||
if (error instanceof DB.SnapshotInitError) {
|
||||
if (error.responseCode === 429) {
|
||||
Notifications.add(
|
||||
"Doing so will save you bandwidth, make the next test be ready faster and will not sign you out (which could mean your new personal best would not save to your account).",
|
||||
0,
|
||||
{
|
||||
duration: 0,
|
||||
}
|
||||
);
|
||||
Notifications.add(
|
||||
"You will run into this error if you refresh the website to restart the test. It is NOT recommended to do that. Instead, use tab + enter or just tab (with quick tab mode enabled) to restart the test.",
|
||||
0,
|
||||
{
|
||||
duration: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Notifications.add("Failed to get user data: " + error.message, -1);
|
||||
} else {
|
||||
const message = Misc.createErrorMessage(error, "Failed to get user data");
|
||||
Notifications.add(message, -1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (ActivePage.get() === "loading") {
|
||||
|
@ -138,7 +143,7 @@ async function getDataAndInit(): Promise<boolean> {
|
|||
// filters = defaultResultFilters;
|
||||
void ResultFilters.load();
|
||||
})
|
||||
.catch((e) => {
|
||||
.catch((e: unknown) => {
|
||||
console.log(
|
||||
Misc.createErrorMessage(
|
||||
e,
|
||||
|
@ -222,6 +227,7 @@ async function readyFunction(
|
|||
const hash = window.location.hash;
|
||||
console.debug(`account controller ready`);
|
||||
if (authInitialisedAndConnected) {
|
||||
void PSA.show();
|
||||
console.debug(`auth state changed, user ${user ? true : false}`);
|
||||
console.debug(user);
|
||||
if (user) {
|
||||
|
@ -300,19 +306,24 @@ export async function signIn(email: string, password: string): Promise<void> {
|
|||
.then(async (e) => {
|
||||
await loadUser(e.user);
|
||||
})
|
||||
.catch(function (error) {
|
||||
.catch(function (error: unknown) {
|
||||
console.error(error);
|
||||
let message = error.message;
|
||||
if (error.code === "auth/wrong-password") {
|
||||
message = "Incorrect password";
|
||||
} else if (error.code === "auth/user-not-found") {
|
||||
message = "User not found";
|
||||
} else if (error.code === "auth/invalid-email") {
|
||||
message =
|
||||
"Invalid email format (make sure you are using your email to login - not your username)";
|
||||
} else if (error.code === "auth/invalid-credential") {
|
||||
message =
|
||||
"Email/password is incorrect or your account does not have password authentication enabled.";
|
||||
let message = Misc.createErrorMessage(
|
||||
error,
|
||||
"Failed to sign in with email and password"
|
||||
);
|
||||
if (error instanceof FirebaseError) {
|
||||
if (error.code === "auth/wrong-password") {
|
||||
message = "Incorrect password";
|
||||
} else if (error.code === "auth/user-not-found") {
|
||||
message = "User not found";
|
||||
} else if (error.code === "auth/invalid-email") {
|
||||
message =
|
||||
"Invalid email format (make sure you are using your email to login - not your username)";
|
||||
} else if (error.code === "auth/invalid-credential") {
|
||||
message =
|
||||
"Email/password is incorrect or your account does not have password authentication enabled.";
|
||||
}
|
||||
}
|
||||
Notifications.add(message, -1);
|
||||
LoginPage.hidePreloader();
|
||||
|
@ -354,32 +365,37 @@ async function signInWithProvider(provider: AuthProvider): Promise<void> {
|
|||
await loadUser(signedInUser.user);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch((error: unknown) => {
|
||||
console.log(error);
|
||||
let message = error.message;
|
||||
if (error.code === "auth/wrong-password") {
|
||||
message = "Incorrect password";
|
||||
} else if (error.code === "auth/user-not-found") {
|
||||
message = "User not found";
|
||||
} else if (error.code === "auth/invalid-email") {
|
||||
message =
|
||||
"Invalid email format (make sure you are using your email to login - not your username)";
|
||||
} else if (error.code === "auth/popup-closed-by-user") {
|
||||
message = "";
|
||||
// message = "Popup closed by user";
|
||||
// return;
|
||||
} else if (error.code === "auth/popup-blocked") {
|
||||
message =
|
||||
"Sign in popup was blocked by the browser. Check the address bar for a blocked popup icon, or update your browser settings to allow popups.";
|
||||
} else if (error.code === "auth/user-cancelled") {
|
||||
message = "";
|
||||
// message = "User refused to sign in";
|
||||
// return;
|
||||
} else if (
|
||||
error.code === "auth/account-exists-with-different-credential"
|
||||
) {
|
||||
message =
|
||||
"Account already exists, but its using a different authentication method. Try signing in with a different method";
|
||||
let message = Misc.createErrorMessage(
|
||||
error,
|
||||
"Failed to sign in with popup"
|
||||
);
|
||||
if (error instanceof FirebaseError) {
|
||||
if (error.code === "auth/wrong-password") {
|
||||
message = "Incorrect password";
|
||||
} else if (error.code === "auth/user-not-found") {
|
||||
message = "User not found";
|
||||
} else if (error.code === "auth/invalid-email") {
|
||||
message =
|
||||
"Invalid email format (make sure you are using your email to login - not your username)";
|
||||
} else if (error.code === "auth/popup-closed-by-user") {
|
||||
message = "";
|
||||
// message = "Popup closed by user";
|
||||
// return;
|
||||
} else if (error.code === "auth/popup-blocked") {
|
||||
message =
|
||||
"Sign in popup was blocked by the browser. Check the address bar for a blocked popup icon, or update your browser settings to allow popups.";
|
||||
} else if (error.code === "auth/user-cancelled") {
|
||||
message = "";
|
||||
// message = "User refused to sign in";
|
||||
// return;
|
||||
} else if (
|
||||
error.code === "auth/account-exists-with-different-credential"
|
||||
) {
|
||||
message =
|
||||
"Account already exists, but its using a different authentication method. Try signing in with a different method";
|
||||
}
|
||||
}
|
||||
if (message !== "") {
|
||||
Notifications.add(message, -1);
|
||||
|
@ -430,12 +446,13 @@ async function addAuthProvider(
|
|||
Notifications.add(`${providerName} authentication added`, 1);
|
||||
Settings.updateAuthSections();
|
||||
})
|
||||
.catch(function (error) {
|
||||
.catch(function (error: unknown) {
|
||||
Loader.hide();
|
||||
Notifications.add(
|
||||
`Failed to add ${providerName} authentication: ` + error.message,
|
||||
-1
|
||||
const message = Misc.createErrorMessage(
|
||||
error,
|
||||
`Failed to add ${providerName} authentication`
|
||||
);
|
||||
Notifications.add(message, -1);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -461,8 +478,9 @@ export function signOut(): void {
|
|||
hideFavoriteQuoteLength();
|
||||
}, 125);
|
||||
})
|
||||
.catch(function (error) {
|
||||
Notifications.add(error.message, -1);
|
||||
.catch(function (error: unknown) {
|
||||
const message = Misc.createErrorMessage(error, `Failed to sign out`);
|
||||
Notifications.add(message, -1);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -83,9 +83,7 @@ function updateUI(): void {
|
|||
);
|
||||
const inputGroupLength: number = koCurrInput.length - 1;
|
||||
if (koCurrInput[inputGroupLength]) {
|
||||
const inputCharLength: number = (
|
||||
koCurrInput[inputGroupLength] as string[]
|
||||
).length;
|
||||
const inputCharLength: number = koCurrInput[inputGroupLength].length;
|
||||
//at the end of the word, it will throw a (reading '0') this will be the space
|
||||
try {
|
||||
//if it overflows and returns undefined (e.g input [ㄱ,ㅏ,ㄷ]),
|
||||
|
@ -735,27 +733,25 @@ function handleChar(
|
|||
)?.offsetTop as number;
|
||||
void TestUI.updateWordElement();
|
||||
|
||||
if (!Config.hideExtraLetters) {
|
||||
const newActiveTop = document.querySelector<HTMLElement>(
|
||||
"#words .word.active"
|
||||
)?.offsetTop as number;
|
||||
//stop the word jump by slicing off the last character, update word again
|
||||
if (
|
||||
activeWordTopBeforeJump < newActiveTop &&
|
||||
!TestUI.lineTransition &&
|
||||
TestInput.input.current.length > 1
|
||||
) {
|
||||
if (Config.mode === "zen") {
|
||||
const currentTop = Math.floor(
|
||||
document.querySelectorAll<HTMLElement>("#words .word")[
|
||||
TestUI.currentWordElementIndex - 1
|
||||
]?.offsetTop ?? 0
|
||||
);
|
||||
if (!Config.showAllLines) TestUI.lineJump(currentTop);
|
||||
} else {
|
||||
TestInput.input.current = TestInput.input.current.slice(0, -1);
|
||||
void TestUI.updateWordElement();
|
||||
}
|
||||
const newActiveTop = document.querySelector<HTMLElement>(
|
||||
"#words .word.active"
|
||||
)?.offsetTop as number;
|
||||
//stop the word jump by slicing off the last character, update word again
|
||||
if (
|
||||
activeWordTopBeforeJump < newActiveTop &&
|
||||
!TestUI.lineTransition &&
|
||||
TestInput.input.current.length > 1
|
||||
) {
|
||||
if (Config.mode === "zen") {
|
||||
const currentTop = Math.floor(
|
||||
document.querySelectorAll<HTMLElement>("#words .word")[
|
||||
TestUI.currentWordElementIndex - 1
|
||||
]?.offsetTop ?? 0
|
||||
);
|
||||
if (!Config.showAllLines) TestUI.lineJump(currentTop);
|
||||
} else {
|
||||
TestInput.input.current = TestInput.input.current.slice(0, -1);
|
||||
void TestUI.updateWordElement();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -899,21 +895,18 @@ function handleTab(event: JQuery.KeyDownEvent, popupVisible: boolean): void {
|
|||
return;
|
||||
}
|
||||
|
||||
|
||||
if (TribeState.getState() >= 5 && ActivePage.get() === "test") {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
if (document.activeElement?.id !== "wordsInput") {
|
||||
Focus.set(false);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
$("#wordsInput").on("keydown", (event) => {
|
||||
const pageTestActive: boolean = ActivePage.get() === "test";
|
||||
|
|
|
@ -26,6 +26,14 @@ import {
|
|||
|
||||
let dbSnapshot: MonkeyTypes.Snapshot | undefined;
|
||||
|
||||
export class SnapshotInitError extends Error {
|
||||
constructor(message: string, public responseCode: number) {
|
||||
super(message);
|
||||
this.name = "SnapshotInitError";
|
||||
this.responseCode = responseCode;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSnapshot(): MonkeyTypes.Snapshot | undefined {
|
||||
return dbSnapshot;
|
||||
}
|
||||
|
@ -75,27 +83,23 @@ export async function initSnapshot(): Promise<
|
|||
Ape.presets.get(),
|
||||
]);
|
||||
|
||||
//these objects are explicitly handled so its ok to throw that way
|
||||
if (userResponse.status !== 200) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw {
|
||||
message: `${userResponse.message} (user)`,
|
||||
responseCode: userResponse.status,
|
||||
};
|
||||
throw new SnapshotInitError(
|
||||
`${userResponse.message} (user)`,
|
||||
userResponse.status
|
||||
);
|
||||
}
|
||||
if (configResponse.status !== 200) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw {
|
||||
message: `${configResponse.body.message} (config)`,
|
||||
responseCode: configResponse.status,
|
||||
};
|
||||
throw new SnapshotInitError(
|
||||
`${configResponse.body.message} (config)`,
|
||||
configResponse.status
|
||||
);
|
||||
}
|
||||
if (presetsResponse.status !== 200) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw {
|
||||
message: `${presetsResponse.body.message} (presets)`,
|
||||
responseCode: presetsResponse.status,
|
||||
};
|
||||
throw new SnapshotInitError(
|
||||
`${presetsResponse.body.message} (presets)`,
|
||||
presetsResponse.status
|
||||
);
|
||||
}
|
||||
|
||||
const userData = userResponse.data;
|
||||
|
@ -103,11 +107,10 @@ export async function initSnapshot(): Promise<
|
|||
const presetsData = presetsResponse.body.data;
|
||||
|
||||
if (userData === null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw {
|
||||
message: "Request was successful but user data is null",
|
||||
responseCode: 200,
|
||||
};
|
||||
throw new SnapshotInitError(
|
||||
`Request was successful but user data is null`,
|
||||
200
|
||||
);
|
||||
}
|
||||
|
||||
if (configData !== null && "config" in configData) {
|
||||
|
@ -890,7 +893,7 @@ export async function updateLbMemory<M extends Mode>(
|
|||
api = false
|
||||
): Promise<void> {
|
||||
if (mode === "time") {
|
||||
const timeMode = mode as "time";
|
||||
const timeMode = mode;
|
||||
const timeMode2 = mode2 as "15" | "60";
|
||||
|
||||
const snapshot = getSnapshot();
|
||||
|
@ -912,10 +915,7 @@ export async function updateLbMemory<M extends Mode>(
|
|||
const current = snapshot.lbMemory?.[timeMode]?.[timeMode2]?.[language];
|
||||
|
||||
//this is protected above so not sure why it would be undefined
|
||||
const mem = snapshot.lbMemory[timeMode][timeMode2] as Record<
|
||||
string,
|
||||
number
|
||||
>;
|
||||
const mem = snapshot.lbMemory[timeMode][timeMode2];
|
||||
mem[language] = rank;
|
||||
if (api && current !== rank) {
|
||||
await Ape.users.updateLeaderboardMemory(mode, mode2, language, rank);
|
||||
|
|
|
@ -145,7 +145,7 @@ export async function load(): Promise<void> {
|
|||
|
||||
Object.keys(defaultResultFilters.tags).forEach((tag) => {
|
||||
if (filters.tags[tag] !== undefined) {
|
||||
newTags[tag] = filters.tags[tag] as boolean;
|
||||
newTags[tag] = filters.tags[tag];
|
||||
} else {
|
||||
newTags[tag] = true;
|
||||
}
|
||||
|
|
|
@ -28,21 +28,21 @@ let showingYesterday = false;
|
|||
type LbKey = "15" | "60";
|
||||
|
||||
let currentData: {
|
||||
[key in LbKey]: LeaderboardEntry[];
|
||||
[_key in LbKey]: LeaderboardEntry[];
|
||||
} = {
|
||||
"15": [],
|
||||
"60": [],
|
||||
};
|
||||
|
||||
let currentRank: {
|
||||
[key in LbKey]: Ape.Leaderboards.GetRank | Record<string, never>;
|
||||
[_key in LbKey]: Ape.Leaderboards.GetRank | Record<string, never>;
|
||||
} = {
|
||||
"15": {},
|
||||
"60": {},
|
||||
};
|
||||
|
||||
let currentAvatars: {
|
||||
[key in LbKey]: (string | null)[];
|
||||
[_key in LbKey]: (string | null)[];
|
||||
} = {
|
||||
"15": [],
|
||||
"60": [],
|
||||
|
|
|
@ -4,7 +4,7 @@ import { secondsToString } from "../utils/date-and-time";
|
|||
import * as Notifications from "./notifications";
|
||||
import { format } from "date-fns/format";
|
||||
import * as Alerts from "./alerts";
|
||||
import { PSA } from "@monkeytype/shared-types";
|
||||
import { PSA } from "@monkeytype/contracts/schemas/psas";
|
||||
|
||||
function clearMemory(): void {
|
||||
window.localStorage.setItem("confirmedPSAs", JSON.stringify([]));
|
||||
|
@ -61,7 +61,7 @@ async function getLatest(): Promise<PSA[] | null> {
|
|||
} else if (response.status !== 200) {
|
||||
return null;
|
||||
}
|
||||
return response.data;
|
||||
return response.body.data;
|
||||
}
|
||||
|
||||
export async function show(): Promise<void> {
|
||||
|
|
|
@ -120,6 +120,7 @@ export default class SettingsGroup<T extends ConfigValue> {
|
|||
select.value = this.configValue as string;
|
||||
|
||||
//@ts-expect-error
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
const ss = select.slim as SlimSelect | undefined;
|
||||
ss?.store.setSelectedBy("value", [this.configValue as string]);
|
||||
ss?.render.renderValues();
|
||||
|
|
|
@ -157,9 +157,7 @@ export class TestActivityCalendar implements MonkeyTypes.TestActivityCalendar {
|
|||
}
|
||||
|
||||
private getBuckets(): number[] {
|
||||
const filtered = this.data.filter(
|
||||
(it) => it !== null && it !== undefined
|
||||
) as number[];
|
||||
const filtered = this.data.filter((it) => it !== null && it !== undefined);
|
||||
const sorted = filtered.sort((a, b) => a - b);
|
||||
|
||||
const trimmed = sorted.slice(
|
||||
|
|
|
@ -77,7 +77,7 @@ addToGlobal({
|
|||
tribe: Tribe,
|
||||
tribeState: TribeState,
|
||||
tribeCarets: TribeCarets,
|
||||
createTribeRoom: TribeSocket.default.out.room.create
|
||||
createTribeRoom: TribeSocket.default.out.room.create,
|
||||
});
|
||||
|
||||
if (isDevEnvironment()) {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { format } from "date-fns/format";
|
||||
import { getReleasesFromGitHub } from "../utils/json-data";
|
||||
import AnimatedModal from "../utils/animated-modal";
|
||||
import { createErrorMessage } from "../utils/misc";
|
||||
|
||||
export function show(): void {
|
||||
void modal.show({
|
||||
|
@ -44,9 +45,10 @@ export function show(): void {
|
|||
}
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
.catch((e: unknown) => {
|
||||
const msg = createErrorMessage(e, "Failed to fetch version history");
|
||||
$("#versionHistoryModal .modal").html(
|
||||
`<div class="releases">Failed to fetch version history:<br>${e.message}</div`
|
||||
`<div class="releases">Failed to fetch version history:<br>${msg}</div`
|
||||
);
|
||||
});
|
||||
$("#newVersionIndicator").addClass("hidden");
|
||||
|
|
|
@ -581,9 +581,9 @@ async function fillContent(): Promise<void> {
|
|||
last10++;
|
||||
wpmLast10total += result.wpm;
|
||||
totalAcc10 += result.acc;
|
||||
result.consistency !== undefined
|
||||
? (totalCons10 += result.consistency)
|
||||
: 0;
|
||||
if (result.consistency !== undefined) {
|
||||
totalCons10 += result.consistency;
|
||||
}
|
||||
}
|
||||
testCount++;
|
||||
|
||||
|
@ -624,7 +624,7 @@ async function fillContent(): Promise<void> {
|
|||
acc: result.acc,
|
||||
mode: result.mode,
|
||||
mode2: result.mode2,
|
||||
punctuation: result.punctuation as boolean,
|
||||
punctuation: result.punctuation,
|
||||
language: result.language,
|
||||
timestamp: result.timestamp,
|
||||
difficulty: result.difficulty,
|
||||
|
|
|
@ -856,8 +856,12 @@ function setActiveFunboxButton(): void {
|
|||
}
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
Notifications.add(`Failed to update funbox buttons: ${e.message}`, -1);
|
||||
.catch((e: unknown) => {
|
||||
const message = Misc.createErrorMessage(
|
||||
e,
|
||||
"Failed to update funbox buttons"
|
||||
);
|
||||
Notifications.add(message, -1);
|
||||
});
|
||||
Config.funbox.split("#").forEach((funbox) => {
|
||||
$(
|
||||
|
@ -1421,10 +1425,12 @@ $(
|
|||
|
||||
$(".pageSettings .quickNav .links a").on("click", (e) => {
|
||||
const settingsGroup = e.target.innerText;
|
||||
const isOpen = $(`.pageSettings .settingsGroup.${settingsGroup}`).hasClass(
|
||||
const isClosed = $(`.pageSettings .settingsGroup.${settingsGroup}`).hasClass(
|
||||
"slideup"
|
||||
);
|
||||
isOpen && toggleSettingsGroup(settingsGroup);
|
||||
if (isClosed) {
|
||||
toggleSettingsGroup(settingsGroup);
|
||||
}
|
||||
});
|
||||
|
||||
$(".pageSettings .section.discordIntegration .getLinkAndGoToOauth").on(
|
||||
|
|
|
@ -3,7 +3,6 @@ import * as Misc from "./utils/misc";
|
|||
import * as MonkeyPower from "./elements/monkey-power";
|
||||
import * as Notifications from "./elements/notifications";
|
||||
import * as CookiesModal from "./modals/cookies";
|
||||
import * as PSA from "./elements/psa";
|
||||
import * as ConnectionState from "./states/connection";
|
||||
import * as FunboxList from "./test/funbox/funbox-list";
|
||||
//@ts-expect-error
|
||||
|
@ -46,7 +45,6 @@ $((): void => {
|
|||
.stop(true, true)
|
||||
.animate({ opacity: 1 }, 250);
|
||||
if (ConnectionState.get()) {
|
||||
void PSA.show();
|
||||
void ServerConfiguration.sync().then(() => {
|
||||
if (!ServerConfiguration.get()?.users.signUp) {
|
||||
$(".signInOut").addClass("hidden");
|
||||
|
|
|
@ -32,38 +32,82 @@ export function hide(): void {
|
|||
caret.classList.add("hidden");
|
||||
}
|
||||
|
||||
export function getSpaceWidth(wordElement?: HTMLElement): number {
|
||||
if (!wordElement)
|
||||
wordElement = document
|
||||
.getElementById("words")
|
||||
?.querySelectorAll(".word")?.[0] as HTMLElement | undefined;
|
||||
if (!wordElement) return 0;
|
||||
const wordComputedStyle = window.getComputedStyle(wordElement);
|
||||
return (
|
||||
parseInt(wordComputedStyle.marginRight) +
|
||||
parseInt(wordComputedStyle.marginLeft)
|
||||
);
|
||||
}
|
||||
|
||||
function getTargetPositionLeft(
|
||||
fullWidthCaret: boolean,
|
||||
isLanguageRightToLeft: boolean,
|
||||
currentLetter: HTMLElement | undefined,
|
||||
previousLetter: HTMLElement | undefined,
|
||||
lastWordLetter: HTMLElement,
|
||||
inputLenLongerThanWordLen: boolean
|
||||
activeWordElement: HTMLElement,
|
||||
currentWordNodeList: NodeListOf<Element>,
|
||||
fullWidthCaretWidth: number,
|
||||
wordLen: number,
|
||||
inputLen: number
|
||||
): number {
|
||||
const invisibleExtraLetters = Config.blindMode || Config.hideExtraLetters;
|
||||
let result = 0;
|
||||
|
||||
if (isLanguageRightToLeft) {
|
||||
const fullWidthOffset = fullWidthCaret
|
||||
? 0
|
||||
: currentLetter?.offsetWidth ?? previousLetter?.offsetWidth ?? 0;
|
||||
if (currentLetter !== undefined) {
|
||||
result = currentLetter.offsetLeft + fullWidthOffset;
|
||||
} else if (previousLetter !== undefined) {
|
||||
result =
|
||||
previousLetter.offsetLeft -
|
||||
previousLetter.offsetWidth +
|
||||
fullWidthOffset;
|
||||
if (Config.tapeMode === "off") {
|
||||
let positionOffsetToWord = 0;
|
||||
|
||||
const currentLetter = currentWordNodeList[inputLen] as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
const lastWordLetter = currentWordNodeList[wordLen - 1] as HTMLElement;
|
||||
const lastInputLetter = currentWordNodeList[inputLen - 1] as HTMLElement;
|
||||
|
||||
if (isLanguageRightToLeft) {
|
||||
if (inputLen < wordLen && currentLetter) {
|
||||
positionOffsetToWord =
|
||||
currentLetter?.offsetLeft +
|
||||
(fullWidthCaret ? 0 : fullWidthCaretWidth);
|
||||
} else if (!invisibleExtraLetters) {
|
||||
positionOffsetToWord =
|
||||
lastInputLetter.offsetLeft -
|
||||
(fullWidthCaret ? fullWidthCaretWidth : 0);
|
||||
} else {
|
||||
positionOffsetToWord =
|
||||
lastWordLetter.offsetLeft -
|
||||
(fullWidthCaret ? fullWidthCaretWidth : 0);
|
||||
}
|
||||
} else {
|
||||
if (inputLen < wordLen && currentLetter) {
|
||||
positionOffsetToWord = currentLetter?.offsetLeft;
|
||||
} else if (!invisibleExtraLetters) {
|
||||
positionOffsetToWord =
|
||||
lastInputLetter.offsetLeft + lastInputLetter.offsetWidth;
|
||||
} else {
|
||||
positionOffsetToWord =
|
||||
lastWordLetter.offsetLeft + lastWordLetter.offsetWidth;
|
||||
}
|
||||
}
|
||||
result = activeWordElement.offsetLeft + positionOffsetToWord;
|
||||
} else {
|
||||
if (
|
||||
(Config.blindMode || Config.hideExtraLetters) &&
|
||||
inputLenLongerThanWordLen
|
||||
) {
|
||||
result = lastWordLetter.offsetLeft + lastWordLetter.offsetWidth;
|
||||
} else if (currentLetter !== undefined) {
|
||||
result = currentLetter.offsetLeft;
|
||||
} else if (previousLetter !== undefined) {
|
||||
result = previousLetter.offsetLeft + previousLetter.offsetWidth;
|
||||
const wordsWrapperWidth =
|
||||
$(document.querySelector("#wordsWrapper") as HTMLElement).width() ?? 0;
|
||||
result =
|
||||
wordsWrapperWidth / 2 -
|
||||
(fullWidthCaret && isLanguageRightToLeft ? fullWidthCaretWidth : 0);
|
||||
|
||||
if (Config.tapeMode === "word" && inputLen > 0) {
|
||||
let currentWordWidth = 0;
|
||||
for (let i = 0; i < inputLen; i++) {
|
||||
if (invisibleExtraLetters && i >= wordLen) break;
|
||||
currentWordWidth +=
|
||||
$(currentWordNodeList[i] as HTMLElement).outerWidth(true) ?? 0;
|
||||
}
|
||||
if (isLanguageRightToLeft) currentWordWidth *= -1;
|
||||
result += currentWordWidth;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -81,6 +125,8 @@ export async function updatePosition(noAnim = false): Promise<void> {
|
|||
|
||||
const wordLen = TestWords.words.getCurrent().length;
|
||||
const inputLen = TestInput.input.current.length;
|
||||
const letterIsInvisibleExtra =
|
||||
(Config.blindMode || Config.hideExtraLetters) && inputLen > wordLen;
|
||||
const activeWordEl = document?.querySelector("#words .active") as HTMLElement;
|
||||
//insert temporary character so the caret will work in zen mode
|
||||
const activeWordEmpty = activeWordEl?.children.length === 0;
|
||||
|
@ -91,74 +137,55 @@ export async function updatePosition(noAnim = false): Promise<void> {
|
|||
);
|
||||
}
|
||||
|
||||
const currentWordNodeList = document
|
||||
?.querySelector("#words .active")
|
||||
?.querySelectorAll("letter");
|
||||
|
||||
if (!currentWordNodeList) return;
|
||||
const currentWordNodeList = activeWordEl?.querySelectorAll("letter");
|
||||
if (!currentWordNodeList?.length) return;
|
||||
|
||||
const currentLetter = currentWordNodeList[inputLen] as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
|
||||
const previousLetter: HTMLElement = currentWordNodeList[
|
||||
inputLen - 1
|
||||
] as HTMLElement;
|
||||
|
||||
const lastWordLetter = currentWordNodeList[wordLen - 1] as HTMLElement;
|
||||
|
||||
const spaceWidth = getSpaceWidth(activeWordEl);
|
||||
|
||||
const currentLanguage = await JSONData.getCurrentLanguage(Config.language);
|
||||
const isLanguageRightToLeft = currentLanguage.rightToLeft;
|
||||
const letterPosLeft = getTargetPositionLeft(
|
||||
fullWidthCaret,
|
||||
isLanguageRightToLeft,
|
||||
currentLetter,
|
||||
previousLetter,
|
||||
lastWordLetter,
|
||||
inputLen > wordLen
|
||||
);
|
||||
|
||||
const letterPosTop =
|
||||
currentLetter?.offsetTop ??
|
||||
previousLetter?.offsetTop ??
|
||||
lastWordLetter?.offsetTop;
|
||||
|
||||
// in blind mode, and hide extra letters, extra letters have zero offsets
|
||||
// offsetTop and offsetHeight is the same for all visible letters
|
||||
const letterHeight =
|
||||
currentLetter?.offsetHeight ||
|
||||
previousLetter?.offsetHeight ||
|
||||
lastWordLetter?.offsetHeight ||
|
||||
Config.fontSize * Numbers.convertRemToPixels(1);
|
||||
|
||||
const letterWidth =
|
||||
currentLetter?.offsetWidth ||
|
||||
previousLetter?.offsetWidth ||
|
||||
lastWordLetter?.offsetWidth;
|
||||
|
||||
const letterPosTop = lastWordLetter.offsetTop;
|
||||
const diff = letterHeight - caret.offsetHeight;
|
||||
|
||||
let newTop = activeWordEl.offsetTop + letterPosTop + diff / 2;
|
||||
|
||||
if (Config.caretStyle === "underline") {
|
||||
newTop = activeWordEl.offsetTop + letterPosTop - caret.offsetHeight / 2;
|
||||
}
|
||||
|
||||
let newLeft =
|
||||
activeWordEl.offsetLeft +
|
||||
letterPosLeft -
|
||||
(fullWidthCaret ? 0 : caretWidth / 2);
|
||||
|
||||
const wordsWrapperWidth =
|
||||
$(document.querySelector("#wordsWrapper") as HTMLElement).width() ?? 0;
|
||||
|
||||
if (
|
||||
Config.tapeMode === "letter" ||
|
||||
(Config.tapeMode === "word" && inputLen === 0)
|
||||
) {
|
||||
newLeft = wordsWrapperWidth / 2 - (fullWidthCaret ? 0 : caretWidth / 2);
|
||||
let letterWidth = currentLetter?.offsetWidth || spaceWidth;
|
||||
if (currentLetter?.offsetWidth === 0 && !letterIsInvisibleExtra) {
|
||||
// other than in extra letters in blind mode, it could be zero
|
||||
// if current letter is a zero-width character e.g, diacritics)
|
||||
letterWidth = 0;
|
||||
for (let i = inputLen; i >= 0; i--) {
|
||||
letterWidth = (currentWordNodeList[i] as HTMLElement)?.offsetWidth;
|
||||
if (letterWidth) break;
|
||||
}
|
||||
}
|
||||
|
||||
const newWidth = fullWidthCaret ? (letterWidth ?? 0) + "px" : "";
|
||||
|
||||
const letterPosLeft = getTargetPositionLeft(
|
||||
fullWidthCaret,
|
||||
isLanguageRightToLeft,
|
||||
activeWordEl,
|
||||
currentWordNodeList,
|
||||
letterWidth,
|
||||
wordLen,
|
||||
inputLen
|
||||
);
|
||||
const newLeft = letterPosLeft - (fullWidthCaret ? 0 : caretWidth / 2);
|
||||
|
||||
let smoothlinescroll = $("#words .smoothScroller").height();
|
||||
if (smoothlinescroll === undefined) smoothlinescroll = 0;
|
||||
|
||||
|
|
|
@ -42,10 +42,7 @@ export function getMode(): CustomTextMode {
|
|||
return mode;
|
||||
}
|
||||
|
||||
export function setMode(
|
||||
val: CustomTextMode,
|
||||
tribeOverride = false
|
||||
): void {
|
||||
export function setMode(val: CustomTextMode, tribeOverride = false): void {
|
||||
if (!TribeState.canChangeConfig(tribeOverride)) return;
|
||||
mode = val;
|
||||
limit.value = text.length;
|
||||
|
|
|
@ -44,7 +44,7 @@ export function checkFunboxForcedConfigs(
|
|||
forcedConfigs[key] = fb.forcedConfig[key] as ConfigValue[];
|
||||
} else {
|
||||
forcedConfigs[key] = Arrays.intersect(
|
||||
forcedConfigs[key] as ConfigValue[],
|
||||
forcedConfigs[key],
|
||||
fb.forcedConfig[key] as ConfigValue[],
|
||||
true
|
||||
);
|
||||
|
@ -61,7 +61,7 @@ export function checkFunboxForcedConfigs(
|
|||
throw new Error("No intersection of forced configs");
|
||||
}
|
||||
return {
|
||||
result: (forcedConfigs[key] ?? []).includes(value as ConfigValue),
|
||||
result: (forcedConfigs[key] ?? []).includes(value),
|
||||
forcedConfigs: forcedConfigs[key],
|
||||
};
|
||||
}
|
||||
|
@ -303,7 +303,7 @@ export function areFunboxesCompatible(
|
|||
if (allowedConfig[key]) {
|
||||
if (
|
||||
Arrays.intersect(
|
||||
allowedConfig[key] as ConfigValue[],
|
||||
allowedConfig[key],
|
||||
f.forcedConfig[key] as ConfigValue[],
|
||||
true
|
||||
).length === 0
|
||||
|
|
|
@ -18,12 +18,8 @@ import * as TestInput from "../test-input";
|
|||
import * as WeakSpot from "../weak-spot";
|
||||
import { getPoem } from "../poetry";
|
||||
import { getSection } from "../wikipedia";
|
||||
<<<<<<< HEAD
|
||||
import * as TribeState from "../../tribe/tribe-state";
|
||||
import * as IPGenerator from "../ip-addresses";
|
||||
=======
|
||||
import * as IPAddresses from "../../utils/ip-addresses";
|
||||
>>>>>>> master
|
||||
import {
|
||||
areFunboxesCompatible,
|
||||
checkFunboxForcedConfigs,
|
||||
|
@ -32,13 +28,9 @@ import * as TribeConfigSyncEvent from "../../observables/tribe-config-sync-event
|
|||
import { Wordset } from "../wordset";
|
||||
import * as LayoutfluidFunboxTimer from "./layoutfluid-funbox-timer";
|
||||
import * as DDR from "../../utils/ddr";
|
||||
<<<<<<< HEAD
|
||||
import * as Random from "../../utils/random";
|
||||
import { HighlightMode, Mode } from "@monkeytype/shared-types/config";
|
||||
=======
|
||||
import { HighlightMode } from "@monkeytype/contracts/schemas/configs";
|
||||
import { Mode } from "@monkeytype/contracts/schemas/shared";
|
||||
>>>>>>> master
|
||||
|
||||
const prefixSize = 2;
|
||||
|
||||
|
@ -476,13 +468,8 @@ FunboxList.setFunboxFunctions("IPv4", {
|
|||
},
|
||||
punctuateWord(word: string): string {
|
||||
let w = word;
|
||||
<<<<<<< HEAD
|
||||
if (Random.get() < 0.25) {
|
||||
w = IPGenerator.addressToCIDR(word);
|
||||
=======
|
||||
if (Math.random() < 0.25) {
|
||||
w = IPAddresses.addressToCIDR(word);
|
||||
>>>>>>> master
|
||||
}
|
||||
return w;
|
||||
},
|
||||
|
@ -497,13 +484,8 @@ FunboxList.setFunboxFunctions("IPv6", {
|
|||
},
|
||||
punctuateWord(word: string): string {
|
||||
let w = word;
|
||||
<<<<<<< HEAD
|
||||
if (Random.get() < 0.25) {
|
||||
w = IPGenerator.addressToCIDR(word);
|
||||
=======
|
||||
if (Math.random() < 0.25) {
|
||||
w = IPAddresses.addressToCIDR(word);
|
||||
>>>>>>> master
|
||||
}
|
||||
// Compress
|
||||
if (w.includes(":")) {
|
||||
|
|
|
@ -236,7 +236,6 @@ export async function update(expectedStepEnd: number): Promise<void> {
|
|||
word.offsetTop +
|
||||
currentLetter.offsetTop -
|
||||
Config.fontSize * Numbers.convertRemToPixels(1) * 0.1;
|
||||
newLeft;
|
||||
if (settings.currentLetterIndex === -1) {
|
||||
newLeft =
|
||||
word.offsetLeft +
|
||||
|
|
|
@ -783,7 +783,7 @@ export function updateRateQuote(randomQuote: MonkeyTypes.Quote | null): void {
|
|||
quoteStats?.average?.toFixed(1) ?? ""
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
.catch((e: unknown) => {
|
||||
$(".pageTest #result #rateQuoteButton .rating").text("?");
|
||||
});
|
||||
$(".pageTest #result #rateQuoteButton")
|
||||
|
@ -1050,7 +1050,7 @@ export function updateTagsAfterEdit(
|
|||
|
||||
tagIds.forEach((tag, index) => {
|
||||
if (checked.includes(tag)) return;
|
||||
if (tagPbIds.includes(tag) as boolean) {
|
||||
if (tagPbIds.includes(tag)) {
|
||||
html += `<div tagid="${tag}" data-balloon-pos="up">${tagNames[index]}<i class="fas fa-crown"></i></div>`;
|
||||
} else {
|
||||
html += `<div tagid="${tag}">${tagNames[index]}</div>`;
|
||||
|
|
|
@ -414,7 +414,7 @@ export function pushMissedWord(word: string): void {
|
|||
if (!Object.keys(missedWords).includes(word)) {
|
||||
missedWords[word] = 1;
|
||||
} else {
|
||||
missedWords[word]++;
|
||||
(missedWords[word] as number)++;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -155,7 +155,7 @@ ConfigEvent.subscribe((eventKey, eventValue, nosave) => {
|
|||
void updateWordsInputPosition(true);
|
||||
}
|
||||
if (eventKey === "fontSize" || eventKey === "fontFamily")
|
||||
updateHintsPosition().catch((e) => {
|
||||
updateHintsPosition().catch((e: unknown) => {
|
||||
console.error(e);
|
||||
});
|
||||
|
||||
|
@ -167,9 +167,13 @@ ConfigEvent.subscribe((eventKey, eventValue, nosave) => {
|
|||
}
|
||||
|
||||
if (
|
||||
["highlightMode", "blindMode", "indicateTypos", "tapeMode"].includes(
|
||||
eventKey
|
||||
)
|
||||
[
|
||||
"highlightMode",
|
||||
"blindMode",
|
||||
"indicateTypos",
|
||||
"tapeMode",
|
||||
"hideExtraLetters",
|
||||
].includes(eventKey)
|
||||
) {
|
||||
updateWordWrapperClasses();
|
||||
}
|
||||
|
@ -369,6 +373,14 @@ function updateWordWrapperClasses(): void {
|
|||
$("#wordsWrapper").removeClass("indicateTyposBelow");
|
||||
}
|
||||
|
||||
if (Config.hideExtraLetters) {
|
||||
$("#words").addClass("hideExtraLetters");
|
||||
$("#wordsWrapper").addClass("hideExtraLetters");
|
||||
} else {
|
||||
$("#words").removeClass("hideExtraLetters");
|
||||
$("#wordsWrapper").removeClass("hideExtraLetters");
|
||||
}
|
||||
|
||||
const existing =
|
||||
$("#words")
|
||||
?.attr("class")
|
||||
|
@ -864,13 +876,11 @@ export async function updateWordElement(inputOverride?: string): Promise<void> {
|
|||
: currentLetter
|
||||
}</letter>`;
|
||||
} else if (currentLetter === undefined) {
|
||||
if (!Config.hideExtraLetters) {
|
||||
let letter = input[i];
|
||||
if (letter === " " || letter === "\t" || letter === "\n") {
|
||||
letter = "_";
|
||||
}
|
||||
ret += `<letter class="incorrect extra ${tabChar}${nlChar}">${letter}</letter>`;
|
||||
let letter = input[i];
|
||||
if (letter === " " || letter === "\t" || letter === "\n") {
|
||||
letter = "_";
|
||||
}
|
||||
ret += `<letter class="incorrect extra ${tabChar}${nlChar}">${letter}</letter>`;
|
||||
} else {
|
||||
ret +=
|
||||
`<letter class="incorrect ${tabChar}${nlChar}">` +
|
||||
|
@ -903,7 +913,7 @@ export async function updateWordElement(inputOverride?: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
if (Config.highlightMode === "letter" && Config.hideExtraLetters) {
|
||||
if (Config.highlightMode === "letter") {
|
||||
if (input.length > currentWord.length && !Config.blindMode) {
|
||||
wordAtIndex.classList.add("error");
|
||||
} else if (input.length === currentWord.length) {
|
||||
|
@ -964,7 +974,12 @@ export function scrollTape(): void {
|
|||
if (!letters) return;
|
||||
for (let i = 0; i < TestInput.input.current.length; i++) {
|
||||
const letter = letters[i] as HTMLElement;
|
||||
if (Config.blindMode && letter.classList.contains("extra")) continue;
|
||||
if (
|
||||
(Config.blindMode || Config.hideExtraLetters) &&
|
||||
letter.classList.contains("extra")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
currentWordWidth += $(letter).outerWidth(true) ?? 0;
|
||||
}
|
||||
}
|
||||
|
|
4
frontend/src/ts/types/types.d.ts
vendored
4
frontend/src/ts/types/types.d.ts
vendored
|
@ -199,13 +199,13 @@ declare namespace MonkeyTypes {
|
|||
|
||||
type LeaderboardMemory = {
|
||||
time: {
|
||||
[key in "15" | "60"]: Record<string, number>;
|
||||
[_key in "15" | "60"]: Record<string, number>;
|
||||
};
|
||||
};
|
||||
|
||||
type Leaderboards = {
|
||||
time: {
|
||||
[key in 15 | 60]: import("@monkeytype/shared-types").LeaderboardEntry[];
|
||||
[_key in 15 | 60]: import("@monkeytype/shared-types").LeaderboardEntry[];
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -108,6 +108,7 @@ export function nthElementFromArray<T>(
|
|||
*/
|
||||
export function intersect<T>(a: T[], b: T[], removeDuplicates = false): T[] {
|
||||
let t;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
if (b.length > a.length) (t = b), (b = a), (a = t); // indexOf to loop over shorter
|
||||
const filtered = a.filter(function (e) {
|
||||
return b.includes(e);
|
||||
|
|
|
@ -36,7 +36,7 @@ function getIPCidr(
|
|||
bitsLeft -= b;
|
||||
if (bitsLeft < 0) {
|
||||
if (-bitsLeft <= b) {
|
||||
addr[i] &= (2 ** b - 1) ^ (2 ** -bitsLeft - 1);
|
||||
(addr[i] as number) &= (2 ** b - 1) ^ (2 ** -bitsLeft - 1);
|
||||
} else {
|
||||
addr[i] = 0;
|
||||
}
|
||||
|
|
|
@ -481,13 +481,16 @@ export function createErrorMessage(error: unknown, message: string): string {
|
|||
return `${message}: ${error.message}`;
|
||||
}
|
||||
|
||||
const objectWithMessage = error as { message?: string };
|
||||
if (error instanceof Object && "message" in error) {
|
||||
const objectWithMessage = error as { message?: string };
|
||||
|
||||
if (objectWithMessage?.message !== undefined) {
|
||||
return `${message}: ${objectWithMessage.message}`;
|
||||
if (objectWithMessage?.message !== undefined) {
|
||||
return `${message}: ${objectWithMessage.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
console.error("Unknown error", error);
|
||||
return `${message}: Unknown error`;
|
||||
}
|
||||
|
||||
export function isElementVisible(query: string): boolean {
|
||||
|
|
|
@ -98,7 +98,7 @@ export const buildSearchService = <T>(
|
|||
internalDocument.termFrequencies[stemmedToken] = 0;
|
||||
}
|
||||
|
||||
internalDocument.termFrequencies[stemmedToken]++;
|
||||
(internalDocument.termFrequencies[stemmedToken] as number)++;
|
||||
maxTermFrequency = Math.max(
|
||||
maxTermFrequency,
|
||||
internalDocument.termFrequencies[stemmedToken] as number
|
||||
|
|
|
@ -219,7 +219,7 @@ export function loadChallengeFromUrl(getOverride?: string): void {
|
|||
});
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
.catch((e: unknown) => {
|
||||
Notifications.add("Failed to load challenge", -1);
|
||||
console.error(e);
|
||||
});
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"name": "thai",
|
||||
"noLazyMode": true,
|
||||
"ligatures": true,
|
||||
"bcp47": "th-TH",
|
||||
"words": [
|
||||
"ที่",
|
||||
|
|
|
@ -2024,5 +2024,16 @@
|
|||
"row4": ["bB", "gG", "'\"", "wW", "zZ", "/?", ",<", "qQ", "jJ", "kK"],
|
||||
"row5": [" "]
|
||||
}
|
||||
},
|
||||
"romak": {
|
||||
"keymapShowTopRow": false,
|
||||
"type": "ansi",
|
||||
"keys": {
|
||||
"row1": ["`~", "1!", "2@", "3#", "4$", "5%", "6^", "7&", "8*", "9(", "0)", "-_", "=+"],
|
||||
"row2": ["qQ", "bB", "mM", "gG", "kK", "xX", "lL", "oO", "uU", "yY", "[{", "]}", "\\|"],
|
||||
"row3": ["dD", "nN", "sS", "tT", "wW", "zZ", "rR", "aA", "eE", "iI", ";:"],
|
||||
"row4": ["'\"", "fF", "cC", "pP", "vV", "jJ", "hH", ",<", ".>", "/?"],
|
||||
"row5": [" "]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
"length": 63
|
||||
},
|
||||
{
|
||||
"text": "static int factorial(int n)\n{\n\tint result = 1;\n\tfor (int i = 1; i <= n; i++)\n\t{\n\t\tresult = result * i;\n\t}\n\treturn result;\n}",
|
||||
"text": "static int Factorial(int n)\n{\n\tint result = 1;\n\tfor (int i = 1; i <= n; i++)\n\t{\n\t\tresult = result * i;\n\t}\n\treturn result;\n}",
|
||||
"id": 2,
|
||||
"source": "Geeks for Geeks - C# Methods",
|
||||
"length": 123
|
||||
|
|
|
@ -8060,10 +8060,10 @@
|
|||
"length": 489
|
||||
},
|
||||
{
|
||||
"text": "Let's go in the garden. You'll see something waiting, right there where you left it, lying upside down. When you finally find it you'll see how it's faded. The underside is lighter when you turn it around.",
|
||||
"text": "Let's go in the garden. You'll find something waiting, right there where you left it, lying upside down. When you finally find it you'll see how it's faded. The underside is lighter when you turn it around.",
|
||||
"source": "Everything Stays",
|
||||
"id": 1414,
|
||||
"length": 205
|
||||
"length": 206
|
||||
},
|
||||
{
|
||||
"text": "When you lose control, you'll reap the harvest you have sown. And as the fear grows, the bad blood slows and turns to stone. And it's too late to lose the weight you used to need to throw around. So have a good drown, as you go down, all alone... Dragged down by the stone.",
|
||||
|
@ -29734,12 +29734,6 @@
|
|||
"length": 318,
|
||||
"id": 5358
|
||||
},
|
||||
{
|
||||
"text": "The whole world's gone mad. They claim to be my dad's best friend, and want to help me with my revenge. Bizarre, isn't it? I'm handed everything, even the names of the men who stole the people I loved...",
|
||||
"source": "91 days",
|
||||
"length": 203,
|
||||
"id": 5359
|
||||
},
|
||||
{
|
||||
"text": "It is nice to be important, but it's more important to be nice.",
|
||||
"source": "Roger Federer",
|
||||
|
@ -31469,12 +31463,6 @@
|
|||
"length": 256,
|
||||
"id": 5720
|
||||
},
|
||||
{
|
||||
"text": "When I was younger, I left a trail of broken hearts like a rockstar. I'm not proud of it.",
|
||||
"source": "Modern Family",
|
||||
"length": 89,
|
||||
"id": 5722
|
||||
},
|
||||
{
|
||||
"text": "Mistakes are always forgivable, if one has the courage to admit them.",
|
||||
"source": "Bruce Lee",
|
||||
|
@ -31674,7 +31662,7 @@
|
|||
"id": 5760
|
||||
},
|
||||
{
|
||||
"text": "My worst breakup was with Stacy. It was a Sunday morning, we were reading the paper, and I said, \"Oh my God, I think the Eagles could clinch the NFC East!\" and she said, \"We're done\".",
|
||||
"text": "My worst breakup was with Stacy. It was a Sunday morning, we were reading the paper, and I said, \"Oh my God, I think the Eagles could clinch the NFC East!\" and she said, \"We're done.\"",
|
||||
"source": "The Office",
|
||||
"length": 183,
|
||||
"id": 5761
|
||||
|
|
|
@ -307,12 +307,6 @@
|
|||
"length": 118,
|
||||
"id": 53
|
||||
},
|
||||
{
|
||||
"text": "Jatuh hati tidak pernah bisa memilih. Tuhan memilihkan. Kita hanyalah korban. Kecewa adalah konsekuensi, bahagia adalah bonus.",
|
||||
"source": "Fiersa Besari",
|
||||
"length": 126,
|
||||
"id": 54
|
||||
},
|
||||
{
|
||||
"text": "Jadilah kamu manusia yang pada kelahiranmu semua orang tertawa bahagia, tetapi hanya kamu sendiri yang menangis dan pada kematianmu semua orang menangis sedih, tetapi hanya kamu sendiri yang tersenyum.",
|
||||
"source": "Mahatma Gandhi",
|
||||
|
|
|
@ -2308,8 +2308,8 @@
|
|||
{
|
||||
"id": 403,
|
||||
"source": "Лосев Лев - Иосиф Бродский",
|
||||
"text": "Создание стихотворения - всегда катартический опыт, его хочется продлить. Неопубликованные, стихи словно бы не окончены, а публикация - расставание навсегда.",
|
||||
"length": 157
|
||||
"text": "Создание стихотворения - всегда катартический опыт, его хочется продлить. Неопубликованные стихи словно бы не окончены, а публикация - расставание навсегда.",
|
||||
"length": 156
|
||||
},
|
||||
{
|
||||
"id": 404,
|
||||
|
|
|
@ -4,6 +4,7 @@ export default defineConfig({
|
|||
test: {
|
||||
globals: true,
|
||||
environment: "happy-dom",
|
||||
globalSetup: "__tests__/global-setup.ts",
|
||||
setupFiles: ["__tests__/setup-tests.ts"],
|
||||
|
||||
coverage: {
|
||||
|
|
|
@ -38,7 +38,9 @@
|
|||
"**/dist/**": true,
|
||||
"**/public/**": true,
|
||||
"**/coverage/**": true,
|
||||
"**/logs/**": true
|
||||
"**/logs/**": true,
|
||||
"**/.firebase/**": true,
|
||||
"**/.turbo/**": true
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSaveMode": "file",
|
||||
|
|
21
package.json
21
package.json
|
@ -29,12 +29,13 @@
|
|||
"start-fe": "turbo run start --filter @monkeytype/frontend",
|
||||
"docker": "cd backend && docker compose up",
|
||||
"audit-fe": "cd frontend && npm run audit",
|
||||
"release": "node ./bin/release.mjs",
|
||||
"release-fe": "node ./bin/release.mjs --fe",
|
||||
"release-be": "node ./bin/release.mjs --be",
|
||||
"release-no-deploy": "node ./bin/release.mjs --no-deploy",
|
||||
"release-dry": "node ./bin/release.mjs --dry",
|
||||
"hotfix": "npm run build-fe && cd frontend && npm run deploy-live && cd .. && sh ./bin/purgeCfCache.sh",
|
||||
"release": "monkeytype-release",
|
||||
"release-fe": "monkeytype-release --fe",
|
||||
"release-be": "monkeytype-release --be",
|
||||
"release-no-deploy": "monkeytype-release --no-deploy",
|
||||
"release-dry": "monkeytype-release --dry",
|
||||
"hotfix-fe": "npm run build-fe && cd frontend && npm run deploy-live && monkeytype-purge",
|
||||
"hotfix-be": "monkeytype-deploy-be",
|
||||
"deploy-dev": "cd frontend && npm run deploy-dev && cd .. && sh ./bin/purgeCfCache.sh",
|
||||
"pretty-check": "prettier --check .",
|
||||
"pretty-check-be": "prettier --check ./backend",
|
||||
|
@ -55,10 +56,9 @@
|
|||
"node": "20.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@monkeytype/release": "workspace:*",
|
||||
"@commitlint/cli": "17.7.1",
|
||||
"@commitlint/config-conventional": "17.7.0",
|
||||
"@typescript-eslint/eslint-plugin": "7.1.0",
|
||||
"@typescript-eslint/parser": "7.1.0",
|
||||
"conventional-changelog": "4.0.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-prettier": "9.0.0",
|
||||
|
@ -71,9 +71,8 @@
|
|||
"lint-staged": "13.2.3",
|
||||
"only-allow": "1.2.1",
|
||||
"prettier": "2.5.1",
|
||||
"readline-sync": "1.4.10",
|
||||
"turbo": "2.0.9",
|
||||
"typescript": "5.3.3",
|
||||
"typescript": "5.5.4",
|
||||
"wait-for-localhost-cli": "3.2.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
|
@ -85,6 +84,6 @@
|
|||
"eslint"
|
||||
]
|
||||
},
|
||||
"version": "24.31.3",
|
||||
"version": "24.32.0",
|
||||
"packageManager": "pnpm@9.6.0"
|
||||
}
|
||||
|
|
|
@ -19,8 +19,8 @@
|
|||
"esbuild": "0.23.0",
|
||||
"eslint": "8.57.0",
|
||||
"madge": "6.1.0",
|
||||
"rimraf": "5.0.9",
|
||||
"typescript": "5.5.3"
|
||||
"rimraf": "6.0.1",
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
|
|
117
packages/contracts/src/admin.ts
Normal file
117
packages/contracts/src/admin.ts
Normal file
|
@ -0,0 +1,117 @@
|
|||
import { initContract } from "@ts-rest/core";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
CommonResponses,
|
||||
EndpointMetadata,
|
||||
MonkeyResponseSchema,
|
||||
responseWithData,
|
||||
} from "./schemas/api";
|
||||
import { IdSchema } from "./schemas/util";
|
||||
|
||||
export const ToggleBanRequestSchema = z
|
||||
.object({
|
||||
uid: IdSchema,
|
||||
})
|
||||
.strict();
|
||||
export type ToggleBanRequest = z.infer<typeof ToggleBanRequestSchema>;
|
||||
|
||||
export const ToggleBanResponseSchema = responseWithData(
|
||||
z.object({
|
||||
banned: z.boolean(),
|
||||
})
|
||||
).strict();
|
||||
export type ToggleBanResponse = z.infer<typeof ToggleBanResponseSchema>;
|
||||
|
||||
export const AcceptReportsRequestSchema = z
|
||||
.object({
|
||||
reports: z.array(z.object({ reportId: z.string() }).strict()).nonempty(),
|
||||
})
|
||||
.strict();
|
||||
export type AcceptReportsRequest = z.infer<typeof AcceptReportsRequestSchema>;
|
||||
|
||||
export const RejectReportsRequestSchema = z
|
||||
.object({
|
||||
reports: z
|
||||
.array(
|
||||
z
|
||||
.object({ reportId: z.string(), reason: z.string().optional() })
|
||||
.strict()
|
||||
)
|
||||
.nonempty(),
|
||||
})
|
||||
.strict();
|
||||
export type RejectReportsRequest = z.infer<typeof RejectReportsRequestSchema>;
|
||||
|
||||
export const SendForgotPasswordEmailRequestSchema = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
})
|
||||
.strict();
|
||||
export type SendForgotPasswordEmailRequest = z.infer<
|
||||
typeof SendForgotPasswordEmailRequestSchema
|
||||
>;
|
||||
|
||||
const c = initContract();
|
||||
export const adminContract = c.router(
|
||||
{
|
||||
test: {
|
||||
summary: "test permission",
|
||||
description: "Check for admin permission for the current user",
|
||||
method: "GET",
|
||||
path: "",
|
||||
responses: {
|
||||
200: MonkeyResponseSchema,
|
||||
},
|
||||
},
|
||||
toggleBan: {
|
||||
summary: "toggle user ban",
|
||||
description: "Ban an unbanned user or unban a banned user.",
|
||||
method: "POST",
|
||||
path: "/toggleBan",
|
||||
body: ToggleBanRequestSchema,
|
||||
responses: {
|
||||
200: ToggleBanResponseSchema,
|
||||
},
|
||||
},
|
||||
acceptReports: {
|
||||
summary: "accept reports",
|
||||
description: "Accept one or many reports",
|
||||
method: "POST",
|
||||
path: "/report/accept",
|
||||
body: AcceptReportsRequestSchema,
|
||||
responses: {
|
||||
200: MonkeyResponseSchema,
|
||||
},
|
||||
},
|
||||
rejectReports: {
|
||||
summary: "reject reports",
|
||||
description: "Reject one or many reports",
|
||||
method: "POST",
|
||||
path: "/report/reject",
|
||||
body: RejectReportsRequestSchema,
|
||||
responses: {
|
||||
200: MonkeyResponseSchema,
|
||||
},
|
||||
},
|
||||
sendForgotPasswordEmail: {
|
||||
summary: "send forgot password email",
|
||||
description: "Send a forgot password email to the given user email",
|
||||
method: "POST",
|
||||
path: "/sendForgotPasswordEmail",
|
||||
body: SendForgotPasswordEmailRequestSchema,
|
||||
responses: {
|
||||
200: MonkeyResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pathPrefix: "/admin",
|
||||
strictStatusCodes: true,
|
||||
metadata: {
|
||||
openApiTags: "admin",
|
||||
authenticationOptions: { noCache: true },
|
||||
} as EndpointMetadata,
|
||||
|
||||
commonResponses: CommonResponses,
|
||||
}
|
||||
);
|
|
@ -46,7 +46,7 @@ export const apeKeysContract = c.router(
|
|||
summary: "get ape keys",
|
||||
description: "Get ape keys of the current user.",
|
||||
method: "GET",
|
||||
path: "/",
|
||||
path: "",
|
||||
responses: {
|
||||
200: GetApeKeyResponseSchema,
|
||||
},
|
||||
|
@ -55,7 +55,7 @@ export const apeKeysContract = c.router(
|
|||
summary: "add ape key",
|
||||
description: "Add an ape key for the current user.",
|
||||
method: "POST",
|
||||
path: "/",
|
||||
path: "",
|
||||
body: AddApeKeyRequestSchema.strict(),
|
||||
responses: {
|
||||
200: AddApeKeyResponseSchema,
|
||||
|
|
|
@ -22,14 +22,14 @@ export const configsContract = c.router(
|
|||
summary: "get config",
|
||||
description: "Get config of the current user.",
|
||||
method: "GET",
|
||||
path: "/",
|
||||
path: "",
|
||||
responses: {
|
||||
200: GetConfigResponseSchema,
|
||||
},
|
||||
},
|
||||
save: {
|
||||
method: "PATCH",
|
||||
path: "/",
|
||||
path: "",
|
||||
body: PartialConfigSchema.strict(),
|
||||
responses: {
|
||||
200: MonkeyResponseSchema,
|
||||
|
@ -40,7 +40,7 @@ export const configsContract = c.router(
|
|||
},
|
||||
delete: {
|
||||
method: "DELETE",
|
||||
path: "/",
|
||||
path: "",
|
||||
body: c.noBody(),
|
||||
responses: {
|
||||
200: MonkeyResponseSchema,
|
||||
|
|
|
@ -1,12 +1,16 @@
|
|||
import { initContract } from "@ts-rest/core";
|
||||
import { adminContract } from "./admin";
|
||||
import { apeKeysContract } from "./ape-keys";
|
||||
import { configsContract } from "./configs";
|
||||
import { presetsContract } from "./presets";
|
||||
import { apeKeysContract } from "./ape-keys";
|
||||
import { psasContract } from "./psas";
|
||||
|
||||
const c = initContract();
|
||||
|
||||
export const contract = c.router({
|
||||
admin: adminContract,
|
||||
apeKeys: apeKeysContract,
|
||||
configs: configsContract,
|
||||
presets: presetsContract,
|
||||
psas: psasContract,
|
||||
});
|
||||
|
|
|
@ -34,7 +34,7 @@ export const presetsContract = c.router(
|
|||
summary: "get presets",
|
||||
description: "Get presets of the current user.",
|
||||
method: "GET",
|
||||
path: "/",
|
||||
path: "",
|
||||
responses: {
|
||||
200: GetPresetResponseSchema,
|
||||
},
|
||||
|
@ -43,7 +43,7 @@ export const presetsContract = c.router(
|
|||
summary: "add preset",
|
||||
description: "Add a new preset for the current user.",
|
||||
method: "POST",
|
||||
path: "/",
|
||||
path: "",
|
||||
body: AddPresetRequestSchema.strict(),
|
||||
responses: {
|
||||
200: AddPresetResponseSchemna,
|
||||
|
@ -53,7 +53,7 @@ export const presetsContract = c.router(
|
|||
summary: "update preset",
|
||||
description: "Update an existing preset for the current user.",
|
||||
method: "PATCH",
|
||||
path: "/",
|
||||
path: "",
|
||||
body: PresetSchema.strict(),
|
||||
responses: {
|
||||
200: MonkeyResponseSchema,
|
||||
|
|
37
packages/contracts/src/psas.ts
Normal file
37
packages/contracts/src/psas.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { initContract } from "@ts-rest/core";
|
||||
import { z } from "zod";
|
||||
import { PSASchema } from "./schemas/psas";
|
||||
|
||||
import {
|
||||
CommonResponses,
|
||||
EndpointMetadata,
|
||||
responseWithData,
|
||||
} from "./schemas/api";
|
||||
export const GetPsaResponseSchema = responseWithData(z.array(PSASchema));
|
||||
export type GetPsaResponse = z.infer<typeof GetPsaResponseSchema>;
|
||||
|
||||
const c = initContract();
|
||||
export const psasContract = c.router(
|
||||
{
|
||||
get: {
|
||||
summary: "get psas",
|
||||
description: "Get list of public service announcements",
|
||||
method: "GET",
|
||||
path: "",
|
||||
responses: {
|
||||
200: GetPsaResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pathPrefix: "/psas",
|
||||
strictStatusCodes: true,
|
||||
metadata: {
|
||||
openApiTags: "psas",
|
||||
authenticationOptions: {
|
||||
isPublic: true,
|
||||
},
|
||||
} as EndpointMetadata,
|
||||
commonResponses: CommonResponses,
|
||||
}
|
||||
);
|
|
@ -1,6 +1,6 @@
|
|||
import { z, ZodSchema } from "zod";
|
||||
|
||||
export type OpenApiTag = "configs" | "presets" | "ape-keys";
|
||||
export type OpenApiTag = "configs" | "presets" | "ape-keys" | "admin" | "psas";
|
||||
|
||||
export type EndpointMetadata = {
|
||||
/** Authentication options, by default a bearer token is required. */
|
||||
|
@ -74,4 +74,7 @@ export const CommonResponses = {
|
|||
422: MonkeyValidationErrorSchema.describe("Request validation failed"),
|
||||
429: MonkeyClientError.describe("Rate limit exceeded"),
|
||||
500: MonkeyServerError.describe("Generic server error"),
|
||||
503: MonkeyServerError.describe(
|
||||
"Endpoint disabled or server is under maintenance"
|
||||
),
|
||||
};
|
||||
|
|
11
packages/contracts/src/schemas/psas.ts
Normal file
11
packages/contracts/src/schemas/psas.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { z } from "zod";
|
||||
import { IdSchema } from "./util";
|
||||
|
||||
export const PSASchema = z.object({
|
||||
_id: IdSchema,
|
||||
message: z.string(),
|
||||
date: z.number().int().min(0).optional(),
|
||||
level: z.number().int().optional(),
|
||||
sticky: z.boolean().optional(),
|
||||
});
|
||||
export type PSA = z.infer<typeof PSASchema>;
|
|
@ -31,6 +31,14 @@ module.exports = {
|
|||
"no-duplicate-imports": ["error"],
|
||||
"no-constant-condition": ["error"],
|
||||
"no-constant-binary-expression": "error",
|
||||
"no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
argsIgnorePattern: "^(_|e|event)",
|
||||
caughtErrorsIgnorePattern: "^(_|e|error)",
|
||||
varsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"import/no-duplicates": "off",
|
||||
"import/no-unresolved": [
|
||||
"error",
|
||||
|
@ -63,7 +71,7 @@ module.exports = {
|
|||
"@typescript-eslint/require-await": "off",
|
||||
"@typescript-eslint/unbound-method": "off",
|
||||
"@typescript-eslint/await-thenable": "off",
|
||||
"@typescript-eslint/no-useless-template-literals": "off",
|
||||
"@typescript-eslint/no-unnecessary-template-expression": "off",
|
||||
"@typescript-eslint/prefer-promise-reject-errors": "off",
|
||||
"@typescript-eslint/no-this-alias": "off",
|
||||
"@typescript-eslint/no-unnecessary-type-arguments": "off",
|
||||
|
@ -88,7 +96,17 @@ module.exports = {
|
|||
"@typescript-eslint/no-empty-function": "warn",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^(_|e|event)", varsIgnorePattern: "^_" },
|
||||
{
|
||||
argsIgnorePattern: "^(_|e|event)",
|
||||
caughtErrorsIgnorePattern: "^(_|e|error)",
|
||||
varsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-unused-expressions": [
|
||||
"error",
|
||||
{
|
||||
allowTernary: true,
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-var-requires": "error",
|
||||
"@typescript-eslint/no-this-alias": "off",
|
||||
|
|
|
@ -2,8 +2,8 @@
|
|||
"name": "@monkeytype/eslint-config",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "7.1.0",
|
||||
"@typescript-eslint/parser": "7.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.0.1",
|
||||
"@typescript-eslint/parser": "8.0.1",
|
||||
"eslint-config-prettier": "9.1.0"
|
||||
}
|
||||
}
|
||||
|
|
5
packages/release/.eslintrc.cjs
Normal file
5
packages/release/.eslintrc.cjs
Normal file
|
@ -0,0 +1,5 @@
|
|||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@monkeytype/eslint-config"],
|
||||
};
|
|
@ -1,6 +1,11 @@
|
|||
#!/bin/bash
|
||||
|
||||
source .env
|
||||
# Determine the directory of the script
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Source the .env file from the parent directory of the script's directory
|
||||
source "$SCRIPT_DIR/../.env"
|
||||
|
||||
echo "Deploying backend to $BE_HOST with script $BE_SCRIPT_PATH"
|
||||
|
||||
# Connect to SSH and execute remote script
|
|
@ -1,5 +1,11 @@
|
|||
#!/bin/bash
|
||||
source .env
|
||||
|
||||
# Determine the directory of the script
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Source the .env file from the parent directory of the script's directory
|
||||
source "$SCRIPT_DIR/../.env"
|
||||
|
||||
echo "Purging Cloudflare cache for zone $CF_ZONE_ID"
|
||||
response=$(curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \
|
||||
-H "Authorization: Bearer $CF_API_KEY" \
|
26
packages/release/package.json
Normal file
26
packages/release/package.json
Normal file
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "@monkeytype/release",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "nodemon --watch src --exec 'node ./src/index.js --dry'",
|
||||
"dev-changelog": "nodemon ./src/buildChangelog.js",
|
||||
"lint": "eslint \"./**/*.js\"",
|
||||
"purge-cf-cache": "./bin/purgeCfCache.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@monkeytype/eslint-config": "workspace:*",
|
||||
"eslint": "8.57.0",
|
||||
"nodemon": "3.1.4"
|
||||
},
|
||||
"bin": {
|
||||
"monkeytype-release": "./src/index.js",
|
||||
"monkeytype-purge": "./bin/purgeCfCache.sh",
|
||||
"monkeytype-deploy-be": "./bin/deployBackend.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@octokit/rest": "20.1.1",
|
||||
"dotenv": "16.4.5",
|
||||
"readline-sync": "1.4.10"
|
||||
}
|
||||
}
|
|
@ -1,4 +1,3 @@
|
|||
import conventionalChangelog from "conventional-changelog";
|
||||
import { exec } from "child_process";
|
||||
|
||||
// const stream = conventionalChangelog(
|
||||
|
@ -41,7 +40,7 @@ async function getLog() {
|
|||
return new Promise((resolve, reject) => {
|
||||
exec(
|
||||
`git log --oneline $(git describe --tags --abbrev=0 @^)..@ --pretty="format:${lineDelimiter}%H${logDelimiter}%h${logDelimiter}%s${logDelimiter}%b"`,
|
||||
(err, stdout, stderr) => {
|
||||
(err, stdout, _stderr) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
|
@ -250,7 +249,7 @@ function convertStringToLog(logString) {
|
|||
|
||||
//split message using regex based on fix(language): spelling mistakes in Nepali wordlist and quotes (sapradhan) (#4528)
|
||||
//scope is optional, username is optional, pr number is optional
|
||||
const [__, type, scope, message, message2, message3] = title.split(
|
||||
const [_, type, scope, message, message2, message3] = title.split(
|
||||
/^(\w+)(?:\(([^)]+)\))?:\s+(.+?)\s*(?:\(([^)]+)\))?(?:\s+\(([^)]+)\))?(?:\s+\(([^)]+)\))?$/
|
||||
);
|
||||
|
61
bin/release.mjs → packages/release/src/index.js
Normal file → Executable file
61
bin/release.mjs → packages/release/src/index.js
Normal file → Executable file
|
@ -1,12 +1,10 @@
|
|||
import { execSync } from "child_process";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import dotenv from "dotenv";
|
||||
import { readFileSync } from "fs";
|
||||
import fs, { readFileSync } from "fs";
|
||||
import readlineSync from "readline-sync";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import path, { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname } from "path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
@ -18,6 +16,9 @@ const isFrontend = args.includes("--fe");
|
|||
const noDeploy = args.includes("--no-deploy");
|
||||
const isBackend = args.includes("--be");
|
||||
const isDryRun = args.includes("--dry");
|
||||
const noSyncCheck = args.includes("--no-sync-check");
|
||||
|
||||
const PROJECT_ROOT = path.resolve(__dirname, "../../../");
|
||||
|
||||
const runCommand = (command, force) => {
|
||||
if (isDryRun && !force) {
|
||||
|
@ -35,19 +36,41 @@ const runCommand = (command, force) => {
|
|||
}
|
||||
};
|
||||
|
||||
const runProjectRootCommand = (command, force) => {
|
||||
if (isDryRun && !force) {
|
||||
console.log(`[Dry Run] Command: ${command}`);
|
||||
return "[Dry Run] Command executed.";
|
||||
} else {
|
||||
try {
|
||||
const output = execSync(`cd ${PROJECT_ROOT} && ${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) {
|
||||
if (noSyncCheck) {
|
||||
console.log("Skipping sync check.");
|
||||
} else if (isDryRun) {
|
||||
console.log("[Dry Run] Checking sync...");
|
||||
} else {
|
||||
try {
|
||||
// Fetch the latest changes from the remote repository
|
||||
runCommand("git fetch origin");
|
||||
runProjectRootCommand("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();
|
||||
const localMaster = runProjectRootCommand("git rev-parse master").trim();
|
||||
const remoteMaster = runProjectRootCommand(
|
||||
"git rev-parse origin/master"
|
||||
).trim();
|
||||
|
||||
if (localMaster !== remoteMaster) {
|
||||
console.error(
|
||||
|
@ -65,8 +88,12 @@ const checkBranchSync = () => {
|
|||
|
||||
const getCurrentVersion = () => {
|
||||
console.log("Getting current version...");
|
||||
const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
|
||||
return packageJson.version;
|
||||
|
||||
const rootPackageJson = JSON.parse(
|
||||
readFileSync(`${PROJECT_ROOT}/package.json`, "utf-8")
|
||||
);
|
||||
|
||||
return rootPackageJson.version;
|
||||
};
|
||||
|
||||
const incrementVersion = (currentVersion) => {
|
||||
|
@ -134,28 +161,32 @@ const buildProject = () => {
|
|||
filter = "--filter @monkeytype/backend";
|
||||
}
|
||||
|
||||
runCommand("npx turbo lint test validate-json build " + filter);
|
||||
runProjectRootCommand("npx turbo lint test validate-json build " + filter);
|
||||
};
|
||||
|
||||
const deployBackend = () => {
|
||||
console.log("Deploying backend...");
|
||||
runCommand("sh ./bin/deployBackend.sh");
|
||||
runCommand("sh ../bin/deployBackend.sh");
|
||||
};
|
||||
|
||||
const deployFrontend = () => {
|
||||
console.log("Deploying frontend...");
|
||||
runCommand("cd frontend && npx firebase deploy -P live --only hosting");
|
||||
runProjectRootCommand(
|
||||
"cd frontend && npx firebase deploy -P live --only hosting"
|
||||
);
|
||||
};
|
||||
|
||||
const purgeCache = () => {
|
||||
console.log("Purging Cloudflare cache...");
|
||||
runCommand("sh ./bin/purgeCfCache.sh");
|
||||
runCommand("sh ../bin/purgeCfCache.sh");
|
||||
};
|
||||
|
||||
const generateChangelog = async () => {
|
||||
console.log("Generating changelog...");
|
||||
|
||||
const changelog = runCommand("node bin/buildChangelog.mjs", true);
|
||||
const p = path.resolve(__dirname, "./buildChangelog.js");
|
||||
|
||||
const changelog = runCommand(`node ${p}`, true);
|
||||
|
||||
return changelog;
|
||||
};
|
|
@ -13,8 +13,8 @@
|
|||
"devDependencies": {
|
||||
"@monkeytype/eslint-config": "workspace:*",
|
||||
"@monkeytype/typescript-config": "workspace:*",
|
||||
"rimraf": "5.0.9",
|
||||
"typescript": "5.5.3",
|
||||
"rimraf": "6.0.1",
|
||||
"typescript": "5.5.4",
|
||||
"eslint": "8.57.0"
|
||||
},
|
||||
"exports": {
|
||||
|
|
|
@ -301,14 +301,6 @@ export type ResultFilters = {
|
|||
} & Record<string, boolean>;
|
||||
};
|
||||
|
||||
export type PSA = {
|
||||
_id: string;
|
||||
message: string;
|
||||
sticky?: boolean;
|
||||
level?: number;
|
||||
date?: number;
|
||||
};
|
||||
|
||||
export type SpeedHistogram = {
|
||||
[key: string]: number;
|
||||
};
|
||||
|
|
919
pnpm-lock.yaml
919
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue