import crypto from 'node:crypto'; import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; import { UPLOAD_ZONE_CODES, resolveUserWorkspaceRoot, resolveZoneDir, } from './user-space.mjs'; import { assetInternals } from './mindspace-assets.mjs'; import { runBasicFileScan } from './mindspace-scan.mjs'; const SKIP_FILENAMES = new Set(['index.html', '.tkmindhints', '.goosehints', '.ls_output']); function asNumber(value) { return Number(value ?? 0); } export function shouldSyncWorkspaceFilename(filename) { const normalized = String(filename ?? '').trim(); if (!normalized || normalized.startsWith('.')) return false; if (SKIP_FILENAMES.has(normalized)) return false; if (normalized.endsWith('.thumbnail.svg')) return false; if (normalized.endsWith('.sh')) return false; return assetInternals.expectedMimeType(normalized) !== null; } export async function listWorkspaceZoneFiles(workspaceRoot, categoryCode) { if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return []; const zoneDir = resolveZoneDir(workspaceRoot, categoryCode); let entries; try { entries = await fsPromises.readdir(zoneDir, { withFileTypes: true }); } catch { return []; } const files = []; for (const entry of entries) { if (!entry.isFile()) continue; if (!shouldSyncWorkspaceFilename(entry.name)) continue; const absolutePath = path.join(zoneDir, entry.name); const stat = await fsPromises.stat(absolutePath); files.push({ filename: entry.name, absolutePath, sizeBytes: stat.size, mtimeMs: stat.mtimeMs, }); } return files; } export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes, idFactory }) { const absoluteStoragePath = (storageKey) => { const resolved = path.resolve(storageRoot, storageKey); if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path.sep}`)) { throw new Error('存储路径越界'); } return resolved; }; const loadExistingAssets = async (userId, categoryId) => { const [rows] = await pool.query( `SELECT a.id, a.original_filename, a.checksum, a.size_bytes, a.current_version_id, a.status FROM h5_assets a WHERE a.user_id = ? AND a.category_id = ? AND a.status <> 'deleted'`, [userId, categoryId], ); return new Map(rows.map((row) => [row.original_filename, row])); }; /** Skip re-importing workspace files the user already deleted (checksum unchanged). */ const loadDeletedWorkspaceChecksums = async (userId, categoryId) => { const [rows] = await pool.query( `SELECT original_filename, checksum FROM h5_assets WHERE user_id = ? AND category_id = ? AND status = 'deleted' AND source_type = 'workspace'`, [userId, categoryId], ); const byFilename = new Map(); for (const row of rows) { const checksums = byFilename.get(row.original_filename) ?? new Set(); checksums.add(row.checksum); byFilename.set(row.original_filename, checksums); } return byFilename; }; const importWorkspaceFile = async (userId, category, file, buffer) => { const conn = await pool.getConnection(); let finalPath; try { await conn.beginTransaction(); const [spaces] = await conn.query( `SELECT id, quota_bytes, used_bytes, reserved_bytes, status FROM h5_user_spaces WHERE id = ? AND user_id = ? LIMIT 1 FOR UPDATE`, [category.space_id, userId], ); const space = spaces[0]; if (!space || space.status !== 'active') { throw Object.assign(new Error('用户空间不可用'), { code: 'space_unavailable' }); } const available = asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes); if (available < buffer.length) { throw Object.assign(new Error('剩余空间不足'), { code: 'quota_exceeded' }); } const detectedMimeType = assetInternals.detectMimeType(buffer, file.filename); if (!detectedMimeType) { throw Object.assign(new Error('无法确认文件类型'), { code: 'unsupported_file_type' }); } const scan = runBasicFileScan(buffer, { filename: file.filename, mimeType: detectedMimeType, }); const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined'; const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked'; const checksum = crypto.createHash('sha256').update(buffer).digest('hex'); const assetId = idFactory(); const versionId = idFactory(); const finalStorageKey = path.posix.join( 'users', userId, 'assets', assetId, 'versions', versionId, ); finalPath = absoluteStoragePath(finalStorageKey); await fsPromises.mkdir(path.dirname(finalPath), { recursive: true }); await fsPromises.writeFile(finalPath, buffer, { flag: 'wx' }); const now = Date.now(); const visibility = category.category_code === 'public' ? 'public_candidate' : 'private'; await conn.query( `INSERT INTO h5_assets (id, user_id, space_id, category_id, asset_type, mime_type, original_filename, display_name, current_version_id, size_bytes, checksum, risk_level, visibility, status, source_type, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'workspace', ?, ?)`, [ assetId, userId, category.space_id, category.id, assetInternals.assetTypeForMime(detectedMimeType), detectedMimeType, file.filename, path.basename(file.filename, path.extname(file.filename)) || file.filename, versionId, buffer.length, checksum, scan.riskLevel, visibility, assetStatus, now, now, ], ); await conn.query( `INSERT INTO h5_asset_versions (id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type, created_by, change_note, scan_status, created_at) VALUES (?, ?, 1, ?, ?, ?, ?, ?, '从工作区同步', ?, ?)`, [ versionId, assetId, finalStorageKey, buffer.length, checksum, detectedMimeType, userId, versionScanStatus, now, ], ); await conn.query( `UPDATE h5_user_spaces SET used_bytes = used_bytes + ?, updated_at = ? WHERE id = ? AND user_id = ?`, [buffer.length, now, category.space_id, userId], ); await conn.commit(); return { action: 'imported', assetId, filename: file.filename, checksum }; } catch (error) { await conn.rollback(); if (finalPath) await fsPromises.rm(finalPath, { force: true }).catch(() => {}); throw error; } finally { conn.release(); } }; const updateWorkspaceFile = async (userId, category, existing, file, buffer) => { const conn = await pool.getConnection(); let finalPath; try { await conn.beginTransaction(); const [spaces] = await conn.query( `SELECT id, quota_bytes, used_bytes, reserved_bytes, status FROM h5_user_spaces WHERE id = ? AND user_id = ? LIMIT 1 FOR UPDATE`, [category.space_id, userId], ); const space = spaces[0]; if (!space || space.status !== 'active') { throw Object.assign(new Error('用户空间不可用'), { code: 'space_unavailable' }); } const sizeDelta = buffer.length - asNumber(existing.size_bytes); const available = asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes); if (sizeDelta > 0 && available < sizeDelta) { throw Object.assign(new Error('剩余空间不足'), { code: 'quota_exceeded' }); } const detectedMimeType = assetInternals.detectMimeType(buffer, file.filename); if (!detectedMimeType) { throw Object.assign(new Error('无法确认文件类型'), { code: 'unsupported_file_type' }); } const scan = runBasicFileScan(buffer, { filename: file.filename, mimeType: detectedMimeType, }); const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined'; const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked'; const checksum = crypto.createHash('sha256').update(buffer).digest('hex'); const [versionRows] = await conn.query( `SELECT COALESCE(MAX(version_no), 0) AS max_version FROM h5_asset_versions WHERE asset_id = ?`, [existing.id], ); const versionNo = asNumber(versionRows[0]?.max_version) + 1; const versionId = idFactory(); const finalStorageKey = path.posix.join( 'users', userId, 'assets', existing.id, 'versions', versionId, ); finalPath = absoluteStoragePath(finalStorageKey); await fsPromises.mkdir(path.dirname(finalPath), { recursive: true }); await fsPromises.writeFile(finalPath, buffer, { flag: 'wx' }); const now = Date.now(); await conn.query( `UPDATE h5_assets SET current_version_id = ?, size_bytes = ?, checksum = ?, mime_type = ?, asset_type = ?, risk_level = ?, status = ?, updated_at = ? WHERE id = ? AND user_id = ?`, [ versionId, buffer.length, checksum, detectedMimeType, assetInternals.assetTypeForMime(detectedMimeType), scan.riskLevel, assetStatus, now, existing.id, userId, ], ); await conn.query( `INSERT INTO h5_asset_versions (id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type, created_by, change_note, scan_status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '工作区文件更新', ?, ?)`, [ versionId, existing.id, versionNo, finalStorageKey, buffer.length, checksum, detectedMimeType, userId, versionScanStatus, now, ], ); if (sizeDelta !== 0) { await conn.query( `UPDATE h5_user_spaces SET used_bytes = GREATEST(0, used_bytes + ?), updated_at = ? WHERE id = ? AND user_id = ?`, [sizeDelta, now, category.space_id, userId], ); } await conn.commit(); return { action: 'updated', assetId: existing.id, filename: file.filename, checksum }; } catch (error) { await conn.rollback(); if (finalPath) await fsPromises.rm(finalPath, { force: true }).catch(() => {}); throw error; } finally { conn.release(); } }; const syncCategory = async (userId, categoryCode) => { if (!h5Root) return { imported: 0, updated: 0, skipped: 0 }; const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId }); const files = await listWorkspaceZoneFiles(workspaceRoot, categoryCode); const [categories] = await pool.query( `SELECT c.id, c.space_id, c.category_code FROM h5_space_categories c WHERE c.user_id = ? AND c.category_code = ? LIMIT 1`, [userId, categoryCode], ); const category = categories[0]; if (!category) return { imported: 0, updated: 0, skipped: 0 }; const existingByName = await loadExistingAssets(userId, category.id); const deletedWorkspaceChecksums = await loadDeletedWorkspaceChecksums(userId, category.id); let imported = 0; let updated = 0; let skipped = 0; for (const file of files) { if (file.sizeBytes <= 0 || file.sizeBytes > maxFileBytes) { skipped += 1; continue; } const buffer = await fsPromises.readFile(file.absolutePath); const checksum = crypto.createHash('sha256').update(buffer).digest('hex'); if (deletedWorkspaceChecksums.get(file.filename)?.has(checksum)) { skipped += 1; continue; } const existing = existingByName.get(file.filename); if (existing?.checksum === checksum) { skipped += 1; continue; } if (existing) { await updateWorkspaceFile(userId, category, existing, file, buffer); existing.checksum = checksum; updated += 1; } else { await importWorkspaceFile(userId, category, file, buffer); existingByName.set(file.filename, { checksum }); imported += 1; } } return { imported, updated, skipped }; }; const syncUserWorkspace = async (userId, { categoryCode } = {}) => { const codes = categoryCode ? [categoryCode] : UPLOAD_ZONE_CODES; let imported = 0; let updated = 0; let skipped = 0; for (const code of codes) { const result = await syncCategory(userId, code); imported += result.imported; updated += result.updated; skipped += result.skipped; } return { imported, updated, skipped }; }; return { syncUserWorkspace, syncCategory, listWorkspaceZoneFiles }; } export function startWorkspaceAssetSyncWatcher({ publishRoot, syncUserWorkspaceByDirKey }) { if (!publishRoot || !syncUserWorkspaceByDirKey) return () => {}; const pending = new Map(); const schedule = (dirKey, categoryCode) => { const key = `${dirKey}:${categoryCode ?? 'all'}`; const existing = pending.get(key); if (existing) clearTimeout(existing); pending.set( key, setTimeout(() => { pending.delete(key); void syncUserWorkspaceByDirKey(dirKey, categoryCode ? { categoryCode } : {}).catch(() => {}); }, 600), ); }; const watchZoneDir = (dirKey, zoneDir, categoryCode) => { try { fs.watch(zoneDir, (_event, filename) => { if (!filename || !shouldSyncWorkspaceFilename(filename)) return; schedule(dirKey, categoryCode); }); } catch { // ignore unsupported watch targets } }; const attachUser = async (dirKey) => { const userDir = path.join(publishRoot, dirKey); try { await fsPromises.access(userDir); } catch { return; } for (const categoryCode of UPLOAD_ZONE_CODES) { watchZoneDir(dirKey, resolveZoneDir(userDir, categoryCode), categoryCode); } }; for (const entry of fs.readdirSync(publishRoot, { withFileTypes: true })) { if (!entry.isDirectory() || entry.name === 'wiki' || entry.name.startsWith('.')) continue; void attachUser(entry.name); } try { fs.watch(publishRoot, (_event, filename) => { if (!filename) return; const userDir = path.join(publishRoot, filename); if (fs.existsSync(userDir) && fs.statSync(userDir).isDirectory()) { void attachUser(filename); } }); } catch { // ignore } return () => { for (const timer of pending.values()) clearTimeout(timer); pending.clear(); }; }