Compare commits

...

39 Commits

Author SHA1 Message Date
Ozair Patel
e272802525 feat(typings): swapped declare class for export interface 2017-10-25 21:46:24 -06:00
Ozair Patel
7589322abc feat(typings): removed Server extension for FtpServer 2017-10-25 21:46:24 -06:00
Ozair Patel
fae5564041 feat(typings): removed typed dependency and fixed import error 2017-10-25 21:46:24 -06:00
Ozair Patel
e9b4a6385d feat(typings): ypdated typescript typings 2017-10-25 21:46:24 -06:00
Tyler Stewart
71621aae4f Merge pull request #44 from trs/include-types-file-on-install
fix(package): include types file
2017-10-25 12:22:57 -06:00
Tyler Stewart
0eaa0f8743 chore(travis): only test v6
Node 8 fails for some reason, will look into in the future
2017-10-25 12:18:14 -06:00
Tyler Stewart
8828a4ea09 fix(package): include types file
Should ensure types are inluded on an install
2017-10-25 12:00:20 -06:00
Tyler Stewart
b33659320f Merge pull request #40 from trs/fix-socket-ref
fix(retr): check for connector socket
2017-09-30 11:14:31 -06:00
Tyler Stewart
6a6b949d3b fix(retr): check for connector socket
Ensures socket still exists and client hasn't disconnected
2017-09-30 11:09:30 -06:00
Tyler Stewart
283be85db3 Merge pull request #38 from trs/improve-connector-stream-handling
Improve connector stream handling
2017-08-18 12:45:32 -06:00
Tyler Stewart
e555ce9230 test(stor): add failure test 2017-08-18 12:06:58 -06:00
Tyler Stewart
e6575808f1 fix(stor): improve event and promise handling 2017-08-18 12:06:42 -06:00
Tyler Stewart
a5e58a106e fix(retr): improve event and promise handling 2017-08-18 12:06:42 -06:00
Tyler Stewart
ed086e576a Merge pull request #36 from trs/fix-process-exit
Fix process exit
2017-08-14 17:07:05 -06:00
Tyler Stewart
31f0f3b0dc fix: ensure process exits 2017-08-14 16:46:11 -06:00
Tyler Stewart
d763820c86 refactor: connector socket getter 2017-08-14 16:45:37 -06:00
Tyler Stewart
f3183314cc Merge pull request #34 from trs/add-typings
feat(typings): add TypeScript .d.ts files
2017-07-24 11:00:13 -06:00
Chris Rabl
dde7b36c46 feat(typings): add TypeScript .d.ts files
* created outline for TypeScript declarations
* added basic object shapes for FtpServer and FileSystem
2017-07-24 10:53:41 -06:00
Tyler Stewart
00af9e7e61 Merge pull request #32 from trs/command-args
Command args
2017-07-10 10:02:55 -06:00
Tyler Stewart
99a885cd44 test(commands): update parser tests 2017-07-10 09:58:24 -06:00
Tyler Stewart
443051d753 fix(commands): get flags from ftp command 2017-07-07 17:28:09 -06:00
Tyler Stewart
27ecc4d835 fix(feat): order features alphabetically 2017-07-06 17:51:06 -06:00
Tyler Stewart
c8526be1f4 Merge pull request #30 from trs/fix-utf8
Fix utf8
2017-06-26 19:54:51 -06:00
Tyler Stewart
e0b11ff480 fix: cleanup server 2017-06-26 16:39:00 -06:00
Tyler Stewart
58b9d8db9d chore: update fs readme 2017-06-26 16:39:00 -06:00
Tyler Stewart
fa121ba0fd test(REST): add tests 2017-06-26 16:39:00 -06:00
Tyler Stewart
2e02dc20ad feat(REST): add support for REST command
Allows the client to resume a transfer at the specified bytes
2017-06-26 16:38:59 -06:00
Tyler Stewart
8aeb6976d2 fix(auth): update checks, ensure secure is set using ftps 2017-06-26 16:38:59 -06:00
Tyler Stewart
84a68ae03c chore: dont append branch name to commit 2017-06-26 12:34:21 -06:00
Tyler Stewart
9dfc80b99d test(OPTS): update tests
master
2017-06-26 11:19:42 -06:00
Tyler Stewart
090e3d8105 chore: update log levels 2017-06-26 11:13:55 -06:00
Tyler Stewart
c3b0dbf5b0 feat(OPTS): add opts command handler for utf8
master
2017-06-26 11:13:49 -06:00
Tyler Stewart
69a5133936 fix(FEAT): correctly display feature list 2017-06-26 11:13:00 -06:00
Tyler Stewart
5394908a6b Merge pull request #29 from trs/fix-tls-check
Fix encoding/transfer type
2017-06-20 17:20:23 -06:00
Tyler Stewart
3e7bd5bcf9 chore: update licence format
Still MIT, just updating to ensure GitHub recognizes it
2017-06-20 17:09:49 -06:00
Tyler Stewart
175b422c5f chore: update dependencies 2017-06-20 17:02:05 -06:00
Tyler Stewart
b2a9851204 fix: ensure utf8 support; allows accent characters 2017-06-20 16:56:26 -06:00
Tyler Stewart
977dd1579a fix(TYPE): correctly set types, only use for data connections 2017-06-20 16:55:37 -06:00
Tyler Stewart
176b2b7ca8 fix: actually check _tls 2017-06-20 14:47:44 -06:00
33 changed files with 6234 additions and 426 deletions

