#!/usr/bin/env perl
# patch - Apply one or more patches to Webmin core or its modules from
# GitHub or a local file

use strict;
use warnings;

use 5.010;

use Getopt::Long qw(:config permute pass_through);
use Pod::Usage;
use File::Basename;
use Cwd qw(cwd);

my %opt;
GetOptions(
	'help|h' => \$opt{'help'},
	'config|c=s' => \$opt{'config'},
	);
pod2usage(0) if ($opt{'help'});

# Get Webmin path
my $path = cwd;
my $lib = "web-lib-funcs.pl";
if (!-r "$path/$lib") {
	$path = dirname(dirname($0));
	if (!-r "$path/$lib") {
		$path = $path = Cwd::realpath('..');
		}
	}

# Init core
my $config_dir = $opt{'config'} || '/etc/webmin';
$ENV{'WEBMIN_CONFIG'} = $config_dir;
push(@INC, $path);
eval 'use WebminCore';
init_config();

# Check if curl is installed
if (!has_command('curl')) {
	print "\"curl\" command is not installed\n";
	exit 1;
	}

# Check if git is installed
if (!has_command('patch')) {
	if (!has_command('git')) {
		print "Neither \"patch\" nor \"git\" commands are installed\n";
		exit 1;
		}
	}

# Get patch URLs or files
my @patches = @ARGV;

# Params check
if (!@patches) {
	pod2usage(0);
	exit 1;
	}

# Apply all patches in the given order, stopping at the first failure
my $orig_cwd = cwd();
my $applied = 0;
my $failed = 0;
foreach my $patch (@patches) {
	# Show which patch is being applied if there are multiple
	print "Applying $patch ..\n" if (@patches > 1);
	# Always start from the original directory, as applying a patch
	# may change it
	chdir($orig_cwd);
	if (!apply_patch($patch)) {
		$failed = 1;
		last;
		}
	$applied++;
	}

# Restart Webmin once, if any patches were applied, so that changes to
# any file, including miniserv.pl itself, take effect
restart_miniserv() if ($applied);

# Report if some patches were not applied
if ($failed) {
	print "Stopped: $applied of ".scalar(@patches)." patches applied\n"
		if (@patches > 1);
	exit 1;
	}
exit 0;

# apply_patch(patch)
# Apply a single patch given as URL or local file, printing the outcome
# and returning 1 on success or 0 on failure
sub apply_patch
{
my ($patch) = @_;

# Patch check
if ($patch !~ /^https?:\/\//) {
	if (!-r $patch) {
		print "Patch file $patch doesn't exist\n";
		return 0;
		}
	}
elsif ($patch =~ /^https?:\/\/(github|gitlab)\.com/ &&
       $patch !~ /\.patch$/ && $patch !~ /\.diff$/) {
	$patch .= '.patch';
	}

# Parse module name from URL
my $module = "";
if ($patch =~ m{
    (?|
        # GitHub/GitLab commit URL
        https://(?:github|gitlab)\.com/([^/]+)/([^/]+)/commit/([^/]+)
        |
        # GitHub pull request commit URL
        https://github\.com/([^/]+)/([^/]+)/pull/\d+/commits/([^/]+)
        |
        # GitLab merge request URL with commit ID
        https://gitlab\.com/([^/]+)/([^/]+)/-/merge_requests/\d+/diffs\?commit_id=([^&]+)
        |
        # GitHub raw URL
        https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)
        |
        # GitLab raw URL
        https://gitlab\.com/([^/]+)/([^/]+)/-/raw/([^/]+)/(.+)
    )
}x) {
	$module = $2;
	$module = "" if ($2 eq 'webmin');
	# Special handling for some modules
	$module = $module =~ /^virtualmin-pro$/ ?
		'virtual-server/pro' :
			'virtual-server'
				if $module =~ /^virtualmin-(gpl|pro)$/;
	}

# Check if module exists
if (!-d "$path/$module") {
	print "Module '$module' doesn't exist\n";
	return 0;
	}

# Prepare patch command
my $cmd;
my $direct;
my $filename;
my $dir;
my $output;

