<?php

/**
 * @author          Tassos.gr
 * @link            https://www.tassos.gr
 * @copyright       Copyright © 2026 Tassos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
 */

namespace NRFramework\Security;

defined('_JEXEC') or die;

use Joomla\CMS\Factory;
use Joomla\CMS\Session\Session;
use Joomla\Filesystem\Path;

/**
 * Intercepts requests that target known-vulnerable URL contracts on
 * old-bundle extensions (pre-fix ACF Upload taskDelete, pre-fix CF
 * form-submit file move) and re-routes them through the hardened
 * framework primitives. Lets framework-only operators stay safe on
 * old extension bundles until they ship the bumped extension zips.
 *
 * Activation: called from plg_system_nrframework's onAfterInitialise.
 * Runs before com_ajax dispatch, so a blocked request never reaches
 * the old plugin code.
 */
class BackCompatGuard
{
	/**
	 * Run all guards. Each guard checks its own URL shape and is a no-op
	 * for unrelated requests. On a match, it either handles the request
	 * (deletes safely, emits a response, exits) or denies it.
	 *
	 * @return void
	 */
	public static function run()
	{
		try
		{
			self::guardAcfUploadDelete();
			self::guardConvertFormsSubmit();
		}
		catch (\Throwable $e)
		{
			// Never break the host request because of a guard error.
		}
	}

	/**
	 * Intercept old ACF Upload taskDelete.
	 *
	 * Matches: option=com_ajax & plugin=ACFUpload & group=fields & task=delete
	 *
	 * Old ACF (≤ 2.8.12) calls Joomla\Filesystem\File::delete directly on
	 * JPATH_ROOT + base64_decode($_POST['file']), letting a guest with a
	 * frontend CSRF token delete arbitrary files under JPATH_ROOT. We
	 * preempt that here: resolve the input through FileReference against
	 * the same allowlist new ACF builds, then delete via the hardened
	 * \NRFramework\File::delete, and exit before the old code runs.
	 *
	 * @return void
	 */
	private static function guardAcfUploadDelete()
	{
		$app = Factory::getApplication();
		$input = $app->input;

		if ($input->getCmd('option') !== 'com_ajax'
			|| strtolower($input->getCmd('plugin')) !== 'acfupload'
			|| strtolower($input->getCmd('group')) !== 'fields'
			|| strtolower($input->getCmd('task')) !== 'delete')
		{
			return;
		}

		// CSRF — same gate the old code applies, just earlier.
		if (!Session::checkToken('request'))
		{
			self::deny('Invalid Token');
		}

		$filename = $input->get('file', '', 'BASE64');

		if ($filename === '')
		{
			self::deny('ACF_UPLOAD_ERROR_INVALID_FILE');
		}

		$decoded = base64_decode($filename);
		$field_id = (int) $input->getInt('id', 0);

		$bases = [
			Path::clean(JPATH_ROOT . '/media/acfupload/tmp'),
		];

		// If the request carries a field id, also allow the field's
		// configured upload_folder (static prefix only — strip {smart tags}).
		if ($field_id)
		{
			$folder = self::acfFieldUploadFolder($field_id);
			if ($folder !== '')
			{
				$bases[] = Path::clean(JPATH_ROOT . '/' . $folder);
			}
		}

		// DB-scan: every parent directory of every stored acfupload value.
		// Mirrors ACFUploadHelper::resolveStoredFileBases() in the 2.8.13
		// branch, so legacy clients that don't send `id` with delete
		// requests can still hit stored files.
		$bases = array_merge($bases, self::acfStoredFileBases());

		try
		{
			$absolute = FileReference::resolve($decoded, $bases);
			$ok = \NRFramework\File::delete($absolute);
			echo json_encode(['success' => (bool) $ok]);
		}
		catch (\Throwable $e)
		{
			echo json_encode(['success' => false, 'error' => $e->getMessage()]);
		}

		$app->close();
	}

	/**
	 * @param   int  $field_id
	 *
	 * @return  string  The static prefix of the field's upload_folder
	 *                  param, or '' if no usable value.
	 */
	private static function acfFieldUploadFolder($field_id)
	{
		try
		{
			$db = Factory::getDbo();
			$row = $db->setQuery(
				$db->getQuery(true)
					->select($db->quoteName('fieldparams'))
					->from($db->quoteName('#__fields'))
					->where($db->quoteName('id') . ' = ' . (int) $field_id)
					->where($db->quoteName('type') . ' = ' . $db->quote('acfupload'))
					->where($db->quoteName('state') . ' = 1')
			)->loadResult();
		}
		catch (\Throwable $e)
		{
			return '';
		}

		if (!$row)
		{
			return '';
		}

		$params = json_decode($row, true);

		if (!is_array($params) || empty($params['upload_folder']))
		{
			return '';
		}

		$folder = (string) $params['upload_folder'];
		// Strip {smart tags} — only the static prefix is allowlistable.
		$folder = preg_replace('/\{.*$/', '', $folder);

		return trim($folder, "/\\ ");
	}

