molex-ftp-client 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/README.md +47 -0
- package/index.js +11 -469
- package/lib/FTPClient.js +226 -0
- package/lib/commands.js +323 -0
- package/lib/connection.js +176 -0
- package/lib/utils.js +84 -0
- package/package.json +1 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [1.2.0] - 2026-02-02
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- **Major refactoring**: Improved separation of concerns
|
|
7
|
+
- `index.js` now serves as simple entry point
|
|
8
|
+
- Implementation moved to organized `lib/` structure:
|
|
9
|
+
- `lib/FTPClient.js` - Main class definition
|
|
10
|
+
- `lib/connection.js` - Connection and authentication logic
|
|
11
|
+
- `lib/commands.js` - All FTP command implementations
|
|
12
|
+
- `lib/utils.js` - Helper functions
|
|
13
|
+
- Better code maintainability and readability
|
|
14
|
+
- No breaking changes - API remains identical
|
|
15
|
+
|
|
16
|
+
## [1.1.0] - 2026-02-02
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- `ensureDir(dirPath, recursive)` - Ensure directory exists, creating parent directories if needed
|
|
20
|
+
- `ensureParentDir(filePath)` - Ensure parent directory exists for a file path
|
|
21
|
+
- `uploadFile(data, remotePath, ensureDir)` - Upload with automatic directory creation
|
|
22
|
+
- Utility library (`lib/utils.js`) for better code organization
|
|
23
|
+
- Helper functions for FTP command parsing and path manipulation
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- Refactored internal code structure for better maintainability
|
|
27
|
+
- Improved path normalization across all directory operations
|
|
28
|
+
- Better error handling for directory creation
|
|
29
|
+
|
|
30
|
+
### Improved
|
|
31
|
+
- Cleaner API for common operations
|
|
32
|
+
- Reduced boilerplate code needed for directory handling
|
|
33
|
+
- More consistent error messages
|
|
34
|
+
|
|
35
|
+
## [1.0.1] - 2026-02-02
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- Updated repository URLs to correct GitHub location
|
|
39
|
+
|
|
40
|
+
## [1.0.0] - 2026-02-02
|
|
41
|
+
|
|
42
|
+
### Initial Release
|
|
43
|
+
- Zero dependencies FTP client using native Node.js TCP sockets
|
|
44
|
+
- Promise-based API with async/await support
|
|
45
|
+
- Passive mode (PASV) for data transfers
|
|
46
|
+
- Debug logging with configurable options
|
|
47
|
+
- Connection keep-alive and timeout configuration
|
|
48
|
+
- Upload/download files with Buffer support
|
|
49
|
+
- Directory operations (list, cd, mkdir, pwd)
|
|
50
|
+
- File operations (delete, rename, size, exists, modifiedTime)
|
|
51
|
+
- Connection statistics tracking
|
|
52
|
+
- Event-based architecture
|
package/README.md
CHANGED
|
@@ -177,6 +177,24 @@ console.log(`Last modified: ${date.toISOString()}`);
|
|
|
177
177
|
|
|
178
178
|
Returns: `Promise<Date>`
|
|
179
179
|
|
|
180
|
+
#### `uploadFile(data, remotePath, ensureDir)`
|
|
181
|
+
|
|
182
|
+
Upload file and optionally ensure parent directory exists.
|
|
183
|
+
|
|
184
|
+
```javascript
|
|
185
|
+
// Upload with automatic directory creation
|
|
186
|
+
await client.uploadFile('data', '/deep/nested/path/file.txt', true);
|
|
187
|
+
|
|
188
|
+
// Upload without directory creation (default behavior)
|
|
189
|
+
await client.uploadFile('data', '/file.txt');
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
- `data` (string|Buffer): File content
|
|
193
|
+
- `remotePath` (string): Remote file path
|
|
194
|
+
- `ensureDir` (boolean): Create parent directories if needed (default: false)
|
|
195
|
+
|
|
196
|
+
Returns: `Promise<void>`
|
|
197
|
+
|
|
180
198
|
### Directory Operations
|
|
181
199
|
|
|
182
200
|
#### `list(path)`
|
|
@@ -221,6 +239,35 @@ await client.mkdir('/remote/newdir');
|
|
|
221
239
|
|
|
222
240
|
Returns: `Promise<void>`
|
|
223
241
|
|
|
242
|
+
#### `ensureDir(dirPath, recursive)`
|
|
243
|
+
|
|
244
|
+
Ensure directory exists, creating it (and parent directories) if necessary.
|
|
245
|
+
|
|
246
|
+
```javascript
|
|
247
|
+
// Create nested directories recursively
|
|
248
|
+
await client.ensureDir('/deep/nested/path');
|
|
249
|
+
|
|
250
|
+
// Create single directory (no parent creation)
|
|
251
|
+
await client.ensureDir('/newdir', false);
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
- `dirPath` (string): Directory path to ensure exists
|
|
255
|
+
- `recursive` (boolean): Create parent directories if needed (default: true)
|
|
256
|
+
|
|
257
|
+
Returns: `Promise<void>`
|
|
258
|
+
|
|
259
|
+
#### `ensureParentDir(filePath)`
|
|
260
|
+
|
|
261
|
+
Ensure the parent directory exists for a given file path.
|
|
262
|
+
|
|
263
|
+
```javascript
|
|
264
|
+
// Ensures /path/to exists before uploading
|
|
265
|
+
await client.ensureParentDir('/path/to/file.txt');
|
|
266
|
+
await client.upload('data', '/path/to/file.txt');
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Returns: `Promise<void>`
|
|
270
|
+
|
|
224
271
|
### Utilities
|
|
225
272
|
|
|
226
273
|
#### `getStats()`
|
package/index.js
CHANGED
|
@@ -1,475 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*
|
|
1
|
+
/*
|
|
2
|
+
* File: c:\Users\tonyw\Desktop\PRIVS\molex-ftp-client\index.js
|
|
3
|
+
* Project: c:\Users\tonyw\Desktop\PRIVS\molex-ftp-client
|
|
4
|
+
* Created Date: Monday February 2nd 2026
|
|
5
|
+
* Author: Tony Wiedman
|
|
6
|
+
* -----
|
|
7
|
+
* Last Modified: Mon February 2nd 2026 1:23:44
|
|
8
|
+
* Modified By: Tony Wiedman
|
|
9
|
+
* -----
|
|
10
|
+
* Copyright (c) 2026 MolexWorks
|
|
6
11
|
*/
|
|
7
|
-
class FTPClient extends EventEmitter {
|
|
8
|
-
constructor(options = {}) {
|
|
9
|
-
super();
|
|
10
|
-
this.socket = null;
|
|
11
|
-
this.dataSocket = null;
|
|
12
|
-
this.buffer = '';
|
|
13
|
-
this.connected = false;
|
|
14
|
-
this.authenticated = false;
|
|
15
|
-
this.debug = options.debug || false;
|
|
16
|
-
this.timeout = options.timeout || 30000;
|
|
17
|
-
this.keepAlive = options.keepAlive !== false;
|
|
18
|
-
this._log = options.logger || console.log;
|
|
19
|
-
this._commandCount = 0;
|
|
20
|
-
this._lastCommand = null;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Log message if debug is enabled
|
|
25
|
-
* @private
|
|
26
|
-
*/
|
|
27
|
-
_debug(...args) {
|
|
28
|
-
if (this.debug && this._log) {
|
|
29
|
-
this._log('[FTP Debug]', ...args);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Connect to FTP server
|
|
35
|
-
* @param {Object} options - Connection options
|
|
36
|
-
* @param {string} options.host - FTP server host
|
|
37
|
-
* @param {number} [options.port=21] - FTP server port
|
|
38
|
-
* @param {string} [options.user='anonymous'] - Username
|
|
39
|
-
* @param {string} [options.password='anonymous@'] - Password
|
|
40
|
-
* @returns {Promise<void>}
|
|
41
|
-
*/
|
|
42
|
-
async connect({ host, port = 21, user = 'anonymous', password = 'anonymous@' }) {
|
|
43
|
-
this._debug(`Connecting to ${host}:${port} as ${user}`);
|
|
44
|
-
return new Promise((resolve, reject) => {
|
|
45
|
-
this.socket = net.createConnection({ host, port }, () => {
|
|
46
|
-
this.connected = true;
|
|
47
|
-
this._debug('TCP connection established');
|
|
48
|
-
if (this.keepAlive) {
|
|
49
|
-
this.socket.setKeepAlive(true, 10000);
|
|
50
|
-
}
|
|
51
|
-
this.emit('connected');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
this.socket.setEncoding('utf8');
|
|
55
|
-
this.socket.on('data', async (data) => {
|
|
56
|
-
this.buffer += data;
|
|
57
|
-
const lines = this.buffer.split('\r\n');
|
|
58
|
-
this.buffer = lines.pop();
|
|
59
|
-
|
|
60
|
-
for (const line of lines) {
|
|
61
|
-
if (line) {
|
|
62
|
-
this._debug('<<<', line);
|
|
63
|
-
this.emit('response', line);
|
|
64
|
-
const code = parseInt(line.substring(0, 3));
|
|
65
|
-
|
|
66
|
-
// Handle initial connection
|
|
67
|
-
if (code === 220 && !this.authenticated) {
|
|
68
|
-
try {
|
|
69
|
-
this._debug('Authenticating...');
|
|
70
|
-
await this._sendCommand(`USER ${user}`);
|
|
71
|
-
await this._sendCommand(`PASS ${password}`);
|
|
72
|
-
this.authenticated = true;
|
|
73
|
-
this._debug('Authentication successful');
|
|
74
|
-
resolve();
|
|
75
|
-
} catch (err) {
|
|
76
|
-
reject(err);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
this.socket.on('error', (err) => {
|
|
84
|
-
this.emit('error', err);
|
|
85
|
-
reject(err);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
this.socket.on('close', () => {
|
|
89
|
-
this.connected = false;
|
|
90
|
-
this.authenticated = false;
|
|
91
|
-
this.emit('close');
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
setTimeout(() => reject(new Error('Connection timeout')), 10000);
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Send FTP command and wait for response
|
|
100
|
-
* @param {string} command - FTP command
|
|
101
|
-
* @param {boolean} allowPreliminary - Allow 1xx preliminary responses
|
|
102
|
-
* @returns {Promise<Object>}
|
|
103
|
-
*/
|
|
104
|
-
_sendCommand(command, allowPreliminary = false) {
|
|
105
|
-
return new Promise((resolve, reject) => {
|
|
106
|
-
if (!this.connected) {
|
|
107
|
-
return reject(new Error('Not connected'));
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
this._commandCount++;
|
|
111
|
-
this._lastCommand = command;
|
|
112
|
-
const cmdToLog = command.startsWith('PASS ') ? 'PASS ********' : command;
|
|
113
|
-
this._debug('>>>', cmdToLog);
|
|
114
|
-
|
|
115
|
-
const timeoutId = setTimeout(() => {
|
|
116
|
-
this.removeListener('response', responseHandler);
|
|
117
|
-
reject(new Error(`Command timeout: ${cmdToLog}`));
|
|
118
|
-
}, this.timeout);
|
|
119
|
-
|
|
120
|
-
const responseHandler = (line) => {
|
|
121
|
-
clearTimeout(timeoutId);
|
|
122
|
-
const code = parseInt(line.substring(0, 3));
|
|
123
|
-
const message = line.substring(4);
|
|
124
|
-
|
|
125
|
-
// Check if this is a complete response (not a multi-line response in progress)
|
|
126
|
-
if (line.charAt(3) === ' ') {
|
|
127
|
-
// 1xx = Preliminary positive reply (command okay, another command expected)
|
|
128
|
-
// 2xx = Positive completion reply
|
|
129
|
-
// 3xx = Positive intermediate reply (command okay, awaiting more info)
|
|
130
|
-
// 4xx/5xx = Negative replies (errors)
|
|
131
|
-
|
|
132
|
-
if (code >= 100 && code < 200 && allowPreliminary) {
|
|
133
|
-
// Don't remove listener, wait for final response
|
|
134
|
-
this._debug('Preliminary response, waiting for completion...');
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
clearTimeout(timeoutId);
|
|
139
|
-
this.removeListener('response', responseHandler);
|
|
140
|
-
|
|
141
|
-
if (code >= 200 && code < 400) {
|
|
142
|
-
resolve({ code, message, raw: line });
|
|
143
|
-
} else {
|
|
144
|
-
this._debug(`Error response: ${code}`);
|
|
145
|
-
reject(new Error(`FTP Error ${code}: ${message}`));
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
this.on('response', responseHandler);
|
|
151
|
-
this.socket.write(command + '\r\n');
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Enter passive mode and get data connection info
|
|
157
|
-
* @returns {Promise<Object>}
|
|
158
|
-
*/
|
|
159
|
-
async _enterPassiveMode() {
|
|
160
|
-
const response = await this._sendCommand('PASV');
|
|
161
|
-
const match = response.message.match(/\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)/);
|
|
162
|
-
|
|
163
|
-
if (!match) {
|
|
164
|
-
throw new Error('Failed to parse PASV response');
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const host = `${match[1]}.${match[2]}.${match[3]}.${match[4]}`;
|
|
168
|
-
const port = parseInt(match[5]) * 256 + parseInt(match[6]);
|
|
169
|
-
|
|
170
|
-
return { host, port };
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Upload file to FTP server
|
|
175
|
-
* @param {string|Buffer} data - File data
|
|
176
|
-
* @param {string} remotePath - Remote file path
|
|
177
|
-
* @returns {Promise<void>}
|
|
178
|
-
*/
|
|
179
|
-
async upload(data, remotePath) {
|
|
180
|
-
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
|
181
|
-
this._debug(`Uploading ${buffer.length} bytes to ${remotePath}`);
|
|
182
|
-
const { host, port } = await this._enterPassiveMode();
|
|
183
|
-
|
|
184
|
-
return new Promise((resolve, reject) => {
|
|
185
|
-
let commandSent = false;
|
|
186
|
-
|
|
187
|
-
this.dataSocket = net.createConnection({ host, port }, () => {
|
|
188
|
-
// Send STOR command to start upload (expects 150, then 226)
|
|
189
|
-
if (!commandSent) {
|
|
190
|
-
commandSent = true;
|
|
191
|
-
this._debug(`Data connection established for upload`);
|
|
192
|
-
this._sendCommand(`STOR ${remotePath}`, true).catch(reject);
|
|
193
|
-
|
|
194
|
-
// Write data to data socket
|
|
195
|
-
this.dataSocket.write(buffer);
|
|
196
|
-
this.dataSocket.end();
|
|
197
|
-
}
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
this.dataSocket.on('error', reject);
|
|
201
|
-
|
|
202
|
-
this.dataSocket.on('close', () => {
|
|
203
|
-
// Wait for final response from control socket
|
|
204
|
-
const finalHandler = (line) => {
|
|
205
|
-
const code = parseInt(line.substring(0, 3));
|
|
206
|
-
if (code === 226 || code === 250) {
|
|
207
|
-
this.removeListener('response', finalHandler);
|
|
208
|
-
this._debug(`Upload completed successfully`);
|
|
209
|
-
resolve();
|
|
210
|
-
} else if (code >= 400) {
|
|
211
|
-
this.removeListener('response', finalHandler);
|
|
212
|
-
reject(new Error(`FTP Error ${code}: ${line.substring(4)}`));
|
|
213
|
-
}
|
|
214
|
-
};
|
|
215
|
-
this.on('response', finalHandler);
|
|
216
|
-
|
|
217
|
-
// Timeout if no response
|
|
218
|
-
setTimeout(() => {
|
|
219
|
-
this.removeListener('response', finalHandler);
|
|
220
|
-
resolve();
|
|
221
|
-
}, 5000);
|
|
222
|
-
});
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Download file from FTP server
|
|
228
|
-
* @param {string} remotePath - Remote file path
|
|
229
|
-
* @returns {Promise<Buffer>}
|
|
230
|
-
*/
|
|
231
|
-
async download(remotePath) {
|
|
232
|
-
this._debug(`Downloading ${remotePath}`);
|
|
233
|
-
const { host, port } = await this._enterPassiveMode();
|
|
234
|
-
|
|
235
|
-
return new Promise((resolve, reject) => {
|
|
236
|
-
const chunks = [];
|
|
237
|
-
let commandSent = false;
|
|
238
|
-
|
|
239
|
-
this.dataSocket = net.createConnection({ host, port }, () => {
|
|
240
|
-
// Send RETR command to start download (expects 150, then 226)
|
|
241
|
-
if (!commandSent) {
|
|
242
|
-
commandSent = true;
|
|
243
|
-
this._debug(`Data connection established for download`);
|
|
244
|
-
this._sendCommand(`RETR ${remotePath}`, true).catch(reject);
|
|
245
|
-
}
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
this.dataSocket.on('data', (chunk) => {
|
|
249
|
-
chunks.push(chunk);
|
|
250
|
-
this._debug(`Received ${chunk.length} bytes`);
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
this.dataSocket.on('error', reject);
|
|
254
|
-
|
|
255
|
-
this.dataSocket.on('close', () => {
|
|
256
|
-
// Wait for final 226 response
|
|
257
|
-
const finalHandler = (line) => {
|
|
258
|
-
const code = parseInt(line.substring(0, 3));
|
|
259
|
-
if (code === 226 || code === 250) {
|
|
260
|
-
this.removeListener('response', finalHandler);
|
|
261
|
-
const result = Buffer.concat(chunks);
|
|
262
|
-
this._debug(`Download completed: ${result.length} bytes`);
|
|
263
|
-
resolve(result);
|
|
264
|
-
} else if (code >= 400) {
|
|
265
|
-
this.removeListener('response', finalHandler);
|
|
266
|
-
reject(new Error(`FTP Error ${code}: ${line.substring(4)}`));
|
|
267
|
-
}
|
|
268
|
-
};
|
|
269
|
-
this.on('response', finalHandler);
|
|
270
|
-
|
|
271
|
-
// Timeout if no response
|
|
272
|
-
setTimeout(() => {
|
|
273
|
-
this.removeListener('response', finalHandler);
|
|
274
|
-
if (chunks.length > 0) {
|
|
275
|
-
resolve(Buffer.concat(chunks));
|
|
276
|
-
}
|
|
277
|
-
}, 5000);
|
|
278
|
-
});
|
|
279
|
-
});
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* List directory contents
|
|
284
|
-
* @param {string} [path='.'] - Directory path
|
|
285
|
-
* @returns {Promise<string>}
|
|
286
|
-
*/
|
|
287
|
-
async list(path = '.') {
|
|
288
|
-
this._debug(`Listing directory: ${path}`);
|
|
289
|
-
const { host, port } = await this._enterPassiveMode();
|
|
290
|
-
|
|
291
|
-
return new Promise((resolve, reject) => {
|
|
292
|
-
const chunks = [];
|
|
293
|
-
let commandSent = false;
|
|
294
|
-
|
|
295
|
-
this.dataSocket = net.createConnection({ host, port }, () => {
|
|
296
|
-
if (!commandSent) {
|
|
297
|
-
commandSent = true;
|
|
298
|
-
this._sendCommand(`LIST ${path}`, true).catch(reject);
|
|
299
|
-
}
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
this.dataSocket.on('data', (chunk) => {
|
|
303
|
-
chunks.push(chunk);
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
this.dataSocket.on('error', reject);
|
|
307
|
-
|
|
308
|
-
this.dataSocket.on('close', () => {
|
|
309
|
-
// Wait for final 226 response
|
|
310
|
-
const finalHandler = (line) => {
|
|
311
|
-
const code = parseInt(line.substring(0, 3));
|
|
312
|
-
if (code === 226 || code === 250) {
|
|
313
|
-
this.removeListener('response', finalHandler);
|
|
314
|
-
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
315
|
-
}
|
|
316
|
-
};
|
|
317
|
-
this.on('response', finalHandler);
|
|
318
|
-
|
|
319
|
-
// Timeout fallback
|
|
320
|
-
setTimeout(() => {
|
|
321
|
-
this.removeListener('response', finalHandler);
|
|
322
|
-
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
323
|
-
}, 3000);
|
|
324
|
-
});
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Change working directory
|
|
330
|
-
* @param {string} path - Directory path
|
|
331
|
-
* @returns {Promise<void>}
|
|
332
|
-
*/
|
|
333
|
-
async cd(path) {
|
|
334
|
-
await this._sendCommand(`CWD ${path}`);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* Get current working directory
|
|
339
|
-
* @returns {Promise<string>}
|
|
340
|
-
*/
|
|
341
|
-
async pwd() {
|
|
342
|
-
const response = await this._sendCommand('PWD');
|
|
343
|
-
const match = response.message.match(/"(.+)"/);
|
|
344
|
-
return match ? match[1] : '/';
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* Create directory
|
|
349
|
-
* @param {string} path - Directory path
|
|
350
|
-
* @returns {Promise<void>}
|
|
351
|
-
*/
|
|
352
|
-
async mkdir(path) {
|
|
353
|
-
await this._sendCommand(`MKD ${path}`);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/**
|
|
357
|
-
* Delete file
|
|
358
|
-
* @param {string} path - File path
|
|
359
|
-
* @returns {Promise<void>}
|
|
360
|
-
*/
|
|
361
|
-
async delete(path) {
|
|
362
|
-
await this._sendCommand(`DELE ${path}`);
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* Rename file
|
|
367
|
-
* @param {string} from - Current name
|
|
368
|
-
* @param {string} to - New name
|
|
369
|
-
* @returns {Promise<void>}
|
|
370
|
-
*/
|
|
371
|
-
async rename(from, to) {
|
|
372
|
-
await this._sendCommand(`RNFR ${from}`);
|
|
373
|
-
await this._sendCommand(`RNTO ${to}`);
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/**
|
|
377
|
-
* Get file size
|
|
378
|
-
* @param {string} path - File path
|
|
379
|
-
* @returns {Promise<number>}
|
|
380
|
-
*/
|
|
381
|
-
async size(path) {
|
|
382
|
-
this._debug(`Getting size of ${path}`)
|
|
383
|
-
const response = await this._sendCommand(`SIZE ${path}`);
|
|
384
|
-
return parseInt(response.message);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* Check if file or directory exists
|
|
389
|
-
* @param {string} path - File or directory path
|
|
390
|
-
* @returns {Promise<boolean>}
|
|
391
|
-
*/
|
|
392
|
-
async exists(path) {
|
|
393
|
-
try {
|
|
394
|
-
await this.size(path);
|
|
395
|
-
return true;
|
|
396
|
-
} catch (err) {
|
|
397
|
-
return false;
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Get file modification time
|
|
403
|
-
* @param {string} path - File path
|
|
404
|
-
* @returns {Promise<Date>}
|
|
405
|
-
*/
|
|
406
|
-
async modifiedTime(path) {
|
|
407
|
-
this._debug(`Getting modification time of ${path}`);
|
|
408
|
-
const response = await this._sendCommand(`MDTM ${path}`);
|
|
409
|
-
// Parse MDTM response: YYYYMMDDhhmmss
|
|
410
|
-
const match = response.message.match(/(\d{14})/);
|
|
411
|
-
if (match) {
|
|
412
|
-
const str = match[1];
|
|
413
|
-
const year = parseInt(str.substring(0, 4));
|
|
414
|
-
const month = parseInt(str.substring(4, 6)) - 1;
|
|
415
|
-
const day = parseInt(str.substring(6, 8));
|
|
416
|
-
const hour = parseInt(str.substring(8, 10));
|
|
417
|
-
const minute = parseInt(str.substring(10, 12));
|
|
418
|
-
const second = parseInt(str.substring(12, 14));
|
|
419
|
-
return new Date(Date.UTC(year, month, day, hour, minute, second));
|
|
420
|
-
}
|
|
421
|
-
throw new Error('Failed to parse MDTM response');
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
/**
|
|
425
|
-
* Get connection statistics
|
|
426
|
-
* @returns {Object}
|
|
427
|
-
*/
|
|
428
|
-
getStats() {
|
|
429
|
-
return {
|
|
430
|
-
connected: this.connected,
|
|
431
|
-
authenticated: this.authenticated,
|
|
432
|
-
commandCount: this._commandCount,
|
|
433
|
-
lastCommand: this._lastCommand
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/**
|
|
438
|
-
* Enable or disable debug mode
|
|
439
|
-
* @param {boolean} enabled - Enable debug mode
|
|
440
|
-
*/
|
|
441
|
-
setDebug(enabled) {
|
|
442
|
-
this.debug = enabled;
|
|
443
|
-
this._debug(`Debug mode ${enabled ? 'enabled' : 'disabled'}`);
|
|
444
|
-
}
|
|
445
12
|
|
|
446
|
-
/**
|
|
447
|
-
* Close connection
|
|
448
|
-
* @returns {Promise<void>}
|
|
449
|
-
*/
|
|
450
|
-
async close() {
|
|
451
|
-
if (this.connected) {
|
|
452
|
-
this._debug('Closing connection...');
|
|
453
|
-
try {
|
|
454
|
-
await this._sendCommand('QUIT');
|
|
455
|
-
} catch (err) {
|
|
456
|
-
this._debug('Error during QUIT:', err.message);
|
|
457
|
-
}
|
|
458
|
-
this.socket.end();
|
|
459
|
-
this.connected = false;
|
|
460
|
-
this.authenticated = false;
|
|
461
|
-
this._debug('Connection closed');
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
13
|
|
|
465
|
-
|
|
466
|
-
* Disconnect (alias for close)
|
|
467
|
-
* @returns {Promise<void>}
|
|
468
|
-
*/
|
|
469
|
-
async disconnect() {
|
|
470
|
-
return this.close();
|
|
471
|
-
}
|
|
472
|
-
}
|
|
14
|
+
const FTPClient = require('./lib/FTPClient');
|
|
473
15
|
|
|
474
16
|
module.exports = FTPClient;
|
|
475
17
|
module.exports.FTPClient = FTPClient;
|
package/lib/FTPClient.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
const { EventEmitter } = require('events');
|
|
2
|
+
const FTPConnection = require('./connection');
|
|
3
|
+
const FTPCommands = require('./commands');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Lightweight FTP Client using native Node.js TCP sockets (net module)
|
|
7
|
+
*/
|
|
8
|
+
class FTPClient extends EventEmitter {
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
super();
|
|
11
|
+
|
|
12
|
+
// Connection state
|
|
13
|
+
this.socket = null;
|
|
14
|
+
this.dataSocket = null;
|
|
15
|
+
this.buffer = '';
|
|
16
|
+
this.connected = false;
|
|
17
|
+
this.authenticated = false;
|
|
18
|
+
|
|
19
|
+
// Configuration
|
|
20
|
+
this.debug = options.debug || false;
|
|
21
|
+
this.timeout = options.timeout || 30000;
|
|
22
|
+
this.keepAlive = options.keepAlive !== false;
|
|
23
|
+
this._log = options.logger || console.log;
|
|
24
|
+
|
|
25
|
+
// Statistics
|
|
26
|
+
this._commandCount = 0;
|
|
27
|
+
this._lastCommand = null;
|
|
28
|
+
|
|
29
|
+
// Initialize subsystems
|
|
30
|
+
this._connection = new FTPConnection(this);
|
|
31
|
+
this._commands = new FTPCommands(this);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Log message if debug is enabled
|
|
36
|
+
* @private
|
|
37
|
+
*/
|
|
38
|
+
_debug(...args) {
|
|
39
|
+
if (this.debug && this._log) {
|
|
40
|
+
this._log('[FTP Debug]', ...args);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Connect to FTP server
|
|
46
|
+
* @param {Object} options - Connection options
|
|
47
|
+
* @param {string} options.host - FTP server host
|
|
48
|
+
* @param {number} [options.port=21] - FTP server port
|
|
49
|
+
* @param {string} [options.user='anonymous'] - Username
|
|
50
|
+
* @param {string} [options.password='anonymous@'] - Password
|
|
51
|
+
* @returns {Promise<void>}
|
|
52
|
+
*/
|
|
53
|
+
async connect(options) {
|
|
54
|
+
return this._connection.connect(options);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Upload file to FTP server
|
|
59
|
+
* @param {string|Buffer} data - File data
|
|
60
|
+
* @param {string} remotePath - Remote file path
|
|
61
|
+
* @returns {Promise<void>}
|
|
62
|
+
*/
|
|
63
|
+
async upload(data, remotePath) {
|
|
64
|
+
return this._commands.upload(data, remotePath);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Download file from FTP server
|
|
69
|
+
* @param {string} remotePath - Remote file path
|
|
70
|
+
* @returns {Promise<Buffer>}
|
|
71
|
+
*/
|
|
72
|
+
async download(remotePath) {
|
|
73
|
+
return this._commands.download(remotePath);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* List directory contents
|
|
78
|
+
* @param {string} [path='.'] - Directory path
|
|
79
|
+
* @returns {Promise<string>}
|
|
80
|
+
*/
|
|
81
|
+
async list(path = '.') {
|
|
82
|
+
return this._commands.list(path);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Change working directory
|
|
87
|
+
* @param {string} path - Directory path
|
|
88
|
+
* @returns {Promise<void>}
|
|
89
|
+
*/
|
|
90
|
+
async cd(path) {
|
|
91
|
+
return this._commands.cd(path);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get current working directory
|
|
96
|
+
* @returns {Promise<string>}
|
|
97
|
+
*/
|
|
98
|
+
async pwd() {
|
|
99
|
+
return this._commands.pwd();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Create directory
|
|
104
|
+
* @param {string} path - Directory path
|
|
105
|
+
* @returns {Promise<void>}
|
|
106
|
+
*/
|
|
107
|
+
async mkdir(path) {
|
|
108
|
+
return this._commands.mkdir(path);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Delete file
|
|
113
|
+
* @param {string} path - File path
|
|
114
|
+
* @returns {Promise<void>}
|
|
115
|
+
*/
|
|
116
|
+
async delete(path) {
|
|
117
|
+
return this._commands.delete(path);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Rename file
|
|
122
|
+
* @param {string} from - Current name
|
|
123
|
+
* @param {string} to - New name
|
|
124
|
+
* @returns {Promise<void>}
|
|
125
|
+
*/
|
|
126
|
+
async rename(from, to) {
|
|
127
|
+
return this._commands.rename(from, to);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get file size
|
|
132
|
+
* @param {string} path - File path
|
|
133
|
+
* @returns {Promise<number>}
|
|
134
|
+
*/
|
|
135
|
+
async size(path) {
|
|
136
|
+
return this._commands.size(path);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Check if file or directory exists
|
|
141
|
+
* @param {string} path - File or directory path
|
|
142
|
+
* @returns {Promise<boolean>}
|
|
143
|
+
*/
|
|
144
|
+
async exists(path) {
|
|
145
|
+
return this._commands.exists(path);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Ensure directory exists, creating it if necessary
|
|
150
|
+
* @param {string} dirPath - Directory path to ensure exists
|
|
151
|
+
* @param {boolean} recursive - Create parent directories if needed (default: true)
|
|
152
|
+
* @returns {Promise<void>}
|
|
153
|
+
*/
|
|
154
|
+
async ensureDir(dirPath, recursive = true) {
|
|
155
|
+
return this._commands.ensureDir(dirPath, recursive);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Ensure parent directory exists for a file path
|
|
160
|
+
* @param {string} filePath - File path
|
|
161
|
+
* @returns {Promise<void>}
|
|
162
|
+
*/
|
|
163
|
+
async ensureParentDir(filePath) {
|
|
164
|
+
return this._commands.ensureParentDir(filePath);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Upload file and ensure parent directory exists
|
|
169
|
+
* @param {string|Buffer} data - File data
|
|
170
|
+
* @param {string} remotePath - Remote file path
|
|
171
|
+
* @param {boolean} ensureDir - Ensure parent directory exists (default: false)
|
|
172
|
+
* @returns {Promise<void>}
|
|
173
|
+
*/
|
|
174
|
+
async uploadFile(data, remotePath, ensureDir = false) {
|
|
175
|
+
return this._commands.uploadFile(data, remotePath, ensureDir);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Get file modification time
|
|
180
|
+
* @param {string} path - File path
|
|
181
|
+
* @returns {Promise<Date>}
|
|
182
|
+
*/
|
|
183
|
+
async modifiedTime(path) {
|
|
184
|
+
return this._commands.modifiedTime(path);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Get connection statistics
|
|
189
|
+
* @returns {Object}
|
|
190
|
+
*/
|
|
191
|
+
getStats() {
|
|
192
|
+
return {
|
|
193
|
+
connected: this.connected,
|
|
194
|
+
authenticated: this.authenticated,
|
|
195
|
+
commandCount: this._commandCount,
|
|
196
|
+
lastCommand: this._lastCommand
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Enable or disable debug mode
|
|
202
|
+
* @param {boolean} enabled - Enable debug mode
|
|
203
|
+
*/
|
|
204
|
+
setDebug(enabled) {
|
|
205
|
+
this.debug = enabled;
|
|
206
|
+
this._debug(`Debug mode ${enabled ? 'enabled' : 'disabled'}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Close connection
|
|
211
|
+
* @returns {Promise<void>}
|
|
212
|
+
*/
|
|
213
|
+
async close() {
|
|
214
|
+
return this._connection.close();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Disconnect (alias for close)
|
|
219
|
+
* @returns {Promise<void>}
|
|
220
|
+
*/
|
|
221
|
+
async disconnect() {
|
|
222
|
+
return this.close();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = FTPClient;
|
package/lib/commands.js
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
const net = require('net');
|
|
2
|
+
const { normalizePath, getParentDir, parseMdtmResponse } = require('./utils');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* FTP command implementations
|
|
6
|
+
*/
|
|
7
|
+
class FTPCommands {
|
|
8
|
+
constructor(client) {
|
|
9
|
+
this.client = client;
|
|
10
|
+
this.connection = client._connection;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Upload file to FTP server
|
|
15
|
+
* @param {string|Buffer} data - File data
|
|
16
|
+
* @param {string} remotePath - Remote file path
|
|
17
|
+
* @returns {Promise<void>}
|
|
18
|
+
*/
|
|
19
|
+
async upload(data, remotePath) {
|
|
20
|
+
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
|
21
|
+
this.client._debug(`Uploading ${buffer.length} bytes to ${remotePath}`);
|
|
22
|
+
const { host, port } = await this.connection.enterPassiveMode();
|
|
23
|
+
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
let commandSent = false;
|
|
26
|
+
|
|
27
|
+
this.client.dataSocket = net.createConnection({ host, port }, () => {
|
|
28
|
+
// Send STOR command to start upload (expects 150, then 226)
|
|
29
|
+
if (!commandSent) {
|
|
30
|
+
commandSent = true;
|
|
31
|
+
this.client._debug(`Data connection established for upload`);
|
|
32
|
+
this.connection.sendCommand(`STOR ${remotePath}`, true).catch(reject);
|
|
33
|
+
|
|
34
|
+
// Write data to data socket
|
|
35
|
+
this.client.dataSocket.write(buffer);
|
|
36
|
+
this.client.dataSocket.end();
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
this.client.dataSocket.on('error', reject);
|
|
41
|
+
|
|
42
|
+
this.client.dataSocket.on('close', () => {
|
|
43
|
+
// Wait for final response from control socket
|
|
44
|
+
const finalHandler = (line) => {
|
|
45
|
+
const code = parseInt(line.substring(0, 3));
|
|
46
|
+
if (code === 226 || code === 250) {
|
|
47
|
+
this.client.removeListener('response', finalHandler);
|
|
48
|
+
this.client._debug(`Upload completed successfully`);
|
|
49
|
+
resolve();
|
|
50
|
+
} else if (code >= 400) {
|
|
51
|
+
this.client.removeListener('response', finalHandler);
|
|
52
|
+
reject(new Error(`FTP Error ${code}: ${line.substring(4)}`));
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
this.client.on('response', finalHandler);
|
|
56
|
+
|
|
57
|
+
// Timeout if no response
|
|
58
|
+
setTimeout(() => {
|
|
59
|
+
this.client.removeListener('response', finalHandler);
|
|
60
|
+
resolve();
|
|
61
|
+
}, 5000);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Download file from FTP server
|
|
68
|
+
* @param {string} remotePath - Remote file path
|
|
69
|
+
* @returns {Promise<Buffer>}
|
|
70
|
+
*/
|
|
71
|
+
async download(remotePath) {
|
|
72
|
+
this.client._debug(`Downloading ${remotePath}`);
|
|
73
|
+
const { host, port } = await this.connection.enterPassiveMode();
|
|
74
|
+
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const chunks = [];
|
|
77
|
+
let commandSent = false;
|
|
78
|
+
|
|
79
|
+
this.client.dataSocket = net.createConnection({ host, port }, () => {
|
|
80
|
+
// Send RETR command to start download (expects 150, then 226)
|
|
81
|
+
if (!commandSent) {
|
|
82
|
+
commandSent = true;
|
|
83
|
+
this.client._debug(`Data connection established for download`);
|
|
84
|
+
this.connection.sendCommand(`RETR ${remotePath}`, true).catch(reject);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
this.client.dataSocket.on('data', (chunk) => {
|
|
89
|
+
chunks.push(chunk);
|
|
90
|
+
this.client._debug(`Received ${chunk.length} bytes`);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
this.client.dataSocket.on('error', reject);
|
|
94
|
+
|
|
95
|
+
this.client.dataSocket.on('close', () => {
|
|
96
|
+
// Wait for final 226 response
|
|
97
|
+
const finalHandler = (line) => {
|
|
98
|
+
const code = parseInt(line.substring(0, 3));
|
|
99
|
+
if (code === 226 || code === 250) {
|
|
100
|
+
this.client.removeListener('response', finalHandler);
|
|
101
|
+
const result = Buffer.concat(chunks);
|
|
102
|
+
this.client._debug(`Download completed: ${result.length} bytes`);
|
|
103
|
+
resolve(result);
|
|
104
|
+
} else if (code >= 400) {
|
|
105
|
+
this.client.removeListener('response', finalHandler);
|
|
106
|
+
reject(new Error(`FTP Error ${code}: ${line.substring(4)}`));
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
this.client.on('response', finalHandler);
|
|
110
|
+
|
|
111
|
+
// Timeout if no response
|
|
112
|
+
setTimeout(() => {
|
|
113
|
+
this.client.removeListener('response', finalHandler);
|
|
114
|
+
if (chunks.length > 0) {
|
|
115
|
+
resolve(Buffer.concat(chunks));
|
|
116
|
+
}
|
|
117
|
+
}, 5000);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* List directory contents
|
|
124
|
+
* @param {string} [path='.'] - Directory path
|
|
125
|
+
* @returns {Promise<string>}
|
|
126
|
+
*/
|
|
127
|
+
async list(path = '.') {
|
|
128
|
+
this.client._debug(`Listing directory: ${path}`);
|
|
129
|
+
const { host, port } = await this.connection.enterPassiveMode();
|
|
130
|
+
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const chunks = [];
|
|
133
|
+
let commandSent = false;
|
|
134
|
+
|
|
135
|
+
this.client.dataSocket = net.createConnection({ host, port }, () => {
|
|
136
|
+
if (!commandSent) {
|
|
137
|
+
commandSent = true;
|
|
138
|
+
this.connection.sendCommand(`LIST ${path}`, true).catch(reject);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
this.client.dataSocket.on('data', (chunk) => {
|
|
143
|
+
chunks.push(chunk);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
this.client.dataSocket.on('error', reject);
|
|
147
|
+
|
|
148
|
+
this.client.dataSocket.on('close', () => {
|
|
149
|
+
// Wait for final 226 response
|
|
150
|
+
const finalHandler = (line) => {
|
|
151
|
+
const code = parseInt(line.substring(0, 3));
|
|
152
|
+
if (code === 226 || code === 250) {
|
|
153
|
+
this.client.removeListener('response', finalHandler);
|
|
154
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
this.client.on('response', finalHandler);
|
|
158
|
+
|
|
159
|
+
// Timeout fallback
|
|
160
|
+
setTimeout(() => {
|
|
161
|
+
this.client.removeListener('response', finalHandler);
|
|
162
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
163
|
+
}, 3000);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Change working directory
|
|
170
|
+
* @param {string} path - Directory path
|
|
171
|
+
* @returns {Promise<void>}
|
|
172
|
+
*/
|
|
173
|
+
async cd(path) {
|
|
174
|
+
await this.connection.sendCommand(`CWD ${path}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Get current working directory
|
|
179
|
+
* @returns {Promise<string>}
|
|
180
|
+
*/
|
|
181
|
+
async pwd() {
|
|
182
|
+
const response = await this.connection.sendCommand('PWD');
|
|
183
|
+
const match = response.message.match(/"(.+)"/);
|
|
184
|
+
return match ? match[1] : '/';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Create directory
|
|
189
|
+
* @param {string} path - Directory path
|
|
190
|
+
* @returns {Promise<void>}
|
|
191
|
+
*/
|
|
192
|
+
async mkdir(path) {
|
|
193
|
+
await this.connection.sendCommand(`MKD ${path}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Delete file
|
|
198
|
+
* @param {string} path - File path
|
|
199
|
+
* @returns {Promise<void>}
|
|
200
|
+
*/
|
|
201
|
+
async delete(path) {
|
|
202
|
+
await this.connection.sendCommand(`DELE ${path}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Rename file
|
|
207
|
+
* @param {string} from - Current name
|
|
208
|
+
* @param {string} to - New name
|
|
209
|
+
* @returns {Promise<void>}
|
|
210
|
+
*/
|
|
211
|
+
async rename(from, to) {
|
|
212
|
+
await this.connection.sendCommand(`RNFR ${from}`);
|
|
213
|
+
await this.connection.sendCommand(`RNTO ${to}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Get file size
|
|
218
|
+
* @param {string} path - File path
|
|
219
|
+
* @returns {Promise<number>}
|
|
220
|
+
*/
|
|
221
|
+
async size(path) {
|
|
222
|
+
this.client._debug(`Getting size of ${path}`)
|
|
223
|
+
const response = await this.connection.sendCommand(`SIZE ${path}`);
|
|
224
|
+
return parseInt(response.message);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Check if file or directory exists
|
|
229
|
+
* @param {string} path - File or directory path
|
|
230
|
+
* @returns {Promise<boolean>}
|
|
231
|
+
*/
|
|
232
|
+
async exists(path) {
|
|
233
|
+
try {
|
|
234
|
+
await this.size(path);
|
|
235
|
+
return true;
|
|
236
|
+
} catch (err) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Ensure directory exists, creating it if necessary
|
|
243
|
+
* @param {string} dirPath - Directory path to ensure exists
|
|
244
|
+
* @param {boolean} recursive - Create parent directories if needed (default: true)
|
|
245
|
+
* @returns {Promise<void>}
|
|
246
|
+
*/
|
|
247
|
+
async ensureDir(dirPath, recursive = true) {
|
|
248
|
+
this.client._debug(`Ensuring directory exists: ${dirPath}`);
|
|
249
|
+
|
|
250
|
+
// Normalize path
|
|
251
|
+
const normalized = normalizePath(dirPath);
|
|
252
|
+
if (normalized === '/' || normalized === '.') {
|
|
253
|
+
return; // Root or current directory always exists
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Try to cd to the directory
|
|
257
|
+
try {
|
|
258
|
+
await this.cd(normalized);
|
|
259
|
+
this.client._debug(`Directory already exists: ${normalized}`);
|
|
260
|
+
return;
|
|
261
|
+
} catch (err) {
|
|
262
|
+
this.client._debug(`Directory doesn't exist: ${normalized}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// If recursive, ensure parent directory exists first
|
|
266
|
+
if (recursive) {
|
|
267
|
+
const parentDir = normalized.substring(0, normalized.lastIndexOf('/')) || '/';
|
|
268
|
+
if (parentDir !== '/' && parentDir !== '.') {
|
|
269
|
+
await this.ensureDir(parentDir, true);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Create the directory
|
|
274
|
+
try {
|
|
275
|
+
await this.mkdir(normalized);
|
|
276
|
+
this.client._debug(`Created directory: ${normalized}`);
|
|
277
|
+
} catch (err) {
|
|
278
|
+
// Ignore error if directory was created by another process
|
|
279
|
+
if (!err.message.includes('550') && !err.message.includes('exists')) {
|
|
280
|
+
throw err;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Ensure parent directory exists for a file path
|
|
287
|
+
* @param {string} filePath - File path
|
|
288
|
+
* @returns {Promise<void>}
|
|
289
|
+
*/
|
|
290
|
+
async ensureParentDir(filePath) {
|
|
291
|
+
const parentDir = getParentDir(filePath);
|
|
292
|
+
if (parentDir && parentDir !== '.' && parentDir !== '/') {
|
|
293
|
+
await this.ensureDir(parentDir);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Upload file and ensure parent directory exists
|
|
299
|
+
* @param {string|Buffer} data - File data
|
|
300
|
+
* @param {string} remotePath - Remote file path
|
|
301
|
+
* @param {boolean} ensureDir - Ensure parent directory exists (default: false)
|
|
302
|
+
* @returns {Promise<void>}
|
|
303
|
+
*/
|
|
304
|
+
async uploadFile(data, remotePath, ensureDir = false) {
|
|
305
|
+
if (ensureDir) {
|
|
306
|
+
await this.ensureParentDir(remotePath);
|
|
307
|
+
}
|
|
308
|
+
return this.upload(data, remotePath);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Get file modification time
|
|
313
|
+
* @param {string} path - File path
|
|
314
|
+
* @returns {Promise<Date>}
|
|
315
|
+
*/
|
|
316
|
+
async modifiedTime(path) {
|
|
317
|
+
this.client._debug(`Getting modification time of ${path}`);
|
|
318
|
+
const response = await this.connection.sendCommand(`MDTM ${path}`);
|
|
319
|
+
return parseMdtmResponse(response.message);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
module.exports = FTPCommands;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
const net = require('net');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Handle FTP connection establishment and authentication
|
|
5
|
+
*/
|
|
6
|
+
class FTPConnection {
|
|
7
|
+
constructor(client) {
|
|
8
|
+
this.client = client;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Connect to FTP server and authenticate
|
|
13
|
+
* @param {Object} options - Connection options
|
|
14
|
+
* @param {string} options.host - FTP server host
|
|
15
|
+
* @param {number} [options.port=21] - FTP server port
|
|
16
|
+
* @param {string} [options.user='anonymous'] - Username
|
|
17
|
+
* @param {string} [options.password='anonymous@'] - Password
|
|
18
|
+
* @returns {Promise<void>}
|
|
19
|
+
*/
|
|
20
|
+
async connect({ host, port = 21, user = 'anonymous', password = 'anonymous@' }) {
|
|
21
|
+
this.client._debug(`Connecting to ${host}:${port} as ${user}`);
|
|
22
|
+
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
this.client.socket = net.createConnection({ host, port }, () => {
|
|
25
|
+
this.client.connected = true;
|
|
26
|
+
this.client._debug('TCP connection established');
|
|
27
|
+
|
|
28
|
+
if (this.client.keepAlive) {
|
|
29
|
+
this.client.socket.setKeepAlive(true, 10000);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
this.client.emit('connected');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
this.client.socket.setEncoding('utf8');
|
|
36
|
+
this.client.socket.on('data', async (data) => {
|
|
37
|
+
this.client.buffer += data;
|
|
38
|
+
const lines = this.client.buffer.split('\r\n');
|
|
39
|
+
this.client.buffer = lines.pop();
|
|
40
|
+
|
|
41
|
+
for (const line of lines) {
|
|
42
|
+
if (line) {
|
|
43
|
+
this.client._debug('<<<', line);
|
|
44
|
+
this.client.emit('response', line);
|
|
45
|
+
const code = parseInt(line.substring(0, 3));
|
|
46
|
+
|
|
47
|
+
// Handle initial connection
|
|
48
|
+
if (code === 220 && !this.client.authenticated) {
|
|
49
|
+
try {
|
|
50
|
+
this.client._debug('Authenticating...');
|
|
51
|
+
await this.sendCommand(`USER ${user}`);
|
|
52
|
+
await this.sendCommand(`PASS ${password}`);
|
|
53
|
+
this.client.authenticated = true;
|
|
54
|
+
this.client._debug('Authentication successful');
|
|
55
|
+
resolve();
|
|
56
|
+
} catch (err) {
|
|
57
|
+
reject(err);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
this.client.socket.on('error', (err) => {
|
|
65
|
+
this.client.emit('error', err);
|
|
66
|
+
reject(err);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
this.client.socket.on('close', () => {
|
|
70
|
+
this.client.connected = false;
|
|
71
|
+
this.client.authenticated = false;
|
|
72
|
+
this.client.emit('close');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
setTimeout(() => reject(new Error('Connection timeout')), 10000);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Send FTP command and wait for response
|
|
81
|
+
* @param {string} command - FTP command
|
|
82
|
+
* @param {boolean} allowPreliminary - Allow 1xx preliminary responses
|
|
83
|
+
* @returns {Promise<Object>}
|
|
84
|
+
*/
|
|
85
|
+
sendCommand(command, allowPreliminary = false) {
|
|
86
|
+
const { maskPassword } = require('./utils');
|
|
87
|
+
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
if (!this.client.connected) {
|
|
90
|
+
return reject(new Error('Not connected'));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
this.client._commandCount++;
|
|
94
|
+
this.client._lastCommand = command;
|
|
95
|
+
const cmdToLog = maskPassword(command);
|
|
96
|
+
this.client._debug('>>>', cmdToLog);
|
|
97
|
+
|
|
98
|
+
const timeoutId = setTimeout(() => {
|
|
99
|
+
this.client.removeListener('response', responseHandler);
|
|
100
|
+
reject(new Error(`Command timeout: ${cmdToLog}`));
|
|
101
|
+
}, this.client.timeout);
|
|
102
|
+
|
|
103
|
+
const responseHandler = (line) => {
|
|
104
|
+
clearTimeout(timeoutId);
|
|
105
|
+
const code = parseInt(line.substring(0, 3));
|
|
106
|
+
const message = line.substring(4);
|
|
107
|
+
|
|
108
|
+
// Check if this is a complete response (not a multi-line response in progress)
|
|
109
|
+
if (line.charAt(3) === ' ') {
|
|
110
|
+
// 1xx = Preliminary positive reply (command okay, another command expected)
|
|
111
|
+
// 2xx = Positive completion reply
|
|
112
|
+
// 3xx = Positive intermediate reply (command okay, awaiting more info)
|
|
113
|
+
// 4xx/5xx = Negative replies (errors)
|
|
114
|
+
|
|
115
|
+
if (code >= 100 && code < 200 && allowPreliminary) {
|
|
116
|
+
// Don't remove listener, wait for final response
|
|
117
|
+
this.client._debug('Preliminary response, waiting for completion...');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
clearTimeout(timeoutId);
|
|
122
|
+
this.client.removeListener('response', responseHandler);
|
|
123
|
+
|
|
124
|
+
if (code >= 200 && code < 400) {
|
|
125
|
+
resolve({ code, message, raw: line });
|
|
126
|
+
} else {
|
|
127
|
+
this.client._debug(`Error response: ${code}`);
|
|
128
|
+
reject(new Error(`FTP Error ${code}: ${message}`));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
this.client.on('response', responseHandler);
|
|
134
|
+
this.client.socket.write(command + '\r\n');
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Enter passive mode and get data connection info
|
|
140
|
+
* @returns {Promise<Object>}
|
|
141
|
+
*/
|
|
142
|
+
async enterPassiveMode() {
|
|
143
|
+
const response = await this.sendCommand('PASV');
|
|
144
|
+
const match = response.message.match(/\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)/);
|
|
145
|
+
|
|
146
|
+
if (!match) {
|
|
147
|
+
throw new Error('Failed to parse PASV response');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const host = `${match[1]}.${match[2]}.${match[3]}.${match[4]}`;
|
|
151
|
+
const port = parseInt(match[5]) * 256 + parseInt(match[6]);
|
|
152
|
+
|
|
153
|
+
return { host, port };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Close connection
|
|
158
|
+
* @returns {Promise<void>}
|
|
159
|
+
*/
|
|
160
|
+
async close() {
|
|
161
|
+
if (this.client.connected) {
|
|
162
|
+
this.client._debug('Closing connection...');
|
|
163
|
+
try {
|
|
164
|
+
await this.sendCommand('QUIT');
|
|
165
|
+
} catch (err) {
|
|
166
|
+
this.client._debug('Error during QUIT:', err.message);
|
|
167
|
+
}
|
|
168
|
+
this.client.socket.end();
|
|
169
|
+
this.client.connected = false;
|
|
170
|
+
this.client.authenticated = false;
|
|
171
|
+
this.client._debug('Connection closed');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = FTPConnection;
|
package/lib/utils.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FTP Command helpers
|
|
3
|
+
* Utilities for building and parsing FTP commands
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parse PASV response to extract host and port
|
|
8
|
+
* @param {string} message - PASV response message
|
|
9
|
+
* @returns {Object} - { host, port }
|
|
10
|
+
*/
|
|
11
|
+
function parsePasvResponse(message) {
|
|
12
|
+
const match = message.match(/\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)/);
|
|
13
|
+
|
|
14
|
+
if (!match) {
|
|
15
|
+
throw new Error('Failed to parse PASV response');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const host = `${match[1]}.${match[2]}.${match[3]}.${match[4]}`;
|
|
19
|
+
const port = parseInt(match[5]) * 256 + parseInt(match[6]);
|
|
20
|
+
|
|
21
|
+
return { host, port };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse MDTM response to Date object
|
|
26
|
+
* @param {string} message - MDTM response message
|
|
27
|
+
* @returns {Date}
|
|
28
|
+
*/
|
|
29
|
+
function parseMdtmResponse(message) {
|
|
30
|
+
const match = message.match(/(\d{14})/);
|
|
31
|
+
if (!match) {
|
|
32
|
+
throw new Error('Failed to parse MDTM response');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const str = match[1];
|
|
36
|
+
const year = parseInt(str.substring(0, 4));
|
|
37
|
+
const month = parseInt(str.substring(4, 6)) - 1;
|
|
38
|
+
const day = parseInt(str.substring(6, 8));
|
|
39
|
+
const hour = parseInt(str.substring(8, 10));
|
|
40
|
+
const minute = parseInt(str.substring(10, 12));
|
|
41
|
+
const second = parseInt(str.substring(12, 14));
|
|
42
|
+
|
|
43
|
+
return new Date(Date.UTC(year, month, day, hour, minute, second));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Normalize FTP path
|
|
48
|
+
* @param {string} path - Path to normalize
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
function normalizePath(path) {
|
|
52
|
+
return path.replace(/\\/g, '/').replace(/\/+/g, '/');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get parent directory path
|
|
57
|
+
* @param {string} filePath - File path
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
function getParentDir(filePath) {
|
|
61
|
+
const normalized = normalizePath(filePath);
|
|
62
|
+
const lastSlash = normalized.lastIndexOf('/');
|
|
63
|
+
if (lastSlash <= 0) {
|
|
64
|
+
return '/';
|
|
65
|
+
}
|
|
66
|
+
return normalized.substring(0, lastSlash);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Mask password in command for logging
|
|
71
|
+
* @param {string} command - FTP command
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
function maskPassword(command) {
|
|
75
|
+
return command.startsWith('PASS ') ? 'PASS ********' : command;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
parsePasvResponse,
|
|
80
|
+
parseMdtmResponse,
|
|
81
|
+
normalizePath,
|
|
82
|
+
getParentDir,
|
|
83
|
+
maskPassword
|
|
84
|
+
};
|