commit b7c2fa6d19d05449df42a8f71389a561956cd5bf Author: MCHost Date: Mon Jun 23 22:59:27 2025 -0400 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..274134a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/test* +/node_modules +/dist \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..25e207a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 CyberGen49 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8eb25d9 --- /dev/null +++ b/README.md @@ -0,0 +1,309 @@ +# SFTP Browser +A web-based SFTP file browser for easy server file management. + +Forked from: https://github.com/kaysting/sftp-browser + +## Features +- Connect to SFTP servers hosted on any platform +- Import and export server connections +- Navigate directories with ease +- Sort files by different attributes +- Show or hide hidden (dot) files +- Switch between list and tile views +- Download multiple files/directories as a single zip file +- Upload files by dragging and dropping or using the button +- Create new files and directories +- Rename and delete files +- Cut/copy/paste files between directories +- Use "Move to" and "Copy to" dialogs for contextual organization +- Edit file permissions +- Recursively search for files by name within a directory +- View images, videos, and audio files in the browser + - File size limitations may apply +- Edit text files in the browser and save directly to the server + - Syntax highlighting supported for certain languages +- Edit Markdown files with a live preview +- Full mobile support with a responsive UI + +## Connection Management +SFTP Browser supports seamless connection management through unique connection IDs. When a connection is established via the auto-connection endpoint, a unique `connectionId` is generated, and a URL in the format `/connect/:connectionId` is provided. Accessing this URL allows users to connect to the SFTP server without manually entering credentials, as the connection details are retrieved from the server using the `connectionId`. The connection details are stored temporarily on the server (typically for 12 hours) and in the client's `localStorage` for persistence across sessions. Duplicate connections are prevented by clearing existing connections when a new `/connect/:connectionId` URL is accessed. + +## API Basics + +### Authentication +All API endpoints (except `/auto-connection` and `/connect/:connectionId`) require the following request headers for connecting to the target server: +- `sftp-host`: The hostname of the server +- `sftp-port`: The port of the server +- `sftp-username`: The username to log into +- `sftp-password`: The password, if using password authentication +- `sftp-key`: The private key, if using public key authentication + +### Response Format +All API responses are in JSON format and include a boolean `success` property. This is `true` if no errors were encountered, or `false` otherwise. If an error occurs, a string `error` property is included with a description of the error. + +**Successful Response Example:** +```json +{ + "success": true, + "...": "..." +} +``` + +**Failed Response Example:** +```json +{ + "success": false, + "error": "[error description]", + "...": "..." +} +``` + +Failed responses use a 400 or 500 level HTTP status code. + +## API Endpoints + +### Auto-Connection +**Endpoint:** `POST /auto-connection` + +Creates a temporary connection to an SFTP server and generates a unique `connectionId` and URL for accessing the connection without re-entering credentials. + +#### Request Body +- **Required** `host` (string): The hostname of the server +- **Required** `username` (string): The username to log into +- **Optional** `port` (number): The port of the server (defaults to 22) +- **Optional** `password` (string): The password, if using password authentication +- **Optional** `privateKey` (string): The private key, if using public key authentication + +*Note: Either `password` or `privateKey` must be provided.* + +#### Successful Response +- `success` (boolean): `true` if the connection was established +- `connectionId` (string): A unique identifier for the connection +- `connectionUrl` (string): A URL in the format `https:///connect/:connectionId` for accessing the connection + +**Example Response:** +```json +{ + "success": true, + "connectionId": "9795d599219c783637efdecc6a91edd8", + "connectionUrl": "https://sftp.my-mc.link/connect/9795d599219c783637efdecc6a91edd8" +} +``` + +#### Failed Response +- `success` (boolean): `false` if the connection failed +- `error` (string): Description of the error (e.g., "Missing host") + +### Fetch Connection Details +**Endpoint:** `GET /api/connect/:connectionId` + +Retrieves the connection details for a given `connectionId`, typically used when accessing a `/connect/:connectionId` URL. + +#### Path Parameters +- **Required** `connectionId` (string): The unique identifier for the connection + +#### Successful Response +- `success` (boolean): `true` if the connection details were found +- `connection` (object): The connection details + - `host` (string): The hostname of the server + - `port` (number): The port of the server + - `username` (string): The username + - `password` (string, optional): The password, if provided + - `privateKey` (string, optional): The private key, if provided + +**Example Response:** +```json +{ + "success": true, + "connection": { + "host": "my-mc.link", + "port": 39125, + "username": "mc", + "password": "IJhPT15nlOIoJm53" + } +} +``` + +#### Failed Response +- `success` (boolean): `false` if the connection was not found +- `error` (string): Description of the error (e.g., "Connection not found") + +### List Files in a Directory +**Endpoint:** `GET /api/sftp/directories/list` + +#### Query Parameters +- **Required** `path` (string): The target directory path +- **Optional** `dirsOnly` (boolean): If `true`, only directories are returned + +#### Successful Response +- `path` (string): The normalized path +- `list` (object[]): An array of file objects + - `list[].name` (string): The name of the file + - `list[].accessTime` (number): Timestamp of the last access time + - `list[].modifyTime` (number): Timestamp of the last modification time + - `list[].size` (number): File size in bytes + - `list[].type` (string): A 1-character string representing the file type + - `list[].group` (number): ID of the group the file belongs to + - `list[].owner` (number): ID of the user the file belongs to + - `list[].rights` (object): Permissions for the file + - `list[].rights.user` (string): Permissions for the owner (`r`, `w`, `x`) + - `list[].rights.group` (string): Permissions for the group (`r`, `w`, `x`) + - `list[].rights.other` (string): Permissions for others (`r`, `w`, `x`) + - `list[].longname` (string): Raw SFTP output for the file + +### Create a Directory +**Endpoint:** `POST /api/sftp/directories/create` + +#### Query Parameters +- **Required** `path` (string): The new directory path + +#### Successful Response +- `path` (string): The normalized path + +### Delete a Directory +**Endpoint:** `DELETE /api/sftp/directories/delete` + +#### Query Parameters +- **Required** `path` (string): The path of the directory to delete + +#### Successful Response +- `path` (string): The normalized path + +### Check if a Path Exists +**Endpoint:** `GET /api/sftp/files/exists` + +#### Query Parameters +- **Required** `path` (string): The path to check + +#### Successful Response +- `path` (string): The normalized path +- `exists` (boolean): `true` if the path exists, `false` otherwise +- `type` (string|boolean): File type character if the path exists, `false` otherwise + +### Get Information About a File +**Endpoint:** `GET /api/sftp/files/stat` + +#### Query Parameters +- **Required** `path` (string): The path to stat + +#### Successful Response +- `path` (string): The normalized path +- `stats` (object): File statistics + - `accessTime` (number): Last accessed time + - `modifyTime` (number): Last modified time + - `size` (number): File size in bytes + - `uid` (number): ID of the file's owner + - `gid` (number): ID of the file's group + - `mode` (number): Integer representing file type and permissions + - `isBlockDevice` (boolean): `true` if a block device + - `isCharacterDevice` (boolean): `true` if a character device + - `isDirectory` (boolean): `true` if a directory + - `isFIFO` (boolean): `true` if a first-in first-out file + - `isSocket` (boolean): `true` if a socket + - `isSymbolicLink` (boolean): `true` if a symlink + +### Get Raw File Data +**Endpoint:** `GET /api/sftp/files/get/single` + +#### Query Parameters +- **Required** `path` (string): The path of the file + +#### Successful Response +- *Raw file data* + +#### Failed Response +- *JSON error response* + +### Get File Download Link +Gets a temporary URL to download a single file without connection headers. URLs last 24 hours or until the server restarts. + +**Endpoint:** `GET /api/sftp/files/get/single/url` + +#### Query Parameters +- **Required** `path` (string): The path of the file + +#### Successful Response +- `path` (string): The normalized path +- `download_url` (string): The download URL + +### Get Zipped Download Link +Gets a temporary URL for downloading files and directories as a zip archive without connection headers. + +**Endpoint:** `GET /api/sftp/files/get/multi` + +#### Query Parameters +- **Required** `paths` (string[]): JSON-formatted array of file or directory paths + +#### Successful Response +- `path` (string): The normalized path +- `download_url` (string): The download URL + +### Create a File +**Endpoint:** `POST /api/sftp/files/create` + +#### Query Parameters +- **Required** `path` (string): The path of the new file + +#### Request Body +- *Raw data to insert into the new file* + +#### Successful Response +- `path` (string): The normalized path + +### Append Data to a File +**Endpoint:** `PUT /api/sftp/files/append` + +#### Query Parameters +- **Required** `path` (string): The path of the file + +#### Request Body +- *Raw data to append to the file* + +#### Successful Response +- `path` (string): The normalized path + +### Move a File +**Endpoint:** `PUT /api/sftp/files/move` + +#### Query Parameters +- **Required** `pathOld` (string): The current path +- **Required** `pathNew` (string): The new path + +#### Successful Response +- `pathOld` (string): The normalized old path +- `pathNew` (string): The normalized new path + +### Copy a File +Directories not supported. + +**Endpoint:** `PUT /api/sftp/files/copy` + +#### Query Parameters +- **Required** `pathSrc` (string): The source path +- **Required** `pathDest` (string): The destination path + +#### Successful Response +- `pathSrc` (string): The normalized source path +- `pathDest` (string): The normalized destination path + +### Edit a File's Permissions +Directories supported, but without recursion. + +**Endpoint:** `PUT /api/sftp/files/chmod` + +#### Query Parameters +- **Required** `path` (string): The path of the file +- **Required** `mode` (string): The new mode in the form `xyz`, where `x`, `y`, and `z` are integers from 0 to 7 + +#### Successful Response +- `path` (string): The normalized path +- `mode` (string): The supplied mode + +### Delete a File +**Endpoint:** `DELETE /api/sftp/files/delete` + +#### Query Parameters +- **Required** `path` (string): The path of the file + +#### Successful Response +- `path` (string): The normalized path \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..ef61da9 --- /dev/null +++ b/config.json @@ -0,0 +1,3 @@ +{ + "port": 8261 +} \ No newline at end of file diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..651fe28 --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,8 @@ + +module.exports = { + apps: [{ + name: 'sftp-browser', + script: './server.js', + watch: [ 'server.js' ] + }] +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7a70d6e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2090 @@ +{ + "name": "sftp-browser", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sftp-browser", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "archiver": "^6.0.1", + "body-parser": "^1.20.2", + "cyber-express-logger": "^1.0.5", + "dayjs": "^1.11.10", + "electron-squirrel-startup": "^1.0.0", + "express": "^4.18.2", + "express-async-handler": "^1.2.0", + "express-ws": "^5.0.2", + "mime": "^3.0.0", + "ssh2-sftp-client": "^9.1.0", + "web-resources": "^2.3.0" + }, + "devDependencies": { + "electron": "^26.2.4" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/get/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@electron/get/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.2.tgz", + "integrity": "sha512-FD+nQWA2zJjh4L9+pFXqWOi0Hs1ryBCfI+985NjluQ1p8EYtoLvjLOKidXBtZ4/IcxDX4o8/E8qDS3540tNliw==", + "dev": true + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "18.18.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.18.3.tgz", + "integrity": "sha512-0OVfGupTl3NBFr8+iXpfZ8NR7jfFO+P1Q+IO/q0wbo02wYkP5gy36phojeYWpLQ6WAMjl+VfmqUk2YbUfp0irA==", + "dev": true + }, + "node_modules/@types/responselike": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.1.tgz", + "integrity": "sha512-TiGnitEDxj2X0j+98Eqk5lv/Cij8oHd32bU4D/Yw6AOq7vvTk0gSD2GPj0G/HkvhMoVsdlhYF4yqqlyPBTM6Sg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.1.tgz", + "integrity": "sha512-CHzgNU3qYBnp/O4S3yv2tXPlvMTq0YWSTVg2/JYLqWZGHwwgJGAwd00poay/11asPq8wLFwHzubyInqHIFmmiw==", + "dev": true, + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/archiver": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-6.0.1.tgz", + "integrity": "sha512-CXGy4poOLBKptiZH//VlWdFuUC1RESbdZjGjILwBuZ73P7WkAUN0htfSfBq/7k6FRFlpu7bg4JOkj1vU9G6jcQ==", + "dependencies": { + "archiver-utils": "^4.0.1", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^5.0.1" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/archiver-utils": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-4.0.1.tgz", + "integrity": "sha512-Q4Q99idbvzmgCTEAAhi32BkOyq8iVI5EwdO0PmBDSGIzzjYNdcFn7Q7k3OzbLy4kLUPXfJtG6fO2RjftXbobBg==", + "dependencies": { + "glob": "^8.0.0", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/async": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" + }, + "node_modules/b4a": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", + "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/body-parser": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "dev": true, + "optional": true + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "node_modules/buildcheck": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", + "integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dependencies": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cli-color": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/cli-color/-/cli-color-2.0.3.tgz", + "integrity": "sha512-OkoZnxyC4ERN3zLzZaY9Emb7f/MhBOIpePv0Ycok0fJYT+Ouo00UBEIwsVsr0yoow++n5YWlSUgST9GKhNHiRQ==", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.61", + "es6-iterator": "^2.0.3", + "memoizee": "^0.4.15", + "timers-ext": "^0.1.7" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/compress-commons": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-5.0.1.tgz", + "integrity": "sha512-MPh//1cERdLtqwO3pOFLeXtpuai0Y2WCd5AhtKxznqM7WtaMYaOEMSgn45d9D10sIHSfIKE603HlOp8OPGrvag==", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^5.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "node_modules/cpu-features": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.9.tgz", + "integrity": "sha512-AKjgn2rP2yJyfbepsmLfiYcmtNn/2eUvocUyM/09yB0YDiz39HteK/5/T4Onf0pmdYDMgkBoGvRLvEguzyL7wQ==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.17.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-5.0.0.tgz", + "integrity": "sha512-B0EPa1UK+qnpBZpG+7FgPCu0J2ETLpXq09o9BkLkEAhdB6Z61Qo4pJ3JYu0c+Qi+/SAL7QThqnzS06pmSSyZaw==", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/cyber-express-logger": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/cyber-express-logger/-/cyber-express-logger-1.0.5.tgz", + "integrity": "sha512-8Qg8Uy50E03Ma1XoU3zhlXiRMecLxoiRb4Ek2f6FKOMIo9VDkjxII7hkbLLIdj/YdDb6CdVLcIKm5UQccRgVlA==", + "dependencies": { + "cli-color": "^2.0.3", + "dayjs": "^1.11.6", + "express": "^4.18.2" + } + }, + "node_modules/d": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", + "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", + "dependencies": { + "es5-ext": "^0.10.50", + "type": "^1.0.1" + } + }, + "node_modules/dayjs": { + "version": "1.11.10", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", + "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.0.tgz", + "integrity": "sha512-UzGwzcjyv3OtAvolTj1GoyNYzfFR+iqbGjcnBEENZVCpM4/Ng1yhGNvS3lR/xDS74Tb2wGG9WzNSNIOS9UVb2g==", + "dev": true, + "optional": true, + "dependencies": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "optional": true + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/electron": { + "version": "26.2.4", + "resolved": "https://registry.npmjs.org/electron/-/electron-26.2.4.tgz", + "integrity": "sha512-weMUSMyDho5E0DPQ3breba3D96IxwNvtYHjMd/4/wNN3BdI5s3+0orNnPVGJFcLhSvKoxuKUqdVonUocBPwlQA==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^18.11.18", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-squirrel-startup": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/electron-squirrel-startup/-/electron-squirrel-startup-1.0.0.tgz", + "integrity": "sha512-Oce8mvgGdFmwr+DsAcXBmFK8jFfN6yaFAP9IvyhTfupM3nFkBku/7VS/mdtJteWumImkC6P+BKGsxScoDDkv9Q==", + "dependencies": { + "debug": "^2.2.0" + } + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==" + }, + "node_modules/es5-ext": { + "version": "0.10.62", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.62.tgz", + "integrity": "sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==", + "hasInstallScript": true, + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "optional": true + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", + "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", + "dependencies": { + "d": "^1.0.1", + "ext": "^1.1.2" + } + }, + "node_modules/es6-weak-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", + "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.46", + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/express": { + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.1", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.5.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express-async-handler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/express-async-handler/-/express-async-handler-1.2.0.tgz", + "integrity": "sha512-rCSVtPXRmQSW8rmik/AIb2P0op6l7r1fMW538yyvTMltCO4xQEWMmobfrIxN2V1/mVrgxB8Az3reYF6yUZw37w==" + }, + "node_modules/express-ws": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/express-ws/-/express-ws-5.0.2.tgz", + "integrity": "sha512-0uvmuk61O9HXgLhGl3QhNSEtRsQevtmbL94/eILaliEADZBHZOQUAiHFrGPrgsjikohyrmSG5g+sCfASTt0lkQ==", + "dependencies": { + "ws": "^7.4.6" + }, + "engines": { + "node": ">=4.5.0" + }, + "peerDependencies": { + "express": "^4.0.0 || ^5.0.0-alpha.1" + } + }, + "node_modules/express/node_modules/body-parser": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", + "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/express/node_modules/raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/ext/node_modules/type": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", + "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/extract-zip/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + }, + "node_modules/function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "node_modules/get-intrinsic": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", + "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", + "dependencies": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "optional": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "dev": true, + "optional": true, + "dependencies": { + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "optional": true, + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "node_modules/has": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.4.tgz", + "integrity": "sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", + "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", + "dev": true, + "optional": true, + "dependencies": { + "get-intrinsic": "^1.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "dev": true + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "optional": true + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", + "integrity": "sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", + "integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==", + "dependencies": { + "es5-ext": "~0.10.2" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memoizee": { + "version": "0.4.15", + "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.15.tgz", + "integrity": "sha512-UBWmJpLZd5STPm7PMUlOw/TSy972M+z8gcyQ5veOnSDRREz/0bmpyTfKt3/51DhEBqCZQn1udM/5flcSPYhkdQ==", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.53", + "es6-weak-map": "^2.0.3", + "event-emitter": "^0.3.5", + "is-promise": "^2.2.2", + "lru-queue": "^0.1.0", + "next-tick": "^1.1.0", + "timers-ext": "^0.1.7" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/nan": { + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.18.0.tgz", + "integrity": "sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w==", + "optional": true + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-inspect": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", + "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-tick": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", + "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==" + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "engines": { + "node": ">= 4" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "optional": true + }, + "node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "optional": true + }, + "node_modules/ssh2": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.14.0.tgz", + "integrity": "sha512-AqzD1UCqit8tbOKoj6ztDDi1ffJZ2rV2SwlgrVVrHPkV5vWqGJOVp5pmtj18PunkPJAuKQsnInyKV+/Nb2bUnA==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.8", + "nan": "^2.17.0" + } + }, + "node_modules/ssh2-sftp-client": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/ssh2-sftp-client/-/ssh2-sftp-client-9.1.0.tgz", + "integrity": "sha512-Hzdr9OE6GxZjcmyM9tgBSIFVyrHAp9c6U2Y4yBkmYOHoQvZ7pIm27dmltvcmRfxcWiIcg8HBvG5iAikDf+ZuzQ==", + "dependencies": { + "concat-stream": "^2.0.0", + "promise-retry": "^2.0.1", + "ssh2": "^1.12.0" + }, + "engines": { + "node": ">=10.24.1" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamx": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.1.tgz", + "integrity": "sha512-fQMzy2O/Q47rgwErk/eGeLu/roaFWV0jVsogDmrszM9uIw8L5OA+t+V93MgYlufNptfjmYR1tOMWhei/Eh7TQA==", + "dependencies": { + "fast-fifo": "^1.1.0", + "queue-tick": "^1.0.1" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/sumchecker/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/sumchecker/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/tar-stream": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.6.tgz", + "integrity": "sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/timers-ext": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.7.tgz", + "integrity": "sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ==", + "dependencies": { + "es5-ext": "~0.10.46", + "next-tick": "1" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" + }, + "node_modules/type": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", + "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==" + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-resources": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/web-resources/-/web-resources-2.3.0.tgz", + "integrity": "sha512-XgUhew4LvcwUooysE5VAR6WYEDzkeZci75nUse16cFB1FFGtUjsCZ4YijVBrqOLOuLmT0dT3q/NQqhvBpz20ig==" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/ws": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", + "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "optional": true + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zip-stream": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-5.0.1.tgz", + "integrity": "sha512-UfZ0oa0C8LI58wJ+moL46BDIMgCQbnsb+2PoiJYtonhBsMh2bq1eRBVkvjfVsqbEHd9/EgKPUuL9saSSsec8OA==", + "dependencies": { + "archiver-utils": "^4.0.1", + "compress-commons": "^5.0.1", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 12.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0e5c90a --- /dev/null +++ b/package.json @@ -0,0 +1,59 @@ +{ + "name": "sftp-browser", + "version": "1.0.0", + "description": "A web-based SFTP file browser that makes managing your server files easy!", + "main": "server.js", + "scripts": { + "startserver": "node server.js", + "startapp": "electron app.js", + "package": "electron-builder -lw" + }, + "build": { + "appId": "org.simplecyber.sftp-browser", + "productName": "SFTP-Browser", + "files": [ + "web/**/*", + "*.js", + "*.json" + ], + "linux": { + "target": [ + "deb" + ], + "category": "Network", + "icon": "web/icon.png" + }, + "win": { + "target": [ + "zip" + ], + "icon": "web/icon.png" + } + }, + "repository": { + "type": "git", + "url": "git+https://github.com/CyberGen49/sftp-browser.git" + }, + "author": "Kayla (https://cybah.me)", + "license": "ISC", + "bugs": { + "url": "https://github.com/CyberGen49/sftp-browser/issues" + }, + "homepage": "https://github.com/CyberGen49/sftp-browser#readme", + "dependencies": { + "archiver": "^6.0.1", + "body-parser": "^1.20.2", + "cyber-express-logger": "^1.0.5", + "dayjs": "^1.11.10", + "electron-squirrel-startup": "^1.0.0", + "express": "^4.18.2", + "express-async-handler": "^1.2.0", + "express-ws": "^5.0.2", + "mime": "^3.0.0", + "ssh2-sftp-client": "^9.1.0", + "web-resources": "^2.3.0" + }, + "devDependencies": { + "electron": "^26.2.4" + } +} diff --git a/sftp-browser.js b/sftp-browser.js new file mode 100644 index 0000000..e7bbefd --- /dev/null +++ b/sftp-browser.js @@ -0,0 +1,797 @@ +const path = require('path'); +const express = require('express'); +const expressWs = require('express-ws'); +const asyncHandler = require('express-async-handler'); +const logger = require('cyber-express-logger'); +const sftp = require('ssh2-sftp-client'); +const crypto = require('crypto'); +const mime = require('mime'); +const cors = require('cors'); // Added CORS package +const bodyParser = require('body-parser'); +const archiver = require('archiver'); +const rawBodyParser = bodyParser.raw({ + limit: '16mb', + type: '*/*' +}); +const dayjs = require('dayjs'); +const dayjsAdvancedFormat = require('dayjs/plugin/advancedFormat'); +dayjs.extend(dayjsAdvancedFormat); +const utils = require('web-resources'); +const Electron = require('electron'); +const config = require('./config.json'); + +const normalizeRemotePath = remotePath => { + remotePath = path.normalize(remotePath).replace(/\\/g, '/'); + const split = remotePath.split('/').filter(String); + const joined = `/${split.join('/')}`; + return joined; +}; + +const sessions = {}; +const sessionActivity = {}; +const connections = {}; // Store connection details + +const getObjectHash = obj => { + const hash = crypto.createHash('sha256'); + hash.update(JSON.stringify(obj)); + return hash.digest('hex'); +} + +/** + * @param {sftp.ConnectOptions} opts + * @returns {Promise|null} + * */ +const getSession = async (res, opts) => { + const hash = getObjectHash(opts); + const address = `${opts.username}@${opts.host}:${opts.port}`; + if (sessions[hash]) { + console.log(`Using existing connection to ${address}`); + sessionActivity[hash] = Date.now(); + return sessions[hash]; + } + console.log(`Creating new connection to ${address}`); + const session = new sftp(); + sessions[hash] = session; + session.on('end', () => delete sessions[hash]); + session.on('close', () => delete sessions[hash]); + try { + await session.connect(opts); + sessionActivity[hash] = Date.now(); + } catch (error) { + delete sessions[hash]; + console.log(`Connection to ${address} failed`); + return res ? res.sendError(error) : null; + } + return session; +}; + +const srv = express(); +expressWs(srv, undefined, { + wsOptions: { + maxPayload: 1024 * 1024 * 4 + } +}); + +// Enable CORS for all routes +srv.use(cors({ + origin: '*', // Allow all origins; can be restricted to specific origins if needed + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], // Allow necessary methods + allowedHeaders: ['Content-Type', 'sftp-host', 'sftp-port', 'sftp-username', 'sftp-password', 'sftp-key'], // Allow relevant headers + credentials: false // Set to true if cookies or auth headers are needed +})); +srv.use(logger()); +const staticDir = path.join(__dirname, 'web'); +srv.use(express.static(staticDir)); +console.log(`Serving static files from ${staticDir}`); + +const initApi = asyncHandler(async (req, res, next) => { + res.sendData = (status = 200) => res.status(status).json(res.data); + res.sendError = (error, status = 400) => { + res.data.success = false; + res.data.error = `${error}`.replace('Error: ', ''); + res.sendData(status); + } + res.data = { + success: true + }; + req.connectionOpts = { + host: req.headers['sftp-host'], + port: req.headers['sftp-port'] || 22, + username: req.headers['sftp-username'], + password: decodeURIComponent(req.headers['sftp-password'] || '') || undefined, + privateKey: decodeURIComponent(req.headers['sftp-key'] || '') || undefined, + }; + if (!req.connectionOpts.host) + return res.sendError('Missing host header'); + if (!req.connectionOpts.username) + return res.sendError('Missing username header'); + if (!req.connectionOpts.password && !req.connectionOpts.privateKey) + return res.sendError('Missing password or key header'); + req.session = await getSession(res, req.connectionOpts); + if (!req.session) return; + next(); +}); + +// API endpoint to fetch connection details by connectionId +srv.get('/api/connect/:connectionId', asyncHandler(async (req, res) => { + const connectionId = req.params.connectionId; + const connection = connections[connectionId]; + if (!connection) { + return res.status(404).json({ success: false, error: 'Connection not found' }); + } + res.json({ + success: true, + connection: { + host: connection.host, + port: connection.port, + username: connection.username, + password: connection.password, + privateKey: connection.privateKey + } + }); +})); + +// Auto-connection endpoint +srv.post('/auto-connection', bodyParser.json(), asyncHandler(async (req, res) => { + const connectionDetails = { + host: req.body.host, + port: req.body.port || 22, + username: req.body.username, + password: req.body.password || undefined, + privateKey: req.body.privateKey || undefined + }; + + // Validate required fields + if (!connectionDetails.host) { + return res.status(400).json({ success: false, error: 'Missing host' }); + } + if (!connectionDetails.username) { + return res.status(400).json({ success: false, error: 'Missing username' }); + } + if (!connectionDetails.password && !connectionDetails.privateKey) { + return res.status(400).json({ success: false, error: 'Missing password or key' }); + } + + // Generate connection ID + const connectionId = utils.randomHex(32); + + // Store connection details + connections[connectionId] = { + ...connectionDetails, + created: Date.now() + }; + + // Test connection + const session = await getSession(res, connectionDetails); + if (!session) { + delete connections[connectionId]; + return res.status(400).json({ success: false, error: 'Failed to establish connection' }); + } + session.end(); + + // Return connection URL + const connectionUrl = `https://${req.get('host')}/connect/${connectionId}`; + res.json({ + success: true, + connectionId, + connectionUrl + }); +})); + +// Handle connection URL access +srv.get('/connect/:connectionId', asyncHandler(async (req, res) => { + const connectionId = req.params.connectionId; + if (!connections[connectionId]) { + return res.status(404).send('Connection not found'); + } + // Serve the main application page (index.html) + res.sendFile(path.join(staticDir, 'index.html')); +})); + +const keyedRequests = {}; +srv.get('/api/sftp/key', initApi, async (req, res) => { + res.data.key = utils.randomHex(32); + keyedRequests[res.data.key] = req; + res.sendData(); +}); +srv.get('/api/sftp/directories/list', initApi, async (req, res) => { + /** @type {sftp} */ + const session = req.session; + res.data.path = normalizeRemotePath(req.query.path); + res.data.includesFiles = req.query.dirsOnly === 'true' ? false : true; + if (!res.data.path) return res.sendError('Missing path', 400); + try { + res.data.list = await session.list(res.data.path); + if (res.data.list && !res.data.includesFiles) { + res.data.list = res.data.list.filter(item => item.type === 'd'); + } + res.sendData(); + } catch (error) { + res.sendError(error); + } +}); +srv.ws('/api/sftp/directories/search', async (ws, wsReq) => { + if (!wsReq.query.key) return ws.close(); + const req = keyedRequests[wsReq.query.key]; + if (!req) return ws.close(); + // Add uniqueness to the connection opts + // This forces a new connection to be created + req.connectionOpts.ts = Date.now(); + // Create the session and throw an error if it fails + /** @type {sftp} */ + const session = await getSession(null, req.connectionOpts); + const sessionHash = getObjectHash(req.connectionOpts); + if (!session) { + ws.send(JSON.stringify({ + success: false, + error: 'Failed to create session!' + })); + return ws.close(); + } + // Normalize the file path or throw an error if it's missing + const filePath = normalizeRemotePath(wsReq.query.path); + if (!filePath) { + ws.send(JSON.stringify({ + success: false, + error: 'Missing path' + })); + return ws.close(); + } + // Get the query + const query = wsReq.query.query; + if (!query) { + ws.send(JSON.stringify({ + success: false, + error: 'Missing query' + })); + return ws.close(); + } + // Update the session activity periodically + let interval; + const updateActivity = () => { + sessionActivity[sessionHash] = Date.now(); + }; + interval = setInterval(updateActivity, 1000 * 1); + // Handle websocket closure + let isClosed = false; + ws.on('close', () => { + console.log(`Directory search websocket closed`); + session.end(); + clearInterval(interval); + delete sessionActivity[sessionHash]; + isClosed = true; + }); + // Listen for messages + console.log(`Websocket opened to search directory ${req.connectionOpts.username}@${req.connectionOpts.host}:${req.connectionOpts.port} ${filePath}`); + // Function to get a directory listing + const scanDir = async (dirPath) => { + try { + const list = await session.list(dirPath); + return [...list].sort((a, b) => { + // Sort by name + if (a.name < b.name) return -1; + if (a.name > b.name) return 1; + return 0; + }); + } catch (error) { + return null; + } + }; + // Function to send a list when there are enough files + let matchedFiles = []; + let lastSend = 0; + const sendList = () => { + if (matchedFiles.length > 0) { + ws.send(JSON.stringify({ + success: true, + status: 'list', + list: matchedFiles + })); + matchedFiles = []; + lastSend = Date.now(); + } + }; + // Function to recursively search a directory + const recurse = async dirPath => { + if (isClosed) return; + ws.send(JSON.stringify({ + success: true, + status: 'scanning', + path: dirPath + })); + const list = await scanDir(dirPath); + if (!list) { + ws.send(JSON.stringify({ + success: false, + error: `Failed to scan directory ${dirPath}` + })); + return; + } + for (const file of list) { + if (isClosed) return; + file.path = `${dirPath}/${file.name}`; + if (file.name.toLowerCase().includes(query.toLowerCase())) { + matchedFiles.push(file); + } + if ((Date.now() - lastSend) > 1000) sendList(); + if (file.type == 'd') { + await recurse(file.path); + } + } + }; + // Start the search + await recurse(filePath); + if (isClosed) return; + sendList(); + // Send a complete message + ws.send(JSON.stringify({ success: true, status: 'complete' })); + // Close the websocket + ws.close(); +}); +srv.post('/api/sftp/directories/create', initApi, async (req, res) => { + /** @type {sftp} */ + const session = req.session; + res.data.path = normalizeRemotePath(req.query.path); + if (!res.data.path) return res.sendError('Missing path', 400); + try { + await session.mkdir(res.data.path); + res.sendData(); + } catch (error) { + res.sendError(error); + } +}); +srv.delete('/api/sftp/directories/delete', initApi, async (req, res) => { + /** @type {sftp} */ + const session = req.session; + res.data.path = normalizeRemotePath(req.query.path); + if (!res.data.path) return res.sendError('Missing path', 400); + try { + await session.rmdir(res.data.path, true); + res.sendData(); + } catch (error) { + res.sendError(error); + } +}); +srv.get('/api/sftp/files/exists', initApi, async (req, res) => { + /** @type {sftp} */ + const session = req.session; + res.data.path = normalizeRemotePath(req.query.path); + if (!res.data.path) return res.sendError('Missing path', 400); + try { + const type = await session.exists(res.data.path); + res.data.exists = type !== false; + res.data.type = type; + res.sendData(); + } catch (error) { + res.sendError(error); + } +}); +srv.post('/api/sftp/files/create', initApi, rawBodyParser, async (req, res) => { + /** @type {sftp} */ + const session = req.session; + res.data.path = normalizeRemotePath(req.query.path); + if (!res.data.path) return res.sendError('Missing path', 400); + try { + await session.put(req.body, res.data.path); + res.sendData(); + } catch (error) { + res.sendError(error); + } +}); +srv.put('/api/sftp/files/append', initApi, rawBodyParser, async (req, res) => { + /** @type {sftp} */ + const session = req.session; + res.data.path = normalizeRemotePath(req.query.path); + if (!res.data.path) return res.sendError('Missing path', 400); + try { + await session.append(req.body, res.data.path); + res.sendData(); + } catch (error) { + res.sendError(error); + } +}); +srv.ws('/api/sftp/files/append', async (ws, wsReq) => { + if (!wsReq.query.key) return ws.close(); + const req = keyedRequests[wsReq.query.key]; + if (!req) return ws.close(); + // Add uniqueness to the connection opts + // This forces a new connection to be created + req.connectionOpts.ts = Date.now(); + // Create the session and throw an error if it fails + /** @type {sftp} */ + const session = await getSession(null, req.connectionOpts); + const sessionHash = getObjectHash(req.connectionOpts); + if (!session) { + ws.send(JSON.stringify({ + success: false, + error: 'Failed to create session!' + })); + return ws.close(); + } + // Normalize the file path or throw an error if it's missing + const filePath = normalizeRemotePath(wsReq.query.path); + if (!filePath) { + ws.send(JSON.stringify({ + success: false, + error: 'Missing path' + })); + return ws.close(); + } + // Handle websocket closure + ws.on('close', () => { + console.log(`File append websocket closed`); + session.end(); + delete sessionActivity[sessionHash]; + }); + // Listen for messages + console.log(`Websocket opened to append to ${req.connectionOpts.username}@${req.connectionOpts.host}:${req.connectionOpts.port} ${filePath}`); + let isWriting = false; + ws.on('message', async (data) => { + // If we're already writing, send an error + if (isWriting) { + return ws.send(JSON.stringify({ + success: false, + error: 'Writing in progress' + })); + } + try { + // Append the data to the file + isWriting = true; + await session.append(data, filePath); + ws.send(JSON.stringify({ success: true })); + } catch (error) { + ws.send(JSON.stringify({ + success: false, + error: error.toString() + })); + return ws.close(); + } + isWriting = false; + // Update the session activity + sessionActivity[sessionHash] = Date.now(); + }); + // Send a ready message + ws.send(JSON.stringify({ success: true, status: 'ready' })); +}); +srv.delete('/api/sftp/files/delete', initApi, async (req, res) => { + /** @type {sftp} */ + const session = req.session; + res.data.path = normalizeRemotePath(req.query.path); + if (!res.data.path) return res.sendError('Missing path', 400); + try { + await session.delete(res.data.path); + res.sendData(); + } catch (error) { + res.sendError(error); + } +}); +srv.put('/api/sftp/files/move', initApi, async (req, res) => { + /** @type {sftp} */ + const session = req.session; + res.data.pathOld = normalizeRemotePath(req.query.pathOld); + res.data.pathNew = normalizeRemotePath(req.query.pathNew); + if (!res.data.pathOld) return res.sendError('Missing source path', 400); + if (!res.data.pathNew) return res.sendError('Missing destination path', 400); + try { + await session.rename(res.data.pathOld, res.data.pathNew); + res.sendData(); + } catch (error) { + res.sendError(error); + } +}); +srv.put('/api/sftp/files/copy', initApi, async (req, res) => { + /** @type {sftp} */ + const session = req.session; + res.data.pathSrc = normalizeRemotePath(req.query.pathSrc); + res.data.pathDest = normalizeRemotePath(req.query.pathDest); + if (!res.data.pathSrc) return res.sendError('Missing source path', 400); + if (!res.data.pathDest) return res.sendError('Missing destination path', 400); + try { + await session.rcopy(res.data.pathSrc, res.data.pathDest); + res.sendData(); + } catch (error) { + res.sendError(error); + } +}); +srv.put('/api/sftp/files/chmod', initApi, async (req, res) => { + /** @type {sftp} */ + const session = req.session; + res.data.path = normalizeRemotePath(req.query.path); + if (!res.data.path) return res.sendError('Missing path', 400); + res.data.mode = req.query.mode; + try { + await session.chmod(res.data.path, res.data.mode); + res.sendData(); + } catch (error) { + res.sendError(error); + } +}); +srv.get('/api/sftp/files/stat', initApi, async (req, res) => { + /** @type {sftp} */ + const session = req.session; + res.data.path = normalizeRemotePath(req.query.path); + if (!res.data.path) return res.sendError('Missing path', 400); + let stats = null; + try { + stats = await session.stat(res.data.path); + } catch (error) { + return res.sendError(error, 404); + } + res.data.stats = stats; + res.sendData(); +}); +const downloadSingleFileHandler = async (connectionOpts, res, remotePath, stats) => { + let interval; + // Gracefully handle any errors + try { + // Throw an error if it's not a file + if (!stats.isFile) throw new Error('Not a file'); + // Add uniqueness to the connection opts + // This forces a new connection to be created + connectionOpts.ts = Date.now(); + // Create the session and throw an error if it fails + const session = await getSession(res, connectionOpts); + if (!session) throw new Error('Failed to create session'); + // Continuously update the session activity + interval = setInterval(() => { + const hash = getObjectHash(connectionOpts); + sessionActivity[hash] = Date.now(); + }, 1000 * 1); + // When the response closes, end the session + const handleClose = () => { + clearInterval(interval); + session.end(); + }; + res.on('end', handleClose); + res.on('close', handleClose); + res.on('error', handleClose); + // Set response headers + res.setHeader('Content-Type', mime.getType(remotePath) || 'application/octet-stream'); + res.setHeader('Content-Disposition', `attachment; filename="${path.basename(remotePath)}"`); + res.setHeader('Content-Length', stats.size); + // Start the download + console.log(`Starting download: ${connectionOpts.username}@${connectionOpts.host}:${connectionOpts.port} ${remotePath}`); + await session.get(remotePath, res); + // Force-end the response + res.end(); + // On error, clear the interval and send a 400 response + } catch (error) { + clearInterval(interval); + res.status(400).end(); + } +}; +const downloadMultiFileHandler = async (connectionOpts, res, remotePaths, rootPath = '/') => { + rootPath = normalizeRemotePath(rootPath); + let interval; + // Gracefully handle any errors + try { + // Add uniqueness to the connection opts + // This forces a new connection to be created + connectionOpts.ts = Date.now(); + // Create the session and throw an error if it fails + const session = await getSession(res, connectionOpts); + if (!session) throw new Error('Failed to create session'); + // Continuously update the session activity + setInterval(() => { + const hash = getObjectHash(connectionOpts); + sessionActivity[hash] = Date.now(); + }, 1000 * 1); + // Set response headers + let fileName = `Files (${path.basename(rootPath) || 'Root'})`; + if (remotePaths.length == 1) + fileName = path.basename(remotePaths[0]); + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}.zip"`); + // Create the archive and start piping to the response + const archive = archiver('zip'); + archive.pipe(res); + // When the response closes, end the session + const handleClose = () => { + clearInterval(interval); + archive.end(); + session.end(); + }; + res.on('end', handleClose); + res.on('close', handleClose); + res.on('error', handleClose); + // Add file to the archive + const addToArchive = async (remotePath) => { + const archivePath = normalizeRemotePath(remotePath.replace(rootPath, '')); + console.log(`Zipping: ${connectionOpts.username}@${connectionOpts.host}:${connectionOpts.port} ${remotePath}`); + // Get file read stream + const stream = session.createReadStream(remotePath); + const waitToEnd = new Promise(resolve => { + stream.on('end', resolve); + }); + // Add file to archive + archive.append(stream, { + name: archivePath + }); + // Wait for the stream to end + await waitToEnd; + }; + // Recurse through directories and archive files + const recurse = async (remotePath) => { + try { + const stats = await session.stat(remotePath); + if (stats.isFile) { + await addToArchive(remotePath); + } else if (stats.isDirectory) { + const list = await session.list(remotePath); + for (const item of list) { + const subPath = `${remotePath}/${item.name}`; + if (item.type === '-') { + await addToArchive(subPath); + } else { + await recurse(subPath); + } + } + } + } catch (error) { } + }; + for (const remotePath of remotePaths) { + await recurse(remotePath); + } + // Finalize the archive + archive.on('close', () => res.end()); + archive.finalize(); + // On error, clear the interval and send a 400 response + } catch (error) { + clearInterval(interval); + res.status(400).end(); + } +}; +srv.get('/api/sftp/files/get/single', initApi, async (req, res) => { + /** @type {sftp} */ + const session = req.session; + // Get the normalized path and throw an error if it's missing + const remotePath = normalizeRemotePath(req.query.path); + if (!remotePath) return res.sendError('Missing path', 400); + try { + const stats = await session.stat(remotePath); + // Handle the download + await downloadSingleFileHandler(req.connectionOpts, res, remotePath, stats); + } catch (error) { + res.status(400).end(); + } +}); +const rawDownloads = {}; +srv.get('/api/sftp/files/get/single/url', initApi, async (req, res) => { + /** @type {sftp} */ + const session = req.session; + // Get the normalized path and throw an error if it's missing + res.data.path = normalizeRemotePath(req.query.path); + if (!res.data.path) return res.sendError('Missing path', 400); + // Get path stats and throw an error if it's not a file + let stats = null; + try { + stats = await session.stat(res.data.path); + if (!stats?.isFile) throw new Error('Not a file'); + } catch (error) { + return res.sendError(error); + } + // Generate download URL + const id = utils.randomHex(8); + res.data.download_url = `https://${req.get('host')}/dl/${id}`; + // Create download handler + rawDownloads[id] = { + created: Date.now(), + paths: [res.data.path], + handler: async (req2, res2) => { + // Handle the download + await downloadSingleFileHandler(req.connectionOpts, res2, res.data.path, stats); + } + } + res.sendData(); +}); +srv.get('/api/sftp/files/get/multi/url', initApi, async (req, res) => { + try { + // Get the normalized path and throw an error if it's missing + res.data.paths = JSON.parse(req.query.paths); + if (!res.data.paths) throw new Error('Missing path(s)'); + } catch (error) { + return res.sendError(error); + } + // Generate download URL + const id = utils.randomHex(8); + res.data.download_url = `https://${req.get('host')}/dl/${id}`; + // Create download handler + rawDownloads[id] = { + created: Date.now(), + paths: res.data.paths, + isZip: true, + handler: async (req2, res2) => { + // Handle the download + await downloadMultiFileHandler(req.connectionOpts, res2, res.data.paths, req.query.rootPath); + } + } + res.sendData(); +}); +srv.get('/dl/:id', async (req, res) => { + // Get the download handler + const entry = rawDownloads[req.params.id]; + if (!entry) return res.status(404).end(); + // If the user agent looks like a bot + if (req.get('user-agent').match(/(bot|scrape)/)) { + // Send some HTML + res.setHeader('Content-Type', 'text/html'); + const html = /*html*/` + + + Download shared files + + + + + + + +

