[READ-ONLY] a fast, modern browser for the npm registry

feat: display author profile picture (#556)

Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

Gugustinette
Daniel Roe
and committed by
GitHub
ce587063 771474a4

+264 -19
+35
app/components/User/Avatar.vue
··· 1 + <script setup lang="ts"> 2 + const props = defineProps<{ 3 + username: string 4 + }>() 5 + 6 + const { data: gravatarUrl } = useLazyFetch(() => `/api/gravatar/${props.username}`, { 7 + transform: res => (res.hash ? `/_avatar/${res.hash}?s=128&d=404` : null), 8 + getCachedData(key, nuxtApp) { 9 + return nuxtApp.static.data[key] ?? nuxtApp.payload.data[key] 10 + }, 11 + }) 12 + </script> 13 + 14 + <template> 15 + <!-- Avatar --> 16 + <div 17 + class="size-16 shrink-0 rounded-full bg-bg-muted border border-border flex items-center justify-center overflow-hidden" 18 + role="img" 19 + :aria-label="`Avatar for ${username}`" 20 + > 21 + <!-- If Gravatar was fetched, display it --> 22 + <img 23 + v-if="gravatarUrl" 24 + :src="gravatarUrl" 25 + alt="" 26 + width="64" 27 + height="64" 28 + class="w-full h-full object-cover" 29 + /> 30 + <!-- Else fallback to initials --> 31 + <span v-else class="text-2xl text-fg-subtle font-mono" aria-hidden="true"> 32 + {{ username.charAt(0).toUpperCase() }} 33 + </span> 34 + </div> 35 + </template>
+1 -9
app/pages/~[username]/index.vue
··· 178 178 <!-- Header --> 179 179 <header class="mb-8 pb-8 border-b border-border"> 180 180 <div class="flex flex-wrap items-center gap-4"> 181 - <!-- Avatar placeholder --> 182 - <div 183 - class="size-16 shrink-0 rounded-full bg-bg-muted border border-border flex items-center justify-center" 184 - aria-hidden="true" 185 - > 186 - <span class="text-2xl text-fg-subtle font-mono">{{ 187 - username.charAt(0).toUpperCase() 188 - }}</span> 189 - </div> 181 + <UserAvatar :username="username" /> 190 182 <div> 191 183 <h1 class="font-mono text-2xl sm:text-3xl font-medium">~{{ username }}</h1> 192 184 <p v-if="results?.total" class="text-fg-muted text-sm mt-1">
+1 -9
app/pages/~[username]/orgs.vue
··· 120 120 <!-- Header --> 121 121 <header class="mb-8 pb-8 border-b border-border"> 122 122 <div class="flex flex-wrap items-center gap-4 mb-4"> 123 - <!-- Avatar placeholder --> 124 - <div 125 - class="size-16 shrink-0 rounded-full bg-bg-muted border border-border flex items-center justify-center" 126 - aria-hidden="true" 127 - > 128 - <span class="text-2xl text-fg-subtle font-mono">{{ 129 - username.charAt(0).toUpperCase() 130 - }}</span> 131 - </div> 123 + <UserAvatar :username="username" /> 132 124 <div> 133 125 <h1 class="font-mono text-2xl sm:text-3xl font-medium">~{{ username }}</h1> 134 126 <p class="text-fg-muted text-sm mt-1">{{ $t('user.orgs_page.title') }}</p>
+6
nuxt.config.ts
··· 105 105 '/api/registry/docs/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, 106 106 '/api/registry/file/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, 107 107 '/api/registry/files/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } }, 108 + '/_avatar/**': { 109 + isr: 3600, 110 + proxy: { 111 + to: 'https://www.gravatar.com/avatar/**', 112 + }, 113 + }, 108 114 // static pages 109 115 '/about': { prerender: true }, 110 116 '/settings': { prerender: true },
+41
server/api/gravatar/[username].get.ts
··· 1 + import { createError } from 'h3' 2 + import * as v from 'valibot' 3 + import { GravatarQuerySchema } from '#shared/schemas/user' 4 + import { getGravatarFromUsername } from '#server/utils/gravatar' 5 + import { handleApiError } from '#server/utils/error-handler' 6 + 7 + export default defineCachedEventHandler( 8 + async event => { 9 + const rawUsername = getRouterParam(event, 'username') 10 + 11 + try { 12 + const { username } = v.parse(GravatarQuerySchema, { 13 + username: rawUsername, 14 + }) 15 + 16 + const hash = await getGravatarFromUsername(username) 17 + 18 + if (!hash) { 19 + throw createError({ 20 + statusCode: 404, 21 + message: ERROR_GRAVATAR_EMAIL_UNAVAILABLE, 22 + }) 23 + } 24 + 25 + return { hash } 26 + } catch (error: unknown) { 27 + handleApiError(error, { 28 + statusCode: 502, 29 + message: ERROR_GRAVATAR_FETCH_FAILED, 30 + }) 31 + } 32 + }, 33 + { 34 + maxAge: CACHE_MAX_AGE_ONE_DAY, 35 + swr: true, 36 + getKey: event => { 37 + const username = getRouterParam(event, 'username')?.trim().toLowerCase() 38 + return `gravatar:v1:${username}` 39 + }, 40 + }, 41 + )
+13
server/utils/gravatar.ts
··· 1 + import { createHash } from 'node:crypto' 2 + import { fetchUserEmail } from '#server/utils/npm' 3 + 4 + export async function getGravatarFromUsername(username: string): Promise<string | null> { 5 + const handle = username.trim() 6 + if (!handle) return null 7 + 8 + const email = await fetchUserEmail(handle) 9 + if (!email) return null 10 + 11 + const trimmedEmail = email.trim().toLowerCase() 12 + return createHash('md5').update(trimmedEmail).digest('hex') 13 + }
+41 -1
server/utils/npm.ts
··· 1 - import type { Packument } from '#shared/types' 1 + import type { Packument, NpmSearchResponse } from '#shared/types' 2 2 import { encodePackageName, fetchLatestVersion } from '#shared/utils/npm' 3 3 import { maxSatisfying, prerelease } from 'semver' 4 4 import { CACHE_MAX_AGE_FIVE_MINUTES } from '#shared/utils/constants' ··· 99 99 } 100 100 return resolved 101 101 } 102 + 103 + /** 104 + * Find a user's email address from its username 105 + * by exploring metadata in its public packages 106 + */ 107 + export const fetchUserEmail = defineCachedFunction( 108 + async (username: string): Promise<string | null> => { 109 + const handle = username.trim() 110 + if (!handle) return null 111 + 112 + // Fetch packages with the user's handle as a maintainer 113 + const params = new URLSearchParams({ 114 + text: `maintainer:${handle}`, 115 + size: '20', 116 + }) 117 + const response = await $fetch<NpmSearchResponse>(`${NPM_REGISTRY}/-/v1/search?${params}`) 118 + const lowerHandle = handle.toLowerCase() 119 + 120 + // Search for the user's email in packages metadata 121 + for (const result of response.objects) { 122 + const maintainers = result.package.maintainers ?? [] 123 + const match = maintainers.find( 124 + person => 125 + person.username?.toLowerCase() === lowerHandle || 126 + person.name?.toLowerCase() === lowerHandle, 127 + ) 128 + if (match?.email) { 129 + return match.email 130 + } 131 + } 132 + 133 + return null 134 + }, 135 + { 136 + maxAge: CACHE_MAX_AGE_ONE_DAY, 137 + swr: true, 138 + name: 'npm-user-email', 139 + getKey: (username: string) => `npm-user-email:${username.trim().toLowerCase()}`, 140 + }, 141 + )
+27
shared/schemas/user.ts
··· 1 + import * as v from 'valibot' 2 + 3 + const NPM_USERNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i 4 + const NPM_USERNAME_MAX_LENGTH = 50 5 + 6 + /** 7 + * Schema for npm usernames. 8 + */ 9 + export const NpmUsernameSchema = v.pipe( 10 + v.string(), 11 + v.trim(), 12 + v.nonEmpty('Username is required'), 13 + v.maxLength(NPM_USERNAME_MAX_LENGTH, 'Username is too long'), 14 + v.regex(NPM_USERNAME_RE, 'Invalid username format'), 15 + ) 16 + 17 + /** 18 + * Schema for Gravatar query inputs. 19 + */ 20 + export const GravatarQuerySchema = v.object({ 21 + username: NpmUsernameSchema, 22 + }) 23 + 24 + /** @public */ 25 + export type NpmUsername = v.InferOutput<typeof NpmUsernameSchema> 26 + /** @public */ 27 + export type GravatarQuery = v.InferOutput<typeof GravatarQuerySchema>
+4
shared/utils/constants.ts
··· 21 21 export const ERROR_SKILLS_FETCH_FAILED = 'Failed to fetch skills.' 22 22 export const ERROR_SKILL_NOT_FOUND = 'Skill not found.' 23 23 export const ERROR_SKILL_FILE_NOT_FOUND = 'Skill file not found.' 24 + /** @public */ 25 + export const ERROR_GRAVATAR_FETCH_FAILED = 'Failed to fetch Gravatar profile.' 26 + /** @public */ 27 + export const ERROR_GRAVATAR_EMAIL_UNAVAILABLE = "User's email not accessible." 24 28 25 29 // microcosm services 26 30 export const CONSTELLATION_HOST = 'constellation.microcosm.blue'
+18
shared/utils/npm.ts
··· 2 2 import { createError } from 'h3' 3 3 import validatePackageName from 'validate-npm-package-name' 4 4 5 + const NPM_USERNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i 6 + const NPM_USERNAME_MAX_LENGTH = 50 7 + 5 8 /** 6 9 * Encode package name for URL usage. 7 10 * Scoped packages need special handling (@scope/name → @scope%2Fname) ··· 45 48 }) 46 49 } 47 50 } 51 + 52 + /** 53 + * Validate an npm username and throw an HTTP error if invalid. 54 + * Uses a regular expression to check against npm naming rules. 55 + * @public 56 + */ 57 + export function assertValidUsername(username: string): void { 58 + if (!username || username.length > NPM_USERNAME_MAX_LENGTH || !NPM_USERNAME_RE.test(username)) { 59 + throw createError({ 60 + // TODO: throwing 404 rather than 400 as it's cacheable 61 + statusCode: 404, 62 + message: `Invalid username: ${username}`, 63 + }) 64 + } 65 + }
+27
test/nuxt/a11y.spec.ts
··· 57 57 import { 58 58 AppFooter, 59 59 AppHeader, 60 + UserAvatar, 60 61 BuildEnvironment, 61 62 CallToAction, 62 63 CodeDirectoryListing, ··· 1795 1796 distTags: mockDistTags, 1796 1797 urlPattern: '/package/vue/v/{version}', 1797 1798 }, 1799 + }) 1800 + const results = await runAxe(component) 1801 + expect(results.violations).toEqual([]) 1802 + }) 1803 + }) 1804 + 1805 + describe('UserAvatar', () => { 1806 + it('should have no accessibility violations', async () => { 1807 + const component = await mountSuspended(UserAvatar, { 1808 + props: { username: 'testuser' }, 1809 + }) 1810 + const results = await runAxe(component) 1811 + expect(results.violations).toEqual([]) 1812 + }) 1813 + 1814 + it('should have no accessibility violations with short username', async () => { 1815 + const component = await mountSuspended(UserAvatar, { 1816 + props: { username: 'a' }, 1817 + }) 1818 + const results = await runAxe(component) 1819 + expect(results.violations).toEqual([]) 1820 + }) 1821 + 1822 + it('should have no accessibility violations with long username', async () => { 1823 + const component = await mountSuspended(UserAvatar, { 1824 + props: { username: 'verylongusernameexample' }, 1798 1825 }) 1799 1826 const results = await runAxe(component) 1800 1827 expect(results.violations).toEqual([])
+50
test/unit/server/utils/gravatar.spec.ts
··· 1 + import { createHash } from 'node:crypto' 2 + import { beforeEach, describe, expect, it, vi } from 'vitest' 3 + 4 + vi.mock('#server/utils/npm', () => ({ 5 + fetchUserEmail: vi.fn(), 6 + })) 7 + 8 + const { getGravatarFromUsername } = await import('../../../../server/utils/gravatar') 9 + const { fetchUserEmail } = await import('#server/utils/npm') 10 + 11 + describe('gravatar utils', () => { 12 + beforeEach(() => { 13 + vi.clearAllMocks() 14 + }) 15 + 16 + it('returns null when username is empty', async () => { 17 + const hash = await getGravatarFromUsername('') 18 + 19 + expect(hash).toBeNull() 20 + expect(fetchUserEmail).not.toHaveBeenCalled() 21 + }) 22 + 23 + it('returns null when email is not available', async () => { 24 + vi.mocked(fetchUserEmail).mockResolvedValue(null) 25 + 26 + const hash = await getGravatarFromUsername('user') 27 + 28 + expect(hash).toBeNull() 29 + expect(fetchUserEmail).toHaveBeenCalledOnce() 30 + }) 31 + 32 + it('returns md5 hash of trimmed, lowercased email', async () => { 33 + const email = ' Test@Example.com ' 34 + const normalized = 'test@example.com' 35 + const expectedHash = createHash('md5').update(normalized).digest('hex') 36 + vi.mocked(fetchUserEmail).mockResolvedValue(email) 37 + 38 + const hash = await getGravatarFromUsername('user') 39 + 40 + expect(hash).toBe(expectedHash) 41 + }) 42 + 43 + it('trims the username before lookup', async () => { 44 + vi.mocked(fetchUserEmail).mockResolvedValue('user@example.com') 45 + 46 + await getGravatarFromUsername(' user ') 47 + 48 + expect(fetchUserEmail).toHaveBeenCalledWith('user') 49 + }) 50 + })