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

# cpanel - bin/update_horde_config                 Copyright 2022 cPanel, L.L.C.
#                                                           All rights reserved.
# copyright@cpanel.net                                         http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited

package Cpanel::Bin::UpdateHorde;
use strict;
use warnings;

use Cpanel::AccessIds::ReducedPrivileges ();
use Cpanel::CLIProgress                  ();
use Cpanel::Config::LoadCpConf           ();
use Cpanel::Config::Users                ();
use Cpanel::Config::LoadUserDomains      ();
use Cpanel::Exception                    ();
use Cpanel::Horde::DB                    ();
use Cpanel::Horde::Enabled               ();
use Cpanel::PwCache                      ();
use Cpanel::PwCache::Build               ();
use Cpanel::Locale 'lh';
use Cpanel::SafeFile        ();
use Cpanel::StringFunc::Fmt ();
use Cpanel::Time            ();
use Cpanel::LoadFile        ();

use File::Find   ();
use Getopt::Long ();

my $ulcdir   = '/usr/local/cpanel';
my $DIR      = "$ulcdir/base";
my $hordedir = "$ulcdir/base/horde";
my $bin_dir  = "$ulcdir/bin";

use constant FAILED            => 0;
use constant SUCCEEDED         => 1;
use constant NO_CHANGES_NEEDED => 2;

my (
    $opt_user,
    $opt_full,
    $opt_deep,
    $opt_progress,
    $opt_dbonly,
    $opt_nophpini,
    $opt_confonly,
    $opt_dbversion,
    $opt_delete_database,
    $opt_create_defaults,
    $opt_quiet,
    $opt_help,
);

my $userdomains;
my $horde_creator;

if ( !Cpanel::Horde::Enabled::is_enabled() ) {
    print "Horde is not available for 108+\n";
    exit;
}

exit( __PACKAGE__->run(@ARGV) ) unless caller;

sub run {
    my @args = @_;

    my $exit_status = 0;

    _parse_opts_and_create_object( \@args );

    if ( !$opt_dbonly && !$opt_user ) {
        update_conf(0);
        validate_hooks();
    }

    unless ($opt_confonly) {

        # Make sure the db is there only print error if it's an unknown error.
        $ENV{LC_ALL} = 'C';

        my $users = Cpanel::Config::Users::getcpusers();
        Cpanel::PwCache::Build::init_passwdless_pwcache();

        if ( defined $opt_user ) {
            $users = [ grep { $_ eq $opt_user } @$users ];
            if ( !@$users ) {
                die "Couldn't find an account called $opt_user\n";
            }
        }

        $users = [ sort @$users ];

        info( lh()->maketext( 'Running database checks for [numf,_1] account(s) …', scalar(@$users) ) );

        my $bar = Cpanel::CLIProgress->new();
        $bar->init( pos => 0, max => scalar(@$users) );

        my ( @failed, @succeeded, @no_changes_needed );

        my $ctrlc_hit = 0;
        local $SIG{INT} = sub {
            if ($ctrlc_hit) {
                exit;
            }

            $ctrlc_hit = 1;
            print STDERR "\nHit control-c again to abort immediately; otherwise, the script will try to shut down cleanly.\n";
        };

        # Because we don't trust the data above, load the list of domains from a trusted source
        $userdomains = Cpanel::Config::LoadUserDomains::loaduserdomains(
            undef,
            1    # reverse
        );

        my $parallelizer;
        my $required_modules = 0;

        for my $n ( 0 .. $#$users ) {
            if ($ctrlc_hit) {
                last;    # user has initiated a clean shutdown.
            }

            my $user = $users->[$n];

            next if !$user;
            next if $user eq 'root';    # not needed and would cause a premature exit
            my $user_needs_changes = user_needs_changes($user);
            if ( !$opt_delete_database && !$opt_create_defaults && !$user_needs_changes ) {
                push @no_changes_needed, $user;
                if ( !$opt_progress && !$opt_quiet ) {
                    $bar->increment->draw;
                }
                next;
            }

            _require_modules() if !$required_modules;
            $required_modules = 1;
            $parallelizer ||= Cpanel::Parallelizer->new( process_limit => 4 );
            $parallelizer->queue(
                \&update_user, [ $user, $user_needs_changes ],
                sub {
                    #success
                    my ($data) = @_;
                    my ( $user, $status, $message ) = @{$data}{ 'user', 'status', 'message' };
                    if ( $status == SUCCEEDED ) {
                        push @succeeded, $user;
                    }
                    elsif ( $status == NO_CHANGES_NEEDED ) {
                        push @no_changes_needed, $user;
                    }
                    else {
                        push @failed, $user;
                        info( "\n" . lh->maketext( 'The [asis,Horde] update for the user “[_1]” failed because of an error:', $user ) . $message );
                    }

                    # If full progress mode is enabled or quiet mode is enabled, don't draw the concise progress bar.
                    if ( !$opt_progress && !$opt_quiet ) {
                        $bar->increment->draw;
                    }

                },
                sub {
                    #failed
                    warn @_;

                }
            );

        }

        print "\n";
        if ($parallelizer) {
            if ( my $jobs = $parallelizer->jobs_count() ) {
                print lh()->maketext( 'Starting update of [quant,_1,user,users] in parallel …', $jobs ) . "\n" if !$opt_quiet;
                $parallelizer->run();
            }
        }
        $bar->done;

        info( '-' x 72 );
        info( lh()->maketext('Summary:') );
        info();
        info( lh()->maketext( 'Ran database checks on [numf,_1] account(s).', scalar(@$users) ) );
        info();
        info( lh()->maketext( 'There were [numf,_1] accounts with failures during this process (see above): [_2]', scalar(@failed),            _pretty_username_list(@failed) ) );
        info( lh()->maketext( 'There were [numf,_1] accounts successfully processed: [_2]',                        scalar(@succeeded),         _pretty_username_list(@succeeded) ) );
        info( lh()->maketext( 'There were [numf,_1] accounts that did not need any work done: [_2]',               scalar(@no_changes_needed), _pretty_username_list(@no_changes_needed) ) );

        $exit_status = 1 if @failed;
    }

    return $exit_status;
}

