<?php

/**
 * Paid Memberships Pro — Member RSS.
 *
 * Integrates the Member RSS Add On, so that a member may subscribe to a podcast in their podcast
 * app using a personal feed URL and receive the episodes their membership entitles them to.
 *
 * The add-on hangs its authentication off `is_feed()`, which our feed route never satisfies: it is
 * a custom rewrite that sets `aiopod_feed` rather than the `feed` query var. So the add-on never
 * switches the current user for us and we authenticate the request ourselves instead.
 *
 * That is deliberate and must stay that way. Paid Memberships Pro denies access unconditionally
 * inside `is_feed()`, so presenting our route as a feed would drop every restricted episode from
 * every feed, including for the paying members this exists to serve.
 *
 * Tested against Member RSS 1.0. Every function borrowed from it is guarded, so an older or absent
 * copy degrades to the public feed rather than raising a fatal error.
 *
 * @link    https://aiopodcasting.com
 * @since   1.0.3
 *
 * @package AiO_Podcast_Studio
 */

// Exit if accessed directly
if ( ! defined( 'WPINC' ) ) {
	die;
}

/**
 * AIOPOD_Public_PMPro_Member_RSS class.
 *
 * @since 1.0.3
 */
class AIOPOD_Public_PMPro_Member_RSS {

	/**
	 * ID of the member the request authenticated as.
	 *
	 * Zero for an ordinary public request, which is every request until `authenticate_request()`
	 * has resolved a credential. The feed, enclosure and cache-key behaviour all branch on this,
	 * so that a public feed is served exactly as it was before this integration existed.
	 *
	 * @since  1.0.3
	 * @access private
	 * @var    int
	 */
	private $member_id = 0;

	/**
	 * How the request authenticated, either 'memberkey' or 'basic_auth'.
	 *
	 * Empty for a public request. A member feed's **restricted** enclosures carry the same
	 * credential the feed request itself arrived with, so that the podcast app can fetch the media
	 * the same way it fetched the feed. Unrestricted enclosures carry none — see
	 * `filter_episode_file_url()`.
	 *
	 * @since  1.0.3
	 * @access private
	 * @var    string
	 */
	private $auth_method = '';

	/**
	 * Who the request belonged to before a credential switched it.
	 *
	 * Kept so the switch can be undone. See `restore_user_after_handlers()`.
	 *
	 * @since  1.0.3
	 * @access private
	 * @var    int
	 */
	private $previous_user_id = 0;

	/**
	 * Hash of whatever the enclosures in this feed will carry as their credential.
	 *
	 * The member's key when they signed in with one, a fixed marker when they used Basic
	 * Authentication, empty on a public request. It exists because the credential is written into
	 * every enclosure URL, so it is part of what a cached feed contains and has to be part of what
	 * identifies that entry.
	 *
	 * Two ways to get it wrong, both found in testing. A member who regenerates their key keeps the
	 * same ID, levels and roles, so the entry built for the old key would go on being served with
	 * three hundred revoked credentials in it. And the two sign-in methods produce different
	 * enclosure URLs for the same member, so they cannot share an entry either.
	 *
	 * Hashed rather than stored plainly, because it ends up in an option name.
	 *
	 * @since  1.0.3
	 * @access private
	 * @var    string
	 */
	private $auth_token_hash = '';

