@svadmin/lite 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,264 @@
1
+ import { assertSchemaFormSchema, isSchemaFormNodeVisible, prepareSchemaFormValue, validateSchemaFormValue } from '@svadmin/core/schema-form';
2
+ export function schemaFormFieldName(path) {
3
+ return `data:${JSON.stringify(path)}`;
4
+ }
5
+ export const schemaFormActionName = 'schemaFormAction';
6
+ export function encodeSchemaFormArrayAction(action) {
7
+ if (!['add', 'remove'].includes(action.type) ||
8
+ !Array.isArray(action.path) || action.path.length === 0 || action.path.length > 32 ||
9
+ !action.path.every(segment => typeof segment === 'string' && segment.length > 0 && segment.length <= 200)) {
10
+ throw new Error('Invalid schema form action.');
11
+ }
12
+ if (action['type'] === 'remove' &&
13
+ (!Number.isSafeInteger(action.index) || action.index < 0 || action.index > 1000)) {
14
+ throw new Error('Invalid schema form action.');
15
+ }
16
+ if (action['type'] === 'add' && action['index'] !== undefined)
17
+ throw new Error('Invalid schema form action.');
18
+ return JSON.stringify(action);
19
+ }
20
+ export function decodeSchemaFormArrayAction(form) {
21
+ const values = form.getAll(schemaFormActionName);
22
+ if (values.length === 0)
23
+ return;
24
+ if (values.length !== 1 || typeof values[0] !== 'string' || values[0].length > 10000)
25
+ throw new Error('Invalid schema form action.');
26
+ let parsed;
27
+ try {
28
+ parsed = JSON.parse(values[0]);
29
+ }
30
+ catch {
31
+ throw new Error('Invalid schema form action.');
32
+ }
33
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
34
+ throw new Error('Invalid schema form action.');
35
+ const candidate = parsed;
36
+ if ((candidate['type'] !== 'add' && candidate['type'] !== 'remove') ||
37
+ !Array.isArray(candidate['path']) ||
38
+ !candidate['path'].every(segment => typeof segment === 'string' && segment.length > 0 && segment.length <= 200) ||
39
+ candidate['path'].length === 0 || candidate['path'].length > 32 ||
40
+ Object.keys(candidate).some(key => !['type', 'path', 'index'].includes(key))) {
41
+ throw new Error('Invalid schema form action.');
42
+ }
43
+ if (candidate['type'] === 'add' && Object.hasOwn(candidate, 'index'))
44
+ throw new Error('Invalid schema form action.');
45
+ if (candidate['type'] === 'remove' &&
46
+ (!Number.isSafeInteger(candidate['index']) || candidate['index'] < 0 || candidate['index'] > 1000)) {
47
+ throw new Error('Invalid schema form action.');
48
+ }
49
+ return candidate;
50
+ }
51
+ /** 按可信 Schema 解码并验证支持的约束;不替代资源授权和业务命令校验。 */
52
+ export function decodeSchemaFormData(schema, form) {
53
+ if (decodeSchemaFormArrayAction(form))
54
+ throw new Error('Array actions require decodeSchemaFormSubmission.');
55
+ return decodeFields(schema, form, true);
56
+ }
57
+ function decodeFields(schema, form, validate) {
58
+ assertSchemaFormSchema(schema);
59
+ decodeSchemaFormArrayAction(form);
60
+ const allowed = new Set();
61
+ const controllers = {};
62
+ const controllerValues = new Map();
63
+ let nodes = 0;
64
+ let textLength = 0;
65
+ const fail = () => { throw new Error('Invalid schema form data.'); };
66
+ function values(name) {
67
+ allowed.add(name);
68
+ return form.getAll(name).map(value => {
69
+ if (typeof value !== 'string')
70
+ return fail();
71
+ textLength += value.length;
72
+ if (textLength > 1_000_000)
73
+ return fail();
74
+ return value;
75
+ });
76
+ }
77
+ function visit(node, path, depth) {
78
+ const cacheKey = JSON.stringify(path);
79
+ if (controllerValues.has(cacheKey))
80
+ return controllerValues.get(cacheKey);
81
+ if (++nodes > 1000 || depth > 32)
82
+ return fail();
83
+ if (node.type === 'object' || node.properties) {
84
+ const result = {};
85
+ for (const [key, child] of Object.entries(node.properties ?? {})) {
86
+ if (!isSchemaFormNodeVisible(child, controllers))
87
+ continue;
88
+ Object.defineProperty(result, key, {
89
+ value: visit(child, [...path, key], depth + 1),
90
+ enumerable: true, configurable: true, writable: true,
91
+ });
92
+ }
93
+ return result;
94
+ }
95
+ if (node.type === 'array') {
96
+ const counts = values(`length:${JSON.stringify(path)}`);
97
+ if (counts.length !== 1 || !/^(0|[1-9]\d*)$/.test(counts[0] ?? ''))
98
+ return fail();
99
+ const count = Number(counts[0]);
100
+ if (!Number.isSafeInteger(count) || count > 1000 - nodes)
101
+ return fail();
102
+ return Array.from({ length: count }, (_, index) => visit(node.items ?? { type: 'string' }, [...path, String(index)], depth + 1));
103
+ }
104
+ const entries = values(schemaFormFieldName(path));
105
+ if (node.type === 'boolean' && !node.enum) {
106
+ if (entries.length === 1 && entries[0] === 'false')
107
+ return false;
108
+ if (entries.length === 2 && entries[0] === 'false' && entries[1] === 'true')
109
+ return true;
110
+ return fail();
111
+ }
112
+ if (entries.length !== 1)
113
+ return fail();
114
+ const raw = entries[0] ?? '';
115
+ if (node.enum) {
116
+ if (raw === '')
117
+ return undefined;
118
+ if (!/^__json_enum_(0|[1-9]\d*)$/.test(raw))
119
+ return fail();
120
+ const index = Number(raw.slice('__json_enum_'.length));
121
+ if (!Number.isSafeInteger(index) || index >= node.enum.length)
122
+ return fail();
123
+ return node.enum[index];
124
+ }
125
+ if (node.type === 'null')
126
+ return raw === 'null' ? null : fail();
127
+ if (node.type === 'number' || node.type === 'integer') {
128
+ if (raw === '')
129
+ return undefined;
130
+ if (!/^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(raw))
131
+ return fail();
132
+ const number = Number(raw);
133
+ if (!Number.isFinite(number) || (node.type === 'integer' && !Number.isSafeInteger(number)))
134
+ return fail();
135
+ return number;
136
+ }
137
+ if (node.type !== undefined && node.type !== 'string')
138
+ return fail();
139
+ return raw;
140
+ }
141
+ if (schema.type !== 'object' && !schema.properties)
142
+ return fail();
143
+ // 控制字段只允许无条件标量,因此可先解码,不依赖属性声明顺序。
144
+ function collectControllers(node) {
145
+ const path = node.visibleWhen?.path;
146
+ if (path && !controllerValues.has(JSON.stringify(path))) {
147
+ let definition = schema;
148
+ for (const key of path) {
149
+ const next = definition.properties?.[key];
150
+ if (!next)
151
+ return fail();
152
+ definition = next;
153
+ }
154
+ const decoded = visit(definition, [...path], path.length);
155
+ controllerValues.set(JSON.stringify(path), decoded);
156
+ let target = controllers;
157
+ path.forEach((key, index) => {
158
+ if (index === path.length - 1) {
159
+ Object.defineProperty(target, key, { value: decoded, enumerable: true, configurable: true });
160
+ }
161
+ else {
162
+ if (!Object.hasOwn(target, key))
163
+ Object.defineProperty(target, key, {
164
+ value: {}, enumerable: true, configurable: true,
165
+ });
166
+ target = target[key];
167
+ }
168
+ });
169
+ }
170
+ for (const child of Object.values(node.properties ?? {}))
171
+ collectControllers(child);
172
+ if (node.items)
173
+ collectControllers(node.items);
174
+ }
175
+ collectControllers(schema);
176
+ const result = visit(schema, [], 0);
177
+ if (validate && validateSchemaFormValue(schema, result).length > 0)
178
+ return fail();
179
+ for (const key of form.keys()) {
180
+ if ((key.startsWith('data:') || key.startsWith('length:')) && !allowed.has(key))
181
+ return fail();
182
+ }
183
+ return result;
184
+ }
185
+ /** 数组动作只生成下一份草稿;只有 submit 分支才可进入业务写入。 */
186
+ export function decodeSchemaFormSubmission(schema, form) {
187
+ const action = decodeSchemaFormArrayAction(form);
188
+ if (!action)
189
+ return { kind: 'submit', data: decodeSchemaFormData(schema, form) };
190
+ const data = decodeFields(schema, form, false);
191
+ const fail = () => { throw new Error('Invalid schema form action.'); };
192
+ let node = schema;
193
+ let current = data;
194
+ for (const segment of action.path) {
195
+ if (node.type === 'array') {
196
+ if (!/^(0|[1-9]\d*)$/.test(segment) || !Array.isArray(current) ||
197
+ Number(segment) >= current.length)
198
+ return fail();
199
+ node = node.items ?? { type: 'string' };
200
+ current = current[Number(segment)];
201
+ }
202
+ else {
203
+ if (!node.properties || !Object.hasOwn(node.properties, segment) ||
204
+ current === null || typeof current !== 'object' || !Object.hasOwn(current, segment))
205
+ return fail();
206
+ const next = node.properties[segment];
207
+ if (!next)
208
+ return fail();
209
+ node = next;
210
+ current = current[segment];
211
+ }
212
+ if (!isSchemaFormNodeVisible(node, data))
213
+ return fail();
214
+ }
215
+ if (node.type !== 'array' || !Array.isArray(current))
216
+ return fail();
217
+ // 默认值也受结构预算约束,防止嵌套默认数组放大草稿。
218
+ let nodes = 0;
219
+ let length = 0;
220
+ function defaultValue(definition, depth) {
221
+ if (++nodes > 1000 || depth > 32)
222
+ return fail();
223
+ if (definition.default !== undefined)
224
+ return structuredClone(definition.default);
225
+ if (definition.type === 'object' || definition.properties) {
226
+ return Object.fromEntries(Object.entries(definition.properties ?? {})
227
+ .map(([key, child]) => [key, defaultValue(child, depth + 1)]));
228
+ }
229
+ if (definition.type === 'array')
230
+ return [];
231
+ if (definition.type === 'null')
232
+ return null;
233
+ if (definition.enum || definition.type === 'number' || definition.type === 'integer')
234
+ return undefined;
235
+ return definition.type === 'boolean' ? false : '';
236
+ }
237
+ if (action.type === 'add') {
238
+ if (current.length >= Math.min(node.maxItems ?? 1000, 1000))
239
+ return fail();
240
+ current.push(defaultValue(node.items ?? { type: 'string' }, action.path.length + 1));
241
+ }
242
+ else {
243
+ if (action.index === undefined || action.index >= current.length ||
244
+ current.length <= (node.minItems ?? 0))
245
+ return fail();
246
+ current.splice(action.index, 1);
247
+ }
248
+ nodes = 0;
249
+ function checkBudget(value, depth) {
250
+ if (++nodes > 1000 || depth > 32)
251
+ return fail();
252
+ if (typeof value === 'string')
253
+ length += value.length;
254
+ if (length > 1_000_000)
255
+ return fail();
256
+ if (value && typeof value === 'object') {
257
+ for (const child of Object.values(value))
258
+ checkBudget(child, depth + 1);
259
+ }
260
+ }
261
+ checkBudget(data, 0);
262
+ // 草稿允许尚未填写的必填项,但仍投影掉条件隐藏字段。
263
+ return { kind: 'draft', data: prepareSchemaFormValue(schema, data).data };
264
+ }
@@ -1,5 +1,6 @@
1
1
  import { definedOptions } from '@svadmin/core/options';
