@nuvix/storage
Version: 
S3-compatible storage library for Nuvix BaaS platform with support for AWS S3, Wasabi, MinIO, and local storage
1,284 lines (1,269 loc) • 44.6 kB
JavaScript
import { readFile } from 'fs/promises';
import { promises } from 'fs';
import path from 'path';
import crypto, { createHash } from 'crypto';
class Device {
    constructor() {
        /**
         * Max chunk size while transferring file from one device to another
         */
        this.transferChunkSize = 20000000; // 20 MB
    }
    /**
     * Set Transfer Chunk Size
     */
    setTransferChunkSize(chunkSize) {
        this.transferChunkSize = chunkSize;
    }
    /**
     * Get Transfer Chunk Size
     */
    getTransferChunkSize() {
        return this.transferChunkSize;
    }
    /**
     * Move file from given source to given path, return true on success and false on failure.
     */
    async move(source, target) {
        if (source === target) {
            return false;
        }
        if (await this.transfer(source, target, this)) {
            return await this.delete(source);
        }
        return false;
    }
    /**
     * Get the absolute path by resolving strings like ../, .., //, /\ and so on.
     *
     * Works like the realpath function but works on files that does not exist
     */
    getAbsolutePath(path) {
        const normalizedPath = path.replace(/[/\\]/g, "/");
        const parts = normalizedPath.split("/").filter((part) => part.length > 0);
        const absolutes = [];
        for (const part of parts) {
            if (part === ".") {
                continue;
            }
            if (part === "..") {
                absolutes.pop();
            }
            else {
                absolutes.push(part);
            }
        }
        return "/" + absolutes.join("/");
    }
    async getMimeType(filePath) {
        const path = await import('path');
        // Node.js doesn't have a built-in MIME type module, so we'll use a comprehensive mapping
        const ext = path.extname(filePath).toLowerCase().slice(1);
        const mimeTypes = {
            // Text
            txt: "text/plain",
            html: "text/html",
            htm: "text/html",
            css: "text/css",
            csv: "text/csv",
            xml: "text/xml",
            // JavaScript
            js: "application/javascript",
            mjs: "application/javascript",
            json: "application/json",
            // Images
            png: "image/png",
            jpg: "image/jpeg",
            jpeg: "image/jpeg",
            gif: "image/gif",
            svg: "image/svg+xml",
            webp: "image/webp",
            ico: "image/x-icon",
            bmp: "image/bmp",
            tiff: "image/tiff",
            tif: "image/tiff",
            // Documents
            pdf: "application/pdf",
            doc: "application/msword",
            docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
            xls: "application/vnd.ms-excel",
            xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            ppt: "application/vnd.ms-powerpoint",
            pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
            // Audio
            mp3: "audio/mpeg",
            wav: "audio/wav",
            ogg: "audio/ogg",
            m4a: "audio/mp4",
            // Video
            mp4: "video/mp4",
            avi: "video/x-msvideo",
            mov: "video/quicktime",
            wmv: "video/x-ms-wmv",
            flv: "video/x-flv",
            webm: "video/webm",
            // Archives
            zip: "application/zip",
            rar: "application/x-rar-compressed",
            tar: "application/x-tar",
            gz: "application/gzip",
            "7z": "application/x-7z-compressed",
            // Other
            bin: "application/octet-stream",
            exe: "application/octet-stream",
            dmg: "application/octet-stream",
        };
        return mimeTypes[ext] || "application/octet-stream";
    }
}
/**
 * Sets the maximum number of keys returned to the response. By default, the action returns up to 1,000 key names.
 */
Device.MAX_PAGE_SIZE = Number.MAX_SAFE_INTEGER;
class Storage {
    /**
     * Set Device.
     *
     * Add device by name
     *
     * @param name
     * @param device
     * @throws Error
     */
    static setDevice(name, device) {
        this.devices.set(name, device);
    }
    /**
     * Get Device.
     *
     * Get device by name
     *
     * @param name
     * @returns Device
     * @throws Error
     */
    static getDevice(name) {
        if (!this.devices.has(name)) {
            throw new Error(`The device "${name}" is not listed`);
        }
        return this.devices.get(name);
    }
    /**
     * Exists.
     *
     * Checks if given storage name is registered or not
     *
     * @param name
     * @returns boolean
     */
    static exists(name) {
        return this.devices.has(name);
    }
    /**
     * Human readable data size format from bytes input.
     *
     * Based on: https://stackoverflow.com/a/38659168/2299554
     *
     * @param bytes
     * @param decimals
     * @param system
     * @returns string
     */
    static human(bytes, decimals = 2, system = "metric") {
        const mod = system === "binary" ? 1024 : 1000;
        const units = {
            binary: ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"],
            metric: ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"],
        };
        const factor = Math.floor((bytes.toString().length - 1) / 3);
        return `${(bytes / Math.pow(mod, factor)).toFixed(decimals)}${units[system][factor]}`;
    }
}
/**
 * Supported devices
 */
Storage.DEVICE_LOCAL = "local";
Storage.DEVICE_S3 = "s3";
// static readonly DEVICE_DO_SPACES = 'dospaces';
Storage.DEVICE_WASABI = "wasabi";
Storage.DEVICE_MINIO = "minio";
// static readonly DEVICE_BACKBLAZE = 'backblaze';
// static readonly DEVICE_LINODE = 'linode';
/**
 * Devices.
 *
 * List of all available storage devices
 */
