Initial commit

This commit is contained in:
NI
2019-08-07 15:56:51 +08:00
commit 02f14eb14f
206 changed files with 38863 additions and 0 deletions

73
ui/app.css Normal file
View File

@@ -0,0 +1,73 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@charset "utf-8";
#app {
padding: 0;
margin: 0;
width: 100%;
height: 100%;
position: relative;
min-width: 320px;
}
body.app-error {
background: #b44;
color: #fff;
}
#app-loading {
width: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
justify-items: center;
justify-content: center;
}
#app-loading-frame {
flex: 0 0;
padding: 30px;
text-align: center;
}
#app-loading-icon {
background: url("./widgets/busy.svg") center center no-repeat;
background-size: contain;
width: 100%;
height: 200px;
}
#app-loading-error {
font-size: 5em;
color: #fff;
}
#app-loading-title {
color: #fab;
font-size: 1.2em;
font-weight: lighter;
max-width: 500px;
margin: 0 auto;
}
#app-loading-title.error {
color: #fff;
}

304
ui/app.js Normal file
View File

@@ -0,0 +1,304 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import "./common.css";
import "./app.css";
import "./landing.css";
import { Socket } from "./socket.js";
import Vue from "vue";
import Home from "./home.vue";
import Auth from "./auth.vue";
import Loading from "./loading.vue";
import { Commands } from "./commands/commands.js";
import * as ssh from "./commands/ssh.js";
import * as telnet from "./commands/telnet.js";
import { Controls } from "./commands/controls.js";
import { Color as ControlColor } from "./commands/color.js";
import * as telnetctl from "./control/telnet.js";
import * as sshctl from "./control/ssh.js";
import * as xhr from "./xhr.js";
import * as cipher from "./crypto.js";
const maxTimeDiff = 20000;
const mainTemplate = `
<home
v-if="page == 'app'"
:host-path="hostPath"
:query="query"
:connection="socket"
:controls="controls"
:commands="commands"
@navigate-to="changeURLHash"></home>
<auth
v-else-if="page == 'auth'"
:error="authErr"
@auth="submitAuth"
></auth>
<loading v-else :error="loadErr"></loading>
`.trim();
let clientInitialized = false;
function startApp(rootEl) {
let uiControlColor = new ControlColor();
new Vue({
el: rootEl,
components: {
loading: Loading,
auth: Auth,
home: Home
},
data() {
return {
hostPath:
window.location.protocol +
"//" +
window.location.host +
window.location.pathname,
query:
window.location.hash.length > 0 &&
window.location.hash.indexOf("#") === 0
? window.location.hash.slice(1, window.location.hash.length)
: "",
page: "loading",
key: "",
authErr: "",
loadErr: "",
socket: null,
controls: new Controls([
new telnetctl.Telnet(uiControlColor),
new sshctl.SSH(uiControlColor)
]),
commands: new Commands([new telnet.Command(), new ssh.Command()])
};
},
watch: {
loadErr() {
this.isErrored()
? document.body.classList.add("app-error")
: document.body.classList.remove("app-error");
},
authErr() {
this.isErrored()
? document.body.classList.add("app-error")
: document.body.classList.remove("app-error");
}
},
mounted() {
this.tryInitialAuth();
},
methods: {
changeURLHash(newHash) {
window.location.hash = newHash;
},
isErrored() {
return this.authErr.length > 0 || this.loadErr.length > 0;
},
async getSocketAuthKey(privateKey, randomKey) {
const enc = new TextEncoder("utf-8");
return new Uint8Array(
await cipher.hmac512(enc.encode(privateKey), enc.encode(randomKey))
).slice(0, 32);
},
buildBackendSocketURL() {
let r = "";
switch (location.protocol) {
case "https:":
r = "wss://";
break;
default:
r = "ws://";
}
r += location.host + "/socket";
return r;
},
buildSocket(key, dialTimeout, heartbeatInterval) {
return new Socket(
this.buildBackendSocketURL(),
key,
dialTimeout * 1000,
heartbeatInterval * 1000
);
},
async tryInitialAuth() {
try {
let result = await this.doAuth("");
if (result.date) {
let serverTime = result.date.getTime(),
clientTime = new Date().getTime(),
timeDiff = Math.abs(serverTime - clientTime);
if (timeDiff > maxTimeDiff) {
this.loadErr =
"The time difference between this client " +
"and the backend server is beyond operational limit.\r\n\r\n" +
"Please adjust your time so both client and server are " +
"running at same date time";
return;
}
}
let self = this;
switch (result.result) {
case 200:
this.socket = this.buildSocket(
{
data: result.key,
async fetch() {
if (this.data) {
let dKey = this.data;
this.data = null;
return dKey;
}
let result = await self.doAuth("");
if (result.result !== 200) {
throw new Error(
"Unable to fetch key from remote, unexpected " +
"error code: " +
result.result
);
}
return result.key;
}
},
result.timeout,
result.heartbeat
);
this.page = "app";
break;
case 403:
this.page = "auth";
break;
default:
alert("Unexpected backend query status: " + result.result);
}
} catch (e) {
this.loadErr = "Unable to initialize client application: " + e;
}
},
async doAuth(privateKey) {
let result = await this.requestAuth(privateKey);
if (result.key) {
this.key = result.key;
}
return result;
},
async requestAuth(privateKey) {
let authKey =
!privateKey || !this.key
? null
: await this.getSocketAuthKey(privateKey, this.key);
let h = await xhr.head("/socket", {
"X-Key": authKey ? btoa(String.fromCharCode.apply(null, authKey)) : ""
});
let serverDate = h.getResponseHeader("Date");
return {
result: h.status,
key: h.getResponseHeader("X-Key"),
timeout: h.getResponseHeader("X-Timeout"),
heartbeat: h.getResponseHeader("X-Heartbeat"),
date: serverDate ? new Date(serverDate) : null
};
},
async submitAuth(passpharse) {
this.authErr = "";
try {
let result = await this.doAuth(passpharse);
switch (result.result) {
case 200:
this.socket = this.buildSocket(
{
data: passpharse,
fetch() {
return this.data;
}
},
result.timeout,
result.heartbeat
);
this.page = "app";
break;
case 403:
this.authErr = "Authentication has failed. Wrong passpharse?";
break;
default:
this.authErr =
"Unexpected backend query status: " + result.result;
}
} catch (e) {
this.authErr = "Unable to authenticate: " + e;
}
}
}
});
}
function initializeClient() {
if (clientInitialized) {
return;
}
clientInitialized = true;
document.body.classList.remove("landing");
let landingRoot = document.getElementById("landing");
landingRoot.parentNode.removeChild(landingRoot);
let normalRoot = document.createElement("div");
normalRoot.setAttribute("id", "app");
normalRoot.innerHTML = mainTemplate;
document.body.insertBefore(normalRoot, document.body.firstChild);
startApp(normalRoot);
}
window.addEventListener("load", initializeClient);
document.addEventListener("load", initializeClient);
document.addEventListener("DOMContentLoaded", initializeClient);

126
ui/auth.vue Normal file
View File

@@ -0,0 +1,126 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<template>
<div id="auth">
<div id="auth-frame">
<div id="auth-content">
<h1>Authentication required</h1>
<form class="form1" action="javascript:;" method="POST" @submit="auth">
<fieldset>
<div
class="field"
:class="{
error: passpharseErr.length > 0 || error.length > 0
}"
>
Passpharse
<input
v-model="passpharse"
v-focus="true"
:disabled="submitting"
type="password"
autocomplete="off"
name="field.field.name"
placeholder="----------"
autofocus="autofocus"
/>
<div
v-if="passpharseErr.length <= 0 && error.length <= 0"
class="message"
>
A valid password is required in order to use this
<a href="https://github.com/niruix/sshwifty">Sshwifty</a>
instance
</div>
<div v-else class="error">
{{ passpharseErr || error }}
</div>
</div>
<div class="field">
<button type="submit" :disabled="submitting" @click="auth">
Authenticate
</button>
</div>
</fieldset>
</form>
</div>
</div>
</div>
</template>
<script>
export default {
directives: {
focus: {
inserted(el, binding) {
if (!binding.value) {
return;
}
el.focus();
}
}
},
props: {
error: {
type: String,
default: ""
}
},
data() {
return {
submitting: false,
passpharse: "",
passpharseErr: ""
};
},
watch: {
error(newVal) {
if (newVal.length > 0) {
this.submitting = false;
}
}
},
mounted() {},
methods: {
auth() {
if (this.passpharse.length <= 0) {
this.passpharseErr = "Passpharse cannot be empty";
return;
}
if (this.submitting) {
return;
}
this.submitting = true;
this.passpharseErr = "";
this.$emit("auth", this.passpharse);
}
}
};
</script>

50
ui/body.html Normal file
View File

@@ -0,0 +1,50 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<div id="landing">
<div id="landing-message">
<div id="landing-message-logo"></div>
<h1 id="landing-message-title">Loading Sshwifty</h1>
<div id="landing-message-info">
<p>
Client is currently being loaded. Should only take a few seconds, please
wait
</p>
<noscript>
<p>
Also, surely you smart people knows that application such like this
one require JavaScript to run :)
</p>
</noscript>
<p class="copy copy-first">
Copyright &copy; 2019 Rui NI &lt;nirui@gmx.com&gt;
</p>
<p class="copy">
<a href="https://github.com/niruix/sshwifty" target="blank">
Source Code
</a>
<a href="/README.md" target="blank">
Release note
</a>
</p>
</div>
</div>
</div>

228
ui/commands/address.js Normal file
View File

@@ -0,0 +1,228 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import Exception from "./exception.js";
import * as reader from "../stream/reader.js";
import * as common from "./common.js";
export const LOOPBACK = 0x00;
export const IPV4 = 0x01;
export const IPV6 = 0x02;
export const HOSTNAME = 0x03;
export const MAX_ADDR_LEN = 0x3f;
export class Address {
/**
* Read builds an Address from data readed from the reader
*
* @param {reader.Reader} rd The reader
*
* @returns {Address} The Address
*
* @throws {Exception} when address type is invalid
*/
static async read(rd) {
let readed = await reader.readN(rd, 3),
portNum = 0,
addrType = LOOPBACK,
addrData = null;
portNum |= readed[0];
portNum <<= 8;
portNum |= readed[1];
addrType = readed[2] >> 6;
switch (addrType) {
case LOOPBACK:
break;
case IPV4:
addrData = await reader.readN(rd, 4);
break;
case IPV6:
addrData = await reader.readN(rd, 16);
break;
case HOSTNAME:
addrData = await reader.readN(rd, 0x3f & readed[2]);
break;
default:
throw new Exception("Unknown address type");
}
return new Address(addrType, addrData, portNum);
}
/**
* constructor
*
* @param {number} type Type of the address
* @param {Uint8Array} address Address data
* @param {number} port port number of the address
*
*/
constructor(type, address, port) {
this.addrType = type;
this.addrData = address;
this.addrPort = port;
}
/**
* Return the address type
*
*/
type() {
return this.addrType;
}
/**
* Return the address data
*
*/
address() {
return this.addrData;
}
/**
* Return the port data
*
*/
port() {
return this.addrPort;
}
/**
* Buffer returns the marshalled address
*
* @returns {Uint8Array} Marshalled address
*
* @throws {Exception} When address data is invalid
*
*/
buffer() {
switch (this.type()) {
case LOOPBACK:
return new Uint8Array([
this.addrPort >> 8,
this.addrPort & 0xff,
LOOPBACK << 6
]);
case IPV4:
if (this.addrData.length != 4) {
throw new Exception("Invalid address length");
}
return new Uint8Array([
this.addrPort >> 8,
this.addrPort & 0xff,
IPV4 << 6,
this.addrData[0],
this.addrData[1],
this.addrData[2],
this.addrData[3]
]);
case IPV6:
if (this.addrData.length != 16) {
throw new Exception("Invalid address length");
}
return new Uint8Array([
this.addrPort >> 8,
this.addrPort & 0xff,
IPV6 << 6,
this.addrData[0],
this.addrData[1],
this.addrData[2],
this.addrData[3],
this.addrData[4],
this.addrData[5],
this.addrData[6],
this.addrData[7],
this.addrData[8],
this.addrData[9],
this.addrData[10],
this.addrData[11],
this.addrData[12],
this.addrData[13],
this.addrData[14],
this.addrData[15]
]);
case HOSTNAME:
if (this.addrData.length > MAX_ADDR_LEN) {
throw new Exception("Host name cannot longer than " + MAX_ADDR_LEN);
}
let dataBuf = new Uint8Array(this.addrData.length + 3);
dataBuf[0] = (this.addrPort >> 8) & 0xff;
dataBuf[1] = this.addrPort & 0xff;
dataBuf[2] = HOSTNAME << 6;
dataBuf[2] |= this.addrData.length;
dataBuf.set(this.addrData, 3);
return dataBuf;
default:
throw new Exception("Unknown address type");
}
}
}
/**
* Get address data
*
* @param {string} s Address string
* @param {number} defaultPort Default port number
*
* @returns {object} result
*
* @throws {Exception} when the address is invalid
*/
export function parseHostPort(s, defaultPort) {
let d = common.splitHostPort(s, defaultPort),
t = HOSTNAME;
switch (d.type) {
case "IPv4":
t = IPV4;
break;
case "IPv6":
t = IPV6;
break;
case "Hostname":
break;
default:
throw new Exception("Invalid address type");
}
return {
type: t,
address: d.addr,
port: d.port
};
}

102
ui/commands/address_test.js Normal file
View File

@@ -0,0 +1,102 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import assert from "assert";
import * as reader from "../stream/reader.js";
import * as address from "./address.js";
describe("Address", () => {
it("Address Loopback", async () => {
let addr = new address.Address(address.LOOPBACK, null, 8080),
buf = addr.buffer();
let r = new reader.Reader(new reader.Multiple(), data => {
return data;
});
r.feed(buf);
let addr2 = await address.Address.read(r);
assert.equal(addr2.type(), addr.type());
assert.deepEqual(addr2.address(), addr.address());
assert.equal(addr2.port(), addr.port());
});
it("Address IPv4", async () => {
let addr = new address.Address(
address.IPV4,
new Uint8Array([127, 0, 0, 1]),
8080
),
buf = addr.buffer();
let r = new reader.Reader(new reader.Multiple(() => {}), data => {
return data;
});
r.feed(buf);
let addr2 = await address.Address.read(r);
assert.equal(addr2.type(), addr.type());
assert.deepEqual(addr2.address(), addr.address());
assert.equal(addr2.port(), addr.port());
});
it("Address IPv6", async () => {
let addr = new address.Address(
address.IPV6,
new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]),
8080
),
buf = addr.buffer();
let r = new reader.Reader(new reader.Multiple(() => {}), data => {
return data;
});
r.feed(buf);
let addr2 = await address.Address.read(r);
assert.equal(addr2.type(), addr.type());
assert.deepEqual(addr2.address(), addr.address());
assert.equal(addr2.port(), addr.port());
});
it("Address HostName", async () => {
let addr = new address.Address(
address.HOSTNAME,
new Uint8Array(["v", "a", "g", "u", "l", "1", "2", "3"]),
8080
),
buf = addr.buffer();
let r = new reader.Reader(new reader.Multiple(() => {}), data => {
return data;
});
r.feed(buf);
let addr2 = await address.Address.read(r);
assert.equal(addr2.type(), addr.type());
assert.deepEqual(addr2.address(), addr.address());
assert.equal(addr2.port(), addr.port());
});
});

107
ui/commands/color.js Normal file
View File

@@ -0,0 +1,107 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
/**
* Get one color hex byte
*
* @param {number} from Min color number
* @param {number} to Max color number
*
* @returns {string} color byte in string
*
*/
function getRandHex(from, to) {
let color = Math.random() * (to - from) + from,
colorDark = color - color / 20;
let r = Math.round(color).toString(16),
rDark = Math.round(colorDark).toString(16);
if (r.length % 2 !== 0) {
r = "0" + r;
}
if (rDark.length % 2 !== 0) {
rDark = "0" + rDark;
}
return [r, rDark];
}
/**
* Get rand color
*
* @param {number} from Min color number
* @param {number} to Max color number
*
* @returns {string} Color bytes in string
*/
function getRandColor(from, to) {
let r = getRandHex(from, to),
g = getRandHex(from, to),
b = getRandHex(from, to);
return ["#" + r[0] + g[0] + b[0], "#" + r[1] + g[1] + b[1]];
}
export class Color {
/**
* constructor
*/
constructor() {
this.assignedColors = {};
}
/**
* Get one color
*
* @returns {string} Color code
*
*/
get() {
const maxTries = 10;
let tried = 0;
for (;;) {
let color = getRandColor(0x22, 0x33);
if (this.assignedColors[color[0]]) {
tried++;
if (tried < maxTries) {
continue;
}
}
this.assignedColors[color[0]] = true;
return {
color: color[0],
dark: color[1]
};
}
}
/**
* forget already assigned color
*
* @param {string} color Color code
*/
forget(color) {
delete this.assignedColors[color];
}
}

720
ui/commands/commands.js Normal file
View File

@@ -0,0 +1,720 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import Exception from "./exception.js";
import * as stream from "../stream/streams.js";
import * as subscribe from "../stream/subscribe.js";
export const NEXT_PROMPT = 1;
export const NEXT_WAIT = 2;
export const NEXT_DONE = 3;
export class Result {
/**
* constructor
*
* @param {string} name Result type
* @param {Info} info Result info
* @param {object} control Result controller
*/
constructor(name, info, control) {
this.name = name;
this.info = info;
this.control = control;
}
}
class Done {
/**
* constructor
*
* @param {object} data Step data
*
*/
constructor(data) {
this.s = !!data.success;
this.d = data.successData;
this.errorTitle = data.errorTitle;
this.errorMessage = data.errorMessage;
}
/**
* Return the error of current Done
*
* @returns {string} title
*
*/
error() {
return this.errorTitle;
}
/**
* Return the error message of current Done
*
* @returns {string} message
*
*/
message() {
return this.errorMessage;
}
/**
* Returns whether or not current Done is representing a success
*
* @returns {boolean} True when success, false otherwise
*/
success() {
return this.s;
}
/**
* Returns final data
*
* @returns {Result} Successful result
*/
data() {
return this.d;
}
}
class Wait {
/**
* constructor
*
* @param {object} data Step data
*
*/
constructor(data) {
this.t = data.title;
this.m = data.message;
}
/**
* Return the title of current Wait
*
* @returns {string} title
*
*/
title() {
return this.t;
}
/**
* Return the message of current Wait
*
* @returns {string} message
*
*/
message() {
return this.m;
}
}
const defField = {
name: "",
description: "",
type: "",
value: "",
example: "",
verify(v) {
return "OK";
}
};
/**
* Create a Prompt field
*
* @param {object} def Field default value
* @param {object} f Field value
*
* @returns {object} Field data
*
* @throws {Exception} When input field is invalid
*
*/
export function field(def, f) {
let n = {};
for (let i in def) {
n[i] = def[i];
}
for (let i in f) {
if (typeof n[i] !== typeof f[i]) {
throw new Exception(
'Field data type for "' +
i +
'" was not unmatched. Expecting "' +
typeof def[i] +
'", got "' +
typeof f[i] +
'" instead'
);
}
n[i] = f[i];
}
if (!n["name"]) {
throw new Exception('Field "name" must be specified');
}
return n;
}
/**
* Build a group of field value
*
* @param {object} definitions Definition of a group of fields
* @param {array<object>} fs Data of the field group
*
* @returns {array<object>} Result fields
*
* @throws {Exception} When input field is invalid
*
*/
export function fields(definitions, fs) {
let fss = [];
for (let i in fs) {
if (!fs[i]["name"]) {
throw new Exception('Field "name" must be specified');
}
if (!definitions[fs[i].name]) {
throw new Exception('Undefined field "' + fs[i].name + '"');
}
fss.push(field(definitions[fs[i].name], fs[i]));
}
return fss;
}
class Prompt {
/**
* constructor
*
* @param {object} data Step data
*
* @throws {Exception} If the field verify is not a function while
* not null
*/
constructor(data) {
this.t = data.title;
this.m = data.message;
this.a = data.actionText;
this.r = data.respond;
this.c = data.cancel;
this.i = [];
this.f = {};
for (let i in data.inputs) {
let f = field(defField, data.inputs[i]);
this.i.push(f);
this.f[data.inputs[i].name.toLowerCase()] = {
value: f.value,
verify: f.verify
};
}
}
/**
* Return the title of current Prompt
*
* @returns {string} title
*
*/
title() {
return this.t;
}
/**
* Return the message of current Prompt
*
* @returns {string} message
*
*/
message() {
return this.m;
}
/**
* Return the input field of current prompt
*
* @returns {array} Input fields
*
*/
inputs() {
let inputs = [];
for (let i in this.i) {
inputs.push(this.i[i]);
}
return inputs;
}
/**
* Returns the name of the action
*
* @returns {string} Action name
*
*/
actionText() {
return this.a;
}
/**
* Receive the submit of current prompt
*
* @param {object} inputs Input value
*
* @returns {any} The result of the step responder
*
* @throws {Exception} When the field is undefined or invalid
*
*/
submit(inputs) {
let fields = {};
for (let i in this.f) {
fields[i] = this.f[i].value;
}
for (let i in inputs) {
let k = i.toLowerCase();
if (typeof fields[k] === "undefined") {
throw new Exception('Field "' + k + '" is undefined');
}
try {
this.f[k].verify(inputs[i]);
} catch (e) {
throw new Exception('Field "' + k + '" is invalid: ' + e);
}
fields[k] = inputs[i];
}
return this.r(fields);
}
/**
* Cancel current wait operation
*
*/
cancel() {
return this.c();
}
}
/**
* Create a Wizard step
*
* @param {string} type Step type
* @param {object} data Step data
*
* @returns {object} Step data
*
*/
function next(type, data) {
return {
type() {
return type;
},
data() {
return data;
}
};
}
/**
* Create data for a Done step of the wizard
*
* @param {boolean} success
* @param {Success} successData
* @param {string} errorTitle
* @param {string} errorMessage
*
* @returns {object} Done step data
*
*/
export function done(success, successData, errorTitle, errorMessage) {
return next(NEXT_DONE, {
success: success,
successData: successData,
errorTitle: errorTitle,
errorMessage: errorMessage
});
}
/**
* Create data for a Wait step of the wizard
*
* @param {string} title Waiter title
* @param {message} message Waiter message
*
* @returns {object} Done step data
*
*/
export function wait(title, message) {
return next(NEXT_WAIT, {
title: title,
message: message
});
}
/**
* Create data for a Prompt step of the wizard
*
* @param {string} title Title of the prompt
* @param {string} message Message of the prompt
* @param {string} actionText Text of the action (button)
* @param {function} respond Respond callback
* @param {function} cancel cancel handler
* @param {object} inputs Input field objects
*
* @returns {object} Prompt step data
*
*/
export function prompt(title, message, actionText, respond, cancel, inputs) {
return next(NEXT_PROMPT, {
title: title,
message: message,
actionText: actionText,
inputs: inputs,
respond: respond,
cancel: cancel
});
}
class Next {
/**
* constructor
*
* @param {object} data Step data
*/
constructor(data) {
this.t = data.type();
this.d = data.data();
}
/**
* Return step type
*
* @returns {string} Step type
*/
type() {
return this.t;
}
/**
* Return step data
*
* @returns {Done|Prompt} Step data
*
* @throws {Exception} When the step type is unknown
*
*/
data() {
switch (this.type()) {
case NEXT_PROMPT:
return new Prompt(this.d);
case NEXT_WAIT:
return new Wait(this.d);
case NEXT_DONE:
return new Done(this.d);
default:
throw new Exception("Unknown data type");
}
}
}
class Wizard {
/**
* constructor
*
* @param {function} builder Command builder
* @param {subscribe.Subscribe} subs Wizard step subscriber
*
*/
constructor(built, subs) {
this.built = built;
this.subs = subs;
this.closed = false;
}
/**
* Return the Next step
*
* @returns {Next} Next step
*
* @throws {Exception} When wizard is closed
*
*/
async next() {
if (this.closed) {
throw new Exception("Wizard already closed, no next step is available");
}
let n = await this.subs.subscribe();
if (n.type() === NEXT_DONE) {
this.close();
}
return new Next(n);
}
/**
* Return whether or not the command is started
*
* @returns {boolean} True when the command already started, false otherwise
*
*/
started() {
return this.built.started();
}
/**
* Close current wizard
*
* @returns {any} Close result
*
*/
close() {
if (this.closed) {
return;
}
this.closed = true;
return this.built.close();
}
}
export class Info {
/**
* constructor
*
* @param {Builder} info Builder info
*
*/
constructor(info) {
this.type = info.name();
this.info = info.description();
this.tcolor = info.color();
}
/**
* Return command name
*
* @returns {string} Command name
*
*/
name() {
return this.type;
}
/**
* Return command description
*
* @returns {string} Command description
*
*/
description() {
return this.info;
}
/**
* Return the theme color of the command
*
* @returns {string} Command name
*
*/
color() {
return this.tcolor;
}
}
class Builder {
/**
* constructor
*
* @param {object} command Command builder
*
*/
constructor(command) {
this.cid = command.id();
this.builder = (n, i, r, u, y, x) => {
return command.builder(n, i, r, u, y, x);
};
this.launchCmd = (n, i, r, u, y, x) => {
return command.launch(n, i, r, u, y, x);
};
this.launcherCmd = c => {
return command.launcher(c);
};
this.type = command.name();
this.info = command.description();
this.tcolor = command.color();
}
/**
* Return the command ID
*
* @returns {number} Command ID
*
*/
id() {
return this.cid;
}
/**
* Return command name
*
* @returns {string} Command name
*
*/
name() {
return this.type;
}
/**
* Return command description
*
* @returns {string} Command description
*
*/
description() {
return this.info;
}
/**
* Return the theme color of the command
*
* @returns {string} Command name
*
*/
color() {
return this.tcolor;
}
/**
* Build command wizard
*
* @param {stream.Streams} streams
* @param {controls.Controls} controls
* @param {history.History} history
* @param {object} config
*
* @returns {Wizard} Command wizard
*
*/
build(streams, controls, history, config) {
let subs = new subscribe.Subscribe();
return new Wizard(
this.builder(new Info(this), config, streams, subs, controls, history),
subs
);
}
/**
* Launch command wizard out of given launcher string
*
* @param {stream.Streams} streams
* @param {controls.Controls} controls
* @param {history.History} history
* @param {string} launcher Launcher format
*
* @returns {Wizard} Command wizard
*
*/
launch(streams, controls, history, launcher) {
let subs = new subscribe.Subscribe();
return new Wizard(
this.launchCmd(
new Info(this),
launcher,
streams,
subs,
controls,
history
),
subs
);
}
/**
* Build launcher string out of given config
*
* @param {object} config Configuration object
*
* @return {string} Launcher string
*/
launcher(config) {
return this.name() + ":" + this.launcherCmd(config);
}
}
export class Commands {
/**
* constructor
*
* @param {[]object} commands Command array
*
*/
constructor(commands) {
this.commands = [];
for (let i in commands) {
this.commands.push(new Builder(commands[i]));
}
}
/**
* Return all commands
*
* @returns {[]Builder} A group of command
*
*/
all() {
return this.commands;
}
/**
* Select one command
*
* @param {number} id Command ID
*
* @returns {Builder} Command builder
*
*/
select(id) {
return this.commands[id];
}
}

360
ui/commands/common.js Normal file
View File