2
- import { redirect, isRedirect } from '@sveltejs/kit';
2
+ import { FILTER_COLLECTION_LIMIT, isFilterCollectionValue, isCollectionFilterOperator, filterOperatorsForField } from '@svadmin/core/filter-values';
3
+ import { error, redirect, isRedirect } from '@sveltejs/kit';
3
4
  import { resourceToTypeBoxSchema } from './schema-generator';
4
5
  import { parseExplicitBoolean } from './value-normalization';
5
6
  function listRequestState(url, resource) {
@@ -61,57 +62,171 @@ function listRequestFilters(resource, url) {
61
62
  const structured = parseStructuredFilters(resource, url);
62
63
  return structured.length > 0 ? structured : filters;
63
64
  }
65
+ function invalidStructuredFilters() {
66
+ throw error(400, 'Invalid structured filter query.');
67
+ }
64
68
  function parseStructuredFilters(resource, url) {
69
+ const structuredKeys = [...url.searchParams.keys()].filter(key => key.startsWith('filters['));
70
+ const entryPattern = /^filters\[([0-9]+(?:\.value\.[0-9]+)*)\]\[(field|operator|value|values\.(?:0|[1-9]\d*))\]$/;
71
+ if (structuredKeys.some(key => !entryPattern.test(key)))
72
+ invalidStructuredFilters();
65
73
  const entries = [...url.searchParams.entries()]
66
74
  .map(([key, value]) => {
67
- const match = /^filters\[([0-9]+(?:\.value\.[0-9]+)*)\]\[(field|operator|value)\]$/.exec(key);
75
+ const match = entryPattern.exec(key);
68
76
  return match ? { path: match[1], property: match[2], value } : undefined;
69
77
  })
70
78
  .filter((entry) => entry !== undefined);
71
79
  if (entries.length === 0)
72
80
  return [];
81
+ if (entries.length > 300)
82
+ invalidStructuredFilters();
73
83
  const roots = [];
84
+ const seen = new Set();
85
+ let textLength = 0;
74
86
  for (const entry of entries) {
75
- const segments = [...entry.path.matchAll(/\d+/g)].map(match => Number(match[0]));
87
+ textLength += entry.path.length + entry.property.length + entry.value.length;
88
+ if (textLength > 64_000 || entry.path.length > 400)
89
+ invalidStructuredFilters();
90
+ const entryKey = `${entry.path}:${entry.property}`;
91
+ if (seen.has(entryKey))
92
+ invalidStructuredFilters();
93
+ seen.add(entryKey);
94
+ const parts = entry.path.split('.value.');
95
+ if (parts.length > 33 || parts.some(part => !/^(0|[1-9]\d*)$/.test(part)))
96
+ invalidStructuredFilters();
97
+ const segments = parts.map(Number);
76
98
  const rootIndex = segments.shift();
77
- if (rootIndex === undefined || !Number.isSafeInteger(rootIndex) || rootIndex < 0)
78
- continue;
99
+ if (rootIndex === undefined || !Number.isSafeInteger(rootIndex) || rootIndex < 0 || rootIndex > 1000)
100
+ invalidStructuredFilters();
79
101
  let node = roots[rootIndex];
80
102
  if (!node) {
81
103
  node = {};
82
104
  roots[rootIndex] = node;
83
105
  }
84
106
  for (const childIndex of segments) {
85
- if (!Number.isSafeInteger(childIndex) || childIndex < 0)
86
- break;
107
+ if (!Number.isSafeInteger(childIndex) || childIndex < 0 || childIndex > 1000)
108
+ invalidStructuredFilters();
87
109
  node.children ??= [];
88
110
  node = node.children[childIndex] ??= {};
89
111
  }
90
- node[entry.property] = entry.value;
112
+ if (entry.property.startsWith('values.')) {
113
+ const index = Number(entry.property.slice('values.'.length));
114
+ if (!Number.isSafeInteger(index) || index >= FILTER_COLLECTION_LIMIT)
115
+ invalidStructuredFilters();
116
+ (node.values ??= [])[index] = entry.value;
117
+ }
118
+ else if (entry.property === 'field' || entry.property === 'operator' || entry.property === 'value') {
119
+ node[entry.property] = entry.value;
120
+ }
121
+ else
122
+ invalidStructuredFilters();
91
123
  }
92
- function compile(node) {
124
+ const validOperators = new Set([
125
+ 'eq', 'ne', 'lt', 'gt', 'lte', 'gte', 'contains', 'ncontains',
126
+ 'startswith', 'endswith', 'in', 'nin', 'null', 'nnull', 'between', 'nbetween',
127
+ ]);
128
+ let visited = 0;
129
+ function compileList(nodes, depth) {
130
+ // 数组迭代方法会跳过空洞,显式逐索引拒绝稀疏路径。
131
+ const filters = [];
132
+ for (let index = 0; index < nodes.length; index++) {
133
+ const node = nodes[index];
134
+ if (!node)
135
+ invalidStructuredFilters();
136
+ filters.push(compile(node, depth));
137
+ }
138
+ return filters;
139
+ }
140
+ function compile(node, depth) {
141
+ if (++visited > 1000 || depth > 32)
142
+ invalidStructuredFilters();
93
143
  const operator = node.operator;
94
- if (node.field && operator && operator !== 'and' && operator !== 'or') {
95
- const field = resource.fields.find(candidate => candidate.key === node.field);
96
- if (!field)
97
- return undefined;
98
- if (operator === 'null' || operator === 'nnull') {
99
- return { field: node.field, operator: operator, value: null };
144
+ if (operator === 'and' || operator === 'or') {
145
+ if (node.field !== undefined || node.value !== undefined || node.values !== undefined)
146
+ invalidStructuredFilters();
147
+ const children = node.children ?? [];
148
+ return { operator, value: compileList(children, depth + 1) };
149
+ }
150
+ if (node.children !== undefined || !node.field || !operator || !validOperators.has(operator)) {
151
+ invalidStructuredFilters();
152
+ }
153
+ const field = resource.fields.find(candidate => candidate.key === node.field);
154
+ if (!field || field.filterable === false)
155
+ invalidStructuredFilters();
156
+ if (isCollectionFilterOperator(operator)) {
157
+ if (node.value !== undefined || !node.values)
158
+ invalidStructuredFilters();
159
+ const values = [];
160
+ for (let index = 0; index < node.values.length; index++) {
161
+ const raw = node.values[index];
162
+ if (raw === undefined)
163
+ invalidStructuredFilters();
164
+ if (['number', 'currency', 'percent', 'rating', 'rate'].includes(field.type)) {
165
+ if (!/^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(raw))
166
+ invalidStructuredFilters();
167
+ values.push(Number(raw));
168
+ }
169
+ else if (field.type === 'boolean') {
170
+ if (raw !== 'true' && raw !== 'false')
171
+ invalidStructuredFilters();
172
+ values.push(raw === 'true');
173
+ }
174
+ else if (field.type === 'select') {
175
+ // 集合枚举使用 JSON 标量令牌,数值 2 与字符串 "2" 不再碰撞。
176
+ try {
177
+ values.push(JSON.parse(raw));
178
+ }
179
+ catch {
180
+ invalidStructuredFilters();
181
+ }
182
+ }
183
+ else
184
+ values.push(raw);
100
185
  }
101
- if (node.value === undefined || node.value === '')
102
- return undefined;
103
- const value = field.type === 'number' || field.type === 'currency' || field.type === 'percent'
104
- ? Number(node.value)
105
- : field.type === 'boolean' ? node.value === 'true' : node.value;
106
- return { field: node.field, operator: operator, value };
186
+ if (!isFilterCollectionValue(field, operator, values))
187
+ invalidStructuredFilters();
188
+ return { field: node.field, operator: operator, value: values };
107
189
  }
108
- if ((operator === 'and' || operator === 'or') && node.children) {
109
- const children = node.children.map(compile).filter((child) => child !== undefined);
110
- return children.length > 0 ? { operator, value: children } : undefined;
190
+ if (node.values !== undefined || !filterOperatorsForField(field).includes(operator))
191
+ invalidStructuredFilters();
192
+ if (operator === 'null' || operator === 'nnull') {
193
+ if (node.value !== undefined)
194
+ invalidStructuredFilters();
195
+ return { field: node.field, operator, value: null };
196
+ }
197
+ if (node.value === undefined)
198
+ invalidStructuredFilters();
199
+ {
200
+ if ((field.type === 'boolean' && !['eq', 'ne'].includes(operator))
201
+ || (['in', 'nin', 'between', 'nbetween'].includes(operator)))
202
+ invalidStructuredFilters();
203
+ const raw = node.value;
204
+ if (['number', 'currency', 'percent', 'rating', 'rate'].includes(field.type)) {
205
+ if (!['eq', 'ne', 'lt', 'gt', 'lte', 'gte'].includes(operator))
206
+ invalidStructuredFilters();
207
+ if (!/^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(raw))
208
+ invalidStructuredFilters();
209
+ const value = Number(raw);
210
+ if (!Number.isFinite(value))
211
+ invalidStructuredFilters();
212
+ return { field: node.field, operator: operator, value };
213
+ }
214
+ if (field.type === 'boolean') {
215
+ if (raw !== 'true' && raw !== 'false')
216
+ invalidStructuredFilters();
217
+ return { field: node.field, operator: operator, value: raw === 'true' };
218
+ }
219
+ if (field.type === 'select') {
220
+ const matches = field.options?.filter(option => String(option.value) === raw && !option.disabled) ?? [];
221
+ const match = matches[0];
222
+ if (!['eq', 'ne'].includes(operator) || matches.length !== 1 || !match)
223
+ return invalidStructuredFilters();
224
+ return { field: node.field, operator: operator, value: match.value };
225
+ }
226
+ return { field: node.field, operator: operator, value: raw };
111
227
  }
112
- return undefined;
113
228
  }
114
- return roots.map(compile).filter((filter) => filter !== undefined);
229
+ return compileList(roots, 0);
115
230
  }
116
231
  /**
117
232
  * Creates a SvelteKit `load` function that fetches a resource list
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/lite",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "SSR-first lightweight admin UI for @svadmin with optional progressive enhancement",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -49,7 +49,7 @@
49
49
  },
50
50
  "peerDependencies": {
51
51
  "svelte": "^5.56.10",
52
- "@svadmin/core": ">=0.34.2 <0.54.0",
52
+ "@svadmin/core": ">=0.34.2 <0.56.0",
53
53
  "@sveltejs/kit": "^2.70.3"
54
54
  },
55
55
  "dependencies": {