<?php
/**
 * @package   admintoolswp
 * @copyright Copyright (c)2017-2026 Nicholas K. Dionysopoulos / Akeeba Ltd
 * @license   GNU GPL version 3 or later
 */

namespace Akeeba\AdminTools\Admin\Model;

use Akeeba\AdminTools\Admin\Helper\HashHelper;
use Akeeba\AdminTools\Admin\Helper\Wordpress;
use Akeeba\AdminTools\Admin\Model\Mixin\ApacheVersion;
use Akeeba\AdminTools\Library\Encrypt\Randval;
use Akeeba\AdminTools\Library\Mvc\Model\Model;
use DateTime;
use DateTimeZone;

defined('ADMINTOOLSINC') or die;

class AdminPassword extends Model
{
	use ApacheVersion;

	/**
	 * The username for the administrator password protection
	 *
	 * @var  string
	 */
	public $username = '';

	/**
	 * The password for the administrator password protection
	 *
	 * @var  string
	 */
	public $password = '';

	/**
	 * Should I reset custom error pages?
	 *
	 * @var   bool
	 *
	 * @since 1.0.5
	 */
	public $resetErrorPages;

	/**
	 * Password hashing algorithm.
	 *
	 * One of bcrypt, apr1, sha1, crypt, or plain. Default: apr1.
	 *
	 * @var   string
	 * @since 1.7.0
	 */
	public string $hashtype = 'apr1';

	/**
	 * Protection mode. One of php, or everything. Default: everything.
	 *
	 * @var   string
	 * @since 1.7.0
	 */
	public string $mode = 'everything';

	/**
	 * Applies the back-end protection, creating an appropriate .htaccess and
	 * .htpasswd file in the administrator directory.
	 *
	 * @return  bool
	 */
	public function protect()
	{
		$cryptpw      = $this->apacheEncryptPassword($this->hashtype);
		$htpasswd     = $this->username . ':' . $cryptpw . "\n";
		$htpasswdPath = ABSPATH . 'wp-admin/.htpasswd';
		$htaccessPath = ABSPATH . 'wp-admin/.htaccess';

		if (!@file_put_contents($htpasswdPath, $htpasswd))
		{
			return false;
		}

		switch ($this->mode)
		{
			default:
			case 'everything':
				$mode       = "Everything";
				$comment    = "Enable password protection for all resources in this directory and its subdirectories";
				$wrapBefore = '';
				$wrapAfter  = '';
				break;

			case 'php':
				$mode       = "All PHP Files";
				$comment    = "Enable password protection for all .php files in this directory and its subdirectories";
				$wrapBefore = '<FilesMatch "\.php$">';
				$wrapAfter  = '</FilesMatch>';
				break;
		}

		$path = rtrim(ABSPATH . 'wp-admin', '/\\') . '/';
		$date = new DateTime();
		$tz   = new DateTimeZone(Wordpress::get_timezone_string());
		$date->setTimezone($tz);

		$d        = $date->format('Y-m-d H:i:s T');
		$version  = ADMINTOOLSWP_VERSION;
		$htaccess = <<< HTACCESS
################################################################################
## Administrator Password Protection
##
## This file was generated by Admin Tools $version on $d
##
## Password protection mode selected: $mode ({$this->mode})
## Password hashing algorithm used:   {$this->hashtype}
##
## If you are unable to access your site's wp-admin OR see a browser login 
## prompt in the public frontend of your site please delete this file and the
## .htpasswd file in the same folder. 
################################################################################

## $comment
<IfModule mod_auth_basic.c>
	$wrapBefore
	AuthUserFile "$path.htpasswd"
	AuthName "Restricted Area"
	AuthType Basic
	Require valid-user
	$wrapAfter
	
	## Forbid access to the .htpasswd file containing your (hashed) password
	<FilesMatch "^\.ht">
		Require all denied
	</FilesMatch>
	
	## Allow direct access to select WordPress core files
	<FilesMatch "^admin-ajax\.php$">
		Require all granted
	</FilesMatch>
</IfModule>

HTACCESS;

		if ($this->resetErrorPages)
		{
			$htaccess .= <<< HTACCESS
## Reset custom error pages to default
#
# Prevents a 404 error when trying to access your site's wp-admin directory
#
ErrorDocument 401 default
ErrorDocument 403 default

HTACCESS;

		}

		$htaccess .= <<< HTACCESS
## Always allow access to admin-ajax.php
<Files "admin-ajax.php">
	<IfModule !mod_authz_core.c>
		Order allow,deny
		Allow from all
		Satisfy any 
	</IfModule>
	<IfModule mod_authz_core.c>
		<RequireAny>
			Require all granted
		</RequireAny>
	</IfModule>
</Files>



# Always allow access to media directories, they're used for login and password reset
<IfModule mod_setenvif.c>
	SetEnvIf Request_URI "css/" allow_media_folder
	SetEnvIf Request_URI "js/" allow_media_folder
	SetEnvIf Request_URI "images/" allow_media_folder
</IfModule>
<IfModule !mod_authz_core.c>
	Order allow,deny
	Allow from env=allow
	Satisfy any
</IfModule>
<IfModule mod_authz_core.c>
	<RequireAny>
		Require env allow_media_folder
	</RequireAny>
</IfModule>

HTACCESS;


		$status = @file_put_contents($htaccessPath, $htaccess);

		if (!$status || !is_file($path . '/.htpasswd'))
		{
			@unlink($htpasswdPath);

			return false;
		}

		return true;
	}

