<?php

/**
 * Vvveb
 *
 * Copyright (C) 2022  Ziadin Givan
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 */

namespace Vvveb;



/**
 * Escape a URL for HTML output.
 *
 * @param string $url URL to escape
 *
 * @return string Escaped URL
 */

function escUrl(string $url): string {
	return htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
}



/**
 * Escape an attribute value for HTML output.
 *
 * @param string $attr Attribute value to escape
 *
 * @return string Escaped attribute value
 */

function escAttr(string $attr): string {
	return htmlspecialchars($attr, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
}



/**
 * Escape a string for HTML output.
 *
 * @param string $url String to escape
 *
 * @return string Escaped string
 */

function escHtml(string $url): string {
	return htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
}



/**
 * Sanitize filename by removing dangerous characters.
 *
 * @param string $file          The filename to sanitize.
 * @param bool   $normalizePath Whether to normalize directory separators to the OS separator.
 *
 * @return string The sanitized filename.
 */

function sanitizeFileName(string $file, bool $normalizePath = true): string {
	if (! $file) {
		return $file;
	}

	//remove null bytes
	$file = str_replace(chr(0), '', $file);
	//sanitize, remove double dot .. and remove get parameters if any
	$prev = '';
	while ($prev != $file) {
		$prev = $file;
		$file = preg_replace('@\?.*$|\.{2,}|[^\w\-\./: \\\]@' , '', $file);
	}
	//replace directory separators with OS specific separator
	if ($normalizePath) {
		$file = str_replace(['\\', '/'], DS, $file);
	}

	return $file;
}



/**
 * Sanitizes HTML by removing dangerous elements and attributes.
 *
 * @param  mixed $string The string to sanitize. Non-string values are returned as-is.
 * @return mixed The sanitized string, or the original value if not a string.
 */

function sanitizeHTML(mixed $string): mixed {
	if (! is_string($string)) {
		return $string;
	}

	// Fix &entity\n;
	//add <> before tag entities to allow regex matching
	$string = str_replace(['&lt;', '&gt;'], ['&lt;<', '>&gt;'], $string);
	$string = str_replace(['&amp;', '&lt;', '&gt;'], ['&amp;amp;', '&amp;lt;', '&amp;gt;'], $string);
	$string = preg_replace('/(&#*\w+)[\x00-\x20]+;/u', '$1;', $string);
	$string = preg_replace('/(&#x*[0-9A-F]+);*/iu', '$1;', $string);
	$string = html_entity_decode($string, ENT_COMPAT, 'UTF-8');

	// Remove any attribute starting with "on" or xmlns
	$string = preg_replace('#(<[^>]+?[\x00-\x20"\'/])(?:on|xmlns)[^>]*+>#iu', '$1>', $string);
	$string = preg_replace('#[\x00-\x20"\'/](on|xmlns)[:\w]+[\x00-\x20"\'/]*=[\x00-\x20"\'/]*([\'"]+).+?\2#iu', '', $string);

	// Remove javascript: and vbscript: protocols
	$string = preg_replace('#([a-z]*)[\x00-\x20]*=[\x00-\x20]*([`\'"]*)[\x00-\x20]*j[\x00-\x20]*a[\x00-\x20]*v[\x00-\x20]*a[\x00-\x20]*s[\x00-\x20]*c[\x00-\x20]*r[\x00-\x20]*i[\x00-\x20]*p[\x00-\x20]*t[\x00-\x20]*:#iu', '$1=$2nojavascript...', $string);
	$string = preg_replace('#([a-z]*)[\x00-\x20]*=([\'"]*)[\x00-\x20]*v[\x00-\x20]*b[\x00-\x20]*s[\x00-\x20]*c[\x00-\x20]*r[\x00-\x20]*i[\x00-\x20]*p[\x00-\x20]*t[\x00-\x20]*:#iu', '$1=$2novbscript...', $string);
	$string = preg_replace('#([a-z]*)[\x00-\x20]*=([\'"]*)[\x00-\x20]*-moz-binding[\x00-\x20]*:#u', '$1=$2nomozbinding...', $string);

	// Only works in IE: <span style="width: expression(alert('Ping!'));"></span>
	$string = preg_replace('#(<[^>]+?)style[\x00-\x20]*=[\x00-\x20]*[`\'"]*.*?expression[\x00-\x20]*\([^>]*+>#i', '$1>', $string);
	$string = preg_replace('#(<[^>]+?)style[\x00-\x20]*=[\x00-\x20]*[`\'"]*.*?behaviour[\x00-\x20]*\([^>]*+>#i', '$1>', $string);
	$string = preg_replace('#(<[^>]+?)style[\x00-\x20]*=[\x00-\x20]*[`\'"]*.*?s[\x00-\x20]*c[\x00-\x20]*r[\x00-\x20]*i[\x00-\x20]*p[\x00-\x20]*t[\x00-\x20]*:*[^>]*+>#iu', '$1>', $string);

	// Remove namespaced elements (we do not need them)
	$string = preg_replace('#</*\w+:\w[^>]*+>#i', '', $string);

	do {
		// Remove really unwanted tags
		$old_data = $string;
		//$string   = preg_replace('#</*(?:applet|b(?:ase|gsound|link)|embed|frame(?:set)?|i(?:frame|layer)|l(?:ayer|ink)|meta|object|s(?:cript|tyle)|title|xml)[^>]*+>#i', '', $string);
		$string   = preg_replace('#</*(?:applet|b(?:ase|gsound|link)|embed|frame(?:set)?|i(?:layer)|l(?:ayer|ink)|meta|object|s(?:cript|tyle)|title|xml)[^>]*+>#i', '', $string) ?? $string;
	} while ($old_data !== $string);

	//restore entities
	$string = str_replace(['&lt;<', '>&gt;'], ['&lt;', '&gt;'], $string);
	return $string;
}



/**
 * Removes specified HTML tags from a string.
 *
 * @param  string $string The string to process.
 * @param  array  $tags   The tag names to strip (default ['script', 'iframe', 'applet']).
 * @return string The string with tags removed.
 */

function stripTags(string $string, array $tags = ['script', 'iframe', 'applet']): string {
	if ($string && $tags) {
		foreach ($tags as $tag) {
			$string = preg_replace('@<\s*' . $tag . '.*?>.*?</' . $tag . '\s*>@im', '', $string) ?? $string;;
		}
	}

	return $string;
}



/**
 * Recursively strips tags from all string values in an array.
 *
 * @param  array $array The array to process.
 * @param  array $tags  The tag names to strip.
 * @return array The array with tags stripped from string values.
 */

function stripTagsArray(array $array, array $tags): array {
	if ($tags) {
		foreach ($array as $key => &$value) {
			if (is_string($value)) {
				$value = stripTags($value, $tags);
			} elseif (is_array($value)) {
				$value = stripTagsArray($value, $tags);
			}
		}
		unset($value);
	}

	return $array;
}



/**
 * Removes specified characters from a string.
 *
 * @param  string $string The string to process.
 * @param  array  $chars  The characters to remove.
 * @return string The string with characters removed.
 */

function stripChars(string $string, array $chars = ['(', ')', '{', '}', ':', '<', '>']): string {
	return str_replace($chars, '', $string);
}



/**
 * Validates URL and resolves hostname to prevent SSRF attacks.
 *
 * @param  string       $url The URL to validate.
 * @return string|array The cURL resolve entries, an empty string for invalid URLs, or an empty array for private/reserved IPs.
 */

function validateUrl(string $url): array {
	$p = parse_url($url);
	$scheme = $p['scheme'] ?? 'https';
	$port   = (string) ($p['port'] ?? ($scheme === 'http' ? '80' : '443'));

	if (! $p || ! in_array($scheme, ['http', 'https'], true) || ! in_array($port, ['80', '443'], true) || empty($p['host'])) {
		return [];
	}

	if (($scheme === 'http' && $port !== '80') || ($scheme === 'https' && $port !== '443')) {
		return [];
	}

	$ips = gethostbynamel($p['host']) ?: [];

	$results = [];
	foreach ($ips as $ip) {
		$results[] = $p['host'] . ':' . $port . ':[' . $ip . ']';

		if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
			return []; // private / reserved / loopback / link-local
		}
	}

	return $results;
}



