<?php
if ( ! defined("_INCLUDED_GLOBAL")) {
	die("Access Denied");
}
/**
 * HTML purifier
 *   - 메일 본문 등에서 XSS 방어
 */

/**
 * HTML purifier 설정
 *   - 설정 가이드) http://htmlpurifier.org/live/configdoc/plain.html
 *
 * @return array
 */
function purifier_config()
{
	return array(
		'Core.Encoding'         => 'UTF-8',
//		'Core.CollectErrors'    => true,      // [2024-11-14] 태그가 많을 경우 성능 저하가 있어 활성화하지 않음
		'Cache.DefinitionImpl'  => null,     // 개별 메일 본문 위주이므로 캐시 비활성화
		'HTML.Doctype'          => 'HTML 4.01 Transitional',
		'HTML.Allowed'          => 'div[style|align],b[style],strong[style],small[style],i[style],em[style],u[style],a[href|title|target|style],ul[style],ol[style],li[style],p[style],br[style],span[style],font[style],img[width|height|alt|src|style],'
			. 'h1[style],h2[style],h3[style],h4[style],h5[style],h6[style],'
			. 'pre[style],address[style],'
			//. 'aside[style],footer[style],header[style],main[style],nav[style],section[style],'
			. 'blockquote[style],dd[style],dl[style],dt[style],hr[style],menu[style],'
//			. 'area[style],map[style],'
			. 'del[style],ins[style],'
			. 'table[width|border|cellpadding|cellspacing|align|bgcolor|style|summary],caption[style],colgroup,col[width],thead,tbody,'
			. 'tr[style|align|valign|bgcolor],'
			. 'th[style|width|height|colspan|rowspan|align|valign|bgcolor],'
			. 'td[style|width|height|colspan|rowspan|align|valign|bgcolor],'
			// [2024-09-28] HTML.Forms 허용
			. 'input[style|disabled|name|size|type|value],'
			. 'select[style|disabled|name|size],'
			. 'option[style|disabled|label|selected|value],'
			. 'textarea[style|disabled|name|rows],'
			. 'button[style|disabled|name|type|value]',
		'HTML.Forms'            => true,
		'CSS.AllowedProperties' => 'font,font-size,font-weight,font-style,font-family,text-decoration,'
			. 'color,background-color,text-align,letter-spacing,word-spacing,text-transform,'
			. 'width,min-width,max-width,height,line-height,'
			. 'padding,padding-top,padding-bottom,padding-left,padding-right,'
			. 'margin,margin-top,margin-bottom,margin-left,margin-right,'
			. 'border,border-top,border-bottom,border-left,border-right,'
			. 'border-width,border-top-width,border-bottom-width,border-left-width,border-right-width,'
			. 'border-style,border-top-style,border-bottom-style,border-left-style,border-right-style,'
			. 'border-color,border-top-color,border-bottom-color,border-left-color,border-right-color,'
			. 'border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,'
			. 'border-collapse,'
			. 'list-style,'
			. 'white-space,word-wrap,overflow-wrap,word-break,'
			. 'background,vertical-align,display,overflow,float,clear,table-layout',
		'CSS.AllowTricky'       => true,      // display: none 등 허용
		'Attr.DefaultImageAlt'  => '',       // img alt 속성이 없을때 이미지 주소를 기본으로 넣지 않도록 빈 문자열 선언
		'URI.Host'              => $_SERVER['HTTP_HOST'],
        'Attr.AllowedFrameTargets' => array( // 링크의 프레임 타겟 허용 대상
            '_blank',
        ),
		'HTML.TargetBlank'      => true,     // 외부 링크는 모두 새창
		'URI.AllowedSchemes'    => array(
			'http'   => true,
			'https'  => true,
			'mailto' => true,
			'ftp'    => true,
			'nntp'   => true,
			'news'   => true,
			'tel'    => true,
			'data'   => true,     // <img src="data:image/png;base64 지원
		),
	);
}

