@indiekit/store-ftp
Version:
FTP content store adaptor for Indiekit
241 lines (208 loc) • 5.99 kB
JavaScript
import path from "node:path";
import process from "node:process";
import { Readable } from "node:stream";
import { IndiekitError } from "@indiekit/error";
import Client from "ssh2-sftp-client";
const defaults = {
directory: "",
password: process.env.FTP_PASSWORD,
port: 22,
user: process.env.FTP_USER,
};
export default class FtpStore {
name = "FTP store";
/**
* @param {object} [options] - Plug-in options
* @param {string} [options.host] - FTP hostname
* @param {number} [options.port] - FTP port
* @param {string} [options.user] - FTP username
* @param {string} [options.password] - FTP password
* @param {string} [options.directory] - Directory
*/
constructor(options = {}) {
this.options = { ...defaults, ...options };
}
get environment() {
return ["FTP_PASSWORD", "FTP_USER"];
}
get info() {
const { directory, host, user } = this.options;
return {
name: `${user} on ${host}`,
uid: `sftp://${host}/${directory}`,
};
}
get prompts() {
return [
{
type: "text",
name: "host",
message: "Where is your FTP server hosted?",
description: "i.e. ftp.server.example",
},
{
type: "text",
name: "user",
message: "What is your FTP username?",
},
{
type: "text",
name: "directory",
message: "Which directory do you want to save files in?",
},
];
}
/**
* Get FTP client interface
* @access private
* @returns {Promise<Client>} FTP client interface
*/
async #client() {
const { host, user: username, password, port } = this.options;
const client = new Client();
try {
await client.connect({ host, username, password, port });
return client;
} catch (error) {
throw new IndiekitError(error.message, {
cause: error,
plugin: this.name,
status: error.status,
});
}
}
/**
* Create readable stream
* @access private
* @param {string} content - File content
* @returns {Readable} Readable stream
*/
#createReadableStream(content) {
const readableStream = new Readable();
readableStream._read = () => {};
readableStream.push(content, "utf8");
// eslint-disable-next-line unicorn/prefer-single-call, unicorn/no-null
readableStream.push(null);
return readableStream;
}
/**
* Get absolute file path
* @access private
* @param {string} filePath - Path to file
* @returns {string} Absolute file path
*/
#absolutePath(filePath) {
return path.join(this.options.directory, filePath);
}
/**
* Create file
* @param {string} filePath - Path to file
* @param {string} content - File content
* @returns {Promise<string>} File created
*/
async createFile(filePath, content) {
const client = await this.#client();
try {
const readableStream = this.#createReadableStream(content);
const absolutePath = this.#absolutePath(filePath);
// Return if file already exists
const fileExists = await client.exists(absolutePath);
if (fileExists) {
return;
}
// Create directory if doesn’t exist
const directory = path.dirname(absolutePath);
const directoryType = await client.exists(directory);
if (directoryType !== "d") {
await client.mkdir(directory, true);
}
await client.put(readableStream, absolutePath);
const url = new URL(this.info.uid);
url.pathname = path.join(url.pathname, filePath);
return url.href;
} catch (error) {
throw new IndiekitError(error.message, {
cause: error,
plugin: this.name,
status: error.status,
});
} finally {
await client.end();
}
}
/**
* Read file
* @param {string} filePath - Path to file
* @returns {Promise<string|NodeJS.WritableStream|Buffer>} File content
*/
async readFile(filePath) {
const client = await this.#client();
try {
const absolutePath = this.#absolutePath(filePath);
return await client.get(absolutePath, undefined, {
readStreamOptions: { encoding: "utf8" },
});
} catch (error) {
throw new IndiekitError(error.message, {
cause: error,
plugin: this.name,
status: error.status,
});
} finally {
await client.end();
}
}
/**
* Update file
* @param {string} filePath - Path to file
* @param {string} content - File content
* @param {object} [options] - Options
* @param {string} [options.newPath] - New path to file
* @returns {Promise<string>} File updated
*/
async updateFile(filePath, content, options) {
const client = await this.#client();
try {
const readableStream = this.#createReadableStream(content);
const absolutePath = this.#absolutePath(filePath);
await client.put(readableStream, absolutePath);
if (options?.newPath) {
await client.rename(absolutePath, this.#absolutePath(options.newPath));
}
const url = new URL(this.info.uid);
url.pathname = path.join(url.pathname, options?.newPath || filePath);
return url.href;
} catch (error) {
throw new IndiekitError(error.message, {
cause: error,
plugin: this.name,
status: error.status,
});
} finally {
await client.end();
}
}
/**
* Delete file
* @param {string} filePath - Path to file
* @returns {Promise<string>} File deleted
*/
async deleteFile(filePath) {
const client = await this.#client();
try {
const absolutePath = this.#absolutePath(filePath);
return await client.delete(absolutePath);
} catch (error) {
throw new IndiekitError(error.message, {
cause: error,
plugin: this.name,
status: error.status,
});
} finally {
await client.end();
}
}
init(Indiekit) {
Indiekit.addStore(this);
}
}