@appsemble/node-utils
Version:
NodeJS utilities used by Appsemble internally.
187 lines • 7.49 kB
JavaScript
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