From 120924e75a4e96740eb843f4a720f8c0969f5e4d Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 11 Oct 2026 16:03:55 +0200 Subject: [PATCH] fix(community): load contributor avatars from open collective's image service The wall routed Open Collective avatars through the Cloudflare resizer, which answers 403 for that origin. Map them to the sized images.opencollective.com render of the profile, render them as plain images, and fall back to the initial when one fails to load. Claude-Session: https://claude.ai/code/session_019zws4ceJu2jctk3XT48fMX --- layers/community/components/CommunityWall.vue | 19 +++++---- layers/community/utils/avatars.ts | 24 +++++++++++ shared/utils/liteSupporters.ts | 4 +- tests/community/avatars.spec.ts | 40 +++++++++++++++++++ tests/community/components.spec.ts | 30 +++++++++++++- 5 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 layers/community/utils/avatars.ts create mode 100644 tests/community/avatars.spec.ts diff --git a/layers/community/components/CommunityWall.vue b/layers/community/components/CommunityWall.vue index ca7c3a60..ba71c0f2 100644 --- a/layers/community/components/CommunityWall.vue +++ b/layers/community/components/CommunityWall.vue @@ -1,5 +1,6 @@ @@ -49,20 +55,17 @@ const avatarOf = (person: Contributor): string | null => { :key="person.key" :class="personClass" > - + @error="failedAvatars.add(person.key)" + > diff --git a/layers/community/utils/avatars.ts b/layers/community/utils/avatars.ts new file mode 100644 index 00000000..053a652a --- /dev/null +++ b/layers/community/utils/avatars.ts @@ -0,0 +1,24 @@ +import { smallAvatarUrl } from '#shared/utils/liteSupporters' + +const OPEN_COLLECTIVE_AVATAR_HOSTS = new Set([ + 'opencollective-production.s3.us-west-1.amazonaws.com', 'images.opencollective.com' +]) + +/** 128px covers the 56px wall box at 2x. */ +const WALL_AVATAR_SIZE = 128 + +/** + * A supporter's avatar as the wall loads it. Open Collective avatars become + * the sized render of the profile: the S3 original is too large, and the + * resizer rejects that origin. Any other host passes through unchanged. + */ +export function supporterAvatarUrl(avatarUrl: string, profile: string | undefined): string | null { + let host: string + try { + host = new URL(avatarUrl).hostname + } catch { + return null + } + if (!OPEN_COLLECTIVE_AVATAR_HOSTS.has(host)) return avatarUrl + return profile ? smallAvatarUrl(profile, WALL_AVATAR_SIZE) : null +} diff --git a/shared/utils/liteSupporters.ts b/shared/utils/liteSupporters.ts index d9db63c8..8937a191 100644 --- a/shared/utils/liteSupporters.ts +++ b/shared/utils/liteSupporters.ts @@ -42,9 +42,9 @@ const avatarOf = (value: unknown): string | null => { } /** Open Collective's resizer: the S3 original runs to 860 KB; 96px covers the 48px box at 2x. */ -export function smallAvatarUrl(profile: string): string | null { +export function smallAvatarUrl(profile: string, size = 96): string | null { const slug = new URL(profile).pathname.split('/').find(Boolean) - return slug ? `https://images.opencollective.com/${slug}/avatar/96.png` : null + return slug ? `https://images.opencollective.com/${slug}/avatar/${size}.png` : null } /** diff --git a/tests/community/avatars.spec.ts b/tests/community/avatars.spec.ts new file mode 100644 index 00000000..85e2dff7 --- /dev/null +++ b/tests/community/avatars.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { smallAvatarUrl } from '../../shared/utils/liteSupporters' +import { supporterAvatarUrl } from '../../layers/community/utils/avatars' + +const S3_ORIGINAL = 'https://opencollective-production.s3.us-west-1.amazonaws.com/account-avatar/1/a.png' +const SIZED = 'https://images.opencollective.com/marc44/avatar/128.png' +const PROFILE = 'https://opencollective.com/marc44' + +describe('supporterAvatarUrl', () => { + it('maps an Open Collective S3 original to the sized render of the profile', () => { + expect(supporterAvatarUrl(S3_ORIGINAL, PROFILE)).toBe(SIZED) + }) + + it('maps an images.opencollective.com avatar to the same sized render', () => { + expect(supporterAvatarUrl('https://images.opencollective.com/marc44/avatar/96.png', PROFILE)).toBe(SIZED) + }) + + it('returns null for an Open Collective avatar without a profile, so the original is never loaded', () => { + expect(supporterAvatarUrl(S3_ORIGINAL, undefined)).toBeNull() + }) + + it('returns null when the profile names no collective slug', () => { + expect(supporterAvatarUrl(S3_ORIGINAL, 'https://opencollective.com/')).toBeNull() + }) + + it('keeps an avatar from another host unchanged', () => { + const other = 'https://mc-heads.net/avatar/marc/128' + expect(supporterAvatarUrl(other, PROFILE)).toBe(other) + }) +}) + +describe('smallAvatarUrl size', () => { + it('renders at the requested size', () => { + expect(smallAvatarUrl(PROFILE, 128)).toBe(SIZED) + }) + + it('renders at 96px unless asked otherwise', () => { + expect(smallAvatarUrl(PROFILE)).toBe('https://images.opencollective.com/marc44/avatar/96.png') + }) +}) diff --git a/tests/community/components.spec.ts b/tests/community/components.spec.ts index 9938a3c8..ee3c40b3 100644 --- a/tests/community/components.spec.ts +++ b/tests/community/components.spec.ts @@ -1,6 +1,7 @@ // @vitest-environment nuxt import { mountSuspended } from '@nuxt/test-utils/runtime' import { describe, expect, it } from 'vitest' +import { nextTick } from 'vue' import CommunityStats from '../../layers/community/components/CommunityStats.vue' import CommunityStrip from '../../layers/community/components/CommunityStrip.vue' import CommunityWall from '../../layers/community/components/CommunityWall.vue' @@ -136,7 +137,34 @@ describe('CommunityWall', () => { contributions: [{ kind: 'supporter', path: 'https://opencollective.com/marc44' }] } const wrapper = await open(CommunityWall, { contributors: [marc] }) - expect(wrapper.get('img').attributes('src')).toContain('opencollective-production') + expect(wrapper.get('img').attributes('src')).toBe('https://images.opencollective.com/marc44/avatar/128.png') + }) + + it('loads the supporter avatar without the image resizer', async () => { + const marc: Contributor = { + key: 'marc', + name: 'Marc', + anchor: 'person-marc', + avatarUrl: 'https://opencollective-production.s3.us-west-1.amazonaws.com/a.png', + contributions: [{ kind: 'supporter', path: 'https://opencollective.com/marc44' }] + } + const wrapper = await open(CommunityWall, { contributors: [marc] }) + expect(wrapper.get('img').attributes('src')).not.toContain('cdn-cgi') + }) + + it('replaces a supporter avatar that fails to load with the initial', async () => { + const marc: Contributor = { + key: 'marc', + name: 'Marc', + anchor: 'person-marc', + avatarUrl: 'https://opencollective-production.s3.us-west-1.amazonaws.com/a.png', + contributions: [{ kind: 'supporter', path: 'https://opencollective.com/marc44' }] + } + const wrapper = await open(CommunityWall, { contributors: [marc] }) + await wrapper.get('img').trigger('error') + await nextTick() + expect(wrapper.find('img').exists()).toBe(false) + expect(wrapper.get('[aria-hidden="true"]').text()).toBe('M') }) it('shows an initial instead of an image for a supporter without avatar', async () => {