Skip to content

Commit b1f1f7f

Browse files
committed
feat: display author profile picture
1 parent 200554e commit b1f1f7f

6 files changed

Lines changed: 223 additions & 9 deletions

File tree

app/pages/~[username]/index.vue

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,26 @@ const router = useRouter()
66
77
const username = computed(() => route.params.username)
88
9+
async function fetchGravatarUrl(handle: string): Promise<string | null> {
10+
if (!handle) return null
11+
12+
try {
13+
const response = await $fetch<{ url: string | null }>(
14+
`/api/gravatar?username=${encodeURIComponent(handle)}&size=64`,
15+
)
16+
return response.url ?? null
17+
} catch {
18+
// Gravatar couldn't be fetched, it is ignored as not considered an error
19+
return null
20+
}
21+
}
22+
23+
const { data: gravatarUrl } = useLazyAsyncData(
24+
() => `gravatar:${username.value}`,
25+
() => fetchGravatarUrl(username.value),
26+
{ watch: [username] },
27+
)
28+
929
// Infinite scroll state
1030
const pageSize = 50
1131
const maxResults = 250 // npm API hard limit
@@ -179,14 +199,25 @@ defineOgImageComponent('Default', {
179199
<!-- Header -->
180200
<header class="mb-8 pb-8 border-b border-border">
181201
<div class="flex items-end gap-4">
182-
<!-- Avatar placeholder -->
202+
<!-- Avatar -->
183203
<div
184-
class="w-16 h-16 rounded-full bg-bg-muted border border-border flex items-center justify-center"
185-
aria-hidden="true"
204+
class="w-16 h-16 rounded-full bg-bg-muted border border-border flex items-center justify-center overflow-hidden"
205+
role="img"
206+
:aria-label="`Avatar for ${username}`"
186207
>
187-
<span class="text-2xl text-fg-subtle font-mono">{{
188-
username.charAt(0).toUpperCase()
189-
}}</span>
208+
<!-- If Gravatar was fetched, display it -->
209+
<img
210+
v-if="gravatarUrl"
211+
:src="gravatarUrl"
212+
alt=""
213+
width="64"
214+
height="64"
215+
class="w-full h-full object-cover"
216+
/>
217+
<!-- Else fallback to initials -->
218+
<span v-else class="text-2xl text-fg-subtle font-mono" aria-hidden="true">
219+
{{ username.charAt(0).toUpperCase() }}
220+
</span>
190221
</div>
191222
<div>
192223
<h1 class="font-mono text-2xl sm:text-3xl font-medium">~{{ username }}</h1>

server/api/gravatar.get.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { H3Event } from 'h3'
2+
import { CACHE_MAX_AGE_ONE_DAY } from '#shared/utils/constants'
3+
import { getGravatarFromUsername } from '#server/utils/gravatar'
4+
import { assertValidUsername } from '#shared/utils/npm'
5+
6+
function getQueryParam(event: H3Event, key: string): string {
7+
const query = getQuery(event)
8+
const value = query[key]
9+
return Array.isArray(value) ? String(value[0] ?? '') : String(value ?? '')
10+
}
11+
12+
export default defineCachedEventHandler(
13+
async event => {
14+
const username = getQueryParam(event, 'username').trim()
15+
16+
if (!username) {
17+
throw createError({
18+
statusCode: 400,
19+
message: 'Username is required',
20+
})
21+
}
22+
23+
assertValidUsername(username)
24+
25+
const sizeParam = Number.parseInt(getQueryParam(event, 'size'), 10)
26+
const size = Number.isNaN(sizeParam) ? 80 : Math.max(16, Math.min(512, sizeParam))
27+
28+
const url = await getGravatarFromUsername(username, size)
29+
30+
if (!url) {
31+
throw createError({
32+
statusCode: 400,
33+
message: "User's email not accessible",
34+
})
35+
}
36+
37+
return { url }
38+
},
39+
{
40+
maxAge: CACHE_MAX_AGE_ONE_DAY,
41+
swr: true,
42+
getKey: event => {
43+
const username = getQueryParam(event, 'username').trim().toLowerCase()
44+
const size = getQueryParam(event, 'size') || '80'
45+
const defaultImg = getQueryParam(event, 'default') || '404'
46+
return `gravatar:v1:${username}:${size}:${defaultImg}`
47+
},
48+
},
49+
)

server/utils/gravatar.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { createHash } from 'node:crypto'
2+
import { fetchUserEmail } from '#server/utils/npm'
3+
4+
const DEFAULT_GRAVATAR_SIZE = 80
5+
6+
export async function getGravatarFromUsername(
7+
username: string,
8+
size: number = DEFAULT_GRAVATAR_SIZE,
9+
): Promise<string | null> {
10+
const handle = username.trim()
11+
if (!handle) return null
12+
13+
const email = await fetchUserEmail(handle)
14+
if (!email) return null
15+
16+
const trimmedEmail = email.trim().toLowerCase()
17+
const md5Hash = createHash('md5').update(trimmedEmail).digest('hex')
18+
return `https://www.gravatar.com/avatar/${md5Hash}?s=${size}&d=404`
19+
}

server/utils/npm.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Packument } from '#shared/types'
1+
import type { Packument, NpmSearchResponse } from '#shared/types'
22
import { maxSatisfying, prerelease } from 'semver'
33

44
const NPM_REGISTRY = 'https://registry.npmjs.org'
@@ -85,3 +85,43 @@ export async function resolveDependencyVersions(
8585
}
8686
return resolved
8787
}
88+
89+
/**
90+
* Find a user's email address from its username
91+
* by exploring metadata in its public packages
92+
*/
93+
export const fetchUserEmail = defineCachedFunction(
94+
async (username: string): Promise<string | null> => {
95+
const handle = username.trim()
96+
if (!handle) return null
97+
98+
// Fetch packages with the user's handle as a maintainer
99+
const params = new URLSearchParams({
100+
text: `maintainer:${handle}`,
101+
size: '20',
102+
})
103+
const response = await $fetch<NpmSearchResponse>(`${NPM_REGISTRY}/-/v1/search?${params}`)
104+
const lowerHandle = handle.toLowerCase()
105+
106+
// Search for the user's email in packages metadata
107+
for (const result of response.objects) {
108+
const maintainers = result.package.maintainers ?? []
109+
const match = maintainers.find(
110+
person =>
111+
person.username?.toLowerCase() === lowerHandle ||
112+
person.name?.toLowerCase() === lowerHandle,
113+
)
114+
if (match?.email) {
115+
return match.email
116+
}
117+
}
118+
119+
return null
120+
},
121+
{
122+
maxAge: CACHE_MAX_AGE_ONE_DAY,
123+
swr: true,
124+
name: 'npm-user-email',
125+
getKey: (username: string) => `npm-user-email:${username.toLowerCase()}`,
126+
},
127+
)

