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; } /** * Carry the member's credential on a transcript URL. * * Transcripts are fetched the same way as the media: by the podcast app, later, with no cookies. * So they follow the rule in `filter_episode_file_url()` exactly — the credential the feed * request arrived with, and only for episodes that are actually restricted, so a shared public * transcript link never carries a member's key. * * @since 1.0.5 * @param string $url Transcript URL. * @param WP_Post $post Episode post object. * @param string $language The transcript row's language, or '' for the podcast's language. * @param string $format Format key. * @return string URL for this request to use. */ public function filter_transcript_url( $url, $post, $language, $format ) { return $this->filter_episode_file_url( $url, $post ); } /** * 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 routes we authenticate. * * The podcast feed, the episode download endpoint and the transcript address. All are needed: * a podcast app fetches the feed and then fetches the media file and the transcripts in separate * requests, carrying no cookies for any of them, so a credential that opens only the feed would * leave every restricted episode undownloadable and its transcripts unreadable. * * 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, download or transcript 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_Public_Transcript::handle_request(), which ends every request it does not ignore if ( '' !== sanitize_text_field( (string) get_query_var( 'aiopod_transcript' ) ) ) { 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; } }