@@ -0,0 +1,360 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import Exception from "./exception.js";
const numCharators = {
"0": true,
"1": true,
"2": true,
"3": true,
"4": true,
"5": true,
"6": true,
"7": true,
"8": true,
"9": true
};
const hexCharators = {
"0": true,
"1": true,
"2": true,
"3": true,
"4": true,
"5": true,
"6": true,
"7": true,
"8": true,
"9": true,
a: true,
b: true,
c: true,
d: true,
e: true,
f: true
};
const hostnameCharators = {
"0": true,
"1": true,
"2": true,
"3": true,
"4": true,
"5": true,
"6": true,
"7": true,
"8": true,
"9": true,
a: true,
b: true,
c: true,
d: true,
e: true,
f: true,
g: true,
h: true,
i: true,
j: true,
k: true,
l: true,
n: true,
m: true,
o: true,
p: true,
q: true,
r: true,
s: true,
t: true,
u: true,
v: true,
w: true,
x: true,
y: true,
z: true,
".": true,
"-": true,
_: true
};
/**
* Test whether or not given string is all number
*
* @param {string} d Input data
*
* @returns {boolean} Return true if given string is all number, false otherwise
*
*/
function isNumber(d) {
for (let i = 0; i < d.length; i++) {
if (!numCharators[d[i]]) {
return false;
}
}
return true;
}
/**
* Test whether or not given string is all hex
*
* @param {string} d Input data
*
* @returns {boolean} Return true if given string is all hex, false otherwise
*
*/
function isHex(d) {
let dd = d.toLowerCase();
for (let i = 0; i < dd.length; i++) {
if (!hexCharators[dd[i]]) {
return false;
}
}
return true;
}
/**
* Test whether or not given string is all hex
*
* @param {string} d Input data
*
* @returns {boolean} Return true if given string is all hex, false otherwise
*
*/
function isHostname(d) {
let dd = d.toLowerCase();
for (let i = 0; i < dd.length; i++) {
if (!hostnameCharators[dd[i]]) {
return false;
}
}
return true;
}
/**
* Parse IPv4 address
*
* @param {string} d IP address
*
* @returns {Uint8Array} Parsed IPv4 Address
*
* @throws {Exception} When the given ip address was not an IPv4 addr
*
*/
export function parseIPv4(d) {
const addrSeg = 4;
let s = d.split(".");
if (s.length != addrSeg) {
throw new Exception("Invalid address");
}
let r = new Uint8Array(addrSeg);
for (let i in s) {
if (!isNumber(s[i])) {
throw new Exception("Invalid address");
}
let ii = parseInt(s[i], 10); // Only support dec
if (isNaN(ii)) {
throw new Exception("Invalid address");
}
if (ii > 0xff) {
throw new Exception("Invalid address");
}
r[i] = ii;
}
return r;
}
/**
* Parse IPv6 address. ::ffff: notation is NOT supported
*
* @param {string} d IP address
*
* @returns {Uint16Array} Parsed IPv6 Address
*
* @throws {Exception} When the given ip address was not an IPv6 addr
*
*/
export function parseIPv6(d) {
const addrSeg = 8;
let s = d.split(":");
if (s.length > addrSeg || s.length <= 1) {
throw new Exception("Invalid address");
}
if (s[0].charAt(0) === "[") {
s[0] = s[0].substring(1, s[0].length);
let end = s.length - 1;
if (s[end].charAt(s[end].length - 1) !== "]") {
throw new Exception("Invalid address");
}
s[end] = s[end].substring(0, s[end].length - 1);
}
let r = new Uint16Array(addrSeg),
rIndexShift = 0;
for (let i = 0; i < s.length; i++) {
if (s[i].length <= 0) {
rIndexShift = addrSeg - s.length;
continue;
}
if (!isHex(s[i])) {
throw new Exception("Invalid address");
}
let ii = parseInt(s[i], 16); // Only support hex
if (isNaN(ii)) {
throw new Exception("Invalid address");
}
if (ii > 0xffff) {
throw new Exception("Invalid address");
}
r[rIndexShift + i] = ii;
}
return r;
}
/**
* Convert string into a {Uint8Array}
*
* @param {string} d Input
*
* @returns {Uint8Array} Output
*
*/
export function strToUint8Array(d) {
let r = new Uint8Array(d.length);
for (let i = 0, j = d.length; i < j; i++) {
r[i] = d.charCodeAt(i);
}
return r;
}
/**
* Parse IPv6 address. ::ffff: notation is NOT supported
*
* @param {string} d IP address
*
* @returns {Uint8Array} Parsed IPv6 Address
*
* @throws {Exception} When the given ip address was not an IPv6 addr
*
*/
export function parseHostname(d) {
if (d.length <= 0) {
throw new Exception("Invalid address");
}
if (!isHostname(d)) {
throw new Exception("Invalid address");
}
return strToUint8Array(d);
}
function parseIP(d) {
try {
return {
type: "IPv4",
data: parseIPv4(d)
};
} catch (e) {
// Do nothing
}
try {
return {
type: "IPv6",
data: new Uint8Array(parseIPv6(d).buffer)
};
} catch (e) {
// Do nothing
}
return {
type: "Hostname",
data: parseHostname(d)
};
}
export function splitHostPort(d, defPort) {
let hps = d.lastIndexOf(":"),
fhps = d.indexOf(":"),
ipv6hps = d.indexOf("[");
if ((hps < 0 || hps != fhps) && ipv6hps < 0) {
let a = parseIP(d);
return {
type: a.type,
addr: a.data,
port: defPort
};
}
if (ipv6hps > 0) {
throw new Exception("Invalid address");
} else if (ipv6hps === 0) {
let ipv6hpse = d.lastIndexOf("]");
if (ipv6hpse <= ipv6hps || ipv6hpse + 1 != hps) {
throw new Exception("Invalid address");
}
}
let addr = d.slice(0, hps),
port = d.slice(hps + 1, d.length);
if (!isNumber(port)) {
throw new Exception("Invalid address");
}
let portNum = parseInt(port, 10),
a = parseIP(addr);
return {
type: a.type,
addr: a.data,
port: portNum
};
}

278
ui/commands/common_test.js Normal file
View File

@@ -0,0 +1,278 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import assert from "assert";
import * as common from "./common.js";
describe("Common", () => {
it("parseIPv4", () => {
let tests = [
{
sample: "127.0.0.1",
expectingFailure: false,
expected: new Uint8Array([127, 0, 0, 1])
},
{
sample: "255.255.255.255",
expectingFailure: false,
expected: new Uint8Array([255, 255, 255, 255])
},
{
sample: "255.255.a.255",
expectingFailure: true,
expected: null
},
{
sample: "255.255.255",
expectingFailure: true,
expected: null
},
{
sample: "2001:db8:1f70::999:de8:7648:6e8",
expectingFailure: true,
expected: null
},
{
sample: "a.ssh.vaguly.com",
expectingFailure: true,
expected: null
}
];
for (let i in tests) {
if (tests[i].expectingFailure) {
let ee = null;
try {
common.parseIPv4(tests[i].sample);
} catch (e) {
ee = e;
}
assert.notEqual(ee, null, "Test " + tests[i].sample);
} else {
let data = common.parseIPv4(tests[i].sample);
assert.deepEqual(data, tests[i].expected);
}
}
});
it("parseIPv6", () => {
let tests = [
{
sample: "2001:db8:1f70:0:999:de8:7648:6e8",
expectingFailure: false,
expected: new Uint16Array([
0x2001,
0xdb8,
0x1f70,
0x0,
0x999,
0xde8,
0x7648,
0x6e8
])
},
{
sample: "2001:db8:85a3::8a2e:370:7334",
expectingFailure: false,
expected: new Uint16Array([
0x2001,
0xdb8,
0x85a3,
0x0,
0x0,
0x8a2e,
0x370,
0x7334
])
},
{
sample: "::1",
expectingFailure: false,
expected: new Uint16Array([0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x01])
},
{
sample: "::",
expectingFailure: false,
expected: new Uint16Array([0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x00])
},
{
sample: "2001:db8:1f70::999:de8:7648:6e8",
expectingFailure: false,
expected: new Uint16Array([
0x2001,
0xdb8,
0x1f70,
0x0,
0x999,
0xde8,
0x7648,
0x6e8
])
},
{
sample: "2001:0db8:ac10:fe01::",
expectingFailure: false,
expected: new Uint16Array([
0x2001,
0x0db8,
0xac10,
0xfe01,
0x0,
0x0,
0x0,
0x0
])
},
{
sample: "::7f00:1",
expectingFailure: false,
expected: new Uint16Array([
0x0000,
0x0000,
0x0000,
0x0000,
0x0000,
0x0000,
0x7f00,
0x0001
])
},
{
sample: "127.0.0.1",
expectingFailure: true,
expected: null
},
{
sample: "255.255.255.255",
expectingFailure: true,
expected: null
},
{
sample: "255.255.a.255",
expectingFailure: true,
expected: null
},
{
sample: "255.255.255",
expectingFailure: true,
expected: null
},
{
sample: "a.ssh.vaguly.com",
expectingFailure: true,
expected: null
}
];
for (let i in tests) {
if (tests[i].expectingFailure) {
let ee = null;
try {
common.parseIPv6(tests[i].sample);
} catch (e) {
ee = e;
}
assert.notEqual(ee, null, "Test " + tests[i].sample);
} else {
let data = common.parseIPv6(tests[i].sample);
assert.deepEqual(data, tests[i].expected);
}
}
});
it("splitHostPort", () => {
let tests = [
// Host name
{
sample: "ssh.vaguly.com",
expectedType: "Hostname",
expectedAddr: common.strToUint8Array("ssh.vaguly.com"),
expectedPort: 22
},
{
sample: "ssh.vaguly.com:22",
expectedType: "Hostname",
expectedAddr: common.strToUint8Array("ssh.vaguly.com"),
expectedPort: 22
},
// IPv4
{
sample: "10.220.179.110",
expectedType: "IPv4",
expectedAddr: new Uint8Array([10, 220, 179, 110]),
expectedPort: 22
},
{
sample: "10.220.179.110:3333",
expectedType: "IPv4",
expectedAddr: new Uint8Array([10, 220, 179, 110]),
expectedPort: 3333
},
// IPv6
{
sample: "2001:db8:1f70::999:de8:7648:6e8",
expectedType: "IPv6",
expectedAddr: new Uint8Array(
new Uint16Array([
0x2001,
0xdb8,
0x1f70,
0x0,
0x999,
0xde8,
0x7648,
0x6e8
]).buffer
),
expectedPort: 22
},
{
sample: "[2001:db8:1f70::999:de8:7648:6e8]:100",
expectedType: "IPv6",
expectedAddr: new Uint8Array(
new Uint16Array([
0x2001,
0xdb8,
0x1f70,
0x0,
0x999,
0xde8,
0x7648,
0x6e8
]).buffer
),
expectedPort: 100
}
];
for (let i in tests) {
let hostport = common.splitHostPort(tests[i].sample, 22);
assert.deepEqual(hostport.type, tests[i].expectedType);
assert.deepEqual(hostport.addr, tests[i].expectedAddr);
assert.equal(hostport.port, tests[i].expectedPort);
}
});
});

61
ui/commands/controls.js vendored Normal file
View File

@@ -0,0 +1,61 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import Exception from "./exception.js";
export class Controls {
/**
* constructor
*
* @param {[]object} controls
*
* @throws {Exception} When control type already been defined
*
*/
constructor(controls) {
this.controls = {};
for (let i in controls) {
let cType = controls[i].type();
if (typeof this.controls[cType] === "object") {
throw new Exception('Control "' + cType + '" already been defined');
}
this.controls[cType] = controls[i];
}
}
/**
* Get a control
*
* @param {string} type Type of the control
* @param {...any} data Data needed to build the control
*
* @returns {object} Control object
*
* @throws {Exception} When given control type is undefined
*
*/
get(type, ...data) {
if (typeof this.controls[type] !== "object") {
throw new Exception('Control "' + type + '" was undefined');
}
return this.controls[type].build(...data);
}
}

106
ui/commands/events.js Normal file
View File

@@ -0,0 +1,106 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import Exception from "./exception.js";
export class Events {
/**
* constructor
*
* @param {[]string} events required events
* @param {object} callbacks Callbacks
*
* @throws {Exception} When event handler is not registered
*
*/
constructor(events, callbacks) {
this.events = {};
this.placeHolders = {};
for (let i in events) {
if (typeof callbacks[events[i]] !== "function") {
throw new Exception(
'Unknown event type for "' +
events[i] +
'". Expecting "function" got "' +
typeof callbacks[events[i]] +
'" instead.'
);
}
let name = events[i];
if (name.indexOf("@") === 0) {
name = name.substring(1);
this.placeHolders[name] = null;
}
this.events[name] = callbacks[events[i]];
}
}
/**
* Place callbacks to pending placeholder events
*
* @param {string} type Event Type
* @param {function} callback Callback function
*/
place(type, callback) {
if (this.placeHolders[type] !== null) {
throw new Exception(
'Event type "' +
type +
'" cannot be appended. It maybe ' +
"unregistered or already been acquired"
);
}
if (typeof callback !== "function") {
throw new Exception(
'Unknown event type for "' +
type +
'". Expecting "function" got "' +
typeof callback +
'" instead.'
);
}
delete this.placeHolders[type];
this.events[type] = callback;
}
/**
* Fire an event
*
* @param {string} type Event type
* @param {...any} data Event data
*
* @returns {any} The result of the event handler
*
* @throws {Exception} When event type is not registered
*
*/
fire(type, ...data) {
if (!this.events[type] && this.placeHolders[type] !== null) {
throw new Exception("Unknown event type: " + type);
}
return this.events[type](...data);
}
}

38
ui/commands/exception.js Normal file
View File

@@ -0,0 +1,38 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
export default class Exception {
/**
* constructor
*
* @param {string} message error message
*
*/
constructor(message) {
this.message = message;
}
/**
* Return the error string
*
* @returns {string} Error message
*
*/
toString() {
return this.message;
}
}

115
ui/commands/history.js Normal file
View File

@@ -0,0 +1,115 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import * as command from "./commands.js";
export class History {
/**
* constructor
*
* @param {array<object>} records
* @param {function} saver
* @param {number} maxItems
*
*/
constructor(records, saver, maxItems) {
this.records = records;
this.maxItems = maxItems;
this.saver = saver;
}
/**
* Save record to history
*
* @param {string} uname unique name
* @param {string} title Title
* @param {command.Info} info Command info
* @param {Date} lastUsed Last used
* @param {object} data Data
*
*/
save(uname, title, lastUsed, info, data) {
for (let i in this.records) {
if (this.records[i].uname !== uname) {
continue;
}
this.records.splice(i, 1);
break;
}
this.records.push({
uname: uname,
title: title,
type: info.name(),
color: info.color(),
last: lastUsed.getTime(),
data: data
});
if (this.records.length > this.maxItems) {
this.records = this.records.slice(
this.records.length - this.maxItems,
this.records.length
);
}
this.saver(this, this.records);
}
/**
* Save record to history
*
* @param {string} uid unique name
*
*/
del(uid) {
for (let i in this.records) {
if (this.records[i].uname !== uid) {
continue;
}
this.records.splice(i, 1);
break;
}
this.saver(this, this.records);
}
/**
* Return all history records
*
* @returns {array<object>} Records
*
*/
all() {
let r = [];
for (let i in this.records) {
r.push({
uid: this.records[i].uname,
title: this.records[i].title,
type: this.records[i].type,
color: this.records[i].color,
last: new Date(this.records[i].last),
data: this.records[i].data
});
}
return r;
}
}

90
ui/commands/integer.js Normal file
View File

@@ -0,0 +1,90 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import Exception from "./exception.js";
import * as reader from "../stream/reader.js";
export const MAX = 0x3fff;
export const MAX_BYTES = 2;
const integerHasNextBit = 0x80;
const integerValueCutter = 0x7f;
export class Integer {
/**
* constructor
*
* @param {number} num Integer number
*
*/
constructor(num) {
this.num = num;
}
/**
* Marshal integer to buffer
*
* @returns {Uint8Array} Integer buffer
*
* @throws {Exception} When number is too large
*
*/
marshal() {
if (this.num > MAX) {
throw new Exception("Integer number cannot be greater than 0x3fff");
}
if (this.num <= integerValueCutter) {
return new Uint8Array([this.num & integerValueCutter]);
}
return new Uint8Array([
(this.num >> 7) | integerHasNextBit,
this.num & integerValueCutter
]);
}
/**
* Parse the reader to build an Integer
*
* @param {reader.Reader} rd Data reader
*
*/
async unmarshal(rd) {
for (let i = 0; i < MAX_BYTES; i++) {
let r = await reader.readOne(rd);
this.num |= r[0] & integerValueCutter;
if ((integerHasNextBit & r[0]) == 0) {
return;
}
this.num <<= 7;
}
}
/**
* Return the value of the number
*
* @returns {number} The integer value
*
*/
value() {
return this.num;
}
}

View File

@@ -0,0 +1,60 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import assert from "assert";
import * as reader from "../stream/reader.js";
import * as integer from "./integer.js";
describe("Integer", () => {
it("Integer 127", async () => {
let i = new integer.Integer(127),
marshalled = i.marshal();
let r = new reader.Reader(new reader.Multiple(() => {}), data => {
return data;
});
assert.equal(marshalled.length, 1);
r.feed(marshalled);
let i2 = new integer.Integer(0);
await i2.unmarshal(r);
assert.equal(i.value(), i2.value());
});
it("Integer MAX", async () => {
let i = new integer.Integer(integer.MAX),
marshalled = i.marshal();
let r = new reader.Reader(new reader.Multiple(() => {}), data => {
return data;
});
assert.equal(marshalled.length, 2);
r.feed(marshalled);
let i2 = new integer.Integer(0);
await i2.unmarshal(r);
assert.equal(i.value(), i2.value());
});
});

801
ui/commands/ssh.js Normal file
View File

@@ -0,0 +1,801 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import * as address from "./address.js";
import * as command from "./commands.js";
import * as common from "./common.js";
import * as event from "./events.js";
import * as reader from "../stream/reader.js";
import * as stream from "../stream/stream.js";
import * as controls from "./controls.js";
import * as header from "../stream/header.js";
import * as history from "./history.js";
import * as strings from "./string.js";
import Exception from "./exception.js";
const AUTHMETHOD_NONE = 0x01;
const AUTHMETHOD_PASSPHARSE = 0x01;
const AUTHMETHOD_PRIVATE_KEY = 0x02;
const COMMAND_ID = 0x01;
const MAX_USERNAME_LEN = 64;
const MAX_PASSWORD_LEN = 4096;
const DEFAULT_PORT = 22;
const SERVER_REMOTE_STDOUT = 0x00;
const SERVER_REMOTE_STDERR = 0x01;
const SERVER_CONNECT_FAILED = 0x02;
const SERVER_CONNECTED = 0x03;
const SERVER_CONNECT_REQUEST_FINGERPRINT = 0x04;
const SERVER_CONNECT_REQUEST_CREDENTIAL = 0x05;
const CLIENT_DATA_STDIN = 0x00;
const CLIENT_DATA_RESIZE = 0x01;
const CLIENT_CONNECT_RESPOND_FINGERPRINT = 0x02;
const CLIENT_CONNECT_RESPOND_CREDENTIAL = 0x03;
const SERVER_REQUEST_ERROR_BAD_USERNAME = 0x01;
const SERVER_REQUEST_ERROR_BAD_ADDRESS = 0x02;
const SERVER_REQUEST_ERROR_BAD_AUTHMETHOD = 0x03;
const FingerprintPromptVerifyPassed = 0x00;
const FingerprintPromptVerifyNoRecord = 0x01;
const FingerprintPromptVerifyMismatch = 0x02;
class SSH {
/**
* constructor
*
* @param {stream.Sender} sd Stream sender
* @param {object} config configuration
* @param {object} callbacks Event callbacks
*
*/
constructor(sd, config, callbacks) {
this.sender = sd;
this.config = config;
this.connected = false;
this.events = new event.Events(
[
"initialization.failed",
"initialized",
"connect.failed",
"connect.succeed",
"connect.fingerprint",
"connect.credential",
"@stdout",
"@stderr",
"close",
"@completed"
],
callbacks
);
}
/**
* Send intial request
*
* @param {stream.InitialSender} initialSender Initial stream request sender
*
*/
run(initialSender) {
let user = new strings.String(this.config.user),
userBuf = user.buffer(),
addr = new address.Address(
this.config.host.type,
this.config.host.address,
this.config.host.port
),
addrBuf = addr.buffer(),
authMethod = new Uint8Array([this.config.auth]);
let data = new Uint8Array(userBuf.length + addrBuf.length + 1);
data.set(userBuf, 0);
data.set(addrBuf, userBuf.length);
data.set(authMethod, userBuf.length + addrBuf.length);
initialSender.send(data);
}
/**
* Receive the initial stream request
*
* @param {header.InitialStream} streamInitialHeader Server respond on the
* initial stream request
*
*/
initialize(streamInitialHeader) {
if (!streamInitialHeader.success()) {
this.events.fire("initialization.failed", streamInitialHeader);
return;
}
this.events.fire("initialized", streamInitialHeader);
}
/**
* Tick the command
*
* @param {header.Stream} streamHeader Stream data header
* @param {reader.Limited} rd Data reader
*
* @returns {any} The result of the ticking
*
* @throws {Exception} When the stream header type is unknown
*
*/
tick(streamHeader, rd) {
switch (streamHeader.marker()) {
case SERVER_CONNECTED:
if (!this.connected) {
this.connected = true;
return this.events.fire("connect.succeed", rd, this);
}
break;
case SERVER_CONNECT_FAILED:
if (!this.connected) {
return this.events.fire("connect.failed", rd);
}
break;
case SERVER_CONNECT_REQUEST_FINGERPRINT:
if (!this.connected) {
return this.events.fire("connect.fingerprint", rd, this.sender);
}
break;
case SERVER_CONNECT_REQUEST_CREDENTIAL:
if (!this.connected) {
return this.events.fire("connect.credential", rd, this.sender);
}
break;
case SERVER_REMOTE_STDOUT:
if (this.connected) {
return this.events.fire("stdout", rd);
}
break;
case SERVER_REMOTE_STDERR:
if (this.connected) {
return this.events.fire("stderr", rd);
}
break;
}
throw new Exception("Unknown stream header marker");
}
/**
* Send close signal to remote
*
*/
async sendClose() {
return await this.sender.close();
}
/**
* Send data to remote
*
* @param {Uint8Array} data
*
*/
async sendData(data) {
return this.sender.send(CLIENT_DATA_STDIN, data);
}
/**
* Send resize request
*
* @param {number} rows
* @param {number} cols
*
*/
async sendResize(rows, cols) {
let data = new DataView(new ArrayBuffer(4));
data.setUint16(0, rows);
data.setUint16(2, cols);
return this.sender.send(CLIENT_DATA_RESIZE, new Uint8Array(data.buffer));
}
/**
* Close the command
*
*/
async close() {
await this.sendClose();
return this.events.fire("close");
}
/**
* Tear down the command completely
*
*/
completed() {
return this.events.fire("completed");
}
}
const initialFieldDef = {
User: {
name: "User",
description: "",
type: "text",
value: "",
example: "root",
verify(d) {
if (d.length <= 0) {
throw new Error("Username must be specified");
}
if (d.length > MAX_USERNAME_LEN) {
throw new Error(
"Username must not longer than " + MAX_USERNAME_LEN + " bytes"
);
}
return "We'll login as user \"" + d + '"';
}
},
Host: {
name: "Host",
description: "",
type: "text",
value: "",
example: "ssh.vaguly.com:22",
verify(d) {
if (d.length <= 0) {
throw new Error("Hostname must be specified");
}
let addr = common.splitHostPort(d, DEFAULT_PORT);
if (addr.addr.length <= 0) {
throw new Error("Cannot be empty");
}
if (addr.addr.length > address.MAX_ADDR_LEN) {
throw new Error(
"Can no longer than " + address.MAX_ADDR_LEN + " bytes"
);
}
if (addr.port <= 0) {
throw new Error("Port must be specified");
}
return "Look like " + addr.type + " address";
}
},
Notice: {
name: "Notice",
description: "",
type: "textdata",
value:
"SSH session is handled by the backend. Traffic will be decrypted " +
"on the backend server and then be transmitted back to your client.",
example: "",
verify(d) {
return "";
}
},
Passpharse: {
name: "Passpharse",
description: "",
type: "password",
value: "",
example: "----------",
verify(d) {
if (d.length <= 0) {
throw new Error("Passpharse must be specified");
}
if (d.length > MAX_PASSWORD_LEN) {
throw new Error(
"It's too long, make it shorter than " + MAX_PASSWORD_LEN + " bytes"
);
}
return "We'll login with this passpharse";
}
},
"Private Key": {
name: "Private Key",
description: "Like the one inside ~/.ssh/id_rsa, can&apos;t be encrypted",
type: "textarea",
value: "",
example:
"-----BEGIN RSA PRIVATE KEY-----\r\n" +
"..... yBQZobkBQ50QqhDivQz4i1Pb33Z0Znjnzjoid4 ....\r\n" +
"-----END RSA PRIVATE KEY-----",
verify(d) {
if (d.length <= 0) {
throw new Error("Private Key must be specified");
}
if (d.length > MAX_PASSWORD_LEN) {
throw new Error(
"It's too long, make it shorter than " + MAX_PASSWORD_LEN + " bytes"
);
}
return "We'll login with this private key";
}
},
Authentication: {
name: "Authentication",
description:
"Please make sure the authentication method that you selected is " +
"supported by the server, otherwise it will be ignored and likely " +
"cause the login to fail",
type: "radio",
value: "",
example: "Password,Private Key,None",
verify(d) {
switch (d) {
case "Password":
case "Private Key":
case "None":
return "";
default:
throw new Error("Authentication method must be specified");
}
}
},
Fingerprint: {
name: "Fingerprint",
description:
"Please carefully verify the fingerprint. DO NOT continue " +
"if the fingerprint is unknown to you, otherwise you maybe " +
"giving your own secrets to an imposter",
type: "textdata",
value: "",
example: "",
verify(d) {
return "";
}
}
};
/**
* Return auth method from given string
*
* @param {string} d string data
*
* @returns {number} Auth method
*
* @throws {Exception} When auth method is invalid
*
*/
function getAuthMethodFromStr(d) {
switch (d) {
case "None":
return AUTHMETHOD_NONE;
case "Password":
return AUTHMETHOD_PASSPHARSE;
case "Private Key":
return AUTHMETHOD_PRIVATE_KEY;
default:
throw new Exception("Unknown Auth method");
}
}
class Wizard {
/**
* constructor
*
* @param {command.Info} info
* @param {object} config
* @param {streams.Streams} streams
* @param {subscribe.Subscribe} subs
* @param {controls.Controls} controls
* @param {history.History} history
*
*/
constructor(info, config, streams, subs, controls, history) {
this.info = info;
this.hasStarted = false;
this.streams = streams;
this.config = config;
this.step = subs;
this.controls = controls;
this.history = history;
this.step.resolve(this.stepInitialPrompt());
}
started() {
return this.hasStarted;
}
close() {
this.step.resolve(
this.stepErrorDone(
"Action cancelled",
"Action has been cancelled without reach any success"
)
);
}
stepErrorDone(title, message) {
return command.done(false, null, title, message);
}
stepSuccessfulDone(data) {
return command.done(
true,
data,
"Success!",
"We have connected to the remote"
);
}
stepWaitForAcceptWait() {
return command.wait(
"Requesting",
"Waiting for request to be accepted by the backend"
);
}
stepWaitForEstablishWait(host) {
return command.wait(
"Connecting to " + host,
"Establishing connection with the remote host, may take a while"
);
}
stepContinueWaitForEstablishWait() {
return command.wait(
"Connecting",
"Establishing connection with the remote host, may take a while"
);
}
/**
*
* @param {stream.Sender} sender
* @param {object} configInput
*
*/
buildCommand(sender, configInput) {
let self = this;
let config = {
user: common.strToUint8Array(configInput.user),
auth: getAuthMethodFromStr(configInput.authentication),
host: address.parseHostPort(configInput.host, DEFAULT_PORT),
fingerprint: configInput.fingerprint
};
return new SSH(sender, config, {
"initialization.failed"(hd) {
switch (hd.data()) {
case SERVER_REQUEST_ERROR_BAD_USERNAME:
self.step.resolve(
self.stepErrorDone("Request failed", "Invalid username")
);
return;
case SERVER_REQUEST_ERROR_BAD_ADDRESS:
self.step.resolve(
self.stepErrorDone("Request failed", "Invalid address")
);
return;
case SERVER_REQUEST_ERROR_BAD_AUTHMETHOD:
self.step.resolve(
self.stepErrorDone("Request failed", "Invalid authication method")
);
return;
}
self.step.resolve(
self.stepErrorDone("Request failed", "Unknown error: " + hd.data())
);
},
initialized(hd) {
self.step.resolve(self.stepWaitForEstablishWait(configInput.host));
},
async "connect.failed"(rd) {
let d = new TextDecoder("utf-8").decode(
await reader.readCompletely(rd)
);
self.step.resolve(self.stepErrorDone("Connection failed", d));
},
"connect.succeed"(rd, commandHandler) {
self.connectionSucceed = true;
self.step.resolve(
self.stepSuccessfulDone(
new command.Result(
configInput.user + "@" + configInput.host,
self.info,
self.controls.get("SSH", {
send(data) {
return commandHandler.sendData(data);
},
close() {
return commandHandler.sendClose();
},
resize(rows, cols) {
return commandHandler.sendResize(rows, cols);
},
events: commandHandler.events
})
)
)
);
self.history.save(
self.info.name() + ":" + configInput.user + "@" + configInput.host,
configInput.user + "@" + configInput.host,
new Date(),
self.info,
configInput
);
},
async "connect.fingerprint"(rd, sd) {
self.step.resolve(
await self.stepFingerprintPrompt(
rd,
sd,
v => {
if (!configInput.fingerprint) {
return FingerprintPromptVerifyNoRecord;
}
if (configInput.fingerprint === v) {
return FingerprintPromptVerifyPassed;
}
return FingerprintPromptVerifyMismatch;
},
newFingerprint => {
configInput.fingerprint = newFingerprint;
}
)
);
},
async "connect.credential"(rd, sd) {
self.step.resolve(self.stepCredentialPrompt(rd, sd, config));
},
"@stdout"(rd) {},
"@stderr"(rd) {},
close() {},
"@completed"() {
self.step.resolve(
self.stepErrorDone(
"Operation has failed",
"Connection has been cancelled"
)
);
}
});
}
stepInitialPrompt() {
let self = this;
if (this.config) {
self.hasStarted = true;
self.streams.request(COMMAND_ID, sd => {
return self.buildCommand(sd, this.config);
});
return self.stepWaitForAcceptWait();
}
return command.prompt(
"SSH",
"Secure Shell Host",
"Connect",
r => {
self.hasStarted = true;
self.streams.request(COMMAND_ID, sd => {
return self.buildCommand(sd, {
user: r.user,
authentication: r.authentication,
host: r.host,
fingerprint: ""
});
});
self.step.resolve(self.stepWaitForAcceptWait());
},
() => {},
command.fields(initialFieldDef, [
{ name: "User" },
{ name: "Host" },
{ name: "Authentication" },
{ name: "Notice" }
])
);
}
async stepFingerprintPrompt(rd, sd, verify, newFingerprint) {
let self = this,
fingerprintData = new TextDecoder("utf-8").decode(
await reader.readCompletely(rd)
),
fingerprintChanged = false;
switch (verify(fingerprintData)) {
case FingerprintPromptVerifyPassed:
sd.send(CLIENT_CONNECT_RESPOND_FINGERPRINT, new Uint8Array([0]));
return this.stepContinueWaitForEstablishWait();
case FingerprintPromptVerifyMismatch:
fingerprintChanged = true;
}
return command.prompt(
!fingerprintChanged
? "Do you recognize this server?"
: "Danger! Server fingerprint has changed!",
!fingerprintChanged
? "Verify server fingerprint displayed below"
: "It's very unusual. Please verify the new server fingerprint below",
!fingerprintChanged ? "Yes, I do" : "I'm aware of the change",
r => {
newFingerprint(fingerprintData);
sd.send(CLIENT_CONNECT_RESPOND_FINGERPRINT, new Uint8Array([0]));
self.step.resolve(self.stepContinueWaitForEstablishWait());
},
() => {
sd.send(CLIENT_CONNECT_RESPOND_FINGERPRINT, new Uint8Array([1]));
self.step.resolve(
command.wait("Rejecting", "Sending rejection to the backend")
);
},
command.fields(initialFieldDef, [
{
name: "Fingerprint",
value: fingerprintData
}
])
);
}
async stepCredentialPrompt(rd, sd, config) {
let self = this,
fieldName = "";
switch (config.auth) {
case AUTHMETHOD_PASSPHARSE:
fieldName = "Passpharse";
break;
case AUTHMETHOD_PRIVATE_KEY:
fieldName = "Private Key";
break;
default:
throw new Exception(
"Prompt is not support by auth method: " + config.auth
);
}
return command.prompt(
"Provide credential",
"Please input your credential",
"Login",
r => {
let vv = r[fieldName.toLowerCase()];
sd.send(
CLIENT_CONNECT_RESPOND_CREDENTIAL,
new TextEncoder("utf-8").encode(vv)
);
self.step.resolve(self.stepContinueWaitForEstablishWait());
},
() => {
sd.close();
self.step.resolve(
command.wait(
"Cancelling login",
"Cancelling login request, please wait"
)
);
},
command.fields(initialFieldDef, [{ name: fieldName }])
);
}
}
export class Command {
constructor() {}
id() {
return COMMAND_ID;
}
name() {
return "SSH";
}
description() {
return "Secure Shell Host";
}
color() {
return "#3c8";
}
builder(info, config, streams, subs, controls, history) {
return new Wizard(info, config, streams, subs, controls, history);
}
launch(info, launcher, streams, subs, controls, history) {
let matchResult = launcher.match(new RegExp("^(.*)\\@(.*)\\|(.*)$"));
if (!matchResult || matchResult.length !== 4) {
throw new Exception('Given launcher "' + launcher + '" was malformed');
}
let user = matchResult[1],
host = matchResult[2],
auth = matchResult[3];
try {
initialFieldDef["User"].verify(user);
initialFieldDef["Host"].verify(host);
initialFieldDef["Authentication"].verify(auth);
} catch (e) {
throw new Exception(
'Given launcher "' + launcher + '" was malformed ' + e
);
}
return this.builder(
info,
{
user: user,
host: host,
authentication: auth
},
streams,
subs,
controls,
history
);
}
launcher(config) {
return config.user + "@" + config.host + "|" + config.authentication;
}
}

