<?php
/**
 * LLMS_Media_Protector class
 *
 * @package LifterLMS/Classes
 *
 * @since 7.7.0
 * @version 7.7.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * LLMS_Media_Protector class.
 *
 * Allows uploaded media files to be protected from unauthorized downloading.
 *
 * WordPress uses the terms "media" and "attachment" interchangeably to describe uploaded files.
 * When a file is uploaded to WordPress, a post is created with type = 'attachment' and the file name and path relative
 * to the upload directory, normally `WP_CONTENT_DIR . '/uploads'`, is saved as '_wp_attached_file' metadata.
 *
 * Example of uploading a file:
 *
 *     $protector = new LLMS_Media_Protector( '/social-learning' );
 *     $id        = $protector->handle_upload( 'image', 0, 'llms_sl_authorize_media_view', $post_data );
 *
 * Example of protecting a file:
 *
 *     add_filter( 'llms_sl_authorize_media_view', array( $this, 'authorize_media_view' ), 10, 3 );
 *
 *     public function authorize_media_view( $is_authorized, $media_id, $url ) {
 *         $is_authorized = current_user_can( 'view_others_students' );
 *         return $is_authorized;
 *     }
 *
 * @since 7.7.0
 *
 * @todo Add handling of HTTP range requests. See {@see https://datatracker.ietf.org/doc/html/rfc7233} and
 *       {@see https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests}.
 * @todo Add WordPress multi-site capability.
 */
class LLMS_Media_Protector {

	/**
	 * The meta key used to specify the filter hook name that authorizes viewing of a media file.
	 *
	 * The key is protected by prefixing it with an underscore '_', which causes WordPress to not display it in
	 * a custom fields interface. {@see is_protected_meta()}.
	 *
	 * @since 7.7.0
	 *
	 * @var string
	 */
	public const AUTHORIZATION_FILTER_KEY = '_llms_media_authorization_filter';

	/**
	 * Serve the media file by reading and outputting it with the readfile() function.
	 *
	 * This is the least efficient way to serve a file because it uses a PHP process instead of a HTTP server thread.
	 * For small files or a small number of protected files on a page, this may not be noticeable. However, the server's
	 * configuration may need to be changed to allow more PHP processes to run, which will use more memory.
	 *
	 * @since 7.7.0
	 *
	 * @var int
	 */
	public const SERVE_READ_FILE = 1;

	/**
	 * Serve the media file by redirecting the HTTP client with a "Location" header.
	 *
	 * This is the least secure way to serve a file because an unprotected URL is given to the HTTP client.
	 * It is unlikely, yet possible, that the URL could then be used by an unauthorized user to view the file.
	 *
	 * @since 7.7.0
	 *
	 * @var int
	 */
	public const SERVE_REDIRECT = 2;

	/**
	 * Serve the media file by sending an "X-Sendfile" style header and let the HTTP server serve the file.
	 *
	 * This is the most efficient and most secure way to serve a file. It requires one of the following HTTP servers.
	 * - {@see https://httpd.apache.org/ Apache httpd} with {@see https://tn123.org/mod_xsendfile/ mod_xsendfile}
	 * - {@see http://cherokee-project.com/doc/other_goodies.html Cherokee}
	 * - {@see https://redmine.lighttpd.net/projects/lighttpd/wiki/X-LIGHTTPD-send-file lighttpd}
	 * - {@see https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/ NGINX}
	 *
	 * @since 7.7.0
	 *
	 * @var int
	 */
	public const SERVE_SEND_FILE = 3;

	/**
	 * The name of the URL parameter for whether the media image should be treated as an icon.
	 *
	 * @since 7.7.0
	 *
	 * @var string
	 */
	public const URL_PARAMETER_ICON = 'llms_media_icon';

	/**
	 * The name of the URL parameter for the media post ID.
	 *
	 * @since 7.7.0
	 *
	 * @var string
	 */
	public const URL_PARAMETER_ID = 'llms_media_id';

	/**
	 * The name of the URL parameter for when the LifterLMS rewrite rule changes a URL that directly accesses the
	 * 'llms-uploads' directory into '/index.php?llms_protected_url=llms-uploads/PATH_TO_FILE'.
	 *
	 * @since 7.7.0
	 *
	 * @var string
	 */
	public const URL_PARAMETER_PROTECTED_URL = 'llms_protected_url';

	/**
	 * The name of the URL parameter for the requested media image size.
	 *
	 * @since 7.7.0
	 *
	 * @var string
	 */
	public const URL_PARAMETER_SIZE = 'llms_media_image_size';

	/**
	 * An optional path added to the base upload path.
	 *
	 * If it is not empty, it will have a leading slash and will not have a trailing slash.
	 * Normally, the full path is `WP_CONTENT_DIR . "/uploads/$base/$additional/$year/$month/$file_name"`.
	 *
	 * @since 7.7.0
	 *
	 * @var string
	 */
	protected $additional_upload_path = '';

