Compare commits

..

59 Commits

Author SHA1 Message Date
Tyler Stewart
83947142df Merge pull request #13 from stewarttylerr/implicit-tls-feats
Implicit tls feats
2017-05-11 17:34:14 -06:00
Tyler Stewart
c54045e0b9 fix: correct ls and ep file stat formats
master

master

master

master
2017-05-11 16:48:08 -06:00
Tyler Stewart
cf71243729 fix: get dataSocket ip if available 2017-05-11 16:38:14 -06:00
Tyler Stewart
7fb43a5790 test: add test certs 2017-05-11 15:49:28 -06:00
Tyler Stewart
e99059125e feat(connection): add client-error events 2017-05-11 15:49:28 -06:00
Tyler Stewart
954e9a1252 feat(server): add greeting and implicit TLS 2017-05-11 15:45:28 -06:00
Tyler Stewart
2b9e163958 chore: exclude errors file from test coverage 2017-05-11 15:39:36 -06:00
Tyler Stewart
c6a49d2191 docs(readme): update readme readability 2017-05-11 15:39:14 -06:00
Tyler Stewart
14e5f87cc3 fix: allow error messages to be set via catch 2017-05-11 15:36:49 -06:00
Tyler Stewart
580b8d6eae Merge pull request #12 from stewarttylerr/fix-compatability-bugs
Fix compatability bugs
2017-05-05 18:29:00 -06:00
Tyler Stewart
a75d63df92 fix(fs): resolve paths correctly
Should solve win32 issues

fix-compatability-bugs

fix-compatability-bugs
2017-05-05 18:08:47 -06:00
Tyler Stewart
301ae110e8 test: update tests and directory structure 2017-05-05 18:08:30 -06:00
Tyler Stewart
4d69b48466 chore: update mocha reporter 2017-05-05 18:08:30 -06:00
Tyler Stewart
ec010697bb feat(commands): remove minimist command parser for basic parsing
Tokenizes command string based on spaces, concats remaining arguments

This allows filesystem commands to handle directories/files with spaces.

fix-compatability-bugs
2017-05-05 18:08:30 -06:00
Tyler Stewart
cf3d543f1a fix(commands): correctly clone command for log 2017-05-05 18:04:54 -06:00
Tyler Stewart
69bec2b01c fix(fs): normalize fs paths
- Attempting to fix compatability on windows
2017-05-04 17:43:46 -06:00
Tyler Stewart
2eac41d127 test: update test setup
master
2017-05-04 17:43:41 -06:00
Tyler Stewart
eb32f93fc6 feat: close server on SIGINT (ctrl+c) 2017-05-04 17:42:47 -06:00
Tyler Stewart
095423606e fix(find-port): stop check at 65535 2017-05-04 17:42:10 -06:00
Tyler Stewart
61cf1bda39 feat(connection): add helper get for socket remote address 2017-05-04 17:40:37 -06:00
Tyler Stewart
75f847ed5d feat(commands): obfuscate password from logs 2017-05-04 17:40:01 -06:00
Tyler Stewart
ad4b32fc13 chore: add .env to github for tests 2017-05-04 17:39:15 -06:00
Tyler Stewart
be3c57bed0 Merge pull request #10 from stewarttylerr/sandbotorg-master
Sandbotorg master
2017-04-27 13:27:46 -06:00
Tyler Stewart
dc7dd1075c test(passive): merge master 2017-04-27 13:22:57 -06:00
salper
543e6cc1cc chore(readme): fix grammar 2017-04-27 13:22:39 -06:00
salper
5c1f8f7a65 fix: plug QUIT command
master
2017-04-27 13:19:29 -06:00
Tyler Stewart
557995a1a9 test(travis): add env variables 2017-04-27 13:17:01 -06:00
Tyler Stewart
45eca5afe0 test(passive): fix test 2017-04-27 12:49:55 -06:00
Tyler Stewart
695e594d97 Merge pull request #6 from stewarttylerr/migate-to-moment
feat: migrate to moment from date-fns, fix ls format
2017-03-31 17:14:44 -06:00
Tyler Stewart
97b55fc92c feat: migrate to moment from date-fns, fix ls format
Date-fns is great, but too early for use
2017-03-31 17:12:57 -06:00
Tyler Stewart
577066850b fix: improve getting current directory 2017-03-30 12:26:04 -06:00
Tyler Stewart
0ec989cf1e docs: update login event for new root option 2017-03-29 10:20:22 -06:00
Tyler Stewart
568833e216 Merge pull request #4 from stewarttylerr/set-fs-root
feat(fs): allow default file system root to be set
2017-03-29 10:15:33 -06:00
Tyler Stewart
6b0c06e588 fix: update tests 2017-03-29 10:13:32 -06:00
Tyler Stewart
acd485a571 test: more tests
set-fs-root
2017-03-28 14:27:40 -06:00
Tyler Stewart
2b2ca45673 test: update and add tests 2017-03-27 17:57:03 -06:00
Tyler Stewart
a62b6f9559 fix: resolve disconnectClient promise, linting 2017-03-27 17:51:10 -06:00
Tyler Stewart
84d54cbc2b fix(TYPE): correctly set encoding 2017-03-27 17:51:10 -06:00
Tyler Stewart
ef6134d91b fix: wrap fs calls with when, linting 2017-03-27 17:51:10 -06:00
Tyler Stewart
043d9369cc chore(readme): minor text fixes 2017-03-27 17:51:10 -06:00
Tyler Stewart
6b81748fd7 chore: minor text fixes 2017-03-27 17:51:10 -06:00
Tyler Stewart
0f4f5cdbd7 chore(readme): update api explainations
master
2017-03-27 17:51:10 -06:00
Tyler Stewart
0293752635 chore(readme): minor text fixes 2017-03-27 13:57:54 -06:00
Tyler Stewart
aa278105f9 chore: minor text fixes 2017-03-27 13:56:51 -06:00
Tyler Stewart
bbe0bf2942 chore(readme): update api explainations
master
2017-03-16 14:09:31 -06:00
Tyler Stewart
846df72e24 feat(fs): allow default file system root to be set
Enables users to only access portions of the file system (eg /home/user)
2017-03-13 13:29:15 -06:00
Tyler Stewart
8227c512dd Merge pull request #1 from stewarttylerr/feat-abor-stou
Feat abor stou
2017-03-10 14:08:47 -07:00
Tyler Stewart
b7e17af99e feat: allow STRU to take name suggesstion 2017-03-10 13:43:37 -07:00
Tyler Stewart
9276f7f448 feat: add STOU command
master
2017-03-10 13:36:09 -07:00
Tyler Stewart
99a0ebd536 feat: add ABOR command
master
2017-03-10 13:35:19 -07:00
Tyler Stewart
0c5f8562d5 fix: log cleanup
master
2017-03-10 13:35:19 -07:00
Tyler Stewart
5154743a3a chore: correct example 2017-03-08 20:55:16 -07:00
Tyler Stewart
6654f2c25c fix(commands.list): fix first file not sending 2017-03-08 16:16:32 -07:00
Tyler Stewart
83540d268a fix: update feat and help commands
master
2017-03-08 12:45:18 -07:00
Tyler Stewart
795c3d7c65 refactor(commands): commands now store all info about themselves
Makes it easier to modify a command

Each command exports an object:
{
  directive: string or array of commands that call this handler
  handler: function to process the command
  syntax: string of how to call the command
  description: human readable explaination of command
  flags: optional object of flags
}
2017-03-08 12:31:44 -07:00
Tyler Stewart
f6d1a3828a feat: add black/white list for commands
Allow black/white list to be set for individual connections

BREAKING CHANGE: name change, removed `disabled_commands`
2017-03-07 18:41:27 -07:00
Tyler Stewart
e5b10c5858 chore: rename to ftp-srv, flows better
master
2017-03-07 18:31:06 -07:00
Tyler Stewart
4a6ab71731 chore: merge commit '43cb87a' 2017-03-06 17:55:12 -07:00
Tyler Stewart
ccc053ac8d chore(readme): update README 2017-03-06 17:49:24 -07:00
128 changed files with 2590 additions and 1135 deletions

2
.env Executable file
View File

@@ -0,0 +1,2 @@
FTP_URL=ftp://127.0.0.1:8880
PASV_RANGE=8881

1
.gitignore vendored
View File

@@ -2,5 +2,4 @@ node_modules/
dist/
reports/
.env
npm-debug.log

View File

@@ -2,6 +2,10 @@ language: node_js
node_js:
- "6"
env:
FTP_URL: ftp://127.0.0.1:8880
PASV_RANGE: 8881
install: npm install
script:

View File

