Compare commits

..

2 Commits

Author SHA1 Message Date
Tyler Stewart
eb24c48669 WIP: migrate logs to signale 2018-05-20 11:07:25 -06:00
Tyler Stewart
a83c391d58 WIP: update tests 2018-05-20 11:06:09 -06:00
168 changed files with 7243 additions and 20419 deletions

View File

@@ -1,97 +1,108 @@
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:
test_node_10:
docker:
- 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
- image: circleci/node:10
environment:
- NODE_VERSION: 10
<<: *base-build
lint:
test_node_8:
docker:
- image: *node-image
steps:
- checkout
- attach_workspace:
at: .
- run:
name: Lint
command: npm run verify
- image: circleci/node:8
environment:
- NODE_VERSION: 8
<<: *base-build
test:
test_node_6:
docker:
- 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
- image: circleci/node:6
environment:
- NODE_VERSION: 6
<<: *base-build
release:
docker:
- image: *node-image
- image: circleci/node:8
environment:
- NODE_VERSION: 8
steps:
- checkout
- attach_workspace:
at: .
- deploy:
name: Release
- <<: *create-cache-file
- restore_cache:
<<: *package-json-cache
- run:
name: Update NPM
command: |
npm run semantic-release
npm install npm@5
npm install semantic-release@11
- deploy:
name: Semantic Release
command: |
npm run semantic-release || true
workflows:
version: 2
publish:
test_and_tag:
jobs:
- build
- lint:
requires:
- build
- test:
requires:
- build
- release_dry_run:
- test_node_10:
filters:
branches:
only: master
- test_node_8:
filters:
branches:
only: master
- test_node_6:
filters:
branches:
only: master
requires:
- test
- lint
- hold_release:
type: approval
requires:
- release_dry_run
- release:
requires:
- hold_release
- 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

9
.eslintignore Normal file
View File

@@ -0,0 +1,9 @@
# 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,6 +1,7 @@
test_tmp/
node_modules/
dist/
reports/
npm-debug.log
.nyc_output/
test_tmp/

View File

@@ -16,10 +16,5 @@
- 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 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`
- 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) 2019 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

332
README.md
View File

@@ -0,0 +1,332 @@
<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)
- [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('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 [signale logger](https://github.com/klauscfhq/signale) 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
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 occurred
`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).
<!--[]-->
## 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)
<!--[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

@@ -19,8 +19,7 @@ function setupYargs() {
})
.option('username', {
describe: 'Blank for anonymous',
type: 'string',
default: ''
type: 'string'
})
.option('password', {
describe: 'Password for given username',
@@ -37,20 +36,6 @@ function setupYargs() {
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();
}
@@ -61,18 +46,15 @@ function setupState(_args) {
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();
} else {
_state.root = dirPath;
}
}
@@ -80,7 +62,7 @@ function setupState(_args) {
_state.credentials = {};
const setCredentials = (username, password, root = null) => {
_state.credentials[username] = {
_state.credentials[_state.credentials] = {
password,
root
};
@@ -90,7 +72,7 @@ function setupState(_args) {
const credentialsFile = path.resolve(_args.credentials);
const credentials = require(credentialsFile);
for (const cred of credentials) {
for (const cred of Object.entries(credentials)) {
setCredentials(cred.username, cred.password, cred.root);
}
} else if (_args.username) {
@@ -113,25 +95,17 @@ function setupState(_args) {
}
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});
const {password, root} = _state.credentials[data.username];
if (_state.anonymous || password === data.password) {
return resolve({root: 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,
const ftpServer = new FtpSrv(_state.url, {
anonymous: _state.anonymous,
blacklist: _state.blacklist
});

View File

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

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

View File

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

162
config/verify/.eslintrc Normal file
View File

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

View File

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

123
ftp-srv.d.ts vendored Normal file
View File

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

View File

@@ -1,8 +1,6 @@
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

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

View File

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

View File

@@ -1,44 +0,0 @@
# 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
View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,42 +0,0 @@
const _ = require('lodash');
const tls = require('tls');
module.exports = {
directive: 'AUTH',
handler: function ({command} = {}) {
const method = _.upperCase(command.arg);
switch (method) {
case 'TLS': return handleTLS.call(this);
default: return this.reply(504);
}
},
syntax: '{{cmd}} <type>',
description: 'Set authentication mechanism',
flags: {
no_auth: true,
feat: 'AUTH TLS'
}
};
function handleTLS() {
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.options.tls);
const secureSocket = new tls.TLSSocket(this.commandSocket, {
isServer: true,
secureContext
});
['data', 'timeout', 'end', 'close', 'drain', 'error'].forEach((event) => {
function forwardEvent() {
this.emit.apply(this, arguments);
}
secureSocket.on(event, forwardEvent.bind(this.commandSocket, event));
});
this.commandSocket = secureSocket;
this.secure = true;
});
}

View File

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

View File

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

View File

@@ -1,56 +0,0 @@
const _ = require('lodash');
const Promise = require('bluebird');
const getFileStat = require('../../helpers/file-stat');
// http://cr.yp.to/ftp/list.html
// http://cr.yp.to/ftp/list/eplf.html
module.exports = {
directive: 'LIST',
handler: function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.get) return this.reply(402, 'Not supported by file system');
if (!this.fs.list) return this.reply(402, 'Not supported by file system');
const simple = command.directive === 'NLST';
const path = command.arg || '.';
return this.connector.waitForConnection()
.tap(() => this.commandSocket.pause())
.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'));
};
return Promise.try(() => files.map((file) => {
const message = getFileMessage(file);
return {
raw: true,
message,
socket: this.connector.socket
};
}));
})
.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) => {
log.error(err);
return this.reply(451, err.message || 'No directory');
})
.finally(() => {
this.connector.end();
this.commandSocket.resume();
});
},
syntax: '{{cmd}} [<path>]',
description: 'Returns information of a file or directory if specified, else information of the current working directory is returned'
};