	/**
	 * A base path for uploaded LifterLMS files.
	 *
	 * If it is not empty, it will have a leading slash and will not have a trailing slash.
	 * Normally, the full path is `WP_CONTENT_DIR . "/uploads/$base/$additional/$year/$month/$file_name"`.
	 *
	 * @since 7.7.0
	 *
	 * @var string
	 */
	protected $base_upload_path = '';

	/**
	 * Set up this class.
	 *
	 * @since 7.7.0
	 *
	 * @param string $additional_upload_path This path is added to the base upload path.
	 * @param string $base_upload_path       This path is appended to the WordPress upload path, which defaults to
	 *                                       `WP_CONTENT_DIR . '/uploads'` in {@see _wp_upload_dir()}.
	 * @return void
	 */
	public function __construct( $additional_upload_path = '', $base_upload_path = '/lifterlms' ) {

		$this->set_base_upload_path( $base_upload_path );
		$this->set_additional_upload_path( $additional_upload_path );
	}

	/**
	 * Adds query parameters to a protected media URL.
	 *
	 * Hooked to the {@see 'wp_get_attachment_image_src'} filter in {@see wp_get_attachment_image_src()}
	 * by {@see LLMS_Media_Protector::register_callbacks()}.
	 *
	 * @since 7.7.0
	 *
	 * @param array|false  $image    {
	 *     Array of image data, or boolean false if no image is available.
	 *
	 *     @type string $0 Image source URL.
	 *     @type int    $1 Image width in pixels.
	 *     @type int    $2 Image height in pixels.
	 *     @type bool   $3 Whether the image is a resized image.
	 * }
	 * @param int          $media_id The post ID of the image.
	 * @param string|int[] $size     Requested image size. Can be any registered image size name,
	 *                               or an array of width and height values in pixels (in that order).
	 * @param bool         $icon     Whether the image should be treated as an icon.
	 * @return array|false
	 */
	public function authorize_media_image_src( $image, $media_id, $size, $icon ) {

		if ( ! is_numeric( $media_id ) || ! intval( $media_id ) ) {
			// Nothing to verify.
			return $image;
		}

		$is_authorized = $this->is_authorized_to_view( get_current_user_id(), $media_id );
		if ( is_null( $is_authorized ) ) {
			// The media file is not protected.
			return $image;
		} elseif ( false === $is_authorized ) {
			// Return the same thing that wp_get_attachment_image_src would return if no image found.
			return false;
		}

		$image[0] = add_query_arg(
			array(
				self::URL_PARAMETER_ID   => $media_id,
				self::URL_PARAMETER_SIZE => rawurlencode( is_array( $size ) ? wp_json_encode( $size ) : $size ),
				self::URL_PARAMETER_ICON => $icon ? 1 : 0,
			),
			trailingslashit( home_url() )
		);

		return $image;
	}

	/**
	 * Returns the unchanged URL if the media file is not protected,
	 * else if the user is authorized, returns a URL that triggers {@see LLMS_Media_Protector::serve_file()} when requested,
	 * else returns a URL to a placeholder file.
	 *
	 * The result of this filter is cached for the duration of the current HTTP request.
	 *
	 * Hooked to the {@see 'wp_get_attachment_url'} filter in {@see wp_get_attachment_url()}
	 * by {@see LLMS_Media_Protector::register_callbacks()}.
	 *
	 * @since 7.7.0
	 *
	 * @param string $url      URL for the given media file.
	 * @param int    $media_id The post ID of the media file.
	 * @return string
	 */
	public function authorize_media_url( $url, $media_id ) {

		$is_authorized = $this->is_authorized_to_view( get_current_user_id(), $media_id );
		if ( true === $is_authorized ) {
			$url = add_query_arg(
				array( self::URL_PARAMETER_ID => $media_id ),
				trailingslashit( home_url() )
			);
		} elseif ( false === $is_authorized ) {
			$url = '';
		}
		// If $is_authorized is null, do not change $url because it is unprotected.

		return $url;
	}

	/**
	 * Modify the media upload directory if this is a LifterLMS request.
	 *
	 * @param $params
	 *
	 * @return array
	 */
	public function change_media_upload_directory( $params ) {
		if ( isset( $_REQUEST['llms'] ) && '1' === $_REQUEST['llms'] ) {
			$params = $this->upload_dir( $params );
		}

		return $params;
	}

	/**
	 * Adds authorization meta after an attachment is added.
	 *
	 * @param $media_id
	 *
	 * @return void
	 */
	public function add_authorization_meta_after_attachment_added( $media_id ) {
		$attachment = get_post( $media_id );
		if ( $attachment && 'attachment' === $attachment->post_type && isset( $_REQUEST['llms'] ) && '1' === $_REQUEST['llms'] ) {
			$this->add_authorization_meta_to_media_post( $media_id );
		}
	}

