Compare commits

..

27 Commits

Author SHA1 Message Date
Tyler Stewart
296573ae01 fix: cli arg consistency 2020-08-13 08:38:01 -06:00
Tyler Stewart
be579f65d0 docs: clarify passive options and behaviour 2020-08-13 08:38:01 -06:00
dependabot[bot]
c440050945 chore(deps): bump npm from 6.13.4 to 6.14.6 (#203)
Bumps [npm](https://github.com/npm/cli) from 6.13.4 to 6.14.6.
- [Release notes](https://github.com/npm/cli/releases)
- [Changelog](https://github.com/npm/cli/blob/latest/CHANGELOG.md)
- [Commits](https://github.com/npm/cli/compare/v6.13.4...v6.14.6)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tyler Stewart <tyler@autovance.com>
2020-07-21 10:05:34 -06:00
Tyler Stewart
ca576acf2e fix: correctly destroy socket on close (#208)
* chore: update owner references

* fix: correctly destroy socket on close

This fixes an issue where the client would never close, hanging the server and preventing shutdown

* fix: only set timeout if greater than 0

* fix: move dependency to top

* fix: notify of command that caused error

* fix: emit disconnect event on client close
2020-07-20 16:30:04 -06:00
dependabot[bot]
649d582f30 chore(deps): bump lodash from 4.17.15 to 4.17.19 (#205)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.15 to 4.17.19.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.15...4.17.19)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-07-17 13:59:28 -06:00
dependabot[bot]
e26caad783 chore(deps): bump https-proxy-agent from 2.2.2 to 2.2.4 (#198) 2020-05-01 09:04:06 -06:00
dependabot[bot]
2470db6482 chore(deps): bump handlebars from 4.1.2 to 4.5.3 (#186) 2020-05-01 09:01:50 -06:00
dependabot[bot]
d78fae0ce6 chore(deps): bump acorn from 6.1.0 to 6.4.1 (#195) 2020-05-01 08:59:54 -06:00
Tyler Stewart
c59e191a39 fix: correct OPTS error code (#190)
If an unknown option is given, the response should be 501
2020-01-07 09:39:22 -07:00
Tyler Stewart
b2b1b2a0d3 fix(commands): 502 error on unsupported command (#185)
* fix(commands): 502 error on unsupported command

Fixes: https://github.com/trs/ftp-srv/issues/184

* test: update test assertion
2020-01-06 15:09:34 -07:00
dependabot[bot]
81fa7fcb89 chore(deps): bump npm from 6.11.2 to 6.13.4 (#183)
Bumps [npm](https://github.com/npm/cli) from 6.11.2 to 6.13.4.
- [Release notes](https://github.com/npm/cli/releases)
- [Changelog](https://github.com/npm/cli/blob/latest/CHANGELOG.md)
- [Commits](https://github.com/npm/cli/compare/v6.11.2...v6.13.4)

Signed-off-by: dependabot[bot] <support@github.com>
2019-12-14 13:00:44 -05:00
Leo Bernard
a18841d770 feat: 'disconnect' event (#175)
Fixes #174
2019-11-08 09:45:27 -07:00
Karoly Gossler
0dbb7f9070 docs: fixes the documentation of pasv_url behaviour (#172)
fixes #171
2019-09-26 12:16:19 -06:00
Tyler Stewart
0b9167e1e4 fix: explicitly promisify fs methods
`promisifyAll` will throw if a method with the suffix `Async` already
exists.

Fixes: https://github.com/trs/ftp-srv/pull/159
2019-08-22 14:57:40 -06:00
Tyler Stewart
484409d2eb feat: allow connecting from local 2019-08-22 14:57:40 -06:00
Tyler Stewart
5ffcef3312 fix: dont format message is empty 2019-08-22 14:57:40 -06:00
Tibor Papp
290769a042 Possibility to send back empty message 2019-08-22 14:57:40 -06:00
Tibor Papp
a1c7f2ffda Return response when folder is empty.
If we do not send back a response, some FTP clients can fail, when they use encryption.
2019-08-22 14:57:40 -06:00
Tyler Stewart
7153ffab4d chore: npm audit 2019-08-22 14:57:40 -06:00
Tyler Stewart
c0e132b70e fix: ensure valid fs path resolution 2019-08-22 14:57:40 -06:00
Tyler Stewart
e661bd10e2 fix: remove socket encoding
By setting the encoding, there becomes issues with binary transfers
(such as photos).
2019-08-22 14:57:40 -06:00
Tyler Stewart
bece42a0c9 feat: close passive server when client disconnects
Since a passive server is created for an individual client, when it
disconnects from the server we can assume the server is done and should
close it.
2019-08-22 14:57:40 -06:00
Tyler Stewart
b1fe56826c feat: disconnect passive server after timeout
If no client connects within 30 seconds of requesting, close the server.
This prevents multiple servers from being created and never closing.
2019-08-22 14:57:40 -06:00
Tyler Stewart
16dbc7895c chore: audit fix 2019-06-21 14:37:56 -06:00
Tyler Stewart
94f0b893e4 fix: enable better concurrent port search
Creates a new instance of a server each time the port search is called.
This ensures that concurrent calls to this function wont hang or produce
`ERR_SERVER_ALREADY_LISTEN`
2019-06-21 14:37:56 -06:00
Tyler Stewart
79d7bd9062 chore: ensure correct test path 2019-06-21 14:37:56 -06:00
Tyler Stewart
44999c714d fix(stor): ensure rejection after destroy 2019-06-21 14:37:56 -06:00
137 changed files with 7355 additions and 17325 deletions

367
README.md
View File

@@ -0,0 +1,367 @@
<p align="center">
<a href="https://github.com/autovance/ftp-srv">
<img alt="ftp-srv" src="logo.png" width="600px" />
</a>
</p>
<p align="center">
Modern, extensible FTP Server
</p>
<p align="center">
<a href="https://www.npmjs.com/package/ftp-srv">
<img alt="npm" src="https://img.shields.io/npm/dm/ftp-srv.svg?style=for-the-badge" />
</a>
<a href="https://circleci.com/gh/autovance/workflows/ftp-srv/tree/master">
<img alt="circleci" src="https://img.shields.io/circleci/project/github/autovance/ftp-srv/master.svg?style=for-the-badge" />
</a>
</p>
---
- [Overview](#overview)
- [Features](#features)
- [Install](#install)
- [Usage](#usage)
- [API](#api)
- [CLI](#cli)
- [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
- Extensible [file systems](#file-system) per connection
- Passive and active transfers
- [Explicit](https://en.wikipedia.org/wiki/FTPS#Explicit) & [Implicit](https://en.wikipedia.org/wiki/FTPS#Implicit) TLS connections
- Promise based API
## Install
`npm install ftp-srv --save`
## Usage
```js
// Quick start
const FtpSrv = require('ftp-srv');
const ftpServer = new FtpSrv({ options ... });
ftpServer.on('login', (data, resolve, reject) => { ... });
...
ftpServer.listen()
.then(() => { ... });
```
## API
### `new FtpSrv({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. `0.0.0.0` will listen on any available hosts for server and passive connections.
__Default:__ `"ftp://127.0.0.1:21"`
#### `pasv_url`
The hostname to provide a client when attempting a passive connection (`PASV`).
If not provided, clients can only connect using an `Active` connection.
#### `pasv_min`
Tne starting port to accept passive connections.
__Default:__ `1024`
#### `pasv_max`
The ending port to accept passive connections.
The range is then queried for an available port to use when required.
__Default:__ `65535`
#### `greeting`
A human readable array of lines or string to send when a client connects.
__Default:__ `null`
#### `tls`
Node [TLS secure context object](https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options) used for implicit (`ftps` protocol) or explicit (`AUTH TLS`) connections.
__Default:__ `false`
#### `anonymous`
If true, will allow clients to authenticate using the username `anonymous`, not requiring a password from the user.
Can also set as a string which allows users to authenticate using the username provided.
The `login` event is then sent with the provided username and `@anonymous` as the password.
__Default:__ `false`
#### `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.
#### `timeout`
Sets the timeout (in ms) after that an idle connection is closed by the server
__Default:__ `0`
## CLI
`ftp-srv` also comes with a builtin CLI.
```bash
$ ftp-srv [url] [options]
```
```bash
$ ftp-srv ftp://0.0.0.0:9876 --root ~/Documents
```
#### `url`
Set the listening URL.
Defaults to `ftp://127.0.0.1:21`
#### `--pasv_url`
The hostname to provide a client when attempting a passive connection (`PASV`).
If not provided, clients can only connect using an `Active` connection.
#### `--pasv_min`
The starting port to accept passive connections.
__Default:__ `1024`
#### `--pasv_max`
The ending port to accept passive connections.
The range is then queried for an available port to use when required.
__Default:__ `65535`
#### `--root` / `-r`
Set the default root directory for users.
Defaults to the current directory.
#### `--credentials` / `-c`
Set the path to a json credentials file.
Format:
```js
[
{
"username": "...",
"password": "...",
"root": "..." // Root directory
},
...
]
```
#### `--username`
Set the username for the only user. Do not provide an argument to allow anonymous login.
#### `--password`
Set the password for the given `username`.
#### `--read-only`
Disable write actions such as upload, delete, etc.
## Events
The `FtpSrv` 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
ftpServer.on('login', ({connection, username, password}, resolve, reject) => { ... });
```
Occurs when a client is attempting to login. Here you can resolve the login request by username and password.
`connection` [client class object](src/connection.js)
`username` string of username from `USER` command
`password` string of password from `PASS` command
`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
`reject` takes an error object
### `client-error`
```js
ftpServer.on('client-error', ({connection, context, error}) => { ... });
```
Occurs when an error arises in the client connection.
`connection` [client class object](src/connection.js)
`context` string of where the error occurred
`error` error object
### `RETR`
```js
connection.on('RETR', (error, filePath) => { ... });
```
Occurs when a file is downloaded.
`error` if successful, will be `null`
`filePath` location to which file was downloaded
### `STOR`
```js
connection.on('STOR', (error, fileName) => { ... });
```
Occurs when a file is uploaded.
`error` if successful, will be `null`
`fileName` name of the file that was uploaded
### `RNTO`
```js
connection.on('RNTO', (error, fileName) => { ... });
```
Occurs when a file is renamed.
`error` if successful, will be `null`
`fileName` name of the file that was renamed
## Supported Commands
See the [command registry](src/commands/registration) for a list of all implemented FTP commands.
## File System
The default [file system](src/fs.js) 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.
The default file system is exported and can be extended as needed:
```js
const {FtpSrv, FileSystem} = require('ftp-srv');
class MyFileSystem extends FileSystem {
constructor() {
super(...arguments);
}
get(fileName) {
...
}
}
```
Custom file systems can implement the following variables depending on the developers needs:
### Methods
#### [`currentDirectory()`](src/fs.js#L40)
Returns a string of the current working directory
__Used in:__ `PWD`
#### [`get(fileName)`](src/fs.js#L44)
Returns a file stat object of file or directory
__Used in:__ `LIST`, `NLST`, `STAT`, `SIZE`, `RNFR`, `MDTM`
#### [`list(path)`](src/fs.js#L50)
Returns array of file and directory stat objects
__Used in:__ `LIST`, `NLST`, `STAT`
#### [`chdir(path)`](src/fs.js#L67)
Returns new directory relative to current directory
__Used in:__ `CWD`, `CDUP`
#### [`mkdir(path)`](src/fs.js#L114)
Returns a path to a newly created directory
__Used in:__ `MKD`
#### [`write(fileName, {append, start})`](src/fs.js#L79)
Returns a writable stream
Options:
`append` if true, append to existing file
`start` if set, specifies the byte offset to write to
__Used in:__ `STOR`, `APPE`
#### [`read(fileName, {start})`](src/fs.js#L90)
Returns a readable stream
Options:
`start` if set, specifies the byte offset to read from
__Used in:__ `RETR`
#### [`delete(path)`](src/fs.js#L105)
Delete a file or directory
__Used in:__ `DELE`
#### [`rename(from, to)`](src/fs.js#L120)
Renames a file or directory
__Used in:__ `RNFR`, `RNTO`
#### [`chmod(path)`](src/fs.js#L126)
Modifies a file or directory's permissions
__Used in:__ `SITE CHMOD`
#### [`getUniqueName()`](src/fs.js#L131)
Returns a unique file name to write to
__Used in:__ `STOU`
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md).
## Contributors
- [OzairP](https://github.com/OzairP)
- [TimLuq](https://github.com/TimLuq)
- [crabl](https://github.com/crabl)
- [hirviid](https://github.com/hirviid)
- [DiegoRBaquero](https://github.com/DiegoRBaquero)
- [edin-m](https://github.com/edin-m)
- [voxsoftware](https://github.com/voxsoftware)
- [jorinvo](https://github.com/jorinvo)
- [Johnnyrook777](https://github.com/Johnnyrook777)
- [qchar](https://github.com/qchar)
- [mikejestes](https://github.com/mikejestes)
- [pkeuter](https://github.com/pkeuter)
- [qiansc](https://github.com/qiansc)
- [broofa](https://github.com/broofa)
- [lafin](https://github.com/lafin)
- [alancnet](https://github.com/alancnet)
- [zgwit](https://github.com/zgwit)
## License
This software is licensed under the MIT Licence. See [LICENSE](LICENSE).
## References
- [https://cr.yp.to/ftp.html](https://cr.yp.to/ftp.html)

View File

@@ -37,19 +37,22 @@ function setupYargs() {
boolean: true,
default: false
})
.option('pasv_url', {
.option('pasv-url', {
describe: 'URL to provide for passive connections',
type: 'string'
type: 'string',
alias: 'pasv_url'
})
.option('pasv_min', {
.option('pasv-min', {
describe: 'Starting point to use when creating passive connections',
type: 'number',
default: 1024
default: 1024,
alias: 'pasv_min'
})
.option('pasv_max', {
.option('pasv-max', {
describe: 'Ending port to use when creating passive connections',
type: 'number',
default: 65535
default: 65535,
alias: 'pasv_max'
})
.parse();
}

View File

@@ -1,15 +0,0 @@
import FTPServer from '../src/server';
const server = new FTPServer();
server.registerPlugin({
command: 'PASS',
handler: async ({connection, reply}) => {
const username = connection.getContext('username');
const password = connection.getContext('password');
// AUTHENTICATE
reply.set([230]);
}
});
server.listen();

View File

@@ -4,9 +4,9 @@ import { EventEmitter } from 'events';
export class FileSystem {
readonly connection: FtpConnection;
readonly root: string;
readonly cwd: string;
readonly connection: FtpConnection;
readonly root: string;
readonly cwd: string;
constructor(connection: FtpConnection, {root, cwd}?: {
root: any;
@@ -42,35 +42,35 @@ export class FileSystem {
}
export class FtpConnection extends EventEmitter {
server: FtpServer;
id: string;
log: any;
transferType: string;
encoding: string;
bufferSize: boolean;
readonly ip: string;
restByteCount: number | undefined;
secure: boolean
server: FtpServer;
id: string;
log: any;
transferType: string;
encoding: string;
bufferSize: boolean;
readonly ip: string;
restByteCount: number | undefined;
secure: boolean
close (code: number, message: number): Promise<any>
login (username: string, password: string): Promise<any>
reply (options: number | Record<string, any>, ...letters: any[]): Promise<any>
close (code: number, message: number): Promise<any>
login (username: string, password: string): Promise<any>
reply (options: number | Object, ...letters: Array<any>): Promise<any>
}
export interface FtpServerOptions {
url?: string;
pasv_min?: number;
pasv_max?: number;
pasv_url?: string;
greeting?: string | string[];
tls?: tls.SecureContextOptions | false;
anonymous?: boolean;
blacklist?: string[];
whitelist?: string[];
file_format?: (stat: Stats) => string | Promise<string> | "ls" | "ep";
log?: any;
timeout?: number;
url?: string,
pasv_min?: number,
pasv_max?: number,
pasv_url?: string,
greeting?: string | string[],
tls?: tls.SecureContextOptions | false,
anonymous?: boolean,
blacklist?: Array<string>,
whitelist?: Array<string>,
file_format?: (stat: Stats) => string | Promise<string> | "ls" | "ep",
log?: any,
timeout?: number
}
export class FtpServer extends EventEmitter {
@@ -85,9 +85,9 @@ export class FtpServer extends EventEmitter {
// emit is exported from super class
setupTLS(_tls: boolean): boolean | {
cert: string;
key: string;
ca: string;
cert: string;
key: string;
ca: string
};
setupGreeting(greet: string): string[];
@@ -98,29 +98,36 @@ export class FtpServer extends EventEmitter {
close(): any;
on(event: "login", listener: (
data: {
connection: FtpConnection;
username: string;
password: string;
},
resolve: (config: {
fs?: FileSystem;
root?: string;
cwd?: string;
blacklist?: string[];
whitelist?: string[];
on(event: "login", listener: (
data: {
connection: FtpConnection,
username: string,
password: string
},
resolve: (config: {
fs?: FileSystem,
root?: string,
cwd?: string,
blacklist?: Array<string>,
whitelist?: Array<string>
}) => void,
reject: (err?: Error) => void
) => void): this;
reject: (err?: Error) => void
) => void): this;
on(event: "client-error", listener: (
data: {
connection: FtpConnection;
context: string;
error: Error;
}
) => void): this;
on(event: "disconnect", listener: (
data: {
connection: FtpConnection,
id: string
}
) => void): this;
on(event: "client-error", listener: (
data: {
connection: FtpConnection,
context: string,
error: Error,
}
) => void): this;
}
export {FtpServer as FtpSrv};

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -1,358 +0,0 @@
<p align="center">
<a href="https://github.com/trs/ftp-srv">
<img alt="ftp-srv" src="logo.png" width="600px" />
</a>
</p>
<p align="center">
Modern, extensible FTP Server
</p>
<p align="center">
<a href="https://www.npmjs.com/package/ftp-srv">
<img alt="npm" src="https://img.shields.io/npm/dm/ftp-srv.svg?style=for-the-badge" />
</a>
<a href="https://circleci.com/gh/trs/workflows/ftp-srv/tree/master">
<img alt="circleci" src="https://img.shields.io/circleci/project/github/trs/ftp-srv/master.svg?style=for-the-badge" />
</a>
</p>
---
- [Overview](#overview)
- [Features](#features)
- [Install](#install)
- [Usage](#usage)
- [API](#api)
- [CLI](#cli)
- [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
- Extensible [file systems](#file-system) per connection
- Passive and active transfers
- [Explicit](https://en.wikipedia.org/wiki/FTPS#Explicit) & [Implicit](https://en.wikipedia.org/wiki/FTPS#Implicit) TLS connections
- Promise based API
## Install
`npm install ftp-srv --save`
## Usage
```js
// Quick start
const FtpSrv = require('ftp-srv');
const ftpServer = new FtpSrv({ options ... });
ftpServer.on('login', (data, resolve, reject) => { ... });
...
ftpServer.listen()
.then(() => { ... });
```
## API
### `new FtpSrv({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. `0.0.0.0` will listen on any available hosts for server and passive connections.
__Default:__ `"ftp://127.0.0.1:21"`
#### `pasv_url`
The hostname to provide a client when attempting a passive connection (`PASV`). This defaults to the provided `url` hostname.
_Note:_ If set to `0.0.0.0`, this will automatically resolve to the external IP of the box.
__Default:__ `"127.0.0.1"`
#### `pasv_min`
Tne starting port to accept passive connections.
__Default:__ `1024`
#### `pasv_max`
The ending port to accept passive connections.
The range is then queried for an available port to use when required.
__Default:__ `65535`
#### `greeting`
A human readable array of lines or string to send when a client connects.
__Default:__ `null`
#### `tls`
Node [TLS secure context object](https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options) used for implicit (`ftps` protocol) or explicit (`AUTH TLS`) connections.
__Default:__ `false`
#### `anonymous`
If true, will allow clients to authenticate using the username `anonymous`, not requiring a password from the user.
Can also set as a string which allows users to authenticate using the username provided.
The `login` event is then sent with the provided username and `@anonymous` as the password.
__Default:__ `false`
#### `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.
#### `timeout`
Sets the timeout (in ms) after that an idle connection is closed by the server
__Default:__ `0`
## CLI
`ftp-srv` also comes with a builtin CLI.
```bash
$ ftp-srv [url] [options]
```
```bash
$ ftp-srv ftp://0.0.0.0:9876 --root ~/Documents
```
#### `url`
Set the listening URL.
Defaults to `ftp://127.0.0.1:21`
#### `--root` / `-r`
Set the default root directory for users.
Defaults to the current directory.
#### `--credentials` / `-c`
Set the path to a json credentials file.
Format:
```js
[
{
"username": "...",
"password": "...",
"root": "..." // Root directory
},
...
]
```
#### `--username`
Set the username for the only user. Do not provide an argument to allow anonymous login.
#### `--password`
Set the password for the given `username`.
## Events
The `FtpSrv` 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
ftpServer.on('login', ({connection, username, password}, resolve, reject) => { ... });
```
Occurs when a client is attempting to login. Here you can resolve the login request by username and password.
`connection` [client class object](src/connection.js)
`username` string of username from `USER` command
`password` string of password from `PASS` command
`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
`reject` takes an error object
### `client-error`
```js
ftpServer.on('client-error', ({connection, context, error}) => { ... });
```
Occurs when an error arises in the client connection.
`connection` [client class object](src/connection.js)
`context` string of where the error occurred
`error` error object
### `RETR`
```js
connection.on('RETR', (error, filePath) => { ... });
```
Occurs when a file is downloaded.
`error` if successful, will be `null`
`filePath` location to which file was downloaded
### `STOR`
```js
connection.on('STOR', (error, fileName) => { ... });
```
Occurs when a file is uploaded.
`error` if successful, will be `null`
`fileName` name of the file that was uploaded
### `RNTO`
```js
connection.on('RNTO', (error, fileName) => { ... });
```
Occurs when a file is renamed.
`error` if successful, will be `null`
`fileName` name of the file that was renamed
## Supported Commands
See the [command registry](src/commands/registration) for a list of all implemented FTP commands.
## File System
The default [file system](src/fs.js) 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.
The default file system is exported and can be extended as needed:
```js
const {FtpSrv, FileSystem} = require('ftp-srv');
class MyFileSystem extends FileSystem {
constructor() {
super(...arguments);
}
get(fileName) {
...
}
}
```
Custom file systems can implement the following variables depending on the developers needs:
### Methods
#### [`currentDirectory()`](src/fs.js#L40)
Returns a string of the current working directory
__Used in:__ `PWD`
#### [`get(fileName)`](src/fs.js#L44)
Returns a file stat object of file or directory
__Used in:__ `LIST`, `NLST`, `STAT`, `SIZE`, `RNFR`, `MDTM`
#### [`list(path)`](src/fs.js#L50)
Returns array of file and directory stat objects
__Used in:__ `LIST`, `NLST`, `STAT`
#### [`chdir(path)`](src/fs.js#L67)
Returns new directory relative to current directory
__Used in:__ `CWD`, `CDUP`
#### [`mkdir(path)`](src/fs.js#L114)
Returns a path to a newly created directory
__Used in:__ `MKD`
#### [`write(fileName, {append, start})`](src/fs.js#L79)
Returns a writable stream
Options:
`append` if true, append to existing file
`start` if set, specifies the byte offset to write to
__Used in:__ `STOR`, `APPE`
#### [`read(fileName, {start})`](src/fs.js#L90)
Returns a readable stream
Options:
`start` if set, specifies the byte offset to read from
__Used in:__ `RETR`
#### [`delete(path)`](src/fs.js#L105)
Delete a file or directory
__Used in:__ `DELE`
#### [`rename(from, to)`](src/fs.js#L120)
Renames a file or directory
__Used in:__ `RNFR`, `RNTO`
#### [`chmod(path)`](src/fs.js#L126)
Modifies a file or directory's permissions
__Used in:__ `SITE CHMOD`
#### [`getUniqueName()`](src/fs.js#L131)
Returns a unique file name to write to
__Used in:__ `STOU`
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md).
## Contributors
- [OzairP](https://github.com/OzairP)
- [TimLuq](https://github.com/TimLuq)
- [crabl](https://github.com/crabl)
- [hirviid](https://github.com/hirviid)
- [DiegoRBaquero](https://github.com/DiegoRBaquero)
- [edin-m](https://github.com/edin-m)
- [voxsoftware](https://github.com/voxsoftware)
- [jorinvo](https://github.com/jorinvo)
- [Johnnyrook777](https://github.com/Johnnyrook777)
- [qchar](https://github.com/qchar)
- [mikejestes](https://github.com/mikejestes)
- [pkeuter](https://github.com/pkeuter)
- [qiansc](https://github.com/qiansc)
- [broofa](https://github.com/broofa)
- [lafin](https://github.com/lafin)
- [alancnet](https://github.com/alancnet)
- [zgwit](https://github.com/zgwit)
## License
This software is licensed under the MIT Licence. See [LICENSE](LICENSE).
## References
- [https://cr.yp.to/ftp.html](https://cr.yp.to/ftp.html)

9976
old/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,91 +0,0 @@
{
"name": "ftp-srv",
"version": "0.0.0",
"description": "Modern, extensible FTP Server",
"keywords": [
"ftp",
"ftp-server",
"ftp-srv",
"ftp-svr",
"ftpd",
"ftpserver",
"server"
],
"license": "MIT",
"files": [
"src",
"bin",
"ftp-srv.d.ts"
],
"main": "ftp-srv.js",
"bin": "./bin/index.js",
"types": "./ftp-srv.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/trs/ftp-srv"
},
"scripts": {
"pre-release": "npm run verify",
"semantic-release": "semantic-release",
"test": "mocha **/*.spec.js --ui bdd --bail",
"verify": "eslint src/**/*.js test/**/*.js bin/**/*.js"
},
"release": {
"verifyConditions": "condition-circle"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},
"lint-staged": {
"*.js": [
"eslint --fix",
"git add"
]
},
"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
},
"eslintConfig": {
"extends": "eslint:recommended",
"env": {
"node": true,
"mocha": true,
"es6": true
},
"parserOptions": {
"ecmaVersion": 6,
"sourceType": "module"
}
},
"dependencies": {
"bluebird": "^3.5.1",
"bunyan": "^1.8.12",
"ip": "^1.1.5",
"lodash": "^4.17.10",
"moment": "^2.22.1",
"uuid": "^3.2.1",
"yargs": "^11.0.0"
},
"devDependencies": {
"@commitlint/cli": "^7.5.2",
"@commitlint/config-conventional": "^7.5.0",
"@icetee/ftp": "^1.0.2",
"chai": "^4.2.0",
"condition-circle": "^2.0.2",
"eslint": "^5.14.1",
"husky": "^1.3.1",
"lint-staged": "^8.1.4",
"mocha": "^5.2.0",
"rimraf": "^2.6.1",
"semantic-release": "^15.10.6",
"sinon": "^2.3.5"
},
"engines": {
"node": ">=6.x"
}
}

View File

@@ -1,56 +0,0 @@
module.exports = {
| 100
| 110
| 120
| 125
| 150
| 200
| 202
| 211
| 212
| 213
| 214
| 215
| 220
| 221
| 225
| 226
| 227
| 230
| 234
| 250
| 257
| 331
| 332
| 350
| 421
| 425
| 426
| 450
| 451
| 452
| 500
| 501
| 502
| 503
| 504
| 530
| 532
| 550
| 551
| 552
| 553
};

View File

@@ -1,29 +0,0 @@
/* eslint no-unused-expressions: 0 */
const {expect} = require('chai');
const net = require('net');
const {getNextPortFactory} = require('../../src/helpers/find-port');
describe('helpers // find-port', function () {
describe('keeps trying new ports', () => {
let getNextPort;
let serverAlreadyRunning;
beforeEach((done) => {
getNextPort = getNextPortFactory('::', 8821);
serverAlreadyRunning = net.createServer();
serverAlreadyRunning.listen(8821, () => done());
});
afterEach((done) => {
serverAlreadyRunning.close(() => done());
});
it('test', () => {
return getNextPort()
.then((port) => {
expect(port).to.equal(8822);
});
});
});
});

12447
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,57 +1,7 @@
{
"name": "ftp-srv",
"version": "5.0.0",
"version": "0.0.0",
"description": "Modern, extensible FTP Server",
"main": "build/index",
"scripts": {
"lint": "eslint src/**/*.ts",
"lint:fix": "npm run lint -- --fix"
},
"jest": {
"testEnvironment": "node",
"moduleFileExtensions": [
"ts",
"js",
"json"
],
"transform": {
".ts": "ts-jest"
},
"testRegex": "\\.test\\.ts",
"globals": {
"ts-jest": {
"tsConfig": "tsconfig.json"
}
}
},
"eslintConfig": {
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "tsconfig.json"
},
"plugins": [
"@typescript-eslint",
"prettier"
],
"extends": "plugin:@typescript-eslint/recommended"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},
"lint-staged": {
"*.ts": [
"eslint --fix",
"git add"
]
},
"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
},
"keywords": [
"ftp",
"ftp-server",
@@ -61,32 +11,81 @@
"ftpserver",
"server"
],
"author": "Tyler Stewart",
"license": "MIT",
"files": [
"src",
"bin",
"ftp-srv.d.ts"
],
"main": "ftp-srv.js",
"bin": "./bin/index.js",
"types": "./ftp-srv.d.ts",
"repository": {
"type": "git",
"url": "git+https://github.com/trs/ftp-srv.git"
"url": "https://github.com/autovance/ftp-srv"
},
"bugs": {
"url": "https://github.com/trs/ftp-srv/issues"
"scripts": {
"pre-release": "npm run verify",
"semantic-release": "semantic-release",
"test": "mocha test/**/*.spec.js test/*.spec.js --ui bdd",
"verify": "eslint src/**/*.js test/**/*.js bin/**/*.js"
},
"release": {
"verifyConditions": "condition-circle"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},
"lint-staged": {
"*.js": [
"eslint --fix",
"git add"
]
},
"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
},
"eslintConfig": {
"extends": "eslint:recommended",
"env": {
"node": true,
"mocha": true,
"es6": true
},
"parserOptions": {
"ecmaVersion": 6,
"sourceType": "module"
}
},
"dependencies": {
"bluebird": "^3.5.1",
"bunyan": "^1.8.12",
"ip": "^1.1.5",
"lodash": "^4.17.15",
"moment": "^2.22.1",
"uuid": "^3.2.1",
"yargs": "^11.0.0"
},
"homepage": "https://github.com/trs/ftp-srv#readme",
"devDependencies": {
"@commitlint/cli": "^7.5.2",
"@commitlint/config-conventional": "^7.5.0",
"@types/jest": "^24.0.10",
"@types/node": "^11.10.5",
"@types/signale": "^1.2.1",
"@typescript-eslint/eslint-plugin": "^1.4.2",
"@typescript-eslint/parser": "^1.4.2",
"eslint": "^5.15.1",
"eslint-plugin-prettier": "^3.0.1",
"@commitlint/cli": "^8.1.0",
"@commitlint/config-conventional": "^8.1.0",
"@icetee/ftp": "^1.0.2",
"chai": "^4.2.0",
"condition-circle": "^2.0.2",
"eslint": "^5.14.1",
"husky": "^1.3.1",
"jest": "^24.3.1",
"lerna": "^3.13.1",
"lint-staged": "^8.1.5",
"prettier": "1.16.4",
"ts-jest": "^24.0.0",
"typescript": "^3.3.3333"
"lint-staged": "^8.1.4",
"mocha": "^5.2.0",
"rimraf": "^2.6.1",
"semantic-release": "^15.13.24",
"sinon": "^2.3.5"
},
"engines": {
"node": ">=6.x"
}
}

View File

@@ -1,65 +0,0 @@
import { CommandRegistration } from ".";
const user: CommandRegistration = {
arguments: ['username'],
description: 'Set the username to authenticate with',
handler: async function ({connection, command, reply}) {
if (connection.hasContext('username')) {
/*
RFC 959
4.1.1.
Servers may allow a new USER command to be
entered at any point in order to change the access control
and/or accounting information. This has the effect of
flushing any user, password, and account information already
supplied and beginning the login sequence again. All
transfer parameters are unchanged and any file transfer in
progress is completed under the old access control
parameters.
*/
connection.unsetContext('username', 'password');
}
connection.setContext('username', command.argument);
reply.set([331]);
}
};
const pass: CommandRegistration = {
arguments: ['password'],
description: 'Set the password to authenticate with',
handler: async function ({connection, command, reply}) {
if (connection.hasContext('password')) {
reply.set([202]);
return;
}
if (!connection.hasContext('username')) {
reply.set([503]);
return;
}
connection.setContext('password', command.argument);
reply.set([230]);
}
};
const acct: CommandRegistration = {
arguments: ['account-information'],
description: 'Set the identifying account',
handler: async function ({connection, command, reply}) {
if (!connection.hasContext('username', 'password')) {
reply.set([503]);
}
connection.setContext('account', command.argument);
reply.set([230]);
}
};
export {
user,
pass,
acct
};

View File

@@ -1,18 +0,0 @@
import { CommandRegistration } from ".";
import { CommandPlugin } from '../server';
const cwd: CommandRegistration = {
description: '',
handler: async ({command, reply}) => {
}
};
const cdup: CommandRegistration = {
description: '',
handler: async ({command, reply}) => {
}
};
export {cwd, cdup};

View File

@@ -1,42 +0,0 @@
import CommandSocket from "../commandSocket";
import FTPServer from "../server";
import { ReplyCode } from "../reply";
export interface Command {
identifier: string;
argument: string;
argumentParts: string[];
}
export type CommandReply = [ReplyCode, ...string[]];
export interface CommandReplyMod {
get: () => CommandReply;
set: (v: CommandReply) => void;
}
export type CommandHandler = (params: {server: Readonly<FTPServer>; connection: Readonly<CommandSocket>; command: Command; reply: CommandReplyMod}) => Promise<void>;
export interface CommandRegistration {
arguments?: string[];
description: string;
handler: CommandHandler;
}
export function parseCommandBuffer(data: Buffer): Command {
const result = data
.toString('utf8')
.replace(/\s+/g, ' ')
.match(/^(\w+?)(?: (.+)$|$)/);
if (!result) throw new Error('Invalid command');
const identifier = result[1].toLocaleUpperCase();
const argument = result.length > 1 ? result[2].trim() : '';
const argumentParts = argument.split(' ');
return {
identifier,
argument,
argumentParts
};
}

View File

@@ -1,131 +0,0 @@
/**
* https://tools.ietf.org/html/rfc2228
* https://tools.ietf.org/html/rfc4217
*/
import { CommandRegistration } from ".";
/*
AUTHENTICATION/SECURITY MECHANISM (AUTH)
The argument field is a Telnet string identifying a supported
mechanism. This string is case-insensitive. Values must be
registered with the IANA, except that values beginning with "X-"
are reserved for local use.
If the server does not recognize the AUTH command, it must respond
with reply code 500. This is intended to encompass the large
deployed base of non-security-aware ftp servers, which will
respond with reply code 500 to any unrecognized command. If the
server does recognize the AUTH command but does not implement the
security extensions, it should respond with reply code 502.
If the server does not understand the named security mechanism, it
should respond with reply code 504.
If the server is not willing to accept the named security
mechanism, it should respond with reply code 534.
If the server is not able to accept the named security mechanism,
such as if a required resource is unavailable, it should respond
with reply code 431.
If the server is willing to accept the named security mechanism,
but requires security data, it must respond with reply code 334.
If the server is willing to accept the named security mechanism,
and does not require any security data, it must respond with reply
code 234.
If the server is responding with a 334 reply code, it may include
security data as described in the next section.
Some servers will allow the AUTH command to be reissued in order
to establish new authentication. The AUTH command, if accepted,
removes any state associated with prior FTP Security commands.
The server must also require that the user reauthorize (that is,
reissue some or all of the USER, PASS, and ACCT commands) in this
case (see section 4 for an explanation of "authorize" in this
context).
AUTH
234
334
502, 504, 534, 431
500, 501, 421
*/
const auth: CommandRegistration = {
arguments: ['<mechanism-name>'],
description: 'Set authentication mechanism',
handler: async ({command, reply}) => {
const method = command.argument.toLocaleUpperCase();
switch (method) {
default: reply.set([504]);
}
}
}
/*
PROTECTION BUFFER SIZE (PBSZ)
The argument is a decimal integer representing the maximum size,
in bytes, of the encoded data blocks to be sent or received during
file transfer. This number shall be no greater than can be
represented in a 32-bit unsigned integer.
This command allows the FTP client and server to negotiate a
maximum protected buffer size for the connection. There is no
default size; the client must issue a PBSZ command before it can
issue the first PROT command.
The PBSZ command must be preceded by a successful security data
exchange.
If the server cannot parse the argument, or if it will not fit in
32 bits, it should respond with a 501 reply code.
If the server has not completed a security data exchange with the
client, it should respond with a 503 reply code.
Otherwise, the server must reply with a 200 reply code. If the
size provided by the client is too large for the server, it must
use a string of the form "PBSZ=number" in the text part of the
reply to indicate a smaller buffer size. The client and the
server must use the smaller of the two buffer sizes if both buffer
sizes are specified.
PBSZ
200
503
500, 501, 421, 530
*/
const pbsz: CommandRegistration = {
arguments: ['<decimal-integer>'],
description: 'The maximum size, in bytes, of the encoded data blocks to be sent or received during file transfer.',
handler: async ({reply}) => {
reply.set([500]);
}
}
/*
PROT
200
504, 536, 503, 534, 431
500, 501, 421, 530
*/
const prot: CommandRegistration = {
arguments: ['<prot-code ::= C | S | E | P>'],
description: 'Indicates to the server what type of data channel protection the client and server will be using',
handler: async ({reply}) => {
reply.set([536]);
}
}
export {
auth,
pbsz,
prot
};

View File

@@ -1,11 +0,0 @@
import { CommandRegistration } from ".";
const stru: CommandRegistration = {
arguments: ['<structure>'],
description: 'Set file transfer structure (Only "F" supported)',
handler: async ({command, reply}) => {
const code = /^F$/i.test(command.argument) ? 200 : 504;
reply.set([code]);
}
}
export {stru};

View File

@@ -1,76 +0,0 @@
import { Socket } from "net";
import { ReplyCode, formatReply } from "./reply";
import { FileSystem } from "./filesystem";
interface Meta {
connectedTime: string;
disconnectedTime?: string;
address?: string;
}
interface Context {
username?: string;
password?: string;
account?: string;
}
export default class CommandSocket {
private instance: Socket;
private meta: Meta;
private context: Context = {};
private filesystem: FileSystem;
constructor(socket: Socket) {
this.instance = socket.setEncoding('utf8');
this.meta = {
address: this.instance.remoteAddress,
connectedTime: new Date().toUTCString()
};
this.filesystem = new FileSystem();
};
public async sendReply(code: ReplyCode, ...lines: string[]) {
const reply = formatReply(code, ...lines);
await new Promise((resolve, reject) => {
this.instance.write(Buffer.from(reply), 'utf8', (err: Error) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
public async close() {
}
public hasContext<K extends keyof Context>(...names: K[]): boolean {
for (const name of names) {
if (!this.context.hasOwnProperty(name)) return false;
}
return true;
}
public getContext<K extends keyof Context>(name: K): Context[K] {
return this.context[name];
}
public setContext<K extends keyof Context>(name: K, value: Context[K]): this {
this.context[name] = value;
return this;
}
public unsetContext<K extends keyof Context>(...names: K[]): this {
for (const name of names) {
delete this.context[name];
}
return this;
}
public unsetAllContext() {
this.context = {};
return this;
}
}

View File

@@ -45,25 +45,25 @@ class FtpCommands {
log.trace({command: logCommand}, 'Handle command');
if (!REGISTRY.hasOwnProperty(command.directive)) {
return this.connection.reply(402, 'Command not allowed');
return this.connection.reply(502, `Command not allowed: ${command.directive}`);
}
if (_.includes(this.blacklist, command.directive)) {
return this.connection.reply(502, 'Command blacklisted');
return this.connection.reply(502, `Command blacklisted: ${command.directive}`);
}
if (this.whitelist.length > 0 && !_.includes(this.whitelist, command.directive)) {
return this.connection.reply(502, 'Command not whitelisted');
return this.connection.reply(502, `Command not whitelisted: ${command.directive}`);
}
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');
return this.connection.reply(530, `Command requires authentication: ${command.directive}`);
}
if (!commandRegister.handler) {
return this.connection.reply(502, 'Handler not set on command');
return this.connection.reply(502, `Handler not set on command: ${command.directive}`);
}
const handler = commandRegister.handler.bind(this.connection);

View File

@@ -1,9 +1,9 @@
const _ = require('lodash');
const registry = require('../registry');
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);

View File

@@ -36,6 +36,7 @@ module.exports = {
.tap(() => this.reply(150))
.then((fileList) => {
if (fileList.length) return this.reply({}, ...fileList);
return this.reply({socket: this.connector.socket, useEmptyMessage: true});
})
.tap(() => this.reply(226))
.catch(Promise.TimeoutError, (err) => {

View File

@@ -13,7 +13,7 @@ module.exports = {
const [_option, ...args] = command.arg.split(' ');
const option = _.toUpper(_option);
if (!OPTIONS.hasOwnProperty(option)) return this.reply(500);
if (!OPTIONS.hasOwnProperty(option)) return this.reply(501, 'Unknown option command');
return OPTIONS[option].call(this, args);
},
syntax: '{{cmd}}',
@@ -33,7 +33,6 @@ function utf8([setting] = []) {
if (!encoding) return this.reply(501, 'Unknown setting for option');
this.encoding = encoding;
if (this.transferType !== 'binary') this.transferType = this.encoding;
return this.reply(200, `UTF8 encoding ${_.toLower(setting)}`);
}

View File

@@ -1,4 +1,5 @@
const PassiveConnector = require('../../connector/passive');
const {isLocalIP} = require('../../helpers/is-local');
module.exports = {
directive: 'PASV',
@@ -10,7 +11,11 @@ module.exports = {
this.connector = new PassiveConnector(this);
return this.connector.setupServer()
.then((server) => {
const address = this.server.options.pasv_url;
let address = this.server.options.pasv_url;
// Allow connecting from local
if (isLocalIP(this.ip)) {
address = this.ip;
}
const {port} = server.address();
const host = address.replace(/\./g, ',');
const portByte1 = port / 256 | 0;

View File

@@ -28,7 +28,7 @@ module.exports = {
stream.on('data', (data) => {
if (stream) stream.pause();
if (this.connector.socket) {
this.connector.socket.write(data, this.transferType, () => stream && stream.resume());
this.connector.socket.write(data, () => stream && stream.resume());
}
});
stream.once('end', () => resolve());

View File

@@ -21,11 +21,14 @@ module.exports = {
const serverPath = stream.path || fileName;
const destroyConnection = (connection, reject) => (err) => {
if (connection) {
if (connection.writable) connection.end();
connection.destroy(err);
try {
if (connection) {
if (connection.writable) connection.end();
connection.destroy(err);
}
} finally {
reject(err);
}
reject(err);
};
const streamPromise = new Promise((resolve, reject) => {
@@ -37,7 +40,7 @@ module.exports = {
this.connector.socket.on('data', (data) => {
if (this.connector.socket) this.connector.socket.pause();
if (stream && stream.writable) {
stream.write(data, this.transferType, () => this.connector.socket && this.connector.socket.resume());
stream.write(data, () => this.connector.socket && this.connector.socket.resume());
}
});
this.connector.socket.once('end', () => {

View File

@@ -32,7 +32,7 @@ class FtpConnection extends EventEmitter {
this.commandSocket.on('data', this._handleData.bind(this));
this.commandSocket.on('timeout', () => {
this.log.trace('Client timeout');
this.close().catch((e) => this.log.trace(e, 'Client close error'));
this.close();
});
this.commandSocket.on('close', () => {
if (this.connector) this.connector.end();
@@ -72,7 +72,7 @@ class FtpConnection extends EventEmitter {
close(code = 421, message = 'Closing connection') {
return Promise.resolve(code)
.then((_code) => _code && this.reply(_code, message))
.then(() => this.commandSocket && this.commandSocket.end());
.finally(() => this.commandSocket && this.commandSocket.destroy());
}
login(username, password) {
@@ -104,15 +104,21 @@ class FtpConnection extends EventEmitter {
else if (typeof letter === 'string') letter = {message: letter}; // allow passing in message as first param
if (!letter.socket) letter.socket = options.socket ? options.socket : this.commandSocket;
if (!letter.message) letter.message = DEFAULT_MESSAGE[options.code] || 'No information';
if (!letter.encoding) letter.encoding = this.encoding;
if (!options.useEmptyMessage) {
if (!letter.message) letter.message = DEFAULT_MESSAGE[options.code] || 'No information';
if (!letter.encoding) letter.encoding = this.encoding;
}
return Promise.resolve(letter.message) // allow passing in a promise as a message
.then((message) => {
const seperator = !options.hasOwnProperty('eol') ?
letters.length - 1 === index ? ' ' : '-' :
options.eol ? ' ' : '-';
message = !letter.raw ? _.compact([letter.code || options.code, message]).join(seperator) : message;
letter.message = message;
if (!options.useEmptyMessage) {
const seperator = !options.hasOwnProperty('eol') ?
letters.length - 1 === index ? ' ' : '-' :
options.eol ? ' ' : '-';
message = !letter.raw ? _.compact([letter.code || options.code, message]).join(seperator) : message;
letter.message = message;
} else {
letter.message = '';
}
return letter;
});
});
@@ -130,7 +136,10 @@ class FtpConnection extends EventEmitter {
}
resolve();
});
} else reject(new errors.SocketError('Socket not writable'));
} else {
this.log.trace({message: letter.message}, 'Could not write message');
reject(new errors.SocketError('Socket not writable'));
}
});
};

View File

@@ -28,7 +28,6 @@ class Active extends Connector {
return closeExistingServer()
.then(() => {
this.dataSocket = new Socket();
this.dataSocket.setEncoding(this.connection.transferType);
this.dataSocket.on('error', (err) => this.server && this.server.emit('client-error', {connection: this.connection, context: 'dataSocket', error: err}));
this.dataSocket.connect({host, port, family}, () => {
this.dataSocket.pause();

View File

@@ -6,6 +6,8 @@ const Promise = require('bluebird');
const Connector = require('./base');
const errors = require('../errors');
const CONNECT_TIMEOUT = 30 * 1000;
class Passive extends Connector {
constructor(connection) {
super(connection);
@@ -30,6 +32,9 @@ class Passive extends Connector {
this.closeServer();
return this.server.getNextPasvPort()
.then((port) => {
this.dataSocket = null;
let idleServerTimeout;
const connectionHandler = (socket) => {
if (!ip.isEqual(this.connection.commandSocket.remoteAddress, socket.remoteAddress)) {
this.log.error({
@@ -41,19 +46,19 @@ class Passive extends Connector {
return this.connection.reply(550, 'Remote addresses do not match')
.finally(() => this.connection.close());
}
clearTimeout(idleServerTimeout);
this.log.trace({port, remoteAddress: socket.remoteAddress}, 'Passive connection fulfilled.');
this.dataSocket = socket;
this.dataSocket.setEncoding(this.connection.transferType);
this.dataSocket.on('error', (err) => this.server && this.server.emit('client-error', {connection: this.connection, context: 'dataSocket', error: err}));
this.dataSocket.once('close', () => this.closeServer());
if (!this.connection.secure) {
this.dataSocket.connected = true;
}
};
this.dataSocket = null;
const serverOptions = Object.assign({}, this.connection.secure ? this.server.options.tls : {}, {pauseOnConnect: true});
this.dataServer = (this.connection.secure ? tls : net).createServer(serverOptions, connectionHandler);
this.dataServer.maxConnections = 1;
@@ -74,6 +79,8 @@ class Passive extends Connector {
this.dataServer.listen(port, this.server.url.hostname, (err) => {
if (err) reject(err);
else {
idleServerTimeout = setTimeout(() => this.closeServer(), CONNECT_TIMEOUT);
this.log.debug({port}, 'Passive connection listening');
resolve(this.dataServer);
}

View File

@@ -1,4 +0,0 @@
export default class DataSocket {
}

View File

@@ -1,113 +0,0 @@
import {parse, join, relative, isAbsolute} from 'path';
import {promises as fs, constants, Stats} from 'fs';
type Parameters<T> = T extends (... args: infer T) => any ? T : never;
type ReturnType<T> = T extends (... args: any[]) => infer T ? T : never;
export interface NodeFileSystem {
stat: (...args: Parameters<typeof fs.stat>) => ReturnType<typeof fs.stat>;
rename: (...args: Parameters<typeof fs.rename>) => ReturnType<typeof fs.rename>;
access: (...args: Parameters<typeof fs.access>) => ReturnType<typeof fs.access>;
rmdir: (...args: Parameters<typeof fs.rmdir>) => ReturnType<typeof fs.rmdir>;
unlink: (...args: Parameters<typeof fs.unlink>) => ReturnType<typeof fs.unlink>;
mkdir: (...args: Parameters<typeof fs.mkdir>) => ReturnType<typeof fs.mkdir>;
chmod: (...args: Parameters<typeof fs.chmod>) => ReturnType<typeof fs.chmod>;
readdir: (...args: Parameters<typeof fs.readdir>) => ReturnType<typeof fs.readdir>;
}
interface FileSystemConfig {
root: string;
current: string;
fs: NodeFileSystem;
}
export class FileSystem {
private rootDirectory: string;
private currentDirectory: string;
private fs: NodeFileSystem;
/**
* @param root absolute path on the server to the users root directory
* @param current relative path from root to the users current directory
*/
constructor(config: Partial<FileSystemConfig> = {}) {
this.rootDirectory = config.root || '/';
this.currentDirectory = config.current || '.';
this.fs = config.fs || fs as unknown as NodeFileSystem;
}
public async absoluteDirectory() {
return this.getAbsolutePath();
}
public async navigate(to: string | null) {
const directory = this.resolvePath(this.currentDirectory, to);
await this.fs.access(this.getAbsolutePath(this.rootDirectory, directory), constants.R_OK);
this.currentDirectory = directory;
return this.currentDirectory;
}
public async stat(path: string | null): Promise<Stats> {
path = path ? this.resolvePath(this.currentDirectory, path) : this.currentDirectory;
const stat = await this.fs.stat(this.getAbsolutePath(this.rootDirectory, path));
return stat;
}
public async rename(from: string, to: string): Promise<string> {
from = this.resolvePath(this.currentDirectory, from);
to = this.resolvePath(this.currentDirectory, to);
await this.fs.rename(
this.getAbsolutePath(this.rootDirectory, from),
this.getAbsolutePath(this.rootDirectory, to)
);
return to;
}
public async delete(path: string): Promise<void> {
path = this.resolvePath(this.currentDirectory, path);
path = this.getAbsolutePath(this.rootDirectory, path);
const stat = await this.stat(path);
if (stat.isDirectory()) await this.fs.rmdir(path);
else await this.fs.unlink(path);
}
public async mkdir(path: string, mode?: string | number): Promise<string> {
path = this.resolvePath(this.currentDirectory, path);
await this.fs.mkdir(this.getAbsolutePath(this.rootDirectory, path), mode);
return path;
}
public async chmod(path: string, mode: string | number): Promise<string> {
path = this.resolvePath(this.currentDirectory, path);
await this.fs.chmod(this.getAbsolutePath(this.rootDirectory, path), mode);
return path;
}
public async readdir(path: string | null): Promise<string[]> {
path = this.resolvePath(this.currentDirectory, path);
const paths = await this.fs.readdir(this.getAbsolutePath(this.rootDirectory, path), {
encoding: 'utf8'
});
return paths as string[];
}
// public write(path: string): any;
// public read(path: string | null): any;
private resolvePath(from: string, to: string | null) {
if (!to) return from;
if (isAbsolute(to)) {
const {root} = parse(this.rootDirectory);
const cwd = this.getAbsolutePath(root); // Pretend `cwd` is the absolute path from root
to = relative(cwd, to);
}
return join(from, to);
}
private getAbsolutePath(root = this.rootDirectory, current = this.currentDirectory) {
return join(root, current);
}
}

View File

@@ -2,13 +2,14 @@ const _ = require('lodash');
const nodePath = require('path');
const uuid = require('uuid');
const Promise = require('bluebird');
const fs = Promise.promisifyAll(require('fs'));
const {createReadStream, createWriteStream, constants} = require('fs');
const fsAsync = require('./helpers/fs-async');
const errors = require('./errors');
class FileSystem {
constructor(connection, {root, cwd} = {}) {
this.connection = connection;
this.cwd = cwd ? nodePath.join(nodePath.sep, cwd) : nodePath.sep;
this.cwd = nodePath.normalize(cwd ? nodePath.join(nodePath.sep, cwd) : nodePath.sep);
this._root = nodePath.resolve(root || process.cwd());
}
@@ -27,8 +28,8 @@ class FileSystem {
})();
const fsPath = (() => {
const resolvedPath = nodePath.resolve(this.root, `.${nodePath.sep}${clientPath}`);
return nodePath.join(resolvedPath);
const resolvedPath = nodePath.join(this.root, clientPath);
return nodePath.resolve(nodePath.normalize(nodePath.join(resolvedPath)));
})();
return {
@@ -43,19 +44,19 @@ class FileSystem {
get(fileName) {
const {fsPath} = this._resolvePath(fileName);
return fs.statAsync(fsPath)
return fsAsync.stat(fsPath)
.then((stat) => _.set(stat, 'name', fileName));
}
list(path = '.') {
const {fsPath} = this._resolvePath(path);
return fs.readdirAsync(fsPath)
return fsAsync.readdir(fsPath)
.then((fileNames) => {
return Promise.map(fileNames, (fileName) => {
const filePath = nodePath.join(fsPath, fileName);
return fs.accessAsync(filePath, fs.constants.F_OK)
return fsAsync.access(filePath, constants.F_OK)
.then(() => {
return fs.statAsync(filePath)
return fsAsync.stat(filePath)
.then((stat) => _.set(stat, 'name', fileName));
})
.catch(() => null);
@@ -66,7 +67,7 @@ class FileSystem {
chdir(path = '.') {
const {fsPath, clientPath} = this._resolvePath(path);
return fs.statAsync(fsPath)
return fsAsync.stat(fsPath)
.tap((stat) => {
if (!stat.isDirectory()) throw new errors.FileSystemError('Not a valid directory');
})
@@ -78,8 +79,8 @@ class FileSystem {
write(fileName, {append = false, start = undefined} = {}) {
const {fsPath, clientPath} = this._resolvePath(fileName);
const stream = fs.createWriteStream(fsPath, {flags: !append ? 'w+' : 'a+', start});
stream.once('error', () => fs.unlinkAsync(fsPath));
const stream = createWriteStream(fsPath, {flags: !append ? 'w+' : 'a+', start});
stream.once('error', () => fsAsync.unlink(fsPath));
stream.once('close', () => stream.end());
return {
stream,
@@ -89,12 +90,12 @@ class FileSystem {
read(fileName, {start = undefined} = {}) {
const {fsPath, clientPath} = this._resolvePath(fileName);
return fs.statAsync(fsPath)
return fsAsync.stat(fsPath)
.tap((stat) => {
if (stat.isDirectory()) throw new errors.FileSystemError('Cannot read a directory');
})
.then(() => {
const stream = fs.createReadStream(fsPath, {flags: 'r', start});
const stream = createReadStream(fsPath, {flags: 'r', start});
return {
stream,
clientPath
@@ -104,28 +105,28 @@ class FileSystem {
delete(path) {
const {fsPath} = this._resolvePath(path);
return fs.statAsync(fsPath)
return fsAsync.stat(fsPath)
.then((stat) => {
if (stat.isDirectory()) return fs.rmdirAsync(fsPath);
else return fs.unlinkAsync(fsPath);
if (stat.isDirectory()) return fsAsync.rmdir(fsPath);
else return fsAsync.unlink(fsPath);
});
}
mkdir(path) {
const {fsPath} = this._resolvePath(path);
return fs.mkdirAsync(fsPath)
return fsAsync.mkdir(fsPath)
.then(() => fsPath);
}
rename(from, to) {
const {fsPath: fromPath} = this._resolvePath(from);
const {fsPath: toPath} = this._resolvePath(to);
return fs.renameAsync(fromPath, toPath);
return fsAsync.rename(fromPath, toPath);
}
chmod(path, mode) {
const {fsPath} = this._resolvePath(path);
return fs.chmodAsync(fsPath, mode);
return fsAsync.chmod(fsPath, mode);
}
getUniqueName() {

View File

@@ -16,10 +16,11 @@ function* portNumberGenerator(min, max = MAX_PORT) {
function getNextPortFactory(host, portMin, portMax, maxAttempts = MAX_PORT_CHECK_ATTEMPT) {
const nextPortNumber = portNumberGenerator(portMin, portMax);
const portCheckServer = net.createServer();
portCheckServer.maxConnections = 0;
return () => new Promise((resolve, reject) => {
const portCheckServer = net.createServer();
portCheckServer.maxConnections = 0;
let attemptCount = 0;
const tryGetPort = () => {
attemptCount++;
@@ -39,6 +40,7 @@ function getNextPortFactory(host, portMin, portMax, maxAttempts = MAX_PORT_CHECK
}
});
portCheckServer.once('listening', () => {
portCheckServer.removeAllListeners();
portCheckServer.close(() => resolve(port));
});

18
src/helpers/fs-async.js Normal file
View File

@@ -0,0 +1,18 @@
const fs = require('fs');
const {promisify} = require('bluebird');
const methods = [
'stat',
'readdir',
'access',
'unlink',
'rmdir',
'mkdir',
'rename',
'chmod'
];
module.exports = methods.reduce((obj, method) => {
obj[method] = promisify(fs[method]);
return obj;
}, {});

3
src/helpers/is-local.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports.isLocalIP = function(ip) {
return ip === '127.0.0.1' || ip == '::1';
}

View File

@@ -40,20 +40,21 @@ class FtpServer extends EventEmitter {
_.get(this, 'options.pasv_min'),
_.get(this, 'options.pasv_max'));
const timeout = Number(this.options.timeout);
this.options.timeout = isNaN(timeout) ? 0 : Number(timeout);
const timeout = Number(this.options.timeout);
this.options.timeout = isNaN(timeout) ? 0 : Number(timeout);
const serverConnectionHandler = (socket) => {
socket.setTimeout(this.options.timeout);
this.options.timeout > 0 && socket.setTimeout(this.options.timeout);
let connection = new Connection(this, {log: this.log, socket});
this.connections[connection.id] = connection;
socket.on('close', () => this.disconnectClient(connection.id));
socket.once('close', () => this.emit('disconnect', {connection, id: connection.id}));
const greeting = this._greeting || [];
const features = this._features || 'Ready';
return connection.reply(220, ...greeting, features)
.finally(() => socket.resume());
.finally(() => socket.resume());
};
const serverOptions = Object.assign({}, this.isTLS ? this.options.tls : {}, {pauseOnConnect: true});

56
src/messages.js Normal file
View File

@@ -0,0 +1,56 @@
module.exports = {
// 100 - 199 :: Remarks
100: 'The requested action is being initiated',
110: 'Restart marker reply',
120: 'Service ready in %s minutes',
125: 'Data connection already open; transfer starting',
150: 'File status okay; about to open data connection',
// 200 - 399 :: Acceptance
/// 200 - 299 :: Positive Completion Replies
/// These type of replies indicate that the requested action was taken and that the server is awaiting another command.
200: 'The requested action has been successfully completed',
202: 'Superfluous command',
211: 'System status, or system help reply',
212: 'Directory status',
213: 'File status',
214: 'Help message', // On how to use the server or the meaning of a particular non-standard command. This reply is useful only to the human user.
215: 'UNIX Type: L8', // NAME system type. Where NAME is an official system name from the list in the Assigned Numbers document.
220: 'Service ready for new user',
221: 'Service closing control connection', // Logged out if appropriate.
225: 'Data connection open; no transfer in progress',
226: 'Closing data connection', // Requested file action successful (for example, file transfer or file abort).
227: 'Entering Passive Mode', // (h1,h2,h3,h4,p1,p2).
230: 'User logged in, proceed',
234: 'Honored',
250: 'Requested file action okay, completed',
257: '\'%s\' created',
/// 300 - 399 :: Positive Intermediate Replies
/// These types of replies indicate that the requested action was taken and that the server is awaiting further information to complete the request.
331: 'Username okay, awaiting password',
332: 'Need account for login',
350: 'Requested file action pending further information',
// 400 - 599 :: Rejection
/// 400 - 499 :: Transient Negative Completion Replies
/// These types of replies indicate that the command was not accepted; the requested action was not taken.
/// However, the error is temporary and the action may be requested again.
421: 'Service not available, closing control connection', // This may be a reply to any command if the service knows it must shut down.
425: 'Unable to open data connection',
426: 'Connection closed; transfer aborted',
450: 'Requested file action not taken', // File unavailable (e.g., file busy).
451: 'Requested action aborted. Local error in processing',
452: 'Requested action not taken. Insufficient storage',
/// 500 - 599 :: Permanent Negative Completion Replies
/// These types of replies indicate that the command was not accepted; the requested action was not taken.
/// The FTP client is "discouraged" from repeating the same exact request.
500: 'Syntax error', // Can close connection
501: 'Syntax error in parameters or arguments',
502: 'Command not supported',
503: 'Bad sequence of commands',
504: 'Command parameter not supported',
530: 'Not logged in', // Permission Denied, Can close connection
532: 'Need account for storing files',
550: 'Requested action not taken. File unavailable', // (e.g., file not found, no access).
551: 'Requested action aborted. Page type unknown',
552: 'Requested file action aborted. Exceeded storage allocation', // (for current directory or dataset).
553: 'Requested action not taken. File name not allowed'
};

View File

@@ -1,75 +0,0 @@
const NEW_LINE = '\r\n';
export type ReplyCode = 100
| 110
| 120
| 125
| 150
| 200
| 202
| 211
| 212
| 213
| 214
| 215
| 220
| 221
| 225
| 226
| 227
| 230
| 232
| 234
| 250
| 257
| 331
| 332
| 350
| 421
| 425
| 426
| 450
| 451
| 452
| 500
| 501
| 502
| 503
| 504
| 530
| 532
| 533
| 536
| 550
| 551
| 552
| 553;
export function formatReply(code: ReplyCode, ...lines: string[]) {
if (lines.length === 0) {
return `${code}${NEW_LINE}`;
}
if (lines.length === 1) {
return `${code} ${lines[0]}${NEW_LINE}`;
}
const [firstLine, ...remainingLines] = lines;
const lastLine = remainingLines.pop();
const startsWithNumbers = /^\d+/;
const formattedLines = remainingLines.map((line) => {
if (startsWithNumbers.test(line)) {
return `\t${line}`
} else {
return line;
}
});
const reply = [
`${code}-${firstLine}`,
...formattedLines,
`${code} ${lastLine}`
];
return `${reply.join(NEW_LINE)}${NEW_LINE}`;
}

View File

@@ -1,204 +0,0 @@
import { SecureContextOptions, createServer as createSecureServer } from 'tls';
import { Server, isIP, createServer, Socket } from 'net';
import Connection from './commandSocket';
import { Command, parseCommandBuffer, CommandRegistration, CommandReply, CommandHandler } from './command';
import { stru } from './command/stru';
import { user, pass, acct } from './command/authentication';
import { NodeFileSystem } from './filesystem';
interface FTPServerConfig {
hostname: string;
port: number;
}
interface PassiveConfig {
hostname: string;
port_min: number;
port_max: number;
}
type EncryptionType = 'IMPLICIT'
| 'EXPLICIT';
interface EncryptionConfig {
type: EncryptionType;
context: SecureContextOptions;
}
export interface CommandPlugin {
command: string;
handler: CommandHandler;
at?: 'before' | 'after';
}
export default class FTPServer {
private instance?: Server;
private config: FTPServerConfig;
private passive?: PassiveConfig;
private encryption?: EncryptionConfig;
private fs?: NodeFileSystem;
private commandHandlers: Map<string, CommandRegistration>;
private plugins: Set<CommandPlugin>;
constructor(config: Partial<FTPServerConfig> = {}) {
this.config = {
hostname: 'localhost',
port: 21,
...config
};
this.plugins = new Set();
this.commandHandlers = new Map([
['USER', user],
['PASS', pass],
['ACCT', acct],
['STRU', stru]
]);
}
public configureEncryption(type: EncryptionType, context: SecureContextOptions) {
this.encryption = {
type,
context
};
return this;
}
public configurePassive(config: PassiveConfig) {
this.passive = {
port_min: 49152,
port_max: 65535,
...config
};
if (isIP(this.passive.hostname) === 0) {
// TODO: resolve url into ip using dns
throw new Error('Passive hostname must be a valid IP address');
}
return this;
}
public configureFileSystem(fs: NodeFileSystem) {
this.fs = fs;
return this;
}
public registerPlugin(plugin: CommandPlugin) {
if (!plugin.at) plugin.at = 'after';
this.plugins.add(plugin);
}
/**
* @returns `230` Login complete
* @returns `331` Username okay, awaiting password
* @returns `530` Login failed
*/
public registerCommand(identifier: 'USER', registration: Partial<CommandRegistration>): this;
public registerCommand(identifier: string, registration: Partial<CommandRegistration>) {
identifier = identifier.toLocaleUpperCase();
const existingHandler = this.commandHandlers.get(identifier);
if (existingHandler) {
registration = {
...existingHandler,
...registration
};
}
if (!registration.handler) {
throw new Error('Cannot register a command without a handler');
}
if (!registration.description) {
throw new Error('Cannot register a command without a description');
}
this.commandHandlers.set(identifier, registration as CommandRegistration);
return this;
}
public listen() {
const connectionHandler = async (socket: Socket) => {
const connection = new Connection(socket);
socket.on('data', async (data) => {
let command: Command;
try {
command = parseCommandBuffer(data);
} catch {
await connection.sendReply(500);
await connection.close();
return;
}
const registration = this.commandHandlers.get(command.identifier);
if (!registration) {
await connection.sendReply(502);
return;
}
if (registration.arguments && registration.arguments.length > 0) {
if (!command.argument) {
await connection.sendReply(501);
return;
}
}
const plugins = [...this.plugins].filter((plug) => {
return plug.command === command.identifier;
});
const beforePlugins = plugins.filter((plug) => plug.at === 'before');
const afterPlugins = plugins.filter((plug) => plug.at === 'after');
try {
// Cache this?
const handles = [
...beforePlugins.map((plug) => plug.handler),
registration.handler,
...afterPlugins.map((plug) => plug.handler)
];
const reply = (() => {
let value: CommandReply = [500];
return {
get: () => [...value] as CommandReply,
set: (v: CommandReply) => {
value = [...v] as CommandReply;
}
}
})();
for (const handle of handles) {
await handle({server: this, connection, command, reply});
}
const [replyCode, ...lines] = reply.get();
await connection.sendReply(replyCode, ...lines);
} catch {
await connection.sendReply(500);
}
});
socket.resume();
await connection.sendReply(200);
}
const serverOptions = {pauseOnConnect: true};
if (this.encryption && this.encryption.type === 'IMPLICIT') {
this.instance = createSecureServer({...serverOptions, ...this.encryption.context}, connectionHandler);
} else {
this.instance = createServer(serverOptions, connectionHandler);
}
this.instance.once('listening', () => {});
this.instance.once('close', () => {});
this.instance.on('error', (err: Error) => {});
this.instance.listen(this.config.port, this.config.hostname);
}
public async close() {
}
}

View File

@@ -90,7 +90,7 @@ describe('FtpCommands', function () {
return commands.handle('bad')
.then(() => {
expect(mockConnection.reply.callCount).to.equal(1);
expect(mockConnection.reply.args[0][0]).to.equal(402);
expect(mockConnection.reply.args[0][0]).to.equal(502);
});
});

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