From 45bec2f50e3d241d3b5962941e333a314dfee637 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Fri, 21 Aug 2026 15:26:11 +0200 Subject: [PATCH 1/4] feat(users): add Hackweek profile histories Add browsable user pages that collect projects, ideas, awards, and participation highlights across Hackweek years. Link people throughout project and navigation surfaces, and extend project search to match team member names.\n\nExpose the profile data through an authenticated users API and cover both the repository behavior and rendered timeline with tests. --- src/app/App.tsx | 2 + src/app/components/AppLayout.tsx | 5 +- src/app/components/ProjectCard.tsx | 9 +- src/app/queries/projects.ts | 9 ++ src/app/routes/ProjectDetailsPage.tsx | 11 +- src/app/routes/ProjectsPage.tsx | 2 +- src/app/routes/UserPage.tsx | 116 +++++++++++++++ src/app/styles.css | 196 +++++++++++++++++++++++++- src/shared/projects.ts | 17 +++ src/worker/index.ts | 2 + src/worker/repositories/projects.ts | 20 ++- src/worker/repositories/users.ts | 122 ++++++++++++++++ src/worker/routes/users.ts | 21 +++ test/app/routes.test.tsx | 57 ++++++++ test/projects/projects.test.ts | 124 ++++++++++++++++ 15 files changed, 700 insertions(+), 13 deletions(-) create mode 100644 src/app/routes/UserPage.tsx create mode 100644 src/worker/repositories/users.ts create mode 100644 src/worker/routes/users.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index 0346ae5..1570291 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -7,6 +7,7 @@ import {AdminPage} from './routes/AdminPage'; import {EditProjectPage, NewProjectPage} from './routes/ProjectEditorPage'; import {ProjectDetailsPage} from './routes/ProjectDetailsPage'; import {ProjectsPage} from './routes/ProjectsPage'; +import {UserPage} from './routes/UserPage'; import {YearAdministrationPage} from './routes/YearAdministrationPage'; import {ProjectVideoWatchPage, VideoWatchPage, WatchPage} from './routes/WatchPage'; import {YearsPage} from './routes/YearsPage'; @@ -54,6 +55,7 @@ export function App() { + {session.user.role === 'admin' ? ( diff --git a/src/app/components/AppLayout.tsx b/src/app/components/AppLayout.tsx index b7a4e90..40e9b13 100644 --- a/src/app/components/AppLayout.tsx +++ b/src/app/components/AppLayout.tsx @@ -66,13 +66,14 @@ export function AppLayout({ {viewModeError && {viewModeError}} )} -
{user.displayName} {user.role} -
+
))} diff --git a/src/app/components/UserAvatar.tsx b/src/app/components/UserAvatar.tsx new file mode 100644 index 0000000..d936fe5 --- /dev/null +++ b/src/app/components/UserAvatar.tsx @@ -0,0 +1,44 @@ +import {useEffect, useState} from 'react'; + +export function UserAvatar({ + user, + className = '', +}: { + user: {id: string; displayName: string; avatarUrl?: string | null}; + className?: string; +}) { + const [failed, setFailed] = useState(false); + + useEffect(() => setFailed(false), [user.id, user.avatarUrl]); + + const classes = `userAvatar${className ? ` ${className}` : ''}`; + if (!user.avatarUrl || failed) { + return ( + + ); + } + + return ( + setFailed(true)} + /> + ); +} + +function initials(value: string) { + return value + .split(/\s+/) + .slice(0, 2) + .map((part) => part[0]) + .join('') + .toUpperCase(); +} diff --git a/src/app/routes/ProjectDetailsPage.tsx b/src/app/routes/ProjectDetailsPage.tsx index 7d0863f..e197e2d 100644 --- a/src/app/routes/ProjectDetailsPage.tsx +++ b/src/app/routes/ProjectDetailsPage.tsx @@ -5,6 +5,7 @@ import {Link, useLocation, useParams} from 'wouter'; import {QueryState} from '../components/AppLayout'; import {Markdown} from '../components/Markdown'; import {ProjectVoting} from '../components/ProjectVoting'; +import {UserAvatar} from '../components/UserAvatar'; import {useBallotStatus} from '../queries/administration'; import {getPlayback, useProjectVideo} from '../queries/videos'; import {ProjectVideoPanel} from '../video/ProjectVideoPanel'; @@ -108,6 +109,29 @@ export function ProjectDetailsPage() { {actionError}

)} + {project.data.project.awards.length > 0 && ( +
+
+

Hackweek honors

+

+ {project.data.project.awards.length === 1 + ? 'award winner' + : 'award winners'} +

+
+
    + {project.data.project.awards.map((award) => ( +
  • + +
    + {award.name} + {award.categoryName} +
    +
  • + ))} +
+
+ )}

project summary

