#!/usr/bin/env perl
# patch - Apply a patch 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 URL or file
my $patch = $ARGV[0];

# Params check
if (!$patch) {
	pod2usage(0);
	exit 1;
	}

# Patch check
if ($patch !~ /^https?:\/\//) {
	if (!-r $patch) {
		print "Patch file $patch doesn't exist\n";
		exit 1;
		}
	}
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";
    exit 1;
}

# 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";
		exit 1;
		}
	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";
		exit 1;
    		}
	}
# Apply patch using patch command
elsif (has_command('patch')) {
	$output = `$cmd 2>&1 | patch -p1 --verbose 2>&1`;
	if ($output !~ /succeeded/i) {
		print "Patch failed: $output\n";
		exit 1;
		}
	}
# Apply patch using git command
else {
	$output = `$cmd 2>&1 | git apply --reject --verbose --whitespace=fix 2>&1`;
	if ($output !~ /applied patch.*?cleanly/i) {
		print "Patch failed: $output\n";
		exit 1;
		}
}

# 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;
}

# Restart Webmin
system("$config_dir/restart");

=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.

=head1 SYNOPSIS

webmin patch 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

=back

=head1 LICENSE AND COPYRIGHT

 Copyright 2024 Ilia Ross <ilia@virtualmin.com>
