diff --git a/.babelrc b/.babelrc
deleted file mode 100644
index 05a655e..0000000
--- a/.babelrc
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "presets": ["@babel/preset-typescript", ["@babel/env"]],
- "compact": true,
- "plugins": ["lodash", "@babel/plugin-proposal-class-properties"]
-}
diff --git a/.eslintignore b/.eslintignore
deleted file mode 100644
index 4a01086..0000000
--- a/.eslintignore
+++ /dev/null
@@ -1,5 +0,0 @@
-node_modules/
-.esm-cache
-dist
-public/
-*hterm*
diff --git a/.eslintrc.js b/.eslintrc.js
deleted file mode 100644
index 526ef78..0000000
--- a/.eslintrc.js
+++ /dev/null
@@ -1,45 +0,0 @@
-module.exports = {
- parser: '@typescript-eslint/parser',
- plugins: ['@typescript-eslint', 'prettier'],
- env: {
- es6: true,
- node: true,
- browser: true,
- },
- root: true,
- extends: [
- 'airbnb-base',
- 'plugin:@typescript-eslint/recommended',
- 'prettier',
- 'prettier/@typescript-eslint',
- ],
- rules: {
- 'linebreak-style': ['error', 'unix'],
- 'arrow-parens': ['error', 'as-needed'],
- 'no-param-reassign': ['error', { props: false }],
- 'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
- 'no-use-before-define': ['error', { functions: false }],
- '@typescript-eslint/no-use-before-define': ['error', { functions: false }],
- 'import/prefer-default-export': 'off',
- 'lines-between-class-members': [
- 'error',
- 'always',
- { exceptAfterSingleLine: true },
- ],
- 'import/extensions': [
- 'error',
- 'always',
- {
- js: 'ignorePackages',
- ts: 'never',
- },
- ],
- },
- settings: {
- 'import/resolver': {
- node: {
- extensions: ['.ts', '.js'],
- },
- },
- },
-};
diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 0000000..311bd94
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,71 @@
+{
+ "parser": "@typescript-eslint/parser",
+ "plugins": ["@typescript-eslint", "prettier", "mocha"],
+ "env": {
+ "es6": true,
+ "node": true,
+ "browser": true
+ },
+ "root": true,
+ "extends": [
+ "airbnb-base",
+ "plugin:@typescript-eslint/recommended",
+ "prettier",
+ "prettier/@typescript-eslint"
+ ],
+ "rules": {
+ "linebreak-style": ["error", "unix"],
+ "arrow-parens": ["error", "as-needed"],
+ "no-param-reassign": [
+ "error",
+ {
+ "props": false
+ }
+ ],
+ "func-style": [
+ "error",
+ "declaration",
+ {
+ "allowArrowFunctions": true
+ }
+ ],
+ "no-use-before-define": [
+ "error",
+ {
+ "functions": false
+ }
+ ],
+ "@typescript-eslint/no-use-before-define": [
+ "error",
+ {
+ "functions": false
+ }
+ ],
+ "import/prefer-default-export": "off",
+ "lines-between-class-members": [
+ "error",
+ "always",
+ {
+ "exceptAfterSingleLine": true
+ }
+ ],
+ "import/extensions": [
+ "error",
+ "always",
+ {
+ "js": "ignorePackages",
+ "ts": "never"
+ }
+ ]
+ },
+ "settings": {
+ "import/resolver": {
+ "typescript": {
+ "project": ["./tsconfig.browser.json", "./tsconfig.node.json"]
+ },
+ "node": {
+ "extensions": [".ts", ".js"]
+ }
+ }
+ }
+}
diff --git a/.gitignore b/.gitignore
index 4bb333d..9c9b375 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,19 +1,118 @@
-lib-cov
-*.seed
+web_modules
+lib
+assets/css
+build
+
+# Created by https://www.toptal.com/developers/gitignore/api/node
+# Edit at https://www.toptal.com/developers/gitignore?templates=node
+
+### Node ###
+# Logs
+logs
*.log
-*.csv
-*.dat
-*.out
-*.pid
-*.gz
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
-tmp
+# Runtime data
pids
-logs
-results
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
-npm-debug.log
-node_modules
-.esm-cache
+# TypeScript v1 declaration files
+typings/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env
+.env.test
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+
+# Next.js build output
+.next
+
+# Nuxt.js build / generate output
+.nuxt
dist
-.idea
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and not Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# TernJS port file
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+.vscode-test
+
+# End of https://www.toptal.com/developers/gitignore/api/node
diff --git a/.npmignore b/.npmignore
index cb11615..125d7a1 100644
--- a/.npmignore
+++ b/.npmignore
@@ -19,10 +19,8 @@ src
*.yml
Dockerfile
*.png
-.babelrc
.dockerignore
.eslint*
.prettierrc.js
-tsconfig.json
-webpack.config.babel.js
+tsconfig
docs
diff --git a/.prettierrc.js b/.prettierrc.js
deleted file mode 100644
index 058f7a4..0000000
--- a/.prettierrc.js
+++ /dev/null
@@ -1,13 +0,0 @@
-module.exports = {
- singleQuote: true,
- trailingComma: 'es5',
- proseWrap: 'always',
- overrides: [
- {
- files: ['*.js', '*.ts'],
- options: {
- printWidth: 80,
- },
- },
- ],
-};
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..b9f1619
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,16 @@
+{
+ "singleQuote": true,
+ "trailingComma": "all",
+ "proseWrap": "always",
+ "overrides": [
+ {
+ "files": [
+ "*.js",
+ "*.ts"
+ ],
+ "options": {
+ "printWidth": 80
+ }
+ }
+ ]
+}
diff --git a/README.md b/README.md
index 9c2aad8..534908b 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-![All Contributors](https://img.shields.io/badge/all_contributors-33-orange.svg?style=flat-square)
+[![All Contributors](https://img.shields.io/badge/all_contributors-33-orange.svg?style=flat-square)](#contributors-)
@@ -35,7 +35,30 @@ yarn global add wetty
## Usage
```sh
-wetty [-h] [--port PORT] [--base BASE] [--sshhost SSH_HOST] [--sshport SSH_PORT] [--sshuser SSH_USER] [--host HOST] [--command COMMAND] [--forcessh] [--bypasshelmet] [--title TITLE] [--sslkey SSL_KEY_PATH] [--sslcert SSL_CERT_PATH]
+$ wetty --help
+Options:
+ --help, -h Print help message [boolean]
+ --version Show version number [boolean]
+ --conf config file to load config from [string]
+ --ssl-key path to SSL key [string]
+ --ssl-cert path to SSL certificate [string]
+ --ssh-host ssh server host [string]
+ --ssh-port ssh server port [number]
+ --ssh-user ssh user [string]
+ --title window title [string]
+ --ssh-auth defaults to "password", you can use "publickey,password"
+ instead [string]
+ --ssh-pass ssh password [string]
+ --ssh-key path to an optional client private key (connection will be
+ password-less and insecure!) [string]
+ --force-ssh Connecting through ssh even if running as root [boolean]
+ --known-hosts path to known hosts file [string]
+ --base, -b base path to wetty [string]
+ --port, -p wetty listen port [number]
+ --host wetty listen host [string]
+ --command, -c command to run in shell [string]
+ --bypass-helmet disable helmet from placing security restrictions [boolean]
+
```
Open your browser on `http://yourserver:3000/wetty` and you will prompted to
@@ -44,11 +67,11 @@ user before hand.
If you run it as root it will launch `/bin/login` (where you can specify the
user name), else it will launch `ssh` and connect by default to `localhost`. The
-SSH connection can be forced using the `--forcessh` option.
+SSH connection can be forced using the `--force-ssh` option.
-If instead you wish to connect to a remote host you can specify the `--sshhost`
-option, the SSH port using the `--sshport` option and the SSH user using the
-`--sshuser` option.
+If instead you wish to connect to a remote host you can specify the `--ssh-host`
+option, the SSH port using the `--ssh-port` option and the SSH user using the
+`--ssh-user` option.
Check out the
[Flags docs](https://github.com/butlerx/wetty/blob/master/docs/flags.md) for a
@@ -94,47 +117,47 @@ Thanks goes to these wonderful people
diff --git a/conf/config.json5 b/conf/config.json5
new file mode 100644
index 0000000..15ddf3f
--- /dev/null
+++ b/conf/config.json5
@@ -0,0 +1,27 @@
+{
+ ssh: {
+ // user: 'username', // default user to use when ssh-ing
+ host: 'localhost', // Server to ssh to
+ auth: 'password', // shh authentication, method. Defaults to "password", you can use "publickey,password" instead'
+ // pass: "password", // Password to use when sshing
+ // key: "", // path to an optional client private key, connection will be password-less and insecure!
+ port: 22, // Port to ssh to
+ knownHosts: '/dev/null', // ssh knownHosts file to use
+ },
+ server: {
+ base: '/wetty/', // URL base to serve resources from
+ port: 3000, // Port to listen on
+ host: '0.0.0.0', // address to listen on
+ title: 'WeTTy - The Web Terminal Emulator', // Page title
+ bypassHelmet: false, // Disable Helmet security checks
+ },
+
+ forceSSH: false, // Force sshing to local machine over login if running as root
+ command: 'login', // Command to run on server. Login will use ssh if connecting to different server
+ /*
+ ssl:{
+ key: 'ssl.key',
+ cert: 'ssl.cert',
+ }
+ */
+}
diff --git a/bin/nginx.template b/conf/nginx.template
similarity index 100%
rename from bin/nginx.template
rename to conf/nginx.template
diff --git a/bin/wetty.conf b/conf/wetty.conf
similarity index 100%
rename from bin/wetty.conf
rename to conf/wetty.conf
diff --git a/bin/wetty.service b/conf/wetty.service
similarity index 83%
rename from bin/wetty.service
rename to conf/wetty.service
index a4d5742..af4a7d4 100644
--- a/bin/wetty.service
+++ b/conf/wetty.service
@@ -11,7 +11,7 @@ After=network.target
[Service]
WorkingDirectory=$HOME/.config/yarn/global/node_modules/wetty/
-ExecStart=/usr/bin/node index.js -p 3000 --host 127.0.0.1
+ExecStart=/usr/bin/node . -p 3000 --host 127.0.0.1
[Install]
WantedBy=multi-user.target
diff --git a/ssh.Dockerfile b/containers/ssh/Dockerfile
similarity index 100%
rename from ssh.Dockerfile
rename to containers/ssh/Dockerfile
diff --git a/Dockerfile b/containers/wetty/Dockerfile
similarity index 92%
rename from Dockerfile
rename to containers/wetty/Dockerfile
index 12db737..22d31dc 100644
--- a/Dockerfile
+++ b/containers/wetty/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:dubnium-alpine as builder
+FROM node:current-alpine as builder
RUN apk add -U build-base python
WORKDIR /usr/src/app
COPY . /usr/src/app
@@ -6,7 +6,7 @@ RUN yarn && \
yarn build && \
yarn install --production --ignore-scripts --prefer-offline
-FROM node:dubnium-alpine
+FROM node:current-alpine
LABEL maintainer="butlerx@notthe.cloud"
WORKDIR /usr/src/app
ENV NODE_ENV=production
diff --git a/docker-compose.yml b/docker-compose.yml
index 7709e2c..a2793d6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,9 +2,10 @@
version: '3.5'
services:
wetty:
- build: .
image: wettyoss/wetty
- container_name: wetty
+ build:
+ context: .
+ dockerfile: containers/wetty/Dockerfile
tty: true
working_dir: /usr/src/app
ports:
@@ -16,9 +17,8 @@ services:
web:
image: nginx
- container_name: wetty-nginx
volumes:
- - ./bin/nginx.template:/etc/nginx/conf.d/wetty.template
+ - ./conf/nginx.template:/etc/nginx/conf.d/wetty.template
ports:
- '80:80'
environment:
@@ -35,9 +35,8 @@ services:
wetty-ssh:
build:
context: .
- dockerfile: ssh.Dockerfile
+ dockerfile: containers/ssh/Dockerfile
image: wettyoss/wetty:ssh
- container_name: 'wetty-ssh'
networks:
default:
diff --git a/docs/flags.md b/docs/flags.md
index 2e2a531..b867f49 100644
--- a/docs/flags.md
+++ b/docs/flags.md
@@ -14,12 +14,12 @@ the `login` binary rather than ssh. If no host is specified it will use
`localhost` as the ssh host.
If instead you wish to connect to a remote host you can specify the host with
-the `--sshhost` flag and pass the IP or DNS address of the host you want to
+the `--ssh-host` flag and pass the IP or DNS address of the host you want to
connect to.
## Default User
-You can specify the default user used to ssh to a host using the `--sshuser`.
+You can specify the default user used to ssh to a host using the `--ssh-user`.
This user can overwritten by going to
`http://yourserver:3000/wetty/ssh/`. If this is left blank a user will
be prompted to enter their username when they connect.
@@ -27,7 +27,7 @@ be prompted to enter their username when they connect.
## SSH Port
By default WeTTy will try to ssh to port `22`, if your host uses an alternative
-ssh port this can be specified with the flag `--sshport`.
+ssh port this can be specified with the flag `--ssh-port`.
## WeTTy URL
diff --git a/docs/https.md b/docs/https.md
index 59f4e1d..8632e3e 100644
--- a/docs/https.md
+++ b/docs/https.md
@@ -6,11 +6,11 @@ either using WeTTy behind a proxy or directly.
See docs for [NGinX](./nginx.md) and [Apache](./apache.md) for running behind a
proxy.
-To run WeTTy directly with SSL use both the `--sslkey` and `--sslcert` flags and
-pass them the path too your cert and key as follows:
+To run WeTTy directly with SSL use both the `--ssl-key` and `--ssl-cert` flags
+and pass them the path too your cert and key as follows:
```bash
-wetty --sslkey key.pem --sslcert cert.pem
+wetty --ssl-key key.pem --ssl-cert cert.pem
```
If you don't have SSL certificates from a CA you can create a self signed
diff --git a/docs/nginx.md b/docs/nginx.md
index 97f69e1..32ecf98 100644
--- a/docs/nginx.md
+++ b/docs/nginx.md
@@ -19,7 +19,7 @@ Put the following configuration in your nginx conf:
```nginx
location ^~ /wetty {
- proxy_pass http://127.0.0.1:3000/WeTTy;
+ proxy_pass http://127.0.0.1:3000/wetty;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
diff --git a/docs/service.md b/docs/service.md
index 07d5735..7e21e6c 100644
--- a/docs/service.md
+++ b/docs/service.md
@@ -7,7 +7,7 @@ bundled with the npm package to make this easier.
```bash
$ yarn global add wetty
-$ sudo cp ~/.config/yarn/global/node_modules/wetty/bin/wetty.conf /etc/init
+$ sudo cp ~/.config/yarn/global/node_modules/wetty/conf/wetty.conf /etc/init
$ sudo start wetty
```
@@ -15,7 +15,7 @@ $ sudo start wetty
```bash
$ yarn global add wetty
-$ cp ~/.config/yarn/global/node_modules/wetty/bin/wetty.service ~/.config/systemd/user/
+$ cp ~/.config/yarn/global/node_modules/wetty/conf/wetty.service ~/.config/systemd/user/
$ systemctl --user enable wetty
$ systemctl --user start wetty
```
diff --git a/index.js b/index.js
deleted file mode 100755
index 7d878cc..0000000
--- a/index.js
+++ /dev/null
@@ -1,124 +0,0 @@
-#! /usr/bin/env node
-/* eslint-disable @typescript-eslint/no-var-requires, import/no-unresolved */
-
-const yargs = require('yargs');
-const wetty = require('./dist').default;
-
-module.exports = wetty.wetty;
-
-/**
- * Check if being run by cli or require
- */
-if (require.main === module) {
- wetty.init(
- yargs
- .options({
- sslkey: {
- demand: false,
- type: 'string',
- description: 'path to SSL key',
- },
- sslcert: {
- demand: false,
- type: 'string',
- description: 'path to SSL certificate',
- },
- sshhost: {
- demand: false,
- description: 'ssh server host',
- type: 'string',
- default: process.env.SSHHOST || 'localhost',
- },
- sshport: {
- demand: false,
- description: 'ssh server port',
- type: 'number',
- default: parseInt(process.env.SSHPORT, 10) || 22,
- },
- sshuser: {
- demand: false,
- description: 'ssh user',
- type: 'string',
- default: process.env.SSHUSER || '',
- },
- title: {
- demand: false,
- description: 'window title',
- type: 'string',
- default: process.env.TITLE || 'WeTTy - The Web Terminal Emulator',
- },
- sshauth: {
- demand: false,
- description:
- 'defaults to "password", you can use "publickey,password" instead',
- type: 'string',
- default: process.env.SSHAUTH || 'password',
- },
- sshpass: {
- demand: false,
- description: 'ssh password',
- type: 'string',
- default: process.env.SSHPASS || undefined,
- },
- sshkey: {
- demand: false,
- description:
- 'path to an optional client private key (connection will be password-less and insecure!)',
- type: 'string',
- default: process.env.SSHKEY || undefined,
- },
- forcessh: {
- demand: false,
- description: 'Connecting through ssh even if running as root',
- type: 'boolean',
- default: process.env.FORCESSH || false
- },
- knownhosts: {
- demand: false,
- description: 'path to known hosts file',
- type: 'string',
- default: process.env.KNOWNHOSTS || '/dev/null',
- },
- base: {
- demand: false,
- alias: 'b',
- description: 'base path to wetty',
- type: 'string',
- default: process.env.BASE || '/wetty/',
- },
- port: {
- demand: false,
- alias: 'p',
- description: 'wetty listen port',
- type: 'number',
- default: parseInt(process.env.PORT, 10) || 3000,
- },
- host: {
- demand: false,
- description: 'wetty listen host',
- default: '0.0.0.0',
- type: 'string',
- },
- command: {
- demand: false,
- alias: 'c',
- description: 'command to run in shell',
- type: 'string',
- default: process.env.COMMAND || 'login',
- },
- bypasshelmet: {
- demand: false,
- description: 'disable helmet from placing security restrictions',
- type: 'boolean',
- default: false,
- },
- help: {
- demand: false,
- alias: 'h',
- type: 'boolean',
- description: 'Print help message',
- },
- })
- .boolean('allow_discovery').argv
- );
-}
diff --git a/package.json b/package.json
index cac59d6..88a5af1 100644
--- a/package.json
+++ b/package.json
@@ -1,8 +1,25 @@
{
"name": "wetty",
- "version": "1.4.1",
+ "version": "2.0.0",
"description": "WeTTY = Web + TTY. Terminal access in browser over http/https",
"homepage": "https://github.com/butlerx/wetty",
+ "license": "MIT",
+ "type": "module",
+ "main": "./build/main.js",
+ "module": "./build/server.js",
+ "files": [
+ "build/"
+ ],
+ "scripts": {
+ "build": "snowpack build",
+ "dev": "NODE_ENV=development concurrently --kill-others --success first \"snowpack dev\" \"nodemon .\"",
+ "prepublishOnly": "snowpack build",
+ "lint": "eslint src/**/*.ts",
+ "start": "NODE_ENV=production node .",
+ "contributor": "all-contributors",
+ "test": "env TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\" }' mocha -r ts-node/register src/**/*.spec.ts",
+ "clean": "rm -rf build yarn-error.log"
+ },
"repository": {
"type": "git",
"url": "git://github.com/butlerx/wetty.git"
@@ -12,20 +29,9 @@
"email": "butlerx@notthe.cloud",
"url": "cianbutler.ie"
},
- "license": "MIT",
"bugs": {
"url": "https://github.com/butlerx/wetty/issues"
},
- "main": "index.js",
- "scripts": {
- "build": "babel-node node_modules/.bin/webpack",
- "contributor": "all-contributors",
- "dev": "NODE_ENV=development concurrently --kill-others --success first \"babel-node node_modules/.bin/webpack --watch\" \"nodemon .\"",
- "lint": "eslint --ext .ts,.js .",
- "prepublishOnly": "NODE_ENV=production yarn build",
- "start": "NODE_ENV=production node .",
- "test": "mocha -r babel-register-ts src/**/*.spec.ts"
- },
"husky": {
"hooks": {
"pre-commit": "lint-staged"
@@ -33,102 +39,122 @@
},
"lint-staged": {
"*.{js,ts}": [
- "eslint --fix",
- "git add"
+ "eslint --fix"
],
"*.{json,scss,md}": [
- "prettier --write",
- "git add"
+ "prettier --write"
]
},
- "bin": {
- "wetty": "./index.js"
- },
"engines": {
"node": ">=10.22"
},
"nodemonConfig": {
"ignore": [
+ "*.scss",
"src/*",
"*.json"
]
},
- "preferGlobal": true,
+ "snowpack": {
+ "installOptions": {
+ "sourceMap": true,
+ "installTypes": true
+ },
+ "mount": {
+ "src/client": "/client",
+ "src/assets": "/assets"
+ },
+ "exclude": [
+ "src/server/**/*.ts",
+ "src/client/**/*.spec.ts",
+ "src/*.ts"
+ ],
+ "plugins": [
+ [
+ "@snowpack/plugin-run-script",
+ {
+ "cmd": "tsc -p tsconfig.browser.json --noEmit",
+ "watch": "$1 --watch"
+ }
+ ],
+ [
+ "@snowpack/plugin-run-script",
+ {
+ "cmd": "sass src/assets/scss:build/assets/css --load-path=node_modules -s compressed --no-source-map",
+ "watch": "$1 --watch"
+ }
+ ],
+ [
+ "@snowpack/plugin-run-script",
+ {
+ "cmd": "tsc -p tsconfig.node.json",
+ "watch": "$1 --watch"
+ }
+ ]
+ ]
+ },
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.25",
"@fortawesome/free-solid-svg-icons": "^5.11.2",
"compression": "^1.7.4",
"express": "^4.17.1",
- "express-winston": "^4.0.1",
+ "express-winston": "^4.0.5",
"file-type": "^12.3.0",
- "fs-extra": "^8.1.0",
- "helmet": "^3.20.1",
- "lodash": "^4.17.15",
+ "fs-extra": "^9.0.1",
+ "helmet": "^4.1.0",
+ "json5": "^2.1.3",
+ "lodash": "^4.17.20",
"node-pty": "^0.9.0",
+ "sass": "^1.26.10",
"serve-favicon": "^2.5.0",
- "socket.io": "^2.2.0",
- "socket.io-client": "^2.2.0",
- "source-map-loader": "^0.2.4",
- "toastify-js": "^1.6.1",
- "winston": "^3.2.1",
+ "socket.io": "^2.3.0",
+ "socket.io-client": "^2.3.0",
+ "toastify-js": "^1.9.1",
+ "winston": "^3.3.3",
"xterm": "^4.8.1",
"xterm-addon-fit": "^0.4.0",
- "yargs": "^14.0.0"
+ "yargs": "^15.4.1"
},
"devDependencies": {
- "@babel/core": "^7.5.5",
- "@babel/node": "^7.5.5",
- "@babel/plugin-proposal-class-properties": "^7.7.4",
- "@babel/preset-env": "^7.5.5",
- "@babel/preset-typescript": "^7.3.3",
- "@babel/register": "^7.5.5",
- "@types/chai": "^4.2.5",
- "@types/compression": "^1.0.1",
- "@types/express": "^4.17.1",
- "@types/fs-extra": "^8.0.0",
- "@types/helmet": "^0.0.44",
+ "@types/chai": "^4.2.12",
+ "@types/compression": "^1.7.0",
+ "@types/express": "^4.17.8",
+ "@types/fs-extra": "^9.0.1",
+ "@types/helmet": "^0.0.48",
"@types/jsdom": "^12.2.4",
- "@types/lodash": "^4.14.138",
- "@types/mocha": "^5.2.7",
+ "@types/lodash": "^4.14.161",
+ "@types/mocha": "^8.0.3",
"@types/morgan": "^1.7.37",
- "@types/node": "^12.7.3",
- "@types/serve-favicon": "^2.2.31",
+ "@types/node": "^14.6.3",
+ "@types/serve-favicon": "^2.5.0",
"@types/sinon": "^7.5.1",
- "@types/socket.io": "^2.1.2",
- "@types/socket.io-client": "^1.4.32",
- "@types/webpack-env": "^1.14.0",
- "@types/yargs": "^13.0.2",
+ "@types/socket.io": "^2.1.11",
+ "@types/socket.io-client": "^1.4.33",
+ "@types/winston": "^2.4.4",
+ "@types/yargs": "^15.0.5",
"@typescript-eslint/eslint-plugin": "^2.5.0",
- "@typescript-eslint/parser": "^2.5.0",
- "all-contributors-cli": "^6.9.3",
- "babel-loader": "^8.0.6",
- "babel-plugin-lodash": "^3.3.4",
- "babel-register-ts": "^7.0.0",
+ "@typescript-eslint/parser": "^4.3.0",
+ "all-contributors-cli": "^6.17.2",
"chai": "^4.2.0",
- "concurrently": "^4.1.2",
- "css-loader": "^3.2.0",
- "eslint": "6.1.0",
- "eslint-config-airbnb-base": "14.0.0",
- "eslint-config-prettier": "^6.4.0",
- "eslint-plugin-import": "^2.18.2",
- "eslint-plugin-prettier": "^3.1.0",
- "file-loader": "^4.2.0",
- "git-authors-cli": "^1.0.18",
- "husky": "^3.0.9",
+ "concurrently": "^5.2.0",
+ "eslint": "^7.8.1",
+ "eslint-config-airbnb-base": "^14.2.0",
+ "eslint-config-prettier": "^6.11.0",
+ "eslint-import-resolver-typescript": "^2.3.0",
+ "eslint-plugin-import": "^2.22.1",
+ "eslint-plugin-mocha": "^8.0.0",
+ "eslint-plugin-prettier": "^3.1.4",
+ "git-authors-cli": "^1.0.28",
+ "husky": "^4.2.5",
"jsdom": "^15.2.1",
- "lint-staged": "~9.2.5",
- "mini-css-extract-plugin": "^0.8.0",
- "mocha": "^6.2.2",
- "node-sass": "^4.12.0",
- "nodemon": "^1.19.2",
- "prettier": "^1.18.2",
- "sass-loader": "^8.0.0",
+ "lint-staged": "^10.2.13",
+ "mocha": "^8.1.3",
+ "nodemon": "^2.0.4",
+ "prettier": "^2.1.1",
"sinon": "^7.5.0",
- "style-loader": "^1.0.0",
- "typescript": "~3.6.2",
- "webpack": "^4.39.3",
- "webpack-cli": "^3.3.7",
- "webpack-node-externals": "^1.7.2"
+ "snowpack": "^2.10.1",
+ "ts-node": "^9.0.0",
+ "typescript": "^4.0.2"
},
"contributors": [
"Krishna Srinivas ",
diff --git a/src/client/favicon.ico b/src/assets/favicon.ico
similarity index 100%
rename from src/client/favicon.ico
rename to src/assets/favicon.ico
diff --git a/src/assets/scss/options.scss b/src/assets/scss/options.scss
new file mode 100644
index 0000000..dc3109b
--- /dev/null
+++ b/src/assets/scss/options.scss
@@ -0,0 +1,52 @@
+@use './variables';
+
+#options {
+ height: 16px;
+ position: absolute;
+ right: 1em;
+ top: 1em;
+ width: 16px;
+ z-index: 20;
+
+ .toggler {
+ color: variables.$lgrey;
+ display: inline-block;
+ font-size: 16px;
+ position: absolute;
+ right: 1em;
+ top: 0;
+ z-index: 20;
+
+ :hover {
+ color: variables.$white;
+ }
+ }
+
+ .editor {
+ background-color: rgba(0, 0, 0, 0.85);
+ border-color: rgba(255, 255, 255, 0.25);
+ border-radius: 0.3em;
+ color: #eee;
+ display: none;
+ font-size: 24px;
+ height: 100%;
+ padding: 0.5em;
+ position: relative;
+ right: 2em;
+ top: 1em;
+ width: 100%;
+ }
+}
+
+#options.opened {
+ height: 50%;
+ width: 50%;
+
+ .editor {
+ display: flex;
+ }
+
+ .error {
+ color: red;
+ }
+}
diff --git a/src/assets/scss/overlay.scss b/src/assets/scss/overlay.scss
new file mode 100644
index 0000000..e887254
--- /dev/null
+++ b/src/assets/scss/overlay.scss
@@ -0,0 +1,28 @@
+@use './variables';
+
+#overlay {
+ background-color: variables.$grey;
+ display: none;
+ height: 100%;
+ position: absolute;
+ width: 100%;
+ z-index: 100;
+
+ .error {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ justify-content: center;
+ width: 100%;
+
+ #msg {
+ align-self: center;
+ color: variables.$white;
+ }
+
+ input {
+ align-self: center;
+ margin: 16px;
+ }
+ }
+}
diff --git a/src/assets/scss/styles.scss b/src/assets/scss/styles.scss
new file mode 100644
index 0000000..36f782b
--- /dev/null
+++ b/src/assets/scss/styles.scss
@@ -0,0 +1,25 @@
+@use 'xterm/css/xterm.css';
+@use 'toastify-js/src/toastify.css';
+@use './variables';
+@use './overlay';
+@use './options';
+@use './terminal';
+
+html,
+body {
+ background-color: variables.$black;
+ height: 100%;
+ margin: 0;
+ overflow: hidden;
+
+ .toastify {
+ border-radius: 0;
+ color: variables.$black;
+ }
+}
+
+.xterm {
+ .xterm-viewport {
+ overflow-y: hidden;
+ }
+}
diff --git a/src/assets/scss/terminal.scss b/src/assets/scss/terminal.scss
new file mode 100644
index 0000000..c447840
--- /dev/null
+++ b/src/assets/scss/terminal.scss
@@ -0,0 +1,6 @@
+#terminal {
+ display: flex;
+ height: 100%;
+ position: relative;
+ width: 100%;
+}
diff --git a/src/assets/scss/variables.scss b/src/assets/scss/variables.scss
new file mode 100644
index 0000000..7467452
--- /dev/null
+++ b/src/assets/scss/variables.scss
@@ -0,0 +1,4 @@
+$black: #000;
+$grey: rgba(0, 0, 0, 0.75);
+$white: #fff;
+$lgrey: #ccc;
diff --git a/src/server/buffer.ts b/src/buffer.ts
similarity index 72%
rename from src/server/buffer.ts
rename to src/buffer.ts
index 5818d51..bf65832 100644
--- a/src/server/buffer.ts
+++ b/src/buffer.ts
@@ -3,13 +3,13 @@ import { createInterface } from 'readline';
ask('Enter your username');
function ask(question: string): Promise {
- const r = createInterface({
+ const rlp = createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise(resolve => {
- r.question(`${question}: `, answer => {
- r.close();
+ rlp.question(`${question}: `, answer => {
+ rlp.close();
resolve(answer);
});
});
diff --git a/src/client/dev.ts b/src/client/dev.ts
new file mode 100644
index 0000000..831a33c
--- /dev/null
+++ b/src/client/dev.ts
@@ -0,0 +1,5 @@
+caches.keys().then(cacheNames => {
+ cacheNames.forEach(cacheName => {
+ caches.delete(cacheName);
+ });
+});
diff --git a/src/client/disconnect.ts b/src/client/disconnect.ts
deleted file mode 100644
index 6d0cf5d..0000000
--- a/src/client/disconnect.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { isNull, isUndefined } from 'lodash';
-import verifyPrompt from './verify';
-import { overlay } from './elements';
-
-export default function disconnect(reason: string): void {
- if (isNull(overlay)) return;
- overlay.style.display = 'block';
- const msg = document.getElementById('msg');
- if (!isUndefined(reason) && !isNull(msg)) msg.innerHTML = reason;
- window.removeEventListener('beforeunload', verifyPrompt, false);
-}
diff --git a/src/client/index.ts b/src/client/index.ts
deleted file mode 100644
index ccdf50f..0000000
--- a/src/client/index.ts
+++ /dev/null
@@ -1,160 +0,0 @@
-import { Terminal } from 'xterm';
-import { isNull } from 'lodash';
-import { FitAddon } from 'xterm-addon-fit';
-import { dom, library } from '@fortawesome/fontawesome-svg-core';
-import { faCogs } from '@fortawesome/free-solid-svg-icons/faCogs';
-import Toastify from 'toastify-js';
-import * as fileType from 'file-type';
-
-import { socket } from './socket';
-import { overlay, terminal } from './elements';
-import { FileDownloader } from './download';
-import verifyPrompt from './verify';
-import disconnect from './disconnect';
-import mobileKeyboard from './mobile';
-import loadOptions from './options';
-import { copySelected, copyShortcut } from './copyToClipboard';
-import './wetty.scss';
-import './favicon.ico';
-
-// Setup for fontawesome
-library.add(faCogs);
-dom.watch();
-
-socket.on('connect', () => {
- const term = new Terminal();
- if (isNull(terminal)) return;
- const fitAddon = new FitAddon();
- term.loadAddon(fitAddon);
- term.open(terminal);
- const resize = (): void => {
- fitAddon.fit();
- socket.emit('resize', { cols: term.cols, rows: term.rows });
- };
-
- const options = loadOptions();
- Object.entries(options).forEach(([key, value]) => {
- term.setOption(key, value);
- });
- const code = JSON.stringify(options, null, 2);
- const editor = document.querySelector('#options .editor');
- if (!isNull(editor)) {
- editor.value = code;
- editor.addEventListener('keyup', () => {
- try {
- const updated = JSON.parse(editor.value);
- const updatedCode = JSON.stringify(updated, null, 2);
- editor.value = updatedCode;
- editor.classList.remove('error');
- localStorage.options = updatedCode;
- Object.keys(updated).forEach(key => {
- const value = updated[key];
- term.setOption(key, value);
- });
- resize();
- } catch {
- // skip
- editor.classList.add('error');
- }
- });
- const toggle = document.querySelector('#options .toggler');
- const optionsElem = document.getElementById('options');
- if (!isNull(toggle) && !isNull(optionsElem)) {
- toggle.addEventListener('click', e => {
- optionsElem.classList.toggle('opened');
- e.preventDefault();
- });
- }
- }
- if (!isNull(overlay)) overlay.style.display = 'none';
- window.addEventListener('beforeunload', verifyPrompt, false);
-
- term.attachCustomKeyEventHandler(copyShortcut);
-
- document.addEventListener(
- 'mouseup',
- () => {
- if (term.hasSelection()) copySelected(term.getSelection());
- },
- false
- );
-
- window.onresize = resize;
- resize();
- term.focus();
- mobileKeyboard();
-
- const fileDownloader = new FileDownloader((bufferCharacters: string) => {
- let fileCharacters = bufferCharacters;
- // Try to decode it as base64, if it fails we assume it's not base64
- try {
- fileCharacters = window.atob(fileCharacters);
- } catch (err) {
- // Assuming it's not base64...
- }
-
- const bytes = new Uint8Array(fileCharacters.length);
- for (let i = 0; i < fileCharacters.length; i += 1) {
- bytes[i] = fileCharacters.charCodeAt(i);
- }
-
- let mimeType = 'application/octet-stream';
- let fileExt = '';
- const typeData = fileType(bytes);
- if (typeData) {
- mimeType = typeData.mime;
- fileExt = typeData.ext;
- }
- // Check if the buffer is ASCII
- // Ref: https://stackoverflow.com/a/14313213
- // eslint-disable-next-line no-control-regex
- else if (/^[\x00-\xFF]*$/.test(fileCharacters)) {
- mimeType = 'text/plain';
- fileExt = 'txt';
- }
- const fileName = `file-${new Date()
- .toISOString()
- .split('.')[0]
- .replace(/-/g, '')
- .replace('T', '')
- .replace(/:/g, '')}${fileExt ? `.${fileExt}` : ''}`;
-
- const blob = new Blob([new Uint8Array(bytes.buffer)], {
- type: mimeType,
- });
- const blobUrl = URL.createObjectURL(blob);
-
- Toastify({
- text: `Download ready: ${fileName}`,
- duration: 10000,
- newWindow: true,
- gravity: 'bottom',
- position: 'right',
- backgroundColor: '#fff',
- stopOnFocus: true,
- }).showToast();
- });
-
- term.onData(data => {
- socket.emit('input', data);
- });
- term.onResize(size => {
- socket.emit('resize', size);
- });
- socket
- .on('data', (data: string) => {
- const remainingData = fileDownloader.buffer(data);
- if (remainingData) {
- term.write(remainingData);
- }
- })
- .on('login', () => {
- term.writeln('');
- resize();
- })
- .on('logout', disconnect)
- .on('disconnect', disconnect)
- .on('error', (err: string | null) => {
- if (err) disconnect(err);
- });
-});
diff --git a/src/client/elements.ts b/src/client/shared/elements.ts
similarity index 56%
rename from src/client/elements.ts
rename to src/client/shared/elements.ts
index 13f476a..e54a1cf 100644
--- a/src/client/elements.ts
+++ b/src/client/shared/elements.ts
@@ -1,2 +1,5 @@
export const overlay = document.getElementById('overlay');
export const terminal = document.getElementById('terminal');
+export const editor = document.querySelector(
+ '#options .editor',
+) as HTMLInputElement;
diff --git a/src/client/shared/verify.ts b/src/client/shared/verify.ts
new file mode 100644
index 0000000..97573f1
--- /dev/null
+++ b/src/client/shared/verify.ts
@@ -0,0 +1,4 @@
+export function verifyPrompt(e: { returnValue: string }): string {
+ e.returnValue = 'Are you sure?';
+ return e.returnValue;
+}
diff --git a/src/client/specs/download.spec.ts b/src/client/specs/download.spec.ts
deleted file mode 100644
index 905ec88..0000000
--- a/src/client/specs/download.spec.ts
+++ /dev/null
@@ -1,195 +0,0 @@
-/* eslint-disable */
-
-import { expect } from 'chai';
-import 'mocha';
-import * as sinon from 'sinon';
-
-import { JSDOM } from 'jsdom';
-import { FileDownloader } from '../download';
-
-const { window } = new JSDOM(`...`);
-
-describe('FileDownloader', () => {
- const FILE_BEGIN = 'BEGIN';
- const FILE_END = 'END';
- let fileDownloader: any;
-
- beforeEach(() => {
- fileDownloader = new FileDownloader(() => { }, FILE_BEGIN, FILE_END);
- });
-
- afterEach(() => {
- sinon.restore();
- });
-
- it('should return data before file markers', () => {
- const onCompleteFileStub = sinon.stub(fileDownloader, 'onCompleteFile');
- expect(
- fileDownloader.buffer(`DATA AT THE LEFT${FILE_BEGIN}FILE${FILE_END}`)
- ).to.equal('DATA AT THE LEFT');
- expect(onCompleteFileStub.calledOnce).to.be.true;
- expect(onCompleteFileStub.getCall(0).args[0]).to.equal('FILE');
- });
-
- it('should return data after file markers', () => {
- const onCompleteFileStub = sinon.stub(fileDownloader, 'onCompleteFile');
- expect(
- fileDownloader.buffer(`${FILE_BEGIN}FILE${FILE_END}DATA AT THE RIGHT`)
- ).to.equal('DATA AT THE RIGHT');
- expect(onCompleteFileStub.calledOnce).to.be.true;
- expect(onCompleteFileStub.getCall(0).args[0]).to.equal('FILE');
- });
-
- it('should return data before and after file markers', () => {
- const onCompleteFileStub = sinon.stub(fileDownloader, 'onCompleteFile');
- expect(
- fileDownloader.buffer(
- `DATA AT THE LEFT${FILE_BEGIN}FILE${FILE_END}DATA AT THE RIGHT`
- )
- ).to.equal('DATA AT THE LEFTDATA AT THE RIGHT');
- expect(onCompleteFileStub.calledOnce).to.be.true;
- expect(onCompleteFileStub.getCall(0).args[0]).to.equal('FILE');
- });
-
- it('should return data before a beginning marker found', () => {
- const onCompleteFileStub = sinon.stub(fileDownloader, 'onCompleteFile');
- expect(fileDownloader.buffer(`DATA AT THE LEFT${FILE_BEGIN}FILE`)).to.equal(
- 'DATA AT THE LEFT'
- );
- });
-
- it('should return data after an ending marker found', () => {
- const onCompleteFileStub = sinon.stub(fileDownloader, 'onCompleteFile');
- expect(fileDownloader.buffer(`${FILE_BEGIN}FI`)).to.equal('');
- expect(fileDownloader.buffer(`LE${FILE_END}DATA AT THE RIGHT`)).to.equal(
- 'DATA AT THE RIGHT'
- );
- expect(onCompleteFileStub.calledOnce).to.be.true;
- expect(onCompleteFileStub.getCall(0).args[0]).to.equal('FILE');
- });
-
- it('should buffer across incomplete file begin marker sequence on two calls', () => {
- fileDownloader = new FileDownloader(() => { }, 'BEGIN', 'END');
- const onCompleteFileStub = sinon.stub(fileDownloader, 'onCompleteFile');
-
- expect(fileDownloader.buffer('BEG')).to.equal('');
- expect(fileDownloader.buffer('INFILEEND')).to.equal('');
- expect(onCompleteFileStub.calledOnce).to.be.true;
- expect(onCompleteFileStub.getCall(0).args[0]).to.equal('FILE');
- });
-
- it('should buffer across incomplete file begin marker sequence on n calls', () => {
- fileDownloader = new FileDownloader(() => { }, 'BEGIN', 'END');
- const onCompleteFileStub = sinon.stub(fileDownloader, 'onCompleteFile');
-
- expect(fileDownloader.buffer('B')).to.equal('');
- expect(fileDownloader.buffer('E')).to.equal('');
- expect(fileDownloader.buffer('G')).to.equal('');
- expect(fileDownloader.buffer('I')).to.equal('');
- expect(fileDownloader.buffer('NFILE' + 'END')).to.equal('');
- expect(onCompleteFileStub.calledOnce).to.be.true;
- expect(onCompleteFileStub.getCall(0).args[0]).to.equal('FILE');
- });
-
- it('should buffer across incomplete file begin marker sequence with data on the left and right on multiple calls', () => {
- fileDownloader = new FileDownloader(() => { }, 'BEGIN', 'END');
- const onCompleteFileStub = sinon.stub(fileDownloader, 'onCompleteFile');
-
- expect(fileDownloader.buffer('DATA AT THE LEFT' + 'B')).to.equal(
- 'DATA AT THE LEFT'
- );
- expect(fileDownloader.buffer('E')).to.equal('');
- expect(fileDownloader.buffer('G')).to.equal('');
- expect(fileDownloader.buffer('I')).to.equal('');
- expect(fileDownloader.buffer('NFILE' + 'ENDDATA AT THE RIGHT')).to.equal(
- 'DATA AT THE RIGHT'
- );
- expect(onCompleteFileStub.calledOnce).to.be.true;
- expect(onCompleteFileStub.getCall(0).args[0]).to.equal('FILE');
- });
-
- it('should buffer across incomplete file begin marker sequence then handle false positive', () => {
- fileDownloader = new FileDownloader(() => { }, 'BEGIN', 'END');
- const onCompleteFileStub = sinon.stub(fileDownloader, 'onCompleteFile');
-
- expect(fileDownloader.buffer('DATA AT THE LEFT' + 'B')).to.equal(
- 'DATA AT THE LEFT'
- );
- expect(fileDownloader.buffer('E')).to.equal('');
- expect(fileDownloader.buffer('G')).to.equal('');
- // This isn't part of the file_begin marker and should trigger the partial
- // file begin marker to be returned with the normal data
- expect(fileDownloader.buffer('ZDATA AT THE RIGHT')).to.equal(
- 'BEGZDATA AT THE RIGHT'
- );
- expect(onCompleteFileStub.called).to.be.false;
- });
-
- it('should buffer across incomplete file end marker sequence on two calls', () => {
- fileDownloader = new FileDownloader(() => { }, 'BEGIN', 'END');
- const mockFilePart1 = 'DATA AT THE LEFTBEGINFILEE';
- const mockFilePart2 = 'NDDATA AT THE RIGHT';
-
- const onCompleteFileStub = sinon.stub(fileDownloader, 'onCompleteFile');
- expect(fileDownloader.buffer(mockFilePart1)).to.equal('DATA AT THE LEFT');
- expect(fileDownloader.buffer(mockFilePart2)).to.equal('DATA AT THE RIGHT');
-
- expect(onCompleteFileStub.calledOnce).to.be.true;
- expect(onCompleteFileStub.getCall(0).args[0]).to.equal('FILE');
- });
-
- it('should buffer across incomplete file end and file begin marker sequence with data on the left and right on multiple calls', () => {
- fileDownloader = new FileDownloader(() => { }, 'BEGIN', 'END');
- const onCompleteFileStub = sinon.stub(fileDownloader, 'onCompleteFile');
-
- expect(fileDownloader.buffer('DATA AT THE LEFT' + 'BE')).to.equal(
- 'DATA AT THE LEFT'
- );
- expect(fileDownloader.buffer('G')).to.equal('');
- expect(fileDownloader.buffer('I')).to.equal('');
- expect(fileDownloader.buffer('NFILEE')).to.equal('');
- expect(fileDownloader.buffer('N')).to.equal('');
- expect(fileDownloader.buffer('DDATA AT THE RIGHT')).to.equal(
- 'DATA AT THE RIGHT'
- );
- expect(onCompleteFileStub.calledOnce).to.be.true;
- expect(onCompleteFileStub.getCall(0).args[0]).to.equal('FILE');
- });
-
- it('should be able to handle multiple files', () => {
- fileDownloader = new FileDownloader(() => { }, 'BEGIN', 'END');
- const onCompleteFileStub = sinon.stub(fileDownloader, 'onCompleteFile');
-
- expect(
- fileDownloader.buffer(
- 'DATA AT THE LEFT' + 'BEGIN' + 'FILE1' + 'END' + 'SECOND DATA' + 'BEGIN'
- )
- ).to.equal('DATA AT THE LEFT' + 'SECOND DATA');
- expect(onCompleteFileStub.calledOnce).to.be.true;
- expect(onCompleteFileStub.getCall(0).args[0]).to.equal('FILE1');
-
- expect(fileDownloader.buffer('FILE2')).to.equal('');
- expect(fileDownloader.buffer('E')).to.equal('');
- expect(fileDownloader.buffer('NDRIGHT')).to.equal('RIGHT');
- expect(onCompleteFileStub.calledTwice).to.be.true;
- expect(onCompleteFileStub.getCall(1).args[0]).to.equal('FILE2');
- });
-
- it('should be able to handle multiple files with an ending marker', () => {
- fileDownloader = new FileDownloader(() => { }, 'BEGIN', 'END');
- const onCompleteFileStub = sinon.stub(fileDownloader, 'onCompleteFile');
-
- expect(
- fileDownloader.buffer('DATA AT THE LEFT' + 'BEGIN' + 'FILE1' + 'EN')
- ).to.equal('DATA AT THE LEFT');
- expect(onCompleteFileStub.calledOnce).to.be.false;
- expect(
- fileDownloader.buffer('D' + 'SECOND DATA' + 'BEGIN' + 'FILE2' + 'EN')
- ).to.equal('SECOND DATA');
- expect(onCompleteFileStub.calledOnce).to.be.true;
- expect(onCompleteFileStub.getCall(0).args[0]).to.equal('FILE1');
- expect(fileDownloader.buffer('D')).to.equal('');
- expect(onCompleteFileStub.calledTwice).to.be.true;
- expect(onCompleteFileStub.getCall(1).args[0]).to.equal('FILE2');
- });
-});
\ No newline at end of file
diff --git a/src/client/verify.ts b/src/client/verify.ts
deleted file mode 100644
index 93cd183..0000000
--- a/src/client/verify.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export default function verifyPrompt(e: { returnValue: string }): string {
- e.returnValue = 'Are you sure?';
- return e.returnValue;
-}
diff --git a/src/client/wetty.scss b/src/client/wetty.scss
deleted file mode 100644
index 7ef9d68..0000000
--- a/src/client/wetty.scss
+++ /dev/null
@@ -1,107 +0,0 @@
-@import '~xterm/css/xterm';
-@import '~toastify-js/src/toastify.css';
-
-$black: #000;
-$grey: rgba(0, 0, 0, 0.75);
-$white: #fff;
-$lgrey: #ccc;
-
-html,
-body {
- background-color: $black;
- height: 100%;
- margin: 0;
- overflow: hidden;
-
- #overlay {
- background-color: $grey;
- display: none;
- height: 100%;
- position: absolute;
- width: 100%;
- z-index: 100;
-
- .error {
- display: flex;
- flex-direction: column;
- height: 100%;
- justify-content: center;
- width: 100%;
-
- #msg {
- align-self: center;
- color: $white;
- }
-
- input {
- align-self: center;
- margin: 16px;
- }
- }
- }
-
- #terminal {
- display: flex;
- height: 100%;
- position: relative;
- width: 100%;
- }
-
- #options {
- position: absolute;
- top: 1em;
- right: 1em;
- z-index: 20;
- height: 16px;
- width: 16px;
-
- a.toggler {
- display: inline-block;
- position: absolute;
- right: 1em;
- top: 0em;
- font-size: 16px;
- color: $lgrey;
- z-index: 20;
-
- :hover {
- color: $white;
- }
- }
-
- .editor {
- background-color: rgba(0, 0, 0, 0.85);
- padding: 0.5em;
- border-radius: 0.3em;
- border-color: rgba(255, 255, 255, 0.25);
- display: none;
- position: relative;
- height: 100%;
- width: 100%;
- top: 1em;
- right: 2em;
- color: #eee;
- font-size: 24px;
- }
- .editor.error {
- color: red;
- }
- }
-
- #options.opened {
- height: 50%;
- width: 50%;
- .editor {
- display: flex;
- }
- }
-
- .toastify {
- border-radius: 0;
- color: $black;
- }
-}
-
-.xterm .xterm-viewport {
- overflow-y: hidden;
-}
diff --git a/src/client/wetty.ts b/src/client/wetty.ts
new file mode 100644
index 0000000..868d882
--- /dev/null
+++ b/src/client/wetty.ts
@@ -0,0 +1,51 @@
+import _ from 'lodash';
+import { dom, library } from '@fortawesome/fontawesome-svg-core';
+import { faCogs } from '@fortawesome/free-solid-svg-icons';
+
+import { FileDownloader } from './wetty/download.js';
+import { disconnect } from './wetty/disconnect.js';
+import { mobileKeyboard } from './wetty/mobile.js';
+import { overlay } from './shared/elements.js';
+import { socket } from './wetty/socket.js';
+import { verifyPrompt } from './shared/verify.js';
+import { terminal } from './wetty/term.js';
+
+// Setup for fontawesome
+library.add(faCogs);
+dom.watch();
+
+socket.on('connect', () => {
+ const term = terminal(socket);
+ if (_.isUndefined(term)) return;
+
+ if (!_.isNull(overlay)) overlay.style.display = 'none';
+ window.addEventListener('beforeunload', verifyPrompt, false);
+
+ term.resizeTerm();
+ term.focus();
+ mobileKeyboard();
+ const fileDownloader = new FileDownloader();
+
+ term.onData((data: string) => {
+ socket.emit('input', data);
+ });
+ term.onResize((size: { cols: number; rows: number }) => {
+ socket.emit('resize', size);
+ });
+ socket
+ .on('data', (data: string) => {
+ const remainingData = fileDownloader.buffer(data);
+ if (remainingData) {
+ term.write(remainingData);
+ }
+ })
+ .on('login', () => {
+ term.writeln('');
+ term.resizeTerm();
+ })
+ .on('logout', disconnect)
+ .on('disconnect', disconnect)
+ .on('error', (err: string | null) => {
+ if (err) disconnect(err);
+ });
+});
diff --git a/src/client/wetty/disconnect.ts b/src/client/wetty/disconnect.ts
new file mode 100644
index 0000000..72a6cef
--- /dev/null
+++ b/src/client/wetty/disconnect.ts
@@ -0,0 +1,11 @@
+import _ from 'lodash';
+import { verifyPrompt } from '../shared/verify.js';
+import { overlay } from '../shared/elements.js';
+
+export function disconnect(reason: string): void {
+ if (_.isNull(overlay)) return;
+ overlay.style.display = 'block';
+ const msg = document.getElementById('msg');
+ if (!_.isUndefined(reason) && !_.isNull(msg)) msg.innerHTML = reason;
+ window.removeEventListener('beforeunload', verifyPrompt, false);
+}
diff --git a/src/client/wetty/download.spec.ts b/src/client/wetty/download.spec.ts
new file mode 100644
index 0000000..a7383f2
--- /dev/null
+++ b/src/client/wetty/download.spec.ts
@@ -0,0 +1,236 @@
+/* eslint-disable */
+
+import { expect } from 'chai';
+import 'mocha';
+import * as sinon from 'sinon';
+
+import { JSDOM } from 'jsdom';
+import { FileDownloader } from './download';
+
+describe('FileDownloader', () => {
+ const FILE_BEGIN = 'BEGIN';
+ const FILE_END = 'END';
+ let fileDownloader: any;
+
+ beforeEach(() => {
+ const { window } = new JSDOM(`...`);
+ global.document = window.document;
+ fileDownloader = new FileDownloader(() => {}, FILE_BEGIN, FILE_END);
+ });
+
+ afterEach(() => {
+ sinon.restore();
+ });
+
+ it('should return data before file markers', () => {
+ const onCompleteFileCallbackStub = sinon.stub(
+ fileDownloader,
+ 'onCompleteFileCallback',
+ );
+ expect(
+ fileDownloader.buffer(`DATA AT THE LEFT${FILE_BEGIN}FILE${FILE_END}`),
+ ).to.equal('DATA AT THE LEFT');
+ expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
+ expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
+ });
+
+ it('should return data after file markers', () => {
+ const onCompleteFileCallbackStub = sinon.stub(
+ fileDownloader,
+ 'onCompleteFileCallback',
+ );
+ expect(
+ fileDownloader.buffer(`${FILE_BEGIN}FILE${FILE_END}DATA AT THE RIGHT`),
+ ).to.equal('DATA AT THE RIGHT');
+ expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
+ expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
+ });
+
+ it('should return data before and after file markers', () => {
+ const onCompleteFileCallbackStub = sinon.stub(
+ fileDownloader,
+ 'onCompleteFileCallback',
+ );
+ expect(
+ fileDownloader.buffer(
+ `DATA AT THE LEFT${FILE_BEGIN}FILE${FILE_END}DATA AT THE RIGHT`,
+ ),
+ ).to.equal('DATA AT THE LEFTDATA AT THE RIGHT');
+ expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
+ expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
+ });
+
+ it('should return data before a beginning marker found', () => {
+ sinon.stub(fileDownloader, 'onCompleteFileCallback');
+ expect(fileDownloader.buffer(`DATA AT THE LEFT${FILE_BEGIN}FILE`)).to.equal(
+ 'DATA AT THE LEFT',
+ );
+ });
+
+ it('should return data after an ending marker found', () => {
+ const onCompleteFileCallbackStub = sinon.stub(
+ fileDownloader,
+ 'onCompleteFileCallback',
+ );
+ expect(fileDownloader.buffer(`${FILE_BEGIN}FI`)).to.equal('');
+ expect(fileDownloader.buffer(`LE${FILE_END}DATA AT THE RIGHT`)).to.equal(
+ 'DATA AT THE RIGHT',
+ );
+ expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
+ expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
+ });
+
+ it('should buffer across incomplete file begin marker sequence on two calls', () => {
+ fileDownloader = new FileDownloader(() => {}, 'BEGIN', 'END');
+ const onCompleteFileCallbackStub = sinon.stub(
+ fileDownloader,
+ 'onCompleteFileCallback',
+ );
+
+ expect(fileDownloader.buffer('BEG')).to.equal('');
+ expect(fileDownloader.buffer('INFILEEND')).to.equal('');
+ expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
+ expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
+ });
+
+ it('should buffer across incomplete file begin marker sequence on n calls', () => {
+ fileDownloader = new FileDownloader(() => {}, 'BEGIN', 'END');
+ const onCompleteFileCallbackStub = sinon.stub(
+ fileDownloader,
+ 'onCompleteFileCallback',
+ );
+
+ expect(fileDownloader.buffer('B')).to.equal('');
+ expect(fileDownloader.buffer('E')).to.equal('');
+ expect(fileDownloader.buffer('G')).to.equal('');
+ expect(fileDownloader.buffer('I')).to.equal('');
+ expect(fileDownloader.buffer('NFILE' + 'END')).to.equal('');
+ expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
+ expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
+ });
+
+ it('should buffer across incomplete file begin marker sequence with data on the left and right on multiple calls', () => {
+ fileDownloader = new FileDownloader(() => {}, 'BEGIN', 'END');
+ const onCompleteFileCallbackStub = sinon.stub(
+ fileDownloader,
+ 'onCompleteFileCallback',
+ );
+
+ expect(fileDownloader.buffer('DATA AT THE LEFT' + 'B')).to.equal(
+ 'DATA AT THE LEFT',
+ );
+ expect(fileDownloader.buffer('E')).to.equal('');
+ expect(fileDownloader.buffer('G')).to.equal('');
+ expect(fileDownloader.buffer('I')).to.equal('');
+ expect(fileDownloader.buffer('NFILE' + 'ENDDATA AT THE RIGHT')).to.equal(
+ 'DATA AT THE RIGHT',
+ );
+ expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
+ expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
+ });
+
+ it('should buffer across incomplete file begin marker sequence then handle false positive', () => {
+ fileDownloader = new FileDownloader(() => {}, 'BEGIN', 'END');
+ const onCompleteFileCallbackStub = sinon.stub(
+ fileDownloader,
+ 'onCompleteFileCallback',
+ );
+
+ expect(fileDownloader.buffer('DATA AT THE LEFT' + 'B')).to.equal(
+ 'DATA AT THE LEFT',
+ );
+ expect(fileDownloader.buffer('E')).to.equal('');
+ expect(fileDownloader.buffer('G')).to.equal('');
+ // This isn't part of the file_begin marker and should trigger the partial
+ // file begin marker to be returned with the normal data
+ expect(fileDownloader.buffer('ZDATA AT THE RIGHT')).to.equal(
+ 'BEGZDATA AT THE RIGHT',
+ );
+ expect(onCompleteFileCallbackStub.called).to.be.false;
+ });
+
+ it('should buffer across incomplete file end marker sequence on two calls', () => {
+ fileDownloader = new FileDownloader(() => {}, 'BEGIN', 'END');
+ const mockFilePart1 = 'DATA AT THE LEFTBEGINFILEE';
+ const mockFilePart2 = 'NDDATA AT THE RIGHT';
+
+ const onCompleteFileCallbackStub = sinon.stub(
+ fileDownloader,
+ 'onCompleteFileCallback',
+ );
+ expect(fileDownloader.buffer(mockFilePart1)).to.equal('DATA AT THE LEFT');
+ expect(fileDownloader.buffer(mockFilePart2)).to.equal('DATA AT THE RIGHT');
+
+ expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
+ expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
+ });
+
+ it('should buffer across incomplete file end and file begin marker sequence with data on the left and right on multiple calls', () => {
+ fileDownloader = new FileDownloader(() => {}, 'BEGIN', 'END');
+ const onCompleteFileCallbackStub = sinon.stub(
+ fileDownloader,
+ 'onCompleteFileCallback',
+ );
+
+ expect(fileDownloader.buffer('DATA AT THE LEFT' + 'BE')).to.equal(
+ 'DATA AT THE LEFT',
+ );
+ expect(fileDownloader.buffer('G')).to.equal('');
+ expect(fileDownloader.buffer('I')).to.equal('');
+ expect(fileDownloader.buffer('NFILEE')).to.equal('');
+ expect(fileDownloader.buffer('N')).to.equal('');
+ expect(fileDownloader.buffer('DDATA AT THE RIGHT')).to.equal(
+ 'DATA AT THE RIGHT',
+ );
+ expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
+ expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
+ });
+
+ it('should be able to handle multiple files', () => {
+ fileDownloader = new FileDownloader(() => {}, 'BEGIN', 'END');
+ const onCompleteFileCallbackStub = sinon.stub(
+ fileDownloader,
+ 'onCompleteFileCallback',
+ );
+
+ expect(
+ fileDownloader.buffer(
+ 'DATA AT THE LEFT' +
+ 'BEGIN' +
+ 'FILE1' +
+ 'END' +
+ 'SECOND DATA' +
+ 'BEGIN',
+ ),
+ ).to.equal('DATA AT THE LEFT' + 'SECOND DATA');
+ expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
+ expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE1');
+
+ expect(fileDownloader.buffer('FILE2')).to.equal('');
+ expect(fileDownloader.buffer('E')).to.equal('');
+ expect(fileDownloader.buffer('NDRIGHT')).to.equal('RIGHT');
+ expect(onCompleteFileCallbackStub.calledTwice).to.be.true;
+ expect(onCompleteFileCallbackStub.getCall(1).args[0]).to.equal('FILE2');
+ });
+
+ it('should be able to handle multiple files with an ending marker', () => {
+ fileDownloader = new FileDownloader(() => {}, 'BEGIN', 'END');
+ const onCompleteFileCallbackStub = sinon.stub(
+ fileDownloader,
+ 'onCompleteFileCallback',
+ );
+
+ expect(
+ fileDownloader.buffer('DATA AT THE LEFT' + 'BEGIN' + 'FILE1' + 'EN'),
+ ).to.equal('DATA AT THE LEFT');
+ expect(onCompleteFileCallbackStub.calledOnce).to.be.false;
+ expect(
+ fileDownloader.buffer('D' + 'SECOND DATA' + 'BEGIN' + 'FILE2' + 'EN'),
+ ).to.equal('SECOND DATA');
+ expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
+ expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE1');
+ expect(fileDownloader.buffer('D')).to.equal('');
+ expect(onCompleteFileCallbackStub.calledTwice).to.be.true;
+ expect(onCompleteFileCallbackStub.getCall(1).args[0]).to.equal('FILE2');
+ });
+});
diff --git a/src/client/download.ts b/src/client/wetty/download.ts
similarity index 62%
rename from src/client/download.ts
rename to src/client/wetty/download.ts
index 7db2064..0eaf508 100644
--- a/src/client/download.ts
+++ b/src/client/wetty/download.ts
@@ -1,6 +1,61 @@
+// @ts-ignore
+import Toastify from 'toastify-js';
+import fileType from 'file-type';
+
const DEFAULT_FILE_BEGIN = '\u001b[5i';
const DEFAULT_FILE_END = '\u001b[4i';
+function onCompleteFile(bufferCharacters: string): void {
+ let fileCharacters = bufferCharacters;
+ // Try to decode it as base64, if it fails we assume it's not base64
+ try {
+ fileCharacters = window.atob(fileCharacters);
+ } catch (err) {
+ // Assuming it's not base64...
+ }
+
+ const bytes = new Uint8Array(fileCharacters.length);
+ for (let i = 0; i < fileCharacters.length; i += 1) {
+ bytes[i] = fileCharacters.charCodeAt(i);
+ }
+
+ let mimeType = 'application/octet-stream';
+ let fileExt = '';
+ const typeData = fileType(bytes);
+ if (typeData) {
+ mimeType = typeData.mime;
+ fileExt = typeData.ext;
+ }
+ // Check if the buffer is ASCII
+ // Ref: https://stackoverflow.com/a/14313213
+ // eslint-disable-next-line no-control-regex
+ else if (/^[\x00-\xFF]*$/.test(fileCharacters)) {
+ mimeType = 'text/plain';
+ fileExt = 'txt';
+ }
+ const fileName = `file-${new Date()
+ .toISOString()
+ .split('.')[0]
+ .replace(/-/g, '')
+ .replace('T', '')
+ .replace(/:/g, '')}${fileExt ? `.${fileExt}` : ''}`;
+
+ const blob = new Blob([new Uint8Array(bytes.buffer)], {
+ type: mimeType,
+ });
+ const blobUrl = URL.createObjectURL(blob);
+
+ Toastify({
+ text: `Download ready: ${fileName}`,
+ duration: 10000,
+ newWindow: true,
+ gravity: 'bottom',
+ position: 'right',
+ backgroundColor: '#fff',
+ stopOnFocus: true,
+ }).showToast();
+}
+
export class FileDownloader {
fileBuffer: string[];
fileBegin: string;
@@ -9,15 +64,15 @@ export class FileDownloader {
onCompleteFileCallback: Function;
constructor(
- onCompleteFileCallback: (file: string) => void,
+ onCompleteFileCallback: Function = onCompleteFile,
fileBegin: string = DEFAULT_FILE_BEGIN,
- fileEnd: string = DEFAULT_FILE_END
+ fileEnd: string = DEFAULT_FILE_END,
) {
this.fileBuffer = [];
- this.onCompleteFileCallback = onCompleteFileCallback;
this.fileBegin = fileBegin;
this.fileEnd = fileEnd;
this.partialFileBegin = '';
+ this.onCompleteFileCallback = onCompleteFileCallback;
}
bufferCharacter(character: string): string {
@@ -69,13 +124,13 @@ export class FileDownloader {
this.fileBuffer.length >= this.fileBegin.length + this.fileEnd.length &&
this.fileBuffer.slice(-this.fileEnd.length).join('') === this.fileEnd
) {
- this.onCompleteFile(
+ this.onCompleteFileCallback(
this.fileBuffer
.slice(
this.fileBegin.length,
- this.fileBuffer.length - this.fileEnd.length
+ this.fileBuffer.length - this.fileEnd.length,
)
- .join('')
+ .join(''),
);
this.fileBuffer = [];
}
@@ -93,13 +148,6 @@ export class FileDownloader {
) {
return data;
}
- return data
- .split('')
- .map(this.bufferCharacter.bind(this))
- .join('');
- }
-
- onCompleteFile(bufferCharacters: string): void {
- this.onCompleteFileCallback(bufferCharacters);
+ return data.split('').map(this.bufferCharacter.bind(this)).join('');
}
}
diff --git a/src/client/mobile.ts b/src/client/wetty/mobile.ts
similarity index 77%
rename from src/client/mobile.ts
rename to src/client/wetty/mobile.ts
index a9d7cd6..014c791 100644
--- a/src/client/mobile.ts
+++ b/src/client/wetty/mobile.ts
@@ -1,8 +1,8 @@
-import { isNull } from 'lodash';
+import _ from 'lodash';
-export default function mobileKeyboard(): void {
+export function mobileKeyboard(): void {
const [screen] = document.getElementsByClassName('xterm-screen');
- if (isNull(screen)) return;
+ if (_.isNull(screen)) return;
screen.setAttribute('contenteditable', 'true');
screen.setAttribute('spellcheck', 'false');
screen.setAttribute('autocorrect', 'false');
diff --git a/src/client/wetty/shared/type.ts b/src/client/wetty/shared/type.ts
new file mode 100644
index 0000000..44d68e6
--- /dev/null
+++ b/src/client/wetty/shared/type.ts
@@ -0,0 +1,5 @@
+import { Terminal } from 'xterm';
+
+export class Term extends Terminal {
+ resizeTerm(): void {}
+}
diff --git a/src/client/socket.ts b/src/client/wetty/socket.ts
similarity index 87%
rename from src/client/socket.ts
rename to src/client/wetty/socket.ts
index 0aec144..4195c8d 100644
--- a/src/client/socket.ts
+++ b/src/client/wetty/socket.ts
@@ -1,4 +1,4 @@
-import * as io from 'socket.io-client';
+import io from 'socket.io-client';
const userRegex = new RegExp('ssh/[^/]+$');
export const trim = (str: string): string => str.replace(/\/*$/, '');
diff --git a/src/client/wetty/term.ts b/src/client/wetty/term.ts
new file mode 100644
index 0000000..08cc418
--- /dev/null
+++ b/src/client/wetty/term.ts
@@ -0,0 +1,24 @@
+import type { Socket } from 'socket.io-client';
+import _ from 'lodash';
+import { FitAddon } from 'xterm-addon-fit';
+import { Terminal } from 'xterm';
+
+import type { Term } from './shared/type';
+import { configureTerm } from './term/confiruragtion.js';
+import { terminal as termElement } from '../shared/elements.js';
+
+export function terminal(socket: typeof Socket): Term | undefined {
+ const term = new Terminal() as Term;
+ if (_.isNull(termElement)) return;
+ const fitAddon = new FitAddon();
+ term.loadAddon(fitAddon);
+ term.open(termElement);
+ term.resizeTerm = () => {
+ fitAddon.fit();
+ socket.emit('resize', { cols: term.cols, rows: term.rows });
+ };
+ configureTerm(term);
+ window.onresize = term.resizeTerm;
+
+ return term;
+}
diff --git a/src/client/wetty/term/confiruragtion.ts b/src/client/wetty/term/confiruragtion.ts
new file mode 100644
index 0000000..9c0c6e2
--- /dev/null
+++ b/src/client/wetty/term/confiruragtion.ts
@@ -0,0 +1,37 @@
+import _ from 'lodash';
+
+import type { Term } from '../shared/type';
+import { copySelected, copyShortcut } from './confiruragtion/clipboard';
+import { onInput } from './confiruragtion/editor';
+import { editor } from '../../shared/elements';
+import { loadOptions } from './confiruragtion/load';
+
+export function configureTerm(term: Term): void {
+ const options = loadOptions();
+ Object.entries(options).forEach(([key, value]) => {
+ term.setOption(key, value);
+ });
+ const config = JSON.stringify(options, null, 2);
+ if (!_.isNull(editor)) {
+ editor.value = config;
+ editor.addEventListener('keyup', onInput(term));
+ const toggle = document.querySelector('#options .toggler');
+ const optionsElem = document.getElementById('options');
+ if (!_.isNull(toggle) && !_.isNull(optionsElem)) {
+ toggle.addEventListener('click', e => {
+ optionsElem.classList.toggle('opened');
+ e.preventDefault();
+ });
+ }
+ }
+
+ term.attachCustomKeyEventHandler(copyShortcut);
+
+ document.addEventListener(
+ 'mouseup',
+ () => {
+ if (term.hasSelection()) copySelected(term.getSelection());
+ },
+ false,
+ );
+}
diff --git a/src/client/copyToClipboard.ts b/src/client/wetty/term/confiruragtion/clipboard.ts
similarity index 74%
rename from src/client/copyToClipboard.ts
rename to src/client/wetty/term/confiruragtion/clipboard.ts
index efad698..a87d0b3 100644
--- a/src/client/copyToClipboard.ts
+++ b/src/client/wetty/term/confiruragtion/clipboard.ts
@@ -1,7 +1,11 @@
-// NOTE text selection on double click or select
+/**
+ Copy text selection to clipboard on double click or select
+ @param text - the selected text to copy
+ @returns boolean to indicate success or failure
+ */
export function copySelected(text: string): boolean {
- if (window.clipboardData?.setData) {
- window.clipboardData.setData('Text', text);
+ if ((window as any).clipboardData?.setData) {
+ (window as any).clipboardData.setData('Text', text);
return true;
}
if (
diff --git a/src/client/wetty/term/confiruragtion/editor.ts b/src/client/wetty/term/confiruragtion/editor.ts
new file mode 100644
index 0000000..95ce885
--- /dev/null
+++ b/src/client/wetty/term/confiruragtion/editor.ts
@@ -0,0 +1,23 @@
+import JSON5 from 'json5';
+
+import type { Term } from '../../shared/type';
+import { editor } from '../../../shared/elements';
+
+export const onInput = (term: Term) => (): void => {
+ try {
+ const updated = JSON5.parse(editor.value);
+ const updatedConf = JSON.stringify(updated, null, 2);
+ if (localStorage.options === updatedConf) return;
+ Object.keys(updated).forEach(key => {
+ const value = updated[key];
+ term.setOption(key, value);
+ });
+ term.resizeTerm();
+ editor.value = updatedConf;
+ editor.classList.remove('error');
+ localStorage.options = updatedConf;
+ } catch {
+ // skip
+ editor.classList.add('error');
+ }
+};
diff --git a/src/client/options.ts b/src/client/wetty/term/confiruragtion/load.ts
similarity index 55%
rename from src/client/options.ts
rename to src/client/wetty/term/confiruragtion/load.ts
index 2db78af..ecfcae6 100644
--- a/src/client/options.ts
+++ b/src/client/wetty/term/confiruragtion/load.ts
@@ -1,9 +1,9 @@
-import { isUndefined } from 'lodash';
+import _ from 'lodash';
-export default function loadOptions(): object {
+export function loadOptions(): object {
const defaultOptions = { fontSize: 14 };
try {
- return isUndefined(localStorage.options)
+ return _.isUndefined(localStorage.options)
? defaultOptions
: JSON.parse(localStorage.options);
} catch {
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..668304c
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,106 @@
+/**
+ * Create WeTTY server
+ * @module WeTTy
+ */
+import yargs from 'yargs';
+import { logger } from './shared/logger.js';
+import { start } from './server.js';
+import { loadConfigFile, mergeCliConf } from './shared/config.js';
+
+const opts = yargs
+ .options('conf', {
+ type: 'string',
+ description: 'config file to load config from',
+ })
+ .option('ssl-key', {
+ type: 'string',
+ description: 'path to SSL key',
+ })
+ .option('ssl-cert', {
+ type: 'string',
+ description: 'path to SSL certificate',
+ })
+ .option('ssh-host', {
+ description: 'ssh server host',
+ type: 'string',
+ })
+ .option('ssh-port', {
+ description: 'ssh server port',
+ type: 'number',
+ })
+ .option('ssh-user', {
+ description: 'ssh user',
+ type: 'string',
+ })
+ .option('title', {
+ description: 'window title',
+ type: 'string',
+ })
+ .option('ssh-auth', {
+ description:
+ 'defaults to "password", you can use "publickey,password" instead',
+ type: 'string',
+ })
+ .option('ssh-pass', {
+ description: 'ssh password',
+ type: 'string',
+ })
+ .option('ssh-key', {
+ demand: false,
+ description:
+ 'path to an optional client private key (connection will be password-less and insecure!)',
+ type: 'string',
+ })
+ .option('force-ssh', {
+ description: 'Connecting through ssh even if running as root',
+ type: 'boolean',
+ })
+ .option('known-hosts', {
+ description: 'path to known hosts file',
+ type: 'string',
+ })
+ .option('base', {
+ alias: 'b',
+ description: 'base path to wetty',
+ type: 'string',
+ })
+ .option('port', {
+ alias: 'p',
+ description: 'wetty listen port',
+ type: 'number',
+ })
+ .option('host', {
+ description: 'wetty listen host',
+ type: 'string',
+ })
+ .option('command', {
+ alias: 'c',
+ description: 'command to run in shell',
+ type: 'string',
+ })
+ .option('allow-iframe', {
+ description:
+ 'Allow wetty to be embedded in an iframe, defaults to allowing same origin',
+ type: 'boolean',
+ })
+ .option('help', {
+ alias: 'h',
+ type: 'boolean',
+ description: 'Print help message',
+ })
+ .boolean('allow_discovery').argv;
+
+if (!opts.help) {
+ loadConfigFile(opts.conf)
+ .then(config => mergeCliConf(opts, config))
+ .then(conf =>
+ start(conf.ssh, conf.server, conf.command, conf.forceSSH, conf.ssl),
+ )
+ .catch((err: Error) => {
+ logger.error(err);
+ process.exitCode = 1;
+ });
+} else {
+ yargs.showHelp();
+ process.exitCode = 0;
+}
diff --git a/src/server.ts b/src/server.ts
new file mode 100644
index 0000000..27f9ec5
--- /dev/null
+++ b/src/server.ts
@@ -0,0 +1,73 @@
+/**
+ * Create WeTTY server
+ * @module WeTTy
+ */
+import type SocketIO from 'socket.io';
+import type { SSH, SSL, Server } from './shared/interfaces.js';
+import { getCommand } from './server/command.js';
+import { logger } from './shared/logger.js';
+import { login } from './server/login.js';
+import { server } from './server/socketServer.js';
+import { spawn } from './server/spawn.js';
+import {
+ sshDefault,
+ serverDefault,
+ forceSSHDefault,
+ defaultCommand,
+} from './shared/defaults.js';
+
+/**
+ * Starts WeTTy Server
+ * @name startServer
+ * @returns Promise that resolves SocketIO server
+ */
+export async function start(
+ ssh: SSH = sshDefault,
+ serverConf: Server = serverDefault,
+ command: string = defaultCommand,
+ forcessh: boolean = forceSSHDefault,
+ ssl?: SSL,
+): Promise {
+ if (ssh.key) {
+ logger.warn(`!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+! Password-less auth enabled using private key from ${ssh.key}.
+! This is dangerous, anything that reaches the wetty server
+! will be able to run remote operations without authentication.
+!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!`);
+ }
+
+ const io = await server(serverConf, ssl);
+ /**
+ * Wetty server connected too
+ * @fires WeTTy#connnection
+ */
+ io.on('connection', async (socket: SocketIO.Socket) => {
+ /**
+ * @event wetty#connection
+ * @name connection
+ */
+ logger.info('Connection accepted.');
+ const { args, user: sshUser } = getCommand(socket, ssh, command, forcessh);
+ logger.debug('Command Generated', {
+ user: sshUser,
+ cmd: args.join(' '),
+ });
+
+ if (sshUser) {
+ spawn(socket, args);
+ } else {
+ try {
+ const username = await login(socket);
+ args[1] = `${username.trim()}@${args[1]}`;
+ logger.debug('Spawning term', {
+ username: username.trim(),
+ cmd: args.join(' ').trim(),
+ });
+ spawn(socket, args);
+ } catch (error) {
+ logger.info('Disconnect signal sent', { err: error });
+ }
+ }
+ });
+ return io;
+}
diff --git a/src/server/cli/index.ts b/src/server/cli/index.ts
deleted file mode 100644
index 5941383..0000000
--- a/src/server/cli/index.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import * as yargs from 'yargs';
-import { logger } from '../utils';
-import WeTTy from '../wetty';
-import { CLI } from './options';
-import { unWrapArgs } from './parseArgs';
-
-export default function init(opts: CLI): void {
- if (!opts.help) {
- const { ssh, server, command, forcessh, ssl } = unWrapArgs(opts);
- WeTTy(ssh, server, command, forcessh, ssl).catch(err => {
- logger.error(err);
- process.exitCode = 1;
- });
- } else {
- yargs.showHelp();
- process.exitCode = 0;
- }
-}
diff --git a/src/server/cli/options.ts b/src/server/cli/options.ts
deleted file mode 100644
index 32510c3..0000000
--- a/src/server/cli/options.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-export interface Options {
- sshhost: string;
- sshport: number;
- sshuser: string;
- sshauth: string;
- sshkey?: string;
- sshpass?: string;
- knownhosts: string;
- sslkey?: string;
- sslcert?: string;
- base: string;
- host: string;
- port: number;
- title: string;
- command?: string;
- forcessh?: boolean;
- bypasshelmet?: boolean;
-}
-
-export interface CLI extends Options {
- help: boolean;
-}
diff --git a/src/server/cli/parseArgs.ts b/src/server/cli/parseArgs.ts
deleted file mode 100644
index c322d9a..0000000
--- a/src/server/cli/parseArgs.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { isUndefined } from 'lodash';
-import { SSH, SSL, Server } from '../interfaces';
-import { Options } from './options';
-
-export function unWrapArgs(
- args: Options
-): { ssh: SSH; server: Server; command?: string; forcessh?: boolean; ssl?: SSL } {
- return {
- ssh: {
- user: args.sshuser,
- host: args.sshhost,
- auth: args.sshauth,
- port: args.sshport,
- pass: args.sshpass,
- key: args.sshkey,
- knownhosts: args.knownhosts,
- },
- server: {
- base: args.base,
- host: args.host,
- port: args.port,
- title: args.title,
- bypasshelmet: args.bypasshelmet || false,
- },
- command: args.command,
- forcessh: args.forcessh,
- ssl:
- isUndefined(args.sslkey) || isUndefined(args.sslcert)
- ? undefined
- : { key: args.sslkey, cert: args.sslcert },
- };
-}
diff --git a/src/server/command.ts b/src/server/command.ts
new file mode 100644
index 0000000..3b51b1e
--- /dev/null
+++ b/src/server/command.ts
@@ -0,0 +1,52 @@
+import url from 'url';
+import type { Socket } from 'socket.io';
+import type { SSH } from '../shared/interfaces';
+import { address } from './command/address.js';
+import { loginOptions } from './command/login.js';
+import { sshOptions } from './command/ssh.js';
+
+const localhost = (host: string): boolean =>
+ process.getuid() === 0 &&
+ (host === 'localhost' || host === '0.0.0.0' || host === '127.0.0.1');
+
+const urlArgs = (
+ referer: string,
+ def: { [s: string]: string },
+): { [s: string]: string } =>
+ Object.assign(def, url.parse(referer, true).query);
+
+export const getCommand = (
+ {
+ request: {
+ headers: { referer },
+ },
+ client: {
+ conn: { remoteAddress },
+ },
+ }: Socket,
+ { user, host, port, auth, pass, key, knownHosts }: SSH,
+ command: string,
+ forcessh: boolean,
+): { args: string[]; user: boolean } => ({
+ args:
+ !forcessh && localhost(host)
+ ? loginOptions(command, remoteAddress)
+ : sshOptions(
+ {
+ ...urlArgs(referer, {
+ port: `${port}`,
+ pass: pass || '',
+ command,
+ auth,
+ knownHosts,
+ }),
+ host: address(referer, user, host),
+ },
+ key,
+ ),
+ user:
+ (!forcessh && localhost(host)) ||
+ user !== '' ||
+ user.includes('@') ||
+ address(referer, user, host).includes('@'),
+});
diff --git a/src/server/command/address.ts b/src/server/command/address.ts
index f7bbf33..4ff8c9b 100644
--- a/src/server/command/address.ts
+++ b/src/server/command/address.ts
@@ -1,8 +1,4 @@
-export default function address(
- referer: string,
- user: string,
- host: string
-): string {
+export function address(referer: string, user: string, host: string): string {
const match = referer.match('.+/ssh/([^/]+)$');
const fallback = user ? `${user}@${host}` : host;
return match ? `${match[1].split('?')[0]}@${host}` : fallback;
diff --git a/src/server/command/index.ts b/src/server/command/index.ts
deleted file mode 100644
index 41f7b71..0000000
--- a/src/server/command/index.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import * as url from 'url';
-import { Socket } from 'socket.io';
-import { SSH } from '../interfaces';
-import address from './address';
-import loginOptions from './login';
-import sshOptions from './ssh';
-
-const localhost = (host: string): boolean =>
- process.getuid() === 0 &&
- (host === 'localhost' || host === '0.0.0.0' || host === '127.0.0.1');
-
-const urlArgs = (
- referer: string,
- def: { [s: string]: string }
-): { [s: string]: string } =>
- Object.assign(def, url.parse(referer, true).query);
-
-export default (
- {
- request: {
- headers: { referer },
- },
- client: {
- conn: { remoteAddress },
- },
- }: Socket,
- { user, host, port, auth, pass, key, knownhosts }: SSH,
- command: string,
- forcessh: boolean
-): { args: string[]; user: boolean } => ({
- args: !forcessh && localhost(host)
- ? loginOptions(command, remoteAddress)
- : sshOptions(
- { ...urlArgs(referer, {
- port: `${port}`,
- pass: pass || '',
- command,
- auth,
- knownhosts,
- }),
- host: address(referer, user, host)
- },
- key
- ),
- user:
- (!forcessh && localhost(host)) ||
- user !== '' ||
- user.includes('@') ||
- address(referer, user, host).includes('@'),
-});
diff --git a/src/server/command/login.ts b/src/server/command/login.ts
index 6453d06..805bf36 100644
--- a/src/server/command/login.ts
+++ b/src/server/command/login.ts
@@ -1,14 +1,11 @@
-import { isUndefined } from 'lodash';
+import isUndefined from 'lodash/isUndefined.js';
const getRemoteAddress = (remoteAddress: string): string =>
isUndefined(remoteAddress.split(':')[3])
? 'localhost'
: remoteAddress.split(':')[3];
-export default function loginOptions(
- command: string,
- remoteAddress: string
-): string[] {
+export function loginOptions(command: string, remoteAddress: string): string[] {
return command === 'login'
? [command, '-h', getRemoteAddress(remoteAddress)]
: [command];
diff --git a/src/server/command/parse.ts b/src/server/command/parse.ts
deleted file mode 100644
index 7ea4d93..0000000
--- a/src/server/command/parse.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { isUndefined } from 'lodash';
-
-export default function parseCommand(command: string, path?: string): string {
- if (command === 'login' && isUndefined(path)) return '';
- return !isUndefined(path)
- ? `$SHELL -c "cd ${path};${command === 'login' ? '$SHELL' : command}"`
- : command;
-}
diff --git a/src/server/command/ssh.ts b/src/server/command/ssh.ts
index fc0e761..1652643 100644
--- a/src/server/command/ssh.ts
+++ b/src/server/command/ssh.ts
@@ -1,21 +1,12 @@
-import { isUndefined } from 'lodash';
-import parseCommand from './parse';
-import logger from '../utils/logger';
+import isUndefined from 'lodash/isUndefined.js';
+import { logger } from '../../shared/logger.js';
-export default function sshOptions(
- {
- pass,
- path,
- command,
- host,
- port,
- auth,
- knownhosts,
- }: { [s: string]: string },
- key?: string
+export function sshOptions(
+ { pass, path, command, host, port, auth, knownHosts }: Record,
+ key?: string,
): string[] {
const cmd = parseCommand(command, path);
- const hostChecking = knownhosts !== '/dev/null' ? 'yes' : 'no';
+ const hostChecking = knownHosts !== '/dev/null' ? 'yes' : 'no';
const sshRemoteOptsBase = [
'ssh',
host,
@@ -25,7 +16,7 @@ export default function sshOptions(
'-o',
`PreferredAuthentications=${auth}`,
'-o',
- `UserKnownHostsFile=${knownhosts}`,
+ `UserKnownHostsFile=${knownHosts}`,
'-o',
`StrictHostKeyChecking=${hostChecking}`,
];
@@ -40,8 +31,12 @@ export default function sshOptions(
sshRemoteOptsBase.splice(sshRemoteOptsBase.indexOf('-o'), 2);
}
- if (cmd === '') {
- return sshRemoteOptsBase;
- }
- return sshRemoteOptsBase.concat([cmd]);
+ return cmd === '' ? sshRemoteOptsBase : sshRemoteOptsBase.concat([cmd]);
+}
+
+function parseCommand(command: string, path?: string): string {
+ if (command === 'login' && isUndefined(path)) return '';
+ return !isUndefined(path)
+ ? `$SHELL -c "cd ${path};${command === 'login' ? '$SHELL' : command}"`
+ : command;
}
diff --git a/src/server/index.ts b/src/server/index.ts
deleted file mode 100644
index 794f010..0000000
--- a/src/server/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import WeTTy from './wetty';
-import init from './cli';
-
-export default { start: WeTTy, init };
diff --git a/src/server/wetty/term/login.ts b/src/server/login.ts
similarity index 64%
rename from src/server/wetty/term/login.ts
rename to src/server/login.ts
index 3018bd7..7dcf6f7 100644
--- a/src/server/wetty/term/login.ts
+++ b/src/server/login.ts
@@ -1,5 +1,14 @@
-import { spawn } from 'node-pty';
-import { xterm } from './xterm';
+import type SocketIO from 'socket.io';
+import pty from 'node-pty';
+import { dirname, resolve as resolvePath } from 'path';
+import { fileURLToPath } from 'url';
+import { xterm } from './shared/xterm.js';
+
+const executable = resolvePath(
+ dirname(fileURLToPath(import.meta.url)),
+ '..',
+ 'buffer.js',
+);
export function login(socket: SocketIO.Socket): Promise {
// Check request-header for username
@@ -12,13 +21,13 @@ export function login(socket: SocketIO.Socket): Promise {
// Request carries no username information
// Create terminal and ask user for username
- const term = spawn('/usr/bin/env', ['node', `${__dirname}/buffer.js`], xterm);
+ const term = pty.spawn('/usr/bin/env', ['node', executable], xterm);
let buf = '';
return new Promise((resolve, reject) => {
term.on('exit', () => {
resolve(buf);
});
- term.on('data', data => {
+ term.on('data', (data: string) => {
socket.emit('data', data);
});
socket
diff --git a/src/server/wetty/term/xterm.ts b/src/server/shared/xterm.ts
similarity index 60%
rename from src/server/wetty/term/xterm.ts
rename to src/server/shared/xterm.ts
index 99c26c4..b4d4480 100644
--- a/src/server/wetty/term/xterm.ts
+++ b/src/server/shared/xterm.ts
@@ -1,5 +1,5 @@
-import { IPtyForkOptions } from 'node-pty';
-import { isUndefined } from 'lodash';
+import isUndefined from 'lodash/isUndefined.js';
+import type { IPtyForkOptions } from 'node-pty';
export const xterm: IPtyForkOptions = {
name: 'xterm-256color',
@@ -10,6 +10,6 @@ export const xterm: IPtyForkOptions = {
{},
...Object.keys(process.env)
.filter((key: string) => !isUndefined(process.env[key]))
- .map((key: string) => ({ [key]: process.env[key] }))
+ .map((key: string) => ({ [key]: process.env[key] })),
),
};
diff --git a/src/server/socketServer.ts b/src/server/socketServer.ts
new file mode 100644
index 0000000..e5fb112
--- /dev/null
+++ b/src/server/socketServer.ts
@@ -0,0 +1,44 @@
+import type SocketIO from 'socket.io';
+import express from 'express';
+import compression from 'compression';
+import winston from 'express-winston';
+
+import type { SSL, SSLBuffer, Server } from '../shared/interfaces.js';
+import { favicon, redirect } from './socketServer/middleware.js';
+import { html } from './socketServer/html.js';
+import { listen } from './socketServer/socket.js';
+import { logger } from '../shared/logger.js';
+import { serveStatic, trim } from './socketServer/assets.js';
+import { policies } from './socketServer/security.js';
+import { loadSSL } from './socketServer/ssl.js';
+
+export async function server(
+ { base, port, host, title, allowIframe }: Server,
+ ssl?: SSL,
+): Promise {
+ const basePath = trim(base);
+ logger.info('Starting server', {
+ ssl,
+ port,
+ base,
+ title,
+ });
+
+ const app = express();
+ const client = html(basePath, title);
+ app
+ .use(`${basePath}/web_modules`, serveStatic('web_modules'))
+ .use(`${basePath}/assets`, serveStatic('assets'))
+ .use(`${basePath}/client`, serveStatic('client'))
+ .use(winston.logger(logger))
+ .use(compression())
+ .use(favicon)
+ .use(redirect)
+ .use(policies(allowIframe))
+ .get(basePath, client)
+ .get(`${basePath}/ssh/:user`, client);
+
+ const sslBuffer: SSLBuffer = await loadSSL(ssl);
+
+ return listen(app, host, port, basePath, sslBuffer);
+}
diff --git a/src/server/socketServer/assets.ts b/src/server/socketServer/assets.ts
new file mode 100644
index 0000000..ef08aa2
--- /dev/null
+++ b/src/server/socketServer/assets.ts
@@ -0,0 +1,6 @@
+import { resolve } from 'path';
+import express from 'express';
+
+export const trim = (str: string): string => str.replace(/\/*$/, '');
+export const serveStatic = (path: string) =>
+ express.static(resolve(process.cwd(), 'build', path));
diff --git a/src/server/socketServer/html.ts b/src/server/socketServer/html.ts
index b6f612c..47785ed 100644
--- a/src/server/socketServer/html.ts
+++ b/src/server/socketServer/html.ts
@@ -1,21 +1,21 @@
-import * as express from 'express';
+import type express from 'express';
+import { isDev } from '../../shared/env.js';
-export default (base: string, title: string) => (
- req: express.Request,
- res: express.Response
-): void => {
- const resourcePath = /^\/ssh\//.test(req.url.replace(base, '/'))
- ? '../'
- : base;
+const jsFiles = isDev ? ['dev', 'wetty'] : ['wetty'];
+const cssFiles = ['styles', 'options', 'overlay', 'terminal'];
- res.send(`
+const render = (
+ title: string,
+ css: string[],
+ js: string[],
+): string => `
-
+
${title}
-
+ ${css.map(file => ``).join('\n')}
@@ -27,11 +27,25 @@ export default (base: string, title: string) => (
+ alt="Toggle options"
+ >
-
+ ${js
+ .map(file => ``)
+ .join('\n')}
-`);
-};
+