diff --git a/docs/content/scripts/gravatar.md b/docs/content/scripts/gravatar.md new file mode 100644 index 00000000..2910dca6 --- /dev/null +++ b/docs/content/scripts/gravatar.md @@ -0,0 +1,26 @@ +--- + +title: Gravatar +description: Use Gravatar in your Nuxt app. +links: +- label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/src/runtime/registry/gravatar.ts + size: xs +- label: "" + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/src/runtime/components/ScriptGravatar.vue + 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. + +::script-stats +:: + +::script-docs +:: + +::script-types +:: diff --git a/src/module.ts b/src/module.ts index 4ea01480..9a7755ea 100644 --- a/src/module.ts +++ b/src/module.ts @@ -350,7 +350,7 @@ export default defineNuxtModule({ googleStaticMapsProxy: config.googleStaticMapsProxy?.enabled ? { enabled: true, cacheMaxAge: config.googleStaticMapsProxy.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 @@ -428,7 +428,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(', ')}`) } } @@ -703,6 +703,21 @@ export default defineNuxtModule({ }) } + // Add Gravatar proxy handler when registry.gravatar is enabled + if (config.registry?.gravatar) { + const gravatarConfig = typeof config.registry.gravatar === 'object' && !Array.isArray(config.registry.gravatar) + ? config.registry.gravatar as Record + : {} + nuxt.options.runtimeConfig.public['nuxt-scripts'] = defu( + { gravatarProxy: { cacheMaxAge: gravatarConfig.cacheMaxAge ?? 3600 } }, + nuxt.options.runtimeConfig.public['nuxt-scripts'] as any, + ) as any + 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 89b6d2b1..8162cb8f 100644 --- a/src/proxy-configs.ts +++ b/src/proxy-configs.ts @@ -318,6 +318,19 @@ function buildProxyConfig(collectPrefix: string) { [`${collectPrefix}/vercel/**`]: { proxy: 'https://va.vercel-scripts.com/**' }, }, }, + + gravatar: { + // Gravatar: avatar proxy — IP anonymized, rest not needed + privacy: { ip: true, userAgent: false, language: false, screen: false, timezone: false, hardware: false }, + rewrite: [ + { from: 'secure.gravatar.com', to: `${collectPrefix}/gravatar` }, + { 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-types.json b/src/registry-types.json index 0cd69912..702874e3 100644 --- a/src/registry-types.json +++ b/src/registry-types.json @@ -336,6 +336,13 @@ "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})" } ], + "gravatar": [ + { + "name": "GravatarApi", + "kind": "interface", + "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}" + } + ], "hotjar": [ { "name": "HotjarOptions", @@ -690,6 +697,38 @@ "code": "export interface UmamiAnalyticsApi {\n track: ((payload?: Record) => void) & ((event_name: string, event_data: Record) => void)\n identify: (session_data?: Record | string) => void\n}" } ], + "vercel-analytics": [ + { + "name": "VercelAnalyticsOptions", + "kind": "const", + "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})" + }, + { + "name": "AllowedPropertyValues", + "kind": "type", + "code": "export type AllowedPropertyValues = string | number | boolean | null" + }, + { + "name": "VercelAnalyticsMode", + "kind": "type", + "code": "export type VercelAnalyticsMode = 'auto' | 'development' | 'production'" + }, + { + "name": "BeforeSendEvent", + "kind": "interface", + "code": "export interface BeforeSendEvent {\n type: 'pageview' | 'event'\n url: string\n}" + }, + { + "name": "BeforeSend", + "kind": "type", + "code": "export type BeforeSend = (event: BeforeSendEvent) => BeforeSendEvent | null" + }, + { + "name": "VercelAnalyticsApi", + "kind": "interface", + "code": "export interface VercelAnalyticsApi {\n va: (event: string, properties?: unknown) => void\n track: (name: string, properties?: Record) => void\n pageview: (options?: { route?: string | null, path?: string }) => void\n}" + } + ], "vimeo-player": [ { "name": "Constructor", @@ -778,18 +817,6 @@ "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}" } ], - "vercel-analytics": [ - { - "name": "VercelAnalyticsOptions", - "kind": "const", - "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})" - }, - { - "name": "VercelAnalyticsApi", - "kind": "interface", - "code": "export interface VercelAnalyticsApi {\n va: (event: string, properties?: unknown) => void\n track: (name: string, properties?: Record) => void\n pageview: (options?: { route?: string | null, path?: string }) => void\n}" - } - ], "carbon-ads": [ { "name": "ScriptCarbonAdsProps", diff --git a/src/registry.ts b/src/registry.ts index da304168..ab6073b4 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -442,5 +442,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..324d4779 --- /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..085d7b22 --- /dev/null +++ b/src/runtime/registry/gravatar.ts @@ -0,0 +1,52 @@ +import type { RegistryScriptInput } from '#nuxt-scripts/types' +import { useRegistryScript } from '#nuxt-scripts/utils' +import { GravatarOptions } from './schemas' + +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/registry/schemas.ts b/src/runtime/registry/schemas.ts index 5b8d5120..94c8b471 100644 --- a/src/runtime/registry/schemas.ts +++ b/src/runtime/registry/schemas.ts @@ -921,3 +921,27 @@ export const XPixelOptions = object({ */ version: optional(string()), }) + +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()), +}) diff --git a/src/runtime/server/gravatar-proxy.ts b/src/runtime/server/gravatar-proxy.ts new file mode 100644 index 00000000..e79c663f --- /dev/null +++ b/src/runtime/server/gravatar-proxy.ts @@ -0,0 +1,77 @@ +import { useRuntimeConfig } from '#imports' +import { createError, defineEventHandler, getHeader, getQuery, setHeader } from 'h3' +import { $fetch } from 'ofetch' +import { withQuery } from 'ufo' + +export default defineEventHandler(async (event) => { + const runtimeConfig = useRuntimeConfig() + const proxyConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.gravatarProxy + + // Validate referer to prevent external abuse + const referer = getHeader(event, 'referer') + const host = getHeader(event, 'host') + if (referer && host) { + let refererHost: string | undefined + try { + refererHost = new URL(referer).host + } + catch {} + if (refererHost && refererHost !== 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://www.gravatar.com/avatar/${hash}`, { + s: size, + d: defaultImg, + r: rating, + }) + + const response = await $fetch.raw(gravatarUrl, { + responseType: 'arrayBuffer', + 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 = proxyConfig?.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 233f05f8..a46b57b6 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -16,6 +16,7 @@ import type { GoogleMapsInput } from './registry/google-maps' import type { GoogleRecaptchaInput } from './registry/google-recaptcha' import type { GoogleSignInInput } from './registry/google-sign-in' import type { GoogleTagManagerInput } from './registry/google-tag-manager' +import type { GravatarInput } from './registry/gravatar' import type { HotjarInput } from './registry/hotjar' import type { IntercomInput } from './registry/intercom' import type { LemonSqueezyInput } from './registry/lemon-squeezy' @@ -189,6 +190,7 @@ export interface ScriptRegistry { vercelAnalytics?: VercelAnalyticsInput vimeoPlayer?: VimeoPlayerInput umamiAnalytics?: UmamiAnalyticsInput + gravatar?: GravatarInput [key: `${string}-npm`]: NpmInput } 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..f3994f35 --- /dev/null +++ b/test/unit/gravatar-proxy.test.ts @@ -0,0 +1,51 @@ +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?.['/_custom/proxy/gravatar/**']).toEqual({ + proxy: 'https://secure.gravatar.com/**', + }) + expect(config?.routes?.['/_custom/proxy/gravatar-avatar/**']).toEqual({ + proxy: 'https://gravatar.com/avatar/**', + }) + }) +}) diff --git a/test/unit/proxy-configs.test.ts b/test/unit/proxy-configs.test.ts index e764f6ea..d573e1af 100644 --- a/test/unit/proxy-configs.test.ts +++ b/test/unit/proxy-configs.test.ts @@ -461,6 +461,7 @@ describe('proxy configs', () => { expect(configs).toHaveProperty('intercom') expect(configs).toHaveProperty('crisp') expect(configs).toHaveProperty('vercelAnalytics') + expect(configs).toHaveProperty('gravatar') }) it('all configs have valid structure', () => {