UNPKG

universal-fs

Version:

Allows a client or server to access the file system in the current codebase or a different one.

717 lines (687 loc) 25.5 kB
import { isBrowser, isNode } from 'browser-or-node'; import bcrypt from 'bcrypt'; import fs from 'fs'; import dotenv from 'dotenv'; import express from 'express'; import ngrok from '@ngrok/ngrok'; import process$1 from 'process'; /** * Find a cookie by name * @param cookieName - The name of the cookie * @returns A cookie string or null */ const getCookie = (cookieName) => { const name = cookieName + "="; const decodedCookie = decodeURIComponent(document.cookie); const cookieArray = decodedCookie.split(";"); for (let i = 0; i < cookieArray.length; i++) { const cookie = cookieArray[i].trim(); if (cookie.indexOf(name) === 0) { return cookie.substring(name.length, cookie.length); } } return null; }; /** * The auth function called in init * @param password - The password to protect the files * @see {@link init} * @internal * @async */ const auth = async (password) => { if (!process.env.UNIVERSAL_FS_PASSWORD && !password) { throw new Error("An environment variable UNIVERSAL_FS_PASSWORD or password prop is required to protect your files"); } if (!password && isBrowser) { throw new Error("A password prop is required in browser environments"); } let response; if (isBrowser) { response = await fetch(getCookie("universal-fs-url") || "", { headers: { Authorization: `Bearer ${bcrypt.hashSync(password || process.env.UNIVERSAL_FS_PASSWORD, 10)}` } }); } else if (isNode) { response = await fetch(fs.readFileSync(".fs/url.txt", "utf8"), { headers: { Authorization: `Bearer ${bcrypt.hashSync(password || process.env.UNIVERSAL_FS_PASSWORD, 10)}` } }); } else { throw new Error("Unsupported environment"); } if (response.status !== 401) { console.info("Authenticated"); if (isBrowser) { document.cookie = `universal-fs-token=${bcrypt.hashSync(password || process.env.UNIVERSAL_FS_PASSWORD, 10)};`; } else if (isNode) { fs.writeFileSync(".fs/token.txt", bcrypt.hashSync(password || process.env.UNIVERSAL_FS_PASSWORD, 10)); } } }; /** * Check if a string is json * @param str The string to check * @returns A boolean if true the string is valid otherwise false */ const isJson = (str) => { try { JSON.parse(str); return true; } catch (err) { return false; } }; /** * The class for controllering the file relay server. */ class Server { /** * The constructor of the class for controllering the file relay server. * @param startServer - An optional custom function to use your own Express server */ constructor(options) { var _a, _b; this.isProtected = true; this.app = express(); this.port = 3000; this.startServer = (_a = options === null || options === void 0 ? void 0 : options.startServer) !== null && _a !== void 0 ? _a : undefined; this.isProtected = (_b = options === null || options === void 0 ? void 0 : options.isProtected) !== null && _b !== void 0 ? _b : true; } /** * Initilizes the file relay server * @returns The url to the open Ngrok tunnel * @async */ async init() { this.app.use(express.json()); let authed = false; if (this.isProtected) { this.app.use((req, res, next) => { if (!process$1.env.UNIVERSAL_FS_PASSWORD) { return res.status(401).json({ success: false, error: "An environment variable UNIVERSAL_FS_PASSWORD is required to protect your files" }); } if (!req.headers.authorization) { return res.status(401).json({ success: false, error: "An Authorization header is required" }); } const token = req.headers.authorization.replace(/^Bearer\s/, ""); if (!bcrypt.compareSync(process$1.env.UNIVERSAL_FS_PASSWORD, token)) { return res.status(401).json({ success: false, error: "Unauthorized request" }); } authed = true; next(); }); } this.app.use((req, res, next) => { const path = decodeURIComponent(req.params.path); if (!authed && (req.query.method === "writeFile" || req.query.method === "mkdir" || req.query.method === "unlink" || req.query.method === "rmdir")) { throw new Error("ILLEGAL METHOD: you cannot use write or delete on a non-protected server"); } const ignoredFile = fs.readFileSync(".gitignore", "utf8").split("\n"); for (const file of ignoredFile) { if (file === path) { return res.status(403).json({ error: "The requested resource is not avabile" }); } } if (!req.query.method || req.query.method === "") { return res.status(422).json({ error: "A method is required" }); } if (req.method === "POST" && !req.body) { return res.status(422).json({ error: "A body is required on post requests" }); } next(); }); this.app.use((req, res, next) => { const path = decodeURIComponent(req.params.path); const validPathRegex = /^(\/?(\.{1,2}\/)*[a-zA-Z0-9\-.]+)+$/; if (!validPathRegex.test(path)) { return res.status(400).json({ success: false, error: `Path ${path} is not valid` }); } next(); }); this.app.get("/:path", async (req, res) => { const path = decodeURIComponent(req.params.path); switch (req.query.method) { case "readFile": let fileOptions = null; let fileBuffer = null; if (isJson(req.headers.options)) { fileOptions = JSON.parse(req.headers.options); } try { fileBuffer = fs.readFileSync(path, fileOptions); return res.json({ success: true, buffer: fileBuffer }); } catch (err) { return res.status(500).json({ success: false, error: err }); } case "readdir": let dirs = null; let dirOptions = null; if (isJson(req.headers.options)) { dirOptions = JSON.parse(req.headers.options); } try { dirs = fs.readdirSync(path, dirOptions); return res.json({ success: true, dirs: dirs }); } catch (err) { return res.status(500).json({ success: false, error: err }); } case "exists": try { const exists = fs.existsSync(path); return res.json({ success: true, exists: exists }); } catch (err) { return res.status(500).json({ success: false, error: err }); } default: // This should never trigger because of the first check return res.status(422).json({ error: "Method not found" }); } }); this.app.post("/:path", (req, res) => { const path = decodeURIComponent(req.params.path); switch (req.query.method) { case "writeFile": let writeOptions; if (isJson(req.headers.options)) { writeOptions = JSON.parse(req.headers.options); } try { fs.writeFileSync(path, req.body.contents, writeOptions); return res .status(200) .json({ success: true, message: "File written" }); } catch (err) { return res.status(500).json({ success: false, error: err }); } case "mkdir": let mkdirOptions = null; if (isJson(req.headers.options)) { mkdirOptions = JSON.parse(req.headers.options); } try { fs.mkdirSync(path, mkdirOptions); return res .status(200) .json({ success: true, message: "Directory created" }); } catch (err) { return res.status(500).json({ success: false, error: err }); } default: return res.status(422).json({ error: "Method not found" }); } }); this.app.delete("/:path", (req, res) => { const path = decodeURIComponent(req.params.path); switch (req.query.method) { case "unlink": try { fs.unlinkSync(path); return res .status(200) .json({ success: true, message: "File deleted" }); } catch (err) { return res.status(500).json({ success: false, error: err }); } case "rmdir": let rmOptions = undefined; if (isJson(req.headers.options)) { rmOptions = JSON.parse(req.headers.options); } try { fs.rmdirSync(path, rmOptions); return res .status(200) .json({ success: true, message: "Directory deleted" }); } catch (err) { return res.status(500).json({ success: false, error: err }); } default: return res.status(422).json({ error: "Method not found" }); } }); if (this.startServer) { let customUrl; if (this.startServer.length === 0) { throw new Error("A startServer function must accpet at least one argument"); } else if (this.startServer.length === 1) { customUrl = await this.startServer(this.app); } else if (this.startServer.length >= 3) { throw new Error("StartServer has too many arguments"); } else { customUrl = await this.startServer(this.app, this.server); } if (!customUrl) { throw new Error("StartServer must return a url"); } return customUrl; } try { this.server = this.app.listen(this.port, () => { console.info(`Listening on this.port ${this.port}`); }); } catch (err) { if (err.code === "EADDRINUSE") { console.info(`this.port ${this.port} is already in use. Trying to connect on this.port ${this.port++}`); this.port = this.port++; } } const listener = await ngrok.connect({ addr: this.port, authtoken: process$1.env.NGROK_AUTHTOKEN }); return listener.url(); } /** * Stops the server by closing the current connection. */ stop() { this.server.close(); } } const getUrl = async () => { let url; if (isBrowser) { url = getCookie("universal-fs-url"); } else if (isNode) { const fs = await import('fs'); url = fs.readFileSync(".fs/url.txt", "utf8"); } return url; }; const getTokenSync = async () => { if (isBrowser) { return getCookie("universal-fs-token"); } else if (isNode) { const fs = await import('fs'); return fs.readFileSync(".fs/token.txt", "utf8"); } }; const pollyfillBuffer = async () => { let BufferPolyfill; if (isBrowser) { BufferPolyfill = (await import('buffer/')).Buffer; } else if (isNode) { BufferPolyfill = Buffer; } else { throw new Error("Failed to pollyfill Buffer. Unsupported environment"); } return BufferPolyfill; }; /** * Asynchronously reads the entire contents of a file. If no encoding is specified (using options.encoding), the data is returned as a Buffer object. Otherwise, the data will be a string. If options is a string, then it specifies the encoding. When the path is a directory, the behavior of fsPromises.readFile() is platform-specific. On macOS, Linux, and Windows, the promise will be rejected with an error. On FreeBSD, a representation of the directory's contents will be returned. An example of reading a package.json file located in the same directory of the running code: ```js import { readFile } from 'universal-fs'; try { const filePath = new URL('./package.json', import.meta.url); const contents = await readFile(filePath, { encoding: 'utf8' }); console.log(contents); } catch (err) { console.error(err.message); } ``` It is possible to abort an ongoing readFile using an AbortSignal. If a request is aborted the promise returned is rejected with an AbortError: ```js import { readFile } from 'universal-fs'; try { const controller = new AbortController(); const { signal } = controller; const promise = readFile(fileName, { signal }); // Abort the request before the promise settles. controller.abort(); await promise; } catch (err) { // When a request is aborted - err is an AbortError console.error(err); } ``` Aborting an ongoing request does not abort individual operating system requests but rather the internal buffering fs.readFile performs. Any specified FileHandle has to support reading. @since universal-fs V1.0.0 | Node.js v10.0.0 @param path filename or FileHandle @param options Read options @return Fulfills with the contents of the file. @async */ const readFile = async (path, options) => { const url = await getUrl(); const BufferPolyfill = await pollyfillBuffer(); try { const response = await fetch(`${url}/${encodeURIComponent(path)}?method=readFile`, { signal: options === null || options === void 0 ? void 0 : options.signal, headers: { Authorization: `Bearer ${await getTokenSync()}`, options: JSON.stringify(options) } }); const { buffer } = await response.json(); if (!(options === null || options === void 0 ? void 0 : options.encoding)) { return buffer; } return BufferPolyfill.from(buffer).toString(options === null || options === void 0 ? void 0 : options.encoding); } catch (err) { throw err; } }; /** * Reads the contents of a directory. * * The optional `options` argument can be a string specifying an encoding, or an * object with an `encoding` property specifying the character encoding to use for * the filenames. If the `encoding` is set to `'buffer'`, the filenames returned * will be passed as `Buffer` objects. * * If `options.withFileTypes` is set to `true`, the returned array will contain `fs.Dirent` objects. * * ```js * import { readdir } from 'universal-fs'; * * try { * const files = await readdir(path); * for (const file of files) * console.log(file); * } catch (err) { * console.error(err); * } * ``` * * Note: Abortable is not present in the Node.js API. * * @since universal-fs V1.0.0 | Node.js v10.0.0 * @return Fulfills with an array of the names of the files in the directory excluding `'.'` and `'..'`. * @async */ const readdir = async (path, options) => { const url = await getUrl(); try { const response = await fetch(`${url}/${encodeURIComponent(path)}?method=readdir`, { signal: options === null || options === void 0 ? void 0 : options.signal, headers: { Authorization: `Bearer ${await getTokenSync()}`, options: JSON.stringify(options) } }); const { dirs } = await response.json(); return dirs; } catch (err) { throw err; } }; /** * Asynchronously writes data to a file, replacing the file if it already exists. `data` can be a string, a buffer, an * [AsyncIterable](https://tc39.github.io/ecma262/#sec-asynciterable-interface), or an * [Iterable](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#The_iterable_protocol) object. * * The `encoding` option is ignored if `data` is a buffer. * * If `options` is a string, then it specifies the encoding. * * The `mode` option only affects the newly created file. See `fs.open()` for more details. * * Any specified `FileHandle` has to support writing. * * It is unsafe to use `fsPromises.writeFile()` multiple times on the same file * without waiting for the promise to be settled. * * Similarly to `fsPromises.readFile` \- `fsPromises.writeFile` is a convenience * method that performs multiple `write` calls internally to write the buffer * passed to it. For performance sensitive code consider using `fs.createWriteStream()` or `filehandle.createWriteStream()`. * * It is possible to use an `AbortSignal` to cancel an `fsPromises.writeFile()`. * ~~Cancelation is "best effort", and some amount of data is likely still * to be written.~~ universal-fs uses fetch requests under the hood. I request can be aborted but beyond a certain point it can no longer be aborted. * * ```js * import { writeFile } from 'universal-fs'; * import { Buffer } from 'node:buffer'; * * try { * const controller = new AbortController(); * const { signal } = controller; * const promise = writeFile('message.txt', 'Hello Node.js', { signal }); * * // Abort the request before the promise settles. * controller.abort(); * * await promise; * } catch (err) { * // When a request is aborted - err is an AbortError * console.error(err); * } * ``` * * Aborting an ongoing request does not abort individual operating * system requests but rather the internal buffering `fs.writeFile` performs. * @since universal-fs v1.0.0 | Node.js v10.0.0 * @param file filename or `FileHandle` * @return Fulfills with `undefined` upon success. * @async */ const writeFile = async (path, data, options) => { const url = await getUrl(); try { const response = await fetch(`${url}/${encodeURIComponent(path)}?method=writeFile`, { signal: options === null || options === void 0 ? void 0 : options.signal, method: "POST", headers: { "Authorization": `Bearer ${await getTokenSync()}`, "options": JSON.stringify(options), "content-type": "application/json" }, body: JSON.stringify({ contents: data }) }); } catch (err) { throw err; } }; /** * Asynchronously creates a directory. * * The optional `options` argument can be an integer specifying `mode` (permission * and sticky bits), or an object with a `mode` property and a `recursive` property indicating whether parent directories should be created. Calling `fsPromises.mkdir()` when `path` is a directory * that exists results in a * rejection only when `recursive` is false. * * ```js * import { mkdir } from 'universal-fs'; * * try { * const projectFolder = new URL('./test/project/', import.meta.url); * const createDir = await mkdir(projectFolder, { recursive: true }); * * console.log(`created ${createDir}`); * } catch (err) { * console.error(err.message); * } * ``` * * *Note: In universal-fs you can abortate a request using AbortController* * * @since universal-fs V1.0.0 | Node.js v10.0.0 * @return Upon success, fulfills with `undefined` if `recursive` is `false`, or the first directory path created if `recursive` is `true`. */ const mkdir = async (path, options) => { const url = await getUrl(); try { await fetch(`${url}/${encodeURIComponent(path)}?method=mkdir`, { method: "POST", signal: options === null || options === void 0 ? void 0 : options.signal, headers: { "Content-Type": "application/json", "Authorization": `Bearer ${await getTokenSync()}`, "options": JSON.stringify(options) } }); } catch (err) { throw err; } }; /** * If `path` refers to a symbolic link, then the link is removed without affecting * the file or directory to which that link refers. If the `path` refers to a file * path that is not a symbolic link, the file is deleted. See the POSIX [`unlink(2)`](http://man7.org/linux/man-pages/man2/unlink.2.html) documentation for more detail. * * *Note: In universal-fs you can abortate a request using AbortController* * * @since universal-fs V1.0.0 | Node.js v10.0.0 * @param path A path to a file. If a URL is provided, it must use the `file:` protocol. * @param options An Abortable * @see {@link Abortable} * @return Fulfills with ~~`undefined`~~ `void` upon success. */ const unlink = async (path, options) => { const url = await getUrl(); try { await fetch(`${url}/${encodeURIComponent(path)}?method=unlink`, { signal: options === null || options === void 0 ? void 0 : options.signal, method: "DELETE" }); } catch (err) { throw err; } }; /** * Removes the directory identified by `path`. * * Using `fsPromises.rmdir()` on a file (not a directory) results in the * promise being rejected with an `ENOENT` error on Windows and an `ENOTDIR` error on POSIX. * * To get a behavior similar to the `rm -rf` Unix command, use `fsPromises.rm()` with options `{ recursive: true, force: true }`. * @since universal-fs V1.0.0 | Node.js v10.0.0 * @return Fulfills with ~~`undefined`~~ `void` upon success. */ const rmdir = async (path, options) => { const url = await getUrl(); try { await fetch(`${url}/${encodeURIComponent(path)}`, { signal: options === null || options === void 0 ? void 0 : options.signal, method: "DELETE", headers: { Authorization: `Bearer ${await getTokenSync()}`, options: JSON.stringify(options) } }); } catch (err) { throw err; } }; /** * A modern implementation of the Node fs @see{@link exists} API. * Returns `true` if the path exists, `false` otherwise in promise form. * * Usage: * ```ts * import { exists } from 'universal-fs'; * * const hasFoundFile = await exists('/path/to/file.txt'); * ``` * * You can also abort an outgoing request using an AbortSignal. If a request is aborted the promise returned is rejected with an AbortError: * ```ts * * import { exists } from 'universal-fs'; * * const controller = new AbortController(); * const { signal } = controller; * * const hasFoundFile = await exists('/path/to/file.txt', { signal }); * * controller.abort(); * ``` */ const exists = async (path, options) => { const url = await getUrl(); try { const response = await fetch(`${url}/${encodeURIComponent(path)}?method=exists`, { signal: options === null || options === void 0 ? void 0 : options.signal, headers: { Authorization: `Bearer ${await getTokenSync()}` } }); const { exists } = await response.json(); return exists; } catch (err) { throw err; } }; dotenv.config({ path: ".env" }); /** * Initialize the connection to the relay server. Avabile in most JS environments * @param url - The url of the relay server * @param password - The password to protect the files * @async */ const init = async (url, password, isProtected) => { const response = await fetch(url); if (response.status === 404) { throw new Error("Relay server not found"); } if (isBrowser) { document.cookie = `universal-fs-url=${url};`; } else if (isNode) { if (!fs.existsSync(".fs")) { console.info("No .fs directory found. Creating .fs directory"); fs.mkdirSync(".fs"); } fs.writeFileSync(".fs/url.txt", url); } if (isProtected) { await auth(password); } }; export { Server, auth, exists, init, mkdir, readFile, readdir, rmdir, unlink, writeFile };