Compare commits

..

1 Commits

Author SHA1 Message Date
Tyler Stewart
21bb611c30 WIP: typescript refactor 2019-04-08 16:37:51 -06:00
227 changed files with 24315 additions and 22014 deletions

View File

@@ -1,112 +1,97 @@
version: 2.1
orbs:
node: circleci/node@5.0.2
commands:
setup_git_bot:
description: set up the bot git user to make changes
steps:
- run:
name: "Git: Botovance"
command: |
git config --global user.name "Bot Vance"
git config --global user.email bot@autovance.com
executors:
node-lts:
parameters:
node-version:
type: string
default: lts
docker:
- image: cimg/node:<< parameters.node-version >>
version: 2
jobs:
lint:
executor: node-lts
build:
docker:
- image: &node-image circleci/node:lts
steps:
- checkout
- node/install-packages
- 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
lint:
docker:
- image: *node-image
steps:
- checkout
- attach_workspace:
at: .
- run:
name: Lint
command: npm run verify
release_dry_run:
executor: node-lts
test:
docker:
- image: *node-image
steps:
- checkout
- node/install-packages
- setup_git_bot
- 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: |
git branch -u "origin/${CIRCLE_BRANCH}"
npx semantic-release --dry-run
npm run semantic-release -- --dry-run
release:
executor: node-lts
docker:
- image: *node-image
steps:
- checkout
- node/install-packages
- setup_git_bot
- attach_workspace:
at: .
- deploy:
name: Release
command: |
git branch -u "origin/${CIRCLE_BRANCH}"
npx semantic-release
npm run semantic-release
workflows:
version: 2
release_scheduled:
triggers:
# 6:03 UTC (mornings) 1 monday
- schedule:
cron: "3 6 * * 1"
filters:
branches:
only:
- main
publish:
jobs:
- lint
- node/test:
matrix:
parameters:
version:
- '12.22'
- '14.19'
- '16.14'
- 'current'
- release:
context: npm-deploy-av
- build
- lint:
requires:
- node/test
- lint
test:
jobs:
- lint
- node/test:
matrix:
parameters:
version:
- '12.22'
- '14.19'
- '16.14'
- 'current'
- build
- test:
requires:
- build
- release_dry_run:
filters:
branches:
only: main
only: master
requires:
- node/test
- test
- lint
- hold_release:
type: approval
requires:
- release_dry_run
- release:
context: npm-deploy-av
requires:
- hold_release

View File

@@ -1,19 +0,0 @@
---
### Acceptance Checklist
- [ ] **Story**: Code is focused on the linked stories and solves a problem
- One of:
- [ ] **For Bugs**: A unit test is added or an existing one modified
- [ ] **For Features**: New unit tests are added covering the new functions or modifications
- [ ] Code Documentation changes are included for public interfaces and important / complex additions
- [ ] External Documentation is included for API changes, or other external facing interfaces
### Review Checklist
- [ ] The code does not duplicate existing functionality that exists elsewhere
- [ ] The code has been linted and follows team practices and style guidelines
- [ ] The changes in the PR are relevant to the title
- changes not related should be moved to a different PR
- [ ] All errors or error handling is actionable, and informs the viewer on how to correct it

View File

@@ -1,5 +0,0 @@
# Lines starting with '#' are comments.
# Each line is a file pattern followed by one or more owners.
# Order is important. The last matching pattern has the most precedence.
* @quorumdms/team-gbt

416
README.md
View File

@@ -1,416 +0,0 @@
<p align="center">
<a href="https://github.com/autovance/ftp-srv">
<img alt="ftp-srv" src="logo.png" width="600px" />
</a>
</p>
<p align="center">
Modern, extensible FTP Server
</p>
<p align="center">
<a href="https://www.npmjs.com/package/ftp-srv">
<img alt="npm" src="https://img.shields.io/npm/dm/ftp-srv.svg?style=for-the-badge" />
</a>
<a href="https://circleci.com/gh/autovance/workflows/ftp-srv/tree/master">
<img alt="circleci" src="https://img.shields.io/circleci/project/github/autovance/ftp-srv/master.svg?style=for-the-badge" />
</a>
</p>
---
## Overview
`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, create an active ftp server.
const FtpSrv = require('ftp-srv');
const port=21;
const ftpServer = new FtpSrv({
url: "ftp://0.0.0.0:" + port,
anonymous: true
});
ftpServer.on('login', ({ connection, username, password }, resolve, reject) => {
if(username === 'anonymous' && password === 'anonymous'){
return resolve({ root:"/" });
}
return reject(new errors.GeneralError('Invalid username or password', 401));
});
ftpServer.listen().then(() => {
console.log('Ftp server is starting...')
});
```
## 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`
`FTP-srv` provides an IP address to the client when a `PASV` command is received in the handshake for a passive connection. Reference [PASV verb](https://cr.yp.to/ftp/retr.html#pasv). This can be one of two options:
- A function which takes one parameter containing the remote IP address of the FTP client. This can be useful when the user wants to return a different IP address depending if the user is connecting from Internet or from an LAN address.
Example:
```js
const { networkInterfaces } = require('os');
const { Netmask } = require('netmask');
const nets = networkInterfaces();
function getNetworks() {
let networks = {};
for (const name of Object.keys(nets)) {
for (const net of nets[name]) {
if (net.family === 'IPv4' && !net.internal) {
networks[net.address + "/24"] = net.address
}
}
}
return networks;
}
const resolverFunction = (address) => {
// const networks = {
// '$GATEWAY_IP/32': `${public_ip}`,
// '10.0.0.0/8' : `${lan_ip}`
// }
const networks = getNetworks();
for (const network in networks) {
if (new Netmask(network).contains(address)) {
return networks[network];
}
}
return "127.0.0.1";
}
new FtpSrv({pasv_url: resolverFunction});
```
- A static IP address (ie. an external WAN **IP address** that the FTP server is bound to). In this case, only connections from localhost are handled differently returning `127.0.0.1` to the client.
If not provided, clients can only connect using an `Active` connection.
#### `pasv_min`
The starting port to accept passive connections.
__Default:__ `1024`
#### `pasv_max`
The ending port to accept passive connections.
The range is then queried for an available port to use when required.
__Default:__ `65535`
#### `greeting`
A human readable array of lines or string to send when a client connects.
__Default:__ `null`
#### `tls`
Node [TLS secure context object](https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options) used for implicit (`ftps` protocol) or explicit (`AUTH TLS`) connections.
__Default:__ `false`
#### `anonymous`
If true, will allow clients to authenticate using the username `anonymous`, not requiring a password from the user.
Can also set as a string which allows users to authenticate using the username provided.
The `login` event is then sent with the provided username and `@anonymous` as the password.
__Default:__ `false`
#### `blacklist`
Array of commands that are not allowed.
Response code `502` is sent to clients sending one of these commands.
__Example:__ `['RMD', 'RNFR', 'RNTO']` will not allow users to delete directories or rename any files.
__Default:__ `[]`
#### `whitelist`
Array of commands that are only allowed.
Response code `502` is sent to clients sending any other command.
__Default:__ `[]`
#### `file_format`
Sets the format to use for file stat queries such as `LIST`.
__Default:__ `"ls"`
__Allowable values:__
- `ls` [bin/ls format](https://cr.yp.to/ftp/list/binls.html)
- `ep` [Easily Parsed LIST format](https://cr.yp.to/ftp/list/eplf.html)
- `function () {}` A custom function returning a format or promise for one.
- Only one argument is passed in: a node [file stat](https://nodejs.org/api/fs.html#fs_class_fs_stats) object with additional file `name` parameter
#### `log`
A [bunyan logger](https://github.com/trentm/node-bunyan) instance. Created by default.
#### `timeout`
Sets the timeout (in ms) after that an idle connection is closed by the server
__Default:__ `0`
## CLI
`ftp-srv` also comes with a builtin CLI.
```bash
$ ftp-srv [url] [options]
```
```bash
$ ftp-srv ftp://0.0.0.0:9876 --root ~/Documents
```
#### `url`
Set the listening URL.
Defaults to `ftp://127.0.0.1:21`
#### `--pasv_url`
The hostname to provide a client when attempting a passive connection (`PASV`).
If not provided, clients can only connect using an `Active` connection.
#### `--pasv_min`
The starting port to accept passive connections.
__Default:__ `1024`
#### `--pasv_max`
The ending port to accept passive connections.
The range is then queried for an available port to use when required.
__Default:__ `65535`
#### `--root` / `-r`
Set the default root directory for users.
Defaults to the current directory.
#### `--credentials` / `-c`
Set the path to a json credentials file.
Format:
```js
[
{
"username": "...",
"password": "...",
"root": "..." // Root directory
},
...
]
```
#### `--username`
Set the username for the only user. Do not provide an argument to allow anonymous login.
#### `--password`
Set the password for the given `username`.
#### `--read-only`
Disable write actions such as upload, delete, etc.
## Events
The `FtpSrv` class extends the [node net.Server](https://nodejs.org/api/net.html#net_class_net_server). Some custom events can be resolved or rejected, such as `login`.
### `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
### `disconnect`
```js
ftpServer.on('disconnect', ({connection, id, newConnectionCount}) => { ... });
```
Occurs when a client has disconnected.
`connection` [client class object](src/connection.js)
`id` string of the disconnected connection id
`id` number of the new connection count (exclusive the disconnected client connection)
### `closed`
```js
ftpServer.on('closed', ({}) => { ... });
```
Occurs when the FTP server has been closed.
### `closing`
```js
ftpServer.on('closing', ({}) => { ... });
```
Occurs when the FTP server has started closing.
### `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
### `server-error`
```js
ftpServer.on('server-error', ({error}) => { ... });
```
Occurs when an error arises in the FTP server.
`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(fileName)`](src/fs.js#L131)
Returns a unique file name to write to. Client requested filename available if you want to base your function on it.
__Used in:__ `STOU`
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md).
## 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,16 +0,0 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 4.x | :white_check_mark: |
| 3.x | :white_check_mark: |
| < 3.0 | :x: |
__Critical vulnerabilities will be ported as far back as possible.__
## Reporting a Vulnerability
Report a security vulnerability directly to the maintainers by sending an email to security@autovance.com
or by reporting a vulnerability to the [NPM and Github security teams](https://docs.npmjs.com/reporting-a-vulnerability-in-an-npm-package).

15
example/index.ts Normal file
View File

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

179
ftp-srv.d.ts vendored
View File

@@ -1,179 +0,0 @@
import type { Server } from 'node:net'
const EventEmitter = import('events').EventEmitter
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(fileName: string): string
}
export class GeneralError extends Error {
/**
* @param message The error message.
* @param code Default value is `400`.
*/
constructor(message: string, code?: number)
}
export class SocketError extends Error {
/**
* @param message The error message.
* @param code Default value is `500`.
*/
constructor(message: string, code?: number)
}
export class FileSystemError extends Error {
/**
* @param message The error message.
* @param code Default value is `400`.
*/
constructor(message: string, code?: number)
}
export class ConnectorError extends Error {
/**
* @param message The error message.
* @param code Default value is `400`.
*/
constructor(message: string, code?: number)
}
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 | Object, ...letters: Array<any>): Promise<any>
}
export interface FtpServerOptions {
url?: string
pasv_min?: number
pasv_max?: number
pasv_url?: string
random_pasv_port?: boolean
greeting?: string | string[]
tls?: import('tls').SecureContextOptions | false
anonymous?: boolean
blacklist?: Array<string>
whitelist?: Array<string>
file_format?: ((stat: import('fs').Stats) => string) | 'ls' | 'ep'
log?: any
timeout?: number
}
export class FtpServer extends EventEmitter {
server: Server
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?: Array<string>
whitelist?: Array<string>
}) => void,
reject: (err?: Error) => void
) => void
): this
on(event: 'disconnect', listener: (data: { connection: FtpConnection; id: string }) => void): this
on(
event: 'client-error',
listener: (data: { connection: FtpConnection; context: string; error: Error }) => void
): this
}
export { FtpServer as FtpSrv }
export default FtpServer