72
ui/commands/string.js Normal file
View File

@@ -0,0 +1,72 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import * as reader from "../stream/reader.js";
import * as integer from "./integer.js";
export class String {
/**
* Read String from given reader
*
* @param {reader.Reader} rd Source reader
*
* @returns {String} readed string
*
*/
static async read(rd) {
let l = new integer.Integer(0);
await l.unmarshal(rd);
return new String(await reader.readN(rd, l.value()));
}
/**
* constructor
*
* @param {Uint8Array} str String data
*/
constructor(str) {
this.str = str;
}
/**
* Return the string
*
* @returns {Uint8Array} String data
*
*/
data() {
return this.str;
}
/**
* Return serialized String as array
*
* @returns {Uint8Array} serialized String
*
*/
buffer() {
let lBytes = new integer.Integer(this.str.length).marshal(),
buf = new Uint8Array(lBytes.length + this.str.length);
buf.set(lBytes, 0);
buf.set(this.str, lBytes.length);
return buf;
}
}

265
ui/commands/string_test.js Normal file
View File

@@ -0,0 +1,265 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import * as strings from "./string.js";
import * as reader from "../stream/reader.js";
import assert from "assert";
describe("String", () => {
it("String 1", async () => {
let s = new strings.String(new Uint8Array(["H", "E", "L", "L", "O"])),
sBuf = s.buffer();
let r = new reader.Reader(new reader.Multiple(() => {}), data => {
return data;
});
r.feed(sBuf);
let s2 = await strings.String.read(r);
assert.deepEqual(s2.data(), s.data());
});
it("String 2", async () => {
let s = new strings.String(
new Uint8Array([
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D"
])
),
sBuf = s.buffer();
let r = new reader.Reader(new reader.Multiple(() => {}), data => {
return data;
});
r.feed(sBuf);
let s2 = await strings.String.read(r);
assert.deepEqual(s2.data(), s.data());
});
});

430
ui/commands/telnet.js Normal file
View File

@@ -0,0 +1,430 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import * as address from "./address.js";
import * as command from "./commands.js";
import * as common from "./common.js";
import * as event from "./events.js";
import * as reader from "../stream/reader.js";
import * as stream from "../stream/stream.js";
import * as controls from "./controls.js";
import * as history from "./history.js";
import * as header from "../stream/header.js";
import Exception from "./exception.js";
const COMMAND_ID = 0x00;
const SERVER_INITIAL_ERROR_BAD_ADDRESS = 0x01;
const SERVER_REMOTE_BAND = 0x00;
const SERVER_DIAL_FAILED = 0x01;
const SERVER_DIAL_CONNECTED = 0x02;
const DEFAULT_PORT = 23;
class Telnet {
/**
* constructor
*
* @param {stream.Sender} sd Stream sender
* @param {object} config configuration
* @param {object} callbacks Event callbacks
*
*/
constructor(sd, config, callbacks) {
this.sender = sd;
this.config = config;
this.connected = false;
this.events = new event.Events(
[
"initialization.failed",
"initialized",
"connect.failed",
"connect.succeed",
"@inband",
"close",
"@completed"
],
callbacks
);
}
/**
* Send intial request
*
* @param {stream.InitialSender} initialSender Initial stream request sender
*
*/
run(initialSender) {
let addr = new address.Address(
this.config.host.type,
this.config.host.address,
this.config.host.port
),
addrBuf = addr.buffer();
let data = new Uint8Array(addrBuf.length);
data.set(addrBuf, 0);
initialSender.send(data);
}
/**
* Receive the initial stream request
*
* @param {header.InitialStream} streamInitialHeader Server respond on the
* initial stream request
*
*/
initialize(streamInitialHeader) {
if (!streamInitialHeader.success()) {
this.events.fire("initialization.failed", streamInitialHeader);
return;
}
this.events.fire("initialized", streamInitialHeader);
}
/**
* Tick the command
*
* @param {header.Stream} streamHeader Stream data header
* @param {reader.Limited} rd Data reader
*
* @returns {any} The result of the ticking
*
* @throws {Exception} When the stream header type is unknown
*
*/
tick(streamHeader, rd) {
switch (streamHeader.marker()) {
case SERVER_DIAL_CONNECTED:
if (!this.connected) {
this.connected = true;
return this.events.fire("connect.succeed", rd, this);
}
break;
case SERVER_DIAL_FAILED:
if (!this.connected) {
return this.events.fire("connect.failed", rd);
}
break;
case SERVER_REMOTE_BAND:
if (this.connected) {
return this.events.fire("inband", rd);
}
break;
}
throw new Exception("Unknown stream header marker");
}
/**
* Send close signal to remote
*
*/
sendClose() {
return this.sender.close();
}
/**
* Send data to remote
*
* @param {Uint8Array} data
*
*/
sendData(data) {
return this.sender.send(0x00, data);
}
/**
* Close the command
*
*/
close() {
this.sendClose();
return this.events.fire("close");
}
/**
* Tear down the command completely
*
*/
completed() {
return this.events.fire("completed");
}
}
const initialFieldDef = {
Host: {
name: "Host",
description:
"Looking for server to connect&quest; Checkout " +
'<a href="http://www.telnet.org/htm/places.htm" target="blank">' +
"telnet.org</a> for public servers.",
type: "text",
value: "",
example: "telnet.vaguly.com:23",
verify(d) {
if (d.length <= 0) {
throw new Error("Hostname must be specified");
}
let addr = common.splitHostPort(d, DEFAULT_PORT);
if (addr.addr.length <= 0) {
throw new Error("Cannot be empty");
}
if (addr.addr.length > address.MAX_ADDR_LEN) {
throw new Error(
"Can no longer than " + address.MAX_ADDR_LEN + " bytes"
);
}
if (addr.port <= 0) {
throw new Error("Port must be specified");
}
return "Look like " + addr.type + " address";
}
}
};
class Wizard {
/**
* constructor
*
* @param {command.Info} info
* @param {object} config
* @param {streams.Streams} streams
* @param {subscribe.Subscribe} subs
* @param {controls.Controls} controls
* @param {history.History} history
*
*/
constructor(info, config, streams, subs, controls, history) {
this.info = info;
this.hasStarted = false;
this.streams = streams;
this.config = config;
this.step = subs;
this.controls = controls;
this.history = history;
this.step.resolve(this.stepInitialPrompt());
}
started() {
return this.hasStarted;
}
close() {
this.step.resolve(
this.stepErrorDone(
"Action cancelled",
"Action has been cancelled without reach any success"
)
);
}
stepErrorDone(title, message) {
return command.done(false, null, title, message);
}
stepSuccessfulDone(data) {
return command.done(
true,
data,
"Success!",
"We have connected to the remote"
);
}
stepWaitForAcceptWait() {
return command.wait(
"Requesting",
"Waiting for request to be accepted by the backend"
);
}
stepWaitForEstablishWait(host) {
return command.wait(
"Connecting to " + host,
"Establishing connection with the remote host, may take a while"
);
}
/**
*
* @param {stream.Sender} sender
* @param {object} configInput
*
*/
buildCommand(sender, configInput) {
let self = this;
let parsedConfig = {
host: address.parseHostPort(configInput.host, DEFAULT_PORT)
};
return new Telnet(sender, parsedConfig, {
"initialization.failed"(streamInitialHeader) {
switch (streamInitialHeader.data()) {
case SERVER_INITIAL_ERROR_BAD_ADDRESS:
self.step.resolve(
self.stepErrorDone("Request rejected", "Invalid address")
);
return;
}
self.step.resolve(
self.stepErrorDone(
"Request rejected",
"Unknown error code: " + streamInitialHeader.data()
)
);
},
initialized(streamInitialHeader) {
self.step.resolve(self.stepWaitForEstablishWait(configInput.host));
},
"connect.succeed"(rd, commandHandler) {
self.step.resolve(
self.stepSuccessfulDone(
new command.Result(
configInput.host,
self.info,
self.controls.get("Telnet", {
send(data) {
return commandHandler.sendData(data);
},
close() {
return commandHandler.sendClose();
},
events: commandHandler.events
})
)
)
);
self.history.save(
self.info.name() + ":" + configInput.host,
configInput.host,
new Date(),
self.info,
configInput
);
},
async "connect.failed"(rd) {
let readed = await reader.readCompletely(rd),
message = new TextDecoder("utf-8").decode(readed.buffer);
self.step.resolve(self.stepErrorDone("Connection failed", message));
},
"@inband"(rd) {},
close() {},
"@completed"() {}
});
}
stepInitialPrompt() {
let self = this;
if (this.config) {
self.hasStarted = true;
self.streams.request(COMMAND_ID, sd => {
return self.buildCommand(sd, this.config);
});
return self.stepWaitForAcceptWait();
}
return command.prompt(
"Telnet",
"Teletype Network",
"Connect",
r => {
self.hasStarted = true;
self.streams.request(COMMAND_ID, sd => {
return self.buildCommand(sd, r);
});
self.step.resolve(self.stepWaitForAcceptWait());
},
() => {},
command.fields(initialFieldDef, [{ name: "Host" }])
);
}
}
export class Command {
constructor() {}
id() {
return COMMAND_ID;
}
name() {
return "Telnet";
}
description() {
return "Teletype Network";
}
color() {
return "#6ac";
}
builder(info, config, streams, subs, controls, history) {
return new Wizard(info, config, streams, subs, controls, history);
}
launch(info, launcher, streams, subs, controls, history) {
try {
initialFieldDef["Host"].verify(launcher);
} catch (e) {
throw new Exception(
'Given launcher "' + launcher + '" was invalid: ' + e
);
}
return this.builder(
info,
{
host: launcher
},
streams,
subs,
controls,
history
);
}
launcher(config) {
return config.host;
}
}

542
ui/common.css Normal file
View File

@@ -0,0 +1,542 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@charset "UTF-8";
@import "~normalize.css";
html,
body {
height: 100%;
}
* {
margin: 0;
padding: 0;
}
p {
overflow: hidden;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
padding: 0;
}
a {
outline: 0;
}
body {
line-height: 1.5;
}
body {
background: #444;
font-family: Arial, Helvetica, sans-serif;
font-size: 1em;
position: relative;
}
/* Tabs */
.tab1 {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-items: center;
list-style: none;
list-style-position: inside;
margin: 0;
padding: 0;
height: 100%;
}
.tab1 > li {
padding: 0 15px;
color: #999;
white-space: nowrap;
word-wrap: none;
text-overflow: ellipsis;
overflow: hidden;
flex: initial;
display: flex;
flex-direction: column;
justify-content: center;
cursor: pointer;
}
.tab1 > li.active {
background: #444;
}
.tab1.tab1-list {
flex-direction: column;
flex-wrap: wrap;
}
.tab1.tab1-list > li {
flex: 0 0;
margin: 0;
padding: 0;
width: 100%;
box-sizing: border-box;
}
.tab2 {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-items: center;
list-style: none;
list-style-position: inside;
margin: 0;
height: 100%;
color: #aaa;
border-bottom: 1px solid #a56;
background: #333;
padding: 0 10px;
position: relative;
}
.tab2::before {
content: " ";
display: block;
position: absolute;
width: 100%;
height: 1px;
left: 0;
right: 0;
bottom: 0;
box-shadow: 0 -3px 3px #0003;
}
.tab2 > li {
flex: auto;
cursor: pointer;
border-color: transparent;
border-width: 1px 1px 0 1px;
border-style: solid;
padding: 7px 10px;
text-align: center;
position: relative;
z-index: 1;
}
.tab2 > li.active {
color: #fff;
background: #644;
margin-bottom: -1px;
border-color: #a56;
border-style: solid;
box-shadow: 0 -2px 2px #0002;
}
/* List */
.lstcl1 {
list-style: none;
list-style-position: inside;
margin: 0;
padding: 0;
width: 100%;
overflow: auto;
}
.lstcl1 > li {
width: 33%;
float: left;
white-space: nowrap;
}
.lstcl1 > li .lst-wrap {
padding: 10px;
margin: 10px;
background: #333;
text-overflow: ellipsis;
overflow: hidden;
box-shadow: 2px 2px 0 0 #222;
}
.lstcl1 > li .lst-wrap:hover {
background: #3a3a3a;
box-shadow: 2px 2px 0 0 #222;
}
.lstcl1 > li .lst-wrap:active {
background: #333;
}
.lst1 {
list-style: none;
list-style-position: inside;
margin: 0;
padding: 0;
width: 100%;
}
.lst1 > li {
border-bottom: 1px solid #555;
}
.lst1 > li:last-child {
border-bottom: none;
}
.lst1 > li .lst-wrap {
padding: 10px;
}
/* Icon */
.icon {
line-height: 1;
overflow: visible;
}
.icon.icon-close1 {
margin-top: -2.5px;
font-size: 26px;
line-height: 0;
}
.icon.icon-close1::before {
content: "\00d7";
font-weight: bold;
display: block;
margin: 0;
padding: 6px;
}
.icon.icon-plus1 {
color: #fff;
background: #a56;
}
.icon.icon-plus1::before {
content: "+";
font-weight: bold;
}
.icon.icon-more1 {
color: #fff;
background: #222;
}
.icon.icon-more1::before {
content: "\2261";
font-weight: bold;
}
.icon.icon-warning1 {
font-size: 20px;
}
.icon.icon-warning1::after {
content: "!";
font-weight: bold;
background: #e11;
padding: 10px;
}
.icon.icon-point1 {
position: relative;
text-shadow: 0 0 3px #fff;
}
.icon.icon-point1::after {
content: "\25CF";
}
/* Windows */
.window {
position: absolute;
}
.window.window1 {
background: #a56;
box-shadow: 0 0 5px #0006;
color: #fff;
font-size: 1em;
display: none;
}
.window.window1.display {
display: block;
}
.window.window1::before {
top: -5px;
position: absolute;
display: block;
content: " ";
width: 10px;
height: 10px;
background: #a56;
transform: rotate(45deg);
}
.window.window1 .window-frame {
width: 100%;
overflow: auto;
position: relative;
}
.window.window1 .window-title {
font-size: 0.9em;
font-weight: bold;
text-transform: uppercase;
color: #e9a;
}
.window.window1 .window-close {
position: absolute;
top: 10px;
right: 10px;
border-color: #844;
cursor: pointer;
}
.window.window1 .window-close::after {
border-color: #844;
}
/* Form1 */
.form1 {
}
.form1 > fieldset,
.form1 > fieldset * {
padding: 0;
margin: 0;
color: #fff;
outline: none;
border: 0;
font-size: 1em;
}
.form1 > fieldset .field {
color: #ccc;
display: block;
width: 100%;
overflow: auto;
padding: 1px;
}
.form1 > fieldset .field.horizontal {
width: auto;
float: left;
margin-right: 10px;
}
.form1 > fieldset .items {
width: 100%;
overflow: auto;
}
.form1 > fieldset .field.horizontal.item {
margin-top: 3px;
margin-bottom: 3px;
}
.form1 > fieldset .field.horizontal:last-child {
margin-right: 0;
}
.form1 > fieldset .field,
.form1 > fieldset .field input,
.form1 > fieldset .field textarea,
.form1 > fieldset .field button {
vertical-align: middle;
font-size: 1.05em;
}
.form1 > fieldset .field {
font-size: 0.95em;
margin-bottom: 10px;
}
.form1 > fieldset .field > .textinfo {
color: #fff;
}
.form1 > fieldset .field > .textinfo > .info {
padding: 10px;
margin: 10px 0;
font-size: 1.1em;
background: #292929;
border: 1px solid #444;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
}
.form1 > fieldset .field:last-child {
margin-bottom: 0;
}
.form1 > fieldset .field:last-child {
margin-bottom: 0;
}
.form1 > fieldset .field > input,
.form1 > fieldset .field > textarea,
.form1 > fieldset .field > button {
box-sizing: border-box;
resize: none;
}
.form1 > fieldset .field > input::placeholder {
color: #666;
}
.form1 > fieldset .field > input:focus::placeholder {
color: #444;
}
.form1 > fieldset .field > input[type="text"],
.form1 > fieldset .field > input[type="email"],
.form1 > fieldset .field > input[type="number"],
.form1 > fieldset .field > input[type="search"],
.form1 > fieldset .field > input[type="tel"],
.form1 > fieldset .field > input[type="url"],
.form1 > fieldset .field > input[type="password"],
.form1 > fieldset .field > textarea {
width: 100%;
padding: 10px;
border: 0;
background: #2e2e2e;
margin-top: 5px;
border-bottom: 2px solid #3e3e3e;
}
.form1 > fieldset .field > textarea {
min-height: 120px;
}
.form1 > fieldset .field.error > .error {
margin-top: 5px;
color: #f55;
}
.form1 > fieldset .field > .message {
margin-top: 5px;
color: #999;
}
.form1 > fieldset .field > .message * {
color: #999;
}
.form1 > fieldset .field > .message > p {
margin-bottom: 5px;
}
.form1 > fieldset .field > .message > a {
color: #e9a;
}
.form1 > fieldset .field.error > input[type="text"],
.form1 > fieldset .field.error > input[type="email"],
.form1 > fieldset .field.error > input[type="number"],
.form1 > fieldset .field.error > input[type="search"],
.form1 > fieldset .field.error > input[type="tel"],
.form1 > fieldset .field.error > input[type="url"],
.form1 > fieldset .field.error > input[type="password"],
.form1 > fieldset .field.error > textarea {
background: #483535;
border-bottom: 2px solid #a83333;
}
.form1 > fieldset .field > input:disabled,
.form1 > fieldset .field > textarea:disabled,
.form1 > fieldset .field > button:disabled {
opacity: 0.35;
}
.form1 > fieldset .field > input:disabled:active,
.form1 > fieldset .field > textarea:disabled:active,
.form1 > fieldset .field > button:disabled:active {
opacity: 0.5;
}
.form1 > fieldset .field > input[type="checkbox"],
.form1 > fieldset .field > input[type="radio"] {
background: #2e2e2e;
margin-right: 3px;
}
.form1 > fieldset .field > input[type="checkbox"]:focus,
.form1 > fieldset .field > input[type="radio"]:focus {
outline: 1px solid #e9a;
}
.form1 > fieldset .field > input[type="text"]:focus,
.form1 > fieldset .field > input[type="email"]:focus,
.form1 > fieldset .field > input[type="number"]:focus,
.form1 > fieldset .field > input[type="search"]:focus,
.form1 > fieldset .field > input[type="tel"]:focus,
.form1 > fieldset .field > input[type="url"]:focus,
.form1 > fieldset .field > input[type="password"]:focus,
.form1 > fieldset .field > textarea:focus {
background: #222;
border-bottom: 2px solid #e9a;
}
.form1 > fieldset .field > button {
padding: 8px 13px;
font-weight: normal;
background: #c78;
border-color: #c78;
border-width: 2px;
border-style: solid;
}
.form1 > fieldset .field > button:hover {
border-color: #a56;
background: #c78;
}
.form1 > fieldset .field > button:active {
background: #a56;
border-color: #a56;
}
.form1 > fieldset .field > button:focus {
outline: 1px dotted #eee;
}
.form1 > fieldset .field > button.secondary {
float: right;
background: transparent;
color: #eee;
border-color: #eee;
}
.form1 > fieldset .field > button.secondary:hover {
border-color: #ddd;
color: #ddd;
}
.form1 > fieldset .field > button.secondary:active {
border-color: #666;
color: #666;
}

132
ui/control/ssh.js Normal file
View File

@@ -0,0 +1,132 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import * as subscribe from "../stream/subscribe.js";
import * as reader from "../stream/reader.js";
import * as color from "../commands/color.js";
class Control {
constructor(data, color) {
this.colorM = color;
this.colors = this.colorM.get();
this.enable = false;
this.sender = data.send;
this.closer = data.close;
this.resizer = data.resize;
this.subs = new subscribe.Subscribe();
let self = this;
data.events.place("stdout", async rd => {
try {
self.subs.resolve(await reader.readCompletely(rd));
} catch (e) {
// Do nothing
}
});
data.events.place("stderr", async rd => {
try {
self.subs.resolve(await reader.readCompletely(rd));
} catch (e) {
// Do nothing
}
});
data.events.place("completed", () => {
self.closed = true;
self.colorM.forget(self.colors.color);
self.subs.reject("Remote connection has been terminated");
});
}
echo() {
return false;
}
resize(dim) {
if (this.closed) {
return;
}
this.resizer(dim.rows, dim.cols);
}
ui() {
return "Console";
}
enabled() {
this.enable = true;
}
disabled() {
this.enable = false;
}
receive() {
return this.subs.subscribe();
}
send(data) {
if (this.closed) {
return;
}
return this.sender(new TextEncoder("utf-8").encode(data));
}
color() {
return this.colors.dark;
}
activeColor() {
return this.colors.color;
}
close() {
if (this.closer === null) {
return;
}
let cc = this.closer;
this.closer = null;
return cc();
}
}
export class SSH {
/**
* constructor
*
* @param {color.Color} c
*/
constructor(c) {
this.color = c;
}
type() {
return "SSH";
}
build(data) {
return new Control(data, this.color);
}
}

451
ui/control/telnet.js Normal file
View File

