Compare commits

..

30 Commits

Author SHA1 Message Date
Tyler Stewart
6a907294b6 fix(fs): wrap fs methods in try
This handles throwing better than `resolve`
2018-07-05 10:58:32 -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
Tyler Stewart
3a7b3d4570 chore(circle): update circle config 2018-05-18 20:53:01 +00:00
Tyler Stewart
dc040eaabd chore: remove unused dev packages/settings 2018-05-18 20:53:01 +00:00
Tyler Stewart
fecec961e1 docs(contributing): update contribution guide
This was an auto generated document, actually make it
2018-05-18 20:53:01 +00:00
Tyler Stewart
5ff677ce42 fix(passive): improve remoteAddress comparisons
Instead of a direct equal comparison, use the `ip` package
2018-05-18 20:53:01 +00:00
Tyler Stewart
cc0f2a5cd3 feat: set hostname to listen on
By default, `net.Server` listens on `0.0.0.0`, which is any available address. This ensures that ftp-srv only listens on the set hostname.
2018-05-18 20:53:01 +00:00
Tim Lundqvist
414433a56e fix: reject on server listen error
See trs/ftp-srv#84
2018-05-18 20:53:01 +00:00
Tyler Stewart
a794f1e5b3 test: fix tests 2018-05-07 11:08:16 -06:00
Mateusz Kucharczyk
1b5d22a3ca fix(typings): updated typescript typings to allow compiling with noImplicitAny option enabled 2018-05-07 10:07:31 -06:00
Jorin Vogel
4205caf7ac fix(rest): small typo 2018-03-29 23:27:33 -06:00
Tyler Stewart
a468d4ffd0 chore(readme): add file events to docs 2018-02-09 19:38:04 -07:00
Tyler Stewart
40b08893ac refactor(connection): only return IP of commandSocket
Since the dataSocket and commandSocket must be on the same IP for a data channel to open, it is redundant to check the dataSocket IP.
2018-02-09 19:38:04 -07:00
Tyler Stewart
8a2454ceea fix(connection): remove listeners on close 2018-02-09 19:38:04 -07:00
Tyler Stewart
0c7cc4fe6e test: new events 2018-02-09 19:38:04 -07:00
Tyler Stewart
6ea6baceb0 feat(server): extend event emitter 2018-02-09 19:38:04 -07:00
Tyler Stewart
b07e0189ee feat(connection): extend event emitter
Allow custom events
2018-02-09 19:38:04 -07:00
Tyler Stewart
ec30a5a4f3 feat(stor): emit connection event on upload success/failure 2018-02-09 19:38:04 -07:00
Tyler Stewart
6020409979 feat(retr): emit connection event on success/failure 2018-02-09 19:38:04 -07:00
40 changed files with 4638 additions and 4451 deletions

View File

@@ -1,87 +1,65 @@
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:
build_node_8:
test_node_10:
docker:
- image: circleci/node:8
steps:
- checkout
- restore_cache:
key: npm-install-node-8-{{ checksum "package.json" }}
- run:
name: Install
command: npm install
- save_cache:
key: npm-install-node-8-{{ checksum "package.json" }}
paths:
- node_modules
build_node_6:
docker:
- image: circleci/node:6
steps:
- checkout
- restore_cache:
key: npm-install-node-6-{{ checksum "package.json" }}
- run:
name: Install
command: npm install
- save_cache:
key: npm-install-node-6-{{ checksum "package.json" }}
paths:
- node_modules
lint:
docker:
- image: circleci/node:8
steps:
- checkout
- restore_cache:
key: npm-install-node-8-{{ checksum "package.json" }}
- run:
name: Lint
command: npm run verify:js
- image: circleci/node:10
environment:
- NODE_VERSION: 10
<<: *base-build
test_node_8:
docker:
- image: circleci/node:8
steps:
- checkout
- restore_cache:
key: npm-install-node-8-{{ checksum "package.json" }}
- run:
name: Test Node 8
command: npm run test:coverage
when: always
- store_test_results:
path: reports
- store_artifacts:
path: reports/coverage
prefix: coverage
environment:
- NODE_VERSION: 8
<<: *base-build
test_node_6:
docker:
- image: circleci/node:6
steps:
- checkout
- restore_cache:
key: npm-install-node-6-{{ checksum "package.json" }}
- run:
name: Test Node 6
command: npm run test:coverage
when: always
- store_test_results:
path: reports
- store_artifacts:
path: reports/coverage
prefix: coverage
environment:
- NODE_VERSION: 6
<<: *base-build
release:
docker:
- image: circleci/node:8
environment:
- NODE_VERSION: 8
steps:
- checkout
- <<: *create-cache-file
- restore_cache:
key: npm-install-node-6-{{ checksum "package.json" }}
<<: *package-json-cache
- run:
name: Update NPM
command: |
@@ -96,45 +74,35 @@ workflows:
version: 2
test_and_tag:
jobs:
- build_node_8:
- test_node_10:
filters:
branches:
only: master
- build_node_6:
filters:
branches:
only: master
- lint:
requires:
- build_node_8
- test_node_6:
requires:
- build_node_6
- test_node_8:
requires:
- build_node_8
filters:
branches:
only: master
- test_node_6:
filters:
branches:
only: master
- release:
requires:
- lint
- test_node_6
- test_node_8
- test_node_10
build_and_test:
jobs:
- build_node_8:
- test_node_10:
filters:
branches:
ignore: master
- build_node_6:
filters:
branches:
ignore: master
- lint:
requires:
- build_node_8
- test_node_6:
requires:
- build_node_6
- test_node_8:
requires:
- build_node_8
filters:
branches:
ignore: master
- test_node_6:
filters:
branches:
ignore: master

