feat(page-data): add private and public dataset API (phase 1-3)
Extract UserDataSpaceService for shared SQLite access, wire logged-in Page Data routes, and add public insert plus password-token read/update/delete with policy storage, rate limits, and regression tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
import { resolveMindSpaceUserPublishDir } from './mindspace-runtime-config.mjs';
|
||||
import { publicationInternals } from './mindspace-publications.mjs';
|
||||
import {
|
||||
buildEffectiveDataset,
|
||||
normalizePageAccessPolicy,
|
||||
policyAllowsAction,
|
||||
} from './page-access-policy.mjs';
|
||||
import { readPageAccessPolicy, writePageAccessPolicy } from './page-data-policy-store.mjs';
|
||||
import {
|
||||
createPageDataRateLimiter,
|
||||
createPageDataSessionStore,
|
||||
hashClientMeta,
|
||||
} from './page-data-session-store.mjs';
|
||||
import { createUserDataSpaceService } from './user-data-space-service.mjs';
|
||||
|
||||
function mapPublicError(error) {
|
||||
if (error?.status && error?.code && error?.message) {
|
||||
return error;
|
||||
}
|
||||
const code = error?.code;
|
||||
if (code === 'publication_not_found') {
|
||||
return { status: 404, code: 'publication_not_found', message: '公开页面不存在或已下线' };
|
||||
}
|
||||
if (code === 'policy_not_found') {
|
||||
return { status: 404, code: 'policy_not_found', message: '页面数据策略未配置' };
|
||||
}
|
||||
if (code === 'dataset_not_found') {
|
||||
return { status: 404, code: 'dataset_not_found', message: '数据集不存在' };
|
||||
}
|
||||
if (code === 'action_not_allowed') {
|
||||
return { status: 403, code: 'action_not_allowed', message: error.message };
|
||||
}
|
||||
if (code === 'columns_not_allowed') {
|
||||
return { status: 403, code: 'columns_not_allowed', message: error.message };
|
||||
}
|
||||
if (code === 'auth_required' || code === 'token_invalid' || code === 'password_invalid') {
|
||||
return { status: 401, code, message: error.message };
|
||||
}
|
||||
if (code === 'rate_limited') {
|
||||
return { status: 429, code: 'rate_limited', message: error.message };
|
||||
}
|
||||
if (code === 'invalid_identifier') {
|
||||
return { status: 403, code: 'columns_not_allowed', message: error.message };
|
||||
}
|
||||
if (code === 'forbidden') {
|
||||
return { status: 403, code: 'forbidden', 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 };
|
||||
}
|
||||
return {
|
||||
status: 400,
|
||||
code: 'page_data_failed',
|
||||
message: error instanceof Error ? error.message : '页面数据操作失败',
|
||||
};
|
||||
}
|
||||
|
||||
export function mapPageDataPublicError(error) {
|
||||
return mapPublicError(error);
|
||||
}
|
||||
|
||||
function rethrowPublicError(error) {
|
||||
throw mapPublicError(error);
|
||||
}
|
||||
|
||||
async function runPublicAction(fn) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
rethrowPublicError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function extractPageDataToken(req) {
|
||||
const header = String(req.headers?.['x-page-data-token'] ?? req.headers?.authorization ?? '').trim();
|
||||
if (!header) return null;
|
||||
if (header.toLowerCase().startsWith('bearer ')) return header.slice(7).trim();
|
||||
return header;
|
||||
}
|
||||
|
||||
export function isPageDataPublicPath(path, method) {
|
||||
if (!path.startsWith('/public/pages/')) return false;
|
||||
if (method === 'POST' && /^\/public\/pages\/[^/]+\/data-auth$/.test(path)) return true;
|
||||
if (method === 'POST' && /^\/public\/pages\/[^/]+\/data\/[^/]+\/rows$/.test(path)) return true;
|
||||
if (method === 'PATCH' && /^\/public\/pages\/[^/]+\/data\/[^/]+\/rows\/[^/]+$/.test(path)) return true;
|
||||
if (method === 'DELETE' && /^\/public\/pages\/[^/]+\/data\/[^/]+\/rows\/[^/]+$/.test(path)) return true;
|
||||
if (method !== 'GET') return false;
|
||||
return (
|
||||
/^\/public\/pages\/[^/]+\/data\/[^/]+$/.test(path) ||
|
||||
/^\/public\/pages\/[^/]+\/data\/[^/]+\/schema$/.test(path) ||
|
||||
/^\/public\/pages\/[^/]+\/data\/[^/]+\/stats$/.test(path)
|
||||
);
|
||||
}
|
||||
|
||||
export function createPageDataPublicService(deps = {}) {
|
||||
const getPool = deps.getPool ?? (() => null);
|
||||
const resolveH5Root = deps.resolveH5Root ?? (() => process.cwd());
|
||||
const resolveWorkspaceRootForOwner =
|
||||
deps.resolveWorkspaceRootForOwner ??
|
||||
((ownerUserId) => resolveMindSpaceUserPublishDir(resolveH5Root(), { id: ownerUserId }));
|
||||
const sessionStore = deps.sessionStore ?? createPageDataSessionStore();
|
||||
const rateLimiter =
|
||||
deps.rateLimiter ??
|
||||
createPageDataRateLimiter({
|
||||
windowMs: Number(process.env.PAGE_DATA_RATE_WINDOW_MS ?? 60_000),
|
||||
maxRequests: Number(process.env.PAGE_DATA_RATE_MAX_REQUESTS ?? 30),
|
||||
});
|
||||
|
||||
async function queryPublication(pageId) {
|
||||
const pool = getPool();
|
||||
if (!pool) {
|
||||
throw Object.assign(new Error('数据库未配置'), { code: 'feature_disabled', status: 503 });
|
||||
}
|
||||
const [rows] = await pool.query(
|
||||
`SELECT pr.id, pr.user_id, pr.page_id, pr.access_mode, pr.password_hash, pr.status, pr.expires_at
|
||||
FROM h5_publish_records pr
|
||||
WHERE pr.page_id = ? AND pr.status = 'online'
|
||||
ORDER BY pr.published_at DESC
|
||||
LIMIT 1`,
|
||||
[pageId],
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
throw Object.assign(new Error('公开页面不存在或已下线'), { code: 'publication_not_found' });
|
||||
}
|
||||
if (row.expires_at && Number(row.expires_at) <= Date.now()) {
|
||||
throw Object.assign(new Error('公开页面不存在或已下线'), { code: 'publication_not_found' });
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function resolveWorkspaceRoot(ownerUserId) {
|
||||
return resolveWorkspaceRootForOwner(ownerUserId);
|
||||
}
|
||||
|
||||
function createOwnerService(ownerUserId) {
|
||||
const pool = getPool();
|
||||
return createUserDataSpaceService({
|
||||
workspaceRoot: resolveWorkspaceRoot(ownerUserId),
|
||||
userId: ownerUserId,
|
||||
query: pool ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEffectiveDataset(ownerUserId, policy, datasetName) {
|
||||
const policyDataset = policy.datasets?.[datasetName];
|
||||
if (!policyDataset) {
|
||||
throw Object.assign(new Error('dataset 未授权'), { code: 'action_not_allowed' });
|
||||
}
|
||||
const ownerService = createOwnerService(ownerUserId);
|
||||
const registryDataset = ownerService.getDataset(datasetName);
|
||||
if (!registryDataset) {
|
||||
throw Object.assign(new Error('dataset 不存在'), { code: 'dataset_not_found' });
|
||||
}
|
||||
const effective = buildEffectiveDataset(registryDataset, policyDataset);
|
||||
if (!effective) {
|
||||
throw Object.assign(new Error('dataset 未授权'), { code: 'action_not_allowed' });
|
||||
}
|
||||
return { ownerService, effective };
|
||||
}
|
||||
|
||||
function enforceRateLimit(req, publication, action) {
|
||||
const ip = req.ip ?? req.socket?.remoteAddress ?? 'unknown';
|
||||
rateLimiter.check(`${publication.page_id}:${action}:${ip}`);
|
||||
}
|
||||
|
||||
function resolveAccessContext({ publication, policy, req, action, datasetName }) {
|
||||
const accessMode = publication.access_mode;
|
||||
if (accessMode === 'public') {
|
||||
if (action === 'read' || action === 'update' || action === 'soft_delete') {
|
||||
throw Object.assign(new Error('当前页面未开放此数据操作'), { code: 'action_not_allowed' });
|
||||
}
|
||||
if (!policyAllowsAction(policy, datasetName, action)) {
|
||||
throw Object.assign(new Error(`dataset 未授权 ${action}`), { code: 'action_not_allowed' });
|
||||
}
|
||||
return { accessMode, session: null };
|
||||
}
|
||||
|
||||
if (accessMode === 'password') {
|
||||
const token = extractPageDataToken(req);
|
||||
const session = token ? sessionStore.verify(token) : null;
|
||||
if (!session || session.pageId !== publication.page_id) {
|
||||
throw Object.assign(new Error('需要有效的页面数据访问令牌'), {
|
||||
code: 'token_invalid',
|
||||
});
|
||||
}
|
||||
if (!policyAllowsAction(policy, datasetName, action)) {
|
||||
throw Object.assign(new Error(`dataset 未授权 ${action}`), { code: 'action_not_allowed' });
|
||||
}
|
||||
return { accessMode, session };
|
||||
}
|
||||
|
||||
if (accessMode === 'login_required') {
|
||||
const viewerId = req.currentUser?.id ?? null;
|
||||
if (!viewerId) {
|
||||
throw Object.assign(new Error('需要登录后才能访问页面数据'), { code: 'auth_required' });
|
||||
}
|
||||
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 } };
|
||||
}
|
||||
|
||||
throw Object.assign(new Error('当前页面访问模式不支持 Page Data API'), { code: 'action_not_allowed' });
|
||||
}
|
||||
|
||||
async function loadPolicy(publication) {
|
||||
const workspaceRoot = resolveWorkspaceRoot(publication.user_id);
|
||||
const policy = readPageAccessPolicy(workspaceRoot, publication.page_id);
|
||||
if (!policy) {
|
||||
throw Object.assign(new Error('页面数据策略未配置'), { code: 'policy_not_found' });
|
||||
}
|
||||
if (policy.ownerUserId !== publication.user_id) {
|
||||
throw Object.assign(new Error('页面数据策略无效'), { code: 'invalid_policy' });
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
|
||||
async function authenticate(pageId, password, req) {
|
||||
return runPublicAction(async () => {
|
||||
const publication = await queryPublication(pageId);
|
||||
enforceRateLimit(req, publication, 'data-auth');
|
||||
if (publication.access_mode !== 'password') {
|
||||
throw Object.assign(new Error('当前页面不需要口令认证'), { code: 'auth_not_required' });
|
||||
}
|
||||
if (!publicationInternals.verifyPassword(password, publication.password_hash)) {
|
||||
throw Object.assign(new Error('页面口令不正确'), { code: 'password_invalid' });
|
||||
}
|
||||
return sessionStore.issue({
|
||||
pageId: publication.page_id,
|
||||
ownerUserId: publication.user_id,
|
||||
publicationId: publication.id,
|
||||
accessMode: publication.access_mode,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function listRows(pageId, datasetName, req, query = {}) {
|
||||
return runPublicAction(async () => {
|
||||
const publication = await queryPublication(pageId);
|
||||
const policy = await loadPolicy(publication);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
async function getSchema(pageId, datasetName, req) {
|
||||
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.getSchemaForDataset(effective);
|
||||
});
|
||||
}
|
||||
|
||||
async function getStats(pageId, datasetName, req) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
async function insertRow(pageId, datasetName, req, payload) {
|
||||
return runPublicAction(async () => {
|
||||
const publication = await queryPublication(pageId);
|
||||
const policy = await loadPolicy(publication);
|
||||
const { session } = resolveAccessContext({
|
||||
publication,
|
||||
policy,
|
||||
req,
|
||||
action: 'insert',
|
||||
datasetName,
|
||||
});
|
||||
enforceRateLimit(req, publication, 'insert');
|
||||
const { ownerService, effective } = resolveEffectiveDataset(publication.user_id, policy, datasetName);
|
||||
return ownerService.insertRowForDataset(effective, payload, {
|
||||
createdIpHash: hashClientMeta(req.ip ?? req.socket?.remoteAddress),
|
||||
userAgentHash: hashClientMeta(req.headers?.['user-agent']),
|
||||
pageDataSessionId: session?.sessionId ?? null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function updateRow(pageId, datasetName, rowId, req, payload) {
|
||||
return runPublicAction(async () => {
|
||||
const publication = await queryPublication(pageId);
|
||||
const policy = await loadPolicy(publication);
|
||||
const { session } = resolveAccessContext({
|
||||
publication,
|
||||
policy,
|
||||
req,
|
||||
action: 'update',
|
||||
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',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function softDeleteRow(pageId, datasetName, rowId, req) {
|
||||
return runPublicAction(async () => {
|
||||
const publication = await queryPublication(pageId);
|
||||
const policy = await loadPolicy(publication);
|
||||
const { session } = resolveAccessContext({
|
||||
publication,
|
||||
policy,
|
||||
req,
|
||||
action: 'soft_delete',
|
||||
datasetName,
|
||||
});
|
||||
enforceRateLimit(req, publication, 'soft_delete');
|
||||
const { ownerService, effective } = resolveEffectiveDataset(publication.user_id, policy, datasetName);
|
||||
return ownerService.softDeleteRowForDataset(effective, rowId, {
|
||||
deletedBy: session?.sessionId ?? 'public',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getOwnerPolicy(user, pageId) {
|
||||
if (!user?.id) throw Object.assign(new Error('未授权'), { code: 'unauthorized', status: 401 });
|
||||
const workspaceRoot = user.workspaceRoot ?? resolveWorkspaceRoot(user.id);
|
||||
const policy = readPageAccessPolicy(workspaceRoot, pageId);
|
||||
if (!policy) return null;
|
||||
if (policy.ownerUserId !== user.id) {
|
||||
throw Object.assign(new Error('无权访问该页面策略'), { code: 'forbidden', status: 403 });
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
|
||||
async function saveOwnerPolicy(user, pageId, policyInput) {
|
||||
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 workspaceRoot = user.workspaceRoot ?? resolveWorkspaceRoot(user.id);
|
||||
const policy = normalizePageAccessPolicy(
|
||||
{
|
||||
...policyInput,
|
||||
pageId,
|
||||
ownerUserId: user.id,
|
||||
accessMode: policyInput.accessMode ?? publication.access_mode,
|
||||
},
|
||||
{ fallbackPageId: pageId, fallbackOwnerUserId: user.id },
|
||||
);
|
||||
return writePageAccessPolicy(workspaceRoot, policy);
|
||||
}
|
||||
|
||||
return {
|
||||
authenticate,
|
||||
listRows,
|
||||
getSchema,
|
||||
getStats,
|
||||
insertRow,
|
||||
updateRow,
|
||||
softDeleteRow,
|
||||
getOwnerPolicy,
|
||||
saveOwnerPolicy,
|
||||
queryPublication,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user