@@ -0,0 +1,451 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import * as subscribe from "../stream/subscribe.js";
import * as reader from "../stream/reader.js";
import * as color from "../commands/color.js";
import Exception from "../commands/exception.js";
// const maxReadBufSize = 1024;
const cmdSE = 240;
// const cmdNOP = 241;
// const cmdDataMark = 242;
// const cmdBreak = 243;
// const cmdInterrputProcess = 244;
// const cmdAbortOutput = 245;
// const cmdAreYouThere = 246;
// const cmdEraseCharacter = 247;
// const cmdEraseLine = 248;
const cmdGoAhead = 249;
const cmdSB = 250;
const cmdWill = 251;
const cmdWont = 252;
const cmdDo = 253;
const cmdDont = 254;
const cmdIAC = 255;
const optEcho = 1;
const optSuppressGoAhead = 3;
const optTerminalType = 24;
const optNAWS = 31;
const optTerminalTypeIs = 0;
const optTerminalTypeSend = 1;
const unknownTermTypeSendData = new Uint8Array([
optTerminalTypeIs,
88,
84,
69,
82,
77
]);
// Most of code of this class is directly from
// https://github.com/ziutek/telnet/blob/master/conn.go#L122
// Thank you!
class Parser {
constructor(sender, flusher, callbacks) {
this.sender = sender;
this.flusher = flusher;
this.callbacks = callbacks;
this.reader = new reader.Multiple(() => {});
this.options = {
echoEnabled: false,
suppressGoAhead: false,
nawsAccpeted: false
};
this.current = 0;
}
sendNego(cmd, option) {
return this.sender(new Uint8Array([cmdIAC, cmd, option]));
}
sendDeny(cmd, o) {
switch (cmd) {
case cmdDo:
return this.sendNego(cmdWont, o);
case (cmdWill, cmdWont):
return this.sendNego(cmdDont, o);
}
}
sendWillSubNego(willCmd, data, option) {
let b = new Uint8Array(6 + data.length + 2);
b.set([cmdIAC, willCmd, option, cmdIAC, cmdSB, option], 0);
b.set(data, 6);
b.set([cmdIAC, cmdSE], data.length + 6);
return this.sender(b);
}
sendSubNego(data, option) {
let b = new Uint8Array(3 + data.length + 2);
b.set([cmdIAC, cmdSB, option], 0);
b.set(data, 3);
b.set([cmdIAC, cmdSE], data.length + 3);
return this.sender(b);
}
async handleTermTypeSubNego(rd) {
let action = await reader.readOne(rd);
if (action[0] !== optTerminalTypeSend) {
return null;
}
let self = this;
return () => {
self.sendSubNego(unknownTermTypeSendData, optTerminalType);
};
}
async handleSubNego(rd) {
let endExec = null;
for (;;) {
let d = await reader.readOne(rd);
switch (d[0]) {
case optTerminalType:
endExec = await this.handleTermTypeSubNego(rd);
continue;
case cmdIAC:
break;
default:
continue;
}
let e = await reader.readOne(rd);
if (e[0] !== cmdSE) {
continue;
}
if (endExec !== null) {
endExec();
}
return;
}
}
handleOption(cmd, option, oldVal, newVal) {
switch (cmd) {
case cmdWill:
if (!oldVal) {
this.sendNego(cmdDo, option);
newVal(true);
}
return;
case cmdWont:
if (oldVal) {
this.sendNego(cmdDont, option);
newVal(false);
}
return;
case cmdDo:
if (!oldVal) {
this.sendNego(cmdWill, option);
newVal(true);
}
return;
case cmdDont:
if (oldVal) {
this.sendNego(cmdWont, option);
newVal(false);
}
return;
}
}
async handleCmd(rd) {
let d = await reader.readOne(rd);
switch (d[0]) {
case cmdWill:
case cmdWont:
case cmdDo:
case cmdDont:
break;
case cmdIAC:
this.flusher(d);
return;
case cmdGoAhead:
return;
case cmdSB:
await this.handleSubNego(rd);
return;
default:
throw new Exception("Unknown command");
}
let o = await reader.readOne(rd);
switch (o[0]) {
case optEcho:
return this.handleOption(d[0], o[0], this.options.echoEnabled, d => {
this.options.echoEnabled = d;
this.callbacks.setEcho(this.options.echoEnabled);
});
case optSuppressGoAhead:
return this.handleOption(
d[0],
o[0],
this.options.suppressGoAhead,
d => {
this.options.suppressGoAhead = d;
}
);
case optNAWS:
// Window resize allowed?
if (d[0] !== cmdDo) {
this.sendDeny(d[0], o[0]);
return;
}
let dim = this.callbacks.getWindowDim(),
dimData = new DataView(new ArrayBuffer(4));
dimData.setUint16(0, dim.cols);
dimData.setUint16(2, dim.rows);
let dimBytes = new Uint8Array(dimData.buffer);
if (this.options.nawsAccpeted) {
this.sendSubNego(dimBytes, optNAWS);
return;
}
this.options.nawsAccpeted = true;
this.sendWillSubNego(cmdWill, dimBytes, optNAWS);
return;
case optTerminalType:
if (d[0] !== cmdDo) {
this.sendDeny(d[0], o[0]);
return;
}
this.sendNego(cmdWill, o[0]);
return;
}
this.sendDeny(d[0], o[0]);
}
requestWindowResize() {
this.options.nawsAccpeted = true;
this.sendNego(cmdWill, optNAWS);
}
async run() {
try {
for (;;) {
let d = await reader.readUntil(this.reader, cmdIAC);
if (!d.found) {
this.flusher(d.data);
continue;
}
if (d.data.length > 1) {
this.flusher(d.data.slice(0, d.data.length - 1));
}
await this.handleCmd(this.reader);
}
} catch (e) {
// Do nothing
}
}
feed(rd, cb) {
this.reader.feed(rd, cb);
}
close() {
this.reader.close();
}
}
class Control {
constructor(data, color) {
this.colorM = color;
this.colors = this.colorM.get();
this.sender = data.send;
this.closer = data.close;
this.closed = false;
this.echoEnabled = true;
this.subs = new subscribe.Subscribe();
this.enable = false;
this.windowDim = {
cols: 65535,
rows: 65535
};
let self = this;
this.parser = new Parser(
this.sender,
d => {
self.subs.resolve(d);
},
{
setEcho(newVal) {
if (newVal) {
self.echoEnabled = false;
return;
}
self.echoEnabled = true;
},
getWindowDim() {
return self.windowDim;
}
}
);
let runWait = this.parser.run();
data.events.place("inband", rd => {
return new Promise((resolve, reject) => {
self.parser.feed(rd, () => {
resolve(true);
});
});
});
data.events.place("completed", async () => {
self.parser.close();
self.closed = true;
self.colorM.forget(self.colors.color);
await runWait;
self.subs.reject("Remote connection has been terminated");
});
}
echo() {
return this.echoEnabled;
}
resize(dim) {
if (this.closed) {
return;
}
this.windowDim.cols = dim.cols;
this.windowDim.rows = dim.rows;
this.parser.requestWindowResize();
}
ui() {
return "Console";
}
enabled() {
this.enable = true;
}
disabled() {
this.enable = false;
}
receive() {
return this.subs.subscribe();
}
send(data) {
if (this.closed) {
return;
}
return this.sender(new TextEncoder("utf-8").encode(data));
}
color() {
return this.colors.dark;
}
activeColor() {
return this.colors.color;
}
close() {
if (this.closer === null) {
return;
}
let cc = this.closer;
this.closer = null;
return cc();
}
}
export class Telnet {
/**
* constructor
*
* @param {color.Color} c
*/
constructor(c) {
this.color = c;
}
type() {
return "Telnet";
}
build(data) {
return new Control(data, this.color);
}
}

103
ui/crypto.js Normal file
View File

@@ -0,0 +1,103 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
/**
* Generate HMAC 512 of given data
*
* @param {Uint8Array} secret Secret key
* @param {Uint8Array} data Data to be HMAC'ed
*/
export async function hmac512(secret, data) {
const key = await crypto.subtle.importKey(
"raw",
secret,
{
name: "HMAC",
hash: { name: "SHA-512" }
},
false,
["sign", "verify"]
);
return crypto.subtle.sign("HMAC", key, data);
}
export const GCMNonceSize = 12;
/**
* Build AES GCM Encryption/Decryption key
*
* @param {Uint8Array} keyData Key data
*/
export function buildGCMKey(keyData) {
return crypto.subtle.importKey("raw", keyData, "aes-gcm", false, [
"encrypt",
"decrypt"
]);
}
/**
* Encrypt data
*
* @param {CryptoKey} key Key
* @param {Uint8Array} iv Nonce
* @param {Uint8Array} plaintext Data to be encrypted
*/
export function encryptGCM(key, iv, plaintext) {
return crypto.subtle.encrypt({ name: "aes-gcm", iv: iv }, key, plaintext);
}
/**
* Decrypt data
*
* @param {CryptoKey} key Key
* @param {Uint8Array} iv Nonce
* @param {Uint8Array} cipherText Data to be decrypted
*/
export function decryptGCM(key, iv, cipherText) {
return crypto.subtle.decrypt({ name: "aes-gcm", iv: iv }, key, cipherText);
}
/**
* generate Random nonce
*
*/
export function generateNonce() {
return crypto.getRandomValues(new Uint8Array(GCMNonceSize));
}
/**
* Increase nonce by one
*
* @param {Uint8Array} nonce Nonce data
*
* @returns {Uint8Array} New nonce
*
*/
export function increaseNonce(nonce) {
for (let i = nonce.length; i > 0; i--) {
nonce[i - 1]++;
if (nonce[i - 1] <= 0) {
continue;
}
break;
}
return nonce;
}

45
ui/history.js Normal file
View File

@@ -0,0 +1,45 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
export class Records {
/**
* constructor
*
* @param {array} data Data space
*/
constructor(data) {
this.data = data;
}
/**
* Insert new item into the history records
*
* @param {number} newData New value
*/
update(newData) {
this.data.shift();
this.data.push(newData);
}
/**
* Return data
*
*/
get() {
return this.data;
}
}

442
ui/home.css Normal file
View File

@@ -0,0 +1,442 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@charset "utf-8";
@import "~roboto-fontface/css/roboto/roboto-fontface.css";
@keyframes home-window-display-flash {
0% {
top: -2px;
opacity: 0;
box-shadow: 0 0 0 transparent;
}
20% {
height: 20px;
box-shadow: 0 0 50px #fff;
}
50% {
opacity: 0.5;
box-shadow: 0 0 10px #fff;
}
100% {
top: 100%;
height: 0;
opacity: 0;
box-shadow: 0 0 0 transparent;
}
}
.home-window-display {
}
.home-window-display::after {
opacity: 0;
z-index: 2;
content: " ";
display: block;
position: absolute;
width: 100%;
height: 0;
top: -2px;
left: 0;
right: 0;
background: #fff;
animation-name: home-window-display-flash;
animation-duration: 0.3s;
animation-iteration-count: 1;
box-shadow: 0 0 10px #fff;
}
#home {
height: 100%;
display: flex;
flex-direction: column;
font: 1em "Roboto", sans-serif;
}
#home-header {
flex: 0 0 40px;
font-size: 0.9em;
color: #fff;
width: 100%;
overflow: auto;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
#home-hd-title {
font-size: 1.1em;
padding: 0 0 0 20px;
font-weight: bold;
flex: 0 0 65px;
text-align: center;
overflow: hidden;
}
#home-hd-delay {
font-size: 0.95em;
display: flex;
flex: 0 0 70px;
flex-direction: row;
overflow: hidden;
margin: 0 10px;
color: #aaa;
text-decoration: none;
justify-items: center;
justify-content: center;
align-items: center;
}
@media (max-width: 768px) {
#home-hd-title {
padding: 0 0 0 10px;
}
#home-hd-delay {
flex: 0 0 60px;
}
}
#home-hd-delay-icon {
color: #bbb;
text-shadow: 0 0 3px #999;
transition: linear 0.2s color, text-shadow;
margin: 5px;
font-size: 0.54em;
}
#home-hd-delay-value {
display: inline-block;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
word-wrap: none;
}
@keyframes home-hd-delay-icon-flash {
0% {
opacity: 1;
}
10% {
opacity: 0;
}
20% {
opacity: 1;
}
30% {
opacity: 0;
}
90% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes home-hd-delay-icon-working {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
#home-hd-delay-icon.green {
color: #1e8;
text-shadow: 0 0 3px #1e8;
}
#home-hd-delay-icon.yellow {
color: #ff4;
text-shadow: 0 0 3px #ff4;
}
#home-hd-delay-icon.orange {
color: #f80;
text-shadow: 0 0 3px #f80;
}
#home-hd-delay-icon.red {
color: #e11;
text-shadow: 0 0 3px #e11;
}
#home-hd-delay-icon.flash {
animation-name: home-hd-delay-icon-flash;
animation-duration: 1s;
animation-iteration-count: infinite;
}
#home-hd-delay-icon.working {
animation-name: home-hd-delay-icon-working;
animation-duration: 1.5s;
animation-iteration-count: infinite;
}
#home-hd-plus {
flex: 0 0;
padding: 0 13px;
text-decoration: none;
font-size: 22px;
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
}
@keyframes home-hd-plus-icon-flash {
0% {
background: #a56;
}
20% {
background: #5a7;
}
40% {
background: #96a;
}
60% {
background: #379;
}
80% {
background: #da0;
}
100% {
background: #a56;
}
}
#home-hd-plus.working {
color: #fff;
background: #a56;
animation-name: home-hd-plus-icon-flash;
animation-duration: 10s;
animation-iteration-count: infinite;
animation-direction: normal;
transition: linear 2s background;
}
#home-hd-plus.working.intensify {
animation-duration: 3s;
}
#home-hd-tabs {
background: #333;
flex: auto;
overflow: hidden;
height: 100%;
display: flex;
flex-direction: row;
justify-content: left;
align-items: center;
}
#home-hd-tabs-tabs {
flex: auto;
overflow: hidden;
}
#home-hd-tabs-tabs > li {
flex: 0 0 180px;
display: flex;
position: relative;
padding: 0 15px;
opacity: 0.5;
color: #999;
}
#home-hd-tabs-tabs > li::after {
content: " ";
display: block;
position: absolute;
bottom: 0;
right: 10px;
left: 10px;
height: 0;
transition: all 0.1s linear;
transition-property: height, right, left;
}
#home-hd-tabs-tabs > li.active::after {
right: 0;
left: 0;
}
#home-hd-tabs-tabs > li.updated::after {
background: #fff3;
height: 2px;
}
#home-hd-tabs-tabs > li.error::after {
background: #d55;
height: 2px;
}
#home-hd-tabs-tabs > li > span.title {
text-overflow: ellipsis;
overflow: hidden;
display: inline-block;
}
#home-hd-tabs-tabs > li > span.title > span.type {
display: inline-block;
font-size: 0.85em;
font-weight: bold;
margin-right: 3px;
text-transform: uppercase;
color: #fff;
background: #222;
padding: 1px 4px;
border-radius: 2px;
}
#home-hd-tabs-tabs > li > .icon-close {
display: none;
}
#home-hd-tabs-tabs > li.active {
color: #fff;
opacity: 1;
}
#home-hd-tabs-tabs > li.active > span.title {
padding-right: 15px;
}
#home-hd-tabs-tabs > li.active > .icon-close {
display: block;
position: absolute;
top: 50%;
right: 10px;
margin-top: -5px;
color: #fff6;
}
#home-hd-tabs-list {
display: flex;
font-size: 22px;
flex: 0 0;
padding: 0 13px;
flex-direction: column;
justify-content: center;
height: 100%;
text-decoration: none;
box-shadow: 0 0 3px #333;
}
#home-content {
flex: auto;
display: flex;
justify-content: center;
flex-direction: column;
color: #fff;
font-size: 1.2em;
}
@media (max-width: 768px) {
#home-content {
font-size: 1em;
}
}
#home-content > .screen {
display: flex;
justify-content: top;
flex-direction: column;
font-size: 1em;
}
#home-content > .screen > .screen-error {
display: block;
padding: 10px;
background: #b44;
color: #fff;
font-size: 0.75em;
flex: 0 0;
}
#home-content > .screen > .screen-screen {
flex: auto;
}
#home-content-wrap {
max-width: 520px;
margin: 50px auto;
padding: 0 30px;
text-align: center;
}
#home-content h1 {
margin: 20px 0;
}
#home-content p {
margin: 10px 0;
font-size: 0.9em;
color: #eee;
line-height: 1.6;
}
#home-content p.secondary {
margin: 5px 0;
line-height: 1.5;
font-size: 0.7em;
color: #aaa;
}
#home-content p a {
color: #e9a;
}
#home-content hr {
height: 2px;
background: #3c3c3c;
border: none;
margin: 30px 0;
}
#home-content-connect {
padding: 5px;
display: inline-block;
width: 20px;
height: 20px;
cursor: pointer;
}

495
ui/home.vue Normal file
View File

@@ -0,0 +1,495 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<template>
<div id="home">
<header id="home-header">
<h1 id="home-hd-title">Sshwifty</h1>
<a id="home-hd-delay" href="javascript:;" @click="showDelayWindow">
<span
id="home-hd-delay-icon"
class="icon icon-point1"
:class="socket.classStyle"
></span>
<span v-if="socket.message.length > 0" id="home-hd-delay-value">{{
socket.message
}}</span>
</a>
<a
id="home-hd-plus"
class="icon icon-plus1"
href="javascript:;"
:class="{
working: connector.inputting,
intensify: connector.inputting && !windows.connect
}"
@click="showConnectWindow"
></a>
<tabs
id="home-hd-tabs"
:tab="tab.current"
:tabs="tab.tabs"
tabs-class="tab1"
list-trigger-class="icon icon-more1"
@current="switchTab"
@list="showTabsWindow"
@close="closeTab"
></tabs>
</header>
<screens
id="home-content"
:screen="tab.current"
:screens="tab.tabs"
@stopped="tabStopped"
@updated="tabUpdated"
>
<div id="home-content-wrap">
<h1>
Hi, this is Sshwifty
</h1>
<p>
An Open Source Web SSH Client that enables you to connect to SSH
servers without download any additional software.
</p>
<p>
To get started, click the
<span
id="home-content-connect"
class="icon icon-plus1"
@click="showConnectWindow"
></span>
icon near the top left corner.
</p>
<hr />
<p class="secondary">
Programmers in China are having a campaign against
<a
href="https://en.wikipedia.org/wiki/996_working_hour_system"
target="blank"
>forced overtime work</a
>. Sshwifty wouldn't exist if it's author have to work such extreme
hours. If you're benefiting from hobbyist project like this one,
please consider to
<a
href="https://github.com/996icu/996.ICU/#what-can-i-do"
target="blank"
>support the action</a
>.
</p>
</div>
</screens>
<connect-widget
:inputting="connector.inputting"
:display="windows.connect"
:connectors="connector.connectors"
:knowns="connector.knowns"
:knowns-launcher-builder="buildknownLauncher"
:busy="connector.busy"
@display="windows.connect = $event"
@connector-select="connectNew"
@known-select="connectKnown"
@known-remove="removeKnown"
>
<connector
:connector="connector.connector"
@cancel="cancelConnection"
@done="connectionSucceed"
>
</connector>
</connect-widget>
<status-widget
:class="socket.windowClass"
:display="windows.delay"
:status="socket.status"
@display="windows.delay = $event"
></status-widget>
<tab-window
:tab="tab.current"
:tabs="tab.tabs"
:display="windows.tabs"
tabs-class="tab1 tab1-list"
@display="windows.tabs = $event"
@current="switchTab"
@close="closeTab"
></tab-window></div
></template>
<script>
import "./home.css";
import ConnectWidget from "./widgets/connect.vue";
import StatusWidget from "./widgets/status.vue";
import Connector from "./widgets/connector.vue";
import Tabs from "./widgets/tabs.vue";
import TabWindow from "./widgets/tab_window.vue";
import Screens from "./widgets/screens.vue";
import * as home_socket from "./home_socketctl.js";
import * as home_history from "./home_historyctl.js";
const BACKEND_CONNECT_ERROR =
"Unable to connect to the Sshwifty backend server: ";
const BACKEND_REQUEST_ERROR = "Unable to perform request: ";
export default {
components: {
"connect-widget": ConnectWidget,
"status-widget": StatusWidget,
connector: Connector,
tabs: Tabs,
"tab-window": TabWindow,
screens: Screens
},
props: {
hostPath: {
type: String,
default: ""
},
query: {
type: String,
default: ""
},
connection: {
type: Object,
default: () => {
return null;
}
},
controls: {
type: Object,
default: () => {
return null;
}
},
commands: {
type: Object,
default: () => {
return null;
}
}
},
data() {
let history = home_history.build(this);
return {
ticker: null,
windows: {
delay: false,
connect: false,
tabs: false
},
socket: home_socket.build(this),
connector: {
historyRec: history,
connector: null,
connectors: this.commands.all(),
inputting: false,
acquired: false,
busy: false,
knowns: history.all()
},
tab: {
current: -1,
lastID: 0,
tabs: []
}
};
},
mounted() {
this.ticker = setInterval(() => {
this.tick();
}, 1000);
if (this.query.length > 1 && this.query.indexOf("+") === 0) {
this.connectLaunch(this.query.slice(1, this.query.length));
this.$emit("navigate-to", "");
}
},
beforeDestroy() {
if (this.ticker === null) {
clearInterval(this.ticker);
this.ticker = null;
}
},
methods: {
tick() {
let now = new Date();
this.socket.update(now, this);
},
closeAllWindow() {
for (let i in this.windows) {
this.windows[i] = false;
}
},
showDelayWindow() {
this.closeAllWindow();
this.windows.delay = true;
},
showConnectWindow() {
this.closeAllWindow();
this.windows.connect = true;
},
showTabsWindow() {
this.closeAllWindow();
this.windows.tabs = true;
},
async getStreamThenRun(run, end) {
let errStr = null;
try {
let conn = await this.connection.get(this.socket);
try {
run(conn);
} catch (e) {
errStr = BACKEND_REQUEST_ERROR + e;
process.env.NODE_ENV === "development" && console.trace(e);
}
} catch (e) {
errStr = BACKEND_CONNECT_ERROR + e;
process.env.NODE_ENV === "development" && console.trace(e);
}
end();
if (errStr !== null) {
alert(errStr);
}
},
runConnect(callback) {
if (this.connector.acquired) {
return;
}
this.connector.acquired = true;
this.connector.busy = true;
this.getStreamThenRun(
stream => {
this.connector.busy = false;
callback(stream);
},
() => {
this.connector.busy = false;
this.connector.acquired = false;
}
);
},
connectNew(connector) {
this.runConnect(stream => {
this.connector.connector = {
id: connector.id(),
name: connector.name(),
description: connector.description(),
wizard: connector.build(
stream,
this.controls,
this.connector.historyRec,
null
)
};
this.connector.inputting = true;
});
},
getConnectorByType(type) {
let connector = null;
for (let c in this.connector.connectors) {
if (this.connector.connectors[c].name() !== type) {
continue;
}
connector = this.connector.connectors[c];
}
return connector;
},
connectKnown(known) {
this.runConnect(stream => {
let connector = this.getConnectorByType(known.type);
if (!connector) {
alert("Unknown connector: " + known.type);
this.connector.inputting = false;
return;
}
this.connector.connector = {
id: connector.id(),
name: connector.name(),
description: connector.description(),
wizard: connector.build(
stream,
this.controls,
this.connector.historyRec,
known.data
)
};
this.connector.inputting = true;
});
},
parseConnectLauncher(ll) {
let llSeparatorIdx = ll.indexOf(":");
// Type must contain at least one charater
if (llSeparatorIdx <= 0) {
throw new Error("Invalid Launcher string");
}
return {
type: ll.slice(0, llSeparatorIdx),
query: ll.slice(llSeparatorIdx + 1, ll.length)
};
},
connectLaunch(launcher) {
this.showConnectWindow();
this.runConnect(stream => {
let ll = this.parseConnectLauncher(launcher),
connector = this.getConnectorByType(ll.type);
if (!connector) {
alert("Unknown connector: " + ll.type);
this.connector.inputting = false;
return;
}
this.connector.connector = {
id: connector.id(),
name: connector.name(),
description: connector.description(),
wizard: connector.launch(
stream,
this.controls,
this.connector.historyRec,
ll.query
)
};
this.connector.inputting = true;
});
},
buildknownLauncher(known) {
let connector = this.getConnectorByType(known.type);
if (!connector) {
return;
}
return this.hostPath + "#+" + connector.launcher(known.data);
},
removeKnown(uid) {
this.connector.historyRec.del(uid);
},
cancelConnection() {
this.connector.inputting = false;
this.connector.acquired = false;
},
connectionSucceed(data) {
this.connector.inputting = false;
this.connector.acquired = false;
this.windows.connect = false;
this.addToTab(data);
},
async addToTab(data) {
await this.switchTab(
this.tab.tabs.push({
id: this.tab.lastID++,
name: data.name,
info: data.info,
control: data.control,
indicator: {
error: "",
updated: false
},
status: {
closing: false
}
}) - 1
);
},
removeFromTab(index) {
let isLast = index === this.tab.tabs.length - 1;
this.tab.tabs.splice(index, 1);
this.tab.current = isLast ? this.tab.tabs.length - 1 : index;
},
async switchTab(to) {
if (this.tab.current >= 0) {
await this.tab.tabs[this.tab.current].control.disabled();
}
this.tab.current = to;
this.tab.tabs[this.tab.current].indicator.updated = false;
await this.tab.tabs[this.tab.current].control.enabled();
},
async closeTab(index) {
if (this.tab.tabs[index].status.closing) {
return;
}
this.tab.tabs[index].status.closing = true;
try {
this.tab.tabs[index].control.disabled();
await this.tab.tabs[index].control.close();
} catch (e) {
alert("Cannot close tab due to error: " + e);
process.env.NODE_ENV === "development" && console.trace(e);
}
this.removeFromTab(index);
},
tabStopped(index, reason) {
if (reason === null) {
this.tab.tabs[index].indicator.error = "";
} else {
this.tab.tabs[index].indicator.error = "" + reason;
}
},
tabUpdated(index) {
this.tab.tabs[index].indicator.updated = this.tab.current !== index;
}
}
};
</script>

22
ui/home_historyctl.js Normal file
View File

@@ -0,0 +1,22 @@
import { History } from "./commands/history.js";
export function build(ctx) {
let rec = JSON.parse(localStorage.getItem("knowns"));
if (!rec) {
rec = [];
}
return new History(
rec,
(h, d) => {
try {
localStorage.setItem("knowns", JSON.stringify(d));
ctx.connector.knowns = h.all();
} catch (e) {
alert("Unable to save remote history due to error: " + e);
}
},
32
);
}

184
ui/home_socketctl.js Normal file
View File

@@ -0,0 +1,184 @@
import { ECHO_FAILED } from "./socket.js";
import * as history from "./history.js";
export function build(ctx) {
const connectionStatusNotConnected = "Sshwifty is not connected";
const connectionStatusConnecting =
"Sshwifty is connecting to it's backend server, it should only take " +
"less than a second or two";
const connectionStatusDisconnected =
"Sshwifty has disconnected from it's backend server";
const connectionStatusConnected =
"Sshwifty has connected to it's backend server, user interface " +
"is operational";
const connectionStatusMeasurable =
"Unable to measure connection delay. The connection maybe very " +
"busy or already lost";
const connectionDelayGood =
"Connection delay is low, operation should be very responsive";
const connectionDelayFair =
"Experiencing minor connection delay, operation should be responded " +
"within reasonable time";
const connectionDelayMedian =
"Experiencing median connection delay, consider slow down your input " +
"to avoid misoperate";
const connectionDelayHeavy =
"Experiencing long connection delay, operation may appear to be sticked";
const buildEmptyHistory = () => {
let r = [];
for (let i = 0; i < 32; i++) {
r.push(0);
}
return r;
};
let inboundPerSecond = 0,
outboundPerSecond = 0,
trafficPreSecondNextUpdate = new Date(),
inboundPre10Seconds = 0,
outboundPre10Seconds = 0,
trafficPre10sNextUpdate = new Date(),
inboundHistory = new history.Records(buildEmptyHistory()),
outboundHistory = new history.Records(buildEmptyHistory()),
trafficSamples = 1;
let delayHistory = new history.Records(buildEmptyHistory()),
delaySamples = 0,
delayPerInterval = 0;
return {
update(time) {
if (time >= trafficPreSecondNextUpdate) {
trafficPreSecondNextUpdate = new Date(time.getTime() + 1000);
inboundPre10Seconds += inboundPerSecond;
outboundPre10Seconds += outboundPerSecond;
this.status.inbound = inboundPerSecond;
this.status.outbound = outboundPerSecond;
inboundPerSecond = 0;
outboundPerSecond = 0;
trafficSamples++;
}
if (time >= trafficPre10sNextUpdate) {
trafficPre10sNextUpdate = new Date(time.getTime() + 10000);
inboundHistory.update(inboundPre10Seconds / trafficSamples);
outboundHistory.update(outboundPre10Seconds / trafficSamples);
inboundPre10Seconds = 0;
outboundPre10Seconds = 0;
trafficSamples = 1;
if (delaySamples > 0) {
delayHistory.update(delayPerInterval / delaySamples);
delaySamples = 0;
delayPerInterval = 0;
}
}
},
classStyle: "",
windowClass: "",
message: "",
status: {
description: connectionStatusNotConnected,
delay: 0,
delayHistory: delayHistory.get(),
inbound: 0,
inboundHistory: inboundHistory.get(),
outbound: 0,
outboundHistory: outboundHistory.get()
},
connecting() {
this.message = "--";
this.classStyle = "working";
this.windowClass = "";
this.status.description = connectionStatusConnecting;
},
connected() {
this.message = "??";
this.classStyle = "working";
this.windowClass = "";
this.status.description = connectionStatusConnected;
},
traffic(inb, outb) {
inboundPerSecond += inb;
outboundPerSecond += outb;
},
echo(delay) {
delayPerInterval += delay > 0 ? delay : 0;
delaySamples++;
if (delay == ECHO_FAILED) {
this.message = "";
this.classStyle = "red flash";
this.windowClass = "red";
this.status.description = connectionStatusMeasurable;
return;
}
let avgDelay = Math.round(delayPerInterval / delaySamples);
this.message = Number(avgDelay).toLocaleString() + "ms";
this.status.delay = avgDelay;
if (avgDelay < 30) {
this.classStyle = "green";
this.windowClass = "green";
this.status.description =
connectionStatusConnected + ". " + connectionDelayGood;
} else if (avgDelay < 100) {
this.classStyle = "yellow";
this.windowClass = "yellow";
this.status.description =
connectionStatusConnected + ". " + connectionDelayFair;
} else if (avgDelay < 300) {
this.classStyle = "orange";
this.windowClass = "orange";
this.status.description =
connectionStatusConnected + ". " + connectionDelayMedian;
} else {
this.classStyle = "red";
this.windowClass = "red";
this.status.description =
connectionStatusConnected + ". " + connectionDelayHeavy;
}
},
close(e) {
ctx.connector.inputting = false;
if (e === null) {
this.message = "";
this.classStyle = "";
this.status.description = connectionStatusDisconnected;
return;
}
this.message = "ERR";
this.classStyle = "red flash";
this.windowClass = "red";
this.status.description = connectionStatusDisconnected + ". Error: " + e;
},
failed(e) {
ctx.connector.inputting = false;
if (e.code) {
this.message = "E" + e.code;
} else {
this.message = "E????";
}
this.classStyle = "red flash";
this.status.description = connectionStatusDisconnected + ". Error: " + e;
}
};
}

