Compare commits

...

58 Commits

Author SHA1 Message Date
Tyler Stewart
21bb611c30 WIP: typescript refactor 2019-04-08 16:37:51 -06:00
Tyler Stewart
0d9131c370 chore: simplify circle config 2019-03-08 11:14:42 -07:00
Tyler Stewart
eafaf6f642 chore: drop testing of node v6 2019-03-08 11:00:56 -07:00
Tyler Stewart
ea99e6ebbd chore: fix circle ci node_modules persistance 2019-03-08 10:47:55 -07:00
Tyler Stewart
342911eb36 fix: dont specify host when port searching (#158)
fix: handle errors on pasv/port commands
2019-03-08 17:40:48 +00:00
ls
0094614ee3 feat: socket timeout (#154)
* feat: managed new param socketTimeout expressed in ms

* feat: changed README.md

* feat: changed README.md

* feat: minor change

* feat: pull request changes

* feat: timeout value is set on 'options' object
2019-03-04 16:45:27 +00:00
Jean Gregory
0773967ba5 fix: find available port using hostname (#155)
The passive url is passed to getNextPortFactory so in passive mode, the
opening of a listening port fail when the passive url is not the ip of
an interface on the ftp server host machine.
2019-03-02 22:56:09 +00:00
Tyler Stewart
e5820688c4 feat: remove pasv url resolution (#153)
* fix: correctly try additional ports on EADDRINUSE

* feat: remove passive url host resoltion

See: https://github.com/trs/ftp-srv/issues/139

* feat: require pasv url to be specified

* fix(pasv): check for set pasv url

* chore: update scripts

* fix(cli): remove undefined values

* chore: add cli to eslint verify

* chore: run eslint

* chore: update scripts

* feat: add maximum number of retries to port selection

* chore: simplify eslint config

* chore: simplify dev dependencies

* chore: generate contributors

* chore: update readme contributors

* chore: add verify step to release

BREAKING CHANGE: remove ipify.org automatic resolution of ip
2019-02-21 18:50:16 +00:00
Tyler Stewart
a3fa1a2f71 Merge pull request #152 from trs/revert-143-remove-pasv-url-resolution
fix: revert "feat: remove host resolution"
2019-02-21 18:43:28 +00:00
Tyler Stewart
e540822d5b Revert "feat: remove host resolution (#143)"
This reverts commit 36f331d15d.
2019-02-21 11:42:32 -07:00
Tyler Stewart
36f331d15d feat: remove host resolution (#143)
* fix: correctly try additional ports on EADDRINUSE

* feat: remove passive url host resoltion

See: https://github.com/trs/ftp-srv/issues/139

* feat: require pasv url to be specified

* fix(pasv): check for set pasv url

* chore: update scripts

* fix(cli): remove undefined values

* chore: add cli to eslint verify

* chore: run eslint

* chore: update scripts

* feat: add maximum number of retries to port selection

* chore: simplify eslint config

* chore: simplify dev dependencies

* chore: generate contributors

* chore: update readme contributors
2019-02-21 18:39:07 +00:00
Devonte
03ff982959 fix: correct writable checks (#147)
fix: correct writable checks
2019-02-21 18:30:58 +00:00
Sergey Kuvakin
8c8f3922a3 feat: expose a RNTO event (#151)
* feat(commands): expose a RNTO event, updated a readme file

* feat(commands): fixed test for the RNTO

* chore: fixed prepush errors

* fix: turnback spaces into a README.md
2019-02-21 18:04:01 +00:00
zGwit
eca26ee86a fix: close connection on timeout (#145)
* Close connection when timeout

* Add promise processing
2019-01-17 02:44:48 +00:00
David Van Gompel
811db7b1a7 fix: add correct typing for tls option (#141) 2019-01-16 23:55:20 +00:00
Tyler Stewart
ab085a1bca chore: remove npm-run-all
Late to the party, but removes dev dependency `npm-run-all` to avoid malicious package
2018-12-21 15:49:38 -07:00
Robert Kieffer
a5f26480e5 fix(cli): correct --root flag logic (#135)
Fixes #134
2018-12-21 22:44:13 +00:00
Mike Estes
e41b04be46 fix: add pasv_url to typescript definitions (#131) 2018-11-19 09:49:43 -07:00
Tyler Stewart
7acf861a4d fix(cli): correctly setup server, add pasv options (#130) 2018-11-19 09:48:24 -07:00
Tyler Stewart
4801ecc0cc fix: correct timeouts around TLS data connection (#128)
* fix: timeouts when using tls

* fix: correct tls connection

* fix(connector): dont prematurely destroy socket

* fix(passive): set connected if not tls

* refactor: dont return promises on connector end

Since we're not waiting, we don't need to return promises
2018-11-12 03:19:57 +00:00
Qian.Sicheng
8e34e4c71a fix: correct type definitions (#125)
* fix types of server options

* fix usage of server options
2018-11-07 08:29:37 -07:00
Tyler Stewart
0afd578683 chore: update semantic-release (#124) 2018-10-30 01:48:36 +00:00
Tyler Stewart
46b0d52ff2 fix: improve uploading of files 2018-10-30 01:38:59 +00:00
Tyler Stewart
185e473edc chore: update test example 2018-10-30 01:38:59 +00:00
Tyler Stewart
92a323f3dd chore: update fs method links 2018-10-30 01:38:59 +00:00
Tyler Stewart
f67e487306 test: update tests 2018-10-30 01:38:59 +00:00
Tyler Stewart
2716123da7 chore: add Johnnyrook777 to contributors 2018-10-30 01:38:59 +00:00
Tyler Stewart
ef207f60c1 feat: replace tls options with actual tls SecureContext
https://github.com/trs/ftp-srv/pull/108

BREAKING CHANGE: tls options no longer assume file paths are passed for key, ca, and cert. Options are passed directly to `tls.createServer`. See: https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options
2018-10-30 01:38:59 +00:00
Tyler Stewart
4d8cf42ad0 chore: update readme with changed options 2018-10-30 01:38:59 +00:00
Tyler Stewart
50c6b92d12 fix: improve connection close events and handlers (#123)
* (fix) base: set datasocket to null once destroyed (#119)

Ensure new sockets get created when uploading files by setting the datasocket to null after its destroyed

*  fix(stor): use the close event (#118)

The 'end' event is not called when using SSL. Should use the 'close' event

* refactor(list): chained promises with less depth (#117)

* Update list.js

Tidy up of the List function.

Makes the intent of the method clearer, as there are less nested promises

* refactor(list): chained promises with less depth

* refactor: fixup formatting

* test: update test file config
2018-10-25 10:25:52 -06:00
Tyler Stewart
a2103e5a3c fix: command parse ignores flags on RETR and SIZE (#122)
* fix: command parse ignores flags on RETR and SIZE (#121)

fixes #120

* refactor: upper directive before checking flag parse

* test: add test for command option parse

* feat: restrict command flags to single dash, single character

Only parse `-x` style flags.
2018-10-25 10:25:32 -06:00
Mark Wheeldon
2302b749fa fix(connector): gracefully end data socket (#116)
Fixes GnuTLS error -110: The TLS connection was non-properly terminated issue
2018-09-21 13:54:59 -06:00
Tyler Stewart
27b43d702b chore: update scripts 2018-09-02 16:20:48 +00:00
Tyler Stewart
fae003e644 test(passive): correctly close test connections 2018-09-02 16:20:48 +00:00
Tyler Stewart
a51678ae70 feat: start pasv port selection from last checked port (#111)
* Update find-port.js

Reuse the Port Number Generator

* feat(passive): initialize port factory in server

Ensures that each passive connection does not have to start from the beginning of the pasv port range

https://github.com/trs/ftp-srv/pull/110

* fix(connector): ensure data socket is destroyed and resolved

If there was a data socket, this would never resolve

* test: use bluebird promises for tests, fix stubs

* fix(commands): ensure connector is ended before resuming
2018-09-02 16:20:48 +00:00
Tyler Stewart
bc26886a0d fix(fs): wrap fs methods in try
This handles throwing better than `resolve`
2018-09-02 16:20:48 +00:00
Tyler Stewart
c9b4371579 feat: respond with full paths in STOR/RETR (#95)
Emit and reply with the full paths to the files.

BREAKING CHANGE: fs expects an object `{stream, clientPath}` in response to `.read()` and `.write()`.

This is implemented in a backwards compatible way, and works with the old `fs` implementation. But since this may break builds in unintented ways.
2018-09-02 16:20:48 +00:00
Tyler Stewart
95471bdd15 test: update tests with refactors 2018-09-02 16:20:48 +00:00
Tyler Stewart
5a36a6685d refactor(passive): increment passive connection ports
Using a generator, will loop through min and max ports in order

This should be faster and more efficient since it starts after the last valid port meaning it has a higher chance of being valid
2018-09-02 16:20:48 +00:00
Tyler Stewart
90a7419661 feat: change server options
BREAKING CHANGE: some options have moved or been renamed
2018-09-02 16:20:48 +00:00
Tyler Stewart
29cb035f66 chore: migration guide v2 to v3 2018-09-02 16:20:48 +00:00
Tyler Stewart
66fc66ed80 fix(connector): build passive server correctly for tls (#106)
* fix(connector): build passive server correctly for tls

Fixes an issue where passive tls connections would never be fulfilled.

This uses `tls` to create a server if the connection is secure, which allows an ftp client to connect correctly

* test: stop skipping explicit tests
2018-08-08 19:07:06 -06:00
Tyler Stewart
c970a42132 chore(package): update package lock (#107)
Fixes vulnerabilities in packages
2018-08-08 19:04:38 -06:00
Tim Lundqvist
30ae54a952 fix: better typings for EventEmitter inheritance (#105) 2018-07-24 11:46:03 -06:00
Tyler Stewart
91be338ebd fix: expose errors as export 2018-07-22 18:47:23 -06:00
Tyler Stewart
2a5013447c fix: fix event emitter extension typing 2018-07-22 18:44:15 -06:00
alancnet
1f15af0fb6 fix(cli): resolve authentication bug (#94)
cli would reject all logins with `530 Cannot destructure property `password` of 'undefined' or 'null'.` because the credentials object was being indexed with `[object Object]`. Even with that fixed, if the username was not found, it would produce that error.
2018-06-08 01:52:51 +00:00
Tyler Stewart
1cf1f750f4 chore(readme): update contributors 2018-06-02 18:51:27 +00:00
Diego Rodríguez Baquero
442490d713 chore(readme): correct word and improve events (#91) 2018-06-02 18:51:27 +00:00
Tyler Stewart
58b9ba27d9 test(fs): add fs tests 2018-05-31 14:28:10 +00:00
Tyler Stewart
87a2138cb3 fix(fs): improve path resolution 2018-05-31 14:28:10 +00:00
Tyler Stewart
9fd423c745 docs: fix circle ci badge
Links only to master branch
2018-05-25 17:34:25 -06:00
Tyler Stewart
363839ec8f chore: fix markdown newlines 2018-05-25 22:50:01 +00:00
Tyler Stewart
d9fc0c9cac feat: pasv_url option to send to client
This has passive connections to listen on the same hostname as the server.
But allows this to be customized via the `pasv_url` option.

Hostnames are no longer resolved if given `0.0.0.0`, except when being given to the client via `PASV`
2018-05-25 22:50:01 +00:00
Tyler Stewart
b0463d65b6 fix(passive): listen on server hostname 2018-05-25 22:50:01 +00:00
Tyler Stewart
47b2cc0593 chore(readme): add cli documentation 2018-05-18 21:21:43 +00:00
Tyler Stewart
e5f24f991d refactor(cli): update state management, call ftp methods 2018-05-18 21:21:43 +00:00
Edin Mujagic
87f3ae79a1 feat(cli): add cli support for ftp-srv 2018-05-18 21:21:43 +00:00
147 changed files with 19681 additions and 6361 deletions

View File

@@ -1,108 +1,97 @@
version: 2
create-cache-file: &create-cache-file
run:
name: Setup cache
command: echo "$NODE_VERSION" > _cache_node_version
package-json-cache: &package-json-cache
key: npm-install-{{ checksum "_cache_node_version" }}-{{ checksum "package-lock.json" }}
base-build: &base-build
steps:
- checkout
- <<: *create-cache-file
- restore_cache:
<<: *package-json-cache
- run:
name: Install
command: npm install
- save_cache:
<<: *package-json-cache
paths:
- node_modules
- run:
name: Lint
command: npm run verify:js
- run:
name: Test
command: npm run test:unit:once
jobs:
test_node_10:
build:
docker:
- image: circleci/node:10
environment:
- NODE_VERSION: 10
<<: *base-build
- image: &node-image circleci/node:lts
steps:
- checkout
- restore_cache:
keys:
- &npm-cache-key npm-cache-{{ .Branch }}-{{ .Revision }}
- npm-cache-{{ .Branch }}
- npm-cache
- run:
name: Install
command: npm ci
- persist_to_workspace:
root: .
paths:
- node_modules
- save_cache:
key: *npm-cache-key
paths:
- ~/.npm/_cacache
test_node_8:
lint:
docker:
- image: circleci/node:8
environment:
- NODE_VERSION: 8
<<: *base-build
- image: *node-image
steps:
- checkout
- attach_workspace:
at: .
- run:
name: Lint
command: npm run verify
test_node_6:
test:
docker:
- image: circleci/node:6
environment:
- NODE_VERSION: 6
<<: *base-build
- image: *node-image
steps:
- checkout
- attach_workspace:
at: .
- deploy:
name: Test
command: |
npm run test
release_dry_run:
docker:
- image: *node-image
steps:
- checkout
- attach_workspace:
at: .
- deploy:
name: Dry Release
command: |
npm run semantic-release -- --dry-run
release:
docker:
- image: circleci/node:8
environment:
- NODE_VERSION: 8
- image: *node-image
steps:
- checkout
- <<: *create-cache-file
- restore_cache:
<<: *package-json-cache
- run:
name: Update NPM
command: |
npm install npm@5
npm install semantic-release@11
- attach_workspace:
at: .
- deploy:
name: Semantic Release
name: Release
command: |
npm run semantic-release || true
npm run semantic-release
workflows:
version: 2
test_and_tag:
publish:
jobs:
- test_node_10:
filters:
branches:
only: master
- test_node_8:
filters:
branches:
only: master
- test_node_6:
- build
- lint:
requires:
- build
- test:
requires:
- build
- release_dry_run:
filters:
branches:
only: master
requires:
- test
- lint
- hold_release:
type: approval
requires:
- release_dry_run
- release:
requires:
- test_node_6
- test_node_8
- test_node_10
build_and_test:
jobs:
- test_node_10:
filters:
branches:
ignore: master
- test_node_8:
filters:
branches:
ignore: master
- test_node_6:
filters:
branches:
ignore: master
- hold_release

View File

@@ -1,9 +0,0 @@
# START_CONFIT_GENERATED_CONTENT
# Common folders to ignore
node_modules/*
bower_components/*
# Config folder (optional - you might want to lint this...)
config/*
# END_CONFIT_GENERATED_CONTENT

5
.gitignore vendored
View File

@@ -1,7 +1,6 @@
test_tmp/
node_modules/
dist/
reports/
npm-debug.log
.nyc_output/
test_tmp/

273
README.md
View File

@@ -1,273 +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/ftp-srv">
<img alt="circleci" src="https://img.shields.io/circleci/project/github/trs/ftp-srv.svg?style=for-the-badge" />
</a>
</p>
---
- [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
- 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('ftp://0.0.0.0:9876', { options ... });
ftpServer.on('login', (data, resolve, reject) => { ... });
...
ftpServer.listen()
.then(() => { ... });
```
## API
### `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"`
#### options
##### `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`
##### `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.
## 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
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
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 occured
`error` error object
### `RETR`
```js
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
on('STOR', (error, fileName) => { ... });
```
Occurs when a file is uploaded.
`error` if successful, will be `null`
`fileName` name of the file that was downloaded
## 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#L29)
Returns a string of the current working directory
__Used in:__ `PWD`
#### [`get(fileName)`](src/fs.js#L33)
Returns a file stat object of file or directory
__Used in:__ `LIST`, `NLST`, `STAT`, `SIZE`, `RNFR`, `MDTM`
#### [`list(path)`](src/fs.js#L39)
Returns array of file and directory stat objects
__Used in:__ `LIST`, `NLST`, `STAT`
#### [`chdir(path)`](src/fs.js#L56)
Returns new directory relative to current directory
__Used in:__ `CWD`, `CDUP`
#### [`mkdir(path)`](src/fs.js#L96)
Returns a path to a newly created directory
__Used in:__ `MKD`
#### [`write(fileName, {append, start})`](src/fs.js#L68)
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#L75)
Returns a readable stream
Options:
`start` if set, specifies the byte offset to read from
__Used in:__ `RETR`
#### [`delete(path)`](src/fs.js#L87)
Delete a file or directory
__Used in:__ `DELE`
#### [`rename(from, to)`](src/fs.js#L102)
Renames a file or directory
__Used in:__ `RNFR`, `RNTO`
#### [`chmod(path)`](src/fs.js#L108)
Modifies 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
See [CONTRIBUTING.md](CONTRIBUTING.md).
<!--[]-->
<!--[RM_LICENSE]-->
## 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

@@ -1,38 +0,0 @@
'use strict';
module.exports = {
types: [
{value: 'feat', name: 'feat: A new feature'},
{value: 'fix', name: 'fix: A bug fix'},
{value: 'docs', name: 'docs: Documentation only changes'},
{value: 'style', name: 'style: Changes that do not affect the meaning of the code\n (white-space, formatting, missing semi-colons, etc)'},
{value: 'refactor', name: 'refactor: A code change that neither fixes a bug nor adds a feature'},
{value: 'perf', name: 'perf: A code change that improves performance'},
{value: 'test', name: 'test: Adding missing tests'},
{value: 'chore', name: 'chore: Changes to the build process or auxiliary tools\n and libraries such as documentation generation'},
{value: 'revert', name: 'revert: Revert to a commit'},
{value: 'WIP', name: 'WIP: Work in progress'}
],
scopes: [],
// it needs to match the value for field type. Eg.: 'fix'
/*
scopeOverrides: {
fix: [
{name: 'merge'},
{name: 'style'},
{name: 'e2eTest'},
{name: 'unitTest'}
]
},
*/
allowCustomScopes: true,
allowBreakingChanges: ['feat', 'fix'],
// Appends the branch name to the footer of the commit. Useful for tracking commits after branches have been merged
appendBranchNameToCommitMessage: false
};

View File

@@ -1,5 +0,0 @@
test/**/*.spec.js
--reporter mocha-multi-reporters
--reporter-options configFile=config/testUnit/reporters.json
--ui bdd
--bail

View File

@@ -1,6 +0,0 @@
{
"reporterEnabled": "spec",
"mochaJunitReporterReporterOptions": {
"mochaFile": "reports/junit.xml"
}
}

View File

@@ -1,162 +0,0 @@
{
"extends": "eslint:recommended",
"env": {
"node": true,
"mocha": true,
"es6": true
},
"plugins": [
"mocha",
"node"
],
"rules": {
"mocha/no-exclusive-tests": 2,
"no-warning-comments": [
1,
{
"terms": ["todo", "fixme", "xxx"],
"location": "start"
},
],
"object-curly-spacing": [
2,
"never"
],
"array-bracket-spacing": [
2,
"never"
],
"brace-style": [
2,
"1tbs"
],
"consistent-return": 0,
"indent": [
"error",
2,
{
"SwitchCase": 1,
"MemberExpression": "off"
}
],
"no-multiple-empty-lines": [
2,
{
"max": 2
}
],
"no-use-before-define": [
2,
"nofunc"
],
"one-var": [
2,
"never"
],
"quote-props": [
2,
"as-needed"
],
"quotes": [
2,
"single"
],
"keyword-spacing": 2,
"space-before-function-paren": [
2,
{
"anonymous": "always",
"named": "never"
}
],
"space-in-parens": [
2,
"never"
],
"strict": [
2,
"global"
],
"curly": [
2,
"multi-line"
],
"eol-last": 2,
"key-spacing": [
2,
{
"beforeColon": false,
"afterColon": true
}
],
"no-eval": 2,
"no-with": 2,
"space-infix-ops": 2,
"dot-notation": [
2,
{
"allowKeywords": true
}
],
"eqeqeq": 2,
"no-alert": 2,
"no-caller": 2,
"no-extend-native": 2,
"no-extra-bind": 2,
"no-implied-eval": 2,
"no-iterator": 2,
"no-label-var": 2,
"no-labels": 2,
"no-lone-blocks": 2,
"no-loop-func": 2,
"no-multi-spaces": 2,
"no-multi-str": 2,
"no-native-reassign": 2,
"no-new": 2,
"no-new-func": 2,
"no-new-wrappers": 2,
"no-octal-escape": 2,
"no-proto": 2,
"no-return-assign": 2,
"no-script-url": 2,
"no-sequences": 2,
"no-unused-expressions": 2,
"yoda": 2,
"no-shadow": 2,
"no-shadow-restricted-names": 2,
"no-undef-init": 2,
"no-console": 1,
"camelcase": [
0,
{
"properties": "never"
}
],
"comma-spacing": 2,
"comma-dangle": 1,
"new-cap": 2,
"new-parens": 2,
"arrow-parens": [2, "as-needed"],
"no-array-constructor": 2,
"array-callback-return": 1,
"no-extra-parens": 2,
"no-new-object": 2,
"no-spaced-func": 2,
"no-trailing-spaces": 2,
"no-underscore-dangle": 0,
"no-fallthrough": 0,
"semi": 2,
"semi-spacing": [
2,
{
"before": false,
"after": true
}
]
},
"parserOptions": {
"emcaVersion": 6,
"sourceType": "module",
"impliedStrict": true
}
}

15
example/index.ts Normal file
View File

@@ -0,0 +1,15 @@
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();

123
ftp-srv.d.ts vendored
View File

@@ -1,123 +0,0 @@
import * as tls from 'tls'
import { Stats } from 'fs'
import { EventEmitter } from 'events';
export class FileSystem {
readonly connection: FtpConnection;
readonly root: string;
readonly cwd: string;
constructor(connection: FtpConnection, {root, cwd}?: {
root: any;
cwd: any;
});
currentDirectory(): string;
get(fileName: string): Promise<any>;
list(path?: string): Promise<any>;
chdir(path?: string): Promise<string>;
write(fileName: string, {append, start}?: {
append?: boolean;
start?: any;
}): any;
read(fileName: string, {start}?: {
start?: any;
}): Promise<any>;
delete(path: string): Promise<any>;
mkdir(path: string): Promise<any>;
rename(from: string, to: string): Promise<any>;
chmod(path: string, mode: string): Promise<any>;
getUniqueName(): string;
}
export class FtpConnection {
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 | Object, ...letters: Array<any>): Promise<any>
}
export interface FtpServerOptions {
pasv_range?: number | string,
greeting?: string | string[],
tls?: tls.SecureContext | false,
anonymous?: boolean,
blacklist?: Array<string>,
whitelist?: Array<string>,
file_format?: (stat: Stats) => string | Promise<string> | "ls" | "ep",
log?: any
}
export class FtpServer {
constructor(url: string, options?: FtpServerOptions);
readonly isTLS: boolean;
listen(): any;
emitPromise(action: any, ...data: any[]): Promise<any>;
emit(action: any, ...data: any[]): void;
setupTLS(_tls: boolean): boolean | {
cert: string;
key: string;
ca: string
};
setupGreeting(greet: string): string[];
setupFeaturesMessage(): string;
disconnectClient(id: string): Promise<any>;
close(): any;
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): EventEmitter;
on(event: "client-error", listener: (
data: {
connection: FtpConnection,
context: string,
error: Error,
}
) => void): EventEmitter;
}
export {FtpServer as FtpSrv};
export default FtpServer;

2
old/.eslintignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules/*
bower_components/*

View File

@@ -16,5 +16,10 @@
- Any new fixes are features should include new or updated [tests](/test).
- Commits follow the [AngularJS Git Commit Message Conventions](https://docs.google.com/document/d/1QrDFcIiPjSLDn3EL15IJygNPiHORgU1_OOAqWjiDU5Y/edit), please review and commit accordingly
- Submit your pull requests to the `master` branch, these will normally be merged into a seperate branch for any finally changes before being merged into `master`.
- Submit any bugs or requests to the issues page in Github.
- Submit your pull requests to the `master` branch, these will normally be merged into a separate branch for any finally changes before being merged into `master`.
- Submit any bugs or requests to the issues page in Github.
## Setup
- Clone the repository `git clone`
- Install dependencies `npm install`

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2018 Tyler Stewart
Copyright (c) 2019 Tyler Stewart
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

358
old/README.md Normal file
View File

@@ -0,0 +1,358 @@
<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)

141
old/bin/index.js Executable file
View File

@@ -0,0 +1,141 @@
#!/usr/bin/env node
const yargs = require('yargs');
const path = require('path');
const FtpSrv = require('../src');
const errors = require('../src/errors');
const args = setupYargs();
const state = setupState(args);
startFtpServer(state);
function setupYargs() {
return yargs
.option('credentials', {
alias: 'c',
describe: 'Load user & pass from json file',
normalize: true
})
.option('username', {
describe: 'Blank for anonymous',
type: 'string',
default: ''
})
.option('password', {
describe: 'Password for given username',
type: 'string'
})
.option('root', {
alias: 'r',
describe: 'Default root directory for users',
type: 'string',
normalize: true
})
.option('read-only', {
describe: 'Disable write actions such as upload, delete, etc',
boolean: true,
default: false
})
.option('pasv_url', {
describe: 'URL to provide for passive connections',
type: 'string'
})
.option('pasv_min', {
describe: 'Starting point to use when creating passive connections',
type: 'number',
default: 1024
})
.option('pasv_max', {
describe: 'Ending port to use when creating passive connections',
type: 'number',
default: 65535
})
.parse();
}
function setupState(_args) {
const _state = {};
function setupOptions() {
if (_args._ && _args._.length > 0) {
_state.url = _args._[0];
}
_state.pasv_url = _args.pasv_url;
_state.pasv_min = _args.pasv_min;
_state.pasv_max = _args.pasv_max;
_state.anonymous = _args.username === '';
}
function setupRoot() {
const dirPath = _args.root;
if (dirPath) {
_state.root = dirPath;
} else {
_state.root = process.cwd();
}
}
function setupCredentials() {
_state.credentials = {};
const setCredentials = (username, password, root = null) => {
_state.credentials[username] = {
password,
root
};
};
if (_args.credentials) {
const credentialsFile = path.resolve(_args.credentials);
const credentials = require(credentialsFile);
for (const cred of credentials) {
setCredentials(cred.username, cred.password, cred.root);
}
} else if (_args.username) {
setCredentials(_args.username, _args.password);
}
}
function setupCommandBlacklist() {
if (_args.readOnly) {
_state.blacklist = ['ALLO', 'APPE', 'DELE', 'MKD', 'RMD', 'RNRF', 'RNTO', 'STOR', 'STRU'];
}
}
setupOptions();
setupRoot();
setupCredentials();
setupCommandBlacklist();
return _state;
}
function startFtpServer(_state) {
// Remove null/undefined options so they get set to defaults, below
for (const key in _state) {
if (_state[key] === undefined) delete _state[key];
}
function checkLogin(data, resolve, reject) {
const user = _state.credentials[data.username];
if (_state.anonymous || user && user.password === data.password) {
return resolve({root: user && user.root || _state.root});
}
return reject(new errors.GeneralError('Invalid username or password', 401));
}
const ftpServer = new FtpSrv({
url: _state.url,
pasv_url: _state.pasv_url,
pasv_min: _state.pasv_min,
pasv_max: _state.pasv_max,
anonymous: _state.anonymous,
blacklist: _state.blacklist
});
ftpServer.on('login', checkLogin);
ftpServer.listen();
}

View File

@@ -0,0 +1,44 @@
# Migration Guide - v2 to v3
The `FtpServer` constructor has been changed to only take one object option. Combining the two just made sense.
### From:
```js
const server = new FtpServer('ftp://0.0.0.0:21');
```
### To:
```js
const server = new FtpServer({
url: 'ftp://0.0.0.0:21'
});
```
----
The `pasv_range` option has been changed to separate integer variables: `pasv_min`, `pasv_max`.
### From:
```js
const server = new FtpServer(..., {
pasv_range: '1000-2000'
});
```
### To:
```js
const server = new FtpServer({
pasv_min: 1000,
pasv_max: 2000
})
```
----
The default passive port range has been changed to `1024` - `65535`
----

127
old/ftp-srv.d.ts vendored Normal file
View File

@@ -0,0 +1,127 @@
import * as tls from 'tls'
import { Stats } from 'fs'
import { EventEmitter } from 'events';
export class FileSystem {
readonly connection: FtpConnection;
readonly root: string;
readonly cwd: string;
constructor(connection: FtpConnection, {root, cwd}?: {
root: any;
cwd: any;
});
currentDirectory(): string;
get(fileName: string): Promise<any>;
list(path?: string): Promise<any>;
chdir(path?: string): Promise<string>;
write(fileName: string, {append, start}?: {
append?: boolean;
start?: any;
}): any;
read(fileName: string, {start}?: {
start?: any;
}): Promise<any>;
delete(path: string): Promise<any>;
mkdir(path: string): Promise<any>;
rename(from: string, to: string): Promise<any>;
chmod(path: string, mode: string): Promise<any>;
getUniqueName(): string;
}
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
close (code: number, message: number): Promise<any>
login (username: string, password: string): Promise<any>
reply (options: number | Record<string, any>, ...letters: 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;
}
export class FtpServer extends EventEmitter {
constructor(options?: FtpServerOptions);
readonly isTLS: boolean;
listen(): any;
emitPromise(action: any, ...data: any[]): Promise<any>;
// emit is exported from super class
setupTLS(_tls: boolean): boolean | {
cert: string;
key: string;
ca: string;
};
setupGreeting(greet: string): string[];
setupFeaturesMessage(): string;
disconnectClient(id: string): Promise<any>;
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[];
}) => void,
reject: (err?: Error) => void
) => void): this;
on(event: "client-error", listener: (
data: {
connection: FtpConnection;
context: string;
error: Error;
}
) => void): this;
}
export {FtpServer as FtpSrv};
export default FtpServer;

View File

@@ -1,6 +1,8 @@
const FtpSrv = require('./src');
const FileSystem = require('./src/fs');
const errors = require('./src/errors');
module.exports = FtpSrv;
module.exports.FtpSrv = FtpSrv;
module.exports.FileSystem = FileSystem;
module.exports.ftpErrors = errors;

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,27 @@
const {get} = require('https');
get('https://api.github.com/repos/trs/ftp-srv/contributors', {
headers: {
'User-Agent': 'Chrome'
}
}, (res) => {
let response = '';
res.on('data', (data) => {
response += data;
});
res.on('end', () => {
const contributors = JSON.parse(response)
.filter((contributor) => contributor.type === 'User');
for (const contributor of contributors) {
const url = contributor.html_url;
const username = contributor.login;
const markdown = `- [${username}](${url})\n`;
process.stdout.write(markdown);
}
});
}).on('error', (err) => {
process.stderr.write(err);
});

9976
old/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

91
old/package.json Normal file
View File

@@ -0,0 +1,91 @@
{
"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

@@ -3,25 +3,30 @@ const Promise = require('bluebird');
const REGISTRY = require('./registry');
const CMD_FLAG_REGEX = new RegExp(/^-(\w{1})$/);
class FtpCommands {
constructor(connection) {
this.connection = connection;
this.previousCommand = {};
this.blacklist = _.get(this.connection, 'server.options.blacklist', []).map(cmd => _.upperCase(cmd));
this.whitelist = _.get(this.connection, 'server.options.whitelist', []).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 strippedMessage = message.replace(/"/g, '');
const [directive, ...args] = strippedMessage.split(' ');
let [directive, ...args] = strippedMessage.split(' ');
directive = _.chain(directive).trim().toUpper().value();
const parseCommandFlags = !['RETR', 'SIZE', 'STOR'].includes(directive);
const params = args.reduce(({arg, flags}, param) => {
if (/^-{1,2}[a-zA-Z0-9_]+/.test(param)) flags.push(param);
if (parseCommandFlags && CMD_FLAG_REGEX.test(param)) flags.push(param);
else arg.push(param);
return {arg, flags};
}, {arg: [], flags: []});
const command = {
directive: _.chain(directive).trim().toUpper().value(),
directive,
arg: params.arg.length ? params.arg.join(' ') : null,
flags: params.flags,
raw: message

View File

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

View File

@@ -20,17 +20,17 @@ module.exports = {
};
function handleTLS() {
if (!this.server._tls) return this.reply(502);
if (!this.server.options.tls) return this.reply(502);
if (this.secure) return this.reply(202);
return this.reply(234)
.then(() => {
const secureContext = tls.createSecureContext(this.server._tls);
const secureContext = tls.createSecureContext(this.server.options.tls);
const secureSocket = new tls.TLSSocket(this.commandSocket, {
isServer: true,
secureContext
});
['data', 'timeout', 'end', 'close', 'drain', 'error'].forEach(event => {
['data', 'timeout', 'end', 'close', 'drain', 'error'].forEach((event) => {
function forwardEvent() {
this.emit.apply(this, arguments);
}

View File

@@ -7,12 +7,12 @@ module.exports = {
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 Promise.resolve(this.fs.chdir(command.arg))
.then(cwd => {
return Promise.try(() => this.fs.chdir(command.arg))
.then((cwd) => {
const path = cwd ? `"${escapePath(cwd)}"` : undefined;
return this.reply(250, path);
})
.catch(err => {
.catch((err) => {
log.error(err);
return this.reply(550, err.message);
});

View File

@@ -6,11 +6,11 @@ module.exports = {
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 Promise.resolve(this.fs.delete(command.arg))
return Promise.try(() => this.fs.delete(command.arg))
.then(() => {
return this.reply(250);
})
.catch(err => {
.catch((err) => {
log.error(err);
return this.reply(550, err.message);
});

View File

@@ -5,7 +5,7 @@ module.exports = {
handler: function () {
this.connector = new PassiveConnector(this);
return this.connector.setupServer()
.then(server => {
.then((server) => {
const {port} = server.address();
return this.reply(229, `EPSV OK (|||${port}|)`);

View File

@@ -11,7 +11,7 @@ module.exports = {
return feats;
}, ['UTF8'])
.sort()
.map(feat => ({
.map((feat) => ({
message: ` ${feat}`,
raw: true
}));

View File

@@ -12,7 +12,7 @@ module.exports = {
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'));
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

@@ -16,33 +16,33 @@ module.exports = {
const path = command.arg || '.';
return this.connector.waitForConnection()
.tap(() => this.commandSocket.pause())
.then(() => Promise.resolve(this.fs.get(path)))
.then(stat => stat.isDirectory() ? Promise.resolve(this.fs.list(path)) : [stat])
.then(files => {
const getFileMessage = file => {
.then(() => Promise.try(() => this.fs.get(path)))
.then((stat) => stat.isDirectory() ? Promise.try(() => this.fs.list(path)) : [stat])
.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 => {
return Promise.try(() => files.map((file) => {
const message = getFileMessage(file);
return {
raw: true,
message,
socket: this.connector.socket
};
});
return this.reply(150)
.then(() => {
if (fileList.length) return this.reply({}, ...fileList);
});
}));
})
.then(() => this.reply(226))
.catch(Promise.TimeoutError, err => {
.tap(() => this.reply(150))
.then((fileList) => {
if (fileList.length) return this.reply({}, ...fileList);
})
.tap(() => this.reply(226))
.catch(Promise.TimeoutError, (err) => {
log.error(err);
return this.reply(425, 'No connection established');
})
.catch(err => {
.catch((err) => {
log.error(err);
return this.reply(451, err.message || 'No directory');
})

View File

@@ -7,12 +7,12 @@ module.exports = {
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 Promise.resolve(this.fs.get(command.arg))
.then(fileStat => {
return Promise.try(() => this.fs.get(command.arg))
.then((fileStat) => {
const modificationTime = moment.utc(fileStat.mtime).format('YYYYMMDDHHmmss.SSS');
return this.reply(213, modificationTime);
})
.catch(err => {
.catch((err) => {
log.error(err);
return this.reply(550, err.message);
});

View File

@@ -7,12 +7,12 @@ module.exports = {
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 Promise.resolve(this.fs.mkdir(command.arg))
.then(dir => {
return Promise.try(() => this.fs.mkdir(command.arg))
.then((dir) => {
const path = dir ? `"${escapePath(dir)}"` : undefined;
return this.reply(257, path);
})
.catch(err => {
.catch((err) => {
log.error(err);
return this.reply(550, err.message);
});

View File

@@ -12,7 +12,7 @@ module.exports = {
.then(() => {
return this.reply(230);
})
.catch(err => {
.catch((err) => {
log.error(err);
return this.reply(530, err.message || 'Authentication failed');
});

View File

@@ -2,17 +2,25 @@ const PassiveConnector = require('../../connector/passive');
module.exports = {
directive: 'PASV',
handler: function () {
handler: function ({log} = {}) {
if (!this.server.options.pasv_url) {
return this.reply(502);
}
this.connector = new PassiveConnector(this);
return this.connector.setupServer()
.then(server => {
const address = this.server.url.hostname;
.then((server) => {
const address = this.server.options.pasv_url;
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})`);
})
.catch((err) => {
log.error(err);
return this.reply(425);
});
},
syntax: '{{cmd}}',

View File

@@ -3,18 +3,22 @@ const ActiveConnector = require('../../connector/active');
module.exports = {
directive: 'PORT',
handler: function ({command} = {}) {
handler: function ({log, 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 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));
.then(() => this.reply(200))
.catch((err) => {
log.error(err);
return this.reply(425);
});
},
syntax: '{{cmd}} <x>,<x>,<x>,<x>,<y>,<y>',
description: 'Specifies an address and port to which the server should connect'

View File

@@ -7,12 +7,12 @@ module.exports = {
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 Promise.resolve(this.fs.currentDirectory())
.then(cwd => {
return Promise.try(() => this.fs.currentDirectory())
.then((cwd) => {
const path = cwd ? `"${escapePath(cwd)}"` : undefined;
return this.reply(257, path);
})
.catch(err => {
.catch((err) => {
log.error(err);
return this.reply(550, err.message);
});

View File

@@ -10,15 +10,22 @@ module.exports = {
return this.connector.waitForConnection()
.tap(() => this.commandSocket.pause())
.then(() => Promise.resolve(this.fs.read(filePath, {start: this.restByteCount})))
.then(stream => {
const destroyConnection = (connection, reject) => err => {
.then(() => Promise.try(() => this.fs.read(filePath, {start: this.restByteCount})))
.then((fsResponse) => {
let {stream, clientPath} = fsResponse;
if (!stream && !clientPath) {
stream = fsResponse;
clientPath = filePath;
}
const serverPath = stream.path || filePath;
const destroyConnection = (connection, reject) => (err) => {
if (connection) connection.destroy(err);
reject(err);
};
const eventsPromise = new Promise((resolve, reject) => {
stream.on('data', data => {
stream.on('data', (data) => {
if (stream) stream.pause();
if (this.connector.socket) {
this.connector.socket.write(data, this.transferType, () => stream && stream.resume());
@@ -34,15 +41,15 @@ module.exports = {
return this.reply(150).then(() => stream.resume() && this.connector.socket.resume())
.then(() => eventsPromise)
.tap(() => this.emit('RETR', null, filePath))
.tap(() => this.emit('RETR', null, serverPath))
.then(() => this.reply(226, clientPath))
.finally(() => stream.destroy && stream.destroy());
})
.then(() => this.reply(226))
.catch(Promise.TimeoutError, err => {
.catch(Promise.TimeoutError, (err) => {
log.error(err);
return this.reply(425, 'No connection established');
})
.catch(err => {
.catch((err) => {
log.error(err);
this.emit('RETR', err);
return this.reply(551, err.message);

View File

@@ -7,12 +7,12 @@ module.exports = {
if (!this.fs.get) return this.reply(402, 'Not supported by file system');
const fileName = command.arg;
return Promise.resolve(this.fs.get(fileName))
return Promise.try(() => this.fs.get(fileName))
.then(() => {
this.renameFrom = fileName;
return this.reply(350);
})
.catch(err => {
.catch((err) => {
log.error(err);
return this.reply(550, err.message);
});

View File

@@ -11,12 +11,14 @@ module.exports = {
const from = this.renameFrom;
const to = command.arg;
return Promise.resolve(this.fs.rename(from, to))
return Promise.try(() => this.fs.rename(from, to))
.then(() => {
return this.reply(250);
})
.catch(err => {
.tap(() => this.emit('RNTO', null, to))
.catch((err) => {
log.error(err);
this.emit('RNTO', err);
return this.reply(550, err.message);
})
.finally(() => {

View File

@@ -6,11 +6,11 @@ module.exports = function ({log, command} = {}) {
const [mode, ...fileNameParts] = command.arg.split(' ');
const fileName = fileNameParts.join(' ');
return Promise.resolve(this.fs.chmod(fileName, parseInt(mode, 8)))
return Promise.try(() => this.fs.chmod(fileName, parseInt(mode, 8)))
.then(() => {
return this.reply(200);
})
.catch(err => {
.catch((err) => {
log.error(err);
return this.reply(500);
});

View File

@@ -6,11 +6,11 @@ module.exports = {
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 Promise.resolve(this.fs.get(command.arg))
.then(fileStat => {
return Promise.try(() => this.fs.get(command.arg))
.then((fileStat) => {
return this.reply(213, {message: fileStat.size});
})
.catch(err => {
.catch((err) => {
log.error(err);
return this.reply(550, err.message);
});

View File

@@ -11,28 +11,28 @@ module.exports = {
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 Promise.resolve(this.fs.get(path))
.then(stat => {
return Promise.try(() => this.fs.get(path))
.then((stat) => {
if (stat.isDirectory()) {
if (!this.fs.list) return this.reply(402, 'Not supported by file system');
return Promise.resolve(this.fs.list(path))
.then(stats => [213, stats]);
return Promise.try(() => this.fs.list(path))
.then((stats) => [213, stats]);
}
return [212, [stat]];
})
.then(([code, fileStats]) => {
return Promise.map(fileStats, file => {
return Promise.map(fileStats, (file) => {
const message = getFileStat(file, _.get(this, 'server.options.file_format', 'ls'));
return {
raw: true,
message
};
})
.then(messages => [code, messages]);
.then((messages) => [code, messages]);
})
.then(([code, messages]) => this.reply(code, 'Status begin', ...messages, 'Status end'))
.catch(err => {
.catch((err) => {
log.error(err);
return this.reply(450, err.message);
});

View File

@@ -11,10 +11,20 @@ module.exports = {
return this.connector.waitForConnection()
.tap(() => this.commandSocket.pause())
.then(() => Promise.resolve(this.fs.write(fileName, {append, start: this.restByteCount})))
.then(stream => {
const destroyConnection = (connection, reject) => err => {
if (connection) connection.destroy(err);
.then(() => Promise.try(() => this.fs.write(fileName, {append, start: this.restByteCount})))
.then((fsResponse) => {
let {stream, clientPath} = fsResponse;
if (!stream && !clientPath) {
stream = fsResponse;
clientPath = fileName;
}
const serverPath = stream.path || fileName;
const destroyConnection = (connection, reject) => (err) => {
if (connection) {
if (connection.writable) connection.end();
connection.destroy(err);
}
reject(err);
};
@@ -24,9 +34,9 @@ module.exports = {
});
const socketPromise = new Promise((resolve, reject) => {
this.connector.socket.on('data', data => {
this.connector.socket.on('data', (data) => {
if (this.connector.socket) this.connector.socket.pause();
if (stream) {
if (stream && stream.writable) {
stream.write(data, this.transferType, () => this.connector.socket && this.connector.socket.resume());
}
});
@@ -41,16 +51,16 @@ module.exports = {
this.restByteCount = 0;
return this.reply(150).then(() => this.connector.socket.resume())
.then(() => Promise.join(streamPromise, socketPromise))
.tap(() => this.emit('STOR', null, fileName))
.then(() => Promise.all([streamPromise, socketPromise]))
.tap(() => this.emit('STOR', null, serverPath))
.then(() => this.reply(226, clientPath))
.finally(() => stream.destroy && stream.destroy());
})
.then(() => this.reply(226, fileName))
.catch(Promise.TimeoutError, err => {
.catch(Promise.TimeoutError, (err) => {
log.error(err);
return this.reply(425, 'No connection established');
})
.catch(err => {
.catch((err) => {
log.error(err);
this.emit('STOR', err);
return this.reply(550, err.message);

View File

@@ -8,12 +8,10 @@ module.exports = {
if (!this.fs.get || !this.fs.getUniqueName) return this.reply(402, 'Not supported by file system');
const fileName = args.command.arg;
return Promise.try(() => {
return Promise.resolve(this.fs.get(fileName))
.then(() => Promise.resolve(this.fs.getUniqueName()))
.catch(() => Promise.resolve(fileName));
})
.then(name => {
return Promise.try(() => this.fs.get(fileName))
.then(() => Promise.try(() => this.fs.getUniqueName()))
.catch(() => fileName)
.then((name) => {
args.command.arg = name;
return stor.call(this, args);
});

View File

@@ -13,7 +13,7 @@ module.exports = {
.then(() => {
return this.reply(230);
})
.catch(err => {
.catch((err) => {
log.error(err);
return this.reply(530, err.message || 'Authentication failed');
});

View File

@@ -43,7 +43,7 @@ const commands = [
const registry = commands.reduce((result, cmd) => {
const aliases = Array.isArray(cmd.directive) ? cmd.directive : [cmd.directive];
aliases.forEach(alias => result[alias] = cmd);
aliases.forEach((alias) => result[alias] = cmd);
return result;
}, {});

View File

@@ -25,12 +25,15 @@ class FtpConnection extends EventEmitter {
this.connector = new BaseConnector(this);
this.commandSocket = options.socket;
this.commandSocket.on('error', err => {
this.commandSocket.on('error', (err) => {
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('timeout', () => {
this.log.trace('Client timeout');
this.close().catch((e) => this.log.trace(e, 'Client close error'));
});
this.commandSocket.on('close', () => {
if (this.connector) this.connector.end();
if (this.commandSocket && !this.commandSocket.destroyed) this.commandSocket.destroy();
@@ -41,7 +44,7 @@ class FtpConnection extends EventEmitter {
_handleData(data) {
const messages = _.compact(data.toString(this.encoding).split('\r\n'));
this.log.trace(messages);
return Promise.mapSeries(messages, message => this.commands.handle(message));
return Promise.mapSeries(messages, (message) => this.commands.handle(message));
}
get ip() {
@@ -68,7 +71,7 @@ class FtpConnection extends EventEmitter {
close(code = 421, message = 'Closing connection') {
return Promise.resolve(code)
.then(_code => _code && this.reply(_code, message))
.then((_code) => _code && this.reply(_code, message))
.then(() => this.commandSocket && this.commandSocket.end());
}
@@ -96,7 +99,7 @@ class FtpConnection extends EventEmitter {
if (!letters.length) letters = [{}];
return Promise.map(letters, (promise, index) => {
return Promise.resolve(promise)
.then(letter => {
.then((letter) => {
if (!letter) letter = {};
else if (typeof letter === 'string') letter = {message: letter}; // allow passing in message as first param
@@ -104,7 +107,7 @@ class FtpConnection extends EventEmitter {
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 => {
.then((message) => {
const seperator = !options.hasOwnProperty('eol') ?
letters.length - 1 === index ? ' ' : '-' :
options.eol ? ' ' : '-';
@@ -116,11 +119,11 @@ class FtpConnection extends EventEmitter {
});
};
const processLetter = letter => {
const processLetter = (letter) => {
return new Promise((resolve, reject) => {
if (letter.socket && letter.socket.writable) {
this.log.trace({port: letter.socket.address().port, encoding: letter.encoding, message: letter.message}, 'Reply');
letter.socket.write(letter.message + '\r\n', letter.encoding, err => {
letter.socket.write(letter.message + '\r\n', letter.encoding, (err) => {
if (err) {
this.log.error(err);
return reject(err);
@@ -132,10 +135,10 @@ class FtpConnection extends EventEmitter {
};
return satisfyParameters()
.then(satisfiedLetters => Promise.mapSeries(satisfiedLetters, (letter, index) => {
.then((satisfiedLetters) => Promise.mapSeries(satisfiedLetters, (letter, index) => {
return processLetter(letter, index);
}))
.catch(err => {
.catch((err) => {
this.log.error(err);
});
}

View File

@@ -29,12 +29,12 @@ class Active extends Connector {
.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.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();
if (this.connection.secure) {
const secureContext = tls.createSecureContext(this.server._tls);
const secureContext = tls.createSecureContext(this.server.options.tls);
const secureSocket = new tls.TLSSocket(this.dataSocket, {
isServer: true,
secureContext

View File

@@ -26,22 +26,28 @@ class Connector {
return Promise.reject(new errors.ConnectorError('No connector setup, send PASV or PORT'));
}
end() {
const closeDataSocket = new Promise(resolve => {
if (this.dataSocket) this.dataSocket.end();
else resolve();
});
const closeDataServer = new Promise(resolve => {
if (this.dataServer) this.dataServer.close(() => resolve());
else resolve();
});
return Promise.all([closeDataSocket, closeDataServer])
.then(() => {
closeSocket() {
if (this.dataSocket) {
const socket = this.dataSocket;
this.dataSocket.end(() => socket.destroy());
this.dataSocket = null;
}
}
closeServer() {
if (this.dataServer) {
this.dataServer.close();
this.dataServer = null;
this.type = false;
});
}
}
end() {
this.closeSocket();
this.closeServer();
this.type = false;
this.connection.connector = new Connector(this);
}
}
module.exports = Connector;

View File

@@ -4,7 +4,6 @@ const ip = require('ip');
const Promise = require('bluebird');
const Connector = require('./base');
const findPort = require('../helpers/find-port');
const errors = require('../errors');
class Passive extends Connector {
@@ -13,7 +12,7 @@ class Passive extends Connector {
this.type = 'passive';
}
waitForConnection({timeout = 5000, delay = 250} = {}) {
waitForConnection({timeout = 5000, delay = 50} = {}) {
if (!this.dataServer) return Promise.reject(new errors.ConnectorError('Passive server not setup'));
const checkSocket = () => {
@@ -28,14 +27,10 @@ class Passive extends Connector {
}
setupServer() {
const closeExistingServer = () => this.dataServer ?
new Promise(resolve => this.dataServer.close(() => resolve())) :
Promise.resolve();
return closeExistingServer()
.then(() => this.getPort())
.then(port => {
const connectionHandler = socket => {
this.closeServer();
return this.server.getNextPasvPort()
.then((port) => {
const connectionHandler = (socket) => {
if (!ip.isEqual(this.connection.commandSocket.remoteAddress, socket.remoteAddress)) {
this.log.error({
pasv_connection: socket.remoteAddress,
@@ -48,36 +43,35 @@ class Passive extends Connector {
}
this.log.trace({port, remoteAddress: socket.remoteAddress}, 'Passive connection fulfilled.');
if (this.connection.secure) {
const secureContext = tls.createSecureContext(this.server._tls);
const secureSocket = new tls.TLSSocket(socket, {
isServer: true,
secureContext
});
this.dataSocket = secureSocket;
} else {
this.dataSocket = socket;
}
this.dataSocket.connected = true;
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.on('close', () => {
this.log.trace('Passive connection closed');
this.end();
});
this.dataSocket.on('error', (err) => this.server && this.server.emit('client-error', {connection: this.connection, context: 'dataSocket', error: err}));
if (!this.connection.secure) {
this.dataSocket.connected = true;
}
};
this.dataSocket = null;
this.dataServer = net.createServer({pauseOnConnect: true}, connectionHandler);
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;
this.dataServer.on('error', err => this.server && this.server.emit('client-error', {connection: this.connection, context: 'dataServer', error: err}));
this.dataServer.on('close', () => {
this.dataServer.on('error', (err) => this.server && this.server.emit('client-error', {connection: this.connection, context: 'dataServer', error: err}));
this.dataServer.once('close', () => {
this.log.trace('Passive server closed');
this.dataServer = null;
this.end();
});
if (this.connection.secure) {
this.dataServer.on('secureConnection', (socket) => {
socket.connected = true;
});
}
return new Promise((resolve, reject) => {
this.dataServer.listen(port, err => {
this.dataServer.listen(port, this.server.url.hostname, (err) => {
if (err) reject(err);
else {
this.log.debug({port}, 'Passive connection listening');
@@ -88,15 +82,5 @@ class Passive extends Connector {
});
}
getPort() {
if (this.server.options.pasv_range) {
const [min, max] = typeof this.server.options.pasv_range === 'string' ?
this.server.options.pasv_range.split('-').map(v => v ? parseInt(v) : v) :
[this.server.options.pasv_range];
return findPort(min, max);
}
throw new errors.ConnectorError('Invalid pasv_range');
}
}
module.exports = Passive;

View File

@@ -8,18 +8,31 @@ const errors = require('./errors');
class FileSystem {
constructor(connection, {root, cwd} = {}) {
this.connection = connection;
this.cwd = cwd || nodePath.sep;
this.root = root || process.cwd();
this.cwd = cwd ? nodePath.join(nodePath.sep, cwd) : nodePath.sep;
this._root = nodePath.resolve(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);
get root() {
return this._root;
}
_resolvePath(path = '.') {
const clientPath = (() => {
path = nodePath.normalize(path);
if (nodePath.isAbsolute(path)) {
return nodePath.join(path);
} else {
return nodePath.join(this.cwd, path);
}
})();
const fsPath = (() => {
const resolvedPath = nodePath.resolve(this.root, `.${nodePath.sep}${clientPath}`);
return nodePath.join(resolvedPath);
})();
return {
serverPath,
clientPath,
fsPath
};
}
@@ -31,19 +44,19 @@ class FileSystem {
get(fileName) {
const {fsPath} = this._resolvePath(fileName);
return fs.statAsync(fsPath)
.then(stat => _.set(stat, 'name', fileName));
.then((stat) => _.set(stat, 'name', fileName));
}
list(path = '.') {
const {fsPath} = this._resolvePath(path);
return fs.readdirAsync(fsPath)
.then(fileNames => {
return Promise.map(fileNames, fileName => {
.then((fileNames) => {
return Promise.map(fileNames, (fileName) => {
const filePath = nodePath.join(fsPath, fileName);
return fs.accessAsync(filePath, fs.constants.F_OK)
.then(() => {
return fs.statAsync(filePath)
.then(stat => _.set(stat, 'name', fileName));
.then((stat) => _.set(stat, 'name', fileName));
})
.catch(() => null);
});
@@ -52,41 +65,47 @@ class FileSystem {
}
chdir(path = '.') {
const {fsPath, serverPath} = this._resolvePath(path);
const {fsPath, clientPath} = this._resolvePath(path);
return fs.statAsync(fsPath)
.tap(stat => {
.tap((stat) => {
if (!stat.isDirectory()) throw new errors.FileSystemError('Not a valid directory');
})
.then(() => {
this.cwd = serverPath;
this.cwd = clientPath;
return this.currentDirectory();
});
}
write(fileName, {append = false, start = undefined} = {}) {
const {fsPath} = this._resolvePath(fileName);
const {fsPath, clientPath} = this._resolvePath(fileName);
const stream = fs.createWriteStream(fsPath, {flags: !append ? 'w+' : 'a+', start});
stream.once('error', () => fs.unlinkAsync(fsPath));
stream.once('close', () => stream.end());
return stream;
return {
stream,
clientPath
};
}
read(fileName, {start = undefined} = {}) {
const {fsPath} = this._resolvePath(fileName);
const {fsPath, clientPath} = this._resolvePath(fileName);
return fs.statAsync(fsPath)
.tap(stat => {
.tap((stat) => {
if (stat.isDirectory()) throw new errors.FileSystemError('Cannot read a directory');
})
.then(() => {
const stream = fs.createReadStream(fsPath, {flags: 'r', start});
return stream;
return {
stream,
clientPath
};
});
}
delete(path) {
const {fsPath} = this._resolvePath(path);
return fs.statAsync(fsPath)
.then(stat => {
.then((stat) => {
if (stat.isDirectory()) return fs.rmdirAsync(fsPath);
else return fs.unlinkAsync(fsPath);
});

View File

@@ -0,0 +1,59 @@
const net = require('net');
const errors = require('../errors');
const MAX_PORT = 65535;
const MAX_PORT_CHECK_ATTEMPT = 5;
function* portNumberGenerator(min, max = MAX_PORT) {
let current = min;
while (true) {
if (current > MAX_PORT || current > max) {
current = min;
}
yield current++;
}
}
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) => {
let attemptCount = 0;
const tryGetPort = () => {
attemptCount++;
if (attemptCount > maxAttempts) {
reject(new errors.ConnectorError('Unable to find valid port'));
return;
}
const {value: port} = nextPortNumber.next();
portCheckServer.removeAllListeners();
portCheckServer.once('error', (err) => {
if (['EADDRINUSE'].includes(err.code)) {
tryGetPort();
} else {
reject(err);
}
});
portCheckServer.once('listening', () => {
portCheckServer.close(() => resolve(port));
});
try {
portCheckServer.listen(port, host);
} catch (err) {
reject(err);
}
};
tryGetPort();
});
}
module.exports = {
getNextPortFactory,
portNumberGenerator
};

View File

@@ -4,37 +4,47 @@ const nodeUrl = require('url');
const buyan = require('bunyan');
const net = require('net');
const tls = require('tls');
const fs = require('fs');
const EventEmitter = require('events');
const Connection = require('./connection');
const resolveHost = require('./helpers/resolve-host');
const {getNextPortFactory} = require('./helpers/find-port');
class FtpServer extends EventEmitter {
constructor(url, options = {}) {
constructor(options = {}) {
super();
this.options = _.merge({
this.options = Object.assign({
log: buyan.createLogger({name: 'ftp-srv'}),
url: 'ftp://127.0.0.1:21',
pasv_min: 1024,
pasv_max: 65535,
pasv_url: null,
anonymous: false,
pasv_range: 22,
file_format: 'ls',
blacklist: [],
whitelist: [],
greeting: null,
tls: false
tls: false,
timeout: 0
}, options);
this._greeting = this.setupGreeting(this.options.greeting);
this._features = this.setupFeaturesMessage();
this._tls = this.setupTLS(this.options.tls);
delete this.options.greeting;
delete this.options.tls;
this.connections = {};
this.log = this.options.log;
this.url = nodeUrl.parse(url || 'ftp://127.0.0.1:21');
this.url = nodeUrl.parse(this.options.url);
this.getNextPasvPort = getNextPortFactory(
_.get(this, 'url.hostname'),
_.get(this, 'options.pasv_min'),
_.get(this, 'options.pasv_max'));
const serverConnectionHandler = socket => {
const timeout = Number(this.options.timeout);
this.options.timeout = isNaN(timeout) ? 0 : Number(timeout);
const serverConnectionHandler = (socket) => {
socket.setTimeout(this.options.timeout);
let connection = new Connection(this, {log: this.log, socket});
this.connections[connection.id] = connection;
@@ -45,36 +55,38 @@ class FtpServer extends EventEmitter {
return connection.reply(220, ...greeting, features)
.finally(() => socket.resume());
};
const serverOptions = _.assign(this.isTLS ? this._tls : {}, {pauseOnConnect: true});
const serverOptions = Object.assign({}, this.isTLS ? this.options.tls : {}, {pauseOnConnect: true});
this.server = (this.isTLS ? tls : net).createServer(serverOptions, serverConnectionHandler);
this.server.on('error', err => this.log.error(err, '[Event] error'));
this.server.on('error', (err) => this.log.error(err, '[Event] error'));
process.on('SIGTERM', () => this.quit());
process.on('SIGINT', () => this.quit());
process.on('SIGQUIT', () => this.quit());
const quit = _.debounce(this.quit.bind(this), 100);
process.on('SIGTERM', quit);
process.on('SIGINT', quit);
process.on('SIGQUIT', quit);
}
get isTLS() {
return this.url.protocol === 'ftps:' && this._tls;
return this.url.protocol === 'ftps:' && this.options.tls;
}
listen() {
return resolveHost(this.url.hostname)
.then(hostname => {
this.url.hostname = hostname;
return new Promise((resolve, reject) => {
this.server.once('error', reject);
this.server.listen(this.url.port, this.url.hostname, err => {
this.server.removeListener('error', reject);
if (err) return reject(err);
this.log.info({
protocol: this.url.protocol.replace(/\W/g, ''),
ip: this.url.hostname,
port: this.url.port
}, 'Listening');
resolve('Listening');
});
if (!this.options.pasv_url) {
this.log.warn('Passive URL not set. Passive connections not available.');
}
return new Promise((resolve, reject) => {
this.server.once('error', reject);
this.server.listen(this.url.port, this.url.hostname, (err) => {
this.server.removeListener('error', reject);
if (err) return reject(err);
this.log.info({
protocol: this.url.protocol.replace(/\W/g, ''),
ip: this.url.hostname,
port: this.url.port
}, 'Listening');
resolve('Listening');
});
});
}
@@ -86,15 +98,6 @@ class FtpServer extends EventEmitter {
});
}
setupTLS(_tls) {
if (!_tls) return false;
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');
@@ -113,7 +116,7 @@ class FtpServer extends EventEmitter {
}
disconnectClient(id) {
return new Promise(resolve => {
return new Promise((resolve) => {
const client = this.connections[id];
if (!client) return resolve();
delete this.connections[id];
@@ -135,9 +138,9 @@ class FtpServer extends EventEmitter {
close() {
this.log.info('Server closing...');
this.server.maxConnections = 0;
return Promise.map(Object.keys(this.connections), id => Promise.try(this.disconnectClient.bind(this, id)))
.then(() => new Promise(resolve => {
this.server.close(err => {
return Promise.map(Object.keys(this.connections), (id) => Promise.try(this.disconnectClient.bind(this, id)))
.then(() => new Promise((resolve) => {
this.server.close((err) => {
if (err) this.log.error(err, 'Error closing server');
resolve('Closed');
});

56
old/src/messages.js Normal file
View File

@@ -0,0 +1,56 @@
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

@@ -20,7 +20,7 @@ describe('FtpCommands', function () {
};
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox = sinon.sandbox.create().usingPromise(Promise);
commands = new FtpCommands(mockConnection);
@@ -64,8 +64,8 @@ describe('FtpCommands', function () {
it('two args, with flags: test -l arg1 -A arg2 --zz88A', () => {
const cmd = commands.parse('test -l arg1 -A arg2 --zz88A');
expect(cmd.directive).to.equal('TEST');
expect(cmd.arg).to.equal('arg1 arg2');
expect(cmd.flags).to.deep.equal(['-l', '-A', '--zz88A']);
expect(cmd.arg).to.equal('arg1 arg2 --zz88A');
expect(cmd.flags).to.deep.equal(['-l', '-A']);
expect(cmd.raw).to.equal('test -l arg1 -A arg2 --zz88A');
});
@@ -76,6 +76,13 @@ describe('FtpCommands', function () {
expect(cmd.flags).to.deep.equal(['-l']);
expect(cmd.raw).to.equal('list -l');
});
it('does not check for option flags', () => {
const cmd = commands.parse('retr -test');
expect(cmd.directive).to.equal('RETR');
expect(cmd.arg).to.equal('-test');
expect(cmd.flags).to.deep.equal([]);
});
});
describe('handle', function () {

View File

@@ -3,7 +3,7 @@ const {expect} = require('chai');
const sinon = require('sinon');
const CMD = 'ABOR';
describe(CMD, function () {
describe.skip(CMD, function () {
let sandbox;
const mockClient = {
reply: () => Promise.resolve(),
@@ -15,7 +15,7 @@ describe(CMD, function () {
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.spy(mockClient, 'reply');
sandbox.spy(mockClient.connector, 'waitForConnection');

View File

@@ -11,7 +11,7 @@ describe(CMD, function () {
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.spy(mockClient, 'reply');
});

View File

@@ -8,13 +8,15 @@ describe(CMD, function () {
const mockClient = {
reply: () => Promise.resolve(),
server: {
_tls: {}
options: {
tls: {}
}
}
};
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.spy(mockClient, 'reply');
});

View File

@@ -16,7 +16,7 @@ describe(CMD, function () {
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.spy(mockClient, 'reply');
sandbox.spy(mockClient.fs, 'chdir');

View File

@@ -13,7 +13,7 @@ describe(CMD, function () {
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.stub(mockClient, 'reply').resolves();
sandbox.stub(mockClient.fs, 'chdir').resolves();

View File

@@ -13,7 +13,7 @@ describe(CMD, function () {
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.stub(mockClient, 'reply').resolves();
sandbox.stub(mockClient.fs, 'delete').resolves();

View File

@@ -13,7 +13,7 @@ describe(CMD, function () {
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.spy(mockClient, 'reply');
sandbox.stub(ActiveConnector.prototype, 'setupConnection').resolves();

View File

@@ -13,7 +13,7 @@ describe(CMD, function () {
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.stub(mockClient, 'reply').resolves();
sandbox.stub(PassiveConnector.prototype, 'setupServer').resolves({

View File

@@ -11,7 +11,7 @@ describe(CMD, function () {
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.spy(mockClient, 'reply');
});

View File

@@ -15,7 +15,7 @@ describe(CMD, function () {
},
connector: {
waitForConnection: () => Promise.resolve({}),
end: () => {}
end: () => Promise.resolve({})
},
commandSocket: {
resume: () => {},
@@ -25,7 +25,7 @@ describe(CMD, function () {
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.stub(mockClient, 'reply').resolves();
sandbox.stub(mockClient.fs, 'get').resolves({

View File

@@ -13,7 +13,7 @@ describe(CMD, function () {
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.stub(mockClient, 'reply').resolves();
sandbox.stub(mockClient.fs, 'get').resolves({mtime: 'Mon, 10 Oct 2011 23:24:11 GMT'});

View File

@@ -13,7 +13,7 @@ describe(CMD, function () {
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.stub(mockClient, 'reply').resolves();
sandbox.stub(mockClient.fs, 'mkdir').resolves();

View File

@@ -11,7 +11,7 @@ describe(CMD, function () {
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.spy(mockClient, 'reply');
});

View File

@@ -15,7 +15,7 @@ describe(CMD, function () {
},
connector: {
waitForConnection: () => Promise.resolve({}),
end: () => {}
end: () => Promise.resolve({})
},
commandSocket: {
resume: () => {},
@@ -25,7 +25,7 @@ describe(CMD, function () {
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.stub(mockClient, 'reply').resolves();
sandbox.stub(mockClient.fs, 'get').resolves({

View File

@@ -11,7 +11,7 @@ describe(CMD, function () {
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.spy(mockClient, 'reply');
});

View File

@@ -11,7 +11,7 @@ describe(CMD, function () {
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.spy(mockClient, 'reply');
});

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