sub user_needs_changes {
    my ($user) = @_;
    my ( $uid, $gid, $homedir ) = ( Cpanel::PwCache::getpwnam_noshadow($user) )[ 2, 3, 7 ];
    if ( !-e "$homedir/etc" ) {    # not a full cpanel account
        return 0;
    }
    my $privs = Cpanel::AccessIds::ReducedPrivileges->new( $uid, $gid );
    return $horde_creator->changes_needed();
}

sub update_user {
    my ( $user, $changes_needed ) = @_;

    # Grab a preview of whether changes are going to be needed so we can decide whether
    # to disable the quota or not. This should be faster than just blindly disabling it
    # every time.
    my ( $uid, $gid ) = ( Cpanel::PwCache::getpwnam_noshadow($user) )[ 2, 3 ];
    my ( $interface_lock, $error, $have_defaults, $tempquota );
    if ( $changes_needed || $opt_delete_database || $opt_create_defaults ) {
        local $ENV{PROGRESS_INFO} = sprintf( '% 20s | ', $user );
        local $0 = "update_horde_config |$ENV{PROGRESS_INFO}";

        $interface_lock = Cpanel::InterfaceLock->new(
            name              => 'HordeUpdate',
            user              => $user,
            unlock_on_destroy => 0,
        );
        my $lock_ok = $interface_lock->lock;

        if ( !$lock_ok ) {
            my $err = "\n";
            $err .= lh()->maketext('The system failed to acquire the [asis,HordeUpdate] lock. Another update is in progress or has failed to complete.') . "\n";
            $err .= lh()->maketext(
                'You must remove the lock file at “[_1]” and rerun the update script. The system has skipped this step because of an error: [_2]',
                Cpanel::InterfaceLock::Tiny::get_lock_path_for_user( "HordeUpdate", $user ),
                $!,
            ) . "\n";
            return { 'user' => $user, 'status' => FAILED, 'message' => $err };
        }
        my $email_accts;
        my @all_possible_horde_ids_for_user;
        local $@;
        eval {
            $email_accts = Whostmgr::Email::list_pops_for( $user, undef, $userdomains );

            @all_possible_horde_ids_for_user = ( $user, @$email_accts );
        };
        if ($@) {
            my $email_fetch_err = $@;

            return { 'user' => $user, 'status' => FAILED, 'message' => Cpanel::Exception::get_string($email_fetch_err) };
        }

        if ($changes_needed) {
            $tempquota = Cpanel::Quota::Temp->new( user => $user );
            $tempquota->disable();
        }

        ( $error, $have_defaults ) = Cpanel::AccessIds::do_as_user_group(
            $uid, $gid,
            sub {
                my $have_defaults = 0;
                local $@;
                eval {
                    if ($opt_delete_database) {
                        unlink Cpanel::Horde::DB::horde_db();

                        # yes, changes are needed because we just deleted the database
                    }
                    else {

                        # If the global_addressbook_shared setting exists, assume that update_horde_config --create-defaults has
                        # already run at some point for this cPanel user.
                        my $pref_result = Cpanel::DAV::Preference::get_preference( $user, 'cpanel', 'global_addressbook_shared' );
                        $have_defaults = $pref_result->meta->ok;

                    }
                };
                local $@;
                eval {
                    $horde_creator->create();
                    if ( $opt_create_defaults && !$have_defaults ) {
                        Cpanel::DBI->allow_dbh_caching(1);

                        for my $principal (@all_possible_horde_ids_for_user) {

                            my $resp = Cpanel::DAV::Defaults::create_calendars_and_address_books($principal);
                            die $resp->as_string if !$resp->meta->ok;

                            $resp = Cpanel::DAV::AddressBooks::set_identity( $principal, { force => 1 } );
                            die $resp->as_string if !$resp->meta->ok;
                        }
                        Cpanel::DBI->allow_dbh_caching(0);    # must close dbh
                    }
                };
                return ( ( $@ ? Cpanel::Exception::get_string($@) : 0 ), $have_defaults );
            }
        );

    }

    my $ret;
    if ($error) {
        info($error);
        $ret = { 'user' => $user, 'status' => FAILED, 'message' => $error };

    }
    elsif ($changes_needed) {
        $ret = { 'user' => $user, 'status' => SUCCEEDED, 'message' => '' };

    }
    else {
        $ret = { 'user' => $user, 'status' => NO_CHANGES_NEEDED, 'message' => '' };

    }

    $tempquota->restore() if $tempquota;

    if ($interface_lock) {
        my $unlock_ok = $interface_lock->unlock;
        if ( !$unlock_ok ) {
            $ret->{'message'} .= "\n";
            $ret->{'message'} .= lh()->maketext(
                'The system failed to release the [asis,HordeUpdate] lock for “[_1]”.',
                $user
            ) . "\n";
        }
    }

    return $ret;

}