132
ui/landing.css Normal file
View File

@@ -0,0 +1,132 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@charset "utf-8";
body.landing {
background: #945;
}
#landing {
min-height: 100%;
font-family: Arial, Helvetica, sans-serif;
display: flex;
flex-direction: column;
justify-content: center;
justify-items: center;
color: #fff;
text-align: center;
}
#landing-message {
font-size: 0.9em;
max-width: 500px;
margin: 0 auto;
padding: 50px;
flex: 0 0;
}
#landing-message a {
text-decoration: none;
color: #fab;
}
#landing-message p {
margin: 10px 0;
}
#landing-message p.copy {
margin: 20px 0 10px 0;
color: #fab;
}
#landing-message p.copy.copy-first {
margin-top: 50px;
}
#landing-message p.copy a {
border: 1px solid #fab;
display: inline-block;
padding: 3px 7px;
margin-left: 5px;
border-radius: 5px;
line-height: initial;
}
#landing-message-logo {
background: url("./widgets/busy.svg") center center no-repeat;
background-size: contain;
width: 100%;
height: 200px;
}
#landing-message-info {
margin-top: 50px;
font-size: 0.9em;
}
#auth {
width: 100%;
min-height: 100%;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
#auth-frame {
flex: 0 0;
padding: 10px;
}
#auth-content {
background: #333;
box-shadow: 0 0 3px #111;
padding: 30px;
max-width: 380px;
margin: 0 auto;
font-size: 1em;
}
#auth-content > h1 {
margin-bottom: 20px;
color: #fab;
font-size: 1.2em;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 2px;
}
#auth-content > h1:after {
content: "\2731";
margin-left: 5px;
}
#auth-content > form {
font-size: 0.9em;
}
#auth-content > form .field:last-child {
margin-top: 30px;
}
#auth-content > form > fieldset {
margin-top: 30px;
}

47
ui/loading.vue Normal file
View File

@@ -0,0 +1,47 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<template>
<div id="app-loading">
<div id="app-loading-frame">
<div v-if="error.length <= 0" id="app-loading-icon"></div>
<div v-else id="app-loading-error">
&times;
</div>
<h1 v-if="error.length <= 0" id="app-loading-title">
Preparing client application
</h1>
<h1 v-else id="app-loading-title" class="error">
{{ error }}
</h1>
</div>
</div>
</template>
<script>
export default {
props: {
error: {
type: String,
default: ""
}
}
};
</script>

4
ui/robots.txt Normal file
View File

@@ -0,0 +1,4 @@
user-agent: *
Allow: /$
Disallow: /

335
ui/socket.js Normal file
View File

@@ -0,0 +1,335 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import * as streams from "./stream/streams.js";
import * as reader from "./stream/reader.js";
import * as sender from "./stream/sender.js";
import * as crypt from "./crypto.js";
export const ECHO_FAILED = streams.ECHO_FAILED;
class Dial {
/**
* constructor
*
* @param {string} address Address to the Websocket server
* @param {number} Dial timeout
* @param {object} privateKey String key that will be used to encrypt and
* decrypt socket traffic
*
*/
constructor(address, timeout, privateKey) {
this.address = address;
this.timeout = timeout;
this.privateKey = privateKey;
}
/**
* Connect to the remote server
*
* @param {number} timeout Connect timeout
*
* @returns {Promise<WebSocket>} When connection is established
*
*/
connect(timeout) {
return new Promise((resolve, reject) => {
let ws = new WebSocket(this.address),
promised = false,
timeoutTimer = setTimeout(() => {
ws.close();
}, timeout),
myRes = w => {
if (promised) {
return;
}
clearTimeout(timeoutTimer);
promised = true;
return resolve(w);
},
myRej = e => {
if (promised) {
return;
}
clearTimeout(timeoutTimer);
promised = true;
return reject(e);
};
ws.addEventListener("open", event => {
myRes(ws);
});
ws.addEventListener("close", event => {
event.toString = () => {
return "WebSocket Error (" + event.code + ")";
};
myRej(event);
});
ws.addEventListener("error", event => {
ws.close();
});
});
}
/**
* Build an socket encrypt and decrypt key string
*
*/
async buildKeyString() {
const enc = new TextEncoder("utf-8");
let rTime = Number(Math.trunc(new Date().getTime() / 100000)),
key = await crypt.hmac512(
enc.encode(await this.privateKey.fetch()),
enc.encode(rTime)
);
return key.slice(0, 24);
}
/**
* Build encrypt and decrypt key
*
*/
async buildKey() {
let kStr = await this.buildKeyString();
return await crypt.buildGCMKey(kStr);
}
/**
* Connect to the server
*
* @param {object} callbacks Callbacks
*
* @returns {object} A pair of ReadWriter which can be used to read and
* send data to the underlaying websocket connection
*
*/
async dial(callbacks) {
let ws = await this.connect(this.timeout),
rd = new reader.Reader(new reader.Multiple(() => {}), data => {
return new Promise(resolve => {
let bufferReader = new FileReader();
bufferReader.onload = event => {
let d = new Uint8Array(event.target.result);
resolve(d);
callbacks.inboundUnpacked(d);
};
bufferReader.readAsArrayBuffer(data);
});
}),
sdDataConvert = rawData => {
return rawData;
},
sd = new sender.Sender(
async rawData => {
let data = await sdDataConvert(rawData);
ws.send(data.buffer);
callbacks.outbound(data);
},
15,
4096 - 64 // Server has a 4096 bytes receive buffer, can be no greater
);
ws.addEventListener("message", event => {
callbacks.inbound(event.data);
rd.feed(event.data);
});
ws.addEventListener("error", event => {
rd.close();
});
ws.addEventListener("close", event => {
rd.close();
});
let senderNonce = crypt.generateNonce();
sd.send(senderNonce);
let receiverNonce = await reader.readN(rd, crypt.GCMNonceSize);
let key = await this.buildKey();
sdDataConvert = async rawData => {
let encoded = await crypt.encryptGCM(key, senderNonce, rawData);
crypt.increaseNonce(senderNonce);
let dataToSend = new Uint8Array(encoded.byteLength + 2);
dataToSend[0] = (encoded.byteLength >> 8) & 0xff;
dataToSend[1] = encoded.byteLength & 0xff;
dataToSend.set(new Uint8Array(encoded), 2);
return dataToSend;
};
let cgmReader = new reader.Multiple(async r => {
try {
let dSizeBytes = await reader.readN(rd, 2),
dSize = 0;
dSize = dSizeBytes[0];
dSize <<= 8;
dSize |= dSizeBytes[1];
let decoded = await crypt.decryptGCM(
key,
receiverNonce,
await reader.readN(rd, dSize)
);
crypt.increaseNonce(receiverNonce);
r.feed(new reader.Buffer(new Uint8Array(decoded), () => {}), () => {});
} catch (e) {
r.close();
}
});
return {
reader: cgmReader,
sender: sd,
ws: ws
};
}
}
export class Socket {
/**
* constructor
*
* @param {string} address Address of the WebSocket server
* @param {object} privateKey String key that will be used to encrypt and
* decrypt socket traffic
* @param {number} timeout Dial timeout
* @param {number} echoInterval Echo interval
*/
constructor(address, privateKey, timeout, echoInterval) {
this.dial = new Dial(address, timeout, privateKey);
this.echoInterval = echoInterval;
this.streamHandler = null;
}
/**
* Return a stream handler
*
* @param {object} callbacks A group of callbacks to call when needed
*
* @returns {Promise<streams.Streams>} The stream manager
*
*/
async get(callbacks) {
let self = this;
if (this.streamHandler) {
return this.streamHandler;
}
callbacks.connecting();
let streamPaused = false,
currentReceived = 0,
currentUnpacked = 0;
const shouldPause = () => {
return currentReceived > currentUnpacked;
};
try {
let conn = await this.dial.dial({
inbound(data) {
currentReceived += data.size;
callbacks.traffic(data.size, 0);
},
inboundUnpacked(data) {
currentUnpacked += data.length;
if (currentUnpacked >= currentReceived) {
currentUnpacked = 0;
currentReceived = 0;
}
if (self.streamHandler !== null) {
if (streamPaused && !shouldPause()) {
streamPaused = false;
self.streamHandler.resume();
return;
} else if (!streamPaused && shouldPause()) {
streamPaused = true;
self.streamHandler.pause();
return;
}
}
},
outbound(data) {
callbacks.traffic(0, data.length);
}
});
let streamHandler = new streams.Streams(conn.reader, conn.sender, {
echoInterval: self.echoInterval,
echoUpdater(delay) {
return callbacks.echo(delay);
},
cleared(e) {
if (self.streamHandler === null) {
return;
}
self.streamHandler = null;
// Close connection first otherwise we may
// risk sending things out
conn.ws.close();
callbacks.close(e);
}
});
streamHandler.serve();
callbacks.connected();
this.streamHandler = streamHandler;
} catch (e) {
callbacks.failed(e);
throw e;
}
return this.streamHandler;
}
}

BIN
ui/sshwifty.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

48
ui/stream/common.js Normal file
View File

@@ -0,0 +1,48 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
/**
* Get one unsafe random number
*
* @param {number} min Min value (included)
* @param {number} max Max value (not included)
*
* @returns {number} Get random number
*
*/
export function getRand(min, max) {
return Math.floor(Math.random() * (max - min + 1) + min);
}
/**
* Get a group of random number
*
* @param {number} n How many number to get
* @param {number} min Min value (included)
* @param {number} max Max value (not included)
*
* @returns {Array<number>} A group of random number
*/
export function getRands(n, min, max) {
let r = [];
for (let i = 0; i < n; i++) {
r.push(getRand(min, max));
}
return r;
}

40
ui/stream/exception.js Normal file
View File

@@ -0,0 +1,40 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
export default class Exception {
/**
* constructor
*
* @param {string} message error message
* @param {boolean} temporary whether or not the error is temporary
*
*/
constructor(message, temporary) {
this.message = message;
this.temporary = temporary;
}
/**
* Return the error string
*
* @returns {string} Error message
*
*/
toString() {
return this.message;
}
}

264
ui/stream/header.js Normal file
View File

@@ -0,0 +1,264 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import Exception from "./exception.js";
export const CONTROL = 0x00;
export const STREAM = 0x40;
export const CLOSE = 0x80;
export const COMPLETED = 0xc0;
export const CONTROL_ECHO = 0x00;
export const CONTROL_PAUSESTREAM = 0x01;
export const CONTROL_RESUMESTREAM = 0x02;
const headerHeaderCutter = 0xc0;
const headerDataCutter = 0x3f;
export const HEADER_MAX_DATA = headerDataCutter;
export class Header {
/**
* constructor
*
* @param {number} headerByte one byte data of the header
*/
constructor(headerByte) {
this.headerByte = headerByte;
}
/**
* Return the header type
*
* @returns {number} Type number
*
*/
type() {
return this.headerByte & headerHeaderCutter;
}
/**
* Return the header data
*
* @returns {number} Data number
*
*/
data() {
return this.headerByte & headerDataCutter;
}
/**
* Set the reader data
*
* @param {number} data
*/
set(data) {
if (data > headerDataCutter) {
throw new Exception("data must not be greater than 0x3f", false);
}
this.headerByte |= headerDataCutter & data;
}
/**
* Return the header value
*
* @returns {number} Header byte data
*
*/
value() {
return this.headerByte;
}
}
export const STREAM_HEADER_BYTE_LENGTH = 2;
export const STREAM_MAX_LENGTH = 0x1fff;
export const STREAM_MAX_MARKER = 0x07;
const streamHeaderLengthFirstByteCutter = 0x1f;
export class Stream {
/**
* constructor
*
* @param {number} headerByte1 First header byte
* @param {number} headerByte2 Second header byte
*
*/
constructor(headerByte1, headerByte2) {
this.headerByte1 = headerByte1;
this.headerByte2 = headerByte2;
}
/**
* Return the marker data
*
* @returns {number} the marker
*
*/
marker() {
return this.headerByte1 >> 5;
}
/**
* Return the stream data length
*
* @returns {number} Length of the stream data
*
*/
length() {
let r = 0;
r |= this.headerByte1 & streamHeaderLengthFirstByteCutter;
r <<= 8;
r |= this.headerByte2;
return r;
}
/**
* Set the header
*
* @param {number} marker Header marker
* @param {number} length Stream data length
*
*/
set(marker, length) {
if (marker > STREAM_MAX_MARKER) {
throw new Exception("marker must not be greater than 0x07", false);
}
if (length > STREAM_MAX_LENGTH) {
throw new Exception("n must not be greater than 0x1fff", false);
}
this.headerByte1 =
(marker << 5) | ((length >> 8) & streamHeaderLengthFirstByteCutter);
this.headerByte2 = length & 0xff;
}
/**
* Return the header data
*
* @returns {Uint8Array} Header data
*
*/
buffer() {
return new Uint8Array([this.headerByte1, this.headerByte2]);
}
}
export class InitialStream extends Stream {
/**
* Return how large the data can be
*
* @returns {number} Max data size
*
*/
static maxDataSize() {
return 0x07ff;
}
/**
* constructor
*
* @param {number} headerByte1 First header byte
* @param {number} headerByte2 Second header byte
*
*/
constructor(headerByte1, headerByte2) {
super(headerByte1, headerByte2);
}
/**
* Return command ID
*
* @returns {number} Command ID
*
*/
command() {
return this.headerByte1 >> 4;
}
/**
* Return data
*
* @returns {number} Data
*
*/
data() {
let r = 0;
r |= this.headerByte1 & 0x07;
r <<= 8;
r |= this.headerByte2 & 0xff;
return r;
}
/**
* Return whether or not the respond is success
*
* @returns {boolean} True when the request is successful, false otherwise
*
*/
success() {
return (this.headerByte1 & 0x08) != 0;
}
/**
* Set the header
*
* @param {number} commandID Command ID
* @param {number} data Stream data
* @param {boolean} success Whether or not the request is successful
*
*/
set(commandID, data, success) {
if (commandID > 0x0f) {
throw new Exception("Command ID must not greater than 0x0f", false);
}
if (data > InitialStream.maxDataSize()) {
throw new Exception("Data must not greater than 0x07ff", false);
}
let dd = data & InitialStream.maxDataSize();
if (success) {
dd |= 0x0800;
}
this.headerByte1 = 0;
this.headerByte1 |= commandID << 4;
this.headerByte1 |= dd >> 8;
this.headerByte2 = 0;
this.headerByte2 |= dd & 0xff;
}
}
/**
* Build a new Header
*
* @param {number} h Header number
*
* @returns {Header} The header which been built
*
*/
export function header(h) {
return new Header(h);
}

56
ui/stream/header_test.js Normal file
View File

@@ -0,0 +1,56 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import * as header from "./header.js";
import assert from "assert";
describe("Header", () => {
it("Header", () => {
let h = new header.Header(header.ECHO);
h.set(63);
let n = new header.Header(h.value());
assert.equal(h.type(), n.type());
assert.equal(h.data(), n.data());
assert.equal(n.type(), header.CONTROL);
assert.equal(n.data(), 63);
});
it("Stream", () => {
let h = new header.Stream(0, 0);
h.set(header.STREAM_MAX_MARKER, header.STREAM_MAX_LENGTH);
assert.equal(h.marker(), header.STREAM_MAX_MARKER);
assert.equal(h.length(), header.STREAM_MAX_LENGTH);
assert.equal(h.headerByte1, 0xff);
assert.equal(h.headerByte2, 0xff);
});
it("InitialStream", () => {
let h = new header.InitialStream(0, 0);
h.set(15, 128, true);
assert.equal(h.command(), 15);
assert.equal(h.data(), 128);
assert.equal(h.success(), true);
});
});

570
ui/stream/reader.js Normal file
View File

@@ -0,0 +1,570 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import Exception from "./exception.js";
import * as subscribe from "./subscribe.js";
export class Buffer {
/**
* constructor
*
* @param {Uint8Array} buffer Array buffer
* @param {function} depleted Callback that will be called when the buffer
* is depleted
*/
constructor(buffer, depleted) {
this.buffer = buffer;
this.used = 0;
this.onDepleted = depleted;
}
/**
* Return the index of given byte inside current available (unused) read
* buffer
*
* @param {number} byteData Target data
* @param {number} maxLen Max search length
*
* @returns {number} Return number >= 0 when found, -1 when not
*/
searchBuffer(byteData, maxLen) {
let searchLen = this.remains();
if (searchLen > maxLen) {
searchLen = maxLen;
}
for (let i = 0; i < searchLen; i++) {
if (this.buffer[i + this.used] !== byteData) {
continue;
}
return i;
}
return -1;
}
/**
* Return the index of given byte inside current available (unused) read
* buffer
*
* @param {number} byteData Target data
*
* @returns {number} Return number >= 0 when found, -1 when not
*/
indexOf(byteData) {
return this.searchBuffer(byteData, this.remains());
}
/**
* Return how many bytes in the source + buffer is still available to be
* read, return 0 when reader is depleted and thus can be ditched
*
* @returns {number} Remaining size
*
*/
remains() {
return this.buffer.length - this.used;
}
/**
* Return how many bytes is still availale in the buffer.
*
* Note: This reader don't have renewable data source, so when buffer
* depletes, the reader is done
*
* @returns {number} Remaining size
*
*/
buffered() {
return this.remains();
}
/**
* Export max n bytes from current buffer
*
* @param {number} n suggested max byte length, set to 0 to refresh buffer
* if current buffer is deplated
*
* @returns {Uint8Array} Exported data
*
* @throws {Exception} When reader has been depleted
*
*/
export(n) {
let remain = this.remains();
if (remain <= 0) {
throw new Exception("Reader has been depleted", false);
}
if (remain > n) {
remain = n;
}
let exported = this.buffer.slice(this.used, this.used + remain);
this.used += exported.length;
if (this.remains() <= 0) {
this.onDepleted();
}
return exported;
}
}
export class Multiple {
/**
* Constructor
*
* @param {function} depleted Callback will be called when all reader is
* depleted
*
*/
constructor(depleted) {
this.reader = null;
this.depleted = depleted;
this.subscribe = new subscribe.Subscribe();
this.closed = false;
}
/**
* Add new reader as sub reader
*
* @param {Buffer} reader
* @param {function} depleted Callback that will be called when given reader
* is depleted
*
* @throws {Exception} When the reader is closed
*
*/
feed(reader, depleted) {
if (this.closed) {
throw new Exception("Reader is closed", false);
}
if (this.reader === null && this.subscribe.pendings() <= 0) {
this.reader = {
reader: reader,
depleted: depleted
};
return;
}
this.subscribe.resolve({
reader: reader,
depleted: depleted
});
}
/**
* Return the index of given byte inside current available (unused) read
* buffer
*
* @param {number} byteData Target data
* @param {number} maxLen Max search length
*
* @returns {number} Return number >= 0 when found, -1 when not
*
*/
searchBuffer(byteData, maxLen) {
if (this.reader === null) {
return -1;
}
return this.reader.reader.searchBuffer(byteData, maxLen);
}
/**
* Return the index of given byte inside current available (unused) read
* buffer
*
* @param {number} byteData Target data
*
* @returns {number} Return number >= 0 when found, -1 when not
*/
indexOf(byteData) {
return this.searchBuffer(byteData, this.buffered());
}
/**
* Return how many bytes still available in the buffer (How many bytes of
* buffer is left for read before reloading from data source)
*
* @returns {number} How many bytes left in the current buffer
*/
buffered() {
if (this.reader == null) {
return 0;
}
return this.reader.reader.buffered();
}
/**
* close current reading
*
*/
close() {
if (this.closed) {
return;
}
this.closed = true;
this.subscribe.reject(new Exception("Reader is closed", false));
}
/**
* Export max n bytes from current buffer
*
* @param {number} n suggested max byte length, set to 0 to refresh buffer
* if current buffer is deplated
*
* @returns {Uint8Array} Exported data
*
*/
async export(n) {
for (;;) {
if (this.reader !== null) {
let exported = await this.reader.reader.export(n);
if (this.reader.reader.remains() <= 0) {
this.reader.depleted();
this.reader = null;
}
return exported;
}
this.depleted(this);
this.reader = await this.subscribe.subscribe();
}
}
}
export class Reader {
/**
* constructor
*
* @param {Multiple} multiple Source reader
* @param {function} bufferConverter Function convert
*
*/
constructor(multiple, bufferConverter) {
this.multiple = multiple;
this.buffers = new subscribe.Subscribe();
this.bufferConverter =
bufferConverter ||
(d => {
return d;
});
this.closed = false;
}
/**
* Add buffer into current reader
*
* @param {Uint8Array} buffer buffer to add
*
* @throws {Exception} When the reader is closed
*
*/
feed(buffer) {
if (this.closed) {
throw new Exception("Reader is closed, new data has been deined", false);
}
this.buffers.resolve(buffer);
}
async reader() {
if (this.closed) {
throw new Exception("Reader is closed, unable to read", false);
}
if (this.multiple.buffered() > 0) {
return this.multiple;
}
let self = this,
converted = await this.bufferConverter(await self.buffers.subscribe());
this.multiple.feed(new Buffer(converted, () => {}), () => {});
return this.multiple;
}
/**
* close current reading
*
*/
close() {
if (this.closed) {
return;
}
this.closed = true;
this.buffers.reject(
new Exception(
"Reader is closed, and thus " + "cannot be operated on",
false
)
);
return this.multiple.close();
}
/**
* Return the index of given byte inside current available (unused) read
* buffer
*
* @param {number} byteData Target data
* @param {number} maxLen Max search length
*
* @returns {number} Return number >= 0 when found, -1 when not
*/
async searchBuffer(byteData, maxLen) {
return (await this.reader()).searchBuffer(byteData, maxLen);
}
/**
* Return the index of given byte inside current available (unused) read
* buffer
*
* @param {number} byteData Target data
*
* @returns {number} Return number >= 0 when found, -1 when not
*/
async indexOf(byteData) {
return (await this.reader()).indexOf(byteData);
}
/**
* Return how many bytes still available in the buffer (How many bytes of
* buffer is left for read before reloading from data source)
*
* @returns {number} How many bytes left in the current buffer
*/
async buffered() {
return (await this.reader()).buffered();
}
/**
* Export max n bytes from current buffer
*
* @param {number} n suggested max byte length, set to 0 to refresh buffer
* if current buffer is deplated
*
* @returns {Uint8Array} Exported data
*
*/
async export(n) {
return (await this.reader()).export(n);
}
}
/**
* Read exactly one bytes from the reader
*
* @param {Reader} reader the source reader
*
* @returns {Uint8Array} Exported data
*
*/
export async function readOne(reader) {
for (;;) {
let d = await reader.export(1);
if (d.length <= 0) {
continue;
}
return d;
}
}
/**
* Read exactly n bytes from the reader
*
* @param {Reader} reader the source reader
* @param {number} n length to read
*
* @returns {Uint8Array} Exported data
*
*/
export async function readN(reader, n) {
let readed = 0,
result = new Uint8Array(n);
while (readed < n) {
let exported = await reader.export(n - readed);
result.set(exported, readed);
readed += exported.length;
}
return result;
}
export class Limited {
/**
* Constructor
*
* @param {Reader} reader the source reader
* @param {number} maxN max bytes to read
*
* @returns {boolean} true when the reader is completed, false otherwise
*
*/
constructor(reader, maxN) {
this.reader = reader;
this.remain = maxN;
}
/**
* Indicate whether or not the current reader is completed
*
* @returns {boolean} true when the reader is completed, false otherwise
*
*/
completed() {
return this.remain <= 0;
}
/**
* Return the index of given byte inside current available (unused) read
* buffer
*
* @param {number} byteData Target data
* @param {number} maxLen Max search length
*
* @returns {number} Return number >= 0 when found, -1 when not
*
*/
searchBuffer(byteData, maxLen) {
return this.reader.searchBuffer(
byteData,
maxLen > this.remain ? this.remain : maxLen
);
}
/**
* Return the index of given byte inside current read buffer
*
* @param {number} byteData Target data
*
* @returns {number} Return number >= 0 when found, -1 when not
*/
indexOf(byteData) {
return this.reader.searchBuffer(byteData, this.remain);
}
/**
* Return how many bytes still available to be read
*
* @returns {number} Remaining size
*
*/
remains() {
return this.remain;
}
/**
* Return how many bytes still available in the buffer (How many bytes of
* buffer is left for read before reloading from data source)
*
* @returns {number} Remaining size
*
*/
buffered() {
let buf = this.reader.buffered();
return buf > this.remain ? this.remain : buf;
}
/**
* Export max n bytes from current buffer
*
* @param {number} n suggested max length
*
* @throws {Exception} when reading already completed
*
* @returns {Uint8Array} Exported data
*
*/
async export(n) {
if (this.completed()) {
throw new Exception("Reader already completed", false);
}
let toRead = n > this.remain ? this.remain : n,
exported = await this.reader.export(toRead);
this.remain -= exported.length;
return exported;
}
}
/**
* Read the whole Limited reader and return the result
*
* @param {Limited} limited the Limited reader
*
* @returns {Uint8Array} Exported data
*
*/
export async function readCompletely(limited) {
return await readN(limited, limited.remains());
}
/**
* Read until given byteData is reached. This function is guaranteed to spit
* out at least one byte
*
* @param {Reader} indexOfReader
* @param {number} byteData
*/
export async function readUntil(indexOfReader, byteData) {
let pos = await indexOfReader.indexOf(byteData),
buffered = await indexOfReader.buffered();
if (pos >= 0) {
return {
data: await readN(indexOfReader, pos + 1),
found: true
};
}
if (buffered <= 0) {
let d = await readOne(indexOfReader);
return {
data: d,
found: d[0] === byteData
};
}
return {
data: await readN(indexOfReader, buffered),
found: false
};
}

220
ui/stream/reader_test.js Normal file
View File

