123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365 |
- "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;
|