UNPKG

reveal-sdk-node

Version:

RevealBI Node.js SDK

1,258 lines (1,099 loc) 39.3 kB
const http = require('http'); const { spawn, execSync } = require('child_process'); const url = require('url'); const { randomUUID } = require('crypto'); const os = require('os'); const path = require('path'); const fs = require('fs'); const puppeteer = require('puppeteer-core'); const puppeteerUtils = require("./exporter/PuppeteerUtils"); const { DashboardExporter } = require('./exporter/exporter'); const netpath = require('./netpath'); const dsHelper = require('./sdk-model/rvDataSourceHelper'); const dsExports = require('./sdk-model/rvDataSourceExports'); const dataModelSerializer = require('./dataModel/dataModelSerializer'); const SYSTEM_REQUEST_ID = "SYSTEM_USER_REQUEST"; const create = function (options) { var revealServer = createServer(options ?? {}); revealServer.start(); const reqListener = (req, res) => revealServer.process(req, res); reqListener.close = (callback) => { revealServer.stop(callback); }; reqListener.exporter = new DashboardExporter(revealServer); return reqListener; }; const normalizeRedisOptions = (redisOptions) => { if (redisOptions == null) { return null; } const normalized = {}; const assignIfSpecified = (key) => { if (Object.prototype.hasOwnProperty.call(redisOptions, key)) { const value = redisOptions[key]; if (value !== undefined) { normalized[key] = value; } } }; assignIfSpecified("connectionString"); assignIfSpecified("endPoints"); assignIfSpecified("user"); assignIfSpecified("password"); assignIfSpecified("useSsl"); assignIfSpecified("defaultDatabase"); assignIfSpecified("clientName"); assignIfSpecified("connectTimeoutMs"); assignIfSpecified("syncTimeoutMs"); assignIfSpecified("asyncTimeoutMs"); assignIfSpecified("connectRetry"); assignIfSpecified("keepAliveSeconds"); assignIfSpecified("abortOnConnectFail"); assignIfSpecified("allowAdmin"); assignIfSpecified("reconnectRetryPolicy"); assignIfSpecified("retryDelayMs"); return Object.keys(normalized).length > 0 ? normalized : null; }; const createServer = (options) => { // Build plugin callback handler registry from options.plugins // Format: { pluginName: { callbackName: async (userContext, message) => string } } const pluginHandlers = {}; if (options.plugins) { for (const plugin of options.plugins) { if (plugin.name && plugin.callbacks) { pluginHandlers[plugin.name] = plugin.callbacks; } } } var srv = { instanceUUID: randomUUID(), 'options': options, state: 0, // 0 start pending, 1 starting, 2 started, 3 engine dead, 4 stopping serverPromise: null, executor: null, userContexts : {}, // Initialized in the request, removed by the same request when done. pluginHandlers: pluginHandlers, start: function() { if (this.state != 0) throw new Error("Start has already been invoked"); console.log("RevealServer starting... [" + this.instanceUUID + "]"); this.state = 1; this.launchCallbackServer(); }, stop: function(callback) { this.state = 4; if (this.callbacksServer) { this.callbacksServer.close(callback); this.callbacksServer = null; } if (this.engineCall) { this.engineCall.kill(); this.engineCall = null; } }, launchCallbackServer: function() { this.callbackHandlers = { 'lifecycle-ready': this.handleLifecycleReady, 'authentication-provider': this.handleAuthenticationProvider, 'dashboard-provider': this.handleDashboardProvider, 'dashboard-storage-provider': this.handleDashboardStorageProvider, 'datasource-provider': this.handleDataSourceProvider, 'datasource-item-provider': this.handleDataSourceItemProvider, 'dsi-list-filter': this.handleDataSourceItemListFilter, 'data-model-edit-schema': this.handleDataModelEditSchema, 'data-model-get-calculated-fields': this.handleDataModelGetCalculatedFields, 'data-model-get-measures': this.handleDataModelGetMeasures, 'webpage-evaluate-function': this.handleWebPageEvaluateFunction, }; this.callbacksServer = http.createServer(async (req, res) => { this.addReqErrorHandler(req, res, 'Callback request failed'); this.addResErrorHandler(res, 'Callback response failed'); var parsedUrl = url.parse(req.url); var searchParams = new URLSearchParams(parsedUrl.search); var requestId = req.headers["revealbi-requestid"]; var auth = req.headers["revealbi-auth"]; if (auth != this.callbackAuth) { console.log("We've received an unauthenticated callback request"); res.statusCode = 404; res.end(); return; } var userContext = null; if (requestId === SYSTEM_REQUEST_ID) { if (this.options.systemUserContextProvider){ userContext = this.options.systemUserContextProvider(); } else if(this.options.userContextProvider){ userContext = this.options.userContextProvider(null); } } else if(requestId != null){ userContext = this.userContexts[requestId]; } var handleParams = { parsedUrl: parsedUrl, searchParams: searchParams, userContext: userContext }; const path = parsedUrl.pathname.substr(1); if (this.callbackHandlers[path]) { await this.callbackHandlers[path].call(this, req, res, handleParams); } else if (path.startsWith('plugin/')) { await this.handlePluginCallback(req, res, handleParams, path); } else { await this.handleDefault(req, res, handleParams); } }); this.callbacksServer.listen(0, '127.0.0.1', () => { if (this.state == 4) return; // shutting down. this.launchDotNet(this.callbacksServer.address().port); }); }, handleLifecycleReady: async function(_, res, handleParams) { this.engineReady(handleParams.searchParams.get("port")); res.statusCode = 200; res.end(); }, handleAuthenticationProvider: async function(req, res, handleParams) { await this.handleTransformation(req, res, handleParams, 'authenticationProvider', dsHelper.toSdkDS); }, handleTransformation: async function(req, res, handleParams, transformerNameOrFunction, jsonDeserializer, resultToJsonFun = null) { var transformer; var transformerName; if (typeof transformerNameOrFunction === 'function') { transformer = transformerNameOrFunction; transformerName = transformer.transformerName; } else { transformer = this.options[transformerNameOrFunction]; transformerName = transformerNameOrFunction; } var body = await streamToString(req); var inputJson = JSON.parse(body); var result; try { var sdkObject = jsonDeserializer(inputJson); result = await transformer?.(handleParams.userContext, sdkObject); } catch (e) { console.error(`Failed to run ${transformerName}: ${e}\n${e.stack}`); } var resultJson = resultToJsonFun ? resultToJsonFun(result) : result?.toJson(); if (resultJson) { res.statusCode = 200; res.setHeader('Content-Type', 'application/javascript'); var responseBody = JSON.stringify(resultJson); res.end(responseBody); } else { res.statusCode = 200; res.end(); } }, handleDashboardProvider: async function(req, res, handleParams) { var dId = handleParams.searchParams.get('dashboardId'); var dashboardProvider = this.options.dashboardProvider; if (!dashboardProvider) { res.statusCode = 404; res.end(); } else { var dashboardStream; try { dashboardStream = await dashboardProvider(handleParams.userContext, dId); } catch (e) { res.statusCode = 500; res.end(`Dashboard load failed. Error name: ${e.name ?? "-"}. Message: ${e.message ?? ("" + e)}`); return; } if (!dashboardStream) { res.statusCode = 404; res.end(); } else { res.statusCode = 200; dashboardStream.pipe(res, { end: true }); dashboardStream.on('error', (e) => { console.log(`dashboard stream failed: ${e}\n${e.stack}`); if (!res.headersSent) { res.statusCode = 500; } res.end(); }); } } }, handleDashboardStorageProvider: async function(req, res, handleParams) { var dId = handleParams.searchParams.get('dashboardId'); var dashboardStorageProvider = this.options.dashboardStorageProvider; if (!dashboardStorageProvider) { res.statusCode = 500; res.end('Save not implemented'); } else { try { await dashboardStorageProvider(handleParams.userContext, dId, req); } catch (e) { res.statusCode = 500; res.end(`Dashboard save failed. Error name: ${e.name ?? "-"}. Message: ${e.message ?? ("" + e)}`); return; } res.statusCode = 200; res.end(); } }, handleDataSourceProvider: async function(req, res, handleParams) { await this.handleTransformation(req, res, handleParams, 'dataSourceProvider', dsHelper.toSdkDS); }, handleDataSourceItemProvider: async function(req, res, handleParams) { await this.handleTransformation(req, res, handleParams, 'dataSourceItemProvider', dsHelper.toSdkDSI); }, handleDataSourceItemListFilter: async function(req, res, handleParams) { var dataSourceItemFilter = this.options.dataSourceItemFilter; var transformerFunction = async function(userContext, dsiList) { return await Promise.all(dsiList.map(async dsi => await dataSourceItemFilter(userContext, dsi))); }; transformerFunction.transformerName = 'dataSourceItemFilter'; await this.handleTransformation(req, res, handleParams, transformerFunction, (jsonList) => jsonList.map(dsiJson => dsiJson != null ? dsHelper.toSdkDSI(dsiJson) : null), (result) => result); }, handleDataModelEditSchema: async function(req, res, handleParams) { var dataModelProvider = this.options.dataModelProvider; var transformerFunction = async function(userContext, { dsi, schema }) { return await dataModelProvider.editSchema(userContext, dsi, schema); }; transformerFunction.transformerName = 'dataModelProvider.editSchema'; await this.handleTransformation(req, res, handleParams, transformerFunction, (inputJson) => ({ dsi: dsHelper.toSdkDSI(inputJson.dataSourceItem), schema: dataModelSerializer.parseSchema(inputJson.schema ?? []) }), (result) => dataModelSerializer.serializeSchema(result)); }, handleDataModelGetCalculatedFields: async function(req, res, handleParams) { var dataModelProvider = this.options.dataModelProvider; var transformerFunction = async function(userContext, sdkDSI) { return await dataModelProvider.getCalculatedFields(userContext, sdkDSI); }; transformerFunction.transformerName = 'dataModelProvider.getCalculatedFields'; await this.handleTransformation(req, res, handleParams, transformerFunction, dsHelper.toSdkDSI, (result) => dataModelSerializer.serializeCalculatedFields(result)); }, handleDataModelGetMeasures: async function(req, res, handleParams) { var dataModelProvider = this.options.dataModelProvider; var transformerFunction = async function(userContext, sdkDSI) { return await dataModelProvider.getMeasures(userContext, sdkDSI); }; transformerFunction.transformerName = 'dataModelProvider.getMeasures'; await this.handleTransformation(req, res, handleParams, transformerFunction, dsHelper.toSdkDSI, (result) => result); }, handleWebPageEvaluateFunction: async function(req, res, handleParams) { var body = await streamToString(req); var inputJson = JSON.parse(body); const browser = await this.ensureScreenshotBrowser(); const page = await browser.newPage(); await this.setupExportTemplateRoutes(page); await page.setViewport({width: inputJson.Width, height: inputJson.Height}); page .on('console', message => console.log(`${message.type().substr(0, 3).toUpperCase()} ${message.text()}`)) .on('pageerror', ({ message }) => console.error(message)) .on('response', response => console.log(`${response.status()} ${response.url()}`)) .on('requestfailed', request => console.log(`${request.failure().errorText} ${request.url()}`)); await page.goto(inputJson.PagePath); if(inputJson.InitScript){ var scriptOptions = {content : inputJson.InitScript}; await page.addScriptTag(scriptOptions); } await page.exposeFunction("infragisticsScreenshot", async () => { return await page.screenshot({ encoding: 'base64' }); }); var timeout = 60000; if(inputJson.Timeout && inputJson.Timeout > 0){ timeout = inputJson.Timeout * 1000; } var isTimeout = false; var timer = setTimeout(() => { isTimeout = true; page.close(); }, timeout); try{ var result = await page.evaluate(Function(`return ${inputJson.F}`)()); timer.close(); res.statusCode = 200; res.setHeader('Content-Type', 'text/plain'); res.end(result); } catch(error) { res.statusCode = 500; res.setHeader('Content-Type', 'text/plain'); if(isTimeout){ res.end("Timeout reached: " + timeout/1000 + "s"); } else { res.end(error); } } finally { if(page != null && !page.isClosed()){ page.close(); } } }, /** * Handles plugin callbacks routed as /plugin/{pluginName}/{callbackName}. * Looks up the registered handler and invokes it with (userContext, message). * Handler must be async and return a string. */ handlePluginCallback: async function(req, res, handleParams, path) { const parts = path.split('/'); const pluginName = parts[1]; const callbackName = parts[2]; if (!pluginName || !callbackName) { res.statusCode = 400; res.end('Invalid plugin callback path, expected /plugin/{pluginName}/{callbackName}'); return; } const pluginCallbacks = this.pluginHandlers[pluginName]; const handler = pluginCallbacks?.[callbackName]; if (!handler) { console.log(`No handler registered for plugin callback: ${pluginName}/${callbackName}`); res.statusCode = 400; res.end(`Plugin callback not found: ${pluginName}/${callbackName}`); return; } const message = await streamToString(req); let result = ''; try { result = await handler(handleParams.userContext, message); } catch (e) { console.error(`Plugin callback ${pluginName}/${callbackName} failed: ${e}\n${e.stack}`); res.statusCode = 500; res.end(`Plugin callback failed: ${e.message ?? e}`); return; } res.statusCode = 200; res.setHeader('Content-Type', 'text/plain'); res.end(result ?? ''); }, handleDefault: async function(req, res, handleParams) { console.log(`Unknown callback: ${handleParams.parsedUrl.url}`); res.statusCode = 400; res.end('Unknown callback'); }, launchDotNet: function(port) { process.on('exit', () => { if (this.state != 4) { console.log('RevealEngine exited. [' + this.instanceUUID + ']'); } if (this.engineCall) { this.engineCall.kill(); this.engineCall = null; } }); this.callbackAuth = randomUUID(); var registry = []; var dataSourcesModules = this.options.dataSources; if (dataSourcesModules) { dataSourcesModules.forEach(moduleName => { const module = moduleName.startsWith('.') // this is something for internal testing. ? require(moduleName) : require(`@revealbi/data-${moduleName}`); for (const expKey in module) { const exp = module[expKey]; if (exp.hasOwnProperty('assemblyInfo')) { dsHelper.registerDataSource(exp.identifier, exp.dataSourceClass, exp.dataSourceItemClass); // TODO this registers globally, not just for this server instance. registry.push(exp.assemblyInfo); } } }); } var engineCallArgsObj = { 'cachePath': this.options.cachePath ?? null, 'callbacksPort': port, 'callbackAuth': this.callbackAuth, 'lic': this.options.license ?? null, 'logLevel': this.options.engineLogLevel ?? null, 'logDir': this.options.engineLogDir ?? null, 'localFileStoragePath': this.options.localFileStoragePath ?? null, 'advancedSettings': this.options.advancedSettings ?? null, 'maxFilterSize': this.options.maxFilterSize ?? null, 'maxDownloadSize': this.options.maxDownloadSize ?? null, 'maxInMemoryCells': this.options.maxInMemoryCells ?? null, 'maxStorageCells': this.options.maxStorageCells ?? null, 'maxTotalStringsSize': this.options.maxTotalStringsSize ?? null, 'maxStringCellSize': this.options.maxStringCellSize ?? null, 'hasDataSourceItemFilter': this.options.dataSourceItemFilter != null, 'hasDataModelProvider': this.options.dataModelProvider != null, 'registry': registry, 'isLegacyCacheEnabled': this.options.isLegacyCacheEnabled ?? true, 'enableCacheEncryption': this.options.enableCacheEncryption ?? false, 'cacheEncryptionPassword': this.options.cacheEncryptionPassword ?? null, 'newLocalProcessingEnabled': this.options.newLocalProcessingEnabled ?? false, 'hasWebBrowser': true, 'exportConfiguration': this.options.exportConfiguration ?? null, 'plugins': (this.options.plugins ?? []).map(p => ({ name: p.name, assemblyPath: p.assemblyPath, className: p.className, parameters: p.parameters ?? {} })) }; const redisOptions = normalizeRedisOptions(this.options.redisOptions); if (redisOptions != null) { engineCallArgsObj['redisOptions'] = redisOptions; } else { engineCallArgsObj['redisOptions'] = null; } var engineCallArgs = JSON.stringify(engineCallArgsObj); var nativeLibPath = this.options._internal_revealEnginePrgPath; if (!nativeLibPath) { nativeLibPath = __dirname + path.sep + ".." + path.sep + netpath.binaryRelativePath; } if (os.platform() != "win32") { try { execSync(`chmod +x ${nativeLibPath}`); } catch (e) { console.error(`Unable to chmod, engine may fail. Error: ${e}`); } } var engineCall = spawn(nativeLibPath, [engineCallArgs]); this.engineCall = engineCall; engineCall.stdout.on('data', (data) => { console.log(`Engine stdout: ${data}`); }); engineCall.stderr.on('data', (data) => { console.log(`Engine stderr: ${data}`); }); engineCall.on('close', async (code) => { if (this.state == 4) { //nop } else { if (code == 55) { console.error("The license key is missing or has expired."); } else if (code == 56) { console.error("The encryption password is required if cache encryption is enabled."); } else if (code == 57) { console.error("The legacy cache provider doesn't support encryption."); } else { console.error(`Engine closed with exit code: ${code}`); } this.engineCall = null; this.engineFailed("Engine exited abnormally"); throw new Error("Engine exited abnormally"); // safer than process.exit() } }); }, engineFailed: function(msg) { if (this.executor != null) { this.executor.reject(msg); } this.state = 3; this.executor = null; // but I keep the serverPromise so to reject new incoming requests. }, engineReady: function(port) { if (this.state != 1) { console.log("readyReceived AGAIN?!"); return; } console.log("RevealServer ready."); this.enginePort = port; this.state = 2; this.serverPromise = null; var executor = this.executor; this.executor = null; executor.resolve(); }, process: function(req, res) { this.doWhenServerReady( () => this.doProcess(req, res), () => { res.statusCode = 503; res.end(); }); }, doWhenServerReady(callback, errorCallback) { if (this.state == 2) { callback(); } else if (this.state == 4) { console.log("Engine was requested to do work after it has been stopped. InstanceId " + this.instanceUUID); errorCallback(); } else { // this includes handling of state == engine dead, will catch immediately and err. this.serverPromise .then(_ => callback()) .catch(_ => errorCallback()); } }, doProcess: function(req, res) { this.addReqErrorHandler(req, res, "Request processing failed"); this.addResErrorHandler(res, "Response processing failed"); var parsedUrl = url.parse(req.url); if (this.options.basePath) { parsedUrl.path = parsedUrl.path.substring(1 + this.options.basePath.length); } if (parsedUrl.path == '/tools/renderhtml/') { this.processRenderHtml(req, res); return; } req.pause(); var userContext = this.options.userContextProvider?.(req); var connector = this.httpRequestToEngineEndpoint( req.method, parsedUrl.path, parsedUrl.query, req.headers, // we're just passing the incoming headers. Do we want to alter/remove any of them? userContext, (serverResponse) => { serverResponse.pause(); res.writeHead(serverResponse.statusCode, serverResponse.headers); serverResponse.pipe(res, { end: true }); serverResponse.resume(); }, e => this.handleError(e, 'Engine invocation failed', res)); req.pipe(connector, { end: true }); req.resume(); }, bridge: function(endpointMethod, endpointPath, endpointQuery, body, endpointHeaders, userContext) { return new Promise((resolve, reject) => { this.doWhenServerReady( () => { let request = this.httpRequestToEngineEndpoint(endpointMethod, endpointPath, endpointQuery, endpointHeaders, userContext, (serverResponse) => { let hasErrors = serverResponse.statusCode == 500; let chunks = []; serverResponse.on('data', (chunk) => { chunks.push(chunk); }).on('end', () => { let bodyStr = Buffer.concat(chunks).toString(); if(hasErrors) reject(bodyStr); else resolve(bodyStr); }); // not sure if we need to listen for 'error' on the serverResponse. We don't do that on doProcess. }, reject); request.write(body); request.end(); }, () => { reject(new Error("Can't process the request. See previous error messages.")); } ); }); }, httpRequestToEngineEndpoint: function(endpointMethod, endpointPath, endpointQuery, endpointHeaders, userContext, callback, errorCallback) { var requestId = randomUUID(); this.userContexts[requestId] = userContext; var headers = {"RevealBI-RequestId": requestId}; if (userContext?.userId != null) { headers["RevealBI-UserId"] = userContext.userId; } Object.assign(headers, endpointHeaders); var connector = http.request({ 'protocol': 'http:', 'host': '127.0.0.1', 'port': this.enginePort, 'path': endpointPath, 'query': endpointQuery, 'method': endpointMethod, 'headers': headers }, function(serverResponse) { callback(serverResponse); }); connector.on('close', () => { delete this.userContexts[requestId]; }); connector.on('error', errorCallback); return connector; // Note that either end or abort needs to be called on this one by the caller, otherwise we would be leaking nodejs resources. }, addReqErrorHandler: function(req, res, msg) { req.on("error", e => this.handleError(e, msg, res)); }, addResErrorHandler: function(res, msg) { res.on("error", e => this.handleError(e, msg, res)); }, handleError: function(e, msg, res) { var uuid = randomUUID(); console.error(`${msg}: ${e}\n${e.stack}. Request #${uuid}`); if (!res.headersSent) { res.statusCode = 500; res.end(`Failed. Request ${uuid}`); } else { res.end(); } }, processRenderHtml: async function(req, res) { var input = await streamToString(req); var tmpHtml = await this.ensureRenderHtmlTemplate(); tmpHtml = tmpHtml + input.replace(/<canvas imgsrc/gi, "<img src").replace(/\/canvas>/gi, "/img>"); + "</body></html>"; var dims = this.extractDimensions(input); var imageStr; try { imageStr = await this.takeScreenshot(tmpHtml, dims); } catch (e) { var uuid = randomUUID(); console.error(`Failed to take screenshot: ${e}\n${e.stack}. Request #${uuid}`); res.statusCode = 500; res.end(`Failed. Request ${uuid}`); return; } res.statusCode = 200; res.setHeader('Content-Type', 'text/plain'); res.end(imageStr); }, extractDimensions: function(html) { return { 'width': this.extractSize(html, 'width', 1024), 'height': this.extractSize(html, 'height', 768) }; }, extractSize: function(html, type, defaultValue) { var wPos = html.indexOf(type); if (wPos < 0) return defaultValue; wPos += type.length + 2; var pxIndex = html.indexOf("px", wPos); var substring = html.substring(wPos, pxIndex); var parsed = parseInt(substring); return parsed; }, takeScreenshot: async function(html, dims) { const browser = await this.ensureScreenshotBrowser(); var page = null; try{ // JavaScript is disabled and all network requests are aborted: this path // renders client-supplied HTML via setContent, and the screenshot HTML is // self-contained. Maps and locales are only needed by the headless export // path (handleWebPageEvaluateFunction), not here. page = await browser.newPage({ javaScriptEnabled: false }); await page.setRequestInterception(true); page.on('request', request => request.abort()); await page.setViewport(dims); await page.setContent(html); var result = await page.screenshot({ encoding: 'base64' }); return result; }finally{ if(page != null && !page.isClosed()) page.close(); } }, ensureRenderHtmlTemplate: function() { return this.loadOnce(async function() { return (await fs.promises.readFile(__dirname + path.sep + "renderHtmlTemplate.html")).toString(); }, 'renderHtmltemplate'); }, // Intercepts requests from the export template page to serve files from disk: // - map shapes via http://mapserver.local/ // - locale bundles via file:// (XHR to file:// is blocked by Chromium) setupExportTemplateRoutes: async function (page) { await page.setRequestInterception(true); page.on("request", async (request) => { const url = request.url(); // Serve locale bundles from disk. The export template is served from a file:// URL, // and Chromium blocks XHR to file:// URLs, which prevents the SDK's LocalizationService // from loading non-English locale bundles. if (url.startsWith("file://") && /\/locales\/RevealResources\w+\.json$/i.test(url)) { try { const uri = new URL(url); let localPath = uri.pathname; if (process.platform === "win32" && localPath.startsWith("/")) { localPath = localPath.substring(1); } localPath = path.normalize(localPath); if (!this.validateLocaleFileRoute(path, localPath)) { console.warn("Rejected locale file access outside expected folder:", localPath); return request.abort(); } if (fs.existsSync(localPath)) { const content = await fs.promises.readFile(localPath, "utf8"); return request.respond({ status: 200, headers: { "content-type": "application/json" }, body: content }); } else { return request.respond({ status: 404, headers: { "content-type": "application/json" }, body: "{}" }); } } catch (ex) { console.error("Error loading locale file:", ex); return request.abort(); } } // Allow the export template to load its own assets from disk, but only // when the resolved path is inside the exportTemplateFiles cache folder. if (url.startsWith("file://")) { try { const uri = new URL(url); let localPath = uri.pathname; if (process.platform === "win32" && localPath.startsWith("/")) { localPath = localPath.substring(1); } if (this.validateTemplateFileRoute(path, localPath)) { return request.continue(); } console.warn("Aborted file:// access outside expected folder:", localPath); return request.abort(); } catch (ex) { console.error("Error parsing file URL:", ex); return request.abort(); } } // Deny-by-default: anything not served by the locale or map handlers above // is treated as an arbitrary external request and aborted to prevent // SSRF / data exfiltration via injected URLs in user content. if (!url.startsWith("http://mapserver.local/")) { console.warn("Aborted external request from export page:", url); return request.abort(); } try { const uri = new URL(url); const encoded = uri.pathname.replace(/^\/+/, ""); const decoded = decodeURIComponent(encoded); let localPath = new URL(decoded).pathname; if (process.platform === "win32" && localPath.startsWith("/")) { localPath = localPath.substring(1); } localPath = path.normalize(localPath); console.log("Resolved localPath:", localPath); if (!this.validateMapServerFilesRoutes(path, localPath)) { console.warn("Rejected map server file outside allowed folder:", localPath); return request.abort(); } if (!fs.existsSync(localPath)) { console.warn("File not found:", localPath); return request.abort(); } const ext = path.extname(localPath).toLowerCase(); const CORS = { "access-control-allow-origin": "*", "access-control-allow-headers": "*" }; if (ext === ".json") { const content = await fs.promises.readFile(localPath, "utf8"); return request.respond({ status: 200, headers: { ...CORS, "content-type": "application/json" }, body: content }); } else { const bytes = await fs.promises.readFile(localPath); return request.respond({ status: 200, headers: { ...CORS, "content-type": "application/octet-stream" }, body: bytes }); } } catch (ex) { console.error("Error loading MapServer file:", ex); request.abort(); } }); }, // Validates that the requested local path is inside the expected MapServerFiles folder. validateMapServerFilesRoutes: function(path, localPath) { const expectedSegment = path.join("exportTemplateFiles", "MapServerFiles").replace(/\\/g, "/"); const normalized = localPath.replace(/\\/g, "/"); return normalized.includes(expectedSegment); }, // Validates that a file:// path resolves inside the expected exportTemplateFiles // cache folder, blocking path-traversal attempts. validateTemplateFileRoute: function(path, localPath) { const normalized = path.normalize(localPath).replace(/\\/g, "/"); return normalized.includes("exportTemplateFiles"); }, // Validates that the requested locale file path is inside the expected Reveal/locales folder. validateLocaleFileRoute: function(path, localPath) { const expectedSegment = path.join("exportTemplateFiles", "Reveal", "locales").replace(/\\/g, "/"); const normalized = path.normalize(localPath).replace(/\\/g, "/"); const fileName = path.basename(normalized); return normalized.includes(expectedSegment) && fileName.startsWith("RevealResources") && fileName.endsWith(".json"); }, ensureScreenshotBrowser: async function() { return this.loadOnce(async () => { var executablePath = this.options.advancedSettings?.get('puppeteer_executable_path'); if (executablePath === undefined) { try { executablePath = await puppeteerUtils.download(); } catch(error) { console.log(error); } } if (!executablePath) { console.error("Couldn't find chromium executable"); } var launchArgs = []; if (os.platform() == "linux" && process.getuid() == 0 /* is root */) { launchArgs.push('--no-sandbox'); // Running as root without --no-sandbox is not supported. See https://crbug.com/638180 } return await puppeteer.launch({ executablePath: executablePath, args: launchArgs}); }, 'screenshotBrowser'); }, loadOnce: async function(loader, name) { var lazyLoads = this.lazyLoads ?? (this.lazyLoads = {}); var loaded = lazyLoads[name]; if (loaded) return loaded; var promiseName = name + "_Promise"; var promise = lazyLoads[promiseName]; if (promise) return promise; promise = lazyLoads[promiseName] = loader(); return promise; } }; srv.serverPromise = new Promise((resolve, reject) => srv.executor = { 'resolve': resolve, 'reject': reject } ); srv.serverPromise.catch(r => { console.log(`Engine failed to start. ${r}`); }); return srv; }; async function warmup() { await createServer().ensureScreenshotBrowser(); } async function streamToString(stream) { const chunks = []; for await (const chunk of stream) { chunks.push(Buffer.from(chunk)); } return Buffer.concat(chunks).toString("utf-8"); } class IRVDashboardCredential { toJson() {} } class RVUsernamePasswordDataSourceCredential extends IRVDashboardCredential { constructor(userName, password, domain) { super(); this._userName = userName; this._password = password; this._domain = domain; } get userName() { return this._userName; } get password() { return this._password; } get domain() { return this._domain; } toJson() { return { "type": "usernamePassword", "username": this._userName, "password": this._password, "domain": this.domain } } } class RVBearerTokenDataSourceCredential extends IRVDashboardCredential { constructor(token, userId) { super(); this._token = token; this._userId = userId; } get token() { return this._token; } get userId() { return this._userId; } toJson() { return { "type": "bearerToken", "token": this._token, "userId": this._userId } } } class RVHeadersDataSourceCredentials extends IRVDashboardCredential { constructor() { super(); if (arguments.length === 1) { this._headers = arguments[0] || new Map(); } else if (arguments.length === 2) { this._headers = new Map(); this.addHeader(arguments[0], arguments[1]) } else { this._headers = new Map(); } } get headers() { return this._headers; } addHeader(key, value) { if (key && value) { this._headers.set(key, value); } } toJson() { const headers = Object.fromEntries(this._headers) return { "type": "headers", headers } } } class RVAmazonWebServicesCredentials extends IRVDashboardCredential { constructor(key, secret, sessionToken) { super(); this._key = key; this._secret = secret; this._sessionToken = sessionToken; } get key() { return this._key; } get secret() { return this._secret; } get sessionToken() { return this._sessionToken; } toJson() { return { "type": "amazonWS", "key": this._key, "secret": this._secret, "sessionToken": this.sessionToken } } } class RVIntegratedAuthenticationCredential extends IRVDashboardCredential { constructor() { super(); } toJson() { return { "type": "integrated" } } } class RVPersonalAccessTokenDataSourceCredential extends IRVDashboardCredential { constructor(token) { super(); this._token = token; } get token() { return this._token; } toJson() { return { "type": "pat", "token": this.token } } } class RVOAuthDataSourceCredential extends IRVDashboardCredential { constructor(clientId, clientSecret) { super(); this._clientId = clientId; this._clientSecret = clientSecret; } get clientId() { return this._clientId; } get clientSecret() { return this._clientSecret; } toJson() { return { "type": "twoLeggedOAuth", "clientId": this._clientId, "clientSecret": this._clientSecret, } } } class RVMicrosoftEntraIDDataSourceCredential extends IRVDashboardCredential { constructor(tenantId, clientId, clientSecret) { super(); this._tenantId = tenantId; this._clientId = clientId; this._clientSecret = clientSecret; } get tenantId() { return this._tenantId; } get clientId() { return this._clientId; } get clientSecret() { return this._clientSecret; } toJson() { return { "type": "microsoftEntraID", "tenantId": this._tenantId, "clientId": this._clientId, "clientSecret": this._clientSecret, } } } class RVKeyPairDataSourceCredential extends IRVDashboardCredential { constructor(userName, privateKey) { super(); this._userName = userName; this._privateKey = privateKey; } get userName() { return this._userName; } get privateKey() { return this._privateKey; } toJson() { return { "type": "keyPair", "userName": this._userName, "privateKey": this._privateKey, } } } class IRVUserContext { get userId() { return null; } get properties() { return null; } } class RVUserContext extends IRVUserContext { constructor(userId, properties) { super(); this._userId = userId; this._properties = properties; } get userId() { return this._userId; } get properties() { return this._properties; } } const me = create; // Credentials: me.RVUsernamePasswordDataSourceCredential = RVUsernamePasswordDataSourceCredential; me.RVBearerTokenDataSourceCredential = RVBearerTokenDataSourceCredential; me.RVHeadersDataSourceCredentials = RVHeadersDataSourceCredentials; me.RVAmazonWebServicesCredentials = RVAmazonWebServicesCredentials; me.RVIntegratedAuthenticationCredential = RVIntegratedAuthenticationCredential; me.RVPersonalAccessTokenDataSourceCredential = RVPersonalAccessTokenDataSourceCredential; me.RVOAuthDataSourceCredential = RVOAuthDataSourceCredential; me.RVMicrosoftEntraIDDataSourceCredential = RVMicrosoftEntraIDDataSourceCredential; me.RVKeyPairDataSourceCredential = RVKeyPairDataSourceCredential; // DS Object.assign(me, dsExports); // me.IRVUserContext = IRVUserContext; me.RVUserContext = RVUserContext; me.warmup = warmup; me.revealCore = create; module.exports = me;