#!/usr/local/cpanel/3rdparty/bin/perl
#                                       Copyright 2025 WebPros International, LLC
#                                                            All rights Reserved.
# copyright@cpanel.net                                          http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited

package autofixer2::fix_addon_features;
BEGIN { unshift @INC, '/usr/local/cpanel'; }

use cPstrict;
use Cpanel::FindBin;
use Cpanel::JSON;
use Cpanel::OS;
use Cpanel::SafeRun::Errors;
use Cpanel::SafeDir::MK;
use Cpanel::SafeRun::Object;
use Cpanel::SafeDir::RM;
use Cpanel::Version::Compare;
use File::Basename;

my $addon_features_directory = '/usr/local/cpanel/whostmgr/addonfeatures';
my $summary_log              = '/var/cpanel/updatelogs/summary.log';
my $update_analysis_dir      = "/usr/local/cpanel/logs/update_analysis";
my @affected_versions        = ( '11.110.0.65', '11.126.0.21', '11.128.0.11', '11.129.9999.68' );
my $affected_version_found   = 0;
use constant max_archives_to_search => 30;
exit run() unless caller;

sub run () {
    return 0 unless supported_on_this_major( 110, 130 );    # Set this to the Min/Max we support this autofixer on.
    return 0 if !is_server_affected();
    if ( my $addon_features_data = get_addon_data() ) {
        create_addon_feature_files($addon_features_data);
    }
    return 0;
}

=head1 METHODS

=over

=item * is_server_affected -- Checks if the server has any affected cPanel version

Checks the cPanel update summary log to determine if the server has ever installed
any of the known affected cPanel versions.

Opens and reads the summary log file located at /var/cpanel/updatelogs/summary.log.
For each line that matches the pattern for a completed update, it extracts the
"before" and "after" version numbers from the update entry. If either version
matches any value in the @affected_versions array, the function returns 1,
indicating the server is affected. Otherwise, it returns 0.

Returns:
    1 if an affected version is found in the log, 0 otherwise.

=back

=cut

sub is_server_affected {
    open( my $FH, '<', $summary_log ) or die "Unable to open the file: $summary_log due to the issue: $!";
    while ( my $data = <$FH> ) {
        chomp($data);

        #   Looking for lines similar to:
        #    [2023-01-21 03:19:43 +0000]   Completed update 11.111.9999.35 -> 11.111.9999.35
        if ( my ( $timestamp, $rest ) = $data =~ /^(.*\])\s+(Completed update.*)$/ ) {

            # At this point we have part of the line like:
            # Completed update 11.111.9999.35 -> 11.111.9999.35
            my ( $before_version, $after_version ) = ( split /\s/, $rest )[ 2, 4 ];

            # Check if the server has had one of the problematic versions installed before.
            return 1 if grep { $_ eq $before_version || $_ eq $after_version } @affected_versions;
        }

    }

    return 0;
}

=over

=item * get_addon_data -- Retrieves addon feature data from update analysis archives

Scans the update analysis directory for archived analytics logs to locate addon feature data
from before a destructive cPanel update.

Opens and sorts up to 30 of the most recent .tar.gz archives in /usr/local/cpanel/logs/update_analysis.
For each archive, it extracts the metadata JSON file, loads its contents, and checks if the
archive contains an affected cPanel version using does_analytics_have_affected_version().
If a matching archive is found, returns the addon_features data from the JSON.

Returns:
    A hash reference containing addon feature data if found, or undef if not found.

=back

=cut