sub update_conf {
    my $cpconf = Cpanel::Config::LoadCpConf::loadcpconf_not_copy();

    my $conf = 'config/conf.php';

    # CentOS 6/7 should always use hunspell
    my $hunspell = '/usr/bin/hunspell';

    my $horde_conf = "$hordedir/$conf";
    info("horde conf: $hordedir/$conf");
    my $conf_lock = Cpanel::SafeFile::safeopen( \*HORDECONF, '<', $horde_conf );

    if ( !$conf_lock ) {
        die("Unable to read $horde_conf: $!");
    }
    my @horde_conf_lines = <HORDECONF>;
    Cpanel::SafeFile::safeclose( \*HORDECONF, $conf_lock );

    my $lock = Cpanel::SafeFile::safeopen( \*HORDECONF, '>', $horde_conf );
    if ( !$lock ) {
        die("Unable to write $horde_conf: $!");
    }

    my $tmpdb_config = <<'EOS';
if (getenv('CPANEL_HORDE_TMPDB')) {
    $conf['sql']['database'] = getenv('CPANEL_HORDE_TMPDB');
}
EOS

    # use a oneliner to remove/update it easier
    $tmpdb_config =~ s{[\n\s]+}{ }g;
    $tmpdb_config .= "\n";

    my $handled_sql_section;
    my $openssl_decl = qq{\$conf['openssl']['path'] = '/usr/bin/openssl';\n};
    my $port_decl    = qq{\$conf['mailer']['params']['port'] = 587;\n};
    foreach my $line (@horde_conf_lines) {
        if ( $line =~ m/^\s*\$\Qconf['sql']/ ) {
            if ( !$handled_sql_section++ ) {
                print HORDECONF <<'SQL';
$conf['sql']['phptype']  = 'sqlite';
$conf['sql']['database'] = posix_getpwuid( posix_getuid() )['dir'] . '/.cphorde/horde.sqlite';
$conf['sql']['mode']     = 0600;
$conf['sql']['charset']  = 'UTF-8';
SQL
            }
        }
        elsif ( $line =~ m/^\s*\$\Qconf['spell']['params']['path']\E/ ) {
            print HORDECONF q{$conf['spell']['params']['path'] = '} . $hunspell . "';\n";
        }
        elsif ( $line =~ m/^\s*\$\Qconf['openssl']['path']\E/ ) {
            print HORDECONF $openssl_decl;
            $openssl_decl = '';
        }
        elsif ( $line =~ m/^\s*\$\Qconf['mailer']['params']['port']\E/ ) {
            print HORDECONF $port_decl;
            $port_decl = '';
        }
        elsif ( $line =~ m/CPANEL_HORDE_TMPDB/ || $line =~ /^\s*$/ ) {
            next;
        }
        elsif ( $line =~ m{/\* CONFIG END} ) {
            print HORDECONF $openssl_decl;
            print HORDECONF $port_decl;
            print HORDECONF $line;
        }
        else {
            print HORDECONF $line;
        }
    }

    print HORDECONF "\n" . $tmpdb_config;

    Cpanel::SafeFile::safeclose( \*HORDECONF, $lock );

    # TODO: check if $conf.rpmsave or .oldsave exists (edited file), 3-way merge
    # if !$update apply the typical 1->5 changes, like Upcase firstchar of some static values (sql => Sql)
    # ...

    if ($handled_sql_section) {
        chmod( 0644, "$hordedir/$conf" );    # The file no longer contains a password
        info('Successfully updated the sql section of the Horde configuration.');
    }
    else {
        info('Failed to update the sql section of the Horde configuration.');    # The file might still contain a password
    }

    return;
}

sub validate_hooks {

    # Valid hooks.php files contain a class of the form App_Hooks or APP_Hooks. If there are hooks.php files in /config
    # directories that don't meet this requirement, newer versions of Horde error out.
    #
    # In some earlier versions, it seems like example files were named hooks.php and just commented out instead of using
    # the hooks.php.dist convention Horde uses now. On some older boxes that have been through many upgrades, they might
    # still have the older version, so we need to check and make sure Horde won't be completely broken.

    my $timestamp = Cpanel::Time::time2dnstime();    # YYYYMMDD
    File::Find::find(
        sub {
            if (   index( $File::Find::name, '/config/hooks.php' ) > -1
                && $File::Find::name =~ m{/config/hooks\.php$}
                && -f $File::Find::name
                && Cpanel::LoadFile::loadfile($File::Find::name) !~ /class\s+[A-Z]\w+_Hooks/ ) {

                my $file_old = $File::Find::name;
                my $file_new = "$file_old.$timestamp";
                print "Invalid Horde hooks.php file found: $file_old\n";
                require File::Copy if !$INC{'File/Copy.pm'};
                if ( File::Copy::move( $file_old, $file_new ) ) {
                    print " -> Moved to $file_new\n";
                }
                else {
                    print " -> Unable to backup/move. Horde will likely not work correctly until this file has been renamed.\n";
                }

            }
        },
        $hordedir
    );

    return;
}

# PROGRESS: Only print if --progress is specified
#     INFO: Always print
sub _callback {
    my ( $type, $msg ) = @_;
    return if $opt_quiet;
    if ( $type eq 'PROGRESS' ) {
        my $line = $ENV{PROGRESS_INFO} . $msg;
        if ($opt_progress) {
            info($line);
        }
    }
    elsif ( $type eq 'INFO' ) {
        info($msg);
    }
    return;
}

sub _usage {
    die <<EOF;
Usage: $0 [--user=...]

This script is responsible for:
  1. Creating the per-user SQLite databases for horde.
  2. Keeping the Horde tables up to date with schema changes.
  3. Assuring critical horde config variables are set correctly.

It should be run:
  - After updating any Horde components that resulted in a change of
    db schema (with --full)
  - After creating a new cPanel account (with --user=<thatuser>)
  - Routinely (with no arguments) in order to make sure all users
    have a Horde SQLite database.
  - If the Horde database(s) ever need to be repaired after having
    tables dropped/lost (with --full)

These uses of update_horde_config happen automatically, and administrators
should only need to run it manually if they believe something has gone wrong
with the automated run or with a Horde database.

Arguments:

        --user=...  Allows you to specify that you only want the per-user database operations
                    to be performed for a single user, not for all users. The global operations
                    (updating conf) will still be performed regardless of whether this option is
                    specified or not.

            --full  Check and upgrade the table schema instead of just checking that the
                    per-user databases exist on disk. This schema check used to be done
                    on every run of update_horde_config, but now that each user has their
                    own copy of the Horde database, this was too time-consuming. The
                    expectation now is that any updates to Horde that require schema
                    upgrades (which are exceedingly rare so far) will include a one-time
                    task to run update_horde_config --full. See also --deep.

            --deep  Only affects the --full flag. When provided, the database check will perform
                    a more thorough schema check. This check is much slower than the default,
                    so it should mainly be used for debugging.

        --progress  Report progress as each user is processed (noisy).

          --dbonly  Only attempt to check / fix database problems; do not update the Horde

                    system-wide configuration file.

        --confonly  Only attempt to check / fix configuration problems; do not perform any Horde
                    database schema checks.

 --dbversion=11.XX  Create the database using Horde table schemas that were current as of
                    cPanel & WHM version 11.XX. This is used by the backup restore system to
                    facilitate restoring pre-11.50 Horde data.

 --create-defaults  When creating or checking the database, ensure that the default address
                    book and calendar are created. This option is suitable for manual use in
                    the case where you are deleting and re-creating an entire database and want
                    the default calendars and address books back.

 --delete-database  Delete the destination database before doing anything else. This means that
                    you will be creating an empty database to replace the original one.
                    THIS WILL CAUSE PERMANENT DATA LOSS if you do not have a backup of the
                    original database.

           --quiet  Keep output to a minimum. Errors or warnings that are produced during
                    the process may still slip through, but the normal output explaining
                    what is being done will be suppressed.

        --confonly  Only update the Horde system-wide configuration file and validate hooks.php
                    files; do not attempt to check / fix database problems.

Examples:

                    Please see https://go.cpanel.net/hordetroubleshoot for example usage.
EOF
}

# Using this instead of Cpanel::Logger because Cpanel::Logger usage in this script will
# result in bad-looking output when run during/after upcp.
sub info {
    my ($msg) = @_;
    return if $opt_quiet;
    $msg ||= '';
    chomp $msg;
    return print "$msg\n";
}

sub _pretty_username_list {
    my @users = @_;
    push @users, 'n/a' if !@users;
    my $wrapped = Cpanel::StringFunc::Fmt::wrap( join( ', ', @users ), 68 );
    $wrapped =~ s/^/    /gm;
    return "\n" . $wrapped . "\n";
}

sub _parse_opts_and_create_object {
    my ($args_ar) = @_;
    $ENV{HOME} = Cpanel::PwCache::gethomedir() or die "Could not determine home directory: $!\n";
    my $getopt_status = Getopt::Long::GetOptionsFromArray(
        $args_ar,
        'user:s'          => \$opt_user,
        'full'            => \$opt_full,
        'deep'            => \$opt_deep,              # Goes along with --full for a deeper db check
        'progress'        => \$opt_progress,
        'dbonly'          => \$opt_dbonly,
        'nophpini'        => \$opt_nophpini,
        'confonly'        => \$opt_confonly,
        'dbversion:s'     => \$opt_dbversion,
        'create-defaults' => \$opt_create_defaults,
        'delete-database' => \$opt_delete_database,
        'quiet'           => \$opt_quiet,
        'help'            => \$opt_help,
      )
      and !$opt_help
      or _usage();

    _usage() if $opt_deep && !$opt_full;

    $horde_creator = Cpanel::Horde::DB->new(
        {
            callback    => \&_callback,
            full        => $opt_full,
            deep        => $opt_deep,
            dbversion   => $opt_dbversion,                            # undef ok
            run_php_ini => ( $opt_nophpini || $opt_user ) ? 0 : 1,    # only setup php when doing all users
        }
    );
    return;
}

sub _require_modules {
    require Cpanel::Parallelizer;
    require Cpanel::DAV::Preference;
    require Cpanel::DAV::Defaults;
    require Cpanel::DAV::AddressBooks;
    require Cpanel::DBI;
    require Cpanel::Quota::Temp;
    require Cpanel::InterfaceLock;
    require Cpanel::InterfaceLock::Tiny;
    require Whostmgr::Email;
    require Cpanel::AccessIds;
    return;
}

1;
