<?php

/**
 * Set up restriction directories.
 *
 * @since 3.5
 */
function pmpro_set_up_restricted_files_directory() {
	// Create restricted folder if it doesn't exist.
	$restricted_file_directory = pmpro_get_restricted_file_path();

	// Bail if we couldn't determine a valid path (e.g. misconfigured Windows servers).
	if ( empty( $restricted_file_directory ) ) {
		return;
	}

	if ( ! file_exists( $restricted_file_directory ) ) {
		wp_mkdir_p( $restricted_file_directory );
	}

	// Create/update .htaccess file for apache servers.
	$htaccess = '<FilesMatch ".*">' . "\n" .
		'  <IfModule !mod_authz_core.c>' . "\n" .
		'    Order allow,deny' . "\n" .
		'    Deny from all' . "\n" .
		'  </IfModule>' . "\n" .
		'  <IfModule mod_authz_core.c>' . "\n" .
		'    Require all denied' . "\n" .
		'  </IfModule>' . "\n" .
		'</FilesMatch>';
	file_put_contents( trailingslashit( $restricted_file_directory ) . '.htaccess', $htaccess );

	// Write a non-dotfile marker so pmpro_is_restricted_directory_protected()
	// has something to HEAD-test against. Without this, sites that have not
	// yet stored any member-uploaded files report "Unable to determine"
	// because pmpro_find_testable_file() deliberately skips dotfiles (and
	// .htaccess is the only file present on a fresh install).
	$marker_path = trailingslashit( $restricted_file_directory ) . 'pmpro-protection-test.txt';
	if ( ! file_exists( $marker_path ) ) {
		$marker_content  = "PMPro restricted-files protection test marker.\n";
		$marker_content .= "This file is intentional. PMPro uses it to verify the directory is\n";
		$marker_content .= "correctly protected from public access. It is safe to delete; PMPro\n";
		$marker_content .= "will recreate it on the next daily run.\n";
		file_put_contents( $marker_path, $marker_content );
	}
}

/**
 * If a restricted file is requested, check if the user has access.
 * If so, serve the file.
 *
 * @since 3.5
 */
function pmpro_restricted_files_check_request() {
	if ( empty( $_REQUEST['pmpro_restricted_file'] ) || empty( $_REQUEST['pmpro_restricted_file_dir'] ) ) {
		return;
	}

	// Get the requested file.
	$file = basename( sanitize_text_field( wp_unslash( $_REQUEST['pmpro_restricted_file'] ) ) );
	$file_dir = basename( sanitize_text_field( wp_unslash( $_REQUEST['pmpro_restricted_file_dir'] ) ) );

	/* 
 		Remove ../-like strings from the URI. 
 		Actually removes any combination of two or more ., /, and \. 
 		This will prevent traversal attacks and loading hidden files. 
 	*/
	$file = preg_replace("/[\.\/\\\\]{2,}/", "", $file);
	$file_dir = preg_replace("/[\.\/\\\\]{2,}/", "", $file_dir);

	/**
	 * Filter to check if a user can access a restricted file.
	 *
	 * @since 3.5
	 *
	 * @param bool   $can_access Whether the user can access the file.
	 * @param string $file_dir   Directory of the restricted file.
	 * @param string $file       Name of the restricted file.
	 */
	if ( empty( apply_filters( 'pmpro_can_access_restricted_file', false, $file_dir, $file ) ) ) {
		wp_die( esc_html__( 'You do not have permission to access this file.', 'paid-memberships-pro' ), 403 );
	}

	// Serve the file.
	$file_path = pmpro_get_restricted_file_path( $file_dir, $file );
	if ( file_exists( $file_path ) ) {
		$finfo = finfo_open( FILEINFO_MIME_TYPE );
		$content_type = finfo_file( $finfo, $file_path );

		/**
		 * Filter the content disposition for the restricted file. 
		 * This automatically defaults to inline for images and attachments for non-images. 
		 * 
		 * @since 3.6
		 * 
		 * @param $is_valid_image boolean This is true for image/* and false for anything else.
		 * @param string $file Name of the restricted file.
		 * @param string $file_dir Directory of the restricted file.
		 * @param string $file_path Path to the restricted file.
		 * 
		 * @return string $content_disposition "inline" for image/* types, "attachment" for other file types.
		 */
		$content_disposition = apply_filters( 'pmpro_restricted_file_content_disposition', wp_getimagesize( $file_path ) ? 'inline' : 'attachment', $file, $file_dir, $file_path );
		if ( $content_disposition !== 'inline' && $content_disposition !== 'attachment' ) {
			$content_disposition = 'attachment'; // Default to attachment if not inline and not attachment.
		}

		finfo_close( $finfo );
		header( 'Content-Type: ' . $content_type );
		header( 'Content-Disposition: ' . $content_disposition . '; filename="' . $file . '"' );
		$bytes = readfile( $file_path );
		if ( false !== $bytes ) {
			// Buffer the action so a misbehaving hook can't append output to the response body and corrupt the download.
			ob_start();
			/**
			 * Fires after a restricted file has been successfully streamed to the client.
			 * Handlers MUST NOT produce output; any bytes written here are discarded.
			 *
			 * @since 3.7
			 *
			 * @param string $file_dir Directory of the file that was served.
			 * @param string $file     File name of the file that was served.
			 */
			do_action( 'pmpro_restricted_file_served', $file_dir, $file );
			ob_end_clean();
		}
		exit;
	} else {
		wp_die(	esc_html__( 'File not found.', 'paid-memberships-pro' ), 404 );
	}
}
add_action( 'init', 'pmpro_restricted_files_check_request' );