	/**
	 * @return  string[]  Parent directories of every persisted acfupload value.
	 */
	private static function acfStoredFileBases()
	{
		try
		{
			$db = Factory::getDbo();
			$rows = $db->setQuery(
				$db->getQuery(true)
					->select($db->quoteName('fv.value'))
					->from($db->quoteName('#__fields_values', 'fv'))
					->join('INNER',
						$db->quoteName('#__fields', 'f')
						. ' ON ' . $db->quoteName('fv.field_id')
						. ' = ' . $db->quoteName('f.id'))
					->where($db->quoteName('f.type') . ' = ' . $db->quote('acfupload'))
					->where($db->quoteName('f.state') . ' = 1')
			)->loadColumn();
		}
		catch (\Throwable $e)
		{
			return [];
		}

		$bases = [];

		foreach ((array) $rows as $row)
		{
			if (!$row)
			{
				continue;
			}

			$decoded = json_decode($row, true);

			if (!is_array($decoded))
			{
				continue;
			}

			$entries = isset($decoded['value']) ? [$decoded] : $decoded;

			foreach ($entries as $entry)
			{
				$rel = is_array($entry)
					? (isset($entry['value']) ? (string) $entry['value'] : '')
					: (string) $entry;

				if ($rel === '')
				{
					continue;
				}

				$full = Path::clean(JPATH_ROOT . '/' . ltrim($rel, '/\\'));
				$bases[dirname($full)] = true;
			}
		}

		return array_keys($bases);
	}

	/**
	 * Intercept old ConvertForms form-submit.
	 *
	 * Matches: option=com_convertforms & task=submit on POST, with a
	 * non-empty cf[…] array carrying base64-encoded source paths.
	 *
	 * Old CF (≤ 4.4.12) base64-decodes each cf[FIELD][] value and feeds
	 * it to \NRFramework\File::move() as a source path. With the new
	 * framework, File::move blocks sources outside JPATH_ROOT ∪ tmp_path,
	 * but a base64-encoded JPATH_ROOT path still passes (Chain-2 internal
	 * source). We close that here by resolving every leaf through
	 * FileReference against the framework temp folder, mirroring the
	 * fix in CF 4.4.13's Fileupload::validate().
	 *
	 * @return void
	 */
	private static function guardConvertFormsSubmit()
	{
		// Only on POST submissions.
		if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST')
		{
			return;
		}

		$app   = Factory::getApplication();
		$input = $app->input;
		$option = $input->getCmd('option');

		// Two known shapes:
		//   1. ?option=com_convertforms&task=submit  (frontend form post)
		//   2. ?option=com_ajax&plugin=convertforms&format=json  (ajax path)
		$isCfDirect = ($option === 'com_convertforms');
		$isCfAjax   = ($option === 'com_ajax' && strtolower((string) $input->getCmd('plugin')) === 'convertforms');

		if (!$isCfDirect && !$isCfAjax)
		{
			return;
		}

		// Convert Forms 4.4.13 and later resolve every submitted file reference through
		// FileReference against this same temp folder, so there is nothing left to preempt.
		// This guard can only read a value as a file path, which means it rejects ordinary
		// field values, so it must stand down once the component protects itself.
		if (self::convertFormsIsHardened())
		{
			return;
		}

		$cf = $input->get('cf', null, 'array');

		if (!is_array($cf) || empty($cf))
		{
			return;
		}

		$tmpFolder = \NRFramework\File::getTempFolder();
		$bases = [$tmpFolder];

		foreach ($cf as $fieldKey => $fieldValue)
		{
			$values = is_array($fieldValue) ? $fieldValue : [$fieldValue];

			foreach ($values as $raw)
			{
				if (!is_string($raw) || $raw === '')
				{
					continue;
				}

				// Only a value carrying a directory separator can point outside the temp
				// folder. Ordinary field values never could, and resolving them rejected them.
				if (!self::looksLikePath($raw))
				{
					continue;
				}

				try
				{
					FileReference::resolve($raw, $bases);
				}
				catch (\Throwable $e)
				{
					self::deny('ConvertForms: rejected file reference outside temp folder');
				}
			}
		}
	}

	/**
	 * Whether the installed Convert Forms already resolves its own file references.
	 *
	 * Unknown or missing version: keep guarding.
	 *
	 * @return  boolean
	 */
	private static function convertFormsIsHardened()
	{
		static $hardened = null;

		if ($hardened !== null)
		{
			return $hardened;
		}

		$hardened = false;

		try
		{
			$db = Factory::getDbo();
			$query = $db->getQuery(true)
				->select($db->quoteName('manifest_cache'))
				->from($db->quoteName('#__extensions'))
				->where($db->quoteName('type') . ' = ' . $db->quote('component'))
				->where($db->quoteName('element') . ' = ' . $db->quote('com_convertforms'));

			$manifest = json_decode((string) $db->setQuery($query)->loadResult(), true);

			if (is_array($manifest) && isset($manifest['version']) && is_string($manifest['version']))
			{
				$hardened = version_compare($manifest['version'], '4.4.13', '>=');
			}
		}
		catch (\Throwable $e)
		{
			$hardened = false;
		}

		return $hardened;
	}

	/**
	 * Whether the value carries a directory separator, decoding base64 payloads first.
	 *
	 * @param   string  $value
	 *
	 * @return  boolean
	 */
	private static function looksLikePath($value)
	{
		$candidates = [$value];

		$decoded = base64_decode($value, true);

		if ($decoded !== false && $decoded !== $value && base64_encode($decoded) === $value)
		{
			$candidates[] = $decoded;
		}

		foreach ($candidates as $candidate)
		{
			if (strpos($candidate, '/') !== false || strpos($candidate, '\\') !== false)
			{
				return true;
			}
		}

		return false;
	}

	/**
	 * Emit a JSON error and terminate the request.
	 *
	 * @param   string  $message
	 *
	 * @return  void
	 */
	private static function deny($message)
	{
		http_response_code(403);
		echo json_encode(['success' => false, 'error' => $message]);
		Factory::getApplication()->close();
	}
}