View File

@@ -1,6 +0,0 @@
import FtpSrv from './src/index.js'
import FileSystem from './src/fs.js'
import ftpErrors from './src/errors.js'
export default FtpSrv
export { FtpSrv, FileSystem, ftpErrors }

View File

@@ -1,30 +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)
})

View File

@@ -1,25 +0,0 @@
const puppeteer = require('puppeteer')
const logoPath = `file://${process.cwd()}/logo/logo.html`
puppeteer.launch().then((browser) => {
return browser
.newPage()
.then((page) => {
return page.goto(logoPath).then(() => page)
})
.then((page) => {
return page
.setViewport({
width: 600,
height: 250,
deviceScaleFactor: 2
})
.then(() =>
page.screenshot({
path: 'logo.png',
omitBackground: true
})
)
})
.then(() => browser.close())
})

358
old/README.md Normal file
View File

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

View File

@@ -1,14 +1,14 @@
#!/usr/bin/env node
const yargs = require('yargs')
const path = require('path')
const yargs = require('yargs');
const path = require('path');
const FtpSrv = require('../src')
const errors = require('../src/errors')
const FtpSrv = require('../src');
const errors = require('../src/errors');
const args = setupYargs()
const state = setupState(args)
startFtpServer(state)
const args = setupYargs();
const state = setupState(args);
startFtpServer(state);
function setupYargs() {
return yargs
@@ -37,97 +37,94 @@ function setupYargs() {
boolean: true,
default: false
})
.option('pasv-url', {
.option('pasv_url', {
describe: 'URL to provide for passive connections',
type: 'string',
alias: 'pasv_url'
type: 'string'
})
.option('pasv-min', {
.option('pasv_min', {
describe: 'Starting point to use when creating passive connections',
type: 'number',
default: 1024,
alias: 'pasv_min'
default: 1024
})
.option('pasv-max', {
.option('pasv_max', {
describe: 'Ending port to use when creating passive connections',
type: 'number',
default: 65535,
alias: 'pasv_max'
default: 65535
})
.parse()
.parse();
}
function setupState(_args) {
const _state = {}
const _state = {};
function setupOptions() {
if (_args._ && _args._.length > 0) {
_state.url = _args._[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 === ''
_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
const dirPath = _args.root;
if (dirPath) {
_state.root = dirPath
_state.root = dirPath;
} else {
_state.root = process.cwd()
_state.root = process.cwd();
}
}
function setupCredentials() {
_state.credentials = {}
_state.credentials = {};
const setCredentials = (username, password, root = null) => {
_state.credentials[username] = {
password,
root
}
}
};
};
if (_args.credentials) {
const credentialsFile = path.resolve(_args.credentials)
const credentials = require(credentialsFile)
const credentialsFile = path.resolve(_args.credentials);
const credentials = require(credentialsFile);
for (const cred of credentials) {
setCredentials(cred.username, cred.password, cred.root)
setCredentials(cred.username, cred.password, cred.root);
}
} else if (_args.username) {
setCredentials(_args.username, _args.password)
setCredentials(_args.username, _args.password);
}
}
function setupCommandBlacklist() {
if (_args.readOnly) {
_state.blacklist = ['ALLO', 'APPE', 'DELE', 'MKD', 'RMD', 'RNRF', 'RNTO', 'STOR', 'STRU']
_state.blacklist = ['ALLO', 'APPE', 'DELE', 'MKD', 'RMD', 'RNRF', 'RNTO', 'STOR', 'STRU'];
}
}
setupOptions()
setupRoot()
setupCredentials()
setupCommandBlacklist()
setupOptions();
setupRoot();
setupCredentials();
setupCommandBlacklist();
return _state
return _state;
}
function startFtpServer(_state) {
// Remove null/undefined options so they get set to defaults, below
for (const key in _state) {
if (_state[key] === undefined) delete _state[key]
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 user = _state.credentials[data.username];
if (_state.anonymous || user && user.password === data.password) {
return resolve({root: user && user.root || _state.root});
}
return reject(new errors.GeneralError('Invalid username or password', 401))
return reject(new errors.GeneralError('Invalid username or password', 401));
}
const ftpServer = new FtpSrv({
@@ -137,8 +134,8 @@ function startFtpServer(_state) {
pasv_max: _state.pasv_max,
anonymous: _state.anonymous,
blacklist: _state.blacklist
})
});
ftpServer.on('login', checkLogin)
ftpServer.listen()
ftpServer.on('login', checkLogin);
ftpServer.listen();
}

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

@@ -0,0 +1,127 @@
import * as tls from 'tls'
import { Stats } from 'fs'
import { EventEmitter } from 'events';
export class FileSystem {
readonly connection: FtpConnection;
readonly root: string;
readonly cwd: string;
constructor(connection: FtpConnection, {root, cwd}?: {
root: any;
cwd: any;
});
currentDirectory(): string;
get(fileName: string): Promise<any>;
list(path?: string): Promise<any>;
chdir(path?: string): Promise<string>;
write(fileName: string, {append, start}?: {
append?: boolean;
start?: any;
}): any;
read(fileName: string, {start}?: {
start?: any;
}): Promise<any>;
delete(path: string): Promise<any>;
mkdir(path: string): Promise<any>;
rename(from: string, to: string): Promise<any>;
chmod(path: string, mode: string): Promise<any>;
getUniqueName(): string;
}
export class FtpConnection extends EventEmitter {
server: FtpServer;
id: string;
log: any;
transferType: string;
encoding: string;
bufferSize: boolean;
readonly ip: string;
restByteCount: number | undefined;
secure: boolean
close (code: number, message: number): Promise<any>
login (username: string, password: string): Promise<any>
reply (options: number | Record<string, any>, ...letters: any[]): Promise<any>
}
export interface FtpServerOptions {
url?: string;
pasv_min?: number;
pasv_max?: number;
pasv_url?: string;
greeting?: string | string[];
tls?: tls.SecureContextOptions | false;
anonymous?: boolean;
blacklist?: string[];
whitelist?: string[];
file_format?: (stat: Stats) => string | Promise<string> | "ls" | "ep";
log?: any;
timeout?: number;
}
export class FtpServer extends EventEmitter {
constructor(options?: FtpServerOptions);
readonly isTLS: boolean;
listen(): any;
emitPromise(action: any, ...data: any[]): Promise<any>;
// emit is exported from super class
setupTLS(_tls: boolean): boolean | {
cert: string;
key: string;
ca: string;
};
setupGreeting(greet: string): string[];
setupFeaturesMessage(): string;
disconnectClient(id: string): Promise<any>;
close(): any;
on(event: "login", listener: (
data: {
connection: FtpConnection;
username: string;
password: string;
},
resolve: (config: {
fs?: FileSystem;
root?: string;
cwd?: string;
blacklist?: string[];
whitelist?: string[];
}) => void,
reject: (err?: Error) => void
) => void): this;
on(event: "client-error", listener: (
data: {
connection: FtpConnection;
context: string;
error: Error;
}
) => void): this;
}
export {FtpServer as FtpSrv};
export default FtpServer;

8
old/ftp-srv.js Normal file
View File

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

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

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

23
old/meta/logo/generate.js Normal file
View File

@@ -0,0 +1,23 @@
const puppeteer = require('puppeteer');
const logoPath = `file://${process.cwd()}/logo/logo.html`;
puppeteer.launch()
.then(browser => {
return browser.newPage()
.then(page => {
return page.goto(logoPath)
.then(() => page);
})
.then(page => {
return page.setViewport({
width: 600,
height: 250,
deviceScaleFactor: 2
})
.then(() => page.screenshot({
path: 'logo.png',
omitBackground: true
}));
})
.then(() => browser.close());
});

9976
old/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

91
old/package.json Normal file
View File

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

76
old/src/commands/index.js Normal file
View File

@@ -0,0 +1,76 @@
const _ = require('lodash');
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));
}
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 params = args.reduce(({arg, flags}, param) => {
if (parseCommandFlags && CMD_FLAG_REGEX.test(param)) flags.push(param);
else arg.push(param);
return {arg, flags};
}, {arg: [], flags: []});
const command = {
directive,
arg: params.arg.length ? params.arg.join(' ') : null,
flags: params.flags,
raw: message
};
return command;
}
handle(command) {
if (typeof command === 'string') command = this.parse(command);
// Obfuscate password from logs
const logCommand = _.clone(command);
if (logCommand.directive === 'PASS') logCommand.arg = '********';
const log = this.connection.log.child({directive: command.directive});
log.trace({command: logCommand}, 'Handle command');
if (!REGISTRY.hasOwnProperty(command.directive)) {
return this.connection.reply(402, 'Command not allowed');
}
if (_.includes(this.blacklist, command.directive)) {
return this.connection.reply(502, 'Command blacklisted');
}
if (this.whitelist.length > 0 && !_.includes(this.whitelist, command.directive)) {
return this.connection.reply(502, 'Command not whitelisted');
}
const commandRegister = REGISTRY[command.directive];
const commandFlags = _.get(commandRegister, 'flags', {});
if (!commandFlags.no_auth && !this.connection.authenticated) {
return this.connection.reply(530, 'Command requires authentication');
}
if (!commandRegister.handler) {
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}))
.finally(() => {
this.previousCommand = _.clone(command);
});
}
}
module.exports = FtpCommands;

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
import stor from './stor.js'
const stor = require('./stor').handler;
export default {
module.exports = {
directive: 'APPE',
handler: function (args) {
return stor.handler.call(this, args)
return stor.call(this, args);
},
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 ({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,11 +1,11 @@
import cwd from './cwd.js'
const cwd = require('./cwd').handler;
export default {
module.exports = {
directive: ['CDUP', 'XCUP'],
handler: function (args) {
args.command.arg = '..'
return cwd.handler.call(this, args)
args.command.arg = '..';
return cwd.call(this, 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 ({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

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

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

View File

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

View File

@@ -1,30 +1,27 @@
import _ from 'lodash'
const _ = require('lodash');
export default {
module.exports = {
directive: 'FEAT',
handler: function () {
const registry = import('../registry')
const registry = require('../registry');
const features = Object.keys(registry)
.reduce(
(feats, cmd) => {
const feat = _.get(registry[cmd], 'flags.feat', null)
if (feat) return _.concat(feats, feat)
return feats
},
['UTF8']
)
.reduce((feats, cmd) => {
const feat = _.get(registry[cmd], 'flags.feat', null);
if (feat) return _.concat(feats, feat);
return feats;
}, ['UTF8'])
.sort()
.map((feat) => ({
message: ` ${feat}`,
raw: true
}))
}));
return features.length
? this.reply(211, 'Extensions supported', ...features, 'End')
: this.reply(211, 'No features')
: this.reply(211, 'No features');
},
syntax: '{{cmd}}',
description: 'Get the feature list implemented by the server',
flags: {
no_auth: true
}
}
};

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
const _ = require('lodash');
const OPTIONS = {
UTF8: utf8,
'UTF-8': utf8
};
module.exports = {
directive: 'OPTS',
handler: function ({command} = {}) {
if (!_.has(command, 'arg')) return this.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);
},
syntax: '{{cmd}}',
description: 'Select options for a feature'
};
function utf8([setting] = []) {
const getEncoding = () => {
switch (_.toUpper(setting)) {
case 'ON': return 'utf8';
case 'OFF': return 'ascii';
default: return null;
}
};
const encoding = getEncoding();
if (!encoding) return this.reply(501, 'Unknown setting for option');
this.encoding = encoding;
if (this.transferType !== 'binary') this.transferType = this.encoding;
return this.reply(200, `UTF8 encoding ${_.toLower(setting)}`);
}

View File

@@ -0,0 +1,25 @@
module.exports = {
directive: 'PASS',
handler: function ({log, command} = {}) {
if (!this.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)
.then(() => {
return this.reply(230);
})
.catch((err) => {
log.error(err);
return this.reply(530, err.message || 'Authentication failed');
});
},
syntax: '{{cmd}} <password>',
description: 'Authentication password',
flags: {
no_auth: true
}
};

View File

@@ -0,0 +1,28 @@
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,9 +1,9 @@
export default {
module.exports = {
directive: 'PBSZ',
handler: function ({ command } = {}) {
if (!this.secure) return this.reply(202, 'Not supported')
this.bufferSize = parseInt(command.arg, 10)
return this.reply(200, this.bufferSize === 0 ? 'OK' : 'Buffer too large: PBSZ=0')
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',
@@ -11,4 +11,4 @@ export default {
no_auth: true,
feat: 'PBSZ'
}
}
};

View File

@@ -0,0 +1,25 @@
const _ = require('lodash');
const ActiveConnector = require('../../connector/active');
module.exports = {
directive: 'PORT',
handler: function ({log, command} = {}) {
this.connector = new ActiveConnector(this);
const rawConnection = _.get(command, 'arg', '').split(',');
if (rawConnection.length !== 6) return this.reply(425);
const ip = rawConnection.slice(0, 4).join('.');
const portBytes = rawConnection.slice(4).map((p) => parseInt(p));
const 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);
});
},
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 ({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

@@ -0,0 +1,22 @@
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,11 +1,11 @@
export default {
module.exports = {
directive: 'QUIT',
handler: function () {
return this.close(221, 'Client called QUIT')
return this.close(221, 'Client called QUIT');
},
syntax: '{{cmd}}',
description: 'Disconnect',
flags: {
no_auth: true
}
}
};

View File

@@ -1,16 +1,16 @@
import _ from 'lodash'
const _ = require('lodash');
export default {
module.exports = {
directive: 'REST',
handler: function ({ command } = {}) {
const arg = _.get(command, 'arg')
const byteCount = parseInt(arg, 10)
handler: function ({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 this.reply(501, 'Byte count must be 0 or greater');
this.restByteCount = byteCount
return this.reply(350, `Restarting next transfer at ${byteCount}`)
this.restByteCount = byteCount;
return this.reply(350, `Restarting next transfer at ${byteCount}`);
},
syntax: '{{cmd}} <byte-count>',
description: 'Restart transfer from the specified point. Resets after any STORE or RETRIEVE'
}
};

View File

@@ -0,0 +1,64 @@
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,10 +1,10 @@
import dele from './dele.js'
const {handler: dele} = require('./dele');
export default {
module.exports = {
directive: ['RMD', 'XRMD'],
handler: function (args) {
return dele.handler.call(this, 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 ({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

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

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

View File

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

View File

@@ -0,0 +1,5 @@
module.exports = {
CHMOD: {
handler: require('./chmod')
}
};

View File

@@ -1,23 +1,23 @@
import Promise from 'bluebird'
const Promise = require('bluebird');
export default {
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')
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)
})
.then((fileStat) => {
return this.reply(213, {message: fileStat.size});
})
.catch((err) => {
log.error(err);
return this.reply(550, err.message);
});
},
syntax: '{{cmd}} <path>',
description: 'Return the size of a file',
flags: {
feat: 'SIZE'
}
}
};

View File

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

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

@@ -0,0 +1,21 @@
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,11 +1,11 @@
export default {
module.exports = {
directive: 'STRU',
handler: function ({ command } = {}) {
return this.reply(/^F$/i.test(command.arg) ? 200 : 504)
handler: function ({command} = {}) {
return this.reply(/^F$/i.test(command.arg) ? 200 : 504);
},
syntax: '{{cmd}} <structure>',
description: 'Set file transfer structure',
flags: {
obsolete: true
}
}
};

View File

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

View File

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

View File

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

@@ -0,0 +1,50 @@
/* eslint no-return-assign: 0 */
const commands = [
require('./registration/abor'),
require('./registration/allo'),
require('./registration/appe'),
require('./registration/auth'),
require('./registration/cdup'),
require('./registration/cwd'),
require('./registration/dele'),
require('./registration/feat'),
require('./registration/help'),
require('./registration/list'),
require('./registration/mdtm'),
require('./registration/mkd'),
require('./registration/mode'),
require('./registration/nlst'),
require('./registration/noop'),
require('./registration/opts'),
require('./registration/pass'),
require('./registration/pasv'),
require('./registration/port'),
require('./registration/pwd'),
require('./registration/quit'),
require('./registration/rest'),
require('./registration/retr'),
require('./registration/rmd'),
require('./registration/rnfr'),
require('./registration/rnto'),
require('./registration/site'),
require('./registration/size'),
require('./registration/stat'),
require('./registration/stor'),
require('./registration/stou'),
require('./registration/stru'),
require('./registration/syst'),
require('./registration/type'),
require('./registration/user'),
require('./registration/pbsz'),
require('./registration/prot'),
require('./registration/eprt'),
require('./registration/epsv')
];
const registry = commands.reduce((result, cmd) => {
const aliases = Array.isArray(cmd.directive) ? cmd.directive : [cmd.directive];
aliases.forEach((alias) => result[alias] = cmd);
return result;
}, {});
module.exports = registry;

146
old/src/connection.js Normal file
View File

@@ -0,0 +1,146 @@
const _ = require('lodash');
const uuid = require('uuid');
const Promise = require('bluebird');
const EventEmitter = require('events');
const BaseConnector = require('./connector/base');
const FileSystem = require('./fs');
const Commands = require('./commands');
const errors = require('./errors');
const DEFAULT_MESSAGE = require('./messages');
class FtpConnection extends EventEmitter {
constructor(server, options) {
super();
this.server = server;
this.id = uuid.v4();
this.log = options.log.child({id: this.id, ip: this.ip});
this.commands = new Commands(this);
this.transferType = 'binary';
this.encoding = 'utf8';
this.bufferSize = false;
this._restByteCount = 0;
this._secure = false;
this.connector = new BaseConnector(this);
this.commandSocket = options.socket;
this.commandSocket.on('error', (err) => {
this.log.error(err, 'Client error');
this.server.emit('client-error', {connection: this, context: 'commandSocket', error: err});
});
this.commandSocket.on('data', this._handleData.bind(this));
this.commandSocket.on('timeout', () => {
this.log.trace('Client timeout');
this.close().catch((e) => this.log.trace(e, 'Client close error'));
});
this.commandSocket.on('close', () => {
if (this.connector) this.connector.end();
if (this.commandSocket && !this.commandSocket.destroyed) this.commandSocket.destroy();
this.removeAllListeners();
});
}
_handleData(data) {
const messages = _.compact(data.toString(this.encoding).split('\r\n'));
this.log.trace(messages);
return Promise.mapSeries(messages, (message) => this.commands.handle(message));
}
get ip() {
try {
return this.commandSocket ? this.commandSocket.remoteAddress : undefined;
} catch (ex) {
return null;
}
}
get restByteCount() {
return this._restByteCount > 0 ? this._restByteCount : undefined;
}
set restByteCount(rbc) {
this._restByteCount = rbc;
}
get secure() {
return this.server.isTLS || this._secure;
}
set secure(sec) {
this._secure = sec;
}
close(code = 421, message = 'Closing connection') {
return Promise.resolve(code)
.then((_code) => _code && this.reply(_code, message))
.then(() => this.commandSocket && this.commandSocket.end());
}
login(username, password) {
return Promise.try(() => {
const loginListeners = this.server.listeners('login');
if (!loginListeners || !loginListeners.length) {
if (!this.server.options.anonymous) throw new errors.GeneralError('No "login" listener setup', 500);
} else {
return this.server.emitPromise('login', {connection: this, username, password});
}
})
.then(({root, cwd, fs, blacklist = [], whitelist = []} = {}) => {
this.authenticated = true;
this.commands.blacklist = _.concat(this.commands.blacklist, blacklist);
this.commands.whitelist = _.concat(this.commands.whitelist, whitelist);
this.fs = fs || new FileSystem(this, {root, cwd});
});
}
reply(options = {}, ...letters) {
const satisfyParameters = () => {
if (typeof options === 'number') options = {code: options}; // allow passing in code as first param
if (!Array.isArray(letters)) letters = [letters];
if (!letters.length) letters = [{}];
return Promise.map(letters, (promise, index) => {
return Promise.resolve(promise)
.then((letter) => {
if (!letter) letter = {};
else if (typeof letter === 'string') letter = {message: letter}; // allow passing in message as first param
if (!letter.socket) letter.socket = options.socket ? options.socket : this.commandSocket;
if (!letter.message) letter.message = DEFAULT_MESSAGE[options.code] || 'No information';
if (!letter.encoding) letter.encoding = this.encoding;
return Promise.resolve(letter.message) // allow passing in a promise as a message
.then((message) => {
const seperator = !options.hasOwnProperty('eol') ?
letters.length - 1 === index ? ' ' : '-' :
options.eol ? ' ' : '-';
message = !letter.raw ? _.compact([letter.code || options.code, message]).join(seperator) : message;
letter.message = message;
return letter;
});
});
});
};
const processLetter = (letter) => {
return new Promise((resolve, reject) => {
if (letter.socket && letter.socket.writable) {
this.log.trace({port: letter.socket.address().port, encoding: letter.encoding, message: letter.message}, 'Reply');
letter.socket.write(letter.message + '\r\n', letter.encoding, (err) => {
if (err) {
this.log.error(err);
return reject(err);
}
resolve();
});
} else reject(new errors.SocketError('Socket not writable'));
});
};
return satisfyParameters()
.then((satisfiedLetters) => Promise.mapSeries(satisfiedLetters, (letter, index) => {
return processLetter(letter, index);
}))
.catch((err) => {
this.log.error(err);
});
}
}
module.exports = FtpConnection;

View File

@@ -0,0 +1,49 @@
const {Socket} = require('net');
const tls = require('tls');
const Promise = require('bluebird');
const Connector = require('./base');
class Active extends Connector {
constructor(connection) {
super(connection);
this.type = 'active';
}
waitForConnection({timeout = 5000, delay = 250} = {}) {
const checkSocket = () => {
if (this.dataSocket && this.dataSocket.connected) {
return Promise.resolve(this.dataSocket);
}
return Promise.resolve().delay(delay)
.then(() => checkSocket());
};
return checkSocket().timeout(timeout);
}
setupConnection(host, port, family = 4) {
const closeExistingServer = () => Promise.resolve(
this.dataSocket ? this.dataSocket.destroy() : undefined);
return closeExistingServer()
.then(() => {
this.dataSocket = new Socket();
this.dataSocket.setEncoding(this.connection.transferType);
this.dataSocket.on('error', (err) => this.server && this.server.emit('client-error', {connection: this.connection, context: 'dataSocket', error: err}));
this.dataSocket.connect({host, port, family}, () => {
this.dataSocket.pause();
if (this.connection.secure) {
const secureContext = tls.createSecureContext(this.server.options.tls);
const secureSocket = new tls.TLSSocket(this.dataSocket, {
isServer: true,
secureContext
});
this.dataSocket = secureSocket;
}
this.dataSocket.connected = true;
});
});
}
}
module.exports = Active;

53
old/src/connector/base.js Normal file
View File

@@ -0,0 +1,53 @@
const Promise = require('bluebird');
const errors = require('../errors');
class Connector {
constructor(connection) {
this.connection = connection;
this.dataSocket = null;
this.dataServer = null;
this.type = false;
}
get log() {
return this.connection.log;
}
get socket() {
return this.dataSocket;
}
get server() {
return this.connection.server;
}
waitForConnection() {
return Promise.reject(new errors.ConnectorError('No connector setup, send PASV or PORT'));
}
closeSocket() {
if (this.dataSocket) {
const socket = this.dataSocket;
this.dataSocket.end(() => socket.destroy());
this.dataSocket = null;
}
}
closeServer() {
if (this.dataServer) {
this.dataServer.close();
this.dataServer = null;
}
}
end() {
this.closeSocket();
this.closeServer();
this.type = false;
this.connection.connector = new Connector(this);
}
}
module.exports = Connector;

View File

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

44
old/src/errors.js Normal file
View File

@@ -0,0 +1,44 @@
class GeneralError extends Error {
constructor(message, code = 400) {
super();
this.code = code;
this.name = 'GeneralError';
this.message = message;
}
}
class SocketError extends Error {
constructor(message, code = 500) {
super();
this.code = code;
this.name = 'SocketError';
this.message = message;
}
}
class FileSystemError extends Error {
constructor(message, code = 400) {
super();
this.code = code;
this.name = 'FileSystemError';
this.message = message;
}
}
class ConnectorError extends Error {
constructor(message, code = 400) {
super();
this.code = code;
this.name = 'ConnectorError';
this.message = message;
}
}
module.exports = {
SocketError,
FileSystemError,
ConnectorError,
GeneralError
};

135
old/src/fs.js Normal file
View File

@@ -0,0 +1,135 @@
const _ = require('lodash');
const nodePath = require('path');
const uuid = require('uuid');
const Promise = require('bluebird');
const fs = Promise.promisifyAll(require('fs'));
const errors = require('./errors');
class FileSystem {
constructor(connection, {root, cwd} = {}) {
this.connection = connection;
this.cwd = cwd ? nodePath.join(nodePath.sep, cwd) : nodePath.sep;
this._root = nodePath.resolve(root || process.cwd());
}
get root() {
return this._root;
}
_resolvePath(path = '.') {
const clientPath = (() => {
path = nodePath.normalize(path);
if (nodePath.isAbsolute(path)) {
return nodePath.join(path);
} else {
return nodePath.join(this.cwd, path);
}
})();
const fsPath = (() => {
const resolvedPath = nodePath.resolve(this.root, `.${nodePath.sep}${clientPath}`);
return nodePath.join(resolvedPath);
})();
return {
clientPath,
fsPath
};
}
currentDirectory() {
return this.cwd;
}
get(fileName) {
const {fsPath} = this._resolvePath(fileName);
return fs.statAsync(fsPath)
.then((stat) => _.set(stat, 'name', fileName));
}
list(path = '.') {
const {fsPath} = this._resolvePath(path);
return fs.readdirAsync(fsPath)
.then((fileNames) => {
return Promise.map(fileNames, (fileName) => {
const filePath = nodePath.join(fsPath, fileName);
return fs.accessAsync(filePath, fs.constants.F_OK)
.then(() => {
return fs.statAsync(filePath)
.then((stat) => _.set(stat, 'name', fileName));
})
.catch(() => null);
});
})
.then(_.compact);
}
chdir(path = '.') {
const {fsPath, clientPath} = this._resolvePath(path);
return fs.statAsync(fsPath)
.tap((stat) => {
if (!stat.isDirectory()) throw new errors.FileSystemError('Not a valid directory');
})
.then(() => {
this.cwd = clientPath;
return this.currentDirectory();
});
}
write(fileName, {append = false, start = undefined} = {}) {
const {fsPath, clientPath} = this._resolvePath(fileName);
const stream = fs.createWriteStream(fsPath, {flags: !append ? 'w+' : 'a+', start});
stream.once('error', () => fs.unlinkAsync(fsPath));
stream.once('close', () => stream.end());
return {
stream,
clientPath
};
}
read(fileName, {start = undefined} = {}) {
const {fsPath, clientPath} = this._resolvePath(fileName);
return fs.statAsync(fsPath)
.tap((stat) => {
if (stat.isDirectory()) throw new errors.FileSystemError('Cannot read a directory');
})
.then(() => {
const stream = fs.createReadStream(fsPath, {flags: 'r', start});
return {
stream,
clientPath
};
});
}
delete(path) {
const {fsPath} = this._resolvePath(path);
return fs.statAsync(fsPath)
.then((stat) => {
if (stat.isDirectory()) return fs.rmdirAsync(fsPath);
else return fs.unlinkAsync(fsPath);
});
}
mkdir(path) {
const {fsPath} = this._resolvePath(path);
return fs.mkdirAsync(fsPath)
.then(() => fsPath);
}
rename(from, to) {
const {fsPath: fromPath} = this._resolvePath(from);
const {fsPath: toPath} = this._resolvePath(to);
return fs.renameAsync(fromPath, toPath);
}
chmod(path, mode) {
const {fsPath} = this._resolvePath(path);
return fs.chmodAsync(fsPath, mode);
}
getUniqueName() {
return uuid.v4().replace(/\W/g, '');
}
}
module.exports = FileSystem;

View File

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

View File

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

View File

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

152
old/src/index.js Normal file
View File

@@ -0,0 +1,152 @@
const _ = require('lodash');
const Promise = require('bluebird');
const nodeUrl = require('url');
const buyan = require('bunyan');
const net = require('net');
const tls = require('tls');
const EventEmitter = require('events');
const Connection = require('./connection');
const {getNextPortFactory} = require('./helpers/find-port');
class FtpServer extends EventEmitter {
constructor(options = {}) {
super();
this.options = Object.assign({
log: buyan.createLogger({name: 'ftp-srv'}),
url: 'ftp://127.0.0.1:21',
pasv_min: 1024,
pasv_max: 65535,
pasv_url: null,
anonymous: false,
file_format: 'ls',
blacklist: [],
whitelist: [],
greeting: null,
tls: false,
timeout: 0
}, options);
this._greeting = this.setupGreeting(this.options.greeting);
this._features = this.setupFeaturesMessage();
delete this.options.greeting;
this.connections = {};
this.log = this.options.log;
this.url = nodeUrl.parse(this.options.url);
this.getNextPasvPort = getNextPortFactory(
_.get(this, 'url.hostname'),
_.get(this, 'options.pasv_min'),
_.get(this, 'options.pasv_max'));
const timeout = Number(this.options.timeout);
this.options.timeout = isNaN(timeout) ? 0 : Number(timeout);
const serverConnectionHandler = (socket) => {
socket.setTimeout(this.options.timeout);
let connection = new Connection(this, {log: this.log, socket});
this.connections[connection.id] = connection;
socket.on('close', () => this.disconnectClient(connection.id));
const greeting = this._greeting || [];
const features = this._features || 'Ready';
return connection.reply(220, ...greeting, features)
.finally(() => socket.resume());
};
const serverOptions = Object.assign({}, this.isTLS ? this.options.tls : {}, {pauseOnConnect: true});
this.server = (this.isTLS ? tls : net).createServer(serverOptions, serverConnectionHandler);
this.server.on('error', (err) => this.log.error(err, '[Event] error'));
const quit = _.debounce(this.quit.bind(this), 100);
process.on('SIGTERM', quit);
process.on('SIGINT', quit);
process.on('SIGQUIT', quit);
}
get isTLS() {
return this.url.protocol === 'ftps:' && this.options.tls;
}
listen() {
if (!this.options.pasv_url) {
this.log.warn('Passive URL not set. Passive connections not available.');
}
return new Promise((resolve, reject) => {
this.server.once('error', reject);
this.server.listen(this.url.port, this.url.hostname, (err) => {
this.server.removeListener('error', reject);
if (err) return reject(err);
this.log.info({
protocol: this.url.protocol.replace(/\W/g, ''),
ip: this.url.hostname,
port: this.url.port
}, 'Listening');
resolve('Listening');
});
});
}
emitPromise(action, ...data) {
return new Promise((resolve, reject) => {
const params = _.concat(data, [resolve, reject]);
this.emit.call(this, action, ...params);
});
}
setupGreeting(greet) {
if (!greet) return [];
const greeting = Array.isArray(greet) ? greet : greet.split('\n');
return greeting;
}
setupFeaturesMessage() {
let features = [];
if (this.options.anonymous) features.push('a');
if (features.length) {
features.unshift('Features:');
features.push('.');
}
return features.length ? features.join(' ') : 'Ready';
}
disconnectClient(id) {
return new Promise((resolve) => {
const client = this.connections[id];
if (!client) return resolve();
delete this.connections[id];
try {
client.close(0);
} catch (err) {
this.log.error(err, 'Error closing connection', {id});
} finally {
resolve('Disconnected');
}
});
}
quit() {
return this.close()
.finally(() => process.exit(0));
}
close() {
this.log.info('Server closing...');
this.server.maxConnections = 0;
return Promise.map(Object.keys(this.connections), (id) => Promise.try(this.disconnectClient.bind(this, id)))
.then(() => new Promise((resolve) => {
this.server.close((err) => {
if (err) this.log.error(err, 'Error closing server');
resolve('Closed');
});
}))
.then(() => this.removeAllListeners());
}
}
module.exports = FtpServer;

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

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

View File

@@ -0,0 +1,125 @@
const {expect} = require('chai');
const Promise = require('bluebird');
const bunyan = require('bunyan');
const sinon = require('sinon');
const FtpCommands = require('../../src/commands');
describe('FtpCommands', function () {
let sandbox;
let commands;
let mockConnection = {
authenticated: false,
log: bunyan.createLogger({name: 'FtpCommands'}),
reply: () => Promise.resolve({}),
server: {
options: {
blacklist: ['allo']
}
}
};
beforeEach(() => {
sandbox = sinon.sandbox.create().usingPromise(Promise);
commands = new FtpCommands(mockConnection);
sandbox.spy(mockConnection, 'reply');
sandbox.spy(commands, 'handle');
sandbox.spy(commands, 'parse');
});
afterEach(() => {
sandbox.restore();
});
describe('parse', function () {
it('no args: test', () => {
const cmd = commands.parse('test');
expect(cmd.directive).to.equal('TEST');
expect(cmd.arg).to.equal(null);
expect(cmd.raw).to.equal('test');
});
it('one arg: test arg', () => {
const cmd = commands.parse('test arg');
expect(cmd.directive).to.equal('TEST');
expect(cmd.arg).to.equal('arg');
expect(cmd.raw).to.equal('test arg');
});
it('two args: test arg1 arg2', () => {
const cmd = commands.parse('test arg1 arg2');
expect(cmd.directive).to.equal('TEST');
expect(cmd.arg).to.equal('arg1 arg2');
expect(cmd.raw).to.equal('test arg1 arg2');
});
it('two args with quotes: test "hello world"', () => {
const cmd = commands.parse('test "hello world"');
expect(cmd.directive).to.equal('TEST');
expect(cmd.arg).to.equal('hello world');
expect(cmd.raw).to.equal('test "hello world"');
});
it('two args, with flags: test -l arg1 -A arg2 --zz88A', () => {
const cmd = commands.parse('test -l arg1 -A arg2 --zz88A');
expect(cmd.directive).to.equal('TEST');
expect(cmd.arg).to.equal('arg1 arg2 --zz88A');
expect(cmd.flags).to.deep.equal(['-l', '-A']);
expect(cmd.raw).to.equal('test -l arg1 -A arg2 --zz88A');
});
it('one arg, with flags: list -l', () => {
const cmd = commands.parse('list -l');
expect(cmd.directive).to.equal('LIST');
expect(cmd.arg).to.equal(null);
expect(cmd.flags).to.deep.equal(['-l']);
expect(cmd.raw).to.equal('list -l');
});
it('does not check for option flags', () => {
const cmd = commands.parse('retr -test');
expect(cmd.directive).to.equal('RETR');
expect(cmd.arg).to.equal('-test');
expect(cmd.flags).to.deep.equal([]);
});
});
describe('handle', function () {
it('fails with unsupported command', () => {
return commands.handle('bad')
.then(() => {
expect(mockConnection.reply.callCount).to.equal(1);
expect(mockConnection.reply.args[0][0]).to.equal(402);
});
});
it('fails with blacklisted command', () => {
return commands.handle('allo')
.then(() => {
expect(mockConnection.reply.callCount).to.equal(1);
expect(mockConnection.reply.args[0][0]).to.equal(502);
expect(mockConnection.reply.args[0][1]).to.match(/blacklisted/);
});
});
it('fails with non whitelisted command', () => {
commands.whitelist.push('USER');
return commands.handle('auth')
.then(() => {
expect(mockConnection.reply.callCount).to.equal(1);
expect(mockConnection.reply.args[0][0]).to.equal(502);
expect(mockConnection.reply.args[0][1]).to.match(/whitelisted/);
});
});
it('fails due to being unauthenticated', () => {
return commands.handle('stor')
.then(() => {
expect(mockConnection.reply.callCount).to.equal(1);
expect(mockConnection.reply.args[0][0]).to.equal(530);
expect(mockConnection.reply.args[0][1]).to.match(/authentication/);
});
});
});
});

View File

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

View File

@@ -0,0 +1,28 @@
const Promise = require('bluebird');
const {expect} = require('chai');
const sinon = require('sinon');
const CMD = 'ALLO';
describe(CMD, function () {
let sandbox;
const mockClient = {
reply: () => Promise.resolve()
};
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.spy(mockClient, 'reply');
});
afterEach(() => {
sandbox.restore();
});
it('// successful', () => {
return cmdFn()
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(202);
});
});
});

View File

@@ -0,0 +1,48 @@
const Promise = require('bluebird');
const {expect} = require('chai');
const sinon = require('sinon');
const CMD = 'AUTH';
describe(CMD, function () {
let sandbox;
const mockClient = {
reply: () => Promise.resolve(),
server: {
options: {
tls: {}
}
}
};
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.spy(mockClient, 'reply');
});
afterEach(() => {
sandbox.restore();
});
it('TLS // supported', () => {
return cmdFn({command: {arg: 'TLS', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(234);
expect(mockClient.secure).to.equal(true);
});
});
it('SSL // not supported', () => {
return cmdFn({command: {arg: 'SSL', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(504);
});
});
it('bad // bad', () => {
return cmdFn({command: {arg: 'bad', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(504);
});
});
});

View File

@@ -0,0 +1,35 @@
const Promise = require('bluebird');
const bunyan = require('bunyan');
const {expect} = require('chai');
const sinon = require('sinon');
const CMD = 'CDUP';
describe(CMD, function () {
let sandbox;
let log = bunyan.createLogger({name: CMD});
const mockClient = {
reply: () => Promise.resolve(),
fs: {
chdir: () => Promise.resolve()
}
};
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.spy(mockClient, 'reply');
sandbox.spy(mockClient.fs, 'chdir');
});
afterEach(() => {
sandbox.restore();
});
it('.. // successful', () => {
return cmdFn({log, command: {directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(250);
expect(mockClient.fs.chdir.args[0][0]).to.equal('..');
});
});
});

View File

@@ -0,0 +1,78 @@
const bunyan = require('bunyan');
const {expect} = require('chai');
const sinon = require('sinon');
const CMD = 'CWD';
describe(CMD, function () {
let sandbox;
let log = bunyan.createLogger({name: CMD});
const mockClient = {
reply: () => {},
fs: {chdir: () => {}}
};
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.stub(mockClient, 'reply').resolves();
sandbox.stub(mockClient.fs, 'chdir').resolves();
});
afterEach(() => {
sandbox.restore();
});
describe('// check', function () {
it('fails on no fs', () => {
const badMockClient = {reply: () => {}};
const badCmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
return badCmdFn()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(550);
});
});
it('fails on no fs chdir command', () => {
const badMockClient = {reply: () => {}, fs: {}};
const badCmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
return badCmdFn()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(402);
});
});
});
it('test // successful', () => {
return cmdFn({log, command: {arg: 'test', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(250);
expect(mockClient.fs.chdir.args[0][0]).to.equal('test');
});
});
it('test // successful', () => {
mockClient.fs.chdir.restore();
sandbox.stub(mockClient.fs, 'chdir').resolves('/test');
return cmdFn({log, command: {arg: 'test', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(250);
expect(mockClient.fs.chdir.args[0][0]).to.equal('test');
});
});
it('bad // unsuccessful', () => {
mockClient.fs.chdir.restore();
sandbox.stub(mockClient.fs, 'chdir').rejects(new Error('Bad'));
return cmdFn({log, command: {arg: 'bad', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(550);
expect(mockClient.fs.chdir.args[0][0]).to.equal('bad');
});
});
});

View File

@@ -0,0 +1,67 @@
const bunyan = require('bunyan');
const {expect} = require('chai');
const sinon = require('sinon');
const CMD = 'DELE';
describe(CMD, function () {
let sandbox;
let log = bunyan.createLogger({name: CMD});
const mockClient = {
reply: () => {},
fs: {delete: () => {}}
};
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.stub(mockClient, 'reply').resolves();
sandbox.stub(mockClient.fs, 'delete').resolves();
});
afterEach(() => {
sandbox.restore();
});
describe('// check', function () {
it('fails on no fs', () => {
const badMockClient = {reply: () => {}};
const badCmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
return badCmdFn()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(550);
});
});
it('fails on no fs delete command', () => {
const badMockClient = {reply: () => {}, fs: {}};
const badCmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
return badCmdFn()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(402);
});
});
});
it('test // successful', () => {
return cmdFn({log, command: {arg: 'test', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(250);
expect(mockClient.fs.delete.args[0][0]).to.equal('test');
});
});
it('bad // unsuccessful', () => {
mockClient.fs.delete.restore();
sandbox.stub(mockClient.fs, 'delete').rejects(new Error('Bad'));
return cmdFn({log, command: {arg: 'bad', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(550);
expect(mockClient.fs.delete.args[0][0]).to.equal('bad');
});
});
});

