package Cpanel::CCS::Userdata;

# cpanel - Cpanel/CCS/DBUtils.pm                   Copyright 2020 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

use strict;
use warnings;

use Cpanel::Debug                  ();
use Cpanel::Sys::Hostname          ();
use Cpanel::Transaction::File::Raw ();
use Cpanel::UUID                   ();

# Now load CCS specific code
use lib '/var/cpanel/perl';
use Cpanel::CCS::Constants ();

# HB-5216
our $mig_touchfile = Cpanel::CCS::Constants::CCS_CPDATA_DIR . "/.ccs_persistence_usernames_migrated";

# Cache persistence HR as a package variable to enable popping it easily.
our $CCS_PERSISTENCE_FILE = Cpanel::CCS::Constants::CCS_CPDATA_DIR() . '/ccs-persistance.json';
our $PERSISTENCE_HR_CACHE;

=head1 NAME

Cpanel::CCS::Userdata

=head1 SYNOPSIS

    my $users_hr = Cpanel::CCS::Userdata::get_users();

=head1 DESCRIPTION

Module all about mongling CCS userdata, since we have to do quite a bit of that.

=head1 SUBROUTINES

=head2 load_persistence_file

Loads /var/cpanel/ccs_persistence.json into memory cache if not already loaded.
$force arg forces reload.

=cut

sub load_persistence_file {
    my ($force) = @_;
    undef $PERSISTENCE_HR_CACHE  if $force;
    return $PERSISTENCE_HR_CACHE if $PERSISTENCE_HR_CACHE;

    require Cpanel::JSON;
    local $@;
    eval { $PERSISTENCE_HR_CACHE = Cpanel::JSON::SafeLoadFile($CCS_PERSISTENCE_FILE); };

    # If it doesn't exist or is corrupted, etc., then just return skeleton HR
    return { map { $_ => undef } qw{users groups} } if !$PERSISTENCE_HR_CACHE;
    return $PERSISTENCE_HR_CACHE;
}

=head2 get_user_uuid_map

Returns a shallow copy of the map of users => uuids within CCS.

=cut

# Return a copy, since this is a cache.
# Since it's only one level deep, shallow clone works fine
sub get_user_uuid_map {
    my $hr = load_persistence_file();
    return $hr->{'users'} ? { %{ $hr->{'users'} } } : {};
}

=head2 get_group_uuid_map

Returns a shallow copy of the map of groups => uuids within CCS.

=cut

sub get_group_uuid_map {
    my $hr = load_persistence_file();
    return $hr->{'groups'} ? { %{ $hr->{'groups'} } } : {};
}

=head2 get_user_uid

Returns the UID of the passed in $user. Subsequent argument can be hash opts:
    'autocreate' => 1 # By default autocreates users.

=cut

sub get_user_uid {
    my ( $user, %opts ) = @_;
    $opts{'autocreate'} //= 1;
    my $user_lookup = get_user_uuid_map();
    my $uuid;
    if ( exists $user_lookup->{$user} ) {
        $uuid = $user_lookup->{$user};
    }
    elsif ( $opts{'autocreate'} ) {

        # HB-5216: We need to migrate the main cpuser from user@maindomain to just user
        # Which could also cause this not to be found. As such, look for that if our
        # migration touchfile does not exist.
        if ( !-f $mig_touchfile ) {
            require Cpanel::Config::HasCpUserFile;
            if ( Cpanel::Config::HasCpUserFile::has_cpuser_file($user) ) {
                require Cpanel::Config::LoadCpUserFile;
                my $cpuser_hr = Cpanel::Config::LoadCpUserFile::load($user);
                my $domain    = $cpuser_hr->{'DNS'};
                $uuid = $user_lookup->{"$user\@$domain"} if ( exists $user_lookup->{"$user\@$domain"} );
            }
        }

        # In this case we wish to alter the currently cached state,
        # So access the cache directly in this case.
        # Uppercase it to be consistent with how MacOS did it.
        $uuid = $PERSISTENCE_HR_CACHE->{'users'}{$user} = uc( Cpanel::UUID::random_uuid() );
    }
    return $uuid;
}

=head2 update_user_uuid

Inputs are CCS username and UUID. Changes the CCS user to have the specified
UUID.

=cut

sub update_user_uuid {
    my ( $email, $uuid ) = @_;

    my $hr = load_persistence_file();

    $hr->{'users'}{$email} = $uuid;

    return;
}

