feat: improve mindspace asset handling and local runtime paths
This commit is contained in:
+179
-1
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user