forked from tsai/budibase
109 changed files with 1645 additions and 611 deletions
Binary file not shown.
@ -0,0 +1,28 @@ |
|||||
|
<script> |
||||
|
import Icon from "../Icon/Icon.svelte" |
||||
|
import { notifications } from "../Stores/notifications" |
||||
|
export let value |
||||
|
|
||||
|
const onClick = e => { |
||||
|
e.stopPropagation() |
||||
|
copyToClipboard(value) |
||||
|
} |
||||
|
|
||||
|
function copyToClipboard(value) { |
||||
|
navigator.clipboard.writeText(value).then(() => { |
||||
|
notifications.success("Copied") |
||||
|
}) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<div on:click|stopPropagation={onClick}> |
||||
|
<Icon size="S" name="Copy" /> |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
div { |
||||
|
overflow: hidden; |
||||
|
text-overflow: ellipsis; |
||||
|
width: 150px; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,159 @@ |
|||||
|
<script context="module"> |
||||
|
import { Label } from "@budibase/bbui" |
||||
|
|
||||
|
export const EditorModes = { |
||||
|
JS: { |
||||
|
name: "javascript", |
||||
|
json: false, |
||||
|
}, |
||||
|
JSON: { |
||||
|
name: "javascript", |
||||
|
json: true, |
||||
|
}, |
||||
|
SQL: { |
||||
|
name: "sql", |
||||
|
}, |
||||
|
Handlebars: { |
||||
|
name: "handlebars", |
||||
|
base: "text/html", |
||||
|
}, |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<script> |
||||
|
import CodeMirror from "components/integration/codemirror" |
||||
|
import { themeStore } from "builderStore" |
||||
|
import { createEventDispatcher, onMount } from "svelte" |
||||
|
|
||||
|
export let mode = EditorModes.JS |
||||
|
export let value = "" |
||||
|
export let height = 300 |
||||
|
export let resize = "none" |
||||
|
export let readonly = false |
||||
|
export let hints = [] |
||||
|
export let label |
||||
|
|
||||
|
const dispatch = createEventDispatcher() |
||||
|
let textarea |
||||
|
let editor |
||||
|
|
||||
|
// Keep editor up to date with value |
||||
|
$: editor?.setValue(value || "") |
||||
|
|
||||
|
// Creates an instance of a code mirror editor |
||||
|
async function createEditor(mode, value) { |
||||
|
if (!CodeMirror || !textarea || editor) { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Configure CM options |
||||
|
const lightTheme = $themeStore.theme.includes("light") |
||||
|
const options = { |
||||
|
mode, |
||||
|
value: value || "", |
||||
|
readOnly: readonly, |
||||
|
theme: lightTheme ? "default" : "tomorrow-night-eighties", |
||||
|
|
||||
|
// Style |
||||
|
lineNumbers: true, |
||||
|
lineWrapping: true, |
||||
|
indentWithTabs: true, |
||||
|
indentUnit: 2, |
||||
|
tabSize: 2, |
||||
|
|
||||
|
// QOL addons |
||||
|
extraKeys: { "Ctrl-Space": "autocomplete" }, |
||||
|
styleActiveLine: { nonEmpty: true }, |
||||
|
autoCloseBrackets: true, |
||||
|
matchBrackets: true, |
||||
|
} |
||||
|
|
||||
|
// Register hints plugin if desired |
||||
|
if (hints?.length) { |
||||
|
CodeMirror.registerHelper("hint", "dictionaryHint", function (editor) { |
||||
|
const cursor = editor.getCursor() |
||||
|
return { |
||||
|
list: hints, |
||||
|
from: CodeMirror.Pos(cursor.line, cursor.ch), |
||||
|
to: CodeMirror.Pos(cursor.line, cursor.ch), |
||||
|
} |
||||
|
}) |
||||
|
CodeMirror.commands.autocomplete = function (cm) { |
||||
|
CodeMirror.showHint(cm, CodeMirror.hint.dictionaryHint) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Construct CM instance |
||||
|
editor = CodeMirror.fromTextArea(textarea, options) |
||||
|
|
||||
|
// Use a blur handler to update the value |
||||
|
editor.on("blur", instance => { |
||||
|
dispatch("change", instance.getValue()) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// Export a function to expose caret position |
||||
|
export const getCaretPosition = () => { |
||||
|
const cursor = editor.getCursor() |
||||
|
return { |
||||
|
start: cursor.ch, |
||||
|
end: cursor.ch, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onMount(() => { |
||||
|
// Create the editor with initial value |
||||
|
createEditor(mode, value) |
||||
|
|
||||
|
// Clean up editor on unmount |
||||
|
return () => { |
||||
|
if (editor) { |
||||
|
editor.toTextArea() |
||||
|
} |
||||
|
} |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
{#if label} |
||||
|
<div style="margin-bottom: var(--spacing-s)"> |
||||
|
<Label small>{label}</Label> |
||||
|
</div> |
||||
|
{/if} |
||||
|
<div |
||||
|
style={`--code-mirror-height: ${height}px; --code-mirror-resize: ${resize}`} |
||||
|
> |
||||
|
<textarea tabindex="0" bind:this={textarea} readonly {value} /> |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
div :global(.CodeMirror) { |
||||
|
height: var(--code-mirror-height); |
||||
|
min-height: var(--code-mirror-height); |
||||
|
font-family: monospace; |
||||
|
line-height: 1.3; |
||||
|
border: var(--spectrum-alias-border-size-thin) solid; |
||||
|
border-color: var(--spectrum-alias-border-color); |
||||
|
border-radius: var(--border-radius-s); |
||||
|
resize: var(--code-mirror-resize); |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
/* Override default active line highlight colour in dark theme */ |
||||
|
div |
||||
|
:global(.CodeMirror-focused.cm-s-tomorrow-night-eighties |
||||
|
.CodeMirror-activeline-background) { |
||||
|
background: rgba(255, 255, 255, 0.075); |
||||
|
} |
||||
|
|
||||
|
/* Remove active line styling when not focused */ |
||||
|
div |
||||
|
:global(.CodeMirror:not(.CodeMirror-focused) |
||||
|
.CodeMirror-activeline-background) { |
||||
|
background: unset; |
||||
|
} |
||||
|
|
||||
|
/* Add a spectrum themed border when focused */ |
||||
|
div :global(.CodeMirror-focused) { |
||||
|
border-color: var(--spectrum-alias-border-color-mouse-focus); |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,15 @@ |
|||||
|
<script> |
||||
|
import { Input } from "@budibase/bbui" |
||||
|
import { isJSBinding } from "@budibase/string-templates" |
||||
|
|
||||
|
export let value |
||||
|
|
||||
|
$: isJS = isJSBinding(value) |
||||
|
</script> |
||||
|
|
||||
|
<Input |
||||
|
{...$$props} |
||||
|
value={isJS ? "(JavaScript function)" : value} |
||||
|
readonly={isJS} |
||||
|
on:change |
||||
|
/> |
||||
@ -1,12 +1,22 @@ |
|||||
import CodeMirror from "codemirror" |
import CodeMirror from "codemirror" |
||||
import "codemirror/lib/codemirror.css" |
import "codemirror/lib/codemirror.css" |
||||
import "codemirror/theme/tomorrow-night-eighties.css" |
|
||||
import "codemirror/addon/hint/show-hint.css" |
// Modes
|
||||
import "codemirror/theme/neo.css" |
import "codemirror/mode/javascript/javascript" |
||||
import "codemirror/mode/sql/sql" |
import "codemirror/mode/sql/sql" |
||||
import "codemirror/mode/css/css" |
import "codemirror/mode/css/css" |
||||
import "codemirror/mode/handlebars/handlebars" |
import "codemirror/mode/handlebars/handlebars" |
||||
import "codemirror/mode/javascript/javascript" |
|
||||
|
// Hints
|
||||
import "codemirror/addon/hint/show-hint" |
import "codemirror/addon/hint/show-hint" |
||||
|
import "codemirror/addon/hint/show-hint.css" |
||||
|
|
||||
|
// Theming
|
||||
|
import "codemirror/theme/tomorrow-night-eighties.css" |
||||
|
|
||||
|
// Functional addons
|
||||
|
import "codemirror/addon/selection/active-line" |
||||
|
import "codemirror/addon/edit/closebrackets" |
||||
|
import "codemirror/addon/edit/matchbrackets" |
||||
|
|
||||
export default CodeMirror |
export default CodeMirror |
||||
|
|||||
@ -1,5 +1,5 @@ |
|||||
{ |
{ |
||||
"watch": ["src"], |
"watch": ["src", "../auth"], |
||||
"ext": "js,ts,json", |
"ext": "js,ts,json", |
||||
"ignore": ["src/**/*.spec.ts", "src/**/*.spec.js"], |
"ignore": ["src/**/*.spec.ts", "src/**/*.spec.js"], |
||||
"exec": "ts-node src/index.ts" |
"exec": "ts-node src/index.ts" |
||||
|
|||||
@ -0,0 +1,8 @@ |
|||||
|
#!/usr/bin/env node
|
||||
|
const updateDotEnv = require("update-dotenv") |
||||
|
|
||||
|
const arg = process.argv.slice(2)[0] |
||||
|
|
||||
|
updateDotEnv({ |
||||
|
DISABLE_ACCOUNT_PORTAL: arg === "enable" ? "" : "1", |
||||
|
}).then(() => console.log("Updated server!")) |
||||
@ -1,98 +1,100 @@ |
|||||
jest.mock("node-fetch", () => jest.fn(() => ({ json: jest.fn(), text: jest.fn() }))) |
jest.mock("node-fetch", () => |
||||
|
jest.fn(() => ({ json: jest.fn(), text: jest.fn() })) |
||||
|
) |
||||
const fetch = require("node-fetch") |
const fetch = require("node-fetch") |
||||
const RestIntegration = require("../rest") |
const RestIntegration = require("../rest") |
||||
|
|
||||
class TestConfiguration { |
class TestConfiguration { |
||||
constructor(config = {}) { |
constructor(config = {}) { |
||||
this.integration = new RestIntegration.integration(config) |
this.integration = new RestIntegration.integration(config) |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
describe("REST Integration", () => { |
describe("REST Integration", () => { |
||||
const BASE_URL = "https://myapi.com" |
const BASE_URL = "https://myapi.com" |
||||
let config |
let config |
||||
|
|
||||
beforeEach(() => { |
beforeEach(() => { |
||||
config = new TestConfiguration({ |
config = new TestConfiguration({ |
||||
url: BASE_URL |
url: BASE_URL, |
||||
}) |
}) |
||||
}) |
}) |
||||
|
|
||||
it("calls the create method with the correct params", async () => { |
it("calls the create method with the correct params", async () => { |
||||
const query = { |
const query = { |
||||
path: "/api", |
path: "api", |
||||
queryString: "?test=1", |
queryString: "test=1", |
||||
headers: { |
headers: { |
||||
Accept: "application/json" |
Accept: "application/json", |
||||
}, |
}, |
||||
json: { |
json: { |
||||
name: "test" |
name: "test", |
||||
} |
}, |
||||
} |
} |
||||
const response = await config.integration.create(query) |
const response = await config.integration.create(query) |
||||
expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api?test=1`, { |
expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api?test=1`, { |
||||
method: "POST", |
method: "POST", |
||||
body: "{\"name\":\"test\"}", |
body: '{"name":"test"}', |
||||
headers: { |
headers: { |
||||
Accept: "application/json" |
Accept: "application/json", |
||||
} |
}, |
||||
}) |
}) |
||||
}) |
}) |
||||
|
|
||||
it("calls the read method with the correct params", async () => { |
it("calls the read method with the correct params", async () => { |
||||
const query = { |
const query = { |
||||
path: "/api", |
path: "api", |
||||
queryString: "?test=1", |
queryString: "test=1", |
||||
headers: { |
headers: { |
||||
Accept: "text/html" |
Accept: "text/html", |
||||
} |
}, |
||||
} |
} |
||||
const response = await config.integration.read(query) |
const response = await config.integration.read(query) |
||||
expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api?test=1`, { |
expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api?test=1`, { |
||||
headers: { |
headers: { |
||||
Accept: "text/html" |
Accept: "text/html", |
||||
} |
}, |
||||
}) |
}) |
||||
}) |
}) |
||||
|
|
||||
it("calls the update method with the correct params", async () => { |
it("calls the update method with the correct params", async () => { |
||||
const query = { |
const query = { |
||||
path: "/api", |
path: "api", |
||||
queryString: "?test=1", |
queryString: "test=1", |
||||
headers: { |
headers: { |
||||
Accept: "application/json" |
Accept: "application/json", |
||||
}, |
}, |
||||
json: { |
json: { |
||||
name: "test" |
name: "test", |
||||
} |
}, |
||||
} |
} |
||||
const response = await config.integration.update(query) |
const response = await config.integration.update(query) |
||||
expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api?test=1`, { |
expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api?test=1`, { |
||||
method: "POST", |
method: "POST", |
||||
body: "{\"name\":\"test\"}", |
body: '{"name":"test"}', |
||||
headers: { |
headers: { |
||||
Accept: "application/json" |
Accept: "application/json", |
||||
} |
}, |
||||
}) |
}) |
||||
}) |
}) |
||||
|
|
||||
it("calls the delete method with the correct params", async () => { |
it("calls the delete method with the correct params", async () => { |
||||
const query = { |
const query = { |
||||
path: "/api", |
path: "api", |
||||
queryString: "?test=1", |
queryString: "test=1", |
||||
headers: { |
headers: { |
||||
Accept: "application/json" |
Accept: "application/json", |
||||
}, |
}, |
||||
json: { |
json: { |
||||
name: "test" |
name: "test", |
||||
} |
}, |
||||
} |
} |
||||
const response = await config.integration.delete(query) |
const response = await config.integration.delete(query) |
||||
expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api?test=1`, { |
expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api?test=1`, { |
||||
method: "DELETE", |
method: "DELETE", |
||||
headers: { |
headers: { |
||||
Accept: "application/json" |
Accept: "application/json", |
||||
} |
}, |
||||
}) |
}) |
||||
}) |
}) |
||||
}) |
}) |
||||
|
|||||
@ -0,0 +1,21 @@ |
|||||
|
const fetch = require("node-fetch") |
||||
|
const { VM, VMScript } = require("vm2") |
||||
|
|
||||
|
class ScriptRunner { |
||||
|
constructor(script, context) { |
||||
|
const code = `let fn = () => {\n${script}\n}; results.out = fn();` |
||||
|
this.vm = new VM() |
||||
|
this.results = { out: "" } |
||||
|
this.vm.setGlobals(context) |
||||
|
this.vm.setGlobal("fetch", fetch) |
||||
|
this.vm.setGlobal("results", this.results) |
||||
|
this.script = new VMScript(code) |
||||
|
} |
||||
|
|
||||
|
execute() { |
||||
|
this.vm.run(this.script) |
||||
|
return this.results.out |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
module.exports = ScriptRunner |
||||
@ -0,0 +1,49 @@ |
|||||
|
const { atob } = require("../utilities") |
||||
|
|
||||
|
// The method of executing JS scripts depends on the bundle being built.
|
||||
|
// This setter is used in the entrypoint (either index.cjs or index.mjs).
|
||||
|
let runJS |
||||
|
module.exports.setJSRunner = runner => (runJS = runner) |
||||
|
|
||||
|
// Helper utility to strip square brackets from a value
|
||||
|
const removeSquareBrackets = value => { |
||||
|
if (!value || typeof value !== "string") { |
||||
|
return value |
||||
|
} |
||||
|
const regex = /\[+(.+)]+/ |
||||
|
const matches = value.match(regex) |
||||
|
if (matches && matches[1]) { |
||||
|
return matches[1] |
||||
|
} |
||||
|
return value |
||||
|
} |
||||
|
|
||||
|
// Our context getter function provided to JS code as $.
|
||||
|
// Extracts a value from context.
|
||||
|
const getContextValue = (path, context) => { |
||||
|
let data = context |
||||
|
path.split(".").forEach(key => { |
||||
|
if (data == null || typeof data !== "object") { |
||||
|
return null |
||||
|
} |
||||
|
data = data[removeSquareBrackets(key)] |
||||
|
}) |
||||
|
return data |
||||
|
} |
||||
|
|
||||
|
// Evaluates JS code against a certain context
|
||||
|
module.exports.processJS = (handlebars, context) => { |
||||
|
try { |
||||
|
// Wrap JS in a function and immediately invoke it.
|
||||
|
// This is required to allow the final `return` statement to be valid.
|
||||
|
const js = `function run(){${atob(handlebars)}};run();` |
||||
|
|
||||
|
// Our $ context function gets a value from context
|
||||
|
const sandboxContext = { $: path => getContextValue(path, context) } |
||||
|
|
||||
|
// Create a sandbox with out context and run the JS
|
||||
|
return runJS(js, sandboxContext) |
||||
|
} catch (error) { |
||||
|
return "Error while executing JS" |
||||
|
} |
||||
|
} |
||||
@ -1,161 +1,28 @@ |
|||||
const handlebars = require("handlebars") |
const { VM } = require("vm2") |
||||
const { registerAll } = require("./helpers/index") |
const templates = require("./index.js") |
||||
const processors = require("./processors") |
const { setJSRunner } = require("./helpers/javascript") |
||||
const { removeHandlebarsStatements } = require("./utilities") |
|
||||
const manifest = require("../manifest.json") |
/** |
||||
|
* CJS entrypoint for rollup |
||||
const hbsInstance = handlebars.create() |
*/ |
||||
registerAll(hbsInstance) |
module.exports.isValid = templates.isValid |
||||
|
module.exports.makePropSafe = templates.makePropSafe |
||||
/** |
module.exports.getManifest = templates.getManifest |
||||
* utility function to check if the object is valid |
module.exports.isJSBinding = templates.isJSBinding |
||||
*/ |
module.exports.encodeJSBinding = templates.encodeJSBinding |
||||
function testObject(object) { |
module.exports.decodeJSBinding = templates.decodeJSBinding |
||||
// JSON stringify will fail if there are any cycles, stops infinite recursion |
module.exports.processStringSync = templates.processStringSync |
||||
try { |
module.exports.processObjectSync = templates.processObjectSync |
||||
JSON.stringify(object) |
module.exports.processString = templates.processString |
||||
} catch (err) { |
module.exports.processObject = templates.processObject |
||||
throw "Unable to process inputs to JSON, cannot recurse" |
|
||||
} |
/** |
||||
} |
* Use vm2 to run JS scripts in a node env |
||||
|
*/ |
||||
/** |
setJSRunner((js, context) => { |
||||
* Given an input object this will recurse through all props to try and update any handlebars statements within. |
const vm = new VM({ |
||||
* @param {object|array} object The input structure which is to be recursed, it is important to note that |
sandbox: context, |
||||
* if the structure contains any cycles then this will fail. |
timeout: 1000 |
||||
* @param {object} context The context that handlebars should fill data from. |
}) |
||||
* @returns {Promise<object|array>} The structure input, as fully updated as possible. |
return vm.run(js) |
||||
*/ |
}) |
||||
module.exports.processObject = async (object, context) => { |
|
||||
testObject(object) |
|
||||
for (let key of Object.keys(object || {})) { |
|
||||
if (object[key] != null) { |
|
||||
let val = object[key] |
|
||||
if (typeof val === "string") { |
|
||||
object[key] = await module.exports.processString(object[key], context) |
|
||||
} else if (typeof val === "object") { |
|
||||
object[key] = await module.exports.processObject(object[key], context) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
return object |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* This will process a single handlebars containing string. If the string passed in has no valid handlebars statements |
|
||||
* then nothing will occur. |
|
||||
* @param {string} string The template string which is the filled from the context object. |
|
||||
* @param {object} context An object of information which will be used to enrich the string. |
|
||||
* @returns {Promise<string>} The enriched string, all templates should have been replaced if they can be. |
|
||||
*/ |
|
||||
module.exports.processString = async (string, context) => { |
|
||||
// TODO: carry out any async calls before carrying out async call |
|
||||
return module.exports.processStringSync(string, context) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Given an input object this will recurse through all props to try and update any handlebars statements within. This is |
|
||||
* a pure sync call and therefore does not have the full functionality of the async call. |
|
||||
* @param {object|array} object The input structure which is to be recursed, it is important to note that |
|
||||
* if the structure contains any cycles then this will fail. |
|
||||
* @param {object} context The context that handlebars should fill data from. |
|
||||
* @returns {object|array} The structure input, as fully updated as possible. |
|
||||
*/ |
|
||||
module.exports.processObjectSync = (object, context) => { |
|
||||
testObject(object) |
|
||||
for (let key of Object.keys(object || {})) { |
|
||||
let val = object[key] |
|
||||
if (typeof val === "string") { |
|
||||
object[key] = module.exports.processStringSync(object[key], context) |
|
||||
} else if (typeof val === "object") { |
|
||||
object[key] = module.exports.processObjectSync(object[key], context) |
|
||||
} |
|
||||
} |
|
||||
return object |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* This will process a single handlebars containing string. If the string passed in has no valid handlebars statements |
|
||||
* then nothing will occur. This is a pure sync call and therefore does not have the full functionality of the async call. |
|
||||
* @param {string} string The template string which is the filled from the context object. |
|
||||
* @param {object} context An object of information which will be used to enrich the string. |
|
||||
* @returns {string} The enriched string, all templates should have been replaced if they can be. |
|
||||
*/ |
|
||||
module.exports.processStringSync = (string, context) => { |
|
||||
if (!exports.isValid(string)) { |
|
||||
return string |
|
||||
} |
|
||||
// take a copy of input incase error |
|
||||
const input = string |
|
||||
if (typeof string !== "string") { |
|
||||
throw "Cannot process non-string types." |
|
||||
} |
|
||||
try { |
|
||||
string = processors.preprocess(string) |
|
||||
// this does not throw an error when template can't be fulfilled, have to try correct beforehand |
|
||||
const template = hbsInstance.compile(string, { |
|
||||
strict: false, |
|
||||
}) |
|
||||
return processors.postprocess(template({ |
|
||||
now: new Date().toISOString(), |
|
||||
...context, |
|
||||
})) |
|
||||
} catch (err) { |
|
||||
return removeHandlebarsStatements(input) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Simple utility function which makes sure that a templating property has been wrapped in literal specifiers correctly. |
|
||||
* @param {string} property The property which is to be wrapped. |
|
||||
* @returns {string} The wrapped property ready to be added to a templating string. |
|
||||
*/ |
|
||||
module.exports.makePropSafe = property => { |
|
||||
return `[${property}]`.replace("[[", "[").replace("]]", "]") |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Checks whether or not a template string contains totally valid syntax (simply tries running it) |
|
||||
* @param string The string to test for valid syntax - this may contain no templates and will be considered valid. |
|
||||
* @returns {boolean} Whether or not the input string is valid. |
|
||||
*/ |
|
||||
module.exports.isValid = string => { |
|
||||
const validCases = [ |
|
||||
"string", |
|
||||
"number", |
|
||||
"object", |
|
||||
"array", |
|
||||
"cannot read property", |
|
||||
"undefined", |
|
||||
] |
|
||||
// this is a portion of a specific string always output by handlebars in the case of a syntax error |
|
||||
const invalidCases = [`expecting '`] |
|
||||
// don't really need a real context to check if its valid |
|
||||
const context = {} |
|
||||
try { |
|
||||
hbsInstance.compile(processors.preprocess(string, false))(context) |
|
||||
return true |
|
||||
} catch (err) { |
|
||||
const msg = err && err.message ? err.message : err |
|
||||
if (!msg) { |
|
||||
return false |
|
||||
} |
|
||||
const invalidCase = invalidCases.some(invalidCase => |
|
||||
msg.toLowerCase().includes(invalidCase) |
|
||||
) |
|
||||
const validCase = validCases.some(validCase => |
|
||||
msg.toLowerCase().includes(validCase) |
|
||||
) |
|
||||
// special case for maths functions - don't have inputs yet |
|
||||
return validCase && !invalidCase |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* We have generated a static manifest file from the helpers that this string templating package makes use of. |
|
||||
* This manifest provides information about each of the helpers and how it can be used. |
|
||||
* @returns The manifest JSON which has been generated from the helpers. |
|
||||
*/ |
|
||||
module.exports.getManifest = () => { |
|
||||
return manifest |
|
||||
} |
|
||||
@ -0,0 +1,204 @@ |
|||||
|
const handlebars = require("handlebars") |
||||
|
const { registerAll } = require("./helpers/index") |
||||
|
const processors = require("./processors") |
||||
|
const { removeHandlebarsStatements, atob, btoa } = require("./utilities") |
||||
|
const manifest = require("../manifest.json") |
||||
|
|
||||
|
const hbsInstance = handlebars.create() |
||||
|
registerAll(hbsInstance) |
||||
|
|
||||
|
/** |
||||
|
* utility function to check if the object is valid |
||||
|
*/ |
||||
|
function testObject(object) { |
||||
|
// JSON stringify will fail if there are any cycles, stops infinite recursion
|
||||
|
try { |
||||
|
JSON.stringify(object) |
||||
|
} catch (err) { |
||||
|
throw "Unable to process inputs to JSON, cannot recurse" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Given an input object this will recurse through all props to try and update any handlebars statements within. |
||||
|
* @param {object|array} object The input structure which is to be recursed, it is important to note that |
||||
|
* if the structure contains any cycles then this will fail. |
||||
|
* @param {object} context The context that handlebars should fill data from. |
||||
|
* @returns {Promise<object|array>} The structure input, as fully updated as possible. |
||||
|
*/ |
||||
|
module.exports.processObject = async (object, context) => { |
||||
|
testObject(object) |
||||
|
for (let key of Object.keys(object || {})) { |
||||
|
if (object[key] != null) { |
||||
|
let val = object[key] |
||||
|
if (typeof val === "string") { |
||||
|
object[key] = await module.exports.processString(object[key], context) |
||||
|
} else if (typeof val === "object") { |
||||
|
object[key] = await module.exports.processObject(object[key], context) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return object |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* This will process a single handlebars containing string. If the string passed in has no valid handlebars statements |
||||
|
* then nothing will occur. |
||||
|
* @param {string} string The template string which is the filled from the context object. |
||||
|
* @param {object} context An object of information which will be used to enrich the string. |
||||
|
* @returns {Promise<string>} The enriched string, all templates should have been replaced if they can be. |
||||
|
*/ |
||||
|
module.exports.processString = async (string, context) => { |
||||
|
// TODO: carry out any async calls before carrying out async call
|
||||
|
return module.exports.processStringSync(string, context) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Given an input object this will recurse through all props to try and update any handlebars statements within. This is |
||||
|
* a pure sync call and therefore does not have the full functionality of the async call. |
||||
|
* @param {object|array} object The input structure which is to be recursed, it is important to note that |
||||
|
* if the structure contains any cycles then this will fail. |
||||
|
* @param {object} context The context that handlebars should fill data from. |
||||
|
* @returns {object|array} The structure input, as fully updated as possible. |
||||
|
*/ |
||||
|
module.exports.processObjectSync = (object, context) => { |
||||
|
testObject(object) |
||||
|
for (let key of Object.keys(object || {})) { |
||||
|
let val = object[key] |
||||
|
if (typeof val === "string") { |
||||
|
object[key] = module.exports.processStringSync(object[key], context) |
||||
|
} else if (typeof val === "object") { |
||||
|
object[key] = module.exports.processObjectSync(object[key], context) |
||||
|
} |
||||
|
} |
||||
|
return object |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* This will process a single handlebars containing string. If the string passed in has no valid handlebars statements |
||||
|
* then nothing will occur. This is a pure sync call and therefore does not have the full functionality of the async call. |
||||
|
* @param {string} string The template string which is the filled from the context object. |
||||
|
* @param {object} context An object of information which will be used to enrich the string. |
||||
|
* @returns {string} The enriched string, all templates should have been replaced if they can be. |
||||
|
*/ |
||||
|
module.exports.processStringSync = (string, context) => { |
||||
|
if (!exports.isValid(string)) { |
||||
|
return string |
||||
|
} |
||||
|
// take a copy of input incase error
|
||||
|
const input = string |
||||
|
if (typeof string !== "string") { |
||||
|
throw "Cannot process non-string types." |
||||
|
} |
||||
|
try { |
||||
|
string = processors.preprocess(string) |
||||
|
// this does not throw an error when template can't be fulfilled, have to try correct beforehand
|
||||
|
const template = hbsInstance.compile(string, { |
||||
|
strict: false, |
||||
|
}) |
||||
|
return processors.postprocess( |
||||
|
template({ |
||||
|
now: new Date().toISOString(), |
||||
|
...context, |
||||
|
}) |
||||
|
) |
||||
|
} catch (err) { |
||||
|
return removeHandlebarsStatements(input) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Simple utility function which makes sure that a templating property has been wrapped in literal specifiers correctly. |
||||
|
* @param {string} property The property which is to be wrapped. |
||||
|
* @returns {string} The wrapped property ready to be added to a templating string. |
||||
|
*/ |
||||
|
module.exports.makePropSafe = property => { |
||||
|
return `[${property}]`.replace("[[", "[").replace("]]", "]") |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Checks whether or not a template string contains totally valid syntax (simply tries running it) |
||||
|
* @param string The string to test for valid syntax - this may contain no templates and will be considered valid. |
||||
|
* @returns {boolean} Whether or not the input string is valid. |
||||
|
*/ |
||||
|
module.exports.isValid = string => { |
||||
|
const validCases = [ |
||||
|
"string", |
||||
|
"number", |
||||
|
"object", |
||||
|
"array", |
||||
|
"cannot read property", |
||||
|
"undefined", |
||||
|
] |
||||
|
// this is a portion of a specific string always output by handlebars in the case of a syntax error
|
||||
|
const invalidCases = [`expecting '`] |
||||
|
// don't really need a real context to check if its valid
|
||||
|
const context = {} |
||||
|
try { |
||||
|
hbsInstance.compile(processors.preprocess(string, false))(context) |
||||
|
return true |
||||
|
} catch (err) { |
||||
|
const msg = err && err.message ? err.message : err |
||||
|
if (!msg) { |
||||
|
return false |
||||
|
} |
||||
|
const invalidCase = invalidCases.some(invalidCase => |
||||
|
msg.toLowerCase().includes(invalidCase) |
||||
|
) |
||||
|
const validCase = validCases.some(validCase => |
||||
|
msg.toLowerCase().includes(validCase) |
||||
|
) |
||||
|
// special case for maths functions - don't have inputs yet
|
||||
|
return validCase && !invalidCase |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* We have generated a static manifest file from the helpers that this string templating package makes use of. |
||||
|
* This manifest provides information about each of the helpers and how it can be used. |
||||
|
* @returns The manifest JSON which has been generated from the helpers. |
||||
|
*/ |
||||
|
module.exports.getManifest = () => { |
||||
|
return manifest |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Checks if a HBS expression is a valid JS HBS expression |
||||
|
* @param handlebars the HBS expression to check |
||||
|
* @returns {boolean} whether the expression is JS or not |
||||
|
*/ |
||||
|
module.exports.isJSBinding = handlebars => { |
||||
|
return module.exports.decodeJSBinding(handlebars) != null |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Encodes a raw JS string as a JS HBS expression |
||||
|
* @param javascript the JS code to encode |
||||
|
* @returns {string} the JS HBS expression |
||||
|
*/ |
||||
|
module.exports.encodeJSBinding = javascript => { |
||||
|
return `{{ js "${btoa(javascript)}" }}` |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Decodes a JS HBS expression to the raw JS code |
||||
|
* @param handlebars the JS HBS expression |
||||
|
* @returns {string|null} the raw JS code |
||||
|
*/ |
||||
|
module.exports.decodeJSBinding = handlebars => { |
||||
|
if (!handlebars || typeof handlebars !== "string") { |
||||
|
return null |
||||
|
} |
||||
|
|
||||
|
// JS is only valid if it is the only HBS expression
|
||||
|
if (!handlebars.trim().startsWith("{{ js ")) { |
||||
|
return null |
||||
|
} |
||||
|
|
||||
|
const captureJSRegex = new RegExp(/{{ js "(.*)" }}/) |
||||
|
const match = handlebars.match(captureJSRegex) |
||||
|
if (!match || match.length < 2) { |
||||
|
return null |
||||
|
} |
||||
|
return atob(match[1]) |
||||
|
} |
||||
@ -1,12 +1,31 @@ |
|||||
import templates from "./index.cjs" |
import vm from "vm" |
||||
|
import templates from "./index.js" |
||||
|
import { setJSRunner } from "./helpers/javascript" |
||||
|
|
||||
/** |
/** |
||||
* This file is simply an entrypoint for rollup - makes a lot of cjs problems go away |
* ES6 entrypoint for rollup |
||||
*/ |
*/ |
||||
export const isValid = templates.isValid |
export const isValid = templates.isValid |
||||
export const makePropSafe = templates.makePropSafe |
export const makePropSafe = templates.makePropSafe |
||||
export const getManifest = templates.getManifest |
export const getManifest = templates.getManifest |
||||
|
export const isJSBinding = templates.isJSBinding |
||||
|
export const encodeJSBinding = templates.encodeJSBinding |
||||
|
export const decodeJSBinding = templates.decodeJSBinding |
||||
export const processStringSync = templates.processStringSync |
export const processStringSync = templates.processStringSync |
||||
export const processObjectSync = templates.processObjectSync |
export const processObjectSync = templates.processObjectSync |
||||
export const processString = templates.processString |
export const processString = templates.processString |
||||
export const processObject = templates.processObject |
export const processObject = templates.processObject |
||||
|
|
||||
|
/** |
||||
|
* Use polyfilled vm to run JS scripts in a browser Env |
||||
|
*/ |
||||
|
setJSRunner((js, context) => { |
||||
|
context = { |
||||
|
...context, |
||||
|
alert: undefined, |
||||
|
setInterval: undefined, |
||||
|
setTimeout: undefined, |
||||
|
} |
||||
|
vm.createContext(context) |
||||
|
return vm.runInNewContext(js, context, { timeout: 1000 }) |
||||
|
}) |
||||
@ -0,0 +1,85 @@ |
|||||
|
const { processStringSync, encodeJSBinding } = require("../src/index.cjs") |
||||
|
|
||||
|
const processJS = (js, context) => { |
||||
|
return processStringSync(encodeJSBinding(js), context) |
||||
|
} |
||||
|
|
||||
|
describe("Test the JavaScript helper", () => { |
||||
|
it("should execute a simple expression", () => { |
||||
|
const output = processJS(`return 1 + 2`) |
||||
|
expect(output).toBe("3") |
||||
|
}) |
||||
|
|
||||
|
it("should be able to use primitive bindings", () => { |
||||
|
const output = processJS(`return $("foo")`, { |
||||
|
foo: "bar", |
||||
|
}) |
||||
|
expect(output).toBe("bar") |
||||
|
}) |
||||
|
|
||||
|
it("should be able to use an object binding", () => { |
||||
|
const output = processJS(`return $("foo").bar`, { |
||||
|
foo: { |
||||
|
bar: "baz", |
||||
|
}, |
||||
|
}) |
||||
|
expect(output).toBe("baz") |
||||
|
}) |
||||
|
|
||||
|
it("should be able to use a complex object binding", () => { |
||||
|
const output = processJS(`return $("foo").bar[0].baz`, { |
||||
|
foo: { |
||||
|
bar: [ |
||||
|
{ |
||||
|
baz: "shazbat", |
||||
|
}, |
||||
|
], |
||||
|
}, |
||||
|
}) |
||||
|
expect(output).toBe("shazbat") |
||||
|
}) |
||||
|
|
||||
|
it("should be able to use a deep binding", () => { |
||||
|
const output = processJS(`return $("foo.bar.baz")`, { |
||||
|
foo: { |
||||
|
bar: { |
||||
|
baz: "shazbat", |
||||
|
}, |
||||
|
}, |
||||
|
}) |
||||
|
expect(output).toBe("shazbat") |
||||
|
}) |
||||
|
|
||||
|
it("should be able to use a deep array binding", () => { |
||||
|
const output = processJS(`return $("foo.0.bar")`, { |
||||
|
foo: [ |
||||
|
{ |
||||
|
bar: "baz", |
||||
|
}, |
||||
|
], |
||||
|
}) |
||||
|
expect(output).toBe("baz") |
||||
|
}) |
||||
|
|
||||
|
it("should handle errors", () => { |
||||
|
const output = processJS(`throw "Error"`) |
||||
|
expect(output).toBe("Error while executing JS") |
||||
|
}) |
||||
|
|
||||
|
it("should timeout after one second", () => { |
||||
|
const output = processJS(`while (true) {}`) |
||||
|
expect(output).toBe("Error while executing JS") |
||||
|
}) |
||||
|
|
||||
|
it("should prevent access to the process global", () => { |
||||
|
const output = processJS(`return process`) |
||||
|
expect(output).toBe("Error while executing JS") |
||||
|
}) |
||||
|
|
||||
|
it("should prevent sandbox escape", () => { |
||||
|
const output = processJS( |
||||
|
`return this.constructor.constructor("return process")()` |
||||
|
) |
||||
|
expect(output).toBe("Error while executing JS") |
||||
|
}) |
||||
|
}) |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue