ftp-srv-esm
Version:
Modern, extensible FTP server (daemon) for Node.js with ESM support. Based on ftp-srv.
157 lines (135 loc) • 4.65 kB
JavaScript
import nodePath from 'path';
import { randomBytes } from 'crypto';
import { accessSync, chmodSync, createReadStream, createWriteStream, constants, mkdirSync, readdirSync, renameSync, rmdirSync, statSync, unlinkSync } from 'fs';
import errors from './errors.js';
class FileSystem {
constructor(connection, {root, cwd} = {}) {
this.connection = connection;
this.cwd = nodePath.normalize(cwd || '/').replace(/[/\\]+/g, '/');
let rootPath = root || process.cwd();
if (nodePath.isAbsolute(rootPath)) {
this._root = nodePath.normalize(rootPath);
} else {
this._root = nodePath.resolve(rootPath);
}
}
get root() {
return this._root;
}
_resolvePath(dir = '.') {
// Use node's path module for normalization
const normalizedPath = nodePath.normalize(dir).replace(/[/\\]+/g, '/');
// Join cwd with new path
let clientPath = nodePath.isAbsolute(normalizedPath)
? normalizedPath
: nodePath.join(this.cwd, normalizedPath).replace(/[/\\]+/g, '/');
// Ensure clientPath starts with a leading slash
if (!clientPath.startsWith('/')) {
clientPath = '/' + clientPath;
}
// Prevent escaping root: clamp to '/'
const segments = clientPath.split('/').filter(Boolean);
let safeSegments = [];
for (const seg of segments) {
if (seg === '..') {
if (safeSegments.length > 0) safeSegments.pop();
} else if (seg !== '.') {
safeSegments.push(seg);
}
}
clientPath = '/' + safeSegments.join('/');
// For fsPath, join root with clientPath, but avoid double-absolute path on Windows
let fsPath = nodePath.join(this._root, clientPath.slice(1));
// Convert fsPath to use forward slashes
fsPath = fsPath.replace(/\\/g, '/');
return {
clientPath,
fsPath
};
}
currentDirectory() {
return this.cwd;
}
chdir(path = '.') {
const {clientPath, fsPath} = this._resolvePath(path);
const statObj = statSync(fsPath);
if (!statObj.isDirectory()) throw new errors.FileSystemError('Not a valid directory');
this.cwd = clientPath;
return this.currentDirectory();
}
list(path = '.', { showHidden = false } = {}) {
const {fsPath} = this._resolvePath(path);
const fileNames = readdirSync(fsPath);
const results = fileNames
.filter(fileName => showHidden || !fileName.startsWith('.'))
.map((fileName) => {
const filePath = nodePath.join(fsPath, fileName);
try {
accessSync(filePath, constants.F_OK);
const statObj = statSync(filePath);
statObj.name = fileName;
return statObj;
} catch {
return null;
}
});
return results.filter(Boolean);
}
get(fileName) {
const {fsPath} = this._resolvePath(fileName);
const statObj = statSync(fsPath);
statObj.name = fileName;
return statObj;
}
write(fileName, {append = false, start = undefined} = {}) {
const {fsPath, clientPath} = this._resolvePath(fileName);
const stream = createWriteStream(fsPath, {flags: !append ? 'w+' : 'a+', start});
stream.once('error', async () => {
try {
unlinkSync(fsPath);
} catch { /* ignore error */ }
});
stream.once('close', () => stream.end());
return {
stream,
clientPath
};
}
read(fileName, {start = undefined} = {}) {
const {clientPath, fsPath} = this._resolvePath(fileName);
if (statSync(fsPath).isDirectory()) {
throw new errors.FileSystemError('Cannot read a directory');
}
const stream = createReadStream(fsPath, {flags: 'r', start});
return {
stream,
clientPath
};
}
delete(path) {
const {fsPath} = this._resolvePath(path);
const statObj = statSync(fsPath);
if (statObj.isDirectory()) return rmdirSync(fsPath);
else return unlinkSync(fsPath);
}
mkdir(path) {
const {fsPath} = this._resolvePath(path);
mkdirSync(fsPath, { recursive: true });
return fsPath;
}
rename(from, to) {
const {fsPath: fromPath} = this._resolvePath(from);
const {fsPath: toPath} = this._resolvePath(to);
return renameSync(fromPath, toPath);
}
chmod(path, mode) {
const {fsPath} = this._resolvePath(path);
return chmodSync(fsPath, mode);
}
getUniqueName() {
const randomPart = randomBytes(8).toString('hex');
const timestampPart = Date.now().toString(36);
return `${timestampPart}-${randomPart}`;
}
}
export default FileSystem;