PluginProbe ʕ •ᴥ•ʔ
WP Job Manager / 2.4.7
WP Job Manager v2.4.7
2.4.7 2.4.6 2.4.5 2.4.4 2.4.3 2.4.2 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.1.0 1.1.1 1.1.2 1.1.3 1.10.0 1.11.0 1.11.1 1.12.0 1.12.1 1.13.0 1.14.0 1.15.0 1.16.0 1.16.1 1.17.0 1.18.0 1.19.0 1.2.0 1.20.0 1.20.1 1.21.0 1.21.1 1.21.2 1.21.3 1.21.4 1.22.0 1.22.1 1.22.2 1.22.3 1.23.0 1.23.1 1.23.10 1.23.11 1.23.12 1.23.13 1.23.2 1.23.3 1.23.4 1.23.5 1.23.6 1.23.7 1.23.8 1.23.9 1.24.0 1.24.0.1 1.25.0 1.25.0.1 1.25.1 1.25.1.1 1.25.2 1.25.2.1 1.25.3 1.25.3.1 1.26.0 1.26.0.1 1.26.1 1.26.1.1 1.26.2 1.26.2.1 1.27.0 1.27.0.1 1.28.0 1.28.0.1 1.29.0 1.29.0.1 1.29.1 1.29.1.1 1.29.2 1.29.2.1 1.29.3 1.29.3.1 1.3.0 1.3.1 1.30.0 1.30.0.1 1.30.1 1.30.1.1 1.30.2 1.30.2.1 1.31.0 1.31.0.1 1.31.1 1.31.1.1 1.31.2 1.31.3 1.32.0 1.32.1 1.32.2 1.32.3 1.33.0 1.33.1 1.33.2 1.33.3 1.33.4 1.33.5 1.34.0 1.34.1 1.34.2 1.34.3 1.34.4 1.34.5 1.35.0 1.35.1 1.35.2 1.35.3 1.36.0 1.36.1 1.36.2 1.37.0 1.38.0 1.38.1 1.39.0 1.4.0 1.40.0 1.40.1 1.40.2 1.41.0 1.42.0 1.5.0 1.5.1 1.5.2 1.6.0 1.7.0 1.7.1 1.7.3 1.8.0 1.8.1 1.8.2 1.9.0 1.9.1 1.9.2 1.9.3 2.0.0 2.1.0 2.1.1 2.2.0 2.2.1 2.2.2 2.3.0 2.4.0 2.4.1
wp-job-manager / includes / class-wp-job-manager-rest-api.php
wp-job-manager / includes Last commit date
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