diff --git a/README.md b/README.md index f046f42..432457d 100644 --- a/README.md +++ b/README.md @@ -192,13 +192,17 @@ __Used in:__ `CWD`, `CDUP` Returns a path to a newly created directory __Used in:__ `MKD` -#### [`write(fileName, {append = false})`](src/fs.js#L68) +#### [`write(fileName, {append, start})`](src/fs.js#L68) Returns a writable stream -Options: `append` if true, append to existing file +Options: + `append` if true, append to existing file + `start` if set, specifies the byte offset to write to __Used in:__ `STOR`, `APPE` -#### [`read(fileName)`](src/fs.js#L75) +#### [`read(fileName, {start})`](src/fs.js#L75) Returns a readable stream +Options: + `start` if set, specifies the byte offset to read from __Used in:__ `RETR` #### [`delete(path)`](src/fs.js#L87) @@ -206,11 +210,11 @@ Delete a file or directory __Used in:__ `DELE` #### [`rename(from, to)`](src/fs.js#L102) -Rename a file or directory +Renames a file or directory __Used in:__ `RNFR`, `RNTO` #### [`chmod(path)`](src/fs.js#L108) -Modify a file or directory's permissions +Modifies a file or directory's permissions __Used in:__ `SITE CHMOD` #### [`getUniqueName()`](src/fs.js#L113) diff --git a/config/release/commitMessageConfig.js b/config/release/commitMessageConfig.js index 2bb5e85..bfaeefc 100644 --- a/config/release/commitMessageConfig.js +++ b/config/release/commitMessageConfig.js @@ -15,12 +15,7 @@ module.exports = { {value: 'WIP', name: 'WIP: Work in progress'} ], - scopes: [ - {name: 'accounts'}, - {name: 'admin'}, - {name: 'exampleScope'}, - {name: 'changeMe'} - ], + scopes: [], // it needs to match the value for field type. Eg.: 'fix' /* @@ -39,5 +34,5 @@ module.exports = { allowBreakingChanges: ['feat', 'fix'], // Appends the branch name to the footer of the commit. Useful for tracking commits after branches have been merged - appendBranchNameToCommitMessage: true + appendBranchNameToCommitMessage: false }; diff --git a/src/commands/registration/auth.js b/src/commands/registration/auth.js index 4ae6b68..6d3f22e 100644 --- a/src/commands/registration/auth.js +++ b/src/commands/registration/auth.js @@ -20,7 +20,8 @@ module.exports = { }; function handleTLS() { - if (!this.server._tls) return this.reply(504); + if (!this.server._tls) return this.reply(502); + if (this.secure) return this.reply(202); return this.reply(234) .then(() => { diff --git a/src/commands/registration/feat.js b/src/commands/registration/feat.js index 365610f..df26895 100644 --- a/src/commands/registration/feat.js +++ b/src/commands/registration/feat.js @@ -10,7 +10,10 @@ module.exports = { if (feat) return _.concat(feats, feat); return feats; }, ['UTF8']) - .map(feat => ` ${feat}`); + .map(feat => ({ + message: ` ${feat}`, + raw: true + })); return features.length ? this.reply(211, 'Extensions supported', ...features, 'End') : this.reply(211, 'No features'); diff --git a/src/commands/registration/opts.js b/src/commands/registration/opts.js index 1f27155..e5f4be9 100644 --- a/src/commands/registration/opts.js +++ b/src/commands/registration/opts.js @@ -1,8 +1,34 @@ +const _ = require('lodash'); + +const OPTIONS = { + UTF8: utf8, + 'UTF-8': utf8 +}; + module.exports = { directive: 'OPTS', - handler: function () { - return this.reply(501); + handler: function ({command} = {}) { + if (!_.has(command, 'arg')) return this.reply(501); + + const [_option, ...args] = command.arg.split(' '); + const option = _.toUpper(_option); + + if (!OPTIONS.hasOwnProperty(option)) return this.reply(500); + return OPTIONS[option].call(this, args); }, syntax: '{{cmd}}', description: 'Select options for a feature' }; + +function utf8([setting] = []) { + switch (_.toUpper(setting)) { + case 'ON': + this.encoding = 'utf8'; + return this.reply(200, 'UTF8 encoding on'); + case 'OFF': + this.encoding = 'ascii'; + return this.reply(200, 'UTF8 encoding off'); + default: + return this.reply(501, 'Unknown setting for option'); + } +} diff --git a/src/commands/registration/pbsz.js b/src/commands/registration/pbsz.js index 6e02687..bf37910 100644 --- a/src/commands/registration/pbsz.js +++ b/src/commands/registration/pbsz.js @@ -8,6 +8,7 @@ module.exports = { syntax: '{{cmd}}', description: 'Protection Buffer Size', flags: { - no_auth: true + no_auth: true, + feat: 'PBSZ' } }; diff --git a/src/commands/registration/prot.js b/src/commands/registration/prot.js index 78a94e4..a70da18 100644 --- a/src/commands/registration/prot.js +++ b/src/commands/registration/prot.js @@ -17,6 +17,7 @@ module.exports = { syntax: '{{cmd}}', description: 'Data Channel Protection Level', flags: { - no_auth: true + no_auth: true, + feat: 'PROT' } }; diff --git a/src/commands/registration/rest.js b/src/commands/registration/rest.js new file mode 100644 index 0000000..cf456be --- /dev/null +++ b/src/commands/registration/rest.js @@ -0,0 +1,16 @@ +const _ = require('lodash'); + +module.exports = { + directive: 'REST', + handler: function ({command} = {}) { + const arg = _.get(command, 'arg'); + const byteCount = parseInt(arg, 10); + + if (isNaN(byteCount) || byteCount < 0) return this.reply(501, 'Byte count must be 0 or greater'); + + this.restByteCount = byteCount; + return this.reply(350, `Resarting next transfer at ${byteCount}`); + }, + syntax: '{{cmd}} ', + description: 'Restart transfer from the specified point. Resets after any STORE or RETRIEVE' +}; diff --git a/src/commands/registration/retr.js b/src/commands/registration/retr.js index 0ab8a70..b8d7491 100644 --- a/src/commands/registration/retr.js +++ b/src/commands/registration/retr.js @@ -12,8 +12,9 @@ module.exports = { this.commandSocket.pause(); dataSocket = socket; }) - .then(() => when.try(this.fs.read.bind(this.fs), command.arg)) + .then(() => when.try(this.fs.read.bind(this.fs), command.arg, {start: this.restByteCount})) .then(stream => { + this.restByteCount = 0; return when.promise((resolve, reject) => { dataSocket.on('error', err => stream.emit('error', err)); diff --git a/src/commands/registration/stor.js b/src/commands/registration/stor.js index 2f665bb..c2277e8 100644 --- a/src/commands/registration/stor.js +++ b/src/commands/registration/stor.js @@ -15,8 +15,9 @@ module.exports = { this.commandSocket.pause(); dataSocket = socket; }) - .then(() => when.try(this.fs.write.bind(this.fs), fileName, {append})) + .then(() => when.try(this.fs.write.bind(this.fs), fileName, {append, start: this.restByteCount})) .then(stream => { + this.restByteCount = 0; return when.promise((resolve, reject) => { stream.once('error', err => dataSocket.emit('error', err)); stream.once('finish', () => resolve(this.reply(226, fileName))); diff --git a/src/commands/registration/type.js b/src/commands/registration/type.js index 5968875..d5edc27 100644 --- a/src/commands/registration/type.js +++ b/src/commands/registration/type.js @@ -2,7 +2,6 @@ module.exports = { directive: 'TYPE', handler: function ({command} = {}) { - if (/^A[0-9]?$/i.test(command.arg)) { this.transferType = 'ascii'; } else if (/^L[0-9]?$/i.test(command.arg) || /^I$/i.test(command.arg)) { diff --git a/src/commands/registry.js b/src/commands/registry.js index b9cfcb2..8dbc0df 100644 --- a/src/commands/registry.js +++ b/src/commands/registry.js @@ -21,6 +21,7 @@ const commands = [ require('./registration/port'), require('./registration/pwd'), require('./registration/quit'), + require('./registration/rest'), require('./registration/retr'), require('./registration/rmd'), require('./registration/rnfr'), diff --git a/src/connection.js b/src/connection.js index 29a65e6..90a9af9 100644 --- a/src/connection.js +++ b/src/connection.js @@ -16,7 +16,10 @@ class FtpConnection { this.log = options.log.child({id: this.id, ip: this.ip}); this.commands = new Commands(this); this.transferType = 'binary'; + this.encoding = 'utf8'; this.bufferSize = false; + this._restByteCount = 0; + this._secure = false; this.connector = new BaseConnector(this); @@ -34,7 +37,7 @@ class FtpConnection { } _handleData(data) { - const messages = _.compact(data.toString('utf8').split('\r\n')); + const messages = _.compact(data.toString(this.encoding).split('\r\n')); this.log.trace(messages); return sequence(messages.map(message => this.commands.handle.bind(this.commands, message))); } @@ -47,6 +50,20 @@ class FtpConnection { } } + get restByteCount() { + return this._restByteCount > 0 ? this._restByteCount : undefined; + } + set restByteCount(rbc) { + this._restByteCount = rbc; + } + + get secure() { + return this.server.isTLS || this._secure; + } + set secure(sec) { + this._secure = sec; + } + close(code = 421, message = 'Closing connection') { return when .resolve(code) @@ -58,7 +75,7 @@ class FtpConnection { return when.try(() => { const loginListeners = this.server.listeners('login'); if (!loginListeners || !loginListeners.length) { - if (!this.server.options.anoymous) throw new errors.GeneralError('No "login" listener setup', 500); + if (!this.server.options.anonymous) throw new errors.GeneralError('No "login" listener setup', 500); } else { return this.server.emitPromise('login', {connection: this, username, password}); } @@ -84,7 +101,7 @@ class FtpConnection { if (!letter.socket) letter.socket = options.socket ? options.socket : this.commandSocket; if (!letter.message) letter.message = DEFAULT_MESSAGE[options.code] || 'No information'; - if (!letter.encoding) letter.encoding = 'utf8'; + if (!letter.encoding) letter.encoding = this.encoding; return when(letter.message) // allow passing in a promise as a message .then(message => { letter.message = message; diff --git a/src/connector/passive.js b/src/connector/passive.js index 1f0ac8f..eb0ae73 100644 --- a/src/connector/passive.js +++ b/src/connector/passive.js @@ -41,7 +41,7 @@ class Passive extends Connector { return this.connection.reply(550, 'Remote addresses do not match') .finally(() => this.connection.close()); } - this.log.debug({port}, 'Passive connection fulfilled.'); + this.log.trace({port, remoteAddress: socket.remoteAddress}, 'Passive connection fulfilled.'); if (this.connection.secure) { const secureContext = tls.createSecureContext(this.server._tls); @@ -57,7 +57,7 @@ class Passive extends Connector { this.dataSocket.setEncoding(this.connection.transferType); this.dataSocket.on('error', err => this.server.emit('client-error', {connection: this.connection, context: 'dataSocket', error: err})); this.dataSocket.on('close', () => { - this.log.debug('Passive connection closed'); + this.log.trace('Passive connection closed'); this.end(); }); }; @@ -67,7 +67,7 @@ class Passive extends Connector { this.dataServer.maxConnections = 1; this.dataServer.on('error', err => this.server.emit('client-error', {connection: this.connection, context: 'dataServer', error: err})); this.dataServer.on('close', () => { - this.log.debug('Passive server closed'); + this.log.trace('Passive server closed'); this.dataServer = null; }); @@ -75,7 +75,7 @@ class Passive extends Connector { this.dataServer.listen(port, err => { if (err) reject(err); else { - this.log.info({port}, 'Passive connection listening'); + this.log.debug({port}, 'Passive connection listening'); resolve(this.dataServer); } }); diff --git a/src/fs.js b/src/fs.js index 65cac2f..b14df76 100644 --- a/src/fs.js +++ b/src/fs.js @@ -65,22 +65,22 @@ class FileSystem { }); } - write(fileName, {append = false} = {}) { + write(fileName, {append = false, start = undefined} = {}) { const {fsPath} = this._resolvePath(fileName); - const stream = syncFs.createWriteStream(fsPath, {flags: !append ? 'w+' : 'a+'}); + const stream = syncFs.createWriteStream(fsPath, {flags: !append ? 'w+' : 'a+', start}); stream.once('error', () => fs.unlink(fsPath)); stream.once('close', () => stream.end()); return stream; } - read(fileName) { + read(fileName, {start = undefined} = {}) { const {fsPath} = this._resolvePath(fileName); return fs.stat(fsPath) .tap(stat => { if (stat.isDirectory()) throw new errors.FileSystemError('Cannot read a directory'); }) .then(() => { - const stream = syncFs.createReadStream(fsPath, {flags: 'r'}); + const stream = syncFs.createReadStream(fsPath, {flags: 'r', start}); return stream; }); } diff --git a/src/index.js b/src/index.js index fc13a4a..fb533e9 100644 --- a/src/index.js +++ b/src/index.js @@ -47,17 +47,13 @@ class FtpServer { this.server = (this.isTLS ? tls : net).createServer(serverOptions, serverConnectionHandler); this.server.on('error', err => this.log.error(err, '[Event] error')); - if (this.isTLS) { - this.server.on('tlsClientError', err => this.log.error(err, '[Event] tlsClientError')); - } this.on = this.server.on.bind(this.server); this.once = this.server.once.bind(this.server); this.listeners = this.server.listeners.bind(this.server); process.on('SIGTERM', () => this.close()); process.on('SIGINT', () => this.close()); - process.on('SIGBREAK', () => this.close()); - process.on('SIGHUP', () => this.close()); + process.on('SIGQUIT', () => this.close()); } get isTLS() { diff --git a/test/commands/registration/opts.spec.js b/test/commands/registration/opts.spec.js index 6b60dd2..9c52043 100644 --- a/test/commands/registration/opts.spec.js +++ b/test/commands/registration/opts.spec.js @@ -19,10 +19,40 @@ describe(CMD, function () { sandbox.restore(); }); - it('// successful', () => { + it('// unsuccessful', () => { return cmdFn() .then(() => { expect(mockClient.reply.args[0][0]).to.equal(501); }); }); + + it('BAD // unsuccessful', () => { + return cmdFn({command: {arg: 'BAD', directive: CMD}}) + .then(() => { + expect(mockClient.reply.args[0][0]).to.equal(500); + }); + }); + + it('UTF8 BAD // unsuccessful', () => { + return cmdFn({command: {arg: 'UTF8 BAD', directive: CMD}}) + .then(() => { + expect(mockClient.reply.args[0][0]).to.equal(501); + }); + }); + + it('UTF8 OFF // successful', () => { + return cmdFn({command: {arg: 'UTF8 OFF', directive: CMD}}) + .then(() => { + expect(mockClient.encoding).to.equal('ascii'); + expect(mockClient.reply.args[0][0]).to.equal(200); + }); + }); + + it('UTF8 ON // successful', () => { + return cmdFn({command: {arg: 'UTF8 ON', directive: CMD}}) + .then(() => { + expect(mockClient.encoding).to.equal('utf8'); + expect(mockClient.reply.args[0][0]).to.equal(200); + }); + }); }); diff --git a/test/commands/registration/rest.spec.js b/test/commands/registration/rest.spec.js new file mode 100644 index 0000000..17a6227 --- /dev/null +++ b/test/commands/registration/rest.spec.js @@ -0,0 +1,58 @@ +const {expect} = require('chai'); +const sinon = require('sinon'); +const when = require('when'); + +const CMD = 'REST'; +describe(CMD, function () { + let sandbox; + const mockClient = { + reply: () => when.resolve() + }; + const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient); + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + + sandbox.spy(mockClient, 'reply'); + }); + afterEach(() => { + sandbox.restore(); + }); + + it('// unsuccessful', () => { + return cmdFn() + .then(() => { + expect(mockClient.reply.args[0][0]).to.equal(501); + }); + }); + + it('-1 // unsuccessful', () => { + return cmdFn({command: { arg: '-1', directive: CMD } }) + .then(() => { + expect(mockClient.reply.args[0][0]).to.equal(501); + }); + }); + + it('bad // unsuccessful', () => { + return cmdFn({command: { arg: 'bad', directive: CMD } }) + .then(() => { + expect(mockClient.reply.args[0][0]).to.equal(501); + }); + }); + + it('1 // successful', () => { + return cmdFn({command: { arg: '1', directive: CMD } }) + .then(() => { + expect(mockClient.restByteCount).to.equal(1); + expect(mockClient.reply.args[0][0]).to.equal(350); + }); + }); + + it('0 // successful', () => { + return cmdFn({command: { arg: '0', directive: CMD } }) + .then(() => { + expect(mockClient.restByteCount).to.equal(0); + expect(mockClient.reply.args[0][0]).to.equal(350); + }); + }); +});