<?php
/**
 * Download proxy: the authenticated path to a protected upload.
 *
 * Threat model, in order:
 *  1. Every link carries a keyed token (an HMAC over the registry id, keyed
 *     with this site's auth salt) that is verified with hash_equals() before
 *     the id is used for anything. The token proves the link was minted by
 *     this site; it grants nothing by itself.
 *  2. The request never contains a filesystem path — only a registry row id.
 *     The id resolves to a stored relative path, which is then realpath-checked
 *     to sit INSIDE the Gravity Forms upload base. Traversal is structurally
 *     impossible, and we still verify.
 *  3. Capability-gated on every request: by default users who can view
 *     Gravity Forms entries (or manage_options when Gravity Forms is absent).
 *     Filterable. A valid token never bypasses this.
 *  4. Never executes or renders: attachment disposition + nosniff + a
 *     no-source CSP, and .php-family files are refused outright.
 *
 * The link token is an HMAC rather than a WordPress nonce because the links
 * live in entry views and notification emails: they must keep working past a
 * nonce's lifetime and for whichever authorised person opens them (nonces are
 * bound to one user session). The endpoint is a read that changes no state;
 * the token supplies the origin check, authorize() supplies authorization.
 *
 * @package Upload_Vault
 */

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

/**
 * Class Uvgf_Proxy
 */
class Uvgf_Proxy {

	/**
	 * Wire endpoints.
	 */
	public static function init() {
		add_action( 'admin_post_uvgf_download', array( __CLASS__, 'handle' ) );
		add_action( 'admin_post_nopriv_uvgf_download', array( __CLASS__, 'handle_nopriv' ) );
	}

	/**
	 * Proxy URL for a registry row. Every URL carries the keyed token for its
	 * id; handle() refuses requests whose token does not verify.
	 *
	 * @param int $file_id Registry id.
	 * @return string
	 */
	public static function url( $file_id ) {
		$file_id = absint( $file_id );
		return add_query_arg(
			array(
				'action' => 'uvgf_download',
				'f'      => $file_id,
				'k'      => self::key_for( $file_id ),
			),
			admin_url( 'admin-post.php' )
		);
	}

	/**
	 * The keyed request token for a registry id: an HMAC over the id, keyed
	 * with this site's auth salt. Site-specific and id-specific, deliberately
	 * not time- or user-bound (see the file header for why a nonce cannot
	 * serve here). Verified with hash_equals() in verify_key().
	 *
	 * @param int $file_id Registry id.
	 * @return string
	 */
	public static function key_for( $file_id ) {
		return hash_hmac( 'sha256', 'uvgf_download|' . absint( $file_id ), wp_salt( 'auth' ) );
	}

	/**
	 * Does a presented token match the keyed token for this id?
	 *
	 * @param int    $file_id Registry id.
	 * @param string $key     Presented token.
	 * @return bool
	 */
	public static function verify_key( $file_id, $key ) {
		return is_string( $key ) && '' !== $key && hash_equals( self::key_for( $file_id ), $key );
	}

	/**
	 * Logged-out: send through wp-login and back.
	 */
	public static function handle_nopriv() {
		auth_redirect();
	}

	/**
	 * Authenticated download.
	 */
	public static function handle() {
		$file_id = isset( $_GET['f'] ) ? absint( $_GET['f'] ) : 0;
		$key     = isset( $_GET['k'] ) ? sanitize_text_field( wp_unslash( $_GET['k'] ) ) : '';

		// Link-origin check before anything else: the id is used only after
		// its keyed token verifies. The token authenticates the LINK, never
		// the user — authorize() below still gates every request.
		if ( $file_id <= 0 || ! self::verify_key( $file_id, $key ) ) {
			self::deny( 403, __( 'This download link is not valid.', 'buildsbyluke-upload-vault-for-gravity-forms' ), null );
		}

		$row = Uvgf_Indexer::get( $file_id );
		if ( ! $row ) {
			self::deny( 404, __( 'File not found in the Upload Vault registry.', 'buildsbyluke-upload-vault-for-gravity-forms' ), null );
		}

		if ( ! self::authorize( $row ) ) {
			self::deny( 403, __( 'You are not allowed to download this file.', 'buildsbyluke-upload-vault-for-gravity-forms' ), $row );
		}

		$abs = self::resolve( $row );
		if ( '' === $abs ) {
			self::deny( 404, __( 'The file no longer exists on disk.', 'buildsbyluke-upload-vault-for-gravity-forms' ), $row );
		}

		/**
		 * Fires just before a permitted download streams.
		 *
		 * @param object $row Registry row.
		 * @param string $abs Absolute path.
		 */
		do_action( 'uvgf_proxy_served', $row, $abs );

		self::stream( $abs );
	}