24
.nycrc
View File

@@ -1,24 +0,0 @@
{
"check-coverage": true,
"per-file": false,
"lines": 90,
"statements": 90,
"functions": 85,
"branches": 75,
"include": [
"src/**/*.js"
],
"exclude": [
"test/**/*.spec.js"
],
"reporter": [
"lcovonly",
"html",
"text",
"cobertura",
"json"
],
"cache": true,
"all": true,
"report-dir": "./reports/coverage/"
}

View File

@@ -1,211 +1,20 @@
<!--[CN_HEADING]-->
# Contributing
<p align="center">
<a href="https://github.com/trs/ftp-srv">
<img alt="ftp-srv" src="logo.png" width="400px" />
</a>
</p>
Welcome! This document explains how you can contribute to making **ftp-srv** even better.
<h1 align="center">
Contributing Guide
</h1>
## Welcome
<!--[]-->
<!--[CN_GETTING_STARTED]-->
# Getting Started
## Installation
```
git clone <this repo>
npm install -g commitizen
npm install -g semantic-release-cli
npm install
```
<!--[]-->
<!--[RM_DIR_STRUCTURE]-->
## Directory Structure
Code is organised into modules which contain one-or-more components. This a great way to ensure maintainable code by encapsulation of behavior logic. A component is basically a self contained app usually in a single file or a folder with each concern as a file: style, template, specs, e2e, and component class. Here's how it looks:
```
ftp-srv/
├──config/ * configuration files live here (e.g. eslint, verify, testUnit)
├──src/ * source code files should be here
├──dist/ * production-build code should live here
├──reports/ * test reports appear here
├──test/ * unit test specifications live here
├──confit.yml * the project config file generated by 'yo confit'
├──CONTRIBUTING.md * how to contribute to the project
├──README.md * this file
└──package.json * NPM package description file
```
<!--[]-->
<!--[CN_GITFLOW_PROCESS]-->
# GitFlow Development Process
This project uses the [GitHub Flow](https://guides.github.com/introduction/flow/index.html) workflow.
## Create a branch
When you're working on a project, you're going to have a bunch of different features or ideas in progress at any given time some of which are ready to go, and others which are not. Branching exists to help you manage this workflow.
When you create a branch in your project, you're creating an environment where you can try out new ideas. Changes you make on a branch don't affect the `master` branch, so you're free to experiment and commit changes, safe in the knowledge that your branch won't be merged until it's ready to be reviewed by someone you're collaborating with.
###ProTip
Branching is a core concept in Git, and the entire GitHub Flow is based upon it. There's only one rule: anything in the `master` branch is always deployable.
Because of this, it's extremely important that your new branch is created off of `master` when working on a feature or a fix. Your branch name should be descriptive (e.g., `refactor-authentication`, `user-content-cache-key`, `make-retina-avatars`), so that others can see what is being worked on.
## Add commits
Once your branch has been created, it's time to start making changes. Whenever you add, edit, or delete a file, you're making a commit, and adding them to your branch. This process of adding commits keeps track of your progress as you work on a feature branch.
Commits also create a transparent history of your work that others can follow to understand what you've done and why. Each commit has an associated commit message, which is a description explaining why a particular change was made. Furthermore, each commit is considered a separate unit of change. This lets you roll back changes if a bug is found, or if you decide to head in a different direction.
###ProTip
Commit messages are important, especially since Git tracks your changes and then displays them as commits once they're pushed to the server. By writing clear commit messages, you can make it easier for other people to follow along and provide feedback.
## Open a pull request
Pull Requests initiate discussion about your commits. Because they're tightly integrated with the underlying Git repository, anyone can see exactly what changes would be merged if they accept your request.
You can open a Pull Request at any point during the development process: when you have little or no code but want to share some screenshots or general ideas, when you're stuck and need help or advice, or when you're ready for someone to review your work. By using GitHub's @mention system in your Pull Request message, you can ask for feedback from specific people or teams, whether they're down the hall or ten time zones away.
###ProTip
Pull Requests are useful for contributing to open source projects and for managing changes to shared repositories. If you're using a Fork & Pull Model, Pull Requests provide a way to notify project maintainers about the changes you'd like them to consider. If you're using a Shared Repository Model, Pull Requests help start code review and conversation about proposed changes before they're merged into the `master` branch.
## Discuss and review your code
Once a Pull Request has been opened, the person or team reviewing your changes may have questions or comments. Perhaps the coding style doesn't match project guidelines, the change is missing unit tests, or maybe everything looks great and props are in order. Pull Requests are designed to encourage and capture this type of conversation.
You can also continue to push to your branch in light of discussion and feedback about your commits. If someone comments that you forgot to do something or if there is a bug in the code, you can fix it in your branch and push up the change. GitHub will show your new commits and any additional feedback you may receive in the unified Pull Request view.
###ProTip
Pull Request comments are written in Markdown, so you can embed images and emoji, use pre-formatted text blocks, and other lightweight formatting.
## Merge to `master`
Once your PR has passed any the integration tests and received approval to merge, it is time to merge your code into the `master` branch.
Once merged, Pull Requests preserve a record of the historical changes to your code. Because they're searchable, they let anyone go back in time to understand why and how a decision was made.
###ProTip
By incorporating certain keywords into the text of your Pull Request, you can associate issues with code. When your Pull Request is merged, the related issues are also closed. For example, entering the phrase Closes #32 would close issue number 32 in the repository. For more information, check out our help article.
<!--[]-->
<!--[CN_BUILD_TASKS]-->
## Build Tasks
Command | Description
:------ | :----------
<pre>npm run build</pre> | Generate production build into [dist/](dist/) folder
<pre>npm run dev</pre> | Run project in development mode (verify code, and re-verify when code is changed)
<pre>npm start</pre> | Alias for `npm run dev` task
<!--[]-->
<!--[CN_TEST_TASKS]-->
## Test Tasks
Command | Description
:------ | :----------
<pre>npm test</pre> | Alias for `npm run test:unit` task
<pre>npm run test:coverage</pre> | Run instrumented unit tests then verify coverage meets defined thresholds<ul><li>Returns non-zero exit code when coverage does not meet thresholds (as defined in istanbul.js)</li></ul>
<pre>npm run test:unit</pre> | Run unit tests whenever JS source or tests change<ul><li>Uses Mocha</li><li>Code coverage</li><li>Runs continuously (best to run in a separate window)</li></ul>
<pre>npm run test:unit:once</pre> | Run unit tests once<ul><li>Uses Mocha</li><li>Code coverage</li></ul>
<!--[]-->
<!--[CN_VERIFY_TASKS]-->
## Verification (Linting) Tasks
Command | Description
:------ | :----------
<pre>npm run verify</pre> | Verify code style and syntax<ul><li>Verifies source *and test code* aginst customisable rules (unlike Webpack loaders)</li></ul>
<pre>npm run verify:js</pre> | Verify Javascript code style and syntax
<pre>npm run verify:js:fix</pre> | Verify Javascript code style and syntax and fix any errors that can be fixed automatically
<pre>npm run verify:js:watch</pre> | Verify Javascript code style and syntax and watch files for changes
<pre>npm run verify:watch</pre> | Runs verify task whenever JS or CSS code is changed
<!--[]-->
<!--[CN_COMMIT_TASKS]-->
## Commit Tasks
Command | Description
:------ | :----------
<pre>git status</pre> | Lists the current branch and the status of changed files
<pre>git log</pre> | Displays the commit log (press Q to quit viewing)
<pre>git add .</pre> | Stages all modified & untracked files, ready to be committed
<pre>git cz</pre> | Commit changes to local repository using Commitizen.<ul><li>Asks questions about the change to generate a valid conventional commit message</li><li>Can be customised by modifying [config/release/commitMessageConfig.js](config/release/commitMessageConfig.js)</li></ul>
<pre>git push</pre> | Push local repository changes to remote repository
<!--[]-->
<!--[CN_DOCUMENTATION_TASKS]-->
<!--[]-->
<!--[CN_RELEASE_TASKS]-->
## Release Tasks
Command | Description
:------ | :----------
<pre>npm run commitmsg</pre> | Git commit message hook that validates the commit message conforms to your commit message conventions.
<pre>npm run pre-release</pre> | Verify code, run unit tests, check test coverage, build software. This task is designed to be run before
the `semantic-release` task.
<ul><li>Run `semantic-release-cli setup` once you have a remote repository. See https://github.com/semantic-release/cli for details.</li><li>Semantic-release integrates with Travis CI (or similar tools) to generate release notes
for each release (which appears in the "Releases" section in GitHub) and
publishes the package to NPM (when all the tests are successful) with a semantic version number.
</li></ul>
<pre>npm run prepush</pre> | Git pre-push hook that verifies code and checks unit test coverage meet minimum thresholds.
<pre>npm run upload-coverage</pre> | Uploads code-coverage metrics to Coveralls.io<ul><li>Setup - https://coveralls.zendesk.com/hc/en-us/articles/201347419-Coveralls-currently-supports</li><li>Define an environment variable called COVERALLS_REPO_TOKEN in your build environment with the repo token from https://coveralls.io/github/<repo-name>/settings</li><li>In your CI configuration (e.g. `travis.yml`), call `npm run upload-coverage` if the build is successful.</li></ul>
<!--[]-->
<!--[CN_CHANGING_BUILD_TOOL_CONFIG]-->
## Changing build-tool configuration
There are 3 ways you can change the build-tool configuration for this project:
1. BEST: Modify the Confit configuration file ([confit.yml](confit.yml)) by hand, then re-run `yo confit` and tell it to use the existing configuration.
1. OK: Re-run `yo confit` and provide new answers to the questions. **Confit will attempt to overwrite your existing configuration (it will prompt for confirmation), so make sure you have committed your code to a source control (e.g. git) first**.
There are certain configuration settings which can **only** be specified by hand, in which case the first approach is still best.
1. RISKY: Modify the generated build-tool config by hand. Be aware that if you re-run `yo confit` it will attempt to overwrite your changes. So commit your changes to source control first.
Additionally, the **currently-generated** configuration can be extended in the following ways:
- The task configuration is defined in [package.json](package.json). It is possible to change the task definitions to add your own sub-tasks.
You can also use the `pre...` and `post...` script-name prefixes to run commands before (pre) and after (post) the generated commands.
- The `entryPoint.entryPoints` string in [confit.yml](confit.yml) is designed to be edited manually. It represents the starting-point(s) of the application (like a `main()` function). A NodeJS application has one entry point. E.g. `src/index.js`
<!--[]-->
- Thank you for your eagerness to contribute, pull requests are encouraged!
## Guidelines
- 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.

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2017 Tyler Stewart
Copyright (c) 2018 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

322
README.md
View File

@@ -14,72 +14,326 @@
<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="npm" src="https://img.shields.io/circleci/project/github/trs/ftp-srv.svg?style=for-the-badge" />
</a>
<a href="https://coveralls.io/github/trs/ftp-srv?branch=master">
<img alt="npm" src="https://img.shields.io/coveralls/github/trs/ftp-srv.svg?style=for-the-badge" />
<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>
---
## Synopsis
- [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)
`ftp-srv` is an extensible FTP server solution that enables custom file systems per connection allowing the use of virtual file systems. By default, it acts like a regular FTP server. Just include it in your project and start listening.
## Overview
`ftp-srv` is a modern and extensible FTP server designed to be simple yet configurable.
## Features
- Passive and Active transfer support
- [Explicit](https://en.wikipedia.org/wiki/FTPS#Explicit) & [Implicit](https://en.wikipedia.org/wiki/FTPS#Implicit) TLS connections
- 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`
```
$ npm install ftp-srv
```
## Quick Start
## Usage
```js
// Quick start
const FtpSrv = require('ftp-srv');
const ftpServer = new FtpSrv('ftp://0.0.0.0:9876', { options ... });
const ftpServer = new FtpSrv('ftp://0.0.0.0:9876');
ftpServer.on('login', ({connection, username, password}, resolve, reject) => {
// fetch credentials from database, file, or hard coded
database.users.fetch({username, password})
.then(() => {
connection.on('STOR', (err, file) => console.log(`Uploaded file: ${file}`));
resolve({
root: '/'
});
})
.catch(() => reject);
});
ftpServer.on('login', (data, resolve, reject) => { ... });
...
ftpServer.listen()
.then(() => {
console.log('Waiting for connections!');
});
.then(() => { ... });
```
## API
Checkout the [Documentation](/docs).
### `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. `0.0.0.0` will listen on any available hosts for server and passive connections.
__Default:__ `"ftp://127.0.0.1:21"`
#### options
##### `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_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.
## 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
## 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).
<!--[]-->
## Contributors
- [OzairP](https://github.com/OzairP)
- [qchar](https://github.com/qchar)
- [jorinvo](https://github.com/jorinvo)
- [voxsoftware](https://github.com/voxsoftware)
- [pkeuter](https://github.com/pkeuter)
- [TimLuq](https://github.com/TimLuq)
- [edin-mg](https://github.com/edin-m)
- [DiegoRBaquero](https://github.com/DiegoRBaquero)
<!--[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)

115
bin/index.js Executable file
View File

@@ -0,0 +1,115 @@
#!/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'
})
.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
})
.parse();
}
function setupState(_args) {
const _state = {};
function setupOptions() {
if (_args._ && _args._.length > 0) {
_state.url = _args._[0];
}
_state.anonymous = _args.username === '';
}
function setupRoot() {
const dirPath = _args.root;
if (dirPath) {
_state.root = process.cwd();
} else {
_state.root = dirPath;
}
}
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) {
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(_state.url, {
anonymous: _state.anonymous,
blacklist: _state.blacklist
});
ftpServer.on('login', checkLogin);
ftpServer.listen();
}

View File

@@ -1,5 +1,5 @@
{
"reporterEnabled": "mocha-pretty-bunyan-nyan",
"reporterEnabled": "spec",
"mochaJunitReporterReporterOptions": {
"mochaFile": "reports/junit.xml"
}

View File

@@ -1,49 +0,0 @@
generator-confit:
app:
_version: 462ecd915fd9db1aef6a37c2b5ce8b58b80c18ba
buildProfile: Latest
copyrightOwner: Tyler Stewart
license: MIT
projectType: node
publicRepository: true
repositoryType: GitHub
paths:
_version: 780b129e0c7e5cab7e29c4f185bcf78524593a33
config:
configDir: config/
input:
srcDir: src/
unitTestDir: test/
output:
prodDir: dist/
reportDir: reports/
buildJS:
_version: ead8ce4280b07d696aff499a5fca1a933727582f
framework: []
frameworkScripts: []
outputFormat: ES6
sourceFormat: ES6
entryPoint:
_version: 39082c3df887fbc08744dfd088c25465e7a2e3a4
entryPoints:
main:
- ftp-srv.js
testUnit:
_version: 30eee42a88ee42cce4f1ae48fe0cbe81647d189a
testDependencies: []
testFramework: mocha
verify:
_version: 30ae86c5022840a01fc08833e238a82c683fa1c7
jsCodingStandard: none
documentation:
_version: b1658da3278b16d1982212f5e8bc05348af20e0b
generateDocs: false
release:
_version: 47f220593935b502abf17cb34a396f692e453c49
checkCodeCoverage: true
commitMessageFormat: Conventional
useSemantic: true
sampleApp:
_version: 00c0a2c6fc0ed17fcccce2d548d35896121e58ba
createSampleApp: false
zzfinish: {}

View File

@@ -1,2 +0,0 @@
## Documentation

5
ftp-srv.d.ts vendored
View File

@@ -1,5 +1,6 @@
import * as tls from 'tls'
import { Stats } from 'fs'
import { EventEmitter } from 'events';
export class FileSystem {
@@ -107,7 +108,7 @@ export class FtpServer {
whitelist?: Array<string>
}) => void,
reject: (err?: Error) => void
) => void)
) => void): EventEmitter;
on(event: "client-error", listener: (
data: {
@@ -115,7 +116,7 @@ export class FtpServer {
context: string,
error: Error,
}
) => void)
) => void): EventEmitter;
}
export {FtpServer as FtpSrv};