View File

@@ -1,25 +0,0 @@
const Promise = require('bluebird');
const moment = require('moment');
module.exports = {
directive: 'MDTM',
handler: function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.get) return this.reply(402, 'Not supported by file system');
return 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) => {
log.error(err);
return this.reply(550, err.message);
});
},
syntax: '{{cmd}} <path>',
description: 'Return the last-modified time of a specified file',
flags: {
feat: 'MDTM'
}
};

View File

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

View File

@@ -1,28 +0,0 @@
const PassiveConnector = require('../../connector/passive');
module.exports = {
directive: 'PASV',
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.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}}',
description: 'Initiate passive mode'
};

View File

@@ -1,14 +0,0 @@
module.exports = {
directive: 'PBSZ',
handler: function ({command} = {}) {
if (!this.secure) return this.reply(202, 'Not suppored');
this.bufferSize = parseInt(command.arg, 10);
return this.reply(200, this.bufferSize === 0 ? 'OK' : 'Buffer too large: PBSZ=0');
},
syntax: '{{cmd}}',
description: 'Protection Buffer Size',
flags: {
no_auth: true,
feat: 'PBSZ'
}
};

View File

@@ -1,23 +0,0 @@
const _ = require('lodash');
module.exports = {
directive: 'PROT',
handler: function ({command} = {}) {
if (!this.secure) return this.reply(202, 'Not suppored');
if (!this.bufferSize && typeof this.bufferSize !== 'number') return this.reply(503);
switch (_.toUpper(command.arg)) {
case 'P': return this.reply(200, 'OK');
case 'C':
case 'S':
case 'E': return this.reply(536, 'Not supported');
default: return this.reply(504);
}
},
syntax: '{{cmd}}',
description: 'Data Channel Protection Level',
flags: {
no_auth: true,
feat: 'PROT'
}
};

View File

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

View File

@@ -1,64 +0,0 @@
const Promise = require('bluebird');
module.exports = {
directive: 'RETR',
handler: function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.read) return this.reply(402, 'Not supported by file system');
const filePath = command.arg;
return this.connector.waitForConnection()
.tap(() => this.commandSocket.pause())
.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) => {
if (stream) stream.pause();
if (this.connector.socket) {
this.connector.socket.write(data, this.transferType, () => stream && stream.resume());
}
});
stream.once('end', () => resolve());
stream.once('error', destroyConnection(this.connector.socket, reject));
this.connector.socket.once('error', destroyConnection(stream, reject));
});
this.restByteCount = 0;
return this.reply(150).then(() => stream.resume() && this.connector.socket.resume())
.then(() => eventsPromise)
.tap(() => this.emit('RETR', null, serverPath))
.then(() => this.reply(226, clientPath))
.finally(() => stream.destroy && stream.destroy());
})
.catch(Promise.TimeoutError, (err) => {
log.error(err);
return this.reply(425, 'No connection established');
})
.catch((err) => {
log.error(err);
this.emit('RETR', err);
return this.reply(551, err.message);
})
.finally(() => {
this.connector.end();
this.commandSocket.resume();
});
},
syntax: '{{cmd}} <path>',
description: 'Retrieve a copy of the file'
};

View File

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

View File

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

View File

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

View File

@@ -1,45 +0,0 @@
const _ = require('lodash');
const Promise = require('bluebird');
const getFileStat = require('../../helpers/file-stat');
module.exports = {
directive: 'STAT',
handler: function (args = {}) {
const {log, command} = args;
const path = _.get(command, 'arg');
if (path) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.get) return this.reply(402, 'Not supported by file system');
return 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.try(() => this.fs.list(path))
.then((stats) => [213, stats]);
}
return [212, [stat]];
})
.then(([code, fileStats]) => {
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(([code, messages]) => this.reply(code, 'Status begin', ...messages, 'Status end'))
.catch((err) => {
log.error(err);
return this.reply(450, err.message);
});
} else {
return this.reply(211, 'Status OK');
}
},
syntax: '{{cmd}} [<path>]',
description: 'Returns the current status'
};

