1class P0wnyShell extends HTMLElement { 2 3 cwd = null; 4 commandHistory = []; 5 historyPosition = 0; 6 7 userName = 'p0wny'; 8 hostName = 'shell'; 9 10 backend = ''; 11 12 eRoot = null; 13 eShellCmdInput = null; 14 eShellContent = null; 15 eShellPrompt = null; 16 17 /** 18 * Constructor 19 * 20 * Creates the shadow root and attaches the event listeners 21 */ 22 constructor() { 23 super(); 24 25 // get the base path of the current script 26 const base = document.currentScript.src.substring(0,document.currentScript.src.lastIndexOf('/')+1); 27 28 this.eRoot = this.attachShadow({mode: 'open'}); 29 this.eRoot.innerHTML = ` 30 <link rel="stylesheet" href="${base}P0wnyShell.css" type="text/css" /> 31 <div id="shell"> 32 <pre id="shell-content"> 33 <div id="shell-logo"><div><slot></slot></div></div> 34 </pre> 35 <div id="shell-input"> 36 <label for="shell-cmd" id="shell-prompt" class="shell-prompt">???</label> 37 <div> 38 <input id="shell-cmd" name="cmd" /> 39 </div> 40 </div> 41 </div> 42 `; 43 44 this.eShellCmdInput = this.eRoot.querySelector('#shell-cmd'); 45 this.eShellContent = this.eRoot.querySelector('#shell-content'); 46 this.eShellPrompt = this.eRoot.querySelector('#shell-prompt'); 47 this.eShellCmdInput.addEventListener('keydown', this.onShellCmdKeyDown.bind(this)); 48 49 // click into the shell to focus the input (unless selecting) 50 this.eRoot.addEventListener('click', function (event) { 51 const selection = window.getSelection(); 52 if (!selection.toString()) { 53 this.eShellCmdInput.focus(); 54 } 55 }.bind(this)); 56 } 57 58 /** 59 * Called when the shadow DOM has been attached to the element 60 */ 61 connectedCallback() { 62 this.userName = this.getAttribute('username') || this.userName; 63 this.hostName = this.getAttribute('hostname') || this.hostName; 64 this.backend = this.getAttribute('backend') || this.backend; 65 this.cwd = this.getAttribute('cwd'); 66 67 this.updateCwd(this.cwd); 68 this.eShellCmdInput.focus(); 69 70 // fetch real username and hostname from backend 71 this.makeRequest('?feature=userhost', {cwd: this.cwd}, function (response) { 72 this.userName = response.username ? atob(response.username) : this.userName; 73 this.hostName = response.hostname ? atob(response.hostname) : this.hostName; 74 this.updateCwd(atob(response.cwd)); 75 }.bind(this)); 76 } 77 78 /** 79 * Add a new command to the shown shell 80 * 81 * @param {string} command 82 */ 83 insertCommand(command) { 84 this.eShellContent.innerHTML += "\n\n"; 85 86 const promptSpan = document.createElement('span'); 87 promptSpan.classList.add('shell-prompt'); 88 this.updatePrompt(this.cwd, promptSpan); 89 this.eShellContent.appendChild(promptSpan); 90 91 const commandSpan = document.createElement('span'); 92 commandSpan.textContent = command; 93 this.eShellContent.appendChild(commandSpan); 94 95 this.eShellContent.innerHTML += "\n"; 96 this.eShellContent.scrollTop = this.eShellContent.scrollHeight; 97 } 98 99 /** 100 * Add command output to the shown shell 101 * 102 * @param {string} stdout 103 */ 104 insertStdout(stdout) { 105 const textElem = document.createTextNode(stdout); 106 this.eShellContent.appendChild(textElem); 107 this.eShellContent.scrollTop = this.eShellContent.scrollHeight; 108 } 109 110 /** 111 * Simple method to decouple a given callback from the main thread 112 * 113 * @param {function} callback 114 */ 115 defer(callback) { 116 setTimeout(callback, 0); 117 } 118 119 /** 120 * Handle an entered shell command 121 * 122 * Commands may be executed directly or passed to the backend 123 * 124 * @param command 125 */ 126 featureShell(command) { 127 this.insertCommand(command); 128 129 if (/^\s*upload\s+\S+\s*$/.test(command)) { 130 // pass to upload feature 131 this.featureUpload(command.match(/^\s*upload\s+(\S+)\s*$/)[1]); 132 } else if (/^\s*clear\s*$/.test(command)) { 133 // Backend shell TERM environment variable not set. Clear command history from UI but keep in buffer 134 this.eShellContent.innerHTML = ''; 135 } else { 136 // send to backend 137 this.makeRequest("?feature=shell", {cmd: command, cwd: this.cwd}, function (response) { 138 if (response.hasOwnProperty('file')) { 139 this.featureDownload(atob(response.name), response.file) 140 } else { 141 this.insertStdout(atob(response.stdout)); 142 this.updateCwd(atob(response.cwd)); 143 } 144 }.bind(this)); 145 } 146 } 147 148 /** 149 * Handle tab auto completion 150 */ 151 featureHint() { 152 if (this.eShellCmdInput.value.trim().length === 0) return; // field is empty -> nothing to complete 153 154 155 const currentCmd = this.eShellCmdInput.value.split(" "); 156 const type = (currentCmd.length === 1) ? "cmd" : "file"; 157 const fileName = (type === "cmd") ? currentCmd[0] : currentCmd[currentCmd.length - 1]; 158 159 this.makeRequest( 160 "?feature=hint", 161 { 162 filename: fileName, 163 cwd: this.cwd, 164 type: type 165 }, 166 function (data) { 167 if (data.files.length <= 1) return; // no completion 168 data.files = data.files.map(function (file) { 169 return atob(file); 170 }); 171 if (data.files.length === 2) { 172 if (type === 'cmd') { 173 self.eShellCmdInput.value = data.files[0]; 174 } else { 175 const currentValue = this.eShellCmdInput.value; 176 this.eShellCmdInput.value = currentValue.replace(/(\S*)$/, data.files[0]); 177 } 178 } else { 179 this.insertCommand(this.eShellCmdInput.value); 180 this.insertStdout(data.files.join("\n")); 181 } 182 }.bind(this) 183 ); 184 185 } 186 187 /** 188 * Make the browser download a file using a data URI 189 * 190 * @param {string} name Name of the file to download 191 * @param {string} file Base64 encoded file content 192 */ 193 featureDownload(name, file) { 194 const element = document.createElement('a'); 195 element.setAttribute('href', 'data:application/octet-stream;base64,' + file); 196 element.setAttribute('download', name); 197 element.style.display = 'none'; 198 199 this.eRoot.appendChild(element); 200 element.click(); 201 this.eRoot.removeChild(element); 202 this.insertStdout('Done.'); 203 } 204 205 /** 206 * Upload a file to the server 207 * 208 * @todo use await/async 209 * @param {string} path Path to upload the file to (relative to current working directory) 210 */ 211 featureUpload(path) { 212 const element = document.createElement('input'); 213 element.setAttribute('type', 'file'); 214 element.style.display = 'none'; 215 document.body.appendChild(element); 216 element.addEventListener('change', function () { 217 const promise = this.getBase64(element.files[0]); 218 promise.then( 219 function (file) { 220 this.makeRequest( 221 '?feature=upload', 222 {path: path, file: file, cwd: this.cwd}, 223 function (response) { 224 this.insertStdout(atob(response.stdout)); 225 this.updateCwd(atob(response.cwd)); 226 }.bind(this) 227 ); 228 }.bind(this), 229 function () { 230 this.insertStdout('An unknown client-side error occurred.'); 231 }.bind(this) 232 ); 233 }.bind(this)); 234 element.click(); 235 document.body.removeChild(element); 236 } 237 238 /** 239 * Get the base64 representation of a file 240 * 241 * @todo make async instead of promise 242 * @param file 243 * @returns {Promise<unknown>} 244 */ 245 getBase64(file) { 246 247 return new Promise(function (resolve, reject) { 248 const reader = new FileReader(); 249 reader.onload = function () { 250 resolve(reader.result.match(/base64,(.*)$/)[1]); 251 }; 252 reader.onerror = reject; 253 reader.readAsDataURL(file); 254 }); 255 } 256 257 /** 258 * Update the current working directory 259 * 260 * @param {string|null} cwd 261 */ 262 updateCwd(cwd = null) { 263 if (cwd) { 264 this.cwd = cwd; 265 this.updatePrompt(); 266 return; 267 } 268 this.makeRequest("?feature=pwd", {}, function (response) { 269 this.cwd = atob(response.cwd); 270 this.updatePrompt(); 271 }.bind(this)); 272 } 273 274 /** 275 * Update the prompt 276 * 277 * @param {string|null} cwd The current working directory, defaults to current one 278 * @param {HTMLElement|null} element The element holding the prompt, defaults to input prompt 279 */ 280 updatePrompt(cwd = null, element = null) { 281 cwd = cwd || this.cwd || "~"; 282 element = element || this.eShellPrompt; 283 284 // create a short version of the current working directory 285 let shortCwd = cwd; 286 if (cwd.split("/").length > 3) { 287 const splittedCwd = cwd.split("/"); 288 shortCwd = "…/" + splittedCwd[splittedCwd.length - 2] + "/" + splittedCwd[splittedCwd.length - 1]; 289 } 290 291 // create the prompt elements 292 const userText = document.createTextNode(this.userName + "@" + this.hostName + ":"); 293 const cwdSpan = document.createElement("span"); 294 cwdSpan.title = cwd; 295 cwdSpan.textContent = shortCwd; 296 const promptText = document.createTextNode("# "); 297 298 // clear the prompt and add the elements 299 element.innerHTML = ''; 300 element.appendChild(userText); 301 element.appendChild(cwdSpan); 302 element.appendChild(promptText); 303 } 304 305 /** 306 * Handle keydown event on shell command input 307 * 308 * @param {KeyboardEvent} event 309 */ 310 onShellCmdKeyDown(event) { 311 switch (event.key) { 312 case "Enter": 313 this.featureShell(this.eShellCmdInput.value); 314 this.insertToHistory(this.eShellCmdInput.value); 315 this.eShellCmdInput.value = ""; 316 break; 317 case "ArrowUp": 318 if (this.historyPosition > 0) { 319 this.historyPosition--; 320 this.eShellCmdInput.blur(); 321 this.eShellCmdInput.value = this.commandHistory[this.historyPosition]; 322 this.defer(function () { 323 this.eShellCmdInput.focus(); 324 }.bind(this)); 325 } 326 break; 327 case "ArrowDown": 328 if (this.historyPosition >= this.commandHistory.length) { 329 break; 330 } 331 this.historyPosition++; 332 if (this.historyPosition === this.commandHistory.length) { 333 this.eShellCmdInput.value = ""; 334 } else { 335 this.eShellCmdInput.blur(); 336 this.eShellCmdInput.focus(); 337 this.eShellCmdInput.value = this.commandHistory[this.historyPosition]; 338 } 339 break; 340 case 'Tab': 341 event.preventDefault(); 342 this.featureHint(); 343 break; 344 } 345 } 346 347 /** 348 * Insert command to history 349 * 350 * @param {string} cmd 351 */ 352 insertToHistory(cmd) { 353 this.commandHistory.push(cmd); 354 this.historyPosition = this.commandHistory.length; 355 } 356 357 /** 358 * Send a request to the backend 359 * 360 * @todo use fetch instead of XMLHttpRequest 361 * @param {string} url base url 362 * @param {object} params An object containing the parameters to send 363 * @param {function} callback A callback function to call when the request is done 364 */ 365 makeRequest(url, params, callback) { 366 function getQueryString() { 367 const a = []; 368 for (const key in params) { 369 if (params.hasOwnProperty(key)) { 370 a.push(encodeURIComponent(key) + "=" + encodeURIComponent(params[key])); 371 } 372 } 373 return a.join("&"); 374 } 375 376 const xhr = new XMLHttpRequest(); 377 xhr.open("POST", this.backend + url, true); 378 xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); 379 xhr.onreadystatechange = function () { 380 if (xhr.readyState === 4 && xhr.status === 200) { 381 try { 382 const responseJson = JSON.parse(xhr.responseText); 383 callback(responseJson); 384 } catch (error) { 385 alert("Error while parsing response: " + error); 386 } 387 } 388 }; 389 xhr.send(getQueryString()); 390 } 391 392 393} 394 395window.customElements.define('p0wny-shell', P0wnyShell); 396