main.js (21122B)
1 /*jslint browser:true */ 2 3 var jQuery; 4 var wssh = {}; 5 6 7 (function() { 8 // For FormData without getter and setter 9 var proto = FormData.prototype, 10 data = {}; 11 12 if (!proto.get) { 13 proto.get = function (name) { 14 if (data[name] === undefined) { 15 var input = document.querySelector('input[name="' + name + '"]'), 16 value; 17 if (input) { 18 if (input.type === 'file') { 19 value = input.files[0]; 20 } else { 21 value = input.value; 22 } 23 data[name] = value; 24 } 25 } 26 return data[name]; 27 }; 28 } 29 30 if (!proto.set) { 31 proto.set = function (name, value) { 32 data[name] = value; 33 }; 34 } 35 }()); 36 37 38 jQuery(function($){ 39 var status = $('#status'), 40 button = $('.btn-primary'), 41 form_container = $('.form-container'), 42 waiter = $('#waiter'), 43 term_type = $('#term'), 44 style = {}, 45 default_title = 'WebSSH', 46 title_element = document.querySelector('title'), 47 form_id = '#connect', 48 debug = document.querySelector(form_id).noValidate, 49 custom_font = document.fonts ? document.fonts.values().next().value : undefined, 50 default_fonts, 51 DISCONNECTED = 0, 52 CONNECTING = 1, 53 CONNECTED = 2, 54 state = DISCONNECTED, 55 messages = {1: 'This client is connecting ...', 2: 'This client is already connnected.'}, 56 key_max_size = 16384, 57 fields = ['hostname', 'port', 'username'], 58 form_keys = fields.concat(['password', 'totp']), 59 opts_keys = ['bgcolor', 'title', 'encoding', 'command', 'term', 'fontsize', 'fontcolor'], 60 url_form_data = {}, 61 url_opts_data = {}, 62 validated_form_data, 63 event_origin, 64 hostname_tester = /((^\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\s*$)|(^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$))|(^\s*((?=.{1,255}$)(?=.*[A-Za-z].*)[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?)*)\s*$)/; 65 66 67 function store_items(names, data) { 68 var i, name, value; 69 70 for (i = 0; i < names.length; i++) { 71 name = names[i]; 72 value = data.get(name); 73 if (value){ 74 window.localStorage.setItem(name, value); 75 } 76 } 77 } 78 79 80 function restore_items(names) { 81 var i, name, value; 82 83 for (i=0; i < names.length; i++) { 84 name = names[i]; 85 value = window.localStorage.getItem(name); 86 if (value) { 87 $('#'+name).val(value); 88 } 89 } 90 } 91 92 93 function populate_form(data) { 94 var names = form_keys.concat(['passphrase']), 95 i, name; 96 97 for (i=0; i < names.length; i++) { 98 name = names[i]; 99 $('#'+name).val(data.get(name)); 100 } 101 } 102 103 104 function get_object_length(object) { 105 return Object.keys(object).length; 106 } 107 108 109 function decode_uri(uri) { 110 try { 111 return decodeURI(uri); 112 } catch(e) { 113 console.error(e); 114 } 115 return ''; 116 } 117 118 119 function decode_password(encoded) { 120 try { 121 return window.atob(encoded); 122 } catch (e) { 123 console.error(e); 124 } 125 return null; 126 } 127 128 129 function parse_url_data(string, form_keys, opts_keys, form_map, opts_map) { 130 var i, pair, key, val, 131 arr = string.split('&'); 132 133 for (i = 0; i < arr.length; i++) { 134 pair = arr[i].split('='); 135 key = pair[0].trim().toLowerCase(); 136 val = pair.slice(1).join('=').trim(); 137 138 if (form_keys.indexOf(key) >= 0) { 139 form_map[key] = val; 140 } else if (opts_keys.indexOf(key) >=0) { 141 opts_map[key] = val; 142 } 143 } 144 145 if (form_map.password) { 146 form_map.password = decode_password(form_map.password); 147 } 148 } 149 150 151 function parse_xterm_style() { 152 var text = $('.xterm-helpers style').text(); 153 var arr = text.split('xterm-normal-char{width:'); 154 style.width = parseFloat(arr[1]); 155 arr = text.split('div{height:'); 156 style.height = parseFloat(arr[1]); 157 } 158 159 160 function get_cell_size(term) { 161 style.width = term._core._renderService._renderer.dimensions.actualCellWidth; 162 style.height = term._core._renderService._renderer.dimensions.actualCellHeight; 163 } 164 165 166 function toggle_fullscreen(term) { 167 $('#terminal .terminal').toggleClass('fullscreen'); 168 term.fitAddon.fit(); 169 } 170 171 172 function current_geometry(term) { 173 if (!style.width || !style.height) { 174 try { 175 get_cell_size(term); 176 } catch (TypeError) { 177 parse_xterm_style(); 178 } 179 } 180 181 var cols = parseInt(window.innerWidth / style.width, 10) - 1; 182 var rows = parseInt(window.innerHeight / style.height, 10); 183 return {'cols': cols, 'rows': rows}; 184 } 185 186 187 function resize_terminal(term) { 188 var geometry = current_geometry(term); 189 term.on_resize(geometry.cols, geometry.rows); 190 } 191 192 193 function set_backgound_color(term, color) { 194 term.setOption('theme', { 195 background: color 196 }); 197 } 198 199 function set_font_color(term, color) { 200 term.setOption('theme', { 201 foreground: color 202 }); 203 } 204 205 function custom_font_is_loaded() { 206 if (!custom_font) { 207 console.log('No custom font specified.'); 208 } else { 209 console.log('Status of custom font ' + custom_font.family + ': ' + custom_font.status); 210 if (custom_font.status === 'loaded') { 211 return true; 212 } 213 if (custom_font.status === 'unloaded') { 214 return false; 215 } 216 } 217 } 218 219 function update_font_family(term) { 220 if (term.font_family_updated) { 221 console.log('Already using custom font family'); 222 return; 223 } 224 225 if (!default_fonts) { 226 default_fonts = term.getOption('fontFamily'); 227 } 228 229 if (custom_font_is_loaded()) { 230 var new_fonts = custom_font.family + ', ' + default_fonts; 231 term.setOption('fontFamily', new_fonts); 232 term.font_family_updated = true; 233 console.log('Using custom font family ' + new_fonts); 234 } 235 } 236 237 238 function reset_font_family(term) { 239 if (!term.font_family_updated) { 240 console.log('Already using default font family'); 241 return; 242 } 243 244 if (default_fonts) { 245 term.setOption('fontFamily', default_fonts); 246 term.font_family_updated = false; 247 console.log('Using default font family ' + default_fonts); 248 } 249 } 250 251 252 function format_geometry(cols, rows) { 253 return JSON.stringify({'cols': cols, 'rows': rows}); 254 } 255 256 257 function read_as_text_with_decoder(file, callback, decoder) { 258 var reader = new window.FileReader(); 259 260 if (decoder === undefined) { 261 decoder = new window.TextDecoder('utf-8', {'fatal': true}); 262 } 263 264 reader.onload = function() { 265 var text; 266 try { 267 text = decoder.decode(reader.result); 268 } catch (TypeError) { 269 console.log('Decoding error happened.'); 270 } finally { 271 if (callback) { 272 callback(text); 273 } 274 } 275 }; 276 277 reader.onerror = function (e) { 278 console.error(e); 279 }; 280 281 reader.readAsArrayBuffer(file); 282 } 283 284 285 function read_as_text_with_encoding(file, callback, encoding) { 286 var reader = new window.FileReader(); 287 288 if (encoding === undefined) { 289 encoding = 'utf-8'; 290 } 291 292 reader.onload = function() { 293 if (callback) { 294 callback(reader.result); 295 } 296 }; 297 298 reader.onerror = function (e) { 299 console.error(e); 300 }; 301 302 reader.readAsText(file, encoding); 303 } 304 305 306 function read_file_as_text(file, callback, decoder) { 307 if (!window.TextDecoder) { 308 read_as_text_with_encoding(file, callback, decoder); 309 } else { 310 read_as_text_with_decoder(file, callback, decoder); 311 } 312 } 313 314 315 function reset_wssh() { 316 var name; 317 318 for (name in wssh) { 319 if (wssh.hasOwnProperty(name) && name !== 'connect') { 320 delete wssh[name]; 321 } 322 } 323 } 324 325 326 function log_status(text, to_populate) { 327 console.log(text); 328 status.html(text.split('\n').join('<br/>')); 329 330 if (to_populate && validated_form_data) { 331 populate_form(validated_form_data); 332 validated_form_data = undefined; 333 } 334 335 if (waiter.css('display') !== 'none') { 336 waiter.hide(); 337 } 338 339 if (form_container.css('display') === 'none') { 340 form_container.show(); 341 } 342 } 343 344 345 function ajax_complete_callback(resp) { 346 button.prop('disabled', false); 347 348 if (resp.status !== 200) { 349 log_status(resp.status + ': ' + resp.statusText, true); 350 state = DISCONNECTED; 351 return; 352 } 353 354 var msg = resp.responseJSON; 355 if (!msg.id) { 356 log_status(msg.status, true); 357 state = DISCONNECTED; 358 return; 359 } 360 361 var ws_url = window.location.href.split(/\?|#/, 1)[0].replace('http', 'ws'), 362 join = (ws_url[ws_url.length-1] === '/' ? '' : '/'), 363 url = ws_url + join + 'ws?id=' + msg.id, 364 sock = new window.WebSocket(url), 365 encoding = 'utf-8', 366 decoder = window.TextDecoder ? new window.TextDecoder(encoding) : encoding, 367 terminal = document.getElementById('terminal'), 368 termOptions = { 369 cursorBlink: true, 370 theme: { 371 background: url_opts_data.bgcolor || 'black', 372 foreground: url_opts_data.fontcolor || 'white' 373 } 374 }; 375 376 if (url_opts_data.fontsize) { 377 var fontsize = window.parseInt(url_opts_data.fontsize); 378 if (fontsize && fontsize > 0) { 379 termOptions.fontSize = fontsize; 380 } 381 } 382 383 var term = new window.Terminal(termOptions); 384 385 term.fitAddon = new window.FitAddon.FitAddon(); 386 term.loadAddon(term.fitAddon); 387 388 console.log(url); 389 if (!msg.encoding) { 390 console.log('Unable to detect the default encoding of your server'); 391 msg.encoding = encoding; 392 } else { 393 console.log('The deault encoding of your server is ' + msg.encoding); 394 } 395 396 function term_write(text) { 397 if (term) { 398 term.write(text); 399 if (!term.resized) { 400 resize_terminal(term); 401 term.resized = true; 402 } 403 } 404 } 405 406 function set_encoding(new_encoding) { 407 // for console use 408 if (!new_encoding) { 409 console.log('An encoding is required'); 410 return; 411 } 412 413 if (!window.TextDecoder) { 414 decoder = new_encoding; 415 encoding = decoder; 416 console.log('Set encoding to ' + encoding); 417 } else { 418 try { 419 decoder = new window.TextDecoder(new_encoding); 420 encoding = decoder.encoding; 421 console.log('Set encoding to ' + encoding); 422 } catch (RangeError) { 423 console.log('Unknown encoding ' + new_encoding); 424 return false; 425 } 426 } 427 } 428 429 wssh.set_encoding = set_encoding; 430 431 if (url_opts_data.encoding) { 432 if (set_encoding(url_opts_data.encoding) === false) { 433 set_encoding(msg.encoding); 434 } 435 } else { 436 set_encoding(msg.encoding); 437 } 438 439 440 wssh.geometry = function() { 441 // for console use 442 var geometry = current_geometry(term); 443 console.log('Current window geometry: ' + JSON.stringify(geometry)); 444 }; 445 446 wssh.send = function(data) { 447 // for console use 448 if (!sock) { 449 console.log('Websocket was already closed'); 450 return; 451 } 452 453 if (typeof data !== 'string') { 454 console.log('Only string is allowed'); 455 return; 456 } 457 458 try { 459 JSON.parse(data); 460 sock.send(data); 461 } catch (SyntaxError) { 462 data = data.trim() + '\r'; 463 sock.send(JSON.stringify({'data': data})); 464 } 465 }; 466 467 wssh.reset_encoding = function() { 468 // for console use 469 if (encoding === msg.encoding) { 470 console.log('Already reset to ' + msg.encoding); 471 } else { 472 set_encoding(msg.encoding); 473 } 474 }; 475 476 wssh.resize = function(cols, rows) { 477 // for console use 478 if (term === undefined) { 479 console.log('Terminal was already destroryed'); 480 return; 481 } 482 483 var valid_args = false; 484 485 if (cols > 0 && rows > 0) { 486 var geometry = current_geometry(term); 487 if (cols <= geometry.cols && rows <= geometry.rows) { 488 valid_args = true; 489 } 490 } 491 492 if (!valid_args) { 493 console.log('Unable to resize terminal to geometry: ' + format_geometry(cols, rows)); 494 } else { 495 term.on_resize(cols, rows); 496 } 497 }; 498 499 wssh.set_bgcolor = function(color) { 500 set_backgound_color(term, color); 501 }; 502 503 wssh.set_fontcolor = function(color) { 504 set_font_color(term, color); 505 }; 506 507 wssh.custom_font = function() { 508 update_font_family(term); 509 }; 510 511 wssh.default_font = function() { 512 reset_font_family(term); 513 }; 514 515 term.on_resize = function(cols, rows) { 516 if (cols !== this.cols || rows !== this.rows) { 517 console.log('Resizing terminal to geometry: ' + format_geometry(cols, rows)); 518 this.resize(cols, rows); 519 sock.send(JSON.stringify({'resize': [cols, rows]})); 520 } 521 }; 522 523 term.onData(function(data) { 524 // console.log(data); 525 sock.send(JSON.stringify({'data': data})); 526 }); 527 528 sock.onopen = function() { 529 term.open(terminal); 530 toggle_fullscreen(term); 531 update_font_family(term); 532 term.focus(); 533 state = CONNECTED; 534 title_element.text = url_opts_data.title || default_title; 535 if (url_opts_data.command) { 536 setTimeout(function () { 537 sock.send(JSON.stringify({'data': url_opts_data.command+'\r'})); 538 }, 500); 539 } 540 }; 541 542 sock.onmessage = function(msg) { 543 read_file_as_text(msg.data, term_write, decoder); 544 }; 545 546 sock.onerror = function(e) { 547 console.error(e); 548 }; 549 550 sock.onclose = function(e) { 551 term.dispose(); 552 term = undefined; 553 sock = undefined; 554 reset_wssh(); 555 log_status(e.reason, true); 556 state = DISCONNECTED; 557 default_title = 'WebSSH'; 558 title_element.text = default_title; 559 }; 560 561 $(window).resize(function(){ 562 if (term) { 563 resize_terminal(term); 564 } 565 }); 566 } 567 568 569 function wrap_object(opts) { 570 var obj = {}; 571 572 obj.get = function(attr) { 573 return opts[attr] || ''; 574 }; 575 576 obj.set = function(attr, val) { 577 opts[attr] = val; 578 }; 579 580 return obj; 581 } 582 583 584 function clean_data(data) { 585 var i, attr, val; 586 var attrs = form_keys.concat(['privatekey', 'passphrase']); 587 588 for (i = 0; i < attrs.length; i++) { 589 attr = attrs[i]; 590 val = data.get(attr); 591 if (typeof val === 'string') { 592 data.set(attr, val.trim()); 593 } 594 } 595 } 596 597 598 function validate_form_data(data) { 599 clean_data(data); 600 601 var hostname = data.get('hostname'), 602 port = data.get('port'), 603 username = data.get('username'), 604 pk = data.get('privatekey'), 605 result = { 606 valid: false, 607 data: data, 608 title: '' 609 }, 610 errors = [], size; 611 612 if (!hostname) { 613 errors.push('Value of hostname is required.'); 614 } else { 615 if (!hostname_tester.test(hostname)) { 616 errors.push('Invalid hostname: ' + hostname); 617 } 618 } 619 620 if (!port) { 621 port = 22; 622 } else { 623 if (!(port > 0 && port < 65535)) { 624 errors.push('Invalid port: ' + port); 625 } 626 } 627 628 if (!username) { 629 errors.push('Value of username is required.'); 630 } 631 632 if (pk) { 633 size = pk.size || pk.length; 634 if (size > key_max_size) { 635 errors.push('Invalid private key: ' + pk.name || ''); 636 } 637 } 638 639 if (!errors.length || debug) { 640 result.valid = true; 641 result.title = username + '@' + hostname + ':' + port; 642 } 643 result.errors = errors; 644 645 return result; 646 } 647 648 // Fix empty input file ajax submission error for safari 11.x 649 function disable_file_inputs(inputs) { 650 var i, input; 651 652 for (i = 0; i < inputs.length; i++) { 653 input = inputs[i]; 654 if (input.files.length === 0) { 655 input.setAttribute('disabled', ''); 656 } 657 } 658 } 659 660 661 function enable_file_inputs(inputs) { 662 var i; 663 664 for (i = 0; i < inputs.length; i++) { 665 inputs[i].removeAttribute('disabled'); 666 } 667 } 668 669 670 function connect_without_options() { 671 // use data from the form 672 var form = document.querySelector(form_id), 673 inputs = form.querySelectorAll('input[type="file"]'), 674 url = form.action, 675 data, pk; 676 677 disable_file_inputs(inputs); 678 data = new FormData(form); 679 pk = data.get('privatekey'); 680 enable_file_inputs(inputs); 681 682 function ajax_post() { 683 status.text(''); 684 button.prop('disabled', true); 685 686 $.ajax({ 687 url: url, 688 type: 'post', 689 data: data, 690 complete: ajax_complete_callback, 691 cache: false, 692 contentType: false, 693 processData: false 694 }); 695 } 696 697 var result = validate_form_data(data); 698 if (!result.valid) { 699 log_status(result.errors.join('\n')); 700 return; 701 } 702 703 if (pk && pk.size && !debug) { 704 read_file_as_text(pk, function(text) { 705 if (text === undefined) { 706 log_status('Invalid private key: ' + pk.name); 707 } else { 708 ajax_post(); 709 } 710 }); 711 } else { 712 ajax_post(); 713 } 714 715 return result; 716 } 717 718 719 function connect_with_options(data) { 720 // use data from the arguments 721 var form = document.querySelector(form_id), 722 url = data.url || form.action, 723 _xsrf = form.querySelector('input[name="_xsrf"]'); 724 725 var result = validate_form_data(wrap_object(data)); 726 if (!result.valid) { 727 log_status(result.errors.join('\n')); 728 return; 729 } 730 731 data.term = term_type.val(); 732 data._xsrf = _xsrf.value; 733 if (event_origin) { 734 data._origin = event_origin; 735 } 736 737 status.text(''); 738 button.prop('disabled', true); 739 740 $.ajax({ 741 url: url, 742 type: 'post', 743 data: data, 744 complete: ajax_complete_callback 745 }); 746 747 return result; 748 } 749 750 751 function connect(hostname, port, username, password, privatekey, passphrase, totp) { 752 // for console use 753 var result, opts; 754 755 if (state !== DISCONNECTED) { 756 console.log(messages[state]); 757 return; 758 } 759 760 if (hostname === undefined) { 761 result = connect_without_options(); 762 } else { 763 if (typeof hostname === 'string') { 764 opts = { 765 hostname: hostname, 766 port: port, 767 username: username, 768 password: password, 769 privatekey: privatekey, 770 passphrase: passphrase, 771 totp: totp 772 }; 773 } else { 774 opts = hostname; 775 } 776 777 result = connect_with_options(opts); 778 } 779 780 if (result) { 781 state = CONNECTING; 782 default_title = result.title; 783 if (hostname) { 784 validated_form_data = result.data; 785 } 786 store_items(fields, result.data); 787 } 788 } 789 790 wssh.connect = connect; 791 792 $(form_id).submit(function(event){ 793 event.preventDefault(); 794 connect(); 795 }); 796 797 798 function cross_origin_connect(event) 799 { 800 console.log(event.origin); 801 var prop = 'connect', 802 args; 803 804 try { 805 args = JSON.parse(event.data); 806 } catch (SyntaxError) { 807 args = event.data.split('|'); 808 } 809 810 if (!Array.isArray(args)) { 811 args = [args]; 812 } 813 814 try { 815 event_origin = event.origin; 816 wssh[prop].apply(wssh, args); 817 } finally { 818 event_origin = undefined; 819 } 820 } 821 822 window.addEventListener('message', cross_origin_connect, false); 823 824 if (document.fonts) { 825 document.fonts.ready.then( 826 function () { 827 if (custom_font_is_loaded() === false) { 828 document.body.style.fontFamily = custom_font.family; 829 } 830 } 831 ); 832 } 833 834 835 parse_url_data( 836 decode_uri(window.location.search.substring(1)) + '&' + decode_uri(window.location.hash.substring(1)), 837 form_keys, opts_keys, url_form_data, url_opts_data 838 ); 839 // console.log(url_form_data); 840 // console.log(url_opts_data); 841 842 if (url_opts_data.term) { 843 term_type.val(url_opts_data.term); 844 } 845 846 if (url_form_data.password === null) { 847 log_status('Password via url must be encoded in base64.'); 848 } else { 849 if (get_object_length(url_form_data)) { 850 waiter.show(); 851 connect(url_form_data); 852 } else { 853 restore_items(fields); 854 form_container.show(); 855 } 856 } 857 858 });