View File

@@ -0,0 +1,60 @@
const Promise = require('bluebird');
const {expect} = require('chai');
const sinon = require('sinon');
const ActiveConnector = require('../../../src/connector/active');
const CMD = 'EPRT';
describe(CMD, function () {
let sandbox;
const mockClient = {
reply: () => Promise.resolve()
};
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.spy(mockClient, 'reply');
sandbox.stub(ActiveConnector.prototype, 'setupConnection').resolves();
});
afterEach(() => {
sandbox.restore();
});
it('// unsuccessful | no argument', () => {
return cmdFn()
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(504);
});
});
it('// unsuccessful | invalid argument', () => {
return cmdFn({command: {arg: 'blah'}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(504);
});
});
it('// successful IPv4', () => {
return cmdFn({command: {arg: '|1|192.168.0.100|35286|'}})
.then(() => {
const [ip, port, family] = ActiveConnector.prototype.setupConnection.args[0];
expect(mockClient.reply.args[0][0]).to.equal(200);
expect(ip).to.equal('192.168.0.100');
expect(port).to.equal('35286');
expect(family).to.equal(4);
});
});
it('// successful IPv6', () => {
return cmdFn({command: {arg: '|2|8536:933f:e7f3:3e91:6dc1:e8c6:8482:7b23|35286|'}})
.then(() => {
const [ip, port, family] = ActiveConnector.prototype.setupConnection.args[0];
expect(mockClient.reply.args[0][0]).to.equal(200);
expect(ip).to.equal('8536:933f:e7f3:3e91:6dc1:e8c6:8482:7b23');
expect(port).to.equal('35286');
expect(family).to.equal(6);
});
});
});

