#!/usr/local/bin/perl
# Show a terminal that is connected to a Websockets server via Webmin proxying

use strict;
use warnings;
no warnings 'uninitialized';
our $unsafe_index_cgi = 1;
require './xterm-lib.pl';    ## no critic
our (%in, %text, %config, %gconfig, %access, %module_info,
     $module_name, $module_config_directory, $module_var_directory,
     $remote_user, $session_id);
ReadParse();

# Check for needed modules
my @modload = (
	['Digest::SHA',            sub { eval { require Digest::SHA;            Digest::SHA->import;            1 } }],
	['Digest::MD5',            sub { eval { require Digest::MD5;            Digest::MD5->import;            1 } }],
	['IO::Select',             sub { eval { require IO::Select;             IO::Select->import;             1 } }],
	['Time::HiRes',            sub { eval { require Time::HiRes;            Time::HiRes->import;            1 } }],
	['Net::WebSocket::Server', sub { eval { require Net::WebSocket::Server; Net::WebSocket::Server->import; 1 } }],
	);
foreach my $m (@modload) {
	my ($modname, $loader) = @$m;
	next if ($loader->());
	ui_print_header(undef, $text{'index_title'}, "", undef, 1, 1, 0);
	my $missinglink = text('index_cpan', "<tt>$modname</tt>",
		    "../cpan/download.cgi?source=3&cpan=$modname&mode=2&return=/$module_name/&returndesc=".urlize($module_info{'desc'}));
	if ($gconfig{'os_type'} eq 'redhat-linux') {
		$missinglink .= " ".
			text('index_epel',
				'https://docs.fedoraproject.org/en-US/epel');
		}
	elsif ($gconfig{'os_type'} eq 'suse-linux') {
		$missinglink =
			text('index_suse', "<tt>$modname</tt>",
				'https://software.opensuse.org/download/package?package=perl-IO-Tty&project=devel%3Alanguages%3Aperl');
		}
	if (get_product_name() eq 'usermin') {
		print text('index_missing', $modname) ."<p>\n";
		}
	else {
		print $missinglink ."<p>\n";
		}
	ui_print_footer("/", $text{'index'});
	exit;
	}

# Get Webmin current version for links serial
my $wver = get_webmin_version();
$wver =~ s/\.//;

# Build Xterm dependency links
my $termlinks = 
	{ 'css' => ["xterm.css?$wver"],
	  'js'  => ["xterm.js?$wver",
	            "xterm-addon-attach.js?$wver",
	            "xterm-addon-fit.js?$wver",
	            "xterm-addon-webgl.js?$wver"] };

# Pre-process options
my $conf_size_str = $config{'size'};
my $def_cols_n = 80;
my $def_rows_n = 24;
my $xmlhr = ($ENV{'HTTP_X_REQUESTED_WITH'} || '') eq "XMLHttpRequest";
my $font_size = $config{'fontsize'} || 14;

# Parse module config
my ($conf_cols_n, $conf_rows_n) = (($conf_size_str || '') =~ /([\d]+)X([\d]+)/i);
$conf_cols_n = int($conf_cols_n);
$conf_rows_n = int($conf_rows_n);

# Set columns and rows vars
my $env_cols = $conf_cols_n || $def_cols_n;
my $env_rows = $conf_rows_n || $def_rows_n;

# Set columns and rows environment vars only
# in fixed mode, and only for old themes
if ($conf_cols_n && $conf_rows_n && !$xmlhr) {
	$ENV{'COLUMNS'} = $conf_cols_n;
	$ENV{'LINES'} = $conf_rows_n;
	}

# Define columns and rows
my $conf_screen_reader = $config{'screen_reader'} eq 'true' ? 'true' : 'false';
my %term_opts;
$term_opts{'Options'} = "{ cols: $env_cols, rows: $env_rows, ".
			    "screenReaderMode: $conf_screen_reader, ".
			    "overviewRuler: { width: 9 }, ".
			    "fontSize: $font_size }";

my $term_size = "
	min-width: ".($conf_cols_n ? "".($conf_cols_n * 9)."px" : "calc(100vw - 22px)").";
	max-width: ".($conf_cols_n ? "".($conf_cols_n * 9)."px" : "calc(100vw - 22px)").";
	min-height: ".($conf_rows_n ? "".($conf_rows_n * 18)."px" : "calc(100vh - 55px)").";
	max-height: ".($conf_rows_n ? "".($conf_rows_n * 18)."px" : "calc(100vh - 55px)").";";

# Tweak old themes inline
my $styles_inline = <<EOF;

body[style='height:100%'] {
	height: 97% !important; 
}
#headln2l a {
	white-space: nowrap;
}
#terminal {
	border: 1px solid #000;
	background-color: #000;
	padding: 2px;
	margin: 0 auto;
	$term_size
}
#terminal:empty:before {
    display: block;
    content: " ";
    overflow: hidden;
    
    width: 12px;
    height: 12px;
    
    margin-top: 4px;
    margin-left: 4px;
    
    border-radius: 50%;
    
    box-sizing: border-box;

    border: 1px solid transparent;
    border-top-color: rgba(255, 255, 255, 0.8);
    border-bottom-color: rgba(255, 255, 255, 0.8);
    animation: jumping-spinner 1s ease infinite;
}

