Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
43 changes: 37 additions & 6 deletions app/pages/~[username]/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,26 @@ const router = useRouter()

const username = computed(() => route.params.username)

async function fetchGravatarUrl(handle: string): Promise<string | null> {
if (!handle) return null

try {
const response = await $fetch<{ url: string | null }>(
`/api/gravatar?username=${encodeURIComponent(handle)}&size=64`,
)
return response.url ?? null
} catch {
// Gravatar couldn't be fetched, it is ignored as not considered an error
return null
}
}

const { data: gravatarUrl } = useLazyAsyncData(
() => `gravatar:${username.value}`,
() => fetchGravatarUrl(username.value),
{ watch: [username] },
)

// Infinite scroll state
const pageSize = 50
const maxResults = 250 // npm API hard limit
Expand Down Expand Up @@ -179,14 +199,25 @@ defineOgImageComponent('Default', {
<!-- Header -->
<header class="mb-8 pb-8 border-b border-border">
<div class="flex flex-wrap items-center gap-4">
<!-- Avatar placeholder -->
<!-- Avatar -->
<div
class="size-16 shrink-0 rounded-full bg-bg-muted border border-border flex items-center justify-center"
aria-hidden="true"
class="w-16 h-16 rounded-full bg-bg-muted border border-border flex items-center justify-center overflow-hidden"
role="img"
:aria-label="`Avatar for ${username}`"
>
<span class="text-2xl text-fg-subtle font-mono">{{
username.charAt(0).toUpperCase()
}}</span>
<!-- 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>
<div>
<h1 class="font-mono text-2xl sm:text-3xl font-medium">~{{ username }}</h1>
Expand Down
51 changes: 51 additions & 0 deletions server/api/gravatar.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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 = getQueryParam(event, 'username')
const rawSize = getQueryParam(event, 'size')

try {
const { username, size } = v.parse(GravatarQuerySchema, {
username: rawUsername,
size: rawSize ? rawSize : undefined,
})

const dataUrl = await getGravatarFromUsername(username, size ?? 80)

if (!dataUrl) {
throw createError({
statusCode: 404,
message: ERROR_GRAVATAR_EMAIL_UNAVAILABLE,
})
}

return { url: dataUrl }
} 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}`
},
},
)
32 changes: 32 additions & 0 deletions server/utils/gravatar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Buffer } from 'node:buffer'
import { createHash } from 'node:crypto'
import { fetchUserEmail } from '#server/utils/npm'

const DEFAULT_GRAVATAR_SIZE = 80

export async function getGravatarFromUsername(
username: string,
size: number = DEFAULT_GRAVATAR_SIZE,
): 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()
const md5Hash = createHash('md5').update(trimmedEmail).digest('hex')
const gravatarUrl = `https://www.gravatar.com/avatar/${md5Hash}?s=${size}&d=404`

try {
const response = await fetch(gravatarUrl)
if (!response.ok) return null

const contentType = response.headers.get('content-type') || 'image/png'
const arrayBuffer = await response.arrayBuffer()
const base64 = Buffer.from(arrayBuffer).toString('base64')
return `data:${contentType};base64,${base64}`
} catch {
return null
}
}
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 { maxSatisfying, prerelease } from 'semver'

const NPM_REGISTRY = 'https://registry.npmjs.org'
Expand Down Expand Up @@ -84,3 +84,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()}`,
},
)
43 changes: 43 additions & 0 deletions shared/schemas/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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,
size: v.optional(
v.pipe(
v.union([v.number(), v.string()]),
v.transform(value => {
if (typeof value === 'string') {
const trimmed = value.trim()
return /^\d+$/.test(trimmed) ? Number(trimmed) : Number.NaN
}
return value
}),
v.check(value => !Number.isNaN(value), 'Invalid size'),
v.number(),
v.minValue(16),
v.maxValue(512),
),
),
})

/** @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 @@ -20,6 +20,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
20 changes: 18 additions & 2 deletions shared/utils/npm.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
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

/**
* Validate an npm package name and throw an HTTP error if invalid.
* Uses validate-npm-package-name to check against npm naming rules.
Expand All @@ -10,9 +13,22 @@ export function assertValidPackageName(name: string): void {
if (!result.validForNewPackages && !result.validForOldPackages) {
const errors = [...(result.errors ?? []), ...(result.warnings ?? [])]
throw createError({
// TODO: throwing 404 rather than 400 as it's cacheable
statusCode: 404,
statusCode: 400,
message: `Invalid package name: ${errors[0] ?? 'unknown error'}`,
})
}
}

/**
* 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({
statusCode: 400,
message: `Invalid username: ${username}`,
})
}
}
81 changes: 81 additions & 0 deletions test/unit/server/utils/gravatar.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Buffer } from 'node:buffer'
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')

const mockFetch = vi.fn()

describe('gravatar utils', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetch.mockReset()
vi.stubGlobal('fetch', mockFetch)
})

it('returns null when username is empty', async () => {
const url = await getGravatarFromUsername('')

expect(url).toBeNull()
expect(fetchUserEmail).not.toHaveBeenCalled()
})

it('returns null when email is not available', async () => {
vi.mocked(fetchUserEmail).mockResolvedValue(null)

const url = await getGravatarFromUsername('user')

expect(url).toBeNull()
expect(fetchUserEmail).toHaveBeenCalledOnce()
})

it('builds a gravatar data URL with a trimmed, lowercased email hash', async () => {
const email = ' Test@Example.com '
const normalized = 'test@example.com'
const hash = createHash('md5').update(normalized).digest('hex')
const imageBytes = new Uint8Array([1, 2, 3])
const base64 = Buffer.from(imageBytes).toString('base64')
vi.mocked(fetchUserEmail).mockResolvedValue(email)
mockFetch.mockResolvedValue({
ok: true,
headers: { get: () => 'image/png' },
arrayBuffer: vi.fn().mockResolvedValue(imageBytes.buffer),
})

const url = await getGravatarFromUsername('user')

expect(url).toBe(`data:image/png;base64,${base64}`)
expect(mockFetch).toHaveBeenCalledWith(`https://www.gravatar.com/avatar/${hash}?s=80&d=404`)
})

it('supports custom size', async () => {
const email = 'user@example.com'
const hash = createHash('md5').update(email).digest('hex')
const imageBytes = new Uint8Array([4, 5, 6])
const base64 = Buffer.from(imageBytes).toString('base64')
vi.mocked(fetchUserEmail).mockResolvedValue(email)
mockFetch.mockResolvedValue({
ok: true,
headers: { get: () => 'image/png' },
arrayBuffer: vi.fn().mockResolvedValue(imageBytes.buffer),
})

const url = await getGravatarFromUsername('user', 128)

expect(url).toBe(`data:image/png;base64,${base64}`)
expect(mockFetch).toHaveBeenCalledWith(`https://www.gravatar.com/avatar/${hash}?s=128&d=404`)
})

it('trims the username before lookup', async () => {
vi.mocked(fetchUserEmail).mockResolvedValue('user@example.com')

await getGravatarFromUsername(' user ')

expect(fetchUserEmail).toHaveBeenCalledWith('user')
})
})
Loading