<?php
/**
 * Protector: writes (and cleanly removes) the deny rules and anti-listing
 * placeholders for the Gravity Forms upload base.
 *
 * Everything goes through core's insert_with_markers(), so an existing
 * .htaccess (Gravity Forms writes its own no-exec rules on some setups) is
 * preserved byte-for-byte outside our BEGIN/END block, and unprotect/uninstall
 * removes exactly what we added.
 *
 * On servers that ignore .htaccess (nginx, IIS) the rules are still written —
 * harmlessly — but the audit will keep reporting "installed, NOT verified"
 * until the server-level snippet is in place. The auditor, never this class,
 * decides what is actually protected.
 *
 * @package Upload_Vault
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Class Uvgf_Protector
 */
class Uvgf_Protector {

	const MARKER = 'Upload Vault for Gravity Forms';

	/**
	 * The deny rules (Apache 2.4 + 2.2 fallback; LiteSpeed reads both).
	 *
	 * @return string[]
	 */
	private static function deny_lines() {
		return array(
			'# Managed by Upload Vault for Gravity Forms — removed on unprotect/uninstall.',
			'Options -Indexes',
			'<IfModule mod_authz_core.c>',
			'Require all denied',
			'</IfModule>',
			'<IfModule !mod_authz_core.c>',
			'Order deny,allow',
			'Deny from all',
			'</IfModule>',
		);
	}

	/**
	 * The per-folder allow override for folders the admin chose to leave open.
	 *
	 * @return string[]
	 */
	private static function allow_lines() {
		return array(
			'# Managed by Upload Vault for Gravity Forms — this folder was left open on purpose.',
			'Options -Indexes',
			'<IfModule mod_authz_core.c>',
			'Require all granted',
			'</IfModule>',
			'<IfModule !mod_authz_core.c>',
			'Order allow,deny',
			'Allow from all',
			'</IfModule>',
		);
	}

	/**
	 * Are our deny rules present in the base .htaccess?
	 *
	 * @return bool
	 */
	public static function rules_installed() {
		$file = Uvgf_Locator::base_dir() . '/.htaccess';
		if ( ! file_exists( $file ) ) {
			return false;
		}
		if ( ! function_exists( 'extract_from_markers' ) ) {
			require_once ABSPATH . 'wp-admin/includes/misc.php';
		}
		$lines = extract_from_markers( $file, self::MARKER );
		return ! empty( $lines );
	}

	/**
	 * Folders the admin chose to leave open.
	 *
	 * @return string[]
	 */
	public static function excluded_folders() {
		$settings = get_option( 'uvgf_settings', array() );
		$open     = isset( $settings['open_form_dirs'] ) && is_array( $settings['open_form_dirs'] ) ? $settings['open_form_dirs'] : array();
		return array_values( array_filter( array_map( 'sanitize_file_name', $open ) ) );
	}

	/**
	 * Is a top-level folder excluded from protection?
	 *
	 * @param string $folder Folder name.
	 * @return bool
	 */
	public static function is_excluded( $folder ) {
		return in_array( $folder, self::excluded_folders(), true );
	}

	/**
	 * Install protection: deny block at the base, allow blocks in excluded
	 * folders, index placeholders everywhere.
	 *
	 * @return array {ok:bool, wrote_rules:bool, placeholders:int, error:string}
	 */
	public static function protect() {
		$base = Uvgf_Locator::base_dir();

		if ( ! is_dir( $base ) ) {
			return array(
				'ok'           => false,
				'wrote_rules'  => false,
				'placeholders' => 0,
				'error'        => 'no_base_dir',
			);
		}

		if ( ! function_exists( 'insert_with_markers' ) ) {
			require_once ABSPATH . 'wp-admin/includes/misc.php';
		}
		$wrote = insert_with_markers( $base . '/.htaccess', self::MARKER, self::deny_lines() );

		// Per-folder allow overrides (deeper directory config wins on Apache).
		foreach ( self::excluded_folders() as $folder ) {
			$dir = $base . '/' . $folder;
			if ( is_dir( $dir ) ) {
				insert_with_markers( $dir . '/.htaccess', self::MARKER, self::allow_lines() );
			}
		}
		// Remove stale allow blocks from folders no longer excluded.
		self::sweep_allow_blocks( $base, self::excluded_folders() );

		$placeholders = self::ensure_placeholders( $base );

		$state              = get_option( 'uvgf_state', array() );
		$state['protected'] = 1;
		update_option( 'uvgf_state', $state, false );

		return array(
			'ok'           => (bool) $wrote,
			'wrote_rules'  => (bool) $wrote,
			'placeholders' => $placeholders,
			'error'        => $wrote ? '' : 'htaccess_write_failed',
		);
	}