#terminal:empty:after {

    display: block;
    content: attr(data-label);
    margin-left: 24px;
    margin-top: -16px;
    font-weight: 100;
    color: rgba(255, 255, 255, 0.8);
    font-family: "Lucida Console", Courier, monospace;
    font-size: 14px;
    text-transform: uppercase;
}
\@keyframes jumping-spinner {
    to {
        transform: rotate(360deg);
    }
}
#terminal + script ~ * {
	display: none
}
#terminal > .terminal {
	visibility: hidden;
	animation: .15s fadeIn;
	animation-fill-mode: forwards;
}
\@keyframes fadeIn {
  99% {
    visibility: hidden;
  }
  100% {
    visibility: visible;
  }
}

EOF

# Print header
ui_print_header(undef, $text{'index_title'}, "", undef, 1, 1, 0, undef,
		 "<link rel=stylesheet href=\"$termlinks->{'css'}[0]\">\n".
		 "<script src=\"$termlinks->{'js'}[0]\"></script>\n".
		 "<script src=\"$termlinks->{'js'}[1]\"></script>\n".
		 "<script src=\"$termlinks->{'js'}[2]\"></script>\n".
		 "<style>$styles_inline</style>\n"
		);

# Print main container
print "<div data-label=\"$text{'index_connecting'}\" id=\"terminal\"></div>\n";

# Get a free port that can be used for the socket. Normal browser sessions
# are revalidated by miniserv while the websocket stays open. Proxied or
# Basic-auth requests may not have a session cookie, so only those routes get
# a one-time backend handshake secret.
my $websocket_session_id = $session_id;
my $backend_session;
if (!$websocket_session_id) {
	$websocket_session_id = generate_miniserv_websocket_token();
	$backend_session = $websocket_session_id;
	}
my $port = allocate_miniserv_websocket(
	$module_name, undef, $backend_session);

# Decide which Unix account the terminal will run as
my $user = resolve_shell_user(\%access, $remote_user, \%in, \%config);
my @uinfo = getpwnam($user);
@uinfo || error(text('index_euser', html_escape($user)));

# Check for directory to start the shell in
my $dir = $in{'dir'};

# Launch the shell server on the allocated port
my $shellserver_cmd = "$module_config_directory/shellserver.pl";
if (!-r $shellserver_cmd) {
	create_wrapper($shellserver_cmd, $module_name, "shellserver.pl");
	}
# shellserver.pl validates the backend websocket key against SESSION_ID. For
# normal sessions miniserv forwards the browser session; no-cookie routes use
# the one-time backend_session stored in miniserv.conf above.
$ENV{'SESSION_ID'} = $websocket_session_id;
system_logged($shellserver_cmd." ".quotemeta($port)." ".quotemeta($user).
	       ($dir ? " ".quotemeta($dir) : "").
	       " >$module_var_directory/websocket-connection-$port.out 2>&1 </dev/null");

# Open the terminal
my $url = get_miniserv_websocket_url($port, $config{'host'}, $module_name);
my $webGLAddon = $termlinks->{'js'}[3];
my $term_script = <<EOF;

(function() {
	const socket = new WebSocket('$url', 'binary'),
	      termcont = document.getElementById('terminal'),
	      err_conn_cannot = 'Cannot connect to the socket ' + socket.url,
	      err_conn_lost = 'Connection to the socket ' + socket.url + ' lost',
	      webGLAddonLink = '$webGLAddon',
	      detectWebGLContext = (function() {
	          const canvas = document.createElement("canvas"),
	                gl = canvas.getContext("webgl") ||
	                     canvas.getContext("experimental-webgl");
	          return gl instanceof WebGLRenderingContext ? true : false;
	      })();
	socket.onopen = function() {
		const term = new Terminal($term_opts{'Options'}),
		      attachAddon = new AttachAddon.AttachAddon(this),
		      fitAddon = new FitAddon.FitAddon(),
		      renderScript = document.createElement('script');

	  renderScript.src = webGLAddonLink;
	  renderScript.async = false;
	  document.body.appendChild(renderScript);

	  // Wait to load requested render addon
	  renderScript.addEventListener('load', function() {
	      let rendererAddon;

	      if (detectWebGLContext && typeof WebglAddon === 'object') {
	          rendererAddon = new WebglAddon.WebglAddon();
	          term.loadAddon(rendererAddon);

	          // Handle case of dropping WebGL context
	          if (rendererAddon.onContextLoss) {
	              rendererAddon.onContextLoss(function() {
	                  rendererAddon.dispose();
	              });
	          }
	      }

	      term.loadAddon(attachAddon);
	      term.loadAddon(fitAddon);
	      term.open(termcont);
	      setTimeout(function() { term.focus() }, 6e2);

	      // On resize event triggered by fit()
	      term.onResize(function(e) {
	          socket.send('\\\\033[8;(' + e.rows + ');(' + e.cols + ')t');
	      });

	      // Observe on terminal container change
	      new ResizeObserver(function() {
	          fitAddon.fit();
	      }).observe(termcont);
	  });
	};
	socket.onerror = function() {
		termcont.innerHTML = '<tt style="color: \#ff0000">Error: ' +
			err_conn_cannot + '</tt>';
	};
	socket.onclose = function() {
		termcont.innerHTML = '<tt style="color: \#ff0000">Error: ' +
			err_conn_lost + '</tt>';
	};
})();

EOF

# Return inline script data depending on type
print "<script>\n";
if ($xmlhr) {
	print "var xterm_argv = ".
          convert_to_json(
            { 'conf'  => \%config,
              'files' => $termlinks,
              'socket_url' => $url,
              'port'  => $port,
              'cols'  => $env_cols,
              'rows'  => $env_rows,
              'uinfo'  => \@uinfo });
	}
else {
	print $term_script;
	}
print "</script>\n";
ui_print_footer();