	/**
	 * Returns a path path with a leading slash and without a trailing slash, or if the given path is empty, an empty string.
	 *
	 * @since 7.7.0
	 *
	 * @param string $path The path to be formatted.
	 * @return string An empty string or a path with a leading slash and without a trailing slash.
	 */
	protected function format_path( $path ) {

		if ( '' === $path ) {
			return $path;
		}

		// Add leading slash.
		if ( strpos( $path, '/' ) !== 0 ) {
			$path = '/' . $path;
		}

		// Strip trailing slash.
		$path = untrailingslashit( $path );

		return $path;
	}

	/**
	 * Returns the additional path that is added onto the base path.
	 *
	 * @since 7.7.0
	 *
	 * @return string
	 */
	public function get_additional_upload_path() {

		return $this->additional_upload_path;
	}

	/**
	 * Returns the base upload path.
	 *
	 * @since 7.7.0
	 *
	 * @return string
	 */
	public function get_base_upload_path() {

		return $this->base_upload_path;
	}

	/**
	 * Returns the absolute path to the media file in the upload directory.
	 *
	 * @since 7.7.0
	 *
	 * @param int $media_id The media post ID.
	 * @return string
	 */
	public function get_media_path( $media_id ) {

		$upload_dir = wp_upload_dir();
		$file_name  = get_post_meta( $media_id, '_wp_attached_file', true );

		return $upload_dir['basedir'] . DIRECTORY_SEPARATOR . $file_name;
	}

	/**
	 * Gets the size from the URL query parameter.
	 *
	 * @see wp_create_image_subsizes()
	 * @since 7.7.0
	 *
	 * @return string|int[]|null
	 */
	protected function get_size() {

		$size = ( isset( $_GET[ self::URL_PARAMETER_SIZE ] ) ) ? sanitize_text_field( wp_unslash( $_GET[ self::URL_PARAMETER_SIZE ] ) ) : null;
		if ( false === $size ) {
			$size = null;
		} elseif ( is_string( $size ) && '[' === $size[0] ) {
			$size = json_decode( $size );
			// Sanitize untrusted external input.
			if ( isset( $size[0] ) ) {
				$size[0] = (int) $size[0];
			}
			if ( isset( $size[1] ) ) {
				$size[1] = (int) $size[1];
			}
		}

		return $size;
	}

	/**
	 * Saves a file submitted from a POST request and creates an attachment post for it.
	 *
	 * @since 7.7.0
	 *
	 * @param string $file_id   Index of the `$_FILES` array that the file was sent. Required.
	 * @param int    $post_id   The post ID of a post to attach the media item to. Required, but can
	 *                          be set to 0, creating a media item that has no relationship to a post.
	 * @param string $hook_name The name of the filter that will be applied by {@see LLMS_Media_Protector::is_authorized_to_view()}.
	 * @param array  $post_data Optional. Set attachment elements that are sent to {@see wp_insert_post()}.
	 *                          The defaults are set in {@see media_handle_upload()}.
	 * @param array  $overrides Optional. Override the {@see wp_handle_upload()} behavior.
	 * @return int|WP_Error Post ID of the media file or a WP_Error object on failure.
	 */
	public function handle_upload(
		$file_id,
		$post_id,
		$hook_name,
		$post_data = array(),
		$overrides = array( 'test_form' => false )
	) {

		$post_data['meta_input'][ self::AUTHORIZATION_FILTER_KEY ] = $hook_name;
		add_filter( 'upload_dir', array( $this, 'upload_dir' ), 10, 1 );
		$media_id = media_handle_upload( $file_id, $post_id, $post_data, $overrides );
		remove_filter( 'upload_dir', array( $this, 'upload_dir' ), 10 );
		$this->add_authorization_meta_to_media_post( $media_id );

		return $media_id;
	}

	/**
	 * See if the media is protected with an authorization filter.
	 *
	 * @param $media_id
	 *
	 * @return bool
	 */
	public function is_media_protected( $media_id ) {
		return (bool) get_post_meta( $media_id, self::AUTHORIZATION_FILTER_KEY, true );
	}