=head2 get_group_uid

returns the UID for the given goup. Autocreates it if none exist.

=cut

sub get_group_uid {
    my ($group_name) = @_;
    my $group_lookup = get_group_uuid_map();
    $group_lookup->{$group_name} = Cpanel::UUID::random_uuid() if ( !exists $group_lookup->{$group_name} );
    return $group_lookup->{$group_name};
}

=head2 commit_updated_uuids

Any changes done with previous subroutines are committed to disk once this
is ran.

=cut

sub commit_updated_uuids {
    require Cpanel::Mkdir;
    Cpanel::Mkdir::ensure_directory_existence_and_mode( Cpanel::CCS::Constants::CCS_CPDATA_DIR(), 0710 );
    Cpanel::JSON::DumpFile( $CCS_PERSISTENCE_FILE, $PERSISTENCE_HR_CACHE );
    if ( !-f $mig_touchfile ) {
        require Cpanel::FileUtils::TouchFile;
        Cpanel::FileUtils::TouchFile::touchfile($mig_touchfile);
    }
    return;
}

=head2 get_users

Returns a HASHREF of userdata for CCS users.
Optionally accepts an array of cpusers to filter by.

=cut

sub get_users {
    my (@cpusers2filter_for) = @_;
    my $output = {};

    my $directories = create_directory(@cpusers2filter_for);
    my $users_ar;
    foreach my $directory ( @{$directories} ) {
        $users_ar = $directory->{'users'};
        foreach my $user_email ( @{$users_ar} ) {
            $output->{$user_email} = { 'cpanel_user' => $directory->{'group'}, 'uuid' => get_user_uid($user_email) };
        }
    }

    return $output;
}

=head2 create_directory

Creates the CCS directory for either all users or the specified users
(if passed in as array).

=cut

our $cached_directories;

sub create_directory {
    return $cached_directories if $cached_directories;
    my @users = @_;

    my $directory = [];

    require Cpanel::PwCache::Build;
    require Cpanel::PwCache::Helpers;
    Cpanel::PwCache::Helpers::no_uid_cache();    #uid cache only needed if we are going to make lots of getpwuid calls
    Cpanel::PwCache::Build::init_passwdless_pwcache();

    # Get the list of users
    require Cpanel::Config::LoadUserDomains;
    my $user_to_primary_domain_map_ref = {};
    if (@users) {
        require Cpanel::AcctUtils::Domain;
        foreach my $user (@users) {
            my $dom = Cpanel::AcctUtils::Domain::getdomain($user);
            next if !$dom;
            $user_to_primary_domain_map_ref->{$user} = $dom;
        }
    }
    else {
        $user_to_primary_domain_map_ref = Cpanel::Config::LoadUserDomains::loadtrueuserdomains( undef, 1 );
    }

    if ( !scalar keys %$user_to_primary_domain_map_ref ) {
        my $msg = "CCS: No cPanel accounts exist on the server.";
        $msg = "CCS: No cPanel user(s) matching the requested account(s) exist on the server." if @users;
        Cpanel::Debug::log_info($msg);
        return [];
    }
    require Cpanel::AcctUtils::Suspended;
    my $domain_to_user_map_ref = Cpanel::Config::LoadUserDomains::loaduserdomains( undef, 1 );
    my %suspended              = map { $_ => Cpanel::AcctUtils::Suspended::is_suspended($_) } keys %$user_to_primary_domain_map_ref;

    # Create the basic directory entry for this user's "realm".
    push @$directory, map {
        {
            'realm'      => $user_to_primary_domain_map_ref->{$_},
            'group'      => $_,
            'groupadmin' => "$_",
            'locations'  => [],                                      # XXX TODO need to have some way to add these to the directory for your organization.
            'users'      => [
                "$_",
            ],
            'suspended' => $suspended{$_} ? 1 : 0,
        }
    } sort keys %$user_to_primary_domain_map_ref;

    foreach my $acct (@$directory) {
        my $virtusers = _list_pops_for_user_with_extra_sauce( $acct->{'group'}, $domain_to_user_map_ref );
        foreach my $virt_user ( keys(%$virtusers) ) {

            # If the main account is suspended, everything else will be
            my $suspended = $acct->{'suspended'};

            # Otherwise check if the "DB" has suspension recorded.
            $suspended = $virtusers->{$virt_user}{'suspended_login'} if ( !$suspended );
            $acct->{'virt_suspended'}{$virt_user} = $suspended;

            # Add the user to the directory.
            push @{ $acct->{'users'} }, $virt_user;
        }
    }

    $cached_directories = $directory if !@users;
    return $directory;
}

