Skip to content

Commit 9576b5b

Browse files
authored
feat: add Gravatar integration with privacy-preserving proxy (#606)
1 parent 2a38fa4 commit 9576b5b

File tree

13 files changed

+422
-14
lines changed

13 files changed

+422
-14
lines changed

docs/content/scripts/gravatar.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
3+
title: Gravatar
4+
description: Use Gravatar in your Nuxt app.
5+
links:
6+
- label: Source
7+
icon: i-simple-icons-github
8+
to: https://github.com/nuxt/scripts/blob/main/src/runtime/registry/gravatar.ts
9+
size: xs
10+
- label: "<ScriptGravatar>"
11+
icon: i-simple-icons-github
12+
to: https://github.com/nuxt/scripts/blob/main/src/runtime/components/ScriptGravatar.vue
13+
size: xs
14+
15+
---
16+
17+
[Gravatar](https://gravatar.com) provides globally recognized avatars linked to email addresses. Nuxt Scripts provides a privacy-preserving integration that proxies avatar requests through your own server, preventing Gravatar from tracking your users.
18+
19+
::script-stats
20+
::
21+
22+
::script-docs
23+
::
24+
25+
::script-types
26+
::

src/module.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ export default defineNuxtModule<ModuleOptions>({
350350
googleStaticMapsProxy: config.googleStaticMapsProxy?.enabled
351351
? { enabled: true, cacheMaxAge: config.googleStaticMapsProxy.cacheMaxAge }
352352
: undefined,
353-
}
353+
} as any
354354

355355
// Merge registry config with existing runtimeConfig.public.scripts for proper env var resolution
356356
// Both scripts.registry and runtimeConfig.public.scripts should be supported
@@ -428,7 +428,7 @@ export default defineNuxtModule<ModuleOptions>({
428428
const partytownConfig = (nuxt.options as any).partytown || {}
429429
const existingForwards = partytownConfig.forward || []
430430
const newForwards = [...new Set([...existingForwards, ...requiredForwards])]
431-
;(nuxt.options as any).partytown = { ...partytownConfig, forward: newForwards }
431+
; (nuxt.options as any).partytown = { ...partytownConfig, forward: newForwards }
432432
logger.info(`[partytown] Auto-configured forwards: ${requiredForwards.join(', ')}`)
433433
}
434434
}
@@ -703,6 +703,21 @@ export default defineNuxtModule<ModuleOptions>({
703703
})
704704
}
705705

706+
// Add Gravatar proxy handler when registry.gravatar is enabled
707+
if (config.registry?.gravatar) {
708+
const gravatarConfig = typeof config.registry.gravatar === 'object' && !Array.isArray(config.registry.gravatar)
709+
? config.registry.gravatar as Record<string, any>
710+
: {}
711+
nuxt.options.runtimeConfig.public['nuxt-scripts'] = defu(
712+
{ gravatarProxy: { cacheMaxAge: gravatarConfig.cacheMaxAge ?? 3600 } },
713+
nuxt.options.runtimeConfig.public['nuxt-scripts'] as any,
714+
) as any
715+
addServerHandler({
716+
route: '/_scripts/gravatar-proxy',
717+
handler: await resolvePath('./runtime/server/gravatar-proxy'),
718+
})
719+
}
720+
706721
// Add X/Twitter embed proxy handlers
707722
addServerHandler({
708723
route: '/api/_scripts/x-embed',

src/proxy-configs.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,19 @@ function buildProxyConfig(collectPrefix: string) {
318318
[`${collectPrefix}/vercel/**`]: { proxy: 'https://va.vercel-scripts.com/**' },
319319
},
320320
},
321+
322+
gravatar: {
323+
// Gravatar: avatar proxy — IP anonymized, rest not needed
324+
privacy: { ip: true, userAgent: false, language: false, screen: false, timezone: false, hardware: false },
325+
rewrite: [
326+
{ from: 'secure.gravatar.com', to: `${collectPrefix}/gravatar` },
327+
{ from: 'gravatar.com/avatar', to: `${collectPrefix}/gravatar-avatar` },
328+
],
329+
routes: {
330+
[`${collectPrefix}/gravatar/**`]: { proxy: 'https://secure.gravatar.com/**' },
331+
[`${collectPrefix}/gravatar-avatar/**`]: { proxy: 'https://gravatar.com/avatar/**' },
332+
},
333+
},
321334
} satisfies Record<string, ProxyConfig>
322335
}
323336

