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.
|
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
|
## 0.24.1 — 2026-07-14 09:24
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default async function SettingsPage() {
|
|||||||
|
|
||||||
const dbUser = await db.query.users.findFirst({
|
const dbUser = await db.query.users.findFirst({
|
||||||
where: eq(users.id, session.user.id),
|
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 (
|
return (
|
||||||
@@ -27,6 +27,7 @@ export default async function SettingsPage() {
|
|||||||
isPrivate: dbUser?.isPrivate ?? false,
|
isPrivate: dbUser?.isPrivate ?? false,
|
||||||
hasCustomAvatar: dbUser?.hasCustomAvatar ?? false,
|
hasCustomAvatar: dbUser?.hasCustomAvatar ?? false,
|
||||||
username: dbUser?.username ?? null,
|
username: dbUser?.username ?? null,
|
||||||
|
useGravatar: dbUser?.useGravatar ?? false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,8 +13,12 @@ const PatchSchema = z.object({
|
|||||||
privateBio: z.string().max(2000).optional().nullable(),
|
privateBio: z.string().max(2000).optional().nullable(),
|
||||||
isPrivate: z.boolean().optional(),
|
isPrivate: z.boolean().optional(),
|
||||||
username: z.string().trim().toLowerCase().regex(USERNAME_PATTERN, "3-20 characters, lowercase letters, numbers, and underscores only").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(),
|
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) {
|
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 });
|
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 };
|
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 !== undefined) {
|
||||||
if (avatarUrl === null) {
|
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;
|
updates.hasCustomAvatar = false;
|
||||||
} else {
|
} else {
|
||||||
updates.avatarUrl = avatarUrl;
|
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));
|
await db.update(users).set(updates).where(eq(users.id, session.user.id));
|
||||||
return NextResponse.json({ ok: true, avatarUrl: updates.avatarUrl });
|
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;
|
isPrivate: boolean;
|
||||||
hasCustomAvatar: boolean;
|
hasCustomAvatar: boolean;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
|
useGravatar: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SettingsForm({ user }: { user: UserProps }) {
|
export function SettingsForm({ user }: { user: UserProps }) {
|
||||||
@@ -43,6 +44,8 @@ export function SettingsForm({ user }: { user: UserProps }) {
|
|||||||
const [username, setUsername] = useState(user.username ?? "");
|
const [username, setUsername] = useState(user.username ?? "");
|
||||||
const [savingUsername, setSavingUsername] = useState(false);
|
const [savingUsername, setSavingUsername] = useState(false);
|
||||||
const [usernameError, setUsernameError] = useState<string | null>(null);
|
const [usernameError, setUsernameError] = useState<string | null>(null);
|
||||||
|
const [useGravatar, setUseGravatar] = useState(user.useGravatar);
|
||||||
|
const [savingGravatar, setSavingGravatar] = useState(false);
|
||||||
|
|
||||||
async function saveProfile() {
|
async function saveProfile() {
|
||||||
setSaving(true);
|
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) {
|
async function savePrivacy(checked: boolean) {
|
||||||
setSavingPrivacy(true);
|
setSavingPrivacy(true);
|
||||||
const previous = isPrivate;
|
const previous = isPrivate;
|
||||||
@@ -138,6 +171,18 @@ export function SettingsForm({ user }: { user: UserProps }) {
|
|||||||
setHasCustomAvatar(custom);
|
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">
|
<div className="space-y-2">
|
||||||
<Label>{t("displayName")}</Label>
|
<Label>{t("displayName")}</Label>
|
||||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
<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 { sendEmail, verifyEmailHtml, resetPasswordHtml, welcomeHtml } from "@/lib/email";
|
||||||
import { isSignupsDisabled } from "@/lib/site-settings";
|
import { isSignupsDisabled } from "@/lib/site-settings";
|
||||||
import { findValidInvite, consumeInvite, INVITE_COOKIE } from "@/lib/invites";
|
import { findValidInvite, consumeInvite, INVITE_COOKIE } from "@/lib/invites";
|
||||||
import { gravatarUrl } from "@/lib/gravatar";
|
|
||||||
import { generateUniqueUsername } from "@/lib/username";
|
import { generateUniqueUsername } from "@/lib/username";
|
||||||
|
|
||||||
export const auth = betterAuth({
|
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));
|
await db.update(users).set({ role: "admin" }).where(eq(users.id, user.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only email/password signups land here without an avatar already
|
// Gravatar is opt-in (Settings → Profile), off by default — no
|
||||||
// set (OAuth providers set `image` — mapped to avatarUrl — before
|
// automatic Gravatar lookup at signup. OAuth providers still set
|
||||||
// this hook runs) — give them a Gravatar-backed default.
|
// `image` (mapped to avatarUrl) before this hook runs; email/
|
||||||
if (!user.image) {
|
// password signups just get the initials fallback until the user
|
||||||
await db.update(users).set({ avatarUrl: gravatarUrl(user.email) }).where(eq(users.id, user.id));
|
// uploads a photo or opts into Gravatar.
|
||||||
}
|
|
||||||
|
|
||||||
// Consume the invite that gated this signup, if any (regardless of
|
// Consume the invite that gated this signup, if any (regardless of
|
||||||
// whether signups have since been re-enabled/disabled).
|
// whether signups have since been re-enabled/disabled).
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// 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 = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.24.1",
|
||||||
date: "2026-07-14 09:24",
|
date: "2026-07-14 09:24",
|
||||||
|
|||||||
@@ -1151,6 +1151,8 @@
|
|||||||
},
|
},
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"changePhoto": "Change photo",
|
"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",
|
"removePhoto": "Remove photo",
|
||||||
"avatarUploadSuccess": "Profile photo updated",
|
"avatarUploadSuccess": "Profile photo updated",
|
||||||
"avatarUploadFailed": "Failed to update profile photo",
|
"avatarUploadFailed": "Failed to update profile photo",
|
||||||
|
|||||||
@@ -1139,6 +1139,8 @@
|
|||||||
},
|
},
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"changePhoto": "Changer la photo",
|
"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",
|
"removePhoto": "Supprimer la photo",
|
||||||
"avatarUploadSuccess": "Photo de profil mise à jour",
|
"avatarUploadSuccess": "Photo de profil mise à jour",
|
||||||
"avatarUploadFailed": "Échec de la mise à jour de la photo de profil",
|
"avatarUploadFailed": "Échec de la mise à jour de la photo de profil",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.24.1",
|
"version": "0.25.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.24.1",
|
"version": "0.25.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"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,
|
"when": 1783976055649,
|
||||||
"tag": "0037_big_nehzno",
|
"tag": "0037_big_nehzno",
|
||||||
"breakpoints": true
|
"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(),
|
name: text("name").notNull(),
|
||||||
avatarUrl: text("avatar_url"),
|
avatarUrl: text("avatar_url"),
|
||||||
hasCustomAvatar: boolean("has_custom_avatar").notNull().default(false),
|
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"),
|
bio: text("bio"),
|
||||||
privateBio: text("private_bio"),
|
privateBio: text("private_bio"),
|
||||||
isPrivate: boolean("is_private").notNull().default(false),
|
isPrivate: boolean("is_private").notNull().default(false),
|
||||||
|
|||||||
Reference in New Issue
Block a user