server/routes/aapi.ts (128 lines of code) (raw):

import type { NextFunction, Request, Response } from 'express'; import { Router } from 'express'; import type { FileAttachment } from '@/shared/fileUploadUtils'; import { base64ToBlob, MAX_AVATAR_FILE_SIZE_KB, validateFileAttachment, } from '@/shared/fileUploadUtils'; import { getConfig } from '../idapiConfig'; import { setOptions } from '../idapiProxy'; import { withIdentity } from '../middleware/identityMiddleware'; import { getConfig as getOktaConfig } from '../oktaConfig'; import { handleError, jsonOrEmpty } from '../util'; interface AvatarAPIErrorResponse { message: string; errors: string[]; } const sendAvatarAPIErrorResponse = ( json: AvatarAPIErrorResponse, status: number, res: Response, ) => { res.status(status).send(json); }; const router = Router(); router.use(withIdentity(401)); router.get( '/avatar', async (req: Request, res: Response, next: NextFunction) => { let config; try { config = await getConfig(); } catch (e) { handleError(e, res, next); return; } const { useOkta } = await getOktaConfig(); const options = setOptions({ sendAuthHeader: true, useOkta, path: '/v1/avatars/user/me/active', subdomain: 'avatar', method: 'GET', cookies: req.cookies, signedCookies: req.signedCookies, config, }); try { const response = await fetch(options.route, { method: options.method, headers: options.headers, }); const json = await jsonOrEmpty(response); if (!response.ok) { res.status(response.status).send(json); } else if (response.status === 204) { return res.sendStatus(204); } else { res.json(json); } } catch (error) { sendAvatarAPIErrorResponse( { message: 'Error.', errors: [error], }, 500, res, ); } }, ); /** * Proxies a FormData payload sent from the client to Avatar API. * Receives a payload of type FileAttachment: * { * name: string, * type: string, * contents: string, * } * Where `contents` is a Base64-encoded string of the file contents. * We use this behaviour rather than a standard file upload using * multipart/form-data becasuse the existing file upload form attached * to the Help Centre contact form uses this method, and the server is * already set up to handle it. */ router.post( '/avatar', async (req: Request, res: Response, next: NextFunction) => { try { const body = JSON.parse(req.body) as FileAttachment; if (!validateFileAttachment(body, MAX_AVATAR_FILE_SIZE_KB)) { throw new Error('Invalid file.'); } let config; try { config = await getConfig(); } catch (e) { handleError(e, res, next); return; } const { useOkta } = await getOktaConfig(); const options = setOptions({ sendAuthHeader: true, useOkta, path: '/v1/avatars', subdomain: 'avatar', method: 'POST', cookies: req.cookies, signedCookies: req.signedCookies, config, }); // Recerate a FormData payload from the Base64-encoded file contents string. // FormData expects a Blob (a File is a type of Blob) so we need to convert // the string to a Blob. const formData = new FormData(); formData.append('file', base64ToBlob(body.contents), body.name); // Manually delete the Content-Type header from the options object. // We need to do this because the Content-Type for a FormData object is akin to: // Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW // And the boundary section is generated internally by the Fetch API to make sure it doesn't // appear in the file data. If we set the Content-Type header ourselves, we don't set the // boundary and the request becomes invalid. We also can't set it to undefined because the // Fetch API will then send it as the string 'undefined'. delete options.headers['Content-Type']; const response = await fetch(options.route, { method: options.method, headers: options.headers, body: formData, }); const json = await jsonOrEmpty(response); if (!response.ok) { res.status(response.status).send(json); } else if (response.status === 204) { return res.sendStatus(204); } else { res.json(json); } } catch (error) { sendAvatarAPIErrorResponse( { message: 'Unexpected error.', errors: [error.message], }, 500, res, ); return; } }, ); export { router };