src/registry-types.json

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,13 @@
336336
"code": "export const GoogleTagManagerOptions = object({\n /**\n * GTM container ID (format: GTM-XXXXXX)\n * @see https://developers.google.com/tag-platform/tag-manager/web#install-the-container\n */\n id: string(),\n\n /**\n * Optional dataLayer variable name\n * @default 'dataLayer'\n * @see https://developers.google.com/tag-platform/tag-manager/web/datalayer#rename_the_data_layer\n */\n l: optional(string()),\n\n /**\n * Authentication token for environment-specific container versions\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n auth: optional(string()),\n\n /**\n * Preview environment name\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n preview: optional(string()),\n\n /** Forces GTM cookies to take precedence when true */\n cookiesWin: optional(union([boolean(), literal('x')])),\n\n /**\n * Enables debug mode when true\n * @see https://support.google.com/tagmanager/answer/6107056\n */\n debug: optional(union([boolean(), literal('x')])),\n\n /**\n * No Personal Advertising - disables advertising features when true\n * @see https://developers.google.com/tag-platform/tag-manager/templates/consent-apis\n */\n npa: optional(union([boolean(), literal('1')])),\n\n /** Custom dataLayer name (alternative to \"l\" property) */\n dataLayer: optional(string()),\n\n /**\n * Environment name for environment-specific container\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n envName: optional(string()),\n\n /** Referrer policy for analytics requests */\n authReferrerPolicy: optional(string()),\n\n /**\n * Default consent settings for GTM\n * @see https://developers.google.com/tag-platform/tag-manager/templates/consent-apis\n */\n defaultConsent: optional(record(string(), union([string(), number()]))),\n})"
337337
}
338338
],
339+
"gravatar": [
340+
{
341+
"name": "GravatarApi",
342+
"kind": "interface",
343+
"code": "export interface GravatarApi {\n /**\n * Get a proxied avatar URL for a given SHA256 email hash.\n * When firstParty mode is enabled, this routes through your server.\n */\n getAvatarUrl: (hash: string, options?: { size?: number, default?: string, rating?: string }) => string\n /**\n * Get a proxied avatar URL using the server-side hashing endpoint.\n * The email is sent to YOUR server (not Gravatar) for hashing.\n * Only available when the gravatar proxy is enabled.\n */\n getAvatarUrlFromEmail: (email: string, options?: { size?: number, default?: string, rating?: string }) => string\n}"
344+
}
345+
],
339346
"hotjar": [
340347
{
341348
"name": "HotjarOptions",
@@ -690,6 +697,38 @@
690697
"code": "export interface UmamiAnalyticsApi {\n track: ((payload?: Record<string, any>) => void) & ((event_name: string, event_data: Record<string, any>) => void)\n identify: (session_data?: Record<string, any> | string) => void\n}"
691698
}
692699
],
700+
"vercel-analytics": [
701+
{
702+
"name": "VercelAnalyticsOptions",
703+
"kind": "const",
704+
"code": "export const VercelAnalyticsOptions = object({\n /**\n * The DSN of the project to send events to.\n * Only required when self-hosting or deploying outside of Vercel.\n */\n dsn: optional(string()),\n /**\n * Whether to disable automatic page view tracking on route changes.\n * Set to true if you want to manually call pageview().\n */\n disableAutoTrack: optional(boolean()),\n /**\n * The mode to use for the analytics script.\n * - `auto` - Automatically detect the environment (default)\n * - `production` - Always use production script\n * - `development` - Always use development script (logs to console)\n */\n mode: optional(union([literal('auto'), literal('development'), literal('production')])),\n /**\n * Whether to enable debug logging.\n * Automatically enabled in development/test environments.\n */\n debug: optional(boolean()),\n /**\n * Custom endpoint for data collection.\n * Useful for self-hosted or proxied setups.\n */\n endpoint: optional(string()),\n})"
705+
},
706+
{
707+
"name": "AllowedPropertyValues",
708+
"kind": "type",
709+
"code": "export type AllowedPropertyValues = string | number | boolean | null"
710+
},
711+
{
712+
"name": "VercelAnalyticsMode",
713+
"kind": "type",
714+
"code": "export type VercelAnalyticsMode = 'auto' | 'development' | 'production'"
715+
},
716+
{
717+
"name": "BeforeSendEvent",
718+
"kind": "interface",
719+
"code": "export interface BeforeSendEvent {\n type: 'pageview' | 'event'\n url: string\n}"
720+
},
721+
{
722+
"name": "BeforeSend",
723+
"kind": "type",
724+
"code": "export type BeforeSend = (event: BeforeSendEvent) => BeforeSendEvent | null"
725+
},
726+
{
727+
"name": "VercelAnalyticsApi",
728+
"kind": "interface",
729+
"code": "export interface VercelAnalyticsApi {\n va: (event: string, properties?: unknown) => void\n track: (name: string, properties?: Record<string, AllowedPropertyValues>) => void\n pageview: (options?: { route?: string | null, path?: string }) => void\n}"
730+
}
731+
],
693732
"vimeo-player": [
694733
{
695734
"name": "Constructor",
@@ -778,18 +817,6 @@
778817
"code": "const ScriptYouTubePlayerDefaults = {\n \"cookies\": \"false\",\n \"trigger\": \"'mousedown'\",\n \"thumbnailSize\": \"'hq720'\",\n \"webp\": \"true\",\n \"playerVars\": \"{ autoplay: 0, playsinline: 1 }\",\n \"width\": \"640\",\n \"height\": \"360\",\n \"ratio\": \"'16/9'\",\n \"placeholderObjectFit\": \"'cover'\"\n}"
779818
}
780819
],
781-
"vercel-analytics": [
782-
{
783-
"name": "VercelAnalyticsOptions",
784-
"kind": "const",
785-
"code": "export const VercelAnalyticsOptions = object({\n /**\n * The DSN of the project to send events to.\n * Only required when self-hosting or deploying outside of Vercel.\n */\n dsn: optional(string()),\n /**\n * Whether to disable automatic page view tracking on route changes.\n * Set to true if you want to manually call pageview().\n */\n disableAutoTrack: optional(boolean()),\n /**\n * The mode to use for the analytics script.\n * - `auto` - Automatically detect the environment (default)\n * - `production` - Always use production script\n * - `development` - Always use development script (logs to console)\n */\n mode: optional(union([literal('auto'), literal('development'), literal('production')])),\n /**\n * Whether to enable debug logging.\n * Automatically enabled in development/test environments.\n */\n debug: optional(boolean()),\n /**\n * Custom endpoint for data collection.\n * Useful for self-hosted or proxied setups.\n */\n endpoint: optional(string()),\n})"
786-
},
787-
{
788-
"name": "VercelAnalyticsApi",
789-
"kind": "interface",
790-
"code": "export interface VercelAnalyticsApi {\n va: (event: string, properties?: unknown) => void\n track: (name: string, properties?: Record<string, AllowedPropertyValues>) => void\n pageview: (options?: { route?: string | null, path?: string }) => void\n}"
791-
}
792-
],
793820
"carbon-ads": [
794821
{
795822
"name": "ScriptCarbonAdsProps",

src/registry.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,5 +442,16 @@ export async function registry(resolve?: (path: string, opts?: ResolvePathOption
442442
from: await resolve('./runtime/registry/umami-analytics'),
443443
},
444444
},
445+
{
446+
label: 'Gravatar',
447+
proxy: 'gravatar',
448+
src: 'https://secure.gravatar.com/js/gprofiles.js',
449+
category: 'utility',
450+
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 256 256"><circle cx="128" cy="128" r="128" fill="#1d4fc4"/><path d="M128 28c-55.2 0-100 44.8-100 100s44.8 100 100 100 100-44.8 100-100S183.2 28 128 28zm0 180c-44.1 0-80-35.9-80-80s35.9-80 80-80 80 35.9 80 80-35.9 80-80 80z" fill="#fff"/></svg>`,
451+
import: {
452+
name: 'useScriptGravatar',
453+
from: await resolve('./runtime/registry/gravatar'),
454+
},
455+
},
445456
]
446457
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<script setup lang="ts">
2+
import { computed, onMounted, ref, useAttrs } from 'vue'
3+
import { useScriptGravatar } from '../registry/gravatar'
4+
5+
const props = withDefaults(defineProps<{
6+
/** Email address — sent to your server proxy for hashing, not sent to Gravatar */
7+
email?: string
8+
/** Pre-computed SHA256 hash of the email */
9+
hash?: string
10+
/** Avatar size in pixels */
11+
size?: number
12+
/** Default avatar style when no Gravatar exists */
13+
default?: string
14+
/** Content rating filter */
15+
rating?: string
16+
/** Enable hovercards on hover */
17+
hovercards?: boolean
18+
}>(), {
19+
size: 80,
20+
default: 'mp',
21+
rating: 'g',
22+
hovercards: false,
23+
})
24+
25+
const attrs = useAttrs()
26+
const imgSrc = ref('')
27+
28+
const { onLoaded } = useScriptGravatar()
29+
30+
const queryOverrides = computed(() => ({
31+
size: props.size,
32+
default: props.default,
33+
rating: props.rating,
34+
}))
35+
36+
onMounted(() => {
37+
onLoaded((api) => {
38+
if (props.email) {
39+
imgSrc.value = api.getAvatarUrlFromEmail(props.email, queryOverrides.value)
40+
}
41+
else if (props.hash) {
42+
imgSrc.value = api.getAvatarUrl(props.hash, queryOverrides.value)
43+
}
44+
})
45+
})
46+
</script>
47+
48+
<template>
49+
<img
50+
v-if="imgSrc"
51+
:src="imgSrc"
52+
:width="size"
53+
:height="size"
54+
:class="{ hovercard: hovercards }"
55+
v-bind="attrs"
56+
:alt="attrs.alt as string || 'Gravatar avatar'"
57+
loading="lazy"
58+
>
59+
<span
60+
v-else
61+
:style="{ display: 'inline-block', width: `${size}px`, height: `${size}px`, borderRadius: '50%', background: '#e0e0e0' }"
62+
/>
63+
</template>

src/runtime/registry/gravatar.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { RegistryScriptInput } from '#nuxt-scripts/types'
2+
import { useRegistryScript } from '#nuxt-scripts/utils'
3+
import { GravatarOptions } from './schemas'
4+
5+
export type GravatarInput = RegistryScriptInput<typeof GravatarOptions>
6+
7+
export interface GravatarApi {
8+
/**
9+
* Get a proxied avatar URL for a given SHA256 email hash.
10+
* When firstParty mode is enabled, this routes through your server.
11+
*/
12+
getAvatarUrl: (hash: string, options?: { size?: number, default?: string, rating?: string }) => string
13+
/**
14+
* Get a proxied avatar URL using the server-side hashing endpoint.
15+
* The email is sent to YOUR server (not Gravatar) for hashing.
16+
* Only available when the gravatar proxy is enabled.
17+
*/
18+
getAvatarUrlFromEmail: (email: string, options?: { size?: number, default?: string, rating?: string }) => string
19+
}
20+
21+
export function useScriptGravatar<T extends GravatarApi>(_options?: GravatarInput) {
22+
return useRegistryScript<T, typeof GravatarOptions>(_options?.key || 'gravatar', (options) => {
23+
const size = options?.size ?? 80
24+
const defaultImg = options?.default ?? 'mp'
25+
const rating = options?.rating ?? 'g'
26+
27+
const buildQuery = (overrides?: { size?: number, default?: string, rating?: string }) => {
28+
const params = new URLSearchParams()
29+
params.set('s', String(overrides?.size ?? size))
30+
params.set('d', overrides?.default ?? defaultImg)
31+
params.set('r', overrides?.rating ?? rating)
32+
return params.toString()
33+
}
34+
35+
return {
36+
scriptInput: {
37+
src: 'https://secure.gravatar.com/js/gprofiles.js',
38+
},
39+
schema: import.meta.dev ? GravatarOptions : undefined,
40+
scriptOptions: {
41+
use: () => ({
42+
getAvatarUrl: (hash: string, overrides?: { size?: number, default?: string, rating?: string }) => {
43+
return `/_scripts/gravatar-proxy?hash=${encodeURIComponent(hash)}&${buildQuery(overrides)}`
44+
},
45+
getAvatarUrlFromEmail: (email: string, overrides?: { size?: number, default?: string, rating?: string }) => {
46+
return `/_scripts/gravatar-proxy?email=${encodeURIComponent(email)}&${buildQuery(overrides)}`
47+
},
48+
}),
49+
},
50+
}
51+
}, _options)
52+
}

src/runtime/registry/schemas.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,3 +921,27 @@ export const XPixelOptions = object({
921921
*/
922922
version: optional(string()),
923923
})
924+
925+
export const GravatarOptions = object({
926+
/**
927+
* Cache duration for proxied avatar images in seconds.
928+
* @default 3600
929+
*/
930+
cacheMaxAge: optional(number()),
931+
/**
932+
* Default image to show when no Gravatar exists.
933+
* @see https://docs.gravatar.com/general/images/#default-image
934+
* @default 'mp'
935+
*/
936+
default: optional(string()),
937+
/**
938+
* Avatar size in pixels (1-2048).
939+
* @default 80
940+
*/
941+
size: optional(number()),
942+
/**
943+
* Content rating filter.
944+
* @default 'g'
945+
*/
946+
rating: optional(string()),
947+
})
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { useRuntimeConfig } from '#imports'
2+
import { createError, defineEventHandler, getHeader, getQuery, setHeader } from 'h3'
3+
import { $fetch } from 'ofetch'
4+
import { withQuery } from 'ufo'
5+
6+
export default defineEventHandler(async (event) => {
7+
const runtimeConfig = useRuntimeConfig()
8+
const proxyConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.gravatarProxy
9+
10+
// Validate referer to prevent external abuse
11+
const referer = getHeader(event, 'referer')
12+
const host = getHeader(event, 'host')
13+
if (referer && host) {
14+
let refererHost: string | undefined
15+
try {
16+
refererHost = new URL(referer).host
17+
}
18+
catch {}
19+
if (refererHost && refererHost !== host) {
20+
throw createError({
21+
statusCode: 403,
22+
statusMessage: 'Invalid referer',
23+
})
24+
}
25+
}
26+
27+
const query = getQuery(event)
28+
let hash = query.hash as string | undefined
29+
const email = query.email as string | undefined
30+
31+
// Server-side hashing: email never leaves your server
32+
if (!hash && email) {
33+
const encoder = new TextEncoder()
34+
const data = encoder.encode(email.trim().toLowerCase())
35+
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
36+
hash = Array.from(new Uint8Array(hashBuffer))
37+
.map(b => b.toString(16).padStart(2, '0'))
38+
.join('')
39+
}
40+
41+
if (!hash) {
42+
throw createError({
43+
statusCode: 400,
44+
statusMessage: 'Either hash or email parameter is required',
45+
})
46+
}
47+
48+
// Build Gravatar URL with query params
49+
const size = query.s as string || '80'
50+
const defaultImg = query.d as string || 'mp'
51+
const rating = query.r as string || 'g'
52+
53+
const gravatarUrl = withQuery(`https://www.gravatar.com/avatar/${hash}`, {
54+
s: size,
55+
d: defaultImg,
56+
r: rating,
57+
})
58+
59+
const response = await $fetch.raw(gravatarUrl, {
60+
responseType: 'arrayBuffer',
61+
headers: {
62+
'User-Agent': 'Nuxt Scripts Gravatar Proxy',
63+
},
64+
}).catch((error: any) => {
65+
throw createError({
66+
statusCode: error.statusCode || 500,
67+
statusMessage: error.statusMessage || 'Failed to fetch Gravatar avatar',
68+
})
69+
})
70+
71+
const cacheMaxAge = proxyConfig?.cacheMaxAge ?? 3600
72+
setHeader(event, 'Content-Type', response.headers.get('content-type') || 'image/jpeg')
73+
setHeader(event, 'Cache-Control', `public, max-age=${cacheMaxAge}, s-maxage=${cacheMaxAge}`)
74+
setHeader(event, 'Vary', 'Accept-Encoding')
75+
76+
return response._data
77+
})

0 commit comments

Comments
 (0)