Click here to download the file.

+ + + `; + res.send(html); + } else { + entry.handler(req, res); + } +}); + +srv.use((req, res) => res.status(404).end()); + +setInterval(() => { + // Delete inactive sessions + for (const hash in sessions) { + const lastActive = sessionActivity[hash]; + if (!lastActive) continue; + if ((Date.now() - lastActive) > 1000 * 60 * 5) { + console.log(`Deleting inactive session`); + sessions[hash].end(); + delete sessions[hash]; + delete sessionActivity[hash]; + } + } + // Delete unused downloads + for (const id in rawDownloads) { + const download = rawDownloads[id]; + if ((Date.now() - download.created) > 1000 * 60 * 60 * 12) { + console.log(`Deleting unused download`); + delete rawDownloads[id]; + } + } + // Delete expired connections + for (const id in connections) { + const connection = connections[id]; + if ((Date.now() - connection.created) > 1000 * 60 * 60 * 12) { + console.log(`Deleting expired connection ${id}`); + delete connections[id]; + } + } +}, 1000 * 30); + +if (Electron.app) { + Electron.app.whenReady().then(async () => { + // Start the server + let port = 8001 + Math.floor(Math.random() * 999); + await new Promise(resolve => { + srv.listen(port, () => { + console.log(`App server listening on port ${port}`) + resolve(); + }); + }); + // Open the window + const window = new Electron.BrowserWindow({ + width: 1100, + height: 720, + autoHideMenuBar: true, + minWidth: 320, + minHeight: 200 + }); + window.loadURL(`http://localhost:${port}`); + // Quit the app when all windows are closed + // unless we're on macOS + Electron.app.on('window-all-closed', () => { + if (process.platform !== 'darwin') Electron.app.quit(); + }); + }); +} else { + srv.listen(config.port, () => console.log(`Standalone server listening on port ${config.port}`)); +} diff --git a/web/assets/file.js b/web/assets/file.js new file mode 100644 index 0000000..d2ccd68 --- /dev/null +++ b/web/assets/file.js @@ -0,0 +1,488 @@ + +const elNavBar = $('#navbar'); +const elControls = $('#controls'); +const elPreview = $('#preview'); +const btnDownload = $('#download'); +const query = new URLSearchParams(window.location.search); +let path = query.get('path'); +activeConnection = connections[query.get('con')]; +let fileStats = null; +let editor; + +const updatePreview = async() => { + // Make sure the file is viewable + const extInfo = getFileExtInfo(path); + if (!extInfo.isViewable) { + return setStatus(`Error: File isn't viewable!`, true); + } + let fileUrl; + try { + const startTime = Date.now(); + let lastUpdate = 0; + const blob = await api.request('get', 'files/get/single', { + path: path + }, null, e => { + if ((Date.now()-lastUpdate) < 100) return; + lastUpdate = Date.now(); + const progress = Math.round((e.loaded / fileStats.size) * 100); + const bps = Math.round(e.loaded / ((Date.now() - startTime) / 1000)); + setStatus(`Downloaded ${formatSize(e.loaded)} of ${formatSize(fileStats.size)} (${formatSize(bps)}/s)`, false, progress); + }, 'blob'); + fileUrl = URL.createObjectURL(blob); + } catch (error) { + return setStatus(`Error: ${error}`, true); + } + elPreview.classList.add(extInfo.type); + const statusHtmlSegments = []; + switch (extInfo.type) { + case 'image': { + const image = document.createElement('img'); + image.src = fileUrl; + await new Promise(resolve => { + image.addEventListener('load', resolve); + }); + elControls.insertAdjacentHTML('beforeend', ` + +
0%
+ +
+ + + `); + const btnZoomOut = $('.btn.zoomOut', elControls); + const btnZoomIn = $('.btn.zoomIn', elControls); + const btnFit = $('.btn.fit', elControls); + const btnReal = $('.btn.real', elControls); + const elZoom = $('.zoom', elControls); + let fitPercent = 100; + const setZoom = percent => { + const minZoom = fitPercent; + const maxZoom = 1000; + const newZoom = Math.min(Math.max(percent, minZoom), maxZoom); + elZoom.innerText = `${Math.round(newZoom)}%`; + const scaledSize = { + width: image.naturalWidth * (newZoom/100), + height: image.naturalHeight * (newZoom/100) + }; + image.style.width = `${scaledSize.width}px`; + image.style.height = `${scaledSize.height}px`; + }; + const changeZoom = percentChange => { + const zoom = parseInt(elZoom.innerText.replace('%', '')); + setZoom(zoom+percentChange); + }; + const fitImage = () => { + const previewRect = elPreview.getBoundingClientRect(); + const previewRatio = previewRect.width / previewRect.height; + const imageRatio = image.naturalWidth / image.naturalHeight; + fitPercent = 100; + if (imageRatio > previewRatio) { + fitPercent = (previewRect.width / image.naturalWidth) * 100; + } else { + fitPercent = (previewRect.height / image.naturalHeight) * 100; + } + fitPercent = Math.min(fitPercent, 100); + setZoom(fitPercent); + image.style.marginTop = ''; + image.style.marginLeft = ''; + }; + btnZoomIn.addEventListener('click', () => { + changeZoom(10); + }); + btnZoomOut.addEventListener('click', () => { + changeZoom(-10); + }); + btnFit.addEventListener('click', () => { + fitImage(); + }); + btnReal.addEventListener('click', () => { + setZoom(100); + }); + elPreview.addEventListener('wheel', e => { + if (getIsMobileDevice()) return; + e.preventDefault(); + const previewRect = elPreview.getBoundingClientRect(); + const relativePos = { + x: (e.clientX - previewRect.left) + elPreview.scrollLeft, + y: (e.clientY - previewRect.top) + elPreview.scrollTop + }; + const percentage = { + x: relativePos.x / elPreview.scrollWidth, + y: relativePos.y / elPreview.scrollHeight + }; + changeZoom(e.deltaY > 0 ? -10 : 10); + const newScroll = { + x: (elPreview.scrollWidth * percentage.x) - relativePos.x, + y: (elPreview.scrollHeight * percentage.y) - relativePos.y + }; + elPreview.scrollLeft += newScroll.x; + elPreview.scrollTop += newScroll.y; + }); + /* + let startTouchDistance = 0; + elPreview.addEventListener('touchstart', e => { + if (!getIsMobileDevice()) return; + if (e.touches.length == 2) { + e.preventDefault(); + const touch1 = e.touches[0]; + const touch2 = e.touches[1]; + const distance = Math.sqrt( + Math.pow(touch1.clientX - touch2.clientX, 2) + + Math.pow(touch1.clientY - touch2.clientY, 2) + ); + startTouchDistance = distance; + } + }); + elPreview.addEventListener('touchmove', e => { + if (!getIsMobileDevice()) return; + if (e.touches.length == 2) { + e.preventDefault(); + const touch1 = e.touches[0]; + const touch2 = e.touches[1]; + const distance = Math.sqrt( + Math.pow(touch1.clientX - touch2.clientX, 2) + + Math.pow(touch1.clientY - touch2.clientY, 2) + ); + const percentChange = (distance - startTouchDistance) / 10; + changeZoom(percentChange); + startTouchDistance = distance; + } + }); + elPreview.addEventListener('touchend', e => { + if (!getIsMobileDevice()) return; + startTouchDistance = 0; + }); + */ + let startCoords = {}; + let startScroll = {}; + let isMouseDown = false; + elPreview.addEventListener('mousedown', e => { + if (getIsMobileDevice()) return; + e.preventDefault(); + startCoords = { x: e.clientX, y: e.clientY }; + startScroll = { x: elPreview.scrollLeft, y: elPreview.scrollTop }; + isMouseDown = true; + elPreview.style.cursor = 'grabbing'; + }); + elPreview.addEventListener('dragstart', e => { + if (getIsMobileDevice()) return; + e.preventDefault(); + }); + elPreview.addEventListener('mousemove', e => { + if (getIsMobileDevice()) return; + e.preventDefault(); + if (!isMouseDown) return; + const newScroll = { + x: startCoords.x - e.clientX + startScroll.x, + y: startCoords.y - e.clientY + startScroll.y + }; + // Update preview scroll + elPreview.scrollLeft = newScroll.x; + elPreview.scrollTop = newScroll.y; + }); + elPreview.addEventListener('mouseup', e => { + if (getIsMobileDevice()) return; + e.preventDefault(); + isMouseDown = false; + elPreview.style.cursor = ''; + }); + elPreview.addEventListener('mouseleave', e => { + if (getIsMobileDevice()) return; + e.preventDefault(); + isMouseDown = false; + elPreview.style.cursor = ''; + }); + elControls.style.display = ''; + elPreview.innerHTML = ''; + elPreview.appendChild(image); + statusHtmlSegments.push(`${image.naturalWidth}x${image.naturalHeight}`); + fitImage(); + window.addEventListener('resize', fitImage); + break; + } + case 'video': { + const video = document.createElement('video'); + video.src = fileUrl; + await new Promise(resolve => { + video.addEventListener('loadedmetadata', resolve); + }); + video.controls = true; + elPreview.innerHTML = ''; + elPreview.appendChild(video); + video.play(); + statusHtmlSegments.push(`${formatSeconds(video.duration)}`); + statusHtmlSegments.push(`${video.videoWidth}x${video.videoHeight}`); + break; + } + case 'audio': { + const audio = document.createElement('audio'); + audio.src = fileUrl; + await new Promise(resolve => { + audio.addEventListener('loadedmetadata', resolve); + }); + audio.controls = true; + elPreview.innerHTML = ''; + elPreview.appendChild(audio); + audio.play(); + statusHtmlSegments.push(`${formatSeconds(audio.duration)}`); + break; + } + case 'markdown': + case 'text': { + // Initialize the textarea + const text = await (await fetch(fileUrl)).text(); + //const textarea = document.createElement('textarea'); + // Initialize CodeMirror + elPreview.innerHTML = ''; + editor = CodeMirror(elPreview, { + value: text, + lineNumbers: true, + lineWrapping: true, + scrollPastEnd: true, + styleActiveLine: true, + autoCloseBrackets: true, + mode: extInfo.codeMirrorMode + }); + const elEditor = $('.CodeMirror', elPreview); + // Load CodeMirror mode + if (extInfo.codeMirrorMode) { + let mode; + CodeMirror.requireMode(extInfo.codeMirrorMode, () => {}, { + path: determinedMode => { + mode = determinedMode; + return `https://codemirror.net/5/mode/${determinedMode}/${determinedMode}.js`; + } + }); + CodeMirror.autoLoadMode(editor, mode); + } + // Add HTML + elControls.insertAdjacentHTML('beforeend', ` + + + +
+ +
18
+ +
+ + `); + // Set up the save button + const btnSave = $('.btn.save', elControls); + const btnSaveText = $('span', btnSave); + btnSave.addEventListener('click', async() => { + if (btnSave.disabled) return; + btnSaveText.innerText = 'Saving...'; + btnSave.disabled = true; + btnSave.classList.remove('info'); + // const res1 = await api.delete('files/delete', { + // path: path + // }); + const res1 = {}; + const res2 = await api.post('files/create', { + path: path + //}, textarea.value); + }, editor.getValue()); + if (res1.error || res2.error) { + setStatus(`Error: ${res2.error || res1.error}`, true); + btnSaveText.innerText = 'Save'; + btnSave.disabled = false; + btnSave.classList.add('info'); + } else { + btnSaveText.innerText = 'Saved!'; + await getUpdatedStats(); + setStatusWithDetails(); + } + }); + //textarea.addEventListener('input', () => { + editor.on('change', () => { + btnSave.disabled = false; + btnSave.classList.add('info'); + btnSaveText.innerText = 'Save'; + }); + window.addEventListener('keydown', e => { + if (e.ctrlKey && e.code == 'KeyS') { + e.preventDefault(); + btnSave.click(); + } + if (e.ctrlKey && e.code == 'Minus') { + e.preventDefault(); + btnTextSmaller.click(); + } + if (e.ctrlKey && e.code == 'Equal') { + e.preventDefault(); + btnTextBigger.click(); + } + }); + window.addEventListener('beforeunload', e => { + if (!btnSave.disabled) { + e.preventDefault(); + e.returnValue = ''; + } + }); + // Set up the word wrap checkbox + const wrapCheckbox = $('input[type="checkbox"]', elControls); + wrapCheckbox.addEventListener('change', () => { + const isChecked = wrapCheckbox.checked; + //textarea.style.whiteSpace = isChecked ? 'pre-wrap' : 'pre'; + editor.setOption('lineWrapping', isChecked); + window.localStorage.setItem('wrapTextEditor', isChecked); + }); + wrapCheckbox.checked = window.localStorage.getItem('wrapTextEditor') == 'true'; + wrapCheckbox.dispatchEvent(new Event('change')); + // Set up markdown controls + let elRendered; + if (extInfo.type == 'markdown') { + elRendered = document.createElement('div'); + elRendered.classList = 'rendered'; + elRendered.style.display = 'none'; + const btnPreview = $('.btn.view', elControls); + const btnEdit = $('.btn.edit', elControls); + // Set up the markdown preview button + btnPreview.addEventListener('click', async() => { + btnPreview.style.display = 'none'; + btnEdit.style.display = ''; + elRendered.style.display = ''; + //textarea.style.display = 'none'; + elEditor.style.display = 'none'; + //elRendered.innerHTML = marked.parse(textarea.value); + elRendered.innerHTML = marked.parse(editor.getValue()); + // Make all links open in a new tab + const links = $$('a', elRendered); + for (const link of links) { + link.target = '_blank'; + } + }); + // Set up the markdown edit button + btnEdit.addEventListener('click', async() => { + btnPreview.style.display = ''; + btnEdit.style.display = 'none'; + elRendered.style.display = 'none'; + //textarea.style.display = ''; + elEditor.style.display = ''; + }); + // View file by default + btnEdit.click(); + } + // Set up text size buttons + const btnTextSmaller = $('.btn.textSmaller', elControls); + const btnTextBigger = $('.btn.textBigger', elControls); + const elTextSize = $('.textSize', elControls); + let size = parseInt(window.localStorage.getItem('textEditorSize')) || 18; + const updateTextSize = () => { + //textarea.style.fontSize = `${size}px`; + elEditor.style.fontSize = `${size}px`; + elTextSize.innerText = size; + window.localStorage.setItem('textEditorSize', size); + } + updateTextSize(); + btnTextSmaller.addEventListener('click', () => { + size--; + updateTextSize(); + }); + btnTextBigger.addEventListener('click', () => { + size++; + updateTextSize(); + }); + // Finalize elements + elControls.style.display = ''; + //elPreview.appendChild(textarea); + if (extInfo.type == 'markdown') + elPreview.appendChild(elRendered); + break; + } + default: { + elPreview.innerHTML = `