View File

@@ -0,0 +1,35 @@
const Promise = require('bluebird');
const {expect} = require('chai');
const sinon = require('sinon');
const PassiveConnector = require('../../../src/connector/passive');
const CMD = 'EPSV';
describe(CMD, function () {
let sandbox;
const mockClient = {
reply: () => Promise.resolve()
};
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.stub(mockClient, 'reply').resolves();
sandbox.stub(PassiveConnector.prototype, 'setupServer').resolves({
address: () => ({port: 12345})
});
});
afterEach(() => {
sandbox.restore();
});
it('// successful IPv4', () => {
return cmdFn()
.then(() => {
const [code, message] = mockClient.reply.args[0];
expect(code).to.equal(229);
expect(message).to.equal('EPSV OK (|||12345|)');
});
});
});

View File

@@ -0,0 +1,49 @@
const Promise = require('bluebird');
const {expect} = require('chai');
const sinon = require('sinon');
const CMD = 'HELP';
describe(CMD, function () {
let sandbox;
const mockClient = {
reply: () => Promise.resolve()
};
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.spy(mockClient, 'reply');
});
afterEach(() => {
sandbox.restore();
});
it('// successful', () => {
return cmdFn({command: {directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(211);
});
});
it('help // successful', () => {
return cmdFn({command: {arg: 'help', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(214);
});
});
it('allo // successful', () => {
return cmdFn({command: {arg: 'allo', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(214);
});
});
it('bad // unsuccessful', () => {
return cmdFn({command: {arg: 'bad', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(502);
});
});
});

View File

@@ -0,0 +1,175 @@
const Promise = require('bluebird');
const bunyan = require('bunyan');
const {expect} = require('chai');
const sinon = require('sinon');
const CMD = 'LIST';
describe(CMD, function () {
let sandbox;
let log = bunyan.createLogger({name: CMD});
const mockClient = {
reply: () => {},
fs: {
list: () => {},
get: () => {}
},
connector: {
waitForConnection: () => Promise.resolve({}),
end: () => Promise.resolve({})
},
commandSocket: {
resume: () => {},
pause: () => {}
}
};
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.stub(mockClient, 'reply').resolves();
sandbox.stub(mockClient.fs, 'get').resolves({
name: 'testdir',
dev: 2114,
ino: 48064969,
mode: 33188,
nlink: 1,
uid: 85,
gid: 100,
rdev: 0,
size: 527,
blksize: 4096,
blocks: 8,
atime: 'Mon, 10 Oct 2011 23:24:11 GMT',
mtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
ctime: 'Mon, 10 Oct 2011 23:24:11 GMT',
birthtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
isDirectory: () => true
});
sandbox.stub(mockClient.fs, 'list').resolves([{
name: 'test1',
dev: 2114,
ino: 48064969,
mode: 33188,
nlink: 1,
uid: 85,
gid: 100,
rdev: 0,
size: 527,
blksize: 4096,
blocks: 8,
atime: 'Mon, 10 Oct 2011 23:24:11 GMT',
mtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
ctime: 'Mon, 10 Oct 2011 23:24:11 GMT',
birthtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
isDirectory: () => false
}, {
name: 'test2',
dev: 2114,
ino: 48064969,
mode: 33188,
nlink: 1,
uid: 85,
gid: 100,
rdev: 0,
size: 527,
blksize: 4096,
blocks: 8,
atime: 'Mon, 10 Oct 2011 23:24:11 GMT',
mtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
ctime: 'Mon, 10 Oct 2011 23:24:11 GMT',
birthtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
isDirectory: () => true
}]);
});
afterEach(() => {
sandbox.restore();
});
describe('// check', function () {
it('fails on no fs', () => {
const badMockClient = {reply: () => {}};
const badCmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
return badCmdFn()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(550);
});
});
it('fails on no fs list command', () => {
const badMockClient = {reply: () => {}, fs: {}};
const badCmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
return badCmdFn()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(402);
});
});
});
it('. // successful', () => {
return cmdFn({log, command: {directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(150);
expect(mockClient.reply.args[1].length).to.equal(3);
expect(mockClient.reply.args[1][1]).to.have.property('raw');
expect(mockClient.reply.args[1][1]).to.have.property('message');
expect(mockClient.reply.args[1][1]).to.have.property('socket');
expect(mockClient.reply.args[2][0]).to.equal(226);
});
});
it('testfile.txt // successful', () => {
mockClient.fs.get.restore();
sandbox.stub(mockClient.fs, 'get').resolves({
name: 'testfile.txt',
dev: 2114,
ino: 48064969,
mode: 33188,
nlink: 1,
uid: 85,
gid: 100,
rdev: 0,
size: 527,
blksize: 4096,
blocks: 8,
atime: 'Mon, 10 Oct 2011 23:24:11 GMT',
mtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
ctime: 'Mon, 10 Oct 2011 23:24:11 GMT',
birthtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
isDirectory: () => false
});
return cmdFn({log, command: {directive: CMD, arg: 'testfile.txt'}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(150);
expect(mockClient.reply.args[1].length).to.equal(2);
expect(mockClient.reply.args[1][1]).to.have.property('raw');
expect(mockClient.reply.args[1][1]).to.have.property('message');
expect(mockClient.reply.args[1][1]).to.have.property('socket');
expect(mockClient.reply.args[2][0]).to.equal(226);
});
});
it('. // unsuccessful', () => {
mockClient.fs.list.restore();
sandbox.stub(mockClient.fs, 'list').rejects(new Error());
return cmdFn({log, command: {directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(451);
});
});
it('. // unsuccessful (timeout)', () => {
sandbox.stub(mockClient.connector, 'waitForConnection').returns(Promise.reject(new Promise.TimeoutError()));
return cmdFn({log, command: {directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(425);
});
});
});

View File

@@ -0,0 +1,66 @@
const bunyan = require('bunyan');
const {expect} = require('chai');
const sinon = require('sinon');
const CMD = 'MDTM';
describe(CMD, function () {
let sandbox;
let log = bunyan.createLogger({name: CMD});
const mockClient = {
reply: () => {},
fs: {get: () => {}}
};
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.stub(mockClient, 'reply').resolves();
sandbox.stub(mockClient.fs, 'get').resolves({mtime: 'Mon, 10 Oct 2011 23:24:11 GMT'});
});
afterEach(() => {
sandbox.restore();
});
describe('// check', function () {
it('fails on no fs', () => {
const badMockClient = {reply: () => {}};
const badCmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
return badCmdFn()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(550);
});
});
it('fails on no fs get command', () => {
const badMockClient = {reply: () => {}, fs: {}};
const badCmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
return badCmdFn()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(402);
});
});
});
it('. // successful', () => {
return cmdFn({log, command: {directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(213);
//expect(mockClient.reply.args[0][1]).to.equal('20111010172411.000');
});
});
it('. // unsuccessful', () => {
mockClient.fs.get.restore();
sandbox.stub(mockClient.fs, 'get').rejects(new Error());
return cmdFn({log, command: {directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(550);
});
});
});

