diff --git a/docs/page-data-api-usage.md b/docs/page-data-api-usage.md index e52242b..10c0192 100644 --- a/docs/page-data-api-usage.md +++ b/docs/page-data-api-usage.md @@ -23,11 +23,10 @@ ```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 +GET /api/page-data/:dataset +POST /api/page-data/:dataset/rows +PATCH /api/page-data/:dataset/rows/:id +DELETE /api/page-data/:dataset/rows/:id # soft delete POST /api/page-data/:dataset/rows/:id/restore GET /api/page-data/policies @@ -53,7 +52,7 @@ GET /api/public/pages/:pageId/data/:dataset/schema GET /api/public/pages/:pageId/data/:dataset/stats ``` -公开请求可带 `x-page-data-token`(口令/登录会话)。**公开页不能直接传 SQL**,只能提交策略白名单字段。 +公开请求可带 `x-page-data-token`(口令/登录会话)。**完全公开**模式下:默认仅 insert;若策略显式开启 `read`,匿名访问者可读白名单字段并查看 stats。 ## HTML 页面嵌入 diff --git a/page-data-acceptance.test.mjs b/page-data-acceptance.test.mjs index 31debac..61872ee 100644 --- a/page-data-acceptance.test.mjs +++ b/page-data-acceptance.test.mjs @@ -174,6 +174,34 @@ test('acceptance: public insert only when authorized; read/update/delete denied assert.equal(deleteDenied.status, 403); }); +test('acceptance: public read and stats when policy enables read', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-public-read-')); + const ownerService = await setupWorkspace(workspaceRoot); + await ownerService.insertDatasetRow('leads', { name: '展示项', note: 'secret' }); + writePageAccessPolicy(workspaceRoot, { + pageId: PAGE_ID, + ownerUserId: OWNER_ID, + accessMode: 'public', + datasets: { + leads: { + read: true, + columns: { read: ['id', 'name', 'created_at'] }, + }, + }, + }); + const app = buildApp(workspaceRoot); + + const listed = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/leads?limit=10`); + assert.equal(listed.status, 200); + assert.equal(listed.body.data.rows.length, 1); + assert.equal(listed.body.data.rows[0].name, '展示项'); + assert.equal(listed.body.data.rows[0].note, undefined); + + const stats = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/leads/stats`); + assert.equal(stats.status, 200); + assert.equal(stats.body.data.total, 1); +}); + 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); diff --git a/page-data-integration.test.mjs b/page-data-integration.test.mjs index 04a76ea..2e554ca 100644 --- a/page-data-integration.test.mjs +++ b/page-data-integration.test.mjs @@ -344,6 +344,14 @@ test('integration: apply-publish route binds dataset after publication', async ( assert.equal(applied.status, 200); assert.equal(applied.body.data.policy.datasets.signups.read, true); + const publicReadEmpty = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/signups`); + assert.equal(publicReadEmpty.status, 200); + assert.equal(publicReadEmpty.body.data.rows.length, 0); + + await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/signups/rows`, { + body: { name: '发布后公开读', phone: '13800000000' }, + }); const publicRead = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/signups`); - assert.equal(publicRead.status, 403); + assert.equal(publicRead.status, 200); + assert.equal(publicRead.body.data.rows.length, 1); }); diff --git a/page-data-public-service.mjs b/page-data-public-service.mjs index 8b3742c..f474f59 100644 --- a/page-data-public-service.mjs +++ b/page-data-public-service.mjs @@ -209,9 +209,15 @@ export function createPageDataPublicService(deps = {}) { function resolveAccessContext({ publication, policy, req, action, datasetName }) { const accessMode = publication.access_mode; if (accessMode === 'public') { - if (action === 'read' || action === 'update' || action === 'soft_delete') { + if (action === 'update' || action === 'soft_delete') { throw Object.assign(new Error('当前页面未开放此数据操作'), { code: 'action_not_allowed' }); } + if (action === 'read') { + if (!policyAllowsAction(policy, datasetName, 'read')) { + throw Object.assign(new Error('当前页面未开放此数据操作'), { code: 'action_not_allowed' }); + } + return { accessMode, session: null }; + } if (!policyAllowsAction(policy, datasetName, action)) { throw Object.assign(new Error(`dataset 未授权 ${action}`), { code: 'action_not_allowed' }); } diff --git a/page-data-public-service.test.mjs b/page-data-public-service.test.mjs index 4952bac..91b2036 100644 --- a/page-data-public-service.test.mjs +++ b/page-data-public-service.test.mjs @@ -111,6 +111,19 @@ test('public page read is denied in public access mode by default', async () => ); }); +test('public page read works when policy explicitly enables read', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-public-read-ok-')); + const ownerService = await setupPublicWorkspace(workspaceRoot, { accessMode: 'public', withRead: true }); + await ownerService.insertRowForDataset(ownerService.getDataset('signups'), { + name: '公开可读', + phone: '13900000002', + }); + const service = createPublicService(workspaceRoot, { accessMode: 'public' }); + const rows = await service.listRows(PAGE_ID, 'signups', { headers: {} }); + assert.equal(rows.rows.length, 1); + assert.equal(rows.rows[0].name, '公开可读'); +}); + 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, { diff --git a/page-data-routes.mjs b/page-data-routes.mjs index 56fe197..fe9fbac 100644 --- a/page-data-routes.mjs +++ b/page-data-routes.mjs @@ -286,6 +286,36 @@ export function attachPageDataRoutes(api, deps) { } }); + api.patch('/page-data/:dataset/rows/:rowId', 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 未启用'); + const payload = parseRowPayload(req, { stripMeta: true }); + if (!payload) return sendError(res, req, 400, 'invalid_request', '更新数据必须是 JSON 对象'); + try { + const result = await service.updateRow(user, req.params.dataset, req.params.rowId, payload); + return sendData(res, req, { dataset: result.dataset.name, row: result.row }); + } catch (error) { + const status = error?.status ?? 400; + return sendError(res, req, status, error?.code ?? 'page_data_failed', error?.message ?? '更新失败'); + } + }); + + api.delete('/page-data/:dataset/rows/:rowId', 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.softDeleteRow(user, req.params.dataset, req.params.rowId); + return sendData(res, req, { dataset: result.dataset.name, row: result.row, deleted: true }); + } 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 未启用'); diff --git a/page-data-routes.test.mjs b/page-data-routes.test.mjs index 09d4e98..7316dc3 100644 --- a/page-data-routes.test.mjs +++ b/page-data-routes.test.mjs @@ -101,6 +101,54 @@ test('page data routes allow logged-in owner to read and insert dataset rows', a assert.equal(stats.body.data.total, 1); }); +test('page data routes allow owner update and soft delete', async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-api-crud-')); + const service = createUserDataSpaceService({ workspaceRoot }); + await service.executeSql(`CREATE TABLE tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + status TEXT DEFAULT 'open', + created_at TEXT, + deleted_at TEXT, + deleted_by TEXT, + updated_at TEXT + );`); + await service.upsertDataset({ + name: 'tasks', + table: 'tasks', + actions: ['read', 'insert', 'update', 'soft_delete'], + columns: { + read: ['id', 'title', 'status', 'created_at'], + insert: ['title'], + update: ['status'], + soft_delete: ['id'], + }, + }); + + const app = createApiApp({ + workspaceRoot, + user: { id: 'user-1', workspaceRoot }, + }); + + const inserted = await requestJson(app, 'POST', '/api/page-data/tasks/rows', { title: '待办' }); + assert.equal(inserted.status, 201); + const rowId = inserted.body.data.row.id; + + const updated = await requestJson(app, 'PATCH', `/api/page-data/tasks/rows/${rowId}`, { + status: 'done', + }); + assert.equal(updated.status, 200); + assert.equal(updated.body.data.row.status, 'done'); + + const deleted = await requestJson(app, 'DELETE', `/api/page-data/tasks/rows/${rowId}`); + assert.equal(deleted.status, 200); + assert.equal(deleted.body.data.deleted, true); + + const listed = await requestJson(app, 'GET', '/api/page-data/tasks?limit=10'); + assert.equal(listed.status, 200); + assert.equal(listed.body.data.rows.length, 0); +}); + test('page data routes reject unauthorized dataset action', async () => { const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-api-deny-')); const service = createUserDataSpaceService({ workspaceRoot }); diff --git a/page-data-service.mjs b/page-data-service.mjs index 502ccb1..bd6a812 100644 --- a/page-data-service.mjs +++ b/page-data-service.mjs @@ -97,6 +97,24 @@ export function createPageDataService(deps = {}) { } } + async function updateRow(user, datasetName, rowId, payload) { + const { service } = await createServiceForUser(user); + try { + return service.updateDatasetRow(datasetName, rowId, payload); + } catch (error) { + throw mapServiceError(error); + } + } + + async function softDeleteRow(user, datasetName, rowId) { + const { service } = await createServiceForUser(user); + try { + return service.softDeleteDatasetRow(datasetName, rowId, { deletedBy: user.id }); + } catch (error) { + throw mapServiceError(error); + } + } + async function restoreRow(user, datasetName, rowId) { const { service } = await createServiceForUser(user); try { @@ -164,6 +182,8 @@ export function createPageDataService(deps = {}) { getSchema, getStats, insertRow, + updateRow, + softDeleteRow, restoreRow, exportDataset, listDatasets, diff --git a/src/components/MindSpacePageDataOpsPanel.tsx b/src/components/MindSpacePageDataOpsPanel.tsx index da619ab..d6e31a7 100644 --- a/src/components/MindSpacePageDataOpsPanel.tsx +++ b/src/components/MindSpacePageDataOpsPanel.tsx @@ -6,9 +6,30 @@ import { resetPageDataPassword, restorePageDataRow, revokePageDataTokens, + savePageDataPolicy, } from '../api/client'; import type { MindSpacePublishCheck, PageDataOpsOverview } from '../types'; +type RowPolicyScope = 'all_rows' | 'own_rows' | 'owner_only'; + +function formatVisitorsText(visitors: Record | undefined) { + return Object.entries(visitors ?? {}) + .map(([userId, role]) => `${userId} ${role}`) + .join('\n'); +} + +function parseVisitorsText(text: string) { + const visitors: Record = {}; + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + const match = trimmed.match(/^(\S+)[\s:]+(viewer|editor)$/i); + if (!match) continue; + visitors[match[1]] = match[2].toLowerCase() as 'viewer' | 'editor'; + } + return visitors; +} + type Props = { pageId: string; pageTitle: string; @@ -29,6 +50,24 @@ export function MindSpacePageDataOpsPanel({ pageId, pageTitle, accessMode, onClo const [busyAction, setBusyAction] = useState(null); const [newPassword, setNewPassword] = useState(''); const [message, setMessage] = useState(null); + const [defaultVisitorRole, setDefaultVisitorRole] = useState<'deny' | 'viewer' | 'editor'>('deny'); + const [visitorsText, setVisitorsText] = useState(''); + const [rowPolicies, setRowPolicies] = useState< + Record + >({}); + + const syncRoleForm = useCallback((policy: PageDataOpsOverview['policy']) => { + setDefaultVisitorRole(policy.defaultVisitorRole ?? 'deny'); + setVisitorsText(formatVisitorsText(policy.visitors)); + const next: Record = {}; + for (const [name, dataset] of Object.entries(policy.datasets ?? {})) { + next[name] = { + scope: dataset.rowPolicy?.scope ?? 'all_rows', + ownerColumn: dataset.rowPolicy?.ownerColumn ?? 'created_by_user_id', + }; + } + setRowPolicies(next); + }, []); const loadOverview = useCallback(async () => { setLoading(true); @@ -36,6 +75,7 @@ export function MindSpacePageDataOpsPanel({ pageId, pageTitle, accessMode, onClo try { const next = await getPageDataOpsOverview(pageId); setOverview(next); + syncRoleForm(next.policy); } catch (err) { const text = err instanceof Error ? err.message : '加载页面数据运维信息失败'; if (text.includes('policy_not_found') || text.includes('策略未配置')) { @@ -47,7 +87,7 @@ export function MindSpacePageDataOpsPanel({ pageId, pageTitle, accessMode, onClo } finally { setLoading(false); } - }, [pageId]); + }, [pageId, syncRoleForm]); useEffect(() => { void loadOverview(); @@ -129,6 +169,113 @@ export function MindSpacePageDataOpsPanel({ pageId, pageTitle, accessMode, onClo ) : null} + {overview.publication.accessMode === 'login_required' ? ( +
+

登录访问 · 角色与行级策略

+ +