#!/usr/local/bin/perl
# Display GRUB 2 boot menu and configuration status.

use strict;
use warnings;
require './grub2-lib.pl';    ## no critic

our (%in, %text, %access);
our $module_name;
our $grub2_formno = 0;

&ReadParse();
&error_setup($text{'acl_ecannot'});
%access = &get_module_acl();
&error("$text{'eacl_np'} $text{'eacl_pview'}")
	if (!&can_use_index(\%access));

# Show configuration/install guidance before rendering module actions.
if (!&grub2_any_installed()) {
	&ui_print_header(&grub2_version_text() || "", $text{'index_title'},
			 "", undef, 1, 1);
	print &ui_alert($text{'index_missing'}, 'warning');
	if ($access{'view'}) {
		# Install issue details include discovered paths and commands.
		foreach my $issue (&grub2_install_issues()) {
			print &ui_div(&text('index_missing_detail',
					    &ui_tag('tt', &html_escape($issue))));
			}
		}
	print &ui_p(&ui_link("@{[&get_webprefix()]}/config.cgi?$module_name",
			     $text{'index_config_link'}));
	if ($access{'install'} && &foreign_available("software")) {
		# Offer package installation only to users allowed to install GRUB.
		&foreign_require("software", "software-lib.pl");
		my $lnk = &software::missing_install_link(
			"grub2-common", $text{'index_install_pkg'},
			"../$module_name/", $text{'index_title'});
		print &ui_p($lnk) if ($lnk);
		}
	&ui_print_footer("/", $text{'index_return'});
	exit;
	}

&ui_print_header(&grub2_version_text() || "", $text{'index_title'}, "",
		 undef, 1, 1, undef, &grub2_action_links(\%access));

if ($access{'view'}) {
	foreach my $warning (&grub2_status_warnings()) {
		print &ui_alert($warning, 'warning');
		}

	# Only the two entry lists are tabs; global settings live in separate pages.
	my @tabs = (
		[ 'entries', $text{'index_entries_tab'} ],
		[ 'custom', $text{'index_custom_tab'} ],
	);
	my %valid = map { $_->[0] => 1 } @tabs;
	my $requested = defined($in{'mode'}) ? $in{'mode'} : '';
	my $mode = $requested && $valid{$requested} ? $requested : 'entries';
	print &ui_tabs_start(\@tabs, "mode", $mode, 1);

	print &ui_tabs_start_tab("mode", "entries");
	&print_entries_tab(\%access);
	print &ui_tabs_end_tab("mode", "entries");

	print &ui_tabs_start_tab("mode", "custom");
	&print_custom_tab(\%access);
	print &ui_tabs_end_tab("mode", "custom");

	print &ui_tabs_end();
	}

&print_action_buttons(\%access);
&ui_print_footer("/", $text{'index_return'});

# can_use_index(&access)
# Returns true if the index can show entry data or a global action.
sub can_use_index
{
my ($access) = @_;
return 1 if ($access->{'view'});
return 1 if ($access->{'edit'} || $access->{'security'} ||
	     $access->{'manual'} || $access->{'install'});
return 1 if ($access->{'apply'} && &grub2_command('mkconfig_cmd'));
return 0;
}

