Files

422 lines
15 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1NCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDExIDEwIj48c3R5bGU+LmJne2ZpbGw6I2ZmMH0ub3tmaWxsOiMwMDB9PC9zdHlsZT48cmVjdCBjbGFzcz0iYmciIHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiLz48cGF0aCBkPSJNMSwxIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik0zLDEgaDEgdjggaC0xIHoiIGNsYXNzPSJvIi8+PHBhdGggZD0iTTUsMSBoMSB2OCBoLTEgeiIgY2xhc3M9Im8iLz48cGF0aCBkPSJNNywxIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik05LDQuMjUgaDEgdjEuNSBoLTEgeiIgY2xhc3M9Im8iLz48L3N2Zz4K">
<title>ClickHouse Web Terminal</title>
<!-- Code Style:
Do not use any JavaScript or CSS frameworks or preprocessors (except xterm.js for terminal emulation).
This HTML page should not require any build systems (node.js, npm, gulp, etc.)
This HTML page should not be minified, instead it should be reasonably minimalistic by itself.
This UI should look as lightweight, clean and fast as possible.
-->
<link rel="stylesheet" href="/js/xterm.min.css">
<script src="/js/xterm.min.js"></script>
<script src="/js/addon-fit.min.js"></script>
<script src="/js/addon-web-links.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #000;
overflow: hidden;
overscroll-behavior: contain;
height: 100vh;
width: 100vw;
}
#terminal-container {
height: 100%;
width: 100%;
overscroll-behavior: contain;
}
/* Dark themed scrollbar */
.xterm-viewport::-webkit-scrollbar {
width: 6px;
}
.xterm-viewport::-webkit-scrollbar-track {
background: #000;
}
.xterm-viewport::-webkit-scrollbar-thumb {
background: #333;
border-radius: 999px;
}
.xterm-viewport::-webkit-scrollbar-thumb:hover {
background: #555;
}
.xterm-viewport {
scrollbar-width: thin;
scrollbar-color: #333 #000;
overscroll-behavior: contain;
}
</style>
</head>
<body>
<div id="terminal-container"></div>
<script>
'use strict';
var term = null;
var fitAddon = null;
var ws = null;
var dataHandler = null;
// State: 'connecting', 'waiting_credentials', 'prompt_user', 'prompt_password', 'active', 'closed'
var state = 'connecting';
var inputBuffer = '';
// Username can be supplied via URL for convenience; the password must never be passed via URL,
// because URLs end up in browser history, referrer headers, server access logs, and proxy logs.
// The password is collected interactively or, when embedded in an iframe, sent in via postMessage.
var params = new URLSearchParams(window.location.search);
/// Empty user means "let the server pick its configured default user", which is not necessarily named "default".
var currentUser = params.get('user') || '';
var currentPassword = '';
var userFromURL = params.has('user');
var inIframe = (window.parent !== window);
// Trusted parent origin learned from the first valid postMessage we receive.
// We need this because postMessage's targetOrigin must equal the parent's actual origin,
// and cross-origin restrictions prevent reading window.parent.location.
var trustedParentOrigin = null;
/// Tell the embedding page that the user pressed Escape, or that the session ended, so that
/// it can close the panel the terminal is embedded in.
///
/// These notifications carry no data, so they go to `'*'` when the parent origin is not
/// (yet) known to be trusted: an embedder learns only that its own iframe is done, which it
/// is entitled to know. Credentials still travel only between the terminal and a trusted
/// parent - see `isTrustedParentOrigin`, which gates the inbound direction.
function notifyParent(type) {
window.parent.postMessage({type: type}, trustedParentOrigin || '*');
}
function isTrustedParentOrigin(origin) {
if (origin === window.location.origin) return true;
try {
var u = new URL(origin);
if (u.protocol !== 'https:') return false;
var h = u.hostname;
return h === 'clickhouse.com'
|| h === 'clickhouse.cloud'
|| h.endsWith('.clickhouse.com')
|| h.endsWith('.clickhouse.cloud');
} catch (e) {
return false;
}
}
function getWebSocketURL() {
var loc = window.location;
var protocol = loc.protocol === 'https:' ? 'wss:' : 'ws:';
return protocol + '//' + loc.host + '/webterminal';
}
function initTerminal() {
term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: '"Cascadia Code", "Fira Code", "JetBrains Mono", Menlo, Monaco, "Courier New", monospace',
theme: {
background: '#000',
foreground: '#d4d4d4',
cursor: '#d4d4d4',
selectionBackground: '#264f78',
black: '#000',
red: '#f44747',
green: '#6a9955',
yellow: '#d7ba7d',
blue: '#569cd6',
magenta: '#c586c0',
cyan: '#4ec9b0',
white: '#ffffff',
brightBlack: '#808080',
brightRed: '#f44747',
brightGreen: '#6a9955',
brightYellow: '#d7ba7d',
brightBlue: '#569cd6',
brightMagenta: '#c586c0',
brightCyan: '#4ec9b0',
brightWhite: '#ffffff'
},
allowProposedApi: true,
scrollback: 10000,
});
fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon.WebLinksAddon());
term.open(document.getElementById('terminal-container'));
fitAddon.fit();
// Escape key: when inside an iframe (playground), notify parent to deactivate terminal
if (window.parent !== window) {
term.attachCustomKeyEventHandler(function(e) {
if (e.type === 'keydown' && e.key === 'Escape') {
notifyParent('webterminal-escape');
return false;
}
return true;
});
}
window.addEventListener('resize', function() {
fitAddon.fit();
});
var container = document.getElementById('terminal-container');
if (window.ResizeObserver) {
new ResizeObserver(function() {
// Skip if container has no usable size (e.g. parent panel is hidden)
if (container.clientWidth > 0 && container.clientHeight > 0) {
fitAddon.fit();
}
}).observe(container);
}
// Single onData handler that dispatches based on state
term.onData(function(data) {
if (state === 'waiting_credentials') {
// The host page owns the credentials, but it may never send them (it does not
// implement the protocol, or its origin is not trusted). Enter takes the login
// over interactively; a later credentials message is then ignored.
if (data.indexOf('\r') >= 0 || data.indexOf('\n') >= 0) {
clearScreen();
promptCredentials();
}
} else if (state === 'prompt_user' || state === 'prompt_password') {
handlePromptInput(data);
} else if (state === 'active') {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(new TextEncoder().encode(data));
}
}
});
term.onResize(function() {
sendResize();
});
term.focus();
}
function sendResize() {
if (ws && ws.readyState === WebSocket.OPEN && term) {
ws.send(JSON.stringify({
type: 'resize',
cols: term.cols,
rows: term.rows
}));
}
}
function handlePromptInput(data) {
for (var i = 0; i < data.length; i++) {
var ch = data[i];
var code = data.charCodeAt(i);
if (ch === '\r' || ch === '\n') {
// Enter - submit
term.write('\r\n');
var value = inputBuffer;
inputBuffer = '';
if (state === 'prompt_user') {
currentUser = value;
promptPassword();
} else if (state === 'prompt_password') {
currentPassword = value;
attemptConnect();
}
return;
} else if (code === 127 || code === 8) {
// Backspace
if (inputBuffer.length > 0) {
inputBuffer = inputBuffer.slice(0, -1);
if (state === 'prompt_user') {
term.write('\b \b');
}
// For password, nothing visible to erase
}
} else if (code === 3) {
// Ctrl+C - reset to user prompt
term.write('^C\r\n');
inputBuffer = '';
promptUser();
return;
} else if (code >= 32) {
// Printable character
inputBuffer += ch;
if (state === 'prompt_user') {
term.write(ch);
}
// For password, don't echo
}
}
}
// Erase the whole viewport. The notice printed while waiting for the host page's credentials
// may have wrapped onto several lines in a narrow terminal, so erasing one line is not enough.
function clearScreen() {
term.write('\x1b[H\x1b[2J');
}
function promptUser() {
state = 'prompt_user';
inputBuffer = '';
term.write('Username: ');
}
function promptPassword() {
state = 'prompt_password';
inputBuffer = '';
term.write('Password: ');
}
function promptCredentials(message) {
if (message) {
term.write('\x1b[31m' + message + '\x1b[0m\r\n');
}
if (userFromURL) {
// User was given in URL, only ask for password
promptPassword();
} else {
promptUser();
}
}
function attemptConnect() {
state = 'connecting';
term.write('\x1b[90mConnecting...\x1b[0m');
if (ws) {
ws.onclose = null;
ws.onerror = null;
ws.close();
ws = null;
}
var url = getWebSocketURL();
var opened = false;
try {
ws = new WebSocket(url);
} catch (e) {
promptCredentials('Connection failed: ' + e.message);
return;
}
ws.binaryType = 'arraybuffer';
ws.onopen = function() {
opened = true;
// Send credentials as the first message (avoids leaking password in URL).
// The "user" field is omitted when empty so the server falls back to
// its default user (which is not necessarily named "default").
const authMessage = { type: 'auth', password: currentPassword };
if (currentUser) {
authMessage.user = currentUser;
}
ws.send(JSON.stringify(authMessage));
state = 'active';
// Erase the "Connecting..." message so PTY output starts on a clean line
term.write('\r\x1b[2K');
sendResize();
};
ws.onmessage = function(event) {
if (event.data instanceof ArrayBuffer) {
term.write(new Uint8Array(event.data));
} else {
term.write(event.data);
}
};
ws.onclose = function(event) {
if (!opened || event.code === 1008) {
// Handshake failed or server rejected authentication (close code 1008)
ws = null;
term.write('\r\x1b[2K');
promptCredentials();
} else {
// Normal disconnect after session was active
if (window.parent !== window) {
// Inside an iframe - notify parent to deactivate terminal
notifyParent('webterminal-closed');
} else {
window.location.href = 'https://github.com/ClickHouse/ClickHouse';
}
}
};
ws.onerror = function() {
// onerror always fires before onclose, handled there
};
}
// Listen for messages from the parent (credentials, refit requests).
// Accepted origins: same-origin, *.clickhouse.com, *.clickhouse.cloud (https only).
// A 'webterminal-hello' handshake lets the parent register its origin so the iframe
// can post back even when the parent is cross-origin.
window.addEventListener('message', function(e) {
// Only accept messages from the embedding parent window. Without this, any window
// from an allowed origin that obtains a reference to us (e.g. via window.open)
// could drive the credentials/refit handlers.
if (e.source !== window.parent) return;
if (!isTrustedParentOrigin(e.origin)) return;
if (!e.data) return;
// Remember the parent origin so outbound posts (escape/closed) can reach it.
trustedParentOrigin = e.origin;
if (e.data.type === 'webterminal-hello') {
return;
} else if (e.data.type === 'webterminal-credentials' && state === 'waiting_credentials') {
currentUser = e.data.user || '';
currentPassword = e.data.password || '';
userFromURL = true;
clearScreen();
attemptConnect();
} else if (e.data.type === 'webterminal-refit' && fitAddon && term) {
// Defer fit until the browser has finished layout after the panel becomes visible,
// otherwise fitAddon may compute a tiny column count from stale dimensions.
requestAnimationFrame(function() {
fitAddon.fit();
if (e.data.focus !== false)
term.focus();
});
}
});
// Start
initTerminal();
if (inIframe && !userFromURL) {
// Embedded without a URL-supplied user: the host page owns the credentials and sends
// `webterminal-credentials` once on iframe `load`. Wait for that message instead of
// connecting on our own - a connection attempt made here can only authenticate as a
// passwordless user, and it would be torn down and replaced by the credentialed one
// a moment later, printing `Connecting...` twice.
state = 'waiting_credentials';
term.write('\x1b[90mWaiting for credentials... (press Enter to log in manually)\x1b[0m');
} else {
// Try to connect automatically using the URL-supplied user (or the server default user)
// with empty password. If the server rejects this, `ws.onclose` falls back to the
// interactive prompt.
attemptConnect();
}
</script>
</body>
</html>