Error!

`; + break; + } + } + const setStatusWithDetails = () => { + setStatus(` +
+ ${formatSize(fileStats.size)} + ${statusHtmlSegments.join('\n')} + ${extInfo.mime} + ${getRelativeDate(fileStats.modifyTime)} +
+ `) + }; + setStatusWithDetails(); + setTimeout(setStatusWithDetails, 60*1000); +} + +const getUpdatedStats = async() => { + // Stat file + const res = await api.get('files/stat', { + path: path + }); + fileStats = res.stats; + return res; +} + +window.addEventListener('load', async() => { + const res = await getUpdatedStats(); + if (!res.error) { + // Update navbar + path = res.path; + document.title = `${activeConnection.name} - ${path}`; + const pathSplit = path.split('/'); + const folderPath = `${pathSplit.slice(0, pathSplit.length - 1).join('/')}/`; + const fileName = pathSplit[pathSplit.length - 1]; + $('.path', elNavBar).innerText = folderPath; + $('.name', elNavBar).innerText = fileName; + updatePreview(fileName); + } else { + return setStatus(`Error: ${res.error}`, true); + } +}); + +btnDownload.addEventListener('click', async() => { + const fileName = $('.name', elNavBar).innerText; + const elSrc = $('img, video, audio', elPreview); + const elText = $('textarea', elPreview); + if (elSrc) { + console.log(`Starting download using downloaded blob`); + return downloadUrl(elSrc.src, fileName); + } else if (elText && editor) { + console.log(`Starting download using text editor value`); + const value = editor.getValue(); + const dataUrl = `data:text/plain;base64,${btoa(value)}`; + return downloadUrl(dataUrl, fileName); + } else { + console.log(`Starting download using URL API`); + const url = await getFileDownloadUrl(path) + downloadUrl(url); + } +}); + +// Let the window finish displaying itself before saving size +setTimeout(() => { + window.addEventListener('resize', () => { + window.localStorage.setItem('viewerWidth', window.innerWidth); + window.localStorage.setItem('viewerHeight', window.innerHeight); + }); +}, 2000); \ No newline at end of file diff --git a/web/assets/index.js b/web/assets/index.js new file mode 100644 index 0000000..3418f5b --- /dev/null +++ b/web/assets/index.js @@ -0,0 +1,2591 @@ + +const btnConnections = $('#connections'); +const btnNavBack = $('#navBack'); +const btnNavForward = $('#navForward'); +const inputNavPath = $('#inputNavPath'); +const btnGo = $('#pathGo'); +const btnPathPopup = $('#pathPopup'); +const elBelowNavBar = $('#belowNavBar'); +const btnDirMenu = $('#dirMenu'); +const btnDeselectAll = $('#deselectAll'); +const btnUpload = $('#upload'); +const btnDirCreate = $('#dirCreate'); +const btnFileCreate = $('#fileCreate'); +const btnSelectionCut = $('#fileCut'); +const btnSelectionCopy = $('#fileCopy'); +const btnSelectionPaste = $('#filePaste'); +const btnRename = $('#fileRename'); +const btnSelectionMoveTo = $('#fileMoveTo'); +const btnSelectionCopyTo = $('#fileCopyTo'); +const btnSelectionDelete = $('#fileDelete'); +const btnSelectionPerms = $('#filePerms'); +const btnDownload = $('#fileDownload'); +const btnShare = $('#fileShare'); +const btnDirSort = $('#dirSort'); +const btnDirView = $('#dirView'); +const btnDirSelection = $('#dirSelection'); +const elFileColHeadings = $('#fileColHeadings'); +const elFiles = $('#files'); +const inputSearch = $('#inputNavSearch'); +const btnSearchCancel = $('#navSearchCancel'); +const btnSearchGo = $('#navSearchGo'); +const forceTileViewWidth = 720; +/** An array of paths in the back history */ +let backPaths = []; +/** An array of paths in the forward history */ +let forwardPaths = []; +/** An array of paths cut or copied to the clipboard */ +let selectionClipboard = []; +/** True the clipboard paste mode is cut */ +let isClipboardCut = false; +/** + * The current file sort order + * @type {'name'|'size'|'date'} + */ +let sortType = window.localStorage.getItem('sortType') || 'name'; +/** + * True of the file sort order is to be reversed + * @type {boolean} + */ +let sortDesc = window.localStorage.getItem('sortDesc'); +sortDesc = (sortDesc == null) ? false : (sortDesc === 'true'); +/** + * True if hidden files should be visible + * @type {boolean} + */ +let showHidden = window.localStorage.getItem('showHidden'); +showHidden = (showHidden == null) ? true : (showHidden === 'true'); +/** + * The current file view mode + * @type {'list'|'tile'} + */ +let viewMode = window.localStorage.getItem('viewMode') || 'list'; +/** True if an upload is in progress */ +let isUploading = false; +/** The index of the most recently selected file, or -1 if no files are selected */ +let lastSelectedIndex = -1; +/** True of a directory load is in progress + * and currently visible files shouldn't be accessed */ +let fileAccessLock = false; +/** +* True of hidden files should be visible +* @type {boolean} +*/ +let showDownloadPopup = window.localStorage.getItem('showDownloadPopup'); +showDownloadPopup = (showDownloadPopup == null) ? true : (showDownloadPopup === 'true'); +// Variables for file name navigation +let keypressString = ''; +let keypressClearTimeout; +// Variables for recursive searching +let searchWebsocket; +let isSearching = false; + +/** + * Saves the current state of the `connections` object to LocalStorage. + */ +const saveConnections = () => { + window.localStorage.setItem('connections', JSON.stringify(connections)); +} + +/** + * Returns the `connections` object as a sorted array, and each value has an added `id` property. + */ +const getSortedConnectionsArray = () => { + const connectionValues = []; + for (const id of Object.keys(connections)) { + const connection = connections[id]; + connectionValues.push({ + id: id, + ...connection + }); + } + connectionValues.sort((a, b) => { + const aName = a.name.toLowerCase(); + const bName = b.name.toLowerCase(); + if (aName < bName) return -1; + if (aName > bName) return 1; + return 0; + }); + return connectionValues; +} + +/** + * Prompts the user to export a connection. + * @param {number} id The connection ID + */ +const exportConnectionDialog = async (id) => { + const connection = connections[id]; + const exportBody = document.createElement('div'); + exportBody.classList = 'col gap-10'; + exportBody.style.maxWidth = '400px'; + exportBody.innerHTML = /*html*/` + + + Only share exports with credentials with people you trust! These credentials grant access to not only your server's files, but oftentimes an interactive terminal (SSH). + `; + new PopupBuilder() + .setTitle(`Export ${connection.name}`) + .addBody(exportBody) + .addAction(action => action + .setLabel('Export') + .setIsPrimary(true) + .setClickHandler(() => { + const includeCredentials = $('input[name="exportCredentials"]:checked', exportBody).value == 'include'; + const data = { + name: connection.name, + host: connection.host, + port: connection.port, + username: connection.username, + path: connection.path + }; + if (includeCredentials) { + if (connection.key) + data.key = connection.key; + if (connection.password) + data.password = connection.password; + } + const blob = new Blob([ + JSON.stringify(data) + ], { + type: 'application/json' + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${connection.name.replace(/[^a-zA-Z-_\. ]/g, '').trim() || 'connection'}.json`; + a.click(); + URL.revokeObjectURL(url); + })) + .addAction(action => action.setLabel('Cancel')) + .show(); +} + +/** + * Opens a dialog popup to manage stored connection information. + */ +const connectionManagerDialog = () => { + const popup = new PopupBuilder(); + const el = document.createElement('div'); + el.id = 'connectionManager'; + el.classList = 'col gap-15'; + const connectionValues = getSortedConnectionsArray(); + for (const connection of connectionValues) { + const entry = document.createElement('div'); + entry.classList = 'entry row gap-10 align-center'; + entry.innerHTML = /*html*/` +
cloud
+
+
+
${connection.name}
+ + ${connection.username}@${connection.host}:${connection.port} +
${connection.path} +
+
+
+ + +
+
+ `; + $('.btn.menu', entry).addEventListener('click', () => { + new ContextMenuBuilder() + .addItem(option => option + .setLabel('Edit...') + .setIcon('edit') + .setClickHandler(async() => { + popup.hide(); + await editConnectionDialog(connection.id); + connectionManagerDialog(); + })) + .addItem(option => option + .setLabel('Export...') + .setIcon('download') + .setClickHandler(async() => { + exportConnectionDialog(connection.id); + })) + .addSeparator() + .addItem(option => option + .setLabel('Delete') + .setIcon('delete') + .setIsDanger(true) + .setClickHandler(async() => { + delete connections[connection.id]; + saveConnections(); + entry.remove(); + })) + .showAtCursor(); + }); + $('.btn.connect', entry).addEventListener('click', () => { + popup.hide(); + setActiveConnection(connection.id); + }); + el.appendChild(entry); + } + const elButtons = document.createElement('div'); + elButtons.classList = 'row gap-10 flex-wrap'; + const btnAdd = document.createElement('button'); + btnAdd.classList = 'btn success small'; + btnAdd.innerHTML = /*html*/` +
add
+ New connection... + `; + btnAdd.addEventListener('click', async() => { + popup.hide(); + await addNewConnectionDialog(); + connectionManagerDialog(); + }); + elButtons.appendChild(btnAdd); + const btnImport = document.createElement('button'); + btnImport.classList = 'btn secondary small'; + btnImport.innerHTML = /*html*/` +
cloud_upload
+ Import... + `; + btnImport.addEventListener('click', async() => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.addEventListener('change', async() => { + const file = input.files[0]; + if (!file) return; + popup.hide(); + const reader = new FileReader(); + reader.addEventListener('load', async() => { + try { + const data = JSON.parse(reader.result); + const id = Date.now(); + connections[id] = data; + saveConnections(); + if (!data.key && !data.password) { + return editConnectionDialog(id); + } + } catch (error) { + console.error(error); + } + connectionManagerDialog(); + }); + reader.readAsText(file); + }); + input.click(); + }); + elButtons.appendChild(btnImport); + el.appendChild(elButtons); + popup + .setTitle('Connections') + .addBody(el) + .addAction(action => action + .setIsPrimary(true) + .setLabel('Done') + .setClickHandler(() => { + saveConnections(); + })); + popup.show(); +} + +/** + * Opens a dialog to edit an existing connection by its ID. + * @param {number} id The connection ID + * @returns {Promise} Resolves with the ID passed in + */ +const editConnectionDialog = async (id) => new Promise(resolve => { + const connection = connections[id]; + if (!connection) throw new Error(`Connection with ID ${id} not found!`); + const securityNote = thing => `Your ${thing} is saved in this browser and only persists on the server during and for a few minutes after each request.`; + const el = document.createElement('div'); + el.classList = 'col gap-10'; + el.innerHTML = /*html*/` +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ + +
+
+
+ + ${securityNote('password')} +
+
+
+ +
+
+ +
+ Your private key is typically located under C:\\Users\\you\\.ssh on Windows, or /home/you/.ssh on Unix. It's not the ".pub" file! The server has to be configured to accept your public key for your private one to work. + ${securityNote('private key')} +
+
+ + +
+ `; + const inputName = $('#inputName', el); + const inputHost = $('#inputHost', el); + const inputPort = $('#inputPort', el); + const inputUsername = $('#inputUsername', el); + const authTypePassword = $('#authTypePassword', el); + const authTypeKey = $('#authTypeKey', el); + const inputPassword = $('#inputPassword', el); + const elPasswordCont = $('#passwordCont', el); + const elKeyCont = $('#keyCont', el); + const inputKey = $('#inputKey', el); + const btnLoadKey = $('#loadKeyFromFile', el); + const inputPath = $('#inputPath', el); + authTypePassword.addEventListener('change', () => { + elPasswordCont.style.display = ''; + elKeyCont.style.display = 'none'; + }); + authTypeKey.addEventListener('change', () => { + elPasswordCont.style.display = 'none'; + elKeyCont.style.display = ''; + }); + if (!connection.password && !connection.key) { + authTypeKey.checked = true; + authTypeKey.dispatchEvent(new Event('change')); + } else if (!connection.password) { + authTypeKey.checked = true; + authTypeKey.dispatchEvent(new Event('change')); + } else { + authTypePassword.checked = true; + authTypePassword.dispatchEvent(new Event('change')); + } + btnLoadKey.addEventListener('click', () => { + const input = document.createElement('input'); + input.type = 'file'; + input.addEventListener('change', () => { + const file = input.files[0]; + if (file.size > 1024) return; + const reader = new FileReader(); + reader.addEventListener('load', () => { + inputKey.value = reader.result; + }); + reader.readAsText(file); + }); + input.click(); + }); + const popup = new PopupBuilder() + .setTitle('Edit connection') + .addBody(el); + popup.addAction(action => action + .setIsPrimary(true) + .setLabel('Save') + .setClickHandler(() => { + connection.host = inputHost.value; + connection.port = inputPort.value || 22; + connection.username = inputUsername.value; + connection.name = inputName.value || `${connection.username}@${connection.host}`; + if (authTypePassword.checked) { + connection.password = inputPassword.value; + delete connection.key; + } else { + connection.key = inputKey.value; + delete connection.password; + } + connection.path = inputPath.value; + saveConnections(); + })); + popup.addAction(action => action.setLabel('Cancel')); + popup.show(); + popup.setOnHide(() => resolve(id)); +}); + +/** + * Adds a new connection with basic placeholder data and runs `editConnectionDialog()` on it. + */ +const addNewConnectionDialog = async() => { + const id = Date.now(); + connections[id] = { + name: 'New Connection', + host: '', + port: 22, + username: '', + key: '', + password: '', + path: '/' + }; + await editConnectionDialog(id); + if (!connections[id].host || !connections[id].username) { + delete connections[id]; + } +} + +/** + * Sets the active connection to the one with the specified ID. + * @param {number} id The connection ID + * @param {string} path An initial directory path to override the saved one + */ +const setActiveConnection = (id, path) => { + if (!connections[id]) { + throw new Error(`Connection with ID ${id} not found!`); + } + backPaths = []; + forwardPaths = []; + activeConnection = JSON.parse(JSON.stringify(connections[id])); + activeConnectionId = id; + selectionClipboard = []; + changePath(path, false); +} + +/** + * Fetches connection details from the server for a given connection ID. + * @param {string} connectionId The connection ID + * @returns {Promise} The connection details or null if failed + */ +const fetchConnectionDetails = async (connectionId) => { + try { + const response = await fetch(`/api/connect/${connectionId}`); + const data = await response.json(); + if (!data.success) { + setStatus(`Error: ${data.error}`, true); + return null; + } + return data.connection; + } catch (error) { + setStatus(`Error fetching connection details: ${error.message}`, true); + return null; + } +}; + +/** + * Changes the path and loads the directory or file. + * @param {string} path The target path + * @param {boolean} pushState If `true`, update the back/forward history + */ +const changePath = async(path, pushState = true) => { + loadStartTime = Date.now(); + if (!activeConnection) return; + // Lock file selection to prevent double-clicking during load + fileAccessLock = true; + // Use the current path if none is specified + path = path || activeConnection.path; + // Disable nav buttons during load + btnNavBack.disabled = true; + btnNavForward.disabled = true; + btnGo.disabled = true; + // Stat the path to make sure it exists + setStatus(`Checking path...`); + const dataStats = await api.get('files/stat', { path: path }); + // If there was an error + if (dataStats.error) { + setStatus(`Error: ${dataStats.error}`, true); + // Otherwise... + } else { + // Get extension info + const info = getFileExtInfo(dataStats.path.split('/').pop(), dataStats.stats.size); + // If the path is a file + if (dataStats.stats.isFile) { + // If the file is viewable, open the file viewer + if (info.isViewable) { + openFileViewer(dataStats.path); + } else { + await downloadFile(dataStats.path); + } + // Update the path bar + inputNavPath.value = activeConnection.path; + // If the path is a directory + } else if (dataStats.stats.isDirectory) { + // Update the path bar + inputNavPath.value = dataStats.path; + // If the path has changed, push the old path to the back history + if (pushState && activeConnection.path != path) + backPaths.push(activeConnection.path); + // Update the stored current path + activeConnection.path = dataStats.path; + // Update display + document.title = `${activeConnection.name} - ${activeConnection.path}`; + window.history.replaceState(null, null, `?con=${activeConnectionId}&path=${encodeURIComponent(activeConnection.path)}`); + // Load the directory + await loadDirectory(dataStats.path); + // Otherwise, show an error + } else { + setStatus(`Error: Path is not a file or directory`, true); + } + } + // Re-enable nav buttons accordingly + btnNavBack.disabled = (backPaths.length == 0); + btnNavForward.disabled = (forwardPaths.length == 0); + btnGo.disabled = false; + // Unlock file selection + fileAccessLock = false; +} + +/** + * Loads a directory and populates the file list. + * @param {string} path The directory path + */ +const loadDirectory = async path => { + // Hide the file view + elFiles.style.transition = 'none'; + elFiles.style.pointerEvents = 'none'; + requestAnimationFrame(() => { + elFiles.style.opacity = 0; + }); + // Remove all existing file elements + elFiles.innerHTML = ` +
Folders
+
+
Files
+
+ `; + const elFilesFolders = $('.folders', elFiles); + const elFilesFiles = $('.files', elFiles); + lastSelectedIndex = -1; + // Disable directory controls + updateDirControls(); + btnUpload.disabled = true; + btnDirCreate.disabled = true; + btnFileCreate.disabled = true; + btnDownload.disabled = true; + btnShare.disabled = true; + btnDirSort.disabled = true; + inputSearch.value = ''; + isSearching = false; + try { searchWebsocket.close(); } catch (error) {} + // Get the directory listing + setStatus(`Loading directory...`); + const data = await api.get('directories/list', { path: path }); + // If an error occurred, update the status bar + // then set the file list to an empty array + if (data.error) { + setStatus(`Error: ${data.error}`, true); + data.list = []; + } + // Add the ".." directory + const list = [{ + name: '..', + type: 'd', + longname: '-' + }, ...data.list]; + // Loop through the list and create file elements + for (const file of list) { + const elFile = getFileEntryElement(file, path); + // Add the file element to the file list + if (file.type == 'd') + elFilesFolders.appendChild(elFile); + else + elFilesFiles.appendChild(elFile); + } + // Sort the file list + sortFiles(); + // Show the file view + elFiles.style.transition = '0.15s var(--bezier)'; + requestAnimationFrame(() => { + elFiles.style.opacity = 1; + setTimeout(() => { + elFiles.style.pointerEvents = ''; + elFiles.style.transition = 'none'; + }, 200); + }); + // Re-enable directory controls accordingly + if (!data.error) { + btnUpload.disabled = false; + btnDirCreate.disabled = false; + btnFileCreate.disabled = false; + btnDownload.disabled = false; + btnShare.disabled = false; + btnDirSort.disabled = false; + inputSearch.placeholder = `Search within ${path.split('/').pop() || '/'}...`; + updateDirControls(); + setStatus(`Loaded directory with ${list.length} items in ${Date.now()-loadStartTime}ms`); + } +} + +const searchDirectory = async(path, query) => { + let startTime = Date.now(); + isSearching = true; + setStatus(`Starting search...`); + document.title = `${activeConnection.name} - Searching for "${query}" in ${path}`; + // Disable controls + updateDirControls(); + btnUpload.disabled = true; + btnDirCreate.disabled = true; + btnFileCreate.disabled = true; + // Remove all existing file elements + elFiles.innerHTML = ` +
Folders
+
+
Files
+
+ `; + const elFilesFolders = $('.folders', elFiles); + const elFilesFiles = $('.files', elFiles); + lastSelectedIndex = -1; + // Get socket key + const resSocketKey = await api.get('key'); + const key = resSocketKey.key; + // Connect to the search websocket + try { searchWebsocket.close(); } catch (error) {} + searchWebsocket = new WebSocket(`wss://${window.location.host}/api/sftp/directories/search?key=${key}&path=${encodeURIComponent(path)}&query=${encodeURIComponent(query)}`); + let count = 0; + let maxCount = 500; + let finishedSuccessfully = false; + searchWebsocket.addEventListener('message', e => { + const data = JSON.parse(e.data); + if (data.error) { + setStatus(`Error: ${data.error}`, true, -1); + } + if (data.status == 'scanning') { + setStatus(`Searching within ${data.path}...`, false, -1); + } + if (data.status == 'complete') { + finishedSuccessfully = true; + searchWebsocket.close(); + } + if (data.status == 'list') { + // Add file elements to the file list + for (const file of data.list) { + const pathSplit = file.path.split('/'); + pathSplit.pop(); + const folderPath = pathSplit.join('/'); + const elFile = getFileEntryElement(file, path); + elFile.classList.add('search'); + const elNameCont = $('.nameCont', elFile); + elNameCont.insertAdjacentHTML('afterbegin', ` +
+ ${folderPath} +
+ `); + if (file.type == 'd') + elFilesFolders.appendChild(elFile); + else + elFilesFiles.appendChild(elFile); + count++; + if (count >= maxCount) { + finishedSuccessfully = true; + searchWebsocket.close(); + } + } + // Sort file list + sortFiles(); + } + }); + searchWebsocket.addEventListener('close', () => { + setStatus(`Found ${count >= maxCount ? `${maxCount}+` : count} file(s) in ${Date.now()-startTime}ms`); + }); +} + + +/** + * Generates a file list entry element with the data for a given file. + * @param {object} file A file object returned from the directory list API + * @param {string} dirPath The path of the directory containing this file + * @returns {HTMLElement} + */ +const getFileEntryElement = (file, dirPath) => { + const elFile = document.createElement('button'); + elFile.classList = 'btn fileEntry row'; + // If the file is "hidden", give it the class + if (file.name != '..' && file.name.substring(0, 1) === '.') { + elFile.classList.add('hidden'); + } + // Get icon + let icon = 'insert_drive_file'; + if (file.type == 'd') icon = 'folder'; + if (file.type == 'l') icon = 'file_present'; + if (file.type == 'b') icon = 'save'; + if (file.type == 'p') icon = 'filter_alt'; + if (file.type == 'c') icon = 'output'; + if (file.type == 's') icon = 'wifi'; + if (file.name == '..') icon = 'drive_folder_upload'; + // Get formatted file info + const sizeFormatted = (file.size && file.type !== 'd') ? formatSize(file.size) : '-'; + const dateRelative = file.modifyTime ? getRelativeDate(file.modifyTime) : '-'; + const dateAbsolute = file.modifyTime ? dayjs(file.modifyTime).format('MMM D, YYYY, h:mm A') : null; + const perms = file.longname.split(' ')[0].replace(/\*/g, ''); + const permsNum = permsStringToNum(perms); + // Add data attributes to the file element + elFile.dataset.path = file.path || `${dirPath}/${file.name}`; + elFile.dataset.type = file.type; + elFile.dataset.name = file.name; + elFile.dataset.size = file.size; + elFile.dataset.date = file.modifyTime; + elFile.dataset.perms = perms; + // Build the HTML + let lower = []; + if (dateRelative !== '-') lower.push(dateRelative); + if (sizeFormatted !== '-') lower.push(sizeFormatted); + elFile.innerHTML = /*html*/` +
${icon}
+
+
${file.name}
+ ${lower.length > 0 ? /*html*/`
${lower.join(' • ')}
`:''} +
+
${dateRelative}
+
${sizeFormatted}
+
${perms}
+ `; + // Handle access + const accessFile = () => { + if (fileAccessLock) return; + forwardPaths = []; + changePath(elFile.dataset.path); + }; + // Handle clicks + let lastClick = 0; + elFile.addEventListener('click', e => { + e.stopPropagation(); + if (getIsMobileDevice()) return; + // If the control key isn't held + if (!e.ctrlKey) { + // If this is a double-click + if ((Date.now()-lastClick) < 300) { + accessFile(); + } + } + // Update our last click time + lastClick = Date.now(); + // Return if .. + if (file.name == '..') return; + // Handle selection + if (e.shiftKey && lastSelectedIndex >= 0) { + // Select all files between the last selected file and this one + // based on this file's index and lastSelectedIndex + const files = [...$$('#files .fileEntry', elFiles)]; + const start = Math.min(lastSelectedIndex, parseInt(elFile.dataset.index)); + const end = Math.max(lastSelectedIndex, parseInt(elFile.dataset.index)); + for (let j = start; j <= end; j++) { + const el = files[j]; + selectFile(el.dataset.path, false, false, false); + } + } else { + // Update selection based on shift and ctrl key state + const state = e.shiftKey || e.ctrlKey; + selectFile(elFile.dataset.path, !state, state); + } + }); + // Handle keypresses + elFile.addEventListener('keydown', e => { + // If the enter key is pressed + if (e.code === 'Enter') { + accessFile(); + } + // Focus the next file + if (e.code == 'ArrowDown') { + e.preventDefault(); + const next = elFile.nextElementSibling; + if (next) next.focus(); + } + // Focus the previous file + if (e.code == 'ArrowUp') { + e.preventDefault(); + const prev = elFile.previousElementSibling; + if (prev) prev.focus(); + } + // If the escape key is pressed, deselect all files + if (e.code === 'Escape') { + deselectAllFiles(); + } + // Return if .. + if (file.name == '..') return; + // If the spacebar is pressed + if (e.code === 'Space') { + // Prevent scrolling + e.preventDefault(); + // Update selection based on ctrl key state + if (file.name != '..') + selectFile(elFile.dataset.path, !e.ctrlKey, true); + } + }); + elFile.addEventListener('keypress', e => { + if (e.ctrlKey || e.shiftKey || e.altKey) return; + clearTimeout(keypressClearTimeout); + keypressString += e.key; + keypressClearTimeout = setTimeout(() => { + keypressString = ''; + }, 500); + // Get all file elements + const files = [...$$('#files .fileEntry', elFiles)]; + // Put all files before this one at the end of the array + // This causes the search to wrap around to the beginning + const filesWrapped = [ + ...files.slice(parseInt(elFile.dataset.index)+1), + ...files.slice(0, parseInt(elFile.dataset.index)+1) + ]; + // Search through file elements and select the first one + // whose data-name starts with the keypress string + for (const el of filesWrapped) { + if (el.dataset.name.toLowerCase().startsWith(keypressString.toLowerCase())) { + selectFile(el.dataset.path, true, false, true); + break; + } + } + }); + // Handle right-clicks + elFile.addEventListener('contextmenu', e => { + e.stopPropagation(); + e.preventDefault(); + if (getIsMobileDevice()) return; + // If the file is already selected, don't change selection + if (!elFile.classList.contains('selected')) { + selectFile(elFile.dataset.path, true, true); + } + fileContextMenu(); + }); + // Handle mobile touch start + let timeTouchStart = 0; + let initialSelectTimeout = null; + let fileListScrollTopOnStart = 0; + elFile.addEventListener('touchstart', e => { + if (!getIsMobileDevice()) return; + timeTouchStart = Date.now(); + fileListScrollTopOnStart = elFiles.scrollTop; + if (!checkIsSelecting()) { + initialSelectTimeout = setTimeout(() => { + if ((Date.now()-timeTouchStart) > 1000) return; + selectFile(elFile.dataset.path, true, false); + if (navigator.vibrate) navigator.vibrate(2); + }, 400); + } + }); + // Handle mobile touch end + elFile.addEventListener('touchend', e => { + if (!getIsMobileDevice()) return; + clearTimeout(initialSelectTimeout); + if ((Date.now()-timeTouchStart) > 380) return; + if (elFiles.scrollTop != fileListScrollTopOnStart) return; + if (checkIsSelecting()) { + selectFile(elFile.dataset.path, false, true); + } else { + accessFile(); + } + }); + // Handle mobile touch move + elFile.addEventListener('touchmove', e => { + if (!getIsMobileDevice()) return; + clearTimeout(initialSelectTimeout); + timeTouchStart = 0; + }); + return elFile; +} + +/** + * Displays a context menu with actions for the selected file(s). + * @param {HTMLElement} elDisplay An HTML element to display the menu relative to + */ +const fileContextMenu = (elDisplay = null) => { + const allVisibleFiles = [...$$('#files .fileEntry:not(.hidden)', elFiles)]; + if (showHidden) + allVisibleFiles.push(...[...$$('#files .fileEntry.hidden', elFiles)]); + const selectedFiles = [...getSelectedFiles()]; + // We have to delay button clicks to allow time for + // the context menu to lose focus trap + const clickButton = btn => setTimeout(() => btn.click(), 100); + // Selection status shortcuts + const isNoneSelected = selectedFiles.length == 0; + const isSomeSelected = selectedFiles.length > 0; + const isSingleSelected = selectedFiles.length == 1; + const isMultiSelected = selectedFiles.length > 1; + const isAllSelected = selectedFiles.length == allVisibleFiles.length-1; + // Build the menu + const menu = new ContextMenuBuilder(); + if (isNoneSelected) menu.addItem(item => { + item.setIcon($('.icon', btnUpload).innerText) + .setLabel('Upload files...') + .setClickHandler(() => clickButton(btnUpload)) + btnUpload.disabled ? item.disable() : item.enable(); + return item; + }); + if (isNoneSelected) menu.addItem(item => { + item.setIcon($('.icon', btnDirCreate).innerText) + .setLabel('New folder...') + .setClickHandler(() => clickButton(btnDirCreate)) + btnDirCreate.disabled ? item.disable() : item.enable(); + return item; + }); + if (isNoneSelected) menu.addItem(item => { + item.setIcon($('.icon', btnFileCreate).innerText) + .setLabel('New file...') + .setClickHandler(() => clickButton(btnFileCreate)) + btnFileCreate.disabled ? item.disable() : item.enable(); + return item; + }); + if (isNoneSelected) menu.addSeparator(); + if (!btnSelectionCut.disabled) menu.addItem(item => { + item.setIcon($('.icon', btnSelectionCut).innerText) + .setLabel(`Cut`) + .setClickHandler(() => clickButton(btnSelectionCut)) + return item; + }); + if (!btnSelectionCopy.disabled) menu.addItem(item => { + item.setIcon($('.icon', btnSelectionCopy).innerText) + .setLabel(`Copy`) + .setClickHandler(() => clickButton(btnSelectionCopy)) + return item; + }); + if (isNoneSelected) menu.addItem(item => { + item.setIcon($('.icon', btnSelectionPaste).innerText) + .setLabel(`Paste`) + .setClickHandler(() => clickButton(btnSelectionPaste)) + btnSelectionPaste.disabled ? item.disable() : item.enable(); + return item; + }); + if (isSomeSelected) menu.addSeparator(); + if (!btnRename.disabled) menu.addItem(item => { + item.setIcon($('.icon', btnRename).innerText) + .setLabel('Rename...') + .setClickHandler(() => clickButton(btnRename)) + return item; + }); + if (!btnSelectionMoveTo.disabled) menu.addItem(item => { + item.setIcon($('.icon', btnSelectionMoveTo).innerText) + .setLabel(`Move to...`) + .setClickHandler(() => clickButton(btnSelectionMoveTo)) + return item; + }); + if (!btnSelectionCopyTo.disabled) menu.addItem(item => { + item.setIcon($('.icon', btnSelectionCopyTo).innerText) + .setLabel(`Copy to...`) + .setClickHandler(() => clickButton(btnSelectionCopyTo)) + return item; + }); + if (!btnSelectionDelete.disabled) menu.addItem(item => { + item.setIcon($('.icon', btnSelectionDelete).innerText) + .setLabel(`Delete...`) + .setClickHandler(() => clickButton(btnSelectionDelete)) + .setIsDanger(true) + return item; + }); + if (!btnSelectionPerms.disabled) menu.addItem(item => { + item.setIcon($('.icon', btnSelectionPerms).innerText) + .setLabel(`Edit permissions...`) + .setClickHandler(() => clickButton(btnSelectionPerms)) + return item; + }); + menu.addSeparator(); + menu.addItem(item => { + item.setIcon('download') + .setLabel(`Download`) + .setClickHandler(() => clickButton(btnDownload)) + btnDownload.disabled ? item.disable() : item.enable(); + return item; + }); + if (!isLocalhost) menu.addItem(item => { + item.setIcon('share') + .setLabel(`Copy download link...`) + .setClickHandler(() => clickButton(btnShare)) + btnDownload.disabled ? item.disable() : item.enable(); + return item; + }); + if (!isMultiSelected) menu.addSeparator(); + if (!isMultiSelected) menu.addItem(item => { + item.setIcon('conversion_path') + .setLabel('Copy path') + .setClickHandler(() => { + const path = isNoneSelected ? activeConnection.path : selectedFiles[0].dataset.path; + navigator.clipboard.writeText(path); + setStatus(`Copied path to clipboard`); + }) + return item; + }); + if (allVisibleFiles.length > 1) + menu.addSeparator(); + if (!isAllSelected) menu.addItem(item => item + .setIcon('select_all') + .setLabel('Select all') + .setTooltip('Ctrl + A') + .setClickHandler(selectAllFiles)) + if (isSomeSelected) menu.addItem(item => item + .setIcon('select') + .setLabel('Deselect all') + .setTooltip('Ctrl + Shift + A') + .setClickHandler(deselectAllFiles)) + if (isSomeSelected && !isAllSelected) menu.addItem(item => item + .setIcon('move_selection_up') + .setLabel('Invert selection') + .setTooltip('Ctrl + Alt + A') + .setClickHandler(invertFileSelection)) + if (elDisplay) { + const rect = elDisplay.getBoundingClientRect(); + menu.showAtCoords(rect.left, rect.bottom-5); + } else { + menu.showAtCursor(); + } +} + +/** + * Sorts the current file list using `sortType` and `sortDesc`. + */ +const sortFiles = () => { + deselectAllFiles(); + // Define sorting functions + const sortFuncs = { + name: (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }), + size: (a, b) => a.size - b.size, + date: (a, b) => a.date - b.date + }; + // Loop through file sections + const sections = [...$$('.section', elFiles)]; + let i = 0; + for (const section of sections) { + const files = [...$$('#files .fileEntry', section)]; + // Sort files + files.sort((a, b) => { + if (a.dataset.name == '..') return -1; + if (b.dataset.name == '..') return 1; + const aData = { + name: a.dataset.name, + size: parseInt(a.dataset.size), + date: parseInt(a.dataset.date) + }; + const bData = { + name: b.dataset.name, + size: parseInt(b.dataset.size), + date: parseInt(b.dataset.date) + }; + const sortFunc = sortFuncs[sortType]; + return sortFunc(aData, bData) * (sortDesc ? -1 : 1); + }); + // Append files to the file list + for (const file of files) { + file.dataset.index = i; + section.appendChild(file); + i++; + } + } +} + +/** + * Changes the file sort type and re-sorts the file list. + * @param {sortType} type The new sort type + */ +const changeFileSortType = type => { + sortType = type; + window.localStorage.setItem('sortType', sortType); + sortFiles(); +} + +/** + * Changes the file sort direction and re-sorts the file list. + * @param {sortDesc} descending + */ +const changeFileSortDirection = descending => { + sortDesc = descending; + window.localStorage.setItem('sortDesc', sortDesc); + sortFiles(); +} + +/** + * Changes the file view mode and updates the file list. + * @param {viewMode} type The new view mode + */ +const changeFileViewMode = type => { + if (type == 'list' && window.innerWidth < forceTileViewWidth) { + return new PopupBuilder() + .setTitle(`Can't switch to list view`) + .addBodyHTML(`