@@ -0,0 +1,220 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import assert from "assert";
import * as reader from "./reader.js";
describe("Reader", () => {
it("Buffer", async () => {
let buf = new reader.Buffer(
new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]),
() => {}
);
let ex = buf.export(1);
assert.equal(ex.length, 1);
assert.equal(ex[0], 0);
assert.equal(buf.remains(), 7);
ex = await reader.readCompletely(buf);
assert.equal(ex.length, 7);
assert.deepEqual(ex, new Uint8Array([1, 2, 3, 4, 5, 6, 7]));
assert.equal(buf.remains(), 0);
});
it("Reader", async () => {
const maxTests = 3;
let IntvCount = 0,
r = new reader.Reader(new reader.Multiple(() => {}), data => {
return data;
}),
expected = [
0,
1,
2,
3,
4,
5,
6,
7,
0,
1,
2,
3,
4,
5,
6,
7,
0,
1,
2,
3,
4,
5,
6,
7
],
feedIntv = setInterval(() => {
r.feed(Uint8Array.from(expected.slice(0, 8)));
IntvCount++;
if (IntvCount < maxTests) {
return;
}
clearInterval(feedIntv);
}, 300);
let result = [];
while (result.length < expected.length) {
result.push((await r.export(1))[0]);
}
assert.deepEqual(result, expected);
});
it("readOne", async () => {
let r = new reader.Reader(new reader.Multiple(() => {}), data => {
return data;
});
setTimeout(() => {
r.feed(Uint8Array.from([0, 1, 2, 3, 4, 5, 7]));
}, 100);
let rr = await reader.readOne(r);
assert.deepEqual(rr, [0]);
rr = await reader.readOne(r);
assert.deepEqual(rr, [1]);
});
it("readN", async () => {
let r = new reader.Reader(new reader.Multiple(() => {}), data => {
return data;
});
setTimeout(() => {
r.feed(Uint8Array.from([0, 1, 2, 3, 4, 5, 7]));
}, 100);
let rr = await reader.readN(r, 3);
assert.deepEqual(rr, [0, 1, 2]);
rr = await reader.readN(r, 3);
assert.deepEqual(rr, [3, 4, 5]);
});
it("Limited", async () => {
const maxTests = 3;
let IntvCount = 0,
r = new reader.Reader(new reader.Multiple(() => {}), data => {
return data;
}),
expected = [0, 1, 2, 3, 4, 5, 6, 7, 0, 1],
limited = new reader.Limited(r, 10),
feedIntv = setInterval(() => {
r.feed(Uint8Array.from(expected.slice(0, 8)));
IntvCount++;
if (IntvCount < maxTests) {
return;
}
clearInterval(feedIntv);
}, 300);
let result = [];
while (!limited.completed()) {
result.push((await limited.export(1))[0]);
}
assert.equal(limited.completed(), true);
assert.deepEqual(result, expected);
});
it("readCompletely", async () => {
const maxTests = 3;
let IntvCount = 0,
r = new reader.Reader(new reader.Multiple(() => {}), data => {
return data;
}),
expected = [0, 1, 2, 3, 4, 5, 6, 7, 0, 1],
limited = new reader.Limited(r, 10),
feedIntv = setInterval(() => {
r.feed(Uint8Array.from(expected.slice(0, 8)));
IntvCount++;
if (IntvCount < maxTests) {
return;
}
clearInterval(feedIntv);
}, 300);
let result = await reader.readCompletely(limited);
assert.equal(limited.completed(), true);
assert.deepEqual(result, expected);
});
it("readUntil", async () => {
const maxTests = 3;
let IntvCount = 0,
r = new reader.Reader(new reader.Multiple(() => {}), data => {
return data;
}),
sample = [0, 1, 2, 3, 4, 5, 6, 7, 0, 1],
expected1 = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]),
expected2 = new Uint8Array([0, 1]),
limited = new reader.Limited(r, 10),
feedIntv = setInterval(() => {
r.feed(Uint8Array.from(sample));
IntvCount++;
if (IntvCount < maxTests) {
return;
}
clearInterval(feedIntv);
}, 300);
let result = await reader.readUntil(limited, 7);
assert.equal(limited.completed(), false);
assert.deepEqual(result.data, expected1);
assert.deepEqual(result.found, true);
result = await reader.readUntil(limited, 7);
assert.equal(limited.completed(), true);
assert.deepEqual(result.data, expected2);
assert.deepEqual(result.found, false);
});
});

200
ui/stream/sender.js Normal file
View File

@@ -0,0 +1,200 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import Exception from "./exception.js";
import * as subscribe from "./subscribe.js";
export class Sender {
/**
* constructor
*
* @param {function} sender Underlaying sender
* @param {number} bufferDelay in ms
*
*/
constructor(sender, bufferDelay, maxSegSize) {
this.sender = sender;
this.delay = bufferDelay;
this.maxSegSize = maxSegSize;
this.timeout = null;
this.buffered = new Uint8Array(this.maxSegSize);
this.bufferedSize = 0;
this.subscribe = new subscribe.Subscribe();
this.sendingPoc = this.sending();
this.resolves = [];
this.rejects = [];
}
/**
* Sender proc
*
*/
async sending() {
for (;;) {
let fetched = await this.subscribe.subscribe();
await this.sender(fetched);
}
}
/**
* Clear everything
*
*/
async clear() {
if (this.timeout !== null) {
clearTimeout(this.timeout);
this.timeout = null;
}
this.buffered = null;
this.bufferedSize = 0;
this.subscribe.reject(new Exception("Sender has been closed", false));
try {
await this.sendingPoc;
} catch (e) {
// Do nothing
}
this.reject(new Exception("Sending has been cancelled", true));
}
/**
* Call resolves
*
* @param {any} d Data
*/
resolve(d) {
for (let i in this.resolves) {
this.resolves[i](d);
}
this.resolves = [];
this.rejects = [];
}
/**
* Call rejects
*
* @param {any} d Data
*/
reject(d) {
for (let i in this.rejects) {
this.rejects[i](d);
}
this.resolves = [];
this.rejects = [];
}
/**
* Send buffer to the sender
*
*/
flushBuffer() {
if (this.bufferedSize <= 0) {
return;
}
if (this.timeout !== null) {
clearTimeout(this.timeout);
this.timeout = null;
}
this.resolve(true);
let d = this.buffered.slice(0, this.bufferedSize);
this.subscribe.resolve(d);
if (d.length >= this.buffered.length) {
this.buffered = new Uint8Array(this.maxSegSize);
this.bufferedSize = 0;
} else {
this.buffered = this.buffered.slice(d.length, this.buffered.length);
this.bufferedSize = 0;
}
}
/**
* Append buffer to internal data storage
*
* @param {Uint8Array} buf Buffer data
*/
appendBuffer(buf) {
let remain = this.buffered.length - this.bufferedSize;
if (remain <= 0) {
this.flushBuffer();
remain = this.buffered.length - this.bufferedSize;
}
let start = 0,
end = remain;
while (start < buf.length) {
if (end > buf.length) {
end = buf.length;
}
let d = buf.slice(start, end);
this.buffered.set(d, this.bufferedSize);
this.bufferedSize += d.length;
if (this.buffered.length >= this.bufferedSize) {
this.flushBuffer();
}
start += d.length;
end = start + (this.buffered.length - this.bufferedSize);
}
}
/**
* Send data
*
* @param {Uint8Array} data data to send
*
* @throws {Exception} when sending has been cancelled
*
* @returns {Promise} will be resolved when the data is send and will be
* rejected when the data is not
*
*/
send(data) {
let self = this;
return new Promise((resolve, reject) => {
self.resolves.push(resolve);
self.rejects.push(reject);
this.appendBuffer(data);
if (this.bufferedSize <= 0) {
return;
}
self.timeout = setTimeout(() => {
self.flushBuffer();
}, self.delay);
});
}
}

325
ui/stream/stream.js Normal file
View File

@@ -0,0 +1,325 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import Exception from "./exception.js";
import * as header from "./header.js";
import * as reader from "./reader.js";
import * as sender from "./sender.js";
export class Sender {
/**
* constructor
*
* @param {number} id ID of the stream
* @param {sender.Sender} sd The data sender
*
*/
constructor(id, sd) {
this.id = id;
this.sender = sd;
this.closed = false;
}
/**
* Sends data to remote
*
* @param {number} marker binary marker
* @param {Uint8Array} data data to be sent
*
* @throws {Exception} When the sender already been closed
*
*/
send(marker, data) {
if (this.closed) {
throw new Exception(
"Sender already been closed. No data can be send",
false
);
}
let reqHeader = new header.Header(header.STREAM),
stHeader = new header.Stream(0, 0),
d = new Uint8Array(data.length + 3);
reqHeader.set(this.id);
stHeader.set(marker, data.length);
d[0] = reqHeader.value();
d.set(stHeader.buffer(), 1);
d.set(data, 3);
return this.sender.send(d);
}
/**
* Send stream signals
*
* @param {number} signal Signal value
*
* @throws {Exception} When the sender already been closed
*
*/
signal(signal) {
if (this.closed) {
throw new Exception(
"Sender already been closed. No signal can be send",
false
);
}
let reqHeader = new header.Header(signal);
reqHeader.set(this.id);
return this.sender.send(new Uint8Array([reqHeader.value()]));
}
/**
* Send close signal and close current sender
*
*/
close() {
if (this.closed) {
return;
}
let r = this.signal(header.CLOSE);
this.closed = true;
return r;
}
}
export class InitialSender {
/**
* constructor
*
* @param {number} id ID of the stream
* @param {number} commandID ID of the command
* @param {sender.Sender} sd The data sender
*
*/
constructor(id, commandID, sd) {
this.id = id;
this.command = commandID;
this.sender = sd;
}
/**
* Return how large the data can be
*
* @returns {number} Max data size
*
*/
static maxDataLength() {
return header.InitialStream.maxDataSize();
}
/**
* Sends data to remote
*
* @param {Uint8Array} data data to be sent
*
*/
send(data) {
let reqHeader = new header.Header(header.STREAM),
stHeader = new header.InitialStream(0, 0),
d = new Uint8Array(data.length + 3);
reqHeader.set(this.id);
stHeader.set(this.command, data.length, true);
d[0] = reqHeader.value();
d.set(stHeader.buffer(), 1);
d.set(data, 3);
return this.sender.send(d);
}
}
export class Stream {
/**
* constructor
*
* @param {number} id ID of the stream
*
*/
constructor(id) {
this.id = id;
this.command = null;
this.isInitializing = false;
this.isShuttingDown = false;
}
/**
* Returns whether or not current stream is running
*
* @returns {boolean} True when it's running, false otherwise
*
*/
running() {
return this.command !== null;
}
/**
* Returns whether or not current stream is initializing
*
* @returns {boolean} True when it's initializing, false otherwise
*
*/
initializing() {
return this.isInitializing;
}
/**
* Unsets current stream
*
*/
clear() {
this.command = null;
this.isInitializing = false;
this.isShuttingDown = false;
}
/**
* Request the stream for a new command
*
* @param {number} commandID Command ID
* @param {function} commandBuilder Function that returns a command
* @param {sender.Sender} sd Data sender
*
* @throws {Exception} when stream already running
*
*/
run(commandID, commandBuilder, sd) {
if (this.running()) {
throw new Exception(
"Stream already running, cannot accept new commands",
false
);
}
this.isInitializing = true;
this.command = commandBuilder(new Sender(this.id, sd));
return this.command.run(new InitialSender(this.id, commandID, sd));
}
/**
* Called when initialization respond has been received
*
* @param {header.InitialStream} streamInitialHeader Stream Initial header
*
* @throws {Exception} When the stream is not running, or been shutting down
*
*/
initialize(hd) {
if (!this.running()) {
throw new Exception(
"Cannot initialize a stream that is not running",
false
);
}
if (this.isShuttingDown) {
throw new Exception(
"Cannot initialize a stream that is about to shutdown",
false
);
}
this.command.initialize(hd);
if (!hd.success()) {
this.clear();
return;
}
this.isInitializing = false;
}
/**
* Called when Stream data has been received
*
* @param {header.Stream} streamHeader Stream header
* @param {reader.Limited} rd Data reader
*
* @throws {Exception} When the stream is not running, or shutting down
*
*/
tick(streamHeader, rd) {
if (!this.running()) {
throw new Exception("Cannot tick a stream that is not running", false);
}
if (this.isShuttingDown) {
throw new Exception(
"Cannot tick a stream that is about to shutdown",
false
);
}
return this.command.tick(streamHeader, rd);
}
/**
* Called when stream close request has been received
*
* @throws {Exception} When the stream is not running, or shutting down
*
*/
close() {
if (!this.running()) {
throw new Exception("Cannot close a stream that is not running", false);
}
if (this.isShuttingDown) {
throw new Exception(
"Cannot close a stream that is about to shutdown",
false
);
}
this.isShuttingDown = true;
this.command.close();
}
/**
* Called when stream completed respond has been received
*
* @throws {Exception} When stream isn't running, or not shutting down
*
*/
completed() {
if (!this.running()) {
throw new Exception("Cannot close a stream that is not running", false);
}
if (!this.isShuttingDown) {
throw new Exception(
"Can't complete current stream because Close " +
"signal is not received",
false
);
}
this.command.completed();
this.clear();
}
}

436
ui/stream/streams.js Normal file
View File

@@ -0,0 +1,436 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import Exception from "./exception.js";
import * as header from "./header.js";
import * as stream from "./stream.js";
import * as reader from "./reader.js";
import * as sender from "./sender.js";
import * as common from "./common.js";
export const ECHO_FAILED = -1;
export class Requested {
/**
* constructor
*
* @param {stream.Stream} stream The selected stream
* @param {any} result Result of the run
*
*/
constructor(stream, result) {
this.stream = stream;
this.result = result;
}
}
export class Streams {
/**
* constructor
*
* @param {reader.Reader} reader The data reader
* @param {sender.Sender} sender The data sender
* @param {object} config Configuration
*/
constructor(reader, sender, config) {
this.reader = reader;
this.sender = sender;
this.config = config;
this.echoTimer = null;
this.lastEchoTime = null;
this.lastEchoData = null;
this.stop = false;
this.streams = [];
for (let i = 0; i <= header.HEADER_MAX_DATA; i++) {
this.streams.push(new stream.Stream(i));
}
}
/**
* Starts stream proccessing
*
* @returns {Promise<true>} When service is completed
*
* @throws {Exception} When the process already started
*
*/
async serve() {
if (this.echoTimer !== null) {
throw new Exception("Already started", false);
}
this.echoTimer = setInterval(() => {
this.sendEcho();
}, this.config.echoInterval);
this.stop = false;
this.sendEcho();
let ee = null;
while (!this.stop && ee === null) {
try {
await this.tick();
} catch (e) {
if (!e.temporary) {
ee = e;
}
}
}
this.clear(ee);
if (ee !== null) {
throw new Exception("Streams is closed: " + ee, false);
}
}
/**
* Clear current proccess
*
* @param {Exception} e An error caused this clear. Null when no error
*
*/
clear(e) {
if (this.stop) {
return;
}
this.stop = true;
if (this.echoTimer != null) {
clearInterval(this.echoTimer);
this.echoTimer = null;
}
for (let i in this.streams) {
if (!this.streams[i].running()) {
continue;
}
try {
this.streams[i].close();
} catch (e) {
// Do nothing
}
try {
this.streams[i].completed();
} catch (e) {
//Do nothing
}
}
try {
this.sender.clear();
} catch (e) {
process.env.NODE_ENV === "development" && console.trace(e);
}
try {
this.reader.close();
} catch (e) {
process.env.NODE_ENV === "development" && console.trace(e);
}
this.config.cleared(e);
}
/**
* Request remote to pause stream sending
*
*/
pause() {
let pauseHeader = header.header(header.CONTROL);
pauseHeader.set(1);
return this.sender.send(
new Uint8Array([pauseHeader.value(), header.CONTROL_PAUSESTREAM])
);
}
/**
* Request remote to resume stream sending
*
*/
resume() {
let pauseHeader = header.header(header.CONTROL);
pauseHeader.set(1);
return this.sender.send(
new Uint8Array([pauseHeader.value(), header.CONTROL_RESUMESTREAM])
);
}
/**
* Request stream for given command
*
* @param {number} commandID Command ID
* @param {function} commandBuilder Command builder
*
* @returns {Requested} The result of the stream command
*
*/
request(commandID, commandBuilder) {
try {
for (let i in this.streams) {
if (this.streams[i].running()) {
continue;
}
return new Requested(
this.streams[i],
this.streams[i].run(commandID, commandBuilder, this.sender)
);
}
throw new Exception("No stream is currently available", true);
} catch (e) {
throw new Exception("Stream request has failed: " + e, true);
}
}
/**
* Send echo request
*
*/
sendEcho() {
let echoHeader = header.header(header.CONTROL),
randomNum = new Uint8Array(common.getRands(8, 0, 255));
echoHeader.set(randomNum.length - 1);
randomNum[0] = echoHeader.value();
randomNum[1] = header.CONTROL_ECHO;
this.sender.send(randomNum).then(() => {
if (this.lastEchoTime !== null || this.lastEchoData !== null) {
this.lastEchoTime = null;
this.lastEchoData = null;
this.config.echoUpdater(ECHO_FAILED);
}
this.lastEchoTime = new Date();
this.lastEchoData = randomNum.slice(2, randomNum.length);
});
}
/**
* handle received control request
*
* @param {reader.Reader} rd The reader
*
*/
async handleControl(rd) {
let controlType = await reader.readOne(rd),
delay = 0,
echoBytes = null;
switch (controlType[0]) {
case header.CONTROL_ECHO:
echoBytes = await reader.readCompletely(rd);
if (this.lastEchoTime === null || this.lastEchoData === null) {
return;
}
if (this.lastEchoData.length !== echoBytes.length) {
return;
}
for (let i in this.lastEchoData) {
if (this.lastEchoData[i] == echoBytes[i]) {
continue;
}
this.lastEchoTime = null;
this.lastEchoData = null;
this.config.echoUpdater(ECHO_FAILED);
return;
}
delay = new Date().getTime() - this.lastEchoTime.getTime();
if (delay < 0) {
delay = 0;
}
this.lastEchoTime = null;
this.lastEchoData = null;
this.config.echoUpdater(delay);
return;
}
await reader.readCompletely(rd);
throw new Exception("Unknown control signal: " + controlType);
}
/**
* handle received stream respond
*
* @param {header.Header} hd The header
* @param {reader.Reader} rd The reader
*
* @throws {Exception} when given stream is not running
*
*/
async handleStream(hd, rd) {
if (hd.data() >= this.streams.length) {
return;
}
let stream = this.streams[hd.data()];
if (!stream.running()) {
// WARNING: Connection must be reset at this point because we cannot
// determine how many bytes to read
throw new Exception(
'Remote is requesting for stream "' +
hd.data() +
'" which is not running',
false
);
}
let initialHeaderBytes = await reader.readN(rd, 2);
// WARNING: It's the stream's responsibility to ensure stream data is
// completely readed before return
if (stream.initializing()) {
let streamHeader = new header.InitialStream(
initialHeaderBytes[0],
initialHeaderBytes[1]
);
return stream.initialize(streamHeader);
}
let streamHeader = new header.Stream(
initialHeaderBytes[0],
initialHeaderBytes[1]
),
streamReader = new reader.Limited(rd, streamHeader.length());
let tickResult = await stream.tick(streamHeader, streamReader);
await reader.readCompletely(streamReader);
return tickResult;
}
/**
* handle received close respond
*
* @param {header.Header} hd The header
*
* @throws {Exception} when given stream is not running
*
*/
async handleClose(hd) {
if (hd.data() >= this.streams.length) {
return;
}
let stream = this.streams[hd.data()];
if (!stream.running()) {
// WARNING: Connection must be reset at this point because we cannot
// determine how many bytes to read
throw new Exception(
'Remote is requesting for stream "' +
hd.data() +
'" to be closed, but the stream is not running',
false
);
}
let cResult = await stream.close();
let completedHeader = new header.Header(header.COMPLETED);
completedHeader.set(hd.data());
this.sender.send(new Uint8Array([completedHeader.value()]));
return cResult;
}
/**
* handle received close respond
*
* @param {header.Header} hd The header
*
* @throws {Exception} when given stream is not running
*
*/
async handleCompleted(hd) {
if (hd.data() >= this.streams.length) {
return;
}
let stream = this.streams[hd.data()];
if (!stream.running()) {
// WARNING: Connection must be reset at this point because we cannot
// determine how many bytes to read
throw new Exception(
'Remote is requesting for stream "' +
hd.data() +
'" to be completed, but the stream is not running',
false
);
}
return stream.completed();
}
/**
* Main proccess loop
*
* @throws {Exception} when encountered an unknown header
*/
async tick() {
let headerBytes = await reader.readOne(this.reader),
hd = new header.Header(headerBytes[0]);
switch (hd.type()) {
case header.CONTROL:
return this.handleControl(new reader.Limited(this.reader, hd.data()));
case header.STREAM:
return this.handleStream(hd, this.reader);
case header.CLOSE:
return this.handleClose(hd);
case header.COMPLETED:
return this.handleCompleted(hd);
default:
throw new Exception("Unknown header", false);
}
}
}

22
ui/stream/streams_test.js Normal file
View File

@@ -0,0 +1,22 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import assert from "assert";
describe("Streams", () => {
it("Header", () => {});
});

114
ui/stream/subscribe.js Normal file
View File

@@ -0,0 +1,114 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import Exception from "./exception.js";
const typeReject = 0;
const typeResolve = 1;
export class Subscribe {
/**
* constructor
*
*/
constructor() {
this.res = null;
this.rej = null;
this.pending = [];
}
/**
* Returns how many resolve/reject in the pending
*/
pendings() {
return (
this.pending.length + (this.rej !== null || this.res !== null ? 1 : 0)
);
}
/**
* Resolve the subscribe waiter
*
* @param {any} d Resolve data which will be send to the subscriber
*/
resolve(d) {
if (this.res === null) {
this.pending.push([typeResolve, d]);
return;
}
this.res(d);
}
/**
* Reject the subscribe waiter
*
* @param {any} e Error message that will be send to the subscriber
*
*/
reject(e) {
if (this.rej === null) {
this.pending.push([typeReject, e]);
return;
}
this.rej(e);
}
/**
* Waiting and receive subscribe data
*
* @returns {Promise<any>} Data receiver
*
*/
subscribe() {
if (this.pending.length > 0) {
let p = this.pending.shift();
switch (p[0]) {
case typeReject:
throw p[1];
case typeResolve:
return p[1];
default:
throw new Exception("Unknown pending type", false);
}
}
let self = this;
return new Promise((resolve, reject) => {
self.res = d => {
self.res = null;
self.rej = null;
resolve(d);
};
self.rej = e => {
self.res = null;
self.rej = null;
reject(e);
};
});
}
}

5
ui/widgets/busy.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="150" height="150" viewBox="0 0 26 26">
<path transform="scale(.26)" d="M21.041 34.48c-.026 0-.056.005-.082.008a.716.716 0 0 0-.232.074L6.092 42.113a.698.698 0 0 0-.375.631V44.6a.2.2 0 0 0 0 .007.695.695 0 0 0 .367.608l13.297 7.347a.262.262 0 0 1 .135.233v7.242c.003.384.322.7.707.697h.834a.703.703 0 0 0 .691-.697v-8.055c0-.162.107-.261.27-.261h7.894c.162 0 .264.1.264.261v8.055c.003.384.323.7.707.697h.832a.705.705 0 0 0 .691-.697V45.605c0-.11.024-.155.053-.187a.292.292 0 0 1 .158-.076.3.3 0 0 1 .18.008c.036.015.072.048.113.129.004.007.004.004.008.013a.2.2 0 0 0 0 .008l6.092 13.334c.124.27.39.418.646.414a.703.703 0 0 0 .631-.436l3.838-9.54c.06-.15.129-.165.256-.165s.188.015.248.164l3.832 9.542c.109.283.37.444.63.45a.71.71 0 0 0 .661-.421L55.836 45.5c.046-.1.083-.126.121-.143a.278.278 0 0 1 .172-.007.292.292 0 0 1 .158.076c.03.032.052.077.053.187v14.424a.2.2 0 0 0 0 .008.703.703 0 0 0 .707.69h.834a.696.696 0 0 0 .697-.69.2.2 0 0 0 0-.008V43.795a.696.696 0 0 0-.697-.691H55.01a.695.695 0 0 0-.639.398.2.2 0 0 0-.006 0c-1.688 3.661-3.369 7.33-5.056 10.99-.065.141-.132.161-.256.158-.123-.003-.182-.03-.24-.173l-3.786-9.329a.7.7 0 0 0-.646-.45.71.71 0 0 0-.654.45l-3.793 9.329c-.06.145-.118.17-.24.173-.125.003-.184-.017-.249-.158l-5.054-10.99a.705.705 0 0 0-.639-.398h-2.87a.702.702 0 0 0-.706.69v5.41c0 .161-.107.278-.264.278h-7.894c-.158 0-.27-.117-.27-.279v-5.408a.698.698 0 0 0-.691-.691h-.834a.702.702 0 0 0-.707.69v5.741c0 .11-.078.261-.172.33-.114.084-.13.087-.211.045a.2.2 0 0 0-.008 0 .2.2 0 0 0-.03-.015.2.2 0 0 0-.023-.008c-3.673-1.892-7.333-3.954-11.011-5.889a.276.276 0 0 1-.135-.166.91.91 0 0 1 .008-.277.61.61 0 0 1 .142-.203.694.694 0 0 1 .104-.075c.049-.014.1-.035.144-.06 4.332-2.275 8.706-4.465 13.047-6.71a.702.702 0 0 0 .377-.622v-.699a.703.703 0 0 0-.707-.706zm40.654 8.624a.705.705 0 0 0-.697.707v16.224a.71.71 0 0 0 .705.7h.826a.704.704 0 0 0 .7-.7v-8.053c0-.16.109-.261.271-.261h6.887a.702.702 0 0 0 .699-.707v-.842a.698.698 0 0 0-.7-.69H63.5c-.157 0-.271-.118-.271-.279v-3.598c0-.16.109-.263.271-.263h12.334c.162 0 .264.102.264.263v14.43c.003.38.31.695.69.7a.2.2 0 0 0 .007 0h.826a.709.709 0 0 0 .707-.7v-14.43c0-.162.102-.263.264-.263h4.543c.094 0 .177.043.226.12l4.846 7.686a.25.25 0 0 1 .045.135v6.752a.707.707 0 0 0 .691.7h.832a.707.707 0 0 0 .692-.7v-6.752c0-.044.014-.086.045-.135l5.642-8.97c.289-.456-.046-1.072-.586-1.074h-.955a.7.7 0 0 0-.6.324l-4.433 7.031c-.064.101-.145.135-.225.135-.08 0-.16-.034-.224-.135l-4.44-7.031a.696.696 0 0 0-.586-.324c-7.465 0-14.936-.004-22.402 0h-.008zM4.447 47.303c-.022 0-.046.005-.068.008a.696.696 0 0 0-.639.69v.812a.713.713 0 0 0 .37.617l10.816 5.949.008.008a.2.2 0 0 0 .023.015.2.2 0 0 0 .03.022c.093.05.142.127.142.226v1.23c0 .1-.047.186-.135.235a.2.2 0 0 0-.045.03l-10.847 6.22a.7.7 0 0 0-.362.608v.85a.2.2 0 0 0 0 .007c.004.528.597.858 1.053.592l11.898-6.887a.699.699 0 0 0 .346-.61v-3.267a.703.703 0 0 0-.361-.61c-3.964-2.217-11.89-6.655-11.89-6.655a.2.2 0 0 0-.009 0 .689.689 0 0 0-.33-.09z" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="0.8" stroke="#e9a" stroke-dasharray="20 20 20 20 20" stroke-dashoffset="-50">
<animate begin="0s" attributeName="stroke-dashoffset" from="-100" to="100" dur="5s" repeatCount="indefinite" restart="always"/>
</path>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

316
ui/widgets/chart.vue Normal file
View File