# print_entries_tab(&access)
# Outputs generated boot menu entries and selected-entry runtime actions.
sub print_entries_tab
{
my ($access) = @_;
my @entries = &grub2_boot_entries();
my $parsed = &read_grub_defaults();
my %env = &grub2_read_env();
# Selection roles are derived from both defaults and grubenv state.
my %selection = &grub2_entry_selection_roles(\@entries, $parsed, \%env);
my $can_default = $access->{'view'} && $access->{'runtime'} &&
		  &grub2_command('set_default_cmd');
my $can_once = $access->{'view'} && $access->{'runtime'} &&
	       &grub2_command('reboot_once_cmd');
my $show_actions = $can_default || $can_once;
print &ui_div($text{'index_entries_desc'});
if (!@entries) {
	print &ui_alert($text{'index_no_entries'}, 'info');
	return;
	}
my @heads = (
	$text{'index_col_title'},
	$text{'index_col_group'},
	$text{'index_col_selection'},
	($show_actions ? ( $text{'index_col_actions'} ) : ( )),
);
my @tds = (
	"",
	"",
	"width=10% nowrap",
	($show_actions ? ( "width=10% nowrap" ) : ( )),
);
print &ui_columns_start(\@heads, 100, 0, \@tds);
foreach my $entry (@entries) {
	# Path displays submenu nesting; BLS top-level entries have no submenu path.
	my @cols = (
		&entry_title_cell($entry),
		@{$entry->{'path'} || []}
			? &html_escape(join(' > ', @{$entry->{'path'}}))
			: $text{'index_top'},
		&selection_cell($selection{$entry->{'index'}}),
		($show_actions ? ( &entry_actions_cell(
			$entry, $can_default, $can_once) ) : ( )),
	);
	print &ui_columns_row(\@cols, \@tds);
	}
print &ui_columns_end();
}

# print_custom_tab(&access)
# Outputs editable custom menu entries from the configured custom file.
sub print_custom_tab
{
my ($access) = @_;
my $file = &grub2_config_value('custom_file') || '';
my @entries = &grub2_custom_entries($file);
my $can_edit = $access->{'manual'} && $file ne '';
print &ui_div($text{'index_custom_desc'});
if ($file eq '') {
	# A blank custom file path means the module cannot safely offer editing.
	print &ui_alert($text{'custom_enofile'}, 'info');
	return;
	}
if ($can_edit && @entries) {
	# Checked-table actions need a stable form number for select-all links.
	my $formno = $grub2_formno;
	print &ui_form_start("custom_action.cgi", "post", undef,
			     "id='grub2_custom_form'");
	$grub2_formno++;
	&print_custom_links($can_edit, scalar(@entries), $formno);
	}
elsif (@entries) {
	&print_custom_links($can_edit, scalar(@entries), $grub2_formno);
	}
if (!@entries) {
	print &ui_br();
	print &ui_p($text{'custom_empty'});
	if ($can_edit) {
		# Empty state uses a compact link, matching other Webmin list pages.
		print &ui_link("edit_custom.cgi", $text{'custom_add'},
				    "plus");
		print &ui_br();
		}
	return;
	}
# A single editable entry can be deleted, but cannot be reordered.
my $show_order = $can_edit && @entries > 1;
my @tds = $can_edit ? (
	"width=5",
	"",
	"",
	($show_order ? ( "width=40 style='white-space: nowrap; text-align: center'" ) : ( )),
	) : ( );
print &ui_columns_start([
	($can_edit ? ( "" ) : ( )),
	$text{'index_col_title'},
	$text{'index_col_group'},
	($show_order ? ( $text{'index_col_order'} ) : ( )),
	], 100, 0, \@tds);
foreach my $entry (@entries) {
	# Custom indexes refer to parsed menuentry blocks in the custom file.
	my $idx = $entry->{'custom_index'};
	my $title = &entry_title_cell($entry, "edit_custom.cgi?idx=$idx");
	my @cols = (
		$title,
		@{$entry->{'path'} || []}
			? &html_escape(join(' > ', @{$entry->{'path'}}))
			: $text{'index_top'},
		($show_order ? ( &custom_order_cell($idx, \@entries) ) : ( )),
	);
	if ($can_edit) {
		print &ui_checked_columns_row(\@cols, \@tds, "d", $idx);
		}
	else {
		print &ui_columns_row(\@cols);
		}
	}
print &ui_columns_end();
if ($can_edit) {
	my @left_buttons;
	my @right_buttons = (
		[ "delete", $text{'index_delete_entry'}, undef, undef,
		  "form='grub2_custom_form'" ],
	);
	print &ui_form_end_side_by_side("grub2_custom_form",
					\@left_buttons, \@right_buttons);
	}
}

