#!/usr/local/cpanel/3rdparty/bin/perl

#                                      Copyright 2026 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::backfill_nova_feature_lists;

use cPstrict;

=encoding utf-8

=head1 NAME

backfill_nova_feature_lists

=head1 SYNOPSIS

    backfill_nova_feature_lists [--help]

=head1 DESCRIPTION

This script backfills a Nova feature's default features into every
feature list where that feature is already enabled, not just the
plugin's own default list. This keeps existing Nova feature lists in
sync whenever new defaults are added to the feature's metadata (the
C<dependencies> entry in C<install.json>). It does this for two
anchor features independently: C<standalone-nova> and
C<nova-wordpress>.

The defaults are read from each anchor feature's own metadata rather
than being duplicated here, so they cannot drift from C<install.json>.
C<nova-wordpress>'s half also requires C<Cpanel::Nova::Meridian::is_expected()>,
which C<standalone-nova>'s does not: on the real upgrade path this
script only ever runs from C<Cpanel::Nova::LifeCycle>'s
C<@UPGRADE_STEPS>, where C<install_plugin> unconditionally republishes
C<nova-wordpress> metadata earlier in the same step list, before
C<Cpanel::Nova::Meridian>'s post-upgrade reconcile runs - so the
metadata is present here even on a server with no Meridian, and
without the C<is_expected()> check the C<default> feature list would
treat that transient metadata as reason enough to enable
C<nova-wordpress> itself (feature lists other than C<default> are
unaffected, since only C<default> auto-enables a feature it has no
explicit entry for). That would force-enable C<nova-wordpress>'s
dependencies - C<mysql>, C<redirects>, C<apitokens>,
C<wp-toolkit-site-import> - in the C<default> list on every
non-Meridian server, overriding any of them an administrator had
explicitly disabled there, for a feature the server can never actually
use. Either metadata being absent (a save failure, or this script
running outside the plugin lifecycle) or Meridian not being expected
skips this half of the backfill without touching its run-once lock -
it retries on every later upgrade until both conditions hold.

All of a feature list's new defaults are applied in a single write.
Each write re-validates the entire feature list against its
C<allow_list> and C<only_one_rules>, so writing one feature at a time
can fail validation partway through a list that is missing several
defaults and never converge.

Each anchor runs once per its own version constant (C<DEFAULTS_VERSION>
for C<standalone-nova>, C<WORDPRESS_DEFAULTS_VERSION> for
C<nova-wordpress>) under its own lock, so adding defaults to one
feature never forces a redundant re-run of the other. When new
defaults are added to an anchor's C<install.json> entry, bump its
version constant so existing servers re-run that half of the backfill.
Each lock is only marked complete once every Nova feature list is
inspected and written successfully for that anchor; if any list cannot
be inspected, loaded, or validated, the lock is left unset so the next
upgrade retries it.

The C<nova-wordpress> lock also encodes any WP Toolkit-owned dependency
that was trimmed from its metadata because WP Toolkit does not register
it on this server. The run that applied only the remaining dependencies
therefore does not stop a later run, once the dependency is registered,
from applying the full set.

A feature list counts as having C<standalone-nova> enabled only when it
writes C<1> for it.

For C<nova-wordpress> a list also counts when it does not mention the
feature at all and the C<default> list does not turn it off: core treats an
absent feature as enabled, so accounts on such a list are entitled to it.
This includes the C<default> list itself. The backfill therefore writes
C<nova-wordpress>'s dependencies into those lists too, overriding an
administrator's explicit C<=0> for a dependency (for example
C<wp-toolkit=0>) even though they never touched C<nova-wordpress> there.
That is an accepted trade-off. A list that omits C<nova-wordpress> but
enables a feature its C<block_list> forbids (a C<standalone-nova> list) is
skipped, since core would refuse the write. A feature list with
C<nova-wordpress> explicitly C<0>, or C<nova-wordpress> disabled
server-wide, is never touched.

Features disabled server-wide (present in the C<disabled> list) are
never written, since that server-wide state takes precedence over any
individual feature list and enabling them here would silently override
the administrator's choice.

Each run re-applies the feature's I<entire> current dependency list, not
just the defaults added since the previous C<DEFAULTS_VERSION>. Bumping
C<DEFAULTS_VERSION> again therefore re-enables every entry in that list
that is not disabled server-wide, including any that an administrator
may have since disabled within an individual feature list.

=head1 OPTIONS

=over 4

=item B<--help>, B<-h>

Show this help message.

=back

=head1 EXIT STATUS

