diff --git a/AGENTS.md b/AGENTS.md index 674e112..158e52c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,5 +72,6 @@ Cursor 额外加载:`.cursor/rules/mindspace-publish-chat-finish-guards.mdc` ## 仓库惯例 - 本地开发:`pnpm dev`(见 [README.md](README.md)) +- Page Data API:见 [docs/page-data-api-usage.md](docs/page-data-api-usage.md);改动相关路径后执行 `npm run verify:page-data` - 生产隔离:[docs/service-isolation-runbook.md](docs/service-isolation-runbook.md) - 发版须 Git commit,禁止本机直 `rsync` 到 `103/105` diff --git a/db.mjs b/db.mjs index d257cb2..9ff9aef 100644 --- a/db.mjs +++ b/db.mjs @@ -834,6 +834,19 @@ export async function migrateSchema(pool) { AND c.category_code IN ('public', 'oa')`, ); + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_page_data_policy_index ( + page_id CHAR(36) PRIMARY KEY, + owner_user_id CHAR(36) NOT NULL, + access_mode VARCHAR(32) NOT NULL, + dataset_count INT UNSIGNED NOT NULL DEFAULT 0, + scope_hash CHAR(16) NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_h5_page_data_policy_owner (owner_user_id, updated_at), + CONSTRAINT fk_h5_page_data_policy_owner FOREIGN KEY (owner_user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query(` CREATE TABLE IF NOT EXISTS h5_user_feedback ( id CHAR(36) PRIMARY KEY, diff --git a/docs/architecture/page-data-api-public-access-plan-20260708.md b/docs/architecture/page-data-api-public-access-plan-20260708.md index 99b172e..0398d0c 100644 --- a/docs/architecture/page-data-api-public-access-plan-20260708.md +++ b/docs/architecture/page-data-api-public-access-plan-20260708.md @@ -2,7 +2,7 @@ 日期: 2026-07-08 -状态: Draft,仅开发设计;本文不代表已实现。 +状态: 已实现(Phase 1–5 + 产品层);以代码与 `docs/page-data-api-usage.md` 为准。 ## 1. 背景 diff --git a/docs/page-data-api-usage.md b/docs/page-data-api-usage.md new file mode 100644 index 0000000..e52242b --- /dev/null +++ b/docs/page-data-api-usage.md @@ -0,0 +1,122 @@ +# Page Data API 使用说明 + +面向 MindSpace 页面 owner、Agent 与 HTML 页面开发者。实现细节见 [page-data-api-public-access-plan-20260708.md](architecture/page-data-api-public-access-plan-20260708.md)。 + +## 快速路径 + +1. **Agent** 用 `private_data_execute` 建表,用 `private_data_register_dataset` 注册 dataset。 +2. **发布页面** 时在发布面板勾选「启用页面数据」,选择 dataset 与公开能力;或发布后让 Agent 调用 `private_data_set_page_policy`。 +3. **HTML 页面** 引入 `/assets/page-data-client.js`,用 `MindSpacePageData.createClient({ pageId })` 读写数据。 +4. **运维** 在 MindSpace 页面详情「页面数据」面板查看日志、导出、撤销令牌、恢复软删除、关闭 dataset。 + +## 访问模式与默认能力 + +| 发布访问模式 | Page Data 模式 | 默认公开能力 | +|-------------|----------------|-------------| +| 完全公开 | `public` | 仅 insert | +| 口令访问 | `password` | read + insert | +| 登录访问 | `login_required` | read + insert + update | + +可在发布面板或策略中逐项覆盖。 + +## Owner API(需登录) + +```text +GET /api/page-data # 列出已注册 dataset +GET /api/page-data/:dataset # 读行 +POST /api/page-data/:dataset/rows # 插入 +GET /api/page-data/:dataset/schema +GET /api/page-data/:dataset/stats +GET /api/page-data/:dataset/export?format=json|csv +POST /api/page-data/:dataset/rows/:id/restore + +GET /api/page-data/policies +GET /api/page-data/policies/:pageId +PUT /api/page-data/policies/:pageId +POST /api/page-data/policies/:pageId/apply-publish +GET /api/page-data/policies/:pageId/ops +GET /api/page-data/policies/:pageId/logs +POST /api/page-data/policies/:pageId/tokens/revoke +POST /api/page-data/policies/:pageId/password/reset +POST /api/page-data/policies/:pageId/datasets/:dataset/close +``` + +## 公开 API(无需平台登录) + +```text +POST /api/public/pages/:pageId/data-auth # 口令页换 token +GET /api/public/pages/:pageId/data/:dataset +POST /api/public/pages/:pageId/data/:dataset/rows +PATCH /api/public/pages/:pageId/data/:dataset/rows/:id +DELETE /api/public/pages/:pageId/data/:dataset/rows/:id +GET /api/public/pages/:pageId/data/:dataset/schema +GET /api/public/pages/:pageId/data/:dataset/stats +``` + +公开请求可带 `x-page-data-token`(口令/登录会话)。**公开页不能直接传 SQL**,只能提交策略白名单字段。 + +## HTML 页面嵌入 + +```html + + +``` + +### 公开表单 + Cloudflare Turnstile + +服务端配置环境变量 `PAGE_DATA_TURNSTILE_SECRET` 后,**完全公开**模式的 insert 必须带验证码。 + +```html + + +
+
+ + +
+ +``` + +验证码 token 也可放在请求体 `turnstile_token` / `captcha_token`,或通过头 `x-page-data-captcha` 传递。 + +## Agent MCP 工具 + +- `private_data_register_dataset` — 注册 dataset +- `private_data_set_page_policy` — 为已发布页写 Page Access Policy +- `private_data_close_page_dataset` — 关闭某 dataset 的公开访问 + +## 本地验证 + +```bash +node --test page-data-acceptance.test.mjs page-data-integration.test.mjs page-data-public-service.test.mjs +node scripts/run-memind-tests.mjs --mode changed +``` + +## 安全提醒 + +- 公开读默认关闭;开启前确认字段不含敏感信息。 +- 密码访问无法强身份绑定,仅适合轻量协作。 +- 生产公开写入建议开启 Turnstile 并监控「页面数据」操作日志。 diff --git a/mindspace-sandbox-mcp.mjs b/mindspace-sandbox-mcp.mjs index 8ee3dd4..5f9dc94 100644 --- a/mindspace-sandbox-mcp.mjs +++ b/mindspace-sandbox-mcp.mjs @@ -18,8 +18,9 @@ import { createScheduleService } from './schedule-service.mjs'; import { resolveScheduleTimestamp } from './schedule-time.mjs'; import { renderLongImage } from './mindspace-long-image.mjs'; import { createUserDataSpaceService } from './user-data-space-service.mjs'; -import { writePageAccessPolicy } from './page-data-policy-store.mjs'; -import { normalizePageAccessPolicy } from './page-access-policy.mjs'; +import { writePageAccessPolicy, readPageAccessPolicy } from './page-data-policy-store.mjs'; +import { closePolicyDataset, normalizePageAccessPolicy } from './page-access-policy.mjs'; +import { upsertPageDataPolicyIndex } from './page-data-policy-index.mjs'; const SANDBOX_ROOT = process.argv[2]?.trim() || process.env.SANDBOX_ROOT?.trim(); if (!SANDBOX_ROOT) { @@ -287,12 +288,38 @@ const ALL_TOOLS = [ datasets: { type: 'object', description: - 'dataset 授权,例如 { registrations: { insert: true, columns: { insert: ["name","phone"] } } }', + 'dataset 授权,例如 { registrations: { insert: true, columns: { insert: ["name","phone"] }, rowPolicy: { scope: "own_rows" } } }', + }, + defaultVisitorRole: { + type: 'string', + description: 'login_required 模式下未列名访问者的默认角色:deny、viewer、editor', + }, + visitors: { + type: 'object', + description: '访问者角色映射,例如 { "user-2": "editor", "user-3": "viewer" }', + }, + roles: { + type: 'object', + description: '可选角色权限覆盖,例如 { viewer: { read: true, insert: false } }', }, }, required: ['pageId', 'accessMode', 'datasets'], }, }, + { + name: 'private_data_close_page_dataset', + description: + '关闭已发布页面的某个 dataset 公开访问能力。写入策略文件并将 read/insert/update/delete 全部关闭。', + inputSchema: { + type: 'object', + properties: { + pageId: { type: 'string', description: '页面 ID' }, + dataset: { type: 'string', description: '要关闭的 dataset 名称' }, + ownerUserId: { type: 'string', description: '页面 owner 用户 ID,可选,默认当前会话用户' }, + }, + required: ['pageId', 'dataset'], + }, + }, ]; let quotaPool = null; @@ -554,12 +581,31 @@ async function callTool(name, args) { ownerUserId, accessMode: args.accessMode, datasets: args.datasets, + defaultVisitorRole: args.defaultVisitorRole, + visitors: args.visitors, + roles: args.roles, }, { fallbackPageId: args.pageId, fallbackOwnerUserId: ownerUserId }, ); const saved = writePageAccessPolicy(SANDBOX, policy); + if (isQuotaSyncConfigured()) { + await upsertPageDataPolicyIndex(getQuotaPool(), saved).catch(() => null); + } return [{ type: 'text', text: JSON.stringify(saved, null, 2) }]; } + case 'private_data_close_page_dataset': { + const ownerUserId = String(args.ownerUserId ?? PRIVATE_DATA_USER_ID ?? '').trim(); + if (!ownerUserId) throw new Error('缺少 ownerUserId,无法关闭 dataset'); + const policy = readPageAccessPolicy(SANDBOX, args.pageId); + if (!policy) throw new Error('页面数据策略不存在'); + if (policy.ownerUserId !== ownerUserId) throw new Error('无权关闭该页面 dataset'); + const closed = closePolicyDataset(policy, args.dataset); + const saved = writePageAccessPolicy(SANDBOX, closed); + if (isQuotaSyncConfigured()) { + await upsertPageDataPolicyIndex(getQuotaPool(), saved).catch(() => null); + } + return [{ type: 'text', text: JSON.stringify(saved.datasets[args.dataset], null, 2) }]; + } case 'schedule_create_item': { const timezone = args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? 'Asia/Shanghai'; const startAt = resolveScheduleTimestamp({ diff --git a/mindspace-sandbox-mcp.test.mjs b/mindspace-sandbox-mcp.test.mjs index 4da241b..9833e7a 100644 --- a/mindspace-sandbox-mcp.test.mjs +++ b/mindspace-sandbox-mcp.test.mjs @@ -132,7 +132,7 @@ test('sandbox MCP exposes and protects the user private data space', async (t) = test('sandbox MCP registers datasets and page policies for Page Data API', async (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mindspace-sandbox-page-data-')); const server = startSandbox(root, { - ALLOWED_TOOLS: 'private_data_execute,private_data_register_dataset,private_data_set_page_policy', + ALLOWED_TOOLS: 'private_data_execute,private_data_register_dataset,private_data_set_page_policy,private_data_close_page_dataset', PRIVATE_DATA_USER_ID: 'user-1', }); t.after(() => server.child.kill()); @@ -187,6 +187,15 @@ test('sandbox MCP registers datasets and page policies for Page Data API', async const saved = JSON.parse(fs.readFileSync(path.join(root, '.mindspace', 'page-data-policies', 'page-1.json'), 'utf8')); assert.equal(saved.pageId, 'page-1'); assert.equal(saved.datasets.signups.insert, true); + + const close = await server.request('tools/call', { + name: 'private_data_close_page_dataset', + arguments: { pageId: 'page-1', dataset: 'signups' }, + }); + assert.equal(close.result.isError, false); + const closed = JSON.parse(close.result.content[0].text); + assert.equal(closed.closed, true); + assert.equal(closed.insert, false); }); test('sandbox MCP exposes schedule tools only when schedule env is configured', async (t) => { diff --git a/package.json b/package.json index b85ce91..c573ab8 100644 --- a/package.json +++ b/package.json @@ -55,13 +55,14 @@ "mock:memory-v2-services": "node scripts/mock-memory-v2-services.mjs", "test:memind": "node scripts/run-memind-tests.mjs", "test:scenario": "node scripts/run-scenario-test.mjs", - "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", + "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", "test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs", "verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs", "verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs", "verify:mindspace-publish-guards": "node scripts/verify-mindspace-publish-guards.mjs", "verify:mindspace-publish-guards:full": "node scripts/verify-mindspace-publish-guards.mjs --with-runtime", "verify:mindspace-page-sync-guards": "node scripts/verify-mindspace-page-sync-guards.mjs", + "verify:page-data": "node --test page-data-acceptance.test.mjs page-data-integration.test.mjs page-data-public-service.test.mjs page-data-ops.test.mjs", "verify:wechat-channel-isolation": "node scripts/check-wechat-channel-isolation.mjs", "verify:goosed-proxy-boundary": "node scripts/check-goosed-proxy-boundary.mjs", "verify:h5-session-patches": "node scripts/verify-h5-session-patches.mjs", diff --git a/page-access-policy.mjs b/page-access-policy.mjs index 2896ea3..ffd5afc 100644 --- a/page-access-policy.mjs +++ b/page-access-policy.mjs @@ -1,4 +1,9 @@ import { assertSafeSqlIdentifier, normalizeDatasetConfig } from './user-data-space-service.mjs'; +import { + normalizeRowPolicy, + normalizeRoleOverrides, + normalizeVisitorAssignments, +} from './page-access-visitor.mjs'; export const PAGE_DATA_ACCESS_MODES = new Set(['public', 'password', 'login_required']); @@ -28,6 +33,9 @@ export function normalizePolicyDataset(name, raw) { maxInsertBytes: Number(raw.limits.maxInsertBytes ?? 8192), } : undefined, + rowPolicy: normalizeRowPolicy(raw.rowPolicy ?? raw.row_policy), + closed: Boolean(raw.closed), + closedAt: raw.closedAt ?? raw.closed_at ?? null, }; } @@ -48,11 +56,24 @@ export function normalizePageAccessPolicy(raw, { fallbackPageId = null, fallback for (const [name, config] of Object.entries(datasetsInput)) { datasets[assertSafeSqlIdentifier(name, 'dataset 名称')] = normalizePolicyDataset(name, config); } + const defaultVisitorRole = String( + raw.defaultVisitorRole ?? + raw.default_visitor_role ?? + (accessMode === 'login_required' ? 'viewer' : 'deny'), + ).trim(); + if (!['deny', 'viewer', 'editor'].includes(defaultVisitorRole)) { + throw Object.assign(new Error(`不支持的 defaultVisitorRole:${defaultVisitorRole}`), { + code: 'invalid_policy', + }); + } return { pageId, ownerUserId, workspaceRef: String(raw.workspaceRef ?? raw.workspace_ref ?? '').trim() || null, accessMode, + defaultVisitorRole, + visitors: normalizeVisitorAssignments(raw.visitors), + roles: normalizeRoleOverrides(raw.roles), datasets, updatedAt: raw.updatedAt ?? new Date().toISOString(), }; @@ -112,6 +133,7 @@ export function defaultDatasetsForAccessMode(accessMode, datasets = {}) { export function policyAllowsAction(policy, datasetName, action) { const dataset = policy?.datasets?.[datasetName]; if (!dataset) return false; + if (dataset.closed) return false; if (action === 'read') return dataset.read; if (action === 'insert') return dataset.insert; if (action === 'update') return dataset.update; @@ -119,3 +141,94 @@ export function policyAllowsAction(policy, datasetName, action) { if (action === 'hard_delete') return dataset.hardDelete; return false; } + +export function closePolicyDataset(policy, datasetName) { + const dataset = policy?.datasets?.[datasetName]; + if (!dataset) { + throw Object.assign(new Error('dataset 未授权'), { code: 'dataset_not_found' }); + } + return { + ...policy, + datasets: { + ...policy.datasets, + [datasetName]: { + ...dataset, + read: false, + insert: false, + update: false, + softDelete: false, + hardDelete: false, + closed: true, + closedAt: new Date().toISOString(), + }, + }, + updatedAt: new Date().toISOString(), + }; +} + +export function mapPublicationAccessModeToPageData(accessMode) { + const mode = String(accessMode ?? '').trim(); + if (PAGE_DATA_ACCESS_MODES.has(mode)) return mode; + return null; +} + +export function buildDatasetPolicyFromRegistry( + registryDataset, + { read = false, insert = false, update = false, softDelete = false, accessMode = 'public' } = {}, +) { + const rowPolicy = + accessMode === 'login_required' && (update || softDelete || read) + ? { scope: 'own_rows', ownerColumn: 'created_by_user_id' } + : null; + return normalizePolicyDataset(registryDataset?.name ?? 'dataset', { + read: Boolean(read), + insert: Boolean(insert), + update: Boolean(update), + softDelete: Boolean(softDelete), + columns: { + read: registryDataset?.columns?.read ?? [], + insert: registryDataset?.columns?.insert ?? [], + update: registryDataset?.columns?.update ?? [], + soft_delete: registryDataset?.columns?.soft_delete ?? ['id'], + }, + rowPolicy, + }); +} + +export function buildPageDataPolicyFromPublishInput({ + pageId, + ownerUserId, + accessMode, + datasetName, + registryDataset, + capabilities = {}, +}) { + const mappedAccessMode = mapPublicationAccessModeToPageData(accessMode); + if (!mappedAccessMode) { + throw Object.assign(new Error('当前发布访问模式不支持 Page Data'), { code: 'invalid_policy' }); + } + const defaults = + mappedAccessMode === 'public' + ? { read: false, insert: true, update: false, softDelete: false } + : mappedAccessMode === 'password' + ? { read: true, insert: true, update: false, softDelete: false } + : { read: true, insert: true, update: true, softDelete: false }; + const datasetPolicy = buildDatasetPolicyFromRegistry(registryDataset, { + read: capabilities.read ?? defaults.read, + insert: capabilities.insert ?? defaults.insert, + update: capabilities.update ?? defaults.update, + softDelete: capabilities.softDelete ?? defaults.softDelete, + accessMode: mappedAccessMode, + }); + return normalizePageAccessPolicy( + { + pageId, + ownerUserId, + accessMode: mappedAccessMode, + datasets: { + [datasetName]: datasetPolicy, + }, + }, + { fallbackPageId: pageId, fallbackOwnerUserId: ownerUserId }, + ); +} diff --git a/page-access-policy.test.mjs b/page-access-policy.test.mjs index fb31488..e565fbb 100644 --- a/page-access-policy.test.mjs +++ b/page-access-policy.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { buildEffectiveDataset, + closePolicyDataset, normalizePageAccessPolicy, policyAllowsAction, } from './page-access-policy.mjs'; @@ -65,3 +66,17 @@ test('policyAllowsAction respects per-dataset flags', () => { assert.equal(policyAllowsAction(policy, 'ledger', 'insert'), true); assert.equal(policyAllowsAction(policy, 'missing', 'read'), false); }); + +test('closePolicyDataset disables all dataset actions', () => { + const policy = normalizePageAccessPolicy({ + pageId: 'page-1', + ownerUserId: 'user-1', + accessMode: 'password', + datasets: { + ledger: { read: true, insert: true, update: true, softDelete: true, columns: {} }, + }, + }); + const closed = closePolicyDataset(policy, 'ledger'); + assert.equal(closed.datasets.ledger.closed, true); + assert.equal(policyAllowsAction(closed, 'ledger', 'insert'), false); +}); diff --git a/page-access-visitor.mjs b/page-access-visitor.mjs new file mode 100644 index 0000000..3858ef8 --- /dev/null +++ b/page-access-visitor.mjs @@ -0,0 +1,149 @@ +import { assertSafeSqlIdentifier } from './user-data-space-service.mjs'; + +export const PAGE_DATA_VISITOR_ROLES = new Set(['viewer', 'editor', 'owner']); + +export const DEFAULT_ROLE_PERMISSIONS = { + viewer: { read: true, insert: false, update: false, softDelete: false }, + editor: { read: true, insert: true, update: true, softDelete: true }, + owner: { read: true, insert: true, update: true, softDelete: true, hardDelete: false }, +}; + +export function normalizeRowPolicy(raw) { + if (!raw || typeof raw !== 'object') return null; + const scope = String(raw.scope ?? 'all_rows').trim(); + if (!['all_rows', 'own_rows', 'owner_only'].includes(scope)) { + throw Object.assign(new Error(`不支持的 rowPolicy.scope:${scope}`), { code: 'invalid_policy' }); + } + const ownerColumn = assertSafeSqlIdentifier( + raw.ownerColumn ?? raw.owner_column ?? 'created_by_user_id', + 'rowPolicy.ownerColumn', + ); + return { scope, ownerColumn }; +} + +export function normalizeVisitorAssignments(raw) { + if (!raw || typeof raw !== 'object') return {}; + const visitors = {}; + for (const [userId, value] of Object.entries(raw)) { + const visitorId = String(userId ?? '').trim(); + if (!visitorId) continue; + const role = typeof value === 'string' ? value.trim() : String(value?.role ?? '').trim(); + if (!PAGE_DATA_VISITOR_ROLES.has(role) || role === 'owner') { + throw Object.assign(new Error(`访问者角色无效:${role}`), { code: 'invalid_policy' }); + } + visitors[visitorId] = role; + } + return visitors; +} + +export function normalizeRoleOverrides(raw) { + if (!raw || typeof raw !== 'object') return {}; + const roles = {}; + for (const [roleName, permissions] of Object.entries(raw)) { + const role = String(roleName ?? '').trim(); + if (!PAGE_DATA_VISITOR_ROLES.has(role)) { + throw Object.assign(new Error(`角色定义无效:${role}`), { code: 'invalid_policy' }); + } + roles[role] = { + read: Boolean(permissions?.read), + insert: Boolean(permissions?.insert), + update: Boolean(permissions?.update), + softDelete: Boolean(permissions?.softDelete ?? permissions?.soft_delete), + hardDelete: Boolean(permissions?.hardDelete ?? permissions?.hard_delete), + }; + } + return roles; +} + +export function resolveVisitorRole(policy, visitorUserId) { + const visitorId = String(visitorUserId ?? '').trim(); + if (!visitorId) return null; + if (visitorId === policy.ownerUserId) return 'owner'; + const assigned = policy.visitors?.[visitorId]; + if (assigned) return assigned; + const defaultRole = String(policy.defaultVisitorRole ?? 'deny').trim(); + if (defaultRole === 'deny') return null; + if (!PAGE_DATA_VISITOR_ROLES.has(defaultRole)) return null; + return defaultRole; +} + +export function resolveRolePermissions(policy, role) { + const defaults = DEFAULT_ROLE_PERMISSIONS[role] ?? DEFAULT_ROLE_PERMISSIONS.viewer; + const overrides = policy.roles?.[role]; + if (!overrides) return { ...defaults }; + return { + read: overrides.read ?? defaults.read, + insert: overrides.insert ?? defaults.insert, + update: overrides.update ?? defaults.update, + softDelete: overrides.softDelete ?? defaults.softDelete, + hardDelete: overrides.hardDelete ?? defaults.hardDelete, + }; +} + +export function roleAllowsAction(rolePermissions, action) { + if (!rolePermissions) return false; + if (action === 'read') return Boolean(rolePermissions.read); + if (action === 'insert') return Boolean(rolePermissions.insert); + if (action === 'update') return Boolean(rolePermissions.update); + if (action === 'soft_delete') return Boolean(rolePermissions.softDelete); + if (action === 'hard_delete') return Boolean(rolePermissions.hardDelete); + return false; +} + +export function buildEffectiveDatasetForRole(registryDataset, policyDataset, rolePermissions) { + if (!registryDataset || !policyDataset || !rolePermissions) return null; + const actions = []; + if (policyDataset.read && rolePermissions.read) actions.push('read'); + if (policyDataset.insert && rolePermissions.insert) actions.push('insert'); + if (policyDataset.update && rolePermissions.update) actions.push('update'); + if (policyDataset.softDelete && rolePermissions.softDelete) actions.push('soft_delete'); + if (policyDataset.hardDelete && rolePermissions.hardDelete) actions.push('hard_delete'); + if (!actions.length) return null; + const columns = { + read: policyDataset.columns?.read ?? registryDataset.columns?.read ?? [], + insert: policyDataset.columns?.insert ?? registryDataset.columns?.insert ?? [], + update: policyDataset.columns?.update ?? registryDataset.columns?.update ?? [], + soft_delete: policyDataset.columns?.soft_delete ?? registryDataset.columns?.soft_delete ?? ['id'], + }; + return { + name: registryDataset.name, + table: registryDataset.table, + description: registryDataset.description, + actions, + columns, + limits: policyDataset.limits ?? registryDataset.limits, + rowPolicy: policyDataset.rowPolicy ?? null, + }; +} + +export function resolveRowAccessScope(rowPolicy, { + visitorRole, + visitorUserId, + ownerUserId, + tableColumns = [], +} = {}) { + if (!rowPolicy || rowPolicy.scope === 'all_rows') { + return { bypass: true, whereClause: null, ownerColumn: null, visitorUserId }; + } + const isOwner = visitorRole === 'owner' || visitorUserId === ownerUserId; + if (rowPolicy.scope === 'owner_only') { + if (isOwner) return { bypass: true, whereClause: null, ownerColumn: null, visitorUserId }; + throw Object.assign(new Error('当前数据集仅页面所有者可访问'), { code: 'row_not_allowed' }); + } + if (rowPolicy.scope === 'own_rows') { + if (isOwner) return { bypass: true, whereClause: null, ownerColumn: rowPolicy.ownerColumn, visitorUserId }; + const column = rowPolicy.ownerColumn; + const hasColumn = tableColumns.some((item) => item.name === column); + if (!hasColumn) { + throw Object.assign(new Error(`行级权限列不存在:${column}`), { code: 'row_policy_invalid' }); + } + const safeVisitorId = String(visitorUserId ?? '').replace(/'/g, "''"); + return { + bypass: false, + whereClause: `"${column}" = '${safeVisitorId}'`, + ownerColumn: column, + visitorUserId, + }; + } + return { bypass: true, whereClause: null, ownerColumn: null, visitorUserId }; +} diff --git a/page-access-visitor.test.mjs b/page-access-visitor.test.mjs new file mode 100644 index 0000000..377bdee --- /dev/null +++ b/page-access-visitor.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { normalizePageAccessPolicy } from './page-access-policy.mjs'; +import { + buildEffectiveDatasetForRole, + resolveRowAccessScope, + resolveVisitorRole, + roleAllowsAction, +} from './page-access-visitor.mjs'; + +test('resolveVisitorRole maps owner, assigned visitors and defaults', () => { + const policy = normalizePageAccessPolicy({ + pageId: 'page-1', + ownerUserId: 'owner-1', + accessMode: 'login_required', + defaultVisitorRole: 'deny', + visitors: { 'editor-1': 'editor', 'viewer-1': 'viewer' }, + datasets: {}, + }); + assert.equal(resolveVisitorRole(policy, 'owner-1'), 'owner'); + assert.equal(resolveVisitorRole(policy, 'editor-1'), 'editor'); + assert.equal(resolveVisitorRole(policy, 'viewer-1'), 'viewer'); + assert.equal(resolveVisitorRole(policy, 'stranger-1'), null); +}); + +test('buildEffectiveDatasetForRole intersects dataset and role permissions', () => { + const effective = buildEffectiveDatasetForRole( + { + name: 'tasks', + table: 'tasks', + actions: ['read', 'insert', 'update', 'soft_delete'], + columns: { + read: ['id', 'title'], + insert: ['title'], + update: ['title'], + soft_delete: ['id'], + }, + }, + { + read: true, + insert: true, + update: true, + softDelete: true, + columns: { + read: ['id', 'title'], + insert: ['title'], + update: ['title'], + }, + }, + { read: true, insert: false, update: false, softDelete: false }, + ); + assert.deepEqual(effective.actions, ['read']); + assert.equal(roleAllowsAction({ read: true, insert: false }, 'insert'), false); +}); + +test('resolveRowAccessScope enforces own_rows and owner_only', () => { + const ownRows = resolveRowAccessScope( + { scope: 'own_rows', ownerColumn: 'created_by_user_id' }, + { + visitorRole: 'editor', + visitorUserId: 'user-a', + ownerUserId: 'owner-1', + tableColumns: [{ name: 'created_by_user_id' }], + }, + ); + assert.match(ownRows.whereClause, /created_by_user_id/); + assert.match(ownRows.whereClause, /user-a/); + + const ownerBypass = resolveRowAccessScope( + { scope: 'own_rows', ownerColumn: 'created_by_user_id' }, + { + visitorRole: 'owner', + visitorUserId: 'owner-1', + ownerUserId: 'owner-1', + tableColumns: [{ name: 'created_by_user_id' }], + }, + ); + assert.equal(ownerBypass.whereClause, null); + + assert.throws( + () => + resolveRowAccessScope( + { scope: 'owner_only', ownerColumn: 'created_by_user_id' }, + { + visitorRole: 'editor', + visitorUserId: 'user-a', + ownerUserId: 'owner-1', + tableColumns: [], + }, + ), + (error) => error.code === 'row_not_allowed', + ); +}); diff --git a/page-data-acceptance.test.mjs b/page-data-acceptance.test.mjs new file mode 100644 index 0000000..31debac --- /dev/null +++ b/page-data-acceptance.test.mjs @@ -0,0 +1,272 @@ +/** + * Page Data API 第一版验收(对应 docs/architecture/page-data-api-public-access-plan-20260708.md §14) + */ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import express from 'express'; +import { attachPageDataRoutes } from './page-data-routes.mjs'; +import { createPageDataService } from './page-data-service.mjs'; +import { createPageDataPublicService } from './page-data-public-service.mjs'; +import { createUserDataSpaceService } from './user-data-space-service.mjs'; +import { writePageAccessPolicy } from './page-data-policy-store.mjs'; + +const PAGE_ID = 'page-acceptance-1'; +const OWNER_ID = 'user-acceptance-1'; + +function createPool(accessMode = 'public') { + return { + async query(sql, params) { + if (sql.includes('FROM h5_publish_records')) { + return [ + [ + { + id: 'pub-acc-1', + user_id: OWNER_ID, + page_id: PAGE_ID, + access_mode: accessMode, + password_hash: null, + status: 'online', + expires_at: null, + }, + ], + ]; + } + if (sql.includes('h5_page_data_policy_index')) { + return [[]]; + } + return [[]]; + }, + }; +} + +async function setupWorkspace(workspaceRoot) { + const service = createUserDataSpaceService({ workspaceRoot }); + await service.executeSql(`CREATE TABLE leads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + note TEXT, + secret_field TEXT, + created_at TEXT, + deleted_at TEXT, + deleted_by TEXT + );`); + await service.upsertDataset({ + name: 'leads', + table: 'leads', + description: '线索', + actions: ['read', 'insert', 'update', 'soft_delete'], + columns: { + read: ['id', 'name', 'note', 'created_at'], + insert: ['name', 'note'], + update: ['note'], + soft_delete: ['id'], + }, + }); + return service; +} + +function buildApp(workspaceRoot, pool = createPool('public')) { + const pageDataPublicService = createPageDataPublicService({ + getPool: () => pool, + resolveWorkspaceRootForOwner: () => workspaceRoot, + }); + const api = express.Router(); + api.use(express.json({ limit: '32kb' })); + api.use((req, _res, next) => { + if (req.headers['x-test-user']) { + req.currentUser = { id: OWNER_ID, workspaceRoot }; + } + next(); + }); + attachPageDataRoutes(api, { + sendData: (res, _req, data, status = 200) => res.status(status).json({ data }), + sendError: (res, _req, status, code, message) => + res.status(status).json({ error: { code, message } }), + getPageDataService: () => + createPageDataService({ + getPool: () => pool, + resolveWorkspaceRoot: async () => workspaceRoot, + }), + getPageDataPublicService: () => pageDataPublicService, + }); + const app = express(); + app.set('trust proxy', true); + app.use('/api', api); + return app; +} + +async function request(app, method, url, { body, headers } = {}) { + const server = app.listen(0); + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}${url}`, { + method, + headers: { + ...(body ? { 'content-type': 'application/json' } : {}), + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + }); + return { status: response.status, body: await response.json() }; + } finally { + await new Promise((resolve) => server.close(resolve)); + } +} + +test('acceptance: agent dataset registry + owner read/insert via Page Data API', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-owner-')); + await setupWorkspace(workspaceRoot); + const app = buildApp(workspaceRoot); + + const datasets = await request(app, 'GET', '/api/page-data', { + headers: { 'x-test-user': '1' }, + }); + assert.equal(datasets.status, 200); + assert.equal(datasets.body.data.datasets[0].name, 'leads'); + + const inserted = await request(app, 'POST', '/api/page-data/leads/rows', { + headers: { 'x-test-user': '1' }, + body: { name: 'Owner 线索', note: '内部' }, + }); + assert.equal(inserted.status, 201); + + const listed = await request(app, 'GET', '/api/page-data/leads?limit=10', { + headers: { 'x-test-user': '1' }, + }); + assert.equal(listed.status, 200); + assert.equal(listed.body.data.rows.length, 1); +}); + +test('acceptance: public insert only when authorized; read/update/delete denied by default', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-public-')); + await setupWorkspace(workspaceRoot); + writePageAccessPolicy(workspaceRoot, { + pageId: PAGE_ID, + ownerUserId: OWNER_ID, + accessMode: 'public', + datasets: { + leads: { + insert: true, + columns: { insert: ['name', 'note'] }, + }, + }, + }); + const app = buildApp(workspaceRoot); + + const ok = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/leads/rows`, { + body: { name: '公开线索' }, + }); + assert.equal(ok.status, 201); + + const readDenied = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/leads`); + assert.equal(readDenied.status, 403); + assert.equal(readDenied.body.error.code, 'action_not_allowed'); + + const updateDenied = await request(app, 'PATCH', `/api/public/pages/${PAGE_ID}/data/leads/rows/1`, { + body: { note: 'hack' }, + }); + assert.equal(updateDenied.status, 403); + + const deleteDenied = await request(app, 'DELETE', `/api/public/pages/${PAGE_ID}/data/leads/rows/1`); + assert.equal(deleteDenied.status, 403); +}); + +test('acceptance: unauthorized dataset/action/column and SQL-like keys are rejected', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-deny-')); + await setupWorkspace(workspaceRoot); + writePageAccessPolicy(workspaceRoot, { + pageId: PAGE_ID, + ownerUserId: OWNER_ID, + accessMode: 'public', + datasets: { + leads: { insert: true, columns: { insert: ['name'] } }, + }, + }); + const app = buildApp(workspaceRoot); + + const unknownDataset = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/unknown/rows`, { + body: { name: 'x' }, + }); + assert.equal(unknownDataset.status, 403); + + const badColumn = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/leads/rows`, { + body: { name: 'x', secret_field: 'leak' }, + }); + assert.equal(badColumn.status, 403); + assert.equal(badColumn.body.error.code, 'columns_not_allowed'); + + const sqlKey = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/leads/rows`, { + body: { name: 'x', sql: 'DROP TABLE leads' }, + }); + assert.equal(sqlKey.status, 403); +}); + +test('acceptance: apply-publish creates policy for published page', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-publish-')); + await setupWorkspace(workspaceRoot); + const app = buildApp(workspaceRoot); + + const applied = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/apply-publish`, { + headers: { 'x-test-user': '1' }, + body: { + datasetName: 'leads', + capabilities: { read: false, insert: true, update: false, softDelete: false }, + }, + }); + assert.equal(applied.status, 200); + assert.equal(applied.body.data.policy.datasets.leads.insert, true); + assert.equal(applied.body.data.policy.datasets.leads.read, false); + + const publicInsert = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/leads/rows`, { + body: { name: '发布后提交' }, + }); + assert.equal(publicInsert.status, 201); +}); + +test('acceptance: turnstile enforced for public insert when secret configured', async () => { + const previousSecret = process.env.PAGE_DATA_TURNSTILE_SECRET; + const previousFetch = globalThis.fetch; + process.env.PAGE_DATA_TURNSTILE_SECRET = 'acceptance-secret'; + globalThis.fetch = async (url, init) => { + if (String(url).includes('challenges.cloudflare.com')) { + return { + ok: true, + async json() { + return { success: true }; + }, + }; + } + return previousFetch(url, init); + }; + + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-captcha-')); + await setupWorkspace(workspaceRoot); + writePageAccessPolicy(workspaceRoot, { + pageId: PAGE_ID, + ownerUserId: OWNER_ID, + accessMode: 'public', + datasets: { leads: { insert: true, columns: { insert: ['name'] } } }, + }); + const app = buildApp(workspaceRoot); + + try { + const denied = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/leads/rows`, { + body: { name: '无验证码' }, + }); + assert.equal(denied.status, 400); + assert.equal(denied.body.error.code, 'captcha_required'); + + const ok = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/leads/rows`, { + headers: { 'x-page-data-captcha': 'token-ok' }, + body: { name: '有验证码' }, + }); + assert.equal(ok.status, 201); + } finally { + globalThis.fetch = previousFetch; + if (previousSecret == null) delete process.env.PAGE_DATA_TURNSTILE_SECRET; + else process.env.PAGE_DATA_TURNSTILE_SECRET = previousSecret; + } +}); diff --git a/page-data-browser-client.mjs b/page-data-browser-client.mjs new file mode 100644 index 0000000..7e7118d --- /dev/null +++ b/page-data-browser-client.mjs @@ -0,0 +1,113 @@ +function normalizeApiBase(apiBase) { + const base = String(apiBase ?? '/api').trim() || '/api'; + return base.replace(/\/$/, ''); +} + +export function buildPageDataAuthPath(apiBase, pageId) { + const base = normalizeApiBase(apiBase); + return `${base}/public/pages/${encodeURIComponent(String(pageId ?? '').trim())}/data-auth`; +} + +export function buildPageDataPublicPath(apiBase, pageId, dataset, suffix = '') { + const base = normalizeApiBase(apiBase); + const safePageId = encodeURIComponent(String(pageId ?? '').trim()); + const safeDataset = encodeURIComponent(String(dataset ?? '').trim()); + const tail = suffix ? (suffix.startsWith('/') ? suffix : `/${suffix}`) : ''; + return `${base}/public/pages/${safePageId}/data/${safeDataset}${tail}`; +} + +function buildAuthHeaders(token) { + const headers = { accept: 'application/json' }; + if (token) headers['x-page-data-token'] = token; + return headers; +} + +async function parseJsonResponse(response) { + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const error = payload?.error ?? {}; + throw Object.assign(new Error(error.message ?? '页面数据请求失败'), { + code: error.code ?? 'page_data_failed', + status: response.status, + }); + } + return payload?.data ?? payload; +} + +export function createPageDataBrowserClient({ + pageId, + apiBase = '/api', + token = null, + fetchImpl = globalThis.fetch, +} = {}) { + if (!pageId) throw new Error('pageId 不能为空'); + if (typeof fetchImpl !== 'function') throw new Error('fetch 不可用'); + + let sessionToken = token; + + async function request(method, path, { body, query, auth = true, extraHeaders = {} } = {}) { + const url = new URL(path, typeof window !== 'undefined' ? window.location.origin : 'http://localhost'); + if (query && typeof query === 'object') { + for (const [key, value] of Object.entries(query)) { + if (value == null || value === '') continue; + url.searchParams.set(key, String(value)); + } + } + const headers = { + ...(body ? { 'content-type': 'application/json' } : {}), + ...(auth ? buildAuthHeaders(sessionToken) : { accept: 'application/json' }), + ...(extraHeaders && typeof extraHeaders === 'object' ? extraHeaders : {}), + }; + const response = await fetchImpl(url.toString(), { + method, + headers, + credentials: 'include', + body: body ? JSON.stringify(body) : undefined, + }); + return parseJsonResponse(response); + } + + return { + get pageId() { + return pageId; + }, + get token() { + return sessionToken; + }, + setToken(nextToken) { + sessionToken = nextToken ? String(nextToken) : null; + }, + async authenticate(password) { + const result = await request('POST', buildPageDataAuthPath(apiBase, pageId), { + body: { password }, + auth: false, + }); + if (result?.token) sessionToken = result.token; + return result; + }, + async listRows(dataset, query = {}) { + return request('GET', buildPageDataPublicPath(apiBase, pageId, dataset), { query }); + }, + async getSchema(dataset) { + return request('GET', buildPageDataPublicPath(apiBase, pageId, dataset, 'schema')); + }, + async getStats(dataset) { + return request('GET', buildPageDataPublicPath(apiBase, pageId, dataset, 'stats')); + }, + async insertRow(dataset, row, options = {}) { + const captchaToken = + options.captchaToken ?? options.turnstileToken ?? options.captcha_token ?? null; + const extraHeaders = captchaToken ? { 'x-page-data-captcha': String(captchaToken) } : {}; + return request('POST', buildPageDataPublicPath(apiBase, pageId, dataset, 'rows'), { + body: row, + extraHeaders, + }); + }, + async updateRow(dataset, rowId, row) { + return request('PATCH', buildPageDataPublicPath(apiBase, pageId, dataset, `rows/${rowId}`), { body: row }); + }, + async deleteRow(dataset, rowId) { + return request('DELETE', buildPageDataPublicPath(apiBase, pageId, dataset, `rows/${rowId}`)); + }, + }; +} diff --git a/page-data-browser-client.test.mjs b/page-data-browser-client.test.mjs new file mode 100644 index 0000000..7800f2c --- /dev/null +++ b/page-data-browser-client.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildPageDataAuthPath, + buildPageDataPublicPath, + createPageDataBrowserClient, +} from './page-data-browser-client.mjs'; + +test('buildPageDataPublicPath encodes page and dataset segments', () => { + assert.equal( + buildPageDataPublicPath('/api', 'page 1', 'sign ups', 'rows'), + '/api/public/pages/page%201/data/sign%20ups/rows', + ); + assert.equal(buildPageDataAuthPath('/api/', 'page-1'), '/api/public/pages/page-1/data-auth'); +}); + +test('createPageDataBrowserClient sends token header and parses success payload', async () => { + const calls = []; + const client = createPageDataBrowserClient({ + pageId: 'page-1', + apiBase: '/api', + token: 'token-abc', + fetchImpl: async (url, init) => { + calls.push({ url, init }); + return { + ok: true, + async json() { + return { data: { rows: [{ id: 1, title: 'A' }] } }; + }, + }; + }, + }); + const rows = await client.listRows('tasks', { limit: 5 }); + assert.equal(rows.rows[0].title, 'A'); + assert.match(calls[0].url, /\/api\/public\/pages\/page-1\/data\/tasks\?limit=5/); + assert.equal(calls[0].init.headers['x-page-data-token'], 'token-abc'); + assert.equal(calls[0].init.credentials, 'include'); +}); + +test('createPageDataBrowserClient authenticate stores returned token', async () => { + const client = createPageDataBrowserClient({ + pageId: 'page-1', + fetchImpl: async () => ({ + ok: true, + async json() { + return { data: { token: 'fresh-token', expiresAt: Date.now() + 1000 } }; + }, + }), + }); + const auth = await client.authenticate('secret-pass'); + assert.equal(auth.token, 'fresh-token'); + assert.equal(client.token, 'fresh-token'); +}); + +test('createPageDataBrowserClient insertRow sends captcha header when provided', async () => { + const calls = []; + const client = createPageDataBrowserClient({ + pageId: 'page-1', + fetchImpl: async (url, init) => { + calls.push({ url, init }); + return { + ok: true, + async json() { + return { data: { row: { id: 1, title: 'A' } } }; + }, + }; + }, + }); + await client.insertRow('signups', { name: 'A' }, { turnstileToken: 'captcha-123' }); + assert.equal(calls[0].init.headers['x-page-data-captcha'], 'captcha-123'); +}); diff --git a/page-data-captcha.mjs b/page-data-captcha.mjs new file mode 100644 index 0000000..a11933f --- /dev/null +++ b/page-data-captcha.mjs @@ -0,0 +1,58 @@ +const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; + +export function isPageDataCaptchaEnabled() { + return Boolean(String(process.env.PAGE_DATA_TURNSTILE_SECRET ?? '').trim()); +} + +export async function verifyPageDataCaptcha(token, remoteIp, { fetchImpl = globalThis.fetch } = {}) { + const secret = String(process.env.PAGE_DATA_TURNSTILE_SECRET ?? '').trim(); + if (!secret) { + return { ok: true, skipped: true }; + } + const responseToken = String(token ?? '').trim(); + if (!responseToken) { + throw Object.assign(new Error('提交表单需要验证码'), { code: 'captcha_required', status: 400 }); + } + const body = new URLSearchParams({ + secret, + response: responseToken, + }); + if (remoteIp) body.set('remoteip', String(remoteIp)); + const response = await fetchImpl(TURNSTILE_VERIFY_URL, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + const payload = await response.json().catch(() => ({})); + if (!payload?.success) { + throw Object.assign(new Error('验证码校验失败'), { + code: 'captcha_invalid', + status: 403, + }); + } + return { ok: true, skipped: false }; +} + +export function extractPageDataCaptchaToken(req, payload) { + return ( + String(req.headers?.['x-page-data-captcha'] ?? '').trim() || + String(payload?.turnstile_token ?? payload?.turnstileToken ?? payload?.captcha_token ?? '').trim() || + null + ); +} + +export const PAGE_DATA_META_FIELDS = new Set([ + 'turnstile_token', + 'turnstileToken', + 'captcha_token', + 'updated_by_label', +]); + +export function stripPageDataMetaFields(payload) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload; + const cleaned = { ...payload }; + for (const key of PAGE_DATA_META_FIELDS) { + delete cleaned[key]; + } + return cleaned; +} diff --git a/page-data-captcha.test.mjs b/page-data-captcha.test.mjs new file mode 100644 index 0000000..3738a44 --- /dev/null +++ b/page-data-captcha.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + extractPageDataCaptchaToken, + stripPageDataMetaFields, + verifyPageDataCaptcha, +} from './page-data-captcha.mjs'; +import { + buildPageDataPolicyFromPublishInput, + mapPublicationAccessModeToPageData, +} from './page-access-policy.mjs'; + +test('stripPageDataMetaFields removes captcha and meta keys', () => { + assert.deepEqual( + stripPageDataMetaFields({ name: '张三', turnstile_token: 'abc', updated_by_label: 'x' }), + { name: '张三' }, + ); +}); + +test('verifyPageDataCaptcha skips when secret is not configured', async () => { + const prev = process.env.PAGE_DATA_TURNSTILE_SECRET; + delete process.env.PAGE_DATA_TURNSTILE_SECRET; + try { + const result = await verifyPageDataCaptcha(null, '127.0.0.1'); + assert.equal(result.skipped, true); + } finally { + if (prev) process.env.PAGE_DATA_TURNSTILE_SECRET = prev; + } +}); + +test('verifyPageDataCaptcha rejects missing token when enabled', async () => { + const prev = process.env.PAGE_DATA_TURNSTILE_SECRET; + process.env.PAGE_DATA_TURNSTILE_SECRET = 'test-secret'; + try { + await assert.rejects( + () => verifyPageDataCaptcha('', '127.0.0.1'), + (error) => error.code === 'captcha_required', + ); + } finally { + if (prev) process.env.PAGE_DATA_TURNSTILE_SECRET = prev; + else delete process.env.PAGE_DATA_TURNSTILE_SECRET; + } +}); + +test('extractPageDataCaptchaToken reads header and body fields', () => { + assert.equal( + extractPageDataCaptchaToken({ headers: { 'x-page-data-captcha': 'hdr' } }, {}), + 'hdr', + ); + assert.equal(extractPageDataCaptchaToken({}, { turnstile_token: 'body' }), 'body'); +}); + +test('buildPageDataPolicyFromPublishInput applies defaults by access mode', () => { + const policy = buildPageDataPolicyFromPublishInput({ + pageId: 'page-1', + ownerUserId: 'user-1', + accessMode: 'public', + datasetName: 'signups', + registryDataset: { + name: 'signups', + table: 'signups', + columns: { + read: ['id', 'name'], + insert: ['name', 'phone'], + update: ['status'], + soft_delete: ['id'], + }, + }, + }); + assert.equal(policy.accessMode, 'public'); + assert.equal(policy.datasets.signups.insert, true); + assert.equal(policy.datasets.signups.read, false); + assert.equal(mapPublicationAccessModeToPageData('private_link'), null); +}); diff --git a/page-data-integration.test.mjs b/page-data-integration.test.mjs index 51cf4e0..04a76ea 100644 --- a/page-data-integration.test.mjs +++ b/page-data-integration.test.mjs @@ -55,6 +55,33 @@ async function setupWorkspace(workspaceRoot) { return service; } +async function setupPublicWorkspace(workspaceRoot) { + const service = createUserDataSpaceService({ workspaceRoot }); + await service.executeSql(`CREATE TABLE signups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + phone TEXT, + status TEXT DEFAULT 'pending', + created_at TEXT, + deleted_at TEXT, + deleted_by TEXT, + updated_at TEXT, + updated_by_label TEXT + );`); + await service.upsertDataset({ + name: 'signups', + table: 'signups', + actions: ['read', 'insert', 'update', 'soft_delete'], + columns: { + read: ['id', 'name', 'phone', 'status', 'created_at'], + insert: ['name', 'phone', 'status'], + update: ['status'], + soft_delete: ['id'], + }, + }); + return service; +} + function buildApp(workspaceRoot) { const pageDataPublicService = createPageDataPublicService({ getPool: () => createPool('public'), @@ -252,3 +279,71 @@ test('integration: password publication flow still works for read after data-aut assert.equal(listed.status, 200); assert.equal(listed.body.data.rows[0].title, '口令可见'); }); + +test('integration: public insert writes operation logs for owner review', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-logs-int-')); + await setupPublicWorkspace(workspaceRoot); + const app = buildApp(workspaceRoot); + await request(app, 'PUT', `/api/page-data/policies/${PAGE_ID}`, { + headers: { 'x-test-user': '1' }, + body: { + pageId: PAGE_ID, + ownerUserId: OWNER_ID, + accessMode: 'public', + datasets: { signups: { insert: true, columns: { insert: ['name', 'phone', 'status'] } } }, + }, + }); + await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/signups/rows`, { + body: { name: '日志测试', phone: '13800000000' }, + }); + const logs = await request(app, 'GET', `/api/page-data/policies/${PAGE_ID}/logs`, { + headers: { 'x-test-user': '1' }, + }); + assert.equal(logs.status, 200); + assert.equal(logs.body.data.logs.length, 1); + assert.equal(logs.body.data.logs[0].action, 'insert'); + assert.equal(logs.body.data.logs[0].dataset, 'signups'); +}); + +test('integration: owner can export dataset and restore soft-deleted rows', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-owner-ops-')); + const ownerService = await setupPublicWorkspace(workspaceRoot); + const inserted = await ownerService.insertDatasetRow('signups', { + name: '导出项', + phone: '13800000001', + }); + const app = buildApp(workspaceRoot); + const exported = await request(app, 'GET', '/api/page-data/signups/export?format=json', { + headers: { 'x-test-user': '1' }, + }); + assert.equal(exported.status, 200); + assert.equal(exported.body.data.rowCount, 1); + + await ownerService.softDeleteRowForDataset(ownerService.getDataset('signups'), inserted.row.id, { + deletedBy: 'test', + }); + const restored = await request(app, 'POST', `/api/page-data/signups/rows/${inserted.row.id}/restore`, { + headers: { 'x-test-user': '1' }, + }); + assert.equal(restored.status, 200); + assert.equal(restored.body.data.restored, true); +}); + +test('integration: apply-publish route binds dataset after publication', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-apply-publish-route-')); + await setupPublicWorkspace(workspaceRoot); + const app = buildApp(workspaceRoot); + + const applied = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/apply-publish`, { + headers: { 'x-test-user': '1' }, + body: { + datasetName: 'signups', + capabilities: { read: true, insert: true, update: false, softDelete: false }, + }, + }); + assert.equal(applied.status, 200); + assert.equal(applied.body.data.policy.datasets.signups.read, true); + + const publicRead = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/signups`); + assert.equal(publicRead.status, 403); +}); diff --git a/page-data-log-store.mjs b/page-data-log-store.mjs new file mode 100644 index 0000000..cafdc46 --- /dev/null +++ b/page-data-log-store.mjs @@ -0,0 +1,47 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const DEFAULT_MAX_LOG_BYTES = 2 * 1024 * 1024; + +export function resolvePageDataLogDir(workspaceRoot) { + return path.join(workspaceRoot, '.mindspace', 'page-data-logs'); +} + +export function resolvePageDataLogPath(workspaceRoot, pageId) { + const safePageId = String(pageId ?? '').trim(); + if (!safePageId || /[\\/]/.test(safePageId)) { + throw Object.assign(new Error('pageId 无效'), { code: 'invalid_page_id' }); + } + return path.join(resolvePageDataLogDir(workspaceRoot), `${safePageId}.jsonl`); +} + +export function appendPageDataLog(workspaceRoot, pageId, entry, { maxBytes = DEFAULT_MAX_LOG_BYTES } = {}) { + const logPath = resolvePageDataLogPath(workspaceRoot, pageId); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + const line = `${JSON.stringify({ ...entry, at: entry.at ?? new Date().toISOString() })}\n`; + fs.appendFileSync(logPath, line, 'utf8'); + if (maxBytes > 0 && fs.existsSync(logPath) && fs.statSync(logPath).size > maxBytes) { + const raw = fs.readFileSync(logPath, 'utf8').trim().split('\n'); + const trimmed = raw.slice(-Math.max(50, Math.floor(raw.length / 2))); + fs.writeFileSync(logPath, `${trimmed.join('\n')}\n`, 'utf8'); + } + return line; +} + +export function listPageDataLogs(workspaceRoot, pageId, { limit = 100, offset = 0 } = {}) { + const logPath = resolvePageDataLogPath(workspaceRoot, pageId); + if (!fs.existsSync(logPath)) return []; + const lines = fs.readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean); + const parsed = lines + .map((line) => { + try { + return JSON.parse(line); + } catch { + return null; + } + }) + .filter(Boolean); + const safeOffset = Math.max(0, Number(offset ?? 0)); + const safeLimit = Math.min(Math.max(1, Number(limit ?? 100)), 500); + return parsed.slice(safeOffset, safeOffset + safeLimit); +} diff --git a/page-data-log-store.test.mjs b/page-data-log-store.test.mjs new file mode 100644 index 0000000..61593c7 --- /dev/null +++ b/page-data-log-store.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + appendPageDataLog, + listPageDataLogs, + resolvePageDataLogPath, +} from './page-data-log-store.mjs'; + +test('appendPageDataLog writes jsonl entries and listPageDataLogs reads them back', () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-log-')); + appendPageDataLog(workspaceRoot, 'page-1', { + action: 'insert', + dataset: 'entries', + rowId: 1, + }); + appendPageDataLog(workspaceRoot, 'page-1', { + action: 'update', + dataset: 'entries', + rowId: 1, + }); + assert.equal(fs.existsSync(resolvePageDataLogPath(workspaceRoot, 'page-1')), true); + const logs = listPageDataLogs(workspaceRoot, 'page-1'); + assert.equal(logs.length, 2); + assert.equal(logs[0].action, 'insert'); + assert.equal(logs[1].action, 'update'); + assert.ok(logs[0].at); +}); diff --git a/page-data-ops.test.mjs b/page-data-ops.test.mjs new file mode 100644 index 0000000..d9d540c --- /dev/null +++ b/page-data-ops.test.mjs @@ -0,0 +1,226 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import express from 'express'; +import { attachPageDataRoutes } from './page-data-routes.mjs'; +import { createPageDataService } from './page-data-service.mjs'; +import { createPageDataPublicService } from './page-data-public-service.mjs'; +import { createPageDataSessionStore } from './page-data-session-store.mjs'; +import { createUserDataSpaceService } from './user-data-space-service.mjs'; +import { writePageAccessPolicy } from './page-data-policy-store.mjs'; +import { publicationInternals } from './mindspace-publications.mjs'; + +const PAGE_ID = 'page-ops-1'; +const OWNER_ID = 'user-ops-1'; +const OLD_PASSWORD = 'old-pass-123'; +const NEW_PASSWORD = 'new-pass-456'; + +function createPool({ accessMode = 'password', password = OLD_PASSWORD, onUpdate } = {}) { + const passwordHash = password ? publicationInternals.hashPassword(password) : null; + const state = { + id: 'pub-ops-1', + user_id: OWNER_ID, + page_id: PAGE_ID, + access_mode: accessMode, + password_hash: passwordHash, + status: 'online', + expires_at: null, + }; + return { + async query(sql, params) { + if (sql.includes('FROM h5_publish_records') && sql.includes('WHERE pr.page_id')) { + return [[{ ...state }]]; + } + if (sql.startsWith('UPDATE h5_publish_records SET password_hash')) { + state.password_hash = params[0]; + onUpdate?.(params); + return [{ affectedRows: 1 }]; + } + return [[]]; + }, + }; +} + +async function setupWorkspace(workspaceRoot) { + const service = createUserDataSpaceService({ workspaceRoot }); + await service.executeSql(`CREATE TABLE signups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + phone TEXT, + created_at TEXT + );`); + await service.upsertDataset({ + name: 'signups', + table: 'signups', + actions: ['read', 'insert'], + columns: { + read: ['id', 'name', 'phone', 'created_at'], + insert: ['name', 'phone'], + }, + }); + writePageAccessPolicy(workspaceRoot, { + pageId: PAGE_ID, + ownerUserId: OWNER_ID, + accessMode: 'password', + datasets: { + signups: { + read: true, + insert: true, + columns: { + read: ['id', 'name', 'phone', 'created_at'], + insert: ['name', 'phone'], + }, + }, + }, + }); + return service; +} + +function buildApp(workspaceRoot, pool, sessionStore) { + const pageDataPublicService = createPageDataPublicService({ + getPool: () => pool, + resolveWorkspaceRootForOwner: () => workspaceRoot, + sessionStore, + }); + const api = express.Router(); + api.use(express.json()); + api.use((req, _res, next) => { + if (req.headers['x-test-user']) { + req.currentUser = { id: OWNER_ID, workspaceRoot }; + } + next(); + }); + attachPageDataRoutes(api, { + sendData: (res, _req, data, status = 200) => res.status(status).json({ data }), + sendError: (res, _req, status, code, message) => res.status(status).json({ error: { code, message } }), + getPageDataService: () => + createPageDataService({ + resolveWorkspaceRoot: async () => workspaceRoot, + }), + getPageDataPublicService: () => pageDataPublicService, + }); + const app = express(); + app.use('/api', api); + return app; +} + +async function request(app, method, url, { body, headers } = {}) { + const server = app.listen(0); + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}${url}`, { + method, + headers: { + ...(body ? { 'content-type': 'application/json' } : {}), + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + }); + return { status: response.status, body: await response.json() }; + } finally { + await new Promise((resolve) => server.close(resolve)); + } +} + +test('ops overview returns datasets stats logs and active sessions', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-ops-overview-')); + const ownerService = await setupWorkspace(workspaceRoot); + await ownerService.insertDatasetRow('signups', { name: '概览项', phone: '13800000000' }); + const sessionStore = createPageDataSessionStore(); + sessionStore.issue({ + pageId: PAGE_ID, + ownerUserId: OWNER_ID, + publicationId: 'pub-ops-1', + accessMode: 'password', + }); + const app = buildApp(workspaceRoot, createPool(), sessionStore); + + await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/signups/rows`, { + body: { name: '公开提交', phone: '13800000001' }, + headers: { 'x-page-data-token': (await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data-auth`, { body: { password: OLD_PASSWORD } })).body.data.token }, + }); + + const overview = await request(app, 'GET', `/api/page-data/policies/${PAGE_ID}/ops`, { + headers: { 'x-test-user': '1' }, + }); + assert.equal(overview.status, 200); + assert.equal(overview.body.data.datasets.length, 1); + assert.equal(overview.body.data.datasets[0].stats.total, 2); + assert.ok(overview.body.data.activeSessionCount >= 1); + assert.ok(overview.body.data.recentLogs.length >= 1); +}); + +test('owner can revoke all tokens and reset password', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-ops-reset-')); + await setupWorkspace(workspaceRoot); + const sessionStore = createPageDataSessionStore(); + const auth1 = sessionStore.issue({ + pageId: PAGE_ID, + ownerUserId: OWNER_ID, + publicationId: 'pub-ops-1', + accessMode: 'password', + }); + const auth2 = sessionStore.issue({ + pageId: PAGE_ID, + ownerUserId: OWNER_ID, + publicationId: 'pub-ops-1', + accessMode: 'password', + }); + let updatedPasswordHash = null; + const app = buildApp(workspaceRoot, createPool({ onUpdate: ([hash]) => { updatedPasswordHash = hash; } }), sessionStore); + + const revoked = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/tokens/revoke`, { + headers: { 'x-test-user': '1' }, + body: { revokeAll: true }, + }); + assert.equal(revoked.status, 200); + assert.equal(revoked.body.data.revokedCount, 2); + assert.equal(sessionStore.verify(auth1.token), null); + assert.equal(sessionStore.verify(auth2.token), null); + + const reset = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/password/reset`, { + headers: { 'x-test-user': '1' }, + body: { password: NEW_PASSWORD }, + }); + assert.equal(reset.status, 200); + assert.equal(reset.body.data.passwordReset, true); + assert.ok(updatedPasswordHash); + assert.ok(publicationInternals.verifyPassword(NEW_PASSWORD, updatedPasswordHash)); + + const oldAuthDenied = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data-auth`, { + body: { password: OLD_PASSWORD }, + }); + assert.equal(oldAuthDenied.status, 401); + + const newAuth = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data-auth`, { + body: { password: NEW_PASSWORD }, + }); + assert.equal(newAuth.status, 200); + assert.ok(newAuth.body.data.token); +}); + +test('owner can close dataset and public insert is denied afterwards', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-ops-close-')); + await setupWorkspace(workspaceRoot); + const sessionStore = createPageDataSessionStore(); + const app = buildApp(workspaceRoot, createPool(), sessionStore); + const auth = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data-auth`, { + body: { password: OLD_PASSWORD }, + }); + + const closed = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/datasets/signups/close`, { + headers: { 'x-test-user': '1' }, + }); + assert.equal(closed.status, 200); + assert.equal(closed.body.data.closed, true); + assert.equal(closed.body.data.policyDataset.closed, true); + + const denied = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/signups/rows`, { + headers: { 'x-page-data-token': auth.body.data.token }, + body: { name: '关闭后提交', phone: '13800000002' }, + }); + assert.equal(denied.status, 403); + assert.equal(denied.body.error.code, 'action_not_allowed'); +}); diff --git a/page-data-policy-index.mjs b/page-data-policy-index.mjs new file mode 100644 index 0000000..575c489 --- /dev/null +++ b/page-data-policy-index.mjs @@ -0,0 +1,90 @@ +import crypto from 'node:crypto'; + +export function buildPageDataPolicyScopeHash(policy) { + const payload = { + accessMode: policy?.accessMode ?? null, + datasets: Object.keys(policy?.datasets ?? {}).sort(), + }; + return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 16); +} + +export async function upsertPageDataPolicyIndex(pool, policy) { + if (!pool || !policy?.pageId || !policy?.ownerUserId) return null; + const datasetCount = Object.keys(policy.datasets ?? {}).length; + const now = Date.now(); + await pool.query( + `INSERT INTO h5_page_data_policy_index + (page_id, owner_user_id, access_mode, dataset_count, scope_hash, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + owner_user_id = VALUES(owner_user_id), + access_mode = VALUES(access_mode), + dataset_count = VALUES(dataset_count), + scope_hash = VALUES(scope_hash), + updated_at = VALUES(updated_at)`, + [ + policy.pageId, + policy.ownerUserId, + policy.accessMode, + datasetCount, + buildPageDataPolicyScopeHash(policy), + now, + ], + ); + return { + pageId: policy.pageId, + ownerUserId: policy.ownerUserId, + accessMode: policy.accessMode, + datasetCount, + scopeHash: buildPageDataPolicyScopeHash(policy), + updatedAt: now, + }; +} + +export async function getPageDataPolicyIndex(pool, pageId) { + if (!pool) return null; + const [rows] = await pool.query( + `SELECT page_id, owner_user_id, access_mode, dataset_count, scope_hash, updated_at + FROM h5_page_data_policy_index + WHERE page_id = ? + LIMIT 1`, + [pageId], + ); + const row = rows[0]; + if (!row) return null; + return { + pageId: row.page_id, + ownerUserId: row.owner_user_id, + accessMode: row.access_mode, + datasetCount: Number(row.dataset_count ?? 0), + scopeHash: row.scope_hash, + updatedAt: Number(row.updated_at ?? 0), + }; +} + +export async function listPageDataPolicyIndexByOwner(pool, ownerUserId, { limit = 50 } = {}) { + if (!pool) return []; + const safeLimit = Math.min(Math.max(1, Number(limit ?? 50)), 200); + const [rows] = await pool.query( + `SELECT page_id, owner_user_id, access_mode, dataset_count, scope_hash, updated_at + FROM h5_page_data_policy_index + WHERE owner_user_id = ? + ORDER BY updated_at DESC + LIMIT ?`, + [ownerUserId, safeLimit], + ); + return rows.map((row) => ({ + pageId: row.page_id, + ownerUserId: row.owner_user_id, + accessMode: row.access_mode, + datasetCount: Number(row.dataset_count ?? 0), + scopeHash: row.scope_hash, + updatedAt: Number(row.updated_at ?? 0), + })); +} + +export async function deletePageDataPolicyIndex(pool, pageId) { + if (!pool || !pageId) return false; + const [result] = await pool.query(`DELETE FROM h5_page_data_policy_index WHERE page_id = ?`, [pageId]); + return Number(result?.affectedRows ?? 0) > 0; +} diff --git a/page-data-policy-index.test.mjs b/page-data-policy-index.test.mjs new file mode 100644 index 0000000..da9ae72 --- /dev/null +++ b/page-data-policy-index.test.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildPageDataPolicyScopeHash, + upsertPageDataPolicyIndex, +} from './page-data-policy-index.mjs'; +import { + mapPublicationAccessModeToPageData, + syncPageDataPolicyAccessMode, +} from './page-data-publish-sync.mjs'; +import { writePageAccessPolicy } from './page-data-policy-store.mjs'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +test('buildPageDataPolicyScopeHash is stable for same policy shape', () => { + const policy = { + accessMode: 'public', + datasets: { signups: { insert: true }, leads: { insert: true } }, + }; + assert.equal( + buildPageDataPolicyScopeHash(policy), + buildPageDataPolicyScopeHash({ + accessMode: 'public', + datasets: { leads: { insert: true }, signups: { insert: true } }, + }), + ); +}); + +test('upsertPageDataPolicyIndex writes mysql row', async () => { + const queries = []; + const pool = { + async query(sql, params) { + queries.push({ sql, params }); + return [{ affectedRows: 1 }]; + }, + }; + const result = await upsertPageDataPolicyIndex(pool, { + pageId: 'page-1', + ownerUserId: 'user-1', + accessMode: 'password', + datasets: { signups: { insert: true } }, + }); + assert.equal(result.datasetCount, 1); + assert.match(queries[0].sql, /h5_page_data_policy_index/); + assert.equal(queries[0].params[0], 'page-1'); +}); + +test('syncPageDataPolicyAccessMode updates policy access mode after publish', () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-publish-sync-')); + writePageAccessPolicy(workspaceRoot, { + pageId: 'page-1', + ownerUserId: 'user-1', + accessMode: 'public', + datasets: { signups: { insert: true, columns: { insert: ['name'] } } }, + }); + const synced = syncPageDataPolicyAccessMode(workspaceRoot, 'page-1', 'password', 'user-1'); + assert.equal(synced?.accessMode, 'password'); + assert.equal(mapPublicationAccessModeToPageData('private_link'), null); +}); diff --git a/page-data-public-service.mjs b/page-data-public-service.mjs index 00f37a1..8b3742c 100644 --- a/page-data-public-service.mjs +++ b/page-data-public-service.mjs @@ -2,15 +2,35 @@ import { resolveMindSpaceUserPublishDir } from './mindspace-runtime-config.mjs'; import { publicationInternals } from './mindspace-publications.mjs'; import { buildEffectiveDataset, + buildPageDataPolicyFromPublishInput, + closePolicyDataset, normalizePageAccessPolicy, policyAllowsAction, } from './page-access-policy.mjs'; +import { + extractPageDataCaptchaToken, + stripPageDataMetaFields, + verifyPageDataCaptcha, +} from './page-data-captcha.mjs'; +import { + buildEffectiveDatasetForRole, + resolveRowAccessScope, + resolveRolePermissions, + resolveVisitorRole, + roleAllowsAction, +} from './page-access-visitor.mjs'; import { readPageAccessPolicy, writePageAccessPolicy } from './page-data-policy-store.mjs'; import { createPageDataRateLimiter, createPageDataSessionStore, hashClientMeta, } from './page-data-session-store.mjs'; +import { appendPageDataLog, listPageDataLogs } from './page-data-log-store.mjs'; +import { + getPageDataPolicyIndex, + listPageDataPolicyIndexByOwner, + upsertPageDataPolicyIndex, +} from './page-data-policy-index.mjs'; import { createUserDataSpaceService } from './user-data-space-service.mjs'; function mapPublicError(error) { @@ -45,12 +65,18 @@ function mapPublicError(error) { if (code === 'forbidden') { return { status: 403, code: 'forbidden', message: error.message }; } + if (code === 'row_not_allowed' || code === 'row_policy_invalid') { + return { status: 403, code: code ?? 'row_not_allowed', message: error.message }; + } if (code === 'unauthorized') { return { status: 401, code: 'unauthorized', message: error.message }; } if (code === 'invalid_payload' || code === 'payload_too_large' || code === 'invalid_policy') { return { status: 400, code: code ?? 'invalid_request', message: error.message }; } + if (code === 'captcha_required' || code === 'captcha_invalid') { + return { status: error.status ?? 400, code, message: error.message }; + } return { status: 400, code: 'page_data_failed', @@ -145,7 +171,7 @@ export function createPageDataPublicService(deps = {}) { }); } - function resolveEffectiveDataset(ownerUserId, policy, datasetName) { + function resolveEffectiveDataset(ownerUserId, policy, datasetName, rolePermissions = null) { const policyDataset = policy.datasets?.[datasetName]; if (!policyDataset) { throw Object.assign(new Error('dataset 未授权'), { code: 'action_not_allowed' }); @@ -155,11 +181,24 @@ export function createPageDataPublicService(deps = {}) { if (!registryDataset) { throw Object.assign(new Error('dataset 不存在'), { code: 'dataset_not_found' }); } - const effective = buildEffectiveDataset(registryDataset, policyDataset); + const effective = rolePermissions + ? buildEffectiveDatasetForRole(registryDataset, policyDataset, rolePermissions) + : buildEffectiveDataset(registryDataset, policyDataset); if (!effective) { throw Object.assign(new Error('dataset 未授权'), { code: 'action_not_allowed' }); } - return { ownerService, effective }; + return { ownerService, effective, policyDataset }; + } + + function resolveRowScope(ownerService, effective, policyDataset, visitorContext) { + if (!policyDataset?.rowPolicy || !visitorContext) return null; + const tableColumns = ownerService.listTableColumns(effective.table); + return resolveRowAccessScope(policyDataset.rowPolicy, { + visitorRole: visitorContext.visitorRole, + visitorUserId: visitorContext.visitorUserId, + ownerUserId: visitorContext.ownerUserId, + tableColumns, + }); } function enforceRateLimit(req, publication, action) { @@ -198,10 +237,24 @@ export function createPageDataPublicService(deps = {}) { if (!viewerId) { throw Object.assign(new Error('需要登录后才能访问页面数据'), { code: 'auth_required' }); } + const visitorRole = resolveVisitorRole(policy, viewerId); + if (!visitorRole) { + throw Object.assign(new Error('当前账号未被授权访问此页面数据'), { code: 'forbidden' }); + } + const rolePermissions = resolveRolePermissions(policy, visitorRole); if (!policyAllowsAction(policy, datasetName, action)) { throw Object.assign(new Error(`dataset 未授权 ${action}`), { code: 'action_not_allowed' }); } - return { accessMode, session: { sessionId: viewerId, ownerUserId: publication.user_id } }; + if (!roleAllowsAction(rolePermissions, action)) { + throw Object.assign(new Error(`当前角色未授权 ${action}`), { code: 'action_not_allowed' }); + } + return { + accessMode, + session: { sessionId: viewerId, ownerUserId: publication.user_id, visitorUserId: viewerId }, + visitorRole, + rolePermissions, + ownerUserId: publication.user_id, + }; } throw Object.assign(new Error('当前页面访问模式不支持 Page Data API'), { code: 'action_not_allowed' }); @@ -242,10 +295,27 @@ export function createPageDataPublicService(deps = {}) { return runPublicAction(async () => { const publication = await queryPublication(pageId); const policy = await loadPolicy(publication); - resolveAccessContext({ publication, policy, req, action: 'read', datasetName }); + const access = resolveAccessContext({ publication, policy, req, action: 'read', datasetName }); enforceRateLimit(req, publication, 'read'); - const { ownerService, effective } = resolveEffectiveDataset(publication.user_id, policy, datasetName); - return ownerService.readRowsForDataset(effective, query); + const { ownerService, effective, policyDataset } = resolveEffectiveDataset( + publication.user_id, + policy, + datasetName, + access.rolePermissions ?? null, + ); + const rowScope = resolveRowScope( + ownerService, + effective, + policyDataset, + access.accessMode === 'login_required' + ? { + visitorRole: access.visitorRole, + visitorUserId: access.session?.visitorUserId, + ownerUserId: access.ownerUserId, + } + : null, + ); + return ownerService.readRowsForDataset(effective, { ...query, rowScope }); }); } @@ -253,8 +323,13 @@ export function createPageDataPublicService(deps = {}) { return runPublicAction(async () => { const publication = await queryPublication(pageId); const policy = await loadPolicy(publication); - resolveAccessContext({ publication, policy, req, action: 'read', datasetName }); - const { ownerService, effective } = resolveEffectiveDataset(publication.user_id, policy, datasetName); + const access = resolveAccessContext({ publication, policy, req, action: 'read', datasetName }); + const { ownerService, effective } = resolveEffectiveDataset( + publication.user_id, + policy, + datasetName, + access.rolePermissions ?? null, + ); return ownerService.getSchemaForDataset(effective); }); } @@ -263,30 +338,112 @@ export function createPageDataPublicService(deps = {}) { return runPublicAction(async () => { const publication = await queryPublication(pageId); const policy = await loadPolicy(publication); - resolveAccessContext({ publication, policy, req, action: 'read', datasetName }); - const { ownerService, effective } = resolveEffectiveDataset(publication.user_id, policy, datasetName); - return ownerService.getStatsForDataset(effective); + const access = resolveAccessContext({ publication, policy, req, action: 'read', datasetName }); + const { ownerService, effective, policyDataset } = resolveEffectiveDataset( + publication.user_id, + policy, + datasetName, + access.rolePermissions ?? null, + ); + const rowScope = resolveRowScope( + ownerService, + effective, + policyDataset, + access.accessMode === 'login_required' + ? { + visitorRole: access.visitorRole, + visitorUserId: access.session?.visitorUserId, + ownerUserId: access.ownerUserId, + } + : null, + ); + return ownerService.getStatsForDataset(effective, { rowScope }); }); } - async function insertRow(pageId, datasetName, req, payload) { + function recordPublicLog(workspaceRoot, publication, { + datasetName, + action, + rowId, + req, + session, + accessMode, + visitorRole, + summary, + }) { + appendPageDataLog(workspaceRoot, publication.page_id, { + pageId: publication.page_id, + dataset: datasetName, + action, + rowId: rowId ?? null, + accessMode, + actor: { + type: accessMode === 'public' ? 'anonymous' : accessMode, + userId: req.currentUser?.id ?? session?.visitorUserId ?? null, + visitorUserId: session?.visitorUserId ?? req.currentUser?.id ?? null, + role: visitorRole ?? null, + sessionId: session?.sessionId ?? null, + ipHash: hashClientMeta(req.ip ?? req.socket?.remoteAddress), + userAgentHash: hashClientMeta(req.headers?.['user-agent']), + }, + summary: summary ?? null, + }); + } + + async function insertRow(pageId, datasetName, req, payloadInput) { return runPublicAction(async () => { const publication = await queryPublication(pageId); const policy = await loadPolicy(publication); - const { session } = resolveAccessContext({ + const access = resolveAccessContext({ publication, policy, req, action: 'insert', datasetName, }); + if (access.accessMode === 'public') { + await verifyPageDataCaptcha( + extractPageDataCaptchaToken(req, payloadInput), + req.ip ?? req.socket?.remoteAddress, + ); + } + const payload = stripPageDataMetaFields(payloadInput); enforceRateLimit(req, publication, 'insert'); - const { ownerService, effective } = resolveEffectiveDataset(publication.user_id, policy, datasetName); - return ownerService.insertRowForDataset(effective, payload, { + const { ownerService, effective, policyDataset } = resolveEffectiveDataset( + publication.user_id, + policy, + datasetName, + access.rolePermissions ?? null, + ); + const rowScope = resolveRowScope( + ownerService, + effective, + policyDataset, + access.accessMode === 'login_required' + ? { + visitorRole: access.visitorRole, + visitorUserId: access.session?.visitorUserId, + ownerUserId: access.ownerUserId, + } + : null, + ); + const result = await ownerService.insertRowForDataset(effective, payload, { createdIpHash: hashClientMeta(req.ip ?? req.socket?.remoteAddress), userAgentHash: hashClientMeta(req.headers?.['user-agent']), - pageDataSessionId: session?.sessionId ?? null, + pageDataSessionId: access.session?.sessionId ?? null, + rowScope, }); + recordPublicLog(resolveWorkspaceRoot(publication.user_id), publication, { + datasetName, + action: 'insert', + rowId: result.row?.id ?? null, + req, + session: access.session, + accessMode: access.accessMode, + visitorRole: access.visitorRole, + summary: { fields: Object.keys(payload ?? {}) }, + }); + return result; }); } @@ -294,7 +451,7 @@ export function createPageDataPublicService(deps = {}) { return runPublicAction(async () => { const publication = await queryPublication(pageId); const policy = await loadPolicy(publication); - const { session } = resolveAccessContext({ + const access = resolveAccessContext({ publication, policy, req, @@ -302,10 +459,39 @@ export function createPageDataPublicService(deps = {}) { datasetName, }); enforceRateLimit(req, publication, 'update'); - const { ownerService, effective } = resolveEffectiveDataset(publication.user_id, policy, datasetName); - return ownerService.updateRowForDataset(effective, rowId, payload, { - updatedByLabel: req.body?.updated_by_label ?? session?.sessionId ?? 'public', + const { ownerService, effective, policyDataset } = resolveEffectiveDataset( + publication.user_id, + policy, + datasetName, + access.rolePermissions ?? null, + ); + const rowScope = resolveRowScope( + ownerService, + effective, + policyDataset, + access.accessMode === 'login_required' + ? { + visitorRole: access.visitorRole, + visitorUserId: access.session?.visitorUserId, + ownerUserId: access.ownerUserId, + } + : null, + ); + const result = await ownerService.updateRowForDataset(effective, rowId, payload, { + updatedByLabel: req.body?.updated_by_label ?? access.session?.sessionId ?? 'public', + rowScope, }); + recordPublicLog(resolveWorkspaceRoot(publication.user_id), publication, { + datasetName, + action: 'update', + rowId, + req, + session: access.session, + accessMode: access.accessMode, + visitorRole: access.visitorRole, + summary: { fields: Object.keys(payload ?? {}) }, + }); + return result; }); } @@ -313,7 +499,7 @@ export function createPageDataPublicService(deps = {}) { return runPublicAction(async () => { const publication = await queryPublication(pageId); const policy = await loadPolicy(publication); - const { session } = resolveAccessContext({ + const access = resolveAccessContext({ publication, policy, req, @@ -321,13 +507,48 @@ export function createPageDataPublicService(deps = {}) { datasetName, }); enforceRateLimit(req, publication, 'soft_delete'); - const { ownerService, effective } = resolveEffectiveDataset(publication.user_id, policy, datasetName); - return ownerService.softDeleteRowForDataset(effective, rowId, { - deletedBy: session?.sessionId ?? 'public', + const { ownerService, effective, policyDataset } = resolveEffectiveDataset( + publication.user_id, + policy, + datasetName, + access.rolePermissions ?? null, + ); + const rowScope = resolveRowScope( + ownerService, + effective, + policyDataset, + access.accessMode === 'login_required' + ? { + visitorRole: access.visitorRole, + visitorUserId: access.session?.visitorUserId, + ownerUserId: access.ownerUserId, + } + : null, + ); + const result = await ownerService.softDeleteRowForDataset(effective, rowId, { + deletedBy: access.session?.sessionId ?? 'public', + rowScope, }); + recordPublicLog(resolveWorkspaceRoot(publication.user_id), publication, { + datasetName, + action: 'soft_delete', + rowId, + req, + session: access.session, + accessMode: access.accessMode, + visitorRole: access.visitorRole, + summary: null, + }); + return result; }); } + function syncPolicyIndex(policy) { + const pool = getPool(); + if (!pool || !policy) return null; + return upsertPageDataPolicyIndex(pool, policy).catch(() => null); + } + async function getOwnerPolicy(user, pageId) { if (!user?.id) throw Object.assign(new Error('未授权'), { code: 'unauthorized', status: 401 }); const workspaceRoot = user.workspaceRoot ?? resolveWorkspaceRoot(user.id); @@ -355,7 +576,196 @@ export function createPageDataPublicService(deps = {}) { }, { fallbackPageId: pageId, fallbackOwnerUserId: user.id }, ); - return writePageAccessPolicy(workspaceRoot, policy); + const saved = writePageAccessPolicy(workspaceRoot, policy); + await syncPolicyIndex(saved); + return saved; + } + + async function saveOwnerPolicyFromPublish(user, pageId, { datasetName, capabilities = {} } = {}) { + if (!user?.id) throw Object.assign(new Error('未授权'), { code: 'unauthorized', status: 401 }); + const publication = await queryPublication(pageId).catch(() => null); + if (!publication || publication.user_id !== user.id) { + throw Object.assign(new Error('页面不存在或未发布'), { code: 'publication_not_found', status: 404 }); + } + const normalizedDatasetName = String(datasetName ?? '').trim(); + if (!normalizedDatasetName) { + throw Object.assign(new Error('缺少 dataset 名称'), { code: 'invalid_request', status: 400 }); + } + const ownerService = createOwnerService(user.id); + const registryDataset = ownerService.getDataset(normalizedDatasetName); + if (!registryDataset) { + throw Object.assign(new Error('dataset 未注册'), { code: 'dataset_not_found', status: 404 }); + } + const workspaceRoot = user.workspaceRoot ?? resolveWorkspaceRoot(user.id); + const policy = buildPageDataPolicyFromPublishInput({ + pageId, + ownerUserId: user.id, + accessMode: publication.access_mode, + datasetName: registryDataset.name, + registryDataset, + capabilities, + }); + const saved = writePageAccessPolicy(workspaceRoot, policy); + await syncPolicyIndex(saved); + return saved; + } + + async function listOwnerPolicies(user) { + if (!user?.id) throw Object.assign(new Error('未授权'), { code: 'unauthorized', status: 401 }); + const pool = getPool(); + if (!pool) return []; + return listPageDataPolicyIndexByOwner(pool, user.id); + } + + async function getPageOpsOverview(user, pageId) { + if (!user?.id) throw Object.assign(new Error('未授权'), { code: 'unauthorized', status: 401 }); + const publication = await queryPublication(pageId); + if (publication.user_id !== user.id) { + throw Object.assign(new Error('无权访问该页面运维信息'), { code: 'forbidden', status: 403 }); + } + const workspaceRoot = user.workspaceRoot ?? resolveWorkspaceRoot(user.id); + const policy = readPageAccessPolicy(workspaceRoot, pageId); + if (!policy) { + throw Object.assign(new Error('页面数据策略未配置'), { code: 'policy_not_found', status: 404 }); + } + const ownerService = createOwnerService(user.id); + const datasets = []; + for (const [name, config] of Object.entries(policy.datasets ?? {})) { + let stats = null; + try { + if (ownerService.getDataset(name)) { + stats = ownerService.getDatasetStats(name); + } + } catch { + stats = null; + } + datasets.push({ + name, + read: config.read, + insert: config.insert, + update: config.update, + softDelete: config.softDelete, + closed: Boolean(config.closed), + closedAt: config.closedAt ?? null, + stats, + }); + } + const logs = listPageDataLogs(workspaceRoot, pageId, { limit: 50, offset: 0 }); + const recentSubmissions = logs.filter((entry) => entry.action === 'insert').slice(0, 10); + const activeSessions = sessionStore.listByPageId(pageId); + const index = await getPageDataPolicyIndex(getPool(), pageId); + const registeredDatasets = ownerService.listDatasets().map((dataset) => dataset.name); + const configuredNames = new Set(Object.keys(policy.datasets ?? {})); + const unconfiguredDatasets = registeredDatasets.filter((name) => !configuredNames.has(name)); + return { + pageId, + publication: { + id: publication.id, + pageId: publication.page_id, + accessMode: publication.access_mode, + status: publication.status, + }, + policy, + index, + registeredDatasets, + unconfiguredDatasets, + datasets, + recentLogs: logs.slice(0, 20), + recentSubmissions, + activeSessionCount: activeSessions.length, + activeSessions, + }; + } + + async function revokePageDataTokens(user, pageId, { token = null, revokeAll = false } = {}) { + if (!user?.id) throw Object.assign(new Error('未授权'), { code: 'unauthorized', status: 401 }); + const publication = await queryPublication(pageId); + if (publication.user_id !== user.id) { + throw Object.assign(new Error('无权撤销该页面令牌'), { code: 'forbidden', status: 403 }); + } + let revokedCount = 0; + if (revokeAll) { + revokedCount = sessionStore.revokeByPageId(pageId); + } else if (token) { + revokedCount = sessionStore.revoke(token) ? 1 : 0; + } else { + throw Object.assign(new Error('请提供 token 或设置 revokeAll'), { code: 'invalid_request', status: 400 }); + } + const workspaceRoot = user.workspaceRoot ?? resolveWorkspaceRoot(user.id); + appendPageDataLog(workspaceRoot, pageId, { + pageId, + action: 'revoke_tokens', + dataset: null, + rowId: null, + accessMode: publication.access_mode, + actor: { type: 'owner', userId: user.id, role: 'owner' }, + summary: { revokedCount, revokeAll: Boolean(revokeAll) }, + }); + return { pageId, revokedCount, revokeAll: Boolean(revokeAll) }; + } + + async function resetPageDataPassword(user, pageId, password) { + if (!user?.id) throw Object.assign(new Error('未授权'), { code: 'unauthorized', status: 401 }); + const publication = await queryPublication(pageId); + if (publication.user_id !== user.id) { + throw Object.assign(new Error('无权重置该页面口令'), { code: 'forbidden', status: 403 }); + } + if (publication.access_mode !== 'password') { + throw Object.assign(new Error('当前页面不是口令访问模式'), { code: 'auth_not_required', status: 400 }); + } + const normalizedPassword = publicationInternals.normalizePassword(password, true); + const passwordHash = publicationInternals.hashPassword(normalizedPassword); + const pool = getPool(); + if (!pool) { + throw Object.assign(new Error('数据库未配置'), { code: 'feature_disabled', status: 503 }); + } + await pool.query( + `UPDATE h5_publish_records SET password_hash = ?, updated_at = ? WHERE id = ? AND user_id = ?`, + [passwordHash, Date.now(), publication.id, user.id], + ); + const revokedCount = sessionStore.revokeByPageId(pageId); + const workspaceRoot = user.workspaceRoot ?? resolveWorkspaceRoot(user.id); + appendPageDataLog(workspaceRoot, pageId, { + pageId, + action: 'password_reset', + dataset: null, + rowId: null, + accessMode: publication.access_mode, + actor: { type: 'owner', userId: user.id, role: 'owner' }, + summary: { revokedSessions: revokedCount }, + }); + return { pageId, passwordReset: true, revokedSessions: revokedCount }; + } + + async function closePageDataset(user, pageId, datasetName) { + if (!user?.id) throw Object.assign(new Error('未授权'), { code: 'unauthorized', status: 401 }); + const publication = await queryPublication(pageId); + if (publication.user_id !== user.id) { + throw Object.assign(new Error('无权关闭该页面 dataset'), { code: 'forbidden', status: 403 }); + } + const workspaceRoot = user.workspaceRoot ?? resolveWorkspaceRoot(user.id); + const policy = readPageAccessPolicy(workspaceRoot, pageId); + if (!policy) { + throw Object.assign(new Error('页面数据策略未配置'), { code: 'policy_not_found', status: 404 }); + } + const closedPolicy = closePolicyDataset(policy, datasetName); + const saved = writePageAccessPolicy(workspaceRoot, closedPolicy); + await syncPolicyIndex(saved); + appendPageDataLog(workspaceRoot, pageId, { + pageId, + action: 'close_dataset', + dataset: datasetName, + rowId: null, + accessMode: publication.access_mode, + actor: { type: 'owner', userId: user.id, role: 'owner' }, + summary: null, + }); + return { + pageId, + dataset: datasetName, + closed: true, + policyDataset: saved.datasets[datasetName], + }; } return { @@ -368,6 +778,12 @@ export function createPageDataPublicService(deps = {}) { softDeleteRow, getOwnerPolicy, saveOwnerPolicy, + saveOwnerPolicyFromPublish, + listOwnerPolicies, + getPageOpsOverview, + revokePageDataTokens, + resetPageDataPassword, + closePageDataset, queryPublication, }; } diff --git a/page-data-public-service.test.mjs b/page-data-public-service.test.mjs index 27be3f8..4952bac 100644 --- a/page-data-public-service.test.mjs +++ b/page-data-public-service.test.mjs @@ -5,7 +5,7 @@ import path from 'node:path'; import test from 'node:test'; import { createPageDataPublicService, isPageDataPublicPath } from './page-data-public-service.mjs'; import { createUserDataSpaceService } from './user-data-space-service.mjs'; -import { writePageAccessPolicy } from './page-data-policy-store.mjs'; +import { writePageAccessPolicy, readPageAccessPolicy } from './page-data-policy-store.mjs'; import { publicationInternals } from './mindspace-publications.mjs'; const PAGE_ID = 'page-public-1'; @@ -111,6 +111,31 @@ test('public page read is denied in public access mode by default', async () => ); }); +test('login_required page read requires login and works for authenticated visitor', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-login-required-')); + const ownerService = await setupPublicWorkspace(workspaceRoot, { + accessMode: 'login_required', + withRead: true, + }); + await ownerService.insertRowForDataset(ownerService.getDataset('signups'), { + name: '登录可见', + phone: '13800000002', + }); + + const service = createPublicService(workspaceRoot, { accessMode: 'login_required' }); + await assert.rejects( + () => service.listRows(PAGE_ID, 'signups', { headers: {} }), + (error) => error.code === 'auth_required', + ); + + const rows = await service.listRows(PAGE_ID, 'signups', { + currentUser: { id: 'visitor-1' }, + headers: {}, + }); + assert.equal(rows.rows.length, 1); + assert.equal(rows.rows[0].name, '登录可见'); +}); + test('password page data-auth returns token and enables read', async () => { const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-password-')); const ownerService = await setupPublicWorkspace(workspaceRoot, { @@ -174,3 +199,214 @@ test('isPageDataPublicPath allows public page data routes without auth', () => { assert.equal(isPageDataPublicPath('/public/pages/page-1/data/signups', 'GET'), true); assert.equal(isPageDataPublicPath('/page-data/signups', 'GET'), false); }); + +async function setupCollaborationWorkspace(workspaceRoot) { + const service = createUserDataSpaceService({ workspaceRoot }); + await service.executeSql(`CREATE TABLE notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + status TEXT DEFAULT 'open', + created_by_user_id TEXT, + created_at TEXT, + deleted_at TEXT, + deleted_by TEXT, + updated_at TEXT, + updated_by_label TEXT + );`); + await service.upsertDataset({ + name: 'notes', + table: 'notes', + actions: ['read', 'insert', 'update', 'soft_delete'], + columns: { + read: ['id', 'title', 'status', 'created_by_user_id', 'created_at'], + insert: ['title'], + update: ['status'], + soft_delete: ['id'], + }, + }); + writePageAccessPolicy(workspaceRoot, { + pageId: PAGE_ID, + ownerUserId: OWNER_ID, + accessMode: 'login_required', + defaultVisitorRole: 'deny', + visitors: { + 'editor-a': 'editor', + 'viewer-a': 'viewer', + }, + datasets: { + notes: { + read: true, + insert: true, + update: true, + softDelete: true, + rowPolicy: { scope: 'own_rows', ownerColumn: 'created_by_user_id' }, + columns: { + read: ['id', 'title', 'status', 'created_by_user_id', 'created_at'], + insert: ['title'], + update: ['status'], + }, + }, + }, + }); + return service; +} + +test('login_required editor can insert and update only own_rows', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-role-editor-')); + const ownerService = await setupCollaborationWorkspace(workspaceRoot); + const service = createPublicService(workspaceRoot, { accessMode: 'login_required' }); + + const inserted = await service.insertRow( + PAGE_ID, + 'notes', + { currentUser: { id: 'editor-a' }, ip: '127.0.0.1', headers: {} }, + { title: '编辑者 A 的笔记' }, + ); + assert.equal(inserted.row.title, '编辑者 A 的笔记'); + + const insertedB = await ownerService.insertRowForDataset(ownerService.getDataset('notes'), { + title: '编辑者 B 的笔记', + }); + await ownerService.executeSql( + `UPDATE notes SET created_by_user_id = 'editor-b' WHERE id = ${insertedB.row.id};`, + ); + + const rowsA = await service.listRows(PAGE_ID, 'notes', { + currentUser: { id: 'editor-a' }, + headers: {}, + }); + assert.equal(rowsA.rows.length, 1); + assert.equal(rowsA.rows[0].title, '编辑者 A 的笔记'); + + const updated = await service.updateRow( + PAGE_ID, + 'notes', + inserted.row.id, + { currentUser: { id: 'editor-a' }, ip: '127.0.0.1', headers: {}, body: {} }, + { status: 'done' }, + ); + assert.equal(updated.row.status, 'done'); + + await assert.rejects( + () => + service.updateRow( + PAGE_ID, + 'notes', + insertedB.row.id, + { currentUser: { id: 'editor-a' }, ip: '127.0.0.1', headers: {}, body: {} }, + { status: 'hijacked' }, + ), + (error) => error.code === 'row_not_allowed', + ); +}); + +test('login_required viewer can read but cannot insert', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-role-viewer-')); + const ownerService = await setupCollaborationWorkspace(workspaceRoot); + await ownerService.executeSql( + `INSERT INTO notes (title, created_by_user_id, created_at) VALUES ('owner seed', '${OWNER_ID}', datetime('now'));`, + ); + + const service = createPublicService(workspaceRoot, { accessMode: 'login_required' }); + const rows = await service.listRows(PAGE_ID, 'notes', { + currentUser: { id: 'viewer-a' }, + headers: {}, + }); + assert.equal(rows.rows.length, 0); + + await assert.rejects( + () => + service.insertRow( + PAGE_ID, + 'notes', + { currentUser: { id: 'viewer-a' }, ip: '127.0.0.1', headers: {} }, + { title: 'viewer insert' }, + ), + (error) => error.code === 'action_not_allowed', + ); +}); + +test('login_required denies visitors not listed when defaultVisitorRole is deny', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-role-deny-')); + await setupCollaborationWorkspace(workspaceRoot); + const service = createPublicService(workspaceRoot, { accessMode: 'login_required' }); + await assert.rejects( + () => + service.listRows(PAGE_ID, 'notes', { + currentUser: { id: 'stranger-1' }, + headers: {}, + }), + (error) => error.code === 'forbidden', + ); +}); + +test('public insert requires captcha when turnstile secret is configured', async () => { + const previousSecret = process.env.PAGE_DATA_TURNSTILE_SECRET; + process.env.PAGE_DATA_TURNSTILE_SECRET = 'test-secret'; + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-captcha-required-')); + await setupPublicWorkspace(workspaceRoot); + const service = createPublicService(workspaceRoot, { accessMode: 'public' }); + try { + await assert.rejects( + () => + service.insertRow(PAGE_ID, 'signups', { ip: '127.0.0.1', headers: {} }, { + name: '匿名用户', + phone: '13900000000', + }), + (error) => error.code === 'captcha_required', + ); + } finally { + if (previousSecret == null) delete process.env.PAGE_DATA_TURNSTILE_SECRET; + else process.env.PAGE_DATA_TURNSTILE_SECRET = previousSecret; + } +}); + +test('public insert accepts verified captcha token', async () => { + const previousSecret = process.env.PAGE_DATA_TURNSTILE_SECRET; + const previousFetch = globalThis.fetch; + process.env.PAGE_DATA_TURNSTILE_SECRET = 'test-secret'; + globalThis.fetch = async () => ({ + ok: true, + async json() { + return { success: true }; + }, + }); + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-captcha-ok-')); + await setupPublicWorkspace(workspaceRoot); + const service = createPublicService(workspaceRoot, { accessMode: 'public' }); + try { + const result = await service.insertRow( + PAGE_ID, + 'signups', + { ip: '127.0.0.1', headers: { 'x-page-data-captcha': 'turnstile-token' } }, + { name: '验证码用户', phone: '13900000001' }, + ); + assert.equal(result.row.name, '验证码用户'); + } finally { + globalThis.fetch = previousFetch; + if (previousSecret == null) delete process.env.PAGE_DATA_TURNSTILE_SECRET; + else process.env.PAGE_DATA_TURNSTILE_SECRET = previousSecret; + } +}); + +test('saveOwnerPolicyFromPublish writes policy for published page', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-apply-publish-')); + await setupPublicWorkspace(workspaceRoot, { accessMode: 'password', withRead: true }); + const service = createPublicService(workspaceRoot, { accessMode: 'password' }); + const user = { id: OWNER_ID, workspaceRoot }; + const policy = await service.saveOwnerPolicyFromPublish(user, PAGE_ID, { + datasetName: 'signups', + capabilities: { + read: true, + insert: true, + update: false, + softDelete: false, + }, + }); + assert.equal(policy.pageId, PAGE_ID); + assert.equal(policy.accessMode, 'password'); + assert.equal(policy.datasets.signups.read, true); + assert.equal(policy.datasets.signups.insert, true); + const saved = readPageAccessPolicy(workspaceRoot, PAGE_ID); + assert.equal(saved.datasets.signups.read, true); +}); diff --git a/page-data-publish-sync.mjs b/page-data-publish-sync.mjs new file mode 100644 index 0000000..cdd6101 --- /dev/null +++ b/page-data-publish-sync.mjs @@ -0,0 +1,24 @@ +import { readPageAccessPolicy, writePageAccessPolicy } from './page-data-policy-store.mjs'; + +const PAGE_DATA_ACCESS_MODES = new Set(['public', 'password', 'login_required']); + +export function mapPublicationAccessModeToPageData(accessMode) { + const mode = String(accessMode ?? '').trim(); + if (PAGE_DATA_ACCESS_MODES.has(mode)) return mode; + return null; +} + +export function syncPageDataPolicyAccessMode(workspaceRoot, pageId, publicationAccessMode, ownerUserId) { + const mapped = mapPublicationAccessModeToPageData(publicationAccessMode); + if (!mapped) return null; + const policy = readPageAccessPolicy(workspaceRoot, pageId); + if (!policy) return null; + if (policy.ownerUserId !== ownerUserId) return null; + if (policy.accessMode === mapped) return policy; + return writePageAccessPolicy(workspaceRoot, { + ...policy, + pageId, + ownerUserId, + accessMode: mapped, + }); +} diff --git a/page-data-routes.mjs b/page-data-routes.mjs index bd45bc5..56fe197 100644 --- a/page-data-routes.mjs +++ b/page-data-routes.mjs @@ -1,3 +1,4 @@ +import { stripPageDataMetaFields } from './page-data-captcha.mjs'; import { mapPageDataPublicError } from './page-data-public-service.mjs'; function requireUser(req, res, sendError) { @@ -14,12 +15,12 @@ function parsePositiveInt(value, fallback) { return Math.floor(parsed); } -function parseRowPayload(req) { +function parseRowPayload(req, { stripMeta = false } = {}) { const payload = req.body?.row && typeof req.body.row === 'object' ? req.body.row : req.body; if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { return null; } - return payload; + return stripMeta ? stripPageDataMetaFields(payload) : payload; } function handlePublicError(res, req, sendError, error) { @@ -44,6 +45,19 @@ export function attachPageDataRoutes(api, deps) { } }); + api.get('/page-data/policies', async (req, res) => { + const user = requireUser(req, res, sendError); + if (!user) return; + const publicService = getPageDataPublicService?.(); + if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用'); + try { + const policies = await publicService.listOwnerPolicies(user); + return sendData(res, req, { policies }); + } catch (error) { + return handlePublicError(res, req, sendError, error); + } + }); + api.get('/page-data/policies/:pageId', async (req, res) => { const user = requireUser(req, res, sendError); if (!user) return; @@ -58,6 +72,24 @@ export function attachPageDataRoutes(api, deps) { } }); + api.post('/page-data/policies/:pageId/apply-publish', async (req, res) => { + const user = requireUser(req, res, sendError); + if (!user) return; + const publicService = getPageDataPublicService?.(); + if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用'); + const datasetName = String(req.body?.datasetName ?? req.body?.dataset ?? '').trim(); + if (!datasetName) return sendError(res, req, 400, 'invalid_request', '缺少 datasetName'); + try { + const policy = await publicService.saveOwnerPolicyFromPublish(user, req.params.pageId, { + datasetName, + capabilities: req.body?.capabilities ?? {}, + }); + return sendData(res, req, { policy }); + } catch (error) { + return handlePublicError(res, req, sendError, error); + } + }); + api.put('/page-data/policies/:pageId', async (req, res) => { const user = requireUser(req, res, sendError); if (!user) return; @@ -71,6 +103,106 @@ export function attachPageDataRoutes(api, deps) { } }); + api.get('/page-data/policies/:pageId/logs', async (req, res) => { + const user = requireUser(req, res, sendError); + if (!user) return; + const service = getPageDataService?.(); + if (!service) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用'); + try { + const result = await service.listPageLogs(user, req.params.pageId, { + limit: parsePositiveInt(req.query.limit, 100), + offset: parsePositiveInt(req.query.offset, 0), + }); + return sendData(res, req, result); + } catch (error) { + const status = error?.status ?? 400; + return sendError(res, req, status, error?.code ?? 'page_data_failed', error?.message ?? '读取失败'); + } + }); + + api.get('/page-data/policies/:pageId/ops', async (req, res) => { + const user = requireUser(req, res, sendError); + if (!user) return; + const publicService = getPageDataPublicService?.(); + if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用'); + try { + const overview = await publicService.getPageOpsOverview(user, req.params.pageId); + return sendData(res, req, overview); + } catch (error) { + return handlePublicError(res, req, sendError, error); + } + }); + + api.post('/page-data/policies/:pageId/tokens/revoke', async (req, res) => { + const user = requireUser(req, res, sendError); + if (!user) return; + const publicService = getPageDataPublicService?.(); + if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用'); + try { + const result = await publicService.revokePageDataTokens(user, req.params.pageId, { + token: req.body?.token ?? null, + revokeAll: Boolean(req.body?.revokeAll ?? req.body?.revoke_all), + }); + return sendData(res, req, result); + } catch (error) { + return handlePublicError(res, req, sendError, error); + } + }); + + api.post('/page-data/policies/:pageId/password/reset', async (req, res) => { + const user = requireUser(req, res, sendError); + if (!user) return; + const publicService = getPageDataPublicService?.(); + if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用'); + const password = String(req.body?.password ?? '').trim(); + if (!password) return sendError(res, req, 400, 'invalid_request', '缺少新口令'); + try { + const result = await publicService.resetPageDataPassword(user, req.params.pageId, password); + return sendData(res, req, result); + } catch (error) { + return handlePublicError(res, req, sendError, error); + } + }); + + api.post('/page-data/policies/:pageId/datasets/:dataset/close', async (req, res) => { + const user = requireUser(req, res, sendError); + if (!user) return; + const publicService = getPageDataPublicService?.(); + if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用'); + try { + const result = await publicService.closePageDataset(user, req.params.pageId, req.params.dataset); + return sendData(res, req, result); + } catch (error) { + return handlePublicError(res, req, sendError, error); + } + }); + + api.get('/page-data/:dataset/export', async (req, res) => { + const user = requireUser(req, res, sendError); + if (!user) return; + const service = getPageDataService?.(); + if (!service) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用'); + try { + const exported = await service.exportDataset(user, req.params.dataset, { + format: req.query.format, + include_deleted: req.query.include_deleted, + limit: parsePositiveInt(req.query.limit, 1000), + }); + if (exported.format === 'csv') { + res.setHeader('content-type', 'text/csv; charset=utf-8'); + res.setHeader( + 'content-disposition', + `attachment; filename="${encodeURIComponent(exported.dataset)}.csv"`, + ); + return res.status(200).send(exported.content); + } + return sendData(res, req, exported); + } catch (error) { + const status = error?.status ?? 400; + return sendError(res, req, status, error?.code ?? 'page_data_failed', error?.message ?? '导出失败'); + } + }); + api.get('/page-data/:dataset', async (req, res) => { const user = requireUser(req, res, sendError); if (!user) return; @@ -82,6 +214,7 @@ export function attachPageDataRoutes(api, deps) { offset: parsePositiveInt(req.query.offset, 0), orderBy: req.query.order_by, orderDir: req.query.order_dir, + include_deleted: req.query.include_deleted, }); return sendData(res, req, { dataset: result.dataset.name, @@ -128,7 +261,7 @@ export function attachPageDataRoutes(api, deps) { if (!user) return; const service = getPageDataService?.(); if (!service) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用'); - const payload = parseRowPayload(req); + const payload = parseRowPayload(req, { stripMeta: true }); if (!payload) return sendError(res, req, 400, 'invalid_request', '提交数据必须是 JSON 对象'); try { const result = await service.insertRow(user, req.params.dataset, payload); @@ -139,6 +272,20 @@ export function attachPageDataRoutes(api, deps) { } }); + api.post('/page-data/:dataset/rows/:rowId/restore', async (req, res) => { + const user = requireUser(req, res, sendError); + if (!user) return; + const service = getPageDataService?.(); + if (!service) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用'); + try { + const result = await service.restoreRow(user, req.params.dataset, req.params.rowId); + return sendData(res, req, result); + } catch (error) { + const status = error?.status ?? 400; + return sendError(res, req, status, error?.code ?? 'page_data_failed', error?.message ?? '恢复失败'); + } + }); + api.post('/public/pages/:pageId/data-auth', async (req, res) => { const publicService = getPageDataPublicService?.(); if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用'); @@ -198,7 +345,7 @@ export function attachPageDataRoutes(api, deps) { api.post('/public/pages/:pageId/data/:dataset/rows', async (req, res) => { const publicService = getPageDataPublicService?.(); if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用'); - const payload = parseRowPayload(req); + const payload = parseRowPayload(req, { stripMeta: true }); if (!payload) return sendError(res, req, 400, 'invalid_request', '提交数据必须是 JSON 对象'); try { const result = await publicService.insertRow(req.params.pageId, req.params.dataset, req, payload); @@ -211,7 +358,7 @@ export function attachPageDataRoutes(api, deps) { api.patch('/public/pages/:pageId/data/:dataset/rows/:rowId', async (req, res) => { const publicService = getPageDataPublicService?.(); if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用'); - const payload = parseRowPayload(req); + const payload = parseRowPayload(req, { stripMeta: true }); if (!payload) return sendError(res, req, 400, 'invalid_request', '更新数据必须是 JSON 对象'); try { const result = await publicService.updateRow( diff --git a/page-data-service.mjs b/page-data-service.mjs index 53eb5ee..502ccb1 100644 --- a/page-data-service.mjs +++ b/page-data-service.mjs @@ -1,4 +1,6 @@ import { createUserDataSpaceService } from './user-data-space-service.mjs'; +import { listPageDataLogs } from './page-data-log-store.mjs'; +import { readPageAccessPolicy } from './page-data-policy-store.mjs'; function mapServiceError(error) { const code = error?.code; @@ -11,6 +13,9 @@ function mapServiceError(error) { if (code === 'columns_not_allowed') { return { status: 403, code: 'columns_not_allowed', message: error.message }; } + if (code === 'invalid_row_id' || code === 'restore_unsupported') { + return { status: 400, code: code ?? 'invalid_request', message: error.message }; + } if (code === 'invalid_payload' || code === 'payload_too_large' || code === 'invalid_dataset_config') { return { status: 400, code: code ?? 'invalid_request', message: error.message }; } @@ -40,21 +45,25 @@ export function createPageDataService(deps = {}) { throw Object.assign(new Error('用户工作区不存在'), { code: 'workspace_not_found' }); } const pool = getPool(); - return createUserDataSpaceService({ + return { workspaceRoot, - userId: user.id, - query: pool ?? null, - }); + service: createUserDataSpaceService({ + workspaceRoot, + userId: user.id, + query: pool ?? null, + }), + }; } async function listRows(user, datasetName, query = {}) { - const service = await createServiceForUser(user); + const { service } = await createServiceForUser(user); try { return service.readDatasetRows(datasetName, { limit: query.limit, offset: query.offset, orderBy: query.orderBy ?? query.order_by, orderDir: query.orderDir ?? query.order_dir, + includeDeleted: ['1', 'true', 'yes'].includes(String(query.include_deleted ?? '').toLowerCase()), }); } catch (error) { throw mapServiceError(error); @@ -62,7 +71,7 @@ export function createPageDataService(deps = {}) { } async function getSchema(user, datasetName) { - const service = await createServiceForUser(user); + const { service } = await createServiceForUser(user); try { return service.getDatasetSchema(datasetName); } catch (error) { @@ -71,7 +80,7 @@ export function createPageDataService(deps = {}) { } async function getStats(user, datasetName) { - const service = await createServiceForUser(user); + const { service } = await createServiceForUser(user); try { return service.getDatasetStats(datasetName); } catch (error) { @@ -80,7 +89,7 @@ export function createPageDataService(deps = {}) { } async function insertRow(user, datasetName, payload) { - const service = await createServiceForUser(user); + const { service } = await createServiceForUser(user); try { return service.insertDatasetRow(datasetName, payload); } catch (error) { @@ -88,8 +97,30 @@ export function createPageDataService(deps = {}) { } } + async function restoreRow(user, datasetName, rowId) { + const { service } = await createServiceForUser(user); + try { + return service.restoreSoftDeletedRow(datasetName, rowId); + } catch (error) { + throw mapServiceError(error); + } + } + + async function exportDataset(user, datasetName, query = {}) { + const { service } = await createServiceForUser(user); + try { + return service.exportDatasetRows(datasetName, { + format: query.format, + includeDeleted: ['1', 'true', 'yes'].includes(String(query.include_deleted ?? '').toLowerCase()), + limit: query.limit, + }); + } catch (error) { + throw mapServiceError(error); + } + } + async function listDatasets(user) { - const service = await createServiceForUser(user); + const { service } = await createServiceForUser(user); return service.listDatasets().map((dataset) => ({ name: dataset.name, table: dataset.table, @@ -98,12 +129,44 @@ export function createPageDataService(deps = {}) { })); } + async function assertPageOwner(user, pageId) { + const pool = getPool(); + if (pool) { + const [rows] = await pool.query( + `SELECT user_id FROM h5_publish_records + WHERE page_id = ? AND status = 'online' + ORDER BY published_at DESC + LIMIT 1`, + [pageId], + ); + const ownerId = rows[0]?.user_id; + if (ownerId && ownerId === user.id) return; + } + const workspaceRoot = await resolveWorkspaceRoot(user); + const policy = readPageAccessPolicy(workspaceRoot, pageId); + if (policy?.ownerUserId === user.id) return; + throw Object.assign(new Error('无权访问该页面数据'), { code: 'forbidden', status: 403 }); + } + + async function listPageLogs(user, pageId, query = {}) { + await assertPageOwner(user, pageId); + const { workspaceRoot } = await createServiceForUser(user); + const logs = listPageDataLogs(workspaceRoot, pageId, { + limit: query.limit, + offset: query.offset, + }); + return { pageId, logs, count: logs.length }; + } + return { getUserAuth, listRows, getSchema, getStats, insertRow, + restoreRow, + exportDataset, listDatasets, + listPageLogs, }; } diff --git a/page-data-session-store.mjs b/page-data-session-store.mjs index 977d415..ffbd7db 100644 --- a/page-data-session-store.mjs +++ b/page-data-session-store.mjs @@ -41,6 +41,34 @@ export function createPageDataSessionStore(options = {}) { return sessions.delete(hashToken(token)); } + function revokeByPageId(pageId) { + let count = 0; + for (const [key, session] of sessions.entries()) { + if (session.pageId === pageId) { + sessions.delete(key); + count += 1; + } + } + return count; + } + + function listByPageId(pageId) { + const now = Date.now(); + const active = []; + for (const session of sessions.values()) { + if (session.pageId !== pageId) continue; + if (session.expiresAt <= now) continue; + active.push({ + sessionId: session.sessionId, + pageId: session.pageId, + ownerUserId: session.ownerUserId, + accessMode: session.accessMode, + expiresAt: session.expiresAt, + }); + } + return active; + } + function clearExpired() { const now = Date.now(); for (const [key, session] of sessions.entries()) { @@ -48,7 +76,7 @@ export function createPageDataSessionStore(options = {}) { } } - return { issue, verify, revoke, clearExpired }; + return { issue, verify, revoke, revokeByPageId, listByPageId, clearExpired }; } export function createPageDataRateLimiter(options = {}) { diff --git a/page-data-session-store.test.mjs b/page-data-session-store.test.mjs new file mode 100644 index 0000000..6695201 --- /dev/null +++ b/page-data-session-store.test.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createPageDataSessionStore } from './page-data-session-store.mjs'; + +test('session store revokeByPageId and listByPageId work per page', () => { + const store = createPageDataSessionStore({ ttlMs: 60_000 }); + const a = store.issue({ pageId: 'page-a', ownerUserId: 'owner-1', publicationId: 'pub-a', accessMode: 'password' }); + const b = store.issue({ pageId: 'page-b', ownerUserId: 'owner-1', publicationId: 'pub-b', accessMode: 'password' }); + assert.equal(store.listByPageId('page-a').length, 1); + assert.equal(store.revokeByPageId('page-a'), 1); + assert.equal(store.verify(a.token), null); + assert.ok(store.verify(b.token)); +}); diff --git a/public/assets/page-data-client.js b/public/assets/page-data-client.js new file mode 100644 index 0000000..f09e4cd --- /dev/null +++ b/public/assets/page-data-client.js @@ -0,0 +1,117 @@ +(function (global) { + 'use strict'; + + function normalizeApiBase(apiBase) { + var base = String(apiBase || '/api').trim() || '/api'; + return base.replace(/\/$/, ''); + } + + function buildAuthPath(apiBase, pageId) { + return normalizeApiBase(apiBase) + '/public/pages/' + encodeURIComponent(String(pageId || '').trim()) + '/data-auth'; + } + + function buildDataPath(apiBase, pageId, dataset, suffix) { + var base = normalizeApiBase(apiBase); + var tail = suffix ? (String(suffix).charAt(0) === '/' ? suffix : '/' + suffix) : ''; + return base + '/public/pages/' + encodeURIComponent(String(pageId || '').trim()) + '/data/' + encodeURIComponent(String(dataset || '').trim()) + tail; + } + + function buildAuthHeaders(token) { + var headers = { accept: 'application/json' }; + if (token) headers['x-page-data-token'] = token; + return headers; + } + + function resolveCaptchaToken(options) { + options = options || {}; + return options.captchaToken || options.turnstileToken || options.captcha_token || null; + } + + async function parseJsonResponse(response) { + var payload = await response.json().catch(function () { return {}; }); + if (!response.ok) { + var error = (payload && payload.error) || {}; + var err = new Error(error.message || '页面数据请求失败'); + err.code = error.code || 'page_data_failed'; + err.status = response.status; + throw err; + } + return (payload && payload.data) || payload; + } + + function createClient(options) { + options = options || {}; + var pageId = options.pageId; + var apiBase = options.apiBase || '/api'; + var fetchImpl = options.fetchImpl || global.fetch; + var sessionToken = options.token || null; + if (!pageId) throw new Error('pageId 不能为空'); + if (typeof fetchImpl !== 'function') throw new Error('fetch 不可用'); + + async function request(method, path, opts) { + opts = opts || {}; + var url = new URL(path, global.location ? global.location.origin : 'http://localhost'); + if (opts.query) { + Object.keys(opts.query).forEach(function (key) { + var value = opts.query[key]; + if (value == null || value === '') return; + url.searchParams.set(key, String(value)); + }); + } + var headers = Object.assign( + {}, + opts.body ? { 'content-type': 'application/json' } : {}, + opts.auth === false ? { accept: 'application/json' } : buildAuthHeaders(sessionToken), + opts.extraHeaders || {}, + ); + var response = await fetchImpl(url.toString(), { + method: method, + headers: headers, + credentials: 'include', + body: opts.body ? JSON.stringify(opts.body) : undefined, + }); + return parseJsonResponse(response); + } + + return { + get pageId() { return pageId; }, + get token() { return sessionToken; }, + setToken: function (nextToken) { sessionToken = nextToken ? String(nextToken) : null; }, + authenticate: function (password) { + return request('POST', buildAuthPath(apiBase, pageId), { body: { password: password }, auth: false }) + .then(function (result) { + if (result && result.token) sessionToken = result.token; + return result; + }); + }, + listRows: function (dataset, query) { + return request('GET', buildDataPath(apiBase, pageId, dataset), { query: query || {} }); + }, + getSchema: function (dataset) { + return request('GET', buildDataPath(apiBase, pageId, dataset, 'schema')); + }, + getStats: function (dataset) { + return request('GET', buildDataPath(apiBase, pageId, dataset, 'stats')); + }, + insertRow: function (dataset, row, insertOptions) { + var captchaToken = resolveCaptchaToken(insertOptions); + return request('POST', buildDataPath(apiBase, pageId, dataset, 'rows'), { + body: row, + extraHeaders: captchaToken ? { 'x-page-data-captcha': String(captchaToken) } : {}, + }); + }, + updateRow: function (dataset, rowId, row) { + return request('PATCH', buildDataPath(apiBase, pageId, dataset, 'rows/' + rowId), { body: row }); + }, + deleteRow: function (dataset, rowId) { + return request('DELETE', buildDataPath(apiBase, pageId, dataset, 'rows/' + rowId)); + }, + }; + } + + global.MindSpacePageData = { + createClient: createClient, + buildAuthPath: buildAuthPath, + buildDataPath: buildDataPath, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/schema.sql b/schema.sql index aa42367..8972707 100644 --- a/schema.sql +++ b/schema.sql @@ -250,6 +250,17 @@ CREATE TABLE IF NOT EXISTS h5_publish_records ( CONSTRAINT fk_h5_publish_scan FOREIGN KEY (security_scan_id) REFERENCES h5_security_scans(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS h5_page_data_policy_index ( + page_id CHAR(36) PRIMARY KEY, + owner_user_id CHAR(36) NOT NULL, + access_mode VARCHAR(32) NOT NULL, + dataset_count INT UNSIGNED NOT NULL DEFAULT 0, + scope_hash CHAR(16) NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_h5_page_data_policy_owner (owner_user_id, updated_at), + CONSTRAINT fk_h5_page_data_policy_owner FOREIGN KEY (owner_user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS h5_publication_asset_refs ( publication_id CHAR(36) NOT NULL, asset_id CHAR(36) NOT NULL, diff --git a/scripts/run-memind-tests.mjs b/scripts/run-memind-tests.mjs index 775d4c0..d6daf1d 100644 --- a/scripts/run-memind-tests.mjs +++ b/scripts/run-memind-tests.mjs @@ -141,6 +141,14 @@ const SCOPE_RULES = [ 'page-data-routes.test.mjs', 'page-data-public-service.test.mjs', 'page-data-integration.test.mjs', + 'page-data-acceptance.test.mjs', + 'page-data-log-store.test.mjs', + 'page-access-visitor.test.mjs', + 'page-data-ops.test.mjs', + 'page-data-session-store.test.mjs', + 'page-data-browser-client.test.mjs', + 'page-data-policy-index.test.mjs', + 'page-data-captcha.test.mjs', 'mindspace-sandbox-mcp.test.mjs', ], }, diff --git a/server.mjs b/server.mjs index 6607433..e1477be 100644 --- a/server.mjs +++ b/server.mjs @@ -188,6 +188,8 @@ import { attachAsrRoutes } from './asr-proxy.mjs'; import { attachPageDataRoutes } from './page-data-routes.mjs'; import { createPageDataService } from './page-data-service.mjs'; import { createPageDataPublicService, isPageDataPublicPath } from './page-data-public-service.mjs'; +import { syncPageDataPolicyAccessMode } from './page-data-publish-sync.mjs'; +import { upsertPageDataPolicyIndex } from './page-data-policy-index.mjs'; import { isNativeH5ApiPath } from './policies.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -4233,6 +4235,18 @@ api.post('/mindspace/v1/pages/:pageId/publish', async (req, res) => { acknowledgedFindingIds: req.body?.acknowledged_finding_ids, }, ); + const workspaceRoot = req.currentUser.workspaceRoot ?? null; + if (workspaceRoot) { + const syncedPolicy = syncPageDataPolicyAccessMode( + workspaceRoot, + req.params.pageId, + publication.accessMode, + req.currentUser.id, + ); + if (syncedPolicy) { + await upsertPageDataPolicyIndex(authPool, syncedPolicy).catch(() => null); + } + } await mindSpaceAudit?.write({ userId: req.currentUser.id, action: 'page.publish', @@ -5970,6 +5984,7 @@ app.use( express.static(path.join(__dirname, 'public/plaza-covers'), { maxAge: '7d' }), ); app.use('/brand', express.static(path.join(__dirname, 'public/brand'), { maxAge: '7d' })); +app.use('/assets', express.static(path.join(__dirname, 'public/assets'), { maxAge: '1d' })); app.get('/dev/wechat-share-preview', async (req, res) => { try { diff --git a/src/api/client.ts b/src/api/client.ts index ed943ac..b6e723a 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -34,6 +34,10 @@ import type { MindSpacePublication, MindSpacePublicationStats, MindSpacePublishCheck, + PageDataAccessPolicy, + PageDataDatasetSummary, + PageDataLogEntry, + PageDataOpsOverview, PlazaCategory, PlazaPostBrief, MindSpaceRedactedCopyResult, @@ -1458,6 +1462,165 @@ export async function updatePublicationStatus( return result.data; } +export async function listOwnerPageDataPolicies(): Promise< + Array<{ + pageId: string; + ownerUserId: string; + accessMode: string; + datasetCount: number; + scopeHash: string; + updatedAt: number; + }> +> { + const result = await apiFetch<{ + data: { + policies: Array<{ + pageId: string; + ownerUserId: string; + accessMode: string; + datasetCount: number; + scopeHash: string; + updatedAt: number; + }>; + }; + }>('/page-data/policies'); + return result.data.policies; +} + +export function buildPageDataExportUrl(dataset: string, format: 'json' | 'csv' = 'json') { + const params = new URLSearchParams({ format }); + return `/api/page-data/${encodeURIComponent(dataset)}/export?${params.toString()}`; +} + +export async function listPageDataDatasets(): Promise { + const result = await apiFetch<{ data: { datasets: PageDataDatasetSummary[] } }>('/page-data'); + return result.data.datasets; +} + +export async function getPageDataPolicy(pageId: string): Promise { + try { + const result = await apiFetch<{ data: { policy: PageDataAccessPolicy } }>( + `/page-data/policies/${encodeURIComponent(pageId)}`, + ); + return result.data.policy; + } catch (error) { + if (error instanceof ApiError && error.status === 404) return null; + throw error; + } +} + +export async function applyPageDataPublishPolicy( + pageId: string, + input: { + datasetName: string; + capabilities?: { + read?: boolean; + insert?: boolean; + update?: boolean; + softDelete?: boolean; + }; + }, +): Promise { + const result = await apiFetch<{ data: { policy: PageDataAccessPolicy } }>( + `/page-data/policies/${encodeURIComponent(pageId)}/apply-publish`, + { + method: 'POST', + body: JSON.stringify({ + datasetName: input.datasetName, + capabilities: input.capabilities ?? {}, + }), + }, + ); + return result.data.policy; +} + +export async function savePageDataPolicy( + pageId: string, + policy: Partial, +): Promise { + const result = await apiFetch<{ data: { policy: PageDataAccessPolicy } }>( + `/page-data/policies/${encodeURIComponent(pageId)}`, + { + method: 'PUT', + body: JSON.stringify(policy), + }, + ); + return result.data.policy; +} + +export async function getPageDataOpsOverview(pageId: string): Promise { + const result = await apiFetch<{ data: PageDataOpsOverview }>( + `/page-data/policies/${encodeURIComponent(pageId)}/ops`, + ); + return result.data; +} + +export async function listPageDataLogs( + pageId: string, + input: { limit?: number; offset?: number } = {}, +): Promise<{ pageId: string; logs: PageDataLogEntry[]; count: number }> { + const params = new URLSearchParams(); + if (input.limit != null) params.set('limit', String(input.limit)); + if (input.offset != null) params.set('offset', String(input.offset)); + const query = params.toString(); + const result = await apiFetch<{ data: { pageId: string; logs: PageDataLogEntry[]; count: number } }>( + `/page-data/policies/${encodeURIComponent(pageId)}/logs${query ? `?${query}` : ''}`, + ); + return result.data; +} + +export async function revokePageDataTokens( + pageId: string, + input: { revokeAll?: boolean; token?: string }, +): Promise<{ pageId: string; revokedCount: number; revokeAll: boolean }> { + const result = await apiFetch<{ data: { pageId: string; revokedCount: number; revokeAll: boolean } }>( + `/page-data/policies/${encodeURIComponent(pageId)}/tokens/revoke`, + { + method: 'POST', + body: JSON.stringify({ + revokeAll: Boolean(input.revokeAll), + token: input.token, + }), + }, + ); + return result.data; +} + +export async function resetPageDataPassword( + pageId: string, + password: string, +): Promise<{ pageId: string; passwordReset: boolean; revokedSessions: number }> { + const result = await apiFetch<{ + data: { pageId: string; passwordReset: boolean; revokedSessions: number }; + }>(`/page-data/policies/${encodeURIComponent(pageId)}/password/reset`, { + method: 'POST', + body: JSON.stringify({ password }), + }); + return result.data; +} + +export async function closePageDataDataset( + pageId: string, + dataset: string, +): Promise<{ pageId: string; dataset: string; closed: boolean }> { + const result = await apiFetch<{ data: { pageId: string; dataset: string; closed: boolean } }>( + `/page-data/policies/${encodeURIComponent(pageId)}/datasets/${encodeURIComponent(dataset)}/close`, + { method: 'POST' }, + ); + return result.data; +} + +export async function restorePageDataRow( + dataset: string, + rowId: number | string, +): Promise<{ restored: boolean; row: Record }> { + const result = await apiFetch<{ data: { restored: boolean; row: Record } }>( + `/page-data/${encodeURIComponent(dataset)}/rows/${encodeURIComponent(String(rowId))}/restore`, + { method: 'POST' }, + ); + return result.data; +} + export async function redactMindSpacePage( pageId: string, input: { diff --git a/src/components/MindSpacePageDataOpsPanel.tsx b/src/components/MindSpacePageDataOpsPanel.tsx new file mode 100644 index 0000000..da619ab --- /dev/null +++ b/src/components/MindSpacePageDataOpsPanel.tsx @@ -0,0 +1,319 @@ +import { useCallback, useEffect, useState, type ReactNode } from 'react'; +import { + buildPageDataExportUrl, + closePageDataDataset, + getPageDataOpsOverview, + resetPageDataPassword, + restorePageDataRow, + revokePageDataTokens, +} from '../api/client'; +import type { MindSpacePublishCheck, PageDataOpsOverview } from '../types'; + +type Props = { + pageId: string; + pageTitle: string; + accessMode: MindSpacePublishCheck['accessMode']; + onClose: () => void; +}; + +function formatLogTime(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); +} + +export function MindSpacePageDataOpsPanel({ pageId, pageTitle, accessMode, onClose }: Props) { + const [overview, setOverview] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [busyAction, setBusyAction] = useState(null); + const [newPassword, setNewPassword] = useState(''); + const [message, setMessage] = useState(null); + + const loadOverview = useCallback(async () => { + setLoading(true); + setError(null); + try { + const next = await getPageDataOpsOverview(pageId); + setOverview(next); + } catch (err) { + const text = err instanceof Error ? err.message : '加载页面数据运维信息失败'; + if (text.includes('policy_not_found') || text.includes('策略未配置')) { + setOverview(null); + setError('当前页面尚未配置 Page Data 策略。请让 Agent 注册 dataset 并调用 private_data_set_page_policy。'); + } else { + setError(text); + } + } finally { + setLoading(false); + } + }, [pageId]); + + useEffect(() => { + void loadOverview(); + }, [loadOverview]); + + const runAction = async (key: string, fn: () => Promise) => { + setBusyAction(key); + setMessage(null); + try { + await fn(); + await loadOverview(); + } catch (err) { + setMessage(err instanceof Error ? err.message : '操作失败'); + } finally { + setBusyAction(null); + } + }; + + const recentDeletions = + overview?.recentLogs.filter( + (entry) => entry.action === 'soft_delete' && entry.dataset && entry.rowId != null, + ) ?? []; + + return ( +
+
+
+

PAGE DATA

+

页面数据运维

+ + {pageTitle} · 页面 ID {pageId} + +
+ +
+ + {loading ?

加载中…

: null} + {error ?

{error}

: null} + {message ?

{message}

: null} + + {overview ? ( + <> +
+
+ 访问模式 + {overview.publication.accessMode} +
+
+ 策略索引 + {overview.index?.scopeHash ?? '—'} +
+
+ 活跃会话 + {overview.activeSessionCount} +
+
+ Dataset 数 + {overview.datasets.length} +
+
+ 最近提交 + {overview.recentSubmissions.length} +
+
+ + {overview.unconfiguredDatasets && overview.unconfiguredDatasets.length > 0 ? ( +
+

已注册但未纳入策略的 Dataset

+

+ {overview.unconfiguredDatasets.map((name) => ( + {name} + )).reduce((acc, node, index) => ( + index === 0 ? [node] : [...acc, '、', node] + ), [])} + {' '}— 需通过 Agent 更新 Page Data 策略后才能公开访问。 +

+
+ ) : null} + +
+

Dataset 状态

+ {overview.datasets.length === 0 ? ( +

策略中尚未声明 dataset。

+ ) : ( +
+ + + + + + + + + + + + + {overview.datasets.map((dataset) => ( + + + + + + + + + + ))} + +
名称更新删除行数 +
+ {dataset.name} + {dataset.closed ? 已关闭 : null} + {dataset.read ? '是' : '否'}{dataset.insert ? '是' : '否'}{dataset.update ? '是' : '否'}{dataset.softDelete ? '是' : '否'}{dataset.stats?.total ?? '—'} +
+ {!dataset.closed ? ( + + ) : null} + + 导出 JSON + + + 导出 CSV + +
+
+
+ )} +
+ +
+

最近提交

+ {overview.recentSubmissions.length === 0 ? ( +

暂无公开写入记录。

+ ) : ( +
    + {overview.recentSubmissions.map((entry, index) => ( +
  • + {formatLogTime(entry.at)} + {entry.dataset} + #{entry.rowId ?? '—'} +
  • + ))} +
+ )} +
+ +
+

最近操作日志

+ {overview.recentLogs.length === 0 ? ( +

暂无操作日志。

+ ) : ( +
    + {overview.recentLogs.map((entry, index) => ( +
  • + {formatLogTime(entry.at)} + {entry.action} + {entry.dataset ?? '—'} + {entry.actor?.role ?? entry.actor?.type ?? '—'} +
  • + ))} +
+ )} +
+ +
+ + + {accessMode === 'password' ? ( +
+ setNewPassword(event.target.value)} + placeholder="新页面口令(至少 8 位)" + autoComplete="new-password" + /> + +
+ ) : null} +
+ +
+

可恢复的软删除记录

+ {recentDeletions.length === 0 ? ( +

最近日志中暂无可恢复记录。

+ ) : ( +
    + {recentDeletions.map((entry, index) => ( +
  • + {formatLogTime(entry.at)} + {entry.dataset} + #{entry.rowId} + +
  • + ))} +
+ )} +
+ +

+ HTML 页面可引入 /assets/page-data-client.js,通过{' '} + MindSpacePageData.createClient({'{'} pageId {'}'}) 调用公开数据 API。 + 公开表单提交可配合 Cloudflare Turnstile,调用{' '} + insertRow(dataset, row, {'{'} turnstileToken {'}'}) 传入验证码。 +

+ + ) : null} +
+ ); +} diff --git a/src/components/MindSpacePageDetail.tsx b/src/components/MindSpacePageDetail.tsx index 36759c4..552f850 100644 --- a/src/components/MindSpacePageDetail.tsx +++ b/src/components/MindSpacePageDetail.tsx @@ -1,12 +1,15 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { usePageDraftHistory, type PageDraftSnapshot } from '../hooks/usePageDraftHistory'; import { + applyPageDataPublishPolicy, checkMindSpacePagePublication, deleteMindSpacePage, fixMindSpacePagePublication, getMindSpacePageDeletePreview, getMindSpacePage, getMindSpacePublicationStats, + getPageDataPolicy, + listPageDataDatasets, listPlazaCategories, offlineMindSpacePublication, publishMindSpacePage, @@ -23,6 +26,7 @@ import type { MindSpacePublishCheck, MindSpaceQuota, MindSpaceRedactionChange, + PageDataDatasetSummary, PlazaCategory, PlazaPostBrief, } from '../types'; @@ -35,6 +39,7 @@ import { MindSpacePagePreviewPanel } from './MindSpacePagePreviewPanel'; import { MindSpaceModal } from './MindSpaceModal'; import { MindSpaceDeletePlazaOption } from './MindSpaceDeletePlazaOption'; import { MindSpacePublishSuccess } from './MindSpacePublishSuccess'; +import { MindSpacePageDataOpsPanel } from './MindSpacePageDataOpsPanel'; import { prepareHtmlPageBrandMarkers } from '../../mindspace-page-tag.mjs'; type AccessMode = MindSpacePublishCheck['accessMode']; @@ -67,6 +72,21 @@ const ACCESS_MODE_LABELS: Record = { owner_only: '仅自己可见', }; +const PAGE_DATA_PUBLISH_MODES = new Set(['public', 'password', 'login_required']); + +function defaultPageDataCapabilities(mode: AccessMode) { + if (mode === 'public') { + return { read: false, insert: true, update: false, softDelete: false }; + } + if (mode === 'password') { + return { read: true, insert: true, update: false, softDelete: false }; + } + if (mode === 'login_required') { + return { read: true, insert: true, update: true, softDelete: false }; + } + return { read: false, insert: false, update: false, softDelete: false }; +} + function slugFromTitle(title: string, pageId?: string) { const ascii = title .normalize('NFKC') @@ -274,6 +294,16 @@ export function MindSpacePageDetail({ const [isExportingImage, setIsExportingImage] = useState(false); const [exportFeedback, setExportFeedback] = useState(null); const [plazaOpen, setPlazaOpen] = useState(false); + const [pageDataOpsOpen, setPageDataOpsOpen] = useState(false); + const [pageDataEnabled, setPageDataEnabled] = useState(false); + const [pageDataDataset, setPageDataDataset] = useState(''); + const [pageDataRead, setPageDataRead] = useState(false); + const [pageDataInsert, setPageDataInsert] = useState(true); + const [pageDataUpdate, setPageDataUpdate] = useState(false); + const [pageDataSoftDelete, setPageDataSoftDelete] = useState(false); + const [pageDataDatasets, setPageDataDatasets] = useState([]); + const [pageDataDatasetsLoading, setPageDataDatasetsLoading] = useState(false); + const [pageDataDatasetsError, setPageDataDatasetsError] = useState(null); const [plazaCategories, setPlazaCategories] = useState([]); const [plazaCategoryId, setPlazaCategoryId] = useState(''); const [plazaTags, setPlazaTags] = useState(''); @@ -292,6 +322,67 @@ export function MindSpacePageDetail({ const [confirmPublicationStatusOpen, setConfirmPublicationStatusOpen] = useState(false); const [statusConfirming, setStatusConfirming] = useState(false); + useEffect(() => { + if (!publishOpen) return; + let cancelled = false; + setPageDataDatasetsLoading(true); + setPageDataDatasetsError(null); + void listPageDataDatasets() + .then((datasets) => { + if (cancelled) return; + setPageDataDatasets(datasets); + setPageDataDataset((current) => current || datasets[0]?.name || ''); + }) + .catch((err) => { + if (cancelled) return; + setPageDataDatasetsError(err instanceof Error ? err.message : '加载 dataset 失败'); + }) + .finally(() => { + if (!cancelled) setPageDataDatasetsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [publishOpen]); + + useEffect(() => { + if (!publishOpen || !page?.id) return; + let cancelled = false; + void getPageDataPolicy(page.id) + .then((policy) => { + if (cancelled || !policy) return; + const datasetNames = Object.keys(policy.datasets ?? {}); + if (datasetNames.length === 0) return; + const primaryDataset = datasetNames[0]; + const datasetPolicy = policy.datasets[primaryDataset]; + if (!datasetPolicy) return; + setPageDataEnabled(true); + setPageDataDataset(primaryDataset); + setPageDataRead(Boolean(datasetPolicy.read)); + setPageDataInsert(Boolean(datasetPolicy.insert)); + setPageDataUpdate(Boolean(datasetPolicy.update)); + setPageDataSoftDelete(Boolean(datasetPolicy.softDelete)); + }) + .catch(() => { + // 无策略时保持默认关闭 + }); + return () => { + cancelled = true; + }; + }, [publishOpen, page?.id]); + + useEffect(() => { + if (!PAGE_DATA_PUBLISH_MODES.has(accessMode)) { + setPageDataEnabled(false); + return; + } + const defaults = defaultPageDataCapabilities(accessMode); + setPageDataRead(defaults.read); + setPageDataInsert(defaults.insert); + setPageDataUpdate(defaults.update); + setPageDataSoftDelete(defaults.softDelete); + }, [accessMode]); + const applyPageRecord = useCallback( (next: MindSpacePage, options?: { recordHistory?: boolean }) => { setPage(next); @@ -427,6 +518,20 @@ export function MindSpacePageDetail({ password: accessPassword || undefined, expiresAt: expiresAt ? new Date(expiresAt).getTime() : null, }); + if (pageDataEnabled && PAGE_DATA_PUBLISH_MODES.has(accessMode)) { + if (!pageDataDataset) { + throw new Error('已启用页面数据,请先选择 dataset'); + } + await applyPageDataPublishPolicy(page.id, { + datasetName: pageDataDataset, + capabilities: { + read: pageDataRead, + insert: pageDataInsert, + update: pageDataUpdate, + softDelete: pageDataSoftDelete, + }, + }); + } let nextPlazaPost: PlazaPostBrief | null = plazaPost; if (pushToPlaza) { nextPlazaPost = await publishToPlazaWithDefaults(publication.id); @@ -604,6 +709,11 @@ export function MindSpacePageDetail({ const publishBlockedBySecurity = Boolean( publishCheck && publishCheck.findings.some((finding) => finding.blocking), ); + const publishPageDataBlocked = Boolean( + pageDataEnabled && + PAGE_DATA_PUBLISH_MODES.has(accessMode) && + (pageDataDatasetsLoading || !pageDataDataset), + ); const publishWarningFindings = publishCheck?.findings.filter((finding) => !finding.blocking) ?? []; @@ -1006,6 +1116,16 @@ export function MindSpacePageDetail({ 发布到广场 ) : null} + @@ -1032,6 +1152,15 @@ export function MindSpacePageDetail({ ) : null} + {pageDataOpsOpen && page.publication ? ( + setPageDataOpsOpen(false)} + /> + ) : null} + {plazaOpen ? (
@@ -1130,6 +1259,103 @@ export function MindSpacePageDetail({ ))} + {PAGE_DATA_PUBLISH_MODES.has(accessMode) ? ( +
+ + {pageDataEnabled ? ( + <> + + {pageDataDatasetsLoading ? ( +

正在加载 dataset…

+ ) : null} + {pageDataDatasetsError ? ( +

{pageDataDatasetsError}

+ ) : null} + {!pageDataDatasetsLoading && pageDataDatasets.length === 0 ? ( +

+ 请先在 Agent 中注册 dataset,再发布并绑定页面数据。 +

+ ) : null} +
+ 公开能力 + + + + +
+

+ 发布成功后会自动写入 Page Data 策略;后续可在「页面数据」面板运维、导出与撤销访问。 +

+ + ) : ( +

+ 若页面需要读写后台数据,可勾选启用;Agent 需先注册 dataset。 +

+ )} +
+ ) : ( +

+ 当前访问方式不支持在发布时绑定页面数据;若页面已发布,可在「页面数据」面板单独配置。 +

+ )} {accessMode === 'password' && (