	/**
	 * Returns true if the user is authorized to view the requested media file, false if not authorized,
	 * or null if the media file is not protected.
	 *
	 * Authorization is handled by the callback added to the filter hook name given to {@see LLMS_Media_Protector::handle_upload()}.
	 *
	 * @since 7.7.0
	 *
	 * @param int $user_id  The user ID.
	 * @param int $media_id The post ID of the media file.
	 * @return bool|null
	 */
	public function is_authorized_to_view( $user_id, $media_id ): ?bool {
		if ( ! is_numeric( $media_id ) || ! intval( $media_id ) ) {
			return null;
		}

		$cache_key     = 'llms-media-authorization-' . $media_id . '-' . $user_id;
		$authorization = wp_cache_get( $cache_key, 'llms_media_authorization', false, $found );
		if ( $found ) {
			return ( ( $authorization === 'null' ) ? null : $authorization );
		}

		$authorization_filter = get_post_meta( $media_id, self::AUTHORIZATION_FILTER_KEY, true );
		if ( ! $authorization_filter ) {
			// We need to use string of 'null' since on some hosting like wordpress.com the value of null comes back as bool false.
			wp_cache_add( $cache_key, 'null', 'llms_media_authorization' );

			return null;
		}

		// The default is to allow WordPress super admins and LifterLMS managers to view all protected media files.
		// @todo Consider allowing users with the some of the 'students' capabilities.
		if ( is_super_admin( $user_id ) ) {
			$is_authorized = true;
		} else {
			$user          = wp_get_current_user();
			$is_authorized = in_array( 'llms_manager', $user->roles, true ) || intval( get_post_field( 'post_author', $media_id ) ) === $user_id;
		}

		// Allow student to view if they have an incomplete attempt for a quiz this media is for.
		if ( ! $is_authorized && llms_get_student() ) {
			// Check if the student is enrolled in a course that has access to the media.
			$authorized_product_id = get_post_meta( $media_id, '_llms_media_protection_product_id', true );
			if ( $authorized_product_id && (
				llms_get_student()->is_enrolled( $authorized_product_id ) ||
				llms_current_user_can_edit_product( $authorized_product_id )
				) ) {
				$is_authorized = true;
			}

			if ( ! $is_authorized ) {
				$authorized_quiz_ids = (array) get_post_meta( $media_id, '_llms_quiz_id', true );

				if ( $authorized_quiz_ids ) {
					$student_quizzes = llms_get_student()->quizzes()->get_all( $authorized_quiz_ids );
					foreach ( $student_quizzes as $student_quiz_attempt ) {
						$quiz_id = $student_quiz_attempt->get( 'quiz_id' );
						if ( ! ( new LLMS_Quiz( $quiz_id ) )->is_open() ) {
							continue;
						}
						if ( 'incomplete' === $student_quiz_attempt->get( 'status' ) ) {
							$is_authorized = true;
							break;
						}
					}
				}
			}
		}

		/**
		 * Allow the plugin that is protecting the file to authorize access to it.
		 *
		 * The default is to allow the user to view the file in case there is a not a callback for the authorization hook.
		 *
		 * @since 7.7.0
		 *
		 * @param bool|null $is_authorized True if the user is authorized to view the media file, false if not authorized,
		 *                                 or null if the file is not protected.
		 * @param int       $media_id      The post ID of the media file.
		 * @param int       $user_id       The ID of the user wanting to view the media file.
		 */
		$is_authorized = apply_filters( $authorization_filter, $is_authorized, $media_id, $user_id );

		// Sanitize value.
		if ( ! is_bool( $is_authorized ) && ! is_null( $is_authorized ) ) {
			$is_authorized = (bool) $is_authorized;
		}

		/**
		 * Determine how long the media authorization is valid for.
		 *
		 * @since 7.7.0
		 *
		 * @param int   $cache_expiration    Time in seconds to cache the authorization for this media file and user.
		 * @param int   $media_id            The post ID of the media file.
		 * @param int   $user_id             The ID of the user wanting to view the media file.
		 */
		$cache_expiration = apply_filters( 'llms_media_protection_cache_expiration_time', MINUTE_IN_SECONDS * 1, $media_id, $user_id );

		wp_cache_add( $cache_key, $is_authorized, 'llms_media_authorization', $cache_expiration );

		return $is_authorized;
	}

	/**
	 * Returns true if the current request has a different modification date or entity tag than the requested file.
	 *
	 * @since 7.7.0
	 *
	 * @param string $file_name The complete path and file name that the request is for.
	 * @param string $entity_tag {@see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag}.
	 * @return bool
	 */
	protected function is_requested_file_modified( $file_name, $entity_tag ): bool {

		$is_modified = true;

		$file_modified     = filemtime( $file_name );
		$if_modified_since = ( isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) : '';
		if ( strtotime( $if_modified_since ) === $file_modified ) {
			$is_modified = false;
		}

		$if_match = llms_filter_input( INPUT_SERVER, 'HTTP_IF_MATCH', FILTER_SANITIZE_URL );
		if ( $if_match === $entity_tag ) {
			$is_modified = false;
		}

		return $is_modified;
	}