# print_action_buttons(&access)
# Outputs the main module actions allowed by ACLs.
sub print_action_buttons
{
my ($access) = @_;
my (@links, @titles, @icons);
my $can_status = $access->{'view'};
my $can_generate = $access->{'apply'} && &grub2_command('mkconfig_cmd');
if ($access->{'install'}) {
	# Primary action tiles are ACL-filtered so unavailable pages stay hidden.
	push(@links, "edit_install.cgi");
	push(@titles, $text{'index_install'});
	push(@icons, "images/install.svg");
	}
if ($access->{'edit'}) {
	push(@links, "edit_defaults.cgi");
	push(@titles, $text{'index_edit_defaults'});
	push(@icons, "images/defaults.svg");
	}
if ($access->{'security'}) {
	push(@links, "edit_security.cgi");
	push(@titles, $text{'index_edit_security'});
	push(@icons, "images/security.svg");
	}
if ($access->{'edit'}) {
	push(@links, "edit_theme.cgi");
	push(@titles, $text{'index_edit_theme'});
	push(@icons, "images/theme.svg");
	}
if ($access->{'manual'}) {
	push(@links, "edit_manual.cgi");
	push(@titles, $text{'index_manual'});
	push(@icons, "images/manual.svg");
	}
return if (!@links && !$can_status && !$can_generate);
# Without view content, the action hub should start directly with actions.
print &ui_hr() if ($access->{'view'});
if (@links) {
	print &ui_subheading($text{'index_global'});
	&icons_table(\@links, \@titles, \@icons, scalar(@links) > 5 ? 5 :
		     scalar(@links));
	}
if ($can_status || $can_generate) {
	print &ui_hr() if (@links);
	print &ui_buttons_start();
	print &ui_buttons_row("status.cgi", $text{'index_view_status'},
			      $text{'index_view_status_msg'}, undef, undef,
			      undef, "get") if ($can_status);
	print &ui_buttons_row("generate.cgi", $text{'index_generate'},
			      $text{'index_generate_msg'},
			      [ [ "redir", &grub2_this_url() ] ])
		if ($can_generate);
	print &ui_buttons_end();
	}
}

# print_custom_links(can-edit?, entry-count, form-number)
# Outputs checked-table links for custom entries.
sub print_custom_links
{
my ($can_edit, $count, $formno) = @_;
return if (!$can_edit);
my @left;
if ($count) {
	push(@left, &select_all_link("d", $formno),
	     &select_invert_link("d", $formno));
	}
push(@left, &ui_link("edit_custom.cgi", $text{'custom_add'}));
print &ui_links_row(\@left);
}

# selection_cell(&roles)
# Returns display text for default and next-boot entry roles.
sub selection_cell
{
my ($roles) = @_;
return '' if (!$roles || !@$roles);
my @labels = map { $text{'index_selection_'.$_} || $_ } @$roles;
return join(', ', @labels);
}

# entry_title_cell(&entry, [link])
# Returns a title cell with useful GRUB entry metadata in inline details.
sub entry_title_cell
{
my ($entry, $link) = @_;
my $title = &html_escape($entry->{'title'} || '');
my $summary = $link ?
	&ui_tag('a', $title, { href => $link, style => 'padding: 0;' }) :
	$title;
return &ui_details({
	'html' => 1,
	'title' => $summary,
	'content' => &entry_details_content($entry),
	'class' => 'inline inlined',
	});
}

