From 37ff6219be15f3fc6167053315c8471ff01fb211 Mon Sep 17 00:00:00 2001 From: Carlos Date: Thu, 20 Aug 2026 10:27:48 -0400 Subject: [PATCH 01/10] fix khix guild profile images Resolve public Guild profile picture references into signed URLs before exposing the club roster, and remove Lena Tran from the KHIX featured designers. Co-authored-by: Codex --- .../khix-guild-profile-images/spec.md | 52 +++++++++++ .../features/khix-guild-profile-images/srd.md | 74 +++++++++++++++ .../khix-guild-profile-images/status.md | 53 +++++++++++ .../khix-guild-profile-images/test-cases.md | 93 +++++++++++++++++++ .../team-cascade/team-roster.test.ts | 85 +++++++++++++++++ .../_components/team-cascade/team-roster.ts | 1 - packages/api/src/routers/guild.ts | 27 +----- .../api/src/tests/guild/club-roster.test.ts | 40 +++++++- .../src/tests/guild/profile-picture.test.ts | 73 +++++++++++++++ packages/api/src/utils/guild/club-roster.ts | 45 ++++++++- .../api/src/utils/guild/profile-picture.ts | 34 +++++++ 11 files changed, 545 insertions(+), 32 deletions(-) create mode 100644 .forge/features/khix-guild-profile-images/spec.md create mode 100644 .forge/features/khix-guild-profile-images/srd.md create mode 100644 .forge/features/khix-guild-profile-images/status.md create mode 100644 .forge/features/khix-guild-profile-images/test-cases.md create mode 100644 apps/khix/src/app/_components/team-cascade/team-roster.test.ts create mode 100644 packages/api/src/tests/guild/profile-picture.test.ts create mode 100644 packages/api/src/utils/guild/profile-picture.ts diff --git a/.forge/features/khix-guild-profile-images/spec.md b/.forge/features/khix-guild-profile-images/spec.md new file mode 100644 index 000000000..9308ae718 --- /dev/null +++ b/.forge/features/khix-guild-profile-images/spec.md @@ -0,0 +1,52 @@ +# KnightHacks IX Guild Profile Images Spec + +Status: Approved + +## User-facing purpose + +Visitors to the KnightHacks IX website should see the current Guild profile +pictures for featured team members. Estefanie Parra's current Guild picture +must render instead of a broken image, and Lena Tran must no longer appear in +the IX featured team display. + +## Users / actors + +- Public visitors to `2026.knighthacks.org`. +- Knight Hacks organizers reviewing the IX team section. + +## User-visible interface + +The existing KnightHacks IX team cascade keeps its current layout, role labels, +selection behavior, and LinkedIn links. Available Guild portraits render in the +existing circular avatars; missing or unavailable portraits continue to fall +back to initials. + +## Scope + +### In scope + +- Repair current Guild profile pictures in the IX team cascade. +- Show Estefanie Parra's current Guild portrait. +- Remove Lena Tran from the IX featured designers. + +### Out of scope + +- Removing or modifying Lena Tran's Guild profile or club roles. +- Changing Guild visibility, profile-upload behavior, team membership, or IX styling. + +## Vocabulary + +- `Guild profile picture`: A public portrait stored by Guild and exposed through a temporary signed URL. +- `featured designer`: A design-team member explicitly selected for the IX team cascade. + +## Acceptance criteria + +- Estefanie Parra's portrait loads successfully on the IX team section. +- Other available Guild portraits continue to load. +- Lena Tran is absent from the IX team cascade. +- Missing or unavailable pictures display initials without breaking the roster. +- Names, roles, selection behavior, and LinkedIn links remain functional. + +## Open questions + +- None. The human confirmed IX-only removal and branch-plus-PR delivery on 2026-08-20. diff --git a/.forge/features/khix-guild-profile-images/srd.md b/.forge/features/khix-guild-profile-images/srd.md new file mode 100644 index 000000000..97d69596e --- /dev/null +++ b/.forge/features/khix-guild-profile-images/srd.md @@ -0,0 +1,74 @@ +# KnightHacks IX Guild Profile Images SRD + +Status: Approved + +## Technical purpose + +Ensure Guild public roster responses resolve both legacy absolute profile-picture +URLs and current object-name references into signed public URLs before clients +render them. Remove Lena Tran's ID from the KHIX featured-designer filter. + +## Relevant principles + +- Keep `apps/khix` a thin client of the `@forge/api` Guild capability. +- Preserve existing public tRPC contracts and app/package boundaries. +- Reuse current profile-picture ownership validation and MinIO signing. + +## Access policy + +`guild.getPublicClubTeamRoster` remains an unauthenticated public query and +returns only opted-in Guild profiles. No authenticated, officer, or admin +behavior changes. + +## Architecture / data flow + +`@forge/api` owns a shared public profile-picture resolver. Guild profile reads +and the public club roster use it to validate the stored reference against the +member's user ID and request a one-hour signed MinIO URL. KHIX continues to read +the roster over the existing Blade tRPC endpoint and uses initials when +`imageUrl` is null. + +## tRPC/API behavior + +`guild.getPublicClubTeamRoster` keeps its current input and output shape. +`PublicClubTeamMember.imageUrl` remains `string | null`, but non-null values are +public signed HTTPS URLs rather than database storage references. Invalid, +unowned, or un-signable references become null without failing the roster. + +## Validation + +No validator changes. Existing profile-picture ownership resolution remains the +authority for accepted legacy URLs and current object names. + +## Data / migration / compatibility + +No schema, migration, data, dependency, or environment changes. Blade must +deploy the API change and KHIX must deploy the featured-designer change. The +unchanged API shape makes either deployment order backward-compatible. + +## Discord integration + +No Discord roles, sync behavior, or Guild records are changed. + +## Configurability review + +Would this require a developer change next year? + +- Answer: The public roster and pictures remain data-driven. The IX featured-designer shortlist is intentionally event-specific and already hard-coded in KHIX. +- If yes, why is hard-coding acceptable or what admin-configurable path is planned? Lena's removal changes only that existing IX-specific shortlist; no new configuration debt is introduced. + +## React / frontend constraints + +No component structure or styling changes. Preserve the existing client-side +roster loading, image component, initials fallback, and interaction behavior. + +## Testing / verification strategy + +- Unit-test public picture resolution for current object names, legacy URLs, invalid ownership, and signing failures. +- Unit-test roster preprocessing so raw references never reach `imageUrl`. +- Unit-test KHIX featured designer filtering with Lena present in the source roster. +- Run targeted API/KHIX tests, affected builds, React analysis, and repository format/lint/typecheck gates. + +## Open questions + +- None. diff --git a/.forge/features/khix-guild-profile-images/status.md b/.forge/features/khix-guild-profile-images/status.md new file mode 100644 index 000000000..a2e378f84 --- /dev/null +++ b/.forge/features/khix-guild-profile-images/status.md @@ -0,0 +1,53 @@ +# KnightHacks IX Guild Profile Images Status + +Current phase: Review / PR + +> This file is the maintained progress tracker for the feature/change. Keep it current whenever decisions, tasks, validation, or open questions change. + +## Decision log + +- 2026-08-20: Repair the shared Guild public roster contract instead of special-casing Estefanie in KHIX. +- 2026-08-20: Support both legacy absolute URLs and current object-name references through the existing ownership resolver. +- 2026-08-20: Remove Lena Tran from KHIX only; preserve her Guild profile and roles. +- 2026-08-20: Deliver on a task branch through one draft PR. + +## Open questions + +- None. + +## Task list + +- [x] Complete reverse-prompting for `spec.md`. +- [x] Complete reverse-prompting for `srd.md`. +- [x] Complete reverse-prompting for `test-cases.md`. +- [x] Human approved the plan before implementation/test generation. +- [x] Implement shared public profile-picture signing. +- [x] Resolve public roster picture references before returning them. +- [x] Remove Lena from KHIX featured designers. +- [x] Add and run regression tests. +- [x] Complete repository validation. +- [ ] Open the draft PR and confirm CI is green. + +## Validation / commands + +- `pnpm exec vitest run packages/api/src/tests/guild/profile-picture.test.ts packages/api/src/tests/guild/club-roster.test.ts` — passed (21 tests). +- `pnpm --filter=@forge/khix exec vitest run src/app/_components/team-cascade/team-roster.test.ts` — passed (1 test). +- `pnpm --filter=@forge/api test` — passed (585 tests; 148 skipped). +- `pnpm --filter=@forge/khix test` — passed (15 tests). +- `pnpm --filter=@forge/api typecheck` — passed. +- `pnpm --filter=@forge/khix typecheck` — passed. +- `pnpm --filter=@forge/club typecheck` — passed. +- `pnpm --filter=@forge/guild typecheck` — passed. +- `pnpm --filter=@forge/blade build` — passed. +- `pnpm --filter=@forge/khix build` — passed. +- `pnpm analyze:react:changed` — passed (no changed TSX files to analyze). +- `pnpm format` — passed. +- `pnpm lint` — passed with existing repository warnings and no errors. +- `pnpm typecheck` — passed (29 tasks). +- Local browser contract verification — passed: all 29 rendered portraits loaded, Estefanie was present, and Lena was absent. + +## Links + +- PRs: +- Issues: +- Discord/thread context: diff --git a/.forge/features/khix-guild-profile-images/test-cases.md b/.forge/features/khix-guild-profile-images/test-cases.md new file mode 100644 index 000000000..ca56c7893 --- /dev/null +++ b/.forge/features/khix-guild-profile-images/test-cases.md @@ -0,0 +1,93 @@ +# KnightHacks IX Guild Profile Images Test Cases + +Status: Approved + +## Scope + +Tests cover public Guild picture resolution, roster URL safety, and the KHIX +featured-designer filter. They do not change or test production data, Guild +membership, uploads, or styling. + +## Test placement plan + +- `packages/api`: Guild profile-picture and club-roster unit tests. +- `apps/khix`: team-roster filtering unit test. +- Repository validation and Blade/KHIX builds before commit. + +## Test cases + +### TC-001: Current Guild object name becomes a signed public URL + +Setup: + +- A visible member has a user-owned object-name profile-picture reference. + +Action: + +- The public picture resolver or club roster reads the member. + +Expected observations: + +- MinIO receives the validated object name and the response contains the signed URL, never the raw key. + +### TC-002: Legacy Guild URL remains compatible + +Setup: + +- A visible member has a legacy absolute MinIO profile-picture URL owned by that member. + +Action: + +- The public picture resolver reads the member. + +Expected observations: + +- The legacy URL is reduced to its owned object name and re-signed through the current Guild storage client. + +### TC-003: KHIX removes Lena only from featured designers + +Setup: + +- The source roster includes officers, directors, organizers, other featured designers, and Lena Tran's design member ID. + +Action: + +- KHIX maps the source roster into team cascade groups. + +Expected observations: + +- Lena is absent, the remaining featured designers are present, and all non-designer groups are unchanged. + +## Negative / regression cases + +### TC-NEG-001: Invalid or unowned reference fails closed + +Setup: + +- A profile-picture reference is malformed or belongs to another user. + +Action: + +- A public Guild profile or roster response is generated. + +Expected observations: + +- No storage signing occurs and the picture field is null, allowing clients to render initials. + +### TC-NEG-002: Storage signing failure preserves the roster + +Setup: + +- A valid owned profile-picture reference exists, but MinIO signing fails. + +Action: + +- A public Guild profile or roster response is generated. + +Expected observations: + +- The picture field is null and the public response remains available without leaking the raw reference. + +## Open questions + +- None. diff --git a/apps/khix/src/app/_components/team-cascade/team-roster.test.ts b/apps/khix/src/app/_components/team-cascade/team-roster.test.ts new file mode 100644 index 000000000..2842bb39c --- /dev/null +++ b/apps/khix/src/app/_components/team-cascade/team-roster.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { loadTeamCascadeGroups } from "./team-roster"; + +const mocks = vi.hoisted(() => ({ + query: vi.fn(), +})); + +vi.mock("./blade-trpc", () => ({ + getBladeTrpcClient: () => ({ + guild: { + getPublicClubTeamRoster: { + query: mocks.query, + }, + }, + }), +})); + +function member(id: string, name: string) { + return { + color: null, + id, + imageUrl: null, + linkedinUrl: null, + name, + teamRole: "Team Member", + }; +} + +describe("KnightHacks IX team roster", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("removes Lena only from the featured designers", async () => { + const officer = member("executive-officer", "Officer"); + const director = member("directors-director", "Director"); + const organizer = member("hackathon-organizer", "Organizer"); + const firstFeaturedDesigner = member( + "design-f06cbff5-b5f8-49d5-8a3c-5b40a59dfcc6", + "Featured Designer One", + ); + const lena = member( + "design-3a0d6777-2276-4ae8-9281-ace2a26d6c94", + "Lena Tran", + ); + const secondFeaturedDesigner = member( + "design-f56f4444-7962-4090-b937-f31674a6ac7e", + "Featured Designer Two", + ); + const unfeaturedDesigner = member("design-other", "Other Designer"); + const signal = new AbortController().signal; + + mocks.query.mockResolvedValue({ + members: { + design: [ + firstFeaturedDesigner, + lena, + secondFeaturedDesigner, + unfeaturedDesigner, + ], + directors: [director], + executive: [officer], + hackathon: [organizer], + }, + teams: [], + }); + + const groups = await loadTeamCascadeGroups( + "https://blade.example.test", + signal, + ); + + expect(groups).toEqual([ + { members: [officer], roleLabel: "Officer" }, + { members: [director], roleLabel: "Director" }, + { members: [organizer], roleLabel: "Organizer" }, + { + members: [firstFeaturedDesigner, secondFeaturedDesigner], + roleLabel: "Designer", + }, + ]); + expect(mocks.query).toHaveBeenCalledWith(undefined, { signal }); + }); +}); diff --git a/apps/khix/src/app/_components/team-cascade/team-roster.ts b/apps/khix/src/app/_components/team-cascade/team-roster.ts index d052e4174..32c8ec976 100644 --- a/apps/khix/src/app/_components/team-cascade/team-roster.ts +++ b/apps/khix/src/app/_components/team-cascade/team-roster.ts @@ -18,7 +18,6 @@ export interface TeamCascadeGroup { const featuredDesignerIds = [ "design-f06cbff5-b5f8-49d5-8a3c-5b40a59dfcc6", - "design-3a0d6777-2276-4ae8-9281-ace2a26d6c94", "design-f56f4444-7962-4090-b937-f31674a6ac7e", ] as const; diff --git a/packages/api/src/routers/guild.ts b/packages/api/src/routers/guild.ts index dcfb1f063..b36ed1a5d 100644 --- a/packages/api/src/routers/guild.ts +++ b/packages/api/src/routers/guild.ts @@ -23,17 +23,13 @@ import { getGlobeCity } from "../utils/career/globe-cities"; import { getUsCity } from "../utils/career/us-cities"; import { getVisiblePublicClubRoster } from "../utils/guild/club-roster"; import { loadClubTeamConfig } from "../utils/guild/club-team-config"; +import { getPublicProfilePictureUrl } from "../utils/guild/profile-picture"; import { normalizePublicGuildText, normalizePublicGuildUrl, } from "../utils/guild/public-profile"; import { getGuildRoleCallout } from "../utils/guild/role-callout"; import { graduatedCondition, hasGraduated } from "../utils/member/graduation"; -import { - PROFILE_PICTURE_BUCKET_NAME, - resolveProfilePictureObjectName, -} from "../utils/profile-picture/security"; -import { profilePictureStorageClient } from "../utils/profile-picture/storage"; import { normalizeOwnedResumeObjectName, RESUME_BUCKET_NAME, @@ -321,27 +317,6 @@ async function getRoleCalloutsByUserId( ); } -async function getPublicProfilePictureUrl(row: PublicMemberRow) { - if (!row.profilePictureReference) return null; - - const objectName = resolveProfilePictureObjectName( - row.profilePictureReference, - row.userId, - ); - if (!objectName) return null; - - try { - return await profilePictureStorageClient.presignedUrl( - "GET", - PROFILE_PICTURE_BUCKET_NAME, - objectName, - 60 * 60, - ); - } catch { - return null; - } -} - async function toPublicProfile( row: PublicMemberRow, roleCallout: ReturnType, diff --git a/packages/api/src/tests/guild/club-roster.test.ts b/packages/api/src/tests/guild/club-roster.test.ts index 6e95c8458..7f115955c 100644 --- a/packages/api/src/tests/guild/club-roster.test.ts +++ b/packages/api/src/tests/guild/club-roster.test.ts @@ -1,7 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -import type { RosterRoleRow } from "../../utils/guild/club-roster"; -import { buildPublicClubRoster } from "../../utils/guild/club-roster"; +import type { + RosterRoleReferenceRow, + RosterRoleRow, +} from "../../utils/guild/club-roster"; +import { + buildPublicClubRoster, + resolveRosterRoleRows, +} from "../../utils/guild/club-roster"; import { clubRoleId, createClubTeamConfigFixture, @@ -38,6 +44,34 @@ function labelsIn( } describe("public club roster bucketing", () => { + it("resolves stored picture references before exposing roster image URLs", async () => { + const [resolvedRow] = rowsFor("Estefanie Parra", "KH IX Team"); + if (!resolvedRow) throw new Error("Expected a roster fixture row"); + + const { guildProfilePictureUrl: _picture, ...row } = resolvedRow; + const profilePictureReference = `${row.userId}/profile-picture-4ac506f0-ab78-4722-88f6-2808763b65aa.jpg`; + const referenceRows: RosterRoleReferenceRow[] = [ + { ...row, profilePictureReference }, + ]; + const signedUrl = "https://storage.example.test/signed-estefanie"; + const resolveProfilePicture = vi.fn().mockResolvedValue(signedUrl); + + const resolvedRows = await resolveRosterRoleRows( + referenceRows, + resolveProfilePicture, + ); + const roster = buildPublicClubRoster(config, resolvedRows); + + expect(resolveProfilePicture).toHaveBeenCalledWith({ + profilePictureReference, + userId: row.userId, + }); + expect(roster.members.hackathon?.[0]?.imageUrl).toBe(signedUrl); + expect(roster.members.hackathon?.[0]?.imageUrl).not.toBe( + profilePictureReference, + ); + }); + it("returns every configured team, in tab order, even when empty", () => { const roster = buildPublicClubRoster(config, []); diff --git a/packages/api/src/tests/guild/profile-picture.test.ts b/packages/api/src/tests/guild/profile-picture.test.ts new file mode 100644 index 000000000..0f71718c1 --- /dev/null +++ b/packages/api/src/tests/guild/profile-picture.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPublicProfilePictureUrl } from "../../utils/guild/profile-picture"; + +const mocks = vi.hoisted(() => ({ + presignedUrl: vi.fn(), +})); + +vi.mock("../../utils/profile-picture/storage", () => ({ + profilePictureStorageClient: { + presignedUrl: mocks.presignedUrl, + }, +})); + +const userId = "00000000-0000-4000-8000-000000000001"; +const objectName = `${userId}/profile-picture-4ac506f0-ab78-4722-88f6-2808763b65aa.jpg`; +const signedUrl = "https://storage.example.test/signed-profile-picture"; + +describe("Guild public profile pictures", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.presignedUrl.mockResolvedValue(signedUrl); + }); + + it.each([ + ["current object name", objectName], + [ + "legacy MinIO URL", + `https://legacy-minio.example.test/guild-profile-pictures/${objectName}`, + ], + ])("signs a user-owned %s", async (_label, profilePictureReference) => { + await expect( + getPublicProfilePictureUrl({ profilePictureReference, userId }), + ).resolves.toBe(signedUrl); + + expect(mocks.presignedUrl).toHaveBeenCalledWith( + "GET", + "guild-profile-pictures", + objectName, + 60 * 60, + ); + }); + + it.each([ + ["missing", null], + ["blank", " "], + ["malformed", "not-a-profile-picture"], + [ + "owned by another user", + "00000000-0000-4000-8000-000000000002/profile-picture-4ac506f0-ab78-4722-88f6-2808763b65aa.jpg", + ], + ])("returns null for a %s reference", async (_label, reference) => { + await expect( + getPublicProfilePictureUrl({ + profilePictureReference: reference, + userId, + }), + ).resolves.toBeNull(); + + expect(mocks.presignedUrl).not.toHaveBeenCalled(); + }); + + it("returns null without leaking a storage failure", async () => { + mocks.presignedUrl.mockRejectedValue(new Error("private storage detail")); + + await expect( + getPublicProfilePictureUrl({ + profilePictureReference: objectName, + userId, + }), + ).resolves.toBeNull(); + }); +}); diff --git a/packages/api/src/utils/guild/club-roster.ts b/packages/api/src/utils/guild/club-roster.ts index 5e4f30d41..5ebeb7a27 100644 --- a/packages/api/src/utils/guild/club-roster.ts +++ b/packages/api/src/utils/guild/club-roster.ts @@ -5,11 +5,13 @@ import { ClubTeamRole } from "@forge/db/schemas/club-team"; import { Member } from "@forge/db/schemas/knight-hacks"; import type { ClubTeamConfig } from "./club-team-config"; +import type { PublicProfilePictureReference } from "./profile-picture"; import { getClubRoleBuckets, holdsClubLeadershipRole, loadClubTeamConfig, } from "./club-team-config"; +import { getPublicProfilePictureUrl } from "./profile-picture"; // Backs the public Club site's team page, which reads it over HTTP tRPC because // apps/club builds with `output: "export"` and has no server runtime. This stays @@ -53,6 +55,17 @@ export interface RosterRoleRow { linkedinProfileUrl: string | null; } +export type RosterRoleReferenceRow = Omit< + RosterRoleRow, + "guildProfilePictureUrl" +> & { + profilePictureReference: string | null; +}; + +type PublicProfilePictureResolver = ( + reference: PublicProfilePictureReference, +) => Promise; + /** A placed member plus the ranking that placed them. Never leaves this module. */ interface RankedClubTeamMember { member: PublicClubTeamMember; @@ -181,6 +194,34 @@ export function buildPublicClubRoster( }; } +export async function resolveRosterRoleRows( + rows: readonly RosterRoleReferenceRow[], + resolveProfilePicture: PublicProfilePictureResolver = getPublicProfilePictureUrl, +): Promise { + const uniqueRowsByUserId = new Map( + rows.map((row) => [row.userId, row] as const), + ); + const profilePicturesByUserId = new Map( + await Promise.all( + [...uniqueRowsByUserId.values()].map( + async (row) => + [ + row.userId, + await resolveProfilePicture({ + profilePictureReference: row.profilePictureReference, + userId: row.userId, + }), + ] as const, + ), + ), + ); + + return rows.map(({ profilePictureReference: _reference, ...row }) => ({ + ...row, + guildProfilePictureUrl: profilePicturesByUserId.get(row.userId) ?? null, + })); +} + export async function getVisiblePublicClubRoster() { const config = await loadClubTeamConfig(); const rows = await db @@ -192,7 +233,7 @@ export async function getVisiblePublicClubRoster() { memberId: Member.id, firstName: Member.firstName, lastName: Member.lastName, - guildProfilePictureUrl: Member.profilePictureUrl, + profilePictureReference: Member.profilePictureUrl, linkedinProfileUrl: Member.linkedinProfileUrl, }) .from(ClubTeamRole) @@ -203,5 +244,5 @@ export async function getVisiblePublicClubRoster() { .where(eq(Member.guildProfileVisible, true)) .orderBy(Roles.name, Member.firstName, Member.lastName, User.name); - return buildPublicClubRoster(config, rows); + return buildPublicClubRoster(config, await resolveRosterRoleRows(rows)); } diff --git a/packages/api/src/utils/guild/profile-picture.ts b/packages/api/src/utils/guild/profile-picture.ts new file mode 100644 index 000000000..2691a2ec8 --- /dev/null +++ b/packages/api/src/utils/guild/profile-picture.ts @@ -0,0 +1,34 @@ +import { + PROFILE_PICTURE_BUCKET_NAME, + resolveProfilePictureObjectName, +} from "../profile-picture/security"; +import { profilePictureStorageClient } from "../profile-picture/storage"; + +export interface PublicProfilePictureReference { + profilePictureReference: string | null; + userId: string; +} + +export async function getPublicProfilePictureUrl({ + profilePictureReference, + userId, +}: PublicProfilePictureReference) { + if (!profilePictureReference) return null; + + const objectName = resolveProfilePictureObjectName( + profilePictureReference, + userId, + ); + if (!objectName) return null; + + try { + return await profilePictureStorageClient.presignedUrl( + "GET", + PROFILE_PICTURE_BUCKET_NAME, + objectName, + 60 * 60, + ); + } catch { + return null; + } +} From 6bfd83d8e80c16a65b8570920b91119a513dfdd7 Mon Sep 17 00:00:00 2001 From: Carlos Date: Thu, 20 Aug 2026 10:28:47 -0400 Subject: [PATCH 02/10] document khix profile image pr Co-authored-by: Codex --- .forge/features/khix-guild-profile-images/status.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.forge/features/khix-guild-profile-images/status.md b/.forge/features/khix-guild-profile-images/status.md index a2e378f84..5d2d7d5c1 100644 --- a/.forge/features/khix-guild-profile-images/status.md +++ b/.forge/features/khix-guild-profile-images/status.md @@ -26,7 +26,8 @@ Current phase: Review / PR - [x] Remove Lena from KHIX featured designers. - [x] Add and run regression tests. - [x] Complete repository validation. -- [ ] Open the draft PR and confirm CI is green. +- [x] Open the draft PR. +- [ ] Confirm CI is green. ## Validation / commands @@ -48,6 +49,6 @@ Current phase: Review / PR ## Links -- PRs: +- PRs: https://github.com/KnightHacks/forge/pull/513 - Issues: - Discord/thread context: From e598a33136cec53219a96b108888e680c7031774 Mon Sep 17 00:00:00 2001 From: Carlos Date: Thu, 20 Aug 2026 10:29:57 -0400 Subject: [PATCH 03/10] record khix profile image ci Co-authored-by: Codex --- .forge/features/khix-guild-profile-images/status.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.forge/features/khix-guild-profile-images/status.md b/.forge/features/khix-guild-profile-images/status.md index 5d2d7d5c1..cf01f88f9 100644 --- a/.forge/features/khix-guild-profile-images/status.md +++ b/.forge/features/khix-guild-profile-images/status.md @@ -27,7 +27,7 @@ Current phase: Review / PR - [x] Add and run regression tests. - [x] Complete repository validation. - [x] Open the draft PR. -- [ ] Confirm CI is green. +- [x] Confirm CI is green. ## Validation / commands @@ -46,6 +46,7 @@ Current phase: Review / PR - `pnpm lint` — passed with existing repository warnings and no errors. - `pnpm typecheck` — passed (29 tasks). - Local browser contract verification — passed: all 29 rendered portraits loaded, Estefanie was present, and Lena was absent. +- GitHub PR checks — passed: CodeQL Analyze, CodeQL, and CodeRabbit. ## Links From 1115d923e72e524b34054f18d7f506d82039b285 Mon Sep 17 00:00:00 2001 From: Carlos Date: Thu, 20 Aug 2026 10:39:43 -0400 Subject: [PATCH 04/10] remove khix feature artifacts Co-authored-by: Codex --- .../khix-guild-profile-images/spec.md | 52 ----------- .../features/khix-guild-profile-images/srd.md | 74 --------------- .../khix-guild-profile-images/status.md | 55 ----------- .../khix-guild-profile-images/test-cases.md | 93 ------------------- 4 files changed, 274 deletions(-) delete mode 100644 .forge/features/khix-guild-profile-images/spec.md delete mode 100644 .forge/features/khix-guild-profile-images/srd.md delete mode 100644 .forge/features/khix-guild-profile-images/status.md delete mode 100644 .forge/features/khix-guild-profile-images/test-cases.md diff --git a/.forge/features/khix-guild-profile-images/spec.md b/.forge/features/khix-guild-profile-images/spec.md deleted file mode 100644 index 9308ae718..000000000 --- a/.forge/features/khix-guild-profile-images/spec.md +++ /dev/null @@ -1,52 +0,0 @@ -# KnightHacks IX Guild Profile Images Spec - -Status: Approved - -## User-facing purpose - -Visitors to the KnightHacks IX website should see the current Guild profile -pictures for featured team members. Estefanie Parra's current Guild picture -must render instead of a broken image, and Lena Tran must no longer appear in -the IX featured team display. - -## Users / actors - -- Public visitors to `2026.knighthacks.org`. -- Knight Hacks organizers reviewing the IX team section. - -## User-visible interface - -The existing KnightHacks IX team cascade keeps its current layout, role labels, -selection behavior, and LinkedIn links. Available Guild portraits render in the -existing circular avatars; missing or unavailable portraits continue to fall -back to initials. - -## Scope - -### In scope - -- Repair current Guild profile pictures in the IX team cascade. -- Show Estefanie Parra's current Guild portrait. -- Remove Lena Tran from the IX featured designers. - -### Out of scope - -- Removing or modifying Lena Tran's Guild profile or club roles. -- Changing Guild visibility, profile-upload behavior, team membership, or IX styling. - -## Vocabulary - -- `Guild profile picture`: A public portrait stored by Guild and exposed through a temporary signed URL. -- `featured designer`: A design-team member explicitly selected for the IX team cascade. - -## Acceptance criteria - -- Estefanie Parra's portrait loads successfully on the IX team section. -- Other available Guild portraits continue to load. -- Lena Tran is absent from the IX team cascade. -- Missing or unavailable pictures display initials without breaking the roster. -- Names, roles, selection behavior, and LinkedIn links remain functional. - -## Open questions - -- None. The human confirmed IX-only removal and branch-plus-PR delivery on 2026-08-20. diff --git a/.forge/features/khix-guild-profile-images/srd.md b/.forge/features/khix-guild-profile-images/srd.md deleted file mode 100644 index 97d69596e..000000000 --- a/.forge/features/khix-guild-profile-images/srd.md +++ /dev/null @@ -1,74 +0,0 @@ -# KnightHacks IX Guild Profile Images SRD - -Status: Approved - -## Technical purpose - -Ensure Guild public roster responses resolve both legacy absolute profile-picture -URLs and current object-name references into signed public URLs before clients -render them. Remove Lena Tran's ID from the KHIX featured-designer filter. - -## Relevant principles - -- Keep `apps/khix` a thin client of the `@forge/api` Guild capability. -- Preserve existing public tRPC contracts and app/package boundaries. -- Reuse current profile-picture ownership validation and MinIO signing. - -## Access policy - -`guild.getPublicClubTeamRoster` remains an unauthenticated public query and -returns only opted-in Guild profiles. No authenticated, officer, or admin -behavior changes. - -## Architecture / data flow - -`@forge/api` owns a shared public profile-picture resolver. Guild profile reads -and the public club roster use it to validate the stored reference against the -member's user ID and request a one-hour signed MinIO URL. KHIX continues to read -the roster over the existing Blade tRPC endpoint and uses initials when -`imageUrl` is null. - -## tRPC/API behavior - -`guild.getPublicClubTeamRoster` keeps its current input and output shape. -`PublicClubTeamMember.imageUrl` remains `string | null`, but non-null values are -public signed HTTPS URLs rather than database storage references. Invalid, -unowned, or un-signable references become null without failing the roster. - -## Validation - -No validator changes. Existing profile-picture ownership resolution remains the -authority for accepted legacy URLs and current object names. - -## Data / migration / compatibility - -No schema, migration, data, dependency, or environment changes. Blade must -deploy the API change and KHIX must deploy the featured-designer change. The -unchanged API shape makes either deployment order backward-compatible. - -## Discord integration - -No Discord roles, sync behavior, or Guild records are changed. - -## Configurability review - -Would this require a developer change next year? - -- Answer: The public roster and pictures remain data-driven. The IX featured-designer shortlist is intentionally event-specific and already hard-coded in KHIX. -- If yes, why is hard-coding acceptable or what admin-configurable path is planned? Lena's removal changes only that existing IX-specific shortlist; no new configuration debt is introduced. - -## React / frontend constraints - -No component structure or styling changes. Preserve the existing client-side -roster loading, image component, initials fallback, and interaction behavior. - -## Testing / verification strategy - -- Unit-test public picture resolution for current object names, legacy URLs, invalid ownership, and signing failures. -- Unit-test roster preprocessing so raw references never reach `imageUrl`. -- Unit-test KHIX featured designer filtering with Lena present in the source roster. -- Run targeted API/KHIX tests, affected builds, React analysis, and repository format/lint/typecheck gates. - -## Open questions - -- None. diff --git a/.forge/features/khix-guild-profile-images/status.md b/.forge/features/khix-guild-profile-images/status.md deleted file mode 100644 index cf01f88f9..000000000 --- a/.forge/features/khix-guild-profile-images/status.md +++ /dev/null @@ -1,55 +0,0 @@ -# KnightHacks IX Guild Profile Images Status - -Current phase: Review / PR - -> This file is the maintained progress tracker for the feature/change. Keep it current whenever decisions, tasks, validation, or open questions change. - -## Decision log - -- 2026-08-20: Repair the shared Guild public roster contract instead of special-casing Estefanie in KHIX. -- 2026-08-20: Support both legacy absolute URLs and current object-name references through the existing ownership resolver. -- 2026-08-20: Remove Lena Tran from KHIX only; preserve her Guild profile and roles. -- 2026-08-20: Deliver on a task branch through one draft PR. - -## Open questions - -- None. - -## Task list - -- [x] Complete reverse-prompting for `spec.md`. -- [x] Complete reverse-prompting for `srd.md`. -- [x] Complete reverse-prompting for `test-cases.md`. -- [x] Human approved the plan before implementation/test generation. -- [x] Implement shared public profile-picture signing. -- [x] Resolve public roster picture references before returning them. -- [x] Remove Lena from KHIX featured designers. -- [x] Add and run regression tests. -- [x] Complete repository validation. -- [x] Open the draft PR. -- [x] Confirm CI is green. - -## Validation / commands - -- `pnpm exec vitest run packages/api/src/tests/guild/profile-picture.test.ts packages/api/src/tests/guild/club-roster.test.ts` — passed (21 tests). -- `pnpm --filter=@forge/khix exec vitest run src/app/_components/team-cascade/team-roster.test.ts` — passed (1 test). -- `pnpm --filter=@forge/api test` — passed (585 tests; 148 skipped). -- `pnpm --filter=@forge/khix test` — passed (15 tests). -- `pnpm --filter=@forge/api typecheck` — passed. -- `pnpm --filter=@forge/khix typecheck` — passed. -- `pnpm --filter=@forge/club typecheck` — passed. -- `pnpm --filter=@forge/guild typecheck` — passed. -- `pnpm --filter=@forge/blade build` — passed. -- `pnpm --filter=@forge/khix build` — passed. -- `pnpm analyze:react:changed` — passed (no changed TSX files to analyze). -- `pnpm format` — passed. -- `pnpm lint` — passed with existing repository warnings and no errors. -- `pnpm typecheck` — passed (29 tasks). -- Local browser contract verification — passed: all 29 rendered portraits loaded, Estefanie was present, and Lena was absent. -- GitHub PR checks — passed: CodeQL Analyze, CodeQL, and CodeRabbit. - -## Links - -- PRs: https://github.com/KnightHacks/forge/pull/513 -- Issues: -- Discord/thread context: diff --git a/.forge/features/khix-guild-profile-images/test-cases.md b/.forge/features/khix-guild-profile-images/test-cases.md deleted file mode 100644 index ca56c7893..000000000 --- a/.forge/features/khix-guild-profile-images/test-cases.md +++ /dev/null @@ -1,93 +0,0 @@ -# KnightHacks IX Guild Profile Images Test Cases - -Status: Approved - -## Scope - -Tests cover public Guild picture resolution, roster URL safety, and the KHIX -featured-designer filter. They do not change or test production data, Guild -membership, uploads, or styling. - -## Test placement plan - -- `packages/api`: Guild profile-picture and club-roster unit tests. -- `apps/khix`: team-roster filtering unit test. -- Repository validation and Blade/KHIX builds before commit. - -## Test cases - -### TC-001: Current Guild object name becomes a signed public URL - -Setup: - -- A visible member has a user-owned object-name profile-picture reference. - -Action: - -- The public picture resolver or club roster reads the member. - -Expected observations: - -- MinIO receives the validated object name and the response contains the signed URL, never the raw key. - -### TC-002: Legacy Guild URL remains compatible - -Setup: - -- A visible member has a legacy absolute MinIO profile-picture URL owned by that member. - -Action: - -- The public picture resolver reads the member. - -Expected observations: - -- The legacy URL is reduced to its owned object name and re-signed through the current Guild storage client. - -### TC-003: KHIX removes Lena only from featured designers - -Setup: - -- The source roster includes officers, directors, organizers, other featured designers, and Lena Tran's design member ID. - -Action: - -- KHIX maps the source roster into team cascade groups. - -Expected observations: - -- Lena is absent, the remaining featured designers are present, and all non-designer groups are unchanged. - -## Negative / regression cases - -### TC-NEG-001: Invalid or unowned reference fails closed - -Setup: - -- A profile-picture reference is malformed or belongs to another user. - -Action: - -- A public Guild profile or roster response is generated. - -Expected observations: - -- No storage signing occurs and the picture field is null, allowing clients to render initials. - -### TC-NEG-002: Storage signing failure preserves the roster - -Setup: - -- A valid owned profile-picture reference exists, but MinIO signing fails. - -Action: - -- A public Guild profile or roster response is generated. - -Expected observations: - -- The picture field is null and the public response remains available without leaking the raw reference. - -## Open questions - -- None. From de02c6d4392d1feac3eb969aea0dff4fe68061a3 Mon Sep 17 00:00:00 2001 From: Carlos Date: Thu, 20 Aug 2026 10:43:45 -0400 Subject: [PATCH 05/10] keep khix fix out of guild router Restore the Guild router to main and harden roster picture parsing against malformed legacy references. Co-authored-by: Codex --- packages/api/src/routers/guild.ts | 27 ++++++++++++++++++- .../src/tests/guild/profile-picture.test.ts | 4 +++ .../api/src/utils/guild/profile-picture.ts | 12 ++++----- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/packages/api/src/routers/guild.ts b/packages/api/src/routers/guild.ts index b36ed1a5d..dcfb1f063 100644 --- a/packages/api/src/routers/guild.ts +++ b/packages/api/src/routers/guild.ts @@ -23,13 +23,17 @@ import { getGlobeCity } from "../utils/career/globe-cities"; import { getUsCity } from "../utils/career/us-cities"; import { getVisiblePublicClubRoster } from "../utils/guild/club-roster"; import { loadClubTeamConfig } from "../utils/guild/club-team-config"; -import { getPublicProfilePictureUrl } from "../utils/guild/profile-picture"; import { normalizePublicGuildText, normalizePublicGuildUrl, } from "../utils/guild/public-profile"; import { getGuildRoleCallout } from "../utils/guild/role-callout"; import { graduatedCondition, hasGraduated } from "../utils/member/graduation"; +import { + PROFILE_PICTURE_BUCKET_NAME, + resolveProfilePictureObjectName, +} from "../utils/profile-picture/security"; +import { profilePictureStorageClient } from "../utils/profile-picture/storage"; import { normalizeOwnedResumeObjectName, RESUME_BUCKET_NAME, @@ -317,6 +321,27 @@ async function getRoleCalloutsByUserId( ); } +async function getPublicProfilePictureUrl(row: PublicMemberRow) { + if (!row.profilePictureReference) return null; + + const objectName = resolveProfilePictureObjectName( + row.profilePictureReference, + row.userId, + ); + if (!objectName) return null; + + try { + return await profilePictureStorageClient.presignedUrl( + "GET", + PROFILE_PICTURE_BUCKET_NAME, + objectName, + 60 * 60, + ); + } catch { + return null; + } +} + async function toPublicProfile( row: PublicMemberRow, roleCallout: ReturnType, diff --git a/packages/api/src/tests/guild/profile-picture.test.ts b/packages/api/src/tests/guild/profile-picture.test.ts index 0f71718c1..99bec81df 100644 --- a/packages/api/src/tests/guild/profile-picture.test.ts +++ b/packages/api/src/tests/guild/profile-picture.test.ts @@ -45,6 +45,10 @@ describe("Guild public profile pictures", () => { ["missing", null], ["blank", " "], ["malformed", "not-a-profile-picture"], + [ + "malformed legacy URL", + "https://legacy-minio.example.test/guild-profile-pictures/%E0%A4%A", + ], [ "owned by another user", "00000000-0000-4000-8000-000000000002/profile-picture-4ac506f0-ab78-4722-88f6-2808763b65aa.jpg", diff --git a/packages/api/src/utils/guild/profile-picture.ts b/packages/api/src/utils/guild/profile-picture.ts index 2691a2ec8..e6b938d7f 100644 --- a/packages/api/src/utils/guild/profile-picture.ts +++ b/packages/api/src/utils/guild/profile-picture.ts @@ -15,13 +15,13 @@ export async function getPublicProfilePictureUrl({ }: PublicProfilePictureReference) { if (!profilePictureReference) return null; - const objectName = resolveProfilePictureObjectName( - profilePictureReference, - userId, - ); - if (!objectName) return null; - try { + const objectName = resolveProfilePictureObjectName( + profilePictureReference, + userId, + ); + if (!objectName) return null; + return await profilePictureStorageClient.presignedUrl( "GET", PROFILE_PICTURE_BUCKET_NAME, From c7f07f0a087683965a784fefc3a111f652770f4f Mon Sep 17 00:00:00 2001 From: Carlos Date: Thu, 20 Aug 2026 10:45:46 -0400 Subject: [PATCH 06/10] explain stored profile url compatibility Co-authored-by: Codex --- packages/api/src/tests/guild/profile-picture.test.ts | 8 ++++---- packages/api/src/utils/guild/profile-picture.ts | 5 +++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/api/src/tests/guild/profile-picture.test.ts b/packages/api/src/tests/guild/profile-picture.test.ts index 99bec81df..db527fee7 100644 --- a/packages/api/src/tests/guild/profile-picture.test.ts +++ b/packages/api/src/tests/guild/profile-picture.test.ts @@ -25,8 +25,8 @@ describe("Guild public profile pictures", () => { it.each([ ["current object name", objectName], [ - "legacy MinIO URL", - `https://legacy-minio.example.test/guild-profile-pictures/${objectName}`, + "older absolute MinIO URL still stored in production", + `https://stored-minio.example.test/guild-profile-pictures/${objectName}`, ], ])("signs a user-owned %s", async (_label, profilePictureReference) => { await expect( @@ -46,8 +46,8 @@ describe("Guild public profile pictures", () => { ["blank", " "], ["malformed", "not-a-profile-picture"], [ - "malformed legacy URL", - "https://legacy-minio.example.test/guild-profile-pictures/%E0%A4%A", + "malformed older absolute URL", + "https://stored-minio.example.test/guild-profile-pictures/%E0%A4%A", ], [ "owned by another user", diff --git a/packages/api/src/utils/guild/profile-picture.ts b/packages/api/src/utils/guild/profile-picture.ts index e6b938d7f..ac4193b0a 100644 --- a/packages/api/src/utils/guild/profile-picture.ts +++ b/packages/api/src/utils/guild/profile-picture.ts @@ -16,6 +16,11 @@ export async function getPublicProfilePictureUrl({ if (!profilePictureReference) return null; try { + // Production profile-picture data is currently mixed: older rows store an + // absolute MinIO URL, while new Guild uploads store an object key. This + // normalizes either form to an owned object name and signs it through the + // current storage client. Remove URL compatibility only after those older + // rows have been migrated, or their existing portraits will disappear. const objectName = resolveProfilePictureObjectName( profilePictureReference, userId, From 845aa3a1a3739ee79087794e9638f055c9c64f63 Mon Sep 17 00:00:00 2001 From: Carlos Date: Thu, 20 Aug 2026 11:00:26 -0400 Subject: [PATCH 07/10] fix khix public profile image urls Co-authored-by: Codex --- .../src/tests/guild/profile-picture.test.ts | 63 +++++++------------ packages/api/src/utils/guild/club-roster.ts | 2 +- .../api/src/utils/guild/profile-picture.ts | 26 ++++---- packages/consts/src/minio.ts | 3 +- 4 files changed, 37 insertions(+), 57 deletions(-) diff --git a/packages/api/src/tests/guild/profile-picture.test.ts b/packages/api/src/tests/guild/profile-picture.test.ts index db527fee7..39fe0b788 100644 --- a/packages/api/src/tests/guild/profile-picture.test.ts +++ b/packages/api/src/tests/guild/profile-picture.test.ts @@ -1,43 +1,35 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { getPublicProfilePictureUrl } from "../../utils/guild/profile-picture"; -const mocks = vi.hoisted(() => ({ - presignedUrl: vi.fn(), -})); - -vi.mock("../../utils/profile-picture/storage", () => ({ - profilePictureStorageClient: { - presignedUrl: mocks.presignedUrl, - }, -})); - const userId = "00000000-0000-4000-8000-000000000001"; const objectName = `${userId}/profile-picture-4ac506f0-ab78-4722-88f6-2808763b65aa.jpg`; -const signedUrl = "https://storage.example.test/signed-profile-picture"; +const publicUrl = + `https://minio-y44gsgsskc4ko4kkwsg0csoc.135.237.97.107.sslip.io/` + + `guild-profile-pictures/${objectName}`; describe("Guild public profile pictures", () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.presignedUrl.mockResolvedValue(signedUrl); - }); - it.each([ ["current object name", objectName], [ - "older absolute MinIO URL still stored in production", + "older absolute MinIO URL still stored for current roster members", `https://stored-minio.example.test/guild-profile-pictures/${objectName}`, ], - ])("signs a user-owned %s", async (_label, profilePictureReference) => { - await expect( + ])("resolves a user-owned %s", (_label, profilePictureReference) => { + expect( getPublicProfilePictureUrl({ profilePictureReference, userId }), - ).resolves.toBe(signedUrl); + ).toBe(publicUrl); + }); - expect(mocks.presignedUrl).toHaveBeenCalledWith( - "GET", - "guild-profile-pictures", - objectName, - 60 * 60, + it("encodes object-name path segments", () => { + expect( + getPublicProfilePictureUrl({ + profilePictureReference: `${userId}/Carlos Catala #1.jpg`, + userId, + }), + ).toBe( + `https://minio-y44gsgsskc4ko4kkwsg0csoc.135.237.97.107.sslip.io/` + + `guild-profile-pictures/${userId}/Carlos%20Catala%20%231.jpg`, ); }); @@ -53,25 +45,12 @@ describe("Guild public profile pictures", () => { "owned by another user", "00000000-0000-4000-8000-000000000002/profile-picture-4ac506f0-ab78-4722-88f6-2808763b65aa.jpg", ], - ])("returns null for a %s reference", async (_label, reference) => { - await expect( + ])("returns null for a %s reference", (_label, reference) => { + expect( getPublicProfilePictureUrl({ profilePictureReference: reference, userId, }), - ).resolves.toBeNull(); - - expect(mocks.presignedUrl).not.toHaveBeenCalled(); - }); - - it("returns null without leaking a storage failure", async () => { - mocks.presignedUrl.mockRejectedValue(new Error("private storage detail")); - - await expect( - getPublicProfilePictureUrl({ - profilePictureReference: objectName, - userId, - }), - ).resolves.toBeNull(); + ).toBeNull(); }); }); diff --git a/packages/api/src/utils/guild/club-roster.ts b/packages/api/src/utils/guild/club-roster.ts index 5ebeb7a27..576b25b22 100644 --- a/packages/api/src/utils/guild/club-roster.ts +++ b/packages/api/src/utils/guild/club-roster.ts @@ -64,7 +64,7 @@ export type RosterRoleReferenceRow = Omit< type PublicProfilePictureResolver = ( reference: PublicProfilePictureReference, -) => Promise; +) => Promise | string | null; /** A placed member plus the ranking that placed them. Never leaves this module. */ interface RankedClubTeamMember { diff --git a/packages/api/src/utils/guild/profile-picture.ts b/packages/api/src/utils/guild/profile-picture.ts index ac4193b0a..1f48e04ab 100644 --- a/packages/api/src/utils/guild/profile-picture.ts +++ b/packages/api/src/utils/guild/profile-picture.ts @@ -1,38 +1,38 @@ +import { MINIO } from "@forge/consts"; + import { PROFILE_PICTURE_BUCKET_NAME, resolveProfilePictureObjectName, } from "../profile-picture/security"; -import { profilePictureStorageClient } from "../profile-picture/storage"; export interface PublicProfilePictureReference { profilePictureReference: string | null; userId: string; } -export async function getPublicProfilePictureUrl({ +export function getPublicProfilePictureUrl({ profilePictureReference, userId, }: PublicProfilePictureReference) { if (!profilePictureReference) return null; try { - // Production profile-picture data is currently mixed: older rows store an - // absolute MinIO URL, while new Guild uploads store an object key. This - // normalizes either form to an owned object name and signs it through the - // current storage client. Remove URL compatibility only after those older - // rows have been migrated, or their existing portraits will disappear. + // Production still has roster members whose pictures were saved as full + // MinIO URLs before Guild began saving object keys. Keep accepting both + // forms until those rows are migrated, but always validate ownership and + // return the same canonical public-bucket URL. const objectName = resolveProfilePictureObjectName( profilePictureReference, userId, ); if (!objectName) return null; - return await profilePictureStorageClient.presignedUrl( - "GET", - PROFILE_PICTURE_BUCKET_NAME, - objectName, - 60 * 60, - ); + const encodedObjectName = objectName + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); + + return `https://${MINIO.ENDPOINT}/${PROFILE_PICTURE_BUCKET_NAME}/${encodedObjectName}`; } catch { return null; } diff --git a/packages/consts/src/minio.ts b/packages/consts/src/minio.ts index 4a8d3cea6..8b5e21858 100644 --- a/packages/consts/src/minio.ts +++ b/packages/consts/src/minio.ts @@ -1,4 +1,5 @@ -export const ENDPOINT = "minio-g0soogg4gs8gwcggw4ococok.knighthacks.org"; +export const ENDPOINT = + "minio-y44gsgsskc4ko4kkwsg0csoc.135.237.97.107.sslip.io"; export const BUCKET_REGION = "us-east-1"; From 2ea503feae6d9b4e81ace2b16faf7b2a44fe7c83 Mon Sep 17 00:00:00 2001 From: Carlos Date: Thu, 20 Aug 2026 11:17:05 -0400 Subject: [PATCH 08/10] support profile picture key migration Co-authored-by: Codex --- apps/cron/src/crons/animals.ts | 22 +++-- .../team-cascade/team-roster.test.ts | 85 ------------------- apps/tk/src/commands/goat.ts | 9 +- packages/api/package.json | 4 + .../api/src/tests/guild/club-roster.test.ts | 43 +++------- .../src/tests/guild/profile-picture.test.ts | 56 ------------ .../tests/profile-picture/public-url.test.ts | 37 ++++++++ packages/api/src/utils/guild/club-roster.ts | 51 ++--------- .../public-url.ts} | 6 +- .../api/src/utils/profile-picture/security.ts | 3 + 10 files changed, 87 insertions(+), 229 deletions(-) delete mode 100644 apps/khix/src/app/_components/team-cascade/team-roster.test.ts delete mode 100644 packages/api/src/tests/guild/profile-picture.test.ts create mode 100644 packages/api/src/tests/profile-picture/public-url.test.ts rename packages/api/src/utils/{guild/profile-picture.ts => profile-picture/public-url.ts} (71%) diff --git a/apps/cron/src/crons/animals.ts b/apps/cron/src/crons/animals.ts index e70f951ce..c6165d60d 100644 --- a/apps/cron/src/crons/animals.ts +++ b/apps/cron/src/crons/animals.ts @@ -4,6 +4,7 @@ import { eq } from "drizzle-orm"; import natural from "natural"; import sharp from "sharp"; +import { getPublicProfilePictureUrl } from "@forge/api/profile-picture"; import { db } from "@forge/db/client"; import { Permissions } from "@forge/db/schemas/auth"; import { Member } from "@forge/db/schemas/knight-hacks"; @@ -81,19 +82,26 @@ export const goat = new CronBuilder({ githubProfileUrl: Member.githubProfileUrl, websiteUrl: Member.websiteUrl, linkedinProfileUrl: Member.linkedinProfileUrl, - profilePictureUrl: Member.profilePictureUrl, + profilePictureReference: Member.profilePictureUrl, guildProfileVisible: Member.guildProfileVisible, + userId: Member.userId, }) .from(Member) .innerJoin(Permissions, eq(Permissions.userId, Member.userId)) .where(eq(Member.guildProfileVisible, true)); - const goatsShuffled = allGoats.sort(() => Math.random() - 0.5); - const goat = goatsShuffled.find((member) => { - return member.profilePictureUrl?.trim(); - }); - - if (!goat?.profilePictureUrl?.trim()) + const goatsShuffled = allGoats + .map(({ profilePictureReference, userId, ...member }) => ({ + ...member, + profilePictureUrl: getPublicProfilePictureUrl({ + profilePictureReference, + userId, + }), + })) + .sort(() => Math.random() - 0.5); + const goat = goatsShuffled.find((member) => member.profilePictureUrl); + + if (!goat?.profilePictureUrl) throw new Error("No valid goat profile found"); const url = [ diff --git a/apps/khix/src/app/_components/team-cascade/team-roster.test.ts b/apps/khix/src/app/_components/team-cascade/team-roster.test.ts deleted file mode 100644 index 2842bb39c..000000000 --- a/apps/khix/src/app/_components/team-cascade/team-roster.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { loadTeamCascadeGroups } from "./team-roster"; - -const mocks = vi.hoisted(() => ({ - query: vi.fn(), -})); - -vi.mock("./blade-trpc", () => ({ - getBladeTrpcClient: () => ({ - guild: { - getPublicClubTeamRoster: { - query: mocks.query, - }, - }, - }), -})); - -function member(id: string, name: string) { - return { - color: null, - id, - imageUrl: null, - linkedinUrl: null, - name, - teamRole: "Team Member", - }; -} - -describe("KnightHacks IX team roster", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("removes Lena only from the featured designers", async () => { - const officer = member("executive-officer", "Officer"); - const director = member("directors-director", "Director"); - const organizer = member("hackathon-organizer", "Organizer"); - const firstFeaturedDesigner = member( - "design-f06cbff5-b5f8-49d5-8a3c-5b40a59dfcc6", - "Featured Designer One", - ); - const lena = member( - "design-3a0d6777-2276-4ae8-9281-ace2a26d6c94", - "Lena Tran", - ); - const secondFeaturedDesigner = member( - "design-f56f4444-7962-4090-b937-f31674a6ac7e", - "Featured Designer Two", - ); - const unfeaturedDesigner = member("design-other", "Other Designer"); - const signal = new AbortController().signal; - - mocks.query.mockResolvedValue({ - members: { - design: [ - firstFeaturedDesigner, - lena, - secondFeaturedDesigner, - unfeaturedDesigner, - ], - directors: [director], - executive: [officer], - hackathon: [organizer], - }, - teams: [], - }); - - const groups = await loadTeamCascadeGroups( - "https://blade.example.test", - signal, - ); - - expect(groups).toEqual([ - { members: [officer], roleLabel: "Officer" }, - { members: [director], roleLabel: "Director" }, - { members: [organizer], roleLabel: "Organizer" }, - { - members: [firstFeaturedDesigner, secondFeaturedDesigner], - roleLabel: "Designer", - }, - ]); - expect(mocks.query).toHaveBeenCalledWith(undefined, { signal }); - }); -}); diff --git a/apps/tk/src/commands/goat.ts b/apps/tk/src/commands/goat.ts index b7ba6c53c..b9b38d227 100644 --- a/apps/tk/src/commands/goat.ts +++ b/apps/tk/src/commands/goat.ts @@ -3,6 +3,7 @@ import { EmbedBuilder, SlashCommandBuilder } from "discord.js"; import natural from "natural"; import sharp from "sharp"; +import { getPublicProfilePictureUrl } from "@forge/api/profile-picture"; import { db } from "@forge/db/client"; import { logger } from "@forge/utils"; @@ -167,7 +168,13 @@ export const getGoatEmbed = async () => { if (!member) continue; const { guildProfileVisible, ...rest } = member; - if (guildProfileVisible) goat = rest; + const profilePictureUrl = getPublicProfilePictureUrl({ + profilePictureReference: member.profilePictureUrl, + userId: goat_id, + }); + if (guildProfileVisible && profilePictureUrl) { + goat = { ...rest, profilePictureUrl }; + } } logger.log(goat); diff --git a/packages/api/package.json b/packages/api/package.json index 9439debb6..b62d4e57a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -13,6 +13,10 @@ "default": "./src/env.ts" }, "./minio/minio-client": "./src/minio/minio-client.ts", + "./profile-picture": { + "types": "./src/utils/profile-picture/public-url.ts", + "default": "./src/utils/profile-picture/public-url.ts" + }, "./discord-archive.server": { "types": "./src/discord-archive.server.ts", "default": "./src/discord-archive.server.ts" diff --git a/packages/api/src/tests/guild/club-roster.test.ts b/packages/api/src/tests/guild/club-roster.test.ts index 7f115955c..b3f4ec435 100644 --- a/packages/api/src/tests/guild/club-roster.test.ts +++ b/packages/api/src/tests/guild/club-roster.test.ts @@ -1,13 +1,7 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; -import type { - RosterRoleReferenceRow, - RosterRoleRow, -} from "../../utils/guild/club-roster"; -import { - buildPublicClubRoster, - resolveRosterRoleRows, -} from "../../utils/guild/club-roster"; +import type { RosterRoleRow } from "../../utils/guild/club-roster"; +import { buildPublicClubRoster } from "../../utils/guild/club-roster"; import { clubRoleId, createClubTeamConfigFixture, @@ -29,7 +23,7 @@ function rowsFor(name: string, ...roleNames: string[]): RosterRoleRow[] { memberId, firstName: firstName ?? name, lastName, - guildProfilePictureUrl: null, + profilePictureReference: null, linkedinProfileUrl: null, })); } @@ -44,31 +38,18 @@ function labelsIn( } describe("public club roster bucketing", () => { - it("resolves stored picture references before exposing roster image URLs", async () => { - const [resolvedRow] = rowsFor("Estefanie Parra", "KH IX Team"); - if (!resolvedRow) throw new Error("Expected a roster fixture row"); + it("returns a canonical public URL for a stored picture object key", () => { + const [row] = rowsFor("Estefanie Parra", "KH IX Team"); + if (!row) throw new Error("Expected a roster fixture row"); - const { guildProfilePictureUrl: _picture, ...row } = resolvedRow; const profilePictureReference = `${row.userId}/profile-picture-4ac506f0-ab78-4722-88f6-2808763b65aa.jpg`; - const referenceRows: RosterRoleReferenceRow[] = [ + const roster = buildPublicClubRoster(config, [ { ...row, profilePictureReference }, - ]; - const signedUrl = "https://storage.example.test/signed-estefanie"; - const resolveProfilePicture = vi.fn().mockResolvedValue(signedUrl); - - const resolvedRows = await resolveRosterRoleRows( - referenceRows, - resolveProfilePicture, - ); - const roster = buildPublicClubRoster(config, resolvedRows); + ]); - expect(resolveProfilePicture).toHaveBeenCalledWith({ - profilePictureReference, - userId: row.userId, - }); - expect(roster.members.hackathon?.[0]?.imageUrl).toBe(signedUrl); - expect(roster.members.hackathon?.[0]?.imageUrl).not.toBe( - profilePictureReference, + expect(roster.members.hackathon?.[0]?.imageUrl).toBe( + `https://minio-y44gsgsskc4ko4kkwsg0csoc.135.237.97.107.sslip.io/` + + `guild-profile-pictures/${profilePictureReference}`, ); }); diff --git a/packages/api/src/tests/guild/profile-picture.test.ts b/packages/api/src/tests/guild/profile-picture.test.ts deleted file mode 100644 index 39fe0b788..000000000 --- a/packages/api/src/tests/guild/profile-picture.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { getPublicProfilePictureUrl } from "../../utils/guild/profile-picture"; - -const userId = "00000000-0000-4000-8000-000000000001"; -const objectName = `${userId}/profile-picture-4ac506f0-ab78-4722-88f6-2808763b65aa.jpg`; -const publicUrl = - `https://minio-y44gsgsskc4ko4kkwsg0csoc.135.237.97.107.sslip.io/` + - `guild-profile-pictures/${objectName}`; - -describe("Guild public profile pictures", () => { - it.each([ - ["current object name", objectName], - [ - "older absolute MinIO URL still stored for current roster members", - `https://stored-minio.example.test/guild-profile-pictures/${objectName}`, - ], - ])("resolves a user-owned %s", (_label, profilePictureReference) => { - expect( - getPublicProfilePictureUrl({ profilePictureReference, userId }), - ).toBe(publicUrl); - }); - - it("encodes object-name path segments", () => { - expect( - getPublicProfilePictureUrl({ - profilePictureReference: `${userId}/Carlos Catala #1.jpg`, - userId, - }), - ).toBe( - `https://minio-y44gsgsskc4ko4kkwsg0csoc.135.237.97.107.sslip.io/` + - `guild-profile-pictures/${userId}/Carlos%20Catala%20%231.jpg`, - ); - }); - - it.each([ - ["missing", null], - ["blank", " "], - ["malformed", "not-a-profile-picture"], - [ - "malformed older absolute URL", - "https://stored-minio.example.test/guild-profile-pictures/%E0%A4%A", - ], - [ - "owned by another user", - "00000000-0000-4000-8000-000000000002/profile-picture-4ac506f0-ab78-4722-88f6-2808763b65aa.jpg", - ], - ])("returns null for a %s reference", (_label, reference) => { - expect( - getPublicProfilePictureUrl({ - profilePictureReference: reference, - userId, - }), - ).toBeNull(); - }); -}); diff --git a/packages/api/src/tests/profile-picture/public-url.test.ts b/packages/api/src/tests/profile-picture/public-url.test.ts new file mode 100644 index 000000000..c54814f14 --- /dev/null +++ b/packages/api/src/tests/profile-picture/public-url.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { getPublicProfilePictureUrl } from "../../utils/profile-picture/public-url"; + +const userId = "00000000-0000-4000-8000-000000000001"; +const objectName = `${userId}/profile-picture-4ac506f0-ab78-4722-88f6-2808763b65aa.jpg`; +const publicUrl = + `https://minio-y44gsgsskc4ko4kkwsg0csoc.135.237.97.107.sslip.io/` + + `guild-profile-pictures/${objectName}`; + +describe("public profile-picture URLs", () => { + it("returns the canonical public URL for a user-owned object key", () => { + expect( + getPublicProfilePictureUrl({ + profilePictureReference: objectName, + userId, + }), + ).toBe(publicUrl); + }); + + it.each([ + ["missing", null], + ["blank", " "], + ["malformed", "not-a-profile-picture"], + [ + "owned by another user", + "00000000-0000-4000-8000-000000000002/profile-picture-4ac506f0-ab78-4722-88f6-2808763b65aa.jpg", + ], + ])("returns null for a reference that is %s", (_label, reference) => { + expect( + getPublicProfilePictureUrl({ + profilePictureReference: reference, + userId, + }), + ).toBeNull(); + }); +}); diff --git a/packages/api/src/utils/guild/club-roster.ts b/packages/api/src/utils/guild/club-roster.ts index 576b25b22..2d81923c9 100644 --- a/packages/api/src/utils/guild/club-roster.ts +++ b/packages/api/src/utils/guild/club-roster.ts @@ -5,13 +5,12 @@ import { ClubTeamRole } from "@forge/db/schemas/club-team"; import { Member } from "@forge/db/schemas/knight-hacks"; import type { ClubTeamConfig } from "./club-team-config"; -import type { PublicProfilePictureReference } from "./profile-picture"; +import { getPublicProfilePictureUrl } from "../profile-picture/public-url"; import { getClubRoleBuckets, holdsClubLeadershipRole, loadClubTeamConfig, } from "./club-team-config"; -import { getPublicProfilePictureUrl } from "./profile-picture"; // Backs the public Club site's team page, which reads it over HTTP tRPC because // apps/club builds with `output: "export"` and has no server runtime. This stays @@ -51,21 +50,10 @@ export interface RosterRoleRow { memberId: string; firstName: string | null; lastName: string | null; - guildProfilePictureUrl: string | null; + profilePictureReference: string | null; linkedinProfileUrl: string | null; } -export type RosterRoleReferenceRow = Omit< - RosterRoleRow, - "guildProfilePictureUrl" -> & { - profilePictureReference: string | null; -}; - -type PublicProfilePictureResolver = ( - reference: PublicProfilePictureReference, -) => Promise | string | null; - /** A placed member plus the ranking that placed them. Never leaves this module. */ interface RankedClubTeamMember { member: PublicClubTeamMember; @@ -163,7 +151,10 @@ export function buildPublicClubRoster( displayName: row.displayName, }), teamRole, - imageUrl: toNonEmptyString(row.guildProfilePictureUrl), + imageUrl: getPublicProfilePictureUrl({ + profilePictureReference: row.profilePictureReference, + userId: row.userId, + }), linkedinUrl: toNonEmptyString(row.linkedinProfileUrl), color: row.roleColor, }, @@ -194,34 +185,6 @@ export function buildPublicClubRoster( }; } -export async function resolveRosterRoleRows( - rows: readonly RosterRoleReferenceRow[], - resolveProfilePicture: PublicProfilePictureResolver = getPublicProfilePictureUrl, -): Promise { - const uniqueRowsByUserId = new Map( - rows.map((row) => [row.userId, row] as const), - ); - const profilePicturesByUserId = new Map( - await Promise.all( - [...uniqueRowsByUserId.values()].map( - async (row) => - [ - row.userId, - await resolveProfilePicture({ - profilePictureReference: row.profilePictureReference, - userId: row.userId, - }), - ] as const, - ), - ), - ); - - return rows.map(({ profilePictureReference: _reference, ...row }) => ({ - ...row, - guildProfilePictureUrl: profilePicturesByUserId.get(row.userId) ?? null, - })); -} - export async function getVisiblePublicClubRoster() { const config = await loadClubTeamConfig(); const rows = await db @@ -244,5 +207,5 @@ export async function getVisiblePublicClubRoster() { .where(eq(Member.guildProfileVisible, true)) .orderBy(Roles.name, Member.firstName, Member.lastName, User.name); - return buildPublicClubRoster(config, await resolveRosterRoleRows(rows)); + return buildPublicClubRoster(config, rows); } diff --git a/packages/api/src/utils/guild/profile-picture.ts b/packages/api/src/utils/profile-picture/public-url.ts similarity index 71% rename from packages/api/src/utils/guild/profile-picture.ts rename to packages/api/src/utils/profile-picture/public-url.ts index 1f48e04ab..0ca371892 100644 --- a/packages/api/src/utils/guild/profile-picture.ts +++ b/packages/api/src/utils/profile-picture/public-url.ts @@ -3,7 +3,7 @@ import { MINIO } from "@forge/consts"; import { PROFILE_PICTURE_BUCKET_NAME, resolveProfilePictureObjectName, -} from "../profile-picture/security"; +} from "./security"; export interface PublicProfilePictureReference { profilePictureReference: string | null; @@ -17,10 +17,6 @@ export function getPublicProfilePictureUrl({ if (!profilePictureReference) return null; try { - // Production still has roster members whose pictures were saved as full - // MinIO URLs before Guild began saving object keys. Keep accepting both - // forms until those rows are migrated, but always validate ownership and - // return the same canonical public-bucket URL. const objectName = resolveProfilePictureObjectName( profilePictureReference, userId, diff --git a/packages/api/src/utils/profile-picture/security.ts b/packages/api/src/utils/profile-picture/security.ts index 0837d3200..4029cd938 100644 --- a/packages/api/src/utils/profile-picture/security.ts +++ b/packages/api/src/utils/profile-picture/security.ts @@ -47,6 +47,9 @@ export function isProfilePictureObjectOwnedByUser( return true; } +// Older database rows store a full bucket URL; current uploads store an object +// key. Every direct reader must accept both during the staged rollout, so remove +// this parser only after the data migration and its rollback window are over. export function getProfilePictureObjectNameFromLegacyUrl( profilePictureUrl: string, ) { From bf0899ff7cfb2daa3ce566ca50756d27c7042667 Mon Sep 17 00:00:00 2001 From: Carlos Date: Thu, 20 Aug 2026 11:25:21 -0400 Subject: [PATCH 09/10] scope profile image fix to khix Co-authored-by: Codex --- apps/cron/src/crons/animals.ts | 22 ++++------ .../team-cascade/profile-picture-url.test.ts | 32 +++++++++++++++ .../team-cascade/profile-picture-url.ts | 41 +++++++++++++++++++ .../_components/team-cascade/team-roster.ts | 17 +++++--- apps/tk/src/commands/goat.ts | 9 +--- packages/api/package.json | 4 -- .../api/src/tests/guild/club-roster.test.ts | 17 +------- .../tests/profile-picture/public-url.test.ts | 37 ----------------- packages/api/src/utils/guild/club-roster.ts | 10 ++--- .../src/utils/profile-picture/public-url.ts | 35 ---------------- .../api/src/utils/profile-picture/security.ts | 3 -- packages/consts/src/minio.ts | 3 +- 12 files changed, 97 insertions(+), 133 deletions(-) create mode 100644 apps/khix/src/app/_components/team-cascade/profile-picture-url.test.ts create mode 100644 apps/khix/src/app/_components/team-cascade/profile-picture-url.ts delete mode 100644 packages/api/src/tests/profile-picture/public-url.test.ts delete mode 100644 packages/api/src/utils/profile-picture/public-url.ts diff --git a/apps/cron/src/crons/animals.ts b/apps/cron/src/crons/animals.ts index c6165d60d..e70f951ce 100644 --- a/apps/cron/src/crons/animals.ts +++ b/apps/cron/src/crons/animals.ts @@ -4,7 +4,6 @@ import { eq } from "drizzle-orm"; import natural from "natural"; import sharp from "sharp"; -import { getPublicProfilePictureUrl } from "@forge/api/profile-picture"; import { db } from "@forge/db/client"; import { Permissions } from "@forge/db/schemas/auth"; import { Member } from "@forge/db/schemas/knight-hacks"; @@ -82,26 +81,19 @@ export const goat = new CronBuilder({ githubProfileUrl: Member.githubProfileUrl, websiteUrl: Member.websiteUrl, linkedinProfileUrl: Member.linkedinProfileUrl, - profilePictureReference: Member.profilePictureUrl, + profilePictureUrl: Member.profilePictureUrl, guildProfileVisible: Member.guildProfileVisible, - userId: Member.userId, }) .from(Member) .innerJoin(Permissions, eq(Permissions.userId, Member.userId)) .where(eq(Member.guildProfileVisible, true)); - const goatsShuffled = allGoats - .map(({ profilePictureReference, userId, ...member }) => ({ - ...member, - profilePictureUrl: getPublicProfilePictureUrl({ - profilePictureReference, - userId, - }), - })) - .sort(() => Math.random() - 0.5); - const goat = goatsShuffled.find((member) => member.profilePictureUrl); - - if (!goat?.profilePictureUrl) + const goatsShuffled = allGoats.sort(() => Math.random() - 0.5); + const goat = goatsShuffled.find((member) => { + return member.profilePictureUrl?.trim(); + }); + + if (!goat?.profilePictureUrl?.trim()) throw new Error("No valid goat profile found"); const url = [ diff --git a/apps/khix/src/app/_components/team-cascade/profile-picture-url.test.ts b/apps/khix/src/app/_components/team-cascade/profile-picture-url.test.ts new file mode 100644 index 000000000..a0511b52c --- /dev/null +++ b/apps/khix/src/app/_components/team-cascade/profile-picture-url.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { getKhixProfilePictureUrl } from "./profile-picture-url"; + +const objectName = + "2c48b2aa-cccd-4fb7-9a06-a5a369d32545/profile-picture-4ac506f0-ab78-4722-88f6-2808763b65aa.jpg"; +const publicUrl = + `https://minio-y44gsgsskc4ko4kkwsg0csoc.135.237.97.107.sslip.io/` + + `guild-profile-pictures/${objectName}`; + +describe("KHIX profile-picture URLs", () => { + it("turns a current Guild object key into a public URL", () => { + expect(getKhixProfilePictureUrl(objectName)).toBe(publicUrl); + }); + + it("moves an older absolute bucket URL onto the current public host", () => { + expect( + getKhixProfilePictureUrl( + `https://older-minio.example.test/guild-profile-pictures/${objectName}`, + ), + ).toBe(publicUrl); + }); + + it.each([ + ["missing", null], + ["blank", " "], + ["not an object key", "avatar.jpg"], + ["not HTTPS", `http://example.test/guild-profile-pictures/${objectName}`], + ])("rejects a reference that is %s", (_label, reference) => { + expect(getKhixProfilePictureUrl(reference)).toBeNull(); + }); +}); diff --git a/apps/khix/src/app/_components/team-cascade/profile-picture-url.ts b/apps/khix/src/app/_components/team-cascade/profile-picture-url.ts new file mode 100644 index 000000000..2c47ebacf --- /dev/null +++ b/apps/khix/src/app/_components/team-cascade/profile-picture-url.ts @@ -0,0 +1,41 @@ +const PROFILE_PICTURE_ORIGIN = + "https://minio-y44gsgsskc4ko4kkwsg0csoc.135.237.97.107.sslip.io"; +const PROFILE_PICTURE_BUCKET_PATH = "/guild-profile-pictures/"; +const USER_OWNED_OBJECT_NAME = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[^/\\\0]+$/i; + +function getProfilePictureObjectName(reference: string) { + const trimmedReference = reference.trim(); + if (trimmedReference === "") return null; + + let objectName = trimmedReference; + + try { + const url = new URL(trimmedReference); + if (url.protocol !== "https:") return null; + if (!url.pathname.startsWith(PROFILE_PICTURE_BUCKET_PATH)) return null; + + objectName = decodeURIComponent( + url.pathname.slice(PROFILE_PICTURE_BUCKET_PATH.length), + ); + } catch { + // Current Guild uploads store the object key instead of a full URL. + } + + if (objectName.length > 255 || objectName.includes("..")) return null; + return USER_OWNED_OBJECT_NAME.test(objectName) ? objectName : null; +} + +export function getKhixProfilePictureUrl(reference: string | null) { + if (!reference) return null; + + const objectName = getProfilePictureObjectName(reference); + if (!objectName) return null; + + const encodedObjectName = objectName + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); + + return `${PROFILE_PICTURE_ORIGIN}${PROFILE_PICTURE_BUCKET_PATH}${encodedObjectName}`; +} diff --git a/apps/khix/src/app/_components/team-cascade/team-roster.ts b/apps/khix/src/app/_components/team-cascade/team-roster.ts index 32c8ec976..18f24fd79 100644 --- a/apps/khix/src/app/_components/team-cascade/team-roster.ts +++ b/apps/khix/src/app/_components/team-cascade/team-roster.ts @@ -3,6 +3,7 @@ import type { inferRouterOutputs } from "@trpc/server"; import type { AppRouter } from "@forge/api"; import { getBladeTrpcClient } from "./blade-trpc"; +import { getKhixProfilePictureUrl } from "./profile-picture-url"; type PublicClubTeamRoster = inferRouterOutputs["guild"]["getPublicClubTeamRoster"]; @@ -48,15 +49,19 @@ export async function loadTeamCascadeGroups( return teamCascadeRosterGroups.map((group) => { const members = roster.members[group.rosterKey] ?? []; + const visibleMembers = + "memberIds" in group + ? members.filter((member) => + group.memberIds.some((memberId) => memberId === member.id), + ) + : members; return { roleLabel: group.roleLabel, - members: - "memberIds" in group - ? members.filter((member) => - group.memberIds.some((memberId) => memberId === member.id), - ) - : members, + members: visibleMembers.map((member) => ({ + ...member, + imageUrl: getKhixProfilePictureUrl(member.imageUrl), + })), }; }); } diff --git a/apps/tk/src/commands/goat.ts b/apps/tk/src/commands/goat.ts index b9b38d227..b7ba6c53c 100644 --- a/apps/tk/src/commands/goat.ts +++ b/apps/tk/src/commands/goat.ts @@ -3,7 +3,6 @@ import { EmbedBuilder, SlashCommandBuilder } from "discord.js"; import natural from "natural"; import sharp from "sharp"; -import { getPublicProfilePictureUrl } from "@forge/api/profile-picture"; import { db } from "@forge/db/client"; import { logger } from "@forge/utils"; @@ -168,13 +167,7 @@ export const getGoatEmbed = async () => { if (!member) continue; const { guildProfileVisible, ...rest } = member; - const profilePictureUrl = getPublicProfilePictureUrl({ - profilePictureReference: member.profilePictureUrl, - userId: goat_id, - }); - if (guildProfileVisible && profilePictureUrl) { - goat = { ...rest, profilePictureUrl }; - } + if (guildProfileVisible) goat = rest; } logger.log(goat); diff --git a/packages/api/package.json b/packages/api/package.json index b62d4e57a..9439debb6 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -13,10 +13,6 @@ "default": "./src/env.ts" }, "./minio/minio-client": "./src/minio/minio-client.ts", - "./profile-picture": { - "types": "./src/utils/profile-picture/public-url.ts", - "default": "./src/utils/profile-picture/public-url.ts" - }, "./discord-archive.server": { "types": "./src/discord-archive.server.ts", "default": "./src/discord-archive.server.ts" diff --git a/packages/api/src/tests/guild/club-roster.test.ts b/packages/api/src/tests/guild/club-roster.test.ts index b3f4ec435..6e95c8458 100644 --- a/packages/api/src/tests/guild/club-roster.test.ts +++ b/packages/api/src/tests/guild/club-roster.test.ts @@ -23,7 +23,7 @@ function rowsFor(name: string, ...roleNames: string[]): RosterRoleRow[] { memberId, firstName: firstName ?? name, lastName, - profilePictureReference: null, + guildProfilePictureUrl: null, linkedinProfileUrl: null, })); } @@ -38,21 +38,6 @@ function labelsIn( } describe("public club roster bucketing", () => { - it("returns a canonical public URL for a stored picture object key", () => { - const [row] = rowsFor("Estefanie Parra", "KH IX Team"); - if (!row) throw new Error("Expected a roster fixture row"); - - const profilePictureReference = `${row.userId}/profile-picture-4ac506f0-ab78-4722-88f6-2808763b65aa.jpg`; - const roster = buildPublicClubRoster(config, [ - { ...row, profilePictureReference }, - ]); - - expect(roster.members.hackathon?.[0]?.imageUrl).toBe( - `https://minio-y44gsgsskc4ko4kkwsg0csoc.135.237.97.107.sslip.io/` + - `guild-profile-pictures/${profilePictureReference}`, - ); - }); - it("returns every configured team, in tab order, even when empty", () => { const roster = buildPublicClubRoster(config, []); diff --git a/packages/api/src/tests/profile-picture/public-url.test.ts b/packages/api/src/tests/profile-picture/public-url.test.ts deleted file mode 100644 index c54814f14..000000000 --- a/packages/api/src/tests/profile-picture/public-url.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { getPublicProfilePictureUrl } from "../../utils/profile-picture/public-url"; - -const userId = "00000000-0000-4000-8000-000000000001"; -const objectName = `${userId}/profile-picture-4ac506f0-ab78-4722-88f6-2808763b65aa.jpg`; -const publicUrl = - `https://minio-y44gsgsskc4ko4kkwsg0csoc.135.237.97.107.sslip.io/` + - `guild-profile-pictures/${objectName}`; - -describe("public profile-picture URLs", () => { - it("returns the canonical public URL for a user-owned object key", () => { - expect( - getPublicProfilePictureUrl({ - profilePictureReference: objectName, - userId, - }), - ).toBe(publicUrl); - }); - - it.each([ - ["missing", null], - ["blank", " "], - ["malformed", "not-a-profile-picture"], - [ - "owned by another user", - "00000000-0000-4000-8000-000000000002/profile-picture-4ac506f0-ab78-4722-88f6-2808763b65aa.jpg", - ], - ])("returns null for a reference that is %s", (_label, reference) => { - expect( - getPublicProfilePictureUrl({ - profilePictureReference: reference, - userId, - }), - ).toBeNull(); - }); -}); diff --git a/packages/api/src/utils/guild/club-roster.ts b/packages/api/src/utils/guild/club-roster.ts index 2d81923c9..5e4f30d41 100644 --- a/packages/api/src/utils/guild/club-roster.ts +++ b/packages/api/src/utils/guild/club-roster.ts @@ -5,7 +5,6 @@ import { ClubTeamRole } from "@forge/db/schemas/club-team"; import { Member } from "@forge/db/schemas/knight-hacks"; import type { ClubTeamConfig } from "./club-team-config"; -import { getPublicProfilePictureUrl } from "../profile-picture/public-url"; import { getClubRoleBuckets, holdsClubLeadershipRole, @@ -50,7 +49,7 @@ export interface RosterRoleRow { memberId: string; firstName: string | null; lastName: string | null; - profilePictureReference: string | null; + guildProfilePictureUrl: string | null; linkedinProfileUrl: string | null; } @@ -151,10 +150,7 @@ export function buildPublicClubRoster( displayName: row.displayName, }), teamRole, - imageUrl: getPublicProfilePictureUrl({ - profilePictureReference: row.profilePictureReference, - userId: row.userId, - }), + imageUrl: toNonEmptyString(row.guildProfilePictureUrl), linkedinUrl: toNonEmptyString(row.linkedinProfileUrl), color: row.roleColor, }, @@ -196,7 +192,7 @@ export async function getVisiblePublicClubRoster() { memberId: Member.id, firstName: Member.firstName, lastName: Member.lastName, - profilePictureReference: Member.profilePictureUrl, + guildProfilePictureUrl: Member.profilePictureUrl, linkedinProfileUrl: Member.linkedinProfileUrl, }) .from(ClubTeamRole) diff --git a/packages/api/src/utils/profile-picture/public-url.ts b/packages/api/src/utils/profile-picture/public-url.ts deleted file mode 100644 index 0ca371892..000000000 --- a/packages/api/src/utils/profile-picture/public-url.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { MINIO } from "@forge/consts"; - -import { - PROFILE_PICTURE_BUCKET_NAME, - resolveProfilePictureObjectName, -} from "./security"; - -export interface PublicProfilePictureReference { - profilePictureReference: string | null; - userId: string; -} - -export function getPublicProfilePictureUrl({ - profilePictureReference, - userId, -}: PublicProfilePictureReference) { - if (!profilePictureReference) return null; - - try { - const objectName = resolveProfilePictureObjectName( - profilePictureReference, - userId, - ); - if (!objectName) return null; - - const encodedObjectName = objectName - .split("/") - .map((segment) => encodeURIComponent(segment)) - .join("/"); - - return `https://${MINIO.ENDPOINT}/${PROFILE_PICTURE_BUCKET_NAME}/${encodedObjectName}`; - } catch { - return null; - } -} diff --git a/packages/api/src/utils/profile-picture/security.ts b/packages/api/src/utils/profile-picture/security.ts index 4029cd938..0837d3200 100644 --- a/packages/api/src/utils/profile-picture/security.ts +++ b/packages/api/src/utils/profile-picture/security.ts @@ -47,9 +47,6 @@ export function isProfilePictureObjectOwnedByUser( return true; } -// Older database rows store a full bucket URL; current uploads store an object -// key. Every direct reader must accept both during the staged rollout, so remove -// this parser only after the data migration and its rollback window are over. export function getProfilePictureObjectNameFromLegacyUrl( profilePictureUrl: string, ) { diff --git a/packages/consts/src/minio.ts b/packages/consts/src/minio.ts index 8b5e21858..4a8d3cea6 100644 --- a/packages/consts/src/minio.ts +++ b/packages/consts/src/minio.ts @@ -1,5 +1,4 @@ -export const ENDPOINT = - "minio-y44gsgsskc4ko4kkwsg0csoc.135.237.97.107.sslip.io"; +export const ENDPOINT = "minio-g0soogg4gs8gwcggw4ococok.knighthacks.org"; export const BUCKET_REGION = "us-east-1"; From f6804d0647171275b2bb09f7fbad1a3f2cd342eb Mon Sep 17 00:00:00 2001 From: Carlos Date: Thu, 20 Aug 2026 11:44:34 -0400 Subject: [PATCH 10/10] remove khix profile image test Co-authored-by: Codex --- .../team-cascade/profile-picture-url.test.ts | 32 ------------------- 1 file changed, 32 deletions(-) delete mode 100644 apps/khix/src/app/_components/team-cascade/profile-picture-url.test.ts diff --git a/apps/khix/src/app/_components/team-cascade/profile-picture-url.test.ts b/apps/khix/src/app/_components/team-cascade/profile-picture-url.test.ts deleted file mode 100644 index a0511b52c..000000000 --- a/apps/khix/src/app/_components/team-cascade/profile-picture-url.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { getKhixProfilePictureUrl } from "./profile-picture-url"; - -const objectName = - "2c48b2aa-cccd-4fb7-9a06-a5a369d32545/profile-picture-4ac506f0-ab78-4722-88f6-2808763b65aa.jpg"; -const publicUrl = - `https://minio-y44gsgsskc4ko4kkwsg0csoc.135.237.97.107.sslip.io/` + - `guild-profile-pictures/${objectName}`; - -describe("KHIX profile-picture URLs", () => { - it("turns a current Guild object key into a public URL", () => { - expect(getKhixProfilePictureUrl(objectName)).toBe(publicUrl); - }); - - it("moves an older absolute bucket URL onto the current public host", () => { - expect( - getKhixProfilePictureUrl( - `https://older-minio.example.test/guild-profile-pictures/${objectName}`, - ), - ).toBe(publicUrl); - }); - - it.each([ - ["missing", null], - ["blank", " "], - ["not an object key", "avatar.jpg"], - ["not HTTPS", `http://example.test/guild-profile-pictures/${objectName}`], - ])("rejects a reference that is %s", (_label, reference) => { - expect(getKhixProfilePictureUrl(reference)).toBeNull(); - }); -});