Your screen is too narrow to switch to list view! Rotate your device or move to a larger screen, then try again.

`) + .addAction(action => action.setLabel('Okay').setIsPrimary(true)) + .show(); + } + viewMode = type; + window.localStorage.setItem('viewMode', viewMode); + elFiles.classList.remove('list', 'tiles'); + elFiles.classList.add(viewMode); + elFileColHeadings.classList.toggle('tiles', type == 'tiles'); +} + +/** Toggles the visibility of hidden files. */ +const toggleHiddenFileVisibility = () => { + showHidden = !showHidden; + window.localStorage.setItem('showHidden', showHidden); + elFiles.classList.toggle('showHidden', showHidden); +} + +/** + * Opens a file preview/editor tab/window. + * @param {string} path The file path. + */ +const openFileViewer = path => { + const url = `/file.html?con=${activeConnectionId}&path=${encodeURIComponent(path)}`; + const isStandalone = + window.matchMedia('(display-mode: standalone)').matches + || window.matchMedia('(display-mode: minimal-ui)').matches; + if (!isStandalone) { + // Open in new tab + window.open(url, '_blank'); + setStatus(`File opened in new tab`); + } else { + // Set size + const viewerWidth = parseInt(window.localStorage.getItem('viewerWidth')) || window.innerWidth; + const viewerHeight = parseInt(window.localStorage.getItem('viewerHeight')) || window.innerHeight; + const coords = { + // Center the new window on top of this one + x: window.screenX + (window.innerWidth - viewerWidth)/2, + y: window.screenY + (window.innerHeight - viewerHeight)/2, + w: viewerWidth, + h: viewerHeight + }; + // Open window + window.open(url, path, `width=${coords.w},height=${coords.h},left=${coords.x},top=${coords.y}`); + setStatus(`File opened in new window`); + } +} + +/** + * Resolves with a download URL for a zip file containing all of the files and directories specified, or `false` if an error occurred. + * @param {string[]} paths An array of file and/or directory paths + * @param {string} rootPath The directory path to start at inside the zip file - leave undefined to use `'/'` + * @returns {Promise} + */ +const getZipDownloadUrl = async(paths, rootPath = '/') => { + const pathsJson = JSON.stringify(paths); + if ((pathsJson.length+rootPath.length) > 1900) { + return setStatus(`Error: Too many selected paths for zip download`, true); + } + setStatus(`Getting zip file download URL...`); + const res = await api.get('files/get/multi/url', { + paths: pathsJson, + rootPath: rootPath + }); + if (res.error) { + return setStatus(`Error: ${res.error}`, true); + } + if (res.download_url) { + return res.download_url; + } + return false; +} + +/** + * Starts a zip file download for all of the paths specified. + * @param {string[]} paths An array of file and/or directory paths + * @param {string} [rootPath='/'] The directory path to start at inside the zip file + */ +const downloadZip = async(paths, rootPath = '/') => { + const url = await getZipDownloadUrl(paths, rootPath); + if (url) { + downloadUrl(url); + setStatus(`Zip file download started`); + } +} + +/** + * Updates the disabled/enabled state of all control buttons depending on the currently selected file entries. + */ +const updateDirControls = () => { + const selectedFiles = $$('.selected', elFiles); + btnSelectionCut.disabled = true; + btnSelectionCopy.disabled = true; + btnSelectionPaste.disabled = true; + btnRename.disabled = true; + btnSelectionMoveTo.disabled = true; + btnSelectionCopyTo.disabled = true; + btnSelectionDelete.disabled = true; + btnSelectionPerms.disabled = true; + if (isSearching) { + btnDownload.disabled = true; + btnShare.disabled = true; + } + btnDeselectAll.style.display = 'none'; + // When no files are selected + if (selectedFiles.length == 0) { + btnDirMenu.classList.remove('info'); + // When files are selected + } else { + btnDirMenu.classList.add('info'); + // When a single file is selected + if (selectedFiles.length == 1) { + btnRename.disabled = false; + } + btnSelectionCut.disabled = false; + btnSelectionCopy.disabled = false; + btnSelectionMoveTo.disabled = false; + btnSelectionCopyTo.disabled = false; + btnSelectionDelete.disabled = false; + btnSelectionPerms.disabled = false; + btnDownload.disabled = false; + btnShare.disabled = false; + btnDeselectAll.style.display = ''; + } + // When there are files in the clipboard + if (selectionClipboard.length > 0 && !isSearching) { + btnSelectionPaste.disabled = false; + } +} + +/** + * An array of all selected file elements. + * @returns {HTMLElement[]} + */ +const getSelectedFiles = () => [...$$('.selected', elFiles)]; + +/** + * Updates the selected state of the file element with the specified path. + * @param {string} path The path of the file to select in the list + * @param {boolean} [deselectOthers] If `true`, other files will be deselected - defaults to `true` + * @param {boolean} [toggle] If `true`, toggle the selected state of this file - defaults to `false` + * @param {boolean} [focus] If `true`, focus this file element in the list - defaults to `false` + */ +const selectFile = (path, deselectOthers = true, toggle = false, focus = false) => { + const el = $(`.fileEntry[data-path="${path}"]`, elFiles); + if (!el) return; + if (el.dataset.name == '..') return; + const isSelected = el.classList.contains('selected'); + if (deselectOthers) deselectAllFiles(); + if (toggle) + el.classList.toggle('selected', !isSelected); + else + el.classList.add('selected'); + if (focus) el.focus(); + deselectHiddenFiles(); + lastSelectedIndex = parseInt(el.dataset.index); + updateDirControls(); +} + +/** + * Selects all files in the file list, excluding hidden ones if they aren't visible. + */ +const selectAllFiles = () => { + const files = [...$$('.fileEntry', elFiles)]; + for (const el of files) { + if (el.dataset.name == '..') continue; + el.classList.add('selected'); + } + deselectHiddenFiles(); + lastSelectedIndex = parseInt($('.fileEntry.selected:last-child', elFiles).dataset.index || -1); + updateDirControls(); +} + +/** + * Deselects all files in the file list. + */ +const deselectAllFiles = () => { + const selected = getSelectedFiles(); + for (const el of selected) { + el.classList.remove('selected'); + } + lastSelectedIndex = -1; + updateDirControls(); +} + +/** + * Deselects all invisible files in the file list. Nothing will happen if hidden files are visible. + */ +const deselectHiddenFiles = () => { + if (showHidden) return; + const hidden = [...$$('.hidden', elFiles)]; + for (const el of hidden) { + el.classList.remove('selected'); + } + updateDirControls(); +} + +/** + * Inverts the current file selection, only including visible files. + */ +const invertFileSelection = () => { + const files = [...$$('#files .fileEntry', elFiles)]; + files.shift(); + for (const el of files) { + el.classList.toggle('selected'); + } + lastSelectedIndex = parseInt($('#files .fileEntry:last-child', elFiles).dataset.index) || -1; + deselectHiddenFiles(); + updateDirControls(); +} + +/** + * Returns `true` if there are currently files selected, `false` otherwise. + * @returns {boolean} + */ +const checkIsSelecting = () => { + const selected = getSelectedFiles(); + return selected.length > 0; +} + +/** + * Opens a dialog prompting the user to create a directory. + */ +const createDirectoryDialog = () => { + const el = document.createElement('div'); + el.innerHTML = /*html*/` +
+ +
+ `; + const inputDirName = $('#inputDirName', el); + const popup = new PopupBuilder() + .setTitle('New folder') + .addBody(el) + .addAction(action => action + .setIsPrimary(true) + .setLabel('Create') + .setClickHandler(async() => { + const name = inputDirName.value; + if (!name) return; + const path = `${activeConnection.path}/${name}`; + const data = await api.post('directories/create', { path: path }); + if (data.error) { + setStatus(`Error: ${data.error}`, true); + } else { + await changePath(); + selectFile(data.path, true, false, true); + } + })) + .addAction(action => action.setLabel('Cancel')) + .show(); + inputDirName.focus(); + inputDirName.addEventListener('keydown', e => { + if (e.key === 'Enter') { + $('.btn:first-of-type', popup.el).click(); + } + }); +} + +/** + * Opens a dialog prompting the user to rename the file with the specified path. + * @param {string} path The file path + */ +const renameFileDialog = async(path, shouldReload = true) => new Promise(resolve => { + const el = document.createElement('div'); + const currentName = path.split('/').pop(); + el.innerHTML = /*html*/` +
+ +
+ `; + const input = $('#inputFileName', el); + const popup = new PopupBuilder() + .setTitle(`Rename file`) + .addBody(el) + .addAction(action => action + .setIsPrimary(true) + .setLabel('Rename') + .setClickHandler(async() => { + popup.setOnHide(() => {}); + const name = input.value; + if (!name) return resolve(path); + const pathOld = path; + const dir = pathOld.split('/').slice(0, -1).join('/'); + let pathNew = `${dir}/${name}`; + if (pathNew == pathOld) return resolve(path); + // Check if the new path exists + if (await checkFileExists(pathNew)) { + if ((await fileConflictDialog(pathNew, false, true)).type == 'skip') { + setStatus(`Rename cancelled`); + return resolve(); + } + pathNew = await getAvailableFileName(dir, name); + } + const data = await api.put('files/move', { + pathOld, pathNew + }); + if (data.error) { + setStatus(`Error: ${data.error}`, true); + } else if (shouldReload) { + const pathNewDir = data.pathNew.split('/').slice(0, -1).join('/'); + await changePath(pathNewDir); + selectFile(data.pathNew, true, false, true); + } + resolve(data.pathNew || path); + })) + .addAction(action => action.setLabel('Cancel')) + .setOnHide(() => resolve(path)) + .show(); + input.focus(); + input.select(); + input.addEventListener('keydown', e => { + if (e.key === 'Enter') { + $('.btn:first-of-type', popup.el).click(); + } + }); +}); + +/** + * Opens a dialog prompting the user to select a directory with an interactive browser. + * @param {string} [startPath] The directory to start in + * @param {string} [title] The popup title + * @param {string} [actionLabel] The label of the confirm button + * @returns {Promise} A promise resolving to the selected directory path, or `null` if cancelled + */ +const selectDirDialog = async(startPath = activeConnection.path, title = 'Select folder', actionLabel = 'Select') => new Promise(resolve => { + const el = document.createElement('div'); + el.innerHTML = /*html*/` +
+
+ + +
+
+
+ `; + const input = $('#inputDirPath', el); + const btnGo = $('.btn.go', el); + const elFolders = $('.folders', el); + const loadFolders = async dir => { + elFolders.innerHTML = ''; + const data = await api.get('directories/list', { + path: dir, dirsOnly: true + }); + const subDirs = data.list || []; + subDirs.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + if (dir != '/') subDirs.unshift({ name: '..' }); + for (const subDir of subDirs) { + const elDir = document.createElement('button'); + elDir.classList = 'btn fileEntry row gap-10'; + if (subDir.name != '..' && subDir.name.substring(0, 1) === '.') + elDir.classList.add('hidden'); + let subDirPath = path = `${dir}/${subDir.name}`; + elDir.innerHTML = /*html*/` +
folder
+
+
${subDir.name}
+
+ `; + elDir.addEventListener('click', () => { + loadFolders(subDirPath); + }); + elFolders.appendChild(elDir); + } + if (data.path) input.value = data.path; + }; + btnGo.addEventListener('click', () => loadFolders(input.value || startPath || '/')); + btnGo.click(); + input.addEventListener('keydown', e => { + if (e.key === 'Enter') { + btnGo.click(); + } + }); + new PopupBuilder() + .setTitle(title) + .addBody(el) + .addAction(action => action + .setIsPrimary(true) + .setLabel(actionLabel) + .setClickHandler(() => resolve(input.value))) + .addAction(action => action.setLabel('Cancel')) + .setOnHide(() => resolve(null)) + .show(); +}); + +/** + * Moves files from their original directories into a single new directory while keeping their names. + * @param {string} newDirPath The new directory + * @param {string[]} filePaths An array of file paths to move + * @returns {Promise} An array of new paths of the files successfully moved, or `null` if no files were moved + */ +const moveFiles = async(newDirPath, filePaths) => { + // Loop through selected files + const newPaths = []; + let i = 0; + let replaceStatus = { type: 'skip', all: false }; + for (const pathOld of filePaths) { + const name = pathOld.split('/').pop(); + let pathNew = `${newDirPath}/${name}`; + if (pathOld == pathNew) continue; + setStatus(`Moving file: ${pathOld}`, false, Math.round((i/filePaths.length)*100)); + i++; + // Check if the new path exists + // If it does, prompt the user to replace it + if (await checkFileExists(pathNew)) { + if (!replaceStatus.all) { + replaceStatus = await fileConflictDialog(pathNew, true, true); + } + if (replaceStatus.type == 'skip') { + setStatus(`File move skipped`); + continue; + } + if (replaceStatus.type == 'replace') { + const resDelete = await deleteFile(pathNew); + if (resDelete.error) return; + } + if (replaceStatus.type == 'rename') { + pathNew = await getAvailableFileName(newDirPath, name); + } + } + const data = await api.put('files/move', { + pathOld, pathNew + }); + if (data.error) { + setStatus(`Error: ${data.error}`, true); + break; + } + const el = $(`#files .fileEntry[data-path="${pathOld}"]`, elFiles); + if (el) el.remove(); + newPaths.push(data.pathNew); + } + if (newPaths.length > 0) { + setStatus(`Moved ${newPaths.length} file(s) to ${newDirPath}`); + return newPaths; + } + return null; +} + +/** + * Copies files into the provided directory. + * @param {string} newDirPath The target directory + * @param {string[]} filePaths An array of file paths to copy + * @returns {Promise} An array of new paths of the files successfully copied, or `null` if no files were copied + */ +const copyFiles = async(newDirPath, filePaths) => { + // Loop through selected files + const newPaths = []; + let i = 0; + let replaceStatus = { type: 'skip', all: false }; + for (const pathSource of filePaths) { + const name = pathSource.split('/').pop(); + let pathDest = `${newDirPath}/${name}`; + setStatus(`Copying file: ${pathSource}`, false, Math.round((i/filePaths.length)*100)); + i++; + // Check if the new path exists + // If it does, prompt the user to replace it + if (await checkFileExists(pathDest)) { + if (!replaceStatus.all) { + replaceStatus = await fileConflictDialog(name, true, true); + } + if (replaceStatus.type == 'skip') { + setStatus(`File copy skipped`); + continue; + } + if (replaceStatus.type == 'replace') { + const res = await deleteFile(pathDest); + if (res.error) return; + } + if (replaceStatus.type == 'rename') { + pathDest = await getAvailableFileName(newDirPath, name); + } + } + const data = await api.put('files/copy', { + pathSrc: pathSource, pathDest: pathDest + }); + if (data.error) { + setStatus(`Error: ${data.error}`, true); + return false; + } + newPaths.push(data.pathDest); + } + if (newPaths.length > 0) { + setStatus(`Copied ${newPaths.length} file(s) to ${newDirPath}`); + return newPaths; + } + return false; +} + +/** + * Opens a dialog prompting the user to select a directory to transfer the selected files to. + * @param {boolean} copy If `true`, copy the files instead of moving them + * @returns {Promise} An array of new file paths, or `null` if no files were transferred + */ +const moveFilesDialog = async(copy = false) => { + const selectedPaths = [...getSelectedFiles()].map(el => el.dataset.path); + // Prompt the user to select a directory + const newDirPath = await selectDirDialog(undefined, `${copy ? 'Copy':'Move'} ${selectedPaths.length > 1 ? `${selectedPaths.length} files`:'file'}`, `${copy ? 'Copy':'Move'} here`); + if (!newDirPath) return null; + // Move or copy the files + if (copy) + return copyFiles(newDirPath, selectedPaths); + else + return moveFiles(newDirPath, selectedPaths); +} + +/** + * Prompts the user if they want to skip or replace the current file in the current transfer process, with the additional option of doing this for the all remaining conflicts. + * @param {string} fileName The file's name or path to display to the user + * @returns {Promise<'skip'|'skipAll'|'replace'|'replaceAll'>} One of 4 states representing the user's choice: `skip`, `skipAll`, `replace`, `replaceAll` + */ +const fileConflictDialog = (fileName, allowReplace = true, allowDuplicate = false) => new Promise(resolve => { + const el = document.createElement('div'); + el.innerHTML = ` +