Storage.devices = new Map();
class Validator {
}
Validator.TYPE_STRING = "string";
Validator.TYPE_ARRAY = "array";
Validator.TYPE_INTEGER = "integer";
Validator.TYPE_BOOLEAN = "boolean";
class File extends Validator {
    getDescription() {
        return "File is not valid";
    }
    /**
     * NOT MUCH RIGHT NOW.
     *
     * TODO think what to do here, currently only used for parameter to be present in SDKs
     *
     * @param name
     * @return boolean
     */
    isValid(name) {
        return true;
    }
}
class FileExt extends Validator {
    constructor(allowed) {
        super();
        this.allowed = allowed;
    }
    getDescription() {
        return "File extension is not valid";
    }
    isValid(filename) {
        const ext = this.getFileExtension(filename).toLowerCase();
        return this.allowed.includes(ext);
    }
    getFileExtension(filename) {
        const lastDotIndex = filename.lastIndexOf(".");
        return lastDotIndex !== -1 ? filename.substring(lastDotIndex + 1) : "";
    }
}
FileExt.TYPE_JPEG = "jpeg";
FileExt.TYPE_JPG = "jpg";
FileExt.TYPE_GIF = "gif";
FileExt.TYPE_PNG = "png";
FileExt.TYPE_GZIP = "gz";
FileExt.TYPE_ZIP = "zip";
class FileName extends Validator {
    /**
     * Get Description
     */
    getDescription() {
        return "Filename is not valid";
    }
    /**
     * The file name can only contain "a-z", "A-Z", "0-9" and "." and not empty.
     *
     * @param name - The filename to validate
     * @returns boolean indicating if the filename is valid
     */
    isValid(name) {
        if (!name) {
            return false;
        }
        if (typeof name !== "string") {
            return false;
        }
        if (!/^[a-zA-Z0-9.]+$/.test(name)) {
            return false;
        }
        return true;
    }
}
class FileSize extends Validator {
    /**
     * Max size in bytes
     */
    constructor(max) {
        super();
        this.max = max;
    }
    /**
     * Get Description
     */
    getDescription() {
        return `File size can't be bigger than ${this.max}`;
    }
    /**
     * Finds whether a file size is smaller than required limit.
     */
    isValid(fileSize) {
        if (typeof fileSize !== "number" || !Number.isInteger(fileSize)) {
            return false;
        }
        if (fileSize > this.max || fileSize < 0) {
            return false;
        }
        return true;
    }
}
class FileType extends Validator {
    constructor(allowed) {
        super();
        /**
         * File Type Binaries.
         */
        this.types = {
            [FileType.FILE_TYPE_JPEG]: "\xFF\xD8\xFF",
            [FileType.FILE_TYPE_GIF]: "GIF",
            [FileType.FILE_TYPE_PNG]: "\x89\x50\x4e\x47\x0d\x0a",
            [FileType.FILE_TYPE_GZIP]: "application/x-gzip",
        };
        for (const key of allowed) {
            if (!(key in this.types)) {
                throw new Error("Unknown file mime type");
            }
        }
        this.allowed = allowed;
    }
    /**
     * Get Description
     */
    getDescription() {
        return "File mime-type is not allowed ";
    }
    /**
     * Is Valid.
     *
     * Binary check to finds whether a file is of valid type
     */
    async isValid(path) {
        try {
            const buffer = await readFile(path);
            // Calculate the maximum signature length needed
            const maxSignatureLength = Math.max(...Object.values(this.types).map((sig) => sig.length));
            const bytesToRead = Math.min(buffer.length, Math.max(8, maxSignatureLength));
            const bytes = buffer.toString("binary", 0, bytesToRead);
            for (const key of this.allowed) {
                if (bytes.indexOf(this.types[key]) === 0) {
                    return true;
                }
            }
            return false;
        }
        catch {
            return false;
        }
    }
}
/**
 * File Types Constants.
 */
