feat: improve mindspace asset handling and local runtime paths

This commit is contained in:
john
2026-06-28 12:18:26 +08:00
parent 4a9bc710f1
commit ea19ffb5fa
36 changed files with 867 additions and 100 deletions
+179 -1
View File
@@ -1,8 +1,186 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID } from './user-publish.mjs';
import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR, resolvePublishDir } from './user-publish.mjs';
import { workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
const PRIVATE_ASSET_URL_PATTERN =
/(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi;
const PUBLIC_TEMP_IMAGE_DIR = '.tmp-images';
function extensionForMime(mimeType, filename = '') {
const ext = path.extname(String(filename ?? '')).replace(/^\./, '').toLowerCase();
if (ext && /^[a-z0-9]{1,8}$/.test(ext)) return ext === 'jpeg' ? 'jpg' : ext;
if (mimeType === 'image/jpeg') return 'jpg';
if (mimeType === 'image/png') return 'png';
if (mimeType === 'image/webp') return 'webp';
if (mimeType === 'image/gif') return 'gif';
return 'bin';
}
function publicTempImageFilename(assetId, mimeType, fallbackFilename = '') {
return `${assetId}.${extensionForMime(mimeType, fallbackFilename)}`;
}
function absoluteStoragePath(storageRoot, storageKey) {
const resolvedRoot = path.resolve(storageRoot);
const resolved = path.resolve(resolvedRoot, storageKey);
if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
throw Object.assign(new Error('路径越界'), { code: 'invalid_storage_path' });
}
return resolved;
}
function workspaceTempImageRelativeUrl(htmlRelativePath, filename) {
const htmlDir = path.posix.dirname(String(htmlRelativePath ?? '').replace(/^\/+/, ''));
const tmpDir = path.posix.join(PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR);
const relDir =
htmlDir === '.' || !htmlDir
? tmpDir
: path.posix.relative(htmlDir, tmpDir).replace(/\\/g, '/');
return relDir ? `${relDir}/${filename}` : filename;
}
export async function materializePrivateAssetsInWorkspaceHtml({
pool,
storageRoot,
h5Root,
userId,
html,
htmlRelativePath,
writeBack = false,
}) {
const matches = [...String(html).matchAll(PRIVATE_ASSET_URL_PATTERN)];
if (matches.length === 0) {
return { html, count: 0, changed: false };
}
const assetIds = [...new Set(matches.map((match) => match[1]).filter(Boolean))];
const [assets] = await pool.query(
`SELECT a.id, a.mime_type, a.original_filename, v.storage_key
FROM h5_assets a
JOIN h5_asset_versions v ON v.id = a.current_version_id
WHERE a.user_id = ? AND a.id IN (?) AND a.mime_type LIKE 'image/%'
AND a.status <> 'deleted'`,
[userId, assetIds],
);
const publishDir = resolvePublishDir(h5Root, { id: userId });
const tmpDir = path.join(publishDir, PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR);
const urlMap = new Map();
for (const asset of assets) {
const filename = publicTempImageFilename(asset.id, asset.mime_type, asset.original_filename);
const target = path.join(tmpDir, filename);
try {
const sourcePath = absoluteStoragePath(storageRoot, asset.storage_key);
await fs.mkdir(tmpDir, { recursive: true });
await fs.copyFile(sourcePath, target);
urlMap.set(asset.id, workspaceTempImageRelativeUrl(htmlRelativePath, filename));
} catch {
// skip unreadable assets
}
}
if (urlMap.size === 0) {
return { html, count: 0, changed: false };
}
let count = 0;
const result = String(html).replace(PRIVATE_ASSET_URL_PATTERN, (value, assetId) => {
const nextUrl = urlMap.get(assetId);
if (!nextUrl) return value;
count += 1;
return nextUrl;
});
if (count === 0 || result === html) {
return { html, count: 0, changed: false };
}
if (writeBack) {
const htmlPath = path.join(publishDir, htmlRelativePath);
await fs.writeFile(htmlPath, result, 'utf8');
}
return { html: result, count, changed: true };
}
export async function repairMissingHtmlAssetReferences({ pool, userId, sourceContent, html }) {
const collectIds = (text) => [
...new Set([...String(text).matchAll(PRIVATE_ASSET_URL_PATTERN)].map((match) => match[1])),
];
const htmlIds = collectIds(html);
const messageIds = collectIds(sourceContent);
if (htmlIds.length === 0 || messageIds.length === 0) {
return { html, changed: false };
}
const [rows] = await pool.query(
`SELECT id FROM h5_assets
WHERE user_id = ? AND id IN (?) AND mime_type LIKE 'image/%' AND status <> 'deleted'`,
[userId, [...new Set([...htmlIds, ...messageIds])]],
);
const existing = new Set(rows.map((row) => row.id));
const invalidHtmlIds = htmlIds.filter((id) => !existing.has(id));
const validMessageIds = messageIds.filter((id) => existing.has(id));
if (invalidHtmlIds.length === 0 || validMessageIds.length === 0) {
return { html, changed: false };
}
const idMap = new Map();
for (let index = 0; index < invalidHtmlIds.length; index += 1) {
const replacement = validMessageIds[index];
if (!replacement) break;
idMap.set(invalidHtmlIds[index], replacement);
}
if (idMap.size === 0) {
return { html, changed: false };
}
let changed = false;
const nextHtml = String(html).replace(PRIVATE_ASSET_URL_PATTERN, (value, assetId) => {
const replacement = idMap.get(assetId);
if (!replacement) return value;
changed = true;
return value.replace(assetId, replacement);
});
return { html: nextHtml, changed };
}
export function createAssetDataUriResolver(pool, storageRoot, userId) {
const cache = new Map();
return async (assetId) => {
const key = String(assetId ?? '').trim();
if (!key) return null;
if (cache.has(key)) return cache.get(key);
const [rows] = await pool.query(
`SELECT a.mime_type, v.storage_key
FROM h5_assets a
JOIN h5_asset_versions v ON v.id = a.current_version_id
WHERE a.user_id = ? AND a.id = ? AND a.mime_type LIKE 'image/%'
AND a.status <> 'deleted'
LIMIT 1`,
[userId, key],
);
const asset = rows[0];
if (!asset) {
cache.set(key, null);
return null;
}
try {
const buffer = await fs.readFile(absoluteStoragePath(storageRoot, asset.storage_key));
const mimeType = String(asset.mime_type || 'image/jpeg');
const dataUri = `data:${mimeType};base64,${buffer.toString('base64')}`;
cache.set(key, dataUri);
return dataUri;
} catch {
cache.set(key, null);
return null;
}
};
}
const URL_PATTERN =
/https?:\/\/[^\s<>"')\]]+\/(?:MindSpace|temp)\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html)/gi;