UNPKG

@nzz/q-server

Version:

**Maintainer**: [Franco Gervasi](https://github.com/fgervasi)

466 lines (415 loc) 15.3 kB
const Boom = require("@hapi/boom"); const Joi = require("../../../helper/custom-joi.js"); const Hoek = require("@hapi/hoek"); const querystring = require("querystring"); const clone = require("clone"); const crypto = require("crypto"); const helpers = require("./helpers.js"); const sizeValidationObject = require("./size-helpers.js").sizeValidationObject; const validateSize = require("./size-helpers.js").validateSize; const deleteMetaProperties = require("../../../helper/meta-properties").deleteMetaProperties; const configSchemas = require("./configSchemas.js"); function getGetRenderingInfoRoute(config) { return { method: "GET", path: "/rendering-info/{id}/{target}", options: { validate: { params: { id: Joi.string().required(), target: Joi.string().required(), }, query: { toolRuntimeConfig: Joi.object({ size: Joi.object(sizeValidationObject).optional(), }), ignoreInactive: Joi.boolean().optional(), noCache: Joi.boolean().optional(), }, options: { allowUnknown: true, }, }, description: "Returns rendering information for the given graphic id and target (as configured in the environment).", tags: ["api", "reader-facing"], }, handler: async function (request, h) { // This does not cancel the whole request chain, which is painful without Node 15+, but should be done during refactoring. // See the following answer: https://stackoverflow.com/a/37642079 request.raw.req.on("aborted", () => { return h.response().code(499); }); let requestToolRuntimeConfig = {}; if (request.query.toolRuntimeConfig) { if (request.query.toolRuntimeConfig.size) { try { validateSize(request.query.toolRuntimeConfig.size); } catch (err) { if (err.isBoom) { throw err; } else { throw Boom.internal(err); } } } requestToolRuntimeConfig = request.query.toolRuntimeConfig; } // These keys are NOT allowed, because they allow external users to enter any URL which would later be executed without any safety checks // They will be automatically created later during the 'getCompiledToolRuntimeConfig' function delete requestToolRuntimeConfig.fileRequestBaseUrl; delete requestToolRuntimeConfig.toolBaseUrl; request.app.requestId = crypto .createHash("sha1") .update(request.info.id) .digest("hex"); requestToolRuntimeConfig.requestId = request.app.requestId; try { const renderingInfo = await request.server.methods.renderingInfo.getRenderingInfoForId( request.params.id, request.params.target, requestToolRuntimeConfig, request.query.ignoreInactive, { credentials: request.auth.credentials, artifacts: request.auth.artifacts, } ); const response = h.response(renderingInfo.payload); if (renderingInfo.headers.hasOwnProperty("content-type")) { response.header( "content-type", renderingInfo.headers["content-type"] ); } response.header( "cache-control", request.query.noCache === true ? "no-cache" : config.cacheControlHeader ); return response; } catch (err) { if (err.stack) { request.server.log(["error"], err.stack); } if (err.isBoom) { return err; } else { request.server.log(["error"], err.message); } } }, }; } function getPostRenderingInfoRoute(config) { return { method: "POST", path: "/rendering-info/{target}", options: { cache: false, validate: { params: { target: Joi.string().required(), }, payload: { item: Joi.object().required(), toolRuntimeConfig: Joi.object({ size: Joi.object(sizeValidationObject).optional(), }), }, options: { allowUnknown: true, }, }, description: "Returns rendering information for the given data and target (as configured in the environment).", tags: ["api", "editor"], }, handler: async function (request, h) { // This does not cancel the whole request chain, which is painful without Node 15+, but should be done during refactoring. // See the following answer: https://stackoverflow.com/a/37642079 request.raw.req.on("aborted", () => { return h.response().code(499); }); let requestToolRuntimeConfig = {}; if (request.query.toolRuntimeConfig) { requestToolRuntimeConfig = request.query.toolRuntimeConfig; } else if (request.payload.toolRuntimeConfig) { requestToolRuntimeConfig = request.payload.toolRuntimeConfig; } // These keys are NOT allowed, because they allow external users to enter any URL which would later be executed without any safety checks // They will be automatically created later during the 'getCompiledToolRuntimeConfig' function delete requestToolRuntimeConfig.fileRequestBaseUrl; delete requestToolRuntimeConfig.toolBaseUrl; if (requestToolRuntimeConfig && requestToolRuntimeConfig.size) { try { validateSize(requestToolRuntimeConfig.size); } catch (err) { if (err.isBoom) { throw err; } else { throw Boom.internal(err); } } } request.app.requestId = crypto .createHash("sha1") .update(request.info.id) .digest("hex"); requestToolRuntimeConfig.requestId = request.app.requestId; // this property is passed through to the tool in the end to let it know if the item state is available in the database or not const itemStateInDb = false; try { const renderingInfo = await request.server.methods.renderingInfo.getRenderingInfoForItem({ item: request.payload.item, target: request.params.target, requestToolRuntimeConfig, ignoreInactive: request.query.ignoreInactive, itemStateInDb, }); const response = h.response(renderingInfo.payload); if (renderingInfo.headers.hasOwnProperty("content-type")) { response.header( "content-type", renderingInfo.headers["content-type"] ); } response.header( "cache-control", request.query.noCache === true ? "no-cache" : config.cacheControlHeader ); return response; } catch (err) { request.server.log(["error"], err); if (err.isBoom) { return err; } else { return Boom.serverUnavailable(err.message); } } }, }; } module.exports = { name: "q-rendering-info", dependencies: "q-base", register: async function (server, options) { Hoek.assert( server.settings.app.tools && typeof server.settings.app.tools.get === "function", new Error("server.settings.app.tools.get needs to be a function") ); // validate the target config used by this plugin const targetConfigValidationResult = configSchemas.target.validate( server.settings.app.targets.get(`/`), { allowUnknown: true, } ); if (targetConfigValidationResult.error) { throw new Error(targetConfigValidationResult.error); } // validate the tool endpoint config for all the defined targets Object.keys(server.settings.app.tools.get(`/`)).forEach((tool) => { Object.keys(server.settings.app.targets.get(`/`)).forEach((target) => { const endpointConfig = server.settings.app.tools.get( `/${tool}/endpoint`, { target } ); const toolEndpointConfigValidationResult = configSchemas.toolEndpoint.validate(endpointConfig, { allowUnknown: true, }); if (toolEndpointConfigValidationResult.error) { throw new Error( `failed to validate toolEndpoint config: ${JSON.stringify( endpointConfig )}. Joi error: ${toolEndpointConfigValidationResult.error}` ); } }); }); server.method( "renderingInfo.getRenderingInfoForItem", async ({ item, target, requestToolRuntimeConfig, itemStateInDb }) => { // the target needs to be defined, otherwise we fail here const targetConfig = server.settings.app.targets.get(`/${target}`); if (!targetConfig) { throw new Error(`${target} not configured`); } const endpointConfig = server.settings.app.tools.get( `/${item.tool}/endpoint`, { target: target } ); if (!endpointConfig) { throw new Error( `no endpoint configured for tool: ${item.tool} and target: ${target}` ); } let toolEndpointConfig; if (endpointConfig instanceof Function) { toolEndpointConfig = await endpointConfig.apply(this, [ item, requestToolRuntimeConfig, ]); } else { toolEndpointConfig = endpointConfig; } // compile the toolRuntimeConfig from runtimeConfig from server, tool endpoint and request const toolRuntimeConfig = helpers.getCompiledToolRuntimeConfig(item, { serverWideToolRuntimeConfig: options.get("/toolRuntimeConfig", { target: target, tool: item.tool, }), targetToolRuntimeConfig: server.settings.app.targets.get( `/${target}/toolRuntimeConfig` ), toolEndpointConfig: toolEndpointConfig, requestToolRuntimeConfig: requestToolRuntimeConfig, }); const baseUrl = server.settings.app.tools.get(`/${item.tool}/baseUrl`, { target: target, }); const requestUrl = helpers.getRequestUrlFromEndpointConfig( toolEndpointConfig, baseUrl ); // add _id, createdDate and updatedDate as query params to rendering info request // todo: the tool could provide the needed query parameters in the config in a future version let queryParams = ["_id", "createdDate", "updatedDate"]; let query = {}; for (let queryParam of queryParams) { if (item.hasOwnProperty(queryParam) && item[queryParam]) { query[queryParam] = item[queryParam]; } } let queryString = querystring.stringify(query); // strip the meta properties before sending the item to the tool service // and keepMeta is not true in target and endpoint config let keepMeta = false; if (targetConfig.keepMeta === true) { keepMeta = true; } if (endpointConfig.keepMeta === true) { keepMeta = true; } else if (endpointConfig.keepMeta === false) { keepMeta = false; } const requestPayload = { item: keepMeta ? item : deleteMetaProperties(clone(item)), itemStateInDb: itemStateInDb, toolRuntimeConfig: toolRuntimeConfig, }; const { res, payload } = await server.app.wreck.post( `${requestUrl}?${queryString}`, { payload: requestPayload, } ); const contentType = res.headers["content-type"]; // first validate the response content-type against the target type to see if the response is valid if (!helpers.isValidContentTypeForTarget(targetConfig, contentType)) { throw new Error( `no valid response received from endpoint for target ${targetConfig.label}` ); } let renderingInfo; // only application/json and target config type web can be compiled with additional renderingInfo // all the other cases get returned here if (helpers.canGetCompiled(targetConfig, contentType)) { renderingInfo = await helpers.getCompiledRenderingInfo({ renderingInfo: JSON.parse(payload.toString("utf-8")), endpointConfig: toolEndpointConfig, targetConfig, item, toolRuntimeConfig, }); } else { renderingInfo = payload; } renderingInfo = await server.methods.renderingInfo.getProcessedRenderingInfo({ item, targetConfig, endpointConfig, toolRuntimeConfig, renderingInfo, }); return { payload: renderingInfo, headers: res.headers, }; } ); server.method( "renderingInfo.getRenderingInfoForId", async (id, target, requestToolRuntimeConfig, ignoreInactive, session) => { try { const item = await server.methods.db.item.getById({ id, ignoreInactive, session, }); // this property is passed through to the tool in the end to let it know if the item state is available in the database or not const itemStateInDb = true; return server.methods.renderingInfo.getRenderingInfoForItem({ item, target, requestToolRuntimeConfig, itemStateInDb, }); } catch (err) { throw err; } } ); server.method( "renderingInfo.getProcessedRenderingInfo", async ({ item, targetConfig, endpointConfig, toolRuntimeConfig, renderingInfo, session, }) => { const processFunctions = []; if (Array.isArray(targetConfig.processRenderingInfo)) { processFunctions.push(...targetConfig.processRenderingInfo); } else if (targetConfig.processRenderingInfo instanceof Function) { processFunctions.push(targetConfig.processRenderingInfo); } if (Array.isArray(endpointConfig.processRenderingInfo)) { processFunctions.push(...endpointConfig.processRenderingInfo); } else if (endpointConfig.processRenderingInfo instanceof Function) { processFunctions.push(endpointConfig.processRenderingInfo); } for (const func of processFunctions) { renderingInfo = await func.apply(this, [ { item, toolRuntimeConfig, renderingInfo, session }, ]); } return renderingInfo; } ); // calculate the cache control header from options given const cacheControlDirectives = await server.methods.getCacheControlDirectivesFromConfig( options.get("/cache/cacheControl") ); const cacheControlHeader = cacheControlDirectives.join(", "); const routesConfig = { cacheControlHeader: cacheControlHeader, }; server.route([ getGetRenderingInfoRoute(routesConfig), getPostRenderingInfoRoute(routesConfig), ]); }, };