# Almost entirely taken from Whostmgr::Email::list_pops_for().
sub _list_pops_for_user_with_extra_sauce {
    my ( $user, $domain_to_user_map_ref ) = @_;
    die 'Please specify a user.' if !$user;
    require Cpanel::PwCache;
    my $user_homedir      = Cpanel::PwCache::gethomedir($user) || die "The “$user” user does not exist.";
    my @this_user_domains = grep { $domain_to_user_map_ref->{$_} eq $user } keys(%$domain_to_user_map_ref);

    require Cpanel::Validate::EmailLocalPart;
    require Cpanel::AccessIds::ReducedPrivileges;

    # WARNING | This information is being loaded from an untrusted source under the user's control.
    # WARNING | It must only be used in conjunction with a trusted list of domains belonging to
    # WARNING | the user.
    my ( $untrusted_pops_info, $_manage_err );
    require Cpanel::Email::Accounts;
    Cpanel::AccessIds::ReducedPrivileges::call_as_user(
        $user,
        sub {
            local $Cpanel::user    = $user;
            local @Cpanel::DOMAINS = @this_user_domains;

            # HB-5757: Eval this since it dies when user is over quota
            local $@;
            eval {
                ( $untrusted_pops_info, $_manage_err ) = Cpanel::Email::Accounts::manage_email_accounts_db(
                    'event'       => 'fetch',
                    'no_disk'     => 1,
                    'no_validate' => 1,         # read only
                );
            };
            $_manage_err = $@ if $@;
            warn $_manage_err if $_manage_err;
            return 1;
        }
    );

    # Important: Only use the untrusted data as a reference for the list of local parts,
    # not for the list of domains.
    my %trusted_pops_hash;
    for my $domain ( keys %$untrusted_pops_info ) {
        next if ( $domain_to_user_map_ref->{$domain} // '' ) ne $user;
        for my $acct ( keys %{ $untrusted_pops_info->{$domain}{accounts} || {} } ) {
            if (
                ( length $acct && $acct !~ tr/a-zA-Z0-9!#\$\-=?^_{}~//c )    # If it only contains safe chars skip the expensive validate
                || Cpanel::Validate::EmailLocalPart::is_valid($acct)
            ) {
                $trusted_pops_hash{"${acct}\@${domain}"} = $untrusted_pops_info->{$domain}{accounts}{$acct};
            }
        }
    }

    # At this point, we are returning verified data that the caller should be able to trust
    # does not contain any email addresses with bogus domains, even if the account in question
    # had manipulated their email_accounts.json file to misrepresent the domain list.
    return \%trusted_pops_hash;
}

sub _generate_xml_blob {
    my ($directory) = @_;

    # This is actually less efficient and more code than manually writing
    # the XML. However, this will help prevent generating "invalid" XML due
    # to using a generator even though XML::Simple does things in quite the
    # ugly manner (no self-closing tags, etc.). You may also be asking
    # "Why XML::Simple when the POD explicitly reccomends you not to use it
    # in new code?" Well, it's the module reccomended by our standards, so
    # there we go. I could alternatively use XML::LibXML, but that's only
    # "recommended" for use when the XML is "too hard" to generate/parse with
    # XML::Simple. That is not the case in this circumstance.

    # Note here that the XML schema in general favored by CCS here doesn't
    # look very efficient in the first place. Most tags look like they could
    # have been implemented as attributes of the parent element, as often they
    # both contain no extra attribute themselves, just a tag name as a value.
    # Presumably whatever python programmer wrote the XML reader stuff
    # just "did what it took to make it work" at the time.
    require Cpanel::Config::userdata::Load;
    require Cpanel::Sys::Hostname;
    my $hostname = Cpanel::Sys::Hostname::gethostname();

    my $root_uid = get_user_uid('root');
    my $records  = [
        {
            'type'          => 'user',
            'uid'           => [$root_uid],
            'guid'          => [$root_uid],
            'short-name'    => [ 'root', "root\@$hostname" ],
            'email'         => ["root\@$hostname"],
            'full-name'     => ['root'],
            'cpanel-user'   => ['root'],
            'password'      => [''],
            'has-calendars' => [ { 'false' => [""] } ],
            'login-allowed' => [ { 'true'  => [""] } ],
        },
    ];
    my $template_ref = {
        'directory' => {
            'realm'  => "Calendar and Contacts Server",
            'record' => $records,
        },
    };
    require XML::Simple;
    my $xml_obj = XML::Simple->new( 'ForceArray' => 1, 'KeepRoot' => 1 );

    # XML::Simple doesn't do doctype declarations.
    # Pretty hard to mess this part up, so just do it manually.
    my $xml_output = "<!DOCTYPE accounts SYSTEM \"accounts.dtd\">\n";

    my $user_lookup  = get_user_uuid_map();
    my $group_lookup = get_group_uuid_map();
    foreach my $group ( @{$directory} ) {
        my $group_admin = $group->{'groupadmin'};

        # Add the users
        foreach my $user ( @{ $group->{'users'} } ) {
            my $uid = get_user_uid($user);

            my $cpanel_user = $user;
            $user_lookup->{$user} = $uid;

            my $login_allowed = { 'true' => [""] };

            # If the cpuser is suspended, everyone is, so check it first
            $login_allowed = { 'false' => [""] } if ( $group->{'suspended'} || $group->{'virt_suspended'}{$user} );

            my ( $email, $users ) = ( $user, [$user] );
            if ( $user eq $group_admin ) {
                my $ud = Cpanel::Config::userdata::Load::load_userdata_main($user);
                my $main_domain = $ud->{main_domain};

                $email = "$user\@$main_domain";
                $users = [ $user, "$user\@$hostname", "$user\@$main_domain" ];
            }
            push @$records, {
                'type'          => 'user',
                'uid'           => [$uid],
                'guid'          => [$uid],
                'short-name'    => $users,
                'full-name'     => [$user],
                'email'         => [$email],
                'cpanel-user'   => [$cpanel_user],
                'password'      => [''],
                'has-calendars' => { 'true' => [""] },
                'login-allowed' => [$login_allowed],
            };
        }

        # Now add the group data
        my $group_email = "$group->{'groupadmin'}\@$hostname";
        my $group_name  = $group->{'group'};
        my $uid         = get_group_uid($group_name);

        $group_lookup->{$group_name} = $uid;

        my $record2push = {
            'type'       => 'group',
            'uid'        => [$uid],
            'guid'       => [$uid],
            'short-name' => [$group_name],
            'full-name'  => [$group_name],
            'email'      => [$group_email],
            'member-uid' => [],
        };

        foreach my $user ( @{ $group->{'users'} } ) {
            my $mid = $user_lookup->{$user};
            push @{ $record2push->{'member-uid'} }, $mid;
        }
        push @$records, $record2push;
    }

    return $xml_output .= $xml_obj->XMLout($template_ref);
}

=head2 output_xml

Will transform the output of create_directory to the XML that CCS will
consume.

=cut

sub output_xml {
    my ($directory) = @_;

    my $xml_output    = _generate_xml_blob($directory);
    my $ccs_user_file = Cpanel::CCS::Constants::CCS_USER_DIR() . '/' . Cpanel::CCS::Constants::CCS_USER_FILE();

    # Only lock it for as long as is necessary
    {
        my $trans = Cpanel::Transaction::File::Raw->new(
            path        => $ccs_user_file,
            permissions => 0640,
            ownership   => [
                'cpanel-ccs',
                'cpanel-ccs',
            ],
        );
        $trans->set_data( \$xml_output );
        $trans->save_and_close_or_die();
    }
    Cpanel::CCS::Userdata::commit_updated_uuids();

    return 1;
}

=head2 get_cpanel_account_users_uuids

Returns a list of users' uids for the given user.

=cut

sub get_cpanel_account_users_uuids {
    my ($user) = @_;

    my $directories = create_directory($user);
    my $users_ar;
    my $users_hr = { 'cpanel_user' => $user, 'users' => {} };
    foreach my $directory ( @{$directories} ) {
        if ( $directory->{'group'} eq $user ) {
            $users_ar = $directory->{'users'};
            foreach my $user_email ( @{$users_ar} ) {
                $users_hr->{'users'}->{$user_email} = get_user_uid($user_email);
            }
            last;
        }
    }

    return $users_hr;
}

1;