# entry_details_content(&entry)
# Returns compact metadata for a boot entry details disclosure.
sub entry_details_content
{
my ($entry) = @_;
my @rows;
my $index = defined($entry->{'index'}) ? $entry->{'index'} :
	    $entry->{'custom_index'};
# Only include rows that help identify or troubleshoot the selected entry.
push(@rows, &entry_detail_line($text{'index_col_index'}, $index))
	if (defined($index));
push(@rows, &entry_detail_line($text{'index_col_id'}, $entry->{'id'}))
	if ($entry->{'id'});
push(@rows, &entry_source_detail_line($entry))
	if ($entry->{'source_file'});
push(@rows, &entry_detail_line($text{'index_col_version'},
			       $entry->{'version'}));
push(@rows, &entry_detail_line($text{'index_col_kernel'},
			       $entry->{'linux'}));
push(@rows, &entry_detail_line($text{'index_col_initrd'},
			       $entry->{'initrd'}));
push(@rows, &entry_detail_line($text{'index_col_machine_id'},
			       $entry->{'machine-id'}));
push(@rows, &entry_detail_line($text{'index_col_options'},
			       $entry->{'options'}));
return join('', @rows);
}

# entry_source_detail_line(&entry)
# Returns source details without implying generator scripts are entry files.
sub entry_source_detail_line
{
my ($entry) = @_;
my $file = $entry->{'source_file'} || '';
return '' if (!defined($file) || $file eq '');
my $custom_file = &grub2_config_value('custom_file') || '';
my $direct_file = (($entry->{'source'} || '') eq 'bls') ||
		  ($custom_file ne '' && $file eq $custom_file);
# Direct entry files are shortened for readability; generator scripts are not.
my $label = $direct_file ? $text{'index_col_file'} :
	    $text{'index_col_generator'};
my $html;
if ($direct_file) {
	my $display = &entry_file_display_name($file);
	$html = &ui_tag('tt', &html_escape($display), { 'title' => $file });
	}
else {
	$html = &ui_tag('tt', &html_escape($file));
	}
if ($access{'manual'} && &grub2_manual_file($file)) {
	# The manual editor repeats its allowlist check on entry.
	$html = &ui_tag('a', $html, {
		'href' => "edit_manual.cgi?file=".&urlize($file),
		});
	}
return &entry_detail_line($label, $html, 1);
}

# entry_file_display_name(file)
# Returns a short display name for a linked entry file.
sub entry_file_display_name
{
my ($file) = @_;
$file = '' if (!defined($file));
$file =~ s{.*/}{};
return $file;
}

# entry_detail_line(label, value, [html-value?])
# Returns one escaped metadata line for a boot entry details disclosure.
sub entry_detail_line
{
my ($label, $value, $html) = @_;
return '' if (!defined($value) || $value eq '');
my $display = $html ? $value : &ui_tag('tt', &html_escape($value));
return &ui_tag('div',
	       &ui_tag('span', &html_escape($label).':',
		       { 'style' => 'white-space: nowrap;' }).
	       &ui_tag('span', $display,
		       { 'style' => 'min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere;' }),
	       { 'style' => 'display: grid; grid-template-columns: max-content minmax(0, 1fr); column-gap: 0.35em; align-items: start;' });
}

# entry_actions_cell(&entry, can-default?, can-once?)
# Returns runtime action links for one generated boot entry.
sub entry_actions_cell
{
my ($entry, $can_default, $can_once) = @_;
my $idx = $entry->{'index'};
my @actions;
push(@actions, &ui_link("set_default.cgi?idx=$idx",
			$text{'index_set_default'})) if ($can_default);
push(@actions, &ui_link("reboot_once.cgi?idx=$idx",
			$text{'index_reboot_once'})) if ($can_once);
return join(' | ', @actions);
}

# custom_order_cell(index, &entries)
# Returns up/down ordering controls for one custom entry row.
sub custom_order_cell
{
my ($idx, $entries) = @_;
my $up = $idx > 0 &&
	&grub2_paths_equal($entries->[$idx], $entries->[$idx - 1]);
my $down = $idx < @$entries - 1 &&
	&grub2_paths_equal($entries->[$idx], $entries->[$idx + 1]);
# Disable movement across submenu boundaries so nesting remains intact.
return &ui_up_down_arrows("custom_action.cgi?idx=$idx&dir=up",
			  "custom_action.cgi?idx=$idx&dir=down",
			  $up, $down);
}