/**
 * Add a filter to allow access to restricted files for core use-cases.
 *
 * @since 3.5
 *
 * @param  bool   $can_access Whether the user can access the file.
 * @param  string $file_dir   Directory of the restricted file.
 * @return bool               Whether the user can access the file.
 */
function pmpro_can_access_restricted_file( $can_access, $file_dir ) {
	if ( 'logs' === $file_dir ) {
		return current_user_can( 'manage_options' );
	}

	return $can_access;
}
add_filter( 'pmpro_can_access_restricted_file', 'pmpro_can_access_restricted_file', 10, 2 );

/**
 * Get the path to a restricted file.
 *
 * @since 3.5
 *
 * @param  string $file_dir Directory of the restricted file.
 * @param  string $file     Name of the restricted file.
 * @return string           Path to the restricted file.
 */
function pmpro_get_restricted_file_path( $file_dir = '', $file = '' ) {
	// Get a random string to prevent directory traversal attacks.
	$random_string = get_option( 'pmpro_restricted_files_random_string', '' );
	if ( empty( $random_string ) ) {
		$random_string = substr( md5( rand() ), 0, 10 );
		update_option( 'pmpro_restricted_files_random_string', $random_string );
	}

	// Get the directory path.
	$upload_dir_info = wp_upload_dir();

	// Bail if we can't get a valid uploads basedir (e.g. misconfigured Windows servers).
	if ( empty( $upload_dir_info['basedir'] ) ) {
		return '';
	}

	$uploads_dir = trailingslashit( $upload_dir_info['basedir'] );
	$restricted_file_path = $uploads_dir . 'pmpro-' . $random_string . '/';
	if ( ! empty( $file_dir ) ) {
		$restricted_file_path .= $file_dir . '/';

		// Create the directory if it doesn't exist.
		if ( ! file_exists( $restricted_file_path ) ) {
			wp_mkdir_p( $restricted_file_path );
		}

		// Get the file path.
		if ( ! empty( $file ) ) {
			$restricted_file_path .= $file;
		}
	}
	return $restricted_file_path;
}

/**
 * Core implementation for checking if the PMPro restricted files directory
 * is protected from direct access.
 *
 * @since 3.7
 *
 * @return bool|null True if protected, false if accessible, null if unable to determine.
 */
function pmpro_is_restricted_directory_protected() {
	$restricted_dir = pmpro_get_restricted_file_path();

	// Can't test if we couldn't determine the restricted directory path.
	if ( empty( $restricted_dir ) ) {
		return null;
	}

	// Can't test if directory doesn't exist.
	if ( ! is_dir( $restricted_dir ) ) {
		return null;
	}

	// Find a file to test with.
	$test_file = pmpro_find_testable_file( $restricted_dir );
	if ( ! $test_file ) {
		return null;
	}

	// Convert file path to URL.
	$wp_upload_dir = wp_upload_dir();

	// Bail if we can't get a valid basedir (e.g. misconfigured Windows servers).
	if ( empty( $wp_upload_dir['basedir'] ) ) {
		return null;
	}

	// Normalize paths to ensure consistent separators.
	$normalized_test_file = wp_normalize_path( $test_file );
	$normalized_basedir   = wp_normalize_path( $wp_upload_dir['basedir'] );
	$basedir_with_slash   = trailingslashit( $normalized_basedir );

	// Ensure the test file is within the uploads base directory.
	if ( 0 !== strpos( $normalized_test_file, $basedir_with_slash ) ) {
		return null;
	}

	// Build a URL by appending the relative path to the base URL.
	$relative_path = substr( $normalized_test_file, strlen( $basedir_with_slash ) );
	$test_url      = trailingslashit( $wp_upload_dir['baseurl'] ) . ltrim( $relative_path, '/' );
	
	// Attempt direct access.
	$response = wp_remote_head(
		$test_url,
		array(
			'timeout'             => 3, // Short timeout for responsiveness.
			'redirection'         => 1,	// allow one for a HTTP → HTTPS hop
			'reject_unsafe_urls'  => true, // Security measure.
		)
	);

	if ( is_wp_error( $response ) ) {
		return null;
	}

	$status_code = wp_remote_retrieve_response_code( $response );

	// 401/403/404 = protected, 200 = exposed, everything else = unable to determine.
	if ( in_array( $status_code, array( 401, 403, 404 ), true ) ) {
		return true;
	}

	if ( 200 === $status_code ) {
		return false;
	}

	return null;
}

/**
 * Grab a file in the restricted directory that can be used for access testing.
 *
 * @since 3.7
 *
 * @param string $directory The directory path to search.
 * @return string|null File path if found, null otherwise.
 */
function pmpro_find_testable_file( $directory ) {
	try {
		$directory_iterator = new RecursiveDirectoryIterator( $directory, RecursiveDirectoryIterator::SKIP_DOTS );
		$filtered_iterator  = new RecursiveCallbackFilterIterator(
			$directory_iterator,
			static function( $current ) {
				$filename = $current->getFilename();

				// Skip dotfiles and dot directories (e.g. .DS_Store, .htaccess).
				return ! empty( $filename ) && '.' !== $filename[0];
			}
		);
		$iterator = new RecursiveIteratorIterator( $filtered_iterator, RecursiveIteratorIterator::SELF_FIRST );

		foreach ( $iterator as $file ) {
			if ( $file->isFile() ) {
				return $file->getPathname();
			}
		}
	} catch ( UnexpectedValueException $e ) {
		// Directory exists but is not readable or has other access issues.
		return null;
	} catch ( Exception $e ) {
		// Any other unexpected issue while iterating; fail gracefully.
		return null;
	}

	return null;
}