sub get_addon_data {
    opendir( my $DH, $update_analysis_dir ) or die "Unable to open the dir: $!";
    my @files = sort { $b cmp $a } grep { !/^\.\.?$/ } readdir($DH);
    closedir($DH);
    my $tar_bin = Cpanel::FindBin::findbin('tar');
    return if !$tar_bin;

    my $retries = 0;

    foreach my $file (@files) {
        return if $retries == max_archives_to_search;
        next   if $file !~ /\.tar\.gz$/;
        my $zip_file = "$update_analysis_dir/$file";
        print "zip_file : $zip_file \n";
        my $result = eval { Cpanel::SafeRun::Object->new_or_die( 'program' => $tar_bin, 'args' => [ '-x', '-v', '-f', $zip_file, '-C', '/tmp' ] ); };

        # We do not want to look any futher if there is an issue with extracting an archive file
        # as we might either skip the archive with bad version in it or we dont want to roll back
        # into history any further the 1 day back from bad install since we will go back further
        # and miss to capture the actual exact state.
        if ($@) {
            print "There are some issues in extracting the archive for file: '$zip_file'. Exiting.. \n";
            return;
        }

        # Cpanel::SafeRun::Object captures the output in result->{_stduot} on success
        # It outputs both directory 2025-03-21T15:48:42Z/ and json file 2025-03-21T15:48:42Z/meta.json in scalar context
        # with '\n' in it
        my ($meta_json) = ( grep { $_ !~ /\/$/ } split /\n/, ${ $result->{_stdout} } )[0];
        my $json_file = '/tmp/' . $meta_json;
        next unless -e $json_file;
        $retries++;
        print "Processing json_file: '$json_file' \n";
        my $json_content = eval { Cpanel::JSON::LoadFile($json_file); };
        if ($@) {
            print "Unable to read file: '$json_file' \n";
            next;
        }
        clean_up_tmp_json($json_file);
        next if !does_analytics_have_affected_version( $json_content->{version} );
        print "Found the affected version in log: '$json_file' \n";
        return $json_content->{addon_features};

    }
    print "Could not find any of the affected_versions: " . join( ', ', @affected_versions ) . " in update_analysis log. Exiting...\n";
    return;
}

=over

=item * does_analytics_have_affected_version -- Determines if analytics archive contains an affected version

Checks if the provided version information from an analytics archive matches any of the known affected cPanel versions.

If the 'after' version in the version blob matches an affected version, it marks that an affected version was found and returns 0.
If an affected version was previously found, it checks if the current archive is from just before the destructive update by comparing major versions and version order.
Returns 1 if the archive is from just before the destructive update, otherwise returns 0.

Arguments:
    $version_blob - A hash reference containing version information (expects an 'after' key).

Returns:
    1 if the archive is from just before the destructive update, 0 otherwise.

=back

=cut

sub does_analytics_have_affected_version($version_blob) {

    if ( grep { $_ eq $version_blob->{after} } @affected_versions ) {

        # We found the analytics log for the day the destructive install happened.
        # Save that and we will look at the next oldest logs to try to find
        # a preserved good list of dynamically installed features.
        $affected_version_found = 1;
        return 0;
    }

    my $major_version_from_target_analytics = Cpanel::Version::Compare::get_major_release( $version_blob->{after} );

    # We get here after scanning the analytics data from day before the
    # destructive install happened. This check is in the next older file.
    if ($affected_version_found) {
        foreach my $affected_version (@affected_versions) {
            if ( Cpanel::Version::Compare::get_major_release($affected_version) eq $major_version_from_target_analytics ) {

                # We found the analytics archive from just before the destructive install
                return 1 if Cpanel::Version::Compare::compare( $affected_version, '>', $version_blob->{after} );

            }
        }
    }

    # keep looking

    return 0;
}

=over

=item * create_addon_feature_files -- Restores addon feature files from provided data

Restores dynamically installed feature maps used by cPanel plugins and extensions in WHM Feature Manager.
Creates the addon feature files in /usr/local/cpanel/whostmgr/addonfeatures based on the provided data.
Skips the 'enduserlve' feature (intentionally removed) and handles special symlink logic for 'wp-toolkit-deluxe'.
Each feature file contains the feature name and its description.

Arguments:
    $addon_features_data - A hash reference containing feature names and descriptions.

=back

=cut

sub create_addon_feature_files($addon_features_data) {
    print "Restoring dynamically installed feature maps. These feature maps are installed by cPanel plugins and extensions and are used in WHM Feature Manager to map programmatic feature names to display names.\n";
    if ( !create_dir_if_not_exist() ) {
        print "Unable to create directory: '$addon_features_directory'. Nothing left to do. Exiting...\n";
        return;
    }
    foreach my $feature ( keys %{$addon_features_data} ) {

        # We do not restore this one from the data set since it was intentionally removed by CPANEL-47735
        next if $feature eq 'enduserlve';
        my $addon_feature_file = "$addon_features_directory/$feature";
        if ( $feature eq 'wp-toolkit-deluxe' ) {

            # setup symlink if wp-toolkit-deluxe is available in feature-manager
            next if symlink_wp_toolkit_deluxe($addon_feature_file);
        }
        open( my $feature_cfg, '>', $addon_feature_file ) or die "Unable to open the file: '$addon_feature_file' with the error: $! ";

        # Provide the description if we have it otherwise fall back to the programatic feature name.
        my $description = $addon_features_data->{$feature} // $feature;
        print $feature_cfg "$feature:$description\n";
    }
    print "Done Restoring\n";

}