	/**
	 * May the current user download this file?
	 *
	 * @param object $row Registry row.
	 * @return bool
	 */
	public static function authorize( $row ) {
		$allowed = false;

		if ( is_user_logged_in() ) {
			if ( Uvgf_Locator::gf_active() && current_user_can( 'gravityforms_view_entries' ) ) {
				$allowed = true;
			} elseif ( current_user_can( 'manage_options' ) ) {
				$allowed = true;
			}
		}

		/**
		 * Filter download authorization (the Pro layer adds per-form rules).
		 *
		 * @param bool   $allowed Current decision.
		 * @param object $row     Registry row (form_id, relpath, …).
		 */
		return (bool) apply_filters( 'uvgf_proxy_authorize', $allowed, $row );
	}

	/**
	 * Resolve a registry row to a verified absolute path inside the base.
	 *
	 * @param object $row Registry row.
	 * @return string Empty string when invalid.
	 */
	public static function resolve( $row ) {
		$base = realpath( Uvgf_Locator::base_dir() );
		if ( false === $base ) {
			return '';
		}

		$candidate = realpath( $base . DIRECTORY_SEPARATOR . wp_normalize_path( (string) $row->relpath ) );
		if ( false === $candidate || ! is_file( $candidate ) ) {
			return '';
		}

		// Containment: the resolved path must sit inside the resolved base.
		if ( 0 !== strpos( $candidate, $base . DIRECTORY_SEPARATOR ) ) {
			return '';
		}

		// Never serve executable-flavoured files, wherever they came from.
		if ( preg_match( '/\.(php|phtml|phar|php\d)(\.|$)/i', basename( $candidate ) ) ) {
			return '';
		}

		return $candidate;
	}

	/**
	 * Refuse, notify the Pro audit trail, stop.
	 *
	 * @param int         $status  HTTP status.
	 * @param string      $message Message.
	 * @param object|null $row     Registry row when known.
	 */
	private static function deny( $status, $message, $row ) {
		/**
		 * Fires when a download is refused.
		 *
		 * @param object|null $row    Registry row (null when the id was unknown).
		 * @param int         $status HTTP status.
		 */
		do_action( 'uvgf_proxy_denied', $row, $status );

		wp_die( esc_html( $message ), esc_html__( 'Upload Vault', 'buildsbyluke-upload-vault-for-gravity-forms' ), array( 'response' => (int) $status ) );
	}

	/**
	 * Stream a file as an attachment and exit. Public so the Pro share
	 * endpoint can reuse the exact same hardened path.
	 *
	 * @param string $abs Verified absolute path.
	 */
	public static function stream( $abs ) {
		$name = sanitize_file_name( basename( $abs ) );
		$type = wp_check_filetype( $name );
		$mime = ! empty( $type['type'] ) ? $type['type'] : 'application/octet-stream';
		$size = (int) filesize( $abs );

		nocache_headers();
		header( 'X-Content-Type-Options: nosniff' );
		header( "Content-Security-Policy: default-src 'none'" );
		header( 'Content-Type: ' . $mime );
		if ( $size > 0 ) {
			header( 'Content-Length: ' . $size );
		}
		header( 'Content-Disposition: attachment; filename="' . $name . '"' );

		// Discard anything already buffered: a stray byte of markup ahead of the
		// payload corrupts the downloaded file. Doing this also keeps readfile()
		// streaming straight to the client instead of filling a buffer.
		while ( ob_get_level() > 0 ) {
			ob_end_clean();
		}

		// readfile() streams the bytes itself, so no file content is ever echoed
		// through the plugin — there is nothing here to escape, and therefore no
		// security sniff to suppress. The path is registry-resolved and
		// realpath-contained by resolve() above.
		$sent = readfile( $abs ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streaming an attachment download; WP_Filesystem offers no streaming read.
		if ( false === $sent ) {
			wp_die( esc_html__( 'The file could not be opened.', 'buildsbyluke-upload-vault-for-gravity-forms' ), '', array( 'response' => 500 ) );
		}
		exit;
	}
}
