Skip to content

Commit 4e1c478

Browse files
committed
feat: proxy gravatars by returning data urls instead of gravatar url
1 parent 6aab607 commit 4e1c478

3 files changed

Lines changed: 60 additions & 38 deletions

File tree

server/api/gravatar.get.ts

Lines changed: 22 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,42 +11,30 @@ function getQueryParam(event: H3Event, key: string): string {
1111
return Array.isArray(value) ? String(value[0] ?? '') : String(value ?? '')
1212
}
1313

14-
export default defineCachedEventHandler(
15-
async event => {
16-
const rawUsername = getQueryParam(event, 'username')
17-
const rawSize = getQueryParam(event, 'size')
14+
export default defineCachedEventHandler(async event => {
15+
const rawUsername = getQueryParam(event, 'username')
16+
const rawSize = getQueryParam(event, 'size')
1817

19-
try {
20-
const { username, size } = v.parse(GravatarQuerySchema, {
21-
username: rawUsername,
22-
size: rawSize ? rawSize : undefined,
23-
})
24-
25-
const url = await getGravatarFromUsername(username, size ?? 80)
18+
try {
19+
const { username, size } = v.parse(GravatarQuerySchema, {
20+
username: rawUsername,
21+
size: rawSize ? rawSize : undefined,
22+
})
2623

27-
if (!url) {
28-
throw createError({
29-
statusCode: 404,
30-
message: ERROR_GRAVATAR_EMAIL_UNAVAILABLE,
31-
})
32-
}
24+
const dataUrl = await getGravatarFromUsername(username, size ?? 80)
3325

34-
return { url }
35-
} catch (error: unknown) {
36-
handleApiError(error, {
37-
statusCode: 502,
38-
message: ERROR_GRAVATAR_FETCH_FAILED,
26+
if (!dataUrl) {
27+
throw createError({
28+
statusCode: 404,
29+
message: ERROR_GRAVATAR_EMAIL_UNAVAILABLE,
3930
})
4031
}
41-
},
42-
{
43-
maxAge: CACHE_MAX_AGE_ONE_DAY,
44-
swr: true,
45-
getKey: event => {
46-
const username = getQueryParam(event, 'username').trim().toLowerCase()
47-
const size = getQueryParam(event, 'size') || '80'
48-
const defaultImg = getQueryParam(event, 'default') || '404'
49-
return `gravatar:v1:${username}:${size}:${defaultImg}`
50-
},
51-
},
52-
)
32+
33+
return { url: dataUrl }
34+
} catch (error: unknown) {
35+
handleApiError(error, {
36+
statusCode: 502,
37+
message: ERROR_GRAVATAR_FETCH_FAILED,
38+
})
39+
}
40+
})

server/utils/gravatar.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Buffer } from 'node:buffer'
12
import { createHash } from 'node:crypto'
23
import { fetchUserEmail } from '#server/utils/npm'
34

@@ -15,5 +16,17 @@ export async function getGravatarFromUsername(
1516

1617
const trimmedEmail = email.trim().toLowerCase()
1718
const md5Hash = createHash('md5').update(trimmedEmail).digest('hex')
18-
return `https://www.gravatar.com/avatar/${md5Hash}?s=${size}&d=404`
19+
const gravatarUrl = `https://www.gravatar.com/avatar/${md5Hash}?s=${size}&d=404`
20+
21+
try {
22+
const response = await fetch(gravatarUrl)
23+
if (!response.ok) return null
24+
25+
const contentType = response.headers.get('content-type') || 'image/png'
26+
const arrayBuffer = await response.arrayBuffer()
27+
const base64 = Buffer.from(arrayBuffer).toString('base64')
28+
return `data:${contentType};base64,${base64}`
29+
} catch {
30+
return null
31+
}
1932
}

test/unit/server/utils/gravatar.spec.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Buffer } from 'node:buffer'
12
import { createHash } from 'node:crypto'
23
import { beforeEach, describe, expect, it, vi } from 'vitest'
34

@@ -8,9 +9,13 @@ vi.mock('#server/utils/npm', () => ({
89
const { getGravatarFromUsername } = await import('../../../../server/utils/gravatar')
910
const { fetchUserEmail } = await import('#server/utils/npm')
1011

12+
const mockFetch = vi.fn()
13+
1114
describe('gravatar utils', () => {
1215
beforeEach(() => {
1316
vi.clearAllMocks()
17+
mockFetch.mockReset()
18+
vi.stubGlobal('fetch', mockFetch)
1419
})
1520

1621
it('returns null when username is empty', async () => {
@@ -29,25 +34,41 @@ describe('gravatar utils', () => {
2934
expect(fetchUserEmail).toHaveBeenCalledOnce()
3035
})
3136

32-
it('builds a gravatar URL with a trimmed, lowercased email hash', async () => {
37+
it('builds a gravatar data URL with a trimmed, lowercased email hash', async () => {
3338
const email = ' Test@Example.com '
3439
const normalized = 'test@example.com'
3540
const hash = createHash('md5').update(normalized).digest('hex')
41+
const imageBytes = new Uint8Array([1, 2, 3])
42+
const base64 = Buffer.from(imageBytes).toString('base64')
3643
vi.mocked(fetchUserEmail).mockResolvedValue(email)
44+
mockFetch.mockResolvedValue({
45+
ok: true,
46+
headers: { get: () => 'image/png' },
47+
arrayBuffer: vi.fn().mockResolvedValue(imageBytes.buffer),
48+
})
3749

3850
const url = await getGravatarFromUsername('user')
3951

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

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

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

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

5374
it('trims the username before lookup', async () => {

0 commit comments

Comments
 (0)