diff --git a/.github/workflows/fix-formatting.yml b/.github/workflows/fix-formatting.yml index 6aeb8ab3fc8c..c930bd1fae7e 100644 --- a/.github/workflows/fix-formatting.yml +++ b/.github/workflows/fix-formatting.yml @@ -1,7 +1,7 @@ name: Fix formatting env: - PNPM_VERSION: "10.28.1" + PNPM_VERSION: "11.21.0" NODE_VERSION: "24.11.0" permissions: diff --git a/.github/workflows/monkey-ci.yml b/.github/workflows/monkey-ci.yml index d9689e207a89..60f57fc8e3d9 100644 --- a/.github/workflows/monkey-ci.yml +++ b/.github/workflows/monkey-ci.yml @@ -1,7 +1,7 @@ name: Monkey CI env: - PNPM_VERSION: "10.28.1" + PNPM_VERSION: "11.21.0" NODE_VERSION: "24.11.0" RECAPTCHA_SITE_KEY: "6Lc-V8McAAAAAJ7s6LGNe7MBZnRiwbsbiWts87aj" diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 21769de75c9e..000000000000 --- a/.npmrc +++ /dev/null @@ -1,3 +0,0 @@ -engine-strict=true -save-exact=true -save-prefix='' \ No newline at end of file diff --git a/backend/docker/compose.yml b/backend/docker/compose.yml index 8b76224c1328..fbdf4300de38 100644 --- a/backend/docker/compose.yml +++ b/backend/docker/compose.yml @@ -24,7 +24,7 @@ services: build: dockerfile_inline: | FROM node:24.11.0 - RUN npm i -g pnpm@10.28.1 + RUN npm i -g pnpm@11.21.0 RUN mkdir /pnpm-store && chown -R 1000:1000 /pnpm-store user: "node" ##this works as long as your local user has uid=1000 restart: on-failure diff --git a/backend/package.json b/backend/package.json index c28bee3219fe..a659fcd7912a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -54,7 +54,7 @@ "string-similarity": "4.0.4", "swagger-stats": "0.99.7", "ua-parser-js": "0.7.33", - "uuid": "14.0.0", + "uuid": "14.0.1", "winston": "3.6.0", "zod": "3.23.8" }, diff --git a/backend/src/dal/new-quotes.ts b/backend/src/dal/new-quotes.ts index 5741deb1b8cd..60c423836aa3 100644 --- a/backend/src/dal/new-quotes.ts +++ b/backend/src/dal/new-quotes.ts @@ -159,10 +159,12 @@ export async function approve( ); } const language = targetQuote.language; + + const approvedText = editQuote ?? targetQuote.text; const quote: ApproveQuote = { - text: editQuote ?? targetQuote.text, + text: approvedText, source: editSource ?? targetQuote.source, - length: targetQuote.text.length, + length: approvedText.length, approvedBy: name, id: -1, }; diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 9ba0cf3f83ab..bebd45c76211 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -8,7 +8,7 @@ COPY packages packages COPY backend backend #gimme pnpm + build -RUN npm i -g pnpm@10.28.1 && \ +RUN npm i -g pnpm@11.21.0 && \ pnpm i --frozen-lockfile && \ npm run build diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 0a8300268353..64ec35f292e5 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,15 +1,58 @@ -version: "3.8" - services: - monkeytype-frontend: - container_name: monkeytype-frontend - image: monkeytype/monkeytype-frontend:latest - ports: - - "${HTTP_PORT:-8080}:80" + monkeytype-proxy: + container_name: monkeytype-proxy + image: traefik:v3.7 restart: on-failure depends_on: monkeytype-backend: condition: service_healthy + monkeytype-frontend: + condition: service_healthy + ports: + - "${HTTP_PORT:-80}:80" + - "${HTTPS_PORT:-443}:443" + #Dashboard disabled for security (uncomment only for debugging behind VPN) + #- "8080:8080" + networks: + - public + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./letsencrypt:/letsencrypt + command: + - "--providers.docker=true" + - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + + # enable for HTTPS + #- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}" + #- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" + #- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" + #- "--entrypoints.web.http.redirections.entrypoint.to=websecure" + #- "--entrypoints.web.http.redirections.entrypoint.scheme=https" + + # for debugging only + #- "--api.insecure=true" + #- "--log.level=DEBUG" + + monkeytype-frontend: + container_name: monkeytype-frontend + image: monkeytype/monkeytype-frontend:latest + #uncomment if you want to expose the frontend server directly (not recommended) + #ports: + # - "${FRONTEND_PORT:-8080}:80" + restart: on-failure + networks: + - public + labels: + - "traefik.http.routers.frontend.rule=PathPrefix(`/`)" + - "traefik.http.routers.frontend.entrypoints=web,websecure" + - "traefik.http.routers.frontend.priority=25" + - "traefik.http.services.frontend.loadbalancer.server.port=80" + # enable for HTTPS + #- "traefik.http.routers.frontend.rule=Host(`${DOMAIN}`)" + #- "traefik.http.routers.frontend.tls=true" + #- "traefik.http.routers.frontend.tls.certresolver=letsencrypt" + #- "traefik.http.routers.frontend.tls.domains[0].main=${DOMAIN}" env_file: - path: ./.env required: true @@ -20,20 +63,30 @@ services: - FIREBASE_STORAGEBUCKET=${FIREBASE_STORAGEBUCKET:-} - FIREBASE_MESSAGINGSENDERID=${FIREBASE_MESSAGINGSENDERID:-} - FIREBASE_APPID=${FIREBASE_APPID:-} - - MONKEYTYPE_BACKENDURL=${MONKEYTYPE_BACKENDURL} + - MONKEYTYPE_BACKENDURL=${BASE_URL:-http://localhost}/api - RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY:-} + healthcheck: + test: ["CMD", "nc", "-z", "localhost", "80"] + interval: 5s + timeout: 10s + retries: 15 + start_period: 5s monkeytype-backend: container_name: monkeytype-backend image: monkeytype/monkeytype-backend:latest - ports: - - "${BACKEND_PORT:-5005}:5005" + #uncomment if you want to expose the backend server (not recommended) + #ports: + # - "${BACKEND_PORT:-5005}:5005" + networks: + - public # access to the internet, e.g. google firebase + - isolated # access to the databases restart: on-failure environment: - DB_NAME=monkeytype - DB_URI=mongodb://monkeytype-mongodb:27017 - REDIS_URI=redis://monkeytype-redis:6379 - - FRONTEND_URL=${MONKEYTYPE_FRONTENDURL} + - FRONTEND_URL=${BASE_URL:-http://localhost} - RECAPTCHA_SECRET=${RECAPTCHA_SECRET:-} - EMAIL_HOST=${EMAIL_HOST:-} - EMAIL_PORT=${EMAIL_PORT:-} @@ -56,18 +109,32 @@ services: monkeytype-mongodb: condition: service_healthy healthcheck: - test: ["CMD", "nc", "-z", "-v", "localhost", "5005"] + test: ["CMD", "nc", "-z", "localhost", "5005"] interval: 5s timeout: 10s retries: 15 start_period: 5s + labels: + - "traefik.http.middlewares.backend-prefix.stripprefix.prefixes=/api" + - "traefik.http.middlewares.backend-headers.headers.customrequestheaders.CF-Connecting-IP=" + - "traefik.http.routers.backend.middlewares=backend-prefix,backend-headers" + - "traefik.http.routers.backend.rule=PathPrefix(`/api`)" + - "traefik.http.routers.backend.priority=100" + - "traefik.http.routers.backend.entrypoints=web" + - "traefik.http.services.backend.loadbalancer.server.port=5005" + - "traefik.enable=true" + - "traefik.docker.network=monkeytype-public" monkeytype-redis: container_name: monkeytype-redis image: redis:6.2.6 - #uncomment if you want to expose the redis server + #uncomment if you want to expose the redis server (not recommended) #ports: # - "${REDIS_PORT:-6379}:6379" + networks: + - isolated + #uncomment if you want to expose the redis server (not recommended) + #- public restart: on-failure volumes: - redis-data:/data @@ -84,9 +151,13 @@ services: restart: on-failure volumes: - mongo-data:/data/db - #uncomment if you want to expose the mongodb server + #uncomment if you want to expose the mongodb server (not recommended) #ports: # - "${MONGO_PORT:-27017}:27017" + networks: + - isolated + #uncomment if you want to expose the mongodb server (not recommended) + #- public healthcheck: test: echo 'db.stats().ok' | mongo localhost:27017/test --quiet interval: 5s @@ -99,3 +170,10 @@ volumes: name: monkeytype_mongo_data redis-data: name: monkeytype_redis_data + +networks: + isolated: + name: monkeytype-isolated + internal: true + public: + name: monkeytype-public diff --git a/docker/example.env b/docker/example.env index b0e3f94172a2..14bab0a51660 100644 --- a/docker/example.env +++ b/docker/example.env @@ -2,11 +2,13 @@ ### === Required Config === -# URL of the frontend (accessible by browser) -MONKEYTYPE_FRONTENDURL=http://myserver:8080 - -# URL of the backend (accessible by browser) -MONKEYTYPE_BACKENDURL=http://myserver:5005 +# By default your installation is available on http://localhost +# If you want to expose the installation on a network (private or public) +# you need to have a domain and enable the HTTPS marked entries in the docker-compose.yml +# and uncomment and set the following +#DOMAIN= # example: mydomain.tld +#BASE_URL= # example: https://mydomain.tld +#ACME_EMAIL= # example: certmanager@mydomain.tld ### === Optional: Google reCAPTCHA === @@ -38,7 +40,9 @@ RECAPTCHA_SECRET= ### === Optional: Custom Ports === -# HTTP_PORT=8080 +# HTTP_PORT=80 +# HTTPS_PORT=443 +# FRONTEND_PORT=8080 # BACKEND_PORT=5005 # REDIS_PORT=6379 # MONGO_PORT=27017 diff --git a/docker/frontend/Dockerfile b/docker/frontend/Dockerfile index 4af1d3b73fe4..b73b787205ad 100644 --- a/docker/frontend/Dockerfile +++ b/docker/frontend/Dockerfile @@ -13,7 +13,7 @@ COPY docker/frontend/firebase-config-live.ts frontend/src/ts/constants/firebase- COPY docker/frontend/firebase-config-live.ts frontend/src/ts/constants/firebase-config-live.ts #gimme pnpm + build -RUN npm i -g pnpm && \ +RUN npm i -g pnpm@11.21.0 && \ pnpm i --frozen-lockfile && \ npm run build diff --git a/docs/CONTRIBUTING_ADVANCED.md b/docs/CONTRIBUTING_ADVANCED.md index 242e1940023a..ec5fe790902c 100644 --- a/docs/CONTRIBUTING_ADVANCED.md +++ b/docs/CONTRIBUTING_ADVANCED.md @@ -38,7 +38,7 @@ If you use `nvm` (if you use Windows, use [nvm-windows](https://github.com/corey Alternatively, you can navigate to the NodeJS [website](https://nodejs.org/en/) to download it from there. -For package management, we use `pnpm` instead of `npm` or `yarn`. You can install it by running `npm i -g pnpm@10.28.1`. This will install `pnpm` globally on your machine. +For package management, we use `pnpm` instead of `npm` or `yarn`. You can install it by running `npm i -g pnpm@11.21.0`. This will install `pnpm` globally on your machine. ### Docker (Recommended but Optional) diff --git a/docs/SELF_HOSTING.md b/docs/SELF_HOSTING.md index 191287299c5c..474bc05c8234 100644 --- a/docs/SELF_HOSTING.md +++ b/docs/SELF_HOSTING.md @@ -36,13 +36,25 @@ - create an `.env` file, you can copy the content from the [example.env](https://github.com/monkeytypegame/monkeytype/tree/master/docker/example.env). - download the [backend-configuration.json](https://github.com/monkeytypegame/monkeytype/tree/master/docker/backend-configuration.json) - run `docker compose up -d` -- after the command exits successfully you can access [http://localhost:8080](http://localhost:8080) +- after the command exits successfully you can access [http://localhost](http://localhost) ### Hosting over the network (HTTPS) If you plan to access your self-hosted Monkeytype instance over a local network or the internet (not using `localhost`), **you must serve it over HTTPS**. Modern browsers restrict key web features, such as `crypto.randomUUID`, to secure contexts. Accessing the site via HTTP over a network will cause the frontend to crash with errors like `Uncaught TypeError: crypto.randomUUID is not a function`. -To solve this, you need to place a reverse proxy (like Nginx, Caddy, or Traefik) in front of your containers to handle HTTPS/TLS termination. +#### Enable HTTPS + +Update the `.env` file and uncomment these lines and set the values based on your domain. + +``` +DOMAIN=mydomain.com +BASE_URL=https://mydomain.com +ACME_EMAIL=certmanager@mydomain.com +``` + +Update the `docker-compose.yml` and uncomment all lines marked with `# enable for HTTPS`. + + #### Troubleshooting Frontend Connection Issues @@ -50,8 +62,8 @@ If your reverse proxy is up but you see errors like `Looks like the server is ex Ensure you configure the frontend to talk to your secure backend URL by following these rules in your `.env` file: -1. **Update the frontend and backend URL:** Set `MONKEYTYPE_FRONTENDURL` and `MONKEYTYPE_BACKENDURL` to your full HTTPS backend domain. -2. **Do not include a trailing slash:** Ensure the URL does not end with a `/` (e.g., use `https://api.yourdomain.com`, **not** `https://api.yourdomain.com/`). A trailing slash will cause `404 Not Found` errors due to double slashes in the API calls (like `//configuration`). +1. **Update the frontend and backend URL:** Set `DOMAIN` and `BASE_URL` correctly, usually `BASE_URL` is `https://DOMAIN`. +2. **Do not include a trailing slash:** Ensure the URL does not end with a `/` (e.g., use `https://yourdomain.com`, **not** `https://yourdomain.com/`). A trailing slash will cause `404 Not Found` errors due to double slashes in the API calls (like `//configuration`). 3. **Force container recreation:** Monkeytype is a Single Page Application (SPA), meaning environment variables are baked into the static JavaScript files during startup. If you change your `.env`, you must completely recreate the container for the changes to apply: ```bash diff --git a/frontend/__tests__/components/ui/form/InputField.spec.tsx b/frontend/__tests__/components/ui/form/InputField.spec.tsx index 95f269accc50..7b052bb4a94b 100644 --- a/frontend/__tests__/components/ui/form/InputField.spec.tsx +++ b/frontend/__tests__/components/ui/form/InputField.spec.tsx @@ -1,12 +1,13 @@ import { render, screen, fireEvent } from "@solidjs/testing-library"; import { AnyFieldApi } from "@tanstack/solid-form"; import { describe, it, expect, vi } from "vitest"; +import { z } from "zod"; import { InputField } from "../../../../src/ts/components/ui/form/InputField"; function makeField( name: string, - value?: string | number | boolean, + value?: string | number | boolean | Date, ): AnyFieldApi { let current = value; const meta = { @@ -173,4 +174,67 @@ describe("InputField", () => { fireEvent.input(input, { target: { value: "6" } }); expect(field.handleChange).toHaveBeenCalledWith(6); }); + + it("keeps empty string for string values", async () => { + const field = makeField("age", "test"); + render(() => field} />); + const input = screen.getByRole("textbox"); + + fireEvent.input(input, { target: { value: "" } }); + fireEvent.blur(input); + + expect(field.handleChange).toHaveBeenCalledWith(""); + }); + + it("handles empty string for numeric fields", async () => { + const field = makeField("age", 5); + render(() => field} type="number" />); + const input = screen.getByRole("spinbutton"); + + fireEvent.input(input, { target: { value: "" } }); + fireEvent.blur(input); + + expect(field.handleChange).toHaveBeenCalledWith(undefined); + }); + + it("applies number schema constraints", async () => { + const field = makeField("value", 10); + const schema = z.number().min(1).max(100).int(); + render(() => ( + field} type="number" schema={schema} /> + )); + + const input = screen.getByRole("spinbutton"); + expect(input).toHaveAttribute("min", "1"); + expect(input).toHaveAttribute("max", "100"); + expect(input).toHaveAttribute("step", "1"); + }); + + it("applies float number schema constraints", async () => { + const field = makeField("value", 1.5); + const schema = z.number().min(0.5).max(10.5); + render(() => ( + field} type="number" schema={schema} /> + )); + + const input = screen.getByRole("spinbutton"); + expect(input).toHaveAttribute("min", "0.5"); + expect(input).toHaveAttribute("max", "10.5"); + expect(input).toHaveAttribute("step", "any"); + }); + + it("applies date schema constraints", async () => { + const field = makeField("date", new Date("2024-01-15")); + const schema = z + .date() + .min(new Date("2024-01-01")) + .max(new Date("2024-12-31")); + const { container } = render(() => ( + field} type="date" schema={schema} /> + )); + + const input = container.querySelector("input") as HTMLInputElement; + expect(input).toHaveAttribute("min", "2024-01-01"); + expect(input).toHaveAttribute("max", "2024-12-31"); + }); }); diff --git a/frontend/__tests__/test/test-words.spec.ts b/frontend/__tests__/test/test-words.spec.ts index 085edf395ce3..642c45a2506e 100644 --- a/frontend/__tests__/test/test-words.spec.ts +++ b/frontend/__tests__/test/test-words.spec.ts @@ -60,5 +60,15 @@ describe("test-words", () => { expect(() => words.removeCommitCharacterFromLastWord()).not.toThrow(); expect(words.get()).toEqual([]); }); + + it("does not empty the last word when it's only a newline", () => { + words.push("word\n", 0); + words.push("\n", 0); + words.removeCommitCharacterFromLastWord(); + expect(words.get().map((w) => w.textWithCommit)).toEqual([ + "word\n", + "\n", + ]); + }); }); }); diff --git a/frontend/docker/compose.dev.yml b/frontend/docker/compose.dev.yml index d4178e9bf1e2..a50e0b370238 100644 --- a/frontend/docker/compose.dev.yml +++ b/frontend/docker/compose.dev.yml @@ -5,7 +5,7 @@ services: build: dockerfile_inline: | FROM node:24.11.0 - RUN npm i -g pnpm@10.28.1 + RUN npm i -g pnpm@11.21.0 RUN mkdir /pnpm-store && chown -R 1000:1000 /pnpm-store user: "node" ##this works as long as your local user has uid=1000 # restart: on-failure diff --git a/frontend/package.json b/frontend/package.json index 9234f3fdf969..c9cc780696fd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -111,7 +111,7 @@ "normalize.css": "8.0.1", "oxlint": "1.77.0", "oxlint-tsgolint": "7.0.2001", - "postcss": "8.5.15", + "postcss": "8.5.24", "sass": "1.70.0", "solid-devtools": "0.34.5", "solid-js": "1.9.13", diff --git a/frontend/src/html/pages/test-result.html b/frontend/src/html/pages/test-result.html index 469f6617104c..01f5a9be47fb 100644 --- a/frontend/src/html/pages/test-result.html +++ b/frontend/src/html/pages/test-result.html @@ -185,6 +185,15 @@ > +