${fileName} already exists. What do you want to do?

+ + `; + const checkbox = $('input', el); + const popup = new PopupBuilder() + .setClickOutside(false) + .setTitle(`File exists`) + .addBody(el); + popup.addAction(action => action + .setLabel('Skip') + .setIsPrimary(true) + .setClickHandler(() => resolve({ type: 'skip', all: checkbox.checked }))); + if (allowReplace) + popup.addAction(action => action + .setLabel('Replace') + .setClickHandler(() => resolve({ type: 'replace', all: checkbox.checked }))); + if (allowDuplicate) + popup.addAction(action => action + .setLabel('Rename') + .setClickHandler(() => resolve({ type: 'rename', all: checkbox.checked }))); + popup.show(); +}); + +/** + * Checks if a file exists on the server, returns `null` if error. + * @param {string} path The file path + */ +const checkFileExists = async path => { + const res = await api.get('files/exists', { path: path }); + if (res.error) return null; + return res.exists ? true : false; +} + +/** + * Appends a number to the end of a file name until it's unique in the specified directory. + * @param {string} dir The directory to check within + * @param {string} name The initial file name + * @returns {Promise} A promise resolving to the new file path + */ +const getAvailableFileName = async(dir, name) => { + let i = 1; + let path = `${dir}/${name}`; + const nameWithoutExt = name.split('.').slice(0, -1).join('.'); + const ext = name.split('.').pop(); + while (await checkFileExists(path)) { + path = `${dir}/${nameWithoutExt}-${i}.${ext}`; + i++; + } + return path; +} + +/** + * Uploads input files to the active server. + * @param {FileSystemHandle[]} inputFiles The input files + */ +const uploadFiles = async inputFiles => { + if (isUploading) return new PopupBuilder() + .setTitle('Upload in progress') + .addBodyHTML('

An upload is already in progress. Wait for it to finish before uploading more files.

') + .addAction(action => action.setIsPrimary(true).setLabel('Okay')) + .show(); + isUploading = true; + let isCancelled = false; + let replaceStatus = { type: 'skip', all: false }; + let dirPath = activeConnection.path; + // Handle status and progress bar + let lastStatusSet = 0; + const setUploadStatus = (text, progress = 0) => { + if ((Date.now()-lastStatusSet) < 500) return; + setStatus(`Cancel | ${text}`, false, progress); + const anchor = $('a', elStatusBar); + anchor.addEventListener('click', e => { + isCancelled = true; + }); + lastStatusSet = Date.now(); + }; + // Sort input files + inputFiles = [...inputFiles]; + inputFiles.sort((a, b) => a.name.localeCompare(b.name)); + // Loop through selected files + let startTime = Date.now(); + let totalBytesUploaded = 0; + const paths = []; + for (const file of inputFiles) { + if (isCancelled) break; + setUploadStatus(`Uploading file: ${file.name}`); + // If the file exists, prompt the user to replace it + let fileName = file.name; + let path = `${dirPath}/${fileName}`; + if (await checkFileExists(path)) { + if (!replaceStatus.all) { + replaceStatus = await fileConflictDialog(fileName, true, true); + } + if (replaceStatus.type == 'skip') { + setStatus(`Upload skipped`); + continue; + } + if (replaceStatus.type == 'replace') { + const resDelete = await deleteFile(path); + if (resDelete.error) return; + } + if (replaceStatus.type == 'rename') { + path = await getAvailableFileName(dirPath, fileName); + fileName = path.split('/').pop(); + } + } + // Make a promise to upload the file + await new Promise(async(resolve, reject) => { + let isUploadComplete = false; + // Get socket key + const resSocketKey = await api.get('key'); + const key = resSocketKey.key; + // Connect to the file append websocket + const url = `${wsProtocol}://${apiHost}/api/sftp/files/append?path=${encodeURIComponent(path)}&key=${key}`; + const ws = new WebSocket(url); + // Resolve with error if the websocket closes or errors + // before the upload is complete + ws.addEventListener('close', () => { + if (!isUploadComplete) { + isUploading = false; + setStatus(`Error: Websocket unexpectedly closed`, true) + resolve('unexpectedClose'); + } + }); + ws.addEventListener('error', (e) => { + if (!isUploadComplete) { + isUploading = false; + setStatus(`Error: Websocket error`, true) + resolve('wsError'); + } + }); + // Handle messages + const messageHandlers = []; + ws.addEventListener('message', e => { + const data = JSON.parse(e.data); + console.log(`Message from upload websocket:`, data); + if (!data.success) { + isUploading = false; + setStatus(`Error: ${data.error}`, true) + resolve('error'); + } + const handler = messageHandlers.shift(); + if (handler) handler(data.success || false); + }); + // Wait for the websocket to open + await new Promise(resolve2 => { + messageHandlers.push(resolve2); + }); + console.log(`Opened websocket: ${url}`); + // Upload the file in chunks + const fileSize = file.size; + const bytesPerChunk = 1024*1024*1; + const chunkCount = Math.ceil(file.size / bytesPerChunk); + for (let i = 0; i < chunkCount; i++) { + if (isCancelled) break; + const startByte = i * bytesPerChunk; + const endByte = Math.min(file.size, (i+1) * bytesPerChunk); + const thisChunkSize = endByte - startByte; + const chunk = file.slice(startByte, endByte); + // Upload the chunk + const res = await new Promise(resolve2 => { + // Resolve when the chunk is uploaded + // and server sends a success message + messageHandlers.push(resolve2); + ws.send(chunk); + }); + if (!res) break; + // Update status with progress + totalBytesUploaded += thisChunkSize; + const bytesUploaded = Math.min((i+1)*bytesPerChunk, fileSize); + const bytesPerSecond = totalBytesUploaded / ((Date.now()-startTime)/1000); + const percentUploaded = Math.round((bytesUploaded/fileSize)*100); + setUploadStatus(`Uploading file: ${fileName} | ${formatSize(bytesUploaded)} of ${formatSize(fileSize)} (${formatSize(bytesPerSecond)}/s)`, percentUploaded); + } + isUploadComplete = true; + ws.close(); + resolve('done'); + }); + if (!isUploading) return; + // If the upload was cancelled, delete the file + if (isCancelled) { + await deleteFile(path); + setStatus(`Upload cancelled`); + break; + } + // Add the path to the list of uploaded files + paths.push(path); + // Add the file to the file list + if (dirPath == activeConnection.path) { + const elExisting = $(`.fileEntry[data-path="${path}"]`, elFiles); + if (elExisting) elExisting.remove(); + const elFile = getFileEntryElement({ + name: fileName, + type: '-', + size: file.size, + modifyTime: Date.now(), + longname: '-' + }, dirPath); + $('.section.files', elFiles).appendChild(elFile); + sortFiles(); + } + } + isUploading = false; + if (paths.length == 0) return; + // Select all new files + for (const path of paths) { + selectFile(path, false, false, true); + } + setStatus(`Uploaded ${paths.length} file(s)`); +} + +/** + * Opens a system file picker and uploads the selected files to the active server. + */ +const uploadFilesPrompt = async() => { + // Prompt user to select files + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + input.click(); + // When files are selected + input.addEventListener('change', () => { + uploadFiles(input.files); + }); +} + +/** + * Deletes a file from the active server. + * @param {string} path The file path + * @param {boolean} refresh If `true`, refresh the file list after deleting the file - defaults to `true` + * @returns {Promise} The API response object + */ +const deleteFile = async(path) => { + const data = await api.delete('files/delete', { path: path }); + if (data.error) { + setStatus(`Error: ${data.error}`, true); + } else { + const elFile = $(`.fileEntry[data-path="${path}"]`, elFiles); + if (elFile) elFile.remove(); + } + return data; +} + +/** + * Deletes a directory from the active server. + * @param {string} path The directory path + * @param {boolean} refresh If `true`, refresh the file list after deleting the directory - defaults to `true` + * @returns {Promise} The API response object + */ +const deleteDirectory = async(path) => { + const data = await api.delete('directories/delete', { path: path }); + if (data.error) { + setStatus(`Error: ${data.error}`, true); + } else { + const elFile = $(`.fileEntry[data-path="${path}"]`, elFiles); + if (elFile) elFile.remove(); + } + return data; +} + +/** + * Shows a context menu containing a set of navigable file paths. + * @param {Event} e The `ContextMenu` event + * @param {HTMLElement} btn The button that was clicked + * @param {string[]} paths An array of paths to show in the menu + * @param {ContextMenuBuilder} menu An existing menu object to add items to + */ +const historyContextMenu = (e, btn, paths, menu = new ContextMenuBuilder()) => { + if (btn.disabled) return; + e.preventDefault(); + paths = JSON.parse(JSON.stringify(paths)).reverse(); + for (let i = 0; i < 10; i++) { + let path = paths[i]; + if (!path) break; + let split = path.split('/'); + let base = split.pop(); + let dir = `/${split.join('/')}/`.replace(/\/\//g, '/'); + menu.addItem(item => { + const html = /*html*/` + ${escapeHTML(dir)}${escapeHTML(base)} + `; + item.elLabel.innerHTML = html; + const span = $('span', item.elLabel); + span.title = html; + item.setClickHandler(() => { + changePath(path, false); + }); + return item; + }); + } + menu.el.style.maxWidth = '100%'; + menu.setIconVisibility(false); + const rect = btn.getBoundingClientRect(); + menu.showAtCoords(rect.left, rect.bottom-5); +} + +btnConnections.addEventListener('click', () => { + const menu = new ContextMenuBuilder(); + const connectionValues = getSortedConnectionsArray(); + for (const connection of connectionValues) { + menu.addItem(option => option + .setLabel(connection.name) + .setIcon('cloud') + .setTooltip(`Click to connect to ${connection.name}
${connection.username}@${connection.host}:${connection.port}
${connection.path}
`) + .setClickHandler(() => { + setActiveConnection(connection.id); + })); + }; + menu.addSeparator(); + menu.addItem(option => option + .setLabel('Manage connections...') + .setIcon('smb_share') + .setClickHandler(connectionManagerDialog)); + menu.addItem(option => option + .setLabel('New connection...') + .setIcon('library_add') + .setClickHandler(addNewConnectionDialog)); + menu.addSeparator().addItem(item => item + .setIcon('code') + .setLabel('SFTP Browser GitHub') + .setClickHandler(() => { + window.open('https://github.com/CyberGen49/sftp-browser'); + })); + const rect = btnConnections.getBoundingClientRect(); + menu.showAtCoords(rect.left, rect.bottom-5); +}); + +btnNavBack.addEventListener('click', () => { + if (backPaths.length > 0) { + forwardPaths.push(activeConnection.path); + changePath(backPaths.pop(), false); + } +}); +btnNavForward.addEventListener('click', () => { + if (forwardPaths.length > 0) { + backPaths.push(activeConnection.path); + changePath(forwardPaths.pop(), false); + } +}); + +btnNavBack.addEventListener('contextmenu', (e) => { + historyContextMenu(e, btnNavBack, backPaths); +}); +btnNavForward.addEventListener('contextmenu', (e) => { + historyContextMenu(e, btnNavForward, forwardPaths); +}); + +inputNavPath.addEventListener('keydown', e => { + if (e.key === 'Enter') { + btnGo.click(); + } +}); +btnGo.addEventListener('click', () => { + changePath(inputNavPath.value || '/'); +}); + +btnPathPopup.addEventListener('click', () => { + const menu = new ContextMenuBuilder() + .addItem(item => item + .setIcon('pin_drop') + .setLabel('Go to path...') + .setClickHandler(() => { + const popup = new PopupBuilder() + .setTitle('Go to path') + .addAction(action => action + .setIsPrimary(true) + .setLabel('Go') + .setClickHandler(() => { + const path = $('#inputGoToPath', popup.el).value || activeConnection.path; + if (path == activeConnection.path) return; + changePath(path); + popup.hide(); + })) + .addAction(action => action.setLabel('Cancel')) + const elBody = $('.body', popup.el); + elBody.innerHTML = /*html*/` +
+ +
+ `; + const input = $('#inputGoToPath', elBody); + input.addEventListener('keydown', e => { + if (e.key === 'Enter') { + $('.btn:first-of-type', popup.el).click(); + } + }); + popup.show(); + setTimeout(() => { + input.focus(); + input.select(); + }, 100); + })); + const pathSplit = activeConnection.path.split('/'); + pathSplit.pop(); + if (pathSplit.length > 0) { + menu.addSeparator(); + let path = ''; + for (const node of pathSplit) { + path += `/${node}`; + menu.addItem(item => item + .setIcon('folder_open') + .setLabel(node || '/') + .setClickHandler(() => { + changePath(path); + })); + } + } + const rect = btnPathPopup.getBoundingClientRect(); + menu.showAtCoords(rect.right, rect.bottom-5); +}); + +btnDirMenu.addEventListener('click', () => { + fileContextMenu(btnDirMenu); +}); + +btnDeselectAll.addEventListener('click', deselectAllFiles); + +btnUpload.addEventListener('click', uploadFilesPrompt); + +btnDirCreate.addEventListener('click', createDirectoryDialog); + +btnRename.addEventListener('click', () => { + renameFileDialog(getSelectedFiles()[0].dataset.path); +}); + +btnSelectionCut.addEventListener('click', () => { + selectionClipboard = [...getSelectedFiles()].map(el => el.dataset.path); + const prevEls = [...$$('.cut', elFiles), ...$$('.copied', elFiles)]; + for (const el of prevEls) el.classList.remove('cut', 'copied'); + for (const path of selectionClipboard) { + const el = $(`#files .fileEntry[data-path="${path}"]`, elFiles); + el.classList.add('cut'); + } + isClipboardCut = true; + setStatus(`Cut ${selectionClipboard.length} file path(s) to selection clipboard`); + updateDirControls(); +}); +btnSelectionCopy.addEventListener('click', () => { + selectionClipboard = [...getSelectedFiles()].map(el => el.dataset.path); + const prevEls = [...$$('.cut', elFiles), ...$$('.copied', elFiles)]; + for (const el of prevEls) el.classList.remove('cut', 'copied'); + for (const path of selectionClipboard) { + const el = $(`#files .fileEntry[data-path="${path}"]`, elFiles); + el.classList.add('copied'); + } + isClipboardCut = false; + setStatus(`Copied ${selectionClipboard.length} file path(s) to selection clipboard`); + updateDirControls(); +}); +btnSelectionPaste.addEventListener('click', async() => { + const newDirPath = activeConnection.path; + if (!newDirPath) return; + // Move files + let newPaths = true; + if (isClipboardCut) { + // Move the files + newPaths = await moveFiles(newDirPath, selectionClipboard); + if (!newPaths) return; + // Clear the clipboard + selectionClipboard = []; + // Copy files + } else { + // Copy the files + newPaths = await copyFiles(newDirPath, selectionClipboard); + if (!newPaths) return; + } + // Reload directory + await changePath(); + // Select the new files + for (const path of newPaths) { + selectFile(path, false, false, true); + } +}); + +btnSelectionMoveTo.addEventListener('click', () => moveFilesDialog(false)); +btnSelectionCopyTo.addEventListener('click', () => moveFilesDialog(true)); + +btnFileCreate.addEventListener('click', async() => { + let dir = activeConnection.path; + let filePath = await getAvailableFileName(dir, 'file.txt'); + const data = await api.post('files/create', { path: filePath }, ''); + if (data.error) { + return setStatus(`Error: ${data.error}`, true); + } + filePath = await renameFileDialog(filePath, false); + console.log(filePath) + await changePath(dir); + selectFile(filePath, true, false, true); +}); + +btnSelectionDelete.addEventListener('click', async() => { + const selected = [...getSelectedFiles()]; + const containsDirs = selected.some(el => el.dataset.type === 'd'); + new PopupBuilder() + .setTitle(`Delete ${selected.length == 1 ? 'file':`${selected.length} files`}`) + .addBodyHTML(` +

