Browse Source

fix: validate admin structured fields

彭峰 1 tháng trước cách đây
mục cha
commit
03269e675a

+ 1 - 0
docs/migration/admin-migration-status.md

@@ -13,6 +13,7 @@
 - Angular 18 独立管理端路由 `/admin`,包含独立登录、后台布局、概览、动态 Schema 表格、动态编辑表单、分页、搜索、软删除和迁移状态页。
 - 后台迁移状态页直接读取线上 `xiaoshu.app.gateway/migration_status`,动态显示已实现 action 数量及完整阻塞原因清单,不再使用前端硬编码阻塞计数。
 - 后台“全部数据类”目录由管理员云函数实时返回白名单,所有已开放规范化类均可从界面进入动态列表/编辑页;`_Session`、`Function` 等敏感系统类不进入目录。
+- 动态编辑器对 Object/Array 执行 JSON 类型校验,错误格式会阻止保存;Relation、File、GeoPoint 等无法安全通用写入的 Parse 类型由前后端共同设为只读,避免结构化字段被字符串覆盖。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

+ 6 - 2
scripts/deploy-admin-functions.mjs

@@ -20,6 +20,7 @@ const ALLOWED_CLASSES = new Set([
 const READ_ONLY_CLASSES = new Set(['_Role','Permission','PayPlat','ThirdPlatInfo']);
 const SYSTEM_FIELDS = new Set(['objectId','createdAt','updatedAt','ACL','company','password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','adminRoleKey','isAdmin','roles','role']);
 const HIDDEN_FIELDS = new Set(['password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','apiKey','appSecret','secret','token']);
+const GENERIC_WRITE_TYPES = new Set(['String','Number','Boolean','Date','Pointer','Object','Array']);
 
 function inputOf(request) {
   const body = request.body || {};
@@ -92,6 +93,9 @@ function toParseValue(field, value) {
     return number;
   }
   if (field.type === 'Boolean') return value === true || value === 'true';
+  if (field.type === 'Array') { if (!Array.isArray(value)) fail(400, '数组字段格式无效'); return value; }
+  if (field.type === 'Object') { if (!value || typeof value !== 'object' || Array.isArray(value)) fail(400, '对象字段格式无效'); return value; }
+  if (!GENERIC_WRITE_TYPES.has(field.type)) fail(400, '该字段类型不允许通用编辑: ' + field.type);
   return value;
 }
 async function countClass(className, context) {
@@ -148,7 +152,7 @@ async function handler(request, response) {
     const fields = await schemaFor(className);
     const classWritable = !READ_ONLY_CLASSES.has(className);
     if (operation === 'schema') {
-      const fieldList = Object.entries(fields).filter(([name]) => !HIDDEN_FIELDS.has(name) && !/(?:password|secret|sessiontoken|masterkey|privatekey)/i.test(name)).map(([name, field]) => ({ name, type: field.type, targetClass: field.targetClass, required: field.required === true, writable: classWritable && !SYSTEM_FIELDS.has(name) }));
+      const fieldList = Object.entries(fields).filter(([name]) => !HIDDEN_FIELDS.has(name) && !/(?:password|secret|sessiontoken|masterkey|privatekey)/i.test(name)).map(([name, field]) => ({ name, type: field.type, targetClass: field.targetClass, required: field.required === true, writable: classWritable && !SYSTEM_FIELDS.has(name) && GENERIC_WRITE_TYPES.has(field.type) }));
       return response.json({ success: true, data: { className, label: CLASS_LABELS[className] || className, fields: fieldList, writable: classWritable, supportsSoftDelete: Boolean(fields.isDeleted) } });
     }
     if (operation === 'list') {
@@ -172,7 +176,7 @@ async function handler(request, response) {
       let object;
       if (objectId) { const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); object = await query.get(objectId, { useMasterKey: true }); } else object = new Parse.Object(className);
       const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
-      for (const [name, value] of Object.entries(payload)) { if (!fields[name] || SYSTEM_FIELDS.has(name)) continue; if (value === null) object.unset(name); else object.set(name, toParseValue(fields[name], value)); }
+      for (const [name, value] of Object.entries(payload)) { if (!fields[name] || SYSTEM_FIELDS.has(name)) continue; if (!GENERIC_WRITE_TYPES.has(fields[name].type)) fail(400, '字段不允许通用编辑: ' + name); if (value === null) object.unset(name); else object.set(name, toParseValue(fields[name], value)); }
       if (fields.company && context.company) object.set('company', context.company); if (fields.isDeleted && !objectId) object.set('isDeleted', false);
       await object.save(null, { useMasterKey: true }); await audit(context, objectId ? 'update' : 'create', className, object.id);
       return response.json({ success: true, data: serializeObject(object) });

+ 1 - 1
src/app/admin/pages/admin-resource.component.html

@@ -26,7 +26,7 @@
       @for (field of schema()?.fields || []; track field.name) { @if (field.writable) {
         <label><span>{{ field.name }} <small>{{ field.type }}{{ field.required ? ' · 必填' : '' }}</small></span>
           @if (field.type === 'Boolean') { <input type="checkbox" [ngModel]="draft()[field.name] === true" (ngModelChange)="setField(field, $event)" /> }
-          @else if (field.type === 'Object' || field.type === 'Array') { <textarea rows="5" [ngModel]="inputValue(field)" (ngModelChange)="setField(field, $event)"></textarea> }
+          @else if (field.type === 'Object' || field.type === 'Array') { <textarea rows="5" [class.invalid]="fieldErrors()[field.name]" [ngModel]="inputValue(field)" (ngModelChange)="setField(field, $event)"></textarea>@if (fieldErrors()[field.name]; as fieldError) { <small class="field-error">{{ fieldError }}</small> } }
           @else { <input [type]="field.type === 'Number' ? 'number' : field.type === 'Date' ? 'datetime-local' : 'text'" [ngModel]="inputValue(field)" (ngModelChange)="setField(field, $event)" [placeholder]="field.type === 'Pointer' ? (field.targetClass + ' objectId') : field.name" /> }
         </label>
       } }

+ 1 - 1
src/app/admin/pages/admin-resource.component.scss

@@ -4,5 +4,5 @@
 .table-scroll { overflow: auto; }table { width: 100%; border-collapse: collapse; table-layout: fixed; }th { padding: 12px 15px; color: #6c7889; background: #f8fafc; font-size: 11px; font-weight: 700; text-align: left; text-transform: uppercase; }td { max-width: 220px; padding: 13px 15px; overflow: hidden; border-top: 1px solid #edf0f4; color: #3a4658; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }tbody tr:hover { background: #fbfdfc; }.actions { width: 92px; text-align: right; }.actions button { display: inline-grid; place-items: center; width: 31px; height: 31px; margin-left: 5px; border: 1px solid #dfe5ed; border-radius: 8px; color: #557085; background: white; cursor: pointer; }.actions button.danger { color: #b44951; }.empty-cell { height: 180px; color: #8a95a5; text-align: center; }
 .pagination { display: flex; align-items: center; justify-content: space-between; padding: 14px 17px; border-top: 1px solid #edf0f4; color: #7a8798; font-size: 12px; }.pagination div { display: flex; gap: 8px; }.pagination button { min-height: 34px; }.pagination button:disabled { opacity: .45; cursor: default; }
 .empty { display: grid; place-items: center; gap: 10px; min-height: 300px; color: #7f8b9c; }.inline-error { padding: 10px 17px; color: #b33f48; background: #fff2f3; font-size: 12px; }.spin { animation: spin .8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } }
-.drawer-mask { position: fixed; inset: 0; z-index: 50; border: 0; background: rgba(11,24,42,.42); }.editor-drawer { position: fixed; top: 0; right: 0; z-index: 60; display: grid; grid-template-rows: auto 1fr auto; width: min(560px, 94vw); height: 100vh; background: white; box-shadow: -18px 0 50px rgba(17,36,62,.18); }.editor-drawer header, .editor-drawer footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px 22px; border-bottom: 1px solid #e8ecf2; }.editor-drawer header div { display: grid; gap: 3px; }.editor-drawer header span { color: #8290a3; font-size: 11px; }.editor-drawer header strong { font-size: 18px; }.editor-drawer header button { display: grid; place-items: center; width: 34px; height: 34px; border: 0; border-radius: 8px; background: #f2f5f8; cursor: pointer; }.editor-fields { display: grid; align-content: start; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 17px; overflow: auto; padding: 22px; }.editor-fields label { display: grid; align-content: start; gap: 7px; color: #445165; font-size: 12px; }.editor-fields label:has(textarea) { grid-column: 1 / -1; }.editor-fields small { color: #95a0af; font-weight: 400; }.editor-fields input:not([type=checkbox]), .editor-fields textarea { width: 100%; box-sizing: border-box; padding: 10px 11px; border: 1px solid #dce3eb; border-radius: 8px; outline: 0; background: #fbfcfd; font: inherit; }.editor-fields input:focus, .editor-fields textarea:focus { border-color: #44a681; box-shadow: 0 0 0 3px rgba(68,166,129,.1); }.editor-fields textarea { resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }.editor-drawer footer { justify-content: flex-end; border-top: 1px solid #e8ecf2; border-bottom: 0; }
+.drawer-mask { position: fixed; inset: 0; z-index: 50; border: 0; background: rgba(11,24,42,.42); }.editor-drawer { position: fixed; top: 0; right: 0; z-index: 60; display: grid; grid-template-rows: auto 1fr auto; width: min(560px, 94vw); height: 100vh; background: white; box-shadow: -18px 0 50px rgba(17,36,62,.18); }.editor-drawer header, .editor-drawer footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px 22px; border-bottom: 1px solid #e8ecf2; }.editor-drawer header div { display: grid; gap: 3px; }.editor-drawer header span { color: #8290a3; font-size: 11px; }.editor-drawer header strong { font-size: 18px; }.editor-drawer header button { display: grid; place-items: center; width: 34px; height: 34px; border: 0; border-radius: 8px; background: #f2f5f8; cursor: pointer; }.editor-fields { display: grid; align-content: start; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 17px; overflow: auto; padding: 22px; }.editor-fields label { display: grid; align-content: start; gap: 7px; color: #445165; font-size: 12px; }.editor-fields label:has(textarea) { grid-column: 1 / -1; }.editor-fields small { color: #95a0af; font-weight: 400; }.editor-fields input:not([type=checkbox]), .editor-fields textarea { width: 100%; box-sizing: border-box; padding: 10px 11px; border: 1px solid #dce3eb; border-radius: 8px; outline: 0; background: #fbfcfd; font: inherit; }.editor-fields input:focus, .editor-fields textarea:focus { border-color: #44a681; box-shadow: 0 0 0 3px rgba(68,166,129,.1); }.editor-fields textarea { resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }.editor-fields textarea.invalid { border-color: #cf5961; box-shadow: 0 0 0 3px rgba(207,89,97,.1); }.editor-fields .field-error { color: #b33f48; }.editor-drawer footer { justify-content: flex-end; border-top: 1px solid #e8ecf2; border-bottom: 0; }
 @media (max-width: 700px) { .resource-header { align-items: start; }.resource-header span { display: none; }.header-actions button:first-child { display: none; }.editor-fields { grid-template-columns: 1fr; }.editor-fields label:has(textarea) { grid-column: auto; }.pagination > span { display: none; }.pagination { justify-content: flex-end; } }

+ 42 - 0
src/app/admin/pages/admin-resource.component.spec.ts

@@ -0,0 +1,42 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute } from '@angular/router';
+import { of } from 'rxjs';
+import { AdminFieldSchema } from '../admin.models';
+import { CloudFunctionsService } from '../cloud-functions.service';
+import { AdminResourceComponent } from './admin-resource.component';
+
+describe('AdminResourceComponent', () => {
+  let fixture: ComponentFixture<AdminResourceComponent>;
+  let component: AdminResourceComponent;
+  const functions = { admin: jasmine.createSpy('admin') };
+  const objectField: AdminFieldSchema = { name: 'payload', type: 'Object', writable: true };
+
+  beforeEach(async () => {
+    functions.admin.calls.reset();
+    await TestBed.configureTestingModule({
+      imports: [AdminResourceComponent],
+      providers: [
+        { provide: ActivatedRoute, useValue: { paramMap: of({ get: () => 'CommonModel' }) } },
+        { provide: CloudFunctionsService, useValue: functions },
+      ],
+    }).compileComponents();
+    fixture = TestBed.createComponent(AdminResourceComponent);
+    component = fixture.componentInstance;
+  });
+
+  it('keeps invalid JSON text visible and blocks saving it as a string', async () => {
+    component.setField(objectField, '{invalid');
+    expect(component.inputValue(objectField)).toBe('{invalid');
+    expect(component.fieldErrors()['payload']).toContain('JSON 对象');
+
+    await component.save();
+    expect(functions.admin).not.toHaveBeenCalled();
+    expect(component.error()).toContain('payload');
+  });
+
+  it('accepts a valid JSON object as structured data', () => {
+    component.setField(objectField, '{"enabled":true}');
+    expect(component.fieldErrors()['payload']).toBeUndefined();
+    expect(component.draft()['payload']).toEqual({ enabled: true });
+  });
+});

+ 18 - 5
src/app/admin/pages/admin-resource.component.ts

@@ -26,6 +26,8 @@ export class AdminResourceComponent implements OnInit {
   readonly editorOpen = signal(false);
   readonly draftId = signal('');
   readonly draft = signal<Record<string, unknown>>({});
+  readonly fieldErrors = signal<Record<string, string>>({});
+  readonly fieldInputs = signal<Record<string, string>>({});
   readonly icons = { ChevronLeft, ChevronRight, LoaderCircle, Pencil, Plus, RefreshCw, Search, Trash2, X };
   readonly columns = computed(() => {
     const fields = this.schema()?.fields ?? [];
@@ -53,22 +55,31 @@ export class AdminResourceComponent implements OnInit {
     finally { this.loading.set(false); }
   }
 
-  openCreate(): void { this.draftId.set(''); this.draft.set({}); this.editorOpen.set(true); }
+  openCreate(): void { this.draftId.set(''); this.draft.set({}); this.fieldErrors.set({}); this.fieldInputs.set({}); this.editorOpen.set(true); }
   async openEdit(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     this.error.set('');
     try {
       const detail = await this.functions.admin<Record<string, unknown>>('get', { className: this.className(), objectId });
-      this.draftId.set(objectId); this.draft.set(detail); this.editorOpen.set(true);
+      this.draftId.set(objectId); this.draft.set(detail); this.fieldErrors.set({}); this.fieldInputs.set({}); this.editorOpen.set(true);
     } catch (error) { this.error.set(error instanceof Error ? error.message : '详情加载失败'); }
   }
-  closeEditor(): void { this.editorOpen.set(false); this.draft.set({}); this.draftId.set(''); }
+  closeEditor(): void { this.editorOpen.set(false); this.draft.set({}); this.draftId.set(''); this.fieldErrors.set({}); this.fieldInputs.set({}); }
   setField(field: AdminFieldSchema, raw: unknown): void {
     let value = raw;
     if (field.type === 'Number') value = raw === '' ? null : Number(raw);
     if (field.type === 'Pointer') value = raw ? { __type: 'Pointer', className: field.targetClass, objectId: String(raw) } : null;
     if ((field.type === 'Object' || field.type === 'Array') && typeof raw === 'string') {
-      try { value = raw.trim() ? JSON.parse(raw) : field.type === 'Array' ? [] : {}; } catch { value = raw; }
+      this.fieldInputs.update((current) => ({ ...current, [field.name]: raw }));
+      try {
+        value = raw.trim() ? JSON.parse(raw) : field.type === 'Array' ? [] : {};
+        const valid = field.type === 'Array' ? Array.isArray(value) : Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+        if (!valid) throw new Error();
+        this.fieldErrors.update((current) => { const next = { ...current }; delete next[field.name]; return next; });
+      } catch {
+        this.fieldErrors.update((current) => ({ ...current, [field.name]: field.type === 'Array' ? '请输入有效的 JSON 数组' : '请输入有效的 JSON 对象' }));
+        return;
+      }
     }
     this.draft.update((current) => ({ ...current, [field.name]: value }));
   }
@@ -76,10 +87,12 @@ export class AdminResourceComponent implements OnInit {
     const value = this.draft()[field.name];
     if (field.type === 'Pointer') return String((value as { objectId?: string } | null)?.objectId ?? '');
     if (field.type === 'Date') return String((value as { iso?: string } | string | null)?.hasOwnProperty?.('iso') ? (value as { iso: string }).iso.slice(0, 16) : String(value ?? '').slice(0, 16));
-    if (field.type === 'Object' || field.type === 'Array') return value == null ? '' : JSON.stringify(value, null, 2);
+    if (field.type === 'Object' || field.type === 'Array') return this.fieldInputs()[field.name] ?? (value == null ? '' : JSON.stringify(value, null, 2));
     return typeof value === 'number' ? value : String(value ?? '');
   }
   async save(): Promise<void> {
+    const invalidFields = Object.keys(this.fieldErrors());
+    if (invalidFields.length) { this.error.set(`请先修正字段格式:${invalidFields.join('、')}`); return; }
     this.saving.set(true); this.error.set('');
     try {
       await this.functions.admin('save', { className: this.className(), objectId: this.draftId() || undefined, fields: this.draft() });