161 lines
5.1 KiB
JavaScript
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,
|
|
};
|