View File

@@ -0,0 +1,78 @@
const bunyan = require('bunyan');
const {expect} = require('chai');
const sinon = require('sinon');
const CMD = 'MKD';
describe(CMD, function () {
let sandbox;
let log = bunyan.createLogger({name: CMD});
const mockClient = {
reply: () => {},
fs: {mkdir: () => {}}
};
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.stub(mockClient, 'reply').resolves();
sandbox.stub(mockClient.fs, 'mkdir').resolves();
});
afterEach(() => {
sandbox.restore();
});
describe('// check', function () {
it('fails on no fs', () => {
const badMockClient = {reply: () => {}};
const badCmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
return badCmdFn()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(550);
});
});
it('fails on no fs mkdir command', () => {
const badMockClient = {reply: () => {}, fs: {}};
const badCmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(badMockClient);
sandbox.stub(badMockClient, 'reply').resolves();
return badCmdFn()
.then(() => {
expect(badMockClient.reply.args[0][0]).to.equal(402);
});
});
});
it('test // successful', () => {
return cmdFn({log, command: {arg: 'test', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(257);
expect(mockClient.fs.mkdir.args[0][0]).to.equal('test');
});
});
it('test // successful', () => {
mockClient.fs.mkdir.restore();
sandbox.stub(mockClient.fs, 'mkdir').resolves('test');
return cmdFn({log, command: {arg: 'test', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(257);
expect(mockClient.fs.mkdir.args[0][0]).to.equal('test');
});
});
it('bad // unsuccessful', () => {
mockClient.fs.mkdir.restore();
sandbox.stub(mockClient.fs, 'mkdir').rejects(new Error('Bad'));
return cmdFn({log, command: {arg: 'bad', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(550);
expect(mockClient.fs.mkdir.args[0][0]).to.equal('bad');
});
});
});

View File

@@ -0,0 +1,35 @@
const Promise = require('bluebird');
const {expect} = require('chai');
const sinon = require('sinon');
const CMD = 'MODE';
describe(CMD, function () {
let sandbox;
const mockClient = {
reply: () => Promise.resolve()
};
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.spy(mockClient, 'reply');
});
afterEach(() => {
sandbox.restore();
});
it('S // successful', () => {
return cmdFn({command: {arg: 'S'}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(200);
});
});
it('Q // unsuccessful', () => {
return cmdFn({command: {arg: 'Q'}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(504);
});
});
});