Are you sure you want to delete ${selected.length == 1 ? `${selected[0].dataset.name}`:`these files`}?

+ ${containsDirs ? `

+ ${selected.length == 1 ? 'This file is a directory':'Your selection contains directories'}! Deleting ${selected.length == 1 ? 'it':'them'} will also delete everything inside of ${selected.length == 1 ? 'it':'them'}. +

`:''} +

This usually can't be undone!

+ `) + .addAction(action => action + .setIsDanger(true) + .setLabel('Delete') + .setClickHandler(async() => { + let i = 0; + for (const el of selected) { + setStatus(`Deleting file: ${el.dataset.path}`, false, Math.round((i/selected.length)*100)); + let res = null; + if (el.dataset.type === 'd') { + res = await deleteDirectory(el.dataset.path); + } else { + res = await deleteFile(el.dataset.path); + } + i++; + } + setStatus(`Deleted ${selected.length} file(s)`); + updateDirControls(); + })) + .addAction(action => action.setLabel('Cancel')) + .show(); +}); + +btnSelectionPerms.addEventListener('click', async() => { + const selected = [...getSelectedFiles()]; + // File permissions matrix + // Columns are read, write, execute + // Rows are owner, group, other + let permsMatrix = [ + [ 0, 0, 0 ], + [ 0, 0, 0 ], + [ 0, 0, 0 ] + ]; + for (const el of selected) { + const perms = el.dataset.perms.padEnd(10, '-').split(''); + if (perms[1] != '-') permsMatrix[0][0]++; + if (perms[2] != '-') permsMatrix[0][1]++; + if (perms[3] != '-') permsMatrix[0][2]++; + if (perms[4] != '-') permsMatrix[1][0]++; + if (perms[5] != '-') permsMatrix[1][1]++; + if (perms[6] != '-') permsMatrix[1][2]++; + if (perms[7] != '-') permsMatrix[2][0]++; + if (perms[8] != '-') permsMatrix[2][1]++; + if (perms[9] != '-') permsMatrix[2][2]++; + } + const elMatrix = document.createElement('div'); + elMatrix.classList = 'col permsMatrix'; + elMatrix.innerHTML = /*html*/` +
+
+
Read
+
Write
+
Execute
+
+
+
User
+
+ +
+
+ +
+
+ +
+
+
+
Group
+
+ +
+
+ +
+
+ +
+
+
+
Other
+
+ +
+
+ +
+
+ +
+
+ `; + for (let i = 0; i < 3; i++) { + for (let ii = 0; ii < 3; ii++) { + permsMatrix[i][ii] = Math.round(permsMatrix[i][ii] / selected.length); + if (permsMatrix[i][ii] == 1) { + $(`input[data-row="${i+1}"][data-col="${ii+1}"]`, elMatrix).checked = true; + } + } + } + new PopupBuilder() + .setTitle(`Edit file permissions`) + .addBody(elMatrix) + .addAction(action => action + .setIsPrimary(true) + .setLabel('Save') + .setClickHandler(async() => { + // Get permissions number + let str = '-'; + for (let i = 0; i < 3; i++) { + for (let ii = 0; ii < 3; ii++) { + const checkbox = $(`input[data-row="${i+1}"][data-col="${ii+1}"]`, elMatrix); + if (checkbox.checked) { + str += 'rwx'[ii]; + } else { + str += '-'; + } + } + } + let perms = permsStringToNum(str); + // Set permissions + const changedPaths = []; + try { + let i = 0; + for (const file of selected) { + setStatus(`Updating permissions for ${file.dataset.path}...`, false, Math.round((i/selected.length)*100)); + const res = await api.put('files/chmod', { + path: file.dataset.path, + mode: perms + }); + if (res.error) throw new Error(res.error); + changedPaths.push(file.dataset.path); + i++; + } + // Reload directory and select changed files + if (changedPaths.length > 0) { + await changePath(); + for (const file of selected) { + selectFile(file.dataset.path, false, false, true); + } + } + } catch (error) { + setStatus(`Error: ${error}`, true); + } + })) + .addAction(action => action.setLabel('Cancel')) + .show(); +}); + +btnDownload.addEventListener('click', () => { + const selected = [...getSelectedFiles()]; + const rootPath = activeConnection.path; + if (selected.length == 1) { + if (selected[0].dataset.type === 'd') { + downloadZip([ selected[0].dataset.path ], rootPath); + } else { + downloadFile(selected[0].dataset.path); + } + } else if (selected.length > 1) { + downloadZip(selected.map(el => el.dataset.path), rootPath); + } else { + downloadZip([ activeConnection.path ], rootPath); + } +}); + +btnShare.addEventListener('click', async() => { + new PopupBuilder() + .setTitle('Copy download link') + .addBodyHTML(`

