73b308af0a
Prevent false-positive Finish Guard passes and sync-created fake bindings by assessing publication, policy, dataset, injection, and insert smoke before H5 delivery. Co-authored-by: Cursor <cursoragent@cursor.com>
319 lines
9.1 KiB
JavaScript
319 lines
9.1 KiB
JavaScript
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { htmlUsesPageDataApi } from './page-data-html-detect.mjs';
|
|
import { normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
|
|
|
|
const PUBLIC_HTML_SKIP_DIRS = new Set([
|
|
'.tmp-images',
|
|
'assets',
|
|
'images',
|
|
'shared',
|
|
'node_modules',
|
|
]);
|
|
|
|
function normalizePublicHtmlPath(relativePath) {
|
|
return normalizeWorkspaceRelativePath(relativePath);
|
|
}
|
|
|
|
function titleFromRelativePath(relativePath) {
|
|
const basename = path.basename(String(relativePath ?? ''), '.html');
|
|
return basename.replace(/[-_]+/g, ' ').trim() || '生成的页面';
|
|
}
|
|
|
|
function extractHtmlTitle(content) {
|
|
const match = String(content).match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
return match?.[1]?.trim() ?? '';
|
|
}
|
|
|
|
function extractHtmlSummary(content) {
|
|
const match = String(content).match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i);
|
|
return match?.[1]?.trim().slice(0, 1000) ?? '';
|
|
}
|
|
|
|
async function findPageByWorkspaceRelativePath(pool, userId, relativePath) {
|
|
const [rows] = await pool.query(
|
|
`SELECT p.id, p.updated_at, pv.version_no
|
|
FROM h5_page_records p
|
|
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
|
|
WHERE p.user_id = ?
|
|
AND p.status <> 'deleted'
|
|
AND (
|
|
p.workspace_relative_path = ?
|
|
OR (
|
|
(p.workspace_relative_path IS NULL OR p.workspace_relative_path = '')
|
|
AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ?
|
|
)
|
|
)
|
|
ORDER BY p.updated_at DESC, p.id DESC
|
|
LIMIT 1`,
|
|
[userId, relativePath, relativePath],
|
|
);
|
|
const row = rows[0];
|
|
if (!row) return null;
|
|
return {
|
|
id: row.id,
|
|
updatedAt: Number(row.updated_at ?? 0),
|
|
versionNo: Number(row.version_no ?? 0) || null,
|
|
};
|
|
}
|
|
|
|
function parseJsonColumn(value, fallback = {}) {
|
|
if (value == null || value === '') return { ...fallback };
|
|
if (typeof value === 'object' && !Buffer.isBuffer(value)) return value;
|
|
try {
|
|
return JSON.parse(String(value));
|
|
} catch {
|
|
return { ...fallback };
|
|
}
|
|
}
|
|
|
|
async function loadIndexedWorkspacePages(pool, userId) {
|
|
const [rows] = await pool.query(
|
|
`SELECT p.id, p.updated_at, p.source_asset_id, p.workspace_relative_path,
|
|
pv.version_no, pv.source_snapshot_json
|
|
FROM h5_page_records p
|
|
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
|
|
WHERE p.user_id = ? AND p.status <> 'deleted'`,
|
|
[userId],
|
|
);
|
|
const byPath = new Map();
|
|
for (const row of rows) {
|
|
const snapshot = parseJsonColumn(row.source_snapshot_json);
|
|
const relativePath =
|
|
normalizePublicHtmlPath(row.workspace_relative_path) ??
|
|
normalizePublicHtmlPath(snapshot.relative_path);
|
|
if (relativePath) {
|
|
byPath.set(relativePath, {
|
|
id: row.id,
|
|
updatedAt: Number(row.updated_at ?? 0),
|
|
versionNo: Number(row.version_no ?? 0) || null,
|
|
});
|
|
}
|
|
if (row.source_asset_id && relativePath) {
|
|
byPath.set(`asset:${row.source_asset_id}`, {
|
|
id: row.id,
|
|
updatedAt: Number(row.updated_at ?? 0),
|
|
versionNo: Number(row.version_no ?? 0) || null,
|
|
});
|
|
}
|
|
}
|
|
return byPath;
|
|
}
|
|
|
|
async function listPublishPublicHtmlFiles(publishDir) {
|
|
const publicDir = path.join(publishDir, 'public');
|
|
const files = [];
|
|
|
|
async function walk(absDir, relWithinPublic) {
|
|
let entries;
|
|
try {
|
|
entries = await fs.readdir(absDir, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
if (entry.name.startsWith('.') || PUBLIC_HTML_SKIP_DIRS.has(entry.name)) continue;
|
|
const absPath = path.join(absDir, entry.name);
|
|
const relPath = relWithinPublic ? `${relWithinPublic}/${entry.name}` : entry.name;
|
|
if (entry.isDirectory()) {
|
|
await walk(absPath, relPath);
|
|
continue;
|
|
}
|
|
if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.html')) continue;
|
|
const stat = await fs.stat(absPath);
|
|
files.push({
|
|
relativePath: normalizePublicHtmlPath(`public/${relPath}`),
|
|
absolutePath: absPath,
|
|
mtimeMs: stat.mtimeMs,
|
|
});
|
|
}
|
|
}
|
|
|
|
await walk(publicDir, '');
|
|
return files;
|
|
}
|
|
|
|
async function upsertWorkspaceHtmlPage({
|
|
pool,
|
|
pageService,
|
|
userId,
|
|
indexedPages,
|
|
relativePath,
|
|
content,
|
|
assetId = null,
|
|
sourceMtimeMs = null,
|
|
}) {
|
|
const title = extractHtmlTitle(content) || titleFromRelativePath(relativePath);
|
|
const summary = extractHtmlSummary(content);
|
|
let existing =
|
|
indexedPages.get(relativePath) ??
|
|
(assetId ? indexedPages.get(`asset:${assetId}`) : null);
|
|
|
|
if (!existing && pool) {
|
|
existing = await findPageByWorkspaceRelativePath(pool, userId, relativePath);
|
|
if (existing) {
|
|
indexedPages.set(relativePath, existing);
|
|
if (assetId) indexedPages.set(`asset:${assetId}`, existing);
|
|
}
|
|
}
|
|
|
|
const pageInput = {
|
|
title,
|
|
summary,
|
|
content,
|
|
contentFormat: 'html',
|
|
pageType: 'html',
|
|
templateId: 'editorial',
|
|
categoryCode: 'draft',
|
|
};
|
|
const snapshot = {
|
|
...(assetId ? { source_asset_id: assetId } : {}),
|
|
source_category: 'public',
|
|
content_mode: 'static_html',
|
|
relative_path: relativePath,
|
|
auto_synced: true,
|
|
};
|
|
|
|
if (existing) {
|
|
if (sourceMtimeMs != null && existing.updatedAt >= sourceMtimeMs) {
|
|
return 'skipped';
|
|
}
|
|
await pageService.updatePage(userId, existing.id, {
|
|
...pageInput,
|
|
expectedVersion: existing.versionNo ?? undefined,
|
|
changeNote: '同步工作区页面更新',
|
|
}, {
|
|
snapshot,
|
|
});
|
|
indexedPages.set(relativePath, {
|
|
id: existing.id,
|
|
updatedAt: Date.now(),
|
|
versionNo: existing.versionNo != null ? existing.versionNo + 1 : null,
|
|
});
|
|
return 'updated';
|
|
}
|
|
|
|
const page = await pageService.createFromChat(userId, pageInput, {
|
|
...(assetId ? { assetId } : {}),
|
|
snapshot,
|
|
createdAt:
|
|
sourceMtimeMs != null && Number.isFinite(Number(sourceMtimeMs))
|
|
? Math.floor(Number(sourceMtimeMs))
|
|
: undefined,
|
|
});
|
|
indexedPages.set(relativePath, { id: page.id, updatedAt: Date.now() });
|
|
if (assetId) indexedPages.set(`asset:${assetId}`, { id: page.id, updatedAt: Date.now() });
|
|
return 'created';
|
|
}
|
|
|
|
export async function syncGeneratedPagesFromPublicAssets({
|
|
pool,
|
|
pageService,
|
|
assetService,
|
|
userId,
|
|
publishDir = null,
|
|
syncWorkspaceAssets = null,
|
|
} = {}) {
|
|
if (!pool || !pageService || !userId) {
|
|
return { created: 0, skipped: 0, updated: 0 };
|
|
}
|
|
|
|
if (typeof syncWorkspaceAssets === 'function') {
|
|
await syncWorkspaceAssets(userId, { categoryCode: 'public' }).catch(() => {});
|
|
}
|
|
|
|
const indexedPages = await loadIndexedWorkspacePages(pool, userId);
|
|
let created = 0;
|
|
let updated = 0;
|
|
let skipped = 0;
|
|
|
|
if (publishDir) {
|
|
const htmlFiles = await listPublishPublicHtmlFiles(publishDir);
|
|
for (const file of htmlFiles) {
|
|
try {
|
|
const content = await fs.readFile(file.absolutePath, 'utf8');
|
|
if (!content.trim()) {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
if (htmlUsesPageDataApi(content)) {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
const result = await upsertWorkspaceHtmlPage({
|
|
pool,
|
|
pageService,
|
|
userId,
|
|
indexedPages,
|
|
relativePath: file.relativePath,
|
|
content,
|
|
sourceMtimeMs: file.mtimeMs,
|
|
});
|
|
if (result === 'created') created += 1;
|
|
else if (result === 'updated') updated += 1;
|
|
else skipped += 1;
|
|
} catch (error) {
|
|
console.warn(
|
|
`[MindSpace] page sync failed for ${file.relativePath}:`,
|
|
error?.message ?? error,
|
|
);
|
|
skipped += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!assetService) {
|
|
return { created, updated, skipped };
|
|
}
|
|
|
|
const [rows] = await pool.query(
|
|
`SELECT a.id, a.display_name, a.original_filename, a.updated_at
|
|
FROM h5_assets a
|
|
JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
|
|
WHERE a.user_id = ?
|
|
AND c.category_code = 'public'
|
|
AND a.mime_type = 'text/html'
|
|
AND a.status = 'ready'
|
|
ORDER BY a.updated_at DESC
|
|
LIMIT 200`,
|
|
[userId],
|
|
);
|
|
|
|
for (const asset of rows) {
|
|
const relativePath = normalizePublicHtmlPath(
|
|
String(asset.original_filename ?? '').includes('/')
|
|
? asset.original_filename
|
|
: `public/${asset.original_filename}`,
|
|
);
|
|
if (indexedPages.has(relativePath)) {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
try {
|
|
const { path: assetPath } = await assetService.readAsset(userId, asset.id);
|
|
const content = await fs.readFile(assetPath, 'utf8');
|
|
const result = await upsertWorkspaceHtmlPage({
|
|
pool,
|
|
pageService,
|
|
userId,
|
|
indexedPages,
|
|
relativePath,
|
|
content,
|
|
assetId: asset.id,
|
|
sourceMtimeMs: Number(asset.updated_at ?? 0),
|
|
});
|
|
if (result === 'created') created += 1;
|
|
else if (result === 'updated') updated += 1;
|
|
else skipped += 1;
|
|
} catch (error) {
|
|
console.warn(
|
|
`[MindSpace] asset page sync failed for ${asset.original_filename}:`,
|
|
error?.message ?? error,
|
|
);
|
|
skipped += 1;
|
|
}
|
|
}
|
|
|
|
return { created, updated, skipped };
|
|
}
|