feat: Gravatar opt-in (off by default), configurable in Settings

Previously every account without a custom avatar automatically got
its email MD5-hashed and sent to gravatar.com at signup, with no way
to turn it off. Adds users.useGravatar (default false): removed the
automatic signup-time lookup entirely, and "remove photo" now falls
back to the initials placeholder instead of silently re-deriving a
Gravatar URL. New toggle in Settings -> Profile, off by default,
description explains the MD5-hash-to-third-party tradeoff. Existing
accounts' current avatarUrl is left untouched either way — no
retroactive avatar changes for anyone already using one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-14 09:37:29 +02:00
parent 38516bff63
commit a08588cf85
14 changed files with 5132 additions and 14 deletions
+5
View File
@@ -2,6 +2,11 @@
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
## 0.25.0 — 2026-07-14 09:36
### Added
- **Gravatar is now opt-in, off by default** — previously every account without a custom photo automatically had its email hashed and sent to gravatar.com. Turn it on in Settings → Profile if you want it.
## 0.24.1 — 2026-07-14 09:24
### Fixed
+2 -1
View File
@@ -12,7 +12,7 @@ export default async function SettingsPage() {
const dbUser = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
columns: { bio: true, privateBio: true, isPrivate: true, hasCustomAvatar: true, avatarUrl: true, username: true },
columns: { bio: true, privateBio: true, isPrivate: true, hasCustomAvatar: true, avatarUrl: true, username: true, useGravatar: true },
});
return (
@@ -27,6 +27,7 @@ export default async function SettingsPage() {
isPrivate: dbUser?.isPrivate ?? false,
hasCustomAvatar: dbUser?.hasCustomAvatar ?? false,
username: dbUser?.username ?? null,
useGravatar: dbUser?.useGravatar ?? false,
}}
/>
);
+29 -3
View File
@@ -13,8 +13,12 @@ const PatchSchema = z.object({
privateBio: z.string().max(2000).optional().nullable(),
isPrivate: z.boolean().optional(),
username: z.string().trim().toLowerCase().regex(USERNAME_PATTERN, "3-20 characters, lowercase letters, numbers, and underscores only").optional(),
// A custom-uploaded avatar URL, or null to revert to the Gravatar fallback.
// A custom-uploaded avatar URL, or null to revert to the initials fallback
// (or Gravatar, if useGravatar is on — see below).
avatarUrl: z.string().url().max(2048).optional().nullable(),
// Off by default (Settings → Profile) — Gravatar is looked up by an MD5
// hash of the user's email, sent to a third party.
useGravatar: z.boolean().optional(),
});
export async function PATCH(req: Request) {
@@ -28,11 +32,23 @@ export async function PATCH(req: Request) {
return NextResponse.json({ error: "Username already taken" }, { status: 409 });
}
const { avatarUrl, ...rest } = body.data;
const { avatarUrl, useGravatar, ...rest } = body.data;
const updates: Partial<typeof users.$inferInsert> = { ...rest };
// useGravatar is only ever toggled from the settings form, never alongside
// an avatar upload/removal in the same request — so it's fine for these
// two branches to each independently decide the resulting avatarUrl below.
if (useGravatar !== undefined) {
updates.useGravatar = useGravatar;
if (!(await hasCustomAvatar(session.user.id))) {
updates.avatarUrl = useGravatar ? gravatarUrl(session.user.email) : null;
}
}
if (avatarUrl !== undefined) {
if (avatarUrl === null) {
updates.avatarUrl = gravatarUrl(session.user.email);
const gravatarOptedIn = useGravatar ?? (await getUseGravatar(session.user.id));
updates.avatarUrl = gravatarOptedIn ? gravatarUrl(session.user.email) : null;
updates.hasCustomAvatar = false;
} else {
updates.avatarUrl = avatarUrl;
@@ -43,3 +59,13 @@ export async function PATCH(req: Request) {
await db.update(users).set(updates).where(eq(users.id, session.user.id));
return NextResponse.json({ ok: true, avatarUrl: updates.avatarUrl });
}
async function hasCustomAvatar(userId: string): Promise<boolean> {
const row = await db.query.users.findFirst({ where: eq(users.id, userId), columns: { hasCustomAvatar: true } });
return row?.hasCustomAvatar ?? false;
}
async function getUseGravatar(userId: string): Promise<boolean> {
const row = await db.query.users.findFirst({ where: eq(users.id, userId), columns: { useGravatar: true } });
return row?.useGravatar ?? false;
}
@@ -24,6 +24,7 @@ type UserProps = {
isPrivate: boolean;
hasCustomAvatar: boolean;
username: string | null;
useGravatar: boolean;
};
export function SettingsForm({ user }: { user: UserProps }) {
@@ -43,6 +44,8 @@ export function SettingsForm({ user }: { user: UserProps }) {
const [username, setUsername] = useState(user.username ?? "");
const [savingUsername, setSavingUsername] = useState(false);
const [usernameError, setUsernameError] = useState<string | null>(null);
const [useGravatar, setUseGravatar] = useState(user.useGravatar);
const [savingGravatar, setSavingGravatar] = useState(false);
async function saveProfile() {
setSaving(true);
@@ -102,6 +105,36 @@ export function SettingsForm({ user }: { user: UserProps }) {
}
}
async function saveUseGravatar(checked: boolean) {
setSavingGravatar(true);
const previous = useGravatar;
setUseGravatar(checked);
try {
const res = await fetch("/api/v1/users/me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ useGravatar: checked }),
});
if (res.ok) {
// Only affects the displayed avatar for accounts without a custom
// upload — matches the server's own condition in api/v1/users/me.
if (!hasCustomAvatar) {
const data = await res.json() as { avatarUrl?: string | null };
setAvatarImage(data.avatarUrl ?? null);
}
toast.success(t_common("saved"));
} else {
setUseGravatar(previous);
toast.error(t_common("saveFailed"));
}
} catch {
setUseGravatar(previous);
toast.error(t_common("saveFailed"));
} finally {
setSavingGravatar(false);
}
}
async function savePrivacy(checked: boolean) {
setSavingPrivacy(true);
const previous = isPrivate;
@@ -138,6 +171,18 @@ export function SettingsForm({ user }: { user: UserProps }) {
setHasCustomAvatar(custom);
}}
/>
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
<div>
<p className="text-sm font-medium">{t("useGravatar")}</p>
<p className="text-xs text-muted-foreground max-w-prose">{t("useGravatarDescription")}</p>
</div>
<Switch
id="use-gravatar"
checked={useGravatar}
disabled={savingGravatar}
onCheckedChange={(checked) => { void saveUseGravatar(checked); }}
/>
</div>
<div className="space-y-2">
<Label>{t("displayName")}</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
+5 -7
View File
@@ -5,7 +5,6 @@ import { db, users, sessions, accounts, verifications, twoFactors, eq, count } f
import { sendEmail, verifyEmailHtml, resetPasswordHtml, welcomeHtml } from "@/lib/email";
import { isSignupsDisabled } from "@/lib/site-settings";
import { findValidInvite, consumeInvite, INVITE_COOKIE } from "@/lib/invites";
import { gravatarUrl } from "@/lib/gravatar";
import { generateUniqueUsername } from "@/lib/username";
export const auth = betterAuth({
@@ -130,12 +129,11 @@ export const auth = betterAuth({
await db.update(users).set({ role: "admin" }).where(eq(users.id, user.id));
}
// Only email/password signups land here without an avatar already
// set (OAuth providers set `image` — mapped to avatarUrl — before
// this hook runs) — give them a Gravatar-backed default.
if (!user.image) {
await db.update(users).set({ avatarUrl: gravatarUrl(user.email) }).where(eq(users.id, user.id));
}
// Gravatar is opt-in (Settings → Profile), off by default — no
// automatic Gravatar lookup at signup. OAuth providers still set
// `image` (mapped to avatarUrl) before this hook runs; email/
// password signups just get the initials fallback until the user
// uploads a photo or opts into Gravatar.
// Consume the invite that gated this signup, if any (regardless of
// whether signups have since been re-enabled/disabled).
+8 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.24.1";
export const APP_VERSION = "0.25.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.25.0",
date: "2026-07-14 09:36",
added: [
"**Gravatar is now opt-in, off by default** — previously every account without a custom photo automatically had its email hashed and sent to gravatar.com. Turn it on in Settings → Profile if you want it.",
],
},
{
version: "0.24.1",
date: "2026-07-14 09:24",
+2
View File
@@ -1151,6 +1151,8 @@
},
"profile": "Profile",
"changePhoto": "Change photo",
"useGravatar": "Use Gravatar",
"useGravatarDescription": "Show a Gravatar photo when you haven't uploaded one — sends an MD5 hash of your email to gravatar.com. Off by default.",
"removePhoto": "Remove photo",
"avatarUploadSuccess": "Profile photo updated",
"avatarUploadFailed": "Failed to update profile photo",
+2
View File
@@ -1139,6 +1139,8 @@
},
"profile": "Profil",
"changePhoto": "Changer la photo",
"useGravatar": "Utiliser Gravatar",
"useGravatarDescription": "Afficher une photo Gravatar quand vous n'en avez pas mis en ligne — envoie un hash MD5 de votre e-mail à gravatar.com. Désactivé par défaut.",
"removePhoto": "Supprimer la photo",
"avatarUploadSuccess": "Photo de profil mise à jour",
"avatarUploadFailed": "Échec de la mise à jour de la photo de profil",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.24.1",
"version": "0.25.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "epicure",
"version": "0.24.1",
"version": "0.25.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "use_gravatar" boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
@@ -267,6 +267,13 @@
"when": 1783976055649,
"tag": "0037_big_nehzno",
"breakpoints": true
},
{
"idx": 38,
"version": "7",
"when": 1784014063004,
"tag": "0038_peaceful_norrin_radd",
"breakpoints": true
}
]
}
+4
View File
@@ -21,6 +21,10 @@ export const users = pgTable("users", {
name: text("name").notNull(),
avatarUrl: text("avatar_url"),
hasCustomAvatar: boolean("has_custom_avatar").notNull().default(false),
// Off by default — Gravatar is looked up by an MD5 hash of the user's
// email, sent to a third party (gravatar.com), which some users won't want
// regardless of MD5 being effectively reversible for a known email.
useGravatar: boolean("use_gravatar").notNull().default(false),
bio: text("bio"),
privateBio: text("private_bio"),
isPrivate: boolean("is_private").notNull().default(false),