#!/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 scripts::modify_featurelist;

use cPstrict;

=encoding utf-8

=head1 NAME

modify_featurelist

=head1 USAGE

modify_featurelist --config <path-to-config>

or

modify_featurelist --flag <run-once-flag> --feature <feature-name>
                   [ --enable | --disable ]
                   [ --list <feature-list-name> | --all ]
                   [ --verbose ]

=head1 DESCRIPTION

This script modifies feature lists to enable or disable a certain feature.

The script will only run once per server using the provided flag as a lock to decide if the code should run.

When you pass C<--config>, provide a JSON with Comments (JSONC) file
with the following keys:

=over

=item flag - string - The run-once flag.

=item condition - object - Optional component rules to evaluate.

Conditions can only be provided in the config file. When a condition
is present, this script only updates feature lists when the condition
evaluates true against the local server state.

=item all - boolean - Run on all feature lists except C<disabled>.

The C<default> list is always part of the sweep, even when its file
does not exist yet; a write recreates it.

Use either the C<all> attribute or the C<list> attribute.

=item list - array - The feature lists to modify.

=item feature - string - cPanel feature name.

=item enable - boolean - True to enable by default, false to disable.

=item verbose - boolean - Optional. Print progress details.

=item add_only - boolean - Optional. If true, only adds the feature if
it does not already exist in the feature list. This preserves existing
user choices. If false or omitted, this script sets the feature value
even when the feature already exists.

=back

B<NOTE:> Command-line parameters override values from the config file.

When C<--all> is provided, this script attempts to add the feature to
all feature lists except C<disabled>.

Feature-list-specific validation, including standalone feature rules,
is delegated to C<Cpanel::Features::update_featurelist>. If validation
fails for a specific feature list, this script logs a warning, skips
that list, and continues processing the rest.

When one or more C<--list> arguments are provided, this script updates
the specified feature lists.

You must provide either C<--config> or the following: C<--flag>,
C<--feature>, and either C<--enable> or C<--disable>. All other
arguments are optional.

=head1 METHODS

=cut

use Cpanel::ConfigFiles            ();
use Cpanel::Exception              ();
use Cpanel::Features               ();
use Cpanel::Features::Load         ();
use Cpanel::FileUtils::TouchFile   ();
use Cpanel::Transaction::File::Raw ();
use Cpanel::Version                ();
use Cpanel::Version::Compare       ();

use Cpanel::Imports;

use parent qw( Cpanel::HelpfulScript );

use constant _OPTIONS => (
    'config=s',
    'feature=s',
    'list=s@',
    'all',
    'enable',
    'disable',
    'flag=s',
    'verbose!'
);

our $VERSION_DIR = "/var/cpanel/version";
use constant PLUGIN_DIR => '/var/cpanel/plugins/';

# How long do_once waits for a concurrent invocation's guard. A
# feature-list sweep takes seconds; a day-long default would stall
# package installs behind a stuck holder. A waiter that outlasts this
# gives up and leaves the task for the next invocation — it can never
# take the guard from a live holder, however long the holder runs.
use constant _DO_ONCE_GUARD_WAIT => 120;

__PACKAGE__->new(@ARGV)->run() if !caller;

sub _verify_directories () {

    if ( !-e $Cpanel::ConfigFiles::FEATURES_DIR ) {
        mkdir( $Cpanel::ConfigFiles::FEATURES_DIR, 0755 ) or do {
            logger()->warn("Unable to create feature directory '$Cpanel::ConfigFiles::FEATURES_DIR': $!");
            return;
        };
    }

    if ( !-e $VERSION_DIR ) {
        mkdir( $VERSION_DIR, 0755 ) or do {
            logger()->warn("Unable to create run lock directory '$VERSION_DIR': $!");
            return;
        };
    }

    return 1;
}

sub get_featurelists() {
    opendir( my $dh, $Cpanel::ConfigFiles::FEATURES_DIR ) || do {
        logger()->warn("Cannot open directory: $Cpanel::ConfigFiles::FEATURES_DIR.");
        return;
    };

    my @feature_lists = grep { $_ !~ /^\.\.?$/ && $_ ne 'disabled' } readdir($dh);
    closedir($dh) or die "Failed to retrieve the featurelist: $!";

    # The default list is a disabler layer that applies to every account on
    # the server; include it even when its file is missing so a sweep can
    # recreate it. update_featurelist makes the missing file at write time.
    push @feature_lists, 'default' if !grep { $_ eq 'default' } @feature_lists;

    return @feature_lists;
}

=head2 modify_feature_for_all_feature_lists($feature, $value, $verbose, $add_only)

Adds a feature and value to all feature lists.

=head3 ARGUMENTS

=over

=item * C<$feature> - The feature to add.

=item * C<$value> - 1 to enable the feature, 0 to disable the feature.

=item * C<$verbose> - 1 to print progress details, 0 to run quietly.

=item * C<$add_only> - Optional. If true, only adds the feature if it
is not already present.

=back

=cut

