Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
200554e
Merge remote-tracking branch 'origin/main' into feat/display-author-p…
Gugustinette Jan 31, 2026
b1f1f7f
feat: display author profile picture
Gugustinette Jan 31, 2026
20a9889
fix: follow contributing guidelines
Gugustinette Jan 31, 2026
7ddb1a2
chore: knip being a little silly cute bot
Gugustinette Jan 31, 2026
99057c5
Merge branch 'main' into feat/display-author-profile-picture
Gugustinette Feb 1, 2026
6aab607
fix: put back flex-wrap on avatar header
Gugustinette Feb 1, 2026
4e1c478
feat: proxy gravatars by returning data urls instead of gravatar url
Gugustinette Feb 1, 2026
98381be
Merge branch 'main' into feat/display-author-profile-picture
Gugustinette Feb 1, 2026
78b8141
Merge branch 'main' into feat/display-author-profile-picture
Gugustinette Feb 1, 2026
d50a303
fix: put back caching
Gugustinette Feb 1, 2026
df99512
fix: no default img
Gugustinette Feb 1, 2026
226fe6a
Merge branch 'main' into feat/display-author-profile-picture
Gugustinette Feb 1, 2026
0610924
Merge branch 'main' into feat/display-author-profile-picture
Gugustinette Feb 1, 2026
295218b
Merge branch 'main' into feat/display-author-profile-picture
Gugustinette Feb 1, 2026
766f6bf
Merge branch 'main' into feat/display-author-profile-picture
Gugustinette Feb 1, 2026
63e7259
Merge branch 'main' into feat/display-author-profile-picture
Gugustinette Feb 1, 2026
d7fe2d8
Merge branch 'main' into feat/display-author-profile-picture
Gugustinette Feb 1, 2026
4048ab6
refactor: usefetch + apply to orgs page too
danielroe Feb 1, 2026
584b7a7
perf: use proxy for gravatar
danielroe Feb 1, 2026
1df2a39
fix: remove incorrect statusCode
danielroe Feb 1, 2026
0d7ffcc
fix: statusCode
danielroe Feb 1, 2026
e8e8064
test: update
danielroe Feb 1, 2026
a8bd099
test: add a11y test for `UserAvatar`
danielroe Feb 1, 2026
2c43715
fix: drop query-based cache
danielroe Feb 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions app/components/User/Avatar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<script setup lang="ts">
const props = defineProps<{
username: string
}>()

const { data: gravatarUrl } = useLazyFetch(() => `/api/gravatar/${props.username}`, {
transform: res => (res.hash ? `/_avatar/${res.hash}?s=128&d=404` : null),
getCachedData(key, nuxtApp) {
return nuxtApp.static.data[key] ?? nuxtApp.payload.data[key]
},
})
</script>

<template>
<!-- Avatar -->
<div
class="size-16 shrink-0 rounded-full bg-bg-muted border border-border flex items-center justify-center overflow-hidden"
role="img"
:aria-label="`Avatar for ${username}`"
>
<!-- If Gravatar was fetched, display it -->
<img
v-if="gravatarUrl"
:src="gravatarUrl"
alt=""
width="64"
height="64"
class="w-full h-full object-cover"
/>
<!-- Else fallback to initials -->
<span v-else class="text-2xl text-fg-subtle font-mono" aria-hidden="true">
{{ username.charAt(0).toUpperCase() }}
</span>
</div>
</template>
10 changes: 1 addition & 9 deletions app/pages/~[username]/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -179,15 +179,7 @@ defineOgImageComponent('Default', {
<!-- Header -->
<header class="mb-8 pb-8 border-b border-border">
<div class="flex flex-wrap items-center gap-4">
<!-- Avatar placeholder -->
<div
class="size-16 shrink-0 rounded-full bg-bg-muted border border-border flex items-center justify-center"
aria-hidden="true"
>
<span class="text-2xl text-fg-subtle font-mono">{{
username.charAt(0).toUpperCase()
}}</span>
</div>
<UserAvatar :username="username" />
<div>
<h1 class="font-mono text-2xl sm:text-3xl font-medium">~{{ username }}</h1>
<p v-if="results?.total" class="text-fg-muted text-sm mt-1">
Expand Down
10 changes: 1 addition & 9 deletions app/pages/~[username]/orgs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,7 @@ defineOgImageComponent('Default', {
<!-- Header -->
<header class="mb-8 pb-8 border-b border-border">
<div class="flex flex-wrap items-center gap-4 mb-4">
<!-- Avatar placeholder -->
<div
class="size-16 shrink-0 rounded-full bg-bg-muted border border-border flex items-center justify-center"
aria-hidden="true"
>
<span class="text-2xl text-fg-subtle font-mono">{{
username.charAt(0).toUpperCase()
}}</span>
</div>
<UserAvatar :username="username" />
<div>
<h1 class="font-mono text-2xl sm:text-3xl font-medium">~{{ username }}</h1>
<p class="text-fg-muted text-sm mt-1">{{ $t('user.orgs_page.title') }}</p>
Expand Down
6 changes: 6 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
41 changes: 41 additions & 0 deletions server/api/gravatar/[username].get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { createError } 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'

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 = getRouterParam(event, 'username')?.trim().toLowerCase()
return `gravatar:v1:${username}`
},
},
)
13 changes: 13 additions & 0 deletions server/utils/gravatar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createHash } from 'node:crypto'
import { fetchUserEmail } from '#server/utils/npm'

export async function getGravatarFromUsername(username: string): Promise<string | null> {
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')
}
42 changes: 41 additions & 1 deletion server/utils/npm.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string | null> => {
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<NpmSearchResponse>(`${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()}`,
},
)
27 changes: 27 additions & 0 deletions shared/schemas/user.ts
Original file line number Diff line number Diff line change
@@ -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<typeof NpmUsernameSchema>
/** @public */
export type GravatarQuery = v.InferOutput<typeof GravatarQuerySchema>
4 changes: 4 additions & 0 deletions shared/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
18 changes: 18 additions & 0 deletions shared/utils/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}`,
})
}
}
27 changes: 27 additions & 0 deletions test/nuxt/a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ afterEach(() => {
import {
AppFooter,
AppHeader,
UserAvatar,
BuildEnvironment,
CallToAction,
CodeDirectoryListing,
Expand Down Expand Up @@ -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([])
})
})
})
50 changes: 50 additions & 0 deletions test/unit/server/utils/gravatar.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading