UNPKG

@appsemble/node-utils

Version:

NodeJS utilities used by Appsemble internally.

187 lines 7.49 kB
import { assertKoaCondition, createFormData, EmailQuotaExceededError, getContainerNamespace, getRemapperContext, logger, parseServiceUrl, scaleDeployment, setLastRequestAnnotation, throwKoaError, version, waitForPodReadiness, } from '@appsemble/node-utils'; import { defaultLocale, remap } from '@appsemble/utils'; import axios from 'axios'; import { get, mapValues, pick } from 'lodash-es'; /** * These response headers are forwarded when proxying requests. */ const allowResponseHeaders = [ 'content-encoding', 'content-length', 'content-type', 'transfer-encoding', ]; const supportedActions = ['email', 'notify', 'request']; export async function handleEmail(ctx, app, action, options) { const { mailer, method, request: { body: data }, } = ctx; if (method !== 'POST') { throwKoaError(ctx, 405, 'Method must be POST for email actions'); } const { email } = options; try { await email({ action, data, mailer, options, context: ctx }); } catch (error) { if (error instanceof EmailQuotaExceededError) { throwKoaError(ctx, 429, error.message); } ctx.throw(error); } ctx.status = 204; } export async function handleNotify(ctx, app, action, options) { const { request: { body: data }, } = ctx; const { sendNotifications } = options; const remapperContext = await getRemapperContext(app, app.definition.defaultLanguage || defaultLocale, options, ctx); const to = remap(action.to, data, remapperContext); const title = remap(action.title, data, remapperContext); const body = remap(action.body, data, remapperContext); await sendNotifications({ app, to, title, body }); ctx.status = 204; } function deserializeResource(data) { // Extract the resource and assets from the JSON object const { resource } = data; const assets = data.assets; // Function to replace asset placeholders with actual Blobs const replaceAssets = (value) => { if (Array.isArray(value)) { return value.map(replaceAssets); } if (typeof value === 'string' && /^\d+$/.test(value)) { return assets[Number(value)]; } if (value && typeof value === 'object') { return mapValues(value, replaceAssets); } return value; }; // Replace placeholders and return the deserialized resource return replaceAssets(resource); } async function handleRequestProxy(ctx, app, action, useBody, options) { const { method, query, request: { body, headers }, } = ctx; let data; if (useBody) { if (Object.hasOwn(body, 'assets')) { const deserializedBody = deserializeResource(body); data = createFormData(deserializedBody.length === 1 ? deserializedBody[0] : deserializedBody); } else { data = body; } } else { try { data = JSON.parse(query.data); } catch { throwKoaError(ctx, 400, 'data should be a JSON object.'); } } // TODO const { applyAppServiceSecrets } = options; const remapperContext = await getRemapperContext(app, app.definition.defaultLanguage || defaultLocale, options, ctx); const proxyUrl = new URL(String(remap(action.url ?? null, data, remapperContext))); if (/\/api\/apps\/\d+\/actions\/.*/.test(proxyUrl.pathname) && ctx.URL.host === proxyUrl.host) { throwKoaError(ctx, 400, 'Not allowed to make direct requests to the Appsemble action controller using this action.'); } let params; try { if (query.params) { params = JSON.parse(query.params); } } catch { throwKoaError(ctx, 400, 'params should be a JSON object.'); } let axiosConfig = { method: action.method ?? 'GET', url: String(proxyUrl), params, responseType: 'arraybuffer', headers: {}, }; axiosConfig = await applyAppServiceSecrets({ axiosConfig, context: ctx, app }); axiosConfig.headers ?? (axiosConfig.headers = {}); if (axiosConfig.method?.toUpperCase() !== method) { throwKoaError(ctx, 400, 'Method does not match the request action method'); } if (useBody) { axiosConfig.data = data; if (headers['content-type']) { axiosConfig.headers['Content-Type'] = headers['content-type']; } } axiosConfig.headers['user-agent'] = `AppsembleServer/${version}`; axiosConfig.headers.connection = 'close'; axiosConfig.responseType = 'stream'; axiosConfig.validateStatus = () => true; axiosConfig.decompress = false; let response; const containerUrlPattern = /^http:\/\/(([\da-z-]+).){2}svc.cluster.local/; // Restricting access to only the containers defined by the app if (containerUrlPattern.test(String(proxyUrl))) { axiosConfig.url = axiosConfig.url?.replace(axiosConfig.url.split('.')[1], getContainerNamespace()); const { appId } = parseServiceUrl(String(proxyUrl)); if (appId !== String(app.id)) { throwKoaError(ctx, 403, 'Forbidden'); } } logger.verbose(`Forwarding request to ${axios.getUri(axiosConfig)}`); logger.verbose('Axios Config:'); logger.verbose(axiosConfig); try { response = await axios(axiosConfig); logger.verbose(response); } catch (err) { // If request is sent to a companion container and fails // Try to start it anew and retry the request if (containerUrlPattern.test(String(proxyUrl))) { const { deploymentName, namespace } = parseServiceUrl(String(proxyUrl)); try { await scaleDeployment(namespace, deploymentName, 1); await waitForPodReadiness(namespace, deploymentName, Number.isNaN(Number(process.env.POD_READINESS_TIMEOUT)) ? undefined : Number(process.env.POD_READINESS_TIMEOUT)); response = await axios(axiosConfig); } catch { throwKoaError(ctx, 502, 'Bad Gateway'); } finally { await setLastRequestAnnotation(namespace, deploymentName); } } else { logger.error(err); logger.verbose('Response:'); logger.verbose(response); throwKoaError(ctx, 502, 'Bad Gateway'); } } ctx.status = response.status; ctx.set(pick(response.headers, allowResponseHeaders)); ctx.body = response.data; } export function createProxyHandler(useBody, options) { return async (ctx) => { const { pathParams: { appId, path }, } = ctx; const app = await options.getApp({ context: ctx, query: { where: { id: appId } } }); assertKoaCondition(app != null, ctx, 404, 'App not found'); const appAction = get(app.definition, path); const action = supportedActions.find((act) => act === appAction?.type); switch (action) { case 'email': return handleEmail(ctx, app, appAction, options); case 'notify': return handleNotify(ctx, app, appAction, options); case 'request': return handleRequestProxy(ctx, app, appAction, useBody, options); default: throwKoaError(ctx, 400, 'path does not point to a proxyable action'); } }; } //# sourceMappingURL=actions.js.map