feat(08-01): add /api/mobile/engagement/user/[userId]/photo proxy route

- 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)
This commit is contained in:
lorentz 2026-05-07 20:41:07 -04:00
parent 3f6b13572e
commit 4978780962

View file

@ -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 }
);
}
}