	/**
	 * Removes the administrator protection by removing both the .htaccess and
	 * .htpasswd files from the administrator directory
	 *
	 * @return bool
	 */
	public function unprotect()
	{
		$htaccessPath = ABSPATH . 'wp-admin/.htaccess';
		$htpasswdPath = ABSPATH . 'wp-admin/.htpasswd';

		if (!@unlink($htaccessPath))
		{
			return false;
		}

		if (!@unlink($htpasswdPath))
		{
			return false;
		}

		return true;
	}

	/**
	 * Returns true if both a .htpasswd and .htaccess file exist in the back-end
	 *
	 * @return bool
	 */
	public function isLocked()
	{
		$htaccessPath = ABSPATH . 'wp-admin/.htaccess';
		$htpasswdPath = ABSPATH . 'wp-admin/.htpasswd';

		return @file_exists($htpasswdPath) && @file_exists($htaccessPath);
	}

	protected function apacheEncryptPassword(?string $hashType = 'apr1'): string
	{
		$hashType    = $hashType ?: 'apr1';
		$hashType    = in_array($hashType, ['apr1', 'bcrypt', 'sha1', 'crypt', 'plain'])
			? $hashType : 'apr1';
		$os = strtoupper(PHP_OS);
		$isWindows = substr($os, 0, 3) == 'WIN';
		$isApache24  = version_compare($this->apacheVersion(), '2.4', 'ge');
		$hasSha1     = function_exists('base64_encode') && function_exists('sha1');
		$hasHashSha1 = function_exists('hash') && function_exists('hash_algos') && in_array('sha1', hash_algos());

		// bCrypt is only supported on Apache 2.4+ when PHP's password_hash() is available and supports PASSWORD_BCRYPT.
		if ($hashType == 'bcrypt'
		    && !($isApache24 && function_exists('password_hash')
		         && defined('PASSWORD_BCRYPT')))
		{
			$hashType = 'apr1';
		}

		// SHA-1 is available only when sha1 hashing is available, and we can encode the result.
		if ($hashType === 'sha1')
		{
			$hashType = ($hasSha1 && $hasHashSha1 && function_exists('base64_encode'))
				? $hashType
				: 'apr1';
		}

		// Traditional crypt() is not available on Windows.
		if ($hashType === 'crypt' && !$isWindows)
		{
			$hashType = 'apr1';
		}

		$this->hashtype = $hashType;

		if ($hashType === 'bcrypt')
		{
			return password_hash($this->password, PASSWORD_BCRYPT);
		}

		// Iterated and salted MD5 (APR1)
		if ($hashType === 'apr1')
		{
			$salt = (new Randval())->generateString(4);

			return $this->apr1_hash($this->password, $salt, 1000);
		}

		// SHA-1 encrypted
		if ($hashType === 'sha1')
		{
			$sha1 = $hasHashSha1
				? hash('sha1', $this->password, true)
				: sha1($this->password, true);

			return '{SHA}' . base64_encode($sha1);
		}

		// Traditional crypt(3)
		if ($hashType === 'crypt')
		{
			$salt = (new Randval())->generateString(4);

			return crypt($this->password, $salt);
		}

		// Plain text fallback (you should NEVER use this!)
		return $this->password;
	}

