mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-01 14:57:36 +08:00
Merge branch 'main' into next
This commit is contained in:
+57
@@ -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) => {
|
||||
|
||||
+248
@@ -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);
|
||||
|
||||
+40
@@ -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);
|
||||
}
|
||||
|
||||
+67
@@ -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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user