Files
memind/mindspace-storage-adapter.mjs
T
2026-07-02 21:03:14 +08:00

161 lines
5.1 KiB
JavaScript

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,
};