diff --git a/app/components/CustomizedColorsInput.tsx b/app/components/CustomizedColorsInput.tsx index a29d62fb8..9b5f72882 100644 --- a/app/components/CustomizedColorsInput.tsx +++ b/app/components/CustomizedColorsInput.tsx @@ -1,21 +1,11 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; +import { CUSTOM_CSS_VAR_COLORS } from "~/constants"; import { Button } from "./Button"; import { Label } from "./Label"; -const CUSTOM_COLORS = [ - "bg", - "bg-darker", - "bg-lighter", - "text", - "text-lighter", - "theme", - "theme-secondary", - "chat", -] as const; - type CustomColorsRecord = Partial< - Record<(typeof CUSTOM_COLORS)[number], string> + Record<(typeof CUSTOM_CSS_VAR_COLORS)[number], string> >; export function CustomizedColorsInput({ @@ -33,7 +23,7 @@ export function CustomizedColorsInput({
- {CUSTOM_COLORS.map((cssVar) => { + {CUSTOM_CSS_VAR_COLORS.map((cssVar) => { return (
{t(`custom.colors.${cssVar}`)}
diff --git a/app/constants.ts b/app/constants.ts index 2babab8e3..5447111d4 100644 --- a/app/constants.ts +++ b/app/constants.ts @@ -134,3 +134,14 @@ export const PATCHES = [ // date: "2023-08-30", // }, ]; + +export const CUSTOM_CSS_VAR_COLORS = [ + "bg", + "bg-darker", + "bg-lighter", + "text", + "text-lighter", + "theme", + "theme-secondary", + "chat", +] as const; diff --git a/app/features/team/actions/t.$customUrl.edit.server.test.ts b/app/features/team/actions/t.$customUrl.edit.server.test.ts new file mode 100644 index 000000000..a330cfb19 --- /dev/null +++ b/app/features/team/actions/t.$customUrl.edit.server.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { dbInsertUsers, dbReset, wrappedAction } from "~/utils/Test"; +import { action as teamIndexPageAction } from "../actions/t.server"; +import type { editTeamSchema } from "../team-schemas.server"; +import type { createTeamSchema } from "../team-schemas.server"; +import { action as _editTeamProfileAction } from "./t.$customUrl.edit.server"; + +const createTeamAction = wrappedAction({ + action: teamIndexPageAction, +}); + +const editTeamProfileAction = wrappedAction({ + action: _editTeamProfileAction, +}); + +const DEFAULT_FIELDS = { + _action: "EDIT", + name: "Team 1", + bio: "", + bsky: "", +} as const; + +describe("team page editing", () => { + beforeEach(async () => { + await dbInsertUsers(); + await createTeamAction({ name: "Team 1" }, { user: "regular" }); + }); + afterEach(() => { + dbReset(); + }); + + it("adds valid custom css vars", async () => { + const response = await editTeamProfileAction( + { + css: JSON.stringify({ bg: "#fff" }), + ...DEFAULT_FIELDS, + }, + { user: "regular", params: { customUrl: "team-1" } }, + ); + + expect(response.status).toBe(302); + }); + + it("prevents adding custom css var of unknown property", async () => { + await expect( + editTeamProfileAction( + { + css: JSON.stringify({ + "backdrop-filter": "#fff", + }), + ...DEFAULT_FIELDS, + }, + { user: "regular", params: { customUrl: "team-1" } }, + ), + ).rejects.toThrow("status code: 400"); + }); + + it("prevents adding custom css var of unknown value", async () => { + await expect( + editTeamProfileAction( + { + css: JSON.stringify({ + bg: "url(https://sendou.ink/u?q=1&_data=features%2Fuser-search%2Froutes%2Fu)", + }), + ...DEFAULT_FIELDS, + }, + { user: "regular", params: { customUrl: "team-1" } }, + ), + ).rejects.toThrow("status code: 400"); + }); +}); diff --git a/app/features/team/routes/t.$customUrl.edit.test.ts b/app/features/team/routes/t.$customUrl.edit.test.ts index 97bde465d..8c2bdc31a 100644 --- a/app/features/team/routes/t.$customUrl.edit.test.ts +++ b/app/features/team/routes/t.$customUrl.edit.test.ts @@ -12,6 +12,10 @@ const editTeamAction = wrappedAction({ action: _editTeamAction, }); +const DEFAULT_FIELDS = { + bio: null, +} as any; + describe("team creation", () => { beforeEach(async () => { await dbInsertUsers(); @@ -28,9 +32,7 @@ describe("team creation", () => { { _action: "EDIT", name: "Team 2", - bio: null, - bsky: null, - css: null, + ...DEFAULT_FIELDS, }, { user: "regular", params: { customUrl: "team-1" } }, ); @@ -46,9 +48,7 @@ describe("team creation", () => { { _action: "EDIT", name: "𝓢𝓲𝓵", - bio: null, - bsky: null, - css: null, + ...DEFAULT_FIELDS, }, { user: "regular", params: { customUrl: "team-1" } }, ), diff --git a/app/features/team/team-schemas.server.ts b/app/features/team/team-schemas.server.ts index efb551e63..c7825333d 100644 --- a/app/features/team/team-schemas.server.ts +++ b/app/features/team/team-schemas.server.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { _action, falsyToNull, id, jsonParseable } from "~/utils/zod"; +import { _action, customCssVarObject, falsyToNull, id } from "~/utils/zod"; import { TEAM, TEAM_MEMBER_ROLES } from "./team-constants"; export const teamParamsSchema = z.object({ customUrl: z.string() }); @@ -32,7 +32,7 @@ export const editTeamSchema = z.union([ falsyToNull, z.string().max(TEAM.BSKY_MAX_LENGTH).nullable(), ), - css: z.preprocess(falsyToNull, z.string().refine(jsonParseable).nullable()), + css: customCssVarObject, }), ]); diff --git a/app/features/user-page/routes/u.$identifier.edit.test.ts b/app/features/user-page/routes/u.$identifier.edit.test.ts new file mode 100644 index 000000000..dcd97a0a6 --- /dev/null +++ b/app/features/user-page/routes/u.$identifier.edit.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { MainWeaponId } from "~/modules/in-game-lists"; +import { dbInsertUsers, dbReset, wrappedAction } from "~/utils/Test"; +import { + action as editUserProfileAction, + type userEditActionSchema, +} from "./u.$identifier.edit"; + +const action = wrappedAction({ + action: editUserProfileAction, +}); + +const DEFAULT_FIELDS = { + battlefy: null, + bio: null, + commissionsOpen: 1, + commissionText: null, + country: "FI", + customName: null, + customUrl: null, + favoriteBadgeId: null, + inGameNameDiscriminator: null, + inGameNameText: null, + motionSens: null, + showDiscordUniqueName: 1, + stickSens: null, + weapons: JSON.stringify([ + { weaponSplId: 1 as MainWeaponId, isFavorite: 0 }, + ]) as any, +}; + +describe("user page editing", () => { + beforeEach(async () => { + await dbInsertUsers(); + }); + afterEach(() => { + dbReset(); + }); + + it("adds valid custom css vars", async () => { + const response = await action( + { + css: JSON.stringify({ bg: "#fff" }), + ...DEFAULT_FIELDS, + }, + { user: "regular", params: { identifier: "2" } }, + ); + + expect(response.status).toBe(302); + }); + + it("prevents adding custom css var of unknown property", async () => { + const res = await action( + { + css: JSON.stringify({ + "backdrop-filter": "#fff", + }), + ...DEFAULT_FIELDS, + }, + { user: "regular", params: { identifier: "2" } }, + ); + + expect(res.errors[0]).toBe("Invalid custom CSS var object"); + }); + + it("prevents adding custom css var of unknown value", async () => { + const res = await action( + { + css: JSON.stringify({ + bg: "url(https://sendou.ink/u?q=1&_data=features%2Fuser-search%2Froutes%2Fu)", + }), + ...DEFAULT_FIELDS, + }, + { user: "regular", params: { identifier: "2" } }, + ); + + expect(res.errors[0]).toBe("Invalid custom CSS var object"); + }); +}); diff --git a/app/features/user-page/routes/u.$identifier.edit.tsx b/app/features/user-page/routes/u.$identifier.edit.tsx index 3b0d621ac..a1ba56037 100644 --- a/app/features/user-page/routes/u.$identifier.edit.tsx +++ b/app/features/user-page/routes/u.$identifier.edit.tsx @@ -42,10 +42,10 @@ import { FAQ_PAGE, isCustomUrl, userPage } from "~/utils/urls"; import { actualNumber, checkboxValueToDbBoolean, + customCssVarObject, dbBoolean, falsyToNull, id, - jsonParseable, processMany, safeJSONParse, undefinedToNull, @@ -56,7 +56,7 @@ import type { UserPageLoaderData } from "./u.$identifier"; import "~/styles/u-edit.css"; import { SendouSwitch } from "~/components/elements/Switch"; -const userEditActionSchema = z +export const userEditActionSchema = z .object({ country: z.preprocess( falsyToNull, @@ -127,7 +127,7 @@ const userEditActionSchema = z .refine((val) => /^[0-9a-z]{4,5}$/.test(val)) .nullable(), ), - css: z.preprocess(falsyToNull, z.string().refine(jsonParseable).nullable()), + css: customCssVarObject, weapons: z.preprocess( safeJSONParse, z diff --git a/app/utils/zod.ts b/app/utils/zod.ts index 708138d3a..ade98c0f9 100644 --- a/app/utils/zod.ts +++ b/app/utils/zod.ts @@ -1,5 +1,6 @@ import type { ZodType } from "zod"; import { z } from "zod"; +import { CUSTOM_CSS_VAR_COLORS } from "~/constants"; import type { abilitiesShort } from "~/modules/in-game-lists"; import { abilities, mainWeaponIds, stageIds } from "~/modules/in-game-lists"; import { FRIEND_CODE_REGEXP } from "../features/sendouq/q-constants"; @@ -15,7 +16,8 @@ export const nonEmptyString = z.string().trim().min(1, { export const dbBoolean = z.coerce.number().min(0).max(1).int(); -export const hexCode = z.string().regex(/^#[0-9a-fA-F]{6}$/); +const hexCodeRegex = /^#(?:[0-9a-fA-F]{3}){1,2}$/; // https://stackoverflow.com/a/1636354 +export const hexCode = z.string().regex(hexCodeRegex); const abilityNameToType = (val: string) => abilities.find((ability) => ability.name === val)?.type; @@ -262,3 +264,27 @@ export const dayMonthYear = z.object({ }); export type DayMonthYear = z.infer; + +export const customCssVarObject = z.preprocess( + falsyToNull, + z.string().nullable().refine(validSerializedCustomCssVarObject, { + message: "Invalid custom CSS var object", + }), +); + +function validSerializedCustomCssVarObject(value: unknown) { + if (!value) return true; + + try { + const parsedValue = JSON.parse(value as string); + + for (const [key, value] of Object.entries(parsedValue)) { + if (!CUSTOM_CSS_VAR_COLORS.includes(key as any)) return false; + if (!hexCodeRegex.test(value as string)) return false; + } + + return true; + } catch { + return false; + } +}