This link will allow anyone to download your selected files and folders for the next 24 hours without the need for any credentials. Make sure you aren't sharing anything sensitive!

`) + .addAction(action => action + .setLabel('Copy') + .setIsPrimary(true) + .setClickHandler(async() => { + let url; + let selected = [...getSelectedFiles()]; + const isNoneSelected = selected.length == 0; + const isSingleSelected = selected.length == 1; + const isMultiSelected = selected.length > 1; + if (isNoneSelected) + url = await getZipDownloadUrl([activeConnection.path], activeConnection.path); + else if (isSingleSelected) { + const el = selected[0]; + if (el.dataset.type == 'd') + url = await getZipDownloadUrl([el.dataset.path], activeConnection.path); + else + url = await getFileDownloadUrl(el.dataset.path); + } else if (isMultiSelected) + url = await getZipDownloadUrl(selected.map(el => el.dataset.path), activeConnection.path); + if (url) { + navigator.clipboard.writeText(url); + setStatus(`Copied download link to clipboard`); + } + })) + .addAction(action => action.setLabel('Cancel')) + .show(); +}); +if (isLocalhost) btnShare.style.display = 'none'; + +btnDirView.addEventListener('click', () => { + const menu = new ContextMenuBuilder(); + menu.addItem(item => item + .setIcon(viewMode === 'list' ? 'check' : '') + .setLabel('List') + .setClickHandler(() => changeFileViewMode('list'))); + menu.addItem(item => item + .setIcon(viewMode === 'tiles' ? 'check' : '') + .setLabel('Tiles') + .setClickHandler(() => changeFileViewMode('tiles'))); + menu.addSeparator(); + menu.addItem(item => item + .setIcon(showHidden ? 'check' : '') + .setLabel('Show hidden files') + .setClickHandler(() => toggleHiddenFileVisibility())); + const rect = btnDirView.getBoundingClientRect(); + menu.showAtCoords(rect.left, rect.bottom-5); +}); +elFiles.classList.toggle('showHidden', showHidden); +elFiles.classList.add(viewMode); +elFileColHeadings.classList.toggle('tiles', elFiles.classList.contains('tiles')); + +btnDirSort.addEventListener('click', () => { + const menu = new ContextMenuBuilder(); + menu.addItem(item => item + .setIcon(sortType === 'name' ? 'check' : '') + .setLabel('Name') + .setClickHandler(() => changeFileSortType('name'))); + menu.addItem(item => item + .setIcon(sortType === 'date' ? 'check' : '') + .setLabel('Modified') + .setClickHandler(() => changeFileSortType('date'))); + menu.addItem(item => item + .setIcon(sortType === 'size' ? 'check' : '') + .setLabel('Size') + .setClickHandler(() => changeFileSortType('size'))); + menu.addSeparator(); + menu.addItem(item => item + .setIcon(!sortDesc ? 'check' : '') + .setLabel('Ascending') + .setClickHandler(() => changeFileSortDirection(false))); + menu.addItem(item => item + .setIcon(sortDesc ? 'check' : '') + .setLabel('Descending') + .setClickHandler(() => changeFileSortDirection(true))); + const rect = btnDirSort.getBoundingClientRect(); + menu.showAtCoords(rect.left, rect.bottom-5); +}); + +btnDirSelection.addEventListener('click', () => { + const menu = new ContextMenuBuilder() + .addItem(item => item + .setIcon('select_all') + .setLabel('Select all') + .setTooltip('Ctrl + A') + .setClickHandler(selectAllFiles)) + .addItem(item => item + .setIcon('select') + .setLabel('Deselect all') + .setTooltip('Ctrl + Shift + A') + .setClickHandler(deselectAllFiles)) + .addItem(item => item + .setIcon('move_selection_up') + .setLabel('Invert selection') + .setTooltip('Ctrl + Alt + A') + .setClickHandler(invertFileSelection)); + const rect = btnDirSelection.getBoundingClientRect(); + menu.showAtCoords(rect.left, rect.bottom-5); +}); + +elFiles.addEventListener('dragover', e => { + e.preventDefault(); + e.stopPropagation(); + elFiles.classList.add('dragover'); +}); +elFiles.addEventListener('dragleave', e => { + e.preventDefault(); + e.stopPropagation(); + elFiles.classList.remove('dragover'); +}); +elFiles.addEventListener('drop', e => { + e.preventDefault(); + e.stopPropagation(); + elFiles.classList.remove('dragover'); + const files = []; + for (const file of e.dataTransfer.files) { + if (file.type !== '') { + files.push(file); + } + } + uploadFiles(files); +}); + +elFiles.addEventListener('contextmenu', e => { + e.preventDefault(); + deselectAllFiles(); + fileContextMenu(); +}); + +inputSearch.addEventListener('keydown', e => { + if (e.key === 'Enter') { + btnSearchGo.click(); + } +}); + +btnSearchGo.addEventListener('click', () => { + const value = inputSearch.value.trim(); + if (value) + searchDirectory(activeConnection.path, inputSearch.value); +}); + +btnSearchCancel.addEventListener('click', () => { + if (isSearching) changePath(activeConnection.path); +}); + +window.addEventListener('click', e => { + const matchIds = [ 'controls', 'files', 'filesFiles', 'filesFolders', 'fileColHeadings', 'statusBar' ]; + if (!matchIds.includes(e.target.id)) return; + if (!e.ctrlKey) { + if (getIsMobileDevice()) return; + deselectAllFiles(); + } +}); + +window.addEventListener('keydown', e => { + const elActive = document.activeElement; + const isCtrlF = (e.ctrlKey && e.code == 'KeyF'); + if (elActive.tagName == 'INPUT' && !isCtrlF) return; + let func = (() => { + if (e.ctrlKey) { + if (e.shiftKey) { + // Ctrl + Shift + if (e.code === 'Space') + return () => connectionManagerDialog(); + if (e.code === 'KeyA') + return () => deselectAllFiles(); + } + if (e.altKey) { + // Ctrl + Alt + if (e.code === 'KeyA') + return () => invertFileSelection(); + } + // Ctrl + if (e.code === 'KeyX') + return () => btnSelectionCut.click(); + if (e.code === 'KeyC') + return () => btnSelectionCopy.click(); + if (e.code === 'KeyV') + return () => btnSelectionPaste.click(); + if (e.code === 'KeyA') + return () => selectAllFiles(); + if (e.code === 'KeyR') + return () => btnGo.click(); + if (e.code === 'KeyF') + return () => { + inputSearch.focus(); + inputSearch.select(); + } + } + // Shift + if (e.shiftKey) { + if (e.code === 'KeyD') + return () => btnDownload.click(); + if (e.code === 'KeyH') + return () => toggleHiddenFileVisibility(); + if (e.code === 'KeyN') + return () => btnDirCreate.click(); + if (e.code === 'KeyU') + return () => btnUpload.click(); + if (e.code === 'KeyM') + return () => btnSelectionMoveTo.click(); + if (e.code === 'KeyC') + return () => btnSelectionCopyTo.click(); + } + // Alt + if (e.altKey) { + if (e.code === 'ArrowLeft') + return () => btnNavBack.click(); + if (e.code === 'ArrowRight') + return () => btnNavForward.click(); + } + // No modifiers + if (e.code === 'F2') + return () => btnRename.click(); + if (e.code === 'Delete') + return () => btnSelectionDelete.click(); + })(); + if (func) { + console.log(`Handling press of ${e.code}`); + e.preventDefault(); + func(); + } +}); + +window.addEventListener('resize', () => { + if (window.innerWidth < forceTileViewWidth) { + changeFileViewMode('tiles'); + } +}); + +window.addEventListener('load', async () => { + if ('serviceWorker' in navigator) { + const registration = await navigator.serviceWorker.register('/worker.js'); + console.log('Service Worker registered with scope:', registration.scope); + } + + // Check if the URL matches /connect/:connectionId + const pathMatch = window.location.pathname.match(/^\/connect\/([^/]+)$/); + if (pathMatch) { + const connectionId = pathMatch[1]; + const connectionDetails = await fetchConnectionDetails(connectionId); + if (connectionDetails) { + // Clear all existing connections to prevent duplicates + for (const id in connections) { + delete connections[id]; + } + // Add the new connection using the connectionId as the key + connections[connectionId] = { + name: `${connectionDetails.username}@${connectionDetails.host}`, + host: connectionDetails.host, + port: connectionDetails.port || 22, + username: connectionDetails.username, + password: connectionDetails.password || '', + key: connectionDetails.privateKey || '', + path: '/minecraft' + }; + saveConnections(); + setActiveConnection(connectionId); + } else { + // Show connection manager if connection fails + connectionManagerDialog(); + } + } else { + // Existing logic for query parameters + const params = new URLSearchParams(window.location.search); + const connection = connections[params.get('con') || '0']; + if (connection) { + setActiveConnection(params.get('con'), params.get('path')); + } else { + connectionManagerDialog(); + } + } + + window.dispatchEvent(new Event('resize')); +}); + +// Dynamically update file list relative dates +setInterval(() => { + if (document.hidden) return; + const els = $$('.fileEntry[data-date]', elFiles); + if (els.length > 1000) return; + for (const el of els) { + const timestamp = parseInt(el.dataset.date); + if (!timestamp) continue; + const elDateMain = $('.date', el); + requestAnimationFrame(() => { + const newText = getRelativeDate(timestamp); + if (elDateMain.innerText == newText) return; + elDateMain.innerText = newText; + }); + } +}, 1000*60); \ No newline at end of file diff --git a/web/assets/main.css b/web/assets/main.css new file mode 100644 index 0000000..3276fdc --- /dev/null +++ b/web/assets/main.css @@ -0,0 +1,406 @@ + +* { + min-width: 0px; + min-height: 0px; +} + +.darkmuted { + /* Background and foreground */ + --b0: hsl(215, 25%, 8%); + --b1: hsl(215, 25%, 12%); + --b2: hsl(215, 25%, 16%); + --b3: hsl(215, 25%, 20%); + --b4: hsl(215, 25%, 30%); + --b5: hsl(215, 25%, 40%); + --f4: hsl(215, 25%, 55%); + --f3: hsl(215, 25%, 70%); + --f2: hsl(215, 25%, 85%); + --f1: white; +} +.btn, .textbox { + border-radius: 8px; +} +.btn.iconOnly .icon { + margin: 0px; +} +.btn:focus-visible { + outline: 2px solid var(--f2); +} +.textbox, +.textbox.textarea > textarea { + padding: 0px 12px; + padding-top: 2px; +} +.textbox.textarea { + padding: 8px 0px; +} +.popup { + border-radius: 16px; +} +.context { + border-radius: 12px; + padding: 4px; + gap: 4px; +} +.context > .item { + border-radius: 8px; +} +label.selectOption input[type="radio"], +label.selectOption input[type="checkbox"] { + margin-top: -0.05em; +} +.tooltip { + /* padding: 6px 12px; */ + padding: 8px 12px 5px 12px; + border-radius: 8px; +} +.toastOverlay > .toast > .body { + /* padding: 15px 5px; */ + padding-top: 18px; +} +.popup > .body { + padding-top: 5px; +} +body { + --fontDefault: 'Comfortaa'; +} + +#main { + position: absolute; + top: 0px; + left: 0px; + width: 100%; + height: 100%; + overflow: hidden; +} + +#navbar { + padding: 8px 16px; + border-bottom: 1px solid var(--b3); +} + +#fileHeader .icon { + font-family: 'Material Symbols Filled Rounded'; + font-size: 28px; + color: var(--f3); + user-select: none; +} + +#fileHeader .path { + font-size: 14px; + color: var(--f4); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +#fileHeader .name { + font-size: 18px; + color: var(--f1); + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +#controls { + padding: 8px 16px; + border: 1px solid var(--b3); + border-width: 0px 0px 1px 0px; + overflow-x: auto; + overflow-y: hidden; +} +#controls::-webkit-scrollbar { + height: 3px; + background: transparent; +} +#controls::-webkit-scrollbar-thumb { + background: var(--b4); +} +#controls::-webkit-scrollbar-thumb:hover { + background: var(--b5); +} +#controls .sep { + width: 1px; + height: 20px; + margin: 0px 5px; + background-color: var(--b3); +} +#controls .selectOption { + font-size: 14px; + color: var(--f2); + margin-top: 5px; +} +#controls .selectOption input { + font-size: 28px; +} + +#fileColHeadings { + padding: 10px 24px 6px 24px; + font-weight: bold; + color: var(--b5); + font-size: 14px; + user-select: none; + overflow-y: scroll; + scrollbar-gutter: stable; +} +#fileColHeadings.tiles { + display: none; +} +#files { + overflow-x: hidden; + overflow-y: auto; + height: 0px; + padding: 4px; + padding-top: 2px; + scrollbar-gutter: stable; +} +#files:not(.tiles) > .heading { + display: none; +} +#files.tiles > .heading { + display: block; + padding: 12px 20px 4px 20px; + font-weight: bold; + color: var(--b5); + font-size: 14px; + user-select: none; + flex-shrink: 0; +} +#files > .section { + display: flex; + flex-direction: column; + flex-shrink: 0; + gap: 2px; +} +#files.tiles > .section { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); +} +.fileEntry { + height: auto; + padding: 6px 20px 5px 20px; + justify-content: flex-start; + --bg: transparent; + --fg: var(--f2); + --bgHover: var(--b2); + --bgActive: var(--b3); + font-weight: normal; + text-align: left; + gap: 10px; +} +.fileEntry.search { + padding-top: 9px; + padding-bottom: 7px; +} +.fileEntry.search .nameCont .path { + margin-bottom: -2px; +} +#files.tiles .fileEntry { + height: auto; + padding-top: 11px; + padding-bottom: 9px; + gap: 12px; +} +#files:not(.showHidden) .fileEntry.hidden { + display: none; +} +.fileEntry > .icon { + color: var(--f3); + font-family: 'Material Symbols Filled Rounded'; +} +#files.tiles .fileEntry > .icon { + font-size: 32px; +} +.fileEntry > .nameCont { + gap: 4px; +} +.fileEntry > .nameCont .name { + color: var(--f1); +} +.fileEntry > .nameCont .lower { + display: none; + font-size: 14px; + color: var(--f3); +} +#files.tiles .fileEntry > .nameCont .lower { + display: block; +} +.fileEntry * { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +#files.tiles .fileEntry > :not(.icon):not(.nameCont) { + display: none; +} +.fileEntry > .date, +#fileColHeadings > .date { + width: 150px; +} +.fileEntry > .size, +#fileColHeadings > .size { + width: 100px; +} +.fileEntry > .perms, +#fileColHeadings > .perms { + width: 100px; +} +.fileEntry.selected { + --bg: var(--blue0); + --fg: var(--f1); + --bgHover: var(--blue1); + --bgActive: var(--blue2); +} +.fileEntry.selected > .icon, +.fileEntry.selected > .nameCont .lower { + color: var(--f1); +} +.fileEntry.hidden:not(.selected) > * { + opacity: 0.5; +} +.fileEntry.cut:not(.selected) { + opacity: 0.5; +} + +.permsMatrix .header, +.permsMatrix .cell { + width: 70px; + height: 40px; +} +.permsMatrix .cell { + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} +.permsMatrix .header { + font-size: 14px; + color: var(--f3); + display: flex; +} +.permsMatrix .header.top { + height: 20px; + text-align: center; + justify-content: center; + padding-bottom: 3px; +} +.permsMatrix .header.left { + width: 50px; + text-align: right; + justify-content: flex-end; + align-items: center; +} + +#preview { + overflow: auto; +} +#preview.image, +#preview.video { + background: black; +} +#preview.audio { + padding: 10px; +} +#preview.image { + justify-content: initial; + align-items: initial; + cursor: grab; +} +#preview img { + flex-shrink: 0; + margin: auto; +} +#preview video { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} +#preview audio { + width: 500px; +} +#preview .CodeMirror { + width: 1200px; + height: 100%; + border: none; + border-left: 1px solid var(--b2); + border-right: 1px solid var(--b2); +} +#preview.markdown .rendered { + width: 1200px; + padding: 20px; + margin: auto; +} + +#progressBar { + border-radius: 0px; + margin: 0px; + height: 3px; + display: none; +} +#progressBar.visible { + display: block; +} + +#statusBar { + padding: 8px 10px 6px 10px; + font-size: 15px; + color: var(--f4); + border-top: 1px solid var(--b3); + line-height: 1.2; +} +#statusBar.error { + color: var(--red2); +} + +#connectionManager .entry > .icon { + font-family: 'Material Symbols Outlined Rounded'; + font-size: 32px; + color: var(--f3); + user-select: none; +} +#connectionManager .entry > .row { + gap: 8px 20px; +} + +.moveFilesPicker .folders { + border-radius: 12px; + padding: 4px; + gap: 2px; + border: 1px solid var(--b3); + height: 300px; + overflow-y: auto; +} + +/* 540px */ +@media (max-width: 640px) { + .atLeast640px { + display: none; + } +} +@media (min-width: 641px) { + .atMost640px { + display: none; + } +} +/* 800px */ +@media (max-width: 800px) { + .atLeast800px { + display: none; + } +} +@media (min-width: 801px) { + .atMost800px { + display: none; + } +} +/* 1000px */ +@media (max-width: 1000px) { + .atLeast1000px { + display: none; + } +} +@media (min-width: 1001px) { + .atMost1000px { + display: none; + } +} \ No newline at end of file diff --git a/web/assets/main.js b/web/assets/main.js new file mode 100644 index 0000000..13e503e --- /dev/null +++ b/web/assets/main.js @@ -0,0 +1,325 @@ + +const elProgressBar = $('#progressBar'); +const elStatusBar = $('#statusBar'); +const isElectron = window && window.process && window.process.type; +/** + * The hostname of the API + * @type {string} + */ +let apiHost = window.localStorage.getItem('apiHost') || window.location.host; +let isLocalhost = window.location.hostname == 'localhost'; +let httpProtocol = isLocalhost ? 'http' : 'https'; +let wsProtocol = httpProtocol == 'http' ? 'ws' : 'wss'; +/** An object of saved connection information */ +let connections = JSON.parse(window.localStorage.getItem('connections')) || {}; +/** The current active connection */ +let activeConnection = null; +/** The ID of the current active connection */ +let activeConnectionId = null; + +/** + * Checks if two HTML elements overlap + * @param {HTMLElement} el1 The first element + * @param {HTMLElement} el2 The second element + * @returns {boolean} True if the elements overlap, false otherwise + */ +function checkDoElementsOverlap(el1, el2) { + const rect1 = el1.getBoundingClientRect(); + const rect2 = el2.getBoundingClientRect(); + + const overlap = !(rect1.right < rect2.left || + rect1.left > rect2.right || + rect1.bottom < rect2.top || + rect1.top > rect2.bottom); + + return overlap; +} + +function permsStringToNum(str) { + let temp; + let result = ''; + const user = str.substring(1, 4); + const group = str.substring(4, 7); + const other = str.substring(7, 10); + for (const perm of [user, group, other]) { + temp = 0; + if (perm.includes('r')) temp += 4; + if (perm.includes('w')) temp += 2; + if (perm.includes('x')) temp += 1; + result += temp; + } + return result; +} + +const downloadUrl = (url, name) => { + const a = document.createElement('a'); + a.href = url; + a.download = name || ''; + a.click(); +} + +const getFileExtInfo = (path, size) => { + const ext = path.split('.').pop().toLowerCase(); + const types = { + image: { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + svg: 'image/svg', + webp: 'image/webp' + }, + video: { + mp4: 'video/mp4', + webm: 'video/webm', + ogv: 'video/ogg' + }, + audio: { + mp3: 'audio/mpeg', + wav: 'audio/wav' + }, + text: { + txt: 'text/plain', + html: 'text/html', + css: 'text/css', + js: 'text/javascript', + json: 'application/json', + py: 'text/x-python', + php: 'text/x-php', + java: 'text/x-java-source', + c: 'text/x-c', + cpp: 'text/x-c++', + cs: 'text/x-csharp', + rb: 'text/x-ruby', + go: 'text/x-go', + rs: 'text/x-rust', + swift: 'text/x-swift', + sh: 'text/x-shellscript', + bat: 'text/x-batch', + ps1: 'text/x-powershell', + sql: 'text/x-sql', + yaml: 'text/yaml', + yml: 'text/yaml', + ts: 'text/typescript', + properties: 'text/x-properties', + toml: 'text/x-toml', + cfg: 'text/x-properties', + conf: 'text/x-properties', + ini: 'text/x-properties', + log: 'text/x-log' + }, + markdown: { + md: 'text/markdown', + markdown: 'text/markdown' + } + }; + // https://codemirror.net/5/mode/index.html + // https://github.com/codemirror/codemirror5/tree/master/mode + const getKeywordsObject = keywords => { + const obj = {}; + for (const word of keywords) obj[word] = true; + return obj; + } + const codeMirrorModes = { + html: 'htmlmixed', + css: 'css', + js: 'javascript', + json: { + name: 'javascript', + json: true + }, + py: 'python', + php: 'php', + java: { + name: 'clike', + keywords: getKeywordsObject('abstract assert boolean break byte case catch char class const continue default do double else enum exports extends final finally float for goto if implements import instanceof int interface long native new package private protected public return short static strictfp super switch synchronized this throw throws transient try void volatile while'.split(' ')) + }, + c: { + name: 'clike', + keywords: getKeywordsObject('auto break case char const continue default do double else enum extern float for goto if int long register return short signed sizeof static struct switch typedef union unsigned void volatile while'.split(' ')) + }, + cpp: { + name: 'clike', + keywords: getKeywordsObject('asm auto break case catch char class const const_cast continue default delete do double dynamic_cast else enum explicit export extern false float for friend goto if inline int long mutable namespace new operator private protected public register reinterpret_cast return short signed sizeof static static_cast struct switch template this throw true try typedef typeid typename union unsigned using virtual void volatile wchar_t while'.split(' ')), + useCPP: true + }, + cs: { + name: 'clike', + keywords: getKeywordsObject('abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while'.split(' ')), + }, + rb: 'ruby', + go: 'go', + rs: 'rust', + swift: 'swift', + sh: 'shell', + ps1: 'powershell', + sql: 'sql', + yaml: 'yaml', + yml: 'yaml', + ts: 'javascript', + properties: 'properties', + toml: 'toml', + cfg: 'properties', + conf: 'properties', + ini: 'properties', + md: 'gfm', + markdown: 'gfm' + }; + const maxSizes = { + image: 1024*1024*16, + video: 1024*1024*16, + audio: 1024*1024*16, + text: 1024*1024*2, + markdown: 1024*1024*2 + }; + const data = { isViewable: false, type: null, mime: null }; + if (!path.match(/\./g)) { + data.isViewable = true; + data.type = 'text'; + data.mime = 'application/octet-stream'; + data.codeMirrorMode = null; + } else { + for (const type in types) { + if (types[type][ext]) { + data.isViewable = true; + data.type = type; + data.mime = types[type][ext]; + data.codeMirrorMode = codeMirrorModes[ext] || null; + break; + } + } + } + if (data.isViewable && size) { + if (size > maxSizes[data.type]) { + data.isViewable = false; + } + } + return data; +} + +/** + * Returns a boolean representing if the device has limited input capabilities (no hover and coarse pointer) + */ +const getIsMobileDevice = () => { + const isPointerCoarse = window.matchMedia('(pointer: coarse)').matches; + const isHoverNone = window.matchMedia('(hover: none)').matches; + return isPointerCoarse && isHoverNone; +} + +/** + * Returns an object of headers for API requests that interface with the current active server + */ +const getHeaders = () => { + const headers = { + 'sftp-host': activeConnection.host, + 'sftp-port': activeConnection.port, + 'sftp-username': activeConnection.username + }; + if (activeConnection.password) + headers['sftp-password'] = encodeURIComponent(activeConnection.password); + if (activeConnection.key) + headers['sftp-key'] = encodeURIComponent(activeConnection.key); + return headers; +} + +const api = { + /** + * Makes requests to the API + * @param {'get'|'post'|'put'|'delete'} method The request method + * @param {string} url The sub-URL of an API endpoint + * @param {object|undefined} params An object of key-value query params + * @param {*} body The body of the request, if applicable + * @param {callback|undefined} onProgress A callback function that gets passed an Axios progress event + * @returns {object} An object representing the response data or error info + */ + request: async (method, url, params, body = null, onProgress = () => {}, responseType = 'json') => { + url = `${httpProtocol}://${apiHost}/api/sftp/${url}`; + try { + const opts = { + params, headers: getHeaders(), + onUploadProgress: onProgress, + onDownloadProgress: onProgress, + responseType: responseType + }; + let res = null; + if (method == 'get' || method == 'delete') { + res = await axios[method](url, opts); + } else { + res = await axios[method](url, body, opts); + } + //console.log(`Response from ${url}:`, res.data); + return res.data; + } catch (error) { + if (responseType !== 'json') { + console.error(error); + return null; + } + if (error.response?.data) { + console.warn(`Error ${error.response.status} response from ${url}:`, error.response.data); + return error.response.data; + } else { + console.error(error); + return { + success: false, + error: `${error}` + }; + } + } + }, + get: (url, params) => api.request('get', url, params), + post: (url, params, body) => api.request('post', url, params, body), + put: (url, params, body) => api.request('put', url, params, body), + delete: (url, params) => api.request('delete', url, params) +}; + +/** + * Updates the bottom status bar. + * @param {string} html The status text + * @param {boolean} isError If `true`, turns the status red + * @param {number|null} progress A 0-100 whole number to be used for the progress bar, or `null` to hide it + * @returns {boolean} The negation of `isError` + */ +const setStatus = (html, isError = false, progress = null) => { + elStatusBar.innerHTML = html; + elStatusBar.classList.toggle('error', isError); + elProgressBar.classList.remove('visible'); + if (progress !== null) { + elProgressBar.classList.add('visible'); + if (progress >= 0 && progress <= 100) + elProgressBar.value = progress; + else + elProgressBar.removeAttribute('value'); + } + return !isError; +} + +/** + * Resolves with a download URL for a single file, or `false` if an error occurred. + * @param {string} path The file path + * @returns {Promise} + */ +const getFileDownloadUrl = async path => { + setStatus(`Getting single file download URL...`); + const res = await api.get('files/get/single/url', { + path: path + }); + if (res.error) { + return setStatus(`Error: ${res.error}`, true); + } + if (res.download_url) { + return res.download_url; + } + return false; +} + +/** + * Starts a single-file download. + * @param {string} path The file path + */ +const downloadFile = async path => { + const url = await getFileDownloadUrl(path); + if (url) { + downloadUrl(url); + setStatus(`Single file download started`); + } +} \ No newline at end of file diff --git a/web/assets/main.js_ b/web/assets/main.js_ new file mode 100644 index 0000000..13e503e --- /dev/null +++ b/web/assets/main.js_ @@ -0,0 +1,325 @@ + +const elProgressBar = $('#progressBar'); +const elStatusBar = $('#statusBar'); +const isElectron = window && window.process && window.process.type; +/** + * The hostname of the API + * @type {string} + */ +let apiHost = window.localStorage.getItem('apiHost') || window.location.host; +let isLocalhost = window.location.hostname == 'localhost'; +let httpProtocol = isLocalhost ? 'http' : 'https'; +let wsProtocol = httpProtocol == 'http' ? 'ws' : 'wss'; +/** An object of saved connection information */ +let connections = JSON.parse(window.localStorage.getItem('connections')) || {}; +/** The current active connection */ +let activeConnection = null; +/** The ID of the current active connection */ +let activeConnectionId = null; + +/** + * Checks if two HTML elements overlap + * @param {HTMLElement} el1 The first element + * @param {HTMLElement} el2 The second element + * @returns {boolean} True if the elements overlap, false otherwise + */ +function checkDoElementsOverlap(el1, el2) { + const rect1 = el1.getBoundingClientRect(); + const rect2 = el2.getBoundingClientRect(); + + const overlap = !(rect1.right < rect2.left || + rect1.left > rect2.right || + rect1.bottom < rect2.top || + rect1.top > rect2.bottom); + + return overlap; +} + +function permsStringToNum(str) { + let temp; + let result = ''; + const user = str.substring(1, 4); + const group = str.substring(4, 7); + const other = str.substring(7, 10); + for (const perm of [user, group, other]) { + temp = 0; + if (perm.includes('r')) temp += 4; + if (perm.includes('w')) temp += 2; + if (perm.includes('x')) temp += 1; + result += temp; + } + return result; +} + +const downloadUrl = (url, name) => { + const a = document.createElement('a'); + a.href = url; + a.download = name || ''; + a.click(); +} + +const getFileExtInfo = (path, size) => { + const ext = path.split('.').pop().toLowerCase(); + const types = { + image: { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + svg: 'image/svg', + webp: 'image/webp' + }, + video: { + mp4: 'video/mp4', + webm: 'video/webm', + ogv: 'video/ogg' + }, + audio: { + mp3: 'audio/mpeg', + wav: 'audio/wav' + }, + text: { + txt: 'text/plain', + html: 'text/html', + css: 'text/css', + js: 'text/javascript', + json: 'application/json', + py: 'text/x-python', + php: 'text/x-php', + java: 'text/x-java-source', + c: 'text/x-c', + cpp: 'text/x-c++', + cs: 'text/x-csharp', + rb: 'text/x-ruby', + go: 'text/x-go', + rs: 'text/x-rust', + swift: 'text/x-swift', + sh: 'text/x-shellscript', + bat: 'text/x-batch', + ps1: 'text/x-powershell', + sql: 'text/x-sql', + yaml: 'text/yaml', + yml: 'text/yaml', + ts: 'text/typescript', + properties: 'text/x-properties', + toml: 'text/x-toml', + cfg: 'text/x-properties', + conf: 'text/x-properties', + ini: 'text/x-properties', + log: 'text/x-log' + }, + markdown: { + md: 'text/markdown', + markdown: 'text/markdown' + } + }; + // https://codemirror.net/5/mode/index.html + // https://github.com/codemirror/codemirror5/tree/master/mode + const getKeywordsObject = keywords => { + const obj = {}; + for (const word of keywords) obj[word] = true; + return obj; + } + const codeMirrorModes = { + html: 'htmlmixed', + css: 'css', + js: 'javascript', + json: { + name: 'javascript', + json: true + }, + py: 'python', + php: 'php', + java: { + name: 'clike', + keywords: getKeywordsObject('abstract assert boolean break byte case catch char class const continue default do double else enum exports extends final finally float for goto if implements import instanceof int interface long native new package private protected public return short static strictfp super switch synchronized this throw throws transient try void volatile while'.split(' ')) + }, + c: { + name: 'clike', + keywords: getKeywordsObject('auto break case char const continue default do double else enum extern float for goto if int long register return short signed sizeof static struct switch typedef union unsigned void volatile while'.split(' ')) + }, + cpp: { + name: 'clike', + keywords: getKeywordsObject('asm auto break case catch char class const const_cast continue default delete do double dynamic_cast else enum explicit export extern false float for friend goto if inline int long mutable namespace new operator private protected public register reinterpret_cast return short signed sizeof static static_cast struct switch template this throw true try typedef typeid typename union unsigned using virtual void volatile wchar_t while'.split(' ')), + useCPP: true + }, + cs: { + name: 'clike', + keywords: getKeywordsObject('abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while'.split(' ')), + }, + rb: 'ruby', + go: 'go', + rs: 'rust', + swift: 'swift', + sh: 'shell', + ps1: 'powershell', + sql: 'sql', + yaml: 'yaml', + yml: 'yaml', + ts: 'javascript', + properties: 'properties', + toml: 'toml', + cfg: 'properties', + conf: 'properties', + ini: 'properties', + md: 'gfm', + markdown: 'gfm' + }; + const maxSizes = { + image: 1024*1024*16, + video: 1024*1024*16, + audio: 1024*1024*16, + text: 1024*1024*2, + markdown: 1024*1024*2 + }; + const data = { isViewable: false, type: null, mime: null }; + if (!path.match(/\./g)) { + data.isViewable = true; + data.type = 'text'; + data.mime = 'application/octet-stream'; + data.codeMirrorMode = null; + } else { + for (const type in types) { + if (types[type][ext]) { + data.isViewable = true; + data.type = type; + data.mime = types[type][ext]; + data.codeMirrorMode = codeMirrorModes[ext] || null; + break; + } + } + } + if (data.isViewable && size) { + if (size > maxSizes[data.type]) { + data.isViewable = false; + } + } + return data; +} + +/** + * Returns a boolean representing if the device has limited input capabilities (no hover and coarse pointer) + */ +const getIsMobileDevice = () => { + const isPointerCoarse = window.matchMedia('(pointer: coarse)').matches; + const isHoverNone = window.matchMedia('(hover: none)').matches; + return isPointerCoarse && isHoverNone; +} + +/** + * Returns an object of headers for API requests that interface with the current active server + */ +const getHeaders = () => { + const headers = { + 'sftp-host': activeConnection.host, + 'sftp-port': activeConnection.port, + 'sftp-username': activeConnection.username + }; + if (activeConnection.password) + headers['sftp-password'] = encodeURIComponent(activeConnection.password); + if (activeConnection.key) + headers['sftp-key'] = encodeURIComponent(activeConnection.key); + return headers; +} + +const api = { + /** + * Makes requests to the API + * @param {'get'|'post'|'put'|'delete'} method The request method + * @param {string} url The sub-URL of an API endpoint + * @param {object|undefined} params An object of key-value query params + * @param {*} body The body of the request, if applicable + * @param {callback|undefined} onProgress A callback function that gets passed an Axios progress event + * @returns {object} An object representing the response data or error info + */ + request: async (method, url, params, body = null, onProgress = () => {}, responseType = 'json') => { + url = `${httpProtocol}://${apiHost}/api/sftp/${url}`; + try { + const opts = { + params, headers: getHeaders(), + onUploadProgress: onProgress, + onDownloadProgress: onProgress, + responseType: responseType + }; + let res = null; + if (method == 'get' || method == 'delete') { + res = await axios[method](url, opts); + } else { + res = await axios[method](url, body, opts); + } + //console.log(`Response from ${url}:`, res.data); + return res.data; + } catch (error) { + if (responseType !== 'json') { + console.error(error); + return null; + } + if (error.response?.data) { + console.warn(`Error ${error.response.status} response from ${url}:`, error.response.data); + return error.response.data; + } else { + console.error(error); + return { + success: false, + error: `${error}` + }; + } + } + }, + get: (url, params) => api.request('get', url, params), + post: (url, params, body) => api.request('post', url, params, body), + put: (url, params, body) => api.request('put', url, params, body), + delete: (url, params) => api.request('delete', url, params) +}; + +/** + * Updates the bottom status bar. + * @param {string} html The status text + * @param {boolean} isError If `true`, turns the status red + * @param {number|null} progress A 0-100 whole number to be used for the progress bar, or `null` to hide it + * @returns {boolean} The negation of `isError` + */ +const setStatus = (html, isError = false, progress = null) => { + elStatusBar.innerHTML = html; + elStatusBar.classList.toggle('error', isError); + elProgressBar.classList.remove('visible'); + if (progress !== null) { + elProgressBar.classList.add('visible'); + if (progress >= 0 && progress <= 100) + elProgressBar.value = progress; + else + elProgressBar.removeAttribute('value'); + } + return !isError; +} + +/** + * Resolves with a download URL for a single file, or `false` if an error occurred. + * @param {string} path The file path + * @returns {Promise} + */ +const getFileDownloadUrl = async path => { + setStatus(`Getting single file download URL...`); + const res = await api.get('files/get/single/url', { + path: path + }); + if (res.error) { + return setStatus(`Error: ${res.error}`, true); + } + if (res.download_url) { + return res.download_url; + } + return false; +} + +/** + * Starts a single-file download. + * @param {string} path The file path + */ +const downloadFile = async path => { + const url = await getFileDownloadUrl(path); + if (url) { + downloadUrl(url); + setStatus(`Single file download started`); + } +} \ No newline at end of file diff --git a/web/file.html b/web/file.html new file mode 100644 index 0000000..da2ea32 --- /dev/null +++ b/web/file.html @@ -0,0 +1,60 @@ + + + + + + File Viewer + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ +
+ Loading file... +
+
+ + \ No newline at end of file diff --git a/web/icon.png b/web/icon.png new file mode 100644 index 0000000..ae006d1 Binary files /dev/null and b/web/icon.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..0a5152d --- /dev/null +++ b/web/index.html @@ -0,0 +1,143 @@ + + + + + + SFTP Browser + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + +
+ + + +
+ + + + + +
+ + +
+ + +
+ + +
+ +
+
+
+
Name
+
Modified
+
Size
+
Permissions
+
+
+ +
+ Waiting for connection... +
+
+
+ + \ No newline at end of file diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..ea1a007 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,18 @@ +{ + "id": "/", + "start_url": "/", + "scope": "/", + "name": "SFTP Browser", + "short_name": "SFTP", + "description": "Manage files on your SFTP server with ease!", + "categories": [ "utilities", "productivity" ], + "icons": [{ + "src": "/icon.png", + "size": "256x256", + "type": "image/png", + "purpose": "maskable any" + }], + "background_color": "#1f2733", + "theme_color": "#1f2733", + "display": "standalone" +} \ No newline at end of file diff --git a/web/worker.js b/web/worker.js new file mode 100644 index 0000000..cbfcc4f --- /dev/null +++ b/web/worker.js @@ -0,0 +1,29 @@ + +self.addEventListener('activate', (e) => { + self.clients.claim(); +}); + +self.addEventListener('fetch', (e) => { + const reqUrl = e.request.url; + e.respondWith((async() => { + // Open asset cache and see if this request is in it + const cache = await caches.open('main'); + const match = await caches.match(e.request); + // Request the resource from the network + const netRes = fetch(e.request).then((res) => { + // If the request was successful and this isn't an API call, + // update the cached resource + if (res.ok && !reqUrl.match(/\/api\/sftp\/.*$/)) { + cache.put(e.request, res.clone()); + } + // Return the response + return res; + }).catch(e => { + console.error(e); + return match; + }); + // Return the cached resource if it exists + // Otherwise, return the network request + return match || netRes; + })()); +}); \ No newline at end of file