Add MindSpace decoupling foundation
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
function storageError(message, code, details = {}) {
|
||||
return Object.assign(new Error(message), { code, ...details });
|
||||
}
|
||||
|
||||
export function normalizeMindSpaceStorageKey(key) {
|
||||
const raw = String(key ?? '').trim();
|
||||
if (!raw) {
|
||||
throw storageError('storage key is required', 'invalid_storage_key');
|
||||
}
|
||||
if (raw.includes('\0')) {
|
||||
throw storageError('storage key must not contain null bytes', 'invalid_storage_key');
|
||||
}
|
||||
if (raw.includes('\\')) {
|
||||
throw storageError('storage key must use forward slashes', 'invalid_storage_key');
|
||||
}
|
||||
if (path.isAbsolute(raw) || /^[A-Za-z]:\//.test(raw)) {
|
||||
throw storageError('storage key must be relative', 'invalid_storage_key');
|
||||
}
|
||||
if (raw.split('/').includes('..')) {
|
||||
throw storageError('storage key must not traverse parent directories', 'invalid_storage_key');
|
||||
}
|
||||
|
||||
const normalized = path.posix.normalize(raw.replace(/^\/+/, ''));
|
||||
if (normalized === '.' || normalized.startsWith('../') || normalized.includes('/../')) {
|
||||
throw storageError('storage key must not traverse parent directories', 'invalid_storage_key');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveStoragePath(storageRoot, key) {
|
||||
const root = path.resolve(String(storageRoot ?? ''));
|
||||
if (!root) {
|
||||
throw storageError('storage root is required', 'invalid_storage_root');
|
||||
}
|
||||
const normalizedKey = normalizeMindSpaceStorageKey(key);
|
||||
const target = path.resolve(root, ...normalizedKey.split('/'));
|
||||
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
|
||||
throw storageError('storage key resolves outside storage root', 'invalid_storage_key', {
|
||||
key: normalizedKey,
|
||||
});
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
async function walkFiles(root, dir, prefix, results) {
|
||||
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(dir, entry.name);
|
||||
const relativePath = path.relative(root, absolutePath).split(path.sep).join('/');
|
||||
if (entry.isDirectory()) {
|
||||
await walkFiles(root, absolutePath, prefix, results);
|
||||
} else if (!prefix || relativePath === prefix || relativePath.startsWith(`${prefix}/`)) {
|
||||
const stat = await fsp.stat(absolutePath);
|
||||
results.push({
|
||||
key: relativePath,
|
||||
sizeBytes: stat.size,
|
||||
updatedAt: stat.mtimeMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createLocalMindSpaceStorageAdapter(storageRoot) {
|
||||
const root = path.resolve(storageRoot);
|
||||
|
||||
return {
|
||||
kind: 'local-fs',
|
||||
root,
|
||||
|
||||
async putObject(key, body) {
|
||||
const target = resolveStoragePath(root, key);
|
||||
await fsp.mkdir(path.dirname(target), { recursive: true });
|
||||
await fsp.writeFile(target, body);
|
||||
const stat = await fsp.stat(target);
|
||||
return {
|
||||
key: normalizeMindSpaceStorageKey(key),
|
||||
sizeBytes: stat.size,
|
||||
updatedAt: stat.mtimeMs,
|
||||
};
|
||||
},
|
||||
|
||||
async getObject(key) {
|
||||
return fsp.readFile(resolveStoragePath(root, key));
|
||||
},
|
||||
|
||||
async statObject(key) {
|
||||
try {
|
||||
const stat = await fsp.stat(resolveStoragePath(root, key));
|
||||
return {
|
||||
key: normalizeMindSpaceStorageKey(key),
|
||||
exists: true,
|
||||
sizeBytes: stat.size,
|
||||
updatedAt: stat.mtimeMs,
|
||||
isDirectory: stat.isDirectory(),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
return {
|
||||
key: normalizeMindSpaceStorageKey(key),
|
||||
exists: false,
|
||||
sizeBytes: 0,
|
||||
updatedAt: null,
|
||||
isDirectory: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
async deleteObject(key) {
|
||||
await fsp.rm(resolveStoragePath(root, key), { force: true });
|
||||
return { key: normalizeMindSpaceStorageKey(key), deleted: true };
|
||||
},
|
||||
|
||||
async listObjects(prefix = '') {
|
||||
const normalizedPrefix = prefix ? normalizeMindSpaceStorageKey(prefix).replace(/\/$/, '') : '';
|
||||
const results = [];
|
||||
try {
|
||||
await walkFiles(root, root, normalizedPrefix, results);
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
}
|
||||
return results.sort((a, b) => a.key.localeCompare(b.key));
|
||||
},
|
||||
|
||||
async copyObject(sourceKey, targetKey) {
|
||||
const source = resolveStoragePath(root, sourceKey);
|
||||
const target = resolveStoragePath(root, targetKey);
|
||||
await fsp.mkdir(path.dirname(target), { recursive: true });
|
||||
await fsp.copyFile(source, target);
|
||||
const stat = await fsp.stat(target);
|
||||
return {
|
||||
key: normalizeMindSpaceStorageKey(targetKey),
|
||||
sourceKey: normalizeMindSpaceStorageKey(sourceKey),
|
||||
sizeBytes: stat.size,
|
||||
updatedAt: stat.mtimeMs,
|
||||
};
|
||||
},
|
||||
|
||||
createReadStream(key) {
|
||||
return fs.createReadStream(resolveStoragePath(root, key));
|
||||
},
|
||||
|
||||
createWriteStream(key) {
|
||||
const target = resolveStoragePath(root, key);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
return fs.createWriteStream(target);
|
||||
},
|
||||
|
||||
async getSignedUrl() {
|
||||
throw storageError('local fs adapter does not support signed URLs', 'signed_url_not_supported');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const mindspaceStorageAdapterInternals = {
|
||||
storageError,
|
||||
};
|
||||
Reference in New Issue
Block a user