	/**
	 * Authenticate a member on the podcast feed and episode download routes.
	 *
	 * Runs ahead of both handlers, so each one sees an ordinary logged-in user and needs to know
	 * nothing about how the request was authenticated. A request carrying no credential is left
	 * exactly as it arrived and is served the public feed.
	 *
	 * @since  1.0.3
	 * @return void
	 */
	public function authenticate_request() {
		if ( ! $this->is_our_route() || ! $this->is_active() ) {
			return;
		}

		$this->previous_user_id = get_current_user_id();

		// A credential is what makes a feed a member's. Being signed in is not: the feed at a plain
		// URL answers the same way for everyone, so that what an owner sees when they open it is
		// what subscribers receive. Requests carrying nothing simply fall past both branches below,
		// signed in or not, which is what keeps that true
		//
		// The member key takes precedence over Basic Auth when both are present, which is the
		// order the add-on itself resolves them in
		if ( ! empty( $_REQUEST['memberkey'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$this->authenticate_memberkey();
		} elseif ( ! empty( $_GET['pmpromrss_basic_auth'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$this->authenticate_basic_auth();
		}

		// Neither route has any use for an admin bar — one answers XML, the other a redirect. It is
		// suppressed here rather than left to `restore_user_after_handlers()` because WordPress
		// decides whether to show it on `template_redirect` at priority 0
		// (`wp-includes/default-filters.php`), which is after this runs but before the restore, so
		// by then the decision has already been taken as the member
		if ( $this->member_id ) {
			add_filter( 'show_admin_bar', '__return_false' );
		}
	}

	/**
	 * Undo the switch when neither handler took the request.
	 *
	 * Both the feed and the download endpoint answer their request and end it, so reaching this at
	 * all means the request was not theirs after all — a feed URL naming a podcast that does not
	 * exist is the way there. Without this the rest of the request would go on rendering as the
	 * member, which would turn a feed credential into a way to browse the site as its owner: the
	 * page comes back carrying their admin bar and their signed-in state, and any part of it that
	 * shows members-only content would show theirs.
	 *
	 * Registered after both handlers so that it only ever runs when they have declined.
	 *
	 * @since  1.0.3
	 * @return void
	 */
	public function restore_user_after_handlers() {
		if ( ! $this->member_id ) {
			return;
		}

		wp_set_current_user( $this->previous_user_id );

		// Every one of them, not just the two the other methods happen to test. Leaving a stale
		// credential hash behind would be harmless today and a trap for whoever reads it next
		$this->member_id       = 0;
		$this->auth_method     = '';
		$this->auth_token_hash = '';
	}

	/**
	 * Filter which episodes appear in the feed being rendered.
	 *
	 * A public request is left with the rule core applies for itself, which is answered from the
	 * content alone and is therefore the same for every visitor. Only once a member has been
	 * authenticated does the feed become specific to them, and the cache key varies to match.
	 *
	 * The decision is deferred to `aiopod_current_user_can()` rather than asked of Paid Memberships
	 * Pro directly. The current user really has been switched by this point, so that one call
	 * carries the membership levels, the restriction a podcast passes down to its episodes, and the
	 * role-based restrictions in Premium — all resolved together, and cached for the request.
	 *
	 * @since  1.0.3
	 * @param  bool $includes   True if the content may appear in the feed.
	 * @param  int  $post_id    Podcast or episode ID being considered.
	 * @param  int  $podcast_id ID of the podcast whose feed is being rendered.
	 * @return bool             True to include the content, false to leave it out.
	 */
	public function filter_feed_includes_content( $includes, $post_id, $podcast_id ) {
		if ( ! $this->member_id ) {
			return $includes;
		}

		return aiopod_current_user_can( 'view_aiopod_podcast', (int) $post_id );
	}

	/**
	 * Carry the member's credential on the episode media URL.
	 *
	 * A podcast app fetches the media in a request of its own, minutes or hours after the feed and
	 * carrying no cookies, so the credential has to travel on the URL itself. Whichever way the
	 * feed request authenticated is the way the media request will, so the app is never asked for
	 * something it did not already supply once.
	 *
	 * Only the enclosure is affected. The episode's permalink, its artwork and above all its `guid`
	 * are left alone: apps recognise an episode by its `guid`, and changing it would have every
	 * subscriber download the entire back catalogue again.
	 *
	 * Only episodes that are actually restricted get one. An unrestricted episode is served to
	 * anyone who asks, so a credential on its URL buys nothing and costs something: a member sharing
	 * an episode link from their podcast app would be handing over their key along with it.
	 *
	 * @since  1.0.3
	 * @param  string  $url  Tracking URL for the episode file.
	 * @param  WP_Post $post Episode post object.
	 * @return string        URL for this request to use.
	 */
	public function filter_episode_file_url( $url, $post ) {
		// Zero on every public request, which is what keeps a credential out of the public feed
		// and off the download buttons rendered on the site itself
		if ( ! $this->member_id ) {
			return $url;
		}

		// Already resolved for this episode while deciding whether it belongs in the feed, so this
		// costs nothing beyond the lookup we have made anyway
		if ( empty( $post->ID ) || ! aiopod_is_content_restricted( $post->ID ) ) {
			return $url;
		}

		if ( 'memberkey' === $this->auth_method && function_exists( 'pmpromrss_url' ) ) {
			return pmpromrss_url( $url, $this->member_id );
		}

		if ( 'basic_auth' === $this->auth_method ) {
			return add_query_arg( 'pmpromrss_basic_auth', '1', $url );
		}

		return $url;
	}

	/**
	 * Vary the feed cache key so that one member's feed is never served to another.
	 *
	 * Keyed by three things, each earning its place.
	 *
	 * **The member**, because a member feed is not merely shaped by entitlement — every enclosure
	 * in it carries that member's own key. Two members entitled to identical episodes still receive
	 * different documents, so sharing an entry between them would hand the second member the
	 * first's credential, three hundred times over.
	 *
	 * **The levels they hold**, which is what makes the entry self-correcting. The set is read
	 * fresh on each request, so a lapsed membership resolves to a different key and an upgrade to
	 * another again, each the moment it happens. Neither can read a stale entry, because neither
	 * looks at its old key any more. Keying by member alone would need invalidating by hand at
	 * exactly those two moments, and would quietly serve yesterday's entitlement until someone did.
	 *
	 * **Their roles**, while Premium is applying role-based restrictions of its own.
	 *
	 * **The credential itself**, because it is written into all three hundred enclosure URLs. A
	 * member who regenerates their key keeps the same ID, levels and roles, so without this the
	 * entry built for the old key would keep being served — and every enclosure in it would answer
	 * 403, that key having just been revoked.
	 *
	 * @since  1.0.3
	 * @param  string $key        Transient key the feed will be stored under.
	 * @param  int    $podcast_id ID of the podcast whose feed is being rendered.
	 * @return string             Key for this request to use.
	 */
	public function filter_feed_cache_key( $key, $podcast_id ) {
		if ( ! $this->member_id ) {
			return $key;
		}

		$key .= '_u' . $this->member_id;
		$key .= '_l' . $this->get_levels_hash();

		// The credential is written into every enclosure, so it is part of what the entry contains
		if ( '' !== $this->auth_token_hash ) {
			$key .= '_k' . $this->auth_token_hash;
		}

		// Roles only shape the feed while Premium is applying its role-based restrictions
		if ( class_exists( 'AIOPOD_Premium_Public_Restrictions' ) ) {
			$key .= '_r' . $this->get_roles_hash();
		}

		return $key . '_v' . $this->get_cache_version( $podcast_id );
	}

	/**
	 * Invalidate every cached variant of a podcast's feed.
	 *
	 * The core cache is one entry per podcast and is deleted outright when the podcast changes.
	 * That is not possible here: the variants a member feed produces cannot be enumerated, and
	 * WordPress offers no way to delete transients by prefix. So the version is counted up instead
	 * and becomes part of the key, which retires every variant at once. The entries left behind
	 * are never read again and expire on their own.
	 *
	 * A counter rather than a timestamp, for two reasons. WordPress does not touch a podcast when
	 * one of its episodes is saved, so the podcast's own `post_modified` would sit still through
	 * the commonest change of all — a new episode. And a counter only ever increases, where a
	 * timestamp can repeat itself if the clock moves back or an import writes an older date, which
	 * would send a request back to an entry that had already been retired.
	 *
	 * @since  1.0.3
	 * @param  int     $post_id Post ID.
	 * @param  WP_Post $post    Post object.
	 * @return void
	 */
	public function bump_feed_cache_version( $post_id, $post ) {
		if ( 'aiopod_podcasts' === $post->post_type ) {
			$podcast_id = (int) $post_id;
		} elseif ( 'aiopod_episodes' === $post->post_type && $post->post_parent > 0 ) {
			$podcast_id = (int) $post->post_parent;
		} else {
			return;
		}

		$versions = (array) get_option( 'aiopod_feed_cache_versions', array() );
		$current  = isset( $versions[ $podcast_id ] ) ? (int) $versions[ $podcast_id ] : 0;

		$versions[ $podcast_id ] = $current + 1;

		update_option( 'aiopod_feed_cache_versions', $versions, false );
	}

	/**
	 * Drop a podcast's cache version once the podcast itself is gone.
	 *
	 * Counting it up at that point would keep a number nothing will ever read again: the feed route
	 * answers 404 for a podcast that no longer exists, so no key is ever built from it. Removing it
	 * instead stops the option growing by one entry for every podcast the site has ever deleted.
	 *
	 * Post IDs are never reused, so a later podcast cannot inherit the entry and start from a
	 * version that has already been served.
	 *
	 * An **episode** being deleted is the opposite case and is passed straight on to be counted up:
	 * the feed it belonged to is still there and has just lost an item.
	 *
	 * @since  1.0.3
	 * @param  int     $post_id Post ID.
	 * @param  WP_Post $post    Post object.
	 * @return void
	 */
	public function forget_feed_cache_version( $post_id, $post ) {
		if ( 'aiopod_episodes' === $post->post_type ) {
			$this->bump_feed_cache_version( $post_id, $post );
			return;
		}

		if ( 'aiopod_podcasts' !== $post->post_type ) {
			return;
		}

		$versions   = (array) get_option( 'aiopod_feed_cache_versions', array() );
		$podcast_id = (int) $post_id;

		if ( ! isset( $versions[ $podcast_id ] ) ) {
			return;
		}

		unset( $versions[ $podcast_id ] );

		update_option( 'aiopod_feed_cache_versions', $versions, false );
	}

	/**
	 * Add the podcast feeds to the member's list of RSS feeds.
	 *
	 * Puts each members-only podcast where Paid Memberships Pro already teaches members to look for
	 * their feeds: the `[pmpro_member_rss]` shortcode and the membership account page.
	 *
	 * A podcast is listed when a member's copy of its feed gives them more than the public one does.
	 * That is true in two situations, and the second is the common one.
	 *
	 * The podcast itself is restricted, and this member can reach it — a show they hold no level for
	 * would give them a feed carrying the artwork and description and none of the episodes, which
	 * reads as broken rather than locked under a heading promising them their own content.
	 *
	 * Or the podcast is public but some of its **episodes** are restricted — the show given away
	 * free with bonus episodes for members, which is how a great many podcasts are sold. Without
	 * that second case a member would be shown no feed at all for exactly the show they are paying
	 * for, and nothing would tell them their key belongs on its URL.
	 *
	 * A fully public podcast is never listed: its feed needs no credential, and listing it would
	 * invite a member to subscribe with their key in the URL for no reason.
	 *
	 * The plain feed URL is returned. Paid Memberships Pro passes each one through `pmpromrss_url()`
	 * itself before display, so adding the key here would produce it twice.
	 *
	 * @since  1.0.3
	 * @param  array $feeds Feed URLs to show the member, keyed by title.
	 * @return array        Feeds with this site's podcast feeds added.
	 */
	public function filter_member_feeds( $feeds ) {
		$feeds = (array) $feeds;

		/**
		 * How many podcasts to consider for a member's feed list.
		 *
		 * Bounds the size of the list and the work behind it: deciding whether a podcast is
		 * restricted costs a membership lookup apiece, and this runs on a page the member loads
		 * themselves. Raise it on a site with more than this many podcasts.
		 *
		 * @since 1.0.3
		 * @param int $limit Number of podcasts to consider. Default 25.
		 */
		$limit = (int) apply_filters( 'aiopod_pmpro_member_feeds_limit', 25 );

		if ( $limit < 1 ) {
			return $feeds;
		}

		$podcasts = get_posts( array(
			'post_type'      => 'aiopod_podcasts',
			'post_status'    => 'publish',
			'posts_per_page' => $limit,
			'orderby'        => 'title',
			'order'          => 'ASC',
			'no_found_rows'  => true
		) );

		// Only a podcast that is not restricted in its own right needs its episodes looked at, so
		// only those are asked about. On a site where every show is members-only that is none of
		// them and the queries below never run at all. Both checks are made again in the loop that
		// follows and are cached per post, so asking them here costs a hash lookup
		$candidate_ids = array();

		foreach ( $podcasts as $podcast ) {
			if ( get_post_meta( $podcast->ID, 'is_blocked', true ) ) {
				continue;
			}

			if ( ! aiopod_is_content_restricted( $podcast->ID ) ) {
				$candidate_ids[] = $podcast->ID;
			}
		}

		$restricted_episodes = empty( $candidate_ids ) ? array() : $this->get_restricted_episodes_by_podcast( $candidate_ids );

		foreach ( $podcasts as $podcast ) {
			// Blocked podcasts are withheld from the directories, so there is nothing to subscribe to
			if ( get_post_meta( $podcast->ID, 'is_blocked', true ) ) {
				continue;
			}

			if ( aiopod_is_content_restricted( $podcast->ID ) ) {
				// Costs nothing beyond the check just made — both resolve through the same cached
				// membership lookup for this podcast
				if ( ! aiopod_current_user_can( 'view_aiopod_podcast', $podcast->ID ) ) {
					continue;
				}
			} elseif ( ! empty( $restricted_episodes[ $podcast->ID ] ) ) {
				if ( ! $this->can_access_any( $restricted_episodes[ $podcast->ID ] ) ) {
					continue;
				}
			} else {
				continue;
			}

			$url = aiopod_get_rss_feed_url( $podcast );

			if ( empty( $url ) ) {
				continue;
			}

			// Titles are the array keys here, so two podcasts sharing one would leave only the
			// second listed. The slug is unique and tells them apart
			$title = $podcast->post_title;

			if ( isset( $feeds[ $title ] ) ) {
				$title = $title . ' (' . $podcast->post_name . ')';
			}

			$feeds[ $title ] = $url;
		}

		return $feeds;
	}

	/**
	 * Which podcasts have restricted episodes inside them, and which episodes those are.
	 *
	 * Asked once for the whole site rather than per podcast. Deciding it the obvious way — walking
	 * every episode and asking `aiopod_is_content_restricted()` — would be hundreds of lookups on a
	 * page a member loads themselves, so each mechanism is asked for its own answer in one query.
	 *
	 * Read from Paid Memberships Pro's table rather than through `pmpro_has_membership_access()`,
	 * which is a departure from how the rest of this integration talks to it. It is confined to
	 * deciding what to *list*: no access decision is taken here, every feed those URLs lead to is
	 * still authorised episode by episode through the usual path, and the worst a wrong answer can
	 * do is offer a member a feed they gain nothing from.
	 *
	 * The dependency is safe enough to rely on. Paid Memberships Pro reads that table with raw SQL
	 * in half a dozen places of its own — every access check on the site, its metaboxes, its REST
	 * API — so it cannot be restructured without a migration across their whole codebase. They write
	 * this very query shape themselves, joining it to `posts` filtered by post type, in
	 * `includes/compatibility/lifterlms.php`. There is no API for asking it in bulk.
	 *
	 * Narrowed to the podcasts about to be considered, so nothing is read for a show that will not
	 * be looked at. Within those, only episodes restricted **in their own right** appear: a show
	 * restricted as a whole passes that down to its episodes at read time and writes nothing to
	 * them, so the one case that could involve thousands of episodes costs nothing here.
	 *
	 * Resist adding a `LIMIT` to the queries. Rows are grouped by podcast afterwards, so a limit
	 * would drop whole podcasts out of a member's list depending on which episodes happened to sort
	 * first — silently, and only on the largest sites.
	 *
	 * @since  1.0.3
	 * @access private
	 * @param  array $podcast_ids Podcast IDs to look inside.
	 * @return array              Restricted episode IDs, keyed by the podcast they belong to.
	 */
	private function get_restricted_episodes_by_podcast( $podcast_ids ) {
		global $wpdb;

		$podcast_ids = array_filter( array_map( 'absint', (array) $podcast_ids ) );

		if ( empty( $podcast_ids ) ) {
			return array();
		}

		$in   = implode( ',', $podcast_ids );
		$rows = array();

		// Episodes with membership levels of their own
		if ( $this->is_active() && ! empty( $wpdb->pmpro_memberships_pages ) ) {
			$found = (array) $wpdb->get_results(
				"SELECT p.post_parent AS podcast_id, p.ID AS episode_id
				 FROM {$wpdb->posts} p
				 INNER JOIN {$wpdb->pmpro_memberships_pages} mp ON mp.page_id = p.ID
				 WHERE p.post_type = 'aiopod_episodes' AND p.post_status = 'publish'
				   AND p.post_parent IN ( {$in} )"
			);

			// Should Paid Memberships Pro ever restructure that table, this is where it shows. The
			// consequence is deliberately mild — an empty answer here means members are offered the
			// podcasts restricted as a whole and nothing more, which is where this method started
			// rather than anywhere worse. The error is left to reach the log rather than swallowed,
			// because a cache bug hid behind suppressed database errors in this plugin once already
			if ( ! empty( $wpdb->last_error ) ) {
				$found = array();
			}

			$rows = array_merge( $rows, $found );
		}

		// Episodes carrying Premium's own restriction, while that is switched on
		if ( class_exists( 'AIOPOD_Premium_Public_Restrictions' ) ) {
			$settings = aiopod_get_option( 'aiopod_restrictions_settings' );

			if ( ! empty( $settings['enable_restrictions'] ) ) {
				$rows = array_merge(
					$rows,
					(array) $wpdb->get_results(
						"SELECT p.post_parent AS podcast_id, p.ID AS episode_id
						 FROM {$wpdb->posts} p
						 INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID
						 WHERE p.post_type = 'aiopod_episodes' AND p.post_status = 'publish'
						   AND p.post_parent IN ( {$in} )
						   AND pm.meta_key = 'is_restricted' AND pm.meta_value = '1'"
					)
				);
			}
		}

		$map = array();

		foreach ( $rows as $row ) {
			$map[ (int) $row->podcast_id ][] = (int) $row->episode_id;
		}

		/**
		 * Restricted episode IDs, keyed by podcast, used to decide which feeds a member is offered.
		 *
		 * @since 1.0.3
		 * @param array $map Episode IDs keyed by podcast ID.
		 */
		return apply_filters( 'aiopod_pmpro_restricted_episodes_by_podcast', $map );
	}

	/**
	 * Whether the member may reach any of these episodes.
	 *
	 * Stops at the first one they can, so a member who is entitled costs a single lookup. The
	 * expense falls on the member entitled to none of them, and only for a podcast that is public
	 * with restricted episodes inside it — a show restricted as a whole is settled before this is
	 * ever called.
	 *
	 * Every episode is tried rather than a sample of them, because a show may sell its bonus
	 * episodes at more than one level: giving up early would leave a member holding the higher tier
	 * unaware of a feed carrying episodes waiting for them.
	 *
	 * @since  1.0.3
	 * @access private
	 * @param  array $episode_ids Episode IDs to consider.
	 * @return bool               True if any of them is reachable.
	 */
	private function can_access_any( $episode_ids ) {
		/**
		 * How many of a podcast's restricted episodes to test before giving up.
		 *
		 * A safety valve rather than a working limit, set well above what a podcast selling bonus
		 * episodes would ever hold. It only bites on a show whose episodes were each restricted
		 * individually in their hundreds, where restricting the show itself would have been the
		 * thing to do.
		 *
		 * @since 1.0.3
		 * @param int $limit Number of episodes to test per podcast. Default 100.
		 */
		$limit = (int) apply_filters( 'aiopod_pmpro_member_feeds_episodes_limit', 100 );

		$checked = 0;

		foreach ( $episode_ids as $episode_id ) {
			if ( aiopod_current_user_can( 'view_aiopod_podcast', (int) $episode_id ) ) {
				return true;
			}

			$checked++;

			if ( $limit > 0 && $checked >= $limit ) {
				break;
			}
		}

		return false;
	}

	/**
	 * Authenticate the member key supplied in the query string.
	 *
	 * The key itself is never read here. `pmpromrss_init()` has already validated it and resolved
	 * it to a user on the `init` hook, caching the lookup for twelve hours.
	 *
	 * @since  1.0.3
	 * @access private
	 * @return void
	 */
	private function authenticate_memberkey() {
		global $pmpromrss_user_id;

		// Checked before the key is considered, because the add-on stops resolving keys when this
		// is switched on and would leave us unable to tell a disabled key from an invalid one
		if ( 'Enabled' === get_option( 'pmpro_pmpromrss_disable_url_key' ) ) {
			$this->deny( __( 'Memberkey URL authentication is disabled. Please use Basic Authentication instead.', 'aio-podcast-studio' ), 403 );
		}

		if ( $this->is_spammer() ) {
			$this->deny( __( 'Slow down. Access denied. Please try again later.', 'aio-podcast-studio' ), 403 );
		}

		if ( empty( $pmpromrss_user_id ) ) {
			$this->track_spam();
			$this->deny( __( 'Access denied. Please check your authentication credentials or reach out to the site administrator for assistance.', 'aio-podcast-studio' ), 403 );
		}

		$user = wp_set_current_user( absint( $pmpromrss_user_id ) );

		// A key lives in user meta, which can outlive the account it belonged to
		if ( ! $user->exists() ) {
			$this->track_spam();
			$this->deny( __( 'Access denied. Please check your authentication credentials or reach out to the site administrator for assistance.', 'aio-podcast-studio' ), 403 );
		}

		$this->member_id   = $user->ID;
		$this->auth_method = 'memberkey';

		// Read from the stored key rather than the request, so it is the same value the enclosures
		// will be built from and changes the moment the member regenerates it
		$this->auth_token_hash = md5( (string) get_user_meta( $user->ID, 'pmpromrss_key', true ) );
	}

	/**
	 * Authenticate a member from the Basic Authentication credentials on the request.
	 *
	 * Application passwords only, never the account password, unless the site has opted in to
	 * accepting the member key in their place.
	 *
	 * @since  1.0.3
	 * @access private
	 * @return void
	 */
	private function authenticate_basic_auth() {
		if ( 'Enabled' !== get_option( 'pmpro_pmpromrss_basic_auth' ) || ! function_exists( 'pmpromrss_get_auth_credentials' ) ) {
			$this->deny( __( 'Authentication method not allowed.', 'aio-podcast-studio' ), 403 );
		}

		if ( $this->is_spammer() ) {
			$this->deny( __( 'Slow down. Access denied. Please try again later.', 'aio-podcast-studio' ), 403 );
		}

		$credentials = pmpromrss_get_auth_credentials();

		$username = ! empty( $credentials['username'] ) ? $credentials['username'] : '';
		$password = ! empty( $credentials['password'] ) ? $credentials['password'] : '';

		// Nothing supplied yet, so ask for it. A podcast app answers the challenge with its stored
		// credentials, a browser prompts for them. The realm is sent verbatim as the add-on sends
		// it, untranslated, so that a reader storing credentials against it keeps matching
		if ( empty( $username ) || empty( $password ) ) {
			$this->track_spam();
			$this->deny(
				__( 'Authentication required. Please provide your WordPress username and application password.', 'aio-podcast-studio' ),
				401,
				array( 'WWW-Authenticate' => 'Basic realm="Private Feed - Member Login Required"' )
			);
		}

		// Bails out with null rather than a WP_Error when application passwords are unavailable,
		// so anything that is not a user counts as a failure
		$user = wp_authenticate_application_password( null, $username, $password );

		if ( ! ( $user instanceof WP_User )
			&& 'Enabled' === get_option( 'pmpro_pmpromrss_memberkey_as_password' )
			&& function_exists( 'pmpromrss_authenticate_memberkey_password' ) ) {
			$user = pmpromrss_authenticate_memberkey_password( $username, $password );
		}

		if ( ! ( $user instanceof WP_User ) ) {
			$this->track_spam();
			$this->deny( __( 'Access denied. Please check your authentication credentials or reach out to the site administrator for assistance.', 'aio-podcast-studio' ), 403 );
		}

		wp_set_current_user( $user->ID );

		$this->member_id   = $user->ID;
		$this->auth_method = 'basic_auth';

		// A fixed marker rather than a secret: Basic Authentication puts no per-member value in the
		// enclosures, only the flag that asks for the challenge. It still has to be here, so that a
		// member using both sign-in methods does not have one feed's enclosures served for the other
		$this->auth_token_hash = md5( 'basic_auth' );
	}

	/**
	 * Hash of the membership levels the authenticated member currently holds.
	 *
	 * Sorted first, so that the same set of levels always produces the same hash however Paid
	 * Memberships Pro happened to order them. A member holding no levels hashes the empty set,
	 * which is a key of its own rather than the public one — their feed is not necessarily the
	 * public feed, because Premium may still be granting them something by role.
	 *
	 * @since  1.0.3
	 * @access private
	 * @return string Hash of the level set.
	 */
	private function get_levels_hash() {
		$level_ids = array();

		if ( function_exists( 'pmpro_getMembershipLevelsForUser' ) ) {
			$levels = pmpro_getMembershipLevelsForUser( $this->member_id );

			if ( ! empty( $levels ) ) {
				foreach ( $levels as $level ) {
					$level_ids[] = (int) $level->id;
				}
			}
		}

		sort( $level_ids, SORT_NUMERIC );

		return md5( implode( ',', $level_ids ) );
	}

	/**
	 * Hash of the roles the authenticated member currently holds.
	 *
	 * @since  1.0.3
	 * @access private
	 * @return string Hash of the role set.
	 */
	private function get_roles_hash() {
		// Read from the member ID rather than the current user, so that both halves of the key are
		// provably about the same person however this is reached
		$user = get_userdata( $this->member_id );

		$roles = ( $user && ! empty( $user->roles ) ) ? (array) $user->roles : array();

		sort( $roles );

		return md5( implode( ',', $roles ) );
	}

	/**
	 * Current cache version for a podcast's member feeds.
	 *
	 * @since  1.0.3
	 * @access private
	 * @param  int $podcast_id Podcast ID.
	 * @return int             Version counter, zero until the podcast is first changed.
	 */
	private function get_cache_version( $podcast_id ) {
		$versions = (array) get_option( 'aiopod_feed_cache_versions', array() );

		$podcast_id = (int) $podcast_id;

		return isset( $versions[ $podcast_id ] ) ? (int) $versions[ $podcast_id ] : 0;
	}

	/**
	 * Whether the request is coming from an address Paid Memberships Pro is throttling.
	 *
	 * @since  1.0.3
	 * @access private
	 * @return bool True if the request should be turned away, false if not.
	 */
	private function is_spammer() {
		return function_exists( 'pmpromrss_spam_protection_enabled' )
			&& pmpromrss_spam_protection_enabled()
			&& pmpro_is_spammer();
	}

	/**
	 * Record a failed authentication attempt against the requesting address.
	 *
	 * Feed URLs carry a credential that is guessable in principle, so repeated failures are fed
	 * to the same throttle Paid Memberships Pro applies to its own forms.
	 *
	 * @since  1.0.3
	 * @access private
	 * @return void
	 */
	private function track_spam() {
		if ( function_exists( 'pmpromrss_spam_protection_enabled' ) && pmpromrss_spam_protection_enabled() ) {
			pmpro_track_spam_activity();
		}
	}

	/**
	 * Whether the Member RSS Add On and Paid Memberships Pro are both available.
	 *
	 * @since  1.0.3
	 * @access private
	 * @return bool True if both are active, false if either is missing.
	 */
	private function is_active() {
		return defined( 'PMPROMRSS_VERSION' ) && function_exists( 'pmpro_has_membership_access' );
	}

	/**
	 * Whether the current request is for one of the two routes we authenticate.
	 *
	 * The podcast feed and the episode download endpoint. Both are needed: a podcast app fetches
	 * the feed and then fetches the media file from a separate request, carrying no cookies for
	 * either, so a credential that opens only the feed would leave every episode undownloadable.
	 *
	 * Deliberately mirrors what each handler demands of a request before it will act, rather than
	 * merely testing that a query variable is present. Both handlers answer their request and end
	 * it; a request that satisfies this but neither of them would carry on rendering an ordinary
	 * page with the current user already switched, which would turn a feed credential into a way
	 * to view the site as its owner for that request.
	 *
	 * @since  1.0.3
	 * @access private
	 * @return bool True on the feed or download route, false anywhere else.
	 */
	private function is_our_route() {
		// Matches AIOPOD_Public_Feed::template_redirect()
		if ( 'podcast' === get_query_var( 'aiopod_feed' ) && '' !== get_query_var( 'aiopod_podcast' ) ) {
			return true;
		}

		// Matches AIOPOD_Statistics::handle_download_request()
		return ( get_query_var( 'aiopod_download' ) && '' !== get_query_var( 'aiopod_episode' ) );
	}

	/**
	 * End the request with a plain-text authentication failure.
	 *
	 * Answered as text rather than as a feed or an HTML error page, because the client is a
	 * podcast app or a feed reader. Never cached: the same URL succeeds as soon as the member
	 * corrects the credential.
	 *
	 * @since  1.0.3
	 * @access private
	 * @param  string $message Message to send as the response body.
	 * @param  int    $status  HTTP status code to respond with.
	 * @param  array  $headers Optional. Additional headers to send, as name => value.
	 * @return void
	 */
	private function deny( $message, $status, $headers = array() ) {
		status_header( (int) $status );
		nocache_headers();

		header( 'Content-Type: text/plain; charset=' . get_bloginfo( 'charset' ) );

		foreach ( $headers as $name => $value ) {
			header( $name . ': ' . $value );
		}

		echo esc_html( $message );

		exit;
	}

}
