Skip to content

Commit ce58706

Browse files
feat: display author profile picture (#556)
Co-authored-by: Daniel Roe <daniel@roe.dev>
1 parent 771474a commit ce58706

File tree

12 files changed

+264
-19
lines changed

12 files changed

+264
-19
lines changed

app/components/User/Avatar.vue

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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>

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

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -178,15 +178,7 @@ defineOgImageComponent('Default', {
178178
<!-- Header -->
179179
<header class="mb-8 pb-8 border-b border-border">
180180
<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" />
190182
<div>
191183
<h1 class="font-mono text-2xl sm:text-3xl font-medium">~{{ username }}</h1>
192184
<p v-if="results?.total" class="text-fg-muted text-sm mt-1">

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

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -120,15 +120,7 @@ defineOgImageComponent('Default', {
120120
<!-- Header -->
121121
<header class="mb-8 pb-8 border-b border-border">
122122
<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" />
132124
<div>
133125
<h1 class="font-mono text-2xl sm:text-3xl font-medium">~{{ username }}</h1>
134126
<p class="text-fg-muted text-sm mt-1">{{ $t('user.orgs_page.title') }}</p>

nuxt.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ export default defineNuxtConfig({
105105
'/api/registry/docs/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
106106
'/api/registry/file/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
107107
'/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+
},
108114
// static pages
109115
'/about': { prerender: true },
110116
'/settings': { prerender: true },
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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+
)

server/utils/gravatar.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
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+
}

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 { encodePackageName, fetchLatestVersion } from '#shared/utils/npm'
33
import { maxSatisfying, prerelease } from 'semver'
44
import { CACHE_MAX_AGE_FIVE_MINUTES } from '#shared/utils/constants'
@@ -99,3 +99,43 @@ export async function resolveDependencyVersions(
9999
}
100100
return resolved
101101
}
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+
)

shared/schemas/user.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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>

shared/utils/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export const ERROR_SUGGESTIONS_FETCH_FAILED = 'Failed to fetch suggestions.'
2121
export const ERROR_SKILLS_FETCH_FAILED = 'Failed to fetch skills.'
2222
export const ERROR_SKILL_NOT_FOUND = 'Skill not found.'
2323
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."
2428

2529
// microcosm services
2630
export const CONSTELLATION_HOST = 'constellation.microcosm.blue'

shared/utils/npm.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { getLatestVersion } from 'fast-npm-meta'
22
import { createError } from 'h3'
33
import validatePackageName from 'validate-npm-package-name'
44

5+
const NPM_USERNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i
6+
const NPM_USERNAME_MAX_LENGTH = 50
7+
58
/**
69
* Encode package name for URL usage.
710
* Scoped packages need special handling (@scope/name → @scope%2Fname)
@@ -45,3 +48,18 @@ export function assertValidPackageName(name: string): void {
4548
})
4649
}
4750
}
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+
}

0 commit comments

Comments
 (0)