7855
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,35 +14,35 @@
"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-all verify test:coverage build ",
"build": "cross-env NODE_ENV=production npm run clean:prod",
"clean:prod": "rimraf dist/",
"pre-release": "npm run verify",
"commitmsg": "cz-customizable-ghooks",
"dev": "cross-env NODE_ENV=development npm run verify:watch",
"prepush": "npm-run-all verify test:coverage --silent",
"prepush": "npm-run-all verify test:unit:once --silent",
"semantic-release": "semantic-release",
"start": "npm run dev",
"test": "npm run test:unit",
"test:check-coverage": "nyc check-coverage",
"test:coverage": "npm-run-all test:unit:once test:check-coverage --silent",
"test:unit": "cross-env NODE_ENV=test nyc mocha --opts config/testUnit/mocha.opts -w",
"test:unit:once": "cross-env NODE_ENV=test nyc mocha --opts config/testUnit/mocha.opts",
"upload-coverage": "cat reports/coverage/lcov.info | coveralls",
"test:unit": "cross-env NODE_ENV=test mocha --opts config/testUnit/mocha.opts -w",
"test:unit:once": "cross-env NODE_ENV=test mocha --opts config/testUnit/mocha.opts",
"verify": "npm run verify:js --silent",
"verify:js": "eslint -c config/verify/.eslintrc \"src/**/*.js\" \"test/**/*.js\" \"config/**/*.js\" && echo ✅ verify:js success",
"verify:js:fix": "eslint --fix -c config/verify/.eslintrc \"src/**/*.js\" \"test/**/*.js\" \"config/**/*.js\" && echo ✅ verify:js:fix success",
"verify:js:watch": "chokidar 'src/**/*.js' 'test/**/*.js' 'config/**/*.js' -c 'npm run verify:js:fix' --initial --silent",
"verify:watch": "npm run verify:js:watch --silent"
},
"release": {
"verifyConditions": "condition-circle"
},
"config": {
"commitizen": {
"path": "node_modules/cz-customizable"
@@ -54,16 +54,17 @@
"dependencies": {
"bluebird": "^3.5.1",
"bunyan": "^1.8.12",
"lodash": "^4.17.4",
"moment": "^2.19.1",
"uuid": "^3.1.0"
"ip": "^1.1.5",
"lodash": "^4.17.10",
"moment": "^2.22.1",
"uuid": "^3.2.1",
"yargs": "^11.0.0"
},
"devDependencies": {
"@icetee/ftp": "^0.3.15",
"@icetee/ftp": "^1.0.2",
"chai": "^4.0.2",
"chokidar-cli": "1.2.0",
"condition-circle": "^1.6.0",
"coveralls": "2.13.1",
"cross-env": "3.1.4",
"cz-customizable": "5.2.0",
"cz-customizable-ghooks": "1.5.0",
@@ -78,18 +79,13 @@
"mocha": "3.5.0",
"mocha-junit-reporter": "1.13.0",
"mocha-multi-reporters": "1.1.5",
"mocha-pretty-bunyan-nyan": "^1.0.4",
"npm-run-all": "4.0.2",
"nyc": "11.1.0",
"npm-run-all": "^4.1.3",
"rimraf": "2.6.1",
"semantic-release": "^11.0.2",
"sinon": "^2.3.5"
},
"engines": {
"node": ">=6.x",
"npm": ">=3.9.5"
},
"release": {
"verifyConditions": "condition-circle"
"npm": ">=5.x"
}
}

View File

@@ -7,7 +7,7 @@ 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))
return Promise.try(() => this.fs.chdir(command.arg))
.then(cwd => {
const path = cwd ? `"${escapePath(cwd)}"` : undefined;
return this.reply(250, path);

View File

@@ -6,7 +6,7 @@ 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);
})