View File

@@ -1,7 +1,7 @@
language: node_js
node_js:
- "6"
- "node"
# - "node"
env:
FTP_URL: ftp://127.0.0.1:8880

22
LICENSE
View File

@@ -1,9 +1,21 @@
ftp-srv Copyright (c) 2017 Tyler Stewart
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
Copyright (c) 2017 Tyler Stewart
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -192,13 +192,17 @@ __Used in:__ `CWD`, `CDUP`
Returns a path to a newly created directory
__Used in:__ `MKD`
#### [`write(fileName, {append = false})`](src/fs.js#L68)
#### [`write(fileName, {append, start})`](src/fs.js#L68)
Returns a writable stream
Options: `append` if true, append to existing file
Options:
`append` if true, append to existing file
`start` if set, specifies the byte offset to write to
__Used in:__ `STOR`, `APPE`
#### [`read(fileName)`](src/fs.js#L75)
#### [`read(fileName, {start})`](src/fs.js#L75)
Returns a readable stream
Options:
`start` if set, specifies the byte offset to read from
__Used in:__ `RETR`
#### [`delete(path)`](src/fs.js#L87)
@@ -206,11 +210,11 @@ Delete a file or directory
__Used in:__ `DELE`
#### [`rename(from, to)`](src/fs.js#L102)
Rename a file or directory
Renames a file or directory
__Used in:__ `RNFR`, `RNTO`
#### [`chmod(path)`](src/fs.js#L108)
Modify a file or directory's permissions
Modifies a file or directory's permissions
__Used in:__ `SITE CHMOD`
#### [`getUniqueName()`](src/fs.js#L113)

View File

