mirror of
https://github.com/nocobase/nocobase.git
synced 2026-10-29 18:16:18 +08:00
Merge branch 'main' into next
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
+449
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+84
-3
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user