View File

@@ -16,8 +16,8 @@ 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(() => 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;

View File

@@ -7,7 +7,7 @@ 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))
return Promise.try(() => this.fs.get(command.arg))
.then(fileStat => {
const modificationTime = moment.utc(fileStat.mtime).format('YYYYMMDDHHmmss.SSS');
return this.reply(213, modificationTime);

View File

@@ -7,7 +7,7 @@ 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))
return Promise.try(() => this.fs.mkdir(command.arg))
.then(dir => {
const path = dir ? `"${escapePath(dir)}"` : undefined;
return this.reply(257, path);

View File

@@ -6,7 +6,7 @@ module.exports = {
this.connector = new PassiveConnector(this);
return this.connector.setupServer()
.then(server => {
const address = this.server.url.hostname;
const address = this.server.options.pasv_url;
const {port} = server.address();
const host = address.replace(/\./g, ',');
const portByte1 = port / 256 | 0;

View File

@@ -7,7 +7,7 @@ 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())
return Promise.try(() => this.fs.currentDirectory())
.then(cwd => {
const path = cwd ? `"${escapePath(cwd)}"` : undefined;
return this.reply(257, path);

View File

@@ -9,7 +9,7 @@ module.exports = {
if (isNaN(byteCount) || byteCount < 0) return this.reply(501, 'Byte count must be 0 or greater');
this.restByteCount = byteCount;
return this.reply(350, `Resarting next transfer at ${byteCount}`);
return this.reply(350, `Restarting next transfer at ${byteCount}`);
},
syntax: '{{cmd}} <byte-count>',
description: 'Restart transfer from the specified point. Resets after any STORE or RETRIEVE'

View File

@@ -6,9 +6,11 @@ module.exports = {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.read) return this.reply(402, 'Not supported by file system');
const filePath = command.arg;
return this.connector.waitForConnection()
.tap(() => this.commandSocket.pause())
.then(() => Promise.resolve(this.fs.read(command.arg, {start: this.restByteCount})))
.then(() => Promise.try(() => this.fs.read(filePath, {start: this.restByteCount})))
.then(stream => {
const destroyConnection = (connection, reject) => err => {
if (connection) connection.destroy(err);
@@ -32,6 +34,7 @@ module.exports = {
return this.reply(150).then(() => stream.resume() && this.connector.socket.resume())
.then(() => eventsPromise)
.tap(() => this.emit('RETR', null, filePath))
.finally(() => stream.destroy && stream.destroy());
})
.then(() => this.reply(226))
@@ -41,6 +44,7 @@ module.exports = {
})
.catch(err => {
log.error(err);
this.emit('RETR', err);
return this.reply(551, err.message);
})
.finally(() => {

View File

@@ -7,7 +7,7 @@ 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);

View File

@@ -11,7 +11,7 @@ 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);
})

View File

@@ -6,7 +6,7 @@ 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);
})

View File

@@ -6,7 +6,7 @@ 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))
return Promise.try(() => this.fs.get(command.arg))
.then(fileStat => {
return this.reply(213, {message: fileStat.size});
})