View File

@@ -1,75 +0,0 @@
const Promise = require('bluebird');
module.exports = {
directive: 'STOR',
handler: function ({log, command} = {}) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.write) return this.reply(402, 'Not supported by file system');
const append = command.directive === 'APPE';
const fileName = command.arg;
return this.connector.waitForConnection()
.tap(() => this.commandSocket.pause())
.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);
};
const streamPromise = new Promise((resolve, reject) => {
stream.once('error', destroyConnection(this.connector.socket, reject));
stream.once('finish', () => resolve());
});
const socketPromise = new Promise((resolve, reject) => {
this.connector.socket.on('data', (data) => {
if (this.connector.socket) this.connector.socket.pause();
if (stream && stream.writable) {
stream.write(data, this.transferType, () => this.connector.socket && this.connector.socket.resume());
}
});
this.connector.socket.once('end', () => {
if (stream.listenerCount('close')) stream.emit('close');
else stream.end();
resolve();
});
this.connector.socket.once('error', destroyConnection(stream, reject));
});
this.restByteCount = 0;
return this.reply(150).then(() => this.connector.socket.resume())
.then(() => Promise.all([streamPromise, socketPromise]))
.tap(() => this.emit('STOR', null, serverPath))
.then(() => this.reply(226, clientPath))
.finally(() => stream.destroy && stream.destroy());
})
.catch(Promise.TimeoutError, (err) => {
log.error(err);
return this.reply(425, 'No connection established');
})
.catch((err) => {
log.error(err);
this.emit('STOR', err);
return this.reply(550, err.message);
})
.finally(() => {
this.connector.end();
this.commandSocket.resume();
});
},
syntax: '{{cmd}} <path>',
description: 'Store data as a file at the server site'
};

View File

@@ -1,21 +0,0 @@
const Promise = require('bluebird');
const {handler: stor} = require('./stor');
module.exports = {
directive: 'STOU',
handler: function (args) {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.get || !this.fs.getUniqueName) return this.reply(402, 'Not supported by file system');
const fileName = args.command.arg;
return 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);
});
},
syntax: '{{cmd}}',
description: 'Store file uniquely'
};

View File

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

View File

@@ -1,86 +0,0 @@
const net = require('net');
const tls = require('tls');
const ip = require('ip');
const Promise = require('bluebird');
const Connector = require('./base');
const errors = require('../errors');
class Passive extends Connector {
constructor(connection) {
super(connection);
this.type = 'passive';
}
waitForConnection({timeout = 5000, delay = 50} = {}) {
if (!this.dataServer) return Promise.reject(new errors.ConnectorError('Passive server not setup'));
const checkSocket = () => {
if (this.dataServer && this.dataServer.listening && this.dataSocket && this.dataSocket.connected) {
return Promise.resolve(this.dataSocket);
}
return Promise.resolve().delay(delay)
.then(() => checkSocket());
};
return checkSocket().timeout(timeout);
}
setupServer() {
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,
cmd_connection: this.connection.commandSocket.remoteAddress
}, 'Connecting addresses do not match');
socket.destroy();
return this.connection.reply(550, 'Remote addresses do not match')
.finally(() => this.connection.close());
}
this.log.trace({port, remoteAddress: socket.remoteAddress}, 'Passive connection fulfilled.');
this.dataSocket = socket;
this.dataSocket.setEncoding(this.connection.transferType);
this.dataSocket.on('error', (err) => this.server && this.server.emit('client-error', {connection: this.connection, context: 'dataSocket', error: err}));
if (!this.connection.secure) {
this.dataSocket.connected = true;
}
};
this.dataSocket = null;
const serverOptions = Object.assign({}, this.connection.secure ? this.server.options.tls : {}, {pauseOnConnect: true});
this.dataServer = (this.connection.secure ? tls : net).createServer(serverOptions, connectionHandler);
this.dataServer.maxConnections = 1;
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.end();
});
if (this.connection.secure) {
this.dataServer.on('secureConnection', (socket) => {
socket.connected = true;
});
}
return new Promise((resolve, reject) => {
this.dataServer.listen(port, this.server.url.hostname, (err) => {
if (err) reject(err);
else {
this.log.debug({port}, 'Passive connection listening');
resolve(this.dataServer);
}
});
});
});
}
}
module.exports = Passive;

View File

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

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

View File

