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