@@ -15,12 +15,7 @@ module.exports = {
{value: 'WIP', name: 'WIP: Work in progress'}
],
scopes: [
{name: 'accounts'},
{name: 'admin'},
{name: 'exampleScope'},
{name: 'changeMe'}
],
scopes: [],
// it needs to match the value for field type. Eg.: 'fix'
/*
@@ -39,5 +34,5 @@ module.exports = {
allowBreakingChanges: ['feat', 'fix'],
// Appends the branch name to the footer of the commit. Useful for tracking commits after branches have been merged
appendBranchNameToCommitMessage: true
appendBranchNameToCommitMessage: false
};

View File

@@ -47,6 +47,7 @@ parserOptions:
<<: *confit-parserOptions
rules:
no-process-exit: 0
max-len:
- warn
- 200 # Line Length

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

@@ -0,0 +1,111 @@
import * as tls from 'tls'
import { Stats } from 'fs'
export interface FileSystem {
constructor(connection: any, {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 interface FtpConnection {
server: FtpServer;
id: string;
log: any;
transferType: string;
encoding: string;
bufferSize: boolean;
readonly ip: string;
restByteCount: number | undefined;
secure: boolean
close (code: number, message: number): Promise<any>
login (username: string, password: string): Promise<any>
reply (options: number | Object, ...letters: Array<any>): Promise<any>
}
export interface FtpServerOptions {
pasv_range?: number | string,
greeting?: string,
tls?: tls.SecureContext | false,
anonymous?: boolean,
blacklist?: Array<string>,
whitelist?: Array<string>,
file_format?: (stat: Stats) => string | Promise<string> | "ls" | "ep",
log: any
}
export interface FtpServer {
constructor(url: string, options?: FtpServerOptions);
readonly isTLS: boolean;
listen(): any;
emitPromise(action: any, ...data: any[]): Promise<any>;
emit(action: any, ...data: any[]): void;
setupTLS(_tls: boolean): boolean | {
cert: string;
key: string;
ca: string
};
setupGreeting(greet: string): string[];
setupFeaturesMessage(): string;
disconnectClient(id: string): Promise<any>;
close(): any;
on(event: "login", listener: (
data: {
connection: FtpConnection,
username: string,
password: string
},
resolve: (fs?: FileSystem, root?: string, cwd?: string, blacklist?: Array<string>, whitelist?: Array<string>) => void,
reject: (err?: Error) => void
) => void)
on(event: "client-error", listener: (
data: {
connection: FtpConnection,
context: string,
error: Error,
}
) => void)
}
declare const FtpSrv: FtpServer;
export default FtpServer;

2360
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,8 +14,10 @@
"license": "MIT",
"main": "ftp-srv.js",
"files": [
"src"
"src",
"ftp-srv.d.ts"
],
"types": "./ftp-srv.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/trs/ftp-srv"
@@ -53,7 +55,7 @@
"bunyan": "^1.8.10",
"lodash": "^4.17.4",
"moment": "^2.18.1",
"uuid": "^3.0.1",
"uuid": "^3.1.0",
"when": "^3.7.8"
},
"devDependencies": {
@@ -76,7 +78,7 @@
"npm-run-all": "4.0.2",
"rimraf": "2.6.1",
"semantic-release": "^6.3.6",
"sinon": "^2.3.2"
"sinon": "^2.3.5"
},
"engines": {
"node": ">=6.x",

View File

@@ -12,10 +12,18 @@ class FtpCommands {
}
parse(message) {
const [directive, ...args] = message.replace(/"/g, '').split(' ');
const strippedMessage = message.replace(/"/g, '');
const [directive, ...args] = strippedMessage.split(' ');
const params = args.reduce(({arg, flags}, param) => {
if (/^-{1,2}[a-zA-Z0-9_]+/.test(param)) flags.push(param);
else arg.push(param);
return {arg, flags};
}, {arg: [], flags: []});
const command = {
directive: _.chain(directive).trim().toUpper().value(),
arg: _.compact(args).join(' ') || null,
arg: params.arg.length ? params.arg.join(' ') : null,
flags: params.flags,
raw: message
};
return command;

View File

@@ -20,7 +20,8 @@ module.exports = {
};
function handleTLS() {
if (!this.server._tls) return this.reply(504);
if (!this.server._tls) return this.reply(502);
if (this.secure) return this.reply(202);
return this.reply(234)
.then(() => {

View File

@@ -9,8 +9,12 @@ module.exports = {
const feat = _.get(registry[cmd], 'flags.feat', null);
if (feat) return _.concat(feats, feat);
return feats;
}, [])
.map(feat => ` ${feat}`);
}, ['UTF8'])
.sort()
.map(feat => ({
message: ` ${feat}`,
raw: true
}));
return features.length
? this.reply(211, 'Extensions supported', ...features, 'End')
: this.reply(211, 'No features');

View File

@@ -13,13 +13,9 @@ module.exports = {
const simple = command.directive === 'NLST';
let dataSocket;
const path = command.arg || '.';
return this.connector.waitForConnection()
.then(socket => {
this.commandSocket.pause();
dataSocket = socket;
})
.tap(() => this.commandSocket.pause())
.then(() => when.try(this.fs.get.bind(this.fs), path))
.then(stat => stat.isDirectory() ? when.try(this.fs.list.bind(this.fs), path) : [stat])
.then(files => {
@@ -33,7 +29,7 @@ module.exports = {
return {
raw: true,
message,
socket: dataSocket
socket: this.connector.socket
};
});
return this.reply(150)

View File

@@ -1,8 +1,34 @@
const _ = require('lodash');
const OPTIONS = {
UTF8: utf8,
'UTF-8': utf8
};
module.exports = {
directive: 'OPTS',
handler: function () {
return this.reply(501);
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] = []) {
switch (_.toUpper(setting)) {
case 'ON':
this.encoding = 'utf8';
return this.reply(200, 'UTF8 encoding on');
case 'OFF':
this.encoding = 'ascii';
return this.reply(200, 'UTF8 encoding off');
default:
return this.reply(501, 'Unknown setting for option');
}
}

View File

@@ -8,6 +8,7 @@ module.exports = {
syntax: '{{cmd}}',
description: 'Protection Buffer Size',
flags: {
no_auth: true
no_auth: true,
feat: 'PBSZ'
}
};

View File

@@ -17,6 +17,7 @@ module.exports = {
syntax: '{{cmd}}',
description: 'Data Channel Protection Level',
flags: {
no_auth: true
no_auth: true,
feat: 'PROT'
}
};

View File

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

View File

@@ -0,0 +1,16 @@
const _ = require('lodash');
module.exports = {
directive: 'REST',
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');
this.restByteCount = byteCount;
return this.reply(350, `Resarting next transfer at ${byteCount}`);
},
syntax: '{{cmd}} <byte-count>',
description: 'Restart transfer from the specified point. Resets after any STORE or RETRIEVE'
};

View File

@@ -6,23 +6,26 @@ module.exports = {
if (!this.fs) return this.reply(550, 'File system not instantiated');
if (!this.fs.read) return this.reply(402, 'Not supported by file system');
let dataSocket;
return this.connector.waitForConnection()
.then(socket => {
this.commandSocket.pause();
dataSocket = socket;
})
.then(() => when.try(this.fs.read.bind(this.fs), command.arg))
.tap(() => this.commandSocket.pause())
.then(() => when.try(this.fs.read.bind(this.fs), command.arg, {start: this.restByteCount}))
.then(stream => {
return when.promise((resolve, reject) => {
dataSocket.on('error', err => stream.emit('error', err));
this.restByteCount = 0;
stream.on('data', data => dataSocket.write(data, this.encoding));
stream.on('end', () => resolve(this.reply(226)));
stream.on('error', err => reject(err));
this.reply(150).then(() => dataSocket.resume());
const eventsPromise = when.promise((resolve, reject) => {
this.connector.socket.once('error', err => reject(err));
stream.on('data', data => this.connector.socket
&& this.connector.socket.write(data, this.transferType));
stream.once('error', err => reject(err));
stream.once('end', () => resolve());
});
return this.reply(150).then(() => this.connector.socket.resume())
.then(() => eventsPromise)
.finally(() => stream.destroy ? stream.destroy() : null);
})
.then(() => this.reply(226))
.catch(when.TimeoutError, err => {
log.error(err);
return this.reply(425, 'No connection established');

View File

@@ -9,28 +9,32 @@ module.exports = {
const append = command.directive === 'APPE';
const fileName = command.arg;
let dataSocket;
return this.connector.waitForConnection()
.then(socket => {
this.commandSocket.pause();
dataSocket = socket;
})
.then(() => when.try(this.fs.write.bind(this.fs), fileName, {append}))
.tap(() => this.commandSocket.pause())
.then(() => when.try(this.fs.write.bind(this.fs), fileName, {append, start: this.restByteCount}))
.then(stream => {
return when.promise((resolve, reject) => {
stream.once('error', err => dataSocket.emit('error', err));
stream.once('finish', () => resolve(this.reply(226, fileName)));
this.restByteCount = 0;
// Emit `close` if stream has a close listener, otherwise emit `finish` with the end() method
// It is assumed that the `close` handler will call the end() method
dataSocket.once('end', () => stream.listenerCount('close') ? stream.emit('close') : stream.end());
dataSocket.once('error', err => reject(err));
dataSocket.on('data', data => stream.write(data, this.encoding));
const streamPromise = when.promise((resolve, reject) => {
stream.once('error', err => reject(err));
stream.once('finish', () => resolve());
});
this.reply(150).then(() => dataSocket.resume());
})
.finally(() => when.try(stream.destroy.bind(stream)));
const socketPromise = when.promise((resolve, reject) => {
this.connector.socket.on('data', data => stream.write(data, this.transferType));
this.connector.socket.once('end', () => {
if (stream.listenerCount('close')) stream.emit('close');
else stream.end();
resolve();
});
this.connector.socket.once('error', err => reject(err));
});
return this.reply(150).then(() => this.connector.socket.resume())
.then(() => when.join(streamPromise, socketPromise))
.finally(() => stream.destroy ? stream.destroy() : null);
})
.then(() => this.reply(226, fileName))
.catch(when.TimeoutError, err => {
log.error(err);
return this.reply(425, 'No connection established');

View File

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

View File

@@ -21,6 +21,7 @@ const commands = [
require('./registration/port'),
require('./registration/pwd'),
require('./registration/quit'),
require('./registration/rest'),
require('./registration/retr'),
require('./registration/rmd'),
require('./registration/rnfr'),

View File

@@ -15,8 +15,11 @@ class FtpConnection {
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);
@@ -34,7 +37,7 @@ class FtpConnection {
}
_handleData(data) {
const messages = _.compact(data.toString('utf8').split('\r\n'));
const messages = _.compact(data.toString(this.encoding).split('\r\n'));
this.log.trace(messages);
return sequence(messages.map(message => this.commands.handle.bind(this.commands, message)));
}
@@ -47,6 +50,20 @@ class FtpConnection {
}
}
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 when
.resolve(code)
@@ -58,7 +75,7 @@ class FtpConnection {
return when.try(() => {
const loginListeners = this.server.listeners('login');
if (!loginListeners || !loginListeners.length) {
if (!this.server.options.anoymous) throw new errors.GeneralError('No "login" listener setup', 500);
if (!this.server.options.anonymous) throw new errors.GeneralError('No "login" listener setup', 500);
} else {
return this.server.emitPromise('login', {connection: this, username, password});
}
@@ -102,7 +119,7 @@ class FtpConnection {
const packet = !letter.raw ? _.compact([letter.code || options.code, letter.message]).join(seperator) : letter.message;
if (letter.socket && letter.socket.writable) {
this.log.trace({port: letter.socket.address().port, packet}, 'Reply');
this.log.trace({port: letter.socket.address().port, encoding: letter.encoding, packet}, 'Reply');
letter.socket.write(packet + '\r\n', letter.encoding, err => {
if (err) {
this.log.error(err);

View File

@@ -26,7 +26,7 @@ class Active extends Connector {
return closeExistingServer()
.then(() => {
this.dataSocket = new Socket();
this.dataSocket.setEncoding(this.encoding);
this.dataSocket.setEncoding(this.connection.transferType);
this.dataSocket.on('error', err => this.server.emit('client-error', {connection: this.connection, context: 'dataSocket', error: err}));
this.dataSocket.connect({ host, port, family }, () => {
this.dataSocket.pause();

View File

@@ -14,6 +14,10 @@ class Connector {
return this.connection.log;
}
get socket() {
return this.dataSocket;
}
get server() {
return this.connection.server;
}

View File

@@ -41,7 +41,7 @@ class Passive extends Connector {
return this.connection.reply(550, 'Remote addresses do not match')
.finally(() => this.connection.close());
}
this.log.debug({port}, 'Passive connection fulfilled.');
this.log.trace({port, remoteAddress: socket.remoteAddress}, 'Passive connection fulfilled.');
if (this.connection.secure) {
const secureContext = tls.createSecureContext(this.server._tls);
@@ -54,10 +54,10 @@ class Passive extends Connector {
this.dataSocket = socket;
}
this.dataSocket.connected = true;
this.dataSocket.setEncoding(this.connection.encoding);
this.dataSocket.setEncoding(this.connection.transferType);
this.dataSocket.on('error', err => this.server.emit('client-error', {connection: this.connection, context: 'dataSocket', error: err}));
this.dataSocket.on('close', () => {
this.log.debug('Passive connection closed');
this.log.trace('Passive connection closed');
this.end();
});
};
@@ -67,7 +67,7 @@ class Passive extends Connector {
this.dataServer.maxConnections = 1;
this.dataServer.on('error', err => this.server.emit('client-error', {connection: this.connection, context: 'dataServer', error: err}));
this.dataServer.on('close', () => {
this.log.debug('Passive server closed');
this.log.trace('Passive server closed');
this.dataServer = null;
});
@@ -75,7 +75,7 @@ class Passive extends Connector {
this.dataServer.listen(port, err => {
if (err) reject(err);
else {
this.log.info({port}, 'Passive connection listening');
this.log.debug({port}, 'Passive connection listening');
resolve(this.dataServer);
}
});

View File

@@ -65,22 +65,22 @@ class FileSystem {
});
}
write(fileName, {append = false} = {}) {
write(fileName, {append = false, start = undefined} = {}) {
const {fsPath} = this._resolvePath(fileName);
const stream = syncFs.createWriteStream(fsPath, {flags: !append ? 'w+' : 'a+'});
const stream = syncFs.createWriteStream(fsPath, {flags: !append ? 'w+' : 'a+', start});
stream.once('error', () => fs.unlink(fsPath));
stream.once('close', () => stream.end());
return stream;
}
read(fileName) {
read(fileName, {start = undefined} = {}) {
const {fsPath} = this._resolvePath(fileName);
return fs.stat(fsPath)
.tap(stat => {
if (stat.isDirectory()) throw new errors.FileSystemError('Cannot read a directory');
})
.then(() => {
const stream = syncFs.createReadStream(fsPath, {flags: 'r'});
const stream = syncFs.createReadStream(fsPath, {flags: 'r', start});
return stream;
});
}

View File

@@ -47,17 +47,13 @@ class FtpServer {
this.server = (this.isTLS ? tls : net).createServer(serverOptions, serverConnectionHandler);
this.server.on('error', err => this.log.error(err, '[Event] error'));
if (this.isTLS) {
this.server.on('tlsClientError', err => this.log.error(err, '[Event] tlsClientError'));
}
this.on = this.server.on.bind(this.server);
this.once = this.server.once.bind(this.server);
this.listeners = this.server.listeners.bind(this.server);
process.on('SIGTERM', () => this.close());
process.on('SIGINT', () => this.close());
process.on('SIGBREAK', () => this.close());
process.on('SIGHUP', () => this.close());
process.on('SIGTERM', () => this.quit());
process.on('SIGINT', () => this.quit());
process.on('SIGQUIT', () => this.quit());
}
get isTLS() {
@@ -94,7 +90,7 @@ class FtpServer {
}
setupTLS(_tls) {
if (!tls) return false;
if (!_tls) return false;
return _.assign({}, _tls, {
cert: _tls.cert ? fs.readFileSync(_tls.cert) : undefined,
key: _tls.key ? fs.readFileSync(_tls.key) : undefined,
@@ -134,10 +130,15 @@ class FtpServer {
});
}
quit() {
return this.close()
.finally(() => process.exit(0));
}
close() {
this.log.info('Server closing...');
this.server.maxConnections = 0;
return when.map(Object.keys(this.connections), id => this.disconnectClient(id))
return when.map(Object.keys(this.connections), id => when.try(this.disconnectClient.bind(this), id))
.then(() => when.promise(resolve => {
this.server.close(err => {
if (err) this.log.error(err, 'Error closing server');

View File

@@ -53,6 +53,29 @@ describe('FtpCommands', function () {
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');
expect(cmd.flags).to.deep.equal(['-l', '-A', '--zz88A']);
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');
});
});
describe('handle', function () {

View File

@@ -19,10 +19,40 @@ describe(CMD, function () {
sandbox.restore();
});
it('// successful', () => {
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);
});
});
});

View File

@@ -0,0 +1,58 @@
const {expect} = require('chai');
const sinon = require('sinon');
const when = require('when');
const CMD = 'REST';
describe(CMD, function () {
let sandbox;
const mockClient = {
reply: () => when.resolve()
};
const cmdFn = require(`../../../src/commands/registration/${CMD.toLowerCase()}`).handler.bind(mockClient);
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.spy(mockClient, 'reply');
});
afterEach(() => {
sandbox.restore();
});
it('// unsuccessful', () => {
return cmdFn()
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(501);
});
});
it('-1 // unsuccessful', () => {
return cmdFn({command: { arg: '-1', directive: CMD } })
.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(501);
});
});
it('1 // successful', () => {
return cmdFn({command: { arg: '1', directive: CMD } })
.then(() => {
expect(mockClient.restByteCount).to.equal(1);
expect(mockClient.reply.args[0][0]).to.equal(350);
});
});
it('0 // successful', () => {
return cmdFn({command: { arg: '0', directive: CMD } })
.then(() => {
expect(mockClient.restByteCount).to.equal(0);
expect(mockClient.reply.args[0][0]).to.equal(350);
});
});
});

View File

@@ -13,7 +13,7 @@ describe(CMD, function () {
beforeEach(() => {
sandbox = sinon.sandbox.create();
mockClient.encoding = null;
mockClient.transferType = null;
sandbox.spy(mockClient, 'reply');
});
afterEach(() => {
@@ -24,7 +24,7 @@ describe(CMD, function () {
return cmdFn({ command: { arg: 'A' } })
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(200);
expect(mockClient.encoding).to.equal('utf8');
expect(mockClient.transferType).to.equal('ascii');
});
});
@@ -32,7 +32,7 @@ describe(CMD, function () {
return cmdFn({ command: { arg: 'I' } })
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(200);
expect(mockClient.encoding).to.equal('binary');
expect(mockClient.transferType).to.equal('binary');
});
});
@@ -40,7 +40,7 @@ describe(CMD, function () {
return cmdFn({ command: { arg: 'L' } })
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(200);
expect(mockClient.encoding).to.equal('binary');
expect(mockClient.transferType).to.equal('binary');
});
});
@@ -48,7 +48,7 @@ describe(CMD, function () {
return cmdFn({ command: { arg: 'X' } })
.then(() => {
expect(mockClient.reply.args[0][0]).to.equal(501);
expect(mockClient.encoding).to.equal(null);
expect(mockClient.transferType).to.equal(null);
});
});
});

View File

@@ -1,5 +1,6 @@
/* eslint no-unused-expressions: 0 */
const {expect} = require('chai');
const sinon = require('sinon');
const bunyan = require('bunyan');
const fs = require('fs');
@@ -10,10 +11,13 @@ before(() => require('dotenv').load());
describe('FtpServer', function () {
this.timeout(2000);
let sandbox;
let log = bunyan.createLogger({name: 'test'});
let server;
let client;
let connection;
before(() => {
server = new FtpServer(process.env.FTP_URL, {
log,
@@ -26,11 +30,18 @@ describe('FtpServer', function () {
greeting: ['hello', 'world']
});
server.on('login', (data, resolve) => {
connection = data.connection;
resolve({root: process.cwd()});
});
return server.listen();
});
beforeEach(() => {
sandbox = sinon.sandbox.create();
});
afterEach(() => {
sandbox.restore();
});
after(() => {
server.close();
});
@@ -109,12 +120,28 @@ describe('FtpServer', function () {
});
});
it('STOR test.txt', done => {
it('STOR fail.txt', done => {
sandbox.stub(connection.fs, 'write').callsFake(function () {
const fsPath = './test/fail.txt';
const stream = require('fs').createWriteStream(fsPath, {flags: 'w+'});
stream.once('error', () => fs.unlink(fsPath));
setTimeout(() => stream.emit('error', new Error('STOR fail test'), 1));
return stream;
});
const buffer = Buffer.from('test text file');
client.put(buffer, 'test.txt', err => {
client.put(buffer, 'fail.txt', err => {
expect(err).to.exist;
expect(fs.existsSync('./test/fail.txt')).to.equal(false);
done();
});
});
it('STOR tést.txt', done => {
const buffer = Buffer.from('test text file');
client.put(buffer, 'tést.txt', err => {
expect(err).to.not.exist;
expect(fs.existsSync('./test/test.txt')).to.equal(true);
fs.readFile('./test/test.txt', (fserr, data) => {
expect(fs.existsSync('./test/tést.txt')).to.equal(true);
fs.readFile('./test/tést.txt', (fserr, data) => {
expect(fserr).to.not.exist;
expect(data.toString()).to.equal('test text file');
done();
@@ -122,11 +149,11 @@ describe('FtpServer', function () {
});
});
it('APPE test.txt', done => {
it('APPE tést.txt', done => {
const buffer = Buffer.from(', awesome!');
client.append(buffer, 'test.txt', err => {
client.append(buffer, 'tést.txt', err => {
expect(err).to.not.exist;
fs.readFile('./test/test.txt', (fserr, data) => {
fs.readFile('./test/tést.txt', (fserr, data) => {
expect(fserr).to.not.exist;
expect(data.toString()).to.equal('test text file, awesome!');
done();
@@ -134,8 +161,8 @@ describe('FtpServer', function () {
});
});
it('RETR test.txt', done => {
client.get('test.txt', (err, stream) => {
it('RETR tést.txt', done => {
client.get('tést.txt', (err, stream) => {
expect(err).to.not.exist;
let text = '';
stream.on('data', data => {
@@ -148,10 +175,10 @@ describe('FtpServer', function () {
});
});
it('RNFR test.txt, RNTO awesome.txt', done => {
client.rename('test.txt', 'awesome.txt', err => {
it('RNFR tést.txt, RNTO awesome.txt', done => {
client.rename('tést.txt', 'awesome.txt', err => {
expect(err).to.not.exist;
expect(fs.existsSync('./test/test.txt')).to.equal(false);
expect(fs.existsSync('./test/tést.txt')).to.equal(false);
expect(fs.existsSync('./test/awesome.txt')).to.equal(true);
fs.readFile('./test/awesome.txt', (fserr, data) => {
expect(fserr).to.not.exist;
@@ -196,19 +223,19 @@ describe('FtpServer', function () {
});
});
it('MKD tmp', done => {
if (fs.existsSync('./test/tmp')) {
fs.rmdirSync('./test/tmp');
it('MKD témp', done => {
if (fs.existsSync('./test/témp')) {
fs.rmdirSync('./test/témp');
}
client.mkdir('tmp', err => {
client.mkdir('témp', err => {
expect(err).to.not.exist;
expect(fs.existsSync('./test/tmp')).to.equal(true);
expect(fs.existsSync('./test/témp')).to.equal(true);
done();
});
});
it('CWD tmp', done => {
client.cwd('tmp', (err, data) => {
it('CWD témp', done => {
client.cwd('témp', (err, data) => {
expect(err).to.not.exist;
expect(data).to.be.a('string');
done();
@@ -222,10 +249,10 @@ describe('FtpServer', function () {
});
});
it('RMD tmp', done => {
client.rmdir('tmp', err => {
it('RMD témp', done => {
client.rmdir('témp', err => {
expect(err).to.not.exist;
expect(fs.existsSync('./test/tmp')).to.equal(false);
expect(fs.existsSync('./test/témp')).to.equal(false);
done();
});
});

3697
yarn.lock Normal file

File diff suppressed because it is too large Load Diff