UNPKG

@budibase/core

Version:

core javascript library for budibase

1,929 lines (1,630 loc) 972 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } var fp = require('lodash/fp'); var shortid = require('shortid'); var _ = require('lodash'); var ___default = _interopDefault(_); var bcrypt = _interopDefault(require('bcryptjs')); var compilerUtil = require('@nx-js/compiler-util'); var lunr = _interopDefault(require('lunr')); var safeBuffer = require('safe-buffer'); const commonPlus = extra => fp.union(["onBegin", "onComplete", "onError"])(extra); const common = () => commonPlus([]); const _events = { recordApi: { save: commonPlus(["onInvalid", "onRecordUpdated", "onRecordCreated"]), delete: common(), getContext: common(), getNew: common(), load: common(), validate: common(), uploadFile: common(), downloadFile: common(), }, indexApi: { buildIndex: common(), listItems: common(), delete: common(), aggregates: common(), }, collectionApi: { getAllowedRecordTypes: common(), initialise: common(), delete: common(), }, authApi: { authenticate: common(), authenticateTemporaryAccess: common(), createTemporaryAccess: common(), createUser: common(), enableUser: common(), disableUser: common(), loadAccessLevels: common(), getNewAccessLevel: common(), getNewUser: common(), getNewUserAuth: common(), getUsers: common(), saveAccessLevels: common(), isAuthorized: common(), changeMyPassword: common(), setPasswordFromTemporaryCode: common(), scorePassword: common(), isValidPassword: common(), validateUser: common(), validateAccessLevels: common(), setUserAccessLevels: common(), }, templateApi: { saveApplicationHierarchy: common(), saveActionsAndTriggers: common(), }, actionsApi: { execute: common(), }, }; const _eventsList = []; const makeEvent = (area, method, name) => `${area}:${method}:${name}`; for (const areaKey in _events) { for (const methodKey in _events[areaKey]) { _events[areaKey][methodKey] = fp.reduce((obj, s) => { obj[s] = makeEvent(areaKey, methodKey, s); return obj }, {})(_events[areaKey][methodKey]); } } for (const areaKey in _events) { for (const methodKey in _events[areaKey]) { for (const name in _events[areaKey][methodKey]) { _eventsList.push(_events[areaKey][methodKey][name]); } } } const events = _events; const eventsList = _eventsList; class BadRequestError extends Error { constructor(message) { super(message); this.httpStatusCode = 400; } } class UnauthorisedError extends Error { constructor(message) { super(message); this.httpStatusCode = 401; } } class ForbiddenError extends Error { constructor(message) { super(message); this.httpStatusCode = 403; } } class NotFoundError extends Error { constructor(message) { super(message); this.httpStatusCode = 404; } } const apiWrapper = async ( app, eventNamespace, isAuthorized, eventContext, func, ...params ) => { pushCallStack(app, eventNamespace); if (!isAuthorized(app)) { handleNotAuthorized(app, eventContext, eventNamespace); return } const startDate = Date.now(); const elapsed = () => Date.now() - startDate; try { await app.publish(eventNamespace.onBegin, eventContext); const result = await func(...params); await publishComplete(app, eventContext, eventNamespace, elapsed, result); return result } catch (error) { await publishError(app, eventContext, eventNamespace, elapsed, error); throw error } }; const apiWrapperSync = ( app, eventNamespace, isAuthorized, eventContext, func, ...params ) => { pushCallStack(app, eventNamespace); if (!isAuthorized(app)) { handleNotAuthorized(app, eventContext, eventNamespace); return } const startDate = Date.now(); const elapsed = () => Date.now() - startDate; try { app.publish(eventNamespace.onBegin, eventContext); const result = func(...params); publishComplete(app, eventContext, eventNamespace, elapsed, result); return result } catch (error) { publishError(app, eventContext, eventNamespace, elapsed, error); throw error } }; const handleNotAuthorized = (app, eventContext, eventNamespace) => { const err = new UnauthorisedError(`Unauthorized: ${eventNamespace}`); publishError(app, eventContext, eventNamespace, () => 0, err); throw err }; const pushCallStack = (app, eventNamespace, seedCallId) => { const callId = shortid.generate(); const createCallStack = () => ({ seedCallId: !fp.isUndefined(seedCallId) ? seedCallId : callId, threadCallId: callId, stack: [], }); if (fp.isUndefined(app.calls)) { app.calls = createCallStack(); } app.calls.stack.push({ namespace: eventNamespace, callId, }); }; const popCallStack = app => { app.calls.stack.pop(); if (app.calls.stack.length === 0) { delete app.calls; } }; const publishError = async ( app, eventContext, eventNamespace, elapsed, err ) => { const ctx = fp.cloneDeep(eventContext); ctx.error = err; ctx.elapsed = elapsed(); await app.publish(eventNamespace.onError, ctx); popCallStack(app); }; const publishComplete = async ( app, eventContext, eventNamespace, elapsed, result ) => { const endcontext = fp.cloneDeep(eventContext); endcontext.result = result; endcontext.elapsed = elapsed(); await app.publish(eventNamespace.onComplete, endcontext); popCallStack(app); return result }; const lockOverlapMilliseconds = 10; const getLock = async ( app, lockFile, timeoutMilliseconds, maxLockRetries, retryCount = 0 ) => { try { const timeout = (await app.getEpochTime()) + timeoutMilliseconds; const lock = { timeout, key: lockFile, totalTimeout: timeoutMilliseconds, }; await app.datastore.createFile( lockFile, getLockFileContent(lock.totalTimeout, lock.timeout) ); return lock } catch (e) { if (retryCount == maxLockRetries) { return NO_LOCK } const lock = parseLockFileContent( lockFile, await app.datastore.loadFile(lockFile) ); const currentEpochTime = await app.getEpochTime(); if (currentEpochTime < lock.timeout) { return NO_LOCK } try { await app.datastore.deleteFile(lockFile); } catch (_) { //empty } await sleepForRetry(); return await getLock( app, lockFile, timeoutMilliseconds, maxLockRetries, retryCount + 1 ) } }; const getLockFileContent = (totalTimeout, epochTime) => `${totalTimeout}:${epochTime.toString()}`; const parseLockFileContent = (key, content) => $(content, [ fp.split(":"), parts => ({ totalTimeout: new Number(parts[0]), timeout: new Number(parts[1]), key, }), ]); const releaseLock = async (app, lock) => { const currentEpochTime = await app.getEpochTime(); // only release if not timedout if (currentEpochTime < lock.timeout - lockOverlapMilliseconds) { try { await app.datastore.deleteFile(lock.key); } catch (_) { //empty } } }; const NO_LOCK = "no lock"; const isNolock = id => id === NO_LOCK; const sleepForRetry = () => new Promise(resolve => setTimeout(resolve, lockOverlapMilliseconds)); function hash(password) { return bcrypt.hashSync(password, 10) } function verify(hash, password) { return bcrypt.compareSync(password, hash) } var nodeCrypto = { hash, verify, }; // this is the combinator function const $$ = (...funcs) => arg => _.flow(funcs)(arg); // this is the pipe function const $ = (arg, funcs) => $$(...funcs)(arg); const keySep = "/"; const trimKeySep = str => _.trim(str, keySep); const splitByKeySep = str => fp.split(keySep)(str); const safeKey = key => _.replace(`${keySep}${trimKeySep(key)}`, `${keySep}${keySep}`, keySep); const joinKey = (...strs) => { const paramsOrArray = (strs.length === 1) & fp.isArray(strs[0]) ? strs[0] : strs; return $(paramsOrArray, [ fp.filter(s => !fp.isUndefined(s) && !fp.isNull(s) && s.toString().length > 0), fp.join(keySep), safeKey, ]) }; const splitKey = $$(trimKeySep, splitByKeySep); const getDirFomKey = $$(splitKey, _.dropRight, p => joinKey(...p)); const getFileFromKey = $$(splitKey, _.takeRight, _.head); const configFolder = `${keySep}.config`; const fieldDefinitions = joinKey(configFolder, "fields.json"); const templateDefinitions = joinKey(configFolder, "templates.json"); const appDefinitionFile = joinKey(configFolder, "appDefinition.json"); const dirIndex = folderPath => joinKey(configFolder, "dir", ...splitKey(folderPath), "dir.idx"); const getIndexKeyFromFileKey = $$(getDirFomKey, dirIndex); const ifExists = (val, exists, notExists) => fp.isUndefined(val) ? fp.isUndefined(notExists) ? (() => {})() : notExists() : exists(); const getOrDefault = (val, defaultVal) => ifExists( val, () => val, () => defaultVal ); const not = func => val => !func(val); const isDefined = not(fp.isUndefined); const isNonNull = not(fp.isNull); const isNotNaN = not(fp.isNaN); const allTrue = (...funcArgs) => val => fp.reduce( (result, conditionFunc) => (fp.isNull(result) || result == true) && conditionFunc(val), null )(funcArgs); const anyTrue = (...funcArgs) => val => fp.reduce( (result, conditionFunc) => result == true || conditionFunc(val), null )(funcArgs); const insensitiveEquals = (str1, str2) => str1.trim().toLowerCase() === str2.trim().toLowerCase(); const isSomething = allTrue(isDefined, isNonNull, isNotNaN); const isNothing = not(isSomething); const isNothingOrEmpty = v => isNothing(v) || fp.isEmpty(v); const somethingOrGetDefault = getDefaultFunc => val => isSomething(val) ? val : getDefaultFunc(); const somethingOrDefault = (val, defaultVal) => somethingOrGetDefault(fp.constant(defaultVal))(val); const mapIfSomethingOrDefault = (mapFunc, defaultVal) => val => isSomething(val) ? mapFunc(val) : defaultVal; const mapIfSomethingOrBlank = mapFunc => mapIfSomethingOrDefault(mapFunc, ""); const none = predicate => collection => !fp.some(predicate)(collection); const all = predicate => collection => none(v => !predicate(v))(collection); const isNotEmpty = ob => !fp.isEmpty(ob); const isNonEmptyArray = allTrue(fp.isArray, isNotEmpty); const isNonEmptyString = allTrue(fp.isString, isNotEmpty); const tryOr = failFunc => (func, ...args) => { try { return func.apply(null, ...args) } catch (_) { return failFunc() } }; const tryAwaitOr = failFunc => async (func, ...args) => { try { return await func.apply(null, ...args) } catch (_) { return await failFunc() } }; const defineError = (func, errorPrefix) => { try { return func() } catch (err) { err.message = `${errorPrefix} : ${err.message}`; throw err } }; const tryOrIgnore = tryOr(() => {}); const tryAwaitOrIgnore = tryAwaitOr(async () => {}); const causesException = func => { try { func(); return false } catch (e) { return true } }; const executesWithoutException = func => !causesException(func); const handleErrorWith = returnValInError => tryOr(fp.constant(returnValInError)); const handleErrorWithUndefined = handleErrorWith(undefined); const switchCase = (...cases) => value => { const nextCase = () => _.head(cases)[0](value); const nextResult = () => _.head(cases)[1](value); if (fp.isEmpty(cases)) return // undefined if (nextCase() === true) return nextResult() return switchCase(..._.tail(cases))(value) }; const isValue = val1 => val2 => val1 === val2; const isOneOf = (...vals) => val => fp.includes(val)(vals); const defaultCase = fp.constant(true); const memberMatches = (member, match) => obj => match(obj[member]); const StartsWith = searchFor => searchIn => _.startsWith(searchIn, searchFor); const contains = val => array => _.findIndex(array, v => v === val) > -1; const getHashCode = s => { let hash = 0; let i; let char; let l; if (s.length == 0) return hash for (i = 0, l = s.length; i < l; i++) { char = s.charCodeAt(i); hash = (hash << 5) - hash + char; hash |= 0; // Convert to 32bit integer } // converting to string, but dont want a "-" prefixed if (hash < 0) { return `n${(hash * -1).toString()}` } return hash.toString() }; // thanks to https://blog.grossman.io/how-to-write-async-await-without-try-catch-blocks-in-javascript/ const awEx = async promise => { try { const result = await promise; return [undefined, result] } catch (error) { return [error, undefined] } }; const isSafeInteger = n => fp.isInteger(n) && n <= Number.MAX_SAFE_INTEGER && n >= 0 - Number.MAX_SAFE_INTEGER; const toDateOrNull = s => fp.isNull(s) ? null : fp.isDate(s) ? s : new Date(s); const toBoolOrNull = s => (fp.isNull(s) ? null : s === "true" || s === true); const toNumberOrNull = s => (fp.isNull(s) ? null : fp.toNumber(s)); const isArrayOfString = opts => fp.isArray(opts) && all(fp.isString)(opts); const pushAll = (target, items) => { for (let i of items) target.push(i); }; const pause = async duration => new Promise(res => setTimeout(res, duration)); const retry = async (fn, retries, delay, ...args) => { try { return await fn(...args) } catch (err) { if (retries > 1) { return await pause(delay).then( async () => await retry(fn, retries - 1, delay, ...args) ) } throw err } }; var index = { ifExists, getOrDefault, isDefined, isNonNull, isNotNaN, allTrue, isSomething, mapIfSomethingOrDefault, mapIfSomethingOrBlank, configFolder, fieldDefinitions, isNothing, not, switchCase, defaultCase, StartsWith, contains, templateDefinitions, handleErrorWith, handleErrorWithUndefined, tryOr, tryOrIgnore, tryAwaitOr, tryAwaitOrIgnore, dirIndex, keySep, $, $$, getDirFomKey, getFileFromKey, splitKey, somethingOrDefault, getIndexKeyFromFileKey, joinKey, somethingOrGetDefault, appDefinitionFile, isValue, all, isOneOf, memberMatches, defineError, anyTrue, isNonEmptyArray, causesException, executesWithoutException, none, getHashCode, awEx, apiWrapper, events, eventsList, isNothingOrEmpty, isSafeInteger, toNumber: fp.toNumber, toDate: toDateOrNull, toBool: toBoolOrNull, isArrayOfString, getLock, NO_LOCK, isNolock, insensitiveEquals, pause, retry, pushAll, }; const stringNotEmpty = s => isSomething(s) && s.trim().length > 0; const makerule = (field, error, isValid) => ({ field, error, isValid }); const validationError = (rule, item) => ({ ...rule, item }); const applyRuleSet = ruleSet => itemToValidate => $(ruleSet, [fp.map(applyRule(itemToValidate)), fp.filter(isSomething)]); const applyRule = itemTovalidate => rule => rule.isValid(itemTovalidate) ? null : validationError(rule, itemTovalidate); const compileCode = code => { let func; let safeCode; if (fp.includes("return ")(code)) { safeCode = code; } else { let trimmed = code.trim(); trimmed = trimmed.endsWith(";") ? trimmed.substring(0, trimmed.length - 1) : trimmed; safeCode = `return (${trimmed})`; } try { func = compilerUtil.compileCode(safeCode); } catch (e) { e.message = `Error compiling code : ${code} : ${e.message}`; throw e } return func }; const filterEval = "FILTER_EVALUATE"; const filterCompile = "FILTER_COMPILE"; const mapEval = "MAP_EVALUATE"; const mapCompile = "MAP_COMPILE"; const getEvaluateResult = () => ({ isError: false, passedFilter: true, result: null, }); const compileFilter = index => compileCode(index.filter); const compileMap = index => compileCode(index.map); const passesFilter = (record, index) => { const context = { record }; if (!index.filter) return true const compiledFilter = defineError(() => compileFilter(index), filterCompile); return defineError(() => compiledFilter(context), filterEval) }; const mapRecord = (record, index) => { const recordClone = fp.cloneDeep(record); const context = { record: recordClone }; const map = index.map ? index.map : "return {...record};"; const compiledMap = defineError(() => compileCode(map), mapCompile); const mapped = defineError(() => compiledMap(context), mapEval); const mappedKeys = fp.keys(mapped); for (let i = 0; i < mappedKeys.length; i++) { const key = mappedKeys[i]; mapped[key] = fp.isUndefined(mapped[key]) ? null : mapped[key]; if (fp.isFunction(mapped[key])) { delete mapped[key]; } if (key === "IsNew") { delete mapped.IsNew; } } mapped.key = record.key; mapped.sortKey = index.getSortKey ? compileCode(index.getSortKey)(context) : record.id; return mapped }; const evaluate = record => index => { const result = getEvaluateResult(); try { result.passedFilter = passesFilter(record, index); } catch (err) { result.isError = true; result.passedFilter = false; result.result = err.message; } if (!result.passedFilter) return result try { result.result = mapRecord(record, index); } catch (err) { result.isError = true; result.result = err.message; } return result }; const indexTypes = { reference: "reference", ancestor: "ancestor" }; const indexRuleSet = [ makerule("map", "index has no map function", index => isNonEmptyString(index.map) ), makerule( "map", "index's map function does not compile", index => !isNonEmptyString(index.map) || executesWithoutException(() => compileMap(index)) ), makerule( "filter", "index's filter function does not compile", index => !isNonEmptyString(index.filter) || executesWithoutException(() => compileFilter(index)) ), makerule("name", "must declare a name for index", index => isNonEmptyString(index.name) ), makerule( "name", "there is a duplicate named index on this node", index => fp.isEmpty(index.name) || fp.countBy("name")(index.parent().indexes)[index.name] === 1 ), makerule( "indexType", "reference index may only exist on a record node", index => isRecord(index.parent()) || index.indexType !== indexTypes.reference ), makerule( "indexType", `index type must be one of: ${fp.join(", ")(fp.keys(indexTypes))}`, index => fp.includes(index.indexType)(fp.keys(indexTypes)) ), ]; const getFlattenedHierarchy = (appHierarchy, useCached = true) => { if (isSomething(appHierarchy.getFlattenedHierarchy) && useCached) { return appHierarchy.getFlattenedHierarchy() } const flattenHierarchy = (currentNode, flattened) => { flattened.push(currentNode); if ( (!currentNode.children || currentNode.children.length === 0) && (!currentNode.indexes || currentNode.indexes.length === 0) && (!currentNode.aggregateGroups || currentNode.aggregateGroups.length === 0) ) { return flattened } const unionIfAny = l2 => l1 => fp.union(l1)(!l2 ? [] : l2); const children = $( [], [ unionIfAny(currentNode.children), unionIfAny(currentNode.indexes), unionIfAny(currentNode.aggregateGroups), ] ); for (const child of children) { flattenHierarchy(child, flattened); } return flattened }; appHierarchy.getFlattenedHierarchy = () => flattenHierarchy(appHierarchy, []); return appHierarchy.getFlattenedHierarchy() }; const getLastPartInKey = key => fp.last(splitKey(key)); const getNodesInPath = appHierarchy => key => $(appHierarchy, [ getFlattenedHierarchy, fp.filter(n => new RegExp(`${n.pathRegx()}`).test(key)), ]); const getExactNodeForKey = appHierarchy => key => $(appHierarchy, [ getFlattenedHierarchy, fp.find(n => new RegExp(`${n.pathRegx()}$`).test(key)), ]); const getNodeForCollectionPath = appHierarchy => collectionKey => $(appHierarchy, [ getFlattenedHierarchy, fp.find( n => isCollectionRecord(n) && new RegExp(`${n.collectionPathRegx()}$`).test(collectionKey) ), ]); const hasMatchingAncestor = ancestorPredicate => decendantNode => switchCase( [node => isNothing(node.parent()), fp.constant(false)], [node => ancestorPredicate(node.parent()), fp.constant(true)], [defaultCase, node => hasMatchingAncestor(ancestorPredicate)(node.parent())] )(decendantNode); const getNode = (appHierarchy, nodeKey) => $(appHierarchy, [ getFlattenedHierarchy, fp.find( n => n.nodeKey() === nodeKey || (isCollectionRecord(n) && n.collectionNodeKey() === nodeKey) ), ]); const getCollectionNode = (appHierarchy, nodeKey) => $(appHierarchy, [ getFlattenedHierarchy, fp.find(n => isCollectionRecord(n) && n.collectionNodeKey() === nodeKey), ]); const getNodeByKeyOrNodeKey = (appHierarchy, keyOrNodeKey) => { const nodeByKey = getExactNodeForKey(appHierarchy)(keyOrNodeKey); return isNothing(nodeByKey) ? getNode(appHierarchy, keyOrNodeKey) : nodeByKey }; const getCollectionNodeByKeyOrNodeKey = (appHierarchy, keyOrNodeKey) => { const nodeByKey = getNodeForCollectionPath(appHierarchy)(keyOrNodeKey); return isNothing(nodeByKey) ? getCollectionNode(appHierarchy, keyOrNodeKey) : nodeByKey }; const isNode = (appHierarchy, key) => isSomething(getExactNodeForKey(appHierarchy)(key)); const getActualKeyOfParent = (parentNodeKey, actualChildKey) => $(actualChildKey, [ splitKey, fp.take(splitKey(parentNodeKey).length), ks => joinKey(...ks), ]); const getParentKey = key => { return $(key, [splitKey, fp.take(splitKey(key).length - 1), joinKey]) }; const isKeyAncestorOf = ancestorKey => decendantNode => hasMatchingAncestor(p => p.nodeKey() === ancestorKey)(decendantNode); const hasNoMatchingAncestors = parentPredicate => node => !hasMatchingAncestor(parentPredicate)(node); const findField = (recordNode, fieldName) => fp.find(f => f.name == fieldName)(recordNode.fields); const isAncestor = decendant => ancestor => isKeyAncestorOf(ancestor.nodeKey())(decendant); const isDecendant = ancestor => decendant => isAncestor(decendant)(ancestor); const getRecordNodeId = recordKey => $(recordKey, [splitKey, fp.last, getRecordNodeIdFromId]); const getRecordNodeIdFromId = recordId => $(recordId, [fp.split("-"), fp.first, parseInt]); const getRecordNodeById = (hierarchy, recordId) => $(hierarchy, [ getFlattenedHierarchy, fp.find(n => isRecord(n) && n.nodeId === getRecordNodeIdFromId(recordId)), ]); const recordNodeIdIsAllowed = indexNode => nodeId => indexNode.allowedRecordNodeIds.length === 0 || fp.includes(nodeId)(indexNode.allowedRecordNodeIds); const recordNodeIsAllowed = indexNode => recordNode => recordNodeIdIsAllowed(indexNode)(recordNode.nodeId); const getAllowedRecordNodesForIndex = (appHierarchy, indexNode) => { const recordNodes = $(appHierarchy, [getFlattenedHierarchy, fp.filter(isRecord)]); if (isGlobalIndex(indexNode)) { return $(recordNodes, [fp.filter(recordNodeIsAllowed(indexNode))]) } if (isAncestorIndex(indexNode)) { return $(recordNodes, [ fp.filter(isDecendant(indexNode.parent())), fp.filter(recordNodeIsAllowed(indexNode)), ]) } if (isReferenceIndex(indexNode)) { return $(recordNodes, [ fp.filter(n => fp.some(fieldReversesReferenceToIndex(indexNode))(n.fields)), ]) } }; const getDependantIndexes = (hierarchy, recordNode) => { const allIndexes = $(hierarchy, [getFlattenedHierarchy, fp.filter(isIndex)]); const allowedAncestors = $(allIndexes, [ fp.filter(isAncestorIndex), fp.filter(i => recordNodeIsAllowed(i)(recordNode)), ]); const allowedReference = $(allIndexes, [ fp.filter(isReferenceIndex), fp.filter(i => fp.some(fieldReversesReferenceToIndex(i))(recordNode.fields)), ]); return [...allowedAncestors, ...allowedReference] }; const getNodeFromNodeKeyHash = hierarchy => hash => $(hierarchy, [ getFlattenedHierarchy, fp.find(n => getHashCode(n.nodeKey()) === hash), ]); const isRecord = node => isSomething(node) && node.type === "record"; const isSingleRecord = node => isRecord(node) && node.isSingle; const isCollectionRecord = node => isRecord(node) && !node.isSingle; const isIndex = node => isSomething(node) && node.type === "index"; const isaggregateGroup = node => isSomething(node) && node.type === "aggregateGroup"; const isShardedIndex = node => isIndex(node) && isNonEmptyString(node.getShardName); const isRoot = node => isSomething(node) && node.isRoot(); const findRoot = node => (isRoot(node) ? node : findRoot(node.parent())); const isDecendantOfARecord = hasMatchingAncestor(isRecord); const isGlobalIndex = node => isIndex(node) && isRoot(node.parent()); const isReferenceIndex = node => isIndex(node) && node.indexType === indexTypes.reference; const isAncestorIndex = node => isIndex(node) && node.indexType === indexTypes.ancestor; const isTopLevelRecord = node => isRoot(node.parent()) && isRecord(node); const isTopLevelIndex = node => isRoot(node.parent()) && isIndex(node); const fieldReversesReferenceToNode = node => field => field.type === "reference" && fp.intersection(field.typeOptions.reverseIndexNodeKeys)( fp.map(i => i.nodeKey())(node.indexes) ).length > 0; const fieldReversesReferenceToIndex = indexNode => field => field.type === "reference" && fp.intersection(field.typeOptions.reverseIndexNodeKeys)([indexNode.nodeKey()]) .length > 0; const nodeNameFromNodeKey = (hierarchy, nodeKey) => { const node = getNode(hierarchy, nodeKey); return node ? node.nodeName() : "" }; var hierarchy = { getLastPartInKey, getNodesInPath, getExactNodeForKey, hasMatchingAncestor, getNode, getNodeByKeyOrNodeKey, isNode, getActualKeyOfParent, getParentKey, isKeyAncestorOf, hasNoMatchingAncestors, findField, isAncestor, isDecendant, getRecordNodeId, getRecordNodeIdFromId, getRecordNodeById, recordNodeIdIsAllowed, recordNodeIsAllowed, getAllowedRecordNodesForIndex, getNodeFromNodeKeyHash, isRecord, isCollectionRecord, isIndex, isaggregateGroup, isShardedIndex, isRoot, isDecendantOfARecord, isGlobalIndex, isReferenceIndex, isAncestorIndex, fieldReversesReferenceToNode, fieldReversesReferenceToIndex, getFlattenedHierarchy, isTopLevelIndex, isTopLevelRecord, nodeNameFromNodeKey, }; const getSafeFieldParser = (tryParse, defaultValueFunctions) => ( field, record ) => { if (fp.has(field.name)(record)) { return getSafeValueParser( tryParse, defaultValueFunctions )(record[field.name]) } return defaultValueFunctions[field.getUndefinedValue]() }; const getSafeValueParser = ( tryParse, defaultValueFunctions ) => value => { const parsed = tryParse(value); if (parsed.success) { return parsed.value } return defaultValueFunctions.default() }; const getNewValue = (tryParse, defaultValueFunctions) => field => { const getInitialValue = fp.isUndefined(field) || fp.isUndefined(field.getInitialValue) ? "default" : field.getInitialValue; return fp.has(getInitialValue)(defaultValueFunctions) ? defaultValueFunctions[getInitialValue]() : getSafeValueParser(tryParse, defaultValueFunctions)(getInitialValue) }; const typeFunctions = specificFunctions => _.merge( { value: fp.constant, null: fp.constant(null), }, specificFunctions ); const validateTypeConstraints = validationRules => async ( field, record, context ) => { const fieldValue = record[field.name]; const validateRule = async r => !(await r.isValid(fieldValue, field.typeOptions, context)) ? r.getMessage(fieldValue, field.typeOptions) : ""; const errors = []; for (const r of validationRules) { const err = await validateRule(r); if (isNotEmpty(err)) errors.push(err); } return errors }; const getDefaultOptions = fp.mapValues(v => v.defaultValue); const makerule$1 = (isValid, getMessage) => ({ isValid, getMessage }); const parsedFailed = val => ({ success: false, value: val }); const parsedSuccess = val => ({ success: true, value: val }); const getDefaultExport = ( name, tryParse, functions, options, validationRules, sampleValue, stringify ) => ({ getNew: getNewValue(tryParse, functions), safeParseField: getSafeFieldParser(tryParse, functions), safeParseValue: getSafeValueParser(tryParse, functions), tryParse, name, getDefaultOptions: () => getDefaultOptions(fp.cloneDeep(options)), optionDefinitions: options, validateTypeConstraints: validateTypeConstraints(validationRules), sampleValue, stringify: val => (val === null || val === undefined ? "" : stringify(val)), getDefaultValue: functions.default, }); const stringFunctions = typeFunctions({ default: fp.constant(null), }); const stringTryParse = switchCase( [fp.isString, parsedSuccess], [fp.isNull, parsedSuccess], [defaultCase, v => parsedSuccess(v.toString())] ); const options = { maxLength: { defaultValue: null, isValid: n => n === null || (isSafeInteger(n) && n > 0), requirementDescription: "max length must be null (no limit) or a greater than zero integer", parse: toNumberOrNull, }, values: { defaultValue: null, isValid: v => v === null || (isArrayOfString(v) && v.length > 0 && v.length < 10000), requirementDescription: "'values' must be null (no values) or an array of at least one string", parse: s => s, }, allowDeclaredValuesOnly: { defaultValue: false, isValid: fp.isBoolean, requirementDescription: "allowDeclaredValuesOnly must be true or false", parse: toBoolOrNull, }, }; const typeConstraints = [ makerule$1( async (val, opts) => val === null || opts.maxLength === null || val.length <= opts.maxLength, (val, opts) => `value exceeds maximum length of ${opts.maxLength}` ), makerule$1( async (val, opts) => val === null || opts.allowDeclaredValuesOnly === false || fp.includes(val)(opts.values), val => `"${val}" does not exist in the list of allowed values` ), ]; var string = getDefaultExport( "string", stringTryParse, stringFunctions, options, typeConstraints, "abcde", str => str ); const boolFunctions = typeFunctions({ default: fp.constant(null), }); const boolTryParse = switchCase( [fp.isBoolean, parsedSuccess], [fp.isNull, parsedSuccess], [isOneOf("true", "1", "yes", "on"), () => parsedSuccess(true)], [isOneOf("false", "0", "no", "off"), () => parsedSuccess(false)], [defaultCase, parsedFailed] ); const options$1 = { allowNulls: { defaultValue: true, isValid: fp.isBoolean, requirementDescription: "must be a true or false", parse: toBoolOrNull, }, }; const typeConstraints$1 = [ makerule$1( async (val, opts) => opts.allowNulls === true || val !== null, () => "field cannot be null" ), ]; var bool = getDefaultExport( "bool", boolTryParse, boolFunctions, options$1, typeConstraints$1, true, JSON.stringify ); const numberFunctions = typeFunctions({ default: fp.constant(null), }); const parseStringtoNumberOrNull = s => { const num = Number(s); return isNaN(num) ? parsedFailed(s) : parsedSuccess(num) }; const numberTryParse = switchCase( [fp.isNumber, parsedSuccess], [fp.isString, parseStringtoNumberOrNull], [fp.isNull, parsedSuccess], [defaultCase, parsedFailed] ); const options$2 = { maxValue: { defaultValue: Number.MAX_SAFE_INTEGER, isValid: isSafeInteger, requirementDescription: "must be a valid integer", parse: toNumberOrNull, }, minValue: { defaultValue: 0 - Number.MAX_SAFE_INTEGER, isValid: isSafeInteger, requirementDescription: "must be a valid integer", parse: toNumberOrNull, }, decimalPlaces: { defaultValue: 0, isValid: n => isSafeInteger(n) && n >= 0, requirementDescription: "must be a positive integer", parse: toNumberOrNull, }, }; const getDecimalPlaces = val => { const splitDecimal = val.toString().split("."); if (splitDecimal.length === 1) return 0 return splitDecimal[1].length }; const typeConstraints$2 = [ makerule$1( async (val, opts) => val === null || opts.minValue === null || val >= opts.minValue, (val, opts) => `value (${val.toString()}) must be greater than or equal to ${ opts.minValue }` ), makerule$1( async (val, opts) => val === null || opts.maxValue === null || val <= opts.maxValue, (val, opts) => `value (${val.toString()}) must be less than or equal to ${ opts.minValue } options` ), makerule$1( async (val, opts) => val === null || opts.decimalPlaces >= getDecimalPlaces(val), (val, opts) => `value (${val.toString()}) must have ${ opts.decimalPlaces } decimal places or less` ), ]; var number = getDefaultExport( "number", numberTryParse, numberFunctions, options$2, typeConstraints$2, 1, num => num.toString() ); const dateFunctions = typeFunctions({ default: fp.constant(null), now: () => new Date(), }); const isValidDate = d => d instanceof Date && !isNaN(d); const parseStringToDate = s => switchCase( [isValidDate, parsedSuccess], [defaultCase, parsedFailed] )(new Date(s)); const isNullOrEmpty = d => fp.isNull(d) || (d || "").toString() === ""; const isDateOrEmpty = d => fp.isDate(d) || isNullOrEmpty(d); const dateTryParse = switchCase( [isDateOrEmpty, parsedSuccess], [fp.isString, parseStringToDate], [defaultCase, parsedFailed] ); const options$3 = { maxValue: { defaultValue: null, //defaultValue: new Date(32503680000000), isValid: isDateOrEmpty, requirementDescription: "must be a valid date", parse: toDateOrNull, }, minValue: { defaultValue: null, //defaultValue: new Date(-8520336000000), isValid: isDateOrEmpty, requirementDescription: "must be a valid date", parse: toDateOrNull, }, }; const typeConstraints$3 = [ makerule$1( async (val, opts) => val === null || isNullOrEmpty(opts.minValue) || val >= opts.minValue, (val, opts) => `value (${val.toString()}) must be greater than or equal to ${ opts.minValue }` ), makerule$1( async (val, opts) => val === null || isNullOrEmpty(opts.maxValue) || val <= opts.maxValue, (val, opts) => `value (${val.toString()}) must be less than or equal to ${ opts.minValue } options` ), ]; var datetime = getDefaultExport( "datetime", dateTryParse, dateFunctions, options$3, typeConstraints$3, new Date(1984, 4, 1), date => JSON.stringify(date).replace(new RegExp('"', "g"), "") ); const arrayFunctions = () => typeFunctions({ default: fp.constant([]), }); const mapToParsedArrary = type => $$( fp.map(i => type.safeParseValue(i)), parsedSuccess ); const arrayTryParse = type => switchCase([fp.isArray, mapToParsedArrary(type)], [defaultCase, parsedFailed]); const typeName = type => `array<${type}>`; const options$4 = { maxLength: { defaultValue: 10000, isValid: isSafeInteger, requirementDescription: "must be a positive integer", parse: toNumberOrNull, }, minLength: { defaultValue: 0, isValid: n => isSafeInteger(n) && n >= 0, requirementDescription: "must be a positive integer", parse: toNumberOrNull, }, }; const typeConstraints$4 = [ makerule$1( async (val, opts) => val === null || val.length >= opts.minLength, (val, opts) => `must choose ${opts.minLength} or more options` ), makerule$1( async (val, opts) => val === null || val.length <= opts.maxLength, (val, opts) => `cannot choose more than ${opts.maxLength} options` ), ]; var array = type => getDefaultExport( typeName(type.name), arrayTryParse(type), arrayFunctions(), options$4, typeConstraints$4, [type.sampleValue], JSON.stringify ); const referenceNothing = () => ({ key: "" }); const referenceFunctions = typeFunctions({ default: referenceNothing, }); const hasStringValue = (ob, path) => fp.has(path)(ob) && fp.isString(ob[path]); const isObjectWithKey = v => fp.isObjectLike(v) && hasStringValue(v, "key"); const tryParseFromString = s => { try { const asObj = JSON.parse(s); if (isObjectWithKey) { return parsedSuccess(asObj) } } catch (_) { // EMPTY } return parsedFailed(s) }; const referenceTryParse = v => switchCase( [isObjectWithKey, parsedSuccess], [fp.isString, tryParseFromString], [fp.isNull, () => parsedSuccess(referenceNothing())], [defaultCase, parsedFailed] )(v); const options$5 = { indexNodeKey: { defaultValue: null, isValid: isNonEmptyString, requirementDescription: "must be a non-empty string", parse: s => s, }, displayValue: { defaultValue: "", isValid: isNonEmptyString, requirementDescription: "must be a non-empty string", parse: s => s, }, reverseIndexNodeKeys: { defaultValue: null, isValid: v => isArrayOfString(v) && v.length > 0, requirementDescription: "must be a non-empty array of strings", parse: s => s, }, }; const isEmptyString = s => fp.isString(s) && fp.isEmpty(s); const ensureReferenceExists = async (val, opts, context) => isEmptyString(val.key) || (await context.referenceExists(opts, val.key)); const typeConstraints$5 = [ makerule$1( ensureReferenceExists, (val, opts) => `"${val[opts.displayValue]}" does not exist in options list (key: ${ val.key })` ), ]; var reference = getDefaultExport( "reference", referenceTryParse, referenceFunctions, options$5, typeConstraints$5, { key: "key", value: "value" }, JSON.stringify ); const illegalCharacters = "*?\\/:<>|\0\b\f\v"; const isLegalFilename = filePath => { const fn = fileName(filePath); return ( fn.length <= 255 && fp.intersection(fn.split(""))(illegalCharacters.split("")).length === 0 && none(f => f === "..")(splitKey(filePath)) ) }; const fileNothing = () => ({ relativePath: "", size: 0 }); const fileFunctions = typeFunctions({ default: fileNothing, }); const fileTryParse = v => switchCase( [isValidFile, parsedSuccess], [fp.isNull, () => parsedSuccess(fileNothing())], [defaultCase, parsedFailed] )(v); const fileName = filePath => $(filePath, [splitKey, fp.last]); const isValidFile = f => !fp.isNull(f) && fp.has("relativePath")(f) && fp.has("size")(f) && fp.isNumber(f.size) && fp.isString(f.relativePath) && isLegalFilename(f.relativePath); const options$6 = {}; const typeConstraints$6 = []; var file = getDefaultExport( "file", fileTryParse, fileFunctions, options$6, typeConstraints$6, { relativePath: "some_file.jpg", size: 1000 }, JSON.stringify ); const allTypes = () => { const basicTypes = { string, number, datetime, bool, reference, file, }; const arrays = $(basicTypes, [ fp.keys, fp.map(k => { const kvType = {}; const concreteArray = array(basicTypes[k]); kvType[concreteArray.name] = concreteArray; return kvType }), types => _.assign({}, ...types), ]); return _.merge({}, basicTypes, arrays) }; const all$1 = allTypes(); const getType = typeName => { if (!fp.has(typeName)(all$1)) throw new BadRequestError(`Do not recognise type ${typeName}`) return all$1[typeName] }; const getSampleFieldValue = field => getType(field.type).sampleValue; const getNewFieldValue = field => getType(field.type).getNew(field); const safeParseField = (field, record) => getType(field.type).safeParseField(field, record); const validateFieldParse = (field, record) => fp.has(field.name)(record) ? getType(field.type).tryParse(record[field.name]) : parsedSuccess(undefined); // fields may be undefined by default const getDefaultOptions$1 = type => getType(type).getDefaultOptions(); const validateTypeConstraints$1 = async (field, record, context) => await getType(field.type).validateTypeConstraints(field, record, context); const detectType = value => { if (fp.isString(value)) return string if (fp.isBoolean(value)) return bool if (fp.isNumber(value)) return number if (fp.isDate(value)) return datetime if (fp.isArray(value)) return array(detectType(value[0])) if (fp.isObject(value) && fp.has("key")(value) && fp.has("value")(value)) return reference if (fp.isObject(value) && fp.has("relativePath")(value) && fp.has("size")(value)) return file throw new BadRequestError(`cannot determine type: ${JSON.stringify(value)}`) }; // 5 minutes const tempCodeExpiryLength = 5 * 60 * 1000; const AUTH_FOLDER = "/.auth"; const USERS_LIST_FILE = joinKey(AUTH_FOLDER, "users.json"); const userAuthFile = username => joinKey(AUTH_FOLDER, `auth_${username}.json`); const USERS_LOCK_FILE = joinKey(AUTH_FOLDER, "users_lock"); const ACCESS_LEVELS_FILE = joinKey(AUTH_FOLDER, "access_levels.json"); const ACCESS_LEVELS_LOCK_FILE = joinKey( AUTH_FOLDER, "access_levels_lock" ); const permissionTypes = { CREATE_RECORD: "create record", UPDATE_RECORD: "update record", READ_RECORD: "read record", DELETE_RECORD: "delete record", READ_INDEX: "read index", MANAGE_INDEX: "manage index", MANAGE_COLLECTION: "manage collection", WRITE_TEMPLATES: "write templates", CREATE_USER: "create user", SET_PASSWORD: "set password", CREATE_TEMPORARY_ACCESS: "create temporary access", ENABLE_DISABLE_USER: "enable or disable user", WRITE_ACCESS_LEVELS: "write access levels", LIST_USERS: "list users", LIST_ACCESS_LEVELS: "list access levels", EXECUTE_ACTION: "execute action", SET_USER_ACCESS_LEVELS: "set user access levels", }; const getUserByName = (users, name) => $(users, [fp.find(u => u.name.toLowerCase() === name.toLowerCase())]); const stripUserOfSensitiveStuff = user => { const stripped = fp.clone(user); delete stripped.tempCode; return stripped }; const parseTemporaryCode = fullCode => $(fullCode, [ fp.split(":"), parts => ({ id: parts[1], code: parts[2], }), ]); const isAuthorized = app => (permissionType, resourceKey) => apiWrapperSync( app, events.authApi.isAuthorized, alwaysAuthorized, { resourceKey, permissionType }, _isAuthorized, app, permissionType, resourceKey ); const _isAuthorized = (app, permissionType, resourceKey) => { if (!app.user) { return false } const validType = $(permissionTypes, [fp.values, fp.includes(permissionType)]); if (!validType) { return false } const permMatchesResource = userperm => { const nodeKey = isNothing(resourceKey) ? null : isNode(app.hierarchy, resourceKey) ? getNodeByKeyOrNodeKey(app.hierarchy, resourceKey).nodeKey() : resourceKey; return ( userperm.type === permissionType && (isNothing(resourceKey) || nodeKey === userperm.nodeKey) ) }; return $(app.user.permissions, [fp.some(permMatchesResource)]) }; const nodePermission = type => ({ add: (nodeKey, accessLevel) => accessLevel.permissions.push({ type, nodeKey }), isAuthorized: resourceKey => app => isAuthorized(app)(type, resourceKey), isNode: true, get: nodeKey => ({ type, nodeKey }), }); const staticPermission = type => ({ add: accessLevel => accessLevel.permissions.push({ type }), isAuthorized: app => isAuthorized(app)(type), isNode: false, get: () => ({ type }), }); const createRecord = nodePermission(permissionTypes.CREATE_RECORD); const updateRecord = nodePermission(permissionTypes.UPDATE_RECORD); const deleteRecord = nodePermission(permissionTypes.DELETE_RECORD); const readRecord = nodePermission(permissionTypes.READ_RECORD); const writeTemplates = staticPermission(permissionTypes.WRITE_TEMPLATES); const createUser = staticPermission(permissionTypes.CREATE_USER); const setPassword = staticPermission(permissionTypes.SET_PASSWORD); const readIndex = nodePermission(permissionTypes.READ_INDEX); const manageIndex = staticPermission(permissionTypes.MANAGE_INDEX); const manageCollection = staticPermission(permissionTypes.MANAGE_COLLECTION); const createTemporaryAccess = staticPermission( permissionTypes.CREATE_TEMPORARY_ACCESS ); const enableDisableUser = staticPermission(permissionTypes.ENABLE_DISABLE_USER); const writeAccessLevels = staticPermission(permissionTypes.WRITE_ACCESS_LEVELS); const listUsers = staticPermission(permissionTypes.LIST_USERS); const listAccessLevels = staticPermission(permissionTypes.LIST_ACCESS_LEVELS); const setUserAccessLevels = staticPermission( permissionTypes.SET_USER_ACCESS_LEVELS ); const executeAction = nodePermission(permissionTypes.EXECUTE_ACTION); const alwaysAuthorized = () => true; const permission = { createRecord, updateRecord, deleteRecord, readRecord, writeTemplates, createUser, setPassword, readIndex, createTemporaryAccess, enableDisableUser, writeAccessLevels, listUsers, listAccessLevels, manageIndex, manageCollection, executeAction, setUserAccessLevels, }; const getNew = app => (collectionKey, recordTypeName) => { const recordNode = getRecordNode(app, collectionKey); collectionKey = safeKey(collectionKey); return apiWrapperSync( app, events.recordApi.getNew, permission.createRecord.isAuthorized(recordNode.nodeKey()), { collectionKey, recordTypeName }, _getNew, recordNode, collectionKey ) }; /** * Constructs a record object that can be saved to the backend. * @param {*} recordNode - record * @param {*} collectionKey - nested collection key that the record will be saved to. */ const _getNew = (recordNode, collectionKey) => constructRecord(recordNode, getNewFieldValue, collectionKey); const getRecordNode = (app, collectionKey) => { collectionKey = safeKey(collectionKey); return getNodeForCollectionPath(app.hierarchy)(collectionKey) }; const getNewChild = app => (recordKey, collectionName, recordTypeName) => getNew(app)(joinKey(recordKey, collectionName), recordTypeName); const constructRecord = (recordNode, getFieldValue, collectionKey) => { const record = $(recordNode.fields, [fp.keyBy("name"), fp.mapValues(getFieldValue)]); record.id = `${recordNode.nodeId}-${shortid.generate()}`; record.key = isSingleRecord(recordNode) ? joinKey(collectionKey, recordNode.name) : joinKey(collectionKey, record.id); record.isNew = true; record.type = recordNode.name; return record }; const allIdChars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-"; // this should never be changed - ever // - existing databases depend on the order of chars this string /** * folderStructureArray should return an array like * - [1] = all records fit into one folder * - [2] = all records fite into 2 folders * - [64, 3] = all records fit into 64 * 3 folders * - [64, 64, 10] = all records fit into 64 * 64 * 10 folder * (there are 64 possible chars in allIsChars) */ const folderStructureArray = recordNode => { const totalFolders = Math.ceil(recordNode.estimatedRecordCount / 1000); const folderArray = []; let levelCount = 1; while (64 ** levelCount < totalFolders) { levelCount += 1; folderArray.push(64); } const parentFactor = 64 ** folderArray.length; if (parentFactor < totalFolders) { folderArray.push(Math.ceil(totalFolders / parentFactor)); } return folderArray /* const maxRecords = currentFolderPosition === 0 ? RECORDS_PER_FOLDER : currentFolderPosition * 64 * RECORDS_PER_FOLDER; if(maxRecords < recordNode.estimatedRecordCount) { return folderStructureArray( recordNode, [...currentArray, 64], currentFolderPosition + 1); } else { const childFolderCount = Math.ceil(recordNode.estimatedRecordCount / maxRecords ); return [...currentArray, childFolderCount] }*/ }; const getAllIdsIterator = app => async collection_Key_or_NodeKey => { collection_Key_or_NodeKey = safeKey(collection_Key_or_NodeKey); const recordNode = getCollectionNodeByKeyOrNodeKey(app.hierarchy, collection_Key_or_NodeKey) || getNodeByKeyOrNodeKey(app.hierarchy, collection_Key_or_NodeKey); const getAllIdsIteratorForCollectionKey = async ( recordNode, collectionKey ) => { const folderStructure = folderStructureArray(recordNode); let currentFolderContents = []; let currentPosition = []; const collectionDir = getCollectionDir(app.hierarchy, collectionKey); const basePath = joinKey(collectionDir, recordNode.nodeId.toString()); // "folderStructure" determines the top, sharding folders // we need to add one, for the collection root folder, which // always exists const levels = folderStructure.length + 1; const topLevel = levels - 1; /* populate initial directory structure in form: [ {path: "/a", contents: ["b", "c", "d"]}, {path: "/a/b", contents: ["e","f","g"]}, {path: "/a/b/e", contents: ["1-abcd","2-cdef","3-efgh"]}, ] // stores contents on each parent level // top level has ID folders */ const firstFolder = async () => { let folderLevel = 0; const lastPathHasContent = () => folderLevel === 0 || currentFolderContents[folderLevel - 1].contents.length > 0; while (folderLevel <= topLevel && lastPathHasContent()) { let thisPath = basePath; for (let lev = 0; lev < currentPosition.length; lev++) { thisPath = joinKey(thisPath, currentFolderContents[lev].contents[0]); } con