Skip to content

Commit 00d4dd1

Browse files
authored
add profile and messaging safeguards (#49)
* Harden chat threads, sign-up email, and first-name rules - Cap new chat threads at 6 per hour per initiator (keeps 10 msgs/hour) - Add profiles first_name DB trigger aligned with app validation - Block disposable inbox domains at sign-up (package list + merepost.com) - validateFirstName on sign-up and profile settings; friendly RLS errors in chat - Add i18n strings for disposable email and first-name validation Made-with: Cursor * First name: normalise Unicode spaces; drop duplicate disposable domain Made-with: Cursor * Fix chat_threads RLS policy syntax; use fakeout for disposable emails - Rewrite CREATE POLICY WITH CHECK without the extra closing paren that broke Postgres. - Replace stale disposable-email-domains with fakeout (isDisposableEmail). - Drop unused disposable-email-domains ambient types. Made-with: Cursor * Persist trimmed profile first_name from DB trigger Assign NEW.first_name after trim so stored value matches validation. Follow-up migration for branches that already ran the earlier file. Made-with: Cursor * Restrict chat_threads INSERT to initiator (close rate-limit bypass) Owner-role inserts skipped thread-initiation limit via OR branch. App only creates threads as donor/initiator (ChatWindow). Made-with: Cursor * signUpAction: trim email for checks, redirect query, and auth.signUp Made-with: Cursor * Document why follow-up chat_threads and first_name migrations stay in the chain Made-with: Cursor
1 parent b430ac6 commit 00d4dd1

15 files changed

Lines changed: 543 additions & 50 deletions

messages/de.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@
9797
"signUpFailed": "Registrierung fehlgeschlagen",
9898
"tooManyListings": "Du hast die maximale Anzahl erlaubter Einträge erreicht. Lösche einen deiner aktuellen drei Einträge, um einen neuen zu erstellen.",
9999
"tooManyMessages": "Du hast zu viele Nachrichten gesendet. Bitte versuche es später erneut.",
100+
"tooManyThreads": "Du hast in der letzten Stunde zu viele neue Unterhaltungen begonnen. Bitte versuche es später erneut.",
101+
"firstNameTooShort": "Bitte verwende mindestens 2 Zeichen für deinen Vornamen.",
102+
"firstNameTooLong": "Vornamen dürfen höchstens 24 Zeichen haben.",
103+
"firstNameNotAllowed": "Dieser Vorname ist auf Peels nicht erlaubt.",
104+
"firstNameInvalidChars": "Vornamen dürfen nur Buchstaben, Leerzeichen, Bindestriche und Apostrophe enthalten.",
105+
"disposableEmailNotAllowed": "Bitte verwende eine dauerhafte E-Mail-Adresse statt einer Wegwerf-Inbox.",
100106
"updateEmailFailed": "Hmm, da stimmt etwas nicht. Prüfe deine E-Mail oder versuche es erneut.",
101107
"updateFirstNameFailed": "Entschuldigung, wir konnten deinen Vornamen nicht aktualisieren.",
102108
"updateNewsletterFailed": "Entschuldigung, wir konnten deine Newsletter-Einstellung nicht aktualisieren.",

messages/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@
9797
"signUpFailed": "Sign up failed",
9898
"tooManyListings": "You’ve reached the maximum number of listings allowed. Delete one of your current three to create a new one.",
9999
"tooManyMessages": "You’ve sent too many messages. Please try again later.",
100+
"tooManyThreads": "You’ve started too many new conversations in the last hour. Please try again later.",
101+
"firstNameTooShort": "Please use at least 2 characters for your first name.",
102+
"firstNameTooLong": "First names can be at most 24 characters.",
103+
"firstNameNotAllowed": "That first name isn’t allowed on Peels.",
104+
"firstNameInvalidChars": "First names can only include letters, spaces, hyphens, and apostrophes.",
105+
"disposableEmailNotAllowed": "Please use a long-term email address rather than a disposable inbox.",
100106
"updateEmailFailed": "Hmm, something’s not right. Check your email or try again.",
101107
"updateFirstNameFailed": "Sorry, we couldn’t update your first name.",
102108
"updateNewsletterFailed": "Sorry, we couldn’t update your newsletter preference.",

messages/es.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@
9797
"signUpFailed": "No se pudo completar el registro",
9898
"tooManyListings": "Has alcanzado el número máximo de anuncios permitidos. Elimina uno de tus tres anuncios actuales para crear uno nuevo.",
9999
"tooManyMessages": "Has enviado demasiados mensajes. Inténtalo de nuevo más tarde.",
100+
"tooManyThreads": "Has iniciado demasiadas conversaciones nuevas en la última hora. Inténtalo de nuevo más tarde.",
101+
"firstNameTooShort": "Usa al menos 2 caracteres para tu nombre.",
102+
"firstNameTooLong": "El nombre puede tener como máximo 24 caracteres.",
103+
"firstNameNotAllowed": "Ese nombre no está permitido en Peels.",
104+
"firstNameInvalidChars": "El nombre solo puede incluir letras, espacios, guiones y apóstrofos.",
105+
"disposableEmailNotAllowed": "Usa una dirección de correo de largo plazo en lugar de un buzón desechable.",
100106
"updateEmailFailed": "Hmm, algo no está bien. Revisa tu correo o inténtalo de nuevo.",
101107
"updateFirstNameFailed": "Lo sentimos, no pudimos actualizar tu nombre.",
102108
"updateNewsletterFailed": "Lo sentimos, no pudimos actualizar tu preferencia del boletín.",

package-lock.json

Lines changed: 10 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@vercel/speed-insights": "^1.2.0",
3737
"clsx": "^2.1.1",
3838
"compressorjs": "^1.2.1",
39+
"fakeout": "^1.0.13",
3940
"feed": "^5.1.0",
4041
"lodash": "^4.17.21",
4142
"lucide-react": "^0.456.0",

src/app/actions.ts

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
"use server";
22

3-
import { validateName } from "@/lib/formValidation";
3+
import { isDisposableSignupEmail } from "@/lib/emailValidation";
4+
import {
5+
validateFirstName,
6+
validateName,
7+
type FirstNameErrorCode,
8+
} from "@/lib/formValidation";
49
import { getSafeHttpReferrer } from "@/utils/referrer";
510
import { createClient } from "@/utils/supabase/server";
611
import { getBaseUrl } from "@/utils/url";
@@ -14,14 +19,52 @@ import { headers } from "next/headers";
1419
import { redirect } from "next/navigation";
1520
import { getTranslations } from "next-intl/server";
1621

22+
function translateFirstNameFieldError(
23+
t: Awaited<ReturnType<typeof getTranslations>>,
24+
code?: FirstNameErrorCode
25+
): string {
26+
switch (code) {
27+
case "empty":
28+
return t("emptyName");
29+
case "tooShort":
30+
return t("firstNameTooShort");
31+
case "tooLong":
32+
return t("firstNameTooLong");
33+
case "forbiddenContent":
34+
case "reserved":
35+
return t("firstNameNotAllowed");
36+
case "invalidChars":
37+
return t("firstNameInvalidChars");
38+
default:
39+
return t("generic");
40+
}
41+
}
42+
1743
export const signUpAction = async (formData: FormData, request?: Request) => {
1844
const t = await getTranslations("Errors");
19-
const email = formData.get("email")?.toString();
45+
const email = (formData.get("email")?.toString() ?? "").trim();
2046
const password = formData.get("password")?.toString();
21-
const firstNameValidation = validateName(formData.get("first_name")); // Trim first name
22-
const first_name = firstNameValidation.isValid
23-
? firstNameValidation.value
24-
: null;
47+
const rawFirstName = formData.get("first_name")?.toString();
48+
const firstNameValidation = validateFirstName(formData.get("first_name"));
49+
if (!firstNameValidation.isValid) {
50+
const preservedData = new URLSearchParams();
51+
if (email) preservedData.set("email", email);
52+
if (rawFirstName?.trim())
53+
preservedData.set("first_name", rawFirstName.trim());
54+
const redirectUrl = new URL(
55+
"/sign-up",
56+
(await headers()).get("origin") || getBaseUrl()
57+
);
58+
preservedData.forEach((value, key) => {
59+
redirectUrl.searchParams.append(key, value);
60+
});
61+
redirectUrl.searchParams.append(
62+
"error",
63+
translateFirstNameFieldError(t, firstNameValidation.error)
64+
);
65+
return redirect(redirectUrl.toString());
66+
}
67+
const first_name = firstNameValidation.value;
2568
const newsletterPreference = formData.has("newsletter_preference"); // Will only be passed if input is checked when form submitted
2669

2770
const supabase = await createClient();
@@ -68,6 +111,11 @@ export const signUpAction = async (formData: FormData, request?: Request) => {
68111
return redirect(redirectUrl.toString());
69112
}
70113

114+
if (isDisposableSignupEmail(email)) {
115+
redirectUrl.searchParams.append("error", t("disposableEmailNotAllowed"));
116+
return redirect(redirectUrl.toString());
117+
}
118+
71119
if (turnstileEnabled && !captchaToken) {
72120
redirectUrl.searchParams.append("error", t("verificationChallenge"));
73121
return redirect(redirectUrl.toString());
@@ -214,9 +262,11 @@ export const updateFirstNameAction = async (formData: FormData) => {
214262
data: { user },
215263
} = await supabase.auth.getUser();
216264

217-
const firstNameValidation = validateName(formData.get("first_name"));
265+
const firstNameValidation = validateFirstName(formData.get("first_name"));
218266
if (!firstNameValidation.isValid) {
219-
return { error: t("emptyName") };
267+
return {
268+
error: translateFirstNameFieldError(t, firstNameValidation.error),
269+
};
220270
}
221271

222272
const { error } = await supabase
@@ -228,6 +278,9 @@ export const updateFirstNameAction = async (formData: FormData) => {
228278

229279
if (error) {
230280
console.error("Error updating first name:", error);
281+
if (error.code === "23514" || /first name/i.test(error.message ?? "")) {
282+
return { error: t("firstNameNotAllowed") };
283+
}
231284
return { error: t("updateFirstNameFailed") };
232285
}
233286

src/components/ChatWindow/ChatWindow.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,14 @@ const ChatWindow = memo(function ChatWindow({
121121

122122
// Turn the rate limiting message into something more friendly (original: new row violates row-level security policy for table "chat_messages")
123123
if (errorMessage.includes("violates row-level security policy")) {
124-
setMessageSendError(t("Errors.tooManyMessages"));
124+
if (
125+
errorMessage.includes("chat_threads") ||
126+
errorMessage.includes('"chat_threads"')
127+
) {
128+
setMessageSendError(t("Errors.tooManyThreads"));
129+
} else {
130+
setMessageSendError(t("Errors.tooManyMessages"));
131+
}
125132
} else {
126133
setMessageSendError(errorMessage);
127134
}

src/components/ProfileAccountSettings/ProfileAccountSettings.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
} from "@/app/actions";
1717

1818
import { styled } from "@pigment-css/react";
19-
import { validateName, FIELD_CONFIGS } from "@/lib/formValidation";
19+
import { validateFirstName, FIELD_CONFIGS } from "@/lib/formValidation";
2020
import { useTranslations } from "next-intl";
2121

2222
const List = styled("ul")(({ theme }) => ({
@@ -178,9 +178,28 @@ function ProfileAccountSettings({
178178
};
179179

180180
const handleFirstNameUpdate = async (formData: FormData) => {
181-
const validation = validateName(formData.get("first_name"));
181+
const validation = validateFirstName(formData.get("first_name"));
182182
if (!validation.isValid) {
183-
firstName.setError(t("Errors.emptyName"));
183+
switch (validation.error) {
184+
case "empty":
185+
firstName.setError(t("Errors.emptyName"));
186+
break;
187+
case "tooShort":
188+
firstName.setError(t("Errors.firstNameTooShort"));
189+
break;
190+
case "tooLong":
191+
firstName.setError(t("Errors.firstNameTooLong"));
192+
break;
193+
case "invalidChars":
194+
firstName.setError(t("Errors.firstNameInvalidChars"));
195+
break;
196+
case "forbiddenContent":
197+
case "reserved":
198+
firstName.setError(t("Errors.firstNameNotAllowed"));
199+
break;
200+
default:
201+
firstName.setError(t("Errors.generic"));
202+
}
184203
return;
185204
}
186205

src/components/SignUpForm/SignUpForm.tsx

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import InputHint from "@/components/InputHint";
1212
import Label from "@/components/Label";
1313
import LegalAgreement from "@/components/LegalAgreement";
1414
import { siteConfig } from "@/config/site";
15-
import { FIELD_CONFIGS, validateName } from "@/lib/formValidation";
15+
import { FIELD_CONFIGS, validateFirstName } from "@/lib/formValidation";
1616
import { getStoredAttributionParams } from "@/utils/attributionUtils";
1717
import { isTurnstileEnabled } from "@/utils/utils";
1818
import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile";
@@ -179,9 +179,30 @@ export default function SignUpForm({
179179

180180
// Client-side validation
181181
const formData = new FormData(event.currentTarget);
182-
const validation = validateName(formData.get("first_name")?.toString());
182+
const validation = validateFirstName(
183+
formData.get("first_name")?.toString()
184+
);
183185
if (!validation.isValid) {
184-
setFirstNameError(t("Errors.emptyName"));
186+
switch (validation.error) {
187+
case "empty":
188+
setFirstNameError(t("Errors.emptyName"));
189+
break;
190+
case "tooShort":
191+
setFirstNameError(t("Errors.firstNameTooShort"));
192+
break;
193+
case "tooLong":
194+
setFirstNameError(t("Errors.firstNameTooLong"));
195+
break;
196+
case "invalidChars":
197+
setFirstNameError(t("Errors.firstNameInvalidChars"));
198+
break;
199+
case "forbiddenContent":
200+
case "reserved":
201+
setFirstNameError(t("Errors.firstNameNotAllowed"));
202+
break;
203+
default:
204+
setFirstNameError(t("Errors.generic"));
205+
}
185206
return;
186207
}
187208

src/lib/emailValidation.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { isDisposableEmail } from "fakeout";
2+
3+
/** Disposable domains use the auto-updated dataset bundled with `fakeout`. */
4+
export function isDisposableSignupEmail(email: string): boolean {
5+
return isDisposableEmail(email.trim());
6+
}

0 commit comments

Comments
 (0)