From b1f1f7f13b7be7b54c95852d5d422e39a9f8aa62 Mon Sep 17 00:00:00 2001 From: Gugustinette Date: Sat, 31 Jan 2026 21:32:14 +0100 Subject: [PATCH 01/13] feat: display author profile picture --- app/pages/~[username]/index.vue | 43 +++++++++++++++--- server/api/gravatar.get.ts | 49 ++++++++++++++++++++ server/utils/gravatar.ts | 19 ++++++++ server/utils/npm.ts | 42 ++++++++++++++++- shared/utils/npm.ts | 19 +++++++- test/unit/server/utils/gravatar.spec.ts | 60 +++++++++++++++++++++++++ 6 files changed, 223 insertions(+), 9 deletions(-) create mode 100644 server/api/gravatar.get.ts create mode 100644 server/utils/gravatar.ts create mode 100644 test/unit/server/utils/gravatar.spec.ts diff --git a/app/pages/~[username]/index.vue b/app/pages/~[username]/index.vue index b96c8cc23..5b73fbed2 100644 --- a/app/pages/~[username]/index.vue +++ b/app/pages/~[username]/index.vue @@ -6,6 +6,26 @@ const router = useRouter() const username = computed(() => route.params.username) +async function fetchGravatarUrl(handle: string): Promise { + if (!handle) return null + + try { + const response = await $fetch<{ url: string | null }>( + `/api/gravatar?username=${encodeURIComponent(handle)}&size=64`, + ) + return response.url ?? null + } catch { + // Gravatar couldn't be fetched, it is ignored as not considered an error + return null + } +} + +const { data: gravatarUrl } = useLazyAsyncData( + () => `gravatar:${username.value}`, + () => fetchGravatarUrl(username.value), + { watch: [username] }, +) + // Infinite scroll state const pageSize = 50 const maxResults = 250 // npm API hard limit @@ -179,14 +199,25 @@ defineOgImageComponent('Default', {
- +

~{{ username }}

diff --git a/server/api/gravatar.get.ts b/server/api/gravatar.get.ts new file mode 100644 index 000000000..39e568c13 --- /dev/null +++ b/server/api/gravatar.get.ts @@ -0,0 +1,49 @@ +import type { H3Event } from 'h3' +import { CACHE_MAX_AGE_ONE_DAY } from '#shared/utils/constants' +import { getGravatarFromUsername } from '#server/utils/gravatar' +import { assertValidUsername } from '#shared/utils/npm' + +function getQueryParam(event: H3Event, key: string): string { + const query = getQuery(event) + const value = query[key] + return Array.isArray(value) ? String(value[0] ?? '') : String(value ?? '') +} + +export default defineCachedEventHandler( + async event => { + const username = getQueryParam(event, 'username').trim() + + if (!username) { + throw createError({ + statusCode: 400, + message: 'Username is required', + }) + } + + assertValidUsername(username) + + const sizeParam = Number.parseInt(getQueryParam(event, 'size'), 10) + const size = Number.isNaN(sizeParam) ? 80 : Math.max(16, Math.min(512, sizeParam)) + + const url = await getGravatarFromUsername(username, size) + + if (!url) { + throw createError({ + statusCode: 400, + message: "User's email not accessible", + }) + } + + return { url } + }, + { + maxAge: CACHE_MAX_AGE_ONE_DAY, + swr: true, + getKey: event => { + const username = getQueryParam(event, 'username').trim().toLowerCase() + const size = getQueryParam(event, 'size') || '80' + const defaultImg = getQueryParam(event, 'default') || '404' + return `gravatar:v1:${username}:${size}:${defaultImg}` + }, + }, +) diff --git a/server/utils/gravatar.ts b/server/utils/gravatar.ts new file mode 100644 index 000000000..51796ef1e --- /dev/null +++ b/server/utils/gravatar.ts @@ -0,0 +1,19 @@ +import { createHash } from 'node:crypto' +import { fetchUserEmail } from '#server/utils/npm' + +const DEFAULT_GRAVATAR_SIZE = 80 + +export async function getGravatarFromUsername( + username: string, + size: number = DEFAULT_GRAVATAR_SIZE, +): Promise { + const handle = username.trim() + if (!handle) return null + + const email = await fetchUserEmail(handle) + if (!email) return null + + const trimmedEmail = email.trim().toLowerCase() + const md5Hash = createHash('md5').update(trimmedEmail).digest('hex') + return `https://www.gravatar.com/avatar/${md5Hash}?s=${size}&d=404` +} diff --git a/server/utils/npm.ts b/server/utils/npm.ts index e646b1c40..38febc82e 100644 --- a/server/utils/npm.ts +++ b/server/utils/npm.ts @@ -1,4 +1,4 @@ -import type { Packument } from '#shared/types' +import type { Packument, NpmSearchResponse } from '#shared/types' import { maxSatisfying, prerelease } from 'semver' const NPM_REGISTRY = 'https://registry.npmjs.org' @@ -85,3 +85,43 @@ export async function resolveDependencyVersions( } return resolved } + +/** + * Find a user's email address from its username + * by exploring metadata in its public packages + */ +export const fetchUserEmail = defineCachedFunction( + async (username: string): Promise => { + const handle = username.trim() + if (!handle) return null + + // Fetch packages with the user's handle as a maintainer + const params = new URLSearchParams({ + text: `maintainer:${handle}`, + size: '20', + }) + const response = await $fetch(`${NPM_REGISTRY}/-/v1/search?${params}`) + const lowerHandle = handle.toLowerCase() + + // Search for the user's email in packages metadata + for (const result of response.objects) { + const maintainers = result.package.maintainers ?? [] + const match = maintainers.find( + person => + person.username?.toLowerCase() === lowerHandle || + person.name?.toLowerCase() === lowerHandle, + ) + if (match?.email) { + return match.email + } + } + + return null + }, + { + maxAge: CACHE_MAX_AGE_ONE_DAY, + swr: true, + name: 'npm-user-email', + getKey: (username: string) => `npm-user-email:${username.toLowerCase()}`, + }, +) diff --git a/shared/utils/npm.ts b/shared/utils/npm.ts index 8d9232ae1..544d50087 100644 --- a/shared/utils/npm.ts +++ b/shared/utils/npm.ts @@ -1,6 +1,9 @@ import { createError } from 'h3' import validatePackageName from 'validate-npm-package-name' +const NPM_USERNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i +const NPM_USERNAME_MAX_LENGTH = 50 + /** * Validate an npm package name and throw an HTTP error if invalid. * Uses validate-npm-package-name to check against npm naming rules. @@ -10,9 +13,21 @@ export function assertValidPackageName(name: string): void { if (!result.validForNewPackages && !result.validForOldPackages) { const errors = [...(result.errors ?? []), ...(result.warnings ?? [])] throw createError({ - // TODO: throwing 404 rather than 400 as it's cacheable - statusCode: 404, + statusCode: 400, message: `Invalid package name: ${errors[0] ?? 'unknown error'}`, }) } } + +/** + * Validate an npm username and throw an HTTP error if invalid. + * Uses a regular expression to check against npm naming rules. + */ +export function assertValidUsername(username: string): void { + if (!username || username.length > NPM_USERNAME_MAX_LENGTH || !NPM_USERNAME_RE.test(username)) { + throw createError({ + statusCode: 400, + message: `Invalid username: ${username}`, + }) + } +} diff --git a/test/unit/server/utils/gravatar.spec.ts b/test/unit/server/utils/gravatar.spec.ts new file mode 100644 index 000000000..1ec1f7631 --- /dev/null +++ b/test/unit/server/utils/gravatar.spec.ts @@ -0,0 +1,60 @@ +import { createHash } from 'node:crypto' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('#server/utils/npm', () => ({ + fetchUserEmail: vi.fn(), +})) + +const { getGravatarFromUsername } = await import('../../../../server/utils/gravatar') +const { fetchUserEmail } = await import('#server/utils/npm') + +describe('gravatar utils', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns null when username is empty', async () => { + const url = await getGravatarFromUsername('') + + expect(url).toBeNull() + expect(fetchUserEmail).not.toHaveBeenCalled() + }) + + it('returns null when email is not available', async () => { + vi.mocked(fetchUserEmail).mockResolvedValue(null) + + const url = await getGravatarFromUsername('user') + + expect(url).toBeNull() + expect(fetchUserEmail).toHaveBeenCalledOnce() + }) + + it('builds a gravatar URL with a trimmed, lowercased email hash', async () => { + const email = ' Test@Example.com ' + const normalized = 'test@example.com' + const hash = createHash('md5').update(normalized).digest('hex') + vi.mocked(fetchUserEmail).mockResolvedValue(email) + + const url = await getGravatarFromUsername('user') + + expect(url).toBe(`https://www.gravatar.com/avatar/${hash}?s=80&d=404`) + }) + + it('supports custom size', async () => { + const email = 'user@example.com' + const hash = createHash('md5').update(email).digest('hex') + vi.mocked(fetchUserEmail).mockResolvedValue(email) + + const url = await getGravatarFromUsername('user', 128) + + expect(url).toBe(`https://www.gravatar.com/avatar/${hash}?s=128&d=404`) + }) + + it('trims the username before lookup', async () => { + vi.mocked(fetchUserEmail).mockResolvedValue('user@example.com') + + await getGravatarFromUsername(' user ') + + expect(fetchUserEmail).toHaveBeenCalledWith('user') + }) +}) From 20a9889464d47b4fc1adba46474a124d2d4a65f0 Mon Sep 17 00:00:00 2001 From: Gugustinette Date: Sat, 31 Jan 2026 21:56:08 +0100 Subject: [PATCH 02/13] fix: follow contributing guidelines --- server/api/gravatar.get.ts | 41 +++++++++++++++++++----------------- server/utils/npm.ts | 2 +- shared/schemas/user.ts | 43 ++++++++++++++++++++++++++++++++++++++ shared/utils/constants.ts | 2 ++ 4 files changed, 68 insertions(+), 20 deletions(-) create mode 100644 shared/schemas/user.ts diff --git a/server/api/gravatar.get.ts b/server/api/gravatar.get.ts index 39e568c13..149f3dfd7 100644 --- a/server/api/gravatar.get.ts +++ b/server/api/gravatar.get.ts @@ -1,7 +1,9 @@ import type { H3Event } from 'h3' -import { CACHE_MAX_AGE_ONE_DAY } from '#shared/utils/constants' +import { createError, getQuery } from 'h3' +import * as v from 'valibot' +import { GravatarQuerySchema } from '#shared/schemas/user' import { getGravatarFromUsername } from '#server/utils/gravatar' -import { assertValidUsername } from '#shared/utils/npm' +import { handleApiError } from '#server/utils/error-handler' function getQueryParam(event: H3Event, key: string): string { const query = getQuery(event) @@ -11,30 +13,31 @@ function getQueryParam(event: H3Event, key: string): string { export default defineCachedEventHandler( async event => { - const username = getQueryParam(event, 'username').trim() + const rawUsername = getQueryParam(event, 'username') + const rawSize = getQueryParam(event, 'size') - if (!username) { - throw createError({ - statusCode: 400, - message: 'Username is required', + try { + const { username, size } = v.parse(GravatarQuerySchema, { + username: rawUsername, + size: rawSize ? rawSize : undefined, }) - } - - assertValidUsername(username) - const sizeParam = Number.parseInt(getQueryParam(event, 'size'), 10) - const size = Number.isNaN(sizeParam) ? 80 : Math.max(16, Math.min(512, sizeParam)) + const url = await getGravatarFromUsername(username, size ?? 80) - const url = await getGravatarFromUsername(username, size) + if (!url) { + throw createError({ + statusCode: 404, + message: ERROR_GRAVATAR_EMAIL_UNAVAILABLE, + }) + } - if (!url) { - throw createError({ - statusCode: 400, - message: "User's email not accessible", + return { url } + } catch (error: unknown) { + handleApiError(error, { + statusCode: 502, + message: ERROR_GRAVATAR_FETCH_FAILED, }) } - - return { url } }, { maxAge: CACHE_MAX_AGE_ONE_DAY, diff --git a/server/utils/npm.ts b/server/utils/npm.ts index 38febc82e..8d5ac81bf 100644 --- a/server/utils/npm.ts +++ b/server/utils/npm.ts @@ -122,6 +122,6 @@ export const fetchUserEmail = defineCachedFunction( maxAge: CACHE_MAX_AGE_ONE_DAY, swr: true, name: 'npm-user-email', - getKey: (username: string) => `npm-user-email:${username.toLowerCase()}`, + getKey: (username: string) => `npm-user-email:${username.trim().toLowerCase()}`, }, ) diff --git a/shared/schemas/user.ts b/shared/schemas/user.ts new file mode 100644 index 000000000..571462686 --- /dev/null +++ b/shared/schemas/user.ts @@ -0,0 +1,43 @@ +import * as v from 'valibot' + +const NPM_USERNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i +const NPM_USERNAME_MAX_LENGTH = 50 + +/** + * Schema for npm usernames. + */ +export const NpmUsernameSchema = v.pipe( + v.string(), + v.trim(), + v.nonEmpty('Username is required'), + v.maxLength(NPM_USERNAME_MAX_LENGTH, 'Username is too long'), + v.regex(NPM_USERNAME_RE, 'Invalid username format'), +) + +/** + * Schema for Gravatar query inputs. + */ +export const GravatarQuerySchema = v.object({ + username: NpmUsernameSchema, + size: v.optional( + v.pipe( + v.union([v.number(), v.string()]), + v.transform(value => { + if (typeof value === 'string') { + const trimmed = value.trim() + return /^\d+$/.test(trimmed) ? Number(trimmed) : Number.NaN + } + return value + }), + v.check(value => !Number.isNaN(value), 'Invalid size'), + v.number(), + v.minValue(16), + v.maxValue(512), + ), + ), +}) + +/** @public */ +export type NpmUsername = v.InferOutput +/** @public */ +export type GravatarQuery = v.InferOutput diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts index 211ede420..38ac66ff3 100644 --- a/shared/utils/constants.ts +++ b/shared/utils/constants.ts @@ -19,6 +19,8 @@ export const ERROR_NPM_FETCH_FAILED = 'Failed to fetch package from npm registry export const UNSET_NUXT_SESSION_PASSWORD = 'NUXT_SESSION_PASSWORD not set' /** @public */ export const ERROR_SUGGESTIONS_FETCH_FAILED = 'Failed to fetch suggestions.' +export const ERROR_GRAVATAR_FETCH_FAILED = 'Failed to fetch Gravatar profile.' +export const ERROR_GRAVATAR_EMAIL_UNAVAILABLE = "User's email not accessible." // microcosm services export const CONSTELLATION_HOST = 'constellation.microcosm.blue' From 7ddb1a23cc31483465f6217e1e01fb11ba3e6b89 Mon Sep 17 00:00:00 2001 From: Gugustinette Date: Sat, 31 Jan 2026 22:01:23 +0100 Subject: [PATCH 03/13] chore: knip being a little silly cute bot --- shared/utils/constants.ts | 2 ++ shared/utils/npm.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts index 38ac66ff3..fcbfc846c 100644 --- a/shared/utils/constants.ts +++ b/shared/utils/constants.ts @@ -19,7 +19,9 @@ export const ERROR_NPM_FETCH_FAILED = 'Failed to fetch package from npm registry export const UNSET_NUXT_SESSION_PASSWORD = 'NUXT_SESSION_PASSWORD not set' /** @public */ export const ERROR_SUGGESTIONS_FETCH_FAILED = 'Failed to fetch suggestions.' +/** @public */ export const ERROR_GRAVATAR_FETCH_FAILED = 'Failed to fetch Gravatar profile.' +/** @public */ export const ERROR_GRAVATAR_EMAIL_UNAVAILABLE = "User's email not accessible." // microcosm services diff --git a/shared/utils/npm.ts b/shared/utils/npm.ts index 544d50087..1b612e6bd 100644 --- a/shared/utils/npm.ts +++ b/shared/utils/npm.ts @@ -22,6 +22,7 @@ export function assertValidPackageName(name: string): void { /** * Validate an npm username and throw an HTTP error if invalid. * Uses a regular expression to check against npm naming rules. + * @public */ export function assertValidUsername(username: string): void { if (!username || username.length > NPM_USERNAME_MAX_LENGTH || !NPM_USERNAME_RE.test(username)) { From 6aab607b9a3ead164d47823a4d43aca9f24d51de Mon Sep 17 00:00:00 2001 From: Gugustinette Date: Sun, 1 Feb 2026 11:01:04 +0100 Subject: [PATCH 04/13] fix: put back flex-wrap on avatar header --- app/pages/~[username]/index.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/~[username]/index.vue b/app/pages/~[username]/index.vue index 62a17408d..62d85965b 100644 --- a/app/pages/~[username]/index.vue +++ b/app/pages/~[username]/index.vue @@ -198,7 +198,7 @@ defineOgImageComponent('Default', {
-
+
Date: Sun, 1 Feb 2026 11:29:14 +0100 Subject: [PATCH 05/13] feat: proxy gravatars by returning data urls instead of gravatar url --- server/api/gravatar.get.ts | 56 ++++++++++--------------- server/utils/gravatar.ts | 15 ++++++- test/unit/server/utils/gravatar.spec.ts | 27 ++++++++++-- 3 files changed, 60 insertions(+), 38 deletions(-) diff --git a/server/api/gravatar.get.ts b/server/api/gravatar.get.ts index 149f3dfd7..e1fedd588 100644 --- a/server/api/gravatar.get.ts +++ b/server/api/gravatar.get.ts @@ -11,42 +11,30 @@ function getQueryParam(event: H3Event, key: string): string { return Array.isArray(value) ? String(value[0] ?? '') : String(value ?? '') } -export default defineCachedEventHandler( - async event => { - const rawUsername = getQueryParam(event, 'username') - const rawSize = getQueryParam(event, 'size') +export default defineCachedEventHandler(async event => { + const rawUsername = getQueryParam(event, 'username') + const rawSize = getQueryParam(event, 'size') - try { - const { username, size } = v.parse(GravatarQuerySchema, { - username: rawUsername, - size: rawSize ? rawSize : undefined, - }) - - const url = await getGravatarFromUsername(username, size ?? 80) + try { + const { username, size } = v.parse(GravatarQuerySchema, { + username: rawUsername, + size: rawSize ? rawSize : undefined, + }) - if (!url) { - throw createError({ - statusCode: 404, - message: ERROR_GRAVATAR_EMAIL_UNAVAILABLE, - }) - } + const dataUrl = await getGravatarFromUsername(username, size ?? 80) - return { url } - } catch (error: unknown) { - handleApiError(error, { - statusCode: 502, - message: ERROR_GRAVATAR_FETCH_FAILED, + if (!dataUrl) { + throw createError({ + statusCode: 404, + message: ERROR_GRAVATAR_EMAIL_UNAVAILABLE, }) } - }, - { - maxAge: CACHE_MAX_AGE_ONE_DAY, - swr: true, - getKey: event => { - const username = getQueryParam(event, 'username').trim().toLowerCase() - const size = getQueryParam(event, 'size') || '80' - const defaultImg = getQueryParam(event, 'default') || '404' - return `gravatar:v1:${username}:${size}:${defaultImg}` - }, - }, -) + + return { url: dataUrl } + } catch (error: unknown) { + handleApiError(error, { + statusCode: 502, + message: ERROR_GRAVATAR_FETCH_FAILED, + }) + } +}) diff --git a/server/utils/gravatar.ts b/server/utils/gravatar.ts index 51796ef1e..f178b2f59 100644 --- a/server/utils/gravatar.ts +++ b/server/utils/gravatar.ts @@ -1,3 +1,4 @@ +import { Buffer } from 'node:buffer' import { createHash } from 'node:crypto' import { fetchUserEmail } from '#server/utils/npm' @@ -15,5 +16,17 @@ export async function getGravatarFromUsername( const trimmedEmail = email.trim().toLowerCase() const md5Hash = createHash('md5').update(trimmedEmail).digest('hex') - return `https://www.gravatar.com/avatar/${md5Hash}?s=${size}&d=404` + const gravatarUrl = `https://www.gravatar.com/avatar/${md5Hash}?s=${size}&d=404` + + try { + const response = await fetch(gravatarUrl) + if (!response.ok) return null + + const contentType = response.headers.get('content-type') || 'image/png' + const arrayBuffer = await response.arrayBuffer() + const base64 = Buffer.from(arrayBuffer).toString('base64') + return `data:${contentType};base64,${base64}` + } catch { + return null + } } diff --git a/test/unit/server/utils/gravatar.spec.ts b/test/unit/server/utils/gravatar.spec.ts index 1ec1f7631..8e6cc6eb6 100644 --- a/test/unit/server/utils/gravatar.spec.ts +++ b/test/unit/server/utils/gravatar.spec.ts @@ -1,3 +1,4 @@ +import { Buffer } from 'node:buffer' import { createHash } from 'node:crypto' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -8,9 +9,13 @@ vi.mock('#server/utils/npm', () => ({ const { getGravatarFromUsername } = await import('../../../../server/utils/gravatar') const { fetchUserEmail } = await import('#server/utils/npm') +const mockFetch = vi.fn() + describe('gravatar utils', () => { beforeEach(() => { vi.clearAllMocks() + mockFetch.mockReset() + vi.stubGlobal('fetch', mockFetch) }) it('returns null when username is empty', async () => { @@ -29,25 +34,41 @@ describe('gravatar utils', () => { expect(fetchUserEmail).toHaveBeenCalledOnce() }) - it('builds a gravatar URL with a trimmed, lowercased email hash', async () => { + it('builds a gravatar data URL with a trimmed, lowercased email hash', async () => { const email = ' Test@Example.com ' const normalized = 'test@example.com' const hash = createHash('md5').update(normalized).digest('hex') + const imageBytes = new Uint8Array([1, 2, 3]) + const base64 = Buffer.from(imageBytes).toString('base64') vi.mocked(fetchUserEmail).mockResolvedValue(email) + mockFetch.mockResolvedValue({ + ok: true, + headers: { get: () => 'image/png' }, + arrayBuffer: vi.fn().mockResolvedValue(imageBytes.buffer), + }) const url = await getGravatarFromUsername('user') - expect(url).toBe(`https://www.gravatar.com/avatar/${hash}?s=80&d=404`) + expect(url).toBe(`data:image/png;base64,${base64}`) + expect(mockFetch).toHaveBeenCalledWith(`https://www.gravatar.com/avatar/${hash}?s=80&d=404`) }) it('supports custom size', async () => { const email = 'user@example.com' const hash = createHash('md5').update(email).digest('hex') + const imageBytes = new Uint8Array([4, 5, 6]) + const base64 = Buffer.from(imageBytes).toString('base64') vi.mocked(fetchUserEmail).mockResolvedValue(email) + mockFetch.mockResolvedValue({ + ok: true, + headers: { get: () => 'image/png' }, + arrayBuffer: vi.fn().mockResolvedValue(imageBytes.buffer), + }) const url = await getGravatarFromUsername('user', 128) - expect(url).toBe(`https://www.gravatar.com/avatar/${hash}?s=128&d=404`) + expect(url).toBe(`data:image/png;base64,${base64}`) + expect(mockFetch).toHaveBeenCalledWith(`https://www.gravatar.com/avatar/${hash}?s=128&d=404`) }) it('trims the username before lookup', async () => { From d50a30307af4fb610b895c74eea9dba6e4470b65 Mon Sep 17 00:00:00 2001 From: Gugustinette Date: Sun, 1 Feb 2026 11:39:09 +0100 Subject: [PATCH 06/13] fix: put back caching --- server/api/gravatar.get.ts | 56 +++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/server/api/gravatar.get.ts b/server/api/gravatar.get.ts index e1fedd588..8eef52183 100644 --- a/server/api/gravatar.get.ts +++ b/server/api/gravatar.get.ts @@ -11,30 +11,42 @@ function getQueryParam(event: H3Event, key: string): string { return Array.isArray(value) ? String(value[0] ?? '') : String(value ?? '') } -export default defineCachedEventHandler(async event => { - const rawUsername = getQueryParam(event, 'username') - const rawSize = getQueryParam(event, 'size') +export default defineCachedEventHandler( + async event => { + const rawUsername = getQueryParam(event, 'username') + const rawSize = getQueryParam(event, 'size') - try { - const { username, size } = v.parse(GravatarQuerySchema, { - username: rawUsername, - size: rawSize ? rawSize : undefined, - }) + try { + const { username, size } = v.parse(GravatarQuerySchema, { + username: rawUsername, + size: rawSize ? rawSize : undefined, + }) + + const dataUrl = await getGravatarFromUsername(username, size ?? 80) - const dataUrl = await getGravatarFromUsername(username, size ?? 80) + if (!dataUrl) { + throw createError({ + statusCode: 404, + message: ERROR_GRAVATAR_EMAIL_UNAVAILABLE, + }) + } - if (!dataUrl) { - throw createError({ - statusCode: 404, - message: ERROR_GRAVATAR_EMAIL_UNAVAILABLE, + return { url: dataUrl } + } catch (error: unknown) { + handleApiError(error, { + statusCode: 502, + message: ERROR_GRAVATAR_FETCH_FAILED, }) } - - return { url: dataUrl } - } catch (error: unknown) { - handleApiError(error, { - statusCode: 502, - message: ERROR_GRAVATAR_FETCH_FAILED, - }) - } -}) + }, + { + maxAge: CACHE_MAX_AGE_ONE_DAY, + swr: true, + getKey: event => { + const username = getQueryParam(event, 'username').trim().toLowerCase() + const size = getQueryParam(event, 'size') || '80' + const defaultImg = getQueryParam(event, 'default') || '404' + return `gravatar:v1:${username}:${size}:${defaultImg}` + }, + }, +) From df99512023fd03b5ccbc1ea915d7bc3929a7f746 Mon Sep 17 00:00:00 2001 From: Gugustinette Date: Sun, 1 Feb 2026 11:39:41 +0100 Subject: [PATCH 07/13] fix: no default img --- server/api/gravatar.get.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/api/gravatar.get.ts b/server/api/gravatar.get.ts index 8eef52183..17064fdf3 100644 --- a/server/api/gravatar.get.ts +++ b/server/api/gravatar.get.ts @@ -45,8 +45,7 @@ export default defineCachedEventHandler( getKey: event => { const username = getQueryParam(event, 'username').trim().toLowerCase() const size = getQueryParam(event, 'size') || '80' - const defaultImg = getQueryParam(event, 'default') || '404' - return `gravatar:v1:${username}:${size}:${defaultImg}` + return `gravatar:v1:${username}:${size}` }, }, ) From 4048ab66a348478c129ede6104f00781a80072fe Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 1 Feb 2026 23:21:23 +0000 Subject: [PATCH 08/13] refactor: usefetch + apply to orgs page too --- app/components/User/Avatar.vue | 36 +++++++++++++++++++++++++++++ app/pages/~[username]/index.vue | 41 +-------------------------------- app/pages/~[username]/orgs.vue | 10 +------- 3 files changed, 38 insertions(+), 49 deletions(-) create mode 100644 app/components/User/Avatar.vue diff --git a/app/components/User/Avatar.vue b/app/components/User/Avatar.vue new file mode 100644 index 000000000..5da10e94b --- /dev/null +++ b/app/components/User/Avatar.vue @@ -0,0 +1,36 @@ + + + diff --git a/app/pages/~[username]/index.vue b/app/pages/~[username]/index.vue index 6dc6685c7..f5b414714 100644 --- a/app/pages/~[username]/index.vue +++ b/app/pages/~[username]/index.vue @@ -6,26 +6,6 @@ const router = useRouter() const username = computed(() => route.params.username) -async function fetchGravatarUrl(handle: string): Promise { - if (!handle) return null - - try { - const response = await $fetch<{ url: string | null }>( - `/api/gravatar?username=${encodeURIComponent(handle)}&size=64`, - ) - return response.url ?? null - } catch { - // Gravatar couldn't be fetched, it is ignored as not considered an error - return null - } -} - -const { data: gravatarUrl } = useLazyAsyncData( - () => `gravatar:${username.value}`, - () => fetchGravatarUrl(username.value), - { watch: [username] }, -) - // Infinite scroll state const pageSize = 50 const maxResults = 250 // npm API hard limit @@ -199,26 +179,7 @@ defineOgImageComponent('Default', {
- - +

~{{ username }}

diff --git a/app/pages/~[username]/orgs.vue b/app/pages/~[username]/orgs.vue index 9b92f9212..b3320d182 100644 --- a/app/pages/~[username]/orgs.vue +++ b/app/pages/~[username]/orgs.vue @@ -120,15 +120,7 @@ defineOgImageComponent('Default', {

- - +

~{{ username }}

{{ $t('user.orgs_page.title') }}

From 584b7a73c7d4b2f66eec543868ca307f9c2f1181 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 1 Feb 2026 23:29:32 +0000 Subject: [PATCH 09/13] perf: use proxy for gravatar --- app/components/User/Avatar.vue | 8 ++----- nuxt.config.ts | 6 +++++ .../[username].get.ts} | 12 ++++------ server/utils/gravatar.ts | 23 ++----------------- shared/schemas/user.ts | 16 ------------- 5 files changed, 15 insertions(+), 50 deletions(-) rename server/api/{gravatar.get.ts => gravatar/[username].get.ts} (77%) diff --git a/app/components/User/Avatar.vue b/app/components/User/Avatar.vue index 5da10e94b..16eab74ae 100644 --- a/app/components/User/Avatar.vue +++ b/app/components/User/Avatar.vue @@ -3,12 +3,8 @@ const props = defineProps<{ username: string }>() -const { data: gravatarUrl } = useLazyFetch('/api/gravatar', { - query: { - username: computed(() => props.username), - size: 128, - }, - transform: res => res.url, +const { data: gravatarUrl } = useLazyFetch(() => `/api/gravatar/${props.username}`, { + transform: res => (res.hash ? `/_avatar/${res.hash}?s=128&d=404` : null), }) diff --git a/nuxt.config.ts b/nuxt.config.ts index a21fc498f..234cf6622 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -105,6 +105,12 @@ export default defineNuxtConfig({ '/api/registry/docs/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, '/api/registry/file/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, '/api/registry/files/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, + '/_avatar/**': { + isr: 3600, + proxy: { + to: 'https://www.gravatar.com/avatar/**', + }, + }, // static pages '/about': { prerender: true }, '/settings': { prerender: true }, diff --git a/server/api/gravatar.get.ts b/server/api/gravatar/[username].get.ts similarity index 77% rename from server/api/gravatar.get.ts rename to server/api/gravatar/[username].get.ts index 17064fdf3..c97014cb2 100644 --- a/server/api/gravatar.get.ts +++ b/server/api/gravatar/[username].get.ts @@ -13,25 +13,23 @@ function getQueryParam(event: H3Event, key: string): string { export default defineCachedEventHandler( async event => { - const rawUsername = getQueryParam(event, 'username') - const rawSize = getQueryParam(event, 'size') + const rawUsername = getRouterParam(event, 'username') try { - const { username, size } = v.parse(GravatarQuerySchema, { + const { username } = v.parse(GravatarQuerySchema, { username: rawUsername, - size: rawSize ? rawSize : undefined, }) - const dataUrl = await getGravatarFromUsername(username, size ?? 80) + const hash = await getGravatarFromUsername(username) - if (!dataUrl) { + if (!hash) { throw createError({ statusCode: 404, message: ERROR_GRAVATAR_EMAIL_UNAVAILABLE, }) } - return { url: dataUrl } + return { hash } } catch (error: unknown) { handleApiError(error, { statusCode: 502, diff --git a/server/utils/gravatar.ts b/server/utils/gravatar.ts index f178b2f59..339457b5e 100644 --- a/server/utils/gravatar.ts +++ b/server/utils/gravatar.ts @@ -1,13 +1,7 @@ -import { Buffer } from 'node:buffer' import { createHash } from 'node:crypto' import { fetchUserEmail } from '#server/utils/npm' -const DEFAULT_GRAVATAR_SIZE = 80 - -export async function getGravatarFromUsername( - username: string, - size: number = DEFAULT_GRAVATAR_SIZE, -): Promise { +export async function getGravatarFromUsername(username: string): Promise { const handle = username.trim() if (!handle) return null @@ -15,18 +9,5 @@ export async function getGravatarFromUsername( if (!email) return null const trimmedEmail = email.trim().toLowerCase() - const md5Hash = createHash('md5').update(trimmedEmail).digest('hex') - const gravatarUrl = `https://www.gravatar.com/avatar/${md5Hash}?s=${size}&d=404` - - try { - const response = await fetch(gravatarUrl) - if (!response.ok) return null - - const contentType = response.headers.get('content-type') || 'image/png' - const arrayBuffer = await response.arrayBuffer() - const base64 = Buffer.from(arrayBuffer).toString('base64') - return `data:${contentType};base64,${base64}` - } catch { - return null - } + return createHash('md5').update(trimmedEmail).digest('hex') } diff --git a/shared/schemas/user.ts b/shared/schemas/user.ts index 571462686..6b9fd1e56 100644 --- a/shared/schemas/user.ts +++ b/shared/schemas/user.ts @@ -19,22 +19,6 @@ export const NpmUsernameSchema = v.pipe( */ export const GravatarQuerySchema = v.object({ username: NpmUsernameSchema, - size: v.optional( - v.pipe( - v.union([v.number(), v.string()]), - v.transform(value => { - if (typeof value === 'string') { - const trimmed = value.trim() - return /^\d+$/.test(trimmed) ? Number(trimmed) : Number.NaN - } - return value - }), - v.check(value => !Number.isNaN(value), 'Invalid size'), - v.number(), - v.minValue(16), - v.maxValue(512), - ), - ), }) /** @public */ From 1df2a39170996eaad488f19691bb67c4f8a4800a Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 1 Feb 2026 23:31:52 +0000 Subject: [PATCH 10/13] fix: remove incorrect statusCode --- shared/utils/npm.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shared/utils/npm.ts b/shared/utils/npm.ts index bb466c75c..0b8e3d1f1 100644 --- a/shared/utils/npm.ts +++ b/shared/utils/npm.ts @@ -42,7 +42,8 @@ export function assertValidPackageName(name: string): void { if (!result.validForNewPackages && !result.validForOldPackages) { const errors = [...(result.errors ?? []), ...(result.warnings ?? [])] throw createError({ - statusCode: 400, + // TODO: throwing 404 rather than 400 as it's cacheable + statusCode: 404, message: `Invalid package name: ${errors[0] ?? 'unknown error'}`, }) } From 0d7ffcc78d91f7e869d11122d1b5b879b1ba2e67 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 1 Feb 2026 23:33:54 +0000 Subject: [PATCH 11/13] fix: statusCode --- shared/utils/npm.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shared/utils/npm.ts b/shared/utils/npm.ts index 0b8e3d1f1..ae918a6c4 100644 --- a/shared/utils/npm.ts +++ b/shared/utils/npm.ts @@ -57,7 +57,8 @@ export function assertValidPackageName(name: string): void { export function assertValidUsername(username: string): void { if (!username || username.length > NPM_USERNAME_MAX_LENGTH || !NPM_USERNAME_RE.test(username)) { throw createError({ - statusCode: 400, + // TODO: throwing 404 rather than 400 as it's cacheable + statusCode: 404, message: `Invalid username: ${username}`, }) } From e8e8064d8a869fd242a9a1209582379798cab91a Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 1 Feb 2026 23:38:51 +0000 Subject: [PATCH 12/13] test: update --- test/unit/server/utils/gravatar.spec.ts | 47 +++++-------------------- 1 file changed, 8 insertions(+), 39 deletions(-) diff --git a/test/unit/server/utils/gravatar.spec.ts b/test/unit/server/utils/gravatar.spec.ts index 8e6cc6eb6..491b7914a 100644 --- a/test/unit/server/utils/gravatar.spec.ts +++ b/test/unit/server/utils/gravatar.spec.ts @@ -1,4 +1,3 @@ -import { Buffer } from 'node:buffer' import { createHash } from 'node:crypto' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -9,66 +8,36 @@ vi.mock('#server/utils/npm', () => ({ const { getGravatarFromUsername } = await import('../../../../server/utils/gravatar') const { fetchUserEmail } = await import('#server/utils/npm') -const mockFetch = vi.fn() - describe('gravatar utils', () => { beforeEach(() => { vi.clearAllMocks() - mockFetch.mockReset() - vi.stubGlobal('fetch', mockFetch) }) it('returns null when username is empty', async () => { - const url = await getGravatarFromUsername('') + const hash = await getGravatarFromUsername('') - expect(url).toBeNull() + expect(hash).toBeNull() expect(fetchUserEmail).not.toHaveBeenCalled() }) it('returns null when email is not available', async () => { vi.mocked(fetchUserEmail).mockResolvedValue(null) - const url = await getGravatarFromUsername('user') + const hash = await getGravatarFromUsername('user') - expect(url).toBeNull() + expect(hash).toBeNull() expect(fetchUserEmail).toHaveBeenCalledOnce() }) - it('builds a gravatar data URL with a trimmed, lowercased email hash', async () => { + it('returns md5 hash of trimmed, lowercased email', async () => { const email = ' Test@Example.com ' const normalized = 'test@example.com' - const hash = createHash('md5').update(normalized).digest('hex') - const imageBytes = new Uint8Array([1, 2, 3]) - const base64 = Buffer.from(imageBytes).toString('base64') - vi.mocked(fetchUserEmail).mockResolvedValue(email) - mockFetch.mockResolvedValue({ - ok: true, - headers: { get: () => 'image/png' }, - arrayBuffer: vi.fn().mockResolvedValue(imageBytes.buffer), - }) - - const url = await getGravatarFromUsername('user') - - expect(url).toBe(`data:image/png;base64,${base64}`) - expect(mockFetch).toHaveBeenCalledWith(`https://www.gravatar.com/avatar/${hash}?s=80&d=404`) - }) - - it('supports custom size', async () => { - const email = 'user@example.com' - const hash = createHash('md5').update(email).digest('hex') - const imageBytes = new Uint8Array([4, 5, 6]) - const base64 = Buffer.from(imageBytes).toString('base64') + const expectedHash = createHash('md5').update(normalized).digest('hex') vi.mocked(fetchUserEmail).mockResolvedValue(email) - mockFetch.mockResolvedValue({ - ok: true, - headers: { get: () => 'image/png' }, - arrayBuffer: vi.fn().mockResolvedValue(imageBytes.buffer), - }) - const url = await getGravatarFromUsername('user', 128) + const hash = await getGravatarFromUsername('user') - expect(url).toBe(`data:image/png;base64,${base64}`) - expect(mockFetch).toHaveBeenCalledWith(`https://www.gravatar.com/avatar/${hash}?s=128&d=404`) + expect(hash).toBe(expectedHash) }) it('trims the username before lookup', async () => { From a8bd099a928b288bd27fc08cc8fc44ae10dfc468 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 1 Feb 2026 23:45:46 +0000 Subject: [PATCH 13/13] test: add a11y test for `UserAvatar` --- test/nuxt/a11y.spec.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index dd381b0dd..463420162 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -57,6 +57,7 @@ afterEach(() => { import { AppFooter, AppHeader, + UserAvatar, BuildEnvironment, CallToAction, CodeDirectoryListing, @@ -1794,4 +1795,30 @@ describe('component accessibility audits', () => { expect(results.violations).toEqual([]) }) }) + + describe('UserAvatar', () => { + it('should have no accessibility violations', async () => { + const component = await mountSuspended(UserAvatar, { + props: { username: 'testuser' }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with short username', async () => { + const component = await mountSuspended(UserAvatar, { + props: { username: 'a' }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with long username', async () => { + const component = await mountSuspended(UserAvatar, { + props: { username: 'verylongusernameexample' }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) })