@@ -0,0 +1,316 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<template>
<svg xmlns="http://www.w3.org/2000/svg">
<slot />
</svg>
</template>
<script>
/* eslint vue/attribute-hyphenation: 0 */
const XMLNG = "http://www.w3.org/2000/svg";
const XMLNS = "http://www.w3.org/2000/xmlns/";
const XMLNGLink = "http://www.w3.org/1999/xlink";
class Data {
constructor(data) {
this.data = data;
this.max = this.getMax(data);
}
setMax(max) {
this.max = this.max > max ? this.max : max;
}
getMax(data) {
let max = 0;
for (let i in data) {
if (data[i] <= max) {
continue;
}
max = data[i];
}
return max;
}
}
class BaseDrawer {
constructor() {
this.elements = [];
}
toCellHeight(cellHeight, data, n) {
if (data.max === 0) {
return 0;
}
return (cellHeight / data.max) * n;
}
toBottomHeight(cellHeight, n) {
return cellHeight - n;
}
cellWidth(rootDim, data) {
return rootDim.width / data.data.length;
}
createEl(parent, tag, properties) {
let np = document.createElementNS(XMLNG, tag);
for (let p in properties) {
if (p.indexOf("xlink:") === 0) {
np.setAttributeNS(XMLNGLink, p, properties[p]);
} else if (p.indexOf("xmlns:") === 0) {
np.setAttributeNS(XMLNS, p, properties[p]);
} else {
np.setAttribute(p, properties[p]);
}
}
parent.appendChild(np);
this.elements.push(np);
return np;
}
removeAllEl(parent) {
for (let i in this.elements) {
parent.removeChild(this.elements[i]);
}
this.elements = [];
}
draw(parent, rootDim, data) {}
}
class BarDrawer extends BaseDrawer {
constructor(topBottomPadding) {
super();
this.topBottomPadding = topBottomPadding;
}
draw(parent, rootDim, data) {
let cellWidth = this.cellWidth(rootDim, data),
currentWidth = cellWidth / 2,
cellHalfHeight = rootDim.height - this.topBottomPadding / 2,
cellHeight = rootDim.height - this.topBottomPadding;
for (let i in data.data) {
let h = this.toCellHeight(cellHeight, data, data.data[i]);
this.createEl(parent, "path", {
d:
"M" +
currentWidth +
"," +
Math.round(this.toBottomHeight(cellHalfHeight, h)) +
" L" +
currentWidth +
"," +
cellHalfHeight,
class: h > 0 ? "" : "zero"
});
currentWidth += cellWidth;
}
}
}
class UpsideDownBarDrawer extends BarDrawer {
draw(parent, rootDim, data) {
let cellWidth = this.cellWidth(rootDim, data),
currentWidth = cellWidth / 2,
padHalfHeight = this.topBottomPadding / 2,
cellHeight = rootDim.height - this.topBottomPadding;
for (let i in data.data) {
let h = this.toCellHeight(cellHeight, data, data.data[i]);
this.createEl(parent, "path", {
d:
"M" +
currentWidth +
"," +
padHalfHeight +
" L" +
currentWidth +
"," +
(Math.round(h) + padHalfHeight),
class: h > 0 ? "" : "zero"
});
currentWidth += cellWidth;
}
}
}
class Chart {
constructor(el, width, height, drawer) {
this.el = el;
this.drawer = drawer;
this.group = null;
this.paths = [];
this.dim = { width, height };
this.el.setAttribute(
"viewBox",
"0 0 " +
parseInt(this.dim.width, 10) +
" " +
parseInt(this.dim.height, 10)
);
this.el.setAttribute("preserveAspectRatio", "xMidYMid meet");
}
getGroupRoot() {
if (this.group) {
return this.group;
}
this.group = document.createElementNS(XMLNG, "g");
this.el.appendChild(this.group);
return this.group;
}
draw(data, manualMax) {
let d = new Data(data);
let max = d.max;
d.setMax(manualMax);
this.drawer.removeAllEl(this.getGroupRoot());
this.drawer.draw(this.getGroupRoot(), this.dim, d);
return {
dataMax: max,
resultMax: d.max
};
}
clear() {
this.drawer.removeAllEl();
this.el.removeChild(this.getGroupRoot());
}
}
function buildDrawer(type) {
switch (type) {
case "Bar":
return new BarDrawer(10);
case "UpsideDownBar":
return new UpsideDownBarDrawer(10);
}
return new Error("Undefined drawer: " + type);
}
export default {
props: {
values: {
type: Array,
default: () => []
},
width: {
type: Number,
default: 0
},
height: {
type: Number,
default: 0
},
max: {
type: Number,
default: 0
},
enabled: {
type: Boolean,
default: false
},
type: {
type: String,
default: ""
}
},
data() {
return {
chart: null,
previousMax: 0
};
},
watch: {
values() {
if (!this.enabled) {
return;
}
this.draw();
},
max() {
if (!this.enabled) {
return;
}
this.draw();
},
enabled(newVal) {
if (!newVal) {
return;
}
this.draw();
}
},
mounted() {
this.chart = new Chart(
this.$el,
this.width,
this.height,
buildDrawer(this.type)
);
},
beforeDestroy() {
this.chart.clear();
},
methods: {
draw() {
let r = this.chart.draw(this.values, this.max);
if (r.dataMax === this.previousMax) {
return;
}
this.$emit("max", r.dataMax);
this.previousMax = r.dataMax;
}
}
};
</script>

109
ui/widgets/connect.css Normal file
View File

@@ -0,0 +1,109 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@charset "utf-8";
#connect {
z-index: 999999;
top: 40px;
left: 159px;
display: none;
background: #333;
width: 500px;
}
#connect .window-frame {
max-height: calc(100vh - 40px);
overflow: auto;
}
#connect:before {
left: 30px;
background: #333;
}
@media (max-width: 768px) {
#connect {
left: 20px;
right: 20px;
width: auto;
}
#connect:before {
left: 149px;
}
}
#connect.display {
display: block;
}
#connect h1 {
padding: 15px 15px 0 15px;
margin-bottom: 10px;
color: #999;
}
#connect-close {
cursor: pointer;
color: #999;
right: 10px;
top: 20px;
}
#connect-busy-overlay {
z-index: 2;
background: #2229 url("busy.svg") center center no-repeat;
top: 0;
left: 0;
bottom: 0;
right: 0;
position: absolute;
backdrop-filter: blur(1px);
}
#connect-warning {
padding: 10px;
font-size: 0.85em;
background: #b44;
color: #fff;
}
#connect-warning-icon {
float: left;
display: block;
margin-right: 10px;
}
#connect-warning-icon::after {
background: #c55;
}
#connect-warning-msg {
overflow: auto;
}
#connect-warning-msg p {
margin: 0 0 5px 0;
}
#connect-warning-msg a {
color: #faa;
text-decoration: underline;
}

152
ui/widgets/connect.vue Normal file
View File

@@ -0,0 +1,152 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<template>
<window
id="connect"
flash-class="home-window-display"
:display="display"
@display="$emit('display', $event)"
>
<h1 class="window-title">Establish connection with</h1>
<slot v-if="inputting"></slot>
<connect-switch
v-if="!inputting"
:knowns-length="knowns.length"
:tab="tab"
@switch="switchTab"
></connect-switch>
<connect-new
v-if="tab === 'new' && !inputting"
:connectors="connectors"
@select="selectConnector"
></connect-new>
<connect-known
v-if="tab === 'known' && !inputting"
:knowns="knowns"
:launcher-builder="knownsLauncherBuilder"
@select="selectKnown"
@remove="removeKnown"
></connect-known>
<div id="connect-warning">
<span id="connect-warning-icon" class="icon icon-warning1"></span>
<div id="connect-warning-msg">
<p>
<strong>An insecured service may steal your secrects.</strong>
Always exam the safty of the service before using it.
</p>
<p>
Sshwifty is a free software, you can deploy it on your own trusted
infrastructure.
<a href="https://github.com/niruix/sshwifty" target="_blank"
>Learn more</a
>
</p>
</div>
</div>
<div v-if="busy" id="connect-busy-overlay"></div>
</window>
</template>
<script>
import "./connect.css";
import Window from "./window.vue";
import ConnectSwitch from "./connect_switch.vue";
import ConnectKnown from "./connect_known.vue";
import ConnectNew from "./connect_new.vue";
export default {
components: {
window: Window,
"connect-switch": ConnectSwitch,
"connect-known": ConnectKnown,
"connect-new": ConnectNew
},
props: {
display: {
type: Boolean,
default: false
},
inputting: {
type: Boolean,
default: false
},
knowns: {
type: Array,
default: () => []
},
knownsLauncherBuilder: {
type: Function,
default: () => []
},
connectors: {
type: Array,
default: () => []
},
busy: {
type: Boolean,
default: false
}
},
data() {
return {
tab: "new",
canSelect: true
};
},
methods: {
switchTab(to) {
if (this.inputting) {
return;
}
this.tab = to;
},
selectConnector(connector) {
if (this.inputting) {
return;
}
this.$emit("connector-select", connector);
},
selectKnown(known) {
if (this.inputting) {
return;
}
this.$emit("known-select", known);
},
removeKnown(uid) {
if (this.inputting) {
return;
}
this.$emit("known-remove", uid);
}
}
};
</script>

View File

@@ -0,0 +1,111 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@charset "utf-8";
#connect-known-list {
min-height: 200px;
font-size: 0.75em;
padding: 15px;
background: #3a3a3a;
}
#connect-known-list li {
width: 50%;
position: relative;
}
@media (max-width: 480px) {
#connect-known-list li {
width: 100%;
}
}
#connect-known-list li .lst-wrap {
cursor: pointer;
}
#connect-known-list li .lst-wrap:hover {
background: #444;
}
#connect-known-list li .labels {
position: absolute;
top: 0;
left: 0;
text-transform: uppercase;
font-size: 0.85em;
letter-spacing: 1px;
}
#connect-known-list li .labels > .type {
display: inline-block;
padding: 3px;
background: #a56;
color: #fff;
}
#connect-known-list li .labels > .opt {
display: none;
padding: 3px;
background: #a56;
color: #fff;
text-decoration: none;
z-index: 2;
}
@media (max-width: 480px) {
#connect-known-list li .labels > .opt {
display: inline-block;
}
}
#connect-known-list li .labels > .opt.link {
background: #287;
color: #fff;
}
#connect-known-list li .labels > .opt.link:after {
content: "\02936";
}
#connect-known-list li .labels > .opt.del {
background: #a56;
color: #fff;
}
#connect-known-list li:hover .labels > .opt,
#connect-known-list li:focus .labels > .opt {
display: inline-block;
}
#connect-known-list li h2 {
margin-top: 5px;
margin-bottom: 5px;
text-overflow: ellipsis;
overflow: hidden;
}
#connect-known-list li h2::before {
content: ">_";
color: #555;
font-size: 0.8em;
margin-right: 5px;
font-weight: normal;
}

View File

@@ -0,0 +1,141 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<template>
<div id="connect-known-list">
<ul class="lstcl1">
<li v-for="(known, kk) in knownList" :key="kk">
<div class="labels">
<span class="type" :style="'background-color: ' + known.data.color">
{{ known.data.type }}
</span>
<a
class="opt link"
href="javascript:;"
@click="launcher(known, $event)"
>
{{ known.copyStatus }}
</a>
<a
class="opt del"
href="javascript:;"
@click="remove(known.data.uid)"
>
Remove
</a>
</div>
<div class="lst-wrap" @click="select(known.data)">
<h2 :title="known.data.title">{{ known.data.title }}</h2>
Last: {{ known.data.last.toLocaleString() }}
</div>
</li>
</ul>
</div>
</template>
<script>
import "./connect_known.css";
export default {
props: {
knowns: {
type: Array,
default: () => []
},
launcherBuilder: {
type: Function,
default: () => []
}
},
data() {
return {
knownList: [],
busy: false
};
},
watch: {
knowns(newVal) {
this.reload(newVal);
}
},
mounted() {
this.reload(this.knowns);
},
methods: {
reload(knownList) {
this.knownList = [];
for (let i in knownList) {
this.knownList.unshift({
data: knownList[i],
copying: false,
copyStatus: "Copy link"
});
}
},
select(known) {
if (this.busy) {
return;
}
this.$emit("select", known);
},
async launcher(known, ev) {
if (known.copying || this.busy) {
return;
}
ev.preventDefault();
this.busy = true;
known.copying = true;
known.copyStatus = "Copying";
let lnk = this.launcherBuilder(known.data);
try {
await navigator.clipboard.writeText(lnk);
(() => {
known.copyStatus = "Copied!";
})();
} catch (e) {
(() => {
known.copyStatus = "Failed";
ev.target.setAttribute("href", lnk);
})();
}
setTimeout(() => {
known.copyStatus = "Copy link";
known.copying = false;
}, 2000);
this.busy = false;
},
remove(uid) {
if (this.busy) {
return;
}
this.$emit("remove", uid);
}
}
};
</script>

View File

@@ -0,0 +1,58 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@charset "utf-8";
#connect-new {
min-height: 200px;
background: #3a3a3a;
font-size: 0.75em;
padding: 15px;
}
#connect-new li .lst-wrap:hover {
background: #544;
}
#connect-new li .lst-wrap:active {
background: #444;
}
#connect-new li .lst-wrap {
cursor: pointer;
color: #aaa;
padding: 15px;
}
#connect-new li h2 {
color: #e9a;
}
#connect-new li h2::before {
content: ">";
margin: 0 5px 0 0;
color: #555;
font-weight: normal;
transition: ease 0.3s margin;
}
#connect-new li .lst-wrap:hover h2::before {
content: ">";
margin: 0 3px 0 2px;
}

View File

@@ -0,0 +1,53 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<template>
<div id="connect-new">
<ul class="lst1">
<li
v-for="(connector, ck) in connectors"
:key="ck"
@click="select(connector)"
>
<div class="lst-wrap">
<h2 :style="'color: ' + connector.color()">{{ connector.name() }}</h2>
{{ connector.description() }}
</div>
</li>
</ul>
</div>
</template>
<script>
import "./connect_new.css";
export default {
props: {
connectors: {
type: Array,
default: () => []
}
},
methods: {
select(connector) {
this.$emit("select", connector);
}
}
};
</script>

View File

@@ -0,0 +1,56 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@charset "utf-8";
#connect-switch {
font-size: 0.88em;
color: #aaa;
clear: both;
border-color: #555;
}
#connect-switch li .label {
padding: 2px 7px;
margin-left: 3px;
font-size: 0.85em;
background: #444;
border-radius: 3px;
}
#connect-switch li.active {
border-color: #555;
background: #3a3a3a;
}
#connect-switch li.active .label {
background: #888;
}
#connect-switch li.disabled {
color: #666;
}
#connect-switch.red {
border-color: #a56;
}
#connect-switch.red li.active {
border-color: #a56;
}

View File

@@ -0,0 +1,65 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<template>
<ul id="connect-switch" class="tab2" :class="{ red: tab === 'known' }">
<li :class="{ active: tab === 'new' }" @click="switchTab('new')">
New remote
</li>
<li
:class="{ active: tab === 'known', disabled: knownsLength <= 0 }"
@click="knownsLength > 0 && switchTab('known')"
>
Known remotes
<span v-if="knownsLength > 0" class="label">{{ knownsLength }}</span>
</li>
</ul>
</template>
<script>
import "./connect_switch.css";
export default {
props: {
tab: {
type: String,
default: "new"
},
knownsLength: {
type: Number,
default: 0
}
},
watch: {
knownsLength(newVal) {
if (newVal > 0) {
return;
}
this.switchTab("new");
}
},
methods: {
switchTab(to) {
this.$emit("switch", to);
}
}
};
</script>

141
ui/widgets/connecting.svg Normal file
View File

@@ -0,0 +1,141 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 121.708 21.167">
<g transform="translate(-.748 -275.833)">
<g transform="translate(-75.306 -116.7)">
<rect ry="2.064" y="396.011" x="90.919" height="9.647" width="12.406" fill="none" stroke="gray" stroke-width=".882"/>
<rect ry="1.621" y="397.954" x="93.361" height="8.7" width="11.319" fill="#262626"/>
<rect ry="1.861" y="397.092" x="92.235" height="8.7" width="11.319" fill="#d18b8d"/>
<g transform="translate(1.455)" fill="#fff">
<circle cx="99.925" cy="404.292" r=".801">
<animate id="r2e11" begin="5s;r2e12.end+5s" attributeName="r" from=".801" to="0" dur="0.1s" calcMode="paced"/>
<animate id="r2e12" begin="r2e11.end" attributeName="r" from="0" to=".801" dur="0.1s" calcMode="paced"/>
</circle>
<circle cx="93.956" cy="404.292" r=".801">
<animate begin="r2e11.begin" attributeName="r" from=".801" to="0" dur="0.1s" calcMode="paced"/>
<animate begin="r2e12.begin" attributeName="r" from="0" to=".801" dur="0.1s" calcMode="paced"/>
</circle>
<rect width="2.062" height=".677" x="95.8" y="404.047" ry=".338"/>
</g>
<g transform="translate(1.455)">
<rect width="7.039" height="4.755" x="93.352" y="398.217" ry=".988" fill="#ed9499"/>
<rect width="6.262" height="4.021" x="94.073" y="398.948" ry=".835" fill="#cc7f80"/>
<g transform="translate(-.023 .21)" fill="#f5d0d0">
<rect ry=".185" y="399.571" x="94.849" height=".369" width="4.864"/>
<rect ry=".185" y="400.538" x="94.849" height=".369" width="4.864"/>
<rect ry=".185" y="401.506" x="94.849" height=".369" width="4.864"/>
</g>
</g>
<animateMotion id="r2ud1" begin="0s;r2ud2.end" from="0,0" to="0,1" dur="1s" calcMode="paced"/>
<animateMotion id="r2ud2" begin="r2ud1.end" from="0,1" to="0,0" dur="1s" calcMode="paced"/>
</g>
<rect width="10" height="1.2" x="17" y="295" ry="1" fill="#262626">
<animate id="r2ssw1" begin="0s;r2ssw2.end" attributeName="width" from="10" to="11" dur="1s" calcMode="paced"/>
<animate id="r2ssw2" begin="r2ssw1.end" attributeName="width" from="11" to="10" dur="1s" calcMode="paced"/>
<animate id="r2ssx1" begin="0s;r2ssx2.end" attributeName="x" from="17" to="16.5" dur="1s" calcMode="paced"/>
<animate id="r2ssx2" begin="r2ssx1.end" attributeName="x" from="16.5" to="17" dur="1s" calcMode="paced"/>
</rect>
</g>
<g transform="translate(-.748 -275.833)">
<g>
<g transform="translate(-76.79 -117.665)">
<rect width="12.171" height="15.081" x="134.651" y="394.724" ry="3.796" fill="#262626"/>
<rect width="10.909" height="13.555" x="134.566" y="394.751" ry="2.959" fill="none" stroke="gray" stroke-width=".8"/>
<path d="M60.99 282.131a2.308 2.308 0 1 1 2.308 2.308v3.236" fill="none" stroke="#d18b8d" stroke-width="1.058" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="25.4000003,25.4000003"/>
</g>
<g transform="translate(-76.79 -117.665)">
<g fill="#d18b8d">
<rect width="4.491" height="1.852" x="137.861" y="404.937" ry=".926"/>
<circle cx="136.63" cy="405.863" r=".926"/>
</g>
<rect width="6.648" height="6.35" x="135.704" y="395.855" ry="2.052" fill="#d18b8d"/>
<g>
<rect ry=".573" y="397" x="136.697" height="2" width="1.146" fill="#f9f9f9">
<animate id="r1e21" begin="r1lr1.begin" attributeName="height" from="2" to="1.3" dur="0.2s" fill="freeze"/>
<animate begin="r1e21.begin" attributeName="y" from="397" to="397.7" dur="0.2s" fill="freeze"/>
<animate id="r1e22" begin="r1lr2.begin" attributeName="height" from="1.3" to="2" dur="0.2s" fill="freeze"/>
<animate begin="r1e22.begin" attributeName="y" from="397.7" to="397" dur="0.2s" fill="freeze"/>
</rect>
<rect ry=".573" y="397.7" x="139.492" height="1.3" width="1.146" fill="#f9f9f9">
<animate id="r1e11" begin="r1lr1.begin" attributeName="height" from="1.3" to="2" dur="0.2s" fill="freeze"/>
<animate begin="r1e11.begin" attributeName="y" from="397.7" to="397" dur="0.2s" fill="freeze"/>
<animate id="r1e12" begin="r1lr2.begin" attributeName="height" from="2" to="1.3" dur="0.2s" fill="freeze"/>
<animate begin="r1e12.begin" attributeName="y" from="397" to="397.7" dur="0.2s" fill="freeze"/>
</rect>
<animateMotion begin="r1lr1.begin" from="0,0" to="1,0" dur="0.2s" calcMode="paced" fill="freeze"/>
<animateMotion begin="r1lr2.begin" from="1,0" to="0,0" dur="0.2s" calcMode="paced" fill="freeze"/>
</g>
<rect width="6.648" height=".865" x="135.704" y="403.138" ry=".433" fill="gray"/>
<animateMotion id="r1lr1" begin="r2tor1.end" from="0,0" to="2,0" dur="0.2s" calcMode="paced" fill="freeze"/>
<animateMotion id="r1lr2" begin="r1tor3.end" from="2,0" to="0,0" dur="0.2s" calcMode="paced" fill="freeze"/>
</g>
<animateMotion id="r1ud2" begin="0s;r1ud1.end" from="0,1" to="0,0" dur="1.2s" calcMode="paced"/>
<animateMotion id="r1ud1" begin="r1ud2.end" from="0,0" to="0,1" dur="1.2s" calcMode="paced"/>
</g>
<rect width="9" height="1.2" x="59" y="295" ry="1" fill="#262626">
<animate id="r1ssw1" begin="r1ssw2.end" attributeName="width" from="9" to="10" dur="1.2s" calcMode="paced"/>
<animate id="r1ssw2" begin="0s;r1ssw1.end" attributeName="width" from="10" to="9" dur="1.2s" calcMode="paced"/>
<animate id="r1ssx1" begin="r1ssx2.end" attributeName="x" from="59" to="58.5" dur="1.2s" calcMode="paced"/>
<animate id="r1ssx2" begin="0s;r1ssx1.end" attributeName="x" from="58.5" to="59" dur="1.2s" calcMode="paced"/>
</rect>
</g>
<g transform="translate(-.748 -275.833)">
<g>
<g transform="translate(-75.306 -119.101)">
<rect width="8.852" height="9.42" x="174.008" y="399.075" ry="1.492" fill="#262626"/>
<rect width="8.008" height="8.522" x="173.653" y="398.845" ry=".976" fill="gray" stroke="gray" stroke-width=".628"/>
<rect width="7.01" height="7.46" x="173.878" y="399.387" ry=".854" fill="#262626"/>
</g>
<g transform="translate(-75.306 -119.101)">
<rect width="6.215" height="6.614" x="174.463" y="400.102" ry=".757" fill="#848484"/>
<g fill="#fff">
<rect ry=".278" y="401.302" x="175.442" height=".889" width="1.169">
<animate id="r3e11" begin="4s;r3e12.end+4s" attributeName="width" from="1.169" to="1.769" dur="0.3s" calcMode="paced" fill="freeze"/>
<animate id="r3e12" begin="r3e11.end+4s" attributeName="width" from="1.769" to="1.169" dur="0.3s" calcMode="paced" fill="freeze"/>
</rect>
<rect ry=".278" y="401.302" x="177.29" height=".889" width="2.1">
<animate begin="r3e11.begin" attributeName="width" from="2.1" to="1.5" dur="0.3s" calcMode="paced" fill="freeze"/>
<animate begin="r3e12.begin" attributeName="width" from="1.5" to="2.1" dur="0.3s" calcMode="paced" fill="freeze"/>
<animate begin="r3e11.begin" attributeName="x" from="177.29" to="177.89" dur="0.3s" calcMode="paced" fill="freeze"/>
<animate begin="r3e12.begin" attributeName="x" from="177.89" to="177.29" dur="0.3s" calcMode="paced" fill="freeze"/>
</rect>
</g>
<circle r="1.485" cy="404.653" cx="176.998" fill="#262626"/>
<circle r=".887" cy="404.635" cx="176.998" fill="#d18b8d"/>
<circle r="1.23" cy="404.509" cx="176.817" fill="none" stroke="#aaa" stroke-width=".429"/>
</g>
<animateMotion id="r3ud2" begin="r3ud1.end" from="0,1" to="0,0" dur="1.3s" calcMode="paced"/>
<animateMotion id="r3ud1" begin="0s;r3ud2.end" from="0,0" to="0,1" dur="1.3s" calcMode="paced"/>
</g>
<rect width="7" height="1.2" x="99" y="295" ry="1" fill="#262626">
<animate id="r3ssw1" begin="r3ssw2.end" attributeName="width" from="7" to="8" dur="1.3s" calcMode="paced"/>
<animate id="r3ssw2" begin="0s;r3ssw1.end" attributeName="width" from="8" to="7" dur="1.3s" calcMode="paced"/>
<animate id="r3ssx1" begin="r3ssx2.end" attributeName="x" from="99" to="98.5" dur="1.3s" calcMode="paced"/>
<animate id="r3ssx2" begin="0s;r3ssx1.end" attributeName="x" from="98.5" to="99" dur="1.3s" calcMode="paced"/>
</rect>
</g>
<g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="0.8">
<g stroke-dasharray="20 20 20 20 20 100" stroke-dashoffset="-100">
<path d="M30.13 11.531s5.576-3.02 12.455-3.02c6.88 0 12.456 3.02 12.456 3.02" stroke="#d18b8d">
<animate id="r1tor2" begin="r1lr2.end" attributeName="stroke-dashoffset" from="-100" to="100" dur="1.5s"/>
</path>
<path d="M55.04 8.514s-5.576 3.02-12.455 3.02c-6.879 0-12.455-3.02-12.455-3.02" stroke="gray">
<animate id="r2tor1" begin="0s;r1tor2.end" attributeName="stroke-dashoffset" from="-100" to="100" dur="1.5s"/>
</path>
</g>
<g stroke-dasharray="10 20 10 20 10 20 10 100" stroke-dashoffset="-100">
<path d="M71.24 9.743h16.196a1.897 1.897 0 1 0 0-3.795 1.897 1.897 0 0 0 0 3.795h8.023" stroke="#d18b8d">
<animate id="r1tor3" begin="r3tor1.end" attributeName="stroke-dashoffset" from="-100" to="100" dur="1.5s"/>
</path>
<path d="M 96.192792,9.7509803 H 72.003172" stroke="gray">
<animate id="r3tor1" begin="r1lr1.end" attributeName="stroke-dashoffset" from="-100" to="100" dur="1.5s"/>
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

124
ui/widgets/connector.css Normal file
View File

@@ -0,0 +1,124 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@charset "utf-8";
#connector {
padding: 0 20px 40px 20px;
}
#connector-cancel {
text-decoration: none;
color: #e9a;
}
#connector-cancel.disabled {
color: #444;
}
#connector-cancel::before {
content: "\000AB";
margin-right: 3px;
}
#connector-title {
margin-top: 10px;
text-align: center;
font-size: 0.9em;
color: #aaa;
}
#connector-title > h2 {
color: #e9a;
font-size: 1.3em;
font-weight: bold;
margin: 3px 0;
}
#connector-title.big {
margin: 50px 0;
}
#connector-title.big > h2 {
margin: 10px 0;
}
#connector-fields {
margin-top: 10px;
font-size: 0.9em;
}
#connector-continue {
margin-top: 10px;
font-size: 0.9em;
}
#connector-proccess {
margin-top: 10px;
text-align: center;
font-size: 0.9em;
color: #aaa;
}
#connector-proccess-message {
margin: 30px 0;
}
#connector-proccess-message > h2 {
font-weight: normal;
margin: 10px 0;
color: #e9a;
font-size: 1.2em;
}
#connector-proccess-message > h2 > span {
padding: 2px 10px;
border: 2px solid transparent;
display: inline-block;
}
@keyframes connector-proccess-message-alert {
0% {
border-color: transparent;
}
50% {
outline: 2px solid #e9a;
}
60% {
border-color: #e9a;
outline: none;
}
}
#connector-proccess-message.alert > h2 > span {
outline: 2px solid transparent;
animation-name: connector-proccess-message-alert;
animation-duration: 1.5s;
animation-iteration-count: infinite;
animation-direction: normal;
animation-timing-function: steps(1, end);
}
#connector-proccess-indicater {
width: 100%;
margin: 20px auto;
padding: 0;
}

463
ui/widgets/connector.vue Normal file
View File