FileType.FILE_TYPE_JPEG = "jpeg";
FileType.FILE_TYPE_GIF = "gif";
FileType.FILE_TYPE_PNG = "png";
FileType.FILE_TYPE_GZIP = "gz";
class Upload extends Validator {
    /**
     * Get Description
     */
    getDescription() {
        return "Not a valid upload file";
    }
    /**
     * Check if a file is a valid upload file
     *
     * @param path - The file path to validate
     * @returns Promise that resolves to true if valid upload file, false otherwise
     */
    async isValid(path) {
        if (typeof path !== "string") {
            return false;
        }
        try {
            const stats = await promises.stat(path);
            return stats.isFile();
        }
        catch {
            return false;
        }
    }
}
class Local extends Device {
    constructor(root = "") {
        super();
        this.root = "temp";
        this.MAX_PAGE_SIZE = 1000;
        this.root = root;
    }
    getName() {
        return "Local Storage";
    }
    getType() {
        return Storage.DEVICE_LOCAL;
    }
    getDescription() {
        return "Adapter for Local storage that is in the physical or virtual machine or mounted to it.";
    }
    getRoot() {
        return this.root;
    }
    getPath(filename, prefix) {
        return this.getAbsolutePath(path.join(this.getRoot(), filename));
    }
    async upload(source, filePath, chunk = 1, chunks = 1, metadata = {}) {
        await this.createDirectory(path.dirname(filePath));
        if (chunks === 1) {
            try {
                await promises.rename(source, filePath);
                return chunks;
            }
            catch {
                throw new Error(`Can't upload file ${filePath}`);
            }
        }
        const tmp = path.join(path.dirname(filePath), `tmp_${path.basename(filePath)}`, `${path.basename(filePath)}_chunks.log`);
        await this.createDirectory(path.dirname(tmp));
        const chunkFilePath = path.join(path.dirname(tmp), `${path.parse(filePath).name}.part.${chunk}`);
        if (!(await this.exists(chunkFilePath))) {
            try {
                await promises.appendFile(tmp, `${chunk}\n`);
            }
            catch {
                throw new Error(`Can't write chunk log ${tmp}`);
            }
        }
        try {
            const chunkLogs = await promises.readFile(tmp, "utf8");
            const chunksReceived = chunkLogs.trim().split("\n").length;
            await promises.rename(source, chunkFilePath);
            if (chunks === chunksReceived) {
                await this.joinChunks(filePath, chunks);
                return chunksReceived;
            }
            return chunksReceived;
        }
        catch {
            throw new Error(`Failed to write chunk ${chunk}`);
        }
    }
    async uploadData(data, filePath, contentType, chunk = 1, chunks = 1, metadata = {}) {
        await this.createDirectory(path.dirname(filePath));
        if (chunks === 1) {
            try {
                await promises.writeFile(filePath, data);
                return chunks;
            }
            catch {
                throw new Error(`Can't write file ${filePath}`);
            }
        }
        const tmp = path.join(path.dirname(filePath), `tmp_${path.basename(filePath)}`, `${path.basename(filePath)}_chunks.log`);
        await this.createDirectory(path.dirname(tmp));
        try {
            await promises.appendFile(tmp, `${chunk}\n`);
            const chunkLogs = await promises.readFile(tmp, "utf8");
            const chunksReceived = chunkLogs.trim().split("\n").length;
            const chunkFilePath = path.join(path.dirname(tmp), `${path.parse(filePath).name}.part.${chunk}`);
            await promises.writeFile(chunkFilePath, data);
            if (chunks === chunksReceived) {
                await this.joinChunks(filePath, chunks);
                return chunksReceived;
            }
            return chunksReceived;
        }
        catch {
            throw new Error(`Failed to write chunk ${chunk}`);
        }
    }
    async joinChunks(filePath, chunks) {
        const tmp = path.join(path.dirname(filePath), `tmp_${path.basename(filePath)}`, `${path.basename(filePath)}_chunks.log`);
        for (let i = 1; i <= chunks; i++) {
            const part = path.join(path.dirname(tmp), `${path.parse(filePath).name}.part.${i}`);
            try {
                const data = await promises.readFile(part);
                await promises.appendFile(filePath, data);
                await promises.unlink(part);
            }
            catch {
                throw new Error(`Failed to read/append chunk ${part}`);
            }
        }
        await promises.unlink(tmp);
        await promises.rmdir(path.dirname(tmp));
    }
    async transfer(filePath, destination, device) {
        if (!(await this.exists(filePath))) {
            throw new Error("File Not Found");
        }
        const size = await this.getFileSize(filePath);
        const contentType = await this.getFileMimeType(filePath);
        if (size <= this.transferChunkSize) {
            const source = await this.read(filePath);
            return await device.write(destination, source, contentType);
        }
        const totalChunks = Math.ceil(size / this.transferChunkSize);
        const metadata = { content_type: contentType };
        for (let counter = 0; counter < totalChunks; counter++) {
            const start = counter * this.transferChunkSize;
            const data = await this.read(filePath, start, this.transferChunkSize);
            await device.uploadData(data, destination, contentType, counter + 1, totalChunks, metadata);
        }
        return true;
    }
    async abort(filePath, extra = "") {
        if (await this.exists(filePath)) {
            await promises.unlink(filePath);
        }
        const tmp = path.join(path.dirname(filePath), `tmp_${path.basename(filePath)}`);
        if (!(await this.exists(path.dirname(tmp)))) {
            throw new Error(`File doesn't exist: ${path.dirname(filePath)}`);
        }
        const files = await this.getFiles(tmp);
        for (const file of files) {
            await this.delete(file, true);
        }
        return promises
            .rmdir(tmp)
            .then(() => true)
            .catch(() => false);
    }
    async read(filePath, offset = 0, length) {
        if (!(await this.exists(filePath))) {
            throw new Error("File Not Found");
        }
        const fileHandle = await promises.open(filePath, "r");
        try {
            const size = length ?? (await fileHandle.stat()).size - offset;
            const buffer = Buffer.alloc(size);
            await fileHandle.read(buffer, 0, size, offset);
            return buffer;
        }
        finally {
            await fileHandle.close();
        }
    }
    async write(filePath, data, contentType = "") {
        try {
            await this.createDirectory(path.dirname(filePath));
            await promises.writeFile(filePath, data);
            return true;
        }
        catch {
            throw new Error(`Can't write to path ${filePath}`);
        }
    }
    async move(source, target) {
        if (source === target) {
            return false;
        }
        try {
            await this.createDirectory(path.dirname(target));
            await promises.rename(source, target);
            return true;
        }
        catch {
            return false;
        }
    }
    async delete(filePath, recursive = false) {
        try {
            const stats = await promises.stat(filePath);
            if (stats.isDirectory() && recursive) {
                const files = await this.getFiles(filePath);
                for (const file of files) {
                    await this.delete(file, true);
                }
                await promises.rmdir(filePath);
            }
            else if (stats.isFile() || stats.isSymbolicLink()) {
                await promises.unlink(filePath);
            }
            return true;
        }
        catch {
            return false;
        }
    }
    async deletePath(filePath) {
        const fullPath = path.resolve(path.join(this.getRoot(), filePath));
        try {
            if (!(await this.exists(fullPath))) {
                return false;
            }
            const stats = await promises.stat(fullPath);
            if (!stats.isDirectory()) {
                return false;
            }
            const files = await this.getFiles(fullPath);
            for (const file of files) {
                const stats = await promises.stat(file);
                if (stats.isDirectory()) {
                    const relativePath = file.replace(this.getRoot() + path.sep, "");
                    await this.deletePath(relativePath);
                }
                else {
                    await this.delete(file, true);
                }
            }
            await promises.rmdir(fullPath);
            return true;
        }
        catch {
            return false;
        }
    }
    async exists(filePath) {
        try {
            await promises.access(filePath);
            return true;
        }
        catch {
            return false;
        }
    }
    async getFileSize(filePath) {
        const stats = await promises.stat(filePath);
        return stats.size;
    }
    async getFileMimeType(filePath) {
        return this.getMimeType(filePath);
    }
    async getFileHash(filePath) {
        const data = await promises.readFile(filePath);
        return createHash("md5").update(data).digest("hex");
    }
    async createDirectory(dirPath) {
        try {
            await promises.mkdir(dirPath, { recursive: true, mode: 0o755 });
            return true;
        }
        catch {
            return false;
        }
    }
    async getDirectorySize(dirPath) {
        try {
            let size = 0;
            const files = await promises.readdir(dirPath, { withFileTypes: true });
            for (const file of files) {
                if (file.name.startsWith("."))
                    continue;
                const fullPath = path.join(dirPath, file.name);
                if (file.isDirectory()) {
                    size += await this.getDirectorySize(fullPath);
                }
                else {
                    const stats = await promises.stat(fullPath);
                    size += stats.size;
                }
            }
            return size;
        }
        catch {
            return -1;
        }
    }
    async getPartitionFreeSpace() {
        const stats = await promises.statfs(this.getRoot());
        return stats.bavail * stats.bsize;
    }
    async getPartitionTotalSpace() {
        const stats = await promises.statfs(this.getRoot());
        return stats.blocks * stats.bsize;
    }
    async getFiles(dir, max = this.MAX_PAGE_SIZE, continuationToken = "") {
        try {
            const entries = await promises.readdir(dir, { withFileTypes: true });
            return entries.map((entry) => path.join(dir, entry.name));
        }
        catch {
            return [];
        }
    }
}
class S3 extends Device {
    constructor(root, accessKey, secretKey, bucket, region = S3.US_EAST_1, acl = S3.ACL_PRIVATE, endpointUrl = "") {
        super();
        this.acl = S3.ACL_PRIVATE;
        this.root = "temp";
        this.headers = {
            host: "",
            date: "",
            "content-md5": "",
            "content-type": "",
        };
        this.accessKey = accessKey;
        this.secretKey = secretKey;
        this.bucket = bucket;
        this.region = region;
        this.root = root;
        this.acl = acl;
        this.amzHeaders = {};
        let host;
        if (endpointUrl) {
            host = `${bucket}.${endpointUrl}`;
        }
        else {
            switch (region) {
                case S3.CN_NORTH_1:
                case S3.CN_NORTH_4:
                case S3.CN_NORTHWEST_1:
                    host = `${bucket}.s3.${region}.amazonaws.cn`;
                    break;
                default:
                    host = `${bucket}.s3.${region}.amazonaws.com`;
            }
        }
        this.headers["host"] = host;
    }
    getName() {
        return "S3 Storage";
    }
    getType() {
        return Storage.DEVICE_S3;
    }
    getDescription() {
        return "S3 Bucket Storage drive for AWS or on premise solution";
    }
    getRoot() {
        return this.root;
    }
    getPath(filename, prefix) {
        return `${this.getRoot()}/${filename}`;
    }
    getPartitionTotalSpace() {
        throw new Error("Method not implemented.");
    }
    static setRetryAttempts(attempts) {
        S3.retryAttempts = attempts;
    }
    static setRetryDelay(delay) {
        S3.retryDelay = delay;
    }
    async upload(source, path, chunk = 1, chunks = 1, metadata = {}) {
        let data;
        let contentType;
        if (typeof source === "string") {
            const fs = await import('fs');
            data = await fs.promises.readFile(source);
            contentType = await this.getMimeType(source);
        }
        else {
            data = source;
            contentType = metadata.contentType || "application/octet-stream";
        }
        return this.uploadData(data, path, contentType, chunk, chunks, metadata);
    }
    async uploadData(data, path, contentType, chunk = 1, chunks = 1, metadata = {}) {
        if (chunk === 1 && chunks === 1) {
            await this.write(path, data, contentType);
            return 1;
        }
        let uploadId = metadata["uploadId"];
        if (!uploadId) {
            uploadId = await this.createMultipartUpload(path, contentType);
            metadata["uploadId"] = uploadId;
        }
        metadata["parts"] = metadata["parts"] || {};
        metadata["chunks"] = metadata["chunks"] || 0;
        const etag = await this.uploadPart(data, path, contentType, chunk, uploadId);
        const cleanETag = etag.replace(/^"|"$/g, "");
        if (!(chunk in metadata["parts"])) {
            metadata["chunks"]++;
        }
        metadata["parts"][chunk] = cleanETag;
        if (metadata["chunks"] === chunks) {
            await this.completeMultipartUpload(path, uploadId, metadata["parts"]);
        }
        return metadata["chunks"];
    }
    async transfer(path, destination, device) {
        try {
            const response = await this.getInfo(path);
            const size = parseInt(response["content-length"] || "0");
            const contentType = response["content-type"] || "";
            if (size <= this.transferChunkSize) {
                const source = await this.read(path);
                return device.write(destination, source, contentType);
            }
            const totalChunks = Math.ceil(size / this.transferChunkSize);
            const metadata = { content_type: contentType };
            for (let counter = 0; counter < totalChunks; counter++) {
                const start = counter * this.transferChunkSize;
                const data = await this.read(path, start, this.transferChunkSize);
                await device.uploadData(data, destination, contentType, counter + 1, totalChunks, metadata);
            }
            return true;
        }
        catch (e) {
            throw new Error("File not found");
        }
    }
    async createMultipartUpload(path, contentType) {
        const uri = path !== ""
            ? `/${encodeURIComponent(path).replace(/%2F/g, "/").replace(/%3F/g, "?")}`
            : "/";
        this.headers["content-md5"] = Buffer.from(this.md5("")).toString("base64");
        delete this.amzHeaders["x-amz-content-sha256"];
        this.headers["content-type"] = contentType;
        this.amzHeaders["x-amz-acl"] = this.acl;
        const response = await this.call(S3.METHOD_POST, uri, "", { uploads: "" });
        const uploadId = response.body?.InitiateMultipartUploadResult?.UploadId?.[0];
        return uploadId;
    }
    async uploadPart(data, path, contentType, chunk, uploadId) {
        const uri = path !== ""
            ? `/${encodeURIComponent(path).replace(/%2F/g, "/").replace(/%3F/g, "?")}`
            : "/";
        this.headers["content-type"] = contentType;
        this.headers["content-md5"] = Buffer.from(this.md5(data)).toString("base64");
        this.amzHeaders["x-amz-content-sha256"] = this.sha256(data);
        delete this.amzHeaders["x-amz-acl"];
        const response = await this.call(S3.METHOD_PUT, uri, data, {
            partNumber: chunk.toString(),
            uploadId: uploadId,
        });
        return response.headers["etag"];
    }
    async completeMultipartUpload(path, uploadId, parts) {
        const uri = path !== ""
            ? `/${encodeURIComponent(path).replace(/%2F/g, "/").replace(/%3F/g, "?")}`
            : "/";
        let body = "<CompleteMultipartUpload>";
        for (const [key, etag] of Object.entries(parts)) {
            body += `<Part><ETag>${etag}</ETag><PartNumber>${key}</PartNumber></Part>`;
        }
        body += "</CompleteMultipartUpload>";
        this.amzHeaders["x-amz-content-sha256"] = this.sha256(body);
        this.headers["content-md5"] = Buffer.from(this.md5(body)).toString("base64");
        await this.call(S3.METHOD_POST, uri, body, { uploadId });
        return true;
    }
    async abort(path, extra = "") {
        const uri = path !== "" ? `/${encodeURIComponent(path).replace(/%2F/g, "/")}` : "/";
        delete this.headers["content-type"];
        this.headers["content-md5"] = Buffer.from(this.md5("")).toString("base64");
        await this.call(S3.METHOD_DELETE, uri, "", { uploadId: extra });
        return true;
    }
    async read(path, offset = 0, length) {
        delete this.amzHeaders["x-amz-acl"];
        delete this.amzHeaders["x-amz-content-sha256"];
        delete this.headers["content-type"];
        this.headers["content-md5"] = Buffer.from(this.md5("")).toString("base64");
        const uri = path !== "" ? `/${encodeURIComponent(path).replace(/%2F/g, "/")}` : "/";
        if (length !== undefined) {
            const end = offset + length - 1;
            this.headers["range"] = `bytes=${offset}-${end}`;
        }
        else {
            delete this.headers["range"];
        }
        const response = await this.call(S3.METHOD_GET, uri, "", {}, false);
        return Buffer.from(response.buffer);
    }
    async write(path, data, contentType = "") {
        const uri = path !== ""
            ? `/${encodeURIComponent(path).replace(/%2F/g, "/").replace(/%3F/g, "?")}`
            : "/";
        this.headers["content-type"] = contentType;
        this.headers["content-md5"] = Buffer.from(this.md5(data)).toString("base64");
        this.amzHeaders["x-amz-content-sha256"] = this.sha256(data);
        this.amzHeaders["x-amz-acl"] = this.acl;
        await this.call(S3.METHOD_PUT, uri, data);
        return true;
    }
    async delete(path, recursive = false) {
        const uri = path !== "" ? `/${encodeURIComponent(path).replace(/%2F/g, "/")}` : "/";
        delete this.headers["content-type"];
        delete this.amzHeaders["x-amz-acl"];
        delete this.amzHeaders["x-amz-content-sha256"];
        this.headers["content-md5"] = Buffer.from(this.md5("")).toString("base64");
        await this.call(S3.METHOD_DELETE, uri);
        return true;
    }
    async listObjects(prefix = "", maxKeys = S3.MAX_PAGE_SIZE, continuationToken = "") {
        if (maxKeys > S3.MAX_PAGE_SIZE) {
            throw new Error(`Cannot list more than ${S3.MAX_PAGE_SIZE} objects`);
        }
        const uri = "/";
        prefix = prefix.replace(/^\//, "");
        this.headers["content-type"] = "text/plain";
        this.headers["content-md5"] = Buffer.from(this.md5("")).toString("base64");
        delete this.amzHeaders["x-amz-content-sha256"];
        delete this.amzHeaders["x-amz-acl"];
        const parameters = {
            "list-type": "2",
            prefix: prefix,
            "max-keys": maxKeys.toString(),
        };
        if (continuationToken) {
            parameters["continuation-token"] = continuationToken;
        }
        const response = await this.call(S3.METHOD_GET, uri, "", parameters);
        return response.body;
    }
    async deletePath(path) {
        path = `${this.getRoot()}/${path}`;
        const uri = "/";
        let continuationToken = "";
        do {
            const objects = await this.listObjects(path, S3.MAX_PAGE_SIZE, continuationToken);
            const count = parseInt(objects["KeyCount"] || "1");
            if (count < 1)
                break;
            continuationToken = objects["NextContinuationToken"] || "";
            let body = '<Delete xmlns="http://s3.amazonaws.com/doc/2006-03-01/">';
            if (count > 1) {
                for (const object of objects["Contents"]) {
                    body += `<Object><Key>${object["Key"]}</Key></Object>`;
                }
            }
            else {
                body += `<Object><Key>${objects["Contents"]["Key"]}</Key></Object>`;
            }
            body += "<Quiet>true</Quiet></Delete>";
            this.amzHeaders["x-amz-content-sha256"] = this.sha256(body);
            this.headers["content-md5"] = Buffer.from(this.md5(body)).toString("base64");
            await this.call(S3.METHOD_POST, uri, body, { delete: "" });
        } while (continuationToken);
        return true;
    }
    async exists(path) {
        try {
            await this.getInfo(path);
            return true;
        }
        catch {
            return false;
        }
    }
    async getFileSize(path) {
        const response = await this.getInfo(path);
        return parseInt(response["content-length"] || "0");
    }
    async getFileMimeType(path) {
        const response = await this.getInfo(path);
        return response["content-type"] || "";
    }
    async getFileHash(path) {
        const etag = (await this.getInfo(path))["etag"] || "";
        return etag ? etag.slice(1, -1) : etag;
    }
    async createDirectory(path) {
        return true; // S3 doesn't have directories
    }
    async getDirectorySize(path) {
        return -1;
    }
    async getPartitionFreeSpace() {
        return -1;
    }
    async getFiles(dir, max = S3.MAX_PAGE_SIZE, continuationToken = "") {
        const data = await this.listObjects(dir, max, continuationToken);
        data["IsTruncated"] = data["IsTruncated"] === "true";
        data["KeyCount"] = parseInt(data["KeyCount"]);
        data["MaxKeys"] = parseInt(data["MaxKeys"]);
        return data;
    }
    async getInfo(path) {
        delete this.headers["content-type"];
        delete this.amzHeaders["x-amz-acl"];
        delete this.amzHeaders["x-amz-content-sha256"];
        this.headers["content-md5"] = Buffer.from(this.md5("")).toString("base64");
        const uri = path !== "" ? `/${encodeURIComponent(path).replace(/%2F/g, "/")}` : "/";
        const response = await this.call(S3.METHOD_HEAD, uri);
        return response.headers;
    }
    getSignatureV4(method, uri, parameters = {}) {
        const service = "s3";
        const region = this.region;
        const algorithm = "AWS4-HMAC-SHA256";
        const combinedHeaders = {};
        const amzDateStamp = this.amzHeaders["x-amz-date"].substring(0, 8);
        // Combine headers
        for (const [k, v] of Object.entries(this.headers)) {
            combinedHeaders[k.toLowerCase()] = v.trim();
        }
        for (const [k, v] of Object.entries(this.amzHeaders)) {
            combinedHeaders[k.toLowerCase()] = v.trim();
        }
        // Sort headers
        const sortedHeaders = Object.keys(combinedHeaders).sort();
        const sortedCombinedHeaders = {};
        for (const key of sortedHeaders) {
            sortedCombinedHeaders[key] = combinedHeaders[key];
        }
        // Sort parameters
        const sortedParams = Object.keys(parameters).sort();
        const queryString = sortedParams
            .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(parameters[key])}`)
            .join("&");
        // Create canonical request
        const canonicalRequest = [
            method,
            uri.split("?")[0],
            queryString,
            ...Object.entries(sortedCombinedHeaders).map(([k, v]) => `${k}:${v}`),
            "",
            Object.keys(sortedCombinedHeaders).join(";"),
            this.amzHeaders["x-amz-content-sha256"],
        ].join("\n");
        // Create string to sign
        const credentialScope = [
            amzDateStamp,
            region,
            service,
            "aws4_request",
        ].join("/");
        const stringToSign = [
            algorithm,
            this.amzHeaders["x-amz-date"],
            credentialScope,
            this.sha256(canonicalRequest),
        ].join("\n");
        // Calculate signature
        const kSecret = `AWS4${this.secretKey}`;
        const kDate = this.hmacSha256(amzDateStamp, kSecret);
        const kRegion = this.hmacSha256(region, kDate);
        const kService = this.hmacSha256(service, kRegion);
        const kSigning = this.hmacSha256("aws4_request", kService);
        const signature = this.hmacSha256(stringToSign, kSigning, "hex");
        return `${algorithm} Credential=${this.accessKey}/${credentialScope},SignedHeaders=${Object.keys(sortedCombinedHeaders).join(";")},Signature=${signature}`;
    }
    async call(method, uri, data = "", parameters = {}, decode = true) {
        uri = this.getAbsolutePath(uri);
        const queryString = Object.keys(parameters).length
            ? "?" +
                Object.entries(parameters)
                    .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
                    .join("&")
            : "";
        const url = `https://${this.headers["host"]}${uri}${queryString}`;
        this.amzHeaders["x-amz-date"] = new Date()
            .toISOString()
            .replace(/[:-]|\.\d{3}/g, "");
        if (!this.amzHeaders["x-amz-content-sha256"]) {
            this.amzHeaders["x-amz-content-sha256"] = this.sha256(data);
        }
        const headers = {};
        for (const [header, value] of Object.entries(this.amzHeaders)) {
            if (value.length > 0) {
                headers[header] = value;
            }
        }
        this.headers["date"] = new Date().toUTCString();
        for (const [header, value] of Object.entries(this.headers)) {
            if (value.length > 0) {
                headers[header] = value;
            }
        }
        headers["Authorization"] = this.getSignatureV4(method, uri, parameters);
        const fetchOptions = {
            method,
            headers,
        };
        if (method === S3.METHOD_PUT || method === S3.METHOD_POST) {
            fetchOptions.body = data;
        }
        const response = await fetch(url, fetchOptions);
        if (!response.ok) {
            const errorBody = await response.text();
            throw new Error(`HTTP ${response.status}: ${errorBody}`);
        }
        const responseHeaders = {};
        response.headers.forEach((value, key) => {
            responseHeaders[key.toLowerCase()] = value;
        });
        const buffer = await response.arrayBuffer();
        let body = new TextDecoder().decode(buffer);
        if (decode &&
            (responseHeaders["content-type"] === "application/xml" ||
                (body.startsWith("<?xml") &&
                    responseHeaders["content-type"] !== "image/svg+xml"))) {
            const xml2js = await import('xml2js');
            const parser = new xml2js.Parser();
            body = await parser.parseStringPromise(body);
        }
        return {
            body,
            buffer,
            headers: responseHeaders,
            code: response.status,
        };
    }
    md5(data) {
        const buffer = typeof data === "string" ? Buffer.from(data, "utf8") : data;
        return crypto.createHash("md5").update(buffer).digest();
    }
    sha256(data) {
        const buffer = typeof data === "string" ? Buffer.from(data, "utf8") : data;
        return crypto.createHash("sha256").update(buffer).digest("hex");
    }
    hmacSha256(data, key, encoding = "binary") {
        const dataBuffer = typeof data === "string" ? Buffer.from(data, "utf8") : data;
        const hmac = crypto.createHmac("sha256", key);
        hmac.update(dataBuffer);
        return encoding === "hex" ? hmac.digest("hex") : hmac.digest();
    }
}
// HTTP Methods
S3.METHOD_GET = "GET";
S3.METHOD_POST = "POST";
S3.METHOD_PUT = "PUT";
S3.METHOD_PATCH = "PATCH";
S3.METHOD_DELETE = "DELETE";
S3.METHOD_HEAD = "HEAD";
S3.METHOD_OPTIONS = "OPTIONS";
S3.METHOD_CONNECT = "CONNECT";
S3.METHOD_TRACE = "TRACE";
// AWS Regions
S3.US_EAST_1 = "us-east-1";
S3.US_EAST_2 = "us-east-2";
S3.US_WEST_1 = "us-west-1";
S3.US_WEST_2 = "us-west-2";
S3.AF_SOUTH_1 = "af-south-1";
S3.AP_EAST_1 = "ap-east-1";
S3.AP_SOUTH_1 = "ap-south-1";
S3.AP_NORTHEAST_3 = "ap-northeast-3";
S3.AP_NORTHEAST_2 = "ap-northeast-2";
S3.AP_NORTHEAST_1 = "ap-northeast-1";
S3.AP_SOUTHEAST_1 = "ap-southeast-1";
S3.AP_SOUTHEAST_2 = "ap-southeast-2";
S3.CA_CENTRAL_1 = "ca-central-1";
S3.EU_CENTRAL_1 = "eu-central-1";
S3.EU_WEST_1 = "eu-west-1";
S3.EU_SOUTH_1 = "eu-south-1";
S3.EU_WEST_2 = "eu-west-2";
S3.EU_WEST_3 = "eu-west-3";
S3.EU_NORTH_1 = "eu-north-1";
S3.SA_EAST_1 = "eu-north-1";
S3.CN_NORTH_1 = "cn-north-1";
S3.CN_NORTH_4 = "cn-north-4";
S3.CN_NORTHWEST_1 = "cn-northwest-1";
S3.ME_SOUTH_1 = "me-south-1";
S3.US_GOV_EAST_1 = "us-gov-east-1";
S3.US_GOV_WEST_1 = "us-gov-west-1";
// ACL Flags
S3.ACL_PRIVATE = "private";
S3.ACL_PUBLIC_READ = "public-read";
S3.ACL_PUBLIC_READ_WRITE = "public-read-write";
S3.ACL_AUTHENTICATED_READ = "authenticated-read";
S3.MAX_PAGE_SIZE = 1000;
S3.retryAttempts = 3;
S3.retryDelay = 500;
class Wasabi extends S3 {
    /**
     * Wasabi Constructor
     */
    constructor(root, accessKey, secretKey, bucket, region = Wasabi.EU_CENTRAL_1, acl = S3.ACL_PRIVATE) {
        super(root, accessKey, secretKey, bucket, region, acl);
        this.headers["host"] = `${bucket}.s3.${region}.wasabisys.com`;
    }
    getName() {
        return "Wasabi Storage";
    }
    getDescription() {
        return "Wasabi Storage";
    }
    getType() {
        return Storage.DEVICE_WASABI;
    }
}
/**
 * Regions constants
 */