Always exits 0; per-feature-list failures are logged and retried on the
next upgrade rather than failing the upgrade step.

=head1 SEE ALSO

L<Common::Plugins::Features>, L<Cpanel::Features>, L<Cpanel::Features::MetadataList>

=cut

use Common::Plugins::Features      ();
use Cpanel::Features               ();
use Cpanel::Features::Load         ();
use Cpanel::Features::Metadata     ();
use Cpanel::Features::MetadataList ();
use Cpanel::FileUtils::TouchFile   ();
use Cpanel::Imports;
use Cpanel::Nova::Meridian ();
use Getopt::Long           ();

use constant DEFAULTS_VERSION           => '2.5.0';
use constant WORDPRESS_DEFAULTS_VERSION => '1.0.0';
use constant LOCK_DIR                   => '/var/cpanel/version';

run() if !caller;

=head2 run()

Entry point. Loads both anchors' feature metadata in a single pass (see
L</_load_anchor_metadata()>), then backfills the C<standalone-nova>
anchor unconditionally and the C<nova-wordpress> anchor (a no-op,
retried on every later upgrade, wherever its feature metadata is
absent).

=head3 RETURNS

0 on a normal run. Returns 1, as a logged no-op with neither anchor
backfilled, only when the C<standalone-nova> feature metadata cannot be
loaded (C<install_plugin> must run first to publish it; this metadata
is not Meridian-gated, so this is a defensive branch that should not
occur on a real Nova install). Per-feature-list write failures never
cause this to return non-zero; they are logged and left for the next
upgrade to retry.

=cut