	/**
	 * Perform the hashing of the password
	 *
	 * @param   string  $password    The plain text password to hash
	 * @param   string  $salt        The 8 byte salt to use
	 * @param   int     $iterations  The number of iterations to use
	 *
	 * @return  string  The hashed password
	 */
	protected function apr1_hash($password, $salt, $iterations)
	{
		$len  = strlen($password);
		$text = $password . '$apr1$' . $salt;
		$bin  = HashHelper::md5($password . $salt . $password, true);

		for ($i = $len; $i > 0; $i -= 16)
		{
			$text .= substr($bin, 0, min(16, $i));
		}

		for ($i = $len; $i > 0; $i >>= 1)
		{
			$text .= ($i & 1) ? chr(0) : $password[0];
		}

		$bin = $this->apr1_iterate($text, $iterations, $salt, $password);

		return $this->apr1_convertToHash($bin, $salt);
	}

	protected function apr1_iterate($text, $iterations, $salt, $password)
	{
		$bin = HashHelper::md5($text, true);

		for ($i = 0; $i < $iterations; $i++)
		{
			$new = ($i & 1) ? $password : $bin;

			if ($i % 3)
			{
				$new .= $salt;
			}

			if ($i % 7)
			{
				$new .= $password;
			}

			$new .= ($i & 1) ? $bin : $password;
			$bin = HashHelper::md5($new, true);
		}

		return $bin;
	}

	protected function apr1_convertToHash($bin, $salt)
	{
		$tmp = '$apr1$' . $salt . '$';

		$tmp .= $this->apr1_to64(
			(ord($bin[0]) << 16) | (ord($bin[6]) << 8) | ord($bin[12]),
			4
		);

		$tmp .= $this->apr1_to64(
			(ord($bin[1]) << 16) | (ord($bin[7]) << 8) | ord($bin[13]),
			4
		);

		$tmp .= $this->apr1_to64(
			(ord($bin[2]) << 16) | (ord($bin[8]) << 8) | ord($bin[14]),
			4
		);

		$tmp .= $this->apr1_to64(
			(ord($bin[3]) << 16) | (ord($bin[9]) << 8) | ord($bin[15]),
			4
		);

		$tmp .= $this->apr1_to64(
			(ord($bin[4]) << 16) | (ord($bin[10]) << 8) | ord($bin[5]),
			4
		);

		$tmp .= $this->apr1_to64(
			ord($bin[11]),
			2
		);

		return $tmp;
	}

	/**
	 * Convert the input number to a base64 number of the specified size
	 *
	 * @param   int  $num   The number to convert
	 * @param   int  $size  The size of the result string
	 *
	 * @return  string  The converted representation
	 */
	protected function apr1_to64($num, $size)
	{
		static $seed = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';

		$result = '';

		while (--$size >= 0)
		{
			$result .= $seed[$num & 0x3f];
			$num >>= 6;
		}

		return $result;
	}
}