	/**
	 * Remove every marker block we ever wrote (base + per-folder).
	 *
	 * @return bool
	 */
	public static function unprotect() {
		$base = Uvgf_Locator::base_dir();
		if ( ! is_dir( $base ) ) {
			return true;
		}

		if ( ! function_exists( 'insert_with_markers' ) ) {
			require_once ABSPATH . 'wp-admin/includes/misc.php';
		}
		// Writing an empty block removes the marker section entirely.
		insert_with_markers( $base . '/.htaccess', self::MARKER, array() );
		self::sweep_allow_blocks( $base, array() );

		$state              = get_option( 'uvgf_state', array() );
		$state['protected'] = 0;
		update_option( 'uvgf_state', $state, false );

		return true;
	}

	/**
	 * Remove our allow blocks from any first-level folder not in $keep.
	 *
	 * Both callers (protect/unprotect) have already loaded
	 * wp-admin/includes/misc.php immediately before their own marker writes.
	 *
	 * @param string   $base Base dir.
	 * @param string[] $keep Folder names whose allow block stays.
	 */
	private static function sweep_allow_blocks( $base, $keep ) {
		$entries = glob( $base . '/*', GLOB_ONLYDIR );
		if ( ! is_array( $entries ) ) {
			return;
		}
		foreach ( $entries as $dir ) {
			$folder = basename( $dir );
			if ( in_array( $folder, $keep, true ) ) {
				continue;
			}
			$file = $dir . '/.htaccess';
			if ( file_exists( $file ) && ! empty( extract_from_markers( $file, self::MARKER ) ) ) {
				insert_with_markers( $file, self::MARKER, array() );
			}
		}
	}

	/**
	 * Drop an empty index.html into every directory under the base that lacks
	 * one — kills index listings on every server, including those that ignore
	 * .htaccess. Never overwrites an existing file.
	 *
	 * @param string $base Base dir.
	 * @return int Placeholders written.
	 */
	public static function ensure_placeholders( $base ) {
		$written = 0;
		$dirs    = array( $base );

		try {
			$iter = new RecursiveIteratorIterator(
				new RecursiveDirectoryIterator( $base, FilesystemIterator::SKIP_DOTS ),
				RecursiveIteratorIterator::SELF_FIRST,
				RecursiveIteratorIterator::CATCH_GET_CHILD
			);
			foreach ( $iter as $entry ) {
				if ( $entry->isDir() ) {
					$dirs[] = $entry->getPathname();
				}
			}
		} catch ( Exception $e ) {
			return 0;
		}

		foreach ( $dirs as $dir ) {
			$file = $dir . '/index.html';
			if ( file_exists( $file ) ) {
				continue;
			}
			if ( self::write_file( $file, '' ) ) {
				$written++;
			}
		}
		return $written;
	}

	/**
	 * Write a small file via WP_Filesystem, falling back to native PHP.
	 *
	 * @param string $path     Absolute path.
	 * @param string $contents Contents.
	 * @return bool
	 */
	private static function write_file( $path, $contents ) {
		global $wp_filesystem;

		if ( ! function_exists( 'WP_Filesystem' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}
		if ( WP_Filesystem() && $wp_filesystem instanceof WP_Filesystem_Base && 'direct' === $wp_filesystem->method ) {
			return (bool) $wp_filesystem->put_contents( $path, $contents, FS_CHMOD_FILE );
		}

		return false !== file_put_contents( $path, $contents ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- fallback when WP_Filesystem is unavailable; writes an empty index.html placeholder inside the uploads dir only.
	}
}
