Compare commits
2 Commits
typescript
...
patch-3.1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddc2dc7803 | ||
|
|
5508c2346c |
@@ -1,97 +1,103 @@
|
||||
version: 2
|
||||
|
||||
create-cache-file: &create-cache-file
|
||||
run:
|
||||
name: Setup cache
|
||||
command: echo "$NODE_VERSION" > _cache_node_version
|
||||
|
||||
package-json-cache: &package-json-cache
|
||||
key: npm-install-{{ checksum "_cache_node_version" }}-{{ checksum "package-lock.json" }}
|
||||
|
||||
base-build: &base-build
|
||||
steps:
|
||||
- checkout
|
||||
- <<: *create-cache-file
|
||||
- restore_cache:
|
||||
<<: *package-json-cache
|
||||
- run:
|
||||
name: Install
|
||||
command: npm install
|
||||
- save_cache:
|
||||
<<: *package-json-cache
|
||||
paths:
|
||||
- node_modules
|
||||
- run:
|
||||
name: Lint
|
||||
command: npm run verify:js
|
||||
- run:
|
||||
name: Test
|
||||
command: npm run test:once
|
||||
|
||||
jobs:
|
||||
build:
|
||||
test_node_10:
|
||||
docker:
|
||||
- image: &node-image circleci/node:lts
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
keys:
|
||||
- &npm-cache-key npm-cache-{{ .Branch }}-{{ .Revision }}
|
||||
- npm-cache-{{ .Branch }}
|
||||
- npm-cache
|
||||
- run:
|
||||
name: Install
|
||||
command: npm ci
|
||||
- persist_to_workspace:
|
||||
root: .
|
||||
paths:
|
||||
- node_modules
|
||||
- save_cache:
|
||||
key: *npm-cache-key
|
||||
paths:
|
||||
- ~/.npm/_cacache
|
||||
- image: circleci/node:10
|
||||
environment:
|
||||
- NODE_VERSION: 10
|
||||
<<: *base-build
|
||||
|
||||
lint:
|
||||
test_node_8:
|
||||
docker:
|
||||
- image: *node-image
|
||||
steps:
|
||||
- checkout
|
||||
- attach_workspace:
|
||||
at: .
|
||||
- run:
|
||||
name: Lint
|
||||
command: npm run verify
|
||||
- image: circleci/node:8
|
||||
environment:
|
||||
- NODE_VERSION: 8
|
||||
<<: *base-build
|
||||
|
||||
test:
|
||||
test_node_6:
|
||||
docker:
|
||||
- image: *node-image
|
||||
steps:
|
||||
- checkout
|
||||
- attach_workspace:
|
||||
at: .
|
||||
- deploy:
|
||||
name: Test
|
||||
command: |
|
||||
npm run test
|
||||
|
||||
release_dry_run:
|
||||
docker:
|
||||
- image: *node-image
|
||||
steps:
|
||||
- checkout
|
||||
- attach_workspace:
|
||||
at: .
|
||||
- deploy:
|
||||
name: Dry Release
|
||||
command: |
|
||||
npm run semantic-release -- --dry-run
|
||||
- image: circleci/node:6
|
||||
environment:
|
||||
- NODE_VERSION: 6
|
||||
<<: *base-build
|
||||
|
||||
release:
|
||||
docker:
|
||||
- image: *node-image
|
||||
- image: circleci/node:8
|
||||
environment:
|
||||
- NODE_VERSION: 8
|
||||
steps:
|
||||
- checkout
|
||||
- attach_workspace:
|
||||
at: .
|
||||
- <<: *create-cache-file
|
||||
- restore_cache:
|
||||
<<: *package-json-cache
|
||||
- deploy:
|
||||
name: Release
|
||||
name: Semantic Release
|
||||
command: |
|
||||
npm run semantic-release
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
publish:
|
||||
test_and_tag:
|
||||
jobs:
|
||||
- build
|
||||
- lint:
|
||||
requires:
|
||||
- build
|
||||
- test:
|
||||
requires:
|
||||
- build
|
||||
- release_dry_run:
|
||||
- test_node_10:
|
||||
filters:
|
||||
branches:
|
||||
only: master
|
||||
- test_node_8:
|
||||
filters:
|
||||
branches:
|
||||
only: master
|
||||
- test_node_6:
|
||||
filters:
|
||||
branches:
|
||||
only: master
|
||||
requires:
|
||||
- test
|
||||
- lint
|
||||
- hold_release:
|
||||
type: approval
|
||||
requires:
|
||||
- release_dry_run
|
||||
- release:
|
||||
requires:
|
||||
- hold_release
|
||||
- test_node_6
|
||||
- test_node_8
|
||||
- test_node_10
|
||||
|
||||
build_and_test:
|
||||
jobs:
|
||||
- test_node_10:
|
||||
filters:
|
||||
branches:
|
||||
ignore: master
|
||||
- test_node_8:
|
||||
filters:
|
||||
branches:
|
||||
ignore: master
|
||||
- test_node_6:
|
||||
filters:
|
||||
branches:
|
||||
ignore: master
|
||||
9
.eslintignore
Normal file
9
.eslintignore
Normal file
@@ -0,0 +1,9 @@
|
||||
# START_CONFIT_GENERATED_CONTENT
|
||||
# Common folders to ignore
|
||||
node_modules/*
|
||||
bower_components/*
|
||||
|
||||
# Config folder (optional - you might want to lint this...)
|
||||
config/*
|
||||
|
||||
# END_CONFIT_GENERATED_CONTENT
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
test_tmp/
|
||||
|
||||
node_modules/
|
||||
|
||||
dist/
|
||||
reports/
|
||||
npm-debug.log
|
||||
.nyc_output/
|
||||
test_tmp/
|
||||
@@ -16,10 +16,5 @@
|
||||
|
||||
- Any new fixes are features should include new or updated [tests](/test).
|
||||
- Commits follow the [AngularJS Git Commit Message Conventions](https://docs.google.com/document/d/1QrDFcIiPjSLDn3EL15IJygNPiHORgU1_OOAqWjiDU5Y/edit), please review and commit accordingly
|
||||
- Submit your pull requests to the `master` branch, these will normally be merged into a separate branch for any finally changes before being merged into `master`.
|
||||
- Submit any bugs or requests to the issues page in Github.
|
||||
|
||||
## Setup
|
||||
|
||||
- Clone the repository `git clone`
|
||||
- Install dependencies `npm install`
|
||||
- Submit your pull requests to the `master` branch, these will normally be merged into a seperate branch for any finally changes before being merged into `master`.
|
||||
- Submit any bugs or requests to the issues page in Github.
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 Tyler Stewart
|
||||
Copyright (c) 2018 Tyler Stewart
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
352
README.md
352
README.md
@@ -0,0 +1,352 @@
|
||||
<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.
|
||||
|
||||
## 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`
|
||||
|
||||
<!--[RM_CONTRIBUTING]-->
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
<!--[]-->
|
||||
|
||||
## Contributors
|
||||
|
||||
- [OzairP](https://github.com/OzairP)
|
||||
- [qchar](https://github.com/qchar)
|
||||
- [jorinvo](https://github.com/jorinvo)
|
||||
- [voxsoftware](https://github.com/voxsoftware)
|
||||
- [pkeuter](https://github.com/pkeuter)
|
||||
- [TimLuq](https://github.com/TimLuq)
|
||||
- [edin-mg](https://github.com/edin-m)
|
||||
- [DiegoRBaquero](https://github.com/DiegoRBaquero)
|
||||
- [Johnnyrook777](https://github.com/Johnnyrook777)
|
||||
|
||||
<!--[RM_LICENSE]-->
|
||||
## License
|
||||
|
||||
This software is licensed under the MIT Licence. See [LICENSE](LICENSE).
|
||||
|
||||
<!--[]-->
|
||||
|
||||
## References
|
||||
|
||||
- [https://cr.yp.to/ftp.html](https://cr.yp.to/ftp.html)
|
||||
|
||||
@@ -113,15 +113,11 @@ function setupState(_args) {
|
||||
}
|
||||
|
||||
function startFtpServer(_state) {
|
||||
// Remove null/undefined options so they get set to defaults, below
|
||||
for (const key in _state) {
|
||||
if (_state[key] === undefined) delete _state[key];
|
||||
}
|
||||
|
||||
function checkLogin(data, resolve, reject) {
|
||||
const user = _state.credentials[data.username];
|
||||
if (_state.anonymous || user && user.password === data.password) {
|
||||
return resolve({root: user && user.root || _state.root});
|
||||
const 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));
|
||||
38
config/release/commitMessageConfig.js
Normal file
38
config/release/commitMessageConfig.js
Normal file
@@ -0,0 +1,38 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
|
||||
types: [
|
||||
{value: 'feat', name: 'feat: A new feature'},
|
||||
{value: 'fix', name: 'fix: A bug fix'},
|
||||
{value: 'docs', name: 'docs: Documentation only changes'},
|
||||
{value: 'style', name: 'style: Changes that do not affect the meaning of the code\n (white-space, formatting, missing semi-colons, etc)'},
|
||||
{value: 'refactor', name: 'refactor: A code change that neither fixes a bug nor adds a feature'},
|
||||
{value: 'perf', name: 'perf: A code change that improves performance'},
|
||||
{value: 'test', name: 'test: Adding missing tests'},
|
||||
{value: 'chore', name: 'chore: Changes to the build process or auxiliary tools\n and libraries such as documentation generation'},
|
||||
{value: 'revert', name: 'revert: Revert to a commit'},
|
||||
{value: 'WIP', name: 'WIP: Work in progress'}
|
||||
],
|
||||
|
||||
scopes: [],
|
||||
|
||||
// it needs to match the value for field type. Eg.: 'fix'
|
||||
/*
|
||||
scopeOverrides: {
|
||||
fix: [
|
||||
|
||||
{name: 'merge'},
|
||||
{name: 'style'},
|
||||
{name: 'e2eTest'},
|
||||
{name: 'unitTest'}
|
||||
]
|
||||
},
|
||||
*/
|
||||
|
||||
allowCustomScopes: true,
|
||||
allowBreakingChanges: ['feat', 'fix'],
|
||||
|
||||
// Appends the branch name to the footer of the commit. Useful for tracking commits after branches have been merged
|
||||
appendBranchNameToCommitMessage: false
|
||||
};
|
||||
5
config/testUnit/mocha.opts
Normal file
5
config/testUnit/mocha.opts
Normal file
@@ -0,0 +1,5 @@
|
||||
test/**/*.spec.js
|
||||
--reporter mocha-multi-reporters
|
||||
--reporter-options configFile=config/testUnit/reporters.json
|
||||
--ui bdd
|
||||
--bail
|
||||
6
config/testUnit/reporters.json
Normal file
6
config/testUnit/reporters.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"reporterEnabled": "spec",
|
||||
"mochaJunitReporterReporterOptions": {
|
||||
"mochaFile": "reports/junit.xml"
|
||||
}
|
||||
}
|
||||
162
config/verify/.eslintrc
Normal file
162
config/verify/.eslintrc
Normal file
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"extends": "eslint:recommended",
|
||||
"env": {
|
||||
"node": true,
|
||||
"mocha": true,
|
||||
"es6": true
|
||||
},
|
||||
"plugins": [
|
||||
"mocha",
|
||||
"node"
|
||||
],
|
||||
"rules": {
|
||||
"mocha/no-exclusive-tests": 2,
|
||||
"no-warning-comments": [
|
||||
1,
|
||||
{
|
||||
"terms": ["todo", "fixme", "xxx"],
|
||||
"location": "start"
|
||||
},
|
||||
],
|
||||
"object-curly-spacing": [
|
||||
2,
|
||||
"never"
|
||||
],
|
||||
"array-bracket-spacing": [
|
||||
2,
|
||||
"never"
|
||||
],
|
||||
"brace-style": [
|
||||
2,
|
||||
"1tbs"
|
||||
],
|
||||
"consistent-return": 0,
|
||||
"indent": [
|
||||
"error",
|
||||
2,
|
||||
{
|
||||
"SwitchCase": 1,
|
||||
"MemberExpression": "off"
|
||||
}
|
||||
],
|
||||
"no-multiple-empty-lines": [
|
||||
2,
|
||||
{
|
||||
"max": 2
|
||||
}
|
||||
],
|
||||
"no-use-before-define": [
|
||||
2,
|
||||
"nofunc"
|
||||
],
|
||||
"one-var": [
|
||||
2,
|
||||
"never"
|
||||
],
|
||||
"quote-props": [
|
||||
2,
|
||||
"as-needed"
|
||||
],
|
||||
"quotes": [
|
||||
2,
|
||||
"single"
|
||||
],
|
||||
"keyword-spacing": 2,
|
||||
"space-before-function-paren": [
|
||||
2,
|
||||
{
|
||||
"anonymous": "always",
|
||||
"named": "never"
|
||||
}
|
||||
],
|
||||
"space-in-parens": [
|
||||
2,
|
||||
"never"
|
||||
],
|
||||
"strict": [
|
||||
2,
|
||||
"global"
|
||||
],
|
||||
"curly": [
|
||||
2,
|
||||
"multi-line"
|
||||
],
|
||||
"eol-last": 2,
|
||||
"key-spacing": [
|
||||
2,
|
||||
{
|
||||
"beforeColon": false,
|
||||
"afterColon": true
|
||||
}
|
||||
],
|
||||
"no-eval": 2,
|
||||
"no-with": 2,
|
||||
"space-infix-ops": 2,
|
||||
"dot-notation": [
|
||||
2,
|
||||
{
|
||||
"allowKeywords": true
|
||||
}
|
||||
],
|
||||
"eqeqeq": 2,
|
||||
"no-alert": 2,
|
||||
"no-caller": 2,
|
||||
"no-extend-native": 2,
|
||||
"no-extra-bind": 2,
|
||||
"no-implied-eval": 2,
|
||||
"no-iterator": 2,
|
||||
"no-label-var": 2,
|
||||
"no-labels": 2,
|
||||
"no-lone-blocks": 2,
|
||||
"no-loop-func": 2,
|
||||
"no-multi-spaces": 2,
|
||||
"no-multi-str": 2,
|
||||
"no-native-reassign": 2,
|
||||
"no-new": 2,
|
||||
"no-new-func": 2,
|
||||
"no-new-wrappers": 2,
|
||||
"no-octal-escape": 2,
|
||||
"no-proto": 2,
|
||||
"no-return-assign": 2,
|
||||
"no-script-url": 2,
|
||||
"no-sequences": 2,
|
||||
"no-unused-expressions": 2,
|
||||
"yoda": 2,
|
||||
"no-shadow": 2,
|
||||
"no-shadow-restricted-names": 2,
|
||||
"no-undef-init": 2,
|
||||
"no-console": 1,
|
||||
"camelcase": [
|
||||
0,
|
||||
{
|
||||
"properties": "never"
|
||||
}
|
||||
],
|
||||
"comma-spacing": 2,
|
||||
"comma-dangle": 1,
|
||||
"new-cap": 2,
|
||||
"new-parens": 2,
|
||||
"arrow-parens": [2, "always"],
|
||||
"no-array-constructor": 2,
|
||||
"array-callback-return": 1,
|
||||
"no-extra-parens": 2,
|
||||
"no-new-object": 2,
|
||||
"no-spaced-func": 2,
|
||||
"no-trailing-spaces": 2,
|
||||
"no-underscore-dangle": 0,
|
||||
"no-fallthrough": 0,
|
||||
"semi": 2,
|
||||
"semi-spacing": [
|
||||
2,
|
||||
{
|
||||
"before": false,
|
||||
"after": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"parserOptions": {
|
||||
"emcaVersion": 6,
|
||||
"sourceType": "module",
|
||||
"impliedStrict": true
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import FTPServer from '../src/server';
|
||||
|
||||
const server = new FTPServer();
|
||||
server.registerPlugin({
|
||||
command: 'PASS',
|
||||
handler: async ({connection, reply}) => {
|
||||
const username = connection.getContext('username');
|
||||
const password = connection.getContext('password');
|
||||
|
||||
// AUTHENTICATE
|
||||
|
||||
reply.set([230]);
|
||||
}
|
||||
});
|
||||
server.listen();
|
||||
101
old/ftp-srv.d.ts → ftp-srv.d.ts
vendored
101
old/ftp-srv.d.ts → ftp-srv.d.ts
vendored
@@ -4,9 +4,9 @@ import { EventEmitter } from 'events';
|
||||
|
||||
export class FileSystem {
|
||||
|
||||
readonly connection: FtpConnection;
|
||||
readonly root: string;
|
||||
readonly cwd: string;
|
||||
readonly connection: FtpConnection;
|
||||
readonly root: string;
|
||||
readonly cwd: string;
|
||||
|
||||
constructor(connection: FtpConnection, {root, cwd}?: {
|
||||
root: any;
|
||||
@@ -42,35 +42,34 @@ export class FileSystem {
|
||||
}
|
||||
|
||||
export class FtpConnection extends EventEmitter {
|
||||
server: FtpServer;
|
||||
id: string;
|
||||
log: any;
|
||||
transferType: string;
|
||||
encoding: string;
|
||||
bufferSize: boolean;
|
||||
readonly ip: string;
|
||||
restByteCount: number | undefined;
|
||||
secure: boolean
|
||||
server: FtpServer;
|
||||
id: string;
|
||||
log: any;
|
||||
transferType: string;
|
||||
encoding: string;
|
||||
bufferSize: boolean;
|
||||
readonly ip: string;
|
||||
restByteCount: number | undefined;
|
||||
secure: boolean
|
||||
|
||||
close (code: number, message: number): Promise<any>
|
||||
login (username: string, password: string): Promise<any>
|
||||
reply (options: number | Record<string, any>, ...letters: any[]): Promise<any>
|
||||
close (code: number, message: number): Promise<any>
|
||||
login (username: string, password: string): Promise<any>
|
||||
reply (options: number | Object, ...letters: Array<any>): Promise<any>
|
||||
|
||||
}
|
||||
|
||||
export interface FtpServerOptions {
|
||||
url?: string;
|
||||
pasv_min?: number;
|
||||
pasv_max?: number;
|
||||
pasv_url?: string;
|
||||
greeting?: string | string[];
|
||||
tls?: tls.SecureContextOptions | false;
|
||||
anonymous?: boolean;
|
||||
blacklist?: string[];
|
||||
whitelist?: string[];
|
||||
file_format?: (stat: Stats) => string | Promise<string> | "ls" | "ep";
|
||||
log?: any;
|
||||
timeout?: number;
|
||||
url?: string,
|
||||
pasv_min?: number,
|
||||
pasv_max?: number,
|
||||
pasv_url?: string,
|
||||
greeting?: string | string[],
|
||||
tls?: tls.SecureContextOptions | false,
|
||||
anonymous?: boolean,
|
||||
blacklist?: Array<string>,
|
||||
whitelist?: Array<string>,
|
||||
file_format?: (stat: Stats) => string | Promise<string> | "ls" | "ep",
|
||||
log?: any,
|
||||
}
|
||||
|
||||
export class FtpServer extends EventEmitter {
|
||||
@@ -85,9 +84,9 @@ export class FtpServer extends EventEmitter {
|
||||
// emit is exported from super class
|
||||
|
||||
setupTLS(_tls: boolean): boolean | {
|
||||
cert: string;
|
||||
key: string;
|
||||
ca: string;
|
||||
cert: string;
|
||||
key: string;
|
||||
ca: string
|
||||
};
|
||||
|
||||
setupGreeting(greet: string): string[];
|
||||
@@ -98,29 +97,29 @@ export class FtpServer extends EventEmitter {
|
||||
|
||||
close(): any;
|
||||
|
||||
on(event: "login", listener: (
|
||||
data: {
|
||||
connection: FtpConnection;
|
||||
username: string;
|
||||
password: string;
|
||||
},
|
||||
resolve: (config: {
|
||||
fs?: FileSystem;
|
||||
root?: string;
|
||||
cwd?: string;
|
||||
blacklist?: string[];
|
||||
whitelist?: string[];
|
||||
on(event: "login", listener: (
|
||||
data: {
|
||||
connection: FtpConnection,
|
||||
username: string,
|
||||
password: string
|
||||
},
|
||||
resolve: (config: {
|
||||
fs?: FileSystem,
|
||||
root?: string,
|
||||
cwd?: string,
|
||||
blacklist?: Array<string>,
|
||||
whitelist?: Array<string>
|
||||
}) => void,
|
||||
reject: (err?: Error) => void
|
||||
) => void): this;
|
||||
reject: (err?: Error) => void
|
||||
) => void): this;
|
||||
|
||||
on(event: "client-error", listener: (
|
||||
data: {
|
||||
connection: FtpConnection;
|
||||
context: string;
|
||||
error: Error;
|
||||
}
|
||||
) => void): this;
|
||||
on(event: "client-error", listener: (
|
||||
data: {
|
||||
connection: FtpConnection,
|
||||
context: string,
|
||||
error: Error,
|
||||
}
|
||||
) => void): this;
|
||||
}
|
||||
|
||||
export {FtpServer as FtpSrv};
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
@@ -1,2 +0,0 @@
|
||||
node_modules/*
|
||||
bower_components/*
|
||||
358
old/README.md
358
old/README.md
@@ -1,358 +0,0 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/trs/ftp-srv">
|
||||
<img alt="ftp-srv" src="logo.png" width="600px" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
<p align="center">
|
||||
Modern, extensible FTP Server
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/ftp-srv">
|
||||
<img alt="npm" src="https://img.shields.io/npm/dm/ftp-srv.svg?style=for-the-badge" />
|
||||
</a>
|
||||
|
||||
<a href="https://circleci.com/gh/trs/workflows/ftp-srv/tree/master">
|
||||
<img alt="circleci" src="https://img.shields.io/circleci/project/github/trs/ftp-srv/master.svg?style=for-the-badge" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Features](#features)
|
||||
- [Install](#install)
|
||||
- [Usage](#usage)
|
||||
- [API](#api)
|
||||
- [CLI](#cli)
|
||||
- [Events](#events)
|
||||
- [Supported Commands](#supported-commands)
|
||||
- [File System](#file-system)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
|
||||
## Overview
|
||||
`ftp-srv` is a modern and extensible FTP server designed to be simple yet configurable.
|
||||
|
||||
## Features
|
||||
- Extensible [file systems](#file-system) per connection
|
||||
- Passive and active transfers
|
||||
- [Explicit](https://en.wikipedia.org/wiki/FTPS#Explicit) & [Implicit](https://en.wikipedia.org/wiki/FTPS#Implicit) TLS connections
|
||||
- Promise based API
|
||||
|
||||
## Install
|
||||
`npm install ftp-srv --save`
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
// Quick start
|
||||
|
||||
const FtpSrv = require('ftp-srv');
|
||||
const ftpServer = new FtpSrv({ options ... });
|
||||
|
||||
ftpServer.on('login', (data, resolve, reject) => { ... });
|
||||
...
|
||||
|
||||
ftpServer.listen()
|
||||
.then(() => { ... });
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `new FtpSrv({options})`
|
||||
#### url
|
||||
[URL string](https://nodejs.org/api/url.html#url_url_strings_and_url_objects) indicating the protocol, hostname, and port to listen on for connections.
|
||||
Supported protocols:
|
||||
- `ftp` Plain FTP
|
||||
- `ftps` Implicit FTP over TLS
|
||||
|
||||
_Note:_ The hostname must be the external IP address to accept external connections. `0.0.0.0` will listen on any available hosts for server and passive connections.
|
||||
__Default:__ `"ftp://127.0.0.1:21"`
|
||||
|
||||
#### `pasv_url`
|
||||
The hostname to provide a client when attempting a passive connection (`PASV`). This defaults to the provided `url` hostname.
|
||||
|
||||
_Note:_ If set to `0.0.0.0`, this will automatically resolve to the external IP of the box.
|
||||
__Default:__ `"127.0.0.1"`
|
||||
|
||||
#### `pasv_min`
|
||||
Tne starting port to accept passive connections.
|
||||
__Default:__ `1024`
|
||||
|
||||
#### `pasv_max`
|
||||
The ending port to accept passive connections.
|
||||
The range is then queried for an available port to use when required.
|
||||
__Default:__ `65535`
|
||||
|
||||
#### `greeting`
|
||||
A human readable array of lines or string to send when a client connects.
|
||||
__Default:__ `null`
|
||||
|
||||
#### `tls`
|
||||
Node [TLS secure context object](https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options) used for implicit (`ftps` protocol) or explicit (`AUTH TLS`) connections.
|
||||
__Default:__ `false`
|
||||
|
||||
#### `anonymous`
|
||||
If true, will allow clients to authenticate using the username `anonymous`, not requiring a password from the user.
|
||||
Can also set as a string which allows users to authenticate using the username provided.
|
||||
The `login` event is then sent with the provided username and `@anonymous` as the password.
|
||||
__Default:__ `false`
|
||||
|
||||
#### `blacklist`
|
||||
Array of commands that are not allowed.
|
||||
Response code `502` is sent to clients sending one of these commands.
|
||||
__Example:__ `['RMD', 'RNFR', 'RNTO']` will not allow users to delete directories or rename any files.
|
||||
__Default:__ `[]`
|
||||
|
||||
#### `whitelist`
|
||||
Array of commands that are only allowed.
|
||||
Response code `502` is sent to clients sending any other command.
|
||||
__Default:__ `[]`
|
||||
|
||||
#### `file_format`
|
||||
Sets the format to use for file stat queries such as `LIST`.
|
||||
__Default:__ `"ls"`
|
||||
__Allowable values:__
|
||||
- `ls` [bin/ls format](https://cr.yp.to/ftp/list/binls.html)
|
||||
- `ep` [Easily Parsed LIST format](https://cr.yp.to/ftp/list/eplf.html)
|
||||
- `function () {}` A custom function returning a format or promise for one.
|
||||
- Only one argument is passed in: a node [file stat](https://nodejs.org/api/fs.html#fs_class_fs_stats) object with additional file `name` parameter
|
||||
|
||||
#### `log`
|
||||
A [bunyan logger](https://github.com/trentm/node-bunyan) instance. Created by default.
|
||||
|
||||
#### `timeout`
|
||||
Sets the timeout (in ms) after that an idle connection is closed by the server
|
||||
__Default:__ `0`
|
||||
|
||||
## CLI
|
||||
|
||||
`ftp-srv` also comes with a builtin CLI.
|
||||
|
||||
```bash
|
||||
$ ftp-srv [url] [options]
|
||||
```
|
||||
|
||||
```bash
|
||||
$ ftp-srv ftp://0.0.0.0:9876 --root ~/Documents
|
||||
```
|
||||
|
||||
#### `url`
|
||||
|
||||
Set the listening URL.
|
||||
|
||||
Defaults to `ftp://127.0.0.1:21`
|
||||
|
||||
#### `--root` / `-r`
|
||||
|
||||
Set the default root directory for users.
|
||||
|
||||
Defaults to the current directory.
|
||||
|
||||
#### `--credentials` / `-c`
|
||||
|
||||
Set the path to a json credentials file.
|
||||
|
||||
Format:
|
||||
|
||||
```js
|
||||
[
|
||||
{
|
||||
"username": "...",
|
||||
"password": "...",
|
||||
"root": "..." // Root directory
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
#### `--username`
|
||||
|
||||
Set the username for the only user. Do not provide an argument to allow anonymous login.
|
||||
|
||||
#### `--password`
|
||||
|
||||
Set the password for the given `username`.
|
||||
|
||||
## Events
|
||||
|
||||
The `FtpSrv` class extends the [node net.Server](https://nodejs.org/api/net.html#net_class_net_server). Some custom events can be resolved or rejected, such as `login`.
|
||||
|
||||
### `login`
|
||||
```js
|
||||
ftpServer.on('login', ({connection, username, password}, resolve, reject) => { ... });
|
||||
```
|
||||
|
||||
Occurs when a client is attempting to login. Here you can resolve the login request by username and password.
|
||||
|
||||
`connection` [client class object](src/connection.js)
|
||||
`username` string of username from `USER` command
|
||||
`password` string of password from `PASS` command
|
||||
`resolve` takes an object of arguments:
|
||||
- `fs`
|
||||
- Set a custom file system class for this connection to use.
|
||||
- See [File System](#file-system) for implementation details.
|
||||
- `root`
|
||||
- If `fs` is not provided, this will set the root directory for the connection.
|
||||
- The user cannot traverse lower than this directory.
|
||||
- `cwd`
|
||||
- If `fs` is not provided, will set the starting directory for the connection
|
||||
- This is relative to the `root` directory.
|
||||
- `blacklist`
|
||||
- Commands that are forbidden for only this connection
|
||||
- `whitelist`
|
||||
- If set, this connection will only be able to use the provided commands
|
||||
|
||||
`reject` takes an error object
|
||||
|
||||
### `client-error`
|
||||
```js
|
||||
ftpServer.on('client-error', ({connection, context, error}) => { ... });
|
||||
```
|
||||
|
||||
Occurs when an error arises in the client connection.
|
||||
|
||||
`connection` [client class object](src/connection.js)
|
||||
`context` string of where the error occurred
|
||||
`error` error object
|
||||
|
||||
### `RETR`
|
||||
```js
|
||||
connection.on('RETR', (error, filePath) => { ... });
|
||||
```
|
||||
|
||||
Occurs when a file is downloaded.
|
||||
|
||||
`error` if successful, will be `null`
|
||||
`filePath` location to which file was downloaded
|
||||
|
||||
### `STOR`
|
||||
```js
|
||||
connection.on('STOR', (error, fileName) => { ... });
|
||||
```
|
||||
|
||||
Occurs when a file is uploaded.
|
||||
|
||||
`error` if successful, will be `null`
|
||||
`fileName` name of the file that was uploaded
|
||||
|
||||
### `RNTO`
|
||||
```js
|
||||
connection.on('RNTO', (error, fileName) => { ... });
|
||||
```
|
||||
|
||||
Occurs when a file is renamed.
|
||||
|
||||
`error` if successful, will be `null`
|
||||
`fileName` name of the file that was renamed
|
||||
|
||||
## Supported Commands
|
||||
|
||||
See the [command registry](src/commands/registration) for a list of all implemented FTP commands.
|
||||
|
||||
## File System
|
||||
The default [file system](src/fs.js) can be overwritten to use your own implementation.
|
||||
This can allow for virtual file systems, and more.
|
||||
Each connection can set it's own file system based on the user.
|
||||
|
||||
The default file system is exported and can be extended as needed:
|
||||
```js
|
||||
const {FtpSrv, FileSystem} = require('ftp-srv');
|
||||
|
||||
class MyFileSystem extends FileSystem {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
}
|
||||
|
||||
get(fileName) {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Custom file systems can implement the following variables depending on the developers needs:
|
||||
|
||||
### Methods
|
||||
#### [`currentDirectory()`](src/fs.js#L40)
|
||||
Returns a string of the current working directory
|
||||
__Used in:__ `PWD`
|
||||
|
||||
#### [`get(fileName)`](src/fs.js#L44)
|
||||
Returns a file stat object of file or directory
|
||||
__Used in:__ `LIST`, `NLST`, `STAT`, `SIZE`, `RNFR`, `MDTM`
|
||||
|
||||
#### [`list(path)`](src/fs.js#L50)
|
||||
Returns array of file and directory stat objects
|
||||
__Used in:__ `LIST`, `NLST`, `STAT`
|
||||
|
||||
#### [`chdir(path)`](src/fs.js#L67)
|
||||
Returns new directory relative to current directory
|
||||
__Used in:__ `CWD`, `CDUP`
|
||||
|
||||
#### [`mkdir(path)`](src/fs.js#L114)
|
||||
Returns a path to a newly created directory
|
||||
__Used in:__ `MKD`
|
||||
|
||||
#### [`write(fileName, {append, start})`](src/fs.js#L79)
|
||||
Returns a writable stream
|
||||
Options:
|
||||
`append` if true, append to existing file
|
||||
`start` if set, specifies the byte offset to write to
|
||||
__Used in:__ `STOR`, `APPE`
|
||||
|
||||
#### [`read(fileName, {start})`](src/fs.js#L90)
|
||||
Returns a readable stream
|
||||
Options:
|
||||
`start` if set, specifies the byte offset to read from
|
||||
__Used in:__ `RETR`
|
||||
|
||||
#### [`delete(path)`](src/fs.js#L105)
|
||||
Delete a file or directory
|
||||
__Used in:__ `DELE`
|
||||
|
||||
#### [`rename(from, to)`](src/fs.js#L120)
|
||||
Renames a file or directory
|
||||
__Used in:__ `RNFR`, `RNTO`
|
||||
|
||||
#### [`chmod(path)`](src/fs.js#L126)
|
||||
Modifies a file or directory's permissions
|
||||
__Used in:__ `SITE CHMOD`
|
||||
|
||||
#### [`getUniqueName()`](src/fs.js#L131)
|
||||
Returns a unique file name to write to
|
||||
__Used in:__ `STOU`
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Contributors
|
||||
|
||||
- [OzairP](https://github.com/OzairP)
|
||||
- [TimLuq](https://github.com/TimLuq)
|
||||
- [crabl](https://github.com/crabl)
|
||||
- [hirviid](https://github.com/hirviid)
|
||||
- [DiegoRBaquero](https://github.com/DiegoRBaquero)
|
||||
- [edin-m](https://github.com/edin-m)
|
||||
- [voxsoftware](https://github.com/voxsoftware)
|
||||
- [jorinvo](https://github.com/jorinvo)
|
||||
- [Johnnyrook777](https://github.com/Johnnyrook777)
|
||||
- [qchar](https://github.com/qchar)
|
||||
- [mikejestes](https://github.com/mikejestes)
|
||||
- [pkeuter](https://github.com/pkeuter)
|
||||
- [qiansc](https://github.com/qiansc)
|
||||
- [broofa](https://github.com/broofa)
|
||||
- [lafin](https://github.com/lafin)
|
||||
- [alancnet](https://github.com/alancnet)
|
||||
- [zgwit](https://github.com/zgwit)
|
||||
|
||||
## License
|
||||
|
||||
This software is licensed under the MIT Licence. See [LICENSE](LICENSE).
|
||||
|
||||
## References
|
||||
|
||||
- [https://cr.yp.to/ftp.html](https://cr.yp.to/ftp.html)
|
||||
@@ -1,27 +0,0 @@
|
||||
const {get} = require('https');
|
||||
|
||||
get('https://api.github.com/repos/trs/ftp-srv/contributors', {
|
||||
headers: {
|
||||
'User-Agent': 'Chrome'
|
||||
}
|
||||
}, (res) => {
|
||||
let response = '';
|
||||
res.on('data', (data) => {
|
||||
response += data;
|
||||
});
|
||||
res.on('end', () => {
|
||||
const contributors = JSON.parse(response)
|
||||
.filter((contributor) => contributor.type === 'User');
|
||||
|
||||
for (const contributor of contributors) {
|
||||
const url = contributor.html_url;
|
||||
const username = contributor.login;
|
||||
|
||||
const markdown = `- [${username}](${url})\n`;
|
||||
|
||||
process.stdout.write(markdown);
|
||||
}
|
||||
});
|
||||
}).on('error', (err) => {
|
||||
process.stderr.write(err);
|
||||
});
|
||||
9976
old/package-lock.json
generated
9976
old/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,91 +0,0 @@
|
||||
{
|
||||
"name": "ftp-srv",
|
||||
"version": "0.0.0",
|
||||
"description": "Modern, extensible FTP Server",
|
||||
"keywords": [
|
||||
"ftp",
|
||||
"ftp-server",
|
||||
"ftp-srv",
|
||||
"ftp-svr",
|
||||
"ftpd",
|
||||
"ftpserver",
|
||||
"server"
|
||||
],
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"src",
|
||||
"bin",
|
||||
"ftp-srv.d.ts"
|
||||
],
|
||||
"main": "ftp-srv.js",
|
||||
"bin": "./bin/index.js",
|
||||
"types": "./ftp-srv.d.ts",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/trs/ftp-srv"
|
||||
},
|
||||
"scripts": {
|
||||
"pre-release": "npm run verify",
|
||||
"semantic-release": "semantic-release",
|
||||
"test": "mocha **/*.spec.js --ui bdd --bail",
|
||||
"verify": "eslint src/**/*.js test/**/*.js bin/**/*.js"
|
||||
},
|
||||
"release": {
|
||||
"verifyConditions": "condition-circle"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "lint-staged",
|
||||
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.js": [
|
||||
"eslint --fix",
|
||||
"git add"
|
||||
]
|
||||
},
|
||||
"commitlint": {
|
||||
"extends": [
|
||||
"@commitlint/config-conventional"
|
||||
]
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "eslint:recommended",
|
||||
"env": {
|
||||
"node": true,
|
||||
"mocha": true,
|
||||
"es6": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 6,
|
||||
"sourceType": "module"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"bluebird": "^3.5.1",
|
||||
"bunyan": "^1.8.12",
|
||||
"ip": "^1.1.5",
|
||||
"lodash": "^4.17.10",
|
||||
"moment": "^2.22.1",
|
||||
"uuid": "^3.2.1",
|
||||
"yargs": "^11.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^7.5.2",
|
||||
"@commitlint/config-conventional": "^7.5.0",
|
||||
"@icetee/ftp": "^1.0.2",
|
||||
"chai": "^4.2.0",
|
||||
"condition-circle": "^2.0.2",
|
||||
"eslint": "^5.14.1",
|
||||
"husky": "^1.3.1",
|
||||
"lint-staged": "^8.1.4",
|
||||
"mocha": "^5.2.0",
|
||||
"rimraf": "^2.6.1",
|
||||
"semantic-release": "^15.10.6",
|
||||
"sinon": "^2.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.x"
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
const net = require('net');
|
||||
const errors = require('../errors');
|
||||
|
||||
const MAX_PORT = 65535;
|
||||
const MAX_PORT_CHECK_ATTEMPT = 5;
|
||||
|
||||
function* portNumberGenerator(min, max = MAX_PORT) {
|
||||
let current = min;
|
||||
while (true) {
|
||||
if (current > MAX_PORT || current > max) {
|
||||
current = min;
|
||||
}
|
||||
yield current++;
|
||||
}
|
||||
}
|
||||
|
||||
function getNextPortFactory(host, portMin, portMax, maxAttempts = MAX_PORT_CHECK_ATTEMPT) {
|
||||
const nextPortNumber = portNumberGenerator(portMin, portMax);
|
||||
const portCheckServer = net.createServer();
|
||||
portCheckServer.maxConnections = 0;
|
||||
|
||||
return () => new Promise((resolve, reject) => {
|
||||
let attemptCount = 0;
|
||||
const tryGetPort = () => {
|
||||
attemptCount++;
|
||||
if (attemptCount > maxAttempts) {
|
||||
reject(new errors.ConnectorError('Unable to find valid port'));
|
||||
return;
|
||||
}
|
||||
|
||||
const {value: port} = nextPortNumber.next();
|
||||
|
||||
portCheckServer.removeAllListeners();
|
||||
portCheckServer.once('error', (err) => {
|
||||
if (['EADDRINUSE'].includes(err.code)) {
|
||||
tryGetPort();
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
portCheckServer.once('listening', () => {
|
||||
portCheckServer.close(() => resolve(port));
|
||||
});
|
||||
|
||||
try {
|
||||
portCheckServer.listen(port, host);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
|
||||
tryGetPort();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getNextPortFactory,
|
||||
portNumberGenerator
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
module.exports = {
|
||||
|
||||
| 100
|
||||
| 110
|
||||
| 120
|
||||
| 125
|
||||
| 150
|
||||
|
||||
|
||||
|
||||
| 200
|
||||
| 202
|
||||
| 211
|
||||
| 212
|
||||
| 213
|
||||
| 214
|
||||
| 215
|
||||
| 220
|
||||
| 221
|
||||
| 225
|
||||
| 226
|
||||
| 227
|
||||
| 230
|
||||
| 234
|
||||
| 250
|
||||
| 257
|
||||
|
||||
|
||||
| 331
|
||||
| 332
|
||||
| 350
|
||||
|
||||
|
||||
|
||||
|
||||
| 421
|
||||
| 425
|
||||
| 426
|
||||
| 450
|
||||
| 451
|
||||
| 452
|
||||
|
||||
|
||||
|
||||
| 500
|
||||
| 501
|
||||
| 502
|
||||
| 503
|
||||
| 504
|
||||
| 530
|
||||
| 532
|
||||
| 550
|
||||
| 551
|
||||
| 552
|
||||
| 553
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
/* eslint no-unused-expressions: 0 */
|
||||
const {expect} = require('chai');
|
||||
const net = require('net');
|
||||
|
||||
const {getNextPortFactory} = require('../../src/helpers/find-port');
|
||||
|
||||
describe('helpers // find-port', function () {
|
||||
describe('keeps trying new ports', () => {
|
||||
let getNextPort;
|
||||
let serverAlreadyRunning;
|
||||
beforeEach((done) => {
|
||||
getNextPort = getNextPortFactory('::', 8821);
|
||||
|
||||
serverAlreadyRunning = net.createServer();
|
||||
serverAlreadyRunning.listen(8821, () => done());
|
||||
});
|
||||
|
||||
afterEach((done) => {
|
||||
serverAlreadyRunning.close(() => done());
|
||||
});
|
||||
|
||||
it('test', () => {
|
||||
return getNextPort()
|
||||
.then((port) => {
|
||||
expect(port).to.equal(8822);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
13157
package-lock.json
generated
13157
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
138
package.json
138
package.json
@@ -1,57 +1,7 @@
|
||||
{
|
||||
"name": "ftp-srv",
|
||||
"version": "5.0.0",
|
||||
"version": "3.1.2",
|
||||
"description": "Modern, extensible FTP Server",
|
||||
"main": "build/index",
|
||||
"scripts": {
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"lint:fix": "npm run lint -- --fix"
|
||||
},
|
||||
"jest": {
|
||||
"testEnvironment": "node",
|
||||
"moduleFileExtensions": [
|
||||
"ts",
|
||||
"js",
|
||||
"json"
|
||||
],
|
||||
"transform": {
|
||||
".ts": "ts-jest"
|
||||
},
|
||||
"testRegex": "\\.test\\.ts",
|
||||
"globals": {
|
||||
"ts-jest": {
|
||||
"tsConfig": "tsconfig.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"eslintConfig": {
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"project": "tsconfig.json"
|
||||
},
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"prettier"
|
||||
],
|
||||
"extends": "plugin:@typescript-eslint/recommended"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "lint-staged",
|
||||
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.ts": [
|
||||
"eslint --fix",
|
||||
"git add"
|
||||
]
|
||||
},
|
||||
"commitlint": {
|
||||
"extends": [
|
||||
"@commitlint/config-conventional"
|
||||
]
|
||||
},
|
||||
"keywords": [
|
||||
"ftp",
|
||||
"ftp-server",
|
||||
@@ -61,32 +11,76 @@
|
||||
"ftpserver",
|
||||
"server"
|
||||
],
|
||||
"author": "Tyler Stewart",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"src",
|
||||
"bin",
|
||||
"ftp-srv.d.ts"
|
||||
],
|
||||
"main": "ftp-srv.js",
|
||||
"bin": "./bin/index.js",
|
||||
"types": "./ftp-srv.d.ts",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/trs/ftp-srv.git"
|
||||
"url": "https://github.com/trs/ftp-srv"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/trs/ftp-srv/issues"
|
||||
"scripts": {
|
||||
"pre-release": "npm run verify",
|
||||
"commitmsg": "cz-customizable-ghooks",
|
||||
"dev": "cross-env NODE_ENV=development npm run verify:watch",
|
||||
"prepush": "npm run verify && npm run test:once --silent",
|
||||
"semantic-release": "semantic-release",
|
||||
"start": "npm run dev",
|
||||
"test": "cross-env NODE_ENV=test mocha --opts config/testUnit/mocha.opts -w",
|
||||
"test:once": "cross-env NODE_ENV=test mocha --opts config/testUnit/mocha.opts",
|
||||
"verify": "npm run verify:js --silent",
|
||||
"verify:js": "eslint -c config/verify/.eslintrc \"src/**/*.js\" \"test/**/*.js\" \"config/**/*.js\" && echo ✅ verify:js success",
|
||||
"verify:js:fix": "eslint --fix -c config/verify/.eslintrc \"src/**/*.js\" \"test/**/*.js\" \"config/**/*.js\" && echo ✅ verify:js:fix success"
|
||||
},
|
||||
"release": {
|
||||
"verifyConditions": "condition-circle"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
"path": "node_modules/cz-customizable"
|
||||
},
|
||||
"cz-customizable": {
|
||||
"config": "config/release/commitMessageConfig.js"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"homepage": "https://github.com/trs/ftp-srv#readme",
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^7.5.2",
|
||||
"@commitlint/config-conventional": "^7.5.0",
|
||||
"@types/jest": "^24.0.10",
|
||||
"@types/node": "^11.10.5",
|
||||
"@types/signale": "^1.2.1",
|
||||
"@typescript-eslint/eslint-plugin": "^1.4.2",
|
||||
"@typescript-eslint/parser": "^1.4.2",
|
||||
"eslint": "^5.15.1",
|
||||
"eslint-plugin-prettier": "^3.0.1",
|
||||
"husky": "^1.3.1",
|
||||
"jest": "^24.3.1",
|
||||
"lerna": "^3.13.1",
|
||||
"lint-staged": "^8.1.5",
|
||||
"prettier": "1.16.4",
|
||||
"ts-jest": "^24.0.0",
|
||||
"typescript": "^3.3.3333"
|
||||
"@icetee/ftp": "^1.0.2",
|
||||
"chai": "^4.0.2",
|
||||
"condition-circle": "^1.6.0",
|
||||
"cross-env": "3.1.4",
|
||||
"cz-customizable": "5.2.0",
|
||||
"cz-customizable-ghooks": "1.5.0",
|
||||
"dotenv": "^4.0.0",
|
||||
"eslint": "4.5.0",
|
||||
"eslint-config-google": "0.8.0",
|
||||
"eslint-friendly-formatter": "3.0.0",
|
||||
"eslint-plugin-mocha": "^4.11.0",
|
||||
"eslint-plugin-node": "5.1.1",
|
||||
"husky": "0.13.3",
|
||||
"istanbul": "0.4.5",
|
||||
"mocha": "^5.2.0",
|
||||
"mocha-junit-reporter": "1.13.0",
|
||||
"mocha-multi-reporters": "1.1.5",
|
||||
"rimraf": "2.6.1",
|
||||
"semantic-release": "^15.10.6",
|
||||
"sinon": "^2.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.x",
|
||||
"npm": ">=5.x"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { CommandRegistration } from ".";
|
||||
|
||||
const user: CommandRegistration = {
|
||||
arguments: ['username'],
|
||||
description: 'Set the username to authenticate with',
|
||||
handler: async function ({connection, command, reply}) {
|
||||
if (connection.hasContext('username')) {
|
||||
/*
|
||||
RFC 959
|
||||
4.1.1.
|
||||
Servers may allow a new USER command to be
|
||||
entered at any point in order to change the access control
|
||||
and/or accounting information. This has the effect of
|
||||
flushing any user, password, and account information already
|
||||
supplied and beginning the login sequence again. All
|
||||
transfer parameters are unchanged and any file transfer in
|
||||
progress is completed under the old access control
|
||||
parameters.
|
||||
*/
|
||||
connection.unsetContext('username', 'password');
|
||||
}
|
||||
|
||||
connection.setContext('username', command.argument);
|
||||
|
||||
reply.set([331]);
|
||||
}
|
||||
};
|
||||
|
||||
const pass: CommandRegistration = {
|
||||
arguments: ['password'],
|
||||
description: 'Set the password to authenticate with',
|
||||
handler: async function ({connection, command, reply}) {
|
||||
if (connection.hasContext('password')) {
|
||||
reply.set([202]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!connection.hasContext('username')) {
|
||||
reply.set([503]);
|
||||
return;
|
||||
}
|
||||
|
||||
connection.setContext('password', command.argument);
|
||||
reply.set([230]);
|
||||
}
|
||||
};
|
||||
|
||||
const acct: CommandRegistration = {
|
||||
arguments: ['account-information'],
|
||||
description: 'Set the identifying account',
|
||||
handler: async function ({connection, command, reply}) {
|
||||
if (!connection.hasContext('username', 'password')) {
|
||||
reply.set([503]);
|
||||
}
|
||||
|
||||
connection.setContext('account', command.argument);
|
||||
reply.set([230]);
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
user,
|
||||
pass,
|
||||
acct
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import { CommandRegistration } from ".";
|
||||
import { CommandPlugin } from '../server';
|
||||
|
||||
const cwd: CommandRegistration = {
|
||||
description: '',
|
||||
handler: async ({command, reply}) => {
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
const cdup: CommandRegistration = {
|
||||
description: '',
|
||||
handler: async ({command, reply}) => {
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
export {cwd, cdup};
|
||||
@@ -1,42 +0,0 @@
|
||||
import CommandSocket from "../commandSocket";
|
||||
import FTPServer from "../server";
|
||||
import { ReplyCode } from "../reply";
|
||||
|
||||
export interface Command {
|
||||
identifier: string;
|
||||
argument: string;
|
||||
argumentParts: string[];
|
||||
}
|
||||
|
||||
export type CommandReply = [ReplyCode, ...string[]];
|
||||
|
||||
export interface CommandReplyMod {
|
||||
get: () => CommandReply;
|
||||
set: (v: CommandReply) => void;
|
||||
}
|
||||
export type CommandHandler = (params: {server: Readonly<FTPServer>; connection: Readonly<CommandSocket>; command: Command; reply: CommandReplyMod}) => Promise<void>;
|
||||
|
||||
export interface CommandRegistration {
|
||||
arguments?: string[];
|
||||
description: string;
|
||||
handler: CommandHandler;
|
||||
}
|
||||
|
||||
export function parseCommandBuffer(data: Buffer): Command {
|
||||
const result = data
|
||||
.toString('utf8')
|
||||
.replace(/\s+/g, ' ')
|
||||
.match(/^(\w+?)(?: (.+)$|$)/);
|
||||
|
||||
if (!result) throw new Error('Invalid command');
|
||||
|
||||
const identifier = result[1].toLocaleUpperCase();
|
||||
const argument = result.length > 1 ? result[2].trim() : '';
|
||||
const argumentParts = argument.split(' ');
|
||||
|
||||
return {
|
||||
identifier,
|
||||
argument,
|
||||
argumentParts
|
||||
};
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* https://tools.ietf.org/html/rfc2228
|
||||
* https://tools.ietf.org/html/rfc4217
|
||||
*/
|
||||
|
||||
import { CommandRegistration } from ".";
|
||||
|
||||
/*
|
||||
AUTHENTICATION/SECURITY MECHANISM (AUTH)
|
||||
|
||||
The argument field is a Telnet string identifying a supported
|
||||
mechanism. This string is case-insensitive. Values must be
|
||||
registered with the IANA, except that values beginning with "X-"
|
||||
are reserved for local use.
|
||||
|
||||
If the server does not recognize the AUTH command, it must respond
|
||||
with reply code 500. This is intended to encompass the large
|
||||
deployed base of non-security-aware ftp servers, which will
|
||||
respond with reply code 500 to any unrecognized command. If the
|
||||
server does recognize the AUTH command but does not implement the
|
||||
security extensions, it should respond with reply code 502.
|
||||
|
||||
If the server does not understand the named security mechanism, it
|
||||
should respond with reply code 504.
|
||||
|
||||
If the server is not willing to accept the named security
|
||||
mechanism, it should respond with reply code 534.
|
||||
|
||||
If the server is not able to accept the named security mechanism,
|
||||
such as if a required resource is unavailable, it should respond
|
||||
with reply code 431.
|
||||
|
||||
If the server is willing to accept the named security mechanism,
|
||||
but requires security data, it must respond with reply code 334.
|
||||
|
||||
If the server is willing to accept the named security mechanism,
|
||||
and does not require any security data, it must respond with reply
|
||||
code 234.
|
||||
|
||||
If the server is responding with a 334 reply code, it may include
|
||||
security data as described in the next section.
|
||||
|
||||
Some servers will allow the AUTH command to be reissued in order
|
||||
to establish new authentication. The AUTH command, if accepted,
|
||||
removes any state associated with prior FTP Security commands.
|
||||
The server must also require that the user reauthorize (that is,
|
||||
reissue some or all of the USER, PASS, and ACCT commands) in this
|
||||
case (see section 4 for an explanation of "authorize" in this
|
||||
context).
|
||||
|
||||
AUTH
|
||||
234
|
||||
334
|
||||
502, 504, 534, 431
|
||||
500, 501, 421
|
||||
*/
|
||||
const auth: CommandRegistration = {
|
||||
arguments: ['<mechanism-name>'],
|
||||
description: 'Set authentication mechanism',
|
||||
handler: async ({command, reply}) => {
|
||||
const method = command.argument.toLocaleUpperCase();
|
||||
|
||||
|
||||
|
||||
switch (method) {
|
||||
default: reply.set([504]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
PROTECTION BUFFER SIZE (PBSZ)
|
||||
|
||||
The argument is a decimal integer representing the maximum size,
|
||||
in bytes, of the encoded data blocks to be sent or received during
|
||||
file transfer. This number shall be no greater than can be
|
||||
represented in a 32-bit unsigned integer.
|
||||
|
||||
This command allows the FTP client and server to negotiate a
|
||||
maximum protected buffer size for the connection. There is no
|
||||
default size; the client must issue a PBSZ command before it can
|
||||
issue the first PROT command.
|
||||
|
||||
The PBSZ command must be preceded by a successful security data
|
||||
exchange.
|
||||
|
||||
If the server cannot parse the argument, or if it will not fit in
|
||||
32 bits, it should respond with a 501 reply code.
|
||||
|
||||
If the server has not completed a security data exchange with the
|
||||
client, it should respond with a 503 reply code.
|
||||
|
||||
Otherwise, the server must reply with a 200 reply code. If the
|
||||
size provided by the client is too large for the server, it must
|
||||
use a string of the form "PBSZ=number" in the text part of the
|
||||
reply to indicate a smaller buffer size. The client and the
|
||||
server must use the smaller of the two buffer sizes if both buffer
|
||||
sizes are specified.
|
||||
|
||||
PBSZ
|
||||
200
|
||||
503
|
||||
500, 501, 421, 530
|
||||
*/
|
||||
const pbsz: CommandRegistration = {
|
||||
arguments: ['<decimal-integer>'],
|
||||
description: 'The maximum size, in bytes, of the encoded data blocks to be sent or received during file transfer.',
|
||||
handler: async ({reply}) => {
|
||||
reply.set([500]);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
PROT
|
||||
200
|
||||
504, 536, 503, 534, 431
|
||||
500, 501, 421, 530
|
||||
*/
|
||||
const prot: CommandRegistration = {
|
||||
arguments: ['<prot-code ::= C | S | E | P>'],
|
||||
description: 'Indicates to the server what type of data channel protection the client and server will be using',
|
||||
handler: async ({reply}) => {
|
||||
reply.set([536]);
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
auth,
|
||||
pbsz,
|
||||
prot
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
import { CommandRegistration } from ".";
|
||||
|
||||
const stru: CommandRegistration = {
|
||||
arguments: ['<structure>'],
|
||||
description: 'Set file transfer structure (Only "F" supported)',
|
||||
handler: async ({command, reply}) => {
|
||||
const code = /^F$/i.test(command.argument) ? 200 : 504;
|
||||
reply.set([code]);
|
||||
}
|
||||
}
|
||||
export {stru};
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Socket } from "net";
|
||||
import { ReplyCode, formatReply } from "./reply";
|
||||
import { FileSystem } from "./filesystem";
|
||||
|
||||
interface Meta {
|
||||
connectedTime: string;
|
||||
disconnectedTime?: string;
|
||||
address?: string;
|
||||
}
|
||||
|
||||
interface Context {
|
||||
username?: string;
|
||||
password?: string;
|
||||
account?: string;
|
||||
}
|
||||
|
||||
export default class CommandSocket {
|
||||
private instance: Socket;
|
||||
private meta: Meta;
|
||||
private context: Context = {};
|
||||
private filesystem: FileSystem;
|
||||
|
||||
constructor(socket: Socket) {
|
||||
this.instance = socket.setEncoding('utf8');
|
||||
this.meta = {
|
||||
address: this.instance.remoteAddress,
|
||||
connectedTime: new Date().toUTCString()
|
||||
};
|
||||
this.filesystem = new FileSystem();
|
||||
};
|
||||
|
||||
public async sendReply(code: ReplyCode, ...lines: string[]) {
|
||||
const reply = formatReply(code, ...lines);
|
||||
await new Promise((resolve, reject) => {
|
||||
this.instance.write(Buffer.from(reply), 'utf8', (err: Error) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async close() {
|
||||
|
||||
}
|
||||
|
||||
public hasContext<K extends keyof Context>(...names: K[]): boolean {
|
||||
for (const name of names) {
|
||||
if (!this.context.hasOwnProperty(name)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public getContext<K extends keyof Context>(name: K): Context[K] {
|
||||
return this.context[name];
|
||||
}
|
||||
|
||||
public setContext<K extends keyof Context>(name: K, value: Context[K]): this {
|
||||
this.context[name] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public unsetContext<K extends keyof Context>(...names: K[]): this {
|
||||
for (const name of names) {
|
||||
delete this.context[name];
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public unsetAllContext() {
|
||||
this.context = {};
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,18 @@ const FAMILY = {
|
||||
|
||||
module.exports = {
|
||||
directive: 'EPRT',
|
||||
handler: function ({command} = {}) {
|
||||
handler: function ({log, 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));
|
||||
.then(() => this.reply(200))
|
||||
.catch((err) => {
|
||||
log.error(err);
|
||||
return this.reply(err.code || 425, err.message);
|
||||
});
|
||||
},
|
||||
syntax: '{{cmd}} |<protocol>|<address>|<port>|',
|
||||
description: 'Specifies an address and port to which the server should connect'
|
||||
@@ -2,13 +2,17 @@ const PassiveConnector = require('../../connector/passive');
|
||||
|
||||
module.exports = {
|
||||
directive: 'EPSV',
|
||||
handler: function () {
|
||||
handler: function ({log}) {
|
||||
this.connector = new PassiveConnector(this);
|
||||
return this.connector.setupServer()
|
||||
.then((server) => {
|
||||
const {port} = server.address();
|
||||
|
||||
return this.reply(229, `EPSV OK (|||${port}|)`);
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error(err);
|
||||
return this.reply(err.code || 425, err.message);
|
||||
});
|
||||
},
|
||||
syntax: '{{cmd}} [<protocol>]',
|
||||
@@ -2,11 +2,7 @@ const PassiveConnector = require('../../connector/passive');
|
||||
|
||||
module.exports = {
|
||||
directive: 'PASV',
|
||||
handler: function ({log} = {}) {
|
||||
if (!this.server.options.pasv_url) {
|
||||
return this.reply(502);
|
||||
}
|
||||
|
||||
handler: function () {
|
||||
this.connector = new PassiveConnector(this);
|
||||
return this.connector.setupServer()
|
||||
.then((server) => {
|
||||
@@ -20,7 +16,7 @@ module.exports = {
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error(err);
|
||||
return this.reply(425);
|
||||
return this.reply(err.code || 425, err.message);
|
||||
});
|
||||
},
|
||||
syntax: '{{cmd}}',
|
||||
@@ -3,7 +3,7 @@ const ActiveConnector = require('../../connector/active');
|
||||
|
||||
module.exports = {
|
||||
directive: 'PORT',
|
||||
handler: function ({log, command} = {}) {
|
||||
handler: function ({command} = {}) {
|
||||
this.connector = new ActiveConnector(this);
|
||||
|
||||
const rawConnection = _.get(command, 'arg', '').split(',');
|
||||
@@ -17,7 +17,7 @@ module.exports = {
|
||||
.then(() => this.reply(200))
|
||||
.catch((err) => {
|
||||
log.error(err);
|
||||
return this.reply(425);
|
||||
return this.reply(err.code || 425, err.message);
|
||||
});
|
||||
},
|
||||
syntax: '{{cmd}} <x>,<x>,<x>,<x>,<y>,<y>',
|
||||
@@ -30,8 +30,8 @@ class FtpConnection extends EventEmitter {
|
||||
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.commandSocket.on('timeout', (err) => {
|
||||
this.log.trace(err, 'Client timeout');
|
||||
this.close().catch((e) => this.log.trace(e, 'Client close error'));
|
||||
});
|
||||
this.commandSocket.on('close', () => {
|
||||
@@ -1,7 +1,9 @@
|
||||
const {Socket} = require('net');
|
||||
const tls = require('tls');
|
||||
const ip = require('ip');
|
||||
const Promise = require('bluebird');
|
||||
const Connector = require('./base');
|
||||
const {SocketError} = require('../errors');
|
||||
|
||||
class Active extends Connector {
|
||||
constructor(connection) {
|
||||
@@ -27,6 +29,10 @@ class Active extends Connector {
|
||||
|
||||
return closeExistingServer()
|
||||
.then(() => {
|
||||
if (!ip.isEqual(this.connection.commandSocket.remoteAddress, host)) {
|
||||
throw new SocketError('The given address is not yours', 500);
|
||||
}
|
||||
|
||||
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}));
|
||||
@@ -29,7 +29,7 @@ class Connector {
|
||||
closeSocket() {
|
||||
if (this.dataSocket) {
|
||||
const socket = this.dataSocket;
|
||||
this.dataSocket.end(() => socket.destroy());
|
||||
this.dataSocket.end(() => socket && socket.destroy());
|
||||
this.dataSocket = null;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
|
||||
export default class DataSocket {
|
||||
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import {parse, join, relative, isAbsolute} from 'path';
|
||||
import {promises as fs, constants, Stats} from 'fs';
|
||||
|
||||
type Parameters<T> = T extends (... args: infer T) => any ? T : never;
|
||||
type ReturnType<T> = T extends (... args: any[]) => infer T ? T : never;
|
||||
|
||||
export interface NodeFileSystem {
|
||||
stat: (...args: Parameters<typeof fs.stat>) => ReturnType<typeof fs.stat>;
|
||||
rename: (...args: Parameters<typeof fs.rename>) => ReturnType<typeof fs.rename>;
|
||||
access: (...args: Parameters<typeof fs.access>) => ReturnType<typeof fs.access>;
|
||||
rmdir: (...args: Parameters<typeof fs.rmdir>) => ReturnType<typeof fs.rmdir>;
|
||||
unlink: (...args: Parameters<typeof fs.unlink>) => ReturnType<typeof fs.unlink>;
|
||||
mkdir: (...args: Parameters<typeof fs.mkdir>) => ReturnType<typeof fs.mkdir>;
|
||||
chmod: (...args: Parameters<typeof fs.chmod>) => ReturnType<typeof fs.chmod>;
|
||||
readdir: (...args: Parameters<typeof fs.readdir>) => ReturnType<typeof fs.readdir>;
|
||||
}
|
||||
|
||||
interface FileSystemConfig {
|
||||
root: string;
|
||||
current: string;
|
||||
fs: NodeFileSystem;
|
||||
}
|
||||
|
||||
export class FileSystem {
|
||||
private rootDirectory: string;
|
||||
private currentDirectory: string;
|
||||
private fs: NodeFileSystem;
|
||||
|
||||
/**
|
||||
* @param root absolute path on the server to the users root directory
|
||||
* @param current relative path from root to the users current directory
|
||||
*/
|
||||
constructor(config: Partial<FileSystemConfig> = {}) {
|
||||
this.rootDirectory = config.root || '/';
|
||||
this.currentDirectory = config.current || '.';
|
||||
this.fs = config.fs || fs as unknown as NodeFileSystem;
|
||||
}
|
||||
|
||||
public async absoluteDirectory() {
|
||||
return this.getAbsolutePath();
|
||||
}
|
||||
|
||||
public async navigate(to: string | null) {
|
||||
const directory = this.resolvePath(this.currentDirectory, to);
|
||||
|
||||
await this.fs.access(this.getAbsolutePath(this.rootDirectory, directory), constants.R_OK);
|
||||
|
||||
this.currentDirectory = directory;
|
||||
return this.currentDirectory;
|
||||
}
|
||||
|
||||
public async stat(path: string | null): Promise<Stats> {
|
||||
path = path ? this.resolvePath(this.currentDirectory, path) : this.currentDirectory;
|
||||
const stat = await this.fs.stat(this.getAbsolutePath(this.rootDirectory, path));
|
||||
return stat;
|
||||
}
|
||||
|
||||
public async rename(from: string, to: string): Promise<string> {
|
||||
from = this.resolvePath(this.currentDirectory, from);
|
||||
to = this.resolvePath(this.currentDirectory, to);
|
||||
await this.fs.rename(
|
||||
this.getAbsolutePath(this.rootDirectory, from),
|
||||
this.getAbsolutePath(this.rootDirectory, to)
|
||||
);
|
||||
return to;
|
||||
}
|
||||
|
||||
public async delete(path: string): Promise<void> {
|
||||
path = this.resolvePath(this.currentDirectory, path);
|
||||
path = this.getAbsolutePath(this.rootDirectory, path);
|
||||
|
||||
const stat = await this.stat(path);
|
||||
if (stat.isDirectory()) await this.fs.rmdir(path);
|
||||
else await this.fs.unlink(path);
|
||||
}
|
||||
|
||||
public async mkdir(path: string, mode?: string | number): Promise<string> {
|
||||
path = this.resolvePath(this.currentDirectory, path);
|
||||
await this.fs.mkdir(this.getAbsolutePath(this.rootDirectory, path), mode);
|
||||
return path;
|
||||
}
|
||||
|
||||
public async chmod(path: string, mode: string | number): Promise<string> {
|
||||
path = this.resolvePath(this.currentDirectory, path);
|
||||
await this.fs.chmod(this.getAbsolutePath(this.rootDirectory, path), mode);
|
||||
return path;
|
||||
}
|
||||
|
||||
public async readdir(path: string | null): Promise<string[]> {
|
||||
path = this.resolvePath(this.currentDirectory, path);
|
||||
const paths = await this.fs.readdir(this.getAbsolutePath(this.rootDirectory, path), {
|
||||
encoding: 'utf8'
|
||||
});
|
||||
return paths as string[];
|
||||
}
|
||||
|
||||
// public write(path: string): any;
|
||||
// public read(path: string | null): any;
|
||||
|
||||
private resolvePath(from: string, to: string | null) {
|
||||
if (!to) return from;
|
||||
if (isAbsolute(to)) {
|
||||
const {root} = parse(this.rootDirectory);
|
||||
const cwd = this.getAbsolutePath(root); // Pretend `cwd` is the absolute path from root
|
||||
to = relative(cwd, to);
|
||||
}
|
||||
return join(from, to);
|
||||
}
|
||||
|
||||
private getAbsolutePath(root = this.rootDirectory, current = this.currentDirectory) {
|
||||
return join(root, current);
|
||||
}
|
||||
}
|
||||
36
src/helpers/find-port.js
Normal file
36
src/helpers/find-port.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const net = require('net');
|
||||
const Promise = require('bluebird');
|
||||
const errors = require('../errors');
|
||||
|
||||
function* portNumberGenerator(min, max) {
|
||||
let current = min;
|
||||
while (true) {
|
||||
if (current > 65535 || current > max) {
|
||||
current = min;
|
||||
}
|
||||
yield current++;
|
||||
}
|
||||
}
|
||||
|
||||
function getNextPortFactory(min, max = Infinity) {
|
||||
const nextPortNumber = portNumberGenerator(min, max);
|
||||
const portCheckServer = net.createServer();
|
||||
portCheckServer.maxConnections = 0;
|
||||
portCheckServer.on('error', () => {
|
||||
portCheckServer.listen(nextPortNumber.next().value);
|
||||
});
|
||||
|
||||
return () => new Promise((resolve) => {
|
||||
portCheckServer.once('listening', () => {
|
||||
const {port} = portCheckServer.address();
|
||||
portCheckServer.close(() => resolve(port));
|
||||
});
|
||||
portCheckServer.listen(nextPortNumber.next().value);
|
||||
})
|
||||
.catch(RangeError, (err) => Promise.reject(new errors.ConnectorError(err.message)));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getNextPortFactory,
|
||||
portNumberGenerator
|
||||
};
|
||||
25
src/helpers/resolve-host.js
Normal file
25
src/helpers/resolve-host.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const http = require('http');
|
||||
const Promise = require('bluebird');
|
||||
const errors = require('../errors');
|
||||
|
||||
const IP_WEBSITE = 'http://api.ipify.org/';
|
||||
|
||||
module.exports = function (hostname) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!hostname || hostname === '0.0.0.0') {
|
||||
let ip = '';
|
||||
http.get(IP_WEBSITE, (response) => {
|
||||
if (response.statusCode !== 200) {
|
||||
return reject(new errors.GeneralError('Unable to resolve hostname', response.statusCode));
|
||||
}
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', (chunk) => {
|
||||
ip += chunk;
|
||||
});
|
||||
response.on('end', () => {
|
||||
resolve(ip);
|
||||
});
|
||||
});
|
||||
} else resolve(hostname);
|
||||
});
|
||||
};
|
||||
@@ -7,6 +7,7 @@ const tls = require('tls');
|
||||
const EventEmitter = require('events');
|
||||
|
||||
const Connection = require('./connection');
|
||||
const resolveHost = require('./helpers/resolve-host');
|
||||
const {getNextPortFactory} = require('./helpers/find-port');
|
||||
|
||||
class FtpServer extends EventEmitter {
|
||||
@@ -23,8 +24,7 @@ class FtpServer extends EventEmitter {
|
||||
blacklist: [],
|
||||
whitelist: [],
|
||||
greeting: null,
|
||||
tls: false,
|
||||
timeout: 0
|
||||
tls: false
|
||||
}, options);
|
||||
|
||||
this._greeting = this.setupGreeting(this.options.greeting);
|
||||
@@ -36,15 +36,10 @@ class FtpServer extends EventEmitter {
|
||||
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;
|
||||
|
||||
@@ -72,21 +67,22 @@ class FtpServer extends EventEmitter {
|
||||
}
|
||||
|
||||
listen() {
|
||||
if (!this.options.pasv_url) {
|
||||
this.log.warn('Passive URL not set. Passive connections not available.');
|
||||
}
|
||||
return resolveHost(this.options.pasv_url || this.url.hostname)
|
||||
.then((pasvUrl) => {
|
||||
this.options.pasv_url = pasvUrl;
|
||||
|
||||
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');
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
56
src/messages.js
Normal file
56
src/messages.js
Normal file
@@ -0,0 +1,56 @@
|
||||
module.exports = {
|
||||
// 100 - 199 :: Remarks
|
||||
100: 'The requested action is being initiated',
|
||||
110: 'Restart marker reply',
|
||||
120: 'Service ready in %s minutes',
|
||||
125: 'Data connection already open; transfer starting',
|
||||
150: 'File status okay; about to open data connection',
|
||||
// 200 - 399 :: Acceptance
|
||||
/// 200 - 299 :: Positive Completion Replies
|
||||
/// These type of replies indicate that the requested action was taken and that the server is awaiting another command.
|
||||
200: 'The requested action has been successfully completed',
|
||||
202: 'Superfluous command',
|
||||
211: 'System status, or system help reply',
|
||||
212: 'Directory status',
|
||||
213: 'File status',
|
||||
214: 'Help message', // On how to use the server or the meaning of a particular non-standard command. This reply is useful only to the human user.
|
||||
215: 'UNIX Type: L8', // NAME system type. Where NAME is an official system name from the list in the Assigned Numbers document.
|
||||
220: 'Service ready for new user',
|
||||
221: 'Service closing control connection', // Logged out if appropriate.
|
||||
225: 'Data connection open; no transfer in progress',
|
||||
226: 'Closing data connection', // Requested file action successful (for example, file transfer or file abort).
|
||||
227: 'Entering Passive Mode', // (h1,h2,h3,h4,p1,p2).
|
||||
230: 'User logged in, proceed',
|
||||
234: 'Honored',
|
||||
250: 'Requested file action okay, completed',
|
||||
257: '\'%s\' created',
|
||||
/// 300 - 399 :: Positive Intermediate Replies
|
||||
/// These types of replies indicate that the requested action was taken and that the server is awaiting further information to complete the request.
|
||||
331: 'Username okay, awaiting password',
|
||||
332: 'Need account for login',
|
||||
350: 'Requested file action pending further information',
|
||||
// 400 - 599 :: Rejection
|
||||
/// 400 - 499 :: Transient Negative Completion Replies
|
||||
/// These types of replies indicate that the command was not accepted; the requested action was not taken.
|
||||
/// However, the error is temporary and the action may be requested again.
|
||||
421: 'Service not available, closing control connection', // This may be a reply to any command if the service knows it must shut down.
|
||||
425: 'Unable to open data connection',
|
||||
426: 'Connection closed; transfer aborted',
|
||||
450: 'Requested file action not taken', // File unavailable (e.g., file busy).
|
||||
451: 'Requested action aborted. Local error in processing',
|
||||
452: 'Requested action not taken. Insufficient storage',
|
||||
/// 500 - 599 :: Permanent Negative Completion Replies
|
||||
/// These types of replies indicate that the command was not accepted; the requested action was not taken.
|
||||
/// The FTP client is "discouraged" from repeating the same exact request.
|
||||
500: 'Syntax error', // Can close connection
|
||||
501: 'Syntax error in parameters or arguments',
|
||||
502: 'Command not supported',
|
||||
503: 'Bad sequence of commands',
|
||||
504: 'Command parameter not supported',
|
||||
530: 'Not logged in', // Permission Denied, Can close connection
|
||||
532: 'Need account for storing files',
|
||||
550: 'Requested action not taken. File unavailable', // (e.g., file not found, no access).
|
||||
551: 'Requested action aborted. Page type unknown',
|
||||
552: 'Requested file action aborted. Exceeded storage allocation', // (for current directory or dataset).
|
||||
553: 'Requested action not taken. File name not allowed'
|
||||
};
|
||||
75
src/reply.ts
75
src/reply.ts
@@ -1,75 +0,0 @@
|
||||
const NEW_LINE = '\r\n';
|
||||
|
||||
export type ReplyCode = 100
|
||||
| 110
|
||||
| 120
|
||||
| 125
|
||||
| 150
|
||||
| 200
|
||||
| 202
|
||||
| 211
|
||||
| 212
|
||||
| 213
|
||||
| 214
|
||||
| 215
|
||||
| 220
|
||||
| 221
|
||||
| 225
|
||||
| 226
|
||||
| 227
|
||||
| 230
|
||||
| 232
|
||||
| 234
|
||||
| 250
|
||||
| 257
|
||||
| 331
|
||||
| 332
|
||||
| 350
|
||||
| 421
|
||||
| 425
|
||||
| 426
|
||||
| 450
|
||||
| 451
|
||||
| 452
|
||||
| 500
|
||||
| 501
|
||||
| 502
|
||||
| 503
|
||||
| 504
|
||||
| 530
|
||||
| 532
|
||||
| 533
|
||||
| 536
|
||||
| 550
|
||||
| 551
|
||||
| 552
|
||||
| 553;
|
||||
|
||||
export function formatReply(code: ReplyCode, ...lines: string[]) {
|
||||
if (lines.length === 0) {
|
||||
return `${code}${NEW_LINE}`;
|
||||
}
|
||||
|
||||
if (lines.length === 1) {
|
||||
return `${code} ${lines[0]}${NEW_LINE}`;
|
||||
}
|
||||
|
||||
const [firstLine, ...remainingLines] = lines;
|
||||
const lastLine = remainingLines.pop();
|
||||
|
||||
const startsWithNumbers = /^\d+/;
|
||||
const formattedLines = remainingLines.map((line) => {
|
||||
if (startsWithNumbers.test(line)) {
|
||||
return `\t${line}`
|
||||
} else {
|
||||
return line;
|
||||
}
|
||||
});
|
||||
|
||||
const reply = [
|
||||
`${code}-${firstLine}`,
|
||||
...formattedLines,
|
||||
`${code} ${lastLine}`
|
||||
];
|
||||
return `${reply.join(NEW_LINE)}${NEW_LINE}`;
|
||||
}
|
||||
204
src/server.ts
204
src/server.ts
@@ -1,204 +0,0 @@
|
||||
import { SecureContextOptions, createServer as createSecureServer } from 'tls';
|
||||
import { Server, isIP, createServer, Socket } from 'net';
|
||||
|
||||
import Connection from './commandSocket';
|
||||
import { Command, parseCommandBuffer, CommandRegistration, CommandReply, CommandHandler } from './command';
|
||||
|
||||
import { stru } from './command/stru';
|
||||
import { user, pass, acct } from './command/authentication';
|
||||
import { NodeFileSystem } from './filesystem';
|
||||
|
||||
interface FTPServerConfig {
|
||||
hostname: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
interface PassiveConfig {
|
||||
hostname: string;
|
||||
port_min: number;
|
||||
port_max: number;
|
||||
}
|
||||
|
||||
type EncryptionType = 'IMPLICIT'
|
||||
| 'EXPLICIT';
|
||||
|
||||
interface EncryptionConfig {
|
||||
type: EncryptionType;
|
||||
context: SecureContextOptions;
|
||||
}
|
||||
|
||||
export interface CommandPlugin {
|
||||
command: string;
|
||||
handler: CommandHandler;
|
||||
at?: 'before' | 'after';
|
||||
}
|
||||
|
||||
export default class FTPServer {
|
||||
private instance?: Server;
|
||||
private config: FTPServerConfig;
|
||||
private passive?: PassiveConfig;
|
||||
private encryption?: EncryptionConfig;
|
||||
private fs?: NodeFileSystem;
|
||||
private commandHandlers: Map<string, CommandRegistration>;
|
||||
private plugins: Set<CommandPlugin>;
|
||||
|
||||
constructor(config: Partial<FTPServerConfig> = {}) {
|
||||
this.config = {
|
||||
hostname: 'localhost',
|
||||
port: 21,
|
||||
...config
|
||||
};
|
||||
|
||||
this.plugins = new Set();
|
||||
this.commandHandlers = new Map([
|
||||
['USER', user],
|
||||
['PASS', pass],
|
||||
['ACCT', acct],
|
||||
['STRU', stru]
|
||||
]);
|
||||
}
|
||||
|
||||
public configureEncryption(type: EncryptionType, context: SecureContextOptions) {
|
||||
this.encryption = {
|
||||
type,
|
||||
context
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
public configurePassive(config: PassiveConfig) {
|
||||
this.passive = {
|
||||
port_min: 49152,
|
||||
port_max: 65535,
|
||||
...config
|
||||
};
|
||||
|
||||
if (isIP(this.passive.hostname) === 0) {
|
||||
// TODO: resolve url into ip using dns
|
||||
throw new Error('Passive hostname must be a valid IP address');
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public configureFileSystem(fs: NodeFileSystem) {
|
||||
this.fs = fs;
|
||||
return this;
|
||||
}
|
||||
|
||||
public registerPlugin(plugin: CommandPlugin) {
|
||||
if (!plugin.at) plugin.at = 'after';
|
||||
this.plugins.add(plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns `230` Login complete
|
||||
* @returns `331` Username okay, awaiting password
|
||||
* @returns `530` Login failed
|
||||
*/
|
||||
public registerCommand(identifier: 'USER', registration: Partial<CommandRegistration>): this;
|
||||
public registerCommand(identifier: string, registration: Partial<CommandRegistration>) {
|
||||
identifier = identifier.toLocaleUpperCase();
|
||||
|
||||
const existingHandler = this.commandHandlers.get(identifier);
|
||||
if (existingHandler) {
|
||||
registration = {
|
||||
...existingHandler,
|
||||
...registration
|
||||
};
|
||||
}
|
||||
|
||||
if (!registration.handler) {
|
||||
throw new Error('Cannot register a command without a handler');
|
||||
}
|
||||
if (!registration.description) {
|
||||
throw new Error('Cannot register a command without a description');
|
||||
}
|
||||
|
||||
this.commandHandlers.set(identifier, registration as CommandRegistration);
|
||||
return this;
|
||||
}
|
||||
|
||||
public listen() {
|
||||
const connectionHandler = async (socket: Socket) => {
|
||||
const connection = new Connection(socket);
|
||||
|
||||
socket.on('data', async (data) => {
|
||||
let command: Command;
|
||||
try {
|
||||
command = parseCommandBuffer(data);
|
||||
} catch {
|
||||
await connection.sendReply(500);
|
||||
await connection.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = this.commandHandlers.get(command.identifier);
|
||||
if (!registration) {
|
||||
await connection.sendReply(502);
|
||||
return;
|
||||
}
|
||||
|
||||
if (registration.arguments && registration.arguments.length > 0) {
|
||||
if (!command.argument) {
|
||||
await connection.sendReply(501);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const plugins = [...this.plugins].filter((plug) => {
|
||||
return plug.command === command.identifier;
|
||||
});
|
||||
const beforePlugins = plugins.filter((plug) => plug.at === 'before');
|
||||
const afterPlugins = plugins.filter((plug) => plug.at === 'after');
|
||||
|
||||
try {
|
||||
// Cache this?
|
||||
const handles = [
|
||||
...beforePlugins.map((plug) => plug.handler),
|
||||
registration.handler,
|
||||
...afterPlugins.map((plug) => plug.handler)
|
||||
];
|
||||
|
||||
const reply = (() => {
|
||||
let value: CommandReply = [500];
|
||||
return {
|
||||
get: () => [...value] as CommandReply,
|
||||
set: (v: CommandReply) => {
|
||||
value = [...v] as CommandReply;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
for (const handle of handles) {
|
||||
await handle({server: this, connection, command, reply});
|
||||
}
|
||||
|
||||
const [replyCode, ...lines] = reply.get();
|
||||
await connection.sendReply(replyCode, ...lines);
|
||||
} catch {
|
||||
await connection.sendReply(500);
|
||||
}
|
||||
});
|
||||
|
||||
socket.resume();
|
||||
await connection.sendReply(200);
|
||||
}
|
||||
|
||||
const serverOptions = {pauseOnConnect: true};
|
||||
if (this.encryption && this.encryption.type === 'IMPLICIT') {
|
||||
this.instance = createSecureServer({...serverOptions, ...this.encryption.context}, connectionHandler);
|
||||
} else {
|
||||
this.instance = createServer(serverOptions, connectionHandler);
|
||||
}
|
||||
|
||||
this.instance.once('listening', () => {});
|
||||
this.instance.once('close', () => {});
|
||||
this.instance.on('error', (err: Error) => {});
|
||||
|
||||
this.instance.listen(this.config.port, this.config.hostname);
|
||||
}
|
||||
|
||||
public async close() {
|
||||
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user