sub run() {
    Getopt::Long::GetOptions( 'help|h' => sub { print_usage(); exit(0); } )
      or do { print_usage(); exit(1); };

    my $metadata_by_feature = eval { _load_anchor_metadata() };
    my $standalone_metadata = $metadata_by_feature->{'standalone-nova'};

    if ( $@ || !$standalone_metadata ) {
        logger()->warn("Unable to load feature metadata for standalone-nova; skipping backfill: $@");
        return 1;
    }

    my @defaults = @{ $standalone_metadata->dependencies // [] };

    _do_once( 'backfill_nova_feature_lists_v' . DEFAULTS_VERSION, sub { return _backfill( 'standalone-nova', \@defaults ); } );

    _backfill_wordpress_defaults( $metadata_by_feature->{'nova-wordpress'} );

    return 0;
}

=head2 _load_anchor_metadata()

Loads feature metadata for both anchors, C<standalone-nova> and
C<nova-wordpress>, from a single
C<Cpanel::Features::MetadataList-E<gt>load_all()> pass rather than
calling C<Common::Plugins::Features::get_feature_metadata()> once per
anchor - that function reloads all feature metadata from disk on every
call, so calling it twice here would read it twice for no benefit (see
L<Cpanel::Nova::PackageFormOffers> for the same pattern).

=head3 RETURNS

HashRef - C<{ feature_name =E<gt> Cpanel::Features::Metadata object }>,
missing a key for any feature with no metadata.

=cut

sub _load_anchor_metadata() {
    return Cpanel::Features::MetadataList->new()->load_all()->metadata;
}

=head2 _enables_blocked_feature($effective, $anchor_feature, $block_list)

Returns true when the list C<$effective> enables any feature other than
C<$anchor_feature> that matches a pattern in C<$block_list>, the anchor's own
C<block_list> from its feature metadata.

=cut

sub _enables_blocked_feature ( $effective, $anchor_feature, $block_list ) {
    foreach my $feature ( keys %{$effective} ) {
        next     if $feature eq $anchor_feature || ( $effective->{$feature} // '' ) ne '1';
        return 1 if grep { $feature =~ m/$_/ } @{$block_list};
    }

    return 0;
}

=head2 _backfill_wordpress_defaults($metadata)

The C<nova-wordpress> half of L</run()>. Backfills the C<nova-wordpress>
defaults from C<$metadata> into every feature list where
C<nova-wordpress> is enabled, guarded by a run-once lock keyed on
C<WORDPRESS_DEFAULTS_VERSION>.

Split out from L</run()> because it is gated on two conditions
C<standalone-nova>'s half is not: C<nova-wordpress> feature metadata
can be absent (a metadata-save failure, or this script running outside
the plugin lifecycle - see the L</DESCRIPTION> for why it is not
otherwise absent even on a non-Meridian server), and
C<Cpanel::Nova::Meridian::is_expected()> can be false, which is the
actual "is Meridian available" signal the absent-metadata case looks
like but is not (see L</DESCRIPTION>). Either condition means running
the backfill would be pointless at best (an unusable feature) or
actively harmful (force-enabling C<nova-wordpress>'s dependencies in
the C<default> list on a server that can never use it). Either way it
is not an error: this half is skipped without setting its lock, so the
backfill is retried on the next invocation where both conditions hold.

=head3 ARGUMENTS

=over 4

=item C<$metadata> - Cpanel::Features::Metadata object, or undef if
C<nova-wordpress> has no metadata.

=back

=head3 RETURNS

Nothing meaningful; failures are logged, not surfaced to L</run()>'s
exit status.

=cut

sub _backfill_wordpress_defaults($metadata) {

    # Neither case is an error -- see the head2 above -- so nothing to log.
    return if !$metadata;
    return if !Cpanel::Nova::Meridian::is_expected();

    my @defaults = @{ $metadata->dependencies // [] };

    # A WP Toolkit-owned dependency missing from the metadata was trimmed
    # because it is not registered here (see
    # Cpanel::Nova::Meridian::trim_unregistered_dependencies()). Give that run
    # its own lock so the run with the full set still happens once WP Toolkit
    # registers it, instead of being skipped by this one's lock.
    my %declared = map { $_ => 1 } @defaults;
    my @owned    = Cpanel::Nova::Meridian::WP_TOOLKIT_OWNED_DEPENDENCIES();
    my $trimmed  = join '', map { "_without_$_" } sort grep { !$declared{$_} } @owned;

    my $base = 'backfill_nova_wordpress_feature_lists_v' . WORDPRESS_DEFAULTS_VERSION;

    # A lock only means "this dependency set was backfilled". If WP Toolkit
    # later changes the set back, the earlier set's lock would skip the
    # repair, so clear every other set's lock.
    for my $other ( _dependency_set_suffixes(@owned) ) {
        next if $other eq $trimmed;
        unlink LOCK_DIR . '/cpanel' . $base . $other;
    }

    _do_once( $base . $trimmed, sub { return _backfill( 'nova-wordpress', \@defaults, 1 ); } );

    return;
}

=head2 _dependency_set_suffixes(@owned)

Returns every lock-name suffix C<_backfill_wordpress_defaults> can use: the empty
string for the full set, and C<_without_$dep> for each combination of the
WP Toolkit-owned dependencies in C<@owned> being trimmed.

=cut

sub _dependency_set_suffixes (@owned) {
    my @suffixes = ('');
    for my $dep ( sort @owned ) {
        push @suffixes, map { "${_}_without_$dep" } @suffixes;
    }
    return @suffixes;
}

=head2 _backfill($anchor_feature, $defaults, $enabled_by_omission)

Applies every default feature in C<$defaults> that is not disabled
server-wide to every feature list where C<$anchor_feature> is enabled
(see L</DESCRIPTION> and C<$enabled_by_omission>), in a single validated
write per list. The C<default> template list is
included when it is a real feature list on the server.

=head3 ARGUMENTS

=over 4

=item C<$anchor_feature> - String - The feature whose enablement in a
feature list triggers backfilling C<$defaults> into that same list
(C<standalone-nova> or C<nova-wordpress>).

=item C<$defaults> - ArrayRef - Feature names to enable in each list.
Any feature disabled server-wide (in the C<disabled> list) is skipped.

=back

=head3 RETURNS

Boolean - true only if every Nova feature list was inspected and
written successfully. A list that cannot be inspected during discovery,
fails to load, or fails validation is logged and skipped, but causes
the overall result to be false. Returns true when no feature list has
C<$anchor_feature> enabled.

=cut

sub _backfill ( $anchor_feature, $defaults, $enabled_by_omission = 0 ) {

    # Skip the pseudo-lists that get_feature_lists() returns alongside real
    # user feature lists: 'disabled' is the server-wide off-switch, and
    # 'default'/'Mail Only' are built-in templates, none of which are plans we
    # should backfill. This matches how core's Cpanel::Features::modify_feature
    # sweeps feature lists.
    my @feature_lists =
      grep { !/^(?:disabled|default|Mail Only)$/ } Cpanel::Features::get_feature_lists();

    # The 'default' list is the template new accounts inherit, so keep it in
    # sync too when it is a real feature list on this server.
    push @feature_lists, 'default' if Cpanel::Features::Load::is_feature_list('default');

    # Server-wide disabled features (value '0' in the 'disabled' list) take
    # precedence over any individual feature list. Writing '=1' for one here
    # would persist a latent auto-enable that overrides the administrator's
    # choice the moment they re-enable it server-wide, so drop those from the
    # defaults before writing.
    my %disabled = Cpanel::Features::Load::load_featurelist( 'disabled', '=' );
    my @available_defaults =
      grep { !( exists $disabled{$_} && ( $disabled{$_} // '' ) eq '0' ) } @{$defaults};

    my $all_succeeded = 1;

    my ( $default_list, @block_list );
    if ($enabled_by_omission) {

        # A server-wide disabled anchor makes every list's setting moot.
        return 1 if exists $disabled{$anchor_feature} && ( $disabled{$anchor_feature} // '' ) eq '0';

        # Core's Cpanel::Features::Check treats a feature absent from a list as
        # enabled unless the 'default' list turns it off, so membership has to
        # follow that layering rather than the list file alone.
        $default_list = eval { Cpanel::Features::Load::load_featurelist('default') // {} };
        if ($@) {
            logger()->warn("Unable to load the default feature list while backfilling '$anchor_feature' defaults: $@");
            return 0;
        }

        # A list that omits the anchor counts as enabled, but core refuses to
        # save the defaults into it when it enables something the anchor blocks
        # (a standalone-nova list omitting nova-wordpress). That refusal would
        # leave the run-once lock unset and re-force the defaults on every
        # upgrade.
        @block_list = eval {
            my $anchor_metadata = Cpanel::Features::Metadata->new( { feature => $anchor_feature } );
            return () if !$anchor_metadata->is_metadata_exists;
            $anchor_metadata->load();
            return @{ $anchor_metadata->block_list // [] };
        };
        if ($@) {
            logger()->warn("Unable to load the '$anchor_feature' feature metadata while backfilling its defaults: $@");
            return 0;
        }
    }

    foreach my $list (@feature_lists) {

        # Discover membership here rather than via
        # get_featurelists_with_feature_pattern(): that helper silently drops
        # any list it cannot load, which would let this run report success and
        # set the run-once lock while a Nova list never received the new
        # defaults. Counting an inspection failure as an overall failure keeps
        # the lock unset so the next upgrade retries.
        my $effective = eval { Common::Plugins::Features::load_effective_featurelist($list) };
        if ( $@ || ref $effective ne 'HASH' ) {
            logger()->warn("Unable to inspect feature list '$list' while backfilling '$anchor_feature' defaults: $@");
            $all_succeeded = 0;
            next;
        }

        my $anchor_value = $enabled_by_omission ? ( $effective->{$anchor_feature} // $default_list->{$anchor_feature} // '1' ) : ( $effective->{$anchor_feature} // '' );
        next if $anchor_value ne '1';
        next if $enabled_by_omission && _enables_blocked_feature( $effective, $anchor_feature, \@block_list );

        my $features = eval { Cpanel::Features::Load::load_featurelist($list) };
        if ( $@ || !$features ) {
            logger()->warn("Unable to load feature list '$list' while backfilling '$anchor_feature' defaults: $@");
            $all_succeeded = 0;
            next;
        }

        $features->{$_} = '1' for @available_defaults;

        eval { Cpanel::Features::update_featurelist( $list, $features, 'root', 1 ); };
        if ($@) {
            logger()->warn("Skipping '$list' while backfilling '$anchor_feature' defaults: $@");
            $all_succeeded = 0;
            next;
        }
    }

    return $all_succeeded;
}

=head2 _do_once($flag, $code)

Run-once guard. Runs C<$code> only if the lock named C<$flag> does not
already exist under C<LOCK_DIR>. The lock is created only once
C<$code> returns a true value, so a failing or dying run leaves the
lock unset and retries on the next invocation.

=head3 ARGUMENTS

=over 4

=item C<$flag> - String - Lock name, appended to C<cpanel> under
C<LOCK_DIR>.

=item C<$code> - CodeRef - The work to perform once.

=back

=head3 RETURNS

The return value of C<$code>, or 1 if the lock already existed.

=cut

sub _do_once ( $flag, $code ) {
    my $lock = LOCK_DIR . '/cpanel' . $flag;

    return 1 if -e $lock;

    my $result = eval { $code->() };
    if ($@) {
        logger()->warn("Error while backfilling Nova default features: $@");
        return $result;
    }

    return $result if !$result;

    Cpanel::FileUtils::TouchFile::touchfile($lock);

    return $result;
}

=head2 print_usage()

Print command-line usage information.

=cut

sub print_usage() {
    say "Usage: $0 [--help]";
    say "";
    say "Backfills the standalone-nova and nova-wordpress features' default";
    say "features into every feature list where each is already enabled.";
    return;
}

1;
