58 lines
2.3 KiB
TypeScript
58 lines
2.3 KiB
TypeScript
import type {NextRequest} from 'next/server';
|
|
import {upstreamRaw} from '@/services/api/apiClient';
|
|
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
|
import {failResponse} from '@/shared/services/bff';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
/**
|
|
* GET /api/faces?src=/api/faces/<uuid>.jpg — an authenticated photo, proxied.
|
|
*
|
|
* A browser `<img>` cannot send an Authorization header, and the platform's
|
|
* own image URLs require one. The alternatives were fetch + createObjectURL +
|
|
* revoke-on-unmount at every avatar — which leaks hundreds of copies of one
|
|
* photograph on a screen left open all afternoon — or this: one hop through
|
|
* the origin that already holds the token.
|
|
*
|
|
* ── Why `src` is validated rather than trusted ───────────────────────────
|
|
* An unchecked pass-through would be an open proxy that attaches the
|
|
* merchant's bearer token to any URL an attacker can get into a page. Only
|
|
* same-origin platform paths under /api/faces/ are forwarded.
|
|
*
|
|
* Every hand-out of a photo is written to the platform's audit log, so this
|
|
* must be requested once per screen rather than once per component: two
|
|
* components asking for the same face puts two rows in "who looked at my
|
|
* customers" for one glance at one person.
|
|
*/
|
|
export async function GET(req: NextRequest) {
|
|
const src = new URL(req.url).searchParams.get('src') ?? '';
|
|
|
|
// Relative, no traversal, and inside the faces namespace. Anything else is
|
|
// refused rather than sanitised — a "cleaned" attacker-supplied URL is still
|
|
// attacker-supplied.
|
|
if (!src.startsWith('/api/faces/') || src.includes('..')) {
|
|
return Response.json(
|
|
{error: {code: 'bad_request', message: 'Not a valid image reference.'}},
|
|
{status: 400},
|
|
);
|
|
}
|
|
|
|
try {
|
|
const upstream = await withUpstream((token) =>
|
|
upstreamRaw({path: src, accessToken: token}),
|
|
);
|
|
|
|
return new Response(upstream.body, {
|
|
status: 200,
|
|
headers: {
|
|
'content-type': upstream.headers.get('content-type') ?? 'image/jpeg',
|
|
// Private: this is one merchant's customer, and a shared cache holding
|
|
// it would serve it across tenants.
|
|
'cache-control': 'private, max-age=300',
|
|
},
|
|
});
|
|
} catch (err) {
|
|
return failResponse(err);
|
|
}
|
|
}
|