/**
 * HTML 안전한 문자열만 유지
 *
 * @param string $content
 *
 * @return string
 * @throws Exception
 */
function purifier_clean($content)
{
	global $G_SYS;

	if (isset($_GET['ov'])) {
		if ($_GET['ov'] === '1') {            // HTML 원본 보기
			return $content;
		} else if ($_GET['ov'] === '0') {       // purifier 에러시 텍스트로 보여주기
			$GLOBALS['__NMAIL_HTML_PURIFIER_CLEAN'] = true;

			return purifier_error_content(purifier_content_text($content));
		}
	}

	if ( ! isset($GLOBALS['__NMAIL_HTML_PURIFIER'])) {
		ini_set('memory_limit', '2047M');
//		set_time_limit(120);

		// purifier 에러시 텍스트로 보여주기가 아닐 경우, 에러 처리
		if ( ! isset($_GET['ov']) || $_GET['ov'] !== '0') {

			ini_set('display_errors', false);

			set_error_handler(function ($code, $string, $file, $line) {
				if ($code === E_ERROR) {
					throw new ErrorException($string, null, $code, $file, $line);
				}
			}, E_ALL & ~E_NOTICE & ~E_DEPRECATED & ~E_USER_DEPRECATED & ~E_STRICT);

			register_shutdown_function(function () {
				$error = error_get_last();
				if ($error !== null && $error['type'] === E_ERROR) {
					// PHP 메모리나 시간 초과 에러시, 현재 편지읽기 주소에 &ov=0 추가하여 이동(이동된 페이지에선 에러와 텍스트만 표시)
					movepage($_SERVER['PHP_SELF'] . '?' . get_qs('ov') . '&ov=0');
				}
			});
		}

		require_once dirname($G_SYS['PAGE_ROOT']) . '/vendor/ezyang/htmlpurifier/library/HTMLPurifier.auto.php';

		$config = HTMLPurifier_Config::createDefault();
		$config->loadArray(purifier_config());

		// [2025-07-18] word-wrap 등 직접 선언하여 유지
		$config->set('CSS.Proprietary', true);
		$css_definition = $config->getDefinition('CSS');
		$css_definition->info['word-wrap'] = new HTMLPurifier_AttrDef_Enum(array('normal', 'break-word'));
		$css_definition->info['overflow-wrap'] = new HTMLPurifier_AttrDef_Enum(array('normal', 'break-word'));
		$css_definition->info['word-break'] = new HTMLPurifier_AttrDef_Enum(array('normal', 'break-all', 'keep-all', 'break-word'));

		$GLOBALS['__NMAIL_HTML_PURIFIER'] = new HTMLPurifier($config);
	}

	$GLOBALS['__NMAIL_HTML_PURIFIER_CLEAN'] = true;


	try {
		if (is_local()) {
			$clean_html = $GLOBALS['__NMAIL_HTML_PURIFIER']->purify(purifier_pre_filter($content));
		} else {
			$clean_html = @$GLOBALS['__NMAIL_HTML_PURIFIER']->purify(purifier_pre_filter($content));
		}

		// 필터링후 내용 길이가 같을 경우, [HTML 원본 보기] 생략
		if (strlen(trim($content)) === strlen(trim($clean_html))) {
			$GLOBALS['__NMAIL_HTML_PURIFIER_CLEAN'] = false;
		}

		// [2024-10-23] 필터링후 HTML 태그를 제외한 텍스트 내용이 없을 경우 안내 문구 표시
		$content_text_length = strlen(trim(strip_tags($content)));      // [2024-11-19] 텍스트 원문 길이 (<br) 태그도 삭제되어야 함)
		if ($content_text_length > 0 && strlen(trim(strip_tags($clean_html))) == 0) {
//			$clean_html = '<div>(메일 본문 내용이 보안 필터링되었습니다.  안전한 메일일 경우 화면 상단의 \'HTML 원본 보기\' 를 클릭하시면 됩니다.)</div>'
			$clean_html = purifier_warning_content(purifier_content_text($content));
		}
	} catch (Exception $e) {
		error("[purifier_clean] HTML purifier 실패 : " . $e->getMessage(), 'SECURITY');

		if (is_local()) {
			throw $e;
		}

		$clean_html = purifier_warning_content(purifier_content_text($content));
	}

	// 디버깅
	if (is_local() && $_GET['is_debug']) {
		?>
		<h5 style="color: blue"># purifier_clean
			(
			length : <?= strlen($content) ?> -> <?= strlen($clean_html) ?> ,
			text length : <?= strlen(trim(strip_tags($content))) ?> -> <?= strlen(trim(strip_tags($clean_html))) ?>
			)</h5>
		<table width="100%" border="1">
			<tbody>
			<tr>
				<td width="50%" style="vertical-align: top"><?= $content ?></td>
				<td width="50%" style="vertical-align: top"><?= $clean_html ?></td>
			</tr>
			<tr>
				<td style="vertical-align: top">
					<pre style="width: 50vw"><?= e($content) ?></pre>
				</td>
				<td style="vertical-align: top">
					<pre style="width: 50vw"><?= e($clean_html) ?></pre>
				</td>
			</tr>
			</tbody>
		</table>
		<?php
	}

	return $clean_html;
}

