<?php

/**
 * Handles download requests for files stored by File Upload fields.
 *
 * Class GF_Download
 */
class GF_Download {

	/**
	 * If the request is for a Gravity Forms file download then validate and deliver.
	 *
	 * @since 2.0
	 */
	public static function maybe_process() {
		if ( isset( $_GET['gf-download'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended

			$file     = $_GET['gf-download']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
			$form_id  = rgget( 'form-id' );
			$field_id = rgget( 'field-id' );

			if ( empty( $file ) || empty( $form_id ) ) {
				return;
			}

			$hash     = rgget( 'hash' );
			$entry_id = rgget( 'entry-id' );

			$user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
			$referer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '';
			// IP address with the last octet removed for privacy reasons.
			$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? preg_replace('/([0-9a-fA-F]+|\d+)$/', '*', sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) ) : '';

			GFCommon::log_debug( __METHOD__ . "(): File requested from IP '{$ip}', User Agent '{$user_agent}', Referer '{$referer}'" );

			GFCommon::log_debug( __METHOD__ . "(): Starting file download process. file: {$file}, hash: {$hash}." );

			$file_validation = self::validate_file_path( $file );
			if ( is_wp_error( $file_validation ) ) {
				GFCommon::log_debug( __METHOD__ . sprintf( '(): Not downloading file (%s): %s', $file_validation->get_error_code(), $file ) );
				self::die_401();
			}

			$permission_granted = self::validate_download( $form_id, $field_id, $file, $hash, $entry_id );

			if ( has_filter( 'gform_permission_granted_pre_download' ) ) {
				GFCommon::log_debug( __METHOD__ . '(): Executing functions hooked to gform_permission_granted_pre_download.' );
			}

			/**
			 * Allow custom logic to be used to determine if the file can be accessed.
			 *
			 * @since 2.4.3.2
			 *
			 * @param bool   $permission_granted Indicates if access to the file has been granted. Default is the result of the hash validation.
			 * @param string $form_id            The ID of the form used to upload the requested file.
			 * @param string $field_id           The ID of the field used to upload the requested file.
			 */
			$permission_granted = apply_filters( 'gform_permission_granted_pre_download', $permission_granted, $form_id, $field_id );

			if ( $permission_granted ) {
				GFCommon::log_debug( __METHOD__ . '(): Download validated. Proceeding.' );
				self::deliver( $form_id, $file, $entry_id );
			} else {
				GFCommon::log_debug( __METHOD__ . '(): Download validation failed. Aborting with 401.' );
				self::die_401();
			}
		}
	}

	/**
	 * Verifies the hash for the download.
	 *
	 * @since 2.0
	 * @since 2.9.29 Added the $entry_id param.
	 *
	 * @param string $form_id  The form ID from the query string.
	 * @param string $field_id The field ID from the query string.
	 * @param string $file     The file name from the query string.
	 * @param string $hash     The hash from the query string.
	 * @param string $entry_id The entry ID from the query string.
	 *
	 * @return bool
	 */
	private static function validate_download( $form_id, $field_id, $file, $hash, $entry_id ) {
		if ( empty( $hash ) ) {
			return false;
		}

		if ( has_filter( 'gform_require_login_pre_download' ) ) {
			GFCommon::log_debug( __METHOD__ . '(): Executing functions hooked to gform_require_login_pre_download.' );
		}

		/**
		 * Allows login to be required to access the file.
		 *
		 * @since 2.2.3.16
		 *
		 * @param bool   $require_login Does the user need to be logged in to access the file? Default false.
		 * @param string $form_id       The ID of the form used to upload the requested file.
		 * @param string $field_id      The ID of the field used to upload the requested file.
		 */
		$require_login = apply_filters( 'gform_require_login_pre_download', false, $form_id, $field_id );

		if ( $require_login && ! is_user_logged_in() ) {
			GFCommon::log_debug( __METHOD__ . '(): Login required to access file.' );

			return false;
		}

		$hash_check = GFCommon::generate_download_hash( $form_id, $field_id, $file, $entry_id );
		$valid      = hash_equals( $hash, $hash_check );

		if ( ! $valid ) {
			GFCommon::log_debug( __METHOD__ . '(): Hash failed validation.' );

			return false;
		}

		return true;
	}

	/**
	 * Send the file.
	 *
	 * @since 2.0
	 * @since 2.9.29 Added the $entry_id param.
	 *
	 * @param string $form_id  The form ID from the query string.
	 * @param string $file     The file name from the query string.
	 * @param string $entry_id The entry ID from the query string.
	 */
	private static function deliver( $form_id, $file, $entry_id ) {
		if ( $entry_id && ! GFAPI::entry_exists( $entry_id ) ) {
			GFCommon::log_debug( __METHOD__ . sprintf( '(): Entry ID %d has been deleted. Aborting with 404', $entry_id ) );
			self::die_404();
		}

		$path      = GFFormsModel::get_upload_path( $form_id );
		$file_path = trailingslashit( $path ) . $file;

		GFCommon::log_debug( __METHOD__ . "(): Checking if file exists: {$file_path}." );

		if ( file_exists( $file_path ) ) {
			GFCommon::log_debug( __METHOD__ . '(): File exists. Starting delivery.' );

			$content_type        = self::get_content_type( $file_path );
			$content_disposition = rgget( 'dl' ) ? 'attachment' : 'inline';

			/**
			 * Allows the nocache_headers() to be overridden.
			 *
			 * @since 2.9.15
			 *
			 * @param bool   $enable  If the nocache_headers() should be called. Default true.
			 * @param string $form_id The ID of the form used to download files.
			 * @param string $file    The file to be downloaded.
			 */
			if ( apply_filters( 'gform_enable_download_nocache_headers', true, $form_id, $file ) ) {
				nocache_headers();
			}

			header( 'X-Robots-Tag: noindex', true );
			header( 'Content-Type: ' . $content_type );
			header( 'Content-Description: File Transfer' );
			header( 'Content-Disposition: ' . $content_disposition . '; filename="' . wp_basename( $file ) . '"' );
			header( 'Content-Transfer-Encoding: binary' );
			// Clear buffer AND turn off output buffering before starting delivery of files requested for download to prevent third-parties to corrupt the file content.
			if ( ob_get_contents() ) {
				ob_end_clean();
			}
			self::readfile_chunked( $file_path );
			die();
		} else {
			GFCommon::log_debug( __METHOD__ . '(): File does not exist. Aborting with 404.' );
			self::die_404();
		}
	}

	/**
	 * Returns the appropriate mime type for the file extension.
	 *
	 * @param $file_path
	 *
	 * @return mixed|null|string
	 */
	private static function get_content_type( $file_path ) {
		$info = wp_check_filetype( $file_path );
		$type = rgar( $info, 'type' );

		return $type;
	}

	/**
	 * Reads file in chunks so big downloads are possible without changing PHP.INI
	 * See https://github.com/bcit-ci/CodeIgniter/wiki/Download-helper-for-large-files
	 *
	 * @access   public
	 * @param    string  $file      The file
	 * @param    boolean $retbytes  Return the bytes of file
	 * @return   bool|string        If string, $status || $cnt
	 */
	private static function readfile_chunked( $file, $retbytes = true ) {

		$chunksize = 1024 * 1024;
		$buffer    = '';
		$cnt       = 0;
		$handle    = @fopen( $file, 'r' );

		if ( $size = @filesize( $file ) ) {
			header( 'Content-Length: ' . $size );
		}

		if ( false === $handle ) {
			return false;
		}

		while ( ! @feof( $handle ) ) {
			$buffer = @fread( $handle, $chunksize );
			echo $buffer; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped

			if ( $retbytes ) {
				$cnt += strlen( $buffer );
			}
		}

		$status = @fclose( $handle );

		if ( $retbytes && $status ) {
			return $cnt;
		}

		return $status;
	}

	/**
	 * Ends the request with a 404 (Not Found) HTTP status code. Loads the 404 template if it exists.
	 */
	private static function die_404() {
		global $wp_query;
		status_header( 404 );
		$wp_query->set_404();
		$template_path = get_404_template();
		if ( file_exists( $template_path ) ) {
			require_once( $template_path );
		}
		die();
	}

	/**
	 * Validates a file path from a download request for security concerns.
	 *
	 * Checks for null bytes and directory traversal characters (including encoded variants).
	 *
	 * @since 2.10.2
	 *
	 * @param string $file The file path to validate.
	 *
	 * @return true|WP_Error True if the file path is safe, WP_Error otherwise.
	 */
	public static function validate_file_path( $file ) {
		if ( empty( $file ) ) {
			return new WP_Error( 'empty_file', __( 'The file path is empty.', 'gravityforms' ) );
		}

		// Null byte injection check.
		if ( str_contains( $file, '%00' ) || str_contains( $file, "\0" ) ) {
			return new WP_Error( 'null_byte', __( 'The file path contains a null byte.', 'gravityforms' ) );
		}

		// Directory traversal check on decoded path to catch encoded variants.
		$decoded_file = rawurldecode( rawurldecode( rawurldecode( $file ) ) );
		if ( str_contains( $decoded_file, '..' ) ) {
			return new WP_Error( 'directory_traversal', __( 'The file path contains directory traversal characters.', 'gravityforms' ) );
		}

		return true;
	}

	/**
	 * Ends the request with a 401 (Unauthorized) HTTP status code.
	 */
	private static function die_401() {
		status_header( 401 );
		die();
	}
}