sub modify_feature_for_all_feature_lists ( $feature, $value, $verbose, $add_only = 0 ) {

    my @feature_lists = get_featurelists();
    my $added         = 0;

    my $mail_only_flag = PLUGIN_DIR . "$feature/mail_only";
    foreach my $feature_list (@feature_lists) {
        if ( $feature_list eq 'Mail Only' && $value && !-e $mail_only_flag ) {
            say "Disabling '$feature' for '$feature_list' featurelist." if $verbose;
            $added++                                                    if modify_featurelist( $feature_list, $feature, 0, $verbose, $add_only );
            next;
        }
        $added++ if modify_featurelist( $feature_list, $feature, $value, $verbose, $add_only );
    }

    # One durable line per sweep, so even a clean (all-skips) run leaves an
    # audit mark that it happened; each write also logs its own line.
    logger()->info( sprintf( 'modify_featurelist: wrote %s=%s to %d of %d feature lists', $feature, $value, $added, scalar @feature_lists ) );

    return 1;
}

=head2 modify_featurelist($feature_list, $feature, $value, $verbose, $add_only)

Initialize the feature in the feature list with the provided default value.
This function delegates feature validation to
C<Cpanel::Features::update_featurelist>.

Every successful write is recorded in the cPanel log. A write to the
C<default> list is logged as a warning, because that list applies to every
account whose own feature list has no explicit entry for the feature.

=head3 ARGUMENTS

=over

=item * C<$feature_list> - The feature list to modify.

=item * C<$feature> - The feature to add.

=item * C<$value> - 1 to enable the feature, 0 to disable the feature.

=item * C<$verbose> - 1 to print progress details, 0 to run quietly.

=item * C<$add_only> - Optional. If true, only adds the feature if it
is not already present.

=back

=cut

sub modify_featurelist ( $feature_list, $feature, $value, $verbose, $add_only = 0 ) {

    say "Attempting to modify: $feature_list" if $verbose;

    my $features = eval { Cpanel::Features::Load::load_featurelist($feature_list) };
    if ( my $error = $@ ) {
        logger()->warn("Unable to add default feature entry $feature=$value to feature list $feature_list: $error");
        return;
    }

    # load_featurelist returns undef (without a warning) when the list file
    # does not exist; update_featurelist creates the file at write time.
    $features //= {};

    # If add_only is true and feature already exists, skip the update
    if ( $add_only && exists $features->{$feature} ) {
        say "Feature $feature already exists in $feature_list with value $features->{$feature}, skipping update" if $verbose;
        return;
    }

    $features->{$feature} = $value;

    # Instead of calling whmapi1 directly, we call the backend module to avoid additional overhead of whmapi1 calls
    # but ensure we still get the same result as if we were to call the API.
    # including has_root as 1 to bypass _check_reseller_permissions
    my ($modified) = eval { Cpanel::Features::update_featurelist( $feature_list, $features, 'root', 1 ); };
    if ( my $error = $@ ) {
        my $error_message = Cpanel::Exception::get_string_no_id($error);
        $error_message = "$error" if !defined $error_message;
        my $skip_message = "Skipping update for feature list $feature_list due to exception: $error_message";
        logger()->warn($skip_message);
        say $skip_message if $verbose;
        return;
    }

    # update_featurelist returns undef when it wrote nothing: the value is
    # already in effect, or — the case every enabling --all sweep hits — the
    # entry is absent from the 'default' list and the value is truthy, which
    # core treats as already enabled and discards without touching the file.
    # Nothing was written, so there is nothing to log or count.
    if ( !$modified ) {
        say "Feature $feature=$value is already in effect for feature list $feature_list, skipping update" if $verbose;
        return;
    }

    # Record every write durably: the calling lifecycle usually discards
    # stdout, so without these lines nothing says which lists a seed changed.
    # A write to 'default' logs at warn — that list applies to every account
    # without its own explicit entry, so this is the one write that can undo
    # an enable-by-absence a hoster created by deleting the entry.
    if ( $feature_list eq 'default' ) {
        logger()->warn("Added feature entry $feature=$value to the 'default' feature list, which applies to every account without an explicit $feature entry in its own feature list. Explicit entries on named feature lists are unaffected.");
    }
    else {
        logger()->info("Added feature entry $feature=$value to feature list $feature_list");
    }

    say "Feature $feature added to feature list $feature_list with default of $value" if $verbose;

    return 1;
}

=head2 do_once()

Creates a touch file to track whether a task has been done.
As long as the touch file exists, the task will not run again.

The touch file is only written after the task returns without
dying, so a run that dies is retried on the next invocation. Note
that C<modify_featurelist> catches and logs per-list write failures
rather than dying, so a sweep that skips lists still counts as done;
only an exception outside it triggers a retry. An exclusive
flock-backed guard is held while the task runs, so
concurrent invocations cannot execute the task at the same time; a
waiter that acquires the guard after another invocation succeeded
skips the task, and a waiter that cannot acquire the guard within
the wait period gives up and leaves the task for the next
invocation.

=cut