/**
 * HTML 필터링 경고 내용
 *   - [2024-11-14] 경고 문구와 HTML 태그를 제외한 텍스트 내용만 출력
 *
 * @param string $content_text
 *
 * @return string
 */
function purifier_warning_content($content_text)
{
	return '<div class="mail-html-warning-content" style="background-color: #ffd86b; margin-bottom: 1em; padding-left: 4px; color: #000000;">'
		. '<i class="bi bi-info-circle bi-sm" aria-hidden="true"></i>'
		. ' 메일 본문 HTML 필터링이 되지 않아 텍스트로 보여집니다.  안전한 메일일 경우 화면 상단의 \'HTML 원본 보기\' 를 클릭해주세요.</div>'
		. '<p>' . nl2br($content_text) . '</p>';     // TEXT 내용만 표시
}

/**
 * HTML 필터링 에러 내용
 *   - [2024-11-14] 필터링 에러시 경고 문구와 HTML 태그를 제외한 텍스트 내용만 출력
 *
 * @param string $content_text
 *
 * @return string
 */
function purifier_error_content($content_text)
{
	return '<div class="mail-html-error-content" style="background-color: #ffd86b; margin-bottom: 1em; padding-left: 4px; color: #000000;">'
		. '<i class="bi bi-info-circle bi-sm" aria-hidden="true"></i>'
		. ' 긴 메일 본문 HTML 필터링이 되지 않아 텍스트로 보여집니다.  안전한 메일일 경우 화면 상단의 \'HTML 원본 보기\' 를 클릭해주세요.</div>'
		. '<p>' . nl2br($content_text) . '</p>';     // TEXT 내용만 표시
}

/**
 * HTML 사전 필터
 *   - <!DOCTYPE HTML> 등 제거
 *
 * @param string $content
 *
 * @return string
 */