Wasabi.US_WEST_1 = "us-west-1";
Wasabi.AP_NORTHEAST_1 = "ap-northeast-1";
Wasabi.AP_NORTHEAST_2 = "ap-northeast-2";
Wasabi.EU_CENTRAL_1 = "eu-central-1";
Wasabi.EU_CENTRAL_2 = "eu-central-2";
Wasabi.EU_WEST_1 = "eu-west-1";
Wasabi.EU_WEST_2 = "eu-west-2";
Wasabi.US_CENTRAL_1 = "us-central-1";
Wasabi.US_EAST_1 = "us-east-1";
Wasabi.US_EAST_2 = "us-east-2";
class MinIO extends S3 {
    /**
     * MinIO Constructor
     */
    constructor(root, accessKey, secretKey, bucket, endpoint = "localhost:9000", acl = S3.ACL_PRIVATE, useSSL = false) {
        const protocol = useSSL ? "https" : "http";
        // Remove protocol from endpoint if it exists
        const cleanEndpoint = endpoint.replace(/^https?:\/\//, "");
        super(root, accessKey, secretKey, bucket, "us-east-1", acl, cleanEndpoint);
        // Override the host for MinIO
        this.headers["host"] = `${bucket}.${cleanEndpoint}`;
        // Store the full endpoint URL for MinIO-specific operations
        this.endpointUrl = `${protocol}://${cleanEndpoint}`;
    }
    getName() {
        return "MinIO Storage";
    }
    getDescription() {
        return "MinIO S3-compatible object storage server";
    }
    getType() {
        return Storage.DEVICE_MINIO;
    }
    /**
     * Override the call method to use the custom endpoint
     */
    async call(method, uri, data = "", parameters = {}, decode = true) {
        // Temporarily modify the host header to not include the bucket for MinIO
        const originalHost = this.headers["host"];
        // For MinIO, we need to handle path-style URLs differently
        const bucketName = originalHost.split(".")[0];
        const endpoint = originalHost.split(".").slice(1).join(".");
        // Use path-style URLs for MinIO
        this.headers["host"] = endpoint;
        const queryString = Object.keys(parameters).length
            ? "?" +
                Object.entries(parameters)
                    .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
                    .join("&")
            : "";
        const pathStyleUri = `/${bucketName}${this.getAbsolutePath(uri)}`;
        const url = `${this.endpointUrl}${pathStyleUri}${queryString}`;
        this.amzHeaders["x-amz-date"] = new Date()
            .toISOString()
            .replace(/[:-]|\.\d{3}/g, "");
        if (!this.amzHeaders["x-amz-content-sha256"]) {
            this.amzHeaders["x-amz-content-sha256"] = this.sha256(data);
        }
        const headers = {};
        for (const [header, value] of Object.entries(this.amzHeaders)) {
            if (value.length > 0) {
                headers[header] = value;
            }
        }
        this.headers["date"] = new Date().toUTCString();
        for (const [header, value] of Object.entries(this.headers)) {
            if (value.length > 0) {
                headers[header] = value;
            }
        }
        headers["Authorization"] = this.getSignatureV4(method, pathStyleUri, parameters);
        const fetchOptions = {
            method,
            headers,
        };
        if (method === S3.METHOD_PUT || method === S3.METHOD_POST) {
            fetchOptions.body = data;
        }
        const response = await fetch(url, fetchOptions);
        // Restore original host
        this.headers["host"] = originalHost;
        if (!response.ok) {
            const errorBody = await response.text();
            throw new Error(`HTTP ${response.status}: ${errorBody}`);
        }
        const responseHeaders = {};
        response.headers.forEach((value, key) => {
            responseHeaders[key.toLowerCase()] = value;
        });
        let body = await response.text();
        if (decode &&
            (responseHeaders["content-type"] === "application/xml" ||
                (body.startsWith("<?xml") &&
                    responseHeaders["content-type"] !== "image/svg+xml"))) {
            const xml2js = await import('xml2js');
            const parser = new xml2js.Parser();
            body = await parser.parseStringPromise(body);
        }
        return {
            body,
            headers: responseHeaders,
            code: response.status,
        };
    }
}
export { Device, File, FileExt, FileName, FileSize, FileType, Local, MinIO, S3, Storage, Upload, Wasabi };
//# sourceMappingURL=index.esm.js.map