PluginProbe
SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking / trunk
SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking vtrunk
1.5.0 1.4.0 1.3.0 1.3.1 trunk 0.0.0-alpha.1 0.0.0-alpha.2 0.0.0-alpha.3 0.0.1-beta.1 0.0.1-beta.2 0.0.1-beta.3 0.0.1-beta.4 1.0.0 1.1.0 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4
surecookie / inc / modules / assisted-scan / api.php

api.php in SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking trunk, at inc/modules/assisted-scan/api.php

363 lines 9.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Assisted Scan REST API.
4 *
5 * All four routes are capability + nonce gated. The two the scan window calls add a
6 * session-token gate, and `/page` also checks the expected page index, so a replayed
7 * or out-of-order submission cannot corrupt the run.
8 *
9 * @package SureCookie\Inc\Modules\AssistedScan
10 * @since 1.3.0
11 */
12
13 namespace SureCookie\Inc\Modules\AssistedScan;
14
15 use SureCookie\Inc\API\Base;
16 use SureCookie\Inc\Functions\SendJson;
17 use SureCookie\Inc\Functions\Settings;
18 use SureCookie\Inc\Modules\SiteScanner\Cron;
19 use SureCookie\Inc\Traits\GetInstance;
20 use SureCookie\Inc\Utils\Logger;
21 use WP_REST_Server;
22
23 if ( ! defined( 'ABSPATH' ) ) {
24 exit; // Exit if accessed directly.
25 }
26
27 /**
28 * Api
29 *
30 * @since 1.3.0
31 */
32 class Api extends Base {
33 use GetInstance;
34
35 /**
36 * Register API routes.
37 *
38 * @since 1.3.0
39 * @return void
40 */
41 public function register_routes(): void {
42 register_rest_route(
43 $this->get_api_namespace(),
44 '/assisted-scan/start',
45 [
46 'methods' => WP_REST_Server::CREATABLE,
47 'callback' => [ $this, 'start' ],
48 'permission_callback' => [ $this, 'validate_permission' ],
49 'args' => [
50 'post_ids' => [
51 'type' => 'array',
52 'required' => false,
53 'items' => [ 'type' => 'integer' ],
54 ],
55 ],
56 ]
57 );
58
59 register_rest_route(
60 $this->get_api_namespace(),
61 '/assisted-scan/page',
62 [
63 'methods' => WP_REST_Server::CREATABLE,
64 'callback' => [ $this, 'record_page' ],
65 'permission_callback' => [ $this, 'validate_permission' ],
66 'args' => [
67 'token' => [
68 'type' => 'string',
69 'required' => true,
70 ],
71 'page_index' => [
72 'type' => 'integer',
73 'required' => true,
74 ],
75 ],
76 ]
77 );
78
79 register_rest_route(
80 $this->get_api_namespace(),
81 '/assisted-scan/finish',
82 [
83 'methods' => WP_REST_Server::CREATABLE,
84 'callback' => [ $this, 'finish' ],
85 'permission_callback' => [ $this, 'validate_permission' ],
86 'args' => [
87 'token' => [
88 'type' => 'string',
89 'required' => true,
90 ],
91 ],
92 ]
93 );
94
95 register_rest_route(
96 $this->get_api_namespace(),
97 '/assisted-scan/cancel',
98 [
99 'methods' => WP_REST_Server::CREATABLE,
100 'callback' => [ $this, 'cancel' ],
101 'permission_callback' => [ $this, 'validate_permission' ],
102 ]
103 );
104 }
105
106 /**
107 * Open a walk and hand back the first page to visit.
108 *
109 * @param \WP_REST_Request<array<string, mixed>> $request Request.
110 * @since 1.3.0
111 * @return void
112 */
113 public function start( $request ): void {
114 $session = Session::get_instance();
115
116 // One walk at a time. Silently adopting or discarding an in-flight session
117 // would either lose what it collected or let two tabs fight over the queue.
118 if ( $session->is_active() ) {
119 SendJson::error(
120 [
121 'code' => 'scan_in_progress',
122 'message' => __( 'An assisted scan is already running. Cancel it before starting another.', 'surecookie' ),
123 ]
124 );
125 }
126
127 // A stale session left by an abandoned walk is finalized rather than thrown
128 // away, so its findings still reach the cookie list.
129 if ( ! empty( $session->get() ) ) {
130 Ingest::get_instance()->finalize();
131 }
132
133 $post_ids = $request->get_param( 'post_ids' );
134 $pages = is_array( $post_ids ) && $post_ids !== []
135 ? $this->pages_from_post_ids( $post_ids )
136 : $this->pages_from_settings();
137
138 $pages = array_slice( $pages, 0, Utils::get_max_pages() );
139
140 if ( $pages === [] ) {
141 SendJson::error(
142 [
143 'code' => 'no_pages',
144 'message' => __( 'Select at least one published page to scan.', 'surecookie' ),
145 ]
146 );
147 }
148
149 $state = $session->start( $pages );
150
151 if ( empty( $state['token'] ) ) {
152 SendJson::error(
153 [
154 'code' => 'no_pages',
155 'message' => __( 'Select at least one published page to scan.', 'surecookie' ),
156 ]
157 );
158 }
159
160 Telemetry::record_started();
161
162 Logger::get_instance()->cleanup_logs();
163 Logger::get_instance()->save_log( __( 'Starting assisted scan (collected from your browser).', 'surecookie' ) );
164 Logger::get_instance()->save_log(
165 sprintf(
166 /* translators: %d: number of pages. */
167 __( 'Pages to visit: %d.', 'surecookie' ),
168 count( $state['pages'] )
169 )
170 );
171 Logger::get_instance()->save_log( '' );
172
173 SendJson::success(
174 [
175 'token' => (string) $state['token'],
176 'url' => $session->scan_url( 0, $state ),
177 'total' => count( $state['pages'] ),
178 ]
179 );
180 }
181
182 /**
183 * Accept one collected page and hand back the next URL.
184 *
185 * @param \WP_REST_Request<array<string, mixed>> $request Request.
186 * @since 1.3.0
187 * @return void
188 */
189 public function record_page( $request ): void {
190 $session = Session::get_instance();
191 $token = (string) $request->get_param( 'token' );
192
193 if ( ! $session->verify_token( $token ) ) {
194 SendJson::error(
195 [
196 'code' => 'invalid_token',
197 'message' => __( 'This scan session has expired. Start the scan again.', 'surecookie' ),
198 ]
199 );
200 }
201
202 $findings = $request->get_param( 'findings' );
203 $findings = is_array( $findings ) ? $findings : [];
204
205 $invalid = Ingest::validate_findings( $findings );
206 if ( $invalid !== '' ) {
207 SendJson::error(
208 [
209 'code' => $invalid,
210 'message' => __( 'The scan sent more data than a single page should produce.', 'surecookie' ),
211 ]
212 );
213 }
214
215 $index = (int) $request->get_param( 'page_index' );
216 $error = sanitize_text_field( (string) $request->get_param( 'error' ) );
217
218 if ( ! Ingest::get_instance()->record_page( $index, $findings, $error ) ) {
219 SendJson::error(
220 [
221 'code' => 'unexpected_page',
222 'message' => __( 'That page is not the one this scan was waiting for.', 'surecookie' ),
223 'expected' => $session->current_index(),
224 ]
225 );
226 }
227
228 $state = $session->get();
229
230 SendJson::success(
231 [
232 'next_url' => $session->next_url( $state ),
233 'done' => $session->is_complete( $state ),
234 'progress' => $session->progress( $state ),
235 ]
236 );
237 }
238
239 /**
240 * Close the walk: classify, cross-check, and record it as one scan.
241 *
242 * @param \WP_REST_Request<array<string, mixed>> $request Request.
243 * @since 1.3.0
244 * @return void
245 */
246 public function finish( $request ): void {
247 $session = Session::get_instance();
248
249 if ( ! $session->verify_token( (string) $request->get_param( 'token' ) ) ) {
250 SendJson::error(
251 [
252 'code' => 'invalid_token',
253 'message' => __( 'This scan session has expired. Start the scan again.', 'surecookie' ),
254 ]
255 );
256 }
257
258 SendJson::success( Ingest::get_instance()->finalize() );
259 }
260
261 /**
262 * Abandon the walk.
263 *
264 * A token is accepted but not required, so the admin can clear a session from the
265 * admin screen after the scan window (and its token) is gone. Capability-gated either way.
266 *
267 * @param \WP_REST_Request<array<string, mixed>> $request Request.
268 * @since 1.3.0
269 * @return void
270 */
271 public function cancel( $request ): void {
272 unset( $request );
273
274 Session::get_instance()->clear();
275 Logger::get_instance()->save_log( __( 'Assisted scan cancelled.', 'surecookie' ) );
276
277 SendJson::success( [ 'message' => __( 'Assisted scan cancelled.', 'surecookie' ) ] );
278 }
279
280 /**
281 * Build the walk from the administrator's saved content selection.
282 *
283 * Uses the same URL list the cloud scanner walks, so both scanners agree on scope
284 * and the `surecookie_scanner_page_urls_to_scan` filter still applies to extensions.
285 *
286 * @since 1.3.0
287 * @return array<int, array<string, mixed>>
288 */
289 private function pages_from_settings(): array {
290 $labels = [];
291
292 $scan_pages = Settings::get( 'scan_pages' );
293 foreach ( is_array( $scan_pages ) ? $scan_pages : [] as $entry ) {
294 $post_id = absint( $entry['value'] ?? 0 );
295
296 if ( $post_id <= 0 ) {
297 continue;
298 }
299
300 $permalink = get_permalink( $post_id );
301
302 if ( is_string( $permalink ) && $permalink !== '' ) {
303 $labels[ $permalink ] = [
304 'post_id' => $post_id,
305 'title' => (string) get_the_title( $post_id ),
306 ];
307 }
308 }
309
310 $pages = [];
311
312 foreach ( Cron::get_instance()->get_pages_urls_to_scan() as $url ) {
313 $meta = $labels[ $url ] ?? [];
314 $pages[] = [
315 'url' => $url,
316 'post_id' => absint( $meta['post_id'] ?? 0 ),
317 'title' => (string) ( $meta['title'] ?? '' ),
318 ];
319 }
320
321 return $pages;
322 }
323
324 /**
325 * Build a walk from explicit post ids, for re-scanning a single page.
326 *
327 * @param array<int, mixed> $post_ids Requested post ids.
328 * @since 1.3.0
329 * @return array<int, array<string, mixed>>
330 */
331 private function pages_from_post_ids( array $post_ids ): array {
332 $pages = [];
333
334 foreach ( $post_ids as $raw_id ) {
335 $post_id = absint( $raw_id );
336
337 if ( $post_id <= 0 ) {
338 continue;
339 }
340
341 $post = get_post( $post_id );
342
343 if ( ! $post || $post->post_status !== 'publish' ) {
344 continue;
345 }
346
347 $permalink = get_permalink( $post_id );
348
349 if ( ! is_string( $permalink ) || $permalink === '' ) {
350 continue;
351 }
352
353 $pages[] = [
354 'url' => $permalink,
355 'post_id' => $post_id,
356 'title' => (string) get_the_title( $post_id ),
357 ];
358 }
359
360 return $pages;
361 }
362 }
363