sub do_once (%opts) {
    return unless $opts{version} && $opts{code} && ref $opts{code} eq 'CODE';

    my $lock = _lock_name(%opts);
    return if -e $lock;

    # The completion marker only blocks invocations that start after a
    # success; the guard keeps concurrent invocations from both running
    # the task inside that window.
    #
    # The transaction is used purely as a mutex: it holds a kernel flock
    # on its path for as long as the task runs, and SafeFile's stale-lock
    # handling never breaks a live holder's flock — a waiter that outlasts
    # _DO_ONCE_GUARD_WAIT dies instead of acquiring (caught below). A
    # dotlock-only guard (e.g. Cpanel::FileGuard) does not give that
    # guarantee: with no flock on the target, a waiter breaks the lock
    # after the wait and runs the task concurrently. The guard must lock
    # a separate path, never the marker itself: creating the marker would
    # make every invocation see the task as already done. The guard file
    # is left in place on release; deleting it would reopen the window in
    # which a waiter can acquire a lock its owner no longer holds.
    my $guard = eval {
        Cpanel::Transaction::File::Raw->new(
            path          => $lock . '.lock',
            lock_waittime => _DO_ONCE_GUARD_WAIT,
        );
    };
    if ( !$guard ) {
        warn($@);
        return;
    }

    # A concurrent invocation may have completed the task while this
    # one waited for the guard.
    return if -e $lock;

    my $ret = eval { $opts{code}->(); };
    if ($@) {
        warn($@);
        return $ret;
    }

    _mark_did_once(%opts);

    return $ret;
}

sub _mark_did_once (%opts) {

    my $lock = _lock_name(%opts);

    if ( !Cpanel::FileUtils::TouchFile::touchfile($lock) ) {
        warn("Failed to touch cpanel $opts{version} version file");
    }
    return 1;
}

sub _lock_name (%opts) {
    return $VERSION_DIR . '/cpanel' . $opts{version};
}

sub load_config_file ($file) {
    require Common::JSONC;
    return Common::JSONC::load_jsonc($file);
}

=head2 I<OBJ>->run()

Runs this script.

=cut

sub run ($self) {
    my $config = {};

    my $config_path = $self->getopt('config');
    if ($config_path) {
        $config = load_config_file($config_path);
    }

    $config->{verbose} = $self->getopt('verbose') // !!0;

    # fixed json deserialize issue for add_only
    $config->{add_only} = !!1 if $config->{add_only} && $config->{add_only} eq 'true';
    $config->{add_only} = !!0 if $config->{add_only} && $config->{add_only} eq 'false';
    $config->{add_only} //= !!0;    # Default to false (existing behavior)

    $config->{flag} //= $self->getopt('flag')
      || die $self->full_help();

    $config->{feature} //= $self->getopt('feature')
      || die $self->full_help();

    # only allow one of these arguments
    if ( $self->getopt('enable') && $self->getopt('disable') ) {
        say "ERROR: Only one of --enable or --disable flags may be passed.";
        die $self->full_help();
    }

    # fixed json deserialize issue
    $config->{enable} = !!1 if $config->{enable} && $config->{enable} eq 'true';
    $config->{enable} = !!0 if $config->{enable} && $config->{enable} eq 'false';

    # overwrite if set by command line
    $config->{enable} = !!1 if $self->getopt('enable');
    $config->{enable} = !!0 if $self->getopt('disable');
    die $self->full_help() if !defined $config->{enable};

    # fixed json deserialize issue
    $config->{all} = !!1 if $config->{all} && $config->{all} eq 'true';
    $config->{all} = !!0 if $config->{all} && $config->{all} eq 'false';

    # overwrite if set by command line
    $config->{all} = !!1 if $self->getopt('all');

    $config->{list} = $self->getopt('list') if $self->getopt('list') && $self->getopt('list')->@*;
    die $self->full_help() if !$config->{all} && ( !$config->{list} || ref $config->{list} ne 'ARRAY' || !$config->{list}->@* );

    my $make_changes = !!0;
    if ( $config->{condition} ) {
        require Cpanel::Plugins::Components::Rules;
        my $rules_obj = Cpanel::Plugins::Components::Rules->new( 'config' => $config->{condition} );
        $make_changes = $rules_obj->is_allowed();
    }
    else {
        $make_changes = !!1;
        say "No condition provided, proceeding with feature list modification" if $config->{verbose};
    }

    if ( !$make_changes ) {
        logger()->info("Skipping feature list modification as per the conditional rule passed");
        say "Skipping feature list modification as per the conditional rule that didn't match." if $config->{verbose};
        return 0;
    }

    _verify_directories();

    my $apply = sub {
        my $verbose = $config->{verbose};
        say "Running feature list modification for $config->{feature} with enable set to $config->{enable}";
        if ( $config->{all} ) {
            modify_feature_for_all_feature_lists( $config->{feature}, $config->{enable} ? "1" : "0", $verbose, $config->{add_only} );
        }
        else {
            foreach my $feature_list ( $config->{list}->@* ) {
                modify_featurelist( $feature_list, $config->{feature}, $config->{enable} ? "1" : "0", $verbose, $config->{add_only} );
            }
        }
    };

    do_once(
        'version' => $config->{flag},
        'eol'     => 'never',
        'code'    => $apply,
    );

    return;
}

1;