/**
 * Encrypts a string using OpenSSL with specified cipher and digest.
 *
 * @param  string      $key        The encryption key.
 * @param  string      $value      The plaintext string to encrypt.
 * @param  string      $cipher     The cipher method (default 'aes256').
 * @param  string      $digest     The digest method for key derivation (default 'sha256').
 * @param  string|null &$tag       The authentication tag output.
 * @param  string      $aad        Additional authenticated data.
 * @param  int         $tag_length The authentication tag length (default 16).
 * @return string      The base64-encoded encrypted string.
 */

function encrypt(string $key, string $value, string $cipher = 'aes256', string $digest = 'sha256', ?string &$tag = null, string $aad = '', int $tag_length = 16): string {
	$key       = openssl_digest($key, $digest, true);
	$iv_length = openssl_cipher_iv_length($cipher);
	$iv        = openssl_random_pseudo_bytes($iv_length);

	return base64_encode($iv . openssl_encrypt($value, $cipher, $key, OPENSSL_RAW_DATA, $iv, $tag, $aad, $tag_length));
}



/**
 * Decrypts an OpenSSL-encrypted string.
 *
 * @param  string       $key    The decryption key.
 * @param  string       $value  The base64-encoded encrypted string.
 * @param  string       $cipher The cipher method (default 'aes256').
 * @param  string       $digest The digest method for key derivation (default 'sha256').
 * @param  string|null  &$tag   The authentication tag output.
 * @param  string       $aad    Additional authenticated data.
 * @return string|false The decrypted string, or false on failure.
 */

function decrypt(string $key, string $value, string $cipher = 'aes256', string $digest = 'sha256', ?string &$tag = null, string $aad = ''): string|false {
	$result    = false;

	$key       = openssl_digest($key, $digest, true);
	$iv_length = openssl_cipher_iv_length($cipher);
	$value     = base64_decode($value);
	$iv        = substr($value, 0, $iv_length);
	$value     = substr($value, $iv_length);

	if (strlen($iv) == $iv_length) {
		$result = openssl_decrypt($value, $cipher, $key, OPENSSL_RAW_DATA, $iv, $tag, $aad);
	}

	return $result;
}



/**
 * Generates a string of random digits of specified length.
 *
 * @param  int    $length Number of digits to generate
 * @return string Random digit string
 */

function randomDigits(int $length): string {
	$result = '';

	for ($i = 0; $i < $length; $i++) {
		$result .= random_int(0, 9);
	}

	return $result;
}



/**
 * Check php code for syntax errors, return false on errors.
 * 
 * @param  string $source
 * @return bool
 */

function checkPhpSyntax(string $source): bool {
	try {
		$tokens = token_get_all($source, TOKEN_PARSE);
	} catch (\ParseError $e) {
		return false;
	}

	return $tokens !== false;
}