View File

@@ -11,12 +11,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(path))
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))
return Promise.try(() => this.fs.list(path))
.then(stats => [213, stats]);
}
return [212, [stat]];

View File

@@ -11,7 +11,7 @@ module.exports = {
return this.connector.waitForConnection()
.tap(() => this.commandSocket.pause())
.then(() => Promise.resolve(this.fs.write(fileName, {append, start: this.restByteCount})))
.then(() => Promise.try(() => this.fs.write(fileName, {append, start: this.restByteCount})))
.then(stream => {
const destroyConnection = (connection, reject) => err => {
if (connection) connection.destroy(err);
@@ -42,6 +42,7 @@ module.exports = {
return this.reply(150).then(() => this.connector.socket.resume())
.then(() => Promise.join(streamPromise, socketPromise))
.tap(() => this.emit('STOR', null, fileName))
.finally(() => stream.destroy && stream.destroy());
})
.then(() => this.reply(226, fileName))
@@ -51,6 +52,7 @@ module.exports = {
})
.catch(err => {
log.error(err);
this.emit('STOR', err);
return this.reply(550, err.message);
})
.finally(() => {

View File

@@ -8,11 +8,9 @@ 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));
})
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

@@ -1,6 +1,7 @@
const _ = require('lodash');
const uuid = require('uuid');
const Promise = require('bluebird');
const EventEmitter = require('events');
const BaseConnector = require('./connector/base');
const FileSystem = require('./fs');
@@ -8,8 +9,9 @@ const Commands = require('./commands');
const errors = require('./errors');
const DEFAULT_MESSAGE = require('./messages');
class FtpConnection {
class FtpConnection extends EventEmitter {
constructor(server, options) {
super();
this.server = server;
this.id = uuid.v4();
this.log = options.log.child({id: this.id, ip: this.ip});
@@ -32,6 +34,7 @@ class FtpConnection {
this.commandSocket.on('close', () => {
if (this.connector) this.connector.end();
if (this.commandSocket && !this.commandSocket.destroyed) this.commandSocket.destroy();
this.removeAllListeners();
});
}
@@ -43,7 +46,7 @@ class FtpConnection {
get ip() {
try {
return this.dataSocket ? this.dataSocket.remoteAddress : this.commandSocket.remoteAddress;
return this.commandSocket ? this.commandSocket.remoteAddress : undefined;
} catch (ex) {
return null;
}

View File

@@ -1,5 +1,6 @@
const net = require('net');
const tls = require('tls');
const ip = require('ip');
const Promise = require('bluebird');
const Connector = require('./base');
@@ -35,7 +36,7 @@ class Passive extends Connector {
.then(() => this.getPort())
.then(port => {
const connectionHandler = socket => {
if (this.connection.commandSocket.remoteAddress !== socket.remoteAddress) {
if (!ip.isEqual(this.connection.commandSocket.remoteAddress, socket.remoteAddress)) {
this.log.error({
pasv_connection: socket.remoteAddress,
cmd_connection: this.connection.commandSocket.remoteAddress
@@ -76,7 +77,7 @@ class Passive extends Connector {
});
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');

View File

@@ -8,15 +8,28 @@ 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 serverPath = (() => {
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}${serverPath}`);
return nodePath.join(resolvedPath);
})();
return {
serverPath,

View File

@@ -18,7 +18,8 @@ module.exports = function (fileStat, format = 'ls') {
function ls(fileStat) {
const now = moment.utc();
const mtime = moment.utc(new Date(fileStat.mtime));
const dateFormat = now.diff(mtime, 'months') < 6 ? 'MMM DD HH:mm' : 'MMM DD YYYY';
const timeDiff = now.diff(mtime, 'months');
const dateFormat = timeDiff < 6 ? 'MMM DD HH:mm' : 'MMM DD YYYY';
return [
fileStat.mode ? [

View File

@@ -5,16 +5,19 @@ 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');
class FtpServer {
class FtpServer extends EventEmitter {
constructor(url, options = {}) {
super();
this.options = _.merge({
log: buyan.createLogger({name: 'ftp-srv'}),
anonymous: false,
pasv_range: 22,
pasv_url: null,
file_format: 'ls',
blacklist: [],
whitelist: [],
@@ -47,13 +50,12 @@ class FtpServer {
this.server = (this.isTLS ? tls : net).createServer(serverOptions, serverConnectionHandler);
this.server.on('error', err => this.log.error(err, '[Event] error'));
this.on = this.server.on.bind(this.server);
this.once = this.server.once.bind(this.server);
this.listeners = this.server.listeners.bind(this.server);
process.on('SIGTERM', () => this.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() {
@@ -61,11 +63,14 @@ class FtpServer {
}
listen() {
return resolveHost(this.url.hostname)
.then(hostname => {
this.url.hostname = hostname;
return resolveHost(this.options.pasv_url || this.url.hostname)
.then(pasvUrl => {
this.options.pasv_url = pasvUrl;
return new Promise((resolve, reject) => {
this.server.listen(this.url.port, err => {
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, ''),
@@ -81,14 +86,10 @@ class FtpServer {
emitPromise(action, ...data) {
return new Promise((resolve, reject) => {
const params = _.concat(data, [resolve, reject]);
this.server.emit(action, ...params);
this.emit.call(this, action, ...params);
});
}
emit(action, ...data) {
this.server.emit(action, ...data);
}
setupTLS(_tls) {
if (!_tls) return false;
return _.assign({}, _tls, {
@@ -144,7 +145,8 @@ class FtpServer {
if (err) this.log.error(err, 'Error closing server');
resolve('Closed');
});
}));
}))
.then(() => this.removeAllListeners());
}
}

View File

@@ -2,11 +2,13 @@ const Promise = require('bluebird');
const bunyan = require('bunyan');
const {expect} = require('chai');
const sinon = require('sinon');
const EventEmitter = require('events');
const CMD = 'RETR';
describe(CMD, function () {
let sandbox;
let log = bunyan.createLogger({name: CMD});
let emitter;
const mockClient = {
commandSocket: {
pause: () => {},
@@ -29,6 +31,11 @@ describe(CMD, function () {
read: () => {}
};
emitter = new EventEmitter();
mockClient.emit = emitter.emit.bind(emitter);
mockClient.on = emitter.on.bind(emitter);
mockClient.once = emitter.once.bind(emitter);
sandbox.spy(mockClient, 'reply');
});
afterEach(() => sandbox.restore());
@@ -56,6 +63,7 @@ describe(CMD, function () {
return Promise.reject(new Promise.TimeoutError());
});
return cmdFn({log, command: {arg: 'test.txt'}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(425);
@@ -72,4 +80,20 @@ describe(CMD, function () {
expect(mockClient.reply.args[0][0]).to.equal(551);
});
});
it('// unsuccessful | emits error event', () => {
sandbox.stub(mockClient.connector, 'waitForConnection').callsFake(function () {
return Promise.reject(new Error('test'));
});
let errorEmitted = false;
emitter.once('RETR', err => {
errorEmitted = !!err;
});
return cmdFn({log, command: {arg: 'test.txt'}})
.then(() => {
expect(errorEmitted).to.equal(true);
});
});
});

View File

@@ -2,11 +2,13 @@ const Promise = require('bluebird');
const bunyan = require('bunyan');
const {expect} = require('chai');
const sinon = require('sinon');
const EventEmitter = require('events');
const CMD = 'STOR';
describe(CMD, function () {
let sandbox;
let log = bunyan.createLogger({name: CMD});
let emitter;
const mockClient = {
commandSocket: {
pause: () => {},
@@ -29,6 +31,11 @@ describe(CMD, function () {
write: () => {}
};
emitter = new EventEmitter();
mockClient.emit = emitter.emit.bind(emitter);
mockClient.on = emitter.on.bind(emitter);
mockClient.once = emitter.once.bind(emitter);
sandbox.spy(mockClient, 'reply');
});
afterEach(() => sandbox.restore());
@@ -72,4 +79,20 @@ describe(CMD, function () {
expect(mockClient.reply.args[0][0]).to.equal(550);
});
});
it('// unsuccessful | emits error event', () => {
sandbox.stub(mockClient.connector, 'waitForConnection').callsFake(function () {
return Promise.reject(new Error('test'));
});
let errorEmitted = false;
emitter.once('STOR', err => {
errorEmitted = !!err;
});
return cmdFn({log, command: {arg: 'test.txt'}})
.then(() => {
expect(errorEmitted).to.equal(true);
});
});
});

View File

@@ -16,7 +16,7 @@ describe('Connector - Passive //', function () {
encoding: 'utf8',
log: bunyan.createLogger({name: 'passive-test'}),
commandSocket: {},
server: {options: {}}
server: {options: {}, url: {}}
};
let sandbox;
@@ -64,7 +64,7 @@ describe('Connector - Passive //', function () {
return passive.setupServer()
.then(shouldNotResolve)
.catch(err => {
expect(err.name).to.equal('RangeError');
expect(err).to.be.instanceOf(RangeError);
});
});

83
test/fs.spec.js Normal file
View File

@@ -0,0 +1,83 @@
const {expect} = require('chai');
const nodePath = require('path');
const Promise = require('bluebird');
const FileSystem = require('../src/fs');
const errors = require('../src/errors');
describe('FileSystem', function () {
let fs;
before(function () {
fs = new FileSystem({}, {
root: '/tmp/ftp-srv',
cwd: 'file/1/2/3'
});
});
describe('extend', function () {
class FileSystemOV extends FileSystem {
chdir() {
throw new errors.FileSystemError('Not a valid directory');
}
}
let ovFs;
before(function () {
ovFs = new FileSystemOV({});
});
it('handles error', function () {
return Promise.try(() => ovFs.chdir())
.catch(err => {
expect(err).to.be.instanceof(errors.FileSystemError);
});
});
});
describe('#_resolvePath', function () {
it('gets correct relative path', function () {
const result = fs._resolvePath();
expect(result).to.be.an('object');
expect(result.serverPath).to.equal(
nodePath.normalize('/file/1/2/3'));
expect(result.fsPath).to.equal(
nodePath.resolve('/tmp/ftp-srv/file/1/2/3'));
});
it('gets correct relative path', function () {
const result = fs._resolvePath('..');
expect(result).to.be.an('object');
expect(result.serverPath).to.equal(
nodePath.normalize('/file/1/2'));
expect(result.fsPath).to.equal(
nodePath.resolve('/tmp/ftp-srv/file/1/2'));
});
it('gets correct absolute path', function () {
const result = fs._resolvePath('/other');
expect(result).to.be.an('object');
expect(result.serverPath).to.equal(
nodePath.normalize('/other'));
expect(result.fsPath).to.equal(
nodePath.resolve('/tmp/ftp-srv/other'));
});
it('cannot escape root', function () {
const result = fs._resolvePath('../../../../../../../../../../..');
expect(result).to.be.an('object');
expect(result.serverPath).to.equal(
nodePath.normalize('/'));
expect(result.fsPath).to.equal(
nodePath.resolve('/tmp/ftp-srv'));
});
it('resolves to file', function () {
const result = fs._resolvePath('/cool/file.txt');
expect(result).to.be.an('object');
expect(result.serverPath).to.equal(
nodePath.normalize('/cool/file.txt'));
expect(result.fsPath).to.equal(
nodePath.resolve('/tmp/ftp-srv/cool/file.txt'));
});
});
});

View File

@@ -1,9 +1,20 @@
const {expect} = require('chai');
const sinon = require('sinon');
const moment = require('moment');
const fileStat = require('../../src/helpers/file-stat');
const errors = require('../../src/errors');
describe('helpers // file-stat', function () {
let sandbox;
before(function () {
sandbox = sinon.sandbox.create();
});
afterEach(function () {
sandbox.restore();
});
const STAT = {
name: 'test1',
dev: 2114,
@@ -44,6 +55,11 @@ describe('helpers // file-stat', function () {
describe('format - ls //', function () {
it('formats correctly', () => {
const momentStub = sandbox.stub(moment, 'utc').callThrough();
momentStub.onFirstCall().callsFake(function () {
return moment.utc(new Date('Sept 10 2016'));
});
const format = fileStat(STAT, 'ls');
expect(format).to.equal('-rwxrwxrwx 1 85 100 527 Oct 10 23:24 test1');
});

View File

@@ -172,12 +172,12 @@ describe('Integration', function () {
const stream = fs.createWriteStream(fsPath, {flags: 'w+'});
stream.on('error', () => fs.existsSync(fsPath) && fs.unlinkSync(fsPath));
stream.on('close', () => stream.end());
setTimeout(() => stream.emit('error', new Error('STOR fail test')));
setImmediate(() => stream.emit('error', new Error('STOR fail test')));
return stream;
});
client.put(buffer, 'fail.txt', err => {
setTimeout(() => {
setImmediate(() => {
const fileExists = fs.existsSync(fsPath);
expect(err).to.exist;
expect(fileExists).to.equal(false);
@@ -189,9 +189,14 @@ describe('Integration', function () {
it('STOR tést.txt', done => {
const buffer = Buffer.from('test text file');
const fsPath = `${clientDirectory}/${name}/tést.txt`;
connection.once('STOR', err => {
expect(err).to.not.exist;
});
client.put(buffer, 'tést.txt', err => {
expect(err).to.not.exist;
setTimeout(() => {
setImmediate(() => {
expect(fs.existsSync(fsPath)).to.equal(true);
fs.readFile(fsPath, (fserr, data) => {
expect(fserr).to.not.exist;
@@ -207,7 +212,7 @@ describe('Integration', function () {
const fsPath = `${clientDirectory}/${name}/tést.txt`;
client.append(buffer, 'tést.txt', err => {
expect(err).to.not.exist;
setTimeout(() => {
setImmediate(() => {
expect(fs.existsSync(fsPath)).to.equal(true);
fs.readFile(fsPath, (fserr, data) => {
expect(fserr).to.not.exist;
@@ -219,6 +224,10 @@ describe('Integration', function () {
});
it('RETR tést.txt', done => {
connection.once('RETR', err => {
expect(err).to.not.exist;
});
client.get('tést.txt', (err, stream) => {
expect(err).to.not.exist;
let text = '';
@@ -376,7 +385,7 @@ describe('Integration', function () {
runFileSystemTests('binary');
});
describe.skip('#EXPLICIT', function () {
describe('#EXPLICIT', function () {
before(() => {
return server.close()
.then(() => startServer('ftp://127.0.0.1:8880', {

View File

@@ -1,5 +0,0 @@
{
"mute":false,
"level":"debug",
"reporter":"spec"
}