446 lines
19 KiB
JavaScript
446 lines
19 KiB
JavaScript
import { stripPageDataMetaFields } from './page-data-captcha.mjs';
|
|
import { mapPageDataPublicError } from './page-data-public-service.mjs';
|
|
|
|
// Owner/admin APIs intentionally use a separate namespace from APIs callable
|
|
// by published HTML. A generated public page must only use
|
|
// `/api/public/pages/:pageId/data/...`; keeping `/api/page-data` live made it
|
|
// too easy for an LLM-generated page to accidentally target owner APIs.
|
|
const OWNER_PAGE_DATA_API_PREFIX = '/admin/page-data';
|
|
|
|
export function isLegacyPageDataApiPath(pathname) {
|
|
return /^\/page-data(?:\/|$)/.test(String(pathname ?? ''));
|
|
}
|
|
|
|
function requireUser(req, res, sendError) {
|
|
if (!req.currentUser?.id) {
|
|
sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
|
return null;
|
|
}
|
|
return req.currentUser;
|
|
}
|
|
|
|
function parsePositiveInt(value, fallback) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
|
|
return Math.floor(parsed);
|
|
}
|
|
|
|
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 stripMeta ? stripPageDataMetaFields(payload) : payload;
|
|
}
|
|
|
|
function handlePublicError(res, req, sendError, error) {
|
|
const mapped = mapPageDataPublicError(error);
|
|
return sendError(res, req, mapped.status, mapped.code, mapped.message);
|
|
}
|
|
|
|
export function attachPageDataRoutes(api, deps) {
|
|
const { sendData, sendError, getPageDataService, getPageDataPublicService } = deps;
|
|
|
|
api.get(OWNER_PAGE_DATA_API_PREFIX, 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 datasets = await service.listDatasets(user);
|
|
return sendData(res, req, { datasets });
|
|
} catch (error) {
|
|
const status = error?.status ?? 400;
|
|
return sendError(res, req, status, error?.code ?? 'page_data_failed', error?.message ?? '读取失败');
|
|
}
|
|
});
|
|
|
|
api.get(`${OWNER_PAGE_DATA_API_PREFIX}/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(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId`, 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 policy = await publicService.getOwnerPolicy(user, req.params.pageId);
|
|
if (!policy) return sendError(res, req, 404, 'policy_not_found', '页面数据策略未配置');
|
|
return sendData(res, req, { policy });
|
|
} catch (error) {
|
|
return handlePublicError(res, req, sendError, error);
|
|
}
|
|
});
|
|
|
|
api.post(`${OWNER_PAGE_DATA_API_PREFIX}/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(`${OWNER_PAGE_DATA_API_PREFIX}/policies/:pageId`, 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 policy = await publicService.saveOwnerPolicy(user, req.params.pageId, req.body ?? {});
|
|
return sendData(res, req, { policy });
|
|
} catch (error) {
|
|
return handlePublicError(res, req, sendError, error);
|
|
}
|
|
});
|
|
|
|
api.get(`${OWNER_PAGE_DATA_API_PREFIX}/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(`${OWNER_PAGE_DATA_API_PREFIX}/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(`${OWNER_PAGE_DATA_API_PREFIX}/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(`${OWNER_PAGE_DATA_API_PREFIX}/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(`${OWNER_PAGE_DATA_API_PREFIX}/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(`${OWNER_PAGE_DATA_API_PREFIX}/: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(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset`, 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.listRows(user, req.params.dataset, {
|
|
limit: parsePositiveInt(req.query.limit, undefined),
|
|
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,
|
|
rows: result.rows,
|
|
limit: result.limit,
|
|
offset: result.offset,
|
|
});
|
|
} catch (error) {
|
|
const status = error?.status ?? 400;
|
|
return sendError(res, req, status, error?.code ?? 'page_data_failed', error?.message ?? '读取失败');
|
|
}
|
|
});
|
|
|
|
api.get(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/schema`, 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 schema = await service.getSchema(user, req.params.dataset);
|
|
return sendData(res, req, schema);
|
|
} catch (error) {
|
|
const status = error?.status ?? 400;
|
|
return sendError(res, req, status, error?.code ?? 'page_data_failed', error?.message ?? '读取失败');
|
|
}
|
|
});
|
|
|
|
api.get(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/stats`, 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 stats = await service.getStats(user, req.params.dataset);
|
|
return sendData(res, req, stats);
|
|
} catch (error) {
|
|
const status = error?.status ?? 400;
|
|
return sendError(res, req, status, error?.code ?? 'page_data_failed', error?.message ?? '读取失败');
|
|
}
|
|
});
|
|
|
|
api.post(`${OWNER_PAGE_DATA_API_PREFIX}/:dataset/rows`, 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.insertRow(user, req.params.dataset, payload);
|
|
return sendData(res, req, { dataset: result.dataset.name, row: result.row }, 201);
|
|
} catch (error) {
|
|
const status = error?.status ?? 400;
|
|
return sendError(res, req, status, error?.code ?? 'page_data_failed', error?.message ?? '写入失败');
|
|
}
|
|
});
|
|
|
|
api.post(`${OWNER_PAGE_DATA_API_PREFIX}/: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.patch(`${OWNER_PAGE_DATA_API_PREFIX}/: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(`${OWNER_PAGE_DATA_API_PREFIX}/: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 未启用');
|
|
const password = String(req.body?.password ?? '').trim();
|
|
if (!password) return sendError(res, req, 400, 'invalid_request', '缺少页面口令');
|
|
try {
|
|
const auth = await publicService.authenticate(req.params.pageId, password, req);
|
|
return sendData(res, req, auth);
|
|
} catch (error) {
|
|
return handlePublicError(res, req, sendError, error);
|
|
}
|
|
});
|
|
|
|
api.get('/public/pages/:pageId/data/:dataset', async (req, res) => {
|
|
const publicService = getPageDataPublicService?.();
|
|
if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
|
try {
|
|
const result = await publicService.listRows(req.params.pageId, req.params.dataset, req, {
|
|
limit: parsePositiveInt(req.query.limit, undefined),
|
|
offset: parsePositiveInt(req.query.offset, 0),
|
|
orderBy: req.query.order_by,
|
|
orderDir: req.query.order_dir,
|
|
});
|
|
return sendData(res, req, {
|
|
dataset: result.dataset.name,
|
|
rows: result.rows,
|
|
limit: result.limit,
|
|
offset: result.offset,
|
|
});
|
|
} catch (error) {
|
|
return handlePublicError(res, req, sendError, error);
|
|
}
|
|
});
|
|
|
|
api.get('/public/pages/:pageId/data/:dataset/schema', async (req, res) => {
|
|
const publicService = getPageDataPublicService?.();
|
|
if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
|
try {
|
|
const schema = await publicService.getSchema(req.params.pageId, req.params.dataset, req);
|
|
return sendData(res, req, schema);
|
|
} catch (error) {
|
|
return handlePublicError(res, req, sendError, error);
|
|
}
|
|
});
|
|
|
|
api.get('/public/pages/:pageId/data/:dataset/stats', async (req, res) => {
|
|
const publicService = getPageDataPublicService?.();
|
|
if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
|
try {
|
|
const stats = await publicService.getStats(req.params.pageId, req.params.dataset, req);
|
|
return sendData(res, req, stats);
|
|
} catch (error) {
|
|
return handlePublicError(res, req, sendError, error);
|
|
}
|
|
});
|
|
|
|
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, { 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);
|
|
return sendData(res, req, { dataset: result.dataset.name, row: result.row }, 201);
|
|
} catch (error) {
|
|
return handlePublicError(res, req, sendError, error);
|
|
}
|
|
});
|
|
|
|
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, { stripMeta: true });
|
|
if (!payload) return sendError(res, req, 400, 'invalid_request', '更新数据必须是 JSON 对象');
|
|
try {
|
|
const result = await publicService.updateRow(
|
|
req.params.pageId,
|
|
req.params.dataset,
|
|
req.params.rowId,
|
|
req,
|
|
payload,
|
|
);
|
|
return sendData(res, req, { dataset: result.dataset.name, row: result.row });
|
|
} catch (error) {
|
|
return handlePublicError(res, req, sendError, error);
|
|
}
|
|
});
|
|
|
|
api.delete('/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 未启用');
|
|
try {
|
|
const result = await publicService.deleteRow(
|
|
req.params.pageId,
|
|
req.params.dataset,
|
|
req.params.rowId,
|
|
req,
|
|
);
|
|
return sendData(res, req, result);
|
|
} catch (error) {
|
|
return handlePublicError(res, req, sendError, error);
|
|
}
|
|
});
|
|
|
|
// Do not retain a compatibility alias. It would keep the forbidden API
|
|
// usable from generated pages and turn a future prompt regression into a
|
|
// production data-access bug. Owner UI has moved to /admin/page-data.
|
|
api.use('/page-data', (req, res) =>
|
|
sendError(
|
|
res,
|
|
req,
|
|
410,
|
|
'legacy_page_data_api_removed',
|
|
'旧版 Page Data API 已移除;公开页面请使用 /api/public/pages/:pageId/data,后台请使用 /api/admin/page-data。',
|
|
),
|
|
);
|
|
}
|