Merge branch 'main' into next

This commit is contained in:
gchust
2026-06-07 22:43:31 +08:00
7 changed files with 761 additions and 59 deletions
@@ -2299,6 +2299,63 @@ describe('basic importer', () => {
expect(users[1].get('email')).toBe('test2@test.com');
});
it('should generate different sort values for imported rows', async () => {
await app.destroy();
app = await createMockServer({
plugins: ['field-sort', 'data-source-main', 'error-handler'],
});
const Task = app.db.collection({
name: 'tasks',
fields: [
{
type: 'string',
name: 'name',
},
{
type: 'sort',
name: 'sort',
},
],
});
await app.db.sync();
const columns = [
{
dataIndex: ['name'],
defaultTitle: 'Name',
},
];
const templateCreator = new TemplateCreator({
collection: Task,
columns,
});
const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook;
const worksheet = template.Sheets[template.SheetNames[0]];
XLSX.utils.sheet_add_aoa(worksheet, [['task1'], ['task2'], ['task3']], {
origin: 'A2',
});
const importer = new XlsxImporter({
collectionManager: app.mainDataSource.collectionManager,
collection: Task,
columns,
workbook: template,
});
await importer.run();
const tasks = await Task.repository.find({
sort: ['id'],
});
expect(tasks.map((task) => task.get('sort'))).toEqual([1, 2, 3]);
});
describe('template creator', () => {
it('should create template with explain and field descriptions', async () => {
const User = app.db.collection({
@@ -24,33 +24,42 @@ export class SortField extends Field {
const { model } = this.context.collection;
instances = Array.isArray(instances) ? instances : [instances];
for (const instance of instances) {
if (from == 'create' && isNumber(instance.get(name))) {
continue;
}
if (isNumber(instance.get(name)) && instance._previousDataValues[scopeKey] == instance[scopeKey]) {
continue;
}
await (<typeof SortField>this.constructor).lockManager.runExclusive(
this.context.collection.name,
async () => {
const maxCache = new Map<string, number>();
const where = {};
for (const instance of instances) {
if (from == 'create' && isNumber(instance.get(name))) {
continue;
}
if (isNumber(instance.get(name)) && instance._previousDataValues[scopeKey] == instance[scopeKey]) {
continue;
}
if (scopeKey) {
const value = instance.get(scopeKey);
if (value !== undefined && value !== null) {
where[scopeKey] = value;
}
}
const where = {};
let cacheKey = '__default__';
await (<typeof SortField>this.constructor).lockManager.runExclusive(
this.context.collection.name,
async () => {
const max = await model.max<number, any>(name, { ...options, where });
const newValue = (max || 0) + 1;
if (scopeKey) {
const value = instance.get(scopeKey);
if (value !== undefined && value !== null) {
where[scopeKey] = value;
cacheKey = `${typeof value}:${String(value)}`;
}
}
if (!maxCache.has(cacheKey)) {
const max = await model.max<number, any>(name, { ...options, where });
maxCache.set(cacheKey, max || 0);
}
const newValue = (maxCache.get(cacheKey) ?? 0) + 1;
maxCache.set(cacheKey, newValue);
instance.set(name, newValue);
},
2000,
);
}
}
},
2000,
);
};
onScopeChange = async (instance, options) => {
@@ -7,6 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { vi } from 'vitest';
import { MockServer } from '@nocobase/test';
import { GlobalContext, HttpRequestContext, ServerBaseContext } from '../template/contexts';
import { resolveJsonTemplate } from '../template/resolver';
@@ -78,6 +79,17 @@ describe('variables resolver (no HTTP)', () => {
expect(out.x).toBe(0);
});
it('blocks intrinsic constructor traversal to host process', async () => {
const { req } = makeCtx(1);
const tpl = {
v: "{{ (() => { try { return ({}).constructor.constructor('return process')() ? 'escaped' : 'safe'; } catch (_) { return 'blocked'; } })() }}",
} as any;
const out = await resolveJsonTemplate(tpl, req);
expect(out.v).toBe('blocked');
});
it('preserves unknown placeholders', async () => {
const { req } = makeCtx(1);
const tpl = { x: '{{ ctx.unknown }}', y: 'Hello {{ foo.bar }}' } as any;
@@ -100,6 +112,54 @@ describe('variables resolver (no HTTP)', () => {
expect(out.t).toBe('undefined');
});
it('does not expose koa context internals to SES expressions', async () => {
const { koa, req } = makeCtx(1);
const query = vi.fn();
koa.db = { sequelize: { query } };
const exploit =
"{{ (async () => { const seq = ctx.koaCtx.db.sequelize; await seq.query('SELECT 1'); return 'ran'; })() }}";
const out = await resolveJsonTemplate({ v: exploit }, req);
expect(query).not.toHaveBeenCalled();
expect(out.v).toBe(exploit);
});
it('only exposes explicitly registered context keys in the sandbox', async () => {
const { req } = makeCtx(1);
const tpl = {
app: '{{ ctx.app }}',
db: '{{ ctx.db }}',
koaCtx: '{{ ctx.koaCtx }}',
request: '{{ ctx.request }}',
user: '{{ ctx.user.id }}',
} as any;
const out = await resolveJsonTemplate(tpl, req);
expect(out.app).toBe('{{ ctx.app }}');
expect(out.db).toBe('{{ ctx.db }}');
expect(out.koaCtx).toBe('{{ ctx.koaCtx }}');
expect(out.request).toBe('{{ ctx.request }}');
expect(out.user).toBe(1);
});
it('does not expose plain root object properties as sandbox variables', async () => {
const query = vi.fn();
const rawCtx = {
db: { sequelize: { query } },
user: { id: 1 },
};
const exploit =
"{{ (async () => { const seq = ctx.db.sequelize; await seq.query('SELECT 1'); return 'ran'; })() }}";
const out = await resolveJsonTemplate({ exploit, user: '{{ ctx.user.id }}' }, rawCtx as any);
expect(query).not.toHaveBeenCalled();
expect(out.exploit).toBe(exploit);
expect(out.user).toBe('{{ ctx.user.id }}');
});
it('supports custom ctx methods attached via registry', async () => {
if (!variables.get('twice')) {
variables.register({
@@ -115,6 +175,194 @@ describe('variables resolver (no HTTP)', () => {
expect(out.v).toBe(42);
});
it('blocks constructor traversal on context values and methods', async () => {
if (!variables.get('twice')) {
variables.register({
name: 'twice',
scope: 'request',
attach: (flowCtx) => flowCtx.defineMethod('twice', (n: any) => Number(n) * 2),
});
}
const { koa, req } = makeCtx(1);
const tpl = {
userCtor: '{{ ctx.user.constructor }}',
getCtor: "{{ (await __get('user')).constructor }}",
helperCtor: '{{ __get.constructor }}',
methodCtor: '{{ ctx.twice.constructor }}',
methodStillWorks: '{{ ctx.twice(21) }}',
} as any;
await variables.attachUsedVariables(req, koa, tpl, {});
const out = await resolveJsonTemplate(tpl, req);
expect(out.userCtor).toBe('{{ ctx.user.constructor }}');
expect(out.getCtor).toBe("{{ (await __get('user')).constructor }}");
expect(out.helperCtor).toBe('{{ __get.constructor }}');
expect(out.methodCtor).toBe('{{ ctx.twice.constructor }}');
expect(out.methodStillWorks).toBe(42);
});
it('does not read then accessors on exposed data values', async () => {
const ctx = new ServerBaseContext();
const thenGetter = vi.fn(() => () => undefined);
const payload = { name: 'safe' };
Object.defineProperty(payload, 'then', {
enumerable: true,
configurable: true,
get: thenGetter,
});
ctx.defineProperty('payload', { value: payload });
const out = await resolveJsonTemplate(
{
name: '{{ ctx.payload.name }}',
thenValue: '{{ ctx.payload.then }}',
whole: '{{ ctx.payload }}',
} as any,
ctx,
);
expect(thenGetter).not.toHaveBeenCalled();
expect(out.name).toBe('safe');
expect(out.thenValue).toBe('{{ ctx.payload.then }}');
expect(out.whole).toEqual({ name: 'safe' });
});
it('resolves plain then data fields on exposed data values', async () => {
const ctx = new ServerBaseContext();
ctx.defineProperty('payload', { value: { name: 'safe', then: 'visible' } });
const out = await resolveJsonTemplate(
{
name: '{{ ctx.payload.name }}',
thenValue: '{{ ctx.payload.then }}',
whole: '{{ ctx.payload }}',
} as any,
ctx,
);
expect(out.name).toBe('safe');
expect(out.thenValue).toBe('visible');
expect(out.whole).toEqual({ name: 'safe', then: 'visible' });
});
it('does not expose function-valued then fields on exposed data values', async () => {
const ctx = new ServerBaseContext();
const then = vi.fn(() => undefined);
ctx.defineProperty('payload', { value: { name: 'safe', then } });
const out = await resolveJsonTemplate(
{
name: '{{ ctx.payload.name }}',
thenValue: '{{ ctx.payload.then }}',
whole: '{{ ctx.payload }}',
} as any,
ctx,
);
expect(then).not.toHaveBeenCalled();
expect(out.name).toBe('safe');
expect(out.thenValue).toBe('{{ ctx.payload.then }}');
expect(out.whole).toEqual({ name: 'safe' });
});
it('does not expose or invoke accessor properties from data values', async () => {
const ctx = new ServerBaseContext();
const secretGetter = vi.fn(() => 'secret');
const payload = { name: 'safe' };
Object.defineProperty(payload, 'secret', {
enumerable: true,
configurable: true,
get: secretGetter,
});
ctx.defineProperty('payload', { value: payload });
const out = await resolveJsonTemplate(
{
direct: '{{ ctx.payload.secret }}',
descriptor: "{{ Object.getOwnPropertyDescriptor(ctx.payload, 'secret') ? 'present' : 'missing' }}",
keys: "{{ Object.keys(ctx.payload).join(',') }}",
whole: '{{ ctx.payload }}',
} as any,
ctx,
);
expect(secretGetter).not.toHaveBeenCalled();
expect(out.direct).toBe('{{ ctx.payload.secret }}');
expect(out.descriptor).toBe('missing');
expect(out.keys).toBe('name');
expect(out.whole).toEqual({ name: 'safe' });
});
it('does not invoke array index accessors when unwrapping whole arrays', async () => {
const ctx = new ServerBaseContext();
const itemGetter = vi.fn(() => 'secret');
const items = [];
Object.defineProperty(items, '0', {
enumerable: true,
configurable: true,
get: itemGetter,
});
ctx.defineProperty('items', { value: items });
const out = await resolveJsonTemplate('{{ ctx.items }}', ctx);
expect(itemGetter).not.toHaveBeenCalled();
expect(out).toEqual([]);
});
it('keeps array enumeration and length descriptors usable in the sandbox', async () => {
const ctx = new ServerBaseContext();
ctx.defineProperty('items', { value: ['a', 'b'] });
const out = await resolveJsonTemplate(
{
keys: "{{ Object.keys(ctx.items).join(',') }}",
length: "{{ Object.getOwnPropertyDescriptor(ctx.items, 'length').value }}",
} as any,
ctx,
);
expect(out.keys).toBe('0,1');
expect(out.length).toBe(2);
});
it('supports own data properties on primitive tail values without exposing prototype members', async () => {
const ctx = new ServerBaseContext();
ctx.defineProperty('user', { value: { name: 'Alice' } });
const out = await resolveJsonTemplate(
{
length: '{{ ctx.user.name.length }}',
computedLength: '{{ ctx.user.name.length + 1 }}',
index: '{{ ctx.user.name[0] }}',
prototypeMethod: '{{ ctx.user.name.toString() }}',
constructorValue: '{{ ctx.user.name.constructor }}',
} as any,
ctx,
);
expect(out.length).toBe(5);
expect(out.computedLength).toBe(6);
expect(out.index).toBe('A');
expect(out.prototypeMethod).toBe('{{ ctx.user.name.toString() }}');
expect(out.constructorValue).toBe('{{ ctx.user.name.constructor }}');
});
it('passes the top-level sandbox proxy to delegated getters', async () => {
const parent = new ServerBaseContext();
parent.defineProperty('x', {
get: (flowCtx) => flowCtx.hello(),
});
const child = new ServerBaseContext();
child.defineMethod('hello', () => 'ok');
child.delegate(parent);
const out = await resolveJsonTemplate('{{ ctx.x }}', child);
expect(out).toBe('ok');
});
describe('server resolver: dot-only path aggregation', () => {
it('aggregates across arrays with dot-only path', async () => {
const ctx = new ServerBaseContext();
@@ -12,6 +12,12 @@ import { ResourcerContext } from '@nocobase/resourcer';
type Getter<T = any> = (ctx: ServerBaseContext) => T | Promise<T>;
const BLOCKED_SANDBOX_KEYS = new Set(['__proto__', 'prototype', 'constructor', 'then']);
function isBlockedSandboxKey(key: string) {
return BLOCKED_SANDBOX_KEYS.has(key);
}
export interface PropertyOptions {
/** 固定值,优先级高于 get */
value?: any;
@@ -100,6 +106,38 @@ export class ServerBaseContext {
this._delegates = [];
}
getSandboxKeys(): string[] {
const keys = new Set<string>();
for (const key of Object.keys(this._props)) {
if (!isBlockedSandboxKey(key)) keys.add(key);
}
for (const key of Object.keys(this._methods)) {
if (!isBlockedSandboxKey(key)) keys.add(key);
}
for (const d of this._delegates) {
for (const key of d.getSandboxKeys()) {
if (!isBlockedSandboxKey(key)) keys.add(key);
}
}
return Array.from(keys);
}
getSandboxValue(key: string, current: ServerBaseContext = this.createProxy()): any {
if (isBlockedSandboxKey(key)) return undefined;
if (Object.prototype.hasOwnProperty.call(this._props, key)) {
return this._getOwn(key, current);
}
if (Object.prototype.hasOwnProperty.call(this._methods, key)) {
const fn = this._methods[key];
return typeof fn === 'function' ? fn.bind(this) : fn;
}
for (const d of this._delegates) {
if (!d.getSandboxKeys().includes(key)) continue;
return d.getSandboxValue(key, current);
}
return undefined;
}
/** 创建并返回代理对象(同一实例下保持稳定引用) */
createProxy() {
if (this._proxy) return this._proxy as any;
@@ -9,23 +9,27 @@
import 'ses';
import _ from 'lodash';
import { getValuesByPath } from '@nocobase/utils/client';
// TODO: 是否有必要lockdown?
// // 使用 SES 进行隔离
// declare const lockdown: any;
// try {
// // 测试环境下避免执行全局 lockdown,以免冻结测试依赖(如 Vitest/Chai
// const env = (typeof process !== 'undefined' && (process as any)?.env) ? (process as any).env : {} as any;
// if (typeof lockdown === 'function' && env.NODE_ENV !== 'test') {
// lockdown({ errorTaming: 'unsafe', consoleTaming: 'unsafe' });
// }
// } catch (_) {
// // ignore
// }
import { lockdownSes } from '@nocobase/utils';
import { ServerBaseContext } from './contexts';
export type JSONValue = string | { [key: string]: JSONValue } | JSONValue[];
type SandboxContextSource = {
getSandboxKeys: () => string[];
getSandboxValue: (key: string) => unknown;
};
type SandboxProxyKind = 'context' | 'data' | 'function';
const BLOCKED_SANDBOX_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
const sandboxProxyCache = new WeakMap<object, unknown>();
const sandboxProxyMeta = new WeakMap<object, { kind: SandboxProxyKind; source: unknown }>();
const EMPTY_SANDBOX_CONTEXT: SandboxContextSource = {
getSandboxKeys: () => [],
getSandboxValue: () => undefined,
};
let resolverLockdownReady = false;
/**
* 解析 JSON 模板中形如 {{ ... }} 的占位符(服务端解析)。
* 仅支持以 ctx 开头的路径与表达式(如:{{ ctx.user.id }}、{{ ctx.record.roles[0].name }})。
@@ -70,6 +74,248 @@ async function replacePlaceholders(input: string, ctx: any) {
return result;
}
function isObjectLike(value: unknown): value is object {
return value !== null && (typeof value === 'object' || typeof value === 'function');
}
function isTrustedPromise(value: unknown): value is Promise<unknown> {
return value instanceof Promise;
}
function isBlockedSandboxKey(key: PropertyKey) {
return typeof key === 'string' && BLOCKED_SANDBOX_KEYS.has(key);
}
function getSandboxDataDescriptor(source: object, key: PropertyKey) {
if (isBlockedSandboxKey(key) || typeof key === 'symbol') return undefined;
const descriptor = Reflect.getOwnPropertyDescriptor(source, key);
if (!descriptor || !('value' in descriptor)) return undefined;
if (key === 'then' && typeof descriptor.value === 'function') return undefined;
return descriptor;
}
function getPrimitiveDataDescriptor(source: unknown, key: string) {
if (source == null || isObjectLike(source)) return undefined;
return getSandboxDataDescriptor(Object(source), key);
}
function isSandboxContextSource(value: unknown): value is SandboxContextSource {
return value === EMPTY_SANDBOX_CONTEXT || value instanceof ServerBaseContext;
}
function wrapSandboxValue(value: unknown): unknown {
if (isTrustedPromise(value)) {
return Promise.prototype.then.call(value, (resolved) => wrapSandboxValue(resolved));
}
if (!isObjectLike(value)) return value;
if (sandboxProxyMeta.has(value)) return value;
const cached = sandboxProxyCache.get(value);
if (cached) return cached;
const kind: SandboxProxyKind = isSandboxContextSource(value)
? 'context'
: typeof value === 'function'
? 'function'
: 'data';
const proxy =
kind === 'context'
? createSandboxContextProxy(value as SandboxContextSource)
: kind === 'function'
? createSandboxFunctionProxy(value as (...args: unknown[]) => unknown)
: createSandboxDataProxy(value as Record<PropertyKey, unknown>);
sandboxProxyCache.set(value, proxy);
sandboxProxyMeta.set(proxy as object, { kind, source: value });
return proxy;
}
function wrapRootSandboxContext(ctx: unknown) {
return wrapSandboxValue(isSandboxContextSource(ctx) ? ctx : EMPTY_SANDBOX_CONTEXT);
}
function ensureResolverLockdown() {
if (resolverLockdownReady) return;
lockdownSes({
consoleTaming: 'unsafe',
errorTaming: 'unsafe',
overrideTaming: 'moderate',
stackFiltering: 'verbose',
});
resolverLockdownReady = true;
}
function createSandboxContextProxy(source: SandboxContextSource) {
return new Proxy(Object.create(null), {
get: (_target, key) => {
if (isBlockedSandboxKey(key) || typeof key !== 'string') return undefined;
if (!source.getSandboxKeys().includes(key)) return undefined;
return wrapSandboxValue(source.getSandboxValue(key));
},
has: (_target, key) => typeof key === 'string' && source.getSandboxKeys().includes(key),
ownKeys: () => source.getSandboxKeys(),
getOwnPropertyDescriptor: (_target, key) => {
if (isBlockedSandboxKey(key) || typeof key !== 'string' || !source.getSandboxKeys().includes(key)) {
return undefined;
}
return {
configurable: true,
enumerable: true,
value: wrapSandboxValue(source.getSandboxValue(key)),
};
},
getPrototypeOf: () => null,
set: () => false,
defineProperty: () => false,
deleteProperty: () => false,
});
}
function createSandboxFunctionProxy(source: (...args: unknown[]) => unknown) {
const callable = (...args: unknown[]) => Reflect.apply(source, undefined, args);
return new Proxy(callable, {
apply: (_target, _thisArg, argArray) => wrapSandboxValue(Reflect.apply(source, undefined, argArray)),
get: (_target, key) => {
if (key === 'length' || key === 'name') return Reflect.get(source, key);
return undefined;
},
has: (_target, key) => key === 'length' || key === 'name',
ownKeys: () => [],
getOwnPropertyDescriptor: () => undefined,
getPrototypeOf: () => null,
set: () => false,
defineProperty: () => false,
deleteProperty: () => false,
});
}
function createSandboxDataProxy(source: Record<PropertyKey, unknown>) {
const target = Array.isArray(source) ? new Array(source.length) : Object.create(null);
return new Proxy(target, {
get: (_target, key) => {
const descriptor = getSandboxDataDescriptor(source, key);
if (!descriptor) return undefined;
const value = descriptor.value;
return typeof value === 'function' ? wrapSandboxValue(value.bind(source)) : wrapSandboxValue(value);
},
has: (_target, key) => {
if (isBlockedSandboxKey(key) || typeof key !== 'string') return false;
return !!getSandboxDataDescriptor(source, key);
},
ownKeys: () => {
const keys = Reflect.ownKeys(source).filter((key) => {
if (typeof key !== 'string' || isBlockedSandboxKey(key)) return false;
return !!getSandboxDataDescriptor(source, key);
});
if (Array.isArray(source) && !keys.includes('length')) keys.push('length');
return keys;
},
getOwnPropertyDescriptor: (proxyTarget, key) => {
if (isBlockedSandboxKey(key) || typeof key === 'symbol') return undefined;
if (Array.isArray(source) && key === 'length') {
return Reflect.getOwnPropertyDescriptor(proxyTarget, key);
}
const descriptor = getSandboxDataDescriptor(source, key);
if (!descriptor) return undefined;
return {
configurable: true,
enumerable: descriptor.enumerable,
writable: false,
value: wrapSandboxValue(descriptor.value),
};
},
getPrototypeOf: () => null,
set: () => false,
defineProperty: () => false,
deleteProperty: () => false,
});
}
async function unwrapSandboxValue(value: unknown, seen = new WeakMap<object, unknown>()): Promise<unknown> {
const resolved = isTrustedPromise(value) ? await value : value;
if (!isObjectLike(resolved)) return resolved;
const meta = sandboxProxyMeta.get(resolved);
if (meta?.kind === 'function' || typeof resolved === 'function') return undefined;
const source = meta?.source ?? resolved;
if (!isObjectLike(source)) return source;
if (seen.has(source)) return seen.get(source);
if (isSandboxContextSource(source)) {
const out: Record<string, unknown> = {};
seen.set(source, out);
for (const key of source.getSandboxKeys()) {
if (BLOCKED_SANDBOX_KEYS.has(key)) continue;
out[key] = await unwrapSandboxValue(wrapSandboxValue(source.getSandboxValue(key)), seen);
}
return out;
}
if (Array.isArray(source)) {
const out: unknown[] = [];
seen.set(source, out);
const lengthDescriptor = Reflect.getOwnPropertyDescriptor(source, 'length');
const length = typeof lengthDescriptor?.value === 'number' ? lengthDescriptor.value : 0;
for (let index = 0; index < length; index++) {
const descriptor = Reflect.getOwnPropertyDescriptor(source, String(index));
if (!descriptor || !('value' in descriptor)) continue;
out.push(await unwrapSandboxValue(descriptor.value, seen));
}
return out;
}
if (source instanceof Date) return source;
const out: Record<string, unknown> = {};
seen.set(source, out);
for (const key of Object.keys(source as Record<string, unknown>)) {
if (BLOCKED_SANDBOX_KEYS.has(key)) continue;
const descriptor = getSandboxDataDescriptor(source, key);
if (!descriptor) continue;
out[key] = await unwrapSandboxValue(descriptor.value, seen);
}
return out;
}
function getRootSandboxValue(ctx: unknown, key: string) {
if (BLOCKED_SANDBOX_KEYS.has(key)) return undefined;
if (isSandboxContextSource(ctx)) {
if (!ctx.getSandboxKeys().includes(key)) return undefined;
return wrapSandboxValue(ctx.getSandboxValue(key));
}
return undefined;
}
async function getSandboxProperty(value: unknown, key: string) {
if (BLOCKED_SANDBOX_KEYS.has(key)) return undefined;
const resolved = isTrustedPromise(value) ? await value : value;
if (resolved == null) return undefined;
const meta = isObjectLike(resolved) ? sandboxProxyMeta.get(resolved) : undefined;
const source = meta?.source ?? resolved;
if (isSandboxContextSource(source)) {
if (!source.getSandboxKeys().includes(key)) return undefined;
return wrapSandboxValue(source.getSandboxValue(key));
}
if (typeof source === 'function') {
return key === 'length' || key === 'name' ? Reflect.get(source, key) : undefined;
}
if (!isObjectLike(source)) {
const descriptor = getPrimitiveDataDescriptor(source, key);
if (!descriptor || typeof descriptor.value === 'function') return undefined;
return wrapSandboxValue(descriptor.value);
}
const descriptor = getSandboxDataDescriptor(source, key);
if (!descriptor) return undefined;
const current = descriptor.value;
return typeof current === 'function' ? wrapSandboxValue(current.bind(source)) : wrapSandboxValue(current);
}
// 在 SES 沙箱中执行完整的 JS 表达式;在此之前会将 ctx.* 访问改写为 await __get(var, path)
async function evaluate(expr: string, ctx: any) {
try {
@@ -83,25 +329,25 @@ async function evaluate(expr: string, ctx: any) {
if (dotOnly) {
const first = dotOnly[1];
const rest = dotOnly[2];
const base = await ctx[first];
if (!rest) return base;
const base = getRootSandboxValue(ctx, first);
if (!rest) return await unwrapSandboxValue(wrapSandboxValue(base));
// 使用异步版本取值,逐段 await,并保留数组场景下的隐式聚合语义
const resolved = await asyncGetValuesByPath(base, rest);
// 当 dot path 含 '-' 时可能与减号运算符存在歧义(例如:ctx.aa.bb-ctx.cc)。
// 若按 path 解析未取到值,则回退到 JS 表达式解析,尽量保持兼容。
if (typeof resolved !== 'undefined' || !rest.includes('-')) {
return resolved;
return await unwrapSandboxValue(resolved);
}
}
const transformed = preprocessExpression(raw);
ensureResolverLockdown();
const compartment = new Compartment({
ctx,
__get: (varName: string, path?: string) => getAtPath(ctx, varName, path),
console,
ctx: wrapRootSandboxContext(ctx),
__get: wrapSandboxValue((varName: string, path?: string) => getAtPath(ctx, varName, path)),
});
const wrapped = `(async () => { try { return ${transformed}; } catch (e) { return undefined; } })()`;
return await compartment.evaluate(wrapped);
return await unwrapSandboxValue(await compartment.evaluate(wrapped));
} catch (_) {
return undefined;
}
@@ -112,19 +358,15 @@ async function evaluate(expr: string, ctx: any) {
async function getAtPath(ctx: any, varName: string, path?: string) {
try {
// base may be Promise; wait once
let current = await ctx[varName];
let current = getRootSandboxValue(ctx, varName);
if (!path) return current;
const norm = String(path || '').replace(/^\./, '');
const segments = _.toPath(norm);
for (const seg of segments) {
if (current == null) return undefined;
let val = current[seg];
if (val && typeof val['then'] === 'function') {
val = await val;
}
current = val;
current = await getSandboxProperty(current, seg);
}
return current;
return wrapSandboxValue(current);
} catch (_) {
return undefined;
}
@@ -156,21 +398,22 @@ async function asyncGetValuesByPath(obj: any, path: string, defaultValue?: any):
if (Array.isArray(currentValue)) {
shouldReturnArray = true;
const rest = keys.slice(i).join('.');
const parts = await Promise.all(currentValue.map((el) => asyncGetValuesByPath(el, rest, defaultValue)));
const parts = await Promise.all(
Array.prototype.map.call(currentValue, (el) => asyncGetValuesByPath(el, rest, defaultValue)),
);
// 将数组或标量统一拍平一层
for (const p of parts) {
if (Array.isArray(p)) result.push(...p);
else if (typeof p !== 'undefined') result.push(p);
if (Array.isArray(p)) {
for (let index = 0; index < p.length; index++) {
if (typeof p[index] !== 'undefined') result.push(p[index]);
}
} else if (typeof p !== 'undefined') result.push(p);
}
break;
}
// 普通对象属性访问,若为 Promise 则等待
let val = currentValue?.[key];
if (val && typeof (val as any).then === 'function') {
val = await val;
}
currentValue = val;
currentValue = await getSandboxProperty(currentValue, key);
if (i === keys.length - 1) {
result.push(currentValue);
@@ -9,6 +9,8 @@
import React from 'react';
import { DisplayTitleFieldModel, TableColumnModel } from '@nocobase/client-v2';
import { css } from '@emotion/css';
import { Typography } from 'antd';
import { MapComponent } from '../MapComponent';
import { tExpr } from '../../locale';
@@ -29,6 +31,44 @@ export class DisplayMapFieldModel extends DisplayTitleFieldModel {
return null;
}
render(): any {
const { value, displayStyle, overflowMode, width, style, className } = this.props;
if (displayStyle === 'map') {
return this.renderComponent(value);
}
return (
<Typography.Text
className={className}
ellipsis={
overflowMode === 'ellipsis'
? {
tooltip: {
rootClassName: css`
.ant-tooltip-inner {
color: #000;
max-height: 500px;
overflow-y: auto;
padding: 10px;
}
`,
color: '#fff',
},
}
: false
}
style={{
...(style || {}),
whiteSpace: overflowMode === 'wrap' ? 'normal' : 'nowrap',
width: width || 'auto',
}}
>
{this.renderComponent(value)}
</Typography.Text>
);
}
renderInDisplayStyle() {
return this.renderComponent(this.props.value);
}
@@ -0,0 +1,67 @@
/**
* 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 React from 'react';
import { render, screen } from '@testing-library/react';
import { FlowEngine } from '@nocobase/flow-engine';
import { describe, expect, it, vi } from 'vitest';
import { DisplayPointFieldModel } from '../DisplayPointFieldModel';
vi.mock('../../MapComponent', () => ({
MapComponent: (props) => <div data-testid="map-component">{JSON.stringify(props.value)}</div>,
}));
describe('DisplayMapFieldModel', () => {
function createModel(props: Record<string, any>) {
const engine = new FlowEngine();
engine.registerModels({ DisplayPointFieldModel });
const model = engine.createModel<DisplayPointFieldModel>({
use: DisplayPointFieldModel,
uid: `display-point-${props.displayStyle || 'text'}`,
props,
});
model.context.defineProperty('collectionField', {
value: {
uiSchema: {
'x-component-props': {
mapType: 'amap',
},
},
},
});
return model;
}
it('renders point values as text without converting the coordinate array to an empty string', () => {
const model = createModel({
displayStyle: 'text',
fieldNames: { label: 'name' },
value: [116.397, 39.907],
});
render(<>{model.render()}</>);
expect(screen.getByText('116.397,39.907')).toBeInTheDocument();
});
it('passes the raw point value to the map component in map mode', () => {
const value = [116.397, 39.907];
const model = createModel({
displayStyle: 'map',
value,
});
render(<>{model.render()}</>);
expect(screen.getByTestId('map-component')).toHaveTextContent(JSON.stringify(value));
});
});