3rd-party
3 days ago
abstracts
3 months ago
admin
2 weeks ago
emails
2 weeks ago
forms
3 days ago
helper
3 months ago
promoted-jobs
3 days ago
ui
2 weeks ago
widgets
2 weeks ago
class-access-token.php
2 years ago
class-dev-tools.php
2 years ago
class-guest-session.php
2 years ago
class-guest-user.php
2 years ago
class-job-dashboard-shortcode.php
6 months ago
class-job-listing-stats.php
2 years ago
class-job-overlay.php
2 years ago
class-stats-dashboard.php
2 years ago
class-stats-script.php
3 months ago
class-stats.php
3 months ago
class-wp-job-manager-ajax.php
2 months ago
class-wp-job-manager-api.php
6 years ago
class-wp-job-manager-blocks.php
2 weeks ago
class-wp-job-manager-cache-helper.php
3 months ago
class-wp-job-manager-category-walker.php
6 years ago
class-wp-job-manager-com-api.php
2 years ago
class-wp-job-manager-data-cleaner.php
2 years ago
class-wp-job-manager-data-exporter.php
3 months ago
class-wp-job-manager-dependency-checker.php
2 years ago
class-wp-job-manager-email-notifications.php
2 years ago
class-wp-job-manager-forms.php
5 years ago
class-wp-job-manager-geocode.php
2 weeks ago
class-wp-job-manager-install.php
2 years ago
class-wp-job-manager-post-types.php
3 days ago
class-wp-job-manager-recaptcha.php
6 months ago
class-wp-job-manager-rest-api.php
2 months ago
class-wp-job-manager-shortcodes.php
2 weeks ago
class-wp-job-manager-usage-tracking-data.php
2 years ago
class-wp-job-manager-usage-tracking.php
2 years ago
class-wp-job-manager-widget.php
2 weeks ago
class-wp-job-manager.php
3 months ago
trait-singleton.php
2 years ago
class-wp-job-manager-rest-api.php
270 lines
| 1 | <?php |
| 2 | /** |
| 3 | * File containing the class WP_Job_Manager_REST_API. |
| 4 | * |
| 5 | * @package wp-job-manager |
| 6 | */ |
| 7 | |
| 8 | if ( ! defined( 'ABSPATH' ) ) { |
| 9 | exit; |
| 10 | } |
| 11 | |
| 12 | /** |
| 13 | * Handles functionality related to the REST API. |
| 14 | * |
| 15 | * @since 1.33.0 |
| 16 | */ |
| 17 | class WP_Job_Manager_REST_API { |
| 18 | /** |
| 19 | * Sets up initial hooks. |
| 20 | * |
| 21 | * @static |
| 22 | */ |
| 23 | public static function init() { |
| 24 | add_filter( 'rest_prepare_job_listing', [ __CLASS__, 'prepare_job_listing' ], 10, 2 ); |
| 25 | add_filter( 'rest_job_listing_query', [ __CLASS__, 'exclude_filled_from_query' ], 10, 2 ); |
| 26 | add_filter( 'rest_request_before_callbacks', [ __CLASS__, 'gate_view_capability_for_single' ], 10, 3 ); |
| 27 | add_filter( 'rest_post_search_query', [ __CLASS__, 'gate_search_query_for_listings' ], 10, 2 ); |
| 28 | } |
| 29 | |
| 30 | /** |
| 31 | * Withholds `job_listing` results from the generic WordPress REST Search endpoint |
| 32 | * (`/wp/v2/search`) for a viewer denied by the browse or View Job Capability options. |
| 33 | * |
| 34 | * The search controller runs its own query and never reaches the single-item route |
| 35 | * gate ({@see gate_view_capability_for_single()}) or the job_listing collection gate |
| 36 | * ({@see exclude_filled_from_query()}), so without this a denied viewer can enumerate |
| 37 | * restricted listings — id, title, url — and use the search term as a content oracle. |
| 38 | * |
| 39 | * When the viewer is denied, the `job_listing` post type is dropped from the searched |
| 40 | * types so other requested subtypes (posts, pages) are unaffected; if it was the only |
| 41 | * requested type the query is forced to return nothing. Password-protected listings are |
| 42 | * already excluded from search by WP core, so only the view-capability boundary is |
| 43 | * handled here. |
| 44 | * |
| 45 | * Note: this is intentionally stricter than the single-item route and the feed gate. Both |
| 46 | * of those let a denied *author* still reach their own listings — the single route via |
| 47 | * {@see job_manager_user_can_view_job_listing()}, the feed via `author__in => [ user_id ]`. |
| 48 | * That cannot be mirrored here: the search handler runs one WP_Query across every requested |
| 49 | * subtype, so an `author__in` constraint would also restrict the viewer's posts and pages, |
| 50 | * and per-listing filtering of the results would desync the handler's `found_posts` pagination |
| 51 | * (itself an oracle). A denied author therefore does not see their own listings through generic |
| 52 | * search; their own listings remain reachable via the job dashboard and the single-item route. |
| 53 | * |
| 54 | * @param array $query_args WP_Query args the search handler will run. |
| 55 | * @param WP_REST_Request $request The REST search request. |
| 56 | * @return array |
| 57 | */ |
| 58 | public static function gate_search_query_for_listings( $query_args, $request ) { |
| 59 | $post_types = isset( $query_args['post_type'] ) ? (array) $query_args['post_type'] : []; |
| 60 | if ( ! in_array( WP_Job_Manager_Post_Types::PT_LISTING, $post_types, true ) ) { |
| 61 | return $query_args; |
| 62 | } |
| 63 | |
| 64 | $denied = ! job_manager_user_can_browse_job_listings() |
| 65 | || WP_Job_Manager_Post_Types::viewer_denied_by_view_cap(); |
| 66 | if ( ! $denied ) { |
| 67 | return $query_args; |
| 68 | } |
| 69 | |
| 70 | $remaining = array_values( array_diff( $post_types, [ WP_Job_Manager_Post_Types::PT_LISTING ] ) ); |
| 71 | if ( $remaining ) { |
| 72 | // Other subtypes were requested too — keep searching those, just not listings. |
| 73 | $query_args['post_type'] = $remaining; |
| 74 | } else { |
| 75 | // Listings were the only requested type. Post IDs are never 0, so this is a safe |
| 76 | // empty sentinel. |
| 77 | $query_args['post__in'] = [ 0 ]; |
| 78 | } |
| 79 | |
| 80 | return $query_args; |
| 81 | } |
| 82 | |
| 83 | /** |
| 84 | * Returns 404 for GET requests to a single job listing when the current user is |
| 85 | * denied by the `job_manager_view_job_listing_capability` option. Mirrors WP core's |
| 86 | * "post not found" shape so the existence of restricted listings is not revealed. |
| 87 | * |
| 88 | * The view-capability check is the only gate. For view-cap-*passing* users on |
| 89 | * password-protected listings the request continues and WP core's controller + |
| 90 | * `prepare_job_listing()` produce the password contract (200 + `content.protected`) |
| 91 | * downstream. View-cap-*failing* users always get 404, even on password-protected |
| 92 | * listings — otherwise the password envelope would itself reveal that the listing |
| 93 | * exists at that ID. Author and `preview` short-circuits live inside |
| 94 | * `job_manager_user_can_view_job_listing()`. |
| 95 | * |
| 96 | * @param mixed $response Result from the dispatched request, prior to invoking the callback. |
| 97 | * @param array $handler Route handler used for the request. |
| 98 | * @param WP_REST_Request $request Request used to generate the response. |
| 99 | * @return mixed |
| 100 | */ |
| 101 | public static function gate_view_capability_for_single( $response, $handler, $request ) { |
| 102 | if ( is_wp_error( $response ) ) { |
| 103 | return $response; |
| 104 | } |
| 105 | // HEAD falls back to the GET handler in WP_REST_Server but keeps `HEAD` as the method; |
| 106 | // without HEAD coverage a status-code probe (200 empty body vs 404) could distinguish |
| 107 | // a restricted listing from a missing one. |
| 108 | if ( ! in_array( $request->get_method(), [ 'GET', 'HEAD' ], true ) ) { |
| 109 | return $response; |
| 110 | } |
| 111 | // Match the item route and its children (revisions, autosaves) — all of them can |
| 112 | // surface listing body data and must be gated on the parent post's view capability. |
| 113 | if ( ! preg_match( '#^/wp/v2/job-listings/(?P<id>\d+)(?:/[^?]*)?$#', (string) $request->get_route(), $matches ) ) { |
| 114 | return $response; |
| 115 | } |
| 116 | $post_id = absint( $matches['id'] ); |
| 117 | if ( ! $post_id ) { |
| 118 | return $response; |
| 119 | } |
| 120 | $post = get_post( $post_id ); |
| 121 | if ( ! $post || WP_Job_Manager_Post_Types::PT_LISTING !== $post->post_type ) { |
| 122 | return $response; |
| 123 | } |
| 124 | if ( job_manager_user_can_view_job_listing( $post_id ) ) { |
| 125 | return $response; |
| 126 | } |
| 127 | |
| 128 | return new WP_Error( |
| 129 | 'rest_post_invalid_id', |
| 130 | // String mirrors WP core's WP_REST_Posts_Controller so a denied viewer cannot |
| 131 | // distinguish this 404 from a missing-post 404. |
| 132 | __( 'Invalid post ID.' ), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain |
| 133 | [ 'status' => 404 ] |
| 134 | ); |
| 135 | } |
| 136 | |
| 137 | /** |
| 138 | * Excludes filled job listings from REST API query results, and short-circuits the query when |
| 139 | * the requester does not have the browse-listings capability. |
| 140 | * |
| 141 | * @param array $args Array of query arguments. |
| 142 | * @param WP_REST_Request $request The REST API request. |
| 143 | * @return array |
| 144 | */ |
| 145 | public static function exclude_filled_from_query( $args, $request ) { |
| 146 | // Browse-capability gate — match the same denial the [jobs] shortcode applies, but without surfacing a 403. |
| 147 | if ( ! job_manager_user_can_browse_job_listings() ) { |
| 148 | $args['post__in'] = [ 0 ]; |
| 149 | return $args; |
| 150 | } |
| 151 | |
| 152 | // Password-protected listings are excluded from REST collections regardless of post-type config. |
| 153 | $args['has_password'] = false; |
| 154 | |
| 155 | if ( 1 !== absint( get_option( 'job_manager_hide_filled_positions' ) ) ) { |
| 156 | return $args; |
| 157 | } |
| 158 | |
| 159 | if ( ! isset( $args['meta_query'] ) ) { |
| 160 | $args['meta_query'] = []; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Empty. |
| 161 | } |
| 162 | |
| 163 | $args['meta_query'][] = [ |
| 164 | 'relation' => 'OR', |
| 165 | [ |
| 166 | 'key' => '_filled', |
| 167 | 'value' => '1', |
| 168 | 'compare' => '!=', |
| 169 | ], |
| 170 | [ |
| 171 | 'key' => '_filled', |
| 172 | 'compare' => 'NOT EXISTS', |
| 173 | ], |
| 174 | ]; |
| 175 | |
| 176 | return $args; |
| 177 | } |
| 178 | |
| 179 | /** |
| 180 | * Filters the job listing data for a REST API response. |
| 181 | * |
| 182 | * @param WP_REST_Response $response The response object. |
| 183 | * @param WP_Post $post Post object. |
| 184 | * @return WP_REST_Response |
| 185 | */ |
| 186 | public static function prepare_job_listing( $response, $post ) { |
| 187 | $current_user = wp_get_current_user(); |
| 188 | $fields = WP_Job_Manager_Post_Types::get_job_listing_fields(); |
| 189 | $data = $response->get_data(); |
| 190 | |
| 191 | if ( ! isset( $data['meta'] ) ) { |
| 192 | $data['meta'] = []; |
| 193 | } |
| 194 | |
| 195 | // View-capability denials are normally short-circuited to 404 in |
| 196 | // gate_view_capability_for_single() before we reach this filter. Keep the blanking |
| 197 | // path as defense-in-depth for any code path that still reaches here (collection |
| 198 | // responses, third-party callers re-running this filter), and for password-protected |
| 199 | // listings where the WP contract is 200 + content.protected so clients can render a |
| 200 | // password form. Title / link / slug / featured-media references can themselves carry |
| 201 | // sensitive information so we blank them too. |
| 202 | $is_password_blocked = post_password_required( $post ); |
| 203 | $is_viewcap_blocked = ! job_manager_user_can_view_job_listing( $post->ID ); |
| 204 | $is_blocked = $is_password_blocked || $is_viewcap_blocked; |
| 205 | |
| 206 | // An edit-capable user hitting a *password-only* protected listing legitimately needs |
| 207 | // the raw fields and editor metadata to operate Gutenberg (WP core's controller permits |
| 208 | // the request precisely because of edit_post). WP core itself still blanks |
| 209 | // `content.rendered`, which is enough for the password contract. View-capability |
| 210 | // denials do NOT get this bypass — they are the harder gate. |
| 211 | $bypass_for_editor = $is_password_blocked && ! $is_viewcap_blocked |
| 212 | && current_user_can( 'edit_post', $post->ID ); |
| 213 | |
| 214 | if ( $is_blocked && ! $bypass_for_editor ) { |
| 215 | if ( isset( $data['title']['rendered'] ) ) { |
| 216 | $data['title']['rendered'] = ''; |
| 217 | } |
| 218 | if ( isset( $data['title']['raw'] ) ) { |
| 219 | $data['title']['raw'] = ''; |
| 220 | } |
| 221 | if ( isset( $data['content']['rendered'] ) ) { |
| 222 | $data['content']['rendered'] = ''; |
| 223 | } |
| 224 | if ( isset( $data['content']['raw'] ) ) { |
| 225 | $data['content']['raw'] = ''; |
| 226 | } |
| 227 | if ( isset( $data['content'] ) && is_array( $data['content'] ) ) { |
| 228 | $data['content']['protected'] = true; |
| 229 | } |
| 230 | if ( isset( $data['excerpt']['rendered'] ) ) { |
| 231 | $data['excerpt']['rendered'] = ''; |
| 232 | } |
| 233 | if ( isset( $data['excerpt']['raw'] ) ) { |
| 234 | $data['excerpt']['raw'] = ''; |
| 235 | } |
| 236 | if ( array_key_exists( 'link', $data ) ) { |
| 237 | unset( $data['link'] ); |
| 238 | } |
| 239 | if ( array_key_exists( 'slug', $data ) ) { |
| 240 | $data['slug'] = ''; |
| 241 | } |
| 242 | if ( array_key_exists( 'featured_media', $data ) ) { |
| 243 | $data['featured_media'] = 0; |
| 244 | } |
| 245 | // Links live on WP_REST_Response's private $links property and are merged into the |
| 246 | // serialized `_links` block later by WP_REST_Server::response_to_data(); they are not |
| 247 | // present in $data at this point, so remove_link() is the only effective way to drop |
| 248 | // the featured-media link before serialization. |
| 249 | $response->remove_link( 'https://api.w.org/featuredmedia' ); |
| 250 | $data['meta'] = []; |
| 251 | $response->set_data( $data ); |
| 252 | |
| 253 | return $response; |
| 254 | } |
| 255 | |
| 256 | foreach ( $data['meta'] as $meta_key => $meta_value ) { |
| 257 | if ( isset( $fields[ $meta_key ] ) && is_callable( $fields[ $meta_key ]['auth_view_callback'] ) ) { |
| 258 | $is_viewable = call_user_func( $fields[ $meta_key ]['auth_view_callback'], false, $meta_key, $post->ID, $current_user->ID ); |
| 259 | if ( ! $is_viewable ) { |
| 260 | unset( $data['meta'][ $meta_key ] ); |
| 261 | } |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | $response->set_data( $data ); |
| 266 | |
| 267 | return $response; |
| 268 | } |
| 269 | } |
| 270 |