├── LICENSE ├── README.md ├── TODO ├── lib ├── connection.js └── parser.js ├── package.json └── test ├── test-parser.js └── test.js /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Brian White. All rights reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to 5 | deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | sell copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 19 | IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Description 2 | =========== 3 | 4 | node-ftp is an FTP client module for [node.js](http://nodejs.org/) that provides an asynchronous interface for communicating with an FTP server. 5 | 6 | 7 | Requirements 8 | ============ 9 | 10 | * [node.js](http://nodejs.org/) -- v0.8.0 or newer 11 | 12 | 13 | Install 14 | ======= 15 | 16 | npm install ftp 17 | 18 | 19 | Examples 20 | ======== 21 | 22 | * Get a directory listing of the current (remote) working directory: 23 | 24 | ```javascript 25 | var Client = require('ftp'); 26 | 27 | var c = new Client(); 28 | c.on('ready', function() { 29 | c.list(function(err, list) { 30 | if (err) throw err; 31 | console.dir(list); 32 | c.end(); 33 | }); 34 | }); 35 | // connect to localhost:21 as anonymous 36 | c.connect(); 37 | ``` 38 | 39 | * Download remote file 'foo.txt' and save it to the local file system: 40 | 41 | ```javascript 42 | var Client = require('ftp'); 43 | var fs = require('fs'); 44 | 45 | var c = new Client(); 46 | c.on('ready', function() { 47 | c.get('foo.txt', function(err, stream) { 48 | if (err) throw err; 49 | stream.once('close', function() { c.end(); }); 50 | stream.pipe(fs.createWriteStream('foo.local-copy.txt')); 51 | }); 52 | }); 53 | // connect to localhost:21 as anonymous 54 | c.connect(); 55 | ``` 56 | 57 | * Upload local file 'foo.txt' to the server: 58 | 59 | ```javascript 60 | var Client = require('ftp'); 61 | var fs = require('fs'); 62 | 63 | var c = new Client(); 64 | c.on('ready', function() { 65 | c.put('foo.txt', 'foo.remote-copy.txt', function(err) { 66 | if (err) throw err; 67 | c.end(); 68 | }); 69 | }); 70 | // connect to localhost:21 as anonymous 71 | c.connect(); 72 | ``` 73 | 74 | 75 | API 76 | === 77 | 78 | Events 79 | ------ 80 | 81 | * **greeting**(< _string_ >msg) - Emitted after connection. `msg` is the text the server sent upon connection. 82 | 83 | * **ready**() - Emitted when connection and authentication were sucessful. 84 | 85 | * **close**(< _boolean_ >hadErr) - Emitted when the connection has fully closed. 86 | 87 | * **end**() - Emitted when the connection has ended. 88 | 89 | * **error**(< _Error_ >err) - Emitted when an error occurs. In case of protocol-level errors, `err` contains a 'code' property that references the related 3-digit FTP response code. 90 | 91 | 92 | Methods 93 | ------- 94 | 95 | **\* Note: As with the 'error' event, any error objects passed to callbacks will have a 'code' property for protocol-level errors.** 96 | 97 | * **(constructor)**() - Creates and returns a new FTP client instance. 98 | 99 | * **connect**(< _object_ >config) - _(void)_ - Connects to an FTP server. Valid config properties: 100 | 101 | * host - _string_ - The hostname or IP address of the FTP server. **Default:** 'localhost' 102 | 103 | * port - _integer_ - The port of the FTP server. **Default:** 21 104 | 105 | * secure - _mixed_ - Set to true for both control and data connection encryption, 'control' for control connection encryption only, or 'implicit' for implicitly encrypted control connection (this mode is deprecated in modern times, but usually uses port 990) **Default:** false 106 | 107 | * secureOptions - _object_ - Additional options to be passed to `tls.connect()`. **Default:** (none) 108 | 109 | * user - _string_ - Username for authentication. **Default:** 'anonymous' 110 | 111 | * password - _string_ - Password for authentication. **Default:** 'anonymous@' 112 | 113 | * connTimeout - _integer_ - How long (in milliseconds) to wait for the control connection to be established. **Default:** 10000 114 | 115 | * pasvTimeout - _integer_ - How long (in milliseconds) to wait for a PASV data connection to be established. **Default:** 10000 116 | 117 | * keepalive - _integer_ - How often (in milliseconds) to send a 'dummy' (NOOP) command to keep the connection alive. **Default:** 10000 118 | 119 | * **end**() - _(void)_ - Closes the connection to the server after any/all enqueued commands have been executed. 120 | 121 | * **destroy**() - _(void)_ - Closes the connection to the server immediately. 122 | 123 | ### Required "standard" commands (RFC 959) 124 | 125 | * **list**([< _string_ >path, ][< _boolean_ >useCompression, ]< _function_ >callback) - _(void)_ - Retrieves the directory listing of `path`. `path` defaults to the current working directory. `useCompression` defaults to false. `callback` has 2 parameters: < _Error_ >err, < _array_ >list. `list` is an array of objects with these properties: 126 | 127 | * type - _string_ - A single character denoting the entry type: 'd' for directory, '-' for file (or 'l' for symlink on **\*NIX only**). 128 | 129 | * name - _string_ - The name of the entry. 130 | 131 | * size - _string_ - The size of the entry in bytes. 132 | 133 | * date - _Date_ - The last modified date of the entry. 134 | 135 | * rights - _object_ - The various permissions for this entry **(*NIX only)**. 136 | 137 | * user - _string_ - An empty string or any combination of 'r', 'w', 'x'. 138 | 139 | * group - _string_ - An empty string or any combination of 'r', 'w', 'x'. 140 | 141 | * other - _string_ - An empty string or any combination of 'r', 'w', 'x'. 142 | 143 | * owner - _string_ - The user name or ID that this entry belongs to **(*NIX only)**. 144 | 145 | * group - _string_ - The group name or ID that this entry belongs to **(*NIX only)**. 146 | 147 | * target - _string_ - For symlink entries, this is the symlink's target **(*NIX only)**. 148 | 149 | * sticky - _boolean_ - True if the sticky bit is set for this entry **(*NIX only)**. 150 | 151 | * **get**(< _string_ >path, [< _boolean_ >useCompression, ]< _function_ >callback) - _(void)_ - Retrieves a file at `path` from the server. `useCompression` defaults to false. `callback` has 2 parameters: < _Error_ >err, < _ReadableStream_ >fileStream. 152 | 153 | * **put**(< _mixed_ >input, < _string_ >destPath, [< _boolean_ >useCompression, ]< _function_ >callback) - _(void)_ - Sends data to the server to be stored as `destPath`. `input` can be a ReadableStream, a Buffer, or a path to a local file. `useCompression` defaults to false. `callback` has 1 parameter: < _Error_ >err. 154 | 155 | * **append**(< _mixed_ >input, < _string_ >destPath, [< _boolean_ >useCompression, ]< _function_ >callback) - _(void)_ - Same as **put()**, except if `destPath` already exists, it will be appended to instead of overwritten. 156 | 157 | * **rename**(< _string_ >oldPath, < _string_ >newPath, < _function_ >callback) - _(void)_ - Renames `oldPath` to `newPath` on the server. `callback` has 1 parameter: < _Error_ >err. 158 | 159 | * **logout**(< _function_ >callback) - _(void)_ - Logout the user from the server. `callback` has 1 parameter: < _Error_ >err. 160 | 161 | * **delete**(< _string_ >path, < _function_ >callback) - _(void)_ - Deletes a file, `path`, on the server. `callback` has 1 parameter: < _Error_ >err. 162 | 163 | * **cwd**(< _string_ >path, < _function_ >callback) - _(void)_ - Changes the current working directory to `path`. `callback` has 2 parameters: < _Error_ >err, < _string_ >currentDir. Note: `currentDir` is only given if the server replies with the path in the response text. 164 | 165 | * **abort**(< _function_ >callback) - _(void)_ - Aborts the current data transfer (e.g. from get(), put(), or list()). `callback` has 1 parameter: < _Error_ >err. 166 | 167 | * **site**(< _string_ >command, < _function_ >callback) - _(void)_ - Sends `command` (e.g. 'CHMOD 755 foo', 'QUOTA') using SITE. `callback` has 3 parameters: < _Error_ >err, < _string >responseText, < _integer_ >responseCode. 168 | 169 | * **status**(< _function_ >callback) - _(void)_ - Retrieves human-readable information about the server's status. `callback` has 2 parameters: < _Error_ >err, < _string_ >status. 170 | 171 | * **ascii**(< _function_ >callback) - _(void)_ - Sets the transfer data type to ASCII. `callback` has 1 parameter: < _Error_ >err. 172 | 173 | * **binary**(< _function_ >callback) - _(void)_ - Sets the transfer data type to binary (default at time of connection). `callback` has 1 parameter: < _Error_ >err. 174 | 175 | ### Optional "standard" commands (RFC 959) 176 | 177 | * **mkdir**(< _string_ >path, [< _boolean_ >recursive, ]< _function_ >callback) - _(void)_ - Creates a new directory, `path`, on the server. `recursive` is for enabling a 'mkdir -p' algorithm and defaults to false. `callback` has 1 parameter: < _Error_ >err. 178 | 179 | * **rmdir**(< _string_ >path, [< _boolean_ >recursive, ]< _function_ >callback) - _(void)_ - Removes a directory, `path`, on the server. If `recursive`, this call will delete the contents of the directory if it is not empty. `callback` has 1 parameter: < _Error_ >err. 180 | 181 | * **cdup**(< _function_ >callback) - _(void)_ - Changes the working directory to the parent of the current directory. `callback` has 1 parameter: < _Error_ >err. 182 | 183 | * **pwd**(< _function_ >callback) - _(void)_ - Retrieves the current working directory. `callback` has 2 parameters: < _Error_ >err, < _string_ >cwd. 184 | 185 | * **system**(< _function_ >callback) - _(void)_ - Retrieves the server's operating system. `callback` has 2 parameters: < _Error_ >err, < _string_ >OS. 186 | 187 | * **listSafe**([< _string_ >path, ][< _boolean_ >useCompression, ]< _function_ >callback) - _(void)_ - Similar to list(), except the directory is temporarily changed to `path` to retrieve the directory listing. This is useful for servers that do not handle characters like spaces and quotes in directory names well for the LIST command. This function is "optional" because it relies on pwd() being available. 188 | 189 | ### Extended commands (RFC 3659) 190 | 191 | * **size**(< _string_ >path, < _function_ >callback) - _(void)_ - Retrieves the size of `path`. `callback` has 2 parameters: < _Error_ >err, < _integer_ >numBytes. 192 | 193 | * **lastMod**(< _string_ >path, < _function_ >callback) - _(void)_ - Retrieves the last modified date and time for `path`. `callback` has 2 parameters: < _Error_ >err, < _Date_ >lastModified. 194 | 195 | * **restart**(< _integer_ >byteOffset, < _function_ >callback) - _(void)_ - Sets the file byte offset for the next file transfer action (get/put) to `byteOffset`. `callback` has 1 parameter: < _Error_ >err. 196 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | - Add support for some SITE commands such as CHMOD, CHGRP, and QUOTA 2 | - Active (non-passive) data connections 3 | - IPv6 support 4 | -------------------------------------------------------------------------------- /lib/connection.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'), 2 | tls = require('tls'), 3 | zlib = require('zlib'), 4 | Socket = require('net').Socket, 5 | EventEmitter = require('events').EventEmitter, 6 | inherits = require('util').inherits, 7 | inspect = require('util').inspect, 8 | StringDecoder = require('string_decoder').StringDecoder; 9 | 10 | var Parser = require('./parser'); 11 | var XRegExp = require('xregexp').XRegExp; 12 | 13 | var REX_TIMEVAL = XRegExp.cache('^(?\\d{4})(?\\d{2})(?\\d{2})(?\\d{2})(?\\d{2})(?\\d+)(?:.\\d+)?$'), 14 | RE_PASV = /([\d]+),([\d]+),([\d]+),([\d]+),([-\d]+),([-\d]+)/, 15 | RE_EOL = /\r?\n/g, 16 | RE_WD = /"(.+)"(?: |$)/, 17 | RE_SYST = /^([^ ]+)(?: |$)/; 18 | 19 | var /*TYPE = { 20 | SYNTAX: 0, 21 | INFO: 1, 22 | SOCKETS: 2, 23 | AUTH: 3, 24 | UNSPEC: 4, 25 | FILESYS: 5 26 | },*/ 27 | RETVAL = { 28 | PRELIM: 1, 29 | OK: 2, 30 | WAITING: 3, 31 | ERR_TEMP: 4, 32 | ERR_PERM: 5 33 | }, 34 | /*ERRORS = { 35 | 421: 'Service not available, closing control connection', 36 | 425: 'Can\'t open data connection', 37 | 426: 'Connection closed; transfer aborted', 38 | 450: 'Requested file action not taken / File unavailable (e.g., file busy)', 39 | 451: 'Requested action aborted: local error in processing', 40 | 452: 'Requested action not taken / Insufficient storage space in system', 41 | 500: 'Syntax error / Command unrecognized', 42 | 501: 'Syntax error in parameters or arguments', 43 | 502: 'Command not implemented', 44 | 503: 'Bad sequence of commands', 45 | 504: 'Command not implemented for that parameter', 46 | 530: 'Not logged in', 47 | 532: 'Need account for storing files', 48 | 550: 'Requested action not taken / File unavailable (e.g., file not found, no access)', 49 | 551: 'Requested action aborted: page type unknown', 50 | 552: 'Requested file action aborted / Exceeded storage allocation (for current directory or dataset)', 51 | 553: 'Requested action not taken / File name not allowed' 52 | },*/ 53 | bytesNOOP = new Buffer('NOOP\r\n'); 54 | 55 | var FTP = module.exports = function() { 56 | if (!(this instanceof FTP)) 57 | return new FTP(); 58 | 59 | this._socket = undefined; 60 | this._pasvSock = undefined; 61 | this._feat = undefined; 62 | this._curReq = undefined; 63 | this._queue = []; 64 | this._secstate = undefined; 65 | this._debug = undefined; 66 | this._keepalive = undefined; 67 | this._ending = false; 68 | this._parser = undefined; 69 | this.options = { 70 | host: undefined, 71 | port: undefined, 72 | user: undefined, 73 | password: undefined, 74 | secure: false, 75 | secureOptions: undefined, 76 | connTimeout: undefined, 77 | pasvTimeout: undefined, 78 | aliveTimeout: undefined 79 | }; 80 | this.connected = false; 81 | }; 82 | inherits(FTP, EventEmitter); 83 | 84 | FTP.prototype.connect = function(options) { 85 | var self = this; 86 | if (typeof options !== 'object') 87 | options = {}; 88 | this.connected = false; 89 | this.options.host = options.host || 'localhost'; 90 | this.options.port = options.port || 21; 91 | this.options.user = options.user || 'anonymous'; 92 | this.options.password = options.password || 93 | options.password === '' ? options.password 94 | : 'anonymous@'; 95 | this.options.secure = options.secure || false; 96 | this.options.secureOptions = options.secureOptions; 97 | this.options.connTimeout = options.connTimeout || 10000; 98 | this.options.pasvTimeout = options.pasvTimeout || 10000; 99 | this.options.aliveTimeout = options.keepalive || 10000; 100 | 101 | if (typeof options.debug === 'function') 102 | this._debug = options.debug; 103 | 104 | var secureOptions, 105 | debug = this._debug, 106 | socket = new Socket(); 107 | 108 | socket.setTimeout(0); 109 | socket.setKeepAlive(true); 110 | 111 | this._parser = new Parser({ debug: debug }); 112 | this._parser.on('response', function(code, text) { 113 | var retval = code / 100 >> 0; 114 | if (retval === RETVAL.ERR_TEMP || retval === RETVAL.ERR_PERM) { 115 | if (self._curReq) 116 | self._curReq.cb(makeError(code, text), undefined, code); 117 | else 118 | self.emit('error', makeError(code, text)); 119 | } else if (self._curReq) 120 | self._curReq.cb(undefined, text, code); 121 | 122 | // a hack to signal we're waiting for a PASV data connection to complete 123 | // first before executing any more queued requests ... 124 | // 125 | // also: don't forget our current request if we're expecting another 126 | // terminating response .... 127 | if (self._curReq && retval !== RETVAL.PRELIM) { 128 | self._curReq = undefined; 129 | self._send(); 130 | } 131 | 132 | noopreq.cb(); 133 | }); 134 | 135 | if (this.options.secure) { 136 | secureOptions = {}; 137 | secureOptions.host = this.options.host; 138 | for (var k in this.options.secureOptions) 139 | secureOptions[k] = this.options.secureOptions[k]; 140 | secureOptions.socket = socket; 141 | this.options.secureOptions = secureOptions; 142 | } 143 | 144 | if (this.options.secure === 'implicit') 145 | this._socket = tls.connect(secureOptions, onconnect); 146 | else { 147 | socket.once('connect', onconnect); 148 | this._socket = socket; 149 | } 150 | 151 | var noopreq = { 152 | cmd: 'NOOP', 153 | cb: function() { 154 | clearTimeout(self._keepalive); 155 | self._keepalive = setTimeout(donoop, self.options.aliveTimeout); 156 | } 157 | }; 158 | 159 | function donoop() { 160 | if (!self._socket || !self._socket.writable) 161 | clearTimeout(self._keepalive); 162 | else if (!self._curReq && self._queue.length === 0) { 163 | self._curReq = noopreq; 164 | debug&&debug('[connection] > NOOP'); 165 | self._socket.write(bytesNOOP); 166 | } else 167 | noopreq.cb(); 168 | } 169 | 170 | function onconnect() { 171 | clearTimeout(timer); 172 | clearTimeout(self._keepalive); 173 | self.connected = true; 174 | self._socket = socket; // re-assign for implicit secure connections 175 | 176 | var cmd; 177 | 178 | if (self._secstate) { 179 | if (self._secstate === 'upgraded-tls' && self.options.secure === true) { 180 | cmd = 'PBSZ'; 181 | self._send('PBSZ 0', reentry, true); 182 | } else { 183 | cmd = 'USER'; 184 | self._send('USER ' + self.options.user, reentry, true); 185 | } 186 | } else { 187 | self._curReq = { 188 | cmd: '', 189 | cb: reentry 190 | }; 191 | } 192 | 193 | function reentry(err, text, code) { 194 | if (err && (!cmd || cmd === 'USER' || cmd === 'PASS' || cmd === 'TYPE')) { 195 | self.emit('error', err); 196 | return self._socket && self._socket.end(); 197 | } 198 | if ((cmd === 'AUTH TLS' && code !== 234 && self.options.secure !== true) 199 | || (cmd === 'AUTH SSL' && code !== 334) 200 | || (cmd === 'PBSZ' && code !== 200) 201 | || (cmd === 'PROT' && code !== 200)) { 202 | self.emit('error', makeError(code, 'Unable to secure connection(s)')); 203 | return self._socket && self._socket.end(); 204 | } 205 | 206 | if (!cmd) { 207 | // sometimes the initial greeting can contain useful information 208 | // about authorized use, other limits, etc. 209 | self.emit('greeting', text); 210 | 211 | if (self.options.secure && self.options.secure !== 'implicit') { 212 | cmd = 'AUTH TLS'; 213 | self._send(cmd, reentry, true); 214 | } else { 215 | cmd = 'USER'; 216 | self._send('USER ' + self.options.user, reentry, true); 217 | } 218 | } else if (cmd === 'USER') { 219 | if (code !== 230) { 220 | // password required 221 | if (!self.options.password && 222 | self.options.password !== '') { 223 | self.emit('error', makeError(code, 'Password required')); 224 | return self._socket && self._socket.end(); 225 | } 226 | cmd = 'PASS'; 227 | self._send('PASS ' + self.options.password, reentry, true); 228 | } else { 229 | // no password required 230 | cmd = 'PASS'; 231 | reentry(undefined, text, code); 232 | } 233 | } else if (cmd === 'PASS') { 234 | cmd = 'FEAT'; 235 | self._send(cmd, reentry, true); 236 | } else if (cmd === 'FEAT') { 237 | if (!err) 238 | self._feat = Parser.parseFeat(text); 239 | cmd = 'TYPE'; 240 | self._send('TYPE I', reentry, true); 241 | } else if (cmd === 'TYPE') 242 | self.emit('ready'); 243 | else if (cmd === 'PBSZ') { 244 | cmd = 'PROT'; 245 | self._send('PROT P', reentry, true); 246 | } else if (cmd === 'PROT') { 247 | cmd = 'USER'; 248 | self._send('USER ' + self.options.user, reentry, true); 249 | } else if (cmd.substr(0, 4) === 'AUTH') { 250 | if (cmd === 'AUTH TLS' && code !== 234) { 251 | cmd = 'AUTH SSL'; 252 | return self._send(cmd, reentry, true); 253 | } else if (cmd === 'AUTH TLS') 254 | self._secstate = 'upgraded-tls'; 255 | else if (cmd === 'AUTH SSL') 256 | self._secstate = 'upgraded-ssl'; 257 | socket.removeAllListeners('data'); 258 | socket.removeAllListeners('error'); 259 | socket._decoder = null; 260 | self._curReq = null; // prevent queue from being processed during 261 | // TLS/SSL negotiation 262 | secureOptions.socket = self._socket; 263 | secureOptions.session = undefined; 264 | socket = tls.connect(secureOptions, onconnect); 265 | socket.setEncoding('binary'); 266 | socket.on('data', ondata); 267 | socket.once('end', onend); 268 | socket.on('error', onerror); 269 | } 270 | } 271 | } 272 | 273 | socket.on('data', ondata); 274 | function ondata(chunk) { 275 | debug&&debug('[connection] < ' + inspect(chunk.toString('binary'))); 276 | if (self._parser) 277 | self._parser.write(chunk); 278 | } 279 | 280 | socket.on('error', onerror); 281 | function onerror(err) { 282 | clearTimeout(timer); 283 | clearTimeout(self._keepalive); 284 | self.emit('error', err); 285 | } 286 | 287 | socket.once('end', onend); 288 | function onend() { 289 | ondone(); 290 | self.emit('end'); 291 | } 292 | 293 | socket.once('close', function(had_err) { 294 | ondone(); 295 | self.emit('close', had_err); 296 | }); 297 | 298 | var hasReset = false; 299 | function ondone() { 300 | if (!hasReset) { 301 | hasReset = true; 302 | clearTimeout(timer); 303 | self._reset(); 304 | } 305 | } 306 | 307 | var timer = setTimeout(function() { 308 | self.emit('error', new Error('Timeout while connecting to server')); 309 | self._socket && self._socket.destroy(); 310 | self._reset(); 311 | }, this.options.connTimeout); 312 | 313 | this._socket.connect(this.options.port, this.options.host); 314 | }; 315 | 316 | FTP.prototype.end = function() { 317 | if (this._queue.length) 318 | this._ending = true; 319 | else 320 | this._reset(); 321 | }; 322 | 323 | FTP.prototype.destroy = function() { 324 | this._reset(); 325 | }; 326 | 327 | // "Standard" (RFC 959) commands 328 | FTP.prototype.ascii = function(cb) { 329 | return this._send('TYPE A', cb); 330 | }; 331 | 332 | FTP.prototype.binary = function(cb) { 333 | return this._send('TYPE I', cb); 334 | }; 335 | 336 | FTP.prototype.abort = function(immediate, cb) { 337 | if (typeof immediate === 'function') { 338 | cb = immediate; 339 | immediate = true; 340 | } 341 | if (immediate) 342 | this._send('ABOR', cb, true); 343 | else 344 | this._send('ABOR', cb); 345 | }; 346 | 347 | FTP.prototype.cwd = function(path, cb, promote) { 348 | this._send('CWD ' + path, function(err, text, code) { 349 | if (err) 350 | return cb(err); 351 | var m = RE_WD.exec(text); 352 | cb(undefined, m ? m[1] : undefined); 353 | }, promote); 354 | }; 355 | 356 | FTP.prototype.delete = function(path, cb) { 357 | this._send('DELE ' + path, cb); 358 | }; 359 | 360 | FTP.prototype.site = function(cmd, cb) { 361 | this._send('SITE ' + cmd, cb); 362 | }; 363 | 364 | FTP.prototype.status = function(cb) { 365 | this._send('STAT', cb); 366 | }; 367 | 368 | FTP.prototype.rename = function(from, to, cb) { 369 | var self = this; 370 | this._send('RNFR ' + from, function(err) { 371 | if (err) 372 | return cb(err); 373 | 374 | self._send('RNTO ' + to, cb, true); 375 | }); 376 | }; 377 | 378 | FTP.prototype.logout = function(cb) { 379 | this._send('QUIT', cb); 380 | }; 381 | 382 | FTP.prototype.listSafe = function(path, zcomp, cb) { 383 | if (typeof path === 'string') { 384 | var self = this; 385 | // store current path 386 | this.pwd(function(err, origpath) { 387 | if (err) return cb(err); 388 | // change to destination path 389 | self.cwd(path, function(err) { 390 | if (err) return cb(err); 391 | // get dir listing 392 | self.list(zcomp || false, function(err, list) { 393 | // change back to original path 394 | if (err) return self.cwd(origpath, cb); 395 | self.cwd(origpath, function(err) { 396 | if (err) return cb(err); 397 | cb(err, list); 398 | }); 399 | }); 400 | }); 401 | }); 402 | } else 403 | this.list(path, zcomp, cb); 404 | }; 405 | 406 | FTP.prototype.list = function(path, zcomp, cb) { 407 | var self = this, cmd; 408 | 409 | if (typeof path === 'function') { 410 | // list(function() {}) 411 | cb = path; 412 | path = undefined; 413 | cmd = 'LIST'; 414 | zcomp = false; 415 | } else if (typeof path === 'boolean') { 416 | // list(true, function() {}) 417 | cb = zcomp; 418 | zcomp = path; 419 | path = undefined; 420 | cmd = 'LIST'; 421 | } else if (typeof zcomp === 'function') { 422 | // list('/foo', function() {}) 423 | cb = zcomp; 424 | cmd = 'LIST ' + path; 425 | zcomp = false; 426 | } else 427 | cmd = 'LIST ' + path; 428 | 429 | this._pasv(function(err, sock) { 430 | if (err) 431 | return cb(err); 432 | 433 | if (self._queue[0] && self._queue[0].cmd === 'ABOR') { 434 | sock.destroy(); 435 | return cb(); 436 | } 437 | 438 | var sockerr, done = false, replies = 0, entries, buffer = '', source = sock; 439 | var decoder = new StringDecoder('utf8'); 440 | 441 | if (zcomp) { 442 | source = zlib.createInflate(); 443 | sock.pipe(source); 444 | } 445 | 446 | source.on('data', function(chunk) { 447 | buffer += decoder.write(chunk); 448 | }); 449 | source.once('error', function(err) { 450 | if (!sock.aborting) 451 | sockerr = err; 452 | }); 453 | source.once('end', ondone); 454 | source.once('close', ondone); 455 | 456 | function ondone() { 457 | if (decoder) { 458 | buffer += decoder.end(); 459 | decoder = null; 460 | } 461 | done = true; 462 | final(); 463 | } 464 | function final() { 465 | if (done && replies === 2) { 466 | replies = 3; 467 | if (sockerr) 468 | return cb(new Error('Unexpected data connection error: ' + sockerr)); 469 | if (sock.aborting) 470 | return cb(); 471 | 472 | // process received data 473 | entries = buffer.split(RE_EOL); 474 | entries.pop(); // ending EOL 475 | var parsed = []; 476 | for (var i = 0, len = entries.length; i < len; ++i) { 477 | var parsedVal = Parser.parseListEntry(entries[i]); 478 | if (parsedVal !== null) 479 | parsed.push(parsedVal); 480 | } 481 | 482 | if (zcomp) { 483 | self._send('MODE S', function() { 484 | cb(undefined, parsed); 485 | }, true); 486 | } else 487 | cb(undefined, parsed); 488 | } 489 | } 490 | 491 | if (zcomp) { 492 | self._send('MODE Z', function(err, text, code) { 493 | if (err) { 494 | sock.destroy(); 495 | return cb(makeError(code, 'Compression not supported')); 496 | } 497 | sendList(); 498 | }, true); 499 | } else 500 | sendList(); 501 | 502 | function sendList() { 503 | // this callback will be executed multiple times, the first is when server 504 | // replies with 150 and then a final reply to indicate whether the 505 | // transfer was actually a success or not 506 | self._send(cmd, function(err, text, code) { 507 | if (err) { 508 | sock.destroy(); 509 | if (zcomp) { 510 | self._send('MODE S', function() { 511 | cb(err); 512 | }, true); 513 | } else 514 | cb(err); 515 | return; 516 | } 517 | 518 | // some servers may not open a data connection for empty directories 519 | if (++replies === 1 && code === 226) { 520 | replies = 2; 521 | sock.destroy(); 522 | final(); 523 | } else if (replies === 2) 524 | final(); 525 | }, true); 526 | } 527 | }); 528 | }; 529 | 530 | FTP.prototype.get = function(path, zcomp, cb) { 531 | var self = this; 532 | if (typeof zcomp === 'function') { 533 | cb = zcomp; 534 | zcomp = false; 535 | } 536 | 537 | this._pasv(function(err, sock) { 538 | if (err) 539 | return cb(err); 540 | 541 | if (self._queue[0] && self._queue[0].cmd === 'ABOR') { 542 | sock.destroy(); 543 | return cb(); 544 | } 545 | 546 | // modify behavior of socket events so that we can emit 'error' once for 547 | // either a TCP-level error OR an FTP-level error response that we get when 548 | // the socket is closed (e.g. the server ran out of space). 549 | var sockerr, started = false, lastreply = false, done = false, 550 | source = sock; 551 | 552 | if (zcomp) { 553 | source = zlib.createInflate(); 554 | sock.pipe(source); 555 | sock._emit = sock.emit; 556 | sock.emit = function(ev, arg1) { 557 | if (ev === 'error') { 558 | if (!sockerr) 559 | sockerr = arg1; 560 | return; 561 | } 562 | sock._emit.apply(sock, Array.prototype.slice.call(arguments)); 563 | }; 564 | } 565 | 566 | source._emit = source.emit; 567 | source.emit = function(ev, arg1) { 568 | if (ev === 'error') { 569 | if (!sockerr) 570 | sockerr = arg1; 571 | return; 572 | } else if (ev === 'end' || ev === 'close') { 573 | if (!done) { 574 | done = true; 575 | ondone(); 576 | } 577 | return; 578 | } 579 | source._emit.apply(source, Array.prototype.slice.call(arguments)); 580 | }; 581 | 582 | function ondone() { 583 | if (done && lastreply) { 584 | self._send('MODE S', function() { 585 | source._emit('end'); 586 | source._emit('close'); 587 | }, true); 588 | } 589 | } 590 | 591 | sock.pause(); 592 | 593 | if (zcomp) { 594 | self._send('MODE Z', function(err, text, code) { 595 | if (err) { 596 | sock.destroy(); 597 | return cb(makeError(code, 'Compression not supported')); 598 | } 599 | sendRetr(); 600 | }, true); 601 | } else 602 | sendRetr(); 603 | 604 | function sendRetr() { 605 | // this callback will be executed multiple times, the first is when server 606 | // replies with 150, then a final reply after the data connection closes 607 | // to indicate whether the transfer was actually a success or not 608 | self._send('RETR ' + path, function(err, text, code) { 609 | if (sockerr || err) { 610 | sock.destroy(); 611 | if (!started) { 612 | if (zcomp) { 613 | self._send('MODE S', function() { 614 | cb(sockerr || err); 615 | }, true); 616 | } else 617 | cb(sockerr || err); 618 | } else { 619 | source._emit('error', sockerr || err); 620 | source._emit('close', true); 621 | } 622 | return; 623 | } 624 | // server returns 125 when data connection is already open; we treat it 625 | // just like a 150 626 | if (code === 150 || code === 125) { 627 | started = true; 628 | cb(undefined, source); 629 | } else { 630 | lastreply = true; 631 | ondone(); 632 | } 633 | }, true); 634 | } 635 | }); 636 | }; 637 | 638 | FTP.prototype.put = function(input, path, zcomp, cb) { 639 | this._store('STOR ' + path, input, zcomp, cb); 640 | }; 641 | 642 | FTP.prototype.append = function(input, path, zcomp, cb) { 643 | this._store('APPE ' + path, input, zcomp, cb); 644 | }; 645 | 646 | FTP.prototype.pwd = function(cb) { // PWD is optional 647 | var self = this; 648 | this._send('PWD', function(err, text, code) { 649 | if (code === 502) { 650 | return self.cwd('.', function(cwderr, cwd) { 651 | if (cwderr) 652 | return cb(cwderr); 653 | if (cwd === undefined) 654 | cb(err); 655 | else 656 | cb(undefined, cwd); 657 | }, true); 658 | } else if (err) 659 | return cb(err); 660 | cb(undefined, RE_WD.exec(text)[1]); 661 | }); 662 | }; 663 | 664 | FTP.prototype.cdup = function(cb) { // CDUP is optional 665 | var self = this; 666 | this._send('CDUP', function(err, text, code) { 667 | if (code === 502) 668 | self.cwd('..', cb, true); 669 | else 670 | cb(err); 671 | }); 672 | }; 673 | 674 | FTP.prototype.mkdir = function(path, recursive, cb) { // MKD is optional 675 | if (typeof recursive === 'function') { 676 | cb = recursive; 677 | recursive = false; 678 | } 679 | if (!recursive) 680 | this._send('MKD ' + path, cb); 681 | else { 682 | var self = this, owd, abs, dirs, dirslen, i = -1, searching = true; 683 | 684 | abs = (path[0] === '/'); 685 | 686 | var nextDir = function() { 687 | if (++i === dirslen) { 688 | // return to original working directory 689 | return self._send('CWD ' + owd, cb, true); 690 | } 691 | if (searching) { 692 | self._send('CWD ' + dirs[i], function(err, text, code) { 693 | if (code === 550) { 694 | searching = false; 695 | --i; 696 | } else if (err) { 697 | // return to original working directory 698 | return self._send('CWD ' + owd, function() { 699 | cb(err); 700 | }, true); 701 | } 702 | nextDir(); 703 | }, true); 704 | } else { 705 | self._send('MKD ' + dirs[i], function(err, text, code) { 706 | if (err) { 707 | // return to original working directory 708 | return self._send('CWD ' + owd, function() { 709 | cb(err); 710 | }, true); 711 | } 712 | self._send('CWD ' + dirs[i], nextDir, true); 713 | }, true); 714 | } 715 | }; 716 | this.pwd(function(err, cwd) { 717 | if (err) 718 | return cb(err); 719 | owd = cwd; 720 | if (abs) 721 | path = path.substr(1); 722 | if (path[path.length - 1] === '/') 723 | path = path.substring(0, path.length - 1); 724 | dirs = path.split('/'); 725 | dirslen = dirs.length; 726 | if (abs) 727 | self._send('CWD /', function(err) { 728 | if (err) 729 | return cb(err); 730 | nextDir(); 731 | }, true); 732 | else 733 | nextDir(); 734 | }); 735 | } 736 | }; 737 | 738 | FTP.prototype.rmdir = function(path, recursive, cb) { // RMD is optional 739 | if (typeof recursive === 'function') { 740 | cb = recursive; 741 | recursive = false; 742 | } 743 | if (!recursive) { 744 | return this._send('RMD ' + path, cb); 745 | } 746 | 747 | var self = this; 748 | this.list(path, function(err, list) { 749 | if (err) return cb(err); 750 | var idx = 0; 751 | 752 | // this function will be called once per listing entry 753 | var deleteNextEntry; 754 | deleteNextEntry = function(err) { 755 | if (err) return cb(err); 756 | if (idx >= list.length) { 757 | if (list[0] && list[0].name === path) { 758 | return cb(null); 759 | } else { 760 | return self.rmdir(path, cb); 761 | } 762 | } 763 | 764 | var entry = list[idx++]; 765 | 766 | // get the path to the file 767 | var subpath = null; 768 | if (entry.name[0] === '/') { 769 | // this will be the case when you call deleteRecursively() and pass 770 | // the path to a plain file 771 | subpath = entry.name; 772 | } else { 773 | if (path[path.length - 1] == '/') { 774 | subpath = path + entry.name; 775 | } else { 776 | subpath = path + '/' + entry.name 777 | } 778 | } 779 | 780 | // delete the entry (recursively) according to its type 781 | if (entry.type === 'd') { 782 | if (entry.name === "." || entry.name === "..") { 783 | return deleteNextEntry(); 784 | } 785 | self.rmdir(subpath, true, deleteNextEntry); 786 | } else { 787 | self.delete(subpath, deleteNextEntry); 788 | } 789 | } 790 | deleteNextEntry(); 791 | }); 792 | }; 793 | 794 | FTP.prototype.system = function(cb) { // SYST is optional 795 | this._send('SYST', function(err, text) { 796 | if (err) 797 | return cb(err); 798 | cb(undefined, RE_SYST.exec(text)[1]); 799 | }); 800 | }; 801 | 802 | // "Extended" (RFC 3659) commands 803 | FTP.prototype.size = function(path, cb) { 804 | var self = this; 805 | this._send('SIZE ' + path, function(err, text, code) { 806 | if (code === 502) { 807 | // Note: this may cause a problem as list() is _appended_ to the queue 808 | return self.list(path, function(err, list) { 809 | if (err) 810 | return cb(err); 811 | if (list.length === 1) 812 | cb(undefined, list[0].size); 813 | else { 814 | // path could have been a directory and we got a listing of its 815 | // contents, but here we echo the behavior of the real SIZE and 816 | // return 'File not found' for directories 817 | cb(new Error('File not found')); 818 | } 819 | }, true); 820 | } else if (err) 821 | return cb(err); 822 | cb(undefined, parseInt(text, 10)); 823 | }); 824 | }; 825 | 826 | FTP.prototype.lastMod = function(path, cb) { 827 | var self = this; 828 | this._send('MDTM ' + path, function(err, text, code) { 829 | if (code === 502) { 830 | return self.list(path, function(err, list) { 831 | if (err) 832 | return cb(err); 833 | if (list.length === 1) 834 | cb(undefined, list[0].date); 835 | else 836 | cb(new Error('File not found')); 837 | }, true); 838 | } else if (err) 839 | return cb(err); 840 | var val = XRegExp.exec(text, REX_TIMEVAL), ret; 841 | if (!val) 842 | return cb(new Error('Invalid date/time format from server')); 843 | ret = new Date(val.year + '-' + val.month + '-' + val.date + 'T' + val.hour 844 | + ':' + val.minute + ':' + val.second); 845 | cb(undefined, ret); 846 | }); 847 | }; 848 | 849 | FTP.prototype.restart = function(offset, cb) { 850 | this._send('REST ' + offset, cb); 851 | }; 852 | 853 | 854 | 855 | // Private/Internal methods 856 | FTP.prototype._pasv = function(cb) { 857 | var self = this, first = true, ip, port; 858 | this._send('PASV', function reentry(err, text) { 859 | if (err) 860 | return cb(err); 861 | 862 | self._curReq = undefined; 863 | 864 | if (first) { 865 | var m = RE_PASV.exec(text); 866 | if (!m) 867 | return cb(new Error('Unable to parse PASV server response')); 868 | ip = m[1]; 869 | ip += '.'; 870 | ip += m[2]; 871 | ip += '.'; 872 | ip += m[3]; 873 | ip += '.'; 874 | ip += m[4]; 875 | port = (parseInt(m[5], 10) * 256) + parseInt(m[6], 10); 876 | 877 | first = false; 878 | } 879 | self._pasvConnect(ip, port, function(err, sock) { 880 | if (err) { 881 | // try the IP of the control connection if the server was somehow 882 | // misconfigured and gave for example a LAN IP instead of WAN IP over 883 | // the Internet 884 | if (self._socket && ip !== self._socket.remoteAddress) { 885 | ip = self._socket.remoteAddress; 886 | return reentry(); 887 | } 888 | 889 | // automatically abort PASV mode 890 | self._send('ABOR', function() { 891 | cb(err); 892 | self._send(); 893 | }, true); 894 | 895 | return; 896 | } 897 | cb(undefined, sock); 898 | self._send(); 899 | }); 900 | }); 901 | }; 902 | 903 | FTP.prototype._pasvConnect = function(ip, port, cb) { 904 | var self = this, 905 | socket = new Socket(), 906 | sockerr, 907 | timedOut = false, 908 | timer = setTimeout(function() { 909 | timedOut = true; 910 | socket.destroy(); 911 | cb(new Error('Timed out while making data connection')); 912 | }, this.options.pasvTimeout); 913 | 914 | socket.setTimeout(0); 915 | 916 | socket.once('connect', function() { 917 | self._debug&&self._debug('[connection] PASV socket connected'); 918 | if (self.options.secure === true) { 919 | self.options.secureOptions.socket = socket; 920 | self.options.secureOptions.session = self._socket.getSession(); 921 | //socket.removeAllListeners('error'); 922 | socket = tls.connect(self.options.secureOptions); 923 | //socket.once('error', onerror); 924 | socket.setTimeout(0); 925 | } 926 | clearTimeout(timer); 927 | self._pasvSocket = socket; 928 | cb(undefined, socket); 929 | }); 930 | socket.once('error', onerror); 931 | function onerror(err) { 932 | sockerr = err; 933 | } 934 | socket.once('end', function() { 935 | clearTimeout(timer); 936 | }); 937 | socket.once('close', function(had_err) { 938 | clearTimeout(timer); 939 | if (!self._pasvSocket && !timedOut) { 940 | var errmsg = 'Unable to make data connection'; 941 | if (sockerr) { 942 | errmsg += '( ' + sockerr + ')'; 943 | sockerr = undefined; 944 | } 945 | cb(new Error(errmsg)); 946 | } 947 | self._pasvSocket = undefined; 948 | }); 949 | 950 | socket.connect(port, ip); 951 | }; 952 | 953 | FTP.prototype._store = function(cmd, input, zcomp, cb) { 954 | var isBuffer = Buffer.isBuffer(input); 955 | 956 | if (!isBuffer && input.pause !== undefined) 957 | input.pause(); 958 | 959 | if (typeof zcomp === 'function') { 960 | cb = zcomp; 961 | zcomp = false; 962 | } 963 | 964 | var self = this; 965 | this._pasv(function(err, sock) { 966 | if (err) 967 | return cb(err); 968 | 969 | if (self._queue[0] && self._queue[0].cmd === 'ABOR') { 970 | sock.destroy(); 971 | return cb(); 972 | } 973 | 974 | var sockerr, dest = sock; 975 | sock.once('error', function(err) { 976 | sockerr = err; 977 | }); 978 | 979 | if (zcomp) { 980 | self._send('MODE Z', function(err, text, code) { 981 | if (err) { 982 | sock.destroy(); 983 | return cb(makeError(code, 'Compression not supported')); 984 | } 985 | // draft-preston-ftpext-deflate-04 says min of 8 should be supported 986 | dest = zlib.createDeflate({ level: 8 }); 987 | dest.pipe(sock); 988 | sendStore(); 989 | }, true); 990 | } else 991 | sendStore(); 992 | 993 | function sendStore() { 994 | // this callback will be executed multiple times, the first is when server 995 | // replies with 150, then a final reply after the data connection closes 996 | // to indicate whether the transfer was actually a success or not 997 | self._send(cmd, function(err, text, code) { 998 | if (sockerr || err) { 999 | if (zcomp) { 1000 | self._send('MODE S', function() { 1001 | cb(sockerr || err); 1002 | }, true); 1003 | } else 1004 | cb(sockerr || err); 1005 | return; 1006 | } 1007 | 1008 | if (code === 150 || code === 125) { 1009 | if (isBuffer) 1010 | dest.end(input); 1011 | else if (typeof input === 'string') { 1012 | // check if input is a file path or just string data to store 1013 | fs.stat(input, function(err, stats) { 1014 | if (err) 1015 | dest.end(input); 1016 | else 1017 | fs.createReadStream(input).pipe(dest); 1018 | }); 1019 | } else { 1020 | input.pipe(dest); 1021 | input.resume(); 1022 | } 1023 | } else { 1024 | if (zcomp) 1025 | self._send('MODE S', cb, true); 1026 | else 1027 | cb(); 1028 | } 1029 | }, true); 1030 | } 1031 | }); 1032 | }; 1033 | 1034 | FTP.prototype._send = function(cmd, cb, promote) { 1035 | clearTimeout(this._keepalive); 1036 | if (cmd !== undefined) { 1037 | if (promote) 1038 | this._queue.unshift({ cmd: cmd, cb: cb }); 1039 | else 1040 | this._queue.push({ cmd: cmd, cb: cb }); 1041 | } 1042 | var queueLen = this._queue.length; 1043 | if (!this._curReq && queueLen && this._socket && this._socket.readable) { 1044 | this._curReq = this._queue.shift(); 1045 | if (this._curReq.cmd === 'ABOR' && this._pasvSocket) 1046 | this._pasvSocket.aborting = true; 1047 | this._debug&&this._debug('[connection] > ' + inspect(this._curReq.cmd)); 1048 | this._socket.write(this._curReq.cmd + '\r\n'); 1049 | } else if (!this._curReq && !queueLen && this._ending) 1050 | this._reset(); 1051 | }; 1052 | 1053 | FTP.prototype._reset = function() { 1054 | if (this._pasvSock && this._pasvSock.writable) 1055 | this._pasvSock.end(); 1056 | if (this._socket && this._socket.writable) 1057 | this._socket.end(); 1058 | this._socket = undefined; 1059 | this._pasvSock = undefined; 1060 | this._feat = undefined; 1061 | this._curReq = undefined; 1062 | this._secstate = undefined; 1063 | clearTimeout(this._keepalive); 1064 | this._keepalive = undefined; 1065 | this._queue = []; 1066 | this._ending = false; 1067 | this._parser = undefined; 1068 | this.options.host = this.options.port = this.options.user 1069 | = this.options.password = this.options.secure 1070 | = this.options.connTimeout = this.options.pasvTimeout 1071 | = this.options.keepalive = this._debug = undefined; 1072 | this.connected = false; 1073 | }; 1074 | 1075 | // Utility functions 1076 | function makeError(code, text) { 1077 | var err = new Error(text); 1078 | err.code = code; 1079 | return err; 1080 | } 1081 | -------------------------------------------------------------------------------- /lib/parser.js: -------------------------------------------------------------------------------- 1 | var WritableStream = require('stream').Writable 2 | || require('readable-stream').Writable, 3 | inherits = require('util').inherits, 4 | inspect = require('util').inspect; 5 | 6 | var XRegExp = require('xregexp').XRegExp; 7 | 8 | var REX_LISTUNIX = XRegExp.cache('^(?[\\-ld])(?([\\-r][\\-w][\\-xstT]){3})(?(\\+))?\\s+(?\\d+)\\s+(?\\S+)\\s+(?\\S+)\\s+(?\\d+)\\s+(?((?\\w{3})\\s+(?\\d{1,2})\\s+(?\\d{1,2}):(?\\d{2}))|((?\\w{3})\\s+(?\\d{1,2})\\s+(?\\d{4})))\\s+(?.+)$'), 9 | REX_LISTMSDOS = XRegExp.cache('^(?\\d{2})(?:\\-|\\/)(?\\d{2})(?:\\-|\\/)(?\\d{2,4})\\s+(?\\d{2}):(?\\d{2})\\s{0,1}(?[AaMmPp]{1,2})\\s+(?:(?\\d+)|(?\\))\\s+(?.+)$'), 10 | RE_ENTRY_TOTAL = /^total/, 11 | RE_RES_END = /(?:^|\r?\n)(\d{3}) [^\r\n]*\r?\n/, 12 | RE_EOL = /\r?\n/g, 13 | RE_DASH = /\-/g; 14 | 15 | var MONTHS = { 16 | jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, 17 | jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 18 | }; 19 | 20 | function Parser(options) { 21 | if (!(this instanceof Parser)) 22 | return new Parser(options); 23 | WritableStream.call(this); 24 | 25 | this._buffer = ''; 26 | this._debug = options.debug; 27 | } 28 | inherits(Parser, WritableStream); 29 | 30 | Parser.prototype._write = function(chunk, encoding, cb) { 31 | var m, code, reRmLeadCode, rest = '', debug = this._debug; 32 | 33 | this._buffer += chunk.toString('binary'); 34 | 35 | while (m = RE_RES_END.exec(this._buffer)) { 36 | // support multiple terminating responses in the buffer 37 | rest = this._buffer.substring(m.index + m[0].length); 38 | if (rest.length) 39 | this._buffer = this._buffer.substring(0, m.index + m[0].length); 40 | 41 | debug&&debug('[parser] < ' + inspect(this._buffer)); 42 | 43 | // we have a terminating response line 44 | code = parseInt(m[1], 10); 45 | 46 | // RFC 959 does not require each line in a multi-line response to begin 47 | // with '-', but many servers will do this. 48 | // 49 | // remove this leading '-' (or ' ' from last line) from each 50 | // line in the response ... 51 | reRmLeadCode = '(^|\\r?\\n)'; 52 | reRmLeadCode += m[1]; 53 | reRmLeadCode += '(?: |\\-)'; 54 | reRmLeadCode = new RegExp(reRmLeadCode, 'g'); 55 | var text = this._buffer.replace(reRmLeadCode, '$1').trim(); 56 | this._buffer = rest; 57 | 58 | debug&&debug('[parser] Response: code=' + code + ', buffer=' + inspect(text)); 59 | this.emit('response', code, text); 60 | } 61 | 62 | cb(); 63 | }; 64 | 65 | Parser.parseFeat = function(text) { 66 | var lines = text.split(RE_EOL); 67 | lines.shift(); // initial response line 68 | lines.pop(); // final response line 69 | 70 | for (var i = 0, len = lines.length; i < len; ++i) 71 | lines[i] = lines[i].trim(); 72 | 73 | // just return the raw lines for now 74 | return lines; 75 | }; 76 | 77 | Parser.parseListEntry = function(line) { 78 | var ret, 79 | info, 80 | month, day, year, 81 | hour, mins; 82 | 83 | if (ret = XRegExp.exec(line, REX_LISTUNIX)) { 84 | info = { 85 | type: ret.type, 86 | name: undefined, 87 | target: undefined, 88 | sticky: false, 89 | rights: { 90 | user: ret.permission.substr(0, 3).replace(RE_DASH, ''), 91 | group: ret.permission.substr(3, 3).replace(RE_DASH, ''), 92 | other: ret.permission.substr(6, 3).replace(RE_DASH, '') 93 | }, 94 | acl: (ret.acl === '+'), 95 | owner: ret.owner, 96 | group: ret.group, 97 | size: parseInt(ret.size, 10), 98 | date: undefined 99 | }; 100 | 101 | // check for sticky bit 102 | var lastbit = info.rights.other.slice(-1); 103 | if (lastbit === 't') { 104 | info.rights.other = info.rights.other.slice(0, -1) + 'x'; 105 | info.sticky = true; 106 | } else if (lastbit === 'T') { 107 | info.rights.other = info.rights.other.slice(0, -1); 108 | info.sticky = true; 109 | } 110 | 111 | if (ret.month1 !== undefined) { 112 | month = parseInt(MONTHS[ret.month1.toLowerCase()], 10); 113 | day = parseInt(ret.date1, 10); 114 | year = (new Date()).getFullYear(); 115 | hour = parseInt(ret.hour, 10); 116 | mins = parseInt(ret.minute, 10); 117 | if (month < 10) 118 | month = '0' + month; 119 | if (day < 10) 120 | day = '0' + day; 121 | if (hour < 10) 122 | hour = '0' + hour; 123 | if (mins < 10) 124 | mins = '0' + mins; 125 | info.date = new Date(year + '-' 126 | + month + '-' 127 | + day + 'T' 128 | + hour + ':' 129 | + mins); 130 | // If the date is in the past but no more than 6 months old, year 131 | // isn't displayed and doesn't have to be the current year. 132 | // 133 | // If the date is in the future (less than an hour from now), year 134 | // isn't displayed and doesn't have to be the current year. 135 | // That second case is much more rare than the first and less annoying. 136 | // It's impossible to fix without knowing about the server's timezone, 137 | // so we just don't do anything about it. 138 | // 139 | // If we're here with a time that is more than 28 hours into the 140 | // future (1 hour + maximum timezone offset which is 27 hours), 141 | // there is a problem -- we should be in the second conditional block 142 | if (info.date.getTime() - Date.now() > 100800000) { 143 | info.date = new Date((year - 1) + '-' 144 | + month + '-' 145 | + day + 'T' 146 | + hour + ':' 147 | + mins); 148 | } 149 | 150 | // If we're here with a time that is more than 6 months old, there's 151 | // a problem as well. 152 | // Maybe local & remote servers aren't on the same timezone (with remote 153 | // ahead of local) 154 | // For instance, remote is in 2014 while local is still in 2013. In 155 | // this case, a date like 01/01/13 02:23 could be detected instead of 156 | // 01/01/14 02:23 157 | // Our trigger point will be 3600*24*31*6 (since we already use 31 158 | // as an upper bound, no need to add the 27 hours timezone offset) 159 | if (Date.now() - info.date.getTime() > 16070400000) { 160 | info.date = new Date((year + 1) + '-' 161 | + month + '-' 162 | + day + 'T' 163 | + hour + ':' 164 | + mins); 165 | } 166 | } else if (ret.month2 !== undefined) { 167 | month = parseInt(MONTHS[ret.month2.toLowerCase()], 10); 168 | day = parseInt(ret.date2, 10); 169 | year = parseInt(ret.year, 10); 170 | if (month < 10) 171 | month = '0' + month; 172 | if (day < 10) 173 | day = '0' + day; 174 | info.date = new Date(year + '-' + month + '-' + day); 175 | } 176 | if (ret.type === 'l') { 177 | var pos = ret.name.indexOf(' -> '); 178 | info.name = ret.name.substring(0, pos); 179 | info.target = ret.name.substring(pos+4); 180 | } else 181 | info.name = ret.name; 182 | ret = info; 183 | } else if (ret = XRegExp.exec(line, REX_LISTMSDOS)) { 184 | info = { 185 | name: ret.name, 186 | type: (ret.isdir ? 'd' : '-'), 187 | size: (ret.isdir ? 0 : parseInt(ret.size, 10)), 188 | date: undefined, 189 | }; 190 | month = parseInt(ret.month, 10), 191 | day = parseInt(ret.date, 10), 192 | year = parseInt(ret.year, 10), 193 | hour = parseInt(ret.hour, 10), 194 | mins = parseInt(ret.minute, 10); 195 | 196 | if (year < 70) 197 | year += 2000; 198 | else 199 | year += 1900; 200 | 201 | if (ret.ampm[0].toLowerCase() === 'p' && hour < 12) 202 | hour += 12; 203 | else if (ret.ampm[0].toLowerCase() === 'a' && hour === 12) 204 | hour = 0; 205 | 206 | info.date = new Date(year, month - 1, day, hour, mins); 207 | 208 | ret = info; 209 | } else if (!RE_ENTRY_TOTAL.test(line)) 210 | ret = line; // could not parse, so at least give the end user a chance to 211 | // look at the raw listing themselves 212 | 213 | return ret; 214 | }; 215 | 216 | module.exports = Parser; 217 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { "name": "ftp", 2 | "version": "0.3.10", 3 | "author": "Brian White ", 4 | "description": "An FTP client module for node.js", 5 | "main": "./lib/connection", 6 | "engines": { "node" : ">=0.8.0" }, 7 | "dependencies": { 8 | "xregexp": "2.0.0", 9 | "readable-stream": "1.1.x" 10 | }, 11 | "scripts": { 12 | "test": "node test/test.js" 13 | }, 14 | "keywords": [ "ftp", "client", "transfer" ], 15 | "licenses": [ { "type": "MIT", "url": "http://github.com/mscdex/node-ftp/raw/master/LICENSE" } ], 16 | "repository" : { "type": "git", "url": "http://github.com/mscdex/node-ftp.git" } 17 | } -------------------------------------------------------------------------------- /test/test-parser.js: -------------------------------------------------------------------------------- 1 | var Parser = require('../lib/parser'), 2 | parseListEntry = Parser.parseListEntry; 3 | 4 | var path = require('path'), 5 | assert = require('assert'), 6 | inspect = require('util').inspect; 7 | 8 | var group = path.basename(__filename, '.js') + '/'; 9 | 10 | [ 11 | { source: 'drwxr-xr-x 10 root root 4096 Dec 21 2012 usr', 12 | expected: { 13 | type: 'd', 14 | name: 'usr', 15 | target: undefined, 16 | sticky: false, 17 | rights: { user: 'rwx', group: 'rx', other: 'rx' }, 18 | acl: false, 19 | owner: 'root', 20 | group: 'root', 21 | size: 4096, 22 | date: new Date('2012-12-21T00:00') 23 | }, 24 | what: 'Normal directory' 25 | }, 26 | { source: 'drwxrwxrwx 1 owner group 0 Aug 31 2012 e-books', 27 | expected: { 28 | type: 'd', 29 | name: 'e-books', 30 | target: undefined, 31 | sticky: false, 32 | rights: { user: 'rwx', group: 'rwx', other: 'rwx' }, 33 | acl: false, 34 | owner: 'owner', 35 | group: 'group', 36 | size: 0, 37 | date: new Date('2012-08-31T00:00') 38 | }, 39 | what: 'Normal directory #2' 40 | }, 41 | { source: '-rw-rw-rw- 1 owner group 7045120 Sep 02 2012 music.mp3', 42 | expected: { 43 | type: '-', 44 | name: 'music.mp3', 45 | target: undefined, 46 | sticky: false, 47 | rights: { user: 'rw', group: 'rw', other: 'rw' }, 48 | acl: false, 49 | owner: 'owner', 50 | group: 'group', 51 | size: 7045120, 52 | date: new Date('2012-09-02T00:00') 53 | }, 54 | what: 'Normal file' 55 | }, 56 | { source: '-rw-rw-rw-+ 1 owner group 7045120 Sep 02 2012 music.mp3', 57 | expected: { 58 | type: '-', 59 | name: 'music.mp3', 60 | target: undefined, 61 | sticky: false, 62 | rights: { user: 'rw', group: 'rw', other: 'rw' }, 63 | acl: true, 64 | owner: 'owner', 65 | group: 'group', 66 | size: 7045120, 67 | date: new Date('2012-09-02T00:00') 68 | }, 69 | what: 'File with ACL set' 70 | }, 71 | { source: 'drwxrwxrwt 7 root root 4096 May 19 2012 tmp', 72 | expected: { 73 | type: 'd', 74 | name: 'tmp', 75 | target: undefined, 76 | sticky: true, 77 | rights: { user: 'rwx', group: 'rwx', other: 'rwx' }, 78 | acl: false, 79 | owner: 'root', 80 | group: 'root', 81 | size: 4096, 82 | date: new Date('2012-05-19T00:00') 83 | }, 84 | what: 'Directory with sticky bit and executable for others' 85 | }, 86 | { source: 'drwxrwx--t 7 root root 4096 May 19 2012 tmp', 87 | expected: { 88 | type: 'd', 89 | name: 'tmp', 90 | target: undefined, 91 | sticky: true, 92 | rights: { user: 'rwx', group: 'rwx', other: 'x' }, 93 | acl: false, 94 | owner: 'root', 95 | group: 'root', 96 | size: 4096, 97 | date: new Date('2012-05-19T00:00') 98 | }, 99 | what: 'Directory with sticky bit and executable for others #2' 100 | }, 101 | { source: 'drwxrwxrwT 7 root root 4096 May 19 2012 tmp', 102 | expected: { 103 | type: 'd', 104 | name: 'tmp', 105 | target: undefined, 106 | sticky: true, 107 | rights: { user: 'rwx', group: 'rwx', other: 'rw' }, 108 | acl: false, 109 | owner: 'root', 110 | group: 'root', 111 | size: 4096, 112 | date: new Date('2012-05-19T00:00') 113 | }, 114 | what: 'Directory with sticky bit and not executable for others' 115 | }, 116 | { source: 'drwxrwx--T 7 root root 4096 May 19 2012 tmp', 117 | expected: { 118 | type: 'd', 119 | name: 'tmp', 120 | target: undefined, 121 | sticky: true, 122 | rights: { user: 'rwx', group: 'rwx', other: '' }, 123 | acl: false, 124 | owner: 'root', 125 | group: 'root', 126 | size: 4096, 127 | date: new Date('2012-05-19T00:00') 128 | }, 129 | what: 'Directory with sticky bit and not executable for others #2' 130 | }, 131 | { source: 'total 871', 132 | expected: null, 133 | what: 'Ignored line' 134 | }, 135 | ].forEach(function(v) { 136 | var result = parseListEntry(v.source), 137 | msg = '[' + group + v.what + ']: parsed output mismatch.\n' 138 | + 'Saw: ' + inspect(result) + '\n' 139 | + 'Expected: ' + inspect(v.expected); 140 | assert.deepEqual(result, v.expected, msg); 141 | }); 142 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | require('fs').readdirSync(__dirname).forEach(function(f) { 2 | if (f.substr(0, 5) === 'test-') 3 | require('./' + f); 4 | }); --------------------------------------------------------------------------------