@@ -1,156 +0,0 @@
/* eslint no-unused-expressions: 0 */
const {expect} = require('chai');
const sinon = require('sinon');
const Promise = require('bluebird');
const net = require('net');
const bunyan = require('bunyan');
const PassiveConnector = require('../../src/connector/passive');
const {getNextPortFactory} = require('../../src/helpers/find-port');
describe('Connector - Passive //', function () {
const host = '127.0.0.1';
let mockConnection = {
reply: () => Promise.resolve({}),
close: () => Promise.resolve({}),
encoding: 'utf8',
log: bunyan.createLogger({name: 'passive-test'}),
commandSocket: {
remoteAddress: '::ffff:127.0.0.1'
},
server: {
url: '',
getNextPasvPort: getNextPortFactory(host, 1024)
}
};
let sandbox;
before(() => {
sandbox = sinon.sandbox.create().usingPromise(Promise);
});
beforeEach(() => {
sandbox.spy(mockConnection, 'reply');
sandbox.spy(mockConnection, 'close');
});
afterEach(() => {
sandbox.restore();
});
it('cannot wait for connection with no server', function (done) {
let passive = new PassiveConnector(mockConnection);
passive.waitForConnection()
.catch((err) => {
expect(err.name).to.equal('ConnectorError');
done();
});
});
describe('setup', function () {
before(function () {
sandbox.stub(mockConnection.server, 'getNextPasvPort').value(getNextPortFactory(host));
});
it('no pasv range provided', function (done) {
let passive = new PassiveConnector(mockConnection);
passive.setupServer()
.catch((err) => {
try {
expect(err.name).to.contain('RangeError');
done();
} catch (ex) {
done(ex);
}
});
});
});
describe('setup', function () {
let connection;
before(function () {
sandbox.stub(mockConnection.server, 'getNextPasvPort').value(getNextPortFactory(host, -1, -1));
connection = new PassiveConnector(mockConnection);
});
it('has invalid pasv range', function (done) {
connection.setupServer()
.catch((err) => {
expect(err.name).to.contain('RangeError');
done();
});
});
});
it('sets up a server', function () {
let passive = new PassiveConnector(mockConnection);
return passive.setupServer()
.then(() => {
expect(passive.dataServer).to.exist;
return passive.end();
});
});
describe('setup', function () {
let passive;
let closeFnSpy;
beforeEach(function () {
passive = new PassiveConnector(mockConnection);
return passive.setupServer()
.then(() => {
closeFnSpy = sandbox.spy(passive.dataServer, 'close');
});
});
afterEach(function () {
return passive.end();
});
it('destroys existing server, then sets up a server', function () {
return passive.setupServer()
.then(() => {
expect(closeFnSpy.callCount).to.equal(1);
expect(passive.dataServer).to.exist;
});
});
});
it('refuses connection with different remote address', function (done) {
sandbox.stub(mockConnection.commandSocket, 'remoteAddress').value('bad');
let passive = new PassiveConnector(mockConnection);
passive.setupServer()
.then(() => {
expect(passive.dataServer).to.exist;
const {port} = passive.dataServer.address();
net.createConnection(port);
passive.dataServer.once('connection', () => {
setTimeout(() => {
expect(passive.connection.reply.callCount).to.equal(1);
expect(passive.connection.reply.args[0][0]).to.equal(550);
passive.end();
done();
}, 100);
});
})
.catch(done);
});
it('accepts connection', function () {
let passive = new PassiveConnector(mockConnection);
return passive.setupServer()
.then(() => {
expect(passive.dataServer).to.exist;
const {port} = passive.dataServer.address();
net.createConnection(port);
return passive.waitForConnection();
})
.then(() => {
expect(passive.dataSocket).to.exist;
passive.end();
});
});
});

View File

@@ -1,83 +0,0 @@
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.clientPath).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.clientPath).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.clientPath).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.clientPath).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.clientPath).to.equal(
nodePath.normalize('/cool/file.txt'));
expect(result.fsPath).to.equal(
nodePath.resolve('/tmp/ftp-srv/cool/file.txt'));
});
});
});

View File

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

View File

@@ -1,24 +0,0 @@
const bunyan = require('bunyan');
const fs = require('fs');
const FtpServer = require('../src');
const server = new FtpServer({
log: bunyan.createLogger({name: 'test', level: 'trace'}),
url: 'ftp://127.0.0.1:8880',
pasv_url: '127.0.0.1',
pasv_min: 8881,
greeting: ['Welcome', 'to', 'the', 'jungle!'],
tls: {
key: fs.readFileSync(`${process.cwd()}/test/cert/server.key`),
cert: fs.readFileSync(`${process.cwd()}/test/cert/server.crt`),
ca: fs.readFileSync(`${process.cwd()}/test/cert/server.csr`)
},
file_format: 'ep',
anonymous: 'sillyrabbit'
});
server.on('login', ({username, password}, resolve, reject) => {
if (username === 'test' && password === 'test' || username === 'anonymous') {
resolve({root: require('os').homedir()});
} else reject('Bad username or password');
});
server.listen();

