Files
memind/mindspace-assets.mjs
T

1138 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs';
import { runBasicFileScan } from './mindspace-scan.mjs';
import { extractAttachmentText } from './mindspace-attachment-text.mjs';
import {
assetThumbnailKey,
ensureHtmlThumbnail,
scheduleHtmlThumbnail,
} from './mindspace-thumbnails.mjs';
import { removeZoneMirror, resolveUserWorkspaceRoot } from './user-space.mjs';
import { PUBLIC_ZONE_DIR, PUBLISH_ROOT_DIR, resolvePublicBaseUrl } from './user-publish.mjs';
import { canPreviewAsset, renderAssetPreviewHtml } from './mindspace-asset-preview.mjs';
import { createWorkspaceAssetSync } from './mindspace-workspace-sync.mjs';
const ALLOWED_EXTENSIONS = new Map([
['.txt', 'text/plain'],
['.md', 'text/markdown'],
['.csv', 'text/csv'],
['.pdf', 'application/pdf'],
['.png', 'image/png'],
['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'],
['.webp', 'image/webp'],
['.doc', 'application/msword'],
['.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
['.xls', 'application/vnd.ms-excel'],
['.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
['.ppt', 'application/vnd.ms-powerpoint'],
['.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
['.html', 'text/html'],
['.htm', 'text/html'],
]);
const MAX_IMAGE_UPLOAD_BYTES = 2 * 1024 * 1024;
const PUBLIC_TEMP_IMAGE_DIR = '.tmp-images';
const PUBLIC_IMAGE_EXTENSIONS = new Map([
['image/png', '.png'],
['image/jpeg', '.jpg'],
['image/webp', '.webp'],
]);
function asNumber(value) {
return Number(value ?? 0);
}
function normalizeFilename(filename) {
const normalized = String(filename ?? '').normalize('NFKC').trim();
if (
!normalized ||
normalized === '.' ||
normalized === '..' ||
normalized.includes('/') ||
normalized.includes('\\') ||
normalized.includes('\0') ||
/[\u0000-\u001f\u007f]/.test(normalized)
) {
throw Object.assign(new Error('文件名无效'), { code: 'invalid_filename' });
}
return normalized.slice(0, 255);
}
function expectedMimeType(filename) {
return ALLOWED_EXTENSIONS.get(path.extname(filename).toLowerCase()) ?? null;
}
function extractImageDimensions(buffer, mimeType) {
if (mimeType === 'image/png' && buffer.length >= 24) {
const width = buffer.readUInt32BE(16);
const height = buffer.readUInt32BE(20);
return { width, height };
}
if (mimeType === 'image/jpeg' && buffer.length >= 2) {
let offset = 2;
while (offset < buffer.length - 9) {
if (buffer[offset] !== 0xff) break;
const marker = buffer[offset + 1];
if (marker === 0xd9) break;
if (marker >= 0xd0 && marker <= 0xd8) {
offset += 2;
continue;
}
const segmentLength = buffer.readUInt16BE(offset + 2);
if (marker === 0xc0 || marker === 0xc1 || marker === 0xc2) {
const height = buffer.readUInt16BE(offset + 5);
const width = buffer.readUInt16BE(offset + 7);
return { width, height };
}
offset += segmentLength + 2;
}
}
if (mimeType === 'image/webp' && buffer.length >= 30) {
const width = buffer.readUInt32LE(24) + 1;
const height = buffer.readUInt32LE(28) + 1;
return { width, height };
}
return null;
}
function validateImagePixels(buffer, mimeType) {
if (!mimeType?.startsWith('image/')) return null;
const dims = extractImageDimensions(buffer, mimeType);
if (!dims) return { ok: true };
const { width, height } = dims;
const megapixels = (width * height) / 1_000_000;
if (megapixels > 25) {
throw Object.assign(
new Error(`图片像素超过限制(${megapixels.toFixed(1)}MP > 25MP`),
{ code: 'image_pixel_exceeded' }
);
}
return { ok: true, width, height };
}
function detectMimeType(buffer, filename) {
const head = buffer.subarray(0, 256).toString('utf8').trimStart().toLowerCase();
if (head.startsWith('<!doctype html') || head.startsWith('<html')) return 'text/html';
if (buffer.subarray(0, 5).toString('ascii') === '%PDF-') return 'application/pdf';
if (buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
return 'image/png';
}
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg';
if (
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return 'image/webp';
}
const extensionMime = expectedMimeType(filename);
if (buffer.subarray(0, 2).toString('ascii') === 'PK') return extensionMime;
if (extensionMime?.startsWith('text/')) return extensionMime;
if (['.doc', '.xls', '.ppt'].includes(path.extname(filename).toLowerCase())) {
return extensionMime;
}
return null;
}
function assetTypeForMime(mimeType) {
if (mimeType.startsWith('image/')) return 'image';
if (mimeType === 'text/html') return 'html';
if (mimeType === 'application/pdf') return 'pdf';
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'excel';
if (mimeType.includes('word')) return 'word';
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return 'ppt';
if (mimeType === 'text/markdown') return 'markdown';
return 'file';
}
function visibilityForCategory(categoryCode) {
return categoryCode === 'public' ? 'public_candidate' : 'private';
}
function isPublicImageAsset(row) {
return row?.category_code === 'public' && String(row?.mime_type ?? '').startsWith('image/');
}
function publicTempImageFilename(assetId, mimeType, fallbackFilename = '') {
const fallbackExtension = path.extname(String(fallbackFilename)).toLowerCase();
const extension = PUBLIC_IMAGE_EXTENSIONS.get(mimeType) || fallbackExtension || '.img';
return `${assetId}${extension}`;
}
function publicTempImageStorageKey(userId, assetId, mimeType, fallbackFilename = '') {
return path.posix.join(
'users',
userId,
PUBLIC_ZONE_DIR,
PUBLIC_TEMP_IMAGE_DIR,
publicTempImageFilename(assetId, mimeType, fallbackFilename),
);
}
function publicTempImageUrl(userId, assetId, mimeType, fallbackFilename = '') {
const filename = publicTempImageFilename(assetId, mimeType, fallbackFilename);
return `${resolvePublicBaseUrl()}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(userId)}/${PUBLIC_ZONE_DIR}/${PUBLIC_TEMP_IMAGE_DIR}/${encodeURIComponent(filename)}`;
}
function assetResponse(row) {
return {
id: row.id,
categoryId: row.category_id,
categoryCode: row.category_code,
assetType: row.asset_type,
mimeType: row.mime_type,
filename: row.original_filename,
displayName: row.display_name,
sizeBytes: asNumber(row.size_bytes),
checksum: row.checksum,
riskLevel: row.risk_level,
visibility: row.visibility,
status: row.status,
scanStatus: row.scan_status ?? 'passed',
sourceType: row.source_type,
hasThumbnail: Boolean(row.has_thumbnail ?? row.mime_type === 'text/html'),
publicUrl: isPublicImageAsset(row)
? publicTempImageUrl(row.user_id, row.id, row.mime_type, row.original_filename)
: null,
sourcePageId: row.source_page_id ?? null,
createdAt: asNumber(row.created_at),
updatedAt: asNumber(row.updated_at),
};
}
function assertAssetDownloadable(asset) {
if (asset.status === 'quarantined') {
throw Object.assign(new Error('文件尚未通过安全检查'), { code: 'security_scan_required' });
}
if (asset.scan_status === 'blocked') {
throw Object.assign(new Error('文件存在安全风险,暂不可下载'), { code: 'security_risk_blocked' });
}
if (asset.scan_status === 'pending') {
throw Object.assign(new Error('文件尚未完成安全检查'), { code: 'security_scan_required' });
}
}
export function validateUploadRequest({ filename, sizeBytes, maxFileBytes = DEFAULT_MAX_FILE_BYTES }) {
const normalizedFilename = normalizeFilename(filename);
const normalizedSize = Number(sizeBytes);
if (!Number.isSafeInteger(normalizedSize) || normalizedSize <= 0) {
throw Object.assign(new Error('文件不能为空'), { code: 'invalid_file_size' });
}
if (normalizedSize > maxFileBytes) {
throw Object.assign(new Error('文件超过单文件大小限制'), { code: 'file_too_large' });
}
const mimeType = expectedMimeType(normalizedFilename);
if (!mimeType) {
throw Object.assign(new Error('暂不支持该文件类型'), { code: 'unsupported_file_type' });
}
if (mimeType.startsWith('image/') && normalizedSize > MAX_IMAGE_UPLOAD_BYTES) {
throw Object.assign(new Error('图片文件超过单文件大小限制'), { code: 'file_too_large' });
}
return { filename: normalizedFilename, sizeBytes: normalizedSize, expectedMimeType: mimeType };
}
export function createAssetService(pool, options = {}) {
const storageRoot = path.resolve(options.storageRoot ?? path.join(process.cwd(), 'data', 'mindspace'));
const h5Root = options.h5Root ? path.resolve(options.h5Root) : null;
const maxFileBytes = Number(options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES);
const uploadTtlMs = Number(options.uploadTtlMs ?? 30 * 60 * 1000);
const idFactory = options.idFactory ?? (() => crypto.randomUUID());
const workspaceSync = createWorkspaceAssetSync({
pool,
storageRoot,
h5Root,
maxFileBytes,
idFactory,
});
const mirrorToUserWorkspace = async (userId, { categoryCode, filename, sourcePath }) => {
if (!h5Root) return null;
return mirrorAssetToZone({
workspaceRoot: resolveUserWorkspaceRoot(h5Root, { id: userId }),
categoryCode,
filename,
sourcePath,
});
};
const writePublicTempImageMirror = async (userId, assetId, mimeType, fallbackFilename, sourcePath) => {
if (!h5Root) return null;
const filename = publicTempImageFilename(assetId, mimeType, fallbackFilename);
const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId });
const target = path.join(workspaceRoot, PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR, filename);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.copyFile(sourcePath, target);
return target;
};
const removePublicTempImageMirror = async (userId, assetId, mimeType, fallbackFilename) => {
if (!h5Root) return;
const filename = publicTempImageFilename(assetId, mimeType, fallbackFilename);
const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId });
const target = path.join(workspaceRoot, PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR, filename);
await fs.rm(target, { force: true });
};
const absoluteStoragePath = (storageKey) => {
const resolved = path.resolve(storageRoot, storageKey);
if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path.sep}`)) {
throw new Error('存储路径越界');
}
return resolved;
};
const resolveStoragePathCandidates = (storageKey) => {
const normalized = String(storageKey ?? '').replace(/\\/g, '/');
const withoutMd = normalized.endsWith('.md') ? normalized.slice(0, -3) : normalized;
const match = withoutMd.match(/^users\/([^/]+)\/(assets|pages)\/([^/]+)\/(v\d+)$/);
if (!match) return [normalized];
const [, userId, scope, entityId, versionTag] = match;
return [
normalized,
`users/${userId}/${scope}/${entityId}/versions/${versionTag}.md`,
`users/${userId}/${scope}/${entityId}/versions/${versionTag}`,
].filter((candidate, index, list) => list.indexOf(candidate) === index);
};
const resolveReadableStoragePath = async (storageKey) => {
let lastError = null;
for (const candidate of resolveStoragePathCandidates(storageKey)) {
const absolutePath = absoluteStoragePath(candidate);
try {
await fs.stat(absolutePath);
return absolutePath;
} catch (error) {
if (error?.code === 'ENOENT') {
lastError = error;
continue;
}
throw error;
}
}
throw lastError ?? Object.assign(new Error('存储文件不存在'), { code: 'storage_not_found' });
};
const createUpload = async (userId, input) => {
const validated = validateUploadRequest({ ...input, maxFileBytes });
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [categories] = await conn.query(
`SELECT c.id, c.space_id, c.category_code
FROM h5_space_categories c
WHERE c.id = ? AND c.user_id = ?
LIMIT 1
FOR UPDATE`,
[input.categoryId, userId],
);
const category = categories[0];
if (!category) {
throw Object.assign(new Error('分类不存在'), { code: 'category_not_found' });
}
if (!['oa', 'private', 'public'].includes(category.category_code)) {
throw Object.assign(new Error('该分类不允许直接上传'), {
code: 'category_not_uploadable',
});
}
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 < validated.sizeBytes) {
throw Object.assign(new Error('剩余空间不足'), {
code: 'quota_exceeded',
details: { requiredBytes: validated.sizeBytes, availableBytes: Math.max(0, available) },
});
}
const uploadId = idFactory();
const now = Date.now();
const temporaryStorageKey = path.posix.join('users', userId, 'temp', `${uploadId}.upload`);
await conn.query(
`UPDATE h5_user_spaces
SET reserved_bytes = reserved_bytes + ?, updated_at = ?
WHERE id = ? AND user_id = ?`,
[validated.sizeBytes, now, space.id, userId],
);
await conn.query(
`INSERT INTO h5_upload_sessions
(id, user_id, space_id, category_id, filename, expected_size, declared_mime_type,
reserved_bytes, temporary_storage_key, status, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'reserved', ?, ?)`,
[
uploadId,
userId,
space.id,
category.id,
validated.filename,
validated.sizeBytes,
input.declaredMimeType || null,
validated.sizeBytes,
temporaryStorageKey,
now + uploadTtlMs,
now,
],
);
await conn.commit();
return {
id: uploadId,
filename: validated.filename,
expectedSize: validated.sizeBytes,
uploadUrl: `/api/mindspace/v1/uploads/${uploadId}/content`,
expiresAt: now + uploadTtlMs,
reservedBytes: validated.sizeBytes,
};
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
};
const writeUploadContent = async (userId, uploadId, buffer) => {
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
throw Object.assign(new Error('上传内容为空'), { code: 'invalid_file_size' });
}
const [rows] = await pool.query(
`SELECT id, filename, expected_size, temporary_storage_key, status, expires_at
FROM h5_upload_sessions
WHERE id = ? AND user_id = ?
LIMIT 1`,
[uploadId, userId],
);
const upload = rows[0];
if (!upload) throw Object.assign(new Error('上传会话不存在'), { code: 'upload_not_found' });
if (upload.status !== 'reserved') {
throw Object.assign(new Error('上传会话状态无效'), { code: 'invalid_upload_state' });
}
if (asNumber(upload.expires_at) <= Date.now()) {
throw Object.assign(new Error('上传会话已过期'), { code: 'upload_expired' });
}
if (buffer.length !== asNumber(upload.expected_size) || buffer.length > maxFileBytes) {
throw Object.assign(new Error('上传内容大小与预期不一致'), {
code: 'file_size_mismatch',
});
}
const detectedMimeType = detectMimeType(buffer, upload.filename);
if (!detectedMimeType) {
throw Object.assign(new Error('无法确认文件类型'), { code: 'unsupported_file_type' });
}
if (detectedMimeType.startsWith('image/') && buffer.length > MAX_IMAGE_UPLOAD_BYTES) {
throw Object.assign(new Error('图片文件超过单文件大小限制'), { code: 'file_too_large' });
}
if (detectedMimeType.startsWith('image/')) {
validateImagePixels(buffer, detectedMimeType);
}
const target = absoluteStoragePath(upload.temporary_storage_key);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, buffer, { flag: 'wx' }).catch(async (error) => {
if (error?.code !== 'EEXIST') throw error;
await fs.writeFile(target, buffer);
});
const checksum = crypto.createHash('sha256').update(buffer).digest('hex');
await pool.query(
`UPDATE h5_upload_sessions
SET actual_size = ?, detected_mime_type = ?, checksum = ?, status = 'uploaded'
WHERE id = ? AND user_id = ? AND status = 'reserved'`,
[buffer.length, detectedMimeType, checksum, uploadId, userId],
);
return { sizeBytes: buffer.length, mimeType: detectedMimeType, checksum };
};
const completeUpload = async (userId, uploadId) => {
const conn = await pool.getConnection();
let temporaryPath;
let finalPath;
let publicMirror = null;
try {
await conn.beginTransaction();
const [rows] = await conn.query(
`SELECT u.*, c.category_code
FROM h5_upload_sessions u
JOIN h5_space_categories c ON c.id = u.category_id AND c.user_id = u.user_id
WHERE u.id = ? AND u.user_id = ?
LIMIT 1
FOR UPDATE`,
[uploadId, userId],
);
const upload = rows[0];
if (!upload) throw Object.assign(new Error('上传会话不存在'), { code: 'upload_not_found' });
if (upload.status === 'completed' && upload.completed_asset_id) {
const [existing] = await conn.query(
`SELECT a.*, c.category_code
FROM h5_assets a
JOIN h5_space_categories c ON c.id = a.category_id
WHERE a.id = ? AND a.user_id = ?`,
[upload.completed_asset_id, userId],
);
await conn.commit();
return existing[0] ? assetResponse(existing[0]) : null;
}
if (upload.status !== 'uploaded') {
throw Object.assign(new Error('文件内容尚未上传'), { code: 'invalid_upload_state' });
}
if (
asNumber(upload.actual_size) !== asNumber(upload.expected_size) ||
!upload.detected_mime_type ||
!upload.checksum
) {
throw Object.assign(new Error('上传内容不完整'), { code: 'file_size_mismatch' });
}
const assetId = idFactory();
const versionId = idFactory();
temporaryPath = absoluteStoragePath(upload.temporary_storage_key);
const fileBuffer = await fs.readFile(temporaryPath);
const scan = runBasicFileScan(fileBuffer, {
filename: upload.filename,
mimeType: upload.detected_mime_type,
});
const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined';
const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked';
const shouldPublishTempImage =
upload.category_code === 'public' &&
upload.detected_mime_type.startsWith('image/') &&
scan.scanStatus === 'passed';
const finalStorageKey = shouldPublishTempImage
? publicTempImageStorageKey(userId, assetId, upload.detected_mime_type, upload.filename)
: upload.temporary_storage_key;
finalPath = absoluteStoragePath(finalStorageKey);
if (finalStorageKey !== upload.temporary_storage_key) {
await fs.mkdir(path.dirname(finalPath), { recursive: true });
await fs.rename(temporaryPath, finalPath);
}
if (shouldPublishTempImage) {
publicMirror = await writePublicTempImageMirror(
userId,
assetId,
upload.detected_mime_type,
upload.filename,
finalPath,
);
}
const now = Date.now();
const visibility = visibilityForCategory(upload.category_code);
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'upload', ?, ?)`,
[
assetId,
userId,
upload.space_id,
upload.category_id,
assetTypeForMime(upload.detected_mime_type),
upload.detected_mime_type,
upload.filename,
upload.filename,
versionId,
upload.actual_size,
upload.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,
upload.actual_size,
upload.checksum,
upload.detected_mime_type,
userId,
versionScanStatus,
now,
],
);
await conn.query(
`UPDATE h5_user_spaces
SET reserved_bytes = GREATEST(0, reserved_bytes - ?),
used_bytes = used_bytes + ?,
updated_at = ?
WHERE id = ? AND user_id = ?`,
[upload.reserved_bytes, upload.actual_size, now, upload.space_id, userId],
);
await conn.query(
`UPDATE h5_upload_sessions
SET status = 'completed', completed_at = ?, completed_asset_id = ?
WHERE id = ? AND user_id = ?`,
[now, assetId, uploadId, userId],
);
await conn.commit();
return {
id: assetId,
user_id: userId,
categoryId: upload.category_id,
categoryCode: upload.category_code,
assetType: assetTypeForMime(upload.detected_mime_type),
mimeType: upload.detected_mime_type,
filename: upload.filename,
displayName: upload.filename,
sizeBytes: asNumber(upload.actual_size),
checksum: upload.checksum,
riskLevel: scan.riskLevel,
visibility,
status: assetStatus,
scanStatus: versionScanStatus,
sourceType: 'upload',
createdAt: now,
updatedAt: now,
publicUrl: shouldPublishTempImage
? publicTempImageUrl(userId, assetId, upload.detected_mime_type, upload.filename)
: null,
};
} catch (error) {
await conn.rollback();
if (finalPath && finalPath !== temporaryPath) await fs.rm(finalPath, { force: true }).catch(() => {});
if (publicMirror) await fs.rm(publicMirror, { force: true }).catch(() => {});
throw error;
} finally {
conn.release();
}
};
const cancelUpload = async (userId, uploadId) => {
const conn = await pool.getConnection();
let storageKey;
try {
await conn.beginTransaction();
const [rows] = await conn.query(
`SELECT id, space_id, reserved_bytes, temporary_storage_key, status
FROM h5_upload_sessions
WHERE id = ? AND user_id = ?
LIMIT 1
FOR UPDATE`,
[uploadId, userId],
);
const upload = rows[0];
if (!upload) throw Object.assign(new Error('上传会话不存在'), { code: 'upload_not_found' });
if (['completed', 'cancelled', 'expired'].includes(upload.status)) {
await conn.commit();
return { cancelled: upload.status !== 'completed' };
}
storageKey = upload.temporary_storage_key;
const now = Date.now();
await conn.query(
`UPDATE h5_user_spaces
SET reserved_bytes = GREATEST(0, reserved_bytes - ?), updated_at = ?
WHERE id = ? AND user_id = ?`,
[upload.reserved_bytes, now, upload.space_id, userId],
);
await conn.query(
`UPDATE h5_upload_sessions SET status = 'cancelled' WHERE id = ? AND user_id = ?`,
[uploadId, userId],
);
await conn.commit();
if (storageKey) await fs.rm(absoluteStoragePath(storageKey), { force: true });
return { cancelled: true };
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
};
const listAssets = async (userId, { categoryId, categoryCode, syncWorkspace = true } = {}) => {
if (syncWorkspace && categoryCode && ['oa', 'private', 'public'].includes(categoryCode)) {
await workspaceSync.syncUserWorkspace(userId, { categoryCode }).catch(() => {});
}
const filters = [`a.user_id = ?`, `a.status <> 'deleted'`];
const params = [userId];
if (categoryId) {
filters.push(`a.category_id = ?`);
params.push(categoryId);
}
if (categoryCode) {
filters.push(`c.category_code = ?`);
params.push(categoryCode);
}
const [rows] = await pool.query(
`SELECT a.*, c.category_code,
ANY_VALUE(p.id) AS source_page_id
FROM h5_assets a
JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
LEFT JOIN h5_page_versions pv ON pv.bundle_asset_id = a.id
LEFT JOIN h5_page_records p ON p.id = pv.page_id AND p.status <> 'deleted' AND p.user_id = a.user_id
WHERE ${filters.join(' AND ')}
GROUP BY a.id
ORDER BY a.updated_at DESC
LIMIT 100`,
params,
);
return rows.map(assetResponse);
};
const deleteAsset = async (userId, assetId) => {
const conn = await pool.getConnection();
let mirrorCleanup = null;
let storageCleanup = null;
try {
await conn.beginTransaction();
const [rows] = await conn.query(
`SELECT a.id, a.space_id, a.size_bytes, a.status, a.original_filename, a.mime_type,
c.category_code, v.storage_key
FROM h5_assets a
JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
LEFT JOIN h5_asset_versions v ON v.id = a.current_version_id
WHERE a.id = ? AND a.user_id = ?
LIMIT 1
FOR UPDATE`,
[assetId, userId],
);
const asset = rows[0];
if (!asset || asset.status === 'deleted') {
throw Object.assign(new Error('资产不存在'), { code: 'asset_not_found' });
}
const [pageReferences] = await conn.query(
`SELECT p.id, p.title, p.status,
EXISTS(
SELECT 1 FROM h5_publish_records pr
WHERE pr.page_id = p.id AND pr.user_id = p.user_id AND pr.status = 'online'
) AS published_online
FROM h5_page_versions pv
JOIN h5_page_records p ON p.id = pv.page_id AND p.status <> 'deleted'
WHERE pv.content_asset_id = ? OR pv.bundle_asset_id = ?
GROUP BY p.id, p.title, p.status
LIMIT 10`,
[assetId, assetId],
);
if (pageReferences.length > 0) {
throw Object.assign(new Error('资产正在被页面使用,不能删除'), {
code: 'asset_in_use',
details: {
hint: '请打开关联页面并删除;删除页面会自动下线公开链接并删除页面内容资产,之后即可删除原始资料。',
references: pageReferences.map((page) => ({
type: 'page',
id: page.id,
title: page.title,
status: page.status,
publishedOnline: Boolean(page.published_online),
})),
},
});
}
const now = Date.now();
await conn.query(
`UPDATE h5_assets SET status = 'deleted', deleted_at = ?, updated_at = ?
WHERE id = ? AND user_id = ?`,
[now, now, assetId, userId],
);
await conn.query(
`UPDATE h5_user_spaces
SET used_bytes = GREATEST(0, used_bytes - ?), updated_at = ?
WHERE id = ? AND user_id = ?`,
[asset.size_bytes, now, asset.space_id, userId],
);
await conn.commit();
if (h5Root) {
mirrorCleanup = {
id: asset.id,
categoryCode: asset.category_code,
filename: asset.original_filename,
mimeType: asset.mime_type,
};
}
if (asset.storage_key) storageCleanup = asset.storage_key;
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
if (mirrorCleanup) {
try {
removeZoneMirror({
workspaceRoot: resolveUserWorkspaceRoot(h5Root, { id: userId }),
categoryCode: mirrorCleanup.categoryCode,
filename: mirrorCleanup.filename,
});
if (/\.html$/i.test(mirrorCleanup.filename)) {
const thumbName = mirrorCleanup.filename.replace(/\.html$/i, '.thumbnail.svg');
removeZoneMirror({
workspaceRoot: resolveUserWorkspaceRoot(h5Root, { id: userId }),
categoryCode: mirrorCleanup.categoryCode,
filename: thumbName,
});
}
if (mirrorCleanup.categoryCode === 'public' && mirrorCleanup.mimeType?.startsWith?.('image/')) {
await removePublicTempImageMirror(
userId,
mirrorCleanup.id,
mirrorCleanup.mimeType,
mirrorCleanup.filename,
);
}
} catch {
// Best-effort workspace mirror cleanup; DB delete already committed.
}
}
if (storageCleanup) {
try {
await fs.rm(absoluteStoragePath(storageCleanup), { force: true });
} catch {
// Best-effort physical storage cleanup; DB delete already committed.
}
}
return { deleted: true };
};
const readAsset = async (userId, assetId) => {
const [rows] = await pool.query(
`SELECT a.*, c.category_code, v.storage_key, v.scan_status
FROM h5_assets a
JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
JOIN h5_asset_versions v ON v.id = a.current_version_id
WHERE a.id = ? AND a.user_id = ? AND a.status <> 'deleted'
LIMIT 1`,
[assetId, userId],
);
const asset = rows[0];
if (!asset) throw Object.assign(new Error('资产不存在'), { code: 'asset_not_found' });
assertAssetDownloadable(asset);
return {
asset: assetResponse(asset),
path: await resolveReadableStoragePath(asset.storage_key),
};
};
const readPublicAsset = async (assetId) => {
const [rows] = await pool.query(
`SELECT a.*, c.category_code, v.storage_key, v.scan_status
FROM h5_assets a
JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
JOIN h5_asset_versions v ON v.id = a.current_version_id
WHERE a.id = ? AND a.status <> 'deleted'
LIMIT 1`,
[assetId],
);
const asset = rows[0];
if (!asset) throw Object.assign(new Error('资产不存在'), { code: 'asset_not_found' });
assertAssetDownloadable(asset);
return {
asset: assetResponse(asset),
path: await resolveReadableStoragePath(asset.storage_key),
};
};
const createChatAsset = async (
userId,
{ categoryCode, buffer, filename, displayName, sourceType = 'chat' },
) => {
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
throw Object.assign(new Error('资产内容为空'), { code: 'invalid_file_size' });
}
if (buffer.length > maxFileBytes) {
throw Object.assign(new Error('文件超过单文件大小限制'), { code: 'file_too_large' });
}
const normalizedFilename = normalizeFilename(filename);
const detectedMimeType = detectMimeType(buffer, normalizedFilename);
if (!detectedMimeType) {
throw Object.assign(new Error('无法确认文件类型'), { code: 'unsupported_file_type' });
}
if (detectedMimeType.startsWith('image/') && buffer.length > MAX_IMAGE_UPLOAD_BYTES) {
throw Object.assign(new Error('图片文件超过单文件大小限制'), { code: 'file_too_large' });
}
const conn = await pool.getConnection();
let finalPath;
let publicMirror = null;
try {
await conn.beginTransaction();
const [categories] = await conn.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
FOR UPDATE`,
[userId, categoryCode],
);
const category = categories[0];
if (!category) {
throw Object.assign(new Error('分类不存在'), { code: 'category_not_found' });
}
if (!['oa', 'private', 'public'].includes(category.category_code)) {
throw Object.assign(new Error('该分类不允许保存聊天资产'), {
code: 'category_not_uploadable',
});
}
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',
details: { requiredBytes: buffer.length, availableBytes: Math.max(0, available) },
});
}
const assetId = idFactory();
const versionId = idFactory();
const checksum = crypto.createHash('sha256').update(buffer).digest('hex');
const scan = runBasicFileScan(buffer, {
filename: normalizedFilename,
mimeType: detectedMimeType,
});
const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined';
const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked';
const shouldPublishTempImage =
category.category_code === 'public' &&
detectedMimeType.startsWith('image/') &&
scan.scanStatus === 'passed';
const finalStorageKey = shouldPublishTempImage
? publicTempImageStorageKey(userId, assetId, detectedMimeType, normalizedFilename)
: path.posix.join('users', userId, 'assets', assetId, 'versions', versionId);
finalPath = absoluteStoragePath(finalStorageKey);
await fs.mkdir(path.dirname(finalPath), { recursive: true });
await fs.writeFile(finalPath, buffer, { flag: 'wx' });
if (shouldPublishTempImage) {
publicMirror = await writePublicTempImageMirror(
userId,
assetId,
detectedMimeType,
normalizedFilename,
finalPath,
);
}
const now = Date.now();
const visibility = visibilityForCategory(category.category_code);
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
assetId,
userId,
category.space_id,
category.id,
assetTypeForMime(detectedMimeType),
detectedMimeType,
normalizedFilename,
displayName || normalizedFilename,
versionId,
buffer.length,
checksum,
scan.riskLevel,
visibility,
assetStatus,
sourceType,
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();
if (detectedMimeType === 'text/html') {
scheduleHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), buffer.toString('utf8'), {
title: displayName || normalizedFilename,
subtitle: category.category_code.toUpperCase(),
contentStorageKey: finalStorageKey,
});
}
return assetResponse({
id: assetId,
user_id: userId,
category_id: category.id,
category_code: category.category_code,
asset_type: assetTypeForMime(detectedMimeType),
mime_type: detectedMimeType,
original_filename: normalizedFilename,
display_name: displayName || normalizedFilename,
size_bytes: buffer.length,
checksum,
risk_level: scan.riskLevel,
visibility,
status: assetStatus,
scan_status: versionScanStatus,
source_type: sourceType,
created_at: now,
updated_at: now,
has_thumbnail: detectedMimeType === 'text/html',
});
} catch (error) {
await conn.rollback();
if (finalPath) await fs.rm(finalPath, { force: true }).catch(() => {});
if (publicMirror) await fs.rm(publicMirror, { force: true }).catch(() => {});
throw error;
} finally {
conn.release();
}
};
const renderAssetThumbnail = async (userId, assetId) => {
const { asset, path: assetPath } = await readAsset(userId, assetId);
if (asset.mimeType !== 'text/html') {
throw Object.assign(new Error('该资产不支持缩略图'), { code: 'thumbnail_not_supported' });
}
const [versions] = await pool.query(
`SELECT storage_key FROM h5_asset_versions
WHERE asset_id = ? AND version_no = 1
LIMIT 1`,
[assetId],
);
const html = await fs.readFile(assetPath, 'utf8');
return ensureHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), html, {
title: asset.displayName,
subtitle: asset.categoryCode?.toUpperCase?.() ?? 'HTML',
contentStorageKey: versions[0]?.storage_key,
});
};
const renderAssetPreview = async (userId, assetId, { downloadPath = null } = {}) => {
const { asset, path: assetPath } = await readAsset(userId, assetId);
if (!canPreviewAsset(asset.mimeType)) {
throw Object.assign(new Error('该资产不支持预览'), { code: 'preview_not_supported' });
}
const buffer = await fs.readFile(assetPath);
const downloadUrl =
downloadPath ??
`/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download?inline=1`;
return renderAssetPreviewHtml({ asset, buffer, downloadUrl });
};
const extractAssetText = async (userId, assetId) => {
const { asset, path: assetPath } = await readAsset(userId, assetId);
const buffer = await fs.readFile(assetPath);
const extracted = extractAttachmentText(buffer, asset.mimeType, asset.filename);
return {
asset: assetResponse(asset),
...extracted,
charCount: extracted.text.length,
};
};
const expireStaleUploads = async (now = Date.now()) => {
const [rows] = await pool.query(
`SELECT id, user_id, space_id, reserved_bytes, temporary_storage_key, status
FROM h5_upload_sessions
WHERE status IN ('reserved', 'uploaded') AND expires_at <= ?
LIMIT 200`,
[now],
);
let expired = 0;
for (const upload of rows) {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [locked] = await conn.query(
`SELECT id, reserved_bytes, temporary_storage_key, status
FROM h5_upload_sessions
WHERE id = ? AND user_id = ? AND status IN ('reserved', 'uploaded')
LIMIT 1
FOR UPDATE`,
[upload.id, upload.user_id],
);
const row = locked[0];
if (!row) {
await conn.commit();
continue;
}
await conn.query(
`UPDATE h5_user_spaces
SET reserved_bytes = GREATEST(0, reserved_bytes - ?), updated_at = ?
WHERE id = ? AND user_id = ?`,
[row.reserved_bytes, now, upload.space_id, upload.user_id],
);
await conn.query(
`UPDATE h5_upload_sessions SET status = 'expired' WHERE id = ? AND user_id = ?`,
[upload.id, upload.user_id],
);
await conn.commit();
if (row.temporary_storage_key) {
await fs.rm(absoluteStoragePath(row.temporary_storage_key), { force: true });
}
expired += 1;
} catch {
await conn.rollback();
} finally {
conn.release();
}
}
return expired;
};
return {
storageRoot,
createUpload,
writeUploadContent,
completeUpload,
cancelUpload,
createChatAsset,
listAssets,
deleteAsset,
readAsset,
readPublicAsset,
renderAssetPreview,
extractAssetText,
renderAssetThumbnail,
syncWorkspaceAssets: workspaceSync.syncUserWorkspace,
expireStaleUploads,
};
}
export const assetInternals = {
detectMimeType,
normalizeFilename,
expectedMimeType,
assetTypeForMime,
};