@@ -0,0 +1,463 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<template>
<form
id="connector"
class="form1"
action="javascript:;"
method="POST"
@submit="submitAndGetNext"
>
<a
id="connector-cancel"
href="javascript:;"
:class="{ disabled: working || cancelled }"
@click="cancel()"
>
Cancel
</a>
<div
v-if="!working"
id="connector-title"
:class="{ big: current.fields.length <= 0 }"
>
<h2>{{ current.title || connector.name }}</h2>
<p>{{ current.message || connector.description }}</p>
</div>
<div v-if="working" id="connector-proccess">
<img id="connector-proccess-indicater" src="./connecting.svg" />
<div id="connector-proccess-message" :class="{ alert: current.alert }">
<h2>
<span>{{ current.title || connector.name }}</span>
</h2>
<p>{{ current.message || connector.description }}</p>
</div>
</div>
<fieldset id="connector-fields">
<div
v-for="(field, key) in current.fields"
:key="key"
class="field"
:class="{ error: field.error.length > 0 }"
>
{{ field.field.name }}
<input
v-if="field.field.type === 'text'"
v-model="field.field.value"
v-focus="field.autofocus"
type="text"
autocomplete="off"
:name="field.field.name"
:placeholder="field.field.example"
:autofocus="field.autofocus"
@input="verify(key, field, false)"
@change="verify(key, field, true)"
/>
<input
v-if="field.field.type === 'password'"
v-model="field.field.value"
v-focus="field.autofocus"
type="password"
autocomplete="off"
:name="field.field.name"
:placeholder="field.field.example"
:autofocus="field.autofocus"
@input="verify(key, field, false)"
@change="verify(key, field, true)"
/>
<input
v-if="field.field.type === 'checkbox'"
v-model="field.field.value"
type="checkbox"
autocomplete="off"
:name="field.field.name"
@input="verify(key, field, false)"
@change="verify(key, field, true)"
/>
<textarea
v-if="field.field.type === 'textarea'"
v-model="field.field.value"
v-focus="field.autofocus"
autocomplete="off"
:placeholder="field.field.example"
:name="field.field.name"
:autofocus="field.autofocus"
@input="verify(key, field, false)"
@keyup="expandTextarea"
@change="verify(key, field, true)"
></textarea>
<div v-if="field.field.type === 'textdata'" class="textinfo">
<div class="info">{{ field.field.value }}</div>
</div>
<div v-if="field.field.type === 'radio'" class="items">
<label
v-for="(option, oKey) in field.field.example.split(',')"
:key="oKey"
class="field horizontal item"
>
<input
v-model="field.field.value"
type="radio"
autocomplete="off"
:name="field.field.name"
:value="option"
@input="verify(key, field, false)"
@change="verify(key, field, true)"
/>
{{ option }}
</label>
</div>
<div v-if="field.error.length > 0" class="error">{{ field.error }}</div>
<div v-else-if="field.message.length > 0" class="message">
{{ field.message }}
</div>
<div
v-else-if="field.field.description.length > 0"
class="message"
v-html="field.field.description"
></div>
</div>
<div class="field">
<button
v-if="current.submittable"
type="submit"
:disabled="current.submitting || disabled"
@click="submitAndGetNext"
>
{{ current.actionText }}
</button>
<button
v-if="current.cancellable"
:disabled="current.submitting || disabled"
class="secondary"
@click="cancelAndGetNext"
>
Cancel
</button>
</div>
</fieldset>
</form>
</template>
<script>
import "./connector.css";
import * as command from "../commands/commands.js";
function buildField(i, field) {
return {
verified: false,
inputted: false,
error: "",
message: "",
field: field,
autofocus: i == 0
};
}
function buildEmptyCurrent() {
return {
data: null,
alert: false,
title: "",
message: "",
fields: [],
actionText: "Continue",
cancellable: false,
submittable: false,
submitting: false
};
}
export default {
directives: {
focus: {
inserted(el, binding) {
if (!binding.value) {
return;
}
el.focus();
}
}
},
props: {
connector: {
type: Object,
default: () => null
}
},
data() {
return {
currentConnector: null,
currentConnectorCloseWait: null,
current: buildEmptyCurrent(),
working: false,
disabled: false,
cancelled: false
};
},
watch: {
async connector(oldV, newV) {
if (this.currentConnector !== null) {
await this.closeWizard();
}
this.cancelled = false;
this.currentConnector = newV;
this.runWizard();
}
},
async mounted() {
await this.closeWizard();
this.runWizard();
this.cancelled = false;
},
async beforeDestroy() {
try {
await this.closeWizard();
} catch (e) {
process.env.NODE_ENV === "development" && console.trace(e);
}
},
methods: {
async sendCancel() {
await this.closeWizard();
this.$emit("cancel", true);
},
cancel() {
if (this.cancelled) {
return;
}
this.cancelled = true;
if (this.working) {
return;
}
this.sendCancel();
},
buildCurrent(next) {
this.current = buildEmptyCurrent();
this.working = this.getConnector().wizard.started();
this.current.type = next.type();
this.current.data = next.data();
switch (this.current.type) {
case command.NEXT_PROMPT:
let fields = this.current.data.inputs();
for (let i in fields) {
this.current.fields.push(buildField(i, fields[i]));
}
this.current.actionText = this.current.data.actionText();
this.current.submittable = true;
this.current.alert = true;
this.current.cancellable = true;
// Fallthrough
case command.NEXT_WAIT:
this.current.title = this.current.data.title();
this.current.message = this.current.data.message();
break;
case command.NEXT_DONE:
this.working = false;
this.disabled = true;
if (!this.current.data.success()) {
this.current.title = this.current.data.error();
this.current.message = this.current.data.message();
} else {
this.$emit("done", this.current.data.data());
}
break;
default:
throw new Error("Unknown command type");
}
if (!this.working) {
this.current.cancellable = false;
}
return next;
},
getConnector() {
if (this.currentConnector === null) {
this.currentConnector = this.connector;
}
return this.currentConnector;
},
async closeWizard() {
if (this.currentConnectorCloseWait === null) {
return;
}
let waiter = this.currentConnectorCloseWait;
this.currentConnectorCloseWait = null;
this.getConnector().wizard.close();
await waiter;
},
runWizard() {
if (this.currentConnectorCloseWait !== null) {
throw new Error("Cannot run wizard multiple times");
}
this.currentConnectorCloseWait = (async () => {
while (!this.disabled) {
let next = this.buildCurrent(await this.getConnector().wizard.next());
switch (next.type()) {
case command.NEXT_PROMPT:
case command.NEXT_WAIT:
continue;
case command.NEXT_DONE:
return;
default:
throw new Error("Unknown command type");
}
}
})();
},
getFieldValues() {
let mod = {};
for (let i in this.current.fields) {
mod[this.current.fields[i].field.name] = this.current.fields[
i
].field.value;
}
return mod;
},
expandTextarea(event) {
event.target.style.overflowY = "hidden";
event.target.style.height = "";
event.target.style.height = event.target.scrollHeight + "px";
},
async verify(key, field, force) {
try {
field.message = "" + (await field.field.verify(field.field.value));
field.inputted = true;
field.verified = true;
field.error = "";
} catch (e) {
field.error = "";
field.message = "";
field.verified = false;
if (field.inputted || force) {
field.error = "" + e;
}
}
if (
!field.verified &&
(field.inputted || force) &&
field.error.length <= 0
) {
field.error = "Invalid";
}
this.current.fields[key] = field;
return field.verified;
},
async verifyAll() {
let verified = true;
for (let i in this.current.fields) {
if (await this.verify(i, this.current.fields[i], true)) {
continue;
}
verified = false;
}
return verified;
},
async submitAndGetNext() {
if (this.current.submitting || this.disabled) {
return;
}
if (this.current.data === null || !this.current.submittable) {
return;
}
if (!(await this.verifyAll())) {
return;
}
this.current.submitting = true;
try {
await this.current.data.submit(this.getFieldValues());
} catch (e) {
this.current.submitting = false;
alert("Submission has failed: " + e);
process.env.NODE_ENV === "development" && console.trace(e);
return;
}
},
async cancelAndGetNext() {
if (this.current.submitting || this.disabled) {
return;
}
if (this.current.data === null || !this.current.cancellable) {
return;
}
this.current.submitting = true;
await this.current.data.cancel();
}
}
};
</script>

View File

@@ -0,0 +1,23 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@charset "utf-8";
#home-content > .screen > .screen-screen > .screen-console {
}

View File

@@ -0,0 +1,304 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<template>
<div
class="screen-console"
:style="'background-color: ' + control.activeColor()"
style="top: 0; right: 0; left: 0; bottom: 0; padding 0; margin: 0; position: absolute; overflow: hidden"
/>
</template>
<script>
import { Terminal } from "xterm";
import { WebLinksAddon } from "xterm-addon-web-links";
import { FitAddon } from "xterm-addon-fit";
import "./screen_console.css";
import "xterm/css/xterm.css";
import { isNumber } from "util";
class Term {
constructor(control) {
const resizeDelayInterval = 500;
this.term = new Terminal({
allowTransparency: false,
cursorBlink: true,
cursorStyle: "block",
logLevel: process.env.NODE_ENV === "development" ? "info" : "off"
});
this.fit = new FitAddon();
this.term.loadAddon(this.fit);
this.term.loadAddon(new WebLinksAddon());
this.term.setOption("theme", {
background: control.activeColor()
});
this.term.onData(data => {
control.send(data);
});
this.term.onKey(ev => {
if (!control.echo()) {
return;
}
const printable =
!ev.domEvent.altKey &&
!ev.domEvent.altGraphKey &&
!ev.domEvent.ctrlKey &&
!ev.domEvent.metaKey;
if (ev.domEvent.keyCode === 13) {
this.writeStr("\r\n");
} else if (ev.domEvent.keyCode === 8) {
this.writeStr("\b \b");
} else if (printable) {
this.writeStr(ev.key);
}
});
let resizeDelay = null,
oldRows = 0,
oldCols = 0;
this.term.onResize(dim => {
if (dim.cols === oldCols && dim.rows === oldRows) {
return;
}
oldRows = dim.rows;
oldCols = dim.cols;
if (resizeDelay !== null) {
clearTimeout(resizeDelay);
resizeDelay = null;
}
resizeDelay = setTimeout(() => {
if (!isNumber(dim.cols) || !isNumber(dim.rows)) {
return;
}
if (!dim.cols || !dim.rows) {
return;
}
control.resize({
rows: dim.rows,
cols: dim.cols
});
resizeDelay = null;
}, resizeDelayInterval);
});
}
init(root, callbacks) {
this.term.open(root);
this.term.textarea.addEventListener("focus", callbacks.focus);
this.term.textarea.addEventListener("blur", callbacks.blur);
this.term.element.addEventListener("click", () => {
this.term.textarea.blur();
this.term.textarea.click();
this.term.textarea.focus();
});
this.refit();
}
writeStr(d) {
try {
this.term.write(d);
} catch (e) {
process.env.NODE_ENV === "development" && console.trace(e);
}
}
write(d) {
try {
this.term.writeUtf8(d);
} catch (e) {
process.env.NODE_ENV === "development" && console.trace(e);
}
}
focus() {
try {
this.term.focus();
} catch (e) {
process.env.NODE_ENV === "development" && console.trace(e);
}
}
blur() {
try {
this.term.blur();
} catch (e) {
process.env.NODE_ENV === "development" && console.trace(e);
}
}
refit() {
try {
this.fit.fit();
} catch (e) {
process.env.NODE_ENV === "development" && console.trace(e);
}
}
destroy() {
try {
this.term.dispose();
} catch (e) {
process.env.NODE_ENV === "development" && console.trace(e);
}
}
}
// So it turns out, display: none + xterm.js == trouble, so I changed this
// to a visibility + position: absolute appoarch. Problem resolved, and I
// like to keep it that way.
export default {
props: {
active: {
type: Boolean,
default: false
},
control: {
type: Object,
default: () => null
},
change: {
type: Object,
default: () => null
}
},
data() {
return {
term: new Term(this.control),
runner: null
};
},
watch: {
active() {
this.triggerActive();
},
change: {
handler() {
if (!this.active) {
return;
}
this.fit();
},
deep: true
}
},
mounted() {
this.init();
},
beforeDestroy() {
this.deinit();
},
methods: {
triggerActive() {
this.active ? this.activate() : this.deactivate();
},
init() {
let self = this;
this.term.init(this.$el, {
focus(e) {
document.addEventListener("keyup", self.localKeypress);
document.addEventListener("keydown", self.localKeypress);
},
blur(e) {
document.removeEventListener("keyup", self.localKeypress);
document.removeEventListener("keydown", self.localKeypress);
}
});
this.triggerActive();
this.runRunner();
},
async deinit() {
await this.closeRunner();
await this.deactivate();
this.term.destroy();
},
fit() {
this.term.refit();
},
localKeypress(e) {
if (!e.altKey && !e.shiftKey && !e.ctrlKey) {
return;
}
e.preventDefault();
},
activate() {
this.fit();
window.addEventListener("resize", this.fit);
this.term.focus();
},
async deactivate() {
window.removeEventListener("resize", this.fit);
document.removeEventListener("keyup", this.localKeypress);
document.removeEventListener("keydown", this.localKeypress);
this.term.blur();
},
runRunner() {
if (this.runner !== null) {
return;
}
let self = this;
this.runner = (async () => {
try {
for (;;) {
this.term.write(await this.control.receive());
self.$emit("updated");
}
} catch (e) {
self.$emit("stopped", e);
}
})();
},
async closeRunner() {
if (this.runner === null) {
return;
}
let runner = this.runner;
this.runner = null;
await runner;
}
}
};
</script>

85
ui/widgets/screens.vue Normal file
View File

@@ -0,0 +1,85 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<template>
<main style="position: relative">
<slot v-if="screens.length <= 0"></slot>
<div
v-for="(screenInfo, idx) in screens"
v-if="screens.length > 0"
:key="screenInfo.id"
:style="'visibility: ' + (screen === idx ? 'visible' : 'hidden')"
class="screen"
style="top: 0; right: 0; left: 0; bottom: 0; padding 0; margin: 0; overflow: auto; position: absolute;"
>
<div v-if="screenInfo.indicator.error.length > 0" class="screen-error">
{{ screenInfo.indicator.error }}
</div>
<div class="screen-screen" style="position: relative">
<component
:is="getComponent(screenInfo.control.ui())"
:active="screen === idx"
:control="screenInfo.control"
:change="screenInfo.indicator"
@stopped="stopped(idx, $event)"
@updated="updated(idx)"
></component>
</div>
</div>
</main>
</template>
<script>
import ConsoleScreen from "./screen_console.vue";
export default {
components: {
ConsoleScreen
},
props: {
screen: {
type: Number,
default: 0
},
screens: {
type: Array,
default: () => []
}
},
methods: {
getComponent(ui) {
switch (ui) {
case "Console":
return "ConsoleScreen";
default:
throw new Error("Unknown UI: " + ui);
}
},
stopped(index, stopErr) {
this.$emit("stopped", index, stopErr);
},
updated(index) {
this.$emit("updated", index);
}
}
};
</script>

217
ui/widgets/status.css Normal file
View File

@@ -0,0 +1,217 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@charset "utf-8";
#conn-status {
z-index: 999999;
top: 40px;
left: 96px;
display: none;
width: 500px;
background: #262626;
}
#conn-status .window-frame {
max-height: calc(100vh - 40px);
overflow: auto;
}
#conn-status:before {
left: 30px;
}
@media (max-width: 768px) {
#conn-status {
left: 20px;
right: 20px;
width: auto;
}
#conn-status:before {
left: 91px;
}
}
#conn-status.display {
display: block;
}
#conn-status h1 {
padding: 15px 15px 10px 15px;
background: #a56;
}
#conn-status-info {
padding: 0 15px 15px 15px;
font-size: 0.9em;
line-height: 1.5;
background: #a56;
}
#conn-status.green:before {
background: #287;
}
#conn-status.green h1 {
color: #6ba;
background: #287;
}
#conn-status.green #conn-status-info {
background: #287;
}
#conn-status.yellow:before {
background: #da0;
}
#conn-status.yellow h1 {
color: #fff;
background: #da0;
}
#conn-status.yellow #conn-status-info {
background: #da0;
}
#conn-status.orange:before {
background: #b73;
}
#conn-status.orange h1 {
color: #fff;
background: #b73;
}
#conn-status.orange #conn-status-info {
background: #b73;
}
#conn-status.red:before {
background: #a33;
}
#conn-status.red h1 {
color: #fff;
background: #a33;
}
#conn-status.red #conn-status-info {
background: #a33;
}
.conn-status-chart {
}
.conn-status-chart > .counters {
width: 100%;
overflow: auto;
margin-bottom: 10px;
}
.conn-status-chart > .counters > .counter {
width: 50%;
display: block;
float: left;
margin: 10px 0;
text-align: center;
}
.conn-status-chart > .counters > .counter .name {
font-size: 0.8em;
color: #777;
text-transform: uppercase;
font-weight: bold;
letter-spacing: 1px;
}
.conn-status-chart > .counters > .counter .value {
font-size: 1.5em;
font-weight: lighter;
}
.conn-status-chart > .counters > .counter .value span {
font-size: 0.7em;
}
.conn-status-chart > .chart {
margin: 0 10px;
}
.conn-status-chart > .chart g {
fill: none;
stroke-width: 7px;
stroke-linecap: round;
stroke-linejoin: round;
}
.conn-status-chart > .chart .zero {
stroke: #3a3a3a;
stroke-width: 3px;
}
#conn-status-delay {
padding: 15px 0;
background: #292929;
}
#conn-status-delay > .counters > .counter {
width: 100%;
float: none;
}
#conn-status-delay-chart-background {
--color-start: #e43989;
--color-stop: #9a5fca;
}
#conn-status-delay-chart > g {
fill: none;
stroke: url(#conn-status-delay-chart-background) #2a6;
}
#conn-status-traffic {
padding: 15px 0;
}
#conn-status-traffic-chart-in-background {
--color-start: #0287a8;
--color-stop: #06e7b6;
}
#conn-status-traffic-chart-in > g {
stroke: url(#conn-status-traffic-chart-in-background) #2a6;
}
#conn-status-traffic-chart-out-background {
--color-start: #e46226;
--color-stop: #da356c;
}
#conn-status-traffic-chart-out > g {
stroke: url(#conn-status-traffic-chart-out-background) #2a6;
}
#conn-status-close {
/* ID mainly use for document.getElementById */
cursor: pointer;
right: 10px;
top: 20px;
}

247
ui/widgets/status.vue Normal file
View File

@@ -0,0 +1,247 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<template>
<window
id="conn-status"
flash-class="home-window-display"
:display="display"
@display="$emit('display', $event)"
>
<h1 class="window-title">Connection status</h1>
<div id="conn-status-info">
{{ status.description }}
</div>
<div id="conn-status-delay" class="conn-status-chart">
<div class="counters">
<div class="counter">
<div class="name">Delay</div>
<div
class="value"
v-html="$options.filters.mSecondString(status.delay)"
></div>
</div>
</div>
<div class="chart">
<chart
id="conn-status-delay-chart"
:width="480"
:height="50"
type="Bar"
:enabled="display"
:values="status.delayHistory"
>
<defs>
<linearGradient
id="conn-status-delay-chart-background"
gradientUnits="userSpaceOnUse"
x1="0"
y1="0"
x2="0"
y2="100%"
>
<stop stop-color="var(--color-start)" offset="0%" />
<stop stop-color="var(--color-stop)" offset="100%" />
</linearGradient>
</defs>
</chart>
</div>
</div>
<div id="conn-status-traffic" class="conn-status-chart">
<div class="counters">
<div class="counter">
<div class="name">Inbound</div>
<div
class="value"
v-html="$options.filters.bytePerSecondString(status.inbound)"
></div>
</div>
<div class="counter">
<div class="name">Outbound</div>
<div
class="value"
v-html="$options.filters.bytePerSecondString(status.outbound)"
></div>
</div>
</div>
<div class="chart">
<chart
id="conn-status-traffic-chart-in"
:width="480"
:height="25"
type="Bar"
:max="inoutBoundMax"
:enabled="display"
:values="status.inboundHistory"
@max="inboundMaxColUpdated"
>
<defs>
<linearGradient
id="conn-status-traffic-chart-in-background"
gradientUnits="userSpaceOnUse"
x1="0"
y1="0"
x2="0"
y2="100%"
>
<stop stop-color="var(--color-start)" offset="0%" />
<stop stop-color="var(--color-stop)" offset="100%" />
</linearGradient>
</defs>
</chart>
</div>
<div class="chart">
<chart
id="conn-status-traffic-chart-out"
:width="480"
:height="25"
type="UpsideDownBar"
:max="inoutBoundMax"
:enabled="display"
:values="status.outboundHistory"
@max="outboundMaxColUpdated"
>
<defs>
<linearGradient
id="conn-status-traffic-chart-out-background"
gradientUnits="userSpaceOnUse"
x1="0"
y1="0"
x2="0"
y2="100%"
>
<stop stop-color="var(--color-start)" offset="0%" />
<stop stop-color="var(--color-stop)" offset="100%" />
</linearGradient>
</defs>
</chart>
</div>
</div>
</window>
</template>
<script>
/* eslint vue/attribute-hyphenation: 0 */
import "./status.css";
import Window from "./window.vue";
import Chart from "./chart.vue";
export default {
components: {
window: Window,
chart: Chart
},
filters: {
bytePerSecondString(n) {
const bNames = ["byte/s", "kib/s", "mib/s", "gib/s", "tib/s"];
let remain = n,
nUnit = bNames[0];
for (let i in bNames) {
nUnit = bNames[i];
if (remain < 1024) {
break;
}
remain /= 1024;
}
return (
Number(remain.toFixed(2)).toLocaleString() +
" <span>" +
nUnit +
"</span>"
);
},
mSecondString(n) {
const bNames = ["ms", "s", "m"];
let remain = n,
nUnit = bNames[0];
for (let i in bNames) {
nUnit = bNames[i];
if (remain < 1000) {
break;
}
remain /= 1000;
}
return (
Number(remain.toFixed(2)).toLocaleString() +
" <span>" +
nUnit +
"</span>"
);
}
},
props: {
display: {
type: Boolean,
default: false
},
status: {
type: Object,
default: () => {
return {
description: "",
delay: 0,
delayHistory: [],
inbound: 0,
inboundHistory: [],
outbound: 0,
outboundHistory: []
};
}
}
},
data() {
return {
inoutBoundMax: 0,
inboundMax: 0,
outboundMax: 0
};
},
methods: {
inboundMaxColUpdated(d) {
this.inboundMax = d;
this.inoutBoundMax =
this.inboundMax > this.outboundMax ? this.inboundMax : this.outboundMax;
},
outboundMaxColUpdated(d) {
this.outboundMax = d;
this.inoutBoundMax =
this.inboundMax > this.outboundMax ? this.inboundMax : this.outboundMax;
}
}
};
</script>

103
ui/widgets/tab_list.vue Normal file
View File

@@ -0,0 +1,103 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<template>
<ul :id="id" :class="tabsClass">
<li
v-for="(tabInfo, idx) in tabs"
:key="tabInfo.id"
:class="{
active: tab === idx,
error: tabInfo.indicator.error.length > 0,
updated: tabInfo.indicator.updated
}"
:style="
'background: ' +
(tab === idx
? tabInfo.control.activeColor()
: tabInfo.control.color())
"
@click="switchTo(idx)"
>
<span class="title" :title="tabInfo.name">
<span
class="type"
:title="tabInfo.info.name()"
:style="'background: ' + tabInfo.info.color()"
>
{{ tabInfo.info.name()[0] }}
</span>
{{ tabInfo.name }}
</span>
<span class="icon icon-close icon-close1" @click="closeAt(idx)"></span>
</li>
</ul>
</template>
<script>
export default {
props: {
id: {
type: String,
default: ""
},
tab: {
type: Number,
default: 0
},
tabs: {
type: Array,
default: () => []
},
tabsClass: {
type: String,
default: ""
}
},
watch: {
tab(newVal) {
this.switchTo(newVal);
},
tabs(newVal) {
if (newVal.length > this.tab) {
return;
}
this.switchTo(newVal.length - 1);
}
},
methods: {
switchTo(index) {
if (index < 0 || index >= this.tabs.length) {
return;
}
if (this.tab == index) {
return;
}
this.$emit("current", index);
},
closeAt(index) {
this.$emit("close", index);
}
}
};
</script>

148
ui/widgets/tab_window.css Normal file
View File

@@ -0,0 +1,148 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@charset "utf-8";
#tab-window {
z-index: 999999;
top: 40px;
right: 0px;
display: none;
width: 400px;
background: #333;
}
#tab-window .window-frame {
max-height: calc(100vh - 40px);
overflow: auto;
}
#tab-window:before {
right: 19px;
background: #333;
}
@media (max-width: 768px) {
#tab-window {
width: 80%;
}
}
#tab-window.display {
display: block;
}
#tab-window-close {
cursor: pointer;
right: 10px;
top: 20px;
color: #999;
}
#tab-window h1 {
padding: 15px 15px 0 15px;
margin-bottom: 10px;
color: #999;
}
#tab-window-list > li > .lst-wrap {
padding: 10px 20px;
cursor: pointer;
}
#tab-window-list > li {
border-bottom: none;
}
#tab-window-tabs {
flex: auto;
overflow: hidden;
}
#tab-window-tabs > li {
display: flex;
position: relative;
padding: 15px;
opacity: 0.5;
color: #999;
cursor: pointer;
}
#tab-window-tabs > li::after {
content: " ";
display: block;
position: absolute;
top: 5px;
bottom: 5px;
left: 0;
width: 0;
transition: all 0.1s linear;
transition-property: width, top, bottom;
}
#tab-window-tabs > li.active::after {
top: 0;
bottom: 0;
}
#tab-window-tabs > li.updated::after {
background: #fff3;
width: 5px;
}
#tab-window-tabs > li.error::after {
background: #d55;
width: 5px;
}
#tab-window-tabs > li > span.title {
text-overflow: ellipsis;
overflow: hidden;
display: inline-block;
}
#tab-window-tabs > li > span.title > span.type {
display: inline-block;
font-size: 0.85em;
font-weight: bold;
margin-right: 3px;
text-transform: uppercase;
color: #fff;
background: #222;
padding: 1px 4px;
border-radius: 2px;
}
#tab-window-tabs > li > .icon-close {
display: block;
position: absolute;
top: 50%;
right: 10px;
margin-top: -5px;
color: #fff6;
}
#tab-window-tabs > li.active {
color: #fff;
opacity: 1;
}
#tab-window-tabs > li.active > span.title {
padding-right: 20px;
}

79
ui/widgets/tab_window.vue Normal file
View File

@@ -0,0 +1,79 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<template>
<window
id="tab-window"
flash-class="home-window-display"
:display="display"
@display="$emit('display', $event)"
>
<h1 class="window-title">Opened tabs</h1>
<tab-list
id="tab-window-tabs"
:tab="tab"
:tabs="tabs"
:tabs-class="tabsClass"
@current="$emit('current', $event)"
@close="$emit('close', $event)"
></tab-list>
</window>
</template>
<script>
import "./tab_window.css";
import Window from "./window.vue";
import TabList from "./tab_list.vue";
export default {
components: {
window: Window,
"tab-list": TabList
},
props: {
display: {
type: Boolean,
default: false
},
tab: {
type: Number,
default: 0
},
tabs: {
type: Array,
default: () => []
},
tabsClass: {
type: String,
default: ""
}
},
watch: {
tabs(newV) {
if (newV.length > 0) {
return;
}
this.$emit("display", false);
}
}
};
</script>

76
ui/widgets/tabs.vue Normal file
View File

@@ -0,0 +1,76 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<template>
<div :id="id">
<tab-list
:id="id + '-tabs'"
:tab="tab"
:tabs="tabs"
:tabs-class="tabsClass"
@current="$emit('current', $event)"
@close="$emit('close', $event)"
></tab-list>
<a
v-if="tabs.length > 0"
:id="id + '-list'"
:class="listTriggerClass"
href="javascript:;"
@click="showList"
></a>
</div>
</template>
<script>
import TabList from "./tab_list.vue";
export default {
components: {
"tab-list": TabList
},
props: {
id: {
type: String,
default: ""
},
tab: {
type: Number,
default: 0
},
tabs: {
type: Array,
default: () => []
},
tabsClass: {
type: String,
default: ""
},
listTriggerClass: {
type: String,
default: ""
}
},
methods: {
showList() {
this.$emit("list", this.tabs);
}
}
};
</script>

20
ui/widgets/window.css Normal file
View File

@@ -0,0 +1,20 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@charset "utf-8";

77
ui/widgets/window.vue Normal file
View File

@@ -0,0 +1,77 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<template>
<div
:id="id"
class="window window1"
:class="[{ display: displaying }, { [flashClass]: displaying }]"
>
<div class="window-frame">
<slot />
</div>
<span
:id="id + '-close'"
class="window-close icon icon-close1"
@click="hide"
/>
</div>
</template>
<script>
export default {
props: {
id: {
type: String,
default: ""
},
display: {
type: Boolean,
default: false
},
flashClass: {
type: String,
default: ""
}
},
data() {
return {
displaying: false
};
},
watch: {
display(newVal) {
newVal ? this.show() : this.hide();
}
},
methods: {
show() {
this.displaying = true;
this.$emit("display", this.displaying);
},
hide() {
this.displaying = false;
this.$emit("display", this.displaying);
}
}
};
</script>

46
ui/xhr.js Normal file
View File

@@ -0,0 +1,46 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019 Rui NI <nirui@gmx.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
export function head(url, headers) {
return new Promise((res, rej) => {
let authReq = new XMLHttpRequest();
authReq.onreadystatechange = () => {
if (authReq.readyState !== authReq.DONE) {
return;
}
res(authReq);
};
authReq.onerror = e => {
rej(e);
};
authReq.ontimeout = e => {
rej(e);
};
authReq.open("HEAD", url, true);
for (let h in headers) {
authReq.setRequestHeader(h, headers[h]);
}
authReq.send();
});
}