diff --git a/packages/server/src/api/controllers/row/index.ts b/packages/server/src/api/controllers/row/index.ts index f3165f7f86..237c30cc88 100644 --- a/packages/server/src/api/controllers/row/index.ts +++ b/packages/server/src/api/controllers/row/index.ts @@ -308,16 +308,21 @@ export async function downloadAttachment(ctx: UserCtx) { if (attachments.length === 1) { const attachment = attachments[0] ctx.attachment(attachment.name) - ctx.body = await objectStore.getReadStream( - objectStore.ObjectStoreBuckets.APPS, - attachment.key - ) + if (attachment.key) { + ctx.body = await objectStore.getReadStream( + objectStore.ObjectStoreBuckets.APPS, + attachment.key + ) + } } else { const passThrough = new stream.PassThrough() const archive = archiver.create("zip") archive.pipe(passThrough) for (const attachment of attachments) { + if (!attachment.key) { + continue + } const attachmentStream = await objectStore.getReadStream( objectStore.ObjectStoreBuckets.APPS, attachment.key diff --git a/packages/server/src/sdk/app/backups/imports.ts b/packages/server/src/sdk/app/backups/imports.ts index a16bfb418d..f69fb9f5c8 100644 --- a/packages/server/src/sdk/app/backups/imports.ts +++ b/packages/server/src/sdk/app/backups/imports.ts @@ -34,7 +34,7 @@ type TemplateType = { function rewriteAttachmentUrl(appId: string, attachment: RowAttachment) { // URL looks like: /prod-budi-app-assets/appId/attachments/file.csv - const urlParts = attachment.key.split("/") + const urlParts = attachment.key?.split("/") || [] // remove the app ID urlParts.shift() // add new app ID diff --git a/packages/server/src/utilities/rowProcessor/attachments.ts b/packages/server/src/utilities/rowProcessor/attachments.ts index 4b0cc38cb1..14b830fe50 100644 --- a/packages/server/src/utilities/rowProcessor/attachments.ts +++ b/packages/server/src/utilities/rowProcessor/attachments.ts @@ -44,8 +44,8 @@ export class AttachmentCleanup { if (type === FieldType.ATTACHMENTS && Array.isArray(rowData)) { return rowData .filter(attachment => attachment.key) - .map(attachment => attachment.key) - } else if ("key" in rowData) { + .map(attachment => attachment.key!) + } else if ("key" in rowData && rowData.key) { return [rowData.key] } diff --git a/packages/string-templates/src/index.ts b/packages/string-templates/src/index.ts index 0992813e9d..8d5fe4c16d 100644 --- a/packages/string-templates/src/index.ts +++ b/packages/string-templates/src/index.ts @@ -1,17 +1,18 @@ import { Context, createContext, runInNewContext } from "vm" import { create, TemplateDelegate } from "handlebars" import { registerAll, registerMinimum } from "./helpers/index" -import { preprocess, postprocess } from "./processors" +import { postprocess, preprocess } from "./processors" import { atob, btoa, - isBackendService, - FIND_HBS_REGEX, FIND_ANY_HBS_REGEX, + FIND_HBS_REGEX, findDoubleHbsInstances, + isBackendService, + prefixStrings, } from "./utilities" import { convertHBSBlock } from "./conversion" -import { setJSRunner, removeJSRunner } from "./helpers/javascript" +import { removeJSRunner, setJSRunner } from "./helpers/javascript" import manifest from "./manifest.json" import { ProcessOptions } from "./types" @@ -23,6 +24,7 @@ export { iifeWrapper } from "./iife" const hbsInstance = create() registerAll(hbsInstance) +const helperNames = Object.keys(hbsInstance.helpers) const hbsInstanceNoHelpers = create() registerMinimum(hbsInstanceNoHelpers) const defaultOpts: ProcessOptions = { @@ -45,12 +47,25 @@ function testObject(object: any) { } } +function findOverlappingHelpers(context?: object) { + if (!context) { + return [] + } + const contextKeys = Object.keys(context) + return contextKeys.filter(key => helperNames.includes(key)) +} + /** * Creates a HBS template function for a given string, and optionally caches it. */ const templateCache: Record> = {} -function createTemplate(string: string, opts?: ProcessOptions) { +function createTemplate( + string: string, + opts?: ProcessOptions, + context?: object +) { opts = { ...defaultOpts, ...opts } + const helpersEnabled = !opts?.noHelpers // Finalising adds a helper, can't do this with no helpers const key = `${string}-${JSON.stringify(opts)}` @@ -60,7 +75,25 @@ function createTemplate(string: string, opts?: ProcessOptions) { return templateCache[key] } - string = preprocess(string, opts) + const overlappingHelpers = helpersEnabled + ? findOverlappingHelpers(context) + : [] + + string = preprocess(string, { + ...opts, + disabledHelpers: overlappingHelpers, + }) + + if (context && helpersEnabled) { + if (overlappingHelpers.length > 0) { + for (const block of findHBSBlocks(string)) { + string = string.replace( + block, + prefixStrings(block, overlappingHelpers, "./") + ) + } + } + } // Optionally disable built in HBS escaping if (opts.noEscaping) { @@ -70,6 +103,7 @@ function createTemplate(string: string, opts?: ProcessOptions) { // This does not throw an error when template can't be fulfilled, // have to try correct beforehand const instance = opts.noHelpers ? hbsInstanceNoHelpers : hbsInstance + const template = instance.compile(string, { strict: false, }) @@ -171,7 +205,8 @@ export function processStringSync( throw "Cannot process non-string types." } function process(stringPart: string) { - const template = createTemplate(stringPart, opts) + // context is needed to check for overlap between helpers and context + const template = createTemplate(stringPart, opts, context) const now = Math.floor(Date.now() / 1000) * 1000 const processedString = template({ now: new Date(now).toISOString(), diff --git a/packages/string-templates/src/processors/index.ts b/packages/string-templates/src/processors/index.ts index 308ac5adf4..79085b0dfe 100644 --- a/packages/string-templates/src/processors/index.ts +++ b/packages/string-templates/src/processors/index.ts @@ -29,9 +29,9 @@ export function preprocess(string: string, opts: ProcessOptions) { processor => processor.name !== preprocessor.PreprocessorNames.FINALISE ) } + return process(string, processors, opts) } export function postprocess(string: string) { - let processors = postprocessor.processors - return process(string, processors) + return process(string, postprocessor.processors) } diff --git a/packages/string-templates/src/processors/postprocessor.ts b/packages/string-templates/src/processors/postprocessor.ts index b8d99682b1..6ddc0e67cd 100644 --- a/packages/string-templates/src/processors/postprocessor.ts +++ b/packages/string-templates/src/processors/postprocessor.ts @@ -1,19 +1,21 @@ import { LITERAL_MARKER } from "../helpers/constants" -export const PostProcessorNames = { - CONVERT_LITERALS: "convert-literals", +export enum PostProcessorNames { + CONVERT_LITERALS = "convert-literals", } -class Postprocessor { - name: string - private fn: any +type PostprocessorFn = (statement: string) => string - constructor(name: string, fn: any) { +class Postprocessor { + name: PostProcessorNames + private readonly fn: PostprocessorFn + + constructor(name: PostProcessorNames, fn: PostprocessorFn) { this.name = name this.fn = fn } - process(statement: any) { + process(statement: string) { return this.fn(statement) } } diff --git a/packages/string-templates/src/processors/preprocessor.ts b/packages/string-templates/src/processors/preprocessor.ts index 5e96336e32..97e5c56fcc 100644 --- a/packages/string-templates/src/processors/preprocessor.ts +++ b/packages/string-templates/src/processors/preprocessor.ts @@ -1,25 +1,28 @@ import { HelperNames } from "../helpers" import { swapStrings, isAlphaNumeric } from "../utilities" +import { ProcessOptions } from "../types" const FUNCTION_CASES = ["#", "else", "/"] -export const PreprocessorNames = { - SWAP_TO_DOT: "swap-to-dot-notation", - FIX_FUNCTIONS: "fix-functions", - FINALISE: "finalise", - NORMALIZE_SPACES: "normalize-spaces", +export enum PreprocessorNames { + SWAP_TO_DOT = "swap-to-dot-notation", + FIX_FUNCTIONS = "fix-functions", + FINALISE = "finalise", + NORMALIZE_SPACES = "normalize-spaces", } +type PreprocessorFn = (statement: string, opts?: ProcessOptions) => string + class Preprocessor { name: string - private fn: any + private readonly fn: PreprocessorFn - constructor(name: string, fn: any) { + constructor(name: PreprocessorNames, fn: PreprocessorFn) { this.name = name this.fn = fn } - process(fullString: string, statement: string, opts: Object) { + process(fullString: string, statement: string, opts: ProcessOptions) { const output = this.fn(statement, opts) const idx = fullString.indexOf(statement) return swapStrings(fullString, idx, statement.length, output) @@ -56,8 +59,9 @@ export const processors = [ }), new Preprocessor( PreprocessorNames.FINALISE, - (statement: string, opts: { noHelpers: any }) => { - const noHelpers = opts && opts.noHelpers + (statement: string, opts?: ProcessOptions) => { + const noHelpers = opts?.noHelpers + const helpersEnabled = !noHelpers let insideStatement = statement.slice(2, statement.length - 2) if (insideStatement.charAt(0) === " ") { insideStatement = insideStatement.slice(1) @@ -74,7 +78,8 @@ export const processors = [ } const testHelper = possibleHelper.trim().toLowerCase() if ( - !noHelpers && + helpersEnabled && + !opts?.disabledHelpers?.includes(testHelper) && HelperNames().some(option => testHelper === option.toLowerCase()) ) { insideStatement = `(${insideStatement})` diff --git a/packages/string-templates/src/types.ts b/packages/string-templates/src/types.ts index 1e1ef048a4..19785b9273 100644 --- a/packages/string-templates/src/types.ts +++ b/packages/string-templates/src/types.ts @@ -5,4 +5,5 @@ export interface ProcessOptions { noFinalise?: boolean escapeNewlines?: boolean onlyFound?: boolean + disabledHelpers?: string[] } diff --git a/packages/string-templates/src/utilities.ts b/packages/string-templates/src/utilities.ts index bcb9987c89..18e0926b96 100644 --- a/packages/string-templates/src/utilities.ts +++ b/packages/string-templates/src/utilities.ts @@ -66,3 +66,16 @@ export const btoa = (plainText: string) => { export const atob = (base64: string) => { return Buffer.from(base64, "base64").toString("utf-8") } + +export const prefixStrings = ( + baseString: string, + strings: string[], + prefix: string +) => { + // Escape any special characters in the strings to avoid regex errors + const escapedStrings = strings.map(str => + str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + ) + const regexPattern = new RegExp(`\\b(${escapedStrings.join("|")})\\b`, "g") + return baseString.replace(regexPattern, `${prefix}$1`) +} diff --git a/packages/string-templates/test/helpers.spec.ts b/packages/string-templates/test/helpers.spec.ts index 5f1855535d..12de4f1c29 100644 --- a/packages/string-templates/test/helpers.spec.ts +++ b/packages/string-templates/test/helpers.spec.ts @@ -483,3 +483,37 @@ describe("uuid", () => { expect(output).toMatch(UUID_REGEX) }) }) + +describe("helper overlap", () => { + it("should use context over helpers (regex test helper)", async () => { + const output = await processString("{{ test }}", { test: "a" }) + expect(output).toEqual("a") + }) + + it("should use helper if no sum in context, return the context value otherwise", async () => { + const hbs = "{{ sum 1 2 }}" + const output = await processString(hbs, {}) + expect(output).toEqual("3") + const secondaryOutput = await processString(hbs, { sum: "a" }) + expect(secondaryOutput).toEqual("a") + }) + + it("should handle multiple cases", async () => { + const output = await processString("{{ literal (split test sum) }}", { + test: "a-b", + sum: "-", + }) + expect(output).toEqual(["a", "b"]) + }) + + it("should work as expected when no helpers are set", async () => { + const output = await processString( + "{{ sum }}", + { + sum: "a", + }, + { noHelpers: true } + ) + expect(output).toEqual("a") + }) +}) diff --git a/packages/types/src/documents/app/row.ts b/packages/types/src/documents/app/row.ts index 620e5373d8..42440b2988 100644 --- a/packages/types/src/documents/app/row.ts +++ b/packages/types/src/documents/app/row.ts @@ -131,7 +131,7 @@ export interface RowAttachment { size: number name: string extension: string - key: string + key?: string // Populated on read url?: string }