@@ -1,7 +1,7 @@
<!--[CN_HEADING]-->
# Contributing
Welcome! This document explains how you can contribute to making **ftp-svr** even better.
Welcome! This document explains how you can contribute to making **ftp-srv** even better.
<!--[]-->
@@ -26,7 +26,7 @@ npm install
Code is organised into modules which contain one-or-more components. This a great way to ensure maintainable code by encapsulation of behavior logic. A component is basically a self contained app usually in a single file or a folder with each concern as a file: style, template, specs, e2e, and component class. Here's how it looks:
```
ftp-svr/
ftp-srv/
├──config/ * configuration files live here (e.g. eslint, verify, testUnit)
├──src/ * source code files should be here

View File

@@ -1,4 +1,4 @@
ftp-svr Copyright (c) 2017 Tyler Stewart
ftp-srv Copyright (c) 2017 Tyler Stewart
MIT License

218
README.md
View File

@@ -1,139 +1,191 @@
# ftp-svr [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/)
# ftp-srv [![npm version](https://badge.fury.io/js/ftp-srv.svg)](https://badge.fury.io/js/ftp-srv) [![Build Status](https://travis-ci.org/stewarttylerr/ftp-srv.svg?branch=master)](https://travis-ci.org/stewarttylerr/ftp-srv) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/)
<!--[RM_DESCRIPTION]-->
> Modern, extensible FTP Server
<!--[]-->
- [Overview](#overview)
- [Features](#features)
- [Install](#install)
- [Usage](#usage)
- [API](#api)
- [Events](#events)
- [Supported Commands](#supported-commands)
- [File System](#file-system)
- [Contributing](#contributing)
- [License](#license)
## Overview
`ftp-srv` is a modern and extensible FTP server designed to be simple yet configurable.
## Features
- Supports passive and active connections
- Extensible [file system](#file-system)
- Extensible [file systems](#file-system) per connection
- Passive and active transfers
- Implicit TLS connections
## Install
`npm install ftp-svr --save`
`yarn add ftp-svr`
`npm install ftp-srv --save`
## Usage
- [Options](#options)
- [Events](#events)
- [File System](#file-system)
```js
const FtpSvr = require('ftp-svr');
const ftpServer = new FtpSvr({ [options] ... });
// Quick start
ftpServer.on('...', (data, resolve, reject) => { ... })
const FtpSvr = require('ftp-srv');
const ftpServer = new FtpSvr('ftp://0.0.0.0:9876', { options ... });
ftpServer.on('login', (data, resolve, reject) => { ... });
...
ftpServer.listen()
.then(() => { ... });
```
### Options
__url__ : `ftp://127.0.0.1:21`
> Host and port to listen on and make passive connections to.
Set the hostname to "0.0.0.0" to fetch the external IP automatically: `ftp://0.0.0.0:21`
## API
__pasv_range__ : `22`
> Minimum port or range to use for passive connections.
Provide either a starting integer (`1000`) or a range (`1000-2000`).
### `new FtpSrv(url, [{options}])`
#### `url`
[URL string](https://nodejs.org/api/url.html#url_url_strings_and_url_objects) indicating the protocol, hostname, and port to listen on for connections.
Supported protocols:
- `ftp` Plain FTP
- `ftps` Implicit FTP over TLS
_Note:_ The hostname must be the external IP address to accept external connections. Setting the hostname to `0.0.0.0` will automatically set the external IP.
__Default:__ `"ftp://127.0.0.1:21"`
__anonymous__ : `false`
> Set whether a valid username or password combination is required.
If true, will not require the `PASS` command to be sent for login.
#### `options`
__disabled_commands__ : `[]`
> String array of commands to forbid.
`['RMD', 'RNFR', 'RNTO']`
- ##### `pasv_range`
A starting port (eg `8000`) or a range (eg `"8000-9000"`) to accept passive connections.
This range is then queried for an available port to use when required.
__Default:__ `22`
__file_format__ : `ls`
> Format to use for [file stat](https://nodejs.org/api/fs.html#fs_class_fs_stats) responses (such as with the `LIST` command).
Possible values:
- `ls` : [bin/ls format](https://cr.yp.to/ftp/list/binls.html)
- `ep` : [Easily Parsed LIST format](https://cr.yp.to/ftp/list/eplf.html)
- `function` : pass in your own format function, returning a string:
`function (fileStats) { ... }`
- ##### `greeting`
A human readable array of lines or string to send when a client connects.
__Default:__ `null`
__log__ : `bunyan`
> A [bunyan logger](https://github.com/trentm/node-bunyan) instance.
- ##### `tls`
Node [TLS secure context object](https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options) used for implicit `ftps`.
__Default:__ `{}`
### Events
All events emit the same structure: `({data object}, resolve, reject)`
- ##### `anonymous`
If true, will call the event login after `USER`, not requiring a password from the user.
__Default:__ `false`
__login__ : `{connection, username, password}`
> Occurs after `PASV` (or `USER` if `options.anonymous`)
- ##### `blacklist`
Array of commands that are not allowed.
Response code `502` is sent to clients sending one of these commands.
__Example:__ `['RMD', 'RNFR', 'RNTO']` will not allow users to delete directories or rename any files.
__Default:__ `[]`
- ##### `whitelist`
Array of commands that are only allowed.
Response code `502` is sent to clients sending any other command.
__Default:__ `[]`
- ##### `file_format`
Sets the format to use for file stat queries such as `LIST`.
__Default:__ `"ls"`
__Allowable values:__
- `ls` [bin/ls format](https://cr.yp.to/ftp/list/binls.html)
- `ep` [Easily Parsed LIST format](https://cr.yp.to/ftp/list/eplf.html)
- `function () {}` A custom function returning a format or promise for one.
- Only one argument is passed in: a node [file stat](https://nodejs.org/api/fs.html#fs_class_fs_stats) object with additional file `name` parameter
- ##### `log`
A [bunyan logger](https://github.com/trentm/node-bunyan) instance. Created by default.
## Events
The `FtpSvr` class extends the [node net.Server](https://nodejs.org/api/net.html#net_class_net_server). Some custom events can be resolved or rejected, such as `login`.
### `login`
```js
on('login', {connection, username, password}, resolve, reject) => { ... }
```
resolve({
fs, // [optional] custom file system class
cwd // [optional] initial working directory (if not using custom file system)
})
```
### File System
The file system can be overridden to use your own custom class. This an allow for interacting with files without actually writing them.
Occurs when a client is attempting to login. Here you can resolve the login request by username and password.
`resolve` takes an object of arguments:
- `fs`
- Set a custom file system class for this connection to use.
- See [File System](#file-system) for implementation details.
- `root`
- If `fs` is not provided, this will set the root directory for the connection.
- The user cannot traverse lower than this directory.
- `cwd`
- If `fs` is not provided, will set the starting directory for the connection
- This is relative to the `root` directory.
- `blacklist`
- Commands that are forbidden for only this connection
- `whitelist`
- If set, this connection will only be able to use the provided commands
*Anytime a [file stat](https://nodejs.org/api/fs.html#fs_class_fs_stats) object is used, it must have added `name` property with the file's name.*
`reject` takes an error object
#### Functions
`currentDirectory()`
> Returns a string of the current working directory
### `client-error`
```js
on('client-error', {connection, context, error}) => { ... }
```
> Used in: `PWD`
Occurs when an error occurs in the client connection.
`get(fileName)`
> Returns a file stat object of file or directory
## Supported Commands
> Used in: `STAT`, `SIZE`, `RNFR`, `MDTM`
See [commands](src/commands) for a list of all implemented FTP commands.
`list(path)`
> Returns array of file and directory stat objects
## File System
The default file system can be overwritten to use your own implementation.
This can allow for virtual file systems, and more.
Each connection can set it's own file system based on the user.
> Used in `LIST`, `STAT`
Custom file systems can implement the following variables depending on the developers needs.
`chdir(path)`
> Returns new directory relative to cwd
### Methods
#### [`currentDirectory()`](src/fs.js#L29)
Returns a string of the current working directory
__Used in:__ `PWD`
> Used in `CWD`, `CDUP`
#### [`get(fileName)`](src/fs.js#L33)
Returns a file stat object of file or directory
__Used in:__ `STAT`, `SIZE`, `RNFR`, `MDTM`
`mkdir(path)`
> Return a path to a newly created directory
#### [`list(path)`](src/fs.js#L39)
Returns array of file and directory stat objects
> Used in `MKD`
__Used in:__ `LIST`, `STAT`
`write(fileName, options)`
> Returns a writable stream
Options:
`append` if true, append to existing file
#### [`chdir(path)`](src/fs.js#L56)
Returns new directory relative to current directory
__Used in:__ `CWD`, `CDUP`
> Used in `STOR`, `APPE`
#### [`mkdir(path)`](src/fs.js#L96)
Returns a path to a newly created directory
__Used in:__ `MKD`
`read(fileName)`
> Returns a readable stream
#### [`write(fileName, {append = false})`](src/fs.js#L68)
Returns a writable stream
Options: `append` if true, append to existing file
__Used in:__ `STOR`, `APPE`
> Used in `RETR`
#### [`read(fileName)`](src/fs.js#L75)
Returns a readable stream
__Used in:__ `RETR`
`delete(path)`
> Delete a file or directory
#### [`delete(path)`](src/fs.js#L87)
Delete a file or directory
__Used in:__ `DELE`
> Used in `DELE`
#### [`rename(from, to)`](src/fs.js#L102)
Rename a file or directory
__Used in:__ `RNFR`, `RNTO`
`rename(from, to)`
> Rename a file or directory
#### [`chmod(path)`](src/fs.js#L108)
Modify a file or directory's permissions
__Used in:__ `SITE CHMOD`
> Used in `RNFR`, `RNTO`
`chmod(path)`
> Modify a file or directory's permissions
> Used in `SITE CHMOD`
#### [`getUniqueName()`](src/fs.js#L113)
Returns a unique file name to write to
__Used in:__ `STOU`
<!--[RM_CONTRIBUTING]-->
## Contributing

View File

@@ -1,7 +1,8 @@
// Use JS to support loading of threshold data from external file
var coverageConfig = {
instrumentation: {
root: 'src/'
root: 'src/',
excludes: ['errors.js']
},
check: require('./thresholds.json'),
reporting: {

View File

@@ -1,4 +1,3 @@
test/**/*.spec.js
--reporter list
--no-timeouts
--reporter mocha-pretty-bunyan-nyan
--ui bdd

View File

@@ -1,14 +1,14 @@
{
"global": {
"statements": 70,
"branches": 60,
"functions": 80,
"lines": 80
"statements": 90,
"branches": 80,
"functions": 90,
"lines": 90
},
"each": {
"statements": 0,
"branches": 0,
"functions": 0,
"lines": 0
"statements": 70,
"branches": 40,
"functions": 60,
"lines": 70
}
}

View File

@@ -1,10 +1,11 @@
{
"name": "ftp-svr",
"name": "ftp-srv",
"version": "0.0.0",
"description": "Modern, extensible FTP Server",
"keywords": [
"ftp",
"ftp-server",
"ftp-srv",
"ftp-svr",
"ftpd",
"server"
@@ -13,7 +14,7 @@
"main": "src/index.js",
"repository": {
"type": "git",
"url": "https://github.com/stewarttylerr/ftp-svr"
"url": "https://github.com/stewarttylerr/ftp-srv"
},
"scripts": {
"pre-release": "npm-run-all verify test:coverage build ",
@@ -45,10 +46,9 @@
}
},
"dependencies": {
"bunyan": "^1.8.5",
"date-fns": "^1.28.0",
"bunyan": "^1.8.9",
"lodash": "^4.17.4",
"minimist-string": "^1.0.2",
"moment": "^2.18.1",
"uuid": "^3.0.1",
"when": "^3.7.8"
},
@@ -67,11 +67,11 @@
"husky": "0.13.1",
"istanbul": "0.4.5",
"mocha": "3.2.0",
"mocha-pretty-bunyan-nyan": "^1.0.4",
"npm-run-all": "4.0.1",
"rimraf": "2.5.4",
"semantic-release": "^6.3.2",
"sinon": "^1.17.7",
"sinon-as-promised": "^4.0.2"
"sinon": "^2.1.0"
},
"engines": {
"node": ">=6.x",

View File

@@ -1,3 +0,0 @@
module.exports = function () {
return this.reply(202);
}

View File

@@ -1,19 +0,0 @@
const _ = require('lodash');
module.exports = function ({command} = {}) {
const method = _.upperCase(command._[1]);
switch (method) {
case 'TLS': return handleTLS.call(this);
case 'SSL': return handleSSL.call(this);
default: return this.reply(504);
}
}
function handleTLS() {
return this.reply(504);
}
function handleSSL() {
return this.reply(504);
}

View File

@@ -1,6 +0,0 @@
const cwd = require('./cwd');
module.exports = function(args) {
args.command._ = [args.command._[0], '..'];
return cwd.call(this, args);
}

View File

@@ -1,17 +0,0 @@
const when = require('when');
const escapePath = require('../helpers/escape-path');
module.exports = function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.chdir) return this.reply(402, 'Not supported by file system');
return when(this.fs.chdir(command._[1]))
.then(cwd => {
const path = cwd ? `"${escapePath(cwd)}"` : undefined;
return this.reply(250, path);
})
.catch(err => {
log.error(err);
return this.reply(550, err.message);
});
}

View File

@@ -1,15 +0,0 @@
const when = require('when');
module.exports = function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.delete) return this.reply(402, 'Not supported by file system');
return when(this.fs.delete(command._[1]))
.then(() => {
return this.reply(250);
})
.catch(err => {
log.error(err);
return this.reply(550);
});
}

View File

@@ -1,10 +0,0 @@
const _ = require('lodash');
module.exports = function () {
const registry = require('./registry');
const features = Object.keys(registry)
.filter(cmd => registry[cmd].hasOwnProperty('feat'))
.reduce((feats, cmd) => _.concat(feats, registry[cmd].feat), [])
.map(feat => ` ${feat}`);
return this.reply(211, 'Extensions supported', ...features, 'END');
}

View File

@@ -1,16 +0,0 @@
const _ = require('lodash');
module.exports = function ({command} = {}) {
const registry = require('./registry');
const directive = _.upperCase(command._[1]);
if (directive) {
if (!registry.hasOwnProperty(directive)) return this.reply(502, `Unknown command ${directive}.`);
const {syntax, help, obsolete} = registry[directive];
const reply = _.concat([syntax, help, obsolete ? 'Obsolete' : null]);
return this.reply(214, ...reply);
} else {
const supportedCommands = _.chunk(Object.keys(registry), 5).map(chunk => chunk.join('\t'));
return this.reply(211, 'Supported commands:', ...supportedCommands, 'Use "HELP [command]" for syntax help.');
}
};

View File

@@ -1,29 +1,52 @@
const _ = require('lodash');
const when = require('when');
const REGISTRY = require('./registry');
class FtpCommands {
constructor(connection) {
this.connection = connection;
this.registry = require('./registry');
this.previousCommand = {};
this.disabledCommands = _.get(this.connection, 'server.options.disabled_commands', []).map(cmd => _.upperCase(cmd));
this.blacklist = _.get(this.connection, 'server.options.blacklist', []).map(cmd => _.upperCase(cmd));
this.whitelist = _.get(this.connection, 'server.options.whitelist', []).map(cmd => _.upperCase(cmd));
}
parse(message) {
const [directive, ...args] = message.replace(/"/g, '').split(' ');
const command = {
directive: _.chain(directive).trim().toUpper().value(),
arg: _.compact(args).join(' ') || null,
raw: message
};
return command;
}
handle(command) {
const log = this.connection.log.child({command});
log.trace('Handle command');
if (typeof command === 'string') command = this.parse(command);
if (!this.registry.hasOwnProperty(command.directive)) {
// Obfuscate password from logs
const logCommand = _.clone(command);
if (logCommand.directive === 'PASS') logCommand.arg = '********';
const log = this.connection.log.child({directive: command.directive});
log.trace({command: logCommand}, 'Handle command');
if (!REGISTRY.hasOwnProperty(command.directive)) {
return this.connection.reply(402, 'Command not allowed');
}
if (_.includes(this.disabledCommands, command.directive)) {
return this.connection.reply(502, 'Command forbidden');
if (_.includes(this.blacklist, command.directive)) {
return this.connection.reply(502, 'Command blacklisted');
}
const commandRegister = this.registry[command.directive];
if (!commandRegister.no_auth && !this.connection.authenticated) {
return this.connection.reply(530);
if (this.whitelist.length > 0 && !_.includes(this.whitelist, command.directive)) {
return this.connection.reply(502, 'Command not whitelisted');
}
const commandRegister = REGISTRY[command.directive];
const commandFlags = _.get(commandRegister, 'flags', {});
if (!commandFlags.no_auth && !this.connection.authenticated) {
return this.connection.reply(530, 'Command requires authentication');
}
if (!commandRegister.handler) {

View File

@@ -1,53 +0,0 @@
const _ = require('lodash');
const when = require('when');
const getFileStat = require('../helpers/file-stat');
// http://cr.yp.to/ftp/list.html
// http://cr.yp.to/ftp/list/eplf.html
module.exports = function ({log, command, previous_command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.list) return this.reply(402, 'Not supported by file system');
const simple = command.directive === 'NLST';
let dataSocket;
const directory = command._[1] || '.';
return this.connector.waitForConnection()
.then(socket => {
this.commandSocket.pause();
dataSocket = socket;
})
.then(() => when(this.fs.list(directory)))
.then(files => {
const getFileMessage = (file) => {
if (simple) return file.name;
return getFileStat(file, _.get(this, 'server.options.file_format', 'ls'));
};
const fileList = files.map(file => {
const message = getFileMessage(file);
return {
raw: true,
message,
socket: dataSocket
};
})
return this.reply(150)
.then(() => this.reply(...fileList));
})
.then(() => {
return this.reply(226, 'Transfer OK');
})
.catch(when.TimeoutError, err => {
log.error(err);
return this.reply(425, 'No connection established');
})
.catch(err => {
log.error(err);
return this.reply(err.code || 451, err.message || 'No directory');
})
.finally(() => {
this.connector.end();
this.commandSocket.resume();
});
}

View File

@@ -1,17 +0,0 @@
const when = require('when');
const format = require('date-fns/format');
module.exports = function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.get) return this.reply(402, 'Not supported by file system');
return when(this.fs.get(command._[1]))
.then(fileStat => {
const modificationTime = format(fileStat.mtime, 'YYYYMMDDHHmmss.SSS');
return this.reply(213, modificationTime)
})
.catch(err => {
log.error(err);
return this.reply(550);
});
}

View File

@@ -1,17 +0,0 @@
const when = require('when');
const escapePath = require('../helpers/escape-path');
module.exports = function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.mkdir) return this.reply(402, 'Not supported by file system');
return when(this.fs.mkdir(command._[1]))
.then(dir => {
const path = dir ? `"${escapePath(dir)}"` : undefined;
return this.reply(257, path);
})
.catch(err => {
log.error(err);
return this.reply(550);
});
}

View File

@@ -1,3 +0,0 @@
module.exports = function ({command} = {}) {
return this.reply(command._[1] === 'S' ? 200 : 504);
}

View File

@@ -1,3 +0,0 @@
module.exports = function () {
return this.reply(200);
}

View File

@@ -1,3 +0,0 @@
module.exports = function () {
return this.reply(501);
}

View File

@@ -1,19 +0,0 @@
const _ = require('lodash');
module.exports = function ({log, command} = {}) {
if (!this.username) return this.reply(503);
if (this.username && this.authenticated &&
_.get(this, 'server.options.anonymous') === true) return this.reply(230);
// 332 : require account name (ACCT)
const password = command._[1];
return this.login(this.username, password)
.then(() => {
return this.reply(230);
})
.catch(err => {
log.error(err);
return this.reply(530, err.message || 'Authentication failed');
});
};

View File

@@ -1,15 +0,0 @@
const PassiveConnector = require('../connector/passive');
module.exports = function ({command} = {}) {
this.connector = new PassiveConnector(this);
return this.connector.setupServer()
.then(server => {
const address = this.server.url.hostname;
const {port} = server.address();
const host = address.replace(/\./g, ',');
const portByte1 = port / 256 | 0;
const portByte2 = port % 256;
return this.reply(227, `PASV OK (${host},${portByte1},${portByte2})`);
});
}

View File

@@ -1,16 +0,0 @@
const ActiveConnector = require('../connector/active');
module.exports = function ({command} = {}) {
this.connector = new ActiveConnector(this);
const rawConnection = command._[1].split(',');
if (rawConnection.length !== 6) return this.reply(425);
const ip = rawConnection.slice(0, 4).join('.');
const portBytes = rawConnection.slice(4).map(p => parseInt(p));
const port = portBytes[0] * 256 + portBytes[1];
return this.connector.setupConnection(ip, port)
.then(socket => {
return this.reply(200);
})
}

View File

@@ -1,17 +0,0 @@
const when = require('when');
const escapePath = require('../helpers/escape-path');
module.exports = function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.currentDirectory) return this.reply(402, 'Not supported by file system');
return when(this.fs.currentDirectory())
.then(cwd => {
const path = cwd ? `"${escapePath(cwd)}"` : undefined;
return this.reply(257, path);
})
.catch(err => {
log.error(err);
return this.reply(550, err.message);
});
}

View File

@@ -1,3 +0,0 @@
module.exports = function () {
return this.close(221);
}

View File

@@ -0,0 +1,14 @@
module.exports = {
directive: 'ABOR',
handler: function () {
return this.connector.waitForConnection()
.then(socket => {
return this.reply(426, {socket})
.then(() => this.connector.end());
})
.catch(() => {})
.then(() => this.reply(226));
},
syntax: '{{cmd}}',
description: 'Abort an active file transfer'
};

View File

@@ -0,0 +1,11 @@
module.exports = {
directive: 'ALLO',
handler: function () {
return this.reply(202);
},
syntax: '{{cmd}}',
description: 'Allocate sufficient disk space to receive a file',
flags: {
obsolete: true
}
};

View File

@@ -0,0 +1,10 @@
const stor = require('./stor').handler;
module.exports = {
directive: 'APPE',
handler: function (args) {
return stor.call(this, args);
},
syntax: '{{cmd}} [path]',
description: 'Append to a file'
};

View File

@@ -0,0 +1,27 @@
const _ = require('lodash');
module.exports = {
directive: 'AUTH',
handler: function ({command} = {}) {
const method = _.upperCase(command.arg);
switch (method) {
case 'TLS': return handleTLS.call(this);
case 'SSL': return handleSSL.call(this);
default: return this.reply(504);
}
},
syntax: '{{cmd}} [type]',
description: 'Set authentication mechanism',
flags: {
no_auth: true
}
};
function handleTLS() {
return this.reply(504);
}
function handleSSL() {
return this.reply(504);
}

View File

@@ -0,0 +1,11 @@
const cwd = require('./cwd').handler;
module.exports = {
directive: ['CDUP', 'XCUP'],
handler: function (args) {
args.command.arg = '..';
return cwd.call(this, args);
},
syntax: '{{cmd}}',
description: 'Change to Parent Directory'
};

View File

@@ -0,0 +1,22 @@
const when = require('when');
const escapePath = require('../../helpers/escape-path');
module.exports = {
directive: ['CWD', 'XCWD'],
handler: function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.chdir) return this.reply(402, 'Not supported by file system');
return when.try(this.fs.chdir.bind(this.fs), command.arg)
.then(cwd => {
const path = cwd ? `"${escapePath(cwd)}"` : undefined;
return this.reply(250, path);
})
.catch(err => {
log.error(err);
return this.reply(550, err.message);
});
},
syntax: '{{cmd}}[path]',
description: 'Change working directory'
};

View File

@@ -0,0 +1,20 @@
const when = require('when');
module.exports = {
directive: 'DELE',
handler: function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.delete) return this.reply(402, 'Not supported by file system');
return when.try(this.fs.delete.bind(this.fs), command.arg)
.then(() => {
return this.reply(250);
})
.catch(err => {
log.error(err);
return this.reply(550, err.message);
});
},
syntax: '{{cmd}} [path]',
description: 'Delete file'
};

View File

@@ -0,0 +1,21 @@
const _ = require('lodash');
module.exports = {
directive: 'FEAT',
handler: function () {
const registry = require('../registry');
const features = Object.keys(registry)
.reduce((feats, cmd) => {
const feat = _.get(registry[cmd], 'flags.feat', null);
if (feat) return _.concat(feats, feat);
return feats;
}, [])
.map(feat => ` ${feat}`);
return this.reply(211, 'Extensions supported', ...features, 'END');
},
syntax: '{{cmd}}',
description: 'Get the feature list implemented by the server',
flags: {
no_auth: true
}
};

View File

@@ -0,0 +1,24 @@
const _ = require('lodash');
module.exports = {
directive: 'HELP',
handler: function ({command} = {}) {
const registry = require('../registry');
const directive = _.upperCase(command.arg);
if (directive) {
if (!registry.hasOwnProperty(directive)) return this.reply(502, `Unknown command ${directive}.`);
const {syntax, description} = registry[directive];
const reply = _.concat([syntax.replace('{{cmd}}', directive), description]);
return this.reply(214, ...reply);
} else {
const supportedCommands = _.chunk(Object.keys(registry), 5).map(chunk => chunk.join('\t'));
return this.reply(211, 'Supported commands:', ...supportedCommands, 'Use "HELP [command]" for syntax help.');
}
},
syntax: '{{cmd}} [command(optional)]',
description: 'Returns usage documentation on a command if specified, else a general help document is returned',
flags: {
no_auth: true
}
};

View File

@@ -0,0 +1,60 @@
const _ = require('lodash');
const when = require('when');
const getFileStat = require('../../helpers/file-stat');
// http://cr.yp.to/ftp/list.html
// http://cr.yp.to/ftp/list/eplf.html
module.exports = {
directive: 'LIST',
handler: function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.list) return this.reply(402, 'Not supported by file system');
const simple = command.directive === 'NLST';
let dataSocket;
const directory = command.arg || '.';
return this.connector.waitForConnection()
.then(socket => {
this.commandSocket.pause();
dataSocket = socket;
})
.then(() => when.try(this.fs.list.bind(this.fs), directory))
.then(files => {
const getFileMessage = file => {
if (simple) return file.name;
return getFileStat(file, _.get(this, 'server.options.file_format', 'ls'));
};
const fileList = files.map(file => {
const message = getFileMessage(file);
return {
raw: true,
message,
socket: dataSocket
};
});
return this.reply(150)
.then(() => {
if (fileList.length) return this.reply({}, ...fileList);
});
})
.then(() => {
return this.reply(226, 'Transfer OK');
})
.catch(when.TimeoutError, err => {
log.error(err);
return this.reply(425, 'No connection established');
})
.catch(err => {
log.error(err);
return this.reply(451, err.message || 'No directory');
})
.finally(() => {
this.connector.end();
this.commandSocket.resume();
});
},
syntax: '{{cmd}} [path(optional)]',
description: 'Returns information of a file or directory if specified, else information of the current working directory is returned'
};

View File

@@ -0,0 +1,25 @@
const when = require('when');
const moment = require('moment');
module.exports = {
directive: 'MDTM',
handler: function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.get) return this.reply(402, 'Not supported by file system');
return when.try(this.fs.get.bind(this.fs), command.arg)
.then(fileStat => {
const modificationTime = moment.utc(fileStat.mtime).format('YYYYMMDDHHmmss.SSS');
return this.reply(213, modificationTime);
})
.catch(err => {
log.error(err);
return this.reply(550, err.message);
});
},
syntax: '{{cmd}} [path]',
description: 'Return the last-modified time of a specified file',
flags: {
feat: 'MDTM'
}
};

View File

@@ -0,0 +1,22 @@
const when = require('when');
const escapePath = require('../../helpers/escape-path');
module.exports = {
directive: ['MKD', 'XMKD'],
handler: function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.mkdir) return this.reply(402, 'Not supported by file system');
return when.try(this.fs.mkdir.bind(this.fs), command.arg)
.then(dir => {
const path = dir ? `"${escapePath(dir)}"` : undefined;
return this.reply(257, path);
})
.catch(err => {
log.error(err);
return this.reply(550, err.message);
});
},
syntax: '{{cmd}}[path]',
description: 'Make directory'
};

View File

@@ -0,0 +1,11 @@
module.exports = {
directive: 'MODE',
handler: function ({command} = {}) {
return this.reply(/^S$/i.test(command.arg) ? 200 : 504);
},
syntax: '{{cmd}} [mode]',
description: 'Sets the transfer mode (Stream, Block, or Compressed)',
flags: {
obsolete: true
}
};

View File

@@ -0,0 +1,10 @@
const list = require('./list').handler;
module.exports = {
directive: 'NLST',
handler: function (args) {
return list.call(this, args);
},
syntax: '{{cmd}} [path(optional)]',
description: 'Returns a list of file names in a specified directory'
};

View File

@@ -0,0 +1,11 @@
module.exports = {
directive: 'NOOP',
handler: function () {
return this.reply(200);
},
syntax: '{{cmd}}',
description: 'No operation',
flags: {
no_auth: true
}
};

View File

@@ -0,0 +1,8 @@
module.exports = {
directive: 'OPTS',
handler: function () {
return this.reply(501);
},
syntax: '{{cmd}}',
description: 'Select options for a feature'
};

View File

@@ -0,0 +1,27 @@
const _ = require('lodash');
module.exports = {
directive: 'PASS',
handler: function ({log, command} = {}) {
if (!this.username) return this.reply(503);
if (this.username && this.authenticated &&
_.get(this, 'server.options.anonymous') === true) return this.reply(230);
// 332 : require account name (ACCT)
const password = command.arg;
return this.login(this.username, password)
.then(() => {
return this.reply(230);
})
.catch(err => {
log.error(err);
return this.reply(530, err.message || 'Authentication failed');
});
},
syntax: '{{cmd}} [password]',
description: 'Authentication password',
flags: {
no_auth: true
}
};

View File

@@ -0,0 +1,20 @@
const PassiveConnector = require('../../connector/passive');
module.exports = {
directive: 'PASV',
handler: function () {
this.connector = new PassiveConnector(this);
return this.connector.setupServer()
.then(server => {
const address = this.server.url.hostname;
const {port} = server.address();
const host = address.replace(/\./g, ',');
const portByte1 = port / 256 | 0;
const portByte2 = port % 256;
return this.reply(227, `PASV OK (${host},${portByte1},${portByte2})`);
});
},
syntax: '{{cmd}}',
description: 'Initiate passive mode'
};

View File

@@ -0,0 +1,20 @@
const _ = require('lodash');
const ActiveConnector = require('../../connector/active');
module.exports = {
directive: 'PORT',
handler: function ({command} = {}) {
this.connector = new ActiveConnector(this);
const rawConnection = _.get(command, 'arg', '').split(',');
if (rawConnection.length !== 6) return this.reply(425);
const ip = rawConnection.slice(0, 4).join('.');
const portBytes = rawConnection.slice(4).map(p => parseInt(p));
const port = portBytes[0] * 256 + portBytes[1];
return this.connector.setupConnection(ip, port)
.then(() => this.reply(200));
},
syntax: '{{cmd}} x,x,x,x,y,y',
description: 'Specifies an address and port to which the server should connect'
};

View File

@@ -0,0 +1,22 @@
const when = require('when');
const escapePath = require('../../helpers/escape-path');
module.exports = {
directive: ['PWD', 'XPWD'],
handler: function ({log} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.currentDirectory) return this.reply(402, 'Not supported by file system');
return when.try(this.fs.currentDirectory.bind(this.fs))
.then(cwd => {
const path = cwd ? `"${escapePath(cwd)}"` : undefined;
return this.reply(257, path);
})
.catch(err => {
log.error(err);
return this.reply(550, err.message);
});
},
syntax: '{{cmd}}',
description: 'Print current working directory'
};

View File

@@ -0,0 +1,11 @@
module.exports = {
directive: 'QUIT',
handler: function () {
return this.close(221);
},
syntax: '{{cmd}}',
description: 'Disconnect',
flags: {
no_auth: true
}
};

View File

@@ -0,0 +1,41 @@
const when = require('when');
module.exports = {
directive: 'RETR',
handler: function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.read) return this.reply(402, 'Not supported by file system');
let dataSocket;
return this.connector.waitForConnection()
.then(socket => {
this.commandSocket.pause();
dataSocket = socket;
})
.then(() => when.try(this.fs.read.bind(this.fs), command.arg))
.then(stream => {
return when.promise((resolve, reject) => {
dataSocket.on('error', err => stream.emit('error', err));
stream.on('data', data => dataSocket.write(data, this.encoding));
stream.on('end', () => resolve(this.reply(226)));
stream.on('error', err => reject(err));
this.reply(150).then(() => dataSocket.resume());
});
})
.catch(when.TimeoutError, err => {
log.error(err);
return this.reply(425, 'No connection established');
})
.catch(err => {
log.error(err);
return this.reply(551, err.message);
})
.finally(() => {
this.connector.end();
this.commandSocket.resume();
});
},
syntax: '{{cmd}} [path]',
description: 'Retrieve a copy of the file'
};

View File

@@ -0,0 +1,10 @@
const dele = require('./dele').handler;
module.exports = {
directive: ['RMD', 'XRMD'],
handler: function (args) {
return dele.call(this, args);
},
syntax: '{{cmd}} [path]',
description: 'Remove a directory'
};

View File

@@ -0,0 +1,22 @@
const when = require('when');
module.exports = {
directive: 'RNFR',
handler: function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.get) return this.reply(402, 'Not supported by file system');
const fileName = command.arg;
return when.try(this.fs.get.bind(this.fs), fileName)
.then(() => {
this.renameFrom = fileName;
return this.reply(350);
})
.catch(err => {
log.error(err);
return this.reply(550, err.message);
});
},
syntax: '{{cmd}} [name]',
description: 'Rename from'
};

View File

@@ -0,0 +1,28 @@
const when = require('when');
module.exports = {
directive: 'RNTO',
handler: function ({log, command} = {}) {
if (!this.renameFrom) return this.reply(503);
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.rename) return this.reply(402, 'Not supported by file system');
const from = this.renameFrom;
const to = command.arg;
return when.try(this.fs.rename.bind(this.fs), from, to)
.then(() => {
return this.reply(250);
})
.catch(err => {
log.error(err);
return this.reply(550, err.message);
})
.finally(() => {
delete this.renameFrom;
});
},
syntax: '{{cmd}} [name]',
description: 'Rename to'
};

View File

@@ -1,14 +1,17 @@
const when = require('when');
module.exports = function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.chmod) return this.reply(402, 'Not supported by file system');
const [, mode, fileName] = command._;
return this.fs.chmod(fileName, parseInt(mode, 8))
const [mode, ...fileNameParts] = command.arg.split(' ');
const fileName = fileNameParts.join(' ');
return when.try(this.fs.chmod.bind(this.fs), fileName, parseInt(mode, 8))
.then(() => {
return this.reply(200);
})
.catch(err => {
log.error(err);
return this.reply(500);
})
});
};

View File

@@ -0,0 +1,17 @@
const when = require('when');
module.exports = {
directive: 'SITE',
handler: function ({log, command} = {}) {
const registry = require('./registry');
const subCommand = this.commands.parse(command.arg);
const subLog = log.child({subverb: subCommand.directive});
if (!registry.hasOwnProperty(subCommand.directive)) return this.reply(502);
const handler = registry[subCommand.directive].handler.bind(this);
return when.try(handler, { log: subLog, command: subCommand });
},
syntax: '{{cmd}} [subVerb] [subParams]',
description: 'Sends site specific commands to remote server'
};

View File

@@ -0,0 +1,23 @@
const when = require('when');
module.exports = {
directive: 'SIZE',
handler: function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.get) return this.reply(402, 'Not supported by file system');
return when.try(this.fs.get.bind(this.fs), command.arg)
.then(fileStat => {
return this.reply(213, {message: fileStat.size});
})
.catch(err => {
log.error(err);
return this.reply(550, err.message);
});
},
syntax: '{{cmd}} [path]',
description: 'Return the size of a file',
flags: {
feat: 'SIZE'
}
};

View File

@@ -0,0 +1,44 @@
const _ = require('lodash');
const when = require('when');
const getFileStat = require('../../helpers/file-stat');
module.exports = {
directive: 'STAT',
handler: function (args = {}) {
const {log, command} = args;
const path = _.get(command, 'arg');
if (path) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.get) return this.reply(402, 'Not supported by file system');
return when.try(this.fs.get.bind(this.fs), path)
.then(stat => {
if (stat.isDirectory()) {
if (!this.fs.list) return this.reply(402, 'Not supported by file system');
return when.try(this.fs.list.bind(this.fs), path)
.then(files => {
const fileList = files.map(file => {
const message = getFileStat(file, _.get(this, 'server.options.file_format', 'ls'));
return {
raw: true,
message
};
});
return this.reply(213, 'Status begin', ...fileList, 'Status end');
});
} else {
return this.reply(212, getFileStat(stat, _.get(this, 'server.options.file_format', 'ls')));
}
})
.catch(err => {
log.error(err);
return this.reply(450, err.message);
});
} else {
return this.reply(211, 'Status OK');
}
},
syntax: '{{cmd}} [path(optional)]',
description: 'Returns the current status'
};

View File

@@ -0,0 +1,44 @@
const when = require('when');
module.exports = {
directive: 'STOR',
handler: function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.write) return this.reply(402, 'Not supported by file system');
const append = command.directive === 'APPE';
const fileName = command.arg;
let dataSocket;
return this.connector.waitForConnection()
.then(socket => {
this.commandSocket.pause();
dataSocket = socket;
})
.then(() => when.try(this.fs.write.bind(this.fs), fileName, {append}))
.then(stream => {
return when.promise((resolve, reject) => {
stream.on('error', err => dataSocket.emit('error', err));
dataSocket.on('end', () => stream.end(() => resolve(this.reply(226, fileName))));
dataSocket.on('error', err => reject(err));
dataSocket.on('data', data => stream.write(data, this.encoding));
this.reply(150).then(() => dataSocket.resume());
});
})
.catch(when.TimeoutError, err => {
log.error(err);
return this.reply(425, 'No connection established');
})
.catch(err => {
log.error(err);
return this.reply(550, err.message);
})
.finally(() => {
this.connector.end();
this.commandSocket.resume();
});
},
syntax: '{{cmd}} [path]',
description: 'Store data as a file at the server site'
};

View File

@@ -0,0 +1,24 @@
const when = require('when');
const stor = require('./stor').handler;
module.exports = {
directive: 'STOU',
handler: function (args) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.get || !this.fs.getUniqueName) return this.reply(402, 'Not supported by file system');
const fileName = args.command.arg;
return when.try(() => {
return when.try(this.fs.get.bind(this.fs), fileName)
.then(() => when.try(this.fs.getUniqueName.bind(this.fs)))
.catch(() => when.resolve(fileName));
})
.then(name => {
args.command.arg = name;
return stor.call(this, args);
});
},
syntax: '{{cmd}}',
description: 'Store file uniquely'
};

View File

@@ -0,0 +1,11 @@
module.exports = {
directive: 'STRU',
handler: function ({command} = {}) {
return this.reply(/^F$/i.test(command.arg) ? 200 : 504);
},
syntax: '{{cmd}} [structure]',
description: 'Set file transfer structure',
flags: {
obsolete: true
}
};

View File

@@ -0,0 +1,11 @@
module.exports = {
directive: 'SYST',
handler: function () {
return this.reply(215);
},
syntax: '{{cmd}}',
description: 'Return system type',
flags: {
no_auth: true
}
};

View File

@@ -0,0 +1,20 @@
const _ = require('lodash');
const ENCODING_TYPES = {
A: 'utf-8',
I: 'binary',
L: 'binary'
};
module.exports = {
directive: 'TYPE',
handler: function ({command} = {}) {
const encoding = _.upperCase(command.arg);
if (!ENCODING_TYPES.hasOwnProperty(encoding)) return this.reply(501);
this.encoding = ENCODING_TYPES[encoding];
return this.reply(200);
},
syntax: '{{cmd}} [mode]',
description: 'Set the transfer mode, binary (I) or utf-8 (A)'
};

View File

@@ -0,0 +1,27 @@
module.exports = {
directive: 'USER',
handler: function ({log, command} = {}) {
if (this.username) return this.reply(530, 'Username already set');
this.username = command.arg;
if (!this.username) return this.reply(501, 'Must send username requirement');
if (this.server.options.anonymous === true) {
return this.login(this.username, '@anonymous')
.then(() => {
return this.reply(230);
})
.catch(err => {
log.error(err);
return this.reply(530, err.message || 'Authentication failed');
});
}
return this.reply(331);
},
syntax: '{{cmd}} [username]',
description: 'Authentication username',
flags: {
no_auth: true
}
};

View File

@@ -1,200 +1,45 @@
module.exports = {
AUTH: {
handler: require('./auth'),
syntax: 'AUTH [type]',
help: 'Not supported',
no_auth: true
},
USER: {
handler: require('./user'),
syntax: 'USER [username]',
help: 'Authentication username',
no_auth: true
},
PASS: {
handler: require('./pass'),
syntax: 'PASS [password]',
help: 'Authentication password',
no_auth: true
},
SYST: {
handler: require('./syst'),
syntax: 'SYST',
help: 'Return system type',
no_auth: true
},
FEAT: {
handler: require('./feat'),
syntax: 'FEAT',
help: 'Get the feature list implemented by the server',
no_auth: true
},
PWD: {
handler: require('./pwd'),
syntax: 'PWD',
help: 'Print current working directory'
},
XPWD: {
handler: require('./pwd'),
syntax: 'XPWD',
help: 'Print current working directory'
},
TYPE: {
handler: require('./type'),
syntax: 'TYPE',
help: 'Set the transfer mode'
},
PASV: {
handler: require('./pasv'),
syntax: 'PASV',
help: 'Initiate passive mode'
},
PORT: {
handler: require('./port'),
syntax: 'PORT [x,x,x,x,y,y]',
help: 'Specifies an address and port to which the server should connect'
},
LIST: {
handler: require('./list'),
syntax: 'LIST [path(optional)]',
help: 'Returns information of a file or directory if specified, else information of the current working directory is returned'
},
NLST: {
handler: require('./list'),
syntax: 'NLST [path(optional)]',
help: 'Returns a list of file names in a specified directory'
},
CWD: {
handler: require('./cwd'),
syntax: 'CWD [path]',
help: 'Change working directory'
},
XCWD: {
handler: require('./cwd'),
syntax: 'XCWD [path]',
help: 'Change working directory'
},
CDUP: {
handler: require('./cdup'),
syntax: 'CDUP',
help: 'Change to Parent Directory'
},
XCUP: {
handler: require('./cdup'),
syntax: 'XCUP',
help: 'Change to Parent Directory'
},
STOR: {
handler: require('./stor'),
syntax: 'STOR [path]',
help: 'Accept the data and to store the data as a file at the server site'
},
APPE: {
handler: require('./stor'),
syntax: 'APPE [path]',
help: 'Append to file'
},
RETR: {
handler: require('./retr'),
syntax: 'RETR [path]',
help: 'Retrieve a copy of the file'
},
DELE: {
handler: require('./dele'),
syntax: 'DELE [path]',
help: 'Delete file'
},
RMD: {
handler: require('./dele'),
syntax: 'RMD [path]',
help: 'Remove a directory'
},
XRMD: {
handler: require('./dele'),
syntax: 'XRMD [path]',
help: 'Remove a directory'
},
HELP: {
handler: require('./help'),
syntax: 'HELP [command(optional)]',
help: 'Returns usage documentation on a command if specified, else a general help document is returned'
},
MDTM: {
handler: require('./mdtm'),
syntax: 'MDTM [path]',
help: 'Return the last-modified time of a specified file',
feat: 'MDTM'
},
MKD: {
handler: require('./mkd'),
syntax: 'MKD [path]',
help: 'Make directory'
},
XMKD: {
handler: require('./mkd'),
syntax: 'XMKD [path]',
help: 'Make directory'
},
NOOP: {
handler: require('./noop'),
syntax: 'NOOP',
help: 'No operation',
no_auth: true
},
QUIT: {
handler: require('./quit'),
syntax: 'QUIT',
help: 'Disconnect',
no_auth: true
},
RNFR: {
handler: require('./rnfr'),
syntax: 'RNFR [name]',
help: 'Rename from'
},
RNTO: {
handler: require('./rnto'),
syntax: 'RNTO [name]',
help: 'Rename to'
},
SIZE: {
handler: require('./size'),
syntax: 'SIZE [path]',
help: 'Return the size of a file',
feat: 'SIZE'
},
STAT: {
handler: require('./stat'),
syntax: 'SIZE [path(optional)]',
help: 'Returns the current status'
},
SITE: {
handler: require('./site'),
syntax: 'SITE [subVerb] [subParams]',
help: 'Sends site specific commands to remote server'
},
OPTS: {
handler: require('./opts'),
syntax: 'OPTS',
help: 'Select options for a feature'
},
/* eslint no-return-assign: 0 */
const commands = [
require('./registration/abor'),
require('./registration/allo'),
require('./registration/appe'),
require('./registration/auth'),
require('./registration/cdup'),
require('./registration/cwd'),
require('./registration/dele'),
require('./registration/feat'),
require('./registration/help'),
require('./registration/list'),
require('./registration/mdtm'),
require('./registration/mkd'),
require('./registration/mode'),
require('./registration/nlst'),
require('./registration/noop'),
require('./registration/opts'),
require('./registration/pass'),
require('./registration/pasv'),
require('./registration/port'),
require('./registration/pwd'),
require('./registration/quit'),
require('./registration/retr'),
require('./registration/rmd'),
require('./registration/rnfr'),
require('./registration/rnto'),
require('./registration/site'),
require('./registration/size'),
require('./registration/stat'),
require('./registration/stor'),
require('./registration/stou'),
require('./registration/stru'),
require('./registration/syst'),
require('./registration/type'),
require('./registration/user')
];
STRU: {
handler: require('./stru'),
syntax: 'STRU [structure]',
help: 'Set file transfer structure',
obsolete: true
},
ALLO: {
handler: require('./allo'),
syntax: 'ALLO',
help: 'Allocate sufficient disk space to receive a file',
obsolete: true
},
MODE: {
handler: require('./mode'),
syntax: 'MODE [mode]',
help: 'Sets the transfer mode (Stream, Block, or Compressed)',
obsolete: true
}
};
const registry = commands.reduce((result, cmd) => {
const aliases = Array.isArray(cmd.directive) ? cmd.directive : [cmd.directive];
aliases.forEach(alias => result[alias] = cmd);
return result;
}, {});
module.exports = registry;

View File

@@ -1,36 +0,0 @@
const when = require('when');
module.exports = function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.read) return this.reply(402, 'Not supported by file system');
let dataSocket;
return this.connector.waitForConnection()
.then(socket => {
this.commandSocket.pause();
dataSocket = socket;
})
.then(() => when(this.fs.read(command._[1])))
.then(stream => {
return when.promise((resolve, reject) => {
dataSocket.on('error', err => stream.emit('error', err));
stream.on('data', data => dataSocket.write(data, this.encoding));
stream.on('end', () => resolve(this.reply(226)));
stream.on('error', err => reject(err));
this.reply(150).then(() => dataSocket.resume());
});
})
.catch(when.TimeoutError, err => {
log.error(err);
return this.reply(425, 'No connection established');
})
.catch(err => {
log.error(err);
return this.reply(551);
})
.finally(() => {
this.connector.end();
this.commandSocket.resume();
});
}

View File

@@ -1,17 +0,0 @@
const when = require('when');
module.exports = function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.get) return this.reply(402, 'Not supported by file system');
const fileName = command._[1];
return when(this.fs.get(fileName))
.then(() => {
this.renameFrom = fileName;
return this.reply(350);
})
.catch(err => {
log.error(err);
return this.reply(550);
})
}

View File

@@ -1,23 +0,0 @@
const when = require('when');
module.exports = function ({log, command} = {}) {
if (!this.renameFrom) return this.reply(503);
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.rename) return this.reply(402, 'Not supported by file system');
const from = this.renameFrom;
const to = command._[1];
return when(this.fs.rename(from, to))
.then(() => {
return this.reply(250);
})
.catch(err => {
log.error(err);
return this.reply(550);
})
.finally(() => {
delete this.renameFrom;
});
}

View File

@@ -1,18 +0,0 @@
const _ = require('lodash');
const when = require('when');
const registry = require('./registry');
module.exports = function ({log, command} = {}) {
let [, subverb, ...subparameters] = command._;
subverb = _.upperCase(subverb);
const subLog = log.child({subverb});
if (!registry.hasOwnProperty(subverb)) return this.reply(502);
const subCommand = {
_: [subverb, ...subparameters],
directive: subverb
}
const handler = registry[subverb].handler.bind(this);
return when.try(handler, { log: subLog, command: subCommand });
}

View File

@@ -1,15 +0,0 @@
const when = require('when');
module.exports = function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.get) return this.reply(402, 'Not supported by file system');
return when(this.fs.get(command._[1]))
.then(fileStat => {
return this.reply(213, {message: fileStat.size});
})
.catch(err => {
log.error(err);
return this.reply(550);
});
}

View File

@@ -1,37 +0,0 @@
const _ = require('lodash');
const when = require('when');
const getFileStat = require('../helpers/file-stat');
module.exports = function (args = {}) {
const {log, command} = args;
const path = command._[1];
if (path) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.get) return this.reply(402, 'Not supported by file system');
return when(this.fs.get(path))
.then(stat => {
if (stat.isDirectory()) {
return when(this.fs.list(path))
.then(files => {
const fileList = files.map(file => {
const message = getFileStat(file, _.get(this, 'server.options.file_format', 'ls'));
return {
raw: true,
message
};
})
return this.reply(213, 'Status begin', ...fileList, 'Status end');
})
} else {
return this.reply(212, getFileStat(stat, _.get(this, 'server.options.file_format', 'ls')))
}
})
.catch(err => {
log.error(err);
return this.reply(450);
})
} else {
return this.reply(211, 'Status OK');
}
}

View File

@@ -1,38 +0,0 @@
const when = require('when');
module.exports = function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.write) return this.reply(402, 'Not supported by file system');
const append = command.directive === 'APPE';
let dataSocket;
return this.connector.waitForConnection()
.then(socket => {
this.commandSocket.pause();
dataSocket = socket;
})
.then(() => when(this.fs.write(command._[1], {append})))
.then(stream => {
return when.promise((resolve, reject) => {
stream.on('error', err => dataSocket.emit('error', err));
dataSocket.on('end', () => resolve(this.reply(226)));
dataSocket.on('error', err => reject(err));
dataSocket.on('data', data => stream.write(data, this.encoding));
this.reply(150).then(() => dataSocket.resume());
});
})
.catch(when.TimeoutError, err => {
log.error(err);
return this.reply(425, 'No connection established');
})
.catch(err => {
log.error(err);
return this.reply(553);
})
.finally(() => {
this.connector.end();
this.commandSocket.resume();
});
}

View File

@@ -1,3 +0,0 @@
module.exports = function ({command} = {}) {
return this.reply(command._[1] === 'F' ? 200 : 504);
}

View File

@@ -1,3 +0,0 @@
module.exports = function () {
return this.reply(215);
}

View File

@@ -1,15 +0,0 @@
const _ = require('lodash');
module.exports = function ({command} = {}) {
const encoding = _.upperCase(command._[1]);
switch (encoding) {
case 'A':
this.encoding = 'utf-8';
case 'I':
case 'L':
this.encoding = 'binary';
return this.reply(200);
default:
return this.reply(501);
}
}

View File

@@ -1,15 +0,0 @@
module.exports = function ({log, command} = {}) {
if (this.username) return this.reply(530, 'Username already set');
this.username = command._[1];
if (this.server.options.anonymous === true) {
return this.login(this.username, '@anonymous')
.then(() => {
return this.reply(230);
})
.catch(err => {
log.error(err);
return this.reply(530, err || 'Authentication failed');
});
}
return this.reply(331);
};

View File

@@ -2,8 +2,6 @@ const _ = require('lodash');
const uuid = require('uuid');
const when = require('when');
const sequence = require('when/sequence');
const parseSentence = require('minimist-string');
const net = require('net');
const BaseConnector = require('./connector/base');
const FileSystem = require('./fs');
@@ -14,43 +12,45 @@ const DEFAULT_MESSAGE = require('./messages');
class FtpConnection {
constructor(server, options) {
this.server = server;
this.commandSocket = options.socket;
this.id = uuid.v4();
this.log = options.log.child({ftp_session_id: this.commandSocket.ftp_session_id});
this.log = options.log.child({id: this.id, ip: this.ip});
this.commands = new Commands(this);
this.encoding = 'utf-8';
this.connector = new BaseConnector(this);
this.commandSocket = options.socket;
this.commandSocket.on('error', err => {
console.log('error', err)
});
this.commandSocket.on('data', data => {
const messages = _.compact(data.toString('utf-8').split('\r\n'));
const handleMessage = (message) => {
const command = parseSentence(message);
command.directive = _.upperCase(command._[0]);
return this.commands.handle(command);
};
return sequence(messages.map(message => handleMessage.bind(this, message)));
});
this.commandSocket.on('timeout', () => {
console.log('timeout')
this.log.error(err, 'Client error');
this.server.emit('client-error', {connection: this, context: 'commandSocket', error: err});
});
this.commandSocket.on('data', this._handleData.bind(this));
this.commandSocket.on('timeout', () => {});
this.commandSocket.on('close', () => {
if (this.connector) this.connector.end();
if (this.commandSocket && !this.commandSocket.destroyed) this.commandSocket.destroy();
});
}
_handleData(data) {
const messages = _.compact(data.toString('utf-8').split('\r\n'));
this.log.trace(messages, 'Messages');
return sequence(messages.map(message => this.commands.handle.bind(this.commands, message)));
}
get ip() {
try {
return this.dataSocket ? this.dataSocket.remoteAddress : this.commandSocket.remoteAddress;
} catch (ex) {
return null;
}
}
close(code = 421, message = 'Closing connection') {
return when(() => {
if (code) return this.reply(code, message);
})
.then(() => {
if (this.commandSocket) this.commandSocket.end();
});
return when
.resolve(code)
.then(_code => _code && this.reply(_code, message))
.then(() => this.commandSocket && this.commandSocket.end());
}
login(username, password) {
@@ -59,12 +59,14 @@ class FtpConnection {
if (!loginListeners || !loginListeners.length) {
if (!this.server.options.anoymous) throw new errors.GeneralError('No "login" listener setup', 500);
} else {
return this.server.emit('login', {connection: this, username, password});
return this.server.emitPromise('login', {connection: this, username, password});
}
})
.then(({fs, cwd} = {}) => {
.then(({root, cwd, fs, blacklist = [], whitelist = []} = {}) => {
this.authenticated = true;
this.fs = fs || new FileSystem(this, {cwd});
this.commands.blacklist = _.concat(this.commands.blacklist, blacklist);
this.commands.whitelist = _.concat(this.commands.whitelist, whitelist);
this.fs = fs || new FileSystem(this, {root, cwd});
});
}
@@ -73,7 +75,7 @@ class FtpConnection {
if (typeof options === 'number') options = {code: options}; // allow passing in code as first param
if (!Array.isArray(letters)) letters = [letters];
if (!letters.length) letters = [{}];
return when.map(letters, (promise, index) => {
return when.map(letters, promise => {
return when(promise)
.then(letter => {
if (!letter) letter = {};
@@ -86,16 +88,16 @@ class FtpConnection {
.then(message => {
letter.message = message;
return letter;
})
});
});
});
}
};
const processLetter = (letter, index) => {
return when.promise((resolve, reject) => {
const seperator = !options.hasOwnProperty('eol') ?
(letters.length - 1 === index ? ' ' : '-') :
(options.eol ? ' ' : '-');
letters.length - 1 === index ? ' ' : '-' :
options.eol ? ' ' : '-';
const packet = !letter.raw ? _.compact([letter.code || options.code, letter.message]).join(seperator) : letter.message;
if (letter.socket && letter.socket.writable) {
@@ -109,10 +111,10 @@ class FtpConnection {
});
} else reject(new errors.SocketError('Socket not writable'));
});
}
};
return satisfyParameters()
.then(letters => sequence(letters.map((letter, index) => processLetter.bind(this, letter, index))))
.then(satisfiedLetters => sequence(satisfiedLetters.map((letter, index) => processLetter.bind(this, letter, index))))
.catch(err => {
this.log.error(err);
});

View File

@@ -1,4 +1,4 @@
const net = require('net');
const {Socket} = require('net');
const when = require('when');
const Connector = require('./base');
@@ -8,28 +8,29 @@ class Active extends Connector {
this.type = 'active';
}
waitForConnection() {
waitForConnection({timeout = 5000, delay = 250} = {}) {
return when.iterate(
() => {},
() => this.dataSocket && this.dataSocket.connected,
() => when().delay(250)
).timeout(5000)
() => when().delay(delay)
).timeout(timeout)
.then(() => this.dataSocket);
}
setupConnection(host, port) {
const closeExistingServer = () => this.dataSocket ?
when(this.dataSocket.destroy()) :
when.resolve()
when.resolve();
return closeExistingServer()
.then(() => {
this.dataSocket = new net.Socket();
this.dataSocket = new Socket();
this.dataSocket.setEncoding(this.encoding);
this.dataSocket.connect({ host, port }, () => {
this.dataSocket.pause();
this.dataSocket.connected = true;
});
this.dataSocket.on('error', err => this.server.emit('client-error', {connection: this, context: 'dataSocket', error: err}));
});
}
}

View File

@@ -4,14 +4,20 @@ const errors = require('../errors');
class Connector {
constructor(connection) {
this.connection = connection;
this.server = connection.server;
this.log = connection.log;
this.dataSocket = null;
this.dataServer = null;
this.type = false;
}
get log() {
return this.connection.log;
}
get server() {
return this.connection.server;
}
waitForConnection() {
return when.reject(new errors.ConnectorError('No connector setup, send PASV or PORT'));
}
@@ -21,8 +27,7 @@ class Connector {
if (this.dataServer) this.dataServer.close();
this.dataSocket = null;
this.dataServer = null;
this.connection.connector = new Connector(this.connection);
this.type = false;
}
}
module.exports = Connector;

View File

@@ -1,5 +1,6 @@
const net = require('net');
const when = require('when');
const Connector = require('./base');
const findPort = require('../helpers/find-port');
const errors = require('../errors');
@@ -10,30 +11,28 @@ class Passive extends Connector {
this.type = 'passive';
}
waitForConnection() {
waitForConnection({timeout = 5000, delay = 250} = {}) {
if (!this.dataServer) {
return when.reject(new errors.ConnectorError('Passive server not setup'));
}
return when.iterate(
() => {},
() => this.dataServer && this.dataServer.listening && this.dataSocket,
() => when().delay(250)
).timeout(5000)
() => this.dataServer && this.dataServer.listening && this.dataSocket && this.dataSocket.connected,
() => when().delay(delay)
).timeout(timeout)
.then(() => this.dataSocket);
}
setupServer() {
const closeExistingServer = () => this.dataServer ?
when.promise(resolve => this.dataServer.close(() => resolve())) :
when.resolve()
when.resolve();
return closeExistingServer()
.then(() => this.getPort())
.then(port => {
this.dataSocket = null;
this.dataServer = net.createServer({pauseOnConnect: true});
this.dataServer.maxConnections = 1;
this.dataServer.on('connection', socket => {
this.dataServer = net.createServer({ pauseOnConnect: true }, socket => {
if (this.connection.commandSocket.remoteAddress !== socket.remoteAddress) {
this.log.error({
pasv_connection: socket.remoteAddress,
@@ -44,18 +43,21 @@ class Passive extends Connector {
return this.connection.reply(550, 'Remote addresses do not match')
.finally(() => this.connection.close());
}
this.log.info({port}, 'Passive connection fulfilled.');
this.log.debug({port}, 'Passive connection fulfilled.');
this.dataSocket = socket;
this.dataSocket.connected = true;
this.dataSocket.setEncoding(this.connection.encoding);
this.dataSocket.on('data', data => {
});
this.dataSocket.on('error', err => this.server.emit('client-error', {connection: this, context: 'dataSocket', error: err}));
this.dataSocket.on('close', () => {
this.log.debug('Passive connection closed');
this.end();
});
});
this.dataServer.maxConnections = 1;
this.dataServer.on('error', err => this.server.emit('client-error', {connection: this, context: 'dataServer', error: err}));
this.dataServer.on('close', () => {
this.log.debug('Passive server closed');
this.dataServer = null;
});
@@ -78,7 +80,7 @@ class Passive extends Connector {
[this.server.options.pasv_range];
return findPort(min, max);
} else return undefined;
};
}
}
module.exports = Passive;

View File

@@ -1,5 +1,6 @@
const _ = require('lodash');
const nodePath = require('path');
const uuid = require('uuid');
const when = require('when');
const whenNode = require('when/node');
const syncFs = require('fs');
@@ -7,11 +8,22 @@ const fs = whenNode.liftAll(syncFs);
const errors = require('./errors');
class FileSystem {
constructor(connection, {
cwd = '/'
} = {}) {
constructor(connection, { root, cwd } = {}) {
this.connection = connection;
this.cwd = cwd;
this.cwd = cwd || nodePath.sep;
this.root = root || process.cwd();
}
_resolvePath(path = '') {
const isFromRoot = _.startsWith(path, '/') || _.startsWith(path, nodePath.sep);
const cwd = isFromRoot ? nodePath.sep : this.cwd || nodePath.sep;
const serverPath = nodePath.join(nodePath.sep, cwd, path);
const fsPath = nodePath.join(this.root, serverPath);
return {
serverPath,
fsPath
};
}
currentDirectory() {
@@ -19,17 +31,17 @@ class FileSystem {
}
get(fileName) {
const path = nodePath.resolve(this.cwd, fileName);
return fs.stat(path)
const {fsPath} = this._resolvePath(fileName);
return fs.stat(fsPath)
.then(stat => _.set(stat, 'name', fileName));
}
list(path = '.') {
path = nodePath.resolve(this.cwd, path);
return fs.readdir(path)
const {fsPath} = this._resolvePath(path);
return fs.readdir(fsPath)
.then(fileNames => {
return when.map(fileNames, fileName => {
const filePath = nodePath.join(path, fileName);
const filePath = nodePath.join(fsPath, fileName);
return fs.access(filePath, syncFs.constants.F_OK)
.then(() => {
return fs.stat(filePath)
@@ -42,60 +54,64 @@ class FileSystem {
}
chdir(path = '.') {
path = nodePath.resolve(this.cwd, path);
return fs.stat(path)
const {fsPath, serverPath} = this._resolvePath(path);
return fs.stat(fsPath)
.tap(stat => {
if (!stat.isDirectory()) throw new errors.FileSystemError('Not a valid directory');
})
.then(() => {
this.cwd = path;
return this.cwd;
this.cwd = serverPath;
return this.currentDirectory();
});
}
write(fileName, {append = false} = {}) {
const path = nodePath.resolve(this.cwd, fileName);
const stream = syncFs.createWriteStream(path, {flags: !append ? 'w+' : 'a+'});
stream.on('error', () => fs.unlink(path));
const {fsPath} = this._resolvePath(fileName);
const stream = syncFs.createWriteStream(fsPath, {flags: !append ? 'w+' : 'a+'});
stream.on('error', () => fs.unlink(fsPath));
return stream;
}
read(fileName) {
const path = nodePath.resolve(this.cwd, fileName);
return fs.stat(path)
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(path, {flags: 'r'});
const stream = syncFs.createReadStream(fsPath, {flags: 'r'});
return stream;
});
}
delete(path) {
path = nodePath.resolve(this.cwd, path);
return fs.stat(path)
const {fsPath} = this._resolvePath(path);
return fs.stat(fsPath)
.then(stat => {
if (stat.isDirectory()) return fs.rmdir(path);
else return fs.unlink(path);
})
if (stat.isDirectory()) return fs.rmdir(fsPath);
else return fs.unlink(fsPath);
});
}
mkdir(path) {
path = nodePath.resolve(this.cwd, path);
return fs.mkdir(path)
.then(() => path);
const {fsPath} = this._resolvePath(path);
return fs.mkdir(fsPath)
.then(() => fsPath);
}
rename(from, to) {
const fromPath = nodePath.resolve(this.cwd, from);
const toPath = nodePath.resolve(this.cwd, to);
const {fsPath: fromPath} = this._resolvePath(from);
const {fsPath: toPath} = this._resolvePath(to);
return fs.rename(fromPath, toPath);
}
chmod(path, mode) {
path = nodePath.resolve(this.cwd, path);
return fs.chmod(path, mode);
const {fsPath} = this._resolvePath(path);
return fs.chmod(fsPath, mode);
}
getUniqueName() {
return uuid.v4().replace(/\W/g, '');
}
}
module.exports = FileSystem;

View File

@@ -1,4 +1,4 @@
module.exports = function (path) {
return path
.replace(/"/g, '""');
}
};

View File

@@ -1,52 +1,54 @@
const _ = require('lodash');
const format = require('date-fns/format');
const moment = require('moment');
const errors = require('../errors');
const FORMATS = {
ls,
ep
};
module.exports = function (fileStat, format = 'ls') {
if (typeof format === 'function') return format(fileStat);
const formats = {
'ls': ls,
'ep': ep
};
if (!formats.hasOwnProperty(format)) {
if (!FORMATS.hasOwnProperty(format)) {
throw new errors.FileSystemError('Bad file stat formatter');
}
return formats[format](fileStat);
}
return FORMATS[format](fileStat);
};
function ls(fileStat) {
const now = moment.utc();
const mtime = moment.utc(new Date(fileStat.mtime));
const dateFormat = now.diff(mtime, 'months') < 6 ? 'MMM DD HH:mm' : 'MMM DD YYYY';
return [
fileStat.mode !== null
? [
fileStat.isDirectory() ? 'd' : '-',
400 & fileStat.mode ? 'r' : '-',
200 & fileStat.mode ? 'w' : '-',
100 & fileStat.mode ? 'x' : '-',
40 & fileStat.mode ? 'r' : '-',
20 & fileStat.mode ? 'w' : '-',
10 & fileStat.mode ? 'x' : '-',
4 & fileStat.mode ? 'r' : '-',
2 & fileStat.mode ? 'w' : '-',
1 & fileStat.mode ? 'x' : '-'
].join('')
: fileStat.isDirectory() ? 'drwxr-xr-x' : '-rwxr-xr-x',
fileStat.mode ? [
fileStat.isDirectory() ? 'd' : '-',
fileStat.mode & 256 ? 'r' : '-',
fileStat.mode & 128 ? 'w' : '-',
fileStat.mode & 64 ? 'x' : '-',
fileStat.mode & 32 ? 'r' : '-',
fileStat.mode & 16 ? 'w' : '-',
fileStat.mode & 8 ? 'x' : '-',
fileStat.mode & 4 ? 'r' : '-',
fileStat.mode & 2 ? 'w' : '-',
fileStat.mode & 1 ? 'x' : '-'
].join('') : fileStat.isDirectory() ? 'drwxr-xr-x' : '-rwxr-xr-x',
'1',
fileStat.uid,
fileStat.gid,
fileStat.uid || 1,
fileStat.gid || 1,
_.padStart(fileStat.size, 12),
_.padStart(format(fileStat.mtime, 'MMM DD HH:mm'), 12),
_.padStart(mtime.format(dateFormat), 12),
fileStat.name
].join(' ');
}
function ep(fileStat) {
const facts = [
const facts = _.compact([
fileStat.dev && fileStat.ino ? `i${fileStat.dev.toString(16)}.${fileStat.ino.toString(16)}` : null,
fileStat.size ? `s${fileStat.size}` : null,
fileStat.mtime ? `m${format(fileStat.mtime, 'X')}` : null,
fileStat.mode ? `up${fileStat.mode.toString(8).substr(fileStat.mode.toString(8).length - 3)}` : null,
fileStat.isDirectory() ? 'r' : '/'
].join(',');
fileStat.mtime ? `m${moment.utc(new Date(fileStat.mtime)).format('X')}` : null,
fileStat.mode ? `up${(fileStat.mode & 4095).toString(8)}` : null,
fileStat.isDirectory() ? '/' : 'r'
]).join(',');
return `+${facts}\t${fileStat.name}`;
}

View File

@@ -2,19 +2,19 @@ const net = require('net');
const when = require('when');
const errors = require('../errors');
module.exports = function (min = 22, max = undefined) {
module.exports = function (min = 1, max = undefined) {
return when.promise((resolve, reject) => {
let port = min;
let checkPort = min;
let portCheckServer = net.createServer();
portCheckServer.maxConnections = 0;
portCheckServer.on('error', () => {
if (!max || port < max) {
port = port + 1;
portCheckServer.listen(port);
if (checkPort < 65535 && (!max || checkPort < max)) {
checkPort = checkPort + 1;
portCheckServer.listen(checkPort);
} else {
reject(new errors.GeneralError('Unable to find open port', 500));
}
})
});
portCheckServer.on('listening', () => {
const {port} = portCheckServer.address();
portCheckServer.close(() => {
@@ -22,6 +22,6 @@ module.exports = function (min = 22, max = undefined) {
resolve(port);
});
});
portCheckServer.listen(port);
portCheckServer.listen(checkPort);
});
};

View File

@@ -21,5 +21,5 @@ module.exports = function (hostname) {
});
});
} else resolve(hostname);
})
}
});
};

View File

@@ -3,6 +3,8 @@ const when = require('when');
const nodeUrl = require('url');
const buyan = require('bunyan');
const net = require('net');
const tls = require('tls');
const fs = require('fs');
const Connection = require('./connection');
const resolveHost = require('./helpers/resolve-host');
@@ -10,32 +12,53 @@ const resolveHost = require('./helpers/resolve-host');
class FtpServer {
constructor(url, options = {}) {
this.options = _.merge({
log: buyan.createLogger({name: 'ftp-svr'}),
log: buyan.createLogger({name: 'ftp-srv'}),
anonymous: false,
pasv_range: 22,
file_format: 'ls',
disabled_commands: []
blacklist: [],
whitelist: [],
greeting: null,
tls: {}
}, options);
this._greeting = this.setupGreeting(this.options.greeting);
this._features = this.setupFeaturesMessage();
this._tls = this.setupTLS(this.options.tls);
this.connections = {};
this.log = this.options.log;
this.url = nodeUrl.parse(url || 'ftp://127.0.0.1:21');
this.server = net.createServer({pauseOnConnect: true}, socket => {
const serverConnectionHandler = socket => {
let connection = new Connection(this, {log: this.log, socket});
this.connections[connection.id] = connection;
socket.on('close', () => this.disconnectClient(connection.id));
const greeting = this.getGreetingMessage();
const features = this.getFeaturesMessage();
return connection.reply(220, greeting, features)
const greeting = this._greeting || [];
const features = this._features || 'Ready';
return connection.reply(220, ...greeting, features)
.finally(() => socket.resume());
});
this.server.on('error', err => {
this.log.error(err);
});
};
const serverOptions = _.assign(this.isTLS ? this._tls : {}, { pauseOnConnect: true });
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());
}
get isTLS() {
return this.url.protocol === 'ftps:';
}
listen() {
@@ -45,60 +68,71 @@ class FtpServer {
return when.promise((resolve, reject) => {
this.server.listen(this.url.port, err => {
if (err) return reject(err);
this.log.info({port: this.url.port}, 'Listening');
this.log.info({ip: this.url.hostname, port: this.url.port}, `Listening${this.isTLS ? ' (TLS)' : ''}`);
resolve();
});
});
});
}
emit(action, ...data) {
emitPromise(action, ...data) {
const defer = when.defer();
const params = _.concat(data, [defer.resolve, defer.reject]);
this.server.emit(action, ...params);
return defer.promise;
}
getGreetingMessage() {
return null;
emit(action, ...data) {
this.server.emit(action, ...data);
}
getFeaturesMessage() {
setupTLS(_tls) {
return _.assign(_tls, {
cert: _tls.cert ? fs.readFileSync(_tls.cert) : undefined,
key: _tls.key ? fs.readFileSync(_tls.key) : undefined,
ca: _tls.ca ? Array.isArray(_tls.ca) ? _tls.ca.map(_ca => fs.readFileSync(_ca)) : [fs.readFileSync(_tls.ca)] : undefined
});
}
setupGreeting(greet) {
if (!greet) return [];
const greeting = Array.isArray(greet) ? greet : greet.split('\n');
return greeting;
}
setupFeaturesMessage() {
let features = [];
if (this.options.anonymous) features.push('a');
if (features.length) {
features.unshift('Features:');
features.push('.')
features.push('.');
}
return features.length ? features.join(' ') : 'Ready';
}
setGreeting(gretting) {
if (typeof greeting === 'string') {
this.options.greeting = greeting;
} else {
gretting.then(greeting => {
this.options.gretting = greeting;
})
}
}
disconnectClient(id) {
return when.promise((resolve, reject) => {
return when.promise(resolve => {
const client = this.connections[id];
if (!client) return resolve();
delete this.connections[id];
return client.close(0);
try {
client.close(0);
} catch (err) {
this.log.error(err, 'Error closing connection', {id});
} finally {
resolve();
}
});
}
close() {
this.log.info('Server closing...');
this.server.maxConnections = 0;
return when.map(Object.keys(this.connections), id => this.disconnectClient(id))
.then(() => when.promise((resolve, reject) => {
.then(() => when.promise(resolve => {
this.server.close(err => {
if (err) return reject(err);
if (err) this.log.error(err, 'Error closing server');
resolve();
});
}));

12
test/cert/server.crt Normal file
View File

@@ -0,0 +1,12 @@
-----BEGIN CERTIFICATE-----
MIIBtTCCAR4CCQDsFyLCxvy4qzANBgkqhkiG9w0BAQUFADAfMQswCQYDVQQGEwJD
QTEQMA4GA1UECBMHQWxiZXJ0YTAeFw0xNzA1MDgyMzQzMjNaFw0xODA1MDgyMzQz
MjNaMB8xCzAJBgNVBAYTAkNBMRAwDgYDVQQIEwdBbGJlcnRhMIGfMA0GCSqGSIb3
DQEBAQUAA4GNADCBiQKBgQD2uOwjV7Nb7ubBsK/tHZQ5hUpaQ9QD9Jo8qj7DBOia
C4xbwpF6w+ZDf5OnkR7Hl3QlcofbkXKkLWmG0Mm5wvFA6kYW6D8vMT5Di7+eksf/
agkklBnRtdoBb3lsbMbo3/EXijwHbCKbm+sTTe7dwGK/w6p782K/kgHVWk+L58O7
rQIDAQABMA0GCSqGSIb3DQEBBQUAA4GBAOkX32keFpo0kKQKpeZgxVYjvn4/Voy6
6oLsj7jJYq3oZts1dX6kHVpLEbF9sWKB2iz7nqz7pSN1ATq0IL/5rcxvNwiL4Idv
F8CCBvsBui+0gwX755NJK/L57a5i8yQ5HC65NujGAA4I5+2x8HlefMVuBpEYjzQ2
6lW2OJJ8xtP/
-----END CERTIFICATE-----

10
test/cert/server.csr Normal file
View File

@@ -0,0 +1,10 @@
-----BEGIN CERTIFICATE REQUEST-----
MIIBXjCByAIBADAfMQswCQYDVQQGEwJDQTEQMA4GA1UECBMHQWxiZXJ0YTCBnzAN
BgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA9rjsI1ezW+7mwbCv7R2UOYVKWkPUA/Sa
PKo+wwTomguMW8KResPmQ3+Tp5Eex5d0JXKH25FypC1phtDJucLxQOpGFug/LzE+
Q4u/npLH/2oJJJQZ0bXaAW95bGzG6N/xF4o8B2wim5vrE03u3cBiv8Oqe/Niv5IB
1VpPi+fDu60CAwEAAaAAMA0GCSqGSIb3DQEBBQUAA4GBACzo+Wecs3CTbItrugdL
pP4crsRs+HJljWA0e+WEGKhcd1FjrcLBr4WqzHFQJWHOTz2vM5PiKXPZk9crLxWa
Y8kMhU6eQPnCM6+7Gffm32+VS1ipNlhzHyYsjYpgC3ROElqo0J5M5sas4lbaamr+
FnlyRjrPSUcFdcbPL6ozND3e
-----END CERTIFICATE REQUEST-----

15
test/cert/server.key Normal file
View File

@@ -0,0 +1,15 @@
-----BEGIN RSA PRIVATE KEY-----
MIICXQIBAAKBgQD2uOwjV7Nb7ubBsK/tHZQ5hUpaQ9QD9Jo8qj7DBOiaC4xbwpF6
w+ZDf5OnkR7Hl3QlcofbkXKkLWmG0Mm5wvFA6kYW6D8vMT5Di7+eksf/agkklBnR
tdoBb3lsbMbo3/EXijwHbCKbm+sTTe7dwGK/w6p782K/kgHVWk+L58O7rQIDAQAB
AoGANRPVYUkVwfpkVFkBj/5kC/fb5g1fiDZQFCr/846Tx8giOv9hssqAOBczGcKD
n6a6iu/XwGnLAvzuDd3O+BKzObrKV36u9HfvCxohKaKvhPg3lBlJ5fFq/UNBoLv8
eHz0GUGGoCxwJBAV43ojV1GdyRZ7vdmYw2hzltsHIp7UDqECQQD7yltCCJm3+gcw
p+Sde0+M9CubkTETPwpd3XPu6Bs5f2nxNVj6RAInPEBr5//5UY7Q9BBEHrDPsq0j
/+gsSlWZAkEA+tjf9qRXk9JHoN3PD0xLNEgUZAsQwDic29jxb3xrGkuUjCebKRuC
FU2sAfNgDp+MyG1iyAoZcySzH2Dp3+v7NQJAaBwBo8oelT2in3GsS5ljCSskpMxh
+E1Gog0hFJWQPDP8wCmIwuI/6a02Def9pT8dyDRCTYhLH3YHtSzo+Pc7cQJBAJ4G
XiD8qwc+o00eLsEOaRoIhn/30JenknmVE5QOJ1KrZmtc0Ax3fd15zvBzp4HO1Vu2
PVKTujClYApWfT9JZDkCQQC3Ne79bb5WSsGNbg4eT+FWde4hkdpheBsWraEDN1Pp
NanupXMPNP0EduAQ1O+oPRiZ5pG38MQYcPZHTtlULoiO
-----END RSA PRIVATE KEY-----

18
test/cert/server.key.org Normal file
View File

@@ -0,0 +1,18 @@
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: DES-EDE3-CBC,A253DC8068106194
jrYyhujaDsASrOfb2kn5Tvb0mRyIRsz7gVdjwlUdF2+lPdA/w6Os/NQBo1BIUAJp
lfS5KDTwiE3QPgrBXUNgpy71Yr+MSzmsYWdonGlXGtchohQKXpxtL3qOpczX3ERR
0AZninOKOYLw9+pe/tLNZI78DHxN1X0qTXS56RFydlW3XZbnl2Ux9CGuaCVq6vwh
yzr9H+XTqeh95wTfdXkRdFRSTyUuJ72cvMsBFRDhz60epDmUDo1XDf844BpXXfcU
kQoXHEtNkWZqzsc4ClOopp3Bgtd7eYoOLQluyovHgXzjtsur0xeMkHn9uTfkJ+IM
cYMS71ZKbMePS7XBt3YPLBvVXNcyYhWUP7VdGYxXTPqd1AVWciDB9S5EvfMxnnZz
O4M4ejxV8S7fF/cGju+sRzXx9oHPfo091Q3XKV1hrsUcF+ULrA8A8rHr64bDJ3wp
luhekzwb+5yFfZDj9XUuGMD6pSWYoWB8Jmk8cxVsdZPtGXbTQHFL9/+UZ65wSGpj
CjTLuFyVhY8pliynZH80vsNeRycdfmx93XoLqfS4xwEmI5v/MGUF24eTpF1/VIa5
oKDrVuERdXAn4JBKeaMratrl6p1BhkPe7VNnMUFw3U+C4x+QHISxbboUJiTcCe1C
pT6+YYkxQJ88rKunSEXkQYt6LeYSDg8Dw5y5Oq68DmW2Rp1m4ptTbqk3+uh83vzV
Ff0JnfNfT80GHD3T5hMgizZal2vV8DeH1WAwPzpNaeV6wTy6MSgRLgQ89cCQ0TXV
0GYnCZCaoXA2ldvbB3vW3fweOTr8Mp7aSl4s8K0R8sT3eief9/SyWA==
-----END RSA PRIVATE KEY-----

View File

@@ -0,0 +1,95 @@
const {expect} = require('chai');
const when = require('when');
const bunyan = require('bunyan');
const sinon = require('sinon');
const FtpCommands = require('../../src/commands');
describe('FtpCommands', function () {
let sandbox;
let commands;
let mockConnection = {
authenticated: false,
log: bunyan.createLogger({name: 'FtpCommands'}),
reply: () => when.resolve({}),
server: {
options: {
blacklist: ['allo']
}
}
};
beforeEach(() => {
sandbox = sinon.sandbox.create();
commands = new FtpCommands(mockConnection);
sandbox.spy(mockConnection, 'reply');
sandbox.spy(commands, 'handle');
sandbox.spy(commands, 'parse');
});
afterEach(() => {
sandbox.restore();
});
describe('parse', function () {
it('no args: test', () => {
const cmd = commands.parse('test');
expect(cmd.directive).to.equal('TEST');
expect(cmd.arg).to.equal(null);
expect(cmd.raw).to.equal('test');
});
it('one arg: test arg', () => {
const cmd = commands.parse('test arg');
expect(cmd.directive).to.equal('TEST');
expect(cmd.arg).to.equal('arg');
expect(cmd.raw).to.equal('test arg');
});
it('two args: test arg1 arg2', () => {
const cmd = commands.parse('test arg1 arg2');
expect(cmd.directive).to.equal('TEST');
expect(cmd.arg).to.equal('arg1 arg2');
expect(cmd.raw).to.equal('test arg1 arg2');
});
});
describe('handle', function () {
it('fails with unsupported command', () => {
return commands.handle('bad')
.then(() => {
expect(mockConnection.reply.callCount).to.equal(1);
expect(mockConnection.reply.args[0][0]).to.equal(402);
});
});
it('fails with blacklisted command', () => {
return commands.handle('allo')
.then(() => {
expect(mockConnection.reply.callCount).to.equal(1);
expect(mockConnection.reply.args[0][0]).to.equal(502);
expect(mockConnection.reply.args[0][1]).to.match(/blacklisted/);
});
});
it('fails with non whitelisted command', () => {
commands.whitelist.push('USER');
return commands.handle('auth')
.then(() => {
expect(mockConnection.reply.callCount).to.equal(1);
expect(mockConnection.reply.args[0][0]).to.equal(502);
expect(mockConnection.reply.args[0][1]).to.match(/whitelisted/);
});
});
it('fails due to being unauthenticated', () => {
return commands.handle('stor')
.then(() => {
expect(mockConnection.reply.callCount).to.equal(1);
expect(mockConnection.reply.args[0][0]).to.equal(530);
expect(mockConnection.reply.args[0][1]).to.match(/authentication/);
});
});
});
});

View File

@@ -0,0 +1,53 @@
const when = require('when');
const {expect} = require('chai');
const sinon = require('sinon');
const CMD = 'ABOR';
describe(CMD, function () {
let sandbox;
const mockClient = {
reply: () => when.resolve(),
connector: {
waitForConnection: () => when.resolve(),
end: () => when.resolve()
}
};
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.spy(mockClient, 'reply');
sandbox.spy(mockClient.connector, 'waitForConnection');
sandbox.spy(mockClient.connector, 'end');
});
afterEach(() => {
sandbox.restore();
});
it('// successful | no active connection', done => {
mockClient.connector.waitForConnection.restore();
sandbox.stub(mockClient.connector, 'waitForConnection').rejects();
cmdFn()
.then(() => {
expect(mockClient.connector.waitForConnection.callCount).to.equal(1);
expect(mockClient.connector.end.callCount).to.equal(0);
expect(mockClient.reply.args[0][0]).to.equal(226);
done();
})
.catch(done);
});
it('// successful | active connection', done => {
cmdFn()
.then(() => {
expect(mockClient.connector.waitForConnection.callCount).to.equal(1);
expect(mockClient.connector.end.callCount).to.equal(1);
expect(mockClient.reply.args[0][0]).to.equal(426);
expect(mockClient.reply.args[1][0]).to.equal(226);
done();
})
.catch(done);
});
});

View File

@@ -1,14 +1,14 @@
const when = require('when');
const {expect} = require('chai');
const sinon = require('sinon')
const sinon = require('sinon');
const CMD = 'ALLO';
describe(CMD, done => {
describe(CMD, function () {
let sandbox;
const mockClient = {
reply: () => when.resolve()
};
const CMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(mockClient);
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
@@ -20,11 +20,11 @@ describe(CMD, done => {
});
it('// successful', done => {
CMDFN()
cmdFn()
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(202)
expect(mockClient.reply.args[0][0]).to.equal(202);
done();
})
.catch(done);
})
});
});

View File

@@ -1,14 +1,14 @@
const when = require('when');
const {expect} = require('chai');
const sinon = require('sinon')
const sinon = require('sinon');
const CMD = 'AUTH';
describe(CMD, done => {
describe(CMD, function () {
let sandbox;
const mockClient = {
reply: () => when.resolve()
};
const CMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(mockClient);
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
@@ -20,27 +20,27 @@ describe(CMD, done => {
});
it('TLS // not supported', done => {
CMDFN({command: {_: [CMD, 'TLS'], directive: CMD}})
cmdFn({command: { arg: 'TLS', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(504)
expect(mockClient.reply.args[0][0]).to.equal(504);
done();
})
.catch(done);
});
it('SSL // not supported', done => {
CMDFN({command: {_: [CMD, 'SSL'], directive: CMD}})
cmdFn({command: { arg: 'SSL', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(504)
expect(mockClient.reply.args[0][0]).to.equal(504);
done();
})
.catch(done);
});
it('bad // bad', done => {
CMDFN({command: {_: [CMD, 'bad'], directive: CMD}})
cmdFn({command: { arg: 'bad', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(504)
expect(mockClient.reply.args[0][0]).to.equal(504);
done();
})
.catch(done);

View File

@@ -4,7 +4,7 @@ const {expect} = require('chai');
const sinon = require('sinon');
const CMD = 'CDUP';
describe(CMD, done => {
describe(CMD, function () {
let sandbox;
let log = bunyan.createLogger({name: CMD});
const mockClient = {
@@ -13,7 +13,7 @@ describe(CMD, done => {
chdir: () => when.resolve()
}
};
const CMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(mockClient);
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
@@ -26,7 +26,7 @@ describe(CMD, done => {
});
it('.. // successful', done => {
CMDFN({log, command: {_: [CMD], directive: CMD}})
cmdFn({log, command: {directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(250);
expect(mockClient.fs.chdir.args[0][0]).to.equal('..');

View File

@@ -1,23 +1,21 @@
const when = require('when');
const bunyan = require('bunyan');
const {expect} = require('chai');
const sinon = require('sinon');
require('sinon-as-promised');
const CMD = 'CWD';
describe(CMD, done => {
describe(CMD, function () {
let sandbox;
let log = bunyan.createLogger({name: CMD});
const mockClient = {
reply: () => {},
fs: { chdir: () => {} }
};
const CMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(mockClient);
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.stub(mockClient, 'reply').resolves()
sandbox.stub(mockClient, 'reply').resolves();
sandbox.stub(mockClient.fs, 'chdir').resolves();
});
afterEach(() => {
@@ -27,9 +25,9 @@ describe(CMD, done => {
describe('// check', function () {
it('fails on no fs', done => {
const badMockClient = { reply: () => {} };
const BADCMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(badMockClient);
const badCmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
BADCMDFN()
badCmdFn()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(550);
done();
@@ -39,9 +37,9 @@ describe(CMD, done => {
it('fails on no fs chdir command', done => {
const badMockClient = { reply: () => {}, fs: {} };
const BADCMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(badMockClient);
const badCmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
BADCMDFN()
badCmdFn()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(402);
done();
@@ -51,7 +49,7 @@ describe(CMD, done => {
});
it('test // successful', done => {
CMDFN({log, command: {_: [CMD, 'test'], directive: CMD}})
cmdFn({log, command: { arg: 'test', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(250);
expect(mockClient.fs.chdir.args[0][0]).to.equal('test');
@@ -63,7 +61,7 @@ describe(CMD, done => {
it('test // successful', done => {
mockClient.fs.chdir.restore();
sandbox.stub(mockClient.fs, 'chdir').resolves('/test');
CMDFN({log, command: {_: [CMD, 'test'], directive: CMD}})
cmdFn({log, command: { arg: 'test', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(250);
expect(mockClient.fs.chdir.args[0][0]).to.equal('test');
@@ -74,9 +72,9 @@ describe(CMD, done => {
it('bad // unsuccessful', done => {
mockClient.fs.chdir.restore();
sandbox.stub(mockClient.fs, 'chdir').rejects(new Error('Bad'))
sandbox.stub(mockClient.fs, 'chdir').rejects(new Error('Bad'));
CMDFN({log, command: {_: [CMD, 'bad'], directive: CMD}})
cmdFn({log, command: { arg: 'bad', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(550);
expect(mockClient.fs.chdir.args[0][0]).to.equal('bad');

View File

@@ -1,18 +1,16 @@
const when = require('when');
const bunyan = require('bunyan');
const {expect} = require('chai');
const sinon = require('sinon');
require('sinon-as-promised');
const CMD = 'DELE';
describe(CMD, done => {
describe(CMD, function () {
let sandbox;
let log = bunyan.createLogger({name: CMD});
const mockClient = {
reply: () => {},
fs: { delete: () => {} }
};
const CMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(mockClient);
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
@@ -27,9 +25,9 @@ describe(CMD, done => {
describe('// check', function () {
it('fails on no fs', done => {
const badMockClient = { reply: () => {} };
const BADCMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(badMockClient);
const badCmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
BADCMDFN()
badCmdFn()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(550);
done();
@@ -39,9 +37,9 @@ describe(CMD, done => {
it('fails on no fs delete command', done => {
const badMockClient = { reply: () => {}, fs: {} };
const BADCMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(badMockClient);
const badCmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
BADCMDFN()
badCmdFn()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(402);
done();
@@ -51,7 +49,7 @@ describe(CMD, done => {
});
it('test // successful', done => {
CMDFN({log, command: {_: [CMD, 'test'], directive: CMD}})
cmdFn({log, command: { arg: 'test', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(250);
expect(mockClient.fs.delete.args[0][0]).to.equal('test');
@@ -62,9 +60,9 @@ describe(CMD, done => {
it('bad // unsuccessful', done => {
mockClient.fs.delete.restore();
sandbox.stub(mockClient.fs, 'delete').rejects(new Error('Bad'))
sandbox.stub(mockClient.fs, 'delete').rejects(new Error('Bad'));
CMDFN({log, command: {_: [CMD, 'bad'], directive: CMD}})
cmdFn({log, command: { arg: 'bad', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(550);
expect(mockClient.fs.delete.args[0][0]).to.equal('bad');

View File

@@ -1,14 +1,14 @@
const when = require('when');
const {expect} = require('chai');
const sinon = require('sinon')
const sinon = require('sinon');
const CMD = 'HELP';
describe(CMD, done => {
describe(CMD, function () {
let sandbox;
const mockClient = {
reply: () => when.resolve()
};
const CMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(mockClient);
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
@@ -20,7 +20,7 @@ describe(CMD, done => {
});
it('// successful', done => {
CMDFN({command: {_: [CMD], directive: CMD}})
cmdFn({command: { directive: CMD }})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(211);
done();
@@ -29,7 +29,7 @@ describe(CMD, done => {
});
it('help // successful', done => {
CMDFN({command: {_: [CMD, 'help'], directive: CMD}})
cmdFn({command: { arg: 'help', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(214);
done();
@@ -38,7 +38,7 @@ describe(CMD, done => {
});
it('help // successful', done => {
CMDFN({command: {_: [CMD, 'allo'], directive: CMD}})
cmdFn({command: { arg: 'allo', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(214);
done();
@@ -47,7 +47,7 @@ describe(CMD, done => {
});
it('bad // unsuccessful', done => {
CMDFN({command: {_: [CMD, 'bad'], directive: CMD}})
cmdFn({command: { arg: 'bad', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(502);
done();

View File

@@ -2,17 +2,16 @@ const when = require('when');
const bunyan = require('bunyan');
const {expect} = require('chai');
const sinon = require('sinon');
require('sinon-as-promised')(when.Promise);
const CMD = 'LIST';
describe(CMD, done => {
describe(CMD, function () {
let sandbox;
let log = bunyan.createLogger({name: CMD});
const mockClient = {
reply: () => {},
fs: { list: () => {} },
connector: {
waitForConnection: () => {},
waitForConnection: () => when({}),
end: () => {}
},
commandSocket: {
@@ -20,14 +19,12 @@ describe(CMD, done => {
pause: () => {}
}
};
const mockSocket = {};
const CMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(mockClient);
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.stub(mockClient, 'reply').resolves();
sandbox.stub(mockClient.connector, 'waitForConnection').resolves(mockSocket);
sandbox.stub(mockClient.fs, 'list').resolves([{
name: 'test1',
dev: 2114,
@@ -54,9 +51,9 @@ describe(CMD, done => {
describe('// check', function () {
it('fails on no fs', done => {
const badMockClient = { reply: () => {} };
const BADCMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(badMockClient);
const badCmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
BADCMDFN()
badCmdFn()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(550);
done();
@@ -66,9 +63,9 @@ describe(CMD, done => {
it('fails on no fs list command', done => {
const badMockClient = { reply: () => {}, fs: {} };
const BADCMDFN = require(`../../src/commands/${CMD.toLowerCase()}`).bind(badMockClient);
const badCmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
BADCMDFN()
badCmdFn()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(402);
done();
@@ -78,12 +75,12 @@ describe(CMD, done => {
});
it('. // successful', done => {
CMDFN({log, command: {_: [CMD], directive: CMD}})
cmdFn({log, command: {directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(150);
expect(mockClient.reply.args[1][0]).to.have.property('raw');
expect(mockClient.reply.args[1][0]).to.have.property('message');
expect(mockClient.reply.args[1][0]).to.have.property('socket');
expect(mockClient.reply.args[1][1]).to.have.property('raw');
expect(mockClient.reply.args[1][1]).to.have.property('message');
expect(mockClient.reply.args[1][1]).to.have.property('socket');
expect(mockClient.reply.args[2][0]).to.equal(226);
done();
})
@@ -94,7 +91,7 @@ describe(CMD, done => {
mockClient.fs.list.restore();
sandbox.stub(mockClient.fs, 'list').rejects(new Error());
CMDFN({log, command: {_: [CMD], directive: CMD}})
cmdFn({log, command: {directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(451);
done();
@@ -103,10 +100,9 @@ describe(CMD, done => {
});
it('. // unsuccessful (timeout)', done => {
mockClient.connector.waitForConnection.restore();
sandbox.stub(mockClient.connector, 'waitForConnection').rejects(new when.TimeoutError());
sandbox.stub(mockClient.connector, 'waitForConnection').returns(when.reject(new when.TimeoutError()));
CMDFN({log, command: {_: [CMD], directive: CMD}})
cmdFn({log, command: {directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(425);
done();

Some files were not shown because too many files have changed in this diff Show More