#!/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); }