# If raw URL given, try to download and just replace the whole file
if ($patch =~ m{^https?://raw\.githubusercontent\.com/} ||
    $patch =~ m{^https?://gitlab\.com/.*?/-/raw/}) {
	if ($patch =~ m{
		.*?               # Non-greedy match of everything up to the branch name
		(?:master|main)/  # Branches name
		(.*/)?            # Capture all directories after the branch (group 1)
		([^/]+)$          # Filename (group 2)
	}x)
	{
		$direct = 1;
		$dir = $1 // "";
		$filename = $2;
		}
	else {
		print "Patch failed: Can't parse file name from URL\n";
		return 0;
		}
	my $cd = "$path/$module/$dir";
	$cd =~ s|/+|/|g;
	chdir "$cd";
	$cmd = "curl -w \"%{http_code}\\n\" -s -o $filename @{[quotemeta($patch)]}";
	}
# Download command
elsif ($patch =~ /^https?:\/\//) {
	$cmd = "curl -L -s @{[quotemeta($patch)]}";
	chdir "$path/$module";
	}
# Local file
else {
	$cmd = "cat @{[quotemeta($patch)]}";
	}

# Download file directly
if ($direct) {
	$output = `$cmd 2>&1`;
	if ($output != 200) {
		print "Patch failed: Cannot download '$filename'. HTTP status code: $output\n";
		return 0;
		}
	}
# Apply patch using patch command
elsif (has_command('patch')) {
	# Do not leave .orig backups behind when hunks apply with fuzz or offset
	$output = `$cmd 2>&1 | patch -p1 --verbose --no-backup-if-mismatch 2>&1`;
	if ($output !~ /succeeded/i) {
		print "Patch failed: $output\n";
		return 0;
		}
	}
# Apply patch using git command
else {
	# If the current directory is inside some git repository (e.g. Webmin
	# root tracked with git), git would treat patch paths as relative to
	# that repository root and silently skip them, so stop repository
	# discovery at the parent directory to always apply patch paths
	# relative to the current directory
	local $ENV{'GIT_CEILING_DIRECTORIES'} = dirname(cwd());
	# Allow reduced context, similar to the default fuzz used by patch
	$output = `$cmd 2>&1 | git apply --reject --verbose -C1 --whitespace=fix 2>&1`;
	if ($output !~ /applied patch.*?cleanly/i) {
		print "Patch failed: $output\n";
		return 0;
		}
	}

# Print results
if ($direct) {
	print "File replaced successfully:\n";
	if ($dir) {
		$dir = $dir ? "/$dir" : "";
		$dir =~ s|^/||;
		}
	print "  $dir$filename\n";
	system("sed -i '1s|^#!/usr/local/bin/perl|#!/usr/bin/perl|' \"$filename\"");
	}
else {
	print "Patch applied successfully to:\n";
	print "  $1\n" while $output =~ /^(?|Applied patch\s+(\S+)|patching file\s+(\S+))/mg;
	}
return 1;
}

=pod

=head1 NAME

patch

=head1 DESCRIPTION

Apply a patch to Webmin core or its modules from GitHub/GitLab, a local
file, or by downloading and replacing the entire file from a raw URL.

Multiple patches can be given at once and are applied in the given order,
stopping at the first failure. Webmin is restarted only once after all
patches are applied.

=head1 SYNOPSIS

webmin patch patch-url/file [patch-url/file ...]

=head1 OPTIONS

=over

=item --help, -h

Give this help list.

=item --config, -c

Specify the full path to the Webmin configuration directory. Defaults to
C</etc/webmin>

Examples of usage:

  Apply a patch from a URL.

    - webmin patch https://github.com/webmin/webmin/commit/e6a2bb15b0.patch

    - webmin patch https://github.com/virtualmin/virtualmin-gpl/commit/f4433153d
  
  Apply a patch from local file.
  
    - cd /usr/libexec/webmin/virtual-server/pro &&
      webmin patch /root/virtualmin-pro/patches/patch-1.patch

  Apply multiple patches at once.

    - webmin patch https://github.com/webmin/webmin/commit/e6a2bb15b0 \
                   https://github.com/virtualmin/virtualmin-gpl/commit/f4433153d

=back

=head1 LICENSE AND COPYRIGHT

 Copyright 2024 Ilia Ross <ilia@virtualmin.com>
