Merge branch 'master' into newtribemerge

This commit is contained in:
Miodec 2024-08-09 11:08:04 +02:00
commit 083c18b8b9
97 changed files with 2092 additions and 1085 deletions

View file

@ -2,3 +2,4 @@ backend/dist
backend/__migration__
docker
backend/scripts
**/vitest.config.js

View file

@ -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:

View file

@ -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

View 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
);
}

View file

@ -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(

View 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");
});
});
});

View 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);
});
});
});

View file

@ -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: {

View file

@ -649,5 +649,8 @@ describe("Misc Utils", () => {
},
]);
});
it("handles undefined", () => {
expect(misc.replaceObjectIds(undefined as any)).toBeUndefined();
});
});
});

View file

@ -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"
}

View file

@ -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

View file

@ -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",
},
],
},

View file

@ -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);
}

View file

@ -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));
}

View file

@ -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;

View file

@ -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."
);

View file

@ -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),
},
});

View file

@ -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 {

View file

@ -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),
},
});

View file

@ -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>

View file

@ -312,7 +312,6 @@ export const profanities = [
"feces",
"felcher",
"ficken",
"fitt",
"flikker",
"foreskin",
"fotze",

View file

@ -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 {

View file

@ -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();

View file

@ -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 = {

View file

@ -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"],

View file

@ -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 {

View file

@ -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 = {

View file

@ -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

View file

@ -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;
}
}
}

View file

@ -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));
}

View file

@ -0,0 +1,3 @@
export const setup = () => {
process.env.TZ = "UTC";
};

View file

@ -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"
}
}
}

View file

@ -227,6 +227,12 @@
}
}
&[data-config-name="paceCaret"] {
.buttons {
grid-template-columns: repeat(auto-fit, minmax(6rem, 1fr));
}
}
&.discordIntegration {
.info {
grid-area: buttons;

View file

@ -306,6 +306,14 @@
// }
}
&.hideExtraLetters {
.word {
& letter.extra {
display: none;
}
}
}
&.flipped {
--correct-letter-color: var(--sub-color);
--untyped-letter-color: var(--text-color);

View file

@ -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}`;
}

View file

@ -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,

View file

@ -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);
}
}

View file

@ -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),

View file

@ -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);
});
},

View file

@ -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");

View file

@ -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;
}

View file

@ -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);
});
}

View file

@ -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";

View file

@ -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);

View file

@ -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;
}

View file

@ -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": [],

View file

@ -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> {

View file

@ -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();

View file

@ -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(

View file

@ -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()) {

View file

@ -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");

View file

@ -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,

View file

@ -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(

View file

@ -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");

View file

@ -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;

View file

@ -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;

View file

@ -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

View file

@ -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(":")) {

View file

@ -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 +

View file

@ -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>`;

View file

@ -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)++;
}
}

View file

@ -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;
}
}

View file

@ -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[];
};
};

View file

@ -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);

View file

@ -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;
}

View file

@ -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 {

View file

@ -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

View file

@ -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);
});

View file

@ -1,6 +1,7 @@
{
"name": "thai",
"noLazyMode": true,
"ligatures": true,
"bcp47": "th-TH",
"words": [
"ที่",

View file

@ -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": [" "]
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -2308,8 +2308,8 @@
{
"id": 403,
"source": "Лосев Лев - Иосиф Бродский",
"text": "Создание стихотворения - всегда катартический опыт, его хочется продлить. Неопубликованные, стихи словно бы не окончены, а публикация - расставание навсегда.",
"length": 157
"text": "Создание стихотворения - всегда катартический опыт, его хочется продлить. Неопубликованные стихи словно бы не окончены, а публикация - расставание навсегда.",
"length": 156
},
{
"id": 404,

View file

@ -4,6 +4,7 @@ export default defineConfig({
test: {
globals: true,
environment: "happy-dom",
globalSetup: "__tests__/global-setup.ts",
setupFiles: ["__tests__/setup-tests.ts"],
coverage: {

View file

@ -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",

View file

@ -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"
}

View file

@ -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": {
".": {

View 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,
}
);

View file

@ -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,

View file

@ -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,

View file

@ -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,
});

View file

@ -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,

View 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,
}
);

View file

@ -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"
),
};

View 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>;

View file

@ -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",

View file

@ -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"
}
}

View file

@ -0,0 +1,5 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@monkeytype/eslint-config"],
};

View file

@ -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

View file

@ -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" \

View 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"
}
}

View file

@ -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
View 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;
};

View file

@ -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": {

View file

@ -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;
};

File diff suppressed because it is too large Load diff