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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 +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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.24.1",
|
||||
"version": "0.25.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user