12021
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,57 +1,7 @@
{
"name": "ftp-srv",
"version": "5.0.0",
"version": "0.0.0-development",
"description": "Modern, extensible FTP Server",
"main": "build/index",
"scripts": {
"lint": "eslint src/**/*.ts",
"lint:fix": "npm run lint -- --fix"
},
"jest": {
"testEnvironment": "node",
"moduleFileExtensions": [
"ts",
"js",
"json"
],
"transform": {
".ts": "ts-jest"
},
"testRegex": "\\.test\\.ts",
"globals": {
"ts-jest": {
"tsConfig": "tsconfig.json"
}
}
},
"eslintConfig": {
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "tsconfig.json"
},
"plugins": [
"@typescript-eslint",
"prettier"
],
"extends": "plugin:@typescript-eslint/recommended"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},
"lint-staged": {
"*.ts": [
"eslint --fix",
"git add"
]
},
"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
},
"keywords": [
"ftp",
"ftp-server",
@@ -61,32 +11,81 @@
"ftpserver",
"server"
],
"author": "Tyler Stewart",
"license": "MIT",
"files": [
"src",
"bin",
"ftp-srv.d.ts"
],
"main": "ftp-srv.js",
"bin": "./bin/index.js",
"types": "./ftp-srv.d.ts",
"repository": {
"type": "git",
"url": "git+https://github.com/trs/ftp-srv.git"
"url": "https://github.com/trs/ftp-srv"
},
"bugs": {
"url": "https://github.com/trs/ftp-srv/issues"
"scripts": {
"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:unit:once --silent",
"semantic-release": "semantic-release",
"start": "npm run dev",
"test": "npm run test:unit",
"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"
},
"cz-customizable": {
"config": "config/release/commitMessageConfig.js"
}
},
"dependencies": {
"bluebird": "^3.5.1",
"ip": "^1.1.5",
"lodash": "^4.17.10",
"moment": "^2.22.1",
"signale": "^1.0.1",
"uuid": "^3.2.1",
"yargs": "^11.0.0"
},
"homepage": "https://github.com/trs/ftp-srv#readme",
"devDependencies": {
"@commitlint/cli": "^7.5.2",
"@commitlint/config-conventional": "^7.5.0",
"@types/jest": "^24.0.10",
"@types/node": "^11.10.5",
"@types/signale": "^1.2.1",
"@typescript-eslint/eslint-plugin": "^1.4.2",
"@typescript-eslint/parser": "^1.4.2",
"eslint": "^5.15.1",
"eslint-plugin-prettier": "^3.0.1",
"husky": "^1.3.1",
"jest": "^24.3.1",
"lerna": "^3.13.1",
"lint-staged": "^8.1.5",
"prettier": "1.16.4",
"ts-jest": "^24.0.0",
"typescript": "^3.3.3333"
"@icetee/ftp": "^1.0.2",
"chai": "^4.0.2",
"chokidar-cli": "1.2.0",
"condition-circle": "^1.6.0",
"cross-env": "3.1.4",
"cz-customizable": "5.2.0",
"cz-customizable-ghooks": "1.5.0",
"dotenv": "^4.0.0",
"eslint": "4.5.0",
"eslint-config-google": "0.8.0",
"eslint-friendly-formatter": "3.0.0",
"eslint-plugin-mocha": "^4.11.0",
"eslint-plugin-node": "5.1.1",
"husky": "0.13.3",
"istanbul": "0.4.5",
"mocha": "3.5.0",
"mocha-junit-reporter": "1.13.0",
"mocha-multi-reporters": "1.1.5",
"npm-run-all": "^4.1.3",
"rimraf": "2.6.1",
"semantic-release": "^11.0.2",
"sinon": "^2.3.5"
},
"engines": {
"node": ">=6.x",
"npm": ">=5.x"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,30 +3,25 @@ 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, '');
let [directive, ...args] = strippedMessage.split(' ');
directive = _.chain(directive).trim().toUpper().value();
const parseCommandFlags = !['RETR', 'SIZE', 'STOR'].includes(directive);
const [directive, ...args] = strippedMessage.split(' ');
const params = args.reduce(({arg, flags}, param) => {
if (parseCommandFlags && CMD_FLAG_REGEX.test(param)) flags.push(param);
if (/^-{1,2}[a-zA-Z0-9_]+/.test(param)) flags.push(param);
else arg.push(param);
return {arg, flags};
}, {arg: [], flags: []});
const command = {
directive,
directive: _.chain(directive).trim().toUpper().value(),
arg: params.arg.length ? params.arg.join(' ') : null,
flags: params.flags,
raw: message
@@ -41,19 +36,16 @@ class FtpCommands {
const logCommand = _.clone(command);
if (logCommand.directive === 'PASS') logCommand.arg = '********';
const log = this.connection.log.child({directive: command.directive});
log.trace({command: logCommand}, 'Handle command');
if (!REGISTRY.hasOwnProperty(command.directive)) {
return this.connection.reply(402, 'Command not allowed');
}
if (_.includes(this.blacklist, command.directive)) {
return this.connection.reply(502, 'Command blacklisted');
return this.connection.reply(502, 'Command on blacklist');
}
if (this.whitelist.length > 0 && !_.includes(this.whitelist, command.directive)) {
return this.connection.reply(502, 'Command not whitelisted');
return this.connection.reply(502, 'Command not on whitelist');
}
const commandRegister = REGISTRY[command.directive];
@@ -66,8 +58,7 @@ class FtpCommands {
return this.connection.reply(502, 'Handler not set on command');
}
const handler = commandRegister.handler.bind(this.connection);
return Promise.resolve(handler({log, command, previous_command: this.previousCommand}))
return Promise.try(() => commandRegister.handler.call(this, this.connection, command, this.previousCommand))
.finally(() => {
this.previousCommand = _.clone(command);
});

View File

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

View File

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

View File

@@ -2,8 +2,8 @@ const stor = require('./stor').handler;
module.exports = {
directive: 'APPE',
handler: function (args) {
return stor.call(this, args);
handler: function () {
return stor.call(this, ...arguments);
},
syntax: '{{cmd}} <path>',
description: 'Append to a file'

View File

@@ -0,0 +1,42 @@
const _ = require('lodash');
const tls = require('tls');
module.exports = {
directive: 'AUTH',
handler: function (connection, command) {
const method = _.upperCase(command.arg);
switch (method) {
case 'TLS': return handleTLS.call(this, connection);
default: return connection.reply(504);
}
},
syntax: '{{cmd}} <type>',
description: 'Set authentication mechanism',
flags: {
no_auth: true,
feat: 'AUTH TLS'
}
};
function handleTLS(connection) {
if (!connection.server._tls) return connection.reply(502);
if (connection.secure) return connection.reply(202);
return connection.reply(234)
.then(() => {
const secureContext = tls.createSecureContext(connection.server._tls);
const secureSocket = new tls.TLSSocket(connection.commandSocket, {
isServer: true,
secureContext
});
['data', 'timeout', 'end', 'close', 'drain', 'error'].forEach(event => {
function forwardEvent() {
connection.emit.apply(this, arguments);
}
secureSocket.on(event, forwardEvent.bind(connection.commandSocket, event));
});
connection.commandSocket = secureSocket;
connection.secure = true;
});
}

View File

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

View File

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

View File

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

View File

@@ -8,14 +8,14 @@ const FAMILY = {
module.exports = {
directive: 'EPRT',
handler: function ({command} = {}) {
handler: function (connection, command) {
const [, protocol, ip, port] = _.chain(command).get('arg', '').split('|').value();
const family = FAMILY[protocol];
if (!family) return this.reply(504, 'Unknown network protocol');
if (!family) return connection.reply(504, 'Unknown network protocol');
this.connector = new ActiveConnector(this);
return this.connector.setupConnection(ip, port, family)
.then(() => this.reply(200));
connection.connector = new ActiveConnector(connection);
return connection.connector.setupConnection(ip, port, family)
.then(() => connection.reply(200));
},
syntax: '{{cmd}} |<protocol>|<address>|<port>|',
description: 'Specifies an address and port to which the server should connect'

View File

@@ -2,13 +2,13 @@ const PassiveConnector = require('../../connector/passive');
module.exports = {
directive: 'EPSV',
handler: function () {
this.connector = new PassiveConnector(this);
return this.connector.setupServer()
.then((server) => {
handler: function (connection) {
connection.connector = new PassiveConnector(connection);
return connection.connector.setupServer()
.then(server => {
const {port} = server.address();
return this.reply(229, `EPSV OK (|||${port}|)`);
return connection.reply(229, `EPSV OK (|||${port}|)`);
});
},
syntax: '{{cmd}} [<protocol>]',

View File

@@ -2,7 +2,7 @@ const _ = require('lodash');
module.exports = {
directive: 'FEAT',
handler: function () {
handler: function (connection) {
const registry = require('../registry');
const features = Object.keys(registry)
.reduce((feats, cmd) => {
@@ -11,13 +11,13 @@ module.exports = {
return feats;
}, ['UTF8'])
.sort()
.map((feat) => ({
.map(feat => ({
message: ` ${feat}`,
raw: true
}));
return features.length
? this.reply(211, 'Extensions supported', ...features, 'End')
: this.reply(211, 'No features');
? connection.reply(211, 'Extensions supported', ...features, 'End')
: connection.reply(211, 'No features');
},
syntax: '{{cmd}}',
description: 'Get the feature list implemented by the server',

View File

@@ -2,18 +2,18 @@ const _ = require('lodash');
module.exports = {
directive: 'HELP',
handler: function ({command} = {}) {
handler: function (connection, command) {
const registry = require('../registry');
const directive = _.upperCase(command.arg);
if (directive) {
if (!registry.hasOwnProperty(directive)) return this.reply(502, `Unknown command ${directive}.`);
if (!registry.hasOwnProperty(directive)) return connection.reply(502, `Unknown command ${directive}.`);
const {syntax, description} = registry[directive];
const reply = _.concat([syntax.replace('{{cmd}}', directive), description]);
return this.reply(214, ...reply);
return connection.reply(214, ...reply);
} else {
const supportedCommands = _.chunk(Object.keys(registry), 5).map((chunk) => chunk.join('\t'));
return this.reply(211, 'Supported commands:', ...supportedCommands, 'Use "HELP [command]" for syntax help.');
const supportedCommands = _.chunk(Object.keys(registry), 5).map(chunk => chunk.join('\t'));
return connection.reply(211, 'Supported commands:', ...supportedCommands, 'Use "HELP [command]" for syntax help.');
}
},
syntax: '{{cmd}} [<command>]',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,14 +7,14 @@ const OPTIONS = {
module.exports = {
directive: 'OPTS',
handler: function ({command} = {}) {
if (!_.has(command, 'arg')) return this.reply(501);
handler: function (connection, command) {
if (!_.has(command, 'arg')) return connection.reply(501);
const [_option, ...args] = command.arg.split(' ');
const option = _.toUpper(_option);
if (!OPTIONS.hasOwnProperty(option)) return this.reply(500);
return OPTIONS[option].call(this, args);
if (!OPTIONS.hasOwnProperty(option)) return connection.reply(500);
return OPTIONS[option].call(this, ...args);
},
syntax: '{{cmd}}',
description: 'Select options for a feature'

View File

@@ -1,20 +1,20 @@
module.exports = {
directive: 'PASS',
handler: function ({log, command} = {}) {
if (!this.username) return this.reply(503);
handler: function (connection, command) {
if (!connection.username) return this.reply(503);
if (this.authenticated) return this.reply(202);
// 332 : require account name (ACCT)
const password = command.arg;
if (!password) return this.reply(501, 'Must provide password');
return this.login(this.username, password)
return connection.login(connection.username, password)
.then(() => {
return this.reply(230);
return connection.reply(230);
})
.catch((err) => {
log.error(err);
return this.reply(530, err.message || 'Authentication failed');
.catch(err => {
connection.emit('error', err);
return connection.reply(530, err.message || 'Authentication failed');
});
},
syntax: '{{cmd}} <password>',

View File

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

View File

@@ -0,0 +1,14 @@
module.exports = {
directive: 'PBSZ',
handler: function (connection, command) {
if (!connection.secure) return connection.reply(202, 'Not suppored');
connection.bufferSize = parseInt(command.arg, 10);
return connection.reply(200, connection.bufferSize === 0 ? 'OK' : 'Buffer too large: PBSZ=0');
},
syntax: '{{cmd}}',
description: 'Protection Buffer Size',
flags: {
no_auth: true,
feat: 'PBSZ'
}
};

View File

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

View File

@@ -0,0 +1,23 @@
const _ = require('lodash');
module.exports = {
directive: 'PROT',
handler: function (connection, command) {
if (!connection.secure) return connection.reply(202, 'Not suppored');
if (!connection.bufferSize && typeof connection.bufferSize !== 'number') return connection.reply(503);
switch (_.toUpper(command.arg)) {
case 'P': return connection.reply(200, 'OK');
case 'C':
case 'S':
case 'E': return connection.reply(536, 'Not supported');
default: return connection.reply(504);
}
},
syntax: '{{cmd}}',
description: 'Data Channel Protection Level',
flags: {
no_auth: true,
feat: 'PROT'
}
};

View File

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

View File

@@ -1,7 +1,7 @@
module.exports = {
directive: 'QUIT',
handler: function () {
return this.close(221, 'Client called QUIT');
handler: function (connection) {
return connection.close(221, 'Client called QUIT');
},
syntax: '{{cmd}}',
description: 'Disconnect',

View File

@@ -2,14 +2,14 @@ const _ = require('lodash');
module.exports = {
directive: 'REST',
handler: function ({command} = {}) {
handler: function (connection, command) {
const arg = _.get(command, 'arg');
const byteCount = parseInt(arg, 10);
if (isNaN(byteCount) || byteCount < 0) return this.reply(501, 'Byte count must be 0 or greater');
if (isNaN(byteCount) || byteCount < 0) return connection.reply(501, 'Byte count must be 0 or greater');
this.restByteCount = byteCount;
return this.reply(350, `Restarting next transfer at ${byteCount}`);
connection.restByteCount = byteCount;
return connection.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

@@ -0,0 +1,57 @@
const Promise = require('bluebird');
module.exports = {
directive: 'RETR',
handler: function (connection, command) {
if (!connection.fs) return connection.reply(550, 'File system not instantiated');
if (!connection.fs.read) return connection.reply(402, 'Not supported by file system');
const filePath = command.arg;
return connection.connector.waitForConnection()
.tap(() => connection.commandSocket.pause())
.then(() => Promise.resolve(connection.fs.read(filePath, {start: connection.restByteCount})))
.then(stream => {
const destroyConnection = (conn, reject) => err => {
if (conn) conn.destroy(err);
reject(err);
};
const eventsPromise = new Promise((resolve, reject) => {
stream.on('data', data => {
if (stream) stream.pause();
if (connection.connector.socket) {
connection.connector.socket.write(data, connection.transferType, () => stream && stream.resume());
}
});
stream.once('end', () => resolve());
stream.once('error', destroyConnection(connection.connector.socket, reject));
connection.connector.socket.once('error', destroyConnection(stream, reject));
});
connection.restByteCount = 0;
return connection.reply(150).then(() => stream.resume() && connection.connector.socket.resume())
.then(() => eventsPromise)
.tap(() => connection.emit('RETR', null, filePath))
.finally(() => stream.destroy && stream.destroy());
})
.then(() => connection.reply(226))
.catch(Promise.TimeoutError, err => {
connection.emit('error', err);
return connection.reply(425, 'No connection established');
})
.catch(err => {
connection.emit('error', err);
connection.emit('RETR', err);
return connection.reply(551, err.message);
})
.finally(() => {
connection.connector.end();
connection.commandSocket.resume();
});
},
syntax: '{{cmd}} <path>',
description: 'Retrieve a copy of the file'
};

View File

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

View File

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

View File

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

View File

@@ -6,11 +6,11 @@ module.exports = function ({log, command} = {}) {
const [mode, ...fileNameParts] = command.arg.split(' ');
const fileName = fileNameParts.join(' ');
return Promise.try(() => this.fs.chmod(fileName, parseInt(mode, 8)))
return Promise.resolve(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

@@ -8,7 +8,7 @@ module.exports = {
handler: function ({log, command} = {}) {
const rawSubCommand = _.get(command, 'arg', '');
const subCommand = this.commands.parse(rawSubCommand);
const subLog = log.child({subverb: subCommand.directive});
const subLog = log.scope(subCommand.directive);
if (!registry.hasOwnProperty(subCommand.directive)) return this.reply(502);

View File

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

View File

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

View File

@@ -0,0 +1,65 @@
const Promise = require('bluebird');
module.exports = {
directive: 'STOR',
handler: function (connection, command) {
if (!connection.fs) return connection.reply(550, 'File system not instantiated');
if (!connection.fs.write) return connection.reply(402, 'Not supported by file system');
const append = command.directive === 'APPE';
const fileName = command.arg;
return connection.connector.waitForConnection()
.tap(() => connection.commandSocket.pause())
.then(() => Promise.resolve(connection.fs.write(fileName, {append, start: connection.restByteCount})))
.then(stream => {
const destroyConnection = (conn, reject) => err => {
if (conn) conn.destroy(err);
reject(err);
};
const streamPromise = new Promise((resolve, reject) => {
stream.once('error', destroyConnection(connection.connector.socket, reject));
stream.once('finish', () => resolve());
});
const socketPromise = new Promise((resolve, reject) => {
connection.connector.socket.on('data', data => {
if (connection.connector.socket) connection.connector.socket.pause();
if (stream) {
stream.write(data, connection.transferType, () => connection.connector.socket && connection.connector.socket.resume());
}
});
connection.connector.socket.once('end', () => {
if (stream.listenerCount('close')) stream.emit('close');
else stream.end();
resolve();
});
connection.connector.socket.once('error', destroyConnection(stream, reject));
});
connection.restByteCount = 0;
return connection.reply(150).then(() => connection.connector.socket.resume())
.then(() => Promise.join(streamPromise, socketPromise))
.tap(() => connection.emit('STOR', null, fileName))
.finally(() => stream.destroy && stream.destroy());
})
.then(() => connection.reply(226, fileName))
.catch(Promise.TimeoutError, err => {
connection.emit('error', err);
return connection.reply(425, 'No connection established');
})
.catch(err => {
connection.emit('error', err);
connection.emit('STOR', err);
return connection.reply(550, err.message);
})
.finally(() => {
connection.connector.end();
connection.commandSocket.resume();
});
},
syntax: '{{cmd}} <path>',
description: 'Store data as a file at the server site'
};

View File

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

View File

@@ -1,7 +1,7 @@
module.exports = {
directive: 'STRU',
handler: function ({command} = {}) {
return this.reply(/^F$/i.test(command.arg) ? 200 : 504);
handler: function (connection, command) {
return connection.reply(/^F$/i.test(command.arg) ? 200 : 504);
},
syntax: '{{cmd}} <structure>',
description: 'Set file transfer structure',

View File

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

View File

@@ -1,14 +1,14 @@
module.exports = {
directive: 'TYPE',
handler: function ({command} = {}) {
handler: function (connection, command) {
if (/^A[0-9]?$/i.test(command.arg)) {
this.transferType = 'ascii';
connection.transferType = 'ascii';
} else if (/^L[0-9]?$/i.test(command.arg) || /^I$/i.test(command.arg)) {
this.transferType = 'binary';
connection.transferType = 'binary';
} else {
return this.reply(501);
return connection.reply(501);
}
return this.reply(200, `Switch to "${this.transferType}" transfer mode.`);
return connection.reply(200, `Switch to "${connection.transferType}" transfer mode.`);
},
syntax: '{{cmd}} <mode>',
description: 'Set the transfer mode, binary (I) or ascii (A)',

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