function purifier_pre_filter($content)
{
//	return preg_replace('/<!DOCTYPE[^>]*>/i', '', trim($content));

	$pattern = array(
			'/<!DOCTYPE[^>]*>/i',

		// [2025-07-17] 아웃룩이 아닐때만 보여주는 영역 태그 삭제하여 허용
		//   <!--[if !mso]> <!-->  ...  <!--<![endif]-->
		//   <!--[if (!mso)&(!IE)]><!-->
			'/<!--\[if\s+(!mso|' . preg_quote('(!mso)&(!IE)') . ')+\s*]>\s*<![- ]{2,}>/i',
			'/<!--\s*<!\s*\[endif]\s*-->/i',

		// 아웃룻 및 IE 전용 태그 삭제
		//   <!--[if mso]>  ...  <![endif]-->
		//   <!--[if IE]>  ...  <![endif]-->
		//   <!--[if (mso)|(IE)]>  ...  <![endif]-->
			'/<!--\[if\s+(mso|IE|'  . preg_quote('(mso)|(IE)') . ')+\s*]\s*>.*?<!\s*\[endif]\s*-->/is',
	);

	// [2025-12-16] PHP 5.3.3 에서 <![if !supportLists]> 삭제
	if (version_compare(phpversion(), '5.3.29', '<')) {
		$pattern[] = "/(<\!\[if[^\]]*\]>)/si";
		$pattern[] = "/(<\!\[endif\]>)/si";
	}

	return preg_replace($pattern,
		'',
		str_replace(array(
			'<o:p></o:p>',      // [2024-11-14] MS word (Outlook) 전용 태그 제거하여 속도 향상
			'<o:p>&nbsp;</o:p>',
			'<span lang="EN-US"',
			'<p class="MsoNormal"',
		), array(
			'',
			'&nbsp;',
			'<span',
			'<p',
		), trim($content)));
}

/**
 * 텍스트 내용
 *   - mail_body_text_preview() 처럼 태그가 1줄로 합쳐진 경우에도 줄바꿈 지원
 *   - 단 링크는 미리보기가 아니므로 50자로 줄이지 않고 전체 표시
 *   - 3줄 이상 줄바꿈은 2줄로 줄이기
 *
 * @param string $content
 *
 * @return string
 */
function purifier_content_text($content)
{
//	return trim(
//			strip_tags(
//					str_replace(
//							array(
//									'</p><p',   // Outlook 에서 태그가 1줄로 오므로 P태그끼리 붙은 경우 줄바꿈 추가
//									'</div><div',
//							),
//							array(
//									"</p>\n<p",
//									"</div>\n<div",
//							),
//							$content
//					), '<br>'
//			)
//	);


	// title, script, style 태그 삭제
	// br, hr 은 줄바꿈으로 치환
	$content = preg_replace(
			array(
					"/<title(.*?)title>/si",
					"/<script(.*?)\/script[^>]*>/si",
					"/<style(.*?)\/style[^>]*>/si",
					"/<(br|hr)+[^>]*>/i",
					"/<\/(div|p|table|tr|li|dd|pre|blockquote)+></i",        // 태그가 1줄로 합쳐진 경우 대비하여, 블록 레벨 태그 뒤에 줄바꿈 추가
			),
			array(
					"",
					"",
					"",
					"\n",
					"</\\1>\n<",
			),
			$content
	);
//	echo "<xmp>" . $content . "</xmp>";

	// a 태그는 '내용 + 주소' 형식으로 보여주어 도메인 확인이 쉽게 함
	//   내용과 주소가 같은 경우 주소만 표시
	//   <a href="mailto: 등 제외하고, http/https 인 경우만 표시
	$content = preg_replace_callback('/<a\s+[^>]*href="([^"]+)"[^>]*>(.*?)<\/a>/is', function ($matches) use ($content) {
		$href = trim($matches[1]);
		$text = trim($matches[2]);

		if ($href === $text) {
			return $href;
		}

		if (stripos($href, 'http://') === 0 || stripos($href, 'https://') === 0 ) {
			return $text . ' ' . $href;
		}

		return $text;
	}, $content);

	$content = strip_tags($content);

	// 줄바꿈 \n 로 통일 및 특수문자 치환
	$content = str_ireplace(
			array(
					"\r\n",
					"\r",
					"\t",
					"&nbsp;",
					"&#32;",
					' ',
					'‌',
					'﻿',
					'​',
			),
			array(
					"\n",
					"\n",
					" ",
					" ",
					" ",
					" ",
					" ",
					" ",
					" ",
			),
			$content
	);

	// 3줄 이상 줄바꿈은 2줄로 줄이기
	$patterns2 = array(
			"/\n[ \n]+[ \n]+/",
	);
	$replacements2 = array(
			"\n\n",
	);
	$content = preg_replace($patterns2, $replacements2, $content);


	return trim($content);
}