	/**
	 * Changes the URLs for image attachments prepared for JavaScript.
	 *
	 * Hooked to the {@see 'wp_prepare_attachment_for_js'} filter in {@see wp_prepare_attachment_for_js()}
	 * by {@see LLMS_Media_Protector::register_callbacks()}.
	 *
	 * @since 7.7.0
	 *
	 * @param array       $response   Array of prepared attachment data.
	 * @param WP_Post     $attachment Attachment object.
	 * @param array|false $meta       Array of attachment meta data, or false if there is none.
	 * @return array
	 */
	public function prepare_attachment_for_js( $response, $attachment, $meta ) {

		$is_authorized = $this->is_authorized_to_view( get_current_user_id(), $attachment->ID );
		if ( is_null( $is_authorized ) || ! array_key_exists( 'sizes', $response ) ) {
			return $response;
		}

		foreach ( $response['sizes'] as $size => &$size_meta ) {
			$size_meta['url'] = add_query_arg(
				array(
					self::URL_PARAMETER_ID   => $attachment->ID,
					self::URL_PARAMETER_SIZE => $size,
				),
				trailingslashit( home_url() )
			);
		}

		return $response;
	}

	/**
	 * Reads and outputs the file.
	 *
	 * This method sends the entire file and does not handle
	 * {@see https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests HTTP range requests}.
	 *
	 * @since 7.7.0
	 *
	 * @param string $file_name The file path and name.
	 * @return void
	 */
	protected function read_file( $file_name ): void {

		// @todo What about the web server time limit?
		set_time_limit( 0 );

		// Tell the HTTP client that we do not handle HTTP range requests.
		header( 'Accept-Ranges: none' );

		// Turn off all output buffers to avoid running out of memory with large files.
		// @see https://www.php.net/readfile#refsect1-function.readfile-notes.
		wp_ob_end_flush_all();

		$result = readfile( $file_name ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_readfile
		if ( false === $result ) {
			// Tell the HTTP client that something unspecific went wrong. readfile() outputs warnings to the PHP error log.
			header( 'HTTP/1.1 500 Internal Server Error' );
		}
	}

	/**
	 * Registers the callback functions for action and filter hooks that allow this class to protect uploaded media files.
	 *
	 * @since 7.7.0
	 *
	 * @return self
	 */
	public function register_callbacks() {

		if (
			// phpcs:disable WordPress.Security.NonceVerification.Recommended
			array_key_exists( self::URL_PARAMETER_ID, $_GET ) ||
			array_key_exists( self::URL_PARAMETER_PROTECTED_URL, $_GET )
			// phpcs:enable WordPress.Security.NonceVerification.Recommended
		) {
			add_action( 'init', array( $this, 'serve_file' ), 10 );
		} else {
			add_filter( 'admin_init', array( $this, 'save_mod_rewrite_rules' ), 10, 1 );
			add_filter( 'wp_prepare_attachment_for_js', array( $this, 'prepare_attachment_for_js' ), 99, 3 );
			add_filter( 'wp_get_attachment_image_src', array( $this, 'authorize_media_image_src' ), 10, 4 );
			add_filter( 'wp_get_attachment_url', array( $this, 'authorize_media_url' ), 10, 2 );
			add_filter( 'upload_dir', array( $this, 'change_media_upload_directory' ), 10, 1 );
			add_action( 'add_attachment', array( $this, 'add_authorization_meta_after_attachment_added' ), 10, 1 );
		}

		return $this;
	}

	/**
	 * Adds .htaccess and blank index.php/html files to the upload directory to protect the files from being listed.
	 *
	 * Hooked to the {@see 'flush_rewrite_rules_hard'} filter in {@see WP_Rewrite::flush_rules()}
	 * by {@see LLMS_Media_Protector::register_callbacks()}.
	 *
	 * @since 7.7.0
	 */
	public function save_mod_rewrite_rules() {
		// TODO: Different for multi-site?

		if ( false === get_transient( 'lifterlms_check_media_protection_files' ) ) {
			global $wp_filesystem;
			/** @var WP_Filesystem_Base $wp_filesystem */

			/** Load files that define {@see WP_Filesystem()}, {@see media_handle_sideload()}, and many image functions. */
			require_once ABSPATH . 'wp-admin/includes/file.php';

			WP_Filesystem();

			$uploads = wp_get_upload_dir();

			$upload_path   = $uploads['basedir'] . $this->get_base_upload_path();
			$htaccess_file = $upload_path . '/.htaccess';

			$upload_path_writeable = $wp_filesystem->is_writable( $upload_path );

			$rules  = "Options -Indexes\n";
			$rules .= "deny from all\n";

			if ( $upload_path_writeable && ! $wp_filesystem->exists( $htaccess_file ) ) {
				$wp_filesystem->put_contents( $htaccess_file, $rules, 0644 );
			} elseif ( $upload_path_writeable ) {
				$contents = $wp_filesystem->get_contents( $htaccess_file );
				if ( $contents !== $rules ) {
					$wp_filesystem->put_contents( $htaccess_file, $rules, 0644 );
				}
			}

			if ( $upload_path_writeable && ! $wp_filesystem->exists( $upload_path . '/index.php' ) ) {
				$wp_filesystem->put_contents( $upload_path . '/index.php', '<?php' . PHP_EOL . '// Silence is golden.' );
			}

			if ( $upload_path_writeable && ! $wp_filesystem->exists( $upload_path . '/index.html' ) ) {
				$wp_filesystem->put_contents( $upload_path . '/index.html', '' );
			}

			// Get the main directories in the root of the directory we're scanning.
			$upload_root_dirs = glob( $upload_path . '/*', GLOB_ONLYDIR | GLOB_NOSORT | GLOB_MARK );

			// Now get all the recursive directories.
			$upload_sub_dirs = glob( $upload_path . '/*/**', GLOB_ONLYDIR | GLOB_NOSORT | GLOB_MARK );

			// Merge the two arrays together, and avoid any possible duplicates.
			foreach ( array_unique( array_merge( $upload_root_dirs, $upload_sub_dirs ) ) as $dir ) {
				if ( ! wp_is_writable( $dir ) ) {
					continue;
				}

				// Create index.php, if it doesn't exist.
				if ( ! $wp_filesystem->exists( $dir . 'index.php' ) ) {
					$wp_filesystem->put_contents( $dir . 'index.php', '<?php' . PHP_EOL . '// Silence is golden.' );
				}

				if ( ! $wp_filesystem->exists( $dir . 'index.html' ) ) {
					$wp_filesystem->put_contents( $dir . 'index.html', '' );
				}
			}

			set_transient( 'lifterlms_check_media_protection_files', true, DAY_IN_SECONDS );
		}
	}

	/**
	 * Outputs an X-Sendfile or X-Accel-Redirect HTTP header which will instruct the HTTP server
	 * to send the file so that PHP doesn't have to.
	 *
	 * If none of the following HTTP servers are detected, {@see LLMS_Media_Protector::read_file()} is called.
	 * - {@see https://tn123.org/mod_xsendfile/ Apache mod_xsendfile}
	 * - {@see https://redmine.lighttpd.net/projects/lighttpd/wiki/Docs_ModCGI Lighttpd}
	 * - {@see https://www.nginx.com/resources/wiki/start/topics/examples/xsendfile/ NGINX}
	 * - {@see https://cherokee-project.com/doc/other_goodies.html#x-sendfile Cherokee}
	 *
	 * Add `$_SERVER['MOD_X_SENDFILE_ENABLED'] = '1';` in `wp-config.php` if web server auto-detection isn't working.
	 *
	 * IIS administrators may want to use {@see https://github.com/stakach/IIS-X-Sendfile-plugin}.
	 *
	 * @since 7.7.0
	 *
	 * @param string $file_name The file path and name.
	 * @param int    $media_id  The post ID of the media file. Not used in this implementation, but here for consistency
	 *                          with the other "serve" methods and may be useful in an overriding this method.
	 * @return void
	 */
	protected function send_file( $file_name, $media_id ) {
		$server_software = ( isset( $_SERVER['SERVER_SOFTWARE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) : '' );

		if (
			( array_key_exists( 'MOD_X_SENDFILE_ENABLED', $_SERVER ) && '1' === $_SERVER['MOD_X_SENDFILE_ENABLED'] ) ||
			( function_exists( 'apache_get_modules' ) && in_array( 'mod_xsendfile', apache_get_modules(), true ) ) ||
			stristr( $server_software, 'cherokee' ) ||
			stristr( $server_software, 'lighttpd' )
		) {
			header( "X-Sendfile: $file_name" );

		} elseif ( stristr( $server_software, 'nginx' ) ) {
			/**
			 * @see https://www.nginx.com/resources/wiki/start/topics/examples/xsendfile/
			 * @see https://woocommerce.com/document/digital-downloadable-product-handling/#nginx-setting
			 */
			// NGINX requires a URI without the server's root path.
			$wp_root = $this->find_wp_root();

			if ( $wp_root ) {
				$nginx_file_name = substr( $file_name, strlen( $this->find_wp_root() ) );
				header( 'X-Accel-Redirect: ' . urlencode( $nginx_file_name ) );
			} else {
				$this->read_file( $file_name );
			}
		} else {
			$this->read_file( $file_name );
		}
	}

	protected function find_wp_root() {
		$dir = dirname( WP_CONTENT_DIR );
		while ( $dir ) {
			if ( file_exists( $dir . '/wp-load.php' ) || file_exists( $dir . '/wp-config.php' ) ) {
				return $dir;
			}

			$parent_dir = dirname( $dir );
			if ( $parent_dir === $dir ) {
				// We have reached the root directory
				break;
			}
			$dir = $parent_dir;
		}
		return false; // Root directory not found
	}


	/**
	 * Send headers for the download.
	 *
	 * @since 7.7.0
	 *
	 * @param string $file_name The file path and name.
	 * @param int    $media_id  The post ID of the media file.
	 * @return void
	 */
	protected function send_headers( $file_name, $media_id ) {

		$file_size = @filesize( $file_name ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		if ( ! $file_size ) {
			return;
		}

		$media_file   = get_post( $media_id );
		$content_type = $media_file->post_mime_type;

		header( "Content-Type: $content_type" );
		header( "Content-Length: $file_size" );
		header( 'X-Robots-Tag: noindex, nofollow', true );
	}

	/**
	 * Sends a header that redirects the HTTP client to the media file's URL.
	 *
	 * @since 7.7.0
	 *
	 * @param int               $media_id The post ID of the media file.
	 * @param string|int[]|null $size     A registered image size name, or an array of width and height values in pixels.
	 * @param bool|null         $icon     Whether the image should fall back to a mime type icon.
	 * @return void
	 */
	protected function send_redirect( $media_id, $size, $icon ): void {

		if ( is_null( $size ) && is_null( $icon ) ) {
			$url = wp_get_attachment_url( $media_id );
		} else {
			$url = wp_get_attachment_image_url( $media_id, $size, $icon );
		}

		header( "Location: $url" );
	}

	protected function strip_query_params( $file_name ) {
		$parsed_url = wp_parse_url( $file_name );
		$path       = isset( $parsed_url['path'] ) ? $parsed_url['path'] : $file_name;
		return $path;
	}

	/**
	 * Serves the requested media file to the HTTP client.
	 *
	 * This method calls the {@see llms_exit()} function and does not return.
	 *
	 * Hooked to the {@see 'init'} filter by {@see LLMS_Media_Protector::register_callbacks()}.
	 *
	 * @since 7.7.0
	 *
	 * @return void
	 * @throws LLMS_Unit_Test_Exception_Exit Thrown during unit testing instead of exiting.
	 */
	public function serve_file() {

		$media_id = llms_filter_input( INPUT_GET, self::URL_PARAMETER_ID, FILTER_SANITIZE_NUMBER_INT );

		// Handle a rewritten URL.
		// e.g. `/wp-content/uploads/llms-uploads/2022/01/image.png` is changed by the LifterLMS mod_rewrite rule
		// into `/index.php?llms-uploads=llms_protected_url/2022/01/image.png`.
		if ( empty( $media_id ) ) {
			$attached_file = llms_filter_input( INPUT_GET, self::URL_PARAMETER_PROTECTED_URL, FILTER_SANITIZE_URL );

			/** Extract the optional size. {@see WP_Image_Editor::get_suffix()} and {@see WP_Image_Editor::generate_filename()} */
			$result = preg_match( '/^(.+?)-(\d+x\d+)(.+)$/', $attached_file, $matches );
			if ( $result ) {
				$attached_file = $matches[1] . $matches[3];
				$size          = explode( 'x', $matches[2] );
				$size          = array_map( 'intval', $size );
			}

			$query    = new WP_Query(
				array(
					'fields'      => 'ids',
					'meta_key'    => '_wp_attached_file',
					'meta_value'  => $attached_file,
					'post_status' => 'any',
					'post_type'   => 'attachment',
				)
			);
			$media_id = reset( $query->posts );
		}

		// The auth filter meta needs to exist for the media file to be served by this method.
		if ( ! get_post_meta( $media_id, self::AUTHORIZATION_FILTER_KEY, true ) ) {
			header( 'HTTP/1.1 404 Not Found' );
			llms_exit();
		}

		$media_file = get_post( $media_id );

		// Validate that the attachment post exists.
		if ( is_null( $media_file ) || 'attachment' !== $media_file->post_type ) {
			header( 'HTTP/1.1 404 Not Found' );
			llms_exit();
		}

		$file_name = $this->get_media_path( $media_id );

		// Optionally, use an alternate image size.
		if ( ! isset( $size ) ) {
			$size = $this->get_size();
		}
		$icon = ( isset( $_GET[ self::URL_PARAMETER_ICON ] ) ? sanitize_text_field( wp_unslash( $_GET[ self::URL_PARAMETER_ICON ] ) ) : null );
		if ( ! is_null( $size ) || $icon ) {
			$image     = wp_get_attachment_image_src( $media_id, $size, $icon );
			$file_name = dirname( $file_name ) . '/' . basename( $image[0] );
		}

		$file_name = $this->strip_query_params( $file_name );

		// Validate that the media file exists.
		if ( false === file_exists( $file_name ) ) {
			header( 'HTTP/1.1 404 Not Found' );
			llms_exit();
		}

		// Is the user authorized to view the file?
		$is_authorized = $this->is_authorized_to_view( get_current_user_id(), $media_id );
		if ( false === $is_authorized ) {
			status_header( 404 );
			nocache_headers();
			die( 'File not found.' );
		}

		// An HTTP client, but not a proxy, is allowed to cache the file, but must check with the server before reuse.
		$entity_tag = '"' . md5_file( $file_name ) . '"';
		if ( false === $this->is_requested_file_modified( $file_name, $entity_tag ) ) {
			header( 'HTTP/1.1 304 Not Modified' );
			llms_exit();
		}
		header( 'Cache-Control: private, no-cache' );
		header( "Etag: $entity_tag" );

		$serve_method = self::SERVE_SEND_FILE;

		// If WPEngine, we need to use the slower read file method as send file does not work.
		if ( function_exists( 'is_wpe' ) && is_wpe() ) {
			$serve_method = self::SERVE_READ_FILE;
		}

		/**
		 * Determine how the media file should be served.
		 *
		 * @since 7.7.0
		 *
		 * @param string    $serve_method  One of the LLMS_Media_Protector::SERVE_X constants, {@see LLMS_Media_Protector::SERVE_SEND_FILE}.
		 * @param int       $media_id      The post ID of the media file.
		 * @param bool|null $is_authorized True if the user is authorized to view the requested media file,
		 *                                 false if not authorized, or null if the media file is not protected.
		 */
		$serve_method = apply_filters( 'llms_media_serve_method', $serve_method, $media_id, $is_authorized );

		// Don't use 'llms-uploads=' rewrite + send_redirect() at the same time. Otherwise there will be an infinite loop
		// of HTTP requests for the file and HTTP responses with a '302 Found' redirect back to the same file.
		if ( self::SERVE_REDIRECT === $serve_method && isset( $attached_file ) ) {
			$serve_method = self::SERVE_READ_FILE;
		}

		switch ( $serve_method ) {
			case self::SERVE_READ_FILE:
				$this->send_headers( $file_name, $media_id );
				$this->read_file( $file_name );
				break;
			case self::SERVE_SEND_FILE:
				$this->send_headers( $file_name, $media_id );
				$this->send_file( $file_name, $media_id );
				break;
			case self::SERVE_REDIRECT:
			default:
				$this->send_redirect( $media_id, $size, $icon );
				break;
		}

		llms_exit();
	}

	/**
	 * Sanitizes and sets the additional upload path that is appended to the base upload path.
	 *
	 * @since 7.7.0
	 *
	 * @param string $additional_upload_path
	 * @return self
	 */
	public function set_additional_upload_path( $additional_upload_path ): self {

		$this->additional_upload_path = $this->format_path( $additional_upload_path );

		return $this;
	}

	/**
	 * Sanitizes and sets the base upload path relative to `WP_CONTENT_DIR . '/uploads'`.
	 *
	 * @since 7.7.0
	 *
	 * @param string $base_upload_path
	 * @return self
	 */
	public function set_base_upload_path( $base_upload_path ): self {

		$this->base_upload_path = $this->format_path( $base_upload_path );

		return $this;
	}

	public function get_upload_basedir() {
		return trailingslashit( $this->base_upload_path . $this->additional_upload_path );
	}

	/**
	 * Filters the 'uploads' directory data.
	 *
	 * @since 7.7.0
	 *
	 * @param array $uploads {
	 *     Array of information about the upload directory.
	 *
	 *     @type string       $path    Base directory and subdirectory or full path to upload directory.
	 *     @type string       $url     Base URL and subdirectory or absolute URL to upload directory.
	 *     @type string       $subdir  Subdirectory if uploads use year/month folders option is on.
	 *     @type string       $basedir Path without subdirectory.
	 *     @type string       $baseurl URL path without subdirectory.
	 *     @type string|false $error   False or error message.
	 * }
	 * @return array
	 */
	public function upload_dir( $uploads ) {
		$uploads['subdir'] = trailingslashit( $this->get_upload_basedir() ) . date( 'Y/m' );
		$uploads['path']   = $uploads['basedir'] . $uploads['subdir'];
		$uploads['url']    = $uploads['baseurl'] . $uploads['subdir'];

		return $uploads;
	}

	/**
	 * Add authorization meta to the post.
	 *
	 * @param $post_id
	 * @param string $hook_name The name of the filter that will be applied by {@see LLMS_Media_Protector::is_authorized_to_view()}.
	 *
	 * @return void
	 */
	public function add_authorization_meta_to_media_post( $post_id, $hook_name = 'llms_attachment_is_access_allowed' ) {
		if ( ! is_numeric( $post_id ) ) {
			return;
		}

		update_post_meta( $post_id, self::AUTHORIZATION_FILTER_KEY, $hook_name );
	}
}
