-
Notifications
You must be signed in to change notification settings - Fork 82
feat: add Gravatar integration with privacy-preserving proxy #606
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0c471e5
7624000
3b6372a
73159e6
c11c164
fa4e24e
7707790
a0230c8
9996dd8
ca5d9c7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: "<ScriptGravatar>" | ||
| 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 | ||
| :: |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| <script setup lang="ts"> | ||
| import { computed, onMounted, ref, useAttrs } from 'vue' | ||
| import { useScriptGravatar } from '../registry/gravatar' | ||
|
|
||
| const props = withDefaults(defineProps<{ | ||
| /** Email address β sent to your server proxy for hashing, not sent to Gravatar */ | ||
| email?: string | ||
| /** Pre-computed SHA256 hash of the email */ | ||
| hash?: string | ||
| /** Avatar size in pixels */ | ||
| size?: number | ||
| /** Default avatar style when no Gravatar exists */ | ||
| default?: string | ||
| /** Content rating filter */ | ||
| rating?: string | ||
| /** Enable hovercards on hover */ | ||
| hovercards?: boolean | ||
| }>(), { | ||
| size: 80, | ||
| default: 'mp', | ||
| rating: 'g', | ||
| hovercards: false, | ||
| }) | ||
|
|
||
| const attrs = useAttrs() | ||
| const imgSrc = ref('') | ||
|
|
||
| const { onLoaded } = useScriptGravatar() | ||
|
|
||
| const queryOverrides = computed(() => ({ | ||
| size: props.size, | ||
| default: props.default, | ||
| rating: props.rating, | ||
| })) | ||
|
|
||
| onMounted(() => { | ||
| onLoaded((api) => { | ||
| if (props.email) { | ||
| imgSrc.value = api.getAvatarUrlFromEmail(props.email, queryOverrides.value) | ||
| } | ||
| else if (props.hash) { | ||
| imgSrc.value = api.getAvatarUrl(props.hash, queryOverrides.value) | ||
| } | ||
| }) | ||
| }) | ||
| </script> | ||
|
|
||
| <template> | ||
| <img | ||
| v-if="imgSrc" | ||
| :src="imgSrc" | ||
| :width="size" | ||
| :height="size" | ||
| :class="{ hovercard: hovercards }" | ||
| v-bind="attrs" | ||
| :alt="attrs.alt as string || 'Gravatar avatar'" | ||
| loading="lazy" | ||
| > | ||
| <span | ||
| v-else | ||
| :style="{ display: 'inline-block', width: `${size}px`, height: `${size}px`, borderRadius: '50%', background: '#e0e0e0' }" | ||
| /> | ||
| </template> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof GravatarOptions> | ||
|
|
||
| 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<T extends GravatarApi>(_options?: GravatarInput) { | ||
| return useRegistryScript<T, typeof GravatarOptions>(_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)}` | ||
| }, | ||
|
Comment on lines
+41
to
+47
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π§© Analysis chainπ Script executed: # Check for gravatarProxy references and any guards
rg "gravatarProxy" --type ts --type vue -C 2Repository: nuxt/scripts Length of output: 83 π Script executed: # Read the gravatar.ts file to confirm implementation details
wc -l src/runtime/registry/gravatar.tsRepository: nuxt/scripts Length of output: 91 π Script executed: # Look for the actual file to understand structure
fd "gravatar" --type fRepository: nuxt/scripts Length of output: 284 π Script executed: # Retry the gravatarProxy search with correct file types
rg "gravatarProxy" --type ts -C 2Repository: nuxt/scripts Length of output: 1459 π Script executed: # Read the gravatar.ts file to verify the implementation
cat -n src/runtime/registry/gravatar.tsRepository: nuxt/scripts Length of output: 3355 π Script executed: # Check the gravatar-proxy.ts to see if there are any guards
cat -n src/runtime/server/gravatar-proxy.tsRepository: nuxt/scripts Length of output: 3036 π Script executed: # Check the module.ts to see full proxy configuration and registration
rg -A 5 "Add Gravatar proxy handler" src/module.tsRepository: nuxt/scripts Length of output: 289 π Script executed: # Check the ScriptGravatar component to see if it has any guards
cat -n src/runtime/components/ScriptGravatar.vueRepository: nuxt/scripts Length of output: 2007 π Script executed: # Check the documentation to see if this is documented
cat -n docs/content/scripts/utility/gravatar.mdRepository: nuxt/scripts Length of output: 5678 Avatar URLs hard-code the proxy path with no fallback when disabled.
While the documentation mentions the proxy must be enabled, there is no runtime warning in the API itself or fallback to direct Gravatar URLs. If developers forget to enable the proxy, avatars will silently fail to load without clear feedback. Consider either:
π€ Prompt for AI Agents |
||
| }), | ||
| }, | ||
| } | ||
| }, _options) | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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()), | ||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||
|
Comment on lines
+925
to
+931
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
At Line 930, Proposed fix (remove no-op option from this schema surface) 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()),π Committable suggestion
Suggested change
π€ Prompt for AI Agents |
||||||||||||||||||||||||||||||
| * 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()), | ||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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', | ||
| }) | ||
| }) | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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 | ||
| }) | ||
Uh oh!
There was an error while loading. Please reload this page.