diff --git a/docs/content/scripts/utility/gravatar.md b/docs/content/scripts/utility/gravatar.md new file mode 100644 index 00000000..fdfe6dd3 --- /dev/null +++ b/docs/content/scripts/utility/gravatar.md @@ -0,0 +1,170 @@ +--- +title: Gravatar +description: Add Gravatar avatars and hovercards to your Nuxt app with privacy-preserving server-side proxying. +links: +- label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/src/runtime/registry/gravatar.ts + size: xs +- label: Gravatar Developer Docs + icon: i-simple-icons-gravatar + to: https://docs.gravatar.com/ + size: xs +--- + +[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. + +## Privacy Benefits + +When using the Gravatar proxy: + +- **User IPs are hidden** from Gravatar's servers +- **Email hashes stay server-side** — the `?email=` parameter is SHA256-hashed on YOUR server, so hashes never appear in client HTML +- **Hovercards JS is bundled** through your domain via firstParty mode +- **Configurable caching** reduces requests to Gravatar + +## Nuxt Config Setup + +Enable the Gravatar proxy in your `nuxt.config.ts`: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + registry: { + gravatar: true + }, + gravatarProxy: { + enabled: true, + cacheMaxAge: 3600 // 1 hour (default) + } + } +}) +``` + +## useScriptGravatar + +The `useScriptGravatar` composable loads the Gravatar hovercards script and provides avatar URL helpers. + +```ts +const { proxy } = useScriptGravatar() + +// Get avatar URL from a pre-computed SHA256 hash +const url = proxy.getAvatarUrl('sha256hash', { size: 200 }) + +// Get avatar URL from email (hashed server-side) +const url = proxy.getAvatarUrlFromEmail('user@example.com', { size: 200 }) +``` + +Please follow the [Registry Scripts](/docs/guides/registry-scripts) guide to learn more about advanced usage. + +### GravatarApi + +```ts +export interface GravatarApi { + getAvatarUrl: (hash: string, options?: { + size?: number + default?: string + rating?: string + }) => string + getAvatarUrlFromEmail: (email: string, options?: { + size?: number + default?: string + rating?: string + }) => string +} +``` + +### Config Schema + +```ts +export const GravatarOptions = object({ + cacheMaxAge: optional(number()), + default: optional(string()), // 'mp', '404', 'robohash', etc. + size: optional(number()), // 1-2048 + rating: optional(string()), // 'g', 'pg', 'r', 'x' +}) +``` + +## ScriptGravatar Component + +The `` component provides a simple way to render Gravatar avatars: + +```vue + +``` + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `email` | `string` | — | Email address (hashed server-side, never exposed in HTML) | +| `hash` | `string` | — | Pre-computed SHA256 email hash | +| `size` | `number` | `80` | Avatar size in pixels | +| `default` | `string` | `'mp'` | Default image when no Gravatar exists | +| `rating` | `string` | `'g'` | Content rating filter | +| `hovercards` | `boolean` | `false` | Add hovercards class for profile pop-ups | + +## Example + +### Basic Avatar with Proxy + +```vue + + + +``` + +### With Hovercards + +Load the Gravatar hovercards script to show profile pop-ups on hover: + +```vue + + + +``` + +## Gravatar Proxy Server Handler + +The proxy handler at `/_scripts/gravatar-proxy` accepts: + +| Parameter | Description | +|-----------|-------------| +| `hash` | SHA256 email hash | +| `email` | Raw email (hashed server-side) | +| `s` | Size in pixels (default: 80) | +| `d` | Default image (default: mp) | +| `r` | Rating filter (default: g) | + +``` +/_scripts/gravatar-proxy?email=user@example.com&s=200&d=mp&r=g +/_scripts/gravatar-proxy?hash=abc123...&s=80 +``` diff --git a/src/module.ts b/src/module.ts index 45033fa6..07782722 100644 --- a/src/module.ts +++ b/src/module.ts @@ -192,6 +192,23 @@ export interface ModuleOptions { */ cacheMaxAge?: number } + /** + * Gravatar proxy configuration. + * Proxies avatar images through your server for privacy (hides email hashes and IPs from Gravatar). + * Supports server-side email hashing so the hash never appears in client HTML. + */ + gravatarProxy?: { + /** + * Enable proxying Gravatar avatars through your own origin. + * @default false + */ + enabled?: boolean + /** + * Cache duration for avatar images in seconds. + * @default 3600 (1 hour) + */ + cacheMaxAge?: number + } /** * Whether the module is enabled. * @@ -234,6 +251,10 @@ export default defineNuxtModule({ enabled: false, cacheMaxAge: 3600, }, + gravatarProxy: { + enabled: false, + cacheMaxAge: 3600, + }, enabled: true, debug: false, }, @@ -271,7 +292,10 @@ export default defineNuxtModule({ googleStaticMapsProxy: config.googleStaticMapsProxy?.enabled ? { enabled: true, cacheMaxAge: config.googleStaticMapsProxy.cacheMaxAge } : undefined, - } + gravatarProxy: config.gravatarProxy?.enabled + ? { enabled: true, cacheMaxAge: config.gravatarProxy.cacheMaxAge } + : undefined, + } as any // Merge registry config with existing runtimeConfig.public.scripts for proper env var resolution // Both scripts.registry and runtimeConfig.public.scripts should be supported @@ -348,7 +372,7 @@ export default defineNuxtModule({ const partytownConfig = (nuxt.options as any).partytown || {} const existingForwards = partytownConfig.forward || [] const newForwards = [...new Set([...existingForwards, ...requiredForwards])] - ;(nuxt.options as any).partytown = { ...partytownConfig, forward: newForwards } + ; (nuxt.options as any).partytown = { ...partytownConfig, forward: newForwards } logger.info(`[partytown] Auto-configured forwards: ${requiredForwards.join(', ')}`) } } @@ -664,6 +688,14 @@ export default defineNuxtPlugin({ }) } + // Add Gravatar proxy handler if enabled + if (config.gravatarProxy?.enabled) { + addServerHandler({ + route: '/_scripts/gravatar-proxy', + handler: await resolvePath('./runtime/server/gravatar-proxy'), + }) + } + // Add X/Twitter embed proxy handlers addServerHandler({ route: '/api/_scripts/x-embed', diff --git a/src/proxy-configs.ts b/src/proxy-configs.ts index 89e37d64..d5cb5fcf 100644 --- a/src/proxy-configs.ts +++ b/src/proxy-configs.ts @@ -172,6 +172,19 @@ function buildProxyConfig(collectPrefix: string) { [`${collectPrefix}/hotjar-insights/**`]: { proxy: 'https://insights.hotjar.com/**' }, }, }, + + gravatar: { + rewrite: [ + // Hovercards JS and related scripts + { from: 'secure.gravatar.com', to: `${collectPrefix}/gravatar` }, + // Avatar images (used by hovercards internally) + { from: 'gravatar.com/avatar', to: `${collectPrefix}/gravatar-avatar` }, + ], + routes: { + [`${collectPrefix}/gravatar/**`]: { proxy: 'https://secure.gravatar.com/**' }, + [`${collectPrefix}/gravatar-avatar/**`]: { proxy: 'https://gravatar.com/avatar/**' }, + }, + }, } satisfies Record } diff --git a/src/registry.ts b/src/registry.ts index 5e473f71..ad5b93fc 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -405,5 +405,16 @@ export async function registry(resolve?: (path: string, opts?: ResolvePathOption from: await resolve('./runtime/registry/umami-analytics'), }, }, + { + label: 'Gravatar', + proxy: 'gravatar', + src: 'https://secure.gravatar.com/js/gprofiles.js', + category: 'utility', + logo: ``, + import: { + name: 'useScriptGravatar', + from: await resolve('./runtime/registry/gravatar'), + }, + }, ] } diff --git a/src/runtime/components/ScriptGravatar.vue b/src/runtime/components/ScriptGravatar.vue new file mode 100644 index 00000000..30e9b4a2 --- /dev/null +++ b/src/runtime/components/ScriptGravatar.vue @@ -0,0 +1,63 @@ + + + diff --git a/src/runtime/registry/gravatar.ts b/src/runtime/registry/gravatar.ts new file mode 100644 index 00000000..ddb6a206 --- /dev/null +++ b/src/runtime/registry/gravatar.ts @@ -0,0 +1,76 @@ +import { useRegistryScript } from '#nuxt-scripts/utils' +import type { RegistryScriptInput } from '#nuxt-scripts/types' +import { object, optional, number, string } from '#nuxt-scripts-validator' + +export const GravatarOptions = object({ + /** + * Cache duration for proxied avatar images in seconds. + * @default 3600 + */ + cacheMaxAge: optional(number()), + /** + * Default image to show when no Gravatar exists. + * @see https://docs.gravatar.com/general/images/#default-image + * @default 'mp' + */ + default: optional(string()), + /** + * Avatar size in pixels (1-2048). + * @default 80 + */ + size: optional(number()), + /** + * Content rating filter. + * @default 'g' + */ + rating: optional(string()), +}) + +export type GravatarInput = RegistryScriptInput + +export interface GravatarApi { + /** + * Get a proxied avatar URL for a given SHA256 email hash. + * When firstParty mode is enabled, this routes through your server. + */ + getAvatarUrl: (hash: string, options?: { size?: number, default?: string, rating?: string }) => string + /** + * Get a proxied avatar URL using the server-side hashing endpoint. + * The email is sent to YOUR server (not Gravatar) for hashing. + * Only available when the gravatar proxy is enabled. + */ + getAvatarUrlFromEmail: (email: string, options?: { size?: number, default?: string, rating?: string }) => string +} + +export function useScriptGravatar(_options?: GravatarInput) { + return useRegistryScript(_options?.key || 'gravatar', (options) => { + const size = options?.size ?? 80 + const defaultImg = options?.default ?? 'mp' + const rating = options?.rating ?? 'g' + + const buildQuery = (overrides?: { size?: number, default?: string, rating?: string }) => { + const params = new URLSearchParams() + params.set('s', String(overrides?.size ?? size)) + params.set('d', overrides?.default ?? defaultImg) + params.set('r', overrides?.rating ?? rating) + return params.toString() + } + + return { + scriptInput: { + src: 'https://secure.gravatar.com/js/gprofiles.js', + }, + schema: import.meta.dev ? GravatarOptions : undefined, + scriptOptions: { + use: () => ({ + getAvatarUrl: (hash: string, overrides?: { size?: number, default?: string, rating?: string }) => { + return `/_scripts/gravatar-proxy?hash=${encodeURIComponent(hash)}&${buildQuery(overrides)}` + }, + getAvatarUrlFromEmail: (email: string, overrides?: { size?: number, default?: string, rating?: string }) => { + return `/_scripts/gravatar-proxy?email=${encodeURIComponent(email)}&${buildQuery(overrides)}` + }, + }), + }, + } + }, _options) +} diff --git a/src/runtime/server/gravatar-proxy.ts b/src/runtime/server/gravatar-proxy.ts new file mode 100644 index 00000000..1a8ec3d0 --- /dev/null +++ b/src/runtime/server/gravatar-proxy.ts @@ -0,0 +1,79 @@ +import { createError, defineEventHandler, getHeader, getQuery, setHeader } from 'h3' +import { $fetch } from 'ofetch' +import { withQuery } from 'ufo' +import { useRuntimeConfig } from '#imports' + +export default defineEventHandler(async (event) => { + const runtimeConfig = useRuntimeConfig() + const publicConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.gravatarProxy + + if (!publicConfig?.enabled) { + throw createError({ + statusCode: 404, + statusMessage: 'Gravatar proxy is not enabled', + }) + } + + // Validate referer to prevent external abuse + const referer = getHeader(event, 'referer') + const host = getHeader(event, 'host') + if (referer && host) { + const refererUrl = new URL(referer).host + if (refererUrl !== host) { + throw createError({ + statusCode: 403, + statusMessage: 'Invalid referer', + }) + } + } + + const query = getQuery(event) + let hash = query.hash as string | undefined + const email = query.email as string | undefined + + // Server-side hashing: email never leaves your server + if (!hash && email) { + const encoder = new TextEncoder() + const data = encoder.encode(email.trim().toLowerCase()) + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + hash = Array.from(new Uint8Array(hashBuffer)) + .map(b => b.toString(16).padStart(2, '0')) + .join('') + } + + if (!hash) { + throw createError({ + statusCode: 400, + statusMessage: 'Either hash or email parameter is required', + }) + } + + // Build Gravatar URL with query params + const size = query.s as string || '80' + const defaultImg = query.d as string || 'mp' + const rating = query.r as string || 'g' + + const gravatarUrl = withQuery(`https://gravatar.com/avatar/${hash}`, { + s: size, + d: defaultImg, + r: rating, + }) + + const response = await $fetch.raw(gravatarUrl, { + headers: { + 'User-Agent': 'Nuxt Scripts Gravatar Proxy', + }, + }).catch((error: any) => { + throw createError({ + statusCode: error.statusCode || 500, + statusMessage: error.statusMessage || 'Failed to fetch Gravatar avatar', + }) + }) + + const cacheMaxAge = publicConfig.cacheMaxAge || 3600 + setHeader(event, 'Content-Type', response.headers.get('content-type') || 'image/jpeg') + setHeader(event, 'Cache-Control', `public, max-age=${cacheMaxAge}, s-maxage=${cacheMaxAge}`) + setHeader(event, 'Vary', 'Accept-Encoding') + + return response._data +}) diff --git a/src/runtime/types.ts b/src/runtime/types.ts index dbc7ac4a..3417da27 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -34,6 +34,7 @@ import type { PayPalInput } from './registry/paypal' import type { PostHogInput } from './registry/posthog' import type { GoogleRecaptchaInput } from './registry/google-recaptcha' import type { TikTokPixelInput } from './registry/tiktok-pixel' +import type { GravatarInput } from './registry/gravatar' import { object } from '#nuxt-scripts-validator' export type WarmupStrategy = false | 'preload' | 'preconnect' | 'dns-prefetch' @@ -186,6 +187,7 @@ export interface ScriptRegistry { vimeoPlayer?: VimeoPlayerInput umamiAnalytics?: UmamiAnalyticsInput [key: `${string}-npm`]: NpmInput + gravatar?: GravatarInput } export type NuxtConfigScriptRegistryEntry = true | 'mock' | T | [T, NuxtUseScriptOptionsSerializable] @@ -219,7 +221,7 @@ export type RegistryScriptInput< scriptInput?: ScriptInput scriptOptions?: Omit }) - | Partial> & ( + | Partial> & ( CanBypassOptions extends true ? { /** * A unique key to use for the script, this can be used to load multiple of the same script with different options. diff --git a/test/fixtures/basic/pages/tpc/gravatar.vue b/test/fixtures/basic/pages/tpc/gravatar.vue new file mode 100644 index 00000000..e3002fba --- /dev/null +++ b/test/fixtures/basic/pages/tpc/gravatar.vue @@ -0,0 +1,46 @@ + + + diff --git a/test/unit/gravatar-proxy.test.ts b/test/unit/gravatar-proxy.test.ts new file mode 100644 index 00000000..022bc91e --- /dev/null +++ b/test/unit/gravatar-proxy.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { getProxyConfig } from '../../src/proxy-configs' + +describe('gravatar proxy config', () => { + it('returns proxy config for gravatar', () => { + const config = getProxyConfig('gravatar', '/_scripts/c') + expect(config).toBeDefined() + expect(config?.rewrite).toBeDefined() + expect(config?.routes).toBeDefined() + }) + + it('rewrites secure.gravatar.com for hovercards JS', () => { + const config = getProxyConfig('gravatar', '/_scripts/c') + expect(config?.rewrite).toContainEqual({ + from: 'secure.gravatar.com', + to: '/_scripts/c/gravatar', + }) + }) + + it('rewrites gravatar.com/avatar for image proxying', () => { + const config = getProxyConfig('gravatar', '/_scripts/c') + expect(config?.rewrite).toContainEqual({ + from: 'gravatar.com/avatar', + to: '/_scripts/c/gravatar-avatar', + }) + }) + + it('routes proxy to correct targets', () => { + const config = getProxyConfig('gravatar', '/_scripts/c') + expect(config?.routes?.['/_scripts/c/gravatar/**']).toEqual({ + proxy: 'https://secure.gravatar.com/**', + }) + expect(config?.routes?.['/_scripts/c/gravatar-avatar/**']).toEqual({ + proxy: 'https://gravatar.com/avatar/**', + }) + }) + + it('uses custom collectPrefix', () => { + const config = getProxyConfig('gravatar', '/_custom/proxy') + expect(config?.rewrite).toContainEqual({ + from: 'secure.gravatar.com', + to: '/_custom/proxy/gravatar', + }) + expect(config?.routes).toHaveProperty('/_custom/proxy/gravatar/**') + expect(config?.routes).toHaveProperty('/_custom/proxy/gravatar-avatar/**') + }) +}) diff --git a/test/unit/proxy-configs.test.ts b/test/unit/proxy-configs.test.ts index b11e46a5..332a7708 100644 --- a/test/unit/proxy-configs.test.ts +++ b/test/unit/proxy-configs.test.ts @@ -148,6 +148,7 @@ describe('proxy configs', () => { expect(configs).toHaveProperty('segment') expect(configs).toHaveProperty('clarity') expect(configs).toHaveProperty('hotjar') + expect(configs).toHaveProperty('gravatar') }) it('all configs have valid structure', () => {