Merge branch 'main' into next

This commit is contained in:
mytharcher
2026-02-12 17:06:06 +08:00
18 changed files with 976 additions and 1634 deletions
+1
View File
@@ -57,6 +57,7 @@
"react-dom": "^18.0.0",
"nwsapi": "2.2.7",
"antd": "5.24.2",
"@rushstack/ts-command-line": "4.23.7",
"dayjs": "1.11.13",
"@ant-design/icons": "^5.6.1",
"eslint": "8.57.1"
-3
View File
@@ -19,7 +19,6 @@
"@rsdoctor/rspack-plugin": "^0.4.8",
"@rspack/core": "1.3.2",
"@svgr/webpack": "^8.1.0",
"@types/gulp": "^4.0.13",
"@types/lerna__package": "5.1.0",
"@types/lerna__project": "5.1.0",
"@types/tar": "^6.1.5",
@@ -31,8 +30,6 @@
"css-loader": "^6.8.1",
"esbuild-register": "^3.4.2",
"fast-glob": "^3.3.1",
"gulp": "4.0.2",
"gulp-typescript": "6.0.0-alpha.1",
"javascript-obfuscator": "^4.1.1",
"less": "^4.2.0",
"less-loader": "^12.2.0",
+73 -28
View File
@@ -7,37 +7,82 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import gulp from 'gulp';
import gulpTs from 'gulp-typescript';
import fg from 'fast-glob';
import path from 'path';
import ts from 'typescript';
import { ROOT_PATH } from './constant';
export const buildDeclaration = (cwd: string, targetDir: string) => {
return new Promise((resolve, reject) => {
const srcPath = path.join(cwd, 'src');
const targetPath = path.join(cwd, targetDir);
const INCLUDE_PATTERNS = ['**/*.{ts,tsx}'];
const EXCLUDE_PATTERNS = [
'**/fixtures{,/**}',
'**/demos{,/**}',
'**/__test__{,/**}',
'**/__tests__{,/**}',
'**/__benchmarks__{,/**}',
'**/__e2e__{,/**}',
'**/*.mdx',
'**/*.md',
'**/*.+(test|e2e|spec).+(js|jsx|ts|tsx)',
'**/tsconfig{,.*}.json',
'.umi{,-production,-test}{,/**}',
];
const tsConfig = gulpTs.createProject(path.join(ROOT_PATH, 'tsconfig.json'));
delete tsConfig.config.compilerOptions.paths;
const patterns = [
path.join(srcPath, '**/*.{ts,tsx}'),
`!${path.join(srcPath, '**/fixtures{,/**}')}`,
`!${path.join(srcPath, '**/demos{,/**}')}`,
`!${path.join(srcPath, '**/__test__{,/**}')}`,
`!${path.join(srcPath, '**/__tests__{,/**}')}`,
`!${path.join(srcPath, '**/__benchmarks__{,/**}')}`,
`!${path.join(srcPath, '**/__e2e__{,/**}')}`,
`!${path.join(srcPath, '**/*.mdx')}`,
`!${path.join(srcPath, '**/*.md')}`,
`!${path.join(srcPath, '**/*.+(test|e2e|spec).+(js|jsx|ts|tsx)')}`,
`!${path.join(srcPath, '**/tsconfig{,.*}.json')}`,
`!${path.join(srcPath, '.umi{,-production,-test}{,/**}')}`,
];
gulp
.src(patterns, { base: srcPath, allowEmpty: true })
.pipe(gulpTs(tsConfig.config.compilerOptions))
.dts.pipe(gulp.dest(targetPath))
.on('end', resolve)
.on('error', reject);
const diagnosticHost: ts.FormatDiagnosticsHost = {
getCurrentDirectory: () => process.cwd(),
getCanonicalFileName: (fileName) => (ts.sys.useCaseSensitiveFileNames ? fileName : fileName.toLowerCase()),
getNewLine: () => ts.sys.newLine,
};
function loadCompilerOptions(): ts.CompilerOptions {
const configPath = path.join(ROOT_PATH, 'tsconfig.json');
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
if (configFile.error) {
throw new Error(ts.formatDiagnosticsWithColorAndContext([configFile.error], diagnosticHost));
}
const parsedConfig = ts.parseJsonConfigFileContent(
configFile.config,
ts.sys,
path.dirname(configPath),
undefined,
configPath,
);
const options: ts.CompilerOptions = {
...parsedConfig.options,
};
delete options.paths;
return options;
}
export const buildDeclaration = async (cwd: string, targetDir: string) => {
const srcPath = path.join(cwd, 'src');
const targetPath = path.join(cwd, targetDir);
const files = await fg(INCLUDE_PATTERNS, {
cwd: srcPath,
ignore: EXCLUDE_PATTERNS,
absolute: true,
dot: true,
});
if (!files.length) {
return;
}
const compilerOptions = {
...loadCompilerOptions(),
declaration: true,
emitDeclarationOnly: true,
declarationDir: targetPath,
outDir: targetPath,
rootDir: srcPath,
} satisfies ts.CompilerOptions;
const program = ts.createProgram(files, compilerOptions);
const emitResult = program.emit(undefined, undefined, undefined, true);
const diagnostics = ts.getPreEmitDiagnostics(program).concat(emitResult.diagnostics);
if (diagnostics.length) {
const details = ts.formatDiagnosticsWithColorAndContext(diagnostics, diagnosticHost);
throw new Error(`Failed to build declarations for ${cwd} \n${details}`);
}
};
@@ -0,0 +1,449 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { removeCircularReferences } from '../useLazyLoadDisplayAssociationFieldsOfForm';
describe('removeCircularReferences', () => {
describe('Primitive values', () => {
it('should return null as is', () => {
expect(removeCircularReferences(null)).toBe(null);
});
it('should return undefined as is', () => {
expect(removeCircularReferences(undefined)).toBe(undefined);
});
it('should return numbers as is', () => {
expect(removeCircularReferences(42)).toBe(42);
expect(removeCircularReferences(0)).toBe(0);
expect(removeCircularReferences(-1)).toBe(-1);
});
it('should return strings as is', () => {
expect(removeCircularReferences('hello')).toBe('hello');
expect(removeCircularReferences('')).toBe('');
});
it('should return booleans as is', () => {
expect(removeCircularReferences(true)).toBe(true);
expect(removeCircularReferences(false)).toBe(false);
});
});
describe('Simple objects and arrays', () => {
it('should clone simple object', () => {
const input = { name: 'John', age: 30 };
const output = removeCircularReferences(input);
expect(output).toEqual({ name: 'John', age: 30 });
expect(output).not.toBe(input); // Should be a new object
});
it('should clone simple array', () => {
const input = [1, 2, 3];
const output = removeCircularReferences(input);
expect(output).toEqual([1, 2, 3]);
expect(output).not.toBe(input); // Should be a new array
});
it('should handle nested objects', () => {
const input = {
user: {
name: 'John',
address: {
city: 'New York',
zip: '10001',
},
},
};
const output = removeCircularReferences(input);
expect(output).toEqual(input);
expect(output).not.toBe(input);
expect(output.user).not.toBe(input.user);
});
it('should handle nested arrays', () => {
const input = [
[1, 2],
[3, 4],
[5, 6],
];
const output = removeCircularReferences(input);
expect(output).toEqual(input);
expect(output).not.toBe(input);
expect(output[0]).not.toBe(input[0]);
});
it('should handle mixed nested structures', () => {
const input = {
users: [
{ name: 'Alice', scores: [90, 85, 88] },
{ name: 'Bob', scores: [75, 80, 82] },
],
metadata: {
total: 2,
tags: ['student', 'active'],
},
};
const output = removeCircularReferences(input);
expect(output).toEqual(input);
});
});
describe('Shared references (not circular)', () => {
it('should preserve shared object in multiple properties', () => {
const shared = { id: 1, name: 'shared' };
const input = { a: shared, b: shared };
const output = removeCircularReferences(input);
expect(output).toEqual({
a: { id: 1, name: 'shared' },
b: { id: 1, name: 'shared' },
});
expect(output.a).toBe(output.b); // Should reference the same cleaned object
});
it('should preserve shared object in array', () => {
const shared = { id: 1, name: 'shared' };
const input = [shared, shared, shared];
const output = removeCircularReferences(input);
expect(output).toEqual([
{ id: 1, name: 'shared' },
{ id: 1, name: 'shared' },
{ id: 1, name: 'shared' },
]);
expect(output[0]).toBe(output[1]);
expect(output[1]).toBe(output[2]);
});
it('should preserve shared object in nested structures', () => {
const role = { id: 1, name: 'Admin' };
const input = {
users: [
{ name: 'Alice', role: role },
{ name: 'Bob', role: role },
],
defaultRole: role,
};
const output = removeCircularReferences(input);
expect(output).toEqual({
users: [
{ name: 'Alice', role: { id: 1, name: 'Admin' } },
{ name: 'Bob', role: { id: 1, name: 'Admin' } },
],
defaultRole: { id: 1, name: 'Admin' },
});
expect(output.users[0].role).toBe(output.users[1].role);
expect(output.users[0].role).toBe(output.defaultRole);
});
it('should preserve shared array in multiple properties', () => {
const sharedArray = [1, 2, 3];
const input = { a: sharedArray, b: sharedArray };
const output = removeCircularReferences(input);
expect(output).toEqual({
a: [1, 2, 3],
b: [1, 2, 3],
});
expect(output.a).toBe(output.b);
});
});
describe('Circular references', () => {
it('should remove self-referencing property', () => {
const circular: any = { name: 'circular' };
circular.self = circular;
const output = removeCircularReferences(circular);
expect(output).toEqual({ name: 'circular' });
expect(output.self).toBeUndefined();
});
it('should remove circular reference in nested object', () => {
const parent: any = { name: 'parent' };
const child: any = { name: 'child', parent: parent };
parent.child = child;
const output = removeCircularReferences(parent);
expect(output).toEqual({
name: 'parent',
child: { name: 'child' },
});
expect(output.child.parent).toBeUndefined();
});
it('should remove circular reference in array', () => {
const circular: any = { name: 'item' };
const arr = [circular];
circular.items = arr;
const output = removeCircularReferences(arr);
expect(output).toEqual([{ name: 'item' }]);
expect(output[0].items).toBeUndefined();
});
it('should handle deep circular references', () => {
const a: any = { name: 'a' };
const b: any = { name: 'b', ref: a };
const c: any = { name: 'c', ref: b };
a.ref = c; // Creates circular: a -> c -> b -> a
const output = removeCircularReferences(a);
expect(output.name).toBe('a');
expect(output.ref.name).toBe('c');
expect(output.ref.ref.name).toBe('b');
expect(output.ref.ref.ref).toBeUndefined();
});
it('should handle multiple circular references in same structure', () => {
const obj: any = { name: 'root' };
obj.self1 = obj;
obj.self2 = obj;
obj.data = { value: 42 };
const output = removeCircularReferences(obj);
expect(output).toEqual({
name: 'root',
data: { value: 42 },
});
expect(output.self1).toBeUndefined();
expect(output.self2).toBeUndefined();
});
});
describe('Complex scenarios', () => {
it('should handle shared object with internal circular reference', () => {
const shared: any = { name: 'shared' };
shared.self = shared;
const input = { a: shared, b: shared };
const output = removeCircularReferences(input);
expect(output).toEqual({
a: { name: 'shared' },
b: { name: 'shared' },
});
expect(output.a).toBe(output.b); // Same cleaned object
expect(output.a.self).toBeUndefined();
});
it('should handle sibling shared references during processing', () => {
const siblingShared = { name: 'sibling' };
const input = {
a: siblingShared,
b: siblingShared,
c: { nested: siblingShared },
};
const output = removeCircularReferences(input);
expect(output.a).toBe(output.b);
expect(output.a).toBe(output.c.nested);
expect(output).toEqual({
a: { name: 'sibling' },
b: { name: 'sibling' },
c: { nested: { name: 'sibling' } },
});
});
it('should handle deep structure with shared references at multiple levels', () => {
const deepShared = { id: 1 };
const level2 = { shared: deepShared, data: 'level2' };
const level1 = { shared: deepShared, child: level2, data: 'level1' };
const output = removeCircularReferences(level1);
expect(output.shared).toBe(output.child.shared);
expect(output).toEqual({
shared: { id: 1 },
child: { shared: { id: 1 }, data: 'level2' },
data: 'level1',
});
});
it('should handle array of objects with circular and shared references', () => {
const shared = { id: 1 };
const circular: any = { id: 2 };
circular.self = circular;
const input = [shared, circular, shared];
const output = removeCircularReferences(input);
expect(output).toEqual([{ id: 1 }, { id: 2 }, { id: 1 }]);
expect(output[0]).toBe(output[2]);
expect(output[1].self).toBeUndefined();
});
it('should handle real-world form data structure', () => {
const department = { id: 1, name: 'Engineering' };
const role = { id: 1, name: 'Admin', department };
const input = {
users: [
{
id: 1,
name: 'Alice',
role: role,
department: department,
},
{
id: 2,
name: 'Bob',
role: role,
department: department,
},
],
defaultRole: role,
defaultDepartment: department,
};
const output = removeCircularReferences(input);
// All role references should point to same object
expect(output.users[0].role).toBe(output.users[1].role);
expect(output.users[0].role).toBe(output.defaultRole);
// All department references should point to same object
expect(output.users[0].department).toBe(output.users[1].department);
expect(output.users[0].department).toBe(output.defaultDepartment);
expect(output.users[0].role.department).toBe(output.defaultDepartment);
});
});
describe('Edge cases', () => {
it('should handle empty object', () => {
expect(removeCircularReferences({})).toEqual({});
});
it('should handle empty array', () => {
expect(removeCircularReferences([])).toEqual([]);
});
it('should handle object with undefined values', () => {
const input = { a: 1, b: undefined, c: 3 };
const output = removeCircularReferences(input);
expect(output).toEqual({ a: 1, c: 3 });
expect('b' in output).toBe(false);
});
it('should handle array with undefined values', () => {
const input = [1, undefined, 3];
const output = removeCircularReferences(input);
expect(output).toEqual([1, undefined, 3]);
});
it('should not process non-plain objects (Date, RegExp, etc.)', () => {
const date = new Date('2024-01-01');
const regex = /test/;
const input = { date, regex, number: 42 };
const output = removeCircularReferences(input);
expect(output.date).toBe(date);
expect(output.regex).toBe(regex);
expect(output.number).toBe(42);
});
it('should handle object with null prototype', () => {
const obj = Object.create(null);
obj.a = 1;
obj.b = 2;
const input = { data: obj };
const output = removeCircularReferences(input);
// Non-plain object should be preserved as-is
expect(output.data).toBe(obj);
});
it('should handle very deep nesting', () => {
let deep: any = { value: 0 };
for (let i = 1; i < 100; i++) {
deep = { value: i, child: deep };
}
const output = removeCircularReferences(deep);
// Should successfully process without stack overflow
expect(output.value).toBe(99);
let current = output;
for (let i = 99; i >= 0; i--) {
expect(current.value).toBe(i);
current = current.child;
}
expect(current).toBeUndefined();
});
it('should handle object with circular reference to parent at multiple levels', () => {
const root: any = { name: 'root' };
const level1: any = { name: 'level1', parent: root };
const level2: any = { name: 'level2', parent: level1, root: root };
root.level1 = level1;
level1.level2 = level2;
const output = removeCircularReferences(root);
expect(output.name).toBe('root');
expect(output.level1.name).toBe('level1');
expect(output.level1.level2.name).toBe('level2');
expect(output.level1.parent).toBeUndefined();
expect(output.level1.level2.parent).toBeUndefined();
expect(output.level1.level2.root).toBeUndefined();
});
});
describe('Properties with falsy values', () => {
it('should preserve properties with value 0', () => {
const input = { count: 0, name: 'test' };
const output = removeCircularReferences(input);
expect(output).toEqual({ count: 0, name: 'test' });
});
it('should preserve properties with empty string', () => {
const input = { name: '', value: 'test' };
const output = removeCircularReferences(input);
expect(output).toEqual({ name: '', value: 'test' });
});
it('should preserve properties with false', () => {
const input = { active: false, name: 'test' };
const output = removeCircularReferences(input);
expect(output).toEqual({ active: false, name: 'test' });
});
it('should preserve properties with null', () => {
const input = { data: null, name: 'test' };
const output = removeCircularReferences(input);
expect(output).toEqual({ data: null, name: 'test' });
});
it('should omit properties with undefined', () => {
const input = { data: undefined, name: 'test' };
const output = removeCircularReferences(input);
expect(output).toEqual({ name: 'test' });
expect('data' in output).toBe(false);
});
});
});
@@ -9,7 +9,7 @@
import { Field } from '@formily/core';
import { useField, useFieldSchema, useForm } from '@formily/react';
import { untracked } from '@formily/reactive';
import { toJS, untracked } from '@formily/reactive';
import { nextTick } from '@nocobase/utils/client';
import _ from 'lodash';
import { useEffect, useMemo, useRef } from 'react';
@@ -102,13 +102,18 @@ const useLazyLoadDisplayAssociationFieldsOfForm = () => {
.parseVariable(variableString, formVariable, { appends })
.then(({ value }) => {
nextTick(() => {
const result = transformVariableValue(value, { targetCollectionField: collectionFieldRef.current });
const result = normalizeDisplayAssociationFieldValue(
transformVariableValue(value, { targetCollectionField: collectionFieldRef.current }),
);
if (result == null) {
if (field.value != null) {
field.value = null;
}
} else {
if (_.isEqual(field.value, result)) {
const currentComparableValue = normalizeDisplayAssociationFieldValue(
transformVariableValue(field.value, { targetCollectionField: collectionFieldRef.current }),
);
if (_.isEqual(currentComparableValue, result)) {
return;
}
field.setValue(result);
@@ -128,6 +133,82 @@ const useLazyLoadDisplayAssociationFieldsOfForm = () => {
export default useLazyLoadDisplayAssociationFieldsOfForm;
function normalizeDisplayAssociationFieldValue(value: any) {
return untracked(() => {
const plainValue = toJS(value);
return removeCircularReferences(plainValue);
});
}
/**
* Remove circular references from an object while preserving shared references.
*
* This function distinguishes between:
* - Circular references: Objects that reference themselves in the recursion path (removed)
* - Shared references: Objects that appear multiple times but aren't circular (preserved)
*
* @param value - The value to process
* @param seen - WeakMap cache of processed objects
* @param path - WeakSet tracking current recursion path
* @returns The processed value with circular references removed
*
* @example
* // Shared reference (preserved)
* const shared = { name: "shared" };
* removeCircularReferences({ a: shared, b: shared });
* // Returns: { a: { name: "shared" }, b: { name: "shared" } }
*
* @example
* // Circular reference (removed)
* const circular = { name: "circular" };
* circular.self = circular;
* removeCircularReferences(circular);
* // Returns: { name: "circular" }
*/
export function removeCircularReferences(value: any, seen = new WeakMap<object, any>(), path = new WeakSet<object>()) {
if (value == null || typeof value !== 'object') {
return value;
}
if (!Array.isArray(value) && (!_.isPlainObject(value) || Object.getPrototypeOf(value) === null)) {
return value;
}
// Check if this object is in the current recursion path (true circular reference)
if (path.has(value)) {
return undefined;
}
// If we've seen this object before but it's not in the current path,
// it's a shared reference - return the already-processed (cleaned) version
if (seen.has(value)) {
return seen.get(value);
}
// Mark as seen with a placeholder and add to current path
const result = Array.isArray(value) ? [] : {};
seen.set(value, result);
path.add(value);
if (Array.isArray(value)) {
value.forEach((item, index) => {
result[index] = removeCircularReferences(item, seen, path);
});
} else {
Object.keys(value).forEach((key) => {
const normalized = removeCircularReferences(value[key], seen, path);
if (normalized !== undefined) {
result[key] = normalized;
}
});
}
// Remove from current path as we exit this recursion level
path.delete(value);
return result;
}
/**
* 数据是否已被预加载
* @param record
@@ -7,6 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import assert from 'assert';
import { Collection, createMockDatabase, Database, updateAssociations } from '@nocobase/database';
describe('update associations', () => {
+2 -1
View File
@@ -8,7 +8,8 @@
"dependencies": {
"@formulajs/formulajs": "4.4.9",
"@nocobase/utils": "2.0.0-beta.23",
"mathjs": "^15.1.0"
"mathjs": "^15.1.0",
"ses": "^1.14.0"
},
"repository": {
"type": "git",
@@ -7,7 +7,25 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import formulajs from '../../utils/formulajs';
import { BASE_BLOCKED_IDENTIFIERS } from '@nocobase/utils/client';
import { createFormulaEvaluator } from '../../utils/formulajs';
const blockedIdentifiers = [
...BASE_BLOCKED_IDENTIFIERS,
'window',
'document',
'parent',
'top',
'frames',
'navigator',
'location',
'localStorage',
'sessionStorage',
];
const formulajs = createFormulaEvaluator({
blockedIdentifiers,
});
export default {
label: 'Formula.js',
@@ -164,6 +164,16 @@ describe('evaluate', () => {
const result = formulaEval('Date.parse({{a}})', { a: '2021-01-01T00:00:00.000Z' });
expect(result).toBe(1609459200000);
});
it('should block access to dangerous globals', () => {
expect(formulaEval('typeof process', {})).toBe('undefined');
expect(formulaEval('typeof window', {})).toBe('undefined');
expect(formulaEval('typeof globalThis', {})).toBe('undefined');
});
it('should reject attempts to escape the sandbox', () => {
expect(() => formulaEval('this.constructor.constructor("return process")()', {})).toThrow();
});
});
describe('string', () => {
+29 -2
View File
@@ -7,17 +7,44 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Registry } from '@nocobase/utils';
import { Registry, BASE_BLOCKED_IDENTIFIERS } from '@nocobase/utils';
import { lockdownSes } from '@nocobase/utils';
import { Evaluator } from '../utils';
import mathjs from '../utils/mathjs';
import formulajs from '../utils/formulajs';
import { createFormulaEvaluator } from '../utils/formulajs';
import string from '../utils/string';
export { Evaluator, evaluate, appendArrayColumn } from '../utils';
export const evaluators = new Registry<Evaluator>();
const baseFormulajs = createFormulaEvaluator({
blockedIdentifiers: [
...BASE_BLOCKED_IDENTIFIERS,
'process',
'require',
'module',
'exports',
'__filename',
'__dirname',
'Buffer',
],
});
let formulaLockdownReady = false;
function formulajs(expression, scope) {
if (!formulaLockdownReady) {
lockdownSes({
consoleTaming: 'unsafe',
errorTaming: 'unsafe',
overrideTaming: 'moderate',
stackFiltering: 'verbose',
});
formulaLockdownReady = true;
}
return baseFormulajs(expression, scope);
}
evaluators.register('math.js', mathjs);
evaluators.register('formula.js', formulajs);
evaluators.register('string', string);
+49 -13
View File
@@ -9,20 +9,56 @@
import * as functions from '@formulajs/formulajs';
import { round } from 'mathjs';
import 'ses';
import { evaluate } from '.';
import { evaluate, Scope } from '.';
const fnNames = Object.keys(functions).filter((key) => key !== 'default');
const fns = fnNames.map((key) => functions[key]);
const FUNCTION_NAMES = Object.keys(functions).filter((key) => key !== 'default');
export default evaluate.bind(function (expression: string, scope = {}) {
const fn = new Function(...fnNames, ...Object.keys(scope), `return ${expression}`);
const result = fn(...fns, ...Object.values(scope));
if (typeof result === 'number') {
if (Number.isNaN(result) || !Number.isFinite(result)) {
return null;
}
return round(result, 9);
export interface FormulaEvaluatorOptions {
blockedIdentifiers?: string[];
}
function buildEndowments(scope: Scope, blockedIdentifiers: string[] = []) {
const endowments: Record<string, any> = Object.create(null);
for (const key of FUNCTION_NAMES) {
endowments[key] = functions[key];
}
return result;
}, {});
if (scope && typeof scope === 'object') {
for (const [key, value] of Object.entries(scope)) {
endowments[key] = value;
}
}
for (const key of blockedIdentifiers) {
endowments[key] = undefined;
}
return endowments;
}
function runInSandbox(expression: string, scope: Scope, options: InternalEvaluatorOptions) {
const compartment = new Compartment(buildEndowments(scope, options.blockedIdentifiers));
return compartment.evaluate(expression);
}
interface InternalEvaluatorOptions {
blockedIdentifiers: string[];
}
export function createFormulaEvaluator(options: FormulaEvaluatorOptions = {}) {
const mergedOptions: InternalEvaluatorOptions = {
blockedIdentifiers: options.blockedIdentifiers,
};
return evaluate.bind(function (expression: string, scope: Scope = {}) {
const result = runInSandbox(expression, scope, mergedOptions);
if (typeof result === 'number') {
if (Number.isNaN(result) || !Number.isFinite(result)) {
return null;
}
return round(result, 9);
}
return result;
}, {});
}
export default createFormulaEvaluator();
+2 -2
View File
@@ -11,13 +11,13 @@
import Application from '../application';
const REPL = require('repl');
export default (app: Application) => {
app
.command('console')
.preload()
.action(async () => {
// NOTE: move require('repl') here to lazy load, so it can be locked down by SES
const REPL = require('repl');
await app.start();
const repl = (REPL.start('nocobase > ').context.app = app);
repl.on('exit', async function (err) {
+11
View File
@@ -9,6 +9,7 @@
import { createSystemLogger, getLoggerFilePath, SystemLogger } from '@nocobase/logger';
import { Registry, Toposort, ToposortOptions, uid } from '@nocobase/utils';
import { lockdownSes } from '@nocobase/utils';
import { createStoragePluginsSymlink } from '@nocobase/utils/plugin-symlink';
import { Command } from 'commander';
import compression from 'compression';
@@ -440,6 +441,16 @@ export class Gateway extends EventEmitter {
// See: https://nodejs.org/api/events.html#emittersetmaxlistenersn
mainApp.setMaxListeners(50);
// Delay SES lockdown until the app has finished starting to avoid breaking late-loaded modules.
mainApp.once('afterStart', () => {
lockdownSes({
consoleTaming: 'unsafe',
errorTaming: 'unsafe',
overrideTaming: 'moderate',
stackFiltering: 'verbose',
});
});
let runArgs: any = [process.argv, { throwError: true, from: 'node' }];
if (!isMainThread) {
+2 -1
View File
@@ -16,7 +16,8 @@
"handlebars": "^4.7.8",
"liquidjs": "^10.23.0",
"multer": "^1.4.5-lts.2",
"object-path": "^0.11.8"
"object-path": "^0.11.8",
"ses": "^1.14.0"
},
"gitHead": "d0b4efe4be55f8c79a98a331d99d9f8cf99021a1"
}
+1 -1
View File
@@ -24,7 +24,7 @@ export * from './notification';
export * from './number';
export * from './parse-filter';
export * from './registry';
// export * from './toposort';
export * from './ses';
export * from './i18n';
export * from './isPortalInBody';
export * from './parseHTML';
+1
View File
@@ -38,6 +38,7 @@ export * from './parsedValue';
export * from './perf-hooks';
export * from './registry';
export * from './requireModule';
export * from './ses';
export * from './toposort';
export * from './uid';
export * from './url';
+109
View File
@@ -0,0 +1,109 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import type { LockdownOptions } from 'ses';
import 'ses';
const SES_LOCK_FLAG = Symbol.for('nocobase.utils.sesLockdown');
const BUFFER_OVERRIDE_FLAG = Symbol.for('nocobase.utils.bufferOverrides');
export const BASE_BLOCKED_IDENTIFIERS = [
'globalThis',
'global',
'self',
'Function',
'AsyncFunction',
'GeneratorFunction',
'eval',
'Compartment',
'lockdown',
'harden',
'assert',
];
function isAlreadyLockedError(error: unknown) {
if (!error || typeof error !== 'object') {
return false;
}
const message = (error as Error).message || '';
return message.includes('(SES_ALREADY_LOCKED_DOWN)');
}
function enableBufferPrototypeOverrides() {
const globalRef = globalThis as Record<string | symbol, any>;
if (globalRef[BUFFER_OVERRIDE_FLAG]) {
return;
}
const bufferCtor = globalRef.Buffer as typeof Buffer | undefined;
if (!bufferCtor || !bufferCtor.prototype) {
return;
}
const bufferPrototype = bufferCtor.prototype;
for (const key of Reflect.ownKeys(bufferPrototype)) {
const descriptor = Object.getOwnPropertyDescriptor(bufferPrototype, key);
if (!descriptor || typeof descriptor.value !== 'function' || !descriptor.configurable) {
continue;
}
const original = descriptor.value;
const getter = function () {
return original;
};
const setter = function (this: unknown, value: unknown) {
if (this === bufferPrototype) {
throw new TypeError(`Cannot assign to read only property '${String(key)}' of object 'Buffer.prototype'`);
}
Object.defineProperty(this as object, key, {
configurable: true,
enumerable: true,
writable: true,
value,
});
};
Object.defineProperty(getter, 'originalValue', {
configurable: false,
enumerable: false,
writable: false,
value: original,
});
Object.defineProperty(bufferPrototype, key, {
configurable: descriptor.configurable,
enumerable: descriptor.enumerable,
get: getter,
set: setter,
});
}
globalRef[BUFFER_OVERRIDE_FLAG] = true;
}
export function lockdownSes(options?: LockdownOptions) {
// mysql2 mutates Buffer instances when mocking packets; run the shim before lockdown.
enableBufferPrototypeOverrides();
const globalRef = globalThis as Record<string | symbol, any>;
if (globalRef[SES_LOCK_FLAG]) {
return;
}
if (typeof lockdown !== 'function') {
throw new Error('SES lockdown is unavailable in the current runtime environment.');
}
try {
lockdown(options);
} catch (error) {
if (!isAlreadyLockedError(error)) {
throw error;
}
}
globalRef[SES_LOCK_FLAG] = true;
}
+133 -1579
View File
File diff suppressed because it is too large Load Diff