From 49787809623e6a9b1e481eac65378852aa37fe6a Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 7 May 2026 20:41:07 -0400 Subject: [PATCH] feat(08-01): add /api/mobile/engagement/user/[userId]/photo proxy route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Proxies Microsoft Graph user photo bytes to authenticated mobile clients - requireAuth() is first call — unauthenticated requests get 401 before Graph - 503 when MSGRAPH_* env not configured (isMsgraphConfigured gate, D-26) - 400 for malformed userId (path traversal denylist, permissive per VARCHAR(255)) - 404 neutral response when user has no photo (no userId oracle) - 200 with Cache-Control: private, max-age=3600 on success (D-25) - 502 neutral response on Graph upstream errors (no token/user leakage) --- .../engagement/user/[userId]/photo/route.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 app/api/mobile/engagement/user/[userId]/photo/route.ts diff --git a/app/api/mobile/engagement/user/[userId]/photo/route.ts b/app/api/mobile/engagement/user/[userId]/photo/route.ts new file mode 100644 index 0000000..c3d98ae --- /dev/null +++ b/app/api/mobile/engagement/user/[userId]/photo/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { getMsgraphClient, isMsgraphConfigured } from '@/lib/services/msgraph-factory'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ userId: string }> } +) { + // 1. Auth gate (middleware whitelists /api/mobile/* — handler MUST gate itself) + const { error: authError } = await requireAuth(); + if (authError) return authError; + + // 2. MS Graph configuration gate (D-26) + if (!isMsgraphConfigured()) { + return NextResponse.json( + { error: 'msgraph_not_configured', message: 'Microsoft Graph credentials are not configured' }, + { status: 503 } + ); + } + + const { userId } = await params; + + // 3. Defensive userId shape check — prevents path traversal and malformed + // requests from reaching MS Graph. graph_users.id is VARCHAR(255) + // (verified in migration 041 line 4) — it typically holds Azure AD GUID + // object IDs but the column also permits UPN-style identifiers, so this + // check is permissive: reject anything containing '/', '?', '#', '..', + // or whitespace, or that is empty / longer than 128 chars. Do NOT + // tighten to a strict GUID regex — that would lock out valid UPN-form + // rows the schema explicitly allows. + if (!userId || userId.length > 128 || /[\s/?#]|\.\./.test(userId)) { + return NextResponse.json( + { error: 'invalid_user_id', message: 'Invalid user id' }, + { status: 400 } + ); + } + + try { + const client = getMsgraphClient(); + const photo = await client.getUserPhotoBytes(userId); + + if (!photo) { + // No photo on Graph (whether the user exists or not — neutral 404) + return NextResponse.json( + { error: 'no_photo', message: 'No photo available' }, + { status: 404 } + ); + } + + return new NextResponse(photo.bytes, { + status: 200, + headers: { + 'Content-Type': photo.contentType, + 'Cache-Control': 'private, max-age=3600', + }, + }); + } catch (error) { + console.error('[ENGAGEMENT-USER-PHOTO] Error:', error); + // Neutral error response — do not leak whether the user exists or whether + // the failure was auth/network/upstream. Always 502 for "couldn't reach + // Graph for any reason other than no-photo". + return NextResponse.json( + { error: 'photo_fetch_failed', message: 'Failed to fetch photo' }, + { status: 502 } + ); + } +}