View File

@@ -0,0 +1,132 @@
const Promise = require('bluebird');
const bunyan = require('bunyan');
const {expect} = require('chai');
const sinon = require('sinon');
const CMD = 'NLST';
describe(CMD, function () {
let sandbox;
let log = bunyan.createLogger({name: CMD});
const mockClient = {
reply: () => {},
fs: {
get: () => {},
list: () => {}
},
connector: {
waitForConnection: () => Promise.resolve({}),
end: () => Promise.resolve({})
},
commandSocket: {
resume: () => {},
pause: () => {}
}
};
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.stub(mockClient, 'reply').resolves();
sandbox.stub(mockClient.fs, 'get').resolves({
name: 'testdir',
dev: 2114,
ino: 48064969,
mode: 33188,
nlink: 1,
uid: 85,
gid: 100,
rdev: 0,
size: 527,
blksize: 4096,
blocks: 8,
atime: 'Mon, 10 Oct 2011 23:24:11 GMT',
mtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
ctime: 'Mon, 10 Oct 2011 23:24:11 GMT',
birthtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
isDirectory: () => true
});
sandbox.stub(mockClient.fs, 'list').resolves([{
name: 'test1',
dev: 2114,
ino: 48064969,
mode: 33188,
nlink: 1,
uid: 85,
gid: 100,
rdev: 0,
size: 527,
blksize: 4096,
blocks: 8,
atime: 'Mon, 10 Oct 2011 23:24:11 GMT',
mtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
ctime: 'Mon, 10 Oct 2011 23:24:11 GMT',
birthtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
isDirectory: () => false
}, {
name: 'test2',
dev: 2114,
ino: 48064969,
mode: 33188,
nlink: 1,
uid: 85,
gid: 100,
rdev: 0,
size: 527,
blksize: 4096,
blocks: 8,
atime: 'Mon, 10 Oct 2011 23:24:11 GMT',
mtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
ctime: 'Mon, 10 Oct 2011 23:24:11 GMT',
birthtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
isDirectory: () => true
}]);
});
afterEach(() => {
sandbox.restore();
});
it('. // successful', () => {
return cmdFn({log, command: {directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(150);
expect(mockClient.reply.args[1].length).to.equal(3);
expect(mockClient.reply.args[1][1]).to.have.property('raw');
expect(mockClient.reply.args[1][1]).to.have.property('message');
expect(mockClient.reply.args[1][1]).to.have.property('socket');
expect(mockClient.reply.args[2][0]).to.equal(226);
});
});
it('testfile.txt // successful', () => {
mockClient.fs.get.restore();
sandbox.stub(mockClient.fs, 'get').resolves({
name: 'testfile.txt',
dev: 2114,
ino: 48064969,
mode: 33188,
nlink: 1,
uid: 85,
gid: 100,
rdev: 0,
size: 527,
blksize: 4096,
blocks: 8,
atime: 'Mon, 10 Oct 2011 23:24:11 GMT',
mtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
ctime: 'Mon, 10 Oct 2011 23:24:11 GMT',
birthtime: 'Mon, 10 Oct 2011 23:24:11 GMT',
isDirectory: () => false
});
return cmdFn({log, command: {directive: CMD, arg: 'testfile.txt'}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(150);
expect(mockClient.reply.args[1].length).to.equal(2);
expect(mockClient.reply.args[1][1]).to.have.property('raw');
expect(mockClient.reply.args[1][1]).to.have.property('message');
expect(mockClient.reply.args[1][1]).to.have.property('socket');
expect(mockClient.reply.args[2][0]).to.equal(226);
});
});
});

