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..1879c32 100644 --- a/src/app/components/AppLayout.tsx +++ b/src/app/components/AppLayout.tsx @@ -3,6 +3,7 @@ import {Link, useRoute} from 'wouter'; import type {SessionUser, SessionViewMode} from '../../shared/api'; import sentrySymbol from '../../assets/logos/logo-sentry-symbol.svg'; +import {UserAvatar} from './UserAvatar'; export function AppLayout({ user, @@ -66,13 +67,17 @@ export function AppLayout({ {viewModeError && {viewModeError}} )} -
- {user.displayName} - {user.role} -
+ + + {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/queries/projects.ts b/src/app/queries/projects.ts index 2682aa3..04be016 100644 --- a/src/app/queries/projects.ts +++ b/src/app/queries/projects.ts @@ -13,6 +13,7 @@ import type { ProjectResponse, ProjectsResponse, ProjectWriteRequest, + UserProfileResponse, YearResponse, YearsResponse, } from '../../shared/projects'; @@ -77,6 +78,14 @@ export function useProject(projectId: string) { }); } +export function useUserProfile(userId: string) { + return useQuery({ + queryKey: ['user', userId], + queryFn: () => + apiRequest(`/users/${encodeURIComponent(userId)}`), + }); +} + export function useGroupMutations(yearId: string) { const cache = useQueryClient(); const refresh = () => { diff --git a/src/app/routes/ProjectDetailsPage.tsx b/src/app/routes/ProjectDetailsPage.tsx index 3920bd2..eeaf67a 100644 --- a/src/app/routes/ProjectDetailsPage.tsx +++ b/src/app/routes/ProjectDetailsPage.tsx @@ -5,9 +5,9 @@ import {Link, useLocation, useParams, useSearchParams} from 'wouter'; import {formatBytes} from '../../shared/format'; import {MAX_MEDIA_BYTES} from '../../shared/projects'; import {QueryState} from '../components/AppLayout'; -import {Avatar} from '../components/Avatar'; 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'; @@ -74,7 +74,12 @@ export function ProjectDetailsPage() { {project.data.project.needsHelp && Looking for help}

{project.data.project.name}

-

created by {project.data.project.creator.displayName}

+

+ created by{' '} + + {project.data.project.creator.displayName} + +

{project.data.project.permissions.canClaim && ( @@ -116,6 +121,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

@@ -151,14 +179,17 @@ export function ProjectDetailsPage() { diff --git a/src/app/routes/ProjectsPage.tsx b/src/app/routes/ProjectsPage.tsx index c68770c..af85667 100644 --- a/src/app/routes/ProjectsPage.tsx +++ b/src/app/routes/ProjectsPage.tsx @@ -175,7 +175,7 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) { type="search" value={searchInput} maxLength={100} - placeholder="Search titles and descriptions" + placeholder="Search titles, descriptions, and people" onChange={(event) => { paginationRequestPending.current = false; setSearchInput(event.target.value); diff --git a/src/app/routes/UserPage.tsx b/src/app/routes/UserPage.tsx new file mode 100644 index 0000000..21ec99f --- /dev/null +++ b/src/app/routes/UserPage.tsx @@ -0,0 +1,104 @@ +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() { + const {userId} = useParams<{userId: string}>(); + const profile = useUserProfile(userId); + + return ( + + {profile.data && ( +
+
+ + ← hackweek archive + +
+ +
+

Hackweek maker

+

{profile.data.user.displayName}

+ + {profile.data.user.email} + +
+
+
+ + + + +
+
+ + {profile.data.awards.length > 0 && ( +
+
+

Highlights

+

award shelf

+
+
+ {profile.data.awards.map((award) => ( + + {award.yearId} + {award.name || award.categoryName} + {award.projectName} + + ))} +
+
+ )} + + {!profile.data.years.length ? ( + + ) : ( +
+ {profile.data.years.map((year) => ( +
+
+
+

Hackweek

+

{year.yearId}

+
+ view year → +
+
+ {year.projects.map((project) => ( + + ))} +
+
+ ))} +
+ )} +
+ )} +
+ ); +} + +function Highlight({value, label}: {value: number; label: string}) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/src/app/styles.css b/src/app/styles.css index ac042ce..e2c9245 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -173,7 +173,26 @@ input:disabled { color: var(--danger); } .identity { + 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); } .textButton { padding: 0; @@ -187,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; @@ -227,6 +247,7 @@ main { .archivePage, .projectsPage, .detailPage, +.userPage, .editorPage, .operationsPage { width: 100%; @@ -1062,7 +1083,8 @@ main { padding: 0.27rem 0.5rem; font-size: 0.65rem; } -.projectRow .memberStack > span { +.projectRow .memberStack > span, +.projectRow .memberStack > a { width: 1.65rem; height: 1.65rem; font-size: 0.55rem; @@ -1167,7 +1189,8 @@ main { .memberStack { display: flex; } -.memberStack > span { +.memberStack > span, +.memberStack > a { display: grid; width: 2rem; height: 2rem; @@ -1176,14 +1199,37 @@ main { color: #fff; font-size: 0.62rem; font-weight: 600; + text-decoration: none; border: 2px solid #fff; border-radius: 50%; background: var(--blurple); } -.memberStack > span:nth-child(even) { +.memberStack > a { + overflow: hidden; + transition: transform 120ms ease; +} +.memberStack > :nth-child(even) { color: var(--ink); background: var(--pink); } +.memberStack > a:hover { + z-index: 1; + 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; +} .avatar { display: grid; place-items: center; @@ -1251,6 +1297,157 @@ main { line-height: 1.4; } +.userHero { + position: relative; + overflow: hidden; + padding: clamp(1.5rem, 4vw, 3rem); + color: #fff; + border-radius: 1rem; + background: + radial-gradient(circle at 88% 16%, rgba(255, 112, 188, 0.48), transparent 24%), + linear-gradient(135deg, var(--dark-blurple), #7553ff 72%); + box-shadow: 0 18px 40px rgba(29, 17, 39, 0.2); + animation: rise 0.35s ease-out both; +} +.userHero .backLink, +.userHero a { + color: rgba(255, 255, 255, 0.82); +} +.userHero .kicker { + color: var(--pink); +} +.userIdentity { + display: flex; + gap: 1.5rem; + align-items: center; + padding: clamp(2rem, 6vw, 4rem) 0; +} +.userIdentity > .userAvatar { + display: grid; + width: clamp(5rem, 12vw, 7rem); + height: clamp(5rem, 12vw, 7rem); + flex: 0 0 auto; + place-items: center; + color: var(--ink); + font-size: clamp(1.5rem, 4vw, 2.35rem); + font-weight: 700; + border: 4px solid rgba(255, 255, 255, 0.9); + border-radius: 50%; + background: var(--pink); + object-fit: cover; +} +.userIdentity h1 { + margin: 0 0 0.65rem; + font-size: clamp(2.5rem, 7vw, 4.5rem); + line-height: 0.95; + letter-spacing: -0.06em; +} +.userIdentity a { + font-size: 0.82rem; +} +.userHighlights { + display: grid; + grid-template-columns: repeat(4, 1fr); + padding: 0; + margin: 0; + border-top: 1px solid rgba(255, 255, 255, 0.22); +} +.userHighlights > div { + display: flex; + padding: 1.25rem 1rem 0 0; + flex-direction: column-reverse; +} +.userHighlights dt { + color: rgba(255, 255, 255, 0.68); + font-size: 0.68rem; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.userHighlights dd { + margin: 0 0 0.2rem; + font-size: clamp(1.6rem, 4vw, 2.25rem); + font-weight: 700; +} +.userAwards { + padding: 2rem; + margin: 2rem 0 0; + border: 1px solid #ead07f; + border-radius: 0.8rem; + background: #fff9df; +} +.userAwards > header h2 { + margin: 0; + font-size: 1.8rem; +} +.userAwards > div { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; + margin-top: 1.5rem; +} +.userAwards a { + display: grid; + gap: 0.25rem; + padding: 1rem; + text-decoration: none; + border: 1px solid rgba(118, 82, 7, 0.2); + border-radius: 0.65rem; + background: #fff; +} +.userAwards span, +.userAwards small { + color: #765207; + font-size: 0.7rem; +} +.userTimeline { + margin-top: clamp(3rem, 7vw, 5rem); +} +.userYear { + position: relative; + padding-left: 5rem; + margin-bottom: 4rem; +} +.userYear::before { + position: absolute; + top: 0.5rem; + bottom: -4rem; + left: 1.4rem; + width: 1px; + content: ''; + background: var(--line); +} +.userYear::after { + position: absolute; + top: 0.35rem; + left: 1rem; + width: 0.8rem; + height: 0.8rem; + content: ''; + border: 3px solid var(--paper); + border-radius: 50%; + background: var(--blurple); + box-shadow: 0 0 0 1px var(--blurple); +} +.userYear:last-child::before { + display: none; +} +.userYear > header { + display: flex; + align-items: end; + justify-content: space-between; + padding-bottom: 1.25rem; +} +.userYear h2 { + margin: 0; + font-size: 2rem; + letter-spacing: -0.04em; +} +.userYear > header > a { + color: var(--muted); + font-size: 0.78rem; +} + .groupManager { padding: 1.5rem; margin-bottom: 2rem; @@ -1362,6 +1559,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); @@ -1769,15 +2020,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); } @@ -2239,6 +2491,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; } @@ -2322,7 +2584,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; @@ -2330,6 +2595,9 @@ main { background: transparent; cursor: pointer; } +.teamSearchResults button > span { + min-width: 0; +} .teamSearchResults button:hover, .teamSearchResults button[aria-selected='true'] { background: #f0ecff; @@ -3456,7 +3724,8 @@ kbd { .projectGrid { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .detailLayout { + .detailLayout, + .projectAwards { grid-template-columns: 1fr; } .projectVotingCategory { @@ -3489,7 +3758,7 @@ kbd { .masthead nav a { min-height: 2.8rem; } - .identity span, + .identityCopy, .viewModeSwitch > span { display: none; } @@ -3499,6 +3768,7 @@ kbd { } .yearTimeline, .projectGrid, + .userAwards > div, .adminGrid, .metricGrid { grid-template-columns: 1fr; @@ -3567,6 +3837,28 @@ kbd { .detailActions { min-width: 0; } + .userIdentity { + align-items: flex-start; + flex-direction: column; + } + .userIdentity h1 { + overflow-wrap: anywhere; + } + .userHighlights { + grid-template-columns: repeat(2, 1fr); + } + .userYear { + padding-left: 2.5rem; + } + .userYear::before { + left: 0.4rem; + } + .userYear::after { + left: 0; + } + .userYear > header { + gap: 1rem; + } .projectVoting > header, .projectVotingOwn, .projectVotingCategory, diff --git a/src/shared/projects.ts b/src/shared/projects.ts index f1bb473..ebbdd67 100644 --- a/src/shared/projects.ts +++ b/src/shared/projects.ts @@ -55,6 +55,7 @@ export interface ProjectSummary { export interface ProjectDetail extends ProjectSummary { media: MediaSummary[]; + awards: AwardSummary[]; nominationCategoryIds: string[]; permissions: { canEdit: boolean; @@ -87,6 +88,23 @@ export interface ProjectResponse { project: ProjectDetail; } +export interface UserProfileYear { + yearId: string; + projects: ProjectSummary[]; +} + +export interface UserProfileResponse { + user: ProjectMember; + highlights: { + hackweekCount: number; + projectCount: number; + ideaCount: number; + awardCount: number; + }; + awards: AwardSummary[]; + years: UserProfileYear[]; +} + export interface ProjectOptionsResponse { users: ProjectMember[]; groups: GroupSummary[]; diff --git a/src/shared/videos.ts b/src/shared/videos.ts index d567d6f..15dff98 100644 --- a/src/shared/videos.ts +++ b/src/shared/videos.ts @@ -91,7 +91,11 @@ export interface PlaylistItem { projectName: string; groupId: string | null; groupName: string | null; - teamMembers: Array<{id: string; displayName: string; avatarUrl: string | null}>; + teamMembers: Array<{ + id: string; + displayName: string; + avatarUrl: string | null; + }>; durationSeconds: number; gainDb: number; position: number; diff --git a/src/worker/index.ts b/src/worker/index.ts index 9d8fc88..691572e 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -14,6 +14,7 @@ import {mediaRoutes} from './routes/media'; import {projectsRoutes} from './routes/projects'; import {sessionRoutes} from './routes/session'; import {projectVideoRoutes, videosRoutes} from './routes/videos'; +import {usersRoutes} from './routes/users'; import {votesRoutes} from './routes/votes'; import {yearsRoutes} from './routes/years'; import type {VideoProcessingParams} from './video-processing'; @@ -50,6 +51,7 @@ app.route('/api/projects', projectVideoRoutes); app.route('/api/videos', videosRoutes); app.route('/api/groups', groupsRoutes); app.route('/api/media', mediaRoutes); +app.route('/api/users', usersRoutes); app.route('/api/votes', votesRoutes); app.route('/api/admin', adminRoutes); app.route('/api/admin/analytics', analyticsRoutes); 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 994caf1..1cbe6f9 100644 --- a/src/worker/repositories/projects.ts +++ b/src/worker/repositories/projects.ts @@ -68,6 +68,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( @@ -165,6 +175,18 @@ export async function listProjectOptions(db: D1Database, yearId: string) { }; } +interface ListProjectsOptions { + yearId: string; + kind?: 'project' | 'idea'; + groupId?: string; + categoryId?: string; + search?: string; + hasVideo?: boolean; + userId?: string; + limit: number; + offset: number; +} + export async function listMyProjects(db: D1Database, yearId: string, userId: string) { const {results} = await db .prepare( @@ -185,20 +207,15 @@ export async function listMyProjects(db: D1Database, yearId: string, userId: str return results.map((row) => mapProject(row, members.get(row.id) ?? [])); } -export async function listProjects( +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; - categoryId?: string; - search?: string; - hasVideo?: boolean; - limit: number; - offset: number; - }, + options: ListProjectsOptions, ) { - await getYear(db, options.yearId); const conditions = ['p.year_id = ?', "p.status = 'active'"]; const bindings: unknown[] = [options.yearId]; const countConditions = ['p.year_id = ?', "p.status = 'active'"]; @@ -233,16 +250,42 @@ export async function listProjects( bindings.push(options.categoryId); countBindings.push(options.categoryId); } + if (options.userId) { + const userCondition = `((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 = ? + ))`; + conditions.push(userCondition); + countConditions.push(userCondition); + bindings.push(options.userId, options.userId); + countBindings.push(options.userId, options.userId); + } let relevanceOrder = ''; if (options.search) { const escapedSearch = escapeLikePattern(options.search); const containsPattern = `%${escapedSearch}%`; const searchCondition = `(LOWER(p.name) LIKE LOWER(?) ESCAPE '\\' - OR LOWER(COALESCE(p.summary, '')) LIKE LOWER(?) ESCAPE '\\')`; + OR LOWER(COALESCE(p.summary, '')) LIKE LOWER(?) ESCAPE '\\' + OR (p.kind = 'idea' AND EXISTS ( + SELECT 1 FROM users search_creator + WHERE search_creator.id = p.creator_id + AND LOWER(search_creator.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 + WHERE search_member.project_id = p.id + AND LOWER(search_user.display_name) LIKE LOWER(?) ESCAPE '\\' + ))`; conditions.push(searchCondition); countConditions.push(searchCondition); - bindings.push(containsPattern, containsPattern); - countBindings.push(containsPattern, containsPattern); + bindings.push(containsPattern, containsPattern, containsPattern, containsPattern); + countBindings.push( + containsPattern, + containsPattern, + containsPattern, + containsPattern, + ); relevanceOrder = `CASE WHEN LOWER(p.name) = LOWER(?) THEN 0 WHEN LOWER(p.name) LIKE LOWER(?) ESCAPE '\\' THEN 1 @@ -302,7 +345,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( @@ -311,6 +354,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), ]); @@ -323,6 +378,7 @@ export async function getProject( return { ...project, media: mediaResult.results.map(mapMedia), + awards: awardResult.results.map(mapAward), nominationCategoryIds: nominationIds, permissions: { canEdit: canWrite, @@ -771,6 +827,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 new file mode 100644 index 0000000..3ecc99a --- /dev/null +++ b/src/worker/repositories/users.ts @@ -0,0 +1,170 @@ +import type {AwardSummary} from '../../shared/administration'; +import type {ProjectMember, UserProfileResponse} from '../../shared/projects'; +import {ServiceError} from '../services/errors'; +import {refreshGoogleUserAvatar, userAvatarKey} from '../services/users'; +import {listProjectsForExistingYear} from './projects'; + +interface UserRow { + id: string; + email: string; + display_name: string; + avatar_url: string | null; + is_admin: number; +} + +interface AwardRow { + id: string; + year_id: string; + project_id: string; + project_name: string; + category_id: string; + category_name: string; + name: string; +} + +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); + const body = new Response(refreshed.content).body; + if (!body) throw new ServiceError('NOT_FOUND', 'User avatar not found', 404); + return { + body, + size: refreshed.content.byteLength, + httpEtag: null, + contentType: refreshed.contentType, + }; +} + +export async function getUserProfile( + db: D1Database, + userId: string, +): Promise { + const userRow = await db + .prepare( + `SELECT id, email, display_name, avatar_url, is_admin + FROM users WHERE id = ?`, + ) + .bind(userId) + .first(); + if (!userRow) throw new ServiceError('NOT_FOUND', 'User not found', 404); + + const [yearResult, awardResult] = await Promise.all([ + db + .prepare( + `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.kind = 'idea' AND p.creator_id = ?) OR pm.user_id = ?) + ORDER BY p.year_id DESC`, + ) + .bind(userId, userId) + .all<{year_id: string}>(), + db + .prepare( + `SELECT DISTINCT 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 + LEFT JOIN project_members pm ON pm.project_id = p.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) + .all(), + ]); + + const years = await Promise.all( + yearResult.results.map(async ({year_id}) => ({ + yearId: year_id, + projects: await projectsForUserAndYear(db, userId, year_id), + })), + ); + const projects = years.flatMap((year) => year.projects); + + return { + user: mapUser(userRow), + highlights: { + hackweekCount: years.length, + projectCount: projects.filter((project) => project.kind === 'project').length, + ideaCount: projects.filter((project) => project.kind === 'idea').length, + awardCount: awardResult.results.length, + }, + awards: awardResult.results.map(mapAward), + years, + }; +} + +async function projectsForUserAndYear(db: D1Database, userId: string, yearId: string) { + const projects = []; + let offset = 0; + while (true) { + const page = await listProjectsForExistingYear(db, { + yearId, + userId, + limit: 250, + offset, + }); + projects.push(...page.projects); + if (!page.nextCursor) return projects; + offset = Number(page.nextCursor); + } +} + +function mapUser(row: UserRow): ProjectMember { + const role = row.is_admin ? 'admin' : 'member'; + return { + id: row.id, + email: row.email, + displayName: row.display_name, + avatarUrl: row.avatar_url, + role, + actualRole: role, + }; +} + +function mapAward(row: AwardRow): AwardSummary { + 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, + }; +} 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 new file mode 100644 index 0000000..5afbc86 --- /dev/null +++ b/src/worker/routes/users.ts @@ -0,0 +1,51 @@ +import {Hono} from 'hono'; + +import type {UserProfileResponse} from '../../shared/projects'; +import type {WorkerEnv} from '../index'; +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.DB, + c.env.ATTACHMENTS, + c.req.param('userId'), + ); + const headers = new Headers(); + const contentType = safeAvatarContentType(object.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( + c.env.DB, + c.req.param('userId'), + ); + return c.json(response); + } catch (error) { + const result = errorResponse(error); + return c.json(result.response, result.status); + } +}); diff --git a/src/worker/services/users.ts b/src/worker/services/users.ts index 4796b0d..8ce21ed 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,78 @@ 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 null; + } + const avatarUrl = new URL(user.avatarUrl); + if (!isGoogleusercontentHost(avatarUrl.hostname)) return null; + + 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 null; + const redirect = new URL(location, avatarUrl); + if (redirect.protocol !== 'https:' || !isGoogleusercontentHost(redirect.hostname)) { + return null; + } + return refreshGoogleUserAvatarFromResponse( + bucket, + key, + await fetch(redirect, {redirect: 'manual', signal}), + ); + } + return refreshGoogleUserAvatarFromResponse(bucket, key, response); + } catch { + // Profile photos must never prevent sign-in. A previously cached photo remains valid. + return null; + } +} + +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, +): Promise { + if (!response.ok) return null; + const contentType = safeAvatarContentType(response.headers.get('Content-Type')); + if (!contentType) return null; + const declaredSize = Number(response.headers.get('Content-Length')); + 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 null; + await bucket.put(key, content, { + httpMetadata: {contentType, cacheControl: 'private, max-age=300'}, + customMetadata: {source: 'google'}, + }); + return {content, contentType}; +} + +function isGoogleusercontentHost(hostname: string) { + return ( + hostname === 'googleusercontent.com' || hostname.endsWith('.googleusercontent.com') + ); +} + export async function updateUserProfile( db: D1Database, userId: string, diff --git a/test/app/ProjectForm.test.tsx b/test/app/ProjectForm.test.tsx index 93b1ee4..0084cd8 100644 --- a/test/app/ProjectForm.test.tsx +++ b/test/app/ProjectForm.test.tsx @@ -449,6 +449,7 @@ const projectFixture: ProjectDetail = { mediaCount: 0, hasVideo: false, media: [], + awards: [], nominationCategoryIds: [], permissions: { canEdit: true, diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx index cbcc55c..9c5ed6e 100644 --- a/test/app/routes.test.tsx +++ b/test/app/routes.test.tsx @@ -10,6 +10,7 @@ import {AppLayout} from '../../src/app/components/AppLayout'; import {ProjectCard} from '../../src/app/components/ProjectCard'; import {ProjectDetailsPage} from '../../src/app/routes/ProjectDetailsPage'; import {ProjectsPage} from '../../src/app/routes/ProjectsPage'; +import {UserPage} from '../../src/app/routes/UserPage'; import {YearsPage} from '../../src/app/routes/YearsPage'; import type {BallotStatusResponse} from '../../src/shared/administration'; import type {ProjectDetail} from '../../src/shared/projects'; @@ -748,7 +749,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'); @@ -1307,6 +1312,9 @@ describe('clickable project routes', () => { const input = within(search).getByLabelText('Search projects and ideas'); expect(input.getAttribute('type')).toBe('search'); expect(input.getAttribute('maxlength')).toBe('100'); + expect(input.getAttribute('placeholder')).toBe( + 'Search titles, descriptions, and people', + ); await userEvent.selectOptions(screen.getByLabelText('Group'), 'group'); await waitFor(() => @@ -1364,6 +1372,143 @@ describe('clickable project routes', () => { }); }); + it('renders a user history with highlights, awards, projects, and ideas', async () => { + fetchMock.mockResolvedValue( + json({ + user: { + ...projectFixture.creator, + avatarUrl: 'https://profiles.test/member.jpg', + }, + highlights: { + hackweekCount: 2, + projectCount: 2, + ideaCount: 1, + awardCount: 1, + }, + awards: [ + { + id: 'award', + yearId: '2026', + projectId: 'project', + projectName: 'A small machine', + categoryId: 'delight', + categoryName: 'Delight', + name: 'Most delightful', + }, + ], + years: [ + {yearId: '2026', projects: [projectFixture]}, + { + yearId: '2025', + projects: [ + { + ...projectFixture, + id: 'idea', + yearId: '2025', + name: 'A bright idea', + kind: 'idea', + members: [], + }, + ], + }, + ], + }), + ); + + 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(); + expect(screen.getByRole('heading', {name: 'award shelf'})).toBeTruthy(); + expect(screen.getByRole('link', {name: /Most delightful/})).toBeTruthy(); + expect(screen.getByRole('heading', {name: 'A small machine'})).toBeTruthy(); + expect(screen.getByRole('heading', {name: 'A bright idea'})).toBeTruthy(); + 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: { @@ -1971,6 +2116,7 @@ const projectFixture: ProjectDetail = { mediaCount: 0, hasVideo: false, media: [], + awards: [], nominationCategoryIds: [], permissions: { canEdit: true, diff --git a/test/auth/auth.test.ts b/test/auth/auth.test.ts index 484199e..16c71e5 100644 --- a/test/auth/auth.test.ts +++ b/test/auth/auth.test.ts @@ -133,6 +133,216 @@ 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('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) => { + 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', @@ -403,6 +613,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/projects/projects.test.ts b/test/projects/projects.test.ts index 92f2bdf..2b2efac 100644 --- a/test/projects/projects.test.ts +++ b/test/projects/projects.test.ts @@ -236,6 +236,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, @@ -594,6 +595,253 @@ describe('project and history APIs', () => { expect(secondPage.body).toMatchObject({projectCount: 3, ideaCount: 0}); }); + it('finds every project a person belongs to by their name', async () => { + const teammateToken = await createSessionCookie({ + sub: `project-teammate-${suffix}`, + email: `project-teammate-${suffix}@sentry.io`, + name: 'Ada Lovelace', + }); + await session(teammateToken); + const teammate = await env.DB.prepare('SELECT id FROM users WHERE google_subject = ?') + .bind(`project-teammate-${suffix}`) + .first<{id: string}>(); + const first = await createProject(memberToken, {name: 'Analytical engine'}); + const second = await createProject(memberToken, {name: 'Poetical science'}); + await createProject(memberToken, {name: 'Unrelated project'}); + await env.DB.batch( + [first.id, second.id].map((projectId) => + env.DB.prepare( + 'INSERT INTO project_members (project_id, user_id) VALUES (?, ?)', + ).bind(projectId, teammate!.id), + ), + ); + + const matches = await api( + `/projects?year=${yearId}&kind=project&q=${encodeURIComponent('ada love')}`, + memberToken, + ); + + expect(matches.status).toBe(200); + expect(matches.body.projects.map((project: {id: string}) => project.id)).toEqual([ + first.id, + second.id, + ]); + expect(matches.body).toMatchObject({projectCount: 2, ideaCount: 0}); + expect( + matches.body.projects.every((project: {members: Array<{displayName: string}>}) => + project.members.some(({displayName}) => displayName === 'Ada Lovelace'), + ), + ).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, + ]); + expect(matches.body).toMatchObject({projectCount: 0, ideaCount: 1}); + }); + + 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}`) + .first<{id: string}>(); + const currentProject = await createProject(memberToken, { + name: 'Current profile project', + }); + const priorProject = {id: `profile-prior-project-${suffix}`}; + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO projects + (id, source_id, year_id, creator_id, name, kind) + VALUES (?, ?, ?, ?, ?, 'project')`, + ).bind( + priorProject.id, + priorProject.id, + priorYearId, + member!.id, + 'Earlier profile project', + ), + env.DB.prepare( + 'INSERT INTO project_members (project_id, user_id) VALUES (?, ?)', + ).bind(priorProject.id, member!.id), + ]); + const idea = await createProject(memberToken, { + name: 'Profile idea', + kind: 'idea', + groupId: null, + }); + await env.DB.prepare( + `INSERT INTO awards + (id, source_id, year_id, project_id, category_id, name, creator_id) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + `profile-award-${suffix}`, + `profile-award-${suffix}`, + yearId, + currentProject.id, + categoryId, + 'Brightest signal', + member!.id, + ) + .run(); + + const profile = await api(`/users/${member!.id}`, memberToken); + + expect(profile).toMatchObject({ + status: 200, + body: { + user: {displayName: 'Project Member'}, + highlights: { + hackweekCount: 2, + projectCount: 2, + ideaCount: 1, + awardCount: 1, + }, + awards: [ + { + name: 'Brightest signal', + projectId: currentProject.id, + projectName: 'Current profile project', + }, + ], + }, + }); + expect(profile.body.years.map(({yearId}: {yearId: string}) => yearId)).toEqual([ + yearId, + priorYearId, + ]); + expect( + profile.body.years.flatMap(({projects}: {projects: Array<{id: string}>}) => + projects.map((project) => project.id), + ), + ).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); + + expect(profile).toMatchObject({ + status: 404, + body: {error: {code: 'NOT_FOUND', message: 'User not found'}}, + }); + }); + it('treats SQL wildcards literally and bounds search input', async () => { const percent = await createProject(memberToken, { name: '100% reliable',