shared/utils/npm.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { createError } from 'h3'
22
import validatePackageName from 'validate-npm-package-name'
33

4+
const NPM_USERNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i
5+
const NPM_USERNAME_MAX_LENGTH = 50
6+
47
/**
58
* Validate an npm package name and throw an HTTP error if invalid.
69
* Uses validate-npm-package-name to check against npm naming rules.
@@ -10,9 +13,21 @@ export function assertValidPackageName(name: string): void {
1013
if (!result.validForNewPackages && !result.validForOldPackages) {
1114
const errors = [...(result.errors ?? []), ...(result.warnings ?? [])]
1215
throw createError({
13-
// TODO: throwing 404 rather than 400 as it's cacheable
14-
statusCode: 404,
16+
statusCode: 400,
1517
message: `Invalid package name: ${errors[0] ?? 'unknown error'}`,
1618
})
1719
}
1820
}
21+
22+
/**
23+
* Validate an npm username and throw an HTTP error if invalid.
24+
* Uses a regular expression to check against npm naming rules.
25+
*/
26+
export function assertValidUsername(username: string): void {
27+
if (!username || username.length > NPM_USERNAME_MAX_LENGTH || !NPM_USERNAME_RE.test(username)) {
28+
throw createError({
29+
statusCode: 400,
30+
message: `Invalid username: ${username}`,
31+
})
32+
}
33+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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 url = await getGravatarFromUsername('')
18+
19+
expect(url).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 url = await getGravatarFromUsername('user')
27+
28+
expect(url).toBeNull()
29+
expect(fetchUserEmail).toHaveBeenCalledOnce()
30+
})
31+
32+
it('builds a gravatar URL with a trimmed, lowercased email hash', async () => {
33+
const email = ' Test@Example.com '
34+
const normalized = 'test@example.com'
35+
const hash = createHash('md5').update(normalized).digest('hex')
36+
vi.mocked(fetchUserEmail).mockResolvedValue(email)
37+
38+
const url = await getGravatarFromUsername('user')
39+
40+
expect(url).toBe(`https://www.gravatar.com/avatar/${hash}?s=80&d=404`)
41+
})
42+
43+
it('supports custom size', async () => {
44+
const email = 'user@example.com'
45+
const hash = createHash('md5').update(email).digest('hex')
46+
vi.mocked(fetchUserEmail).mockResolvedValue(email)
47+
48+
const url = await getGravatarFromUsername('user', 128)
49+
50+
expect(url).toBe(`https://www.gravatar.com/avatar/${hash}?s=128&d=404`)
51+
})
52+
53+
it('trims the username before lookup', async () => {
54+
vi.mocked(fetchUserEmail).mockResolvedValue('user@example.com')
55+
56+
await getGravatarFromUsername(' user ')
57+
58+
expect(fetchUserEmail).toHaveBeenCalledWith('user')
59+
})
60+
})

0 commit comments

Comments
 (0)