diff --git a/frontend/src/ts/utils/misc.ts b/frontend/src/ts/utils/misc.ts index 631ffe597dd1..c8e9626bea23 100644 --- a/frontend/src/ts/utils/misc.ts +++ b/frontend/src/ts/utils/misc.ts @@ -2,7 +2,7 @@ import { lastElementFromArray } from "./arrays"; import { Config } from "@monkeytype/schemas/configs"; import { Mode, Mode2, PersonalBests } from "@monkeytype/schemas/shared"; import { Result } from "@monkeytype/schemas/results"; -import { RankAndCount } from "@monkeytype/schemas/users"; +import { RankAndCount, UserNameSchema } from "@monkeytype/schemas/users"; import { roundTo2 } from "@monkeytype/util/numbers"; import { animate, AnimationParams } from "animejs"; import { ElementWithUtils } from "./dom"; @@ -147,11 +147,7 @@ export function escapeHTML(str: T): T { export function isUsernameValid(name: string): boolean { if (name === null || name === undefined || name === "") return false; - if (name.toLowerCase().includes("miodec")) return false; - if (name.toLowerCase().includes("bitly")) return false; - if (name.length > 14) return false; - if (/^\..*/.test(name.toLowerCase())) return false; - return /^[0-9a-zA-Z_.-]+$/.test(name); + return UserNameSchema.safeParse(name).success; } export function clearTimeouts(timeouts: (number | NodeJS.Timeout)[]): void { diff --git a/packages/schemas/__tests__/util.spec.ts b/packages/schemas/__tests__/util.spec.ts new file mode 100644 index 000000000000..8f29eb0496b9 --- /dev/null +++ b/packages/schemas/__tests__/util.spec.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { nameWithSeparators, slug } from "../src/util"; + +describe("Schema Validation Tests", () => { + describe("nameWithSeparators", () => { + const schema = nameWithSeparators(); + + it("accepts valid names", () => { + expect(schema.safeParse("valid_name").success).toBe(true); + expect(schema.safeParse("valid-name").success).toBe(true); + expect(schema.safeParse("valid123").success).toBe(true); + expect(schema.safeParse("Valid_Name-Check").success).toBe(true); + }); + + it("rejects leading/trailing separators", () => { + expect(schema.safeParse("_invalid").success).toBe(false); + expect(schema.safeParse("invalid-").success).toBe(false); + }); + + it("rejects consecutive separators", () => { + expect(schema.safeParse("inv__alid").success).toBe(false); + expect(schema.safeParse("inv--alid").success).toBe(false); + expect(schema.safeParse("inv-_alid").success).toBe(false); + }); + + it("rejects dots", () => { + expect(schema.safeParse("invalid.dot").success).toBe(false); + expect(schema.safeParse(".invalid").success).toBe(false); + }); + }); + + describe("slug", () => { + const schema = slug(); + + it("accepts valid slugs", () => { + expect(schema.safeParse("valid-slug.123_test").success).toBe(true); + expect(schema.safeParse("valid.dots").success).toBe(true); + expect(schema.safeParse("_leading_underscore_is_fine").success).toBe( + true, + ); + expect(schema.safeParse("-leading_hyphen_is_fine").success).toBe(true); + expect(schema.safeParse("trailing_is_fine_in_slug_").success).toBe(true); + }); + + it("rejects leading dots", () => { + expect(schema.safeParse(".invalid").success).toBe(false); + }); + + it("rejects invalid characters", () => { + expect(schema.safeParse("invalid,comma").success).toBe(false); + expect(schema.safeParse(",invalid").success).toBe(false); + expect(schema.safeParse("invalid space").success).toBe(false); + expect(schema.safeParse("invalid#hash").success).toBe(false); + }); + }); +}); diff --git a/packages/schemas/src/ape-keys.ts b/packages/schemas/src/ape-keys.ts index 146c4cc658a1..1022ea028c73 100644 --- a/packages/schemas/src/ape-keys.ts +++ b/packages/schemas/src/ape-keys.ts @@ -1,10 +1,7 @@ import { z } from "zod"; -import { IdSchema } from "./util"; +import { IdSchema, slug } from "./util"; -export const ApeKeyNameSchema = z - .string() - .regex(/^[0-9a-zA-Z_.-]+$/) - .max(20); +export const ApeKeyNameSchema = slug().max(20); export const ApeKeyUserDefinedSchema = z.object({ name: ApeKeyNameSchema, diff --git a/packages/schemas/src/presets.ts b/packages/schemas/src/presets.ts index 5faeb2a68224..91de2dad4b14 100644 --- a/packages/schemas/src/presets.ts +++ b/packages/schemas/src/presets.ts @@ -1,15 +1,12 @@ import { z } from "zod"; -import { IdSchema, TagSchema } from "./util"; +import { IdSchema, nameWithSeparators, TagSchema } from "./util"; import { ConfigGroupName, ConfigGroupNameSchema, PartialConfigSchema, } from "./configs"; -export const PresetNameSchema = z - .string() - .regex(/^[0-9a-zA-Z_-]+$/) - .max(16); +export const PresetNameSchema = nameWithSeparators().max(16); export type PresetName = z.infer; export const PresetTypeSchema = z.enum(["full", "partial"]); diff --git a/packages/schemas/src/users.ts b/packages/schemas/src/users.ts index e592c1bea026..a2a7704e39bf 100644 --- a/packages/schemas/src/users.ts +++ b/packages/schemas/src/users.ts @@ -1,5 +1,5 @@ import { z, ZodEffects, ZodOptional, ZodString } from "zod"; -import { IdSchema, StringNumberSchema } from "./util"; +import { IdSchema, nameWithSeparators, slug, StringNumberSchema } from "./util"; import { LanguageSchema } from "./languages"; import { ModeSchema, @@ -18,10 +18,7 @@ import { ConnectionSchema } from "./connections"; const NoneFilterSchema = z.literal("none"); export const ResultFiltersSchema = z.object({ _id: IdSchema, - name: z - .string() - .regex(/^[0-9a-zA-Z_.-]+$/) - .max(16), + name: slug().max(16), pb: z .object({ no: z.boolean(), @@ -72,11 +69,13 @@ export const UserStreakSchema = z }) .strict(); export type UserStreak = z.infer; +export const TagNameSchema = nameWithSeparators().max(16); +export type TagName = z.infer; export const UserTagSchema = z .object({ _id: IdSchema, - name: z.string(), + name: TagNameSchema, personalBests: PersonalBestsSchema, }) .strict(); @@ -90,19 +89,13 @@ function profileDetailsBase( .transform((value) => (value === null ? undefined : value)); } -export const TwitterProfileSchema = profileDetailsBase( - z - .string() - .max(20) - .regex(/^[0-9a-zA-Z_.-]+$/), -).or(z.literal("")); +export const TwitterProfileSchema = profileDetailsBase(slug().max(20)).or( + z.literal(""), +); -export const GithubProfileSchema = profileDetailsBase( - z - .string() - .max(39) - .regex(/^[0-9a-zA-Z_.-]+$/), -).or(z.literal("")); +export const GithubProfileSchema = profileDetailsBase(slug().max(39)).or( + z.literal(""), +); export const WebsiteSchema = profileDetailsBase( z.string().url().max(200).startsWith("https://"), @@ -125,10 +118,7 @@ export const UserProfileDetailsSchema = z .strict(); export type UserProfileDetails = z.infer; -export const CustomThemeNameSchema = z - .string() - .regex(/^[0-9a-zA-Z_-]+$/) - .max(16); +export const CustomThemeNameSchema = nameWithSeparators().max(16); export type CustomThemeName = z.infer; export const CustomThemeSchema = z @@ -244,14 +234,7 @@ export type FavoriteQuotes = z.infer; export const UserEmailSchema = z.string().email(); export const UserNameSchema = doesNotContainProfanity( "substring", - z - .string() - .min(1) - .max(16) - .regex( - /^[\da-zA-Z_-]+$/, - "Can only contain lower/uppercase letters, underscore and minus.", - ), + slug().min(1).max(16), ); export const UserSchema = z.object({ @@ -297,12 +280,6 @@ export type ResultFiltersGroup = keyof ResultFilters; export type ResultFiltersGroupItem = keyof ResultFilters[T]; -export const TagNameSchema = z - .string() - .regex(/^[0-9a-zA-Z_.-]+$/) - .max(16); -export type TagName = z.infer; - export const TypingStatsSchema = z.object({ completedTests: z.number().int().nonnegative().optional(), startedTests: z.number().int().nonnegative().optional(), diff --git a/packages/schemas/src/util.ts b/packages/schemas/src/util.ts index 6098b9ab3a7b..4abbf2c39c47 100644 --- a/packages/schemas/src/util.ts +++ b/packages/schemas/src/util.ts @@ -8,9 +8,29 @@ export const StringNumberSchema = z ) .or(z.number().transform(String)); export type StringNumber = z.infer; - export const token = (): ZodString => z.string().regex(/^[a-zA-Z0-9_]+$/); +export const slug = (): ZodString => + z + .string() + .regex( + /^[0-9a-zA-Z_.-]+$/, + "Only letters, numbers, underscores, dots and hyphens allowed", + ) + .regex(/^[^.].*$/, "Cannot start with a dot"); + +export const nameWithSeparators = (): ZodString => + z + .string() + .regex( + /^[0-9a-zA-Z_-]+$/, + "Only letters, numbers, underscores and hyphens allowed", + ) + .regex( + /^[a-zA-Z0-9]+(?:[_-][a-zA-Z0-9]+)*$/, + "Separators cannot be at the start or end, or appear multiple times in a row", + ); + export const IdSchema = token(); export type Id = z.infer;