@@ -143,7 +167,13 @@ export function ProjectDetailsPage() {
    {project.data.project.members.map((member) => (
  • - {initials(member.displayName)} + + + {member.displayName} {member.email} @@ -277,15 +307,6 @@ export function ProjectDetailsPage() { ); } -function initials(value: string) { - return value - .split(/\s+/) - .slice(0, 2) - .map((part) => part[0]) - .join('') - .toUpperCase(); -} - function isImageMediaType(mediaType: string | null) { return mediaType?.toLowerCase().startsWith('image/') ?? false; } diff --git a/src/app/routes/UserPage.tsx b/src/app/routes/UserPage.tsx index c5d7f67..21ec99f 100644 --- a/src/app/routes/UserPage.tsx +++ b/src/app/routes/UserPage.tsx @@ -2,6 +2,7 @@ import {Link, useParams} from 'wouter'; import {PageState, QueryState} from '../components/AppLayout'; import {ProjectCard} from '../components/ProjectCard'; +import {UserAvatar} from '../components/UserAvatar'; import {useUserProfile} from '../queries/projects'; export function UserPage() { @@ -17,11 +18,7 @@ export function UserPage() { ← hackweek archive
    - {profile.data.user.avatarUrl ? ( - - ) : ( - - )} +

    Hackweek maker

    {profile.data.user.displayName}

    @@ -105,12 +102,3 @@ function Highlight({value, label}: {value: number; label: string}) {
    ); } - -function initials(value: string) { - return value - .split(/\s+/) - .slice(0, 2) - .map((part) => part[0]) - .join('') - .toUpperCase(); -} diff --git a/src/app/styles.css b/src/app/styles.css index 81c8572..1bdae25 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -173,11 +173,24 @@ input:disabled { color: var(--danger); } .identity { - display: grid; + display: flex; + gap: 0.55rem; + align-items: center; color: var(--ink); text-align: right; text-decoration: none; } +.identity > .userAvatar { + width: 2rem; + height: 2rem; + flex: 0 0 2rem; + color: #fff; + font-size: 0.6rem; + font-weight: 600; + border: 2px solid var(--lavender); + border-radius: 50%; + background: var(--blurple); +} .identity:hover { color: var(--blurple); } @@ -193,15 +206,16 @@ input:disabled { .textButton:hover { color: var(--blurple); } -.identity span, -.identity small { +.identityCopy, +.identityCopy > span, +.identityCopy > small { display: block; } -.identity span { +.identityCopy > span { font-size: 0.84rem; font-weight: 500; } -.identity small { +.identityCopy > small { margin-top: 0.15rem; color: var(--muted); font-size: 0.72rem; @@ -1095,6 +1109,10 @@ main { border-radius: 50%; background: var(--blurple); } +.memberStack > a { + overflow: hidden; + transition: transform 120ms ease; +} .memberStack > :nth-child(even) { color: var(--ink); background: var(--pink); @@ -1104,6 +1122,19 @@ main { color: #fff; transform: translateY(-2px); } +.userAvatar { + display: block; + width: 100%; + height: 100%; + border-radius: inherit; + object-fit: cover; +} +.userAvatar--fallback { + display: grid; + place-items: center; + color: inherit; + background: inherit; +} .openSeat { color: var(--blurple); font-weight: 500; @@ -1184,8 +1215,7 @@ main { align-items: center; padding: clamp(2rem, 6vw, 4rem) 0; } -.userIdentity > img, -.userIdentity > span { +.userIdentity > .userAvatar { display: grid; width: clamp(5rem, 12vw, 7rem); height: clamp(5rem, 12vw, 7rem); @@ -1422,6 +1452,60 @@ main { flex-direction: column; gap: 0.6rem; } +.projectAwards { + display: grid; + grid-template-columns: minmax(12rem, 0.55fr) minmax(0, 1.45fr); + gap: clamp(1.5rem, 5vw, 4rem); + align-items: center; + padding: 1.5rem; + margin-top: 2rem; + color: #56380a; + border: 1px solid #ead07f; + border-radius: 0.8rem; + background: linear-gradient(135deg, rgba(253, 184, 27, 0.14), transparent 55%), #fff9df; + box-shadow: 0 12px 28px rgba(118, 82, 7, 0.08); + animation: rise 0.35s ease-out both; +} +.projectAwards header h2 { + margin: 0; + font-size: clamp(1.6rem, 4vw, 2.2rem); + letter-spacing: -0.04em; +} +.projectAwards ul { + display: grid; + gap: 0.65rem; + padding: 0; + margin: 0; + list-style: none; +} +.projectAwards li { + display: grid; + grid-template-columns: 2.5rem minmax(0, 1fr); + gap: 0.75rem; + align-items: center; + padding: 0.85rem 1rem; + border: 1px solid rgba(118, 82, 7, 0.18); + border-radius: 0.65rem; + background: rgba(255, 255, 255, 0.78); +} +.projectAwards li > span { + display: grid; + width: 2.5rem; + height: 2.5rem; + place-items: center; + color: #765207; + border-radius: 50%; + background: var(--yellow); +} +.projectAwards strong, +.projectAwards small { + display: block; +} +.projectAwards small { + margin-top: 0.2rem; + color: #765207; + font-size: 0.7rem; +} .detailLayout { display: grid; grid-template-columns: minmax(0, 2fr) minmax(16rem, 0.75fr); @@ -1829,15 +1913,16 @@ main { padding: 0.8rem 0; border-top: 1px solid var(--line); } -.teamPanel li > span { - display: grid; +.teamAvatarLink { + display: block; + overflow: hidden; width: 2.4rem; height: 2.4rem; flex: 0 0 2.4rem; - place-items: center; color: #fff; font-size: 0.7rem; font-weight: 600; + text-decoration: none; border-radius: 50%; background: var(--blurple); } @@ -2299,6 +2384,16 @@ main { border-radius: 0.55rem; background: #f5f2ff; } +.teamMemberChip > .userAvatar, +.teamSearchResults .userAvatar { + width: 2rem; + height: 2rem; + flex: 0 0 2rem; + color: #fff; + font-size: 0.6rem; + font-weight: 600; + background: var(--blurple); +} .teamMemberChip > span { min-width: 0; } @@ -2382,7 +2477,10 @@ main { list-style: none; } .teamSearchResults button { + display: flex; width: 100%; + gap: 0.65rem; + align-items: center; padding: 0.6rem 0.7rem; text-align: left; border: 0; @@ -2390,6 +2488,9 @@ main { background: transparent; cursor: pointer; } +.teamSearchResults button > span { + min-width: 0; +} .teamSearchResults button:hover, .teamSearchResults button[aria-selected='true'] { background: #f0ecff; @@ -3527,7 +3628,8 @@ kbd { .projectGrid { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .detailLayout { + .detailLayout, + .projectAwards { grid-template-columns: 1fr; } .projectVotingCategory { @@ -3560,7 +3662,7 @@ kbd { .masthead nav a { min-height: 2.8rem; } - .identity span, + .identityCopy, .viewModeSwitch > span { display: none; } diff --git a/src/shared/projects.ts b/src/shared/projects.ts index 2e2416a..16f3cc1 100644 --- a/src/shared/projects.ts +++ b/src/shared/projects.ts @@ -51,6 +51,7 @@ export interface ProjectSummary { export interface ProjectDetail extends ProjectSummary { media: MediaSummary[]; + awards: AwardSummary[]; nominationCategoryIds: string[]; permissions: { canEdit: boolean; diff --git a/src/shared/videos.ts b/src/shared/videos.ts index d690055..37309bd 100644 --- a/src/shared/videos.ts +++ b/src/shared/videos.ts @@ -88,7 +88,11 @@ export interface PlaylistItem { projectName: string; groupId: string | null; groupName: string | null; - teamMembers: Array<{id: string; displayName: string}>; + teamMembers: Array<{ + id: string; + displayName: string; + avatarUrl: string | null; + }>; durationSeconds: number; gainDb: number; position: number; diff --git a/src/worker/integrations/google-oauth.ts b/src/worker/integrations/google-oauth.ts index 2a57f60..7129e7a 100644 --- a/src/worker/integrations/google-oauth.ts +++ b/src/worker/integrations/google-oauth.ts @@ -151,8 +151,16 @@ function safeAvatarUrl(value: T) { if (!isJsonString(value) || value.length > 2048) return null; try { const url = new URL(value); - return url.protocol === 'https:' ? url.toString() : null; + return url.protocol === 'https:' && isGoogleusercontentHost(url.hostname) + ? url.toString() + : null; } catch { return null; } } + +function isGoogleusercontentHost(hostname: string) { + return ( + hostname === 'googleusercontent.com' || hostname.endsWith('.googleusercontent.com') + ); +} diff --git a/src/worker/repositories/projects.ts b/src/worker/repositories/projects.ts index 7ee40c0..b0a8ae2 100644 --- a/src/worker/repositories/projects.ts +++ b/src/worker/repositories/projects.ts @@ -67,6 +67,16 @@ interface AwardCategoryRow { name: string; } +interface AwardRow { + id: string; + year_id: string; + project_id: string; + project_name: string; + category_id: string; + category_name: string; + name: string; +} + export async function listYears(db: D1Database): Promise { const {results} = await db .prepare( @@ -257,7 +267,7 @@ export async function getProject( if (!row) { throw new ServiceError('NOT_FOUND', 'Project not found', 404); } - const [members, mediaResult, nominationIds, year] = await Promise.all([ + const [members, mediaResult, awardResult, nominationIds, year] = await Promise.all([ membersByProjectIds(db, [projectId]), db .prepare( @@ -266,6 +276,18 @@ export async function getProject( ) .bind(projectId) .all(), + db + .prepare( + `SELECT a.id, a.year_id, a.project_id, p.name project_name, + a.category_id, category.name category_name, a.name + FROM awards a + JOIN projects p ON p.id = a.project_id + JOIN award_categories category ON category.id = a.category_id + WHERE a.project_id = ? + ORDER BY category.name COLLATE NOCASE, a.name COLLATE NOCASE, a.id`, + ) + .bind(projectId) + .all(), nominationCategoryIds(db, projectId), getYear(db, row.year_id), ]); @@ -278,6 +300,7 @@ export async function getProject( return { ...project, media: mediaResult.results.map(mapMedia), + awards: awardResult.results.map(mapAward), nominationCategoryIds: nominationIds, permissions: { canEdit: canWrite, @@ -718,6 +741,18 @@ function mapAwardCategory(row: AwardCategoryRow): AwardCategorySummary { return {id: row.id, yearId: row.year_id, name: row.name}; } +function mapAward(row: AwardRow) { + return { + id: row.id, + yearId: row.year_id, + projectId: row.project_id, + projectName: row.project_name, + categoryId: row.category_id, + categoryName: row.category_name, + name: row.name, + }; +} + function mapMember(row: Omit): ProjectMember { return { id: row.id, diff --git a/src/worker/repositories/users.ts b/src/worker/repositories/users.ts index 419194d..181a339 100644 --- a/src/worker/repositories/users.ts +++ b/src/worker/repositories/users.ts @@ -1,6 +1,7 @@ import type {AwardSummary} from '../../shared/administration'; import type {ProjectMember, UserProfileResponse} from '../../shared/projects'; import {ServiceError} from '../services/errors'; +import {userAvatarKey} from '../services/users'; import {listProjects} from './projects'; interface UserRow { @@ -21,6 +22,12 @@ interface AwardRow { name: string; } +export async function getUserAvatar(bucket: R2Bucket, userId: string) { + const object = await bucket.get(userAvatarKey(userId)); + if (!object) throw new ServiceError('NOT_FOUND', 'User avatar not found', 404); + return object; +} + export async function getUserProfile( db: D1Database, userId: string, diff --git a/src/worker/routes/auth.ts b/src/worker/routes/auth.ts index 01db6c5..13243ea 100644 --- a/src/worker/routes/auth.ts +++ b/src/worker/routes/auth.ts @@ -22,12 +22,12 @@ import { revokeUserSessions, sha256Hex, } from '../services/sessions'; -import {synchronizeGoogleUser} from '../services/users'; +import {refreshGoogleUserAvatar, synchronizeGoogleUser} from '../services/users'; const LOGIN_ATTEMPT_TTL_SECONDS = 10 * 60; interface AuthEnv { - Bindings: AuthBindings & {DB: D1Database}; + Bindings: AuthBindings & {DB: D1Database; ATTACHMENTS: R2Bucket}; Variables: AuthVariables; } @@ -107,6 +107,7 @@ authRoutes.get('/callback', async (c) => { ); const identity = await verifyGoogleIdToken(c.env, config, idToken, consumed.nonce); const user = await synchronizeGoogleUser(c.env.DB, identity); + await refreshGoogleUserAvatar(c.env.ATTACHMENTS, user); await revokeUserSessions(c.env.DB, user.id, now); const session = await createSession(c.env.DB, user.id, now); c.header('Set-Cookie', sessionCookie(session.token, config)); diff --git a/src/worker/routes/users.ts b/src/worker/routes/users.ts index 1304d44..85dcfd8 100644 --- a/src/worker/routes/users.ts +++ b/src/worker/routes/users.ts @@ -2,11 +2,38 @@ import {Hono} from 'hono'; import type {UserProfileResponse} from '../../shared/projects'; import type {WorkerEnv} from '../index'; -import {getUserProfile} from '../repositories/users'; +import {getUserAvatar, getUserProfile} from '../repositories/users'; import {errorResponse} from '../services/errors'; +import {safeAvatarContentType} from '../services/users'; export const usersRoutes = new Hono(); +usersRoutes.get('/:userId/avatar', async (c) => { + try { + const object = await getUserAvatar(c.env.ATTACHMENTS, c.req.param('userId')); + const headers = new Headers(); + object.writeHttpMetadata(headers); + const contentType = safeAvatarContentType(object.httpMetadata?.contentType); + if (!contentType) { + headers.set('Content-Type', 'application/octet-stream'); + headers.set('Content-Disposition', 'attachment'); + } else { + headers.set('Content-Type', contentType!); + headers.set('Content-Disposition', 'inline'); + } + headers.set('Content-Length', String(object.size)); + headers.set('Cache-Control', 'private, max-age=300'); + headers.set('X-Content-Type-Options', 'nosniff'); + headers.set('Content-Security-Policy', "default-src 'none'; sandbox"); + headers.set('Cross-Origin-Resource-Policy', 'same-origin'); + if (object.httpEtag) headers.set('ETag', object.httpEtag); + return new Response(object.body, {headers}); + } catch (error) { + const result = errorResponse(error); + return c.json(result.response, result.status); + } +}); + usersRoutes.get('/:userId', async (c) => { try { const response: UserProfileResponse = await getUserProfile( diff --git a/src/worker/services/users.ts b/src/worker/services/users.ts index 4796b0d..f12a30a 100644 --- a/src/worker/services/users.ts +++ b/src/worker/services/users.ts @@ -1,6 +1,15 @@ import type {SessionUser, UpdateProfileRequest} from '../../shared/api'; import type {SessionIdentity} from './sessions'; +const MAX_AVATAR_BYTES = 2 * 1024 * 1024; +const AVATAR_FETCH_TIMEOUT_MS = 3_000; +const ALLOWED_AVATAR_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); + +export function safeAvatarContentType(value: string | null | undefined) { + const contentType = value?.split(';', 1)[0].trim().toLowerCase(); + return contentType && ALLOWED_AVATAR_TYPES.has(contentType) ? contentType : null; +} + interface UserRow { id: string; source_uid: string; @@ -84,6 +93,71 @@ export async function synchronizeGoogleUser( }; } +export async function refreshGoogleUserAvatar( + bucket: R2Bucket, + user: Pick, +) { + const key = userAvatarKey(user.id); + try { + if (!user.avatarUrl) { + await bucket.delete(key); + return; + } + const avatarUrl = new URL(user.avatarUrl); + if (!isGoogleusercontentHost(avatarUrl.hostname)) return; + + const signal = AbortSignal.timeout(AVATAR_FETCH_TIMEOUT_MS); + const response = await fetch(avatarUrl, { + redirect: 'manual', + signal, + }); + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('Location'); + if (!location) return; + const redirect = new URL(location, avatarUrl); + if (redirect.protocol !== 'https:' || !isGoogleusercontentHost(redirect.hostname)) { + return; + } + return refreshGoogleUserAvatarFromResponse( + bucket, + key, + await fetch(redirect, {redirect: 'manual', signal}), + ); + } + await refreshGoogleUserAvatarFromResponse(bucket, key, response); + } catch { + // Profile photos must never prevent sign-in. A previously cached photo remains valid. + } +} + +export function userAvatarKey(userId: string) { + return `users/${userId}/avatar`; +} + +async function refreshGoogleUserAvatarFromResponse( + bucket: R2Bucket, + key: string, + response: Response, +) { + if (!response.ok) return; + const contentType = safeAvatarContentType(response.headers.get('Content-Type')); + if (!contentType) return; + const declaredSize = Number(response.headers.get('Content-Length')); + if (Number.isFinite(declaredSize) && declaredSize > MAX_AVATAR_BYTES) return; + const content = await response.arrayBuffer(); + if (content.byteLength === 0 || content.byteLength > MAX_AVATAR_BYTES) return; + await bucket.put(key, content, { + httpMetadata: {contentType, cacheControl: 'private, max-age=300'}, + customMetadata: {source: 'google'}, + }); +} + +function isGoogleusercontentHost(hostname: string) { + return ( + hostname === 'googleusercontent.com' || hostname.endsWith('.googleusercontent.com') + ); +} + export async function updateUserProfile( db: D1Database, userId: string, diff --git a/src/worker/services/videos.ts b/src/worker/services/videos.ts index cf9454d..ab60284 100644 --- a/src/worker/services/videos.ts +++ b/src/worker/services/videos.ts @@ -132,10 +132,13 @@ export async function listPlaylist( }>(); if (!results.length) return []; - const membersByProject = new Map>(); + const membersByProject = new Map< + string, + Array<{id: string; displayName: string; avatarUrl: string | null}> + >(); const members = await db .prepare( - `SELECT pm.project_id, u.id user_id, u.display_name + `SELECT pm.project_id, u.id user_id, u.display_name, u.avatar_url FROM project_members pm JOIN users u ON u.id = pm.user_id JOIN projects p ON p.id = pm.project_id @@ -147,10 +150,19 @@ export async function listPlaylist( ORDER BY pm.project_id, u.display_name COLLATE NOCASE, u.id`, ) .bind(yearId) - .all<{project_id: string; user_id: string; display_name: string}>(); + .all<{ + project_id: string; + user_id: string; + display_name: string; + avatar_url: string | null; + }>(); for (const member of members.results) { const projectMembers = membersByProject.get(member.project_id) ?? []; - projectMembers.push({id: member.user_id, displayName: member.display_name}); + projectMembers.push({ + id: member.user_id, + displayName: member.display_name, + avatarUrl: member.avatar_url, + }); membersByProject.set(member.project_id, projectMembers); } diff --git a/test/app/ProjectForm.test.tsx b/test/app/ProjectForm.test.tsx index d69a84b..798c264 100644 --- a/test/app/ProjectForm.test.tsx +++ b/test/app/ProjectForm.test.tsx @@ -448,6 +448,7 @@ const projectFixture: ProjectDetail = { members: [alice], mediaCount: 0, media: [], + awards: [], nominationCategoryIds: [], permissions: { canEdit: true, diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx index 529014d..baa8d01 100644 --- a/test/app/routes.test.tsx +++ b/test/app/routes.test.tsx @@ -619,7 +619,11 @@ describe('clickable project routes', () => { if (!(row instanceof HTMLElement)) throw new Error(); expect(within(row).getByText('Orbital')).toBeTruthy(); expect(within(row).getByText('looking for help')).toBeTruthy(); - expect(within(row).getByLabelText('Member One')).toBeTruthy(); + const memberLink = within(row).getByRole('link', { + name: "View Member One's Hackweek profile", + }); + expect(memberLink.querySelector('img')).toBeNull(); + expect(memberLink.textContent).toBe('MO'); expect(within(row).queryByText('2 attachments')).toBeNull(); expect(within(row).queryByText(projectFixture.summary)).toBeNull(); expect(window.localStorage.getItem('hackweek.projectsView')).toBe('list'); @@ -1012,7 +1016,10 @@ describe('clickable project routes', () => { it('renders a user history with highlights, awards, projects, and ideas', async () => { fetchMock.mockResolvedValue( json({ - user: projectFixture.creator, + user: { + ...projectFixture.creator, + avatarUrl: 'https://profiles.test/member.jpg', + }, highlights: { hackweekCount: 2, projectCount: 2, @@ -1052,6 +1059,9 @@ describe('clickable project routes', () => { renderRoute(, '/users/member', '/users/:userId'); expect(await screen.findByRole('heading', {name: 'Member One'})).toBeTruthy(); + expect(document.querySelector('.userIdentity img')?.src).toBe( + 'http://localhost:3000/api/users/member/avatar', + ); const highlights = screen.getByLabelText('Hackweek highlights'); expect(within(highlights).getAllByText('2', {selector: 'dd'})).toHaveLength(2); expect(within(highlights).getByText('Ideas opened')).toBeTruthy(); @@ -1062,6 +1072,84 @@ describe('clickable project routes', () => { expect(fetchMock).toHaveBeenCalledWith('/api/users/member', undefined); }); + it('uses cached profile photos for project cards and team members', async () => { + const memberWithPhoto = { + ...projectFixture.members[0], + avatarUrl: 'https://profiles.test/member.jpg', + }; + mockProjectDetails({ + detail: { + ...projectFixture, + members: [memberWithPhoto], + }, + }); + + const details = renderRoute( + , + '/years/2026/projects/project', + '/years/:yearId/projects/:projectId', + ); + + const teamAvatar = await screen.findByRole('link', { + name: "View Member One's Hackweek profile", + }); + expect(teamAvatar.querySelector('img')?.getAttribute('src')).toBe( + '/api/users/member/avatar', + ); + details.unmount(); + + renderRoute( + , + '/', + ); + const cardAvatar = screen.getByRole('link', { + name: "View Member One's Hackweek profile", + }); + expect(cardAvatar.querySelector('img')?.getAttribute('src')).toBe( + '/api/users/member/avatar', + ); + }); + + it('shows every award a project has received on its detail page', async () => { + mockProjectDetails({ + detail: { + ...projectFixture, + awards: [ + { + id: 'award-delight', + yearId: '2026', + projectId: 'project', + projectName: 'A small machine', + categoryId: 'delight', + categoryName: 'Delight', + name: 'Most delightful', + }, + { + id: 'award-impact', + yearId: '2026', + projectId: 'project', + projectName: 'A small machine', + categoryId: 'impact', + categoryName: 'Impact', + name: 'Biggest impact', + }, + ], + }, + }); + + renderRoute( + , + '/years/2026/projects/project', + '/years/:yearId/projects/:projectId', + ); + + const awards = await screen.findByRole('region', {name: 'award winners'}); + expect(within(awards).getByText('Most delightful')).toBeTruthy(); + expect(within(awards).getByText('Delight')).toBeTruthy(); + expect(within(awards).getByText('Biggest impact')).toBeTruthy(); + expect(within(awards).getByText('Impact')).toBeTruthy(); + }); + it('adds every open award category before project media and video', async () => { mockProjectDetails({ detail: { @@ -1656,6 +1744,7 @@ const projectFixture: ProjectDetail = { ], mediaCount: 0, media: [], + awards: [], nominationCategoryIds: [], permissions: { canEdit: true, diff --git a/test/auth/auth.test.ts b/test/auth/auth.test.ts index b2c6cbb..0f88f5f 100644 --- a/test/auth/auth.test.ts +++ b/test/auth/auth.test.ts @@ -128,6 +128,184 @@ describe('Google OAuth authorization code flow', () => { }); }); + it('caches the Google profile photo in private storage on every login', async () => { + const first = await beginLogin(); + const avatarUrl = 'https://lh3.googleusercontent.com/member.jpg'; + let avatarVersion = 'first-avatar'; + tokenFetch.mockImplementation(async (input) => { + if (requestUrl(input) === avatarUrl) { + return new Response(avatarVersion, { + headers: {'Content-Type': 'image/jpeg; charset=binary'}, + }); + } + return Response.json({ + id_token: await signGoogleIdToken({nonce: first.nonce, picture: avatarUrl}), + }); + }); + + const firstLogin = await callback(first.state); + const firstCookie = cookieToken(firstLogin.headers.get('Set-Cookie')!); + const user = await env.DB.prepare( + "SELECT id, avatar_url FROM users WHERE google_subject = 'google-member'", + ).first<{id: string; avatar_url: string}>(); + const firstAvatar = await SELF.fetch( + `https://hackweek.test/api/users/${user!.id}/avatar`, + {headers: {Cookie: firstCookie}}, + ); + + expect(user?.avatar_url).toBe(avatarUrl); + expect(firstAvatar.status).toBe(200); + expect(firstAvatar.headers.get('Content-Type')).toBe('image/jpeg'); + expect(firstAvatar.headers.get('Cache-Control')).toBe('private, max-age=300'); + expect(firstAvatar.headers.get('Content-Security-Policy')).toContain('sandbox'); + expect(new TextDecoder().decode(await firstAvatar.arrayBuffer())).toBe( + 'first-avatar', + ); + + avatarVersion = 'refreshed-avatar'; + const second = await beginLogin(); + tokenFetch.mockImplementation(async (input) => { + if (requestUrl(input) === avatarUrl) { + return new Response(avatarVersion, { + headers: {'Content-Type': 'image/jpeg'}, + }); + } + return Response.json({ + id_token: await signGoogleIdToken({nonce: second.nonce, picture: avatarUrl}), + }); + }); + + const secondLogin = await callback(second.state); + const secondCookie = cookieToken(secondLogin.headers.get('Set-Cookie')!); + const refreshedAvatar = await SELF.fetch( + `https://hackweek.test/api/users/${user!.id}/avatar`, + {headers: {Cookie: secondCookie}}, + ); + + expect(new TextDecoder().decode(await refreshedAvatar.arrayBuffer())).toBe( + 'refreshed-avatar', + ); + expect( + tokenFetch.mock.calls.filter(([input]) => requestUrl(input) === avatarUrl), + ).toHaveLength(2); + }); + + it('accepts only safe Google profile-photo redirects', async () => { + const {state, nonce} = await beginLogin(); + const source = 'https://lh3.googleusercontent.com/avatar.jpg'; + const target = 'https://lh7-qw.googleusercontent.com/photos/avatar.jpg'; + tokenFetch.mockImplementation(async (input) => { + const url = requestUrl(input); + if (url === source) + return new Response(null, {status: 302, headers: {Location: target}}); + if (url === target) { + return new Response('redirected-avatar', { + headers: {'Content-Type': 'image/jpeg'}, + }); + } + return Response.json({ + id_token: await signGoogleIdToken({nonce, picture: source}), + }); + }); + + const login = await callback(state); + const cookie = cookieToken(login.headers.get('Set-Cookie')!); + const user = await env.DB.prepare( + "SELECT id FROM users WHERE google_subject = 'google-member'", + ).first<{id: string}>(); + const avatar = await SELF.fetch( + `https://hackweek.test/api/users/${user!.id}/avatar`, + {headers: {Cookie: cookie}}, + ); + expect(new TextDecoder().decode(await avatar.arrayBuffer())).toBe( + 'redirected-avatar', + ); + + const unsafeRedirect = await beginLogin(); + tokenFetch.mockImplementation(async (input) => { + if (requestUrl(input) === source) { + return new Response(null, { + status: 302, + headers: {Location: 'http://attacker.example/avatar.jpg'}, + }); + } + return Response.json({ + id_token: await signGoogleIdToken({ + nonce: unsafeRedirect.nonce, + picture: source, + }), + }); + }); + const unsafeLogin = await callback(unsafeRedirect.state); + expect(unsafeLogin.headers.get('Set-Cookie')).toContain(SESSION_COOKIE_NAME); + expect( + tokenFetch.mock.calls.some( + ([input]) => requestUrl(input) === 'http://attacker.example/avatar.jpg', + ), + ).toBe(false); + }); + + it('rejects unsafe profile-photo hosts and active image content', async () => { + const unsafeHost = await beginLogin(); + tokenFetch.mockImplementation(async () => + Response.json({ + id_token: await signGoogleIdToken({ + nonce: unsafeHost.nonce, + picture: 'https://attacker.example/avatar.jpg', + }), + }), + ); + + const unsafeLogin = await callback(unsafeHost.state); + expect(unsafeLogin.headers.get('Set-Cookie')).toContain(SESSION_COOKIE_NAME); + expect(tokenFetch).toHaveBeenCalledTimes(1); + + const svg = await beginLogin(); + tokenFetch.mockImplementation(async (input) => { + if (requestUrl(input) === 'https://lh3.googleusercontent.com/avatar.svg') { + return new Response('', { + headers: {'Content-Type': 'image/svg+xml'}, + }); + } + return Response.json({ + id_token: await signGoogleIdToken({ + nonce: svg.nonce, + picture: 'https://lh3.googleusercontent.com/avatar.svg', + }), + }); + }); + + const svgLogin = await callback(svg.state); + const svgCookie = cookieToken(svgLogin.headers.get('Set-Cookie')!); + const user = await env.DB.prepare( + "SELECT id FROM users WHERE google_subject = 'google-member'", + ).first<{id: string}>(); + const avatar = await SELF.fetch( + `https://hackweek.test/api/users/${user!.id}/avatar`, + {headers: {Cookie: svgCookie}}, + ); + expect(avatar.status).toBe(404); + }); + + it('keeps sign-in available when a Google profile photo cannot be refreshed', async () => { + const {state, nonce} = await beginLogin(); + tokenFetch.mockImplementation(async (input) => { + if (requestUrl(input) === 'https://lh3.googleusercontent.com/unavailable.jpg') { + return new Response('rate limited', {status: 429}); + } + return Response.json({ + id_token: await signGoogleIdToken({ + nonce, + picture: 'https://lh3.googleusercontent.com/unavailable.jpg', + }), + }); + }); + + const response = await callback(state); + + expect(response.headers.get('Set-Cookie')).toContain(SESSION_COOKIE_NAME); + }); + it('consumes state once even when exchange fails and rejects replay', async () => { const login = await SELF.fetch('https://hackweek.test/api/auth/login', { redirect: 'manual', @@ -398,6 +576,10 @@ describe('Google OAuth configuration', () => { }); }); +function requestUrl(input: string | URL | Request) { + return input instanceof Request ? input.url : input instanceof URL ? input.href : input; +} + async function beginLogin() { const login = await SELF.fetch('https://hackweek.test/api/auth/login', { redirect: 'manual', diff --git a/test/player/controller.test.tsx b/test/player/controller.test.tsx index 5ca6028..817fca1 100644 --- a/test/player/controller.test.tsx +++ b/test/player/controller.test.tsx @@ -160,8 +160,8 @@ const playlist: PlaylistItem[] = [ groupId: 'europe', groupName: 'Europe', teamMembers: [ - {id: 'ada', displayName: 'Ada'}, - {id: 'grace', displayName: 'Grace'}, + {id: 'ada', displayName: 'Ada', avatarUrl: null}, + {id: 'grace', displayName: 'Grace', avatarUrl: null}, ], durationSeconds: 10, gainDb: 6, @@ -173,7 +173,7 @@ const playlist: PlaylistItem[] = [ projectName: 'Second', groupId: 'americas', groupName: 'Americas', - teamMembers: [{id: 'linus', displayName: 'Linus'}], + teamMembers: [{id: 'linus', displayName: 'Linus', avatarUrl: null}], durationSeconds: 20, gainDb: -3, position: 1, diff --git a/test/projects/projects.test.ts b/test/projects/projects.test.ts index cbb5f21..20b22e3 100644 --- a/test/projects/projects.test.ts +++ b/test/projects/projects.test.ts @@ -136,6 +136,7 @@ describe('project and history APIs', () => { }, }); expect(detail.body.project.nominationCategoryIds).toEqual([]); + expect(detail.body.project.awards).toEqual([]); expect(allCategoryNominationCount?.count).toBe(0); expect(updated.body.project.nominationCategoryIds).toEqual([ categoryId, @@ -485,6 +486,57 @@ describe('project and history APIs', () => { ).toBe(true); }); + it('includes every project award in project details', async () => { + const project = await createProject(memberToken, {name: 'Awarded project'}); + const member = await env.DB.prepare('SELECT id FROM users WHERE google_subject = ?') + .bind(`project-member-${suffix}`) + .first<{id: string}>(); + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO awards + (id, source_id, year_id, project_id, category_id, name, creator_id) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).bind( + `award-delight-${suffix}`, + `award-delight-${suffix}`, + yearId, + project.id, + categoryId, + 'Most delightful', + member!.id, + ), + env.DB.prepare( + `INSERT INTO awards + (id, source_id, year_id, project_id, category_id, name, creator_id) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).bind( + `award-craft-${suffix}`, + `award-craft-${suffix}`, + yearId, + project.id, + secondCategoryId, + 'Best crafted', + member!.id, + ), + ]); + + const detail = await api(`/projects/${project.id}`, memberToken); + + expect(detail.status).toBe(200); + expect(detail.body.project.awards).toEqual([ + expect.objectContaining({ + name: 'Best crafted', + categoryName: 'Craft', + projectId: project.id, + }), + expect.objectContaining({ + name: 'Most delightful', + categoryName: 'Delight', + projectId: project.id, + }), + ]); + }); + it('returns a user profile with projects, ideas, and awards across years', async () => { const member = await env.DB.prepare('SELECT id FROM users WHERE google_subject = ?') .bind(`project-member-${suffix}`) diff --git a/test/video-ui/video-ui.test.tsx b/test/video-ui/video-ui.test.tsx index 3f10666..690457b 100644 --- a/test/video-ui/video-ui.test.tsx +++ b/test/video-ui/video-ui.test.tsx @@ -466,8 +466,8 @@ const playlist: PlaylistItem[] = [ groupId: 'europe', groupName: 'Europe', teamMembers: [ - {id: 'ada', displayName: 'Ada Lovelace'}, - {id: 'grace', displayName: 'Grace Hopper'}, + {id: 'ada', displayName: 'Ada Lovelace', avatarUrl: null}, + {id: 'grace', displayName: 'Grace Hopper', avatarUrl: null}, ], durationSeconds: 30, gainDb: 0, @@ -479,7 +479,7 @@ const playlist: PlaylistItem[] = [ projectName: 'Second project', groupId: 'americas', groupName: 'Americas', - teamMembers: [{id: 'linus', displayName: 'Linus Torvalds'}], + teamMembers: [{id: 'linus', displayName: 'Linus Torvalds', avatarUrl: null}], durationSeconds: 45, gainDb: -1, position: 1, From 0d8863ab956ed3cd93c540cbb11a264d3713b6b0 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Fri, 21 Aug 2026 19:28:37 +0200 Subject: [PATCH 3/4] fix(users): align profile ownership and avatar caching Keep claimed projects and awards on the claimant's profile instead of the original idea opener, and avoid repeating year validation while paging profile projects. Include idea creators in people search so ideas remain discoverable by name.\n\nBackfill missing cached avatars on first request for existing users while preserving the hardened Google fetch and R2 serving path. Add regression coverage for claimed ideas, idea-creator search, and avatar cache backfills. --- src/worker/repositories/projects.ts | 33 ++++++++------ src/worker/repositories/users.ts | 57 +++++++++++++++++++---- src/worker/routes/users.ts | 9 ++-- src/worker/services/users.ts | 29 +++++++----- test/auth/auth.test.ts | 32 +++++++++++++ test/projects/projects.test.ts | 70 +++++++++++++++++++++++++++++ 6 files changed, 194 insertions(+), 36 deletions(-) diff --git a/src/worker/repositories/projects.ts b/src/worker/repositories/projects.ts index b0a8ae2..74bbdcd 100644 --- a/src/worker/repositories/projects.ts +++ b/src/worker/repositories/projects.ts @@ -174,19 +174,25 @@ export async function listProjectOptions(db: D1Database, yearId: string) { }; } -export async function listProjects( +interface ListProjectsOptions { + yearId: string; + kind?: 'project' | 'idea'; + groupId?: string; + search?: string; + userId?: string; + limit: number; + offset: number; +} + +export async function listProjects(db: D1Database, options: ListProjectsOptions) { + await getYear(db, options.yearId); + return listProjectsForExistingYear(db, options); +} + +export async function listProjectsForExistingYear( db: D1Database, - options: { - yearId: string; - kind?: 'project' | 'idea'; - groupId?: string; - search?: string; - userId?: string; - limit: number; - offset: number; - }, + options: ListProjectsOptions, ) { - await getYear(db, options.yearId); const conditions = ['p.year_id = ?', "p.status = 'active'"]; const bindings: unknown[] = [options.yearId]; if (options.kind) { @@ -199,7 +205,7 @@ export async function listProjects( } if (options.userId) { conditions.push( - `(p.creator_id = ? OR EXISTS ( + `((p.kind = 'idea' AND p.creator_id = ?) OR EXISTS ( SELECT 1 FROM project_members profile_member WHERE profile_member.project_id = p.id AND profile_member.user_id = ? ))`, @@ -213,6 +219,7 @@ export async function listProjects( conditions.push( `(LOWER(p.name) LIKE LOWER(?) ESCAPE '\\' OR LOWER(COALESCE(p.summary, '')) LIKE LOWER(?) ESCAPE '\\' + OR (p.kind = 'idea' AND LOWER(u.display_name) LIKE LOWER(?) ESCAPE '\\') OR EXISTS ( SELECT 1 FROM project_members search_member JOIN users search_user ON search_user.id = search_member.user_id @@ -220,7 +227,7 @@ export async function listProjects( AND LOWER(search_user.display_name) LIKE LOWER(?) ESCAPE '\\' ))`, ); - bindings.push(containsPattern, containsPattern, containsPattern); + bindings.push(containsPattern, containsPattern, containsPattern, containsPattern); relevanceOrder = `CASE WHEN LOWER(p.name) = LOWER(?) THEN 0 WHEN LOWER(p.name) LIKE LOWER(?) ESCAPE '\\' THEN 1 diff --git a/src/worker/repositories/users.ts b/src/worker/repositories/users.ts index 181a339..dcb1fff 100644 --- a/src/worker/repositories/users.ts +++ b/src/worker/repositories/users.ts @@ -1,8 +1,8 @@ import type {AwardSummary} from '../../shared/administration'; import type {ProjectMember, UserProfileResponse} from '../../shared/projects'; import {ServiceError} from '../services/errors'; -import {userAvatarKey} from '../services/users'; -import {listProjects} from './projects'; +import {refreshGoogleUserAvatar, userAvatarKey} from '../services/users'; +import {listProjectsForExistingYear} from './projects'; interface UserRow { id: string; @@ -22,10 +22,47 @@ interface AwardRow { name: string; } -export async function getUserAvatar(bucket: R2Bucket, userId: string) { - const object = await bucket.get(userAvatarKey(userId)); - if (!object) throw new ServiceError('NOT_FOUND', 'User avatar not found', 404); - return object; +export interface UserAvatarObject { + body: ReadableStream; + size: number; + httpEtag: string | null; + contentType: string | null; +} + +export async function getUserAvatar( + db: D1Database, + bucket: R2Bucket, + userId: string, +): Promise { + const key = userAvatarKey(userId); + const cached = await bucket.get(key); + if (cached) { + return { + body: cached.body, + size: cached.size, + httpEtag: cached.httpEtag, + contentType: cached.httpMetadata?.contentType ?? null, + }; + } + + const user = await db + .prepare('SELECT id, avatar_url FROM users WHERE id = ?') + .bind(userId) + .first<{id: string; avatar_url: string | null}>(); + if (!user?.avatar_url) { + throw new ServiceError('NOT_FOUND', 'User avatar not found', 404); + } + const refreshed = await refreshGoogleUserAvatar(bucket, { + id: user.id, + avatarUrl: user.avatar_url, + }); + if (!refreshed) throw new ServiceError('NOT_FOUND', 'User avatar not found', 404); + return { + body: new Response(refreshed.content).body!, + size: refreshed.content.byteLength, + httpEtag: null, + contentType: refreshed.contentType, + }; } export async function getUserProfile( @@ -47,7 +84,8 @@ export async function getUserProfile( `SELECT DISTINCT p.year_id FROM projects p LEFT JOIN project_members pm ON pm.project_id = p.id - WHERE p.status = 'active' AND (p.creator_id = ? OR pm.user_id = ?) + WHERE p.status = 'active' + AND ((p.kind = 'idea' AND p.creator_id = ?) OR pm.user_id = ?) ORDER BY p.year_id DESC`, ) .bind(userId, userId) @@ -60,7 +98,8 @@ export async function getUserProfile( JOIN projects p ON p.id = a.project_id JOIN award_categories category ON category.id = a.category_id LEFT JOIN project_members pm ON pm.project_id = p.id - WHERE p.status = 'active' AND (p.creator_id = ? OR pm.user_id = ?) + WHERE p.status = 'active' + AND ((p.kind = 'idea' AND p.creator_id = ?) OR pm.user_id = ?) ORDER BY a.year_id DESC, category.name COLLATE NOCASE, a.id`, ) .bind(userId, userId) @@ -92,7 +131,7 @@ async function projectsForUserAndYear(db: D1Database, userId: string, yearId: st const projects = []; let offset = 0; while (true) { - const page = await listProjects(db, { + const page = await listProjectsForExistingYear(db, { yearId, userId, limit: 250, diff --git a/src/worker/routes/users.ts b/src/worker/routes/users.ts index 85dcfd8..5afbc86 100644 --- a/src/worker/routes/users.ts +++ b/src/worker/routes/users.ts @@ -10,10 +10,13 @@ export const usersRoutes = new Hono(); usersRoutes.get('/:userId/avatar', async (c) => { try { - const object = await getUserAvatar(c.env.ATTACHMENTS, c.req.param('userId')); + const object = await getUserAvatar( + c.env.DB, + c.env.ATTACHMENTS, + c.req.param('userId'), + ); const headers = new Headers(); - object.writeHttpMetadata(headers); - const contentType = safeAvatarContentType(object.httpMetadata?.contentType); + const contentType = safeAvatarContentType(object.contentType); if (!contentType) { headers.set('Content-Type', 'application/octet-stream'); headers.set('Content-Disposition', 'attachment'); diff --git a/src/worker/services/users.ts b/src/worker/services/users.ts index f12a30a..8ce21ed 100644 --- a/src/worker/services/users.ts +++ b/src/worker/services/users.ts @@ -96,15 +96,15 @@ export async function synchronizeGoogleUser( export async function refreshGoogleUserAvatar( bucket: R2Bucket, user: Pick, -) { +): Promise { const key = userAvatarKey(user.id); try { if (!user.avatarUrl) { await bucket.delete(key); - return; + return null; } const avatarUrl = new URL(user.avatarUrl); - if (!isGoogleusercontentHost(avatarUrl.hostname)) return; + if (!isGoogleusercontentHost(avatarUrl.hostname)) return null; const signal = AbortSignal.timeout(AVATAR_FETCH_TIMEOUT_MS); const response = await fetch(avatarUrl, { @@ -113,10 +113,10 @@ export async function refreshGoogleUserAvatar( }); if (response.status >= 300 && response.status < 400) { const location = response.headers.get('Location'); - if (!location) return; + if (!location) return null; const redirect = new URL(location, avatarUrl); if (redirect.protocol !== 'https:' || !isGoogleusercontentHost(redirect.hostname)) { - return; + return null; } return refreshGoogleUserAvatarFromResponse( bucket, @@ -124,9 +124,10 @@ export async function refreshGoogleUserAvatar( await fetch(redirect, {redirect: 'manual', signal}), ); } - await refreshGoogleUserAvatarFromResponse(bucket, key, response); + return refreshGoogleUserAvatarFromResponse(bucket, key, response); } catch { // Profile photos must never prevent sign-in. A previously cached photo remains valid. + return null; } } @@ -134,22 +135,28 @@ export function userAvatarKey(userId: string) { return `users/${userId}/avatar`; } +export interface CachedAvatar { + content: ArrayBuffer; + contentType: string; +} + async function refreshGoogleUserAvatarFromResponse( bucket: R2Bucket, key: string, response: Response, -) { - if (!response.ok) return; +): Promise { + if (!response.ok) return null; const contentType = safeAvatarContentType(response.headers.get('Content-Type')); - if (!contentType) return; + if (!contentType) return null; const declaredSize = Number(response.headers.get('Content-Length')); - if (Number.isFinite(declaredSize) && declaredSize > MAX_AVATAR_BYTES) return; + if (Number.isFinite(declaredSize) && declaredSize > MAX_AVATAR_BYTES) return null; const content = await response.arrayBuffer(); - if (content.byteLength === 0 || content.byteLength > MAX_AVATAR_BYTES) return; + if (content.byteLength === 0 || content.byteLength > MAX_AVATAR_BYTES) return null; await bucket.put(key, content, { httpMetadata: {contentType, cacheControl: 'private, max-age=300'}, customMetadata: {source: 'google'}, }); + return {content, contentType}; } function isGoogleusercontentHost(hostname: string) { diff --git a/test/auth/auth.test.ts b/test/auth/auth.test.ts index 0f88f5f..2e2e13e 100644 --- a/test/auth/auth.test.ts +++ b/test/auth/auth.test.ts @@ -287,6 +287,38 @@ describe('Google OAuth authorization code flow', () => { expect(avatar.status).toBe(404); }); + it('backfills a missing avatar cache when the profile image is first requested', async () => { + const cookie = await createSessionCookie({ + picture: 'https://lh3.googleusercontent.com/backfill.jpg', + }); + const user = await env.DB.prepare( + "SELECT id FROM users WHERE google_subject = 'google-member'", + ).first<{id: string}>(); + await Promise.all([ + env.DB.prepare('UPDATE users SET avatar_url = ? WHERE id = ?') + .bind('https://lh3.googleusercontent.com/backfill.jpg', user!.id) + .run(), + env.ATTACHMENTS.delete(`users/${user!.id}/avatar`), + ]); + tokenFetch.mockImplementation( + async () => + new Response('backfilled-avatar', { + headers: {'Content-Type': 'image/jpeg'}, + }), + ); + + const avatar = await SELF.fetch( + `https://hackweek.test/api/users/${user!.id}/avatar`, + {headers: {Cookie: cookie}}, + ); + + expect(tokenFetch).toHaveBeenCalledTimes(1); + expect(avatar.status).toBe(200); + expect(new TextDecoder().decode(await avatar.arrayBuffer())).toBe( + 'backfilled-avatar', + ); + }); + it('keeps sign-in available when a Google profile photo cannot be refreshed', async () => { const {state, nonce} = await beginLogin(); tokenFetch.mockImplementation(async (input) => { diff --git a/test/projects/projects.test.ts b/test/projects/projects.test.ts index 20b22e3..6f0624c 100644 --- a/test/projects/projects.test.ts +++ b/test/projects/projects.test.ts @@ -486,6 +486,25 @@ describe('project and history APIs', () => { ).toBe(true); }); + it('finds ideas by their creator name', async () => { + const idea = await createProject(memberToken, { + name: 'Anonymous title', + summary: 'No matching words here.', + kind: 'idea', + groupId: null, + }); + + const matches = await api( + `/projects?year=${yearId}&kind=idea&q=${encodeURIComponent('project member')}`, + memberToken, + ); + + expect(matches.status).toBe(200); + expect(matches.body.projects.map((project: {id: string}) => project.id)).toEqual([ + idea.id, + ]); + }); + it('includes every project award in project details', async () => { const project = await createProject(memberToken, {name: 'Awarded project'}); const member = await env.DB.prepare('SELECT id FROM users WHERE google_subject = ?') @@ -614,6 +633,57 @@ describe('project and history APIs', () => { ).toEqual(expect.arrayContaining([currentProject.id, priorProject.id, idea.id])); }); + it('keeps claimed ideas with the claimant rather than the original opener', async () => { + const opener = await env.DB.prepare('SELECT id FROM users WHERE google_subject = ?') + .bind(`project-member-${suffix}`) + .first<{id: string}>(); + const idea = await createProject(memberToken, { + name: 'Claimed profile idea', + kind: 'idea', + groupId: null, + }); + await session(outsiderToken); + const claimant = await env.DB.prepare('SELECT id FROM users WHERE google_subject = ?') + .bind(`project-outsider-${suffix}`) + .first<{id: string}>(); + const claimed = await api(`/projects/${idea.id}/claim`, outsiderToken, { + method: 'POST', + body: {...projectPayload(), name: 'Claimed profile project'}, + }); + await env.DB.prepare( + `INSERT INTO awards + (id, source_id, year_id, project_id, category_id, name, creator_id) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + `claimed-profile-award-${suffix}`, + `claimed-profile-award-${suffix}`, + yearId, + idea.id, + categoryId, + 'Claimed award', + claimant!.id, + ) + .run(); + + const openerProfile = await api(`/users/${opener!.id}`, memberToken); + const claimantProfile = await api(`/users/${claimant!.id}`, memberToken); + + expect(claimed.status).toBe(200); + expect( + openerProfile.body.years.flatMap(({projects}: {projects: Array<{id: string}>}) => + projects.map((project) => project.id), + ), + ).not.toContain(idea.id); + expect(openerProfile.body.awards).toEqual([]); + expect(claimantProfile).toMatchObject({ + body: { + highlights: {projectCount: 1, ideaCount: 0, awardCount: 1}, + awards: [{projectId: idea.id, name: 'Claimed award'}], + }, + }); + }); + it('returns 404 for a missing user profile', async () => { const profile = await api('/users/missing-user', memberToken); From 98ed52c99929dc6f595a427c6e51c1e6173c6bbf Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Fri, 21 Aug 2026 19:36:27 +0200 Subject: [PATCH 4/4] fix(users): validate backfilled avatar streams Check that a response body exists before returning an avatar downloaded during cache backfill, preserving the typed streaming contract without a non-null assertion. --- src/worker/repositories/users.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/worker/repositories/users.ts b/src/worker/repositories/users.ts index dcb1fff..3ecc99a 100644 --- a/src/worker/repositories/users.ts +++ b/src/worker/repositories/users.ts @@ -57,8 +57,10 @@ export async function getUserAvatar( avatarUrl: user.avatar_url, }); if (!refreshed) throw new ServiceError('NOT_FOUND', 'User avatar not found', 404); + const body = new Response(refreshed.content).body; + if (!body) throw new ServiceError('NOT_FOUND', 'User avatar not found', 404); return { - body: new Response(refreshed.content).body!, + body, size: refreshed.content.byteLength, httpEtag: null, contentType: refreshed.contentType,