Files
datastore/programs/server/webterminal.html
T
2026-05-19 20:24:10 +00:00

409 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;
height: 100vh;
width: 100vw;
}
#terminal-container {
height: 100%;
width: 100%;
}
/* Dark themed scrollbar */
.xterm-viewport::-webkit-scrollbar {
width: 8px;
}
.xterm-viewport::-webkit-scrollbar-track {
background: #1e1e1e;
}
.xterm-viewport::-webkit-scrollbar-thumb {
background: #555;
border-radius: 4px;
}
.xterm-viewport::-webkit-scrollbar-thumb:hover {
background: #777;
}
.xterm-viewport {
scrollbar-width: thin;
scrollbar-color: #555 #1e1e1e;
}
</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', '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);
var waitingForCredentials = false;
// First connection attempt uses the URL-supplied user (or `default`) with empty password.
// On failure we fall back to the interactive prompt or postMessage-supplied credentials.
var initialAutoAuth = true;
// 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;
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') {
if (trustedParentOrigin) {
window.parent.postMessage({type: 'webterminal-escape'}, trustedParentOrigin);
}
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 === '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
}
}
}
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');
if (initialAutoAuth) {
initialAutoAuth = false;
if (inIframe && !userFromURL) {
// Embedded without URL credentials: defer to parent via postMessage.
waitingForCredentials = true;
return;
}
}
promptCredentials();
} else {
// Normal disconnect after session was active
if (window.parent !== window) {
// Inside an iframe - notify parent to deactivate terminal
if (trustedParentOrigin) {
window.parent.postMessage({type: 'webterminal-closed'}, trustedParentOrigin);
}
} 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' && waitingForCredentials) {
waitingForCredentials = false;
// Parent-supplied credentials are explicit; subsequent failures should prompt
// interactively rather than wait for another (one-shot) parent message.
initialAutoAuth = false;
currentUser = e.data.user || '';
currentPassword = e.data.password || '';
userFromURL = true;
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();
term.focus();
});
}
});
// Start
initTerminal();
if (inIframe && !userFromURL) {
// The parent (e.g. `play.html`) sends `webterminal-credentials` once on iframe `load`.
// Pre-arm the receiver so that message isn't dropped if it arrives before or during
// the initial auto-auth attempt; the in-flight connection is replaced when the message
// is processed.
waitingForCredentials = true;
}
// Try to connect automatically using the URL-supplied user (or `default`) with empty password.
// If the server rejects this, `ws.onclose` falls back to the interactive prompt or, when
// embedded in an iframe without a URL-supplied user, waits for credentials via postMessage.
attemptConnect();
</script>
</body>
</html>