=over

=item * create_dir_if_not_exist -- Ensures the addon features directory exists

Checks if the addon features directory (/usr/local/cpanel/whostmgr/addonfeatures) exists.
If it does not exist, attempts to create it with permissions 0755 using a safe mkdir routine.

Returns:
    1 if the directory exists or is created successfully, 0 otherwise.

=back

=cut

sub create_dir_if_not_exist {
    return 1 if -d $addon_features_directory;
    return Cpanel::SafeDir::MK::safemkdir( $addon_features_directory, '0755' );
}

=over

=item * supported_on_this_major -- Checks if the current major version is within supported range

Determines if the current server's major cPanel version falls within the specified minimum and maximum supported versions.

Arguments:
    $min_ver - The minimum supported major version (inclusive).
    $max_ver - The maximum supported major version (inclusive).

Returns:
    1 if the current major version is supported, 0 otherwise.

=back

=cut

sub supported_on_this_major {
    my ( $min_ver, $max_ver ) = @_;

    my $major = get_major_version();

    return 0 if $major < $min_ver;
    return 0 if $major > $max_ver;

    return 1;
}

=over

=item * get_major_version -- Retrieves the major cPanel version number

Reads the cPanel version file (/usr/local/cpanel/version) and extracts the major version component.
If the version file cannot be read or parsed, returns a safe default value (30).

Returns:
    The major version number as an integer.

=back

=cut

sub get_major_version {
    my $major_version;

    if ( open( my $fh, '<', '/usr/local/cpanel/version' ) ) {
        my $full_version = <$fh>;
        close($fh);
        if ( length $full_version ) {
            chomp $full_version;
            ($major_version) = $full_version =~ /^[0-9]+\.([0-9]+)/;
        }
    }

    # Safe default.
    return $major_version || 30;
}

=over

=item * clean_up_tmp_json -- Removes the temporary directory used for extracted JSON files

Cleans up the temporary directory created during extraction of metadata JSON files from update analysis archives.
Uses safe directory removal routines to ensure the directory and its contents are deleted.

Arguments:
    $tmp_file - The full path to the extracted JSON file; its parent directory will be removed.

=back

=cut

sub clean_up_tmp_json($tmp_file) {
    my $directory = File::Basename::dirname($tmp_file);
    print "Cleaning up the temporary archive extract directory: '$directory' \n";
    Cpanel::SafeDir::RM::safermdir($directory);
    rmdir $directory;
}

=over

=item * symlink_wp_toolkit_deluxe -- Creates or updates symlink for wp-toolkit-deluxe feature

Checks if the wp-toolkit-deluxe feature file exists in the 3rdparty wp-toolkit feature-manager directory.
If it exists, creates or updates a symbolic link in the addon features directory pointing to this file.
Uses a safe symlink creation routine and returns 1 on success, 0 otherwise.

Arguments:
    $addon_features_wp_toolkit_deluxe - The target path for the symlink in the addon features directory.

Returns:
    1 if the symlink is created or updated successfully, 0 otherwise.

=back

=cut

sub symlink_wp_toolkit_deluxe($addon_features_wp_toolkit_deluxe) {
    my $wp_toolkit_file = '/usr/local/cpanel/3rdparty/wp-toolkit/feature-manager/wp-toolkit-deluxe';
    my $ln_bin          = Cpanel::FindBin::findbin('ln');
    return 0 if !$ln_bin;
    if ( -e $wp_toolkit_file ) {
        eval { Cpanel::SafeRun::Errors::saferunallerrors( $ln_bin, '-sf', $wp_toolkit_file, $addon_features_wp_toolkit_deluxe ); };
        return $@ ? 0 : 1;
    }
    return 0;

}

1;

