diff --git a/app/components/User/Avatar.vue b/app/components/User/Avatar.vue new file mode 100644 index 000000000..16eab74ae --- /dev/null +++ b/app/components/User/Avatar.vue @@ -0,0 +1,32 @@ + + + diff --git a/app/pages/~[username]/index.vue b/app/pages/~[username]/index.vue index 580818c42..f5b414714 100644 --- a/app/pages/~[username]/index.vue +++ b/app/pages/~[username]/index.vue @@ -179,15 +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') }}

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/[username].get.ts b/server/api/gravatar/[username].get.ts new file mode 100644 index 000000000..c97014cb2 --- /dev/null +++ b/server/api/gravatar/[username].get.ts @@ -0,0 +1,49 @@ +import type { H3Event } from 'h3' +import { createError, getQuery } from 'h3' +import * as v from 'valibot' +import { GravatarQuerySchema } from '#shared/schemas/user' +import { getGravatarFromUsername } from '#server/utils/gravatar' +import { handleApiError } from '#server/utils/error-handler' + +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 rawUsername = getRouterParam(event, 'username') + + try { + const { username } = v.parse(GravatarQuerySchema, { + username: rawUsername, + }) + + const hash = await getGravatarFromUsername(username) + + if (!hash) { + throw createError({ + statusCode: 404, + message: ERROR_GRAVATAR_EMAIL_UNAVAILABLE, + }) + } + + return { hash } + } 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' + return `gravatar:v1:${username}:${size}` + }, + }, +) diff --git a/server/utils/gravatar.ts b/server/utils/gravatar.ts new file mode 100644 index 000000000..339457b5e --- /dev/null +++ b/server/utils/gravatar.ts @@ -0,0 +1,13 @@ +import { createHash } from 'node:crypto' +import { fetchUserEmail } from '#server/utils/npm' + +export async function getGravatarFromUsername(username: string): Promise { + const handle = username.trim() + if (!handle) return null + + const email = await fetchUserEmail(handle) + if (!email) return null + + const trimmedEmail = email.trim().toLowerCase() + return createHash('md5').update(trimmedEmail).digest('hex') +} diff --git a/server/utils/npm.ts b/server/utils/npm.ts index 59851981d..586762609 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 { encodePackageName, fetchLatestVersion } from '#shared/utils/npm' import { maxSatisfying, prerelease } from 'semver' import { CACHE_MAX_AGE_FIVE_MINUTES } from '#shared/utils/constants' @@ -99,3 +99,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.trim().toLowerCase()}`, + }, +) diff --git a/shared/schemas/user.ts b/shared/schemas/user.ts new file mode 100644 index 000000000..6b9fd1e56 --- /dev/null +++ b/shared/schemas/user.ts @@ -0,0 +1,27 @@ +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, +}) + +/** @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 0325c1739..d7c6ccfa0 100644 --- a/shared/utils/constants.ts +++ b/shared/utils/constants.ts @@ -21,6 +21,10 @@ export const ERROR_SUGGESTIONS_FETCH_FAILED = 'Failed to fetch suggestions.' export const ERROR_SKILLS_FETCH_FAILED = 'Failed to fetch skills.' export const ERROR_SKILL_NOT_FOUND = 'Skill not found.' export const ERROR_SKILL_FILE_NOT_FOUND = 'Skill file not found.' +/** @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 export const CONSTELLATION_HOST = 'constellation.microcosm.blue' diff --git a/shared/utils/npm.ts b/shared/utils/npm.ts index 37e05af3a..ae918a6c4 100644 --- a/shared/utils/npm.ts +++ b/shared/utils/npm.ts @@ -2,6 +2,9 @@ import { getLatestVersion } from 'fast-npm-meta' 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 + /** * Encode package name for URL usage. * Scoped packages need special handling (@scope/name → @scope%2Fname) @@ -45,3 +48,18 @@ 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)) { + throw createError({ + // TODO: throwing 404 rather than 400 as it's cacheable + statusCode: 404, + message: `Invalid username: ${username}`, + }) + } +} 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([]) + }) + }) }) diff --git a/test/unit/server/utils/gravatar.spec.ts b/test/unit/server/utils/gravatar.spec.ts new file mode 100644 index 000000000..491b7914a --- /dev/null +++ b/test/unit/server/utils/gravatar.spec.ts @@ -0,0 +1,50 @@ +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 hash = await getGravatarFromUsername('') + + expect(hash).toBeNull() + expect(fetchUserEmail).not.toHaveBeenCalled() + }) + + it('returns null when email is not available', async () => { + vi.mocked(fetchUserEmail).mockResolvedValue(null) + + const hash = await getGravatarFromUsername('user') + + expect(hash).toBeNull() + expect(fetchUserEmail).toHaveBeenCalledOnce() + }) + + it('returns md5 hash of trimmed, lowercased email', async () => { + const email = ' Test@Example.com ' + const normalized = 'test@example.com' + const expectedHash = createHash('md5').update(normalized).digest('hex') + vi.mocked(fetchUserEmail).mockResolvedValue(email) + + const hash = await getGravatarFromUsername('user') + + expect(hash).toBe(expectedHash) + }) + + it('trims the username before lookup', async () => { + vi.mocked(fetchUserEmail).mockResolvedValue('user@example.com') + + await getGravatarFromUsername(' user ') + + expect(fetchUserEmail).toHaveBeenCalledWith('user') + }) +})