View File

@@ -0,0 +1,28 @@
const Promise = require('bluebird');
const {expect} = require('chai');
const sinon = require('sinon');
const CMD = 'NOOP';
describe(CMD, function () {
let sandbox;
const mockClient = {
reply: () => Promise.resolve()
};
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.spy(mockClient, 'reply');
});
afterEach(() => {
sandbox.restore();
});
it('// successful', () => {
return cmdFn()
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(200);
});
});
});

View File

@@ -0,0 +1,58 @@
const Promise = require('bluebird');
const {expect} = require('chai');
const sinon = require('sinon');
const CMD = 'OPTS';
describe(CMD, function () {
let sandbox;
const mockClient = {
reply: () => Promise.resolve()
};
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create().usingPromise(Promise);
sandbox.spy(mockClient, 'reply');
});
afterEach(() => {
sandbox.restore();
});
it('// unsuccessful', () => {
return cmdFn()
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(501);
});
});
it('BAD // unsuccessful', () => {
return cmdFn({command: {arg: 'BAD', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(500);
});
});
it('UTF8 BAD // unsuccessful', () => {
return cmdFn({command: {arg: 'UTF8 BAD', directive: CMD}})
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(501);
});
});
it('UTF8 OFF // successful', () => {
return cmdFn({command: {arg: 'UTF8 OFF', directive: CMD}})
.then(() => {
expect(mockClient.encoding).to.equal('ascii');
expect(mockClient.reply.args[0][0]).to.equal(200);
});
});
it('UTF8 ON // successful', () => {
return cmdFn({command: {arg: 'UTF8 ON', directive: CMD}})
.then(() => {
expect(mockClient.encoding).to.equal('utf8');
expect(mockClient.reply.args[0][0]).to.equal(200);
});
});
});

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