"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.FTPContext = exports.FTPError = void 0; const net_1 = require("net"); const parseControlResponse_1 = require("./parseControlResponse"); /** * Describes an FTP server error response including the FTP response code. */ class FTPError extends Error { constructor(res) { super(res.message); this.name = this.constructor.name; this.code = res.code; } } exports.FTPError = FTPError; function doNothing() { /** Do nothing */ } /** * 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`. */ class FTPContext { /** * Instantiate an FTP context. * * @param timeout - Timeout in milliseconds to apply to control and data connections. Use 0 for no timeout. * @param encoding - Encoding to use for control connection. UTF-8 by default. Use "latin1" for older servers. */ constructor(timeout = 0, encoding = "utf8") { this.timeout = timeout; /** Debug-level logging of all socket communication. */ this.verbose = false; /** IP version to prefer (4: IPv4, 6: IPv6, undefined: automatic). */ this.ipFamily = undefined; /** Options for TLS connections. */ this.tlsOptions = {}; /** A multiline response might be received as multiple chunks. */ this._partialResponse = ""; this._encoding = encoding; // Help Typescript understand that we do indeed set _socket in the constructor but use the setter method to do so. this._socket = this.socket = this._newSocket(); this._dataSocket = undefined; } /** * Close the context. */ close() { // Internally, closing a context is always described with an error. If there is still a task running, it will // abort with an exception that the user closed the client during a task. If no task is running, no exception is // thrown but all newly submitted tasks after that will abort the exception that the client has been closed. // In addition the user will get a stack trace pointing to where exactly the client has been closed. So in any // case use _closingError to determine whether a context is closed. This also allows us to have a single code-path // for closing a context making the implementation easier. const message = this._task ? "User closed client during task" : "User closed client"; const err = new Error(message); this.closeWithError(err); } /** * Close the context with an error. */ closeWithError(err) { // If this context already has been closed, don't overwrite the reason. if (this._closingError) { return; } this._closingError = err; // Close the sockets but don't fully reset this context to preserve `this._closingError`. this._closeControlSocket(); this._closeSocket(this._dataSocket); // Give the user's task a chance to react, maybe cleanup resources. this._passToHandler(err); // The task might not have been rejected by the user after receiving the error. this._stopTrackingTask(); } /** * Returns true if this context has been closed or hasn't been connected yet. You can reopen it with `access`. */ get closed() { return this.socket.remoteAddress === undefined || this._closingError !== undefined; } /** * Reset this contex and all of its state. */ reset() { this.socket = this._newSocket(); } /** * Get the FTP control 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 not an upgrade to the current one. */ set socket(socket) { // No data socket should be open in any case where the control socket is set or upgraded. this.dataSocket = undefined; // This being a reset, reset any other state apart from the socket. this.tlsOptions = {}; this._partialResponse = ""; if (this._socket) { const newSocketUpgradesExisting = socket.localPort === this._socket.localPort; if (newSocketUpgradesExisting) { this._removeSocketListeners(this.socket); } else { this._closeControlSocket(); } } if (socket) { // Setting a completely new control socket is in essence something like a reset. That's // why we also close any open data connection above. We can go one step further and reset // a possible closing error. That means that a closed FTPContext can be "reopened" by // setting a new control socket. this._closingError = undefined; // Don't set a timeout yet. Timeout for control sockets is only active during a task, see handle() below. socket.setTimeout(0); socket.setEncoding(this._encoding); socket.setKeepAlive(true); socket.on("data", data => this._onControlSocketData(data)); // Server sending a FIN packet is treated as an error. socket.on("end", () => this.closeWithError(new Error("Server sent FIN packet unexpectedly, closing connection."))); // Control being closed without error by server is treated as an error. socket.on("close", hadError => { if (!hadError) this.closeWithError(new Error("Server closed connection unexpectedly.")); }); this._setupDefaultErrorHandlers(socket, "control socket"); } this._socket = socket; } /** * Get the current FTP data connection if present. */ get dataSocket() { return this._dataSocket; } /** * Set the socket for the data connection. This will automatically close the former data socket. */ set dataSocket(socket) { this._closeSocket(this._dataSocket); if (socket) { // Don't set a timeout yet. Timeout data socket should be activated when data transmission starts // and timeout on control socket is deactivated. socket.setTimeout(0); this._setupDefaultErrorHandlers(socket, "data socket"); } this._dataSocket = socket; } /** * Get the currently used encoding. */ get encoding() { return this._encoding; } /** * Set the encoding used for the control socket. * * See https://nodejs.org/api/buffer.html#buffer_buffers_and_character_encodings for what encodings * are supported by Node. */ set encoding(encoding) { this._encoding = encoding; if (this.socket) { this.socket.setEncoding(encoding); } } /** * Send an FTP command without waiting for or handling the result. */ send(command) { const containsPassword = command.startsWith("PASS"); const message = containsPassword ? "> PASS ###" : `> ${command}`; this.log(message); this._socket.write(command + "\r\n", this.encoding); } /** * Send an FTP command and handle the first response. Use this if you have a simple * request-response situation. */ request(command) { return this.handle(command, (res, task) => { if (res instanceof Error) { task.reject(res); } else { task.resolve(res); } }); } /** * Send an FTP command and handle any response until you resolve/reject. Use this if you expect multiple responses * to a request. This returns a Promise that will hold whatever the response handler passed on when resolving/rejecting its task. */ handle(command, responseHandler) { if (this._task) { const err = new Error("User launched a task while another one is still running. Forgot to use 'await' or '.then()'?"); err.stack += `\nRunning task launched at: ${this._task.stack}`; this.closeWithError(err); // Don't return here, continue with returning the Promise that will then be rejected // because the context closed already. That way, users will receive an exception where // they called this method by mistake. } return new Promise((resolveTask, rejectTask) => { this._task = { stack: new Error().stack || "Unknown call stack", responseHandler, resolver: { resolve: arg => { this._stopTrackingTask(); resolveTask(arg); }, reject: err => { this._stopTrackingTask(); rejectTask(err); } } }; if (this._closingError) { // This client has been closed. Provide an error that describes this one as being caused // by `_closingError`, include stack traces for both. const err = new Error(`Client is closed because ${this._closingError.message}`); // Type 'Error' is not correctly defined, doesn't have 'code'. err.stack += `\nClosing reason: ${this._closingError.stack}`; err.code = this._closingError.code !== undefined ? this._closingError.code : "0"; this._passToHandler(err); return; } // Only track control socket timeout during the lifecycle of a task. This avoids timeouts on idle sockets, // the default socket behaviour which is not expected by most users. this.socket.setTimeout(this.timeout); if (command) { this.send(command); } }); } /** * Log message if set to be verbose. */ log(message) { if (this.verbose) { // tslint:disable-next-line no-console console.log(message); } } /** * Return true if the control socket is using TLS. This does not mean that a session * has already been negotiated. */ get hasTLS() { return "encrypted" in this._socket; } /** * Removes reference to current task and handler. This won't resolve or reject the task. * @protected */ _stopTrackingTask() { // Disable timeout on control socket if there is no task active. this.socket.setTimeout(0); this._task = undefined; } /** * Handle incoming data on the control socket. The chunk is going to be of type `string` * because we let `socket` handle encoding with `setEncoding`. * @protected */ _onControlSocketData(chunk) { this.log(`< ${chunk}`); // This chunk might complete an earlier partial response. const completeResponse = this._partialResponse + chunk; const parsed = (0, parseControlResponse_1.parseControlResponse)(completeResponse); // 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); const response = { code, message }; const err = code >= 400 ? new FTPError(response) : undefined; this._passToHandler(err ? err : response); } } /** * Send the current handler a response. This is usually a control socket response * or a socket event, like an error or timeout. * @protected */ _passToHandler(response) { if (this._task) { this._task.responseHandler(response, this._task.resolver); } // Errors other than FTPError always close the client. If there isn't an active task to handle the error, // the next one submitted will receive it using `_closingError`. // There is only one edge-case: If there is an FTPError while no task is active, the error will be dropped. // But that means that the user sent an FTP command with no intention of handling the result. So why should the // error be handled? Maybe log it at least? Debug logging will already do that and the client stays useable after // FTPError. So maybe no need to do anything here. } /** * Setup all error handlers for a socket. * @protected */ _setupDefaultErrorHandlers(socket, identifier) { socket.once("error", error => { error.message += ` (${identifier})`; this.closeWithError(error); }); socket.once("close", hadError => { if (hadError) { this.closeWithError(new Error(`Socket closed due to transmission error (${identifier})`)); } }); socket.once("timeout", () => { socket.destroy(); this.closeWithError(new Error(`Timeout (${identifier})`)); }); } /** * Close the control socket. Sends QUIT, then FIN, and ignores any response or error. */ _closeControlSocket() { this._removeSocketListeners(this._socket); this._socket.on("error", doNothing); this.send("QUIT"); this._closeSocket(this._socket); } /** * Close a socket, ignores any error. * @protected */ _closeSocket(socket) { if (socket) { this._removeSocketListeners(socket); socket.on("error", doNothing); socket.destroy(); } } /** * Remove all default listeners for socket. * @protected */ _removeSocketListeners(socket) { socket.removeAllListeners(); // Before Node.js 10.3.0, using `socket.removeAllListeners()` without any name did not work: https://github.com/nodejs/node/issues/20923. socket.removeAllListeners("timeout"); socket.removeAllListeners("data"); socket.removeAllListeners("end"); socket.removeAllListeners("error"); socket.removeAllListeners("close"); socket.removeAllListeners("connect"); } /** * Provide a new socket instance. * * Internal use only, replaced for unit tests. */ _newSocket() { return new net_1.Socket(); } } exports.FTPContext = FTPContext;