/
FtpContext.js
214 lines (199 loc) · 6.99 KB
/
FtpContext.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
"use strict";
const Socket = require("net").Socket;
const parseControlResponse = require("./parseControlResponse");
/**
* FTPContext holds the control and data sockets of an FTP connection and provides a
* simplified way to interact with an FTP server, handle responses, errors and timeouts.
*
* It doesn't implement or use any FTP commands. It's only a foundation to make writing an FTP
* client as easy as possible. You won't usually instantiate this, but use `Client`.
*/
module.exports = class FTPContext {
/**
* Instantiate an FTP context.
*
* @param {number} [timeout=0] Timeout in milliseconds to apply to control and data connections. Use 0 for no timeout.
* @param {string} [encoding="utf8"] Encoding to use for control connection. UTF-8 by default. Use "latin1" for older servers.
*/
constructor(timeout = 0, encoding = "utf8") {
this._timeout = timeout; // Timeout applied to all connections.
this._task = undefined; // Current task to be resolved or rejected.
this._handler = undefined; // Function that handles incoming messages and resolves or rejects a task.
this._partialResponse = ""; // A multiline response might be received as multiple chunks.
this.encoding = encoding; // The encoding used when reading from and writing on the control socket.
this.tlsOptions = {}; // Options for TLS connections.
this.ipFamily = 6; // IP version to prefer (4: IPv4, 6: IPv6).
this.verbose = false; // The client can log every outgoing and incoming message.
this.socket = new Socket(); // The control connection to the FTP server.
this.dataSocket = undefined; // The data connection to the FTP server.
}
/**
* Close control and data connections.
*/
close() {
this.log("Closing sockets.");
this._closeSocket(this._socket);
this._closeSocket(this._dataSocket);
}
/** @type {Socket} */
get socket() {
return this._socket;
}
/**
* Set the socket for the control connection. This will only close the current control socket
* if the new one is set to `undefined` because you're most likely to be upgrading an existing
* control connection that continues to be used.
*
* @type {Socket}
*/
set socket(socket) {
// No data socket should be open in any case where the control socket is set or upgraded.
this.dataSocket = undefined;
if (this._socket) {
this._socket.removeAllListeners();
}
if (socket) {
socket.setKeepAlive(true);
socket.setTimeout(this._timeout);
socket.on("data", data => this._onControlSocketData(data));
socket.once("error", error => {
this._passToHandler({ error });
this.socket = undefined;
});
socket.once("timeout", () => {
this._passToHandler({ error: "Timeout control socket" });
this.socket = undefined;
});
}
else {
this._closeSocket(this._socket);
}
this._socket = socket;
}
/** @type {Socket} */
get dataSocket() {
return this._dataSocket;
}
/**
* Set the socket for the data connection. This will automatically close the former data socket.
*
* @type {Socket}
**/
set dataSocket(socket) {
this._closeSocket(this._dataSocket);
if (socket) {
socket.setTimeout(this._timeout);
socket.once("error", error => {
this._passToHandler({ error });
this.dataSocket = undefined;
});
socket.once("timeout", () => {
this._passToHandler({ error: "Timeout data socket" });
this.dataSocket = undefined;
});
}
this._dataSocket = socket;
}
/**
* Return true if the control socket is using TLS. This does not mean that a session
* has already been negotiated.
*
* @returns {boolean}
*/
get hasTLS() {
return this._socket && this._socket.encrypted === true;
}
/**
* Send an FTP command and handle any response until the new task is resolved. This returns a Promise that
* will hold whatever the handler passed on when resolving/rejecting its task.
*
* @param {string} command
* @param {HandlerCallback} handler
* @returns {Promise<any>}
*/
handle(command, handler) {
return new Promise((resolvePromise, rejectPromise) => {
this._handler = handler;
this._task = {
// When resolving or rejecting we also want the handler
// to no longer receive any responses or errors.
resolve: (...args) => {
this._handler = undefined;
resolvePromise(...args);
},
reject: (...args) => {
this._handler = undefined;
rejectPromise(...args);
}
};
if (command !== undefined) {
this.send(command);
}
});
}
/**
* Send an FTP command without waiting for or handling the result.
*
* @param {string} command
*/
send(command) {
// Don't log passwords.
const message = command.startsWith("PASS") ? "> PASS ###" : `> ${command}`;
this.log(message);
this._socket.write(command + "\r\n", this.encoding);
}
/**
* Log message if set to be verbose.
*
* @param {string} message
*/
log(message) {
if (this.verbose) {
console.log(message);
}
}
/**
* Handle incoming data on the control socket.
*
* @private
* @param {Buffer} data
*/
_onControlSocketData(data) {
let response = data.toString(this.encoding).trim();
this.log(`< ${response}`);
// This response might complete an earlier partial response.
response = this._partialResponse + response;
const parsed = parseControlResponse(response);
// Remember any incomplete remainder.
this._partialResponse = parsed.rest;
// Each response group is passed along individually.
for (const message of parsed.messages) {
const code = parseInt(message.substr(0, 3), 10);
this._passToHandler({ code, message });
}
}
/**
* Send the current handler a payload. This is usually a control socket response
* or a socket event, like an error or timeout.
*
* @private
* @param {Object} payload
*/
_passToHandler(payload) {
if (this._handler) {
this._handler(payload, this._task);
}
}
/**
* Close a socket.
*
* @private
* @param {Socket} socket
*/
_closeSocket(socket) {
if (socket) {
socket.destroy();
socket.removeAllListeners();
}
}
};