PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.9.14
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.9.14
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
woocommerce-pos / includes / API / Print_Jobs_Controller.php

Print_Jobs_Controller.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.9.14, at includes/API/Print_Jobs_Controller.php

1,350 lines 40.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Print Jobs REST controller.
4 *
5 * @package WCPOS\WooCommercePOS\API
6 */
7
8 namespace WCPOS\WooCommercePOS\API;
9
10 use WCPOS\WooCommercePOS\Logger;
11 use WCPOS\WooCommercePOS\Services\Cloud_Print_Diagnostic;
12 use WCPOS\WooCommercePOS\Services\Cloud_Print_Relay_Service;
13 use WCPOS\WooCommercePOS\Services\Cloud_Print_Registry;
14 use WCPOS\WooCommercePOS\Services\Cloud_Print_Trigger_Service;
15 use WCPOS\WooCommercePOS\Services\PrintNode_Client;
16 use WCPOS\WooCommercePOS\Services\Print_Job_Service;
17 use WCPOS\WooCommercePOS\Services\Provider;
18 use WCPOS\WooCommercePOS\Services\Star_Online_Client;
19 use WP_Error;
20 use WP_REST_Controller;
21 use WP_REST_Request;
22 use WP_REST_Response;
23 use WP_REST_Server;
24
25 use const WCPOS\WooCommercePOS\SHORT_NAME;
26
27 /**
28 * Print_Jobs_Controller class.
29 */
30 class Print_Jobs_Controller extends WP_REST_Controller {
31 /**
32 * Endpoint namespace.
33 *
34 * @var string
35 */
36 protected $namespace = SHORT_NAME . '/v1';
37
38 /**
39 * Route base.
40 *
41 * @var string
42 */
43 protected $rest_base = 'print-jobs';
44
45 /**
46 * Job store.
47 *
48 * @var Print_Job_Service
49 */
50 protected $jobs;
51
52 /**
53 * Cloud printer registry.
54 *
55 * @var Cloud_Print_Registry
56 */
57 protected $registry;
58
59 /**
60 * Constructor.
61 */
62 public function __construct() {
63 $this->jobs = new Print_Job_Service();
64 $this->registry = new Cloud_Print_Registry();
65 }
66
67 /**
68 * Register routes.
69 */
70 public function register_routes(): void {
71 register_rest_route(
72 $this->namespace,
73 '/' . $this->rest_base,
74 array(
75 array(
76 'methods' => WP_REST_Server::READABLE,
77 'callback' => array( $this, 'get_items' ),
78 'permission_callback' => array( $this, 'manage_permissions_check' ),
79 ),
80 array(
81 'methods' => WP_REST_Server::CREATABLE,
82 'callback' => array( $this, 'create_item' ),
83 'permission_callback' => array( $this, 'manage_permissions_check' ),
84 ),
85 )
86 );
87
88 register_rest_route(
89 $this->namespace,
90 '/' . $this->rest_base . '/(?P<id>[\d]+)',
91 array(
92 array(
93 'methods' => WP_REST_Server::READABLE,
94 'callback' => array( $this, 'get_item' ),
95 'permission_callback' => array( $this, 'manage_permissions_check' ),
96 ),
97 array(
98 'methods' => WP_REST_Server::DELETABLE,
99 'callback' => array( $this, 'delete_item' ),
100 'permission_callback' => array( $this, 'manage_permissions_check' ),
101 ),
102 )
103 );
104
105 register_rest_route(
106 $this->namespace,
107 '/' . $this->rest_base . '/(?P<id>[\d]+)/reprint',
108 array(
109 array(
110 'methods' => WP_REST_Server::CREATABLE,
111 'callback' => array( $this, 'reprint_item' ),
112 'permission_callback' => array( $this, 'manage_permissions_check' ),
113 ),
114 )
115 );
116
117 register_rest_route(
118 $this->namespace,
119 '/' . $this->rest_base . '/queue',
120 array(
121 array(
122 'methods' => WP_REST_Server::READABLE,
123 'callback' => array( $this, 'get_queue' ),
124 'permission_callback' => array( $this, 'manage_permissions_check' ),
125 ),
126 )
127 );
128
129 register_rest_route(
130 $this->namespace,
131 '/' . $this->rest_base . '/queue/cancel',
132 array(
133 array(
134 'methods' => WP_REST_Server::CREATABLE,
135 'callback' => array( $this, 'cancel_queue' ),
136 'permission_callback' => array( $this, 'manage_permissions_check' ),
137 ),
138 )
139 );
140
141 register_rest_route(
142 $this->namespace,
143 '/' . $this->rest_base . '/test',
144 array(
145 'methods' => WP_REST_Server::CREATABLE,
146 'callback' => array( $this, 'test_print' ),
147 'permission_callback' => array( $this, 'manage_permissions_check' ),
148 )
149 );
150
151 register_rest_route(
152 $this->namespace,
153 '/' . $this->rest_base . '/relay-verification',
154 array(
155 'methods' => WP_REST_Server::READABLE,
156 'callback' => array( $this, 'relay_verification' ),
157 'permission_callback' => '__return_true',
158 )
159 );
160
161 register_rest_route(
162 $this->namespace,
163 '/' . $this->rest_base . '/relay/register',
164 array(
165 'methods' => WP_REST_Server::CREATABLE,
166 'callback' => array( $this, 'relay_register' ),
167 'permission_callback' => array( $this, 'relay_manage_permissions_check' ),
168 )
169 );
170
171 register_rest_route(
172 $this->namespace,
173 '/' . $this->rest_base . '/cloudprnt',
174 array(
175 array(
176 'methods' => array( 'POST', 'GET', 'DELETE' ),
177 'callback' => array( $this, 'cloudprnt' ),
178 'permission_callback' => array( $this, 'printer_token_permissions_check' ),
179 ),
180 )
181 );
182
183 // Path-credential form: Star printers URL-encode the configured query
184 // string on the wire (& becomes %26), so printer_id/pt can never
185 // arrive as query parameters — but the path is transmitted verbatim.
186 register_rest_route(
187 $this->namespace,
188 '/' . $this->rest_base . '/cloudprnt/(?P<printer_id>[^/]+)/(?P<pt>[^/]+)',
189 array(
190 array(
191 'methods' => array( 'POST', 'GET', 'DELETE' ),
192 'callback' => array( $this, 'cloudprnt' ),
193 'permission_callback' => array( $this, 'printer_token_permissions_check' ),
194 ),
195 )
196 );
197
198 register_rest_route(
199 $this->namespace,
200 '/' . $this->rest_base . '/epson-sdp',
201 array(
202 array(
203 'methods' => WP_REST_Server::CREATABLE,
204 'callback' => array( $this, 'epson_sdp' ),
205 'permission_callback' => array( $this, 'printer_token_permissions_check' ),
206 ),
207 )
208 );
209
210 register_rest_route(
211 $this->namespace,
212 '/' . $this->rest_base . '/epson-sdp/(?P<printer_id>[^/]+)/(?P<pt>[^/]+)',
213 array(
214 array(
215 'methods' => WP_REST_Server::CREATABLE,
216 'callback' => array( $this, 'epson_sdp' ),
217 'permission_callback' => array( $this, 'printer_token_permissions_check' ),
218 ),
219 )
220 );
221
222 register_rest_route(
223 $this->namespace,
224 '/printnode/printers',
225 array(
226 array(
227 'methods' => WP_REST_Server::CREATABLE,
228 'callback' => array( $this, 'printnode_printers' ),
229 'permission_callback' => array( $this, 'manage_permissions_check' ),
230 ),
231 )
232 );
233
234 register_rest_route(
235 $this->namespace,
236 '/star-online/devices',
237 array(
238 array(
239 'methods' => WP_REST_Server::CREATABLE,
240 'callback' => array( $this, 'star_online_devices' ),
241 'permission_callback' => array( $this, 'manage_permissions_check' ),
242 ),
243 )
244 );
245 }
246
247 /**
248 * Proxy the PrintNode account's printer list for the add-printer wizard.
249 *
250 * The API key is supplied in the POST body (never the URL/query, so it does
251 * not leak through logs or history) and is used only for this request; it is
252 * never returned. Only id/name/state are surfaced to the client.
253 *
254 * @param WP_REST_Request $request Request.
255 *
256 * @return \WP_REST_Response|WP_Error
257 */
258 public function printnode_printers( $request ) {
259 // The API key is a secret: read it from the request body only, never the
260 // query string, so it can't leak through server logs or browser history.
261 // get_param() merges query + body, so it is deliberately avoided here.
262 $query = $request->get_query_params();
263 if ( isset( $query['api_key'] ) ) {
264 return new WP_Error(
265 'wcpos_printnode_api_key_in_query',
266 __( 'The PrintNode API key must be sent in the request body, not the query string.', 'woocommerce-pos' ),
267 array( 'status' => 400 )
268 );
269 }
270
271 // JSON bodies land in the JSON param set, form-encoded bodies in POST;
272 // read both (cast handles the null-on-absent case) and never the query set.
273 $json = (array) $request->get_json_params();
274 $body = (array) $request->get_body_params();
275 $api_key = (string) ( $json['api_key'] ?? $body['api_key'] ?? '' );
276 if ( '' === $api_key ) {
277 return new WP_Error(
278 'wcpos_printnode_missing_api_key',
279 __( 'A PrintNode API key is required.', 'woocommerce-pos' ),
280 array( 'status' => 400 )
281 );
282 }
283
284 $result = ( new PrintNode_Client( $api_key ) )->printers();
285 if ( is_wp_error( $result ) ) {
286 // A rejected key is a client input error (the value just typed into
287 // the wizard) → 400 so the UI can prompt for a correct key. Any other
288 // PrintNode failure is an upstream/transport error → 502 (matching
289 // test_print_printnode()).
290 $status = 'wcpos_printnode_unauthorized' === $result->get_error_code() ? 400 : 502;
291
292 return new WP_Error(
293 'wcpos_printnode_printers_failed',
294 $result->get_error_message(),
295 array( 'status' => $status )
296 );
297 }
298
299 $printers = array();
300 foreach ( (array) $result as $printer ) {
301 if ( ! is_array( $printer ) || ! isset( $printer['id'] ) ) {
302 continue;
303 }
304 $printers[] = array(
305 'id' => (int) $printer['id'],
306 'name' => (string) ( $printer['name'] ?? '' ),
307 'state' => (string) ( $printer['state'] ?? '' ),
308 );
309 }
310
311 return new WP_REST_Response( array( 'printers' => $printers ), 200 );
312 }
313
314 /**
315 * Proxy the stario.online device list for the add-printer wizard.
316 *
317 * @param WP_REST_Request $request Request.
318 *
319 * @return \WP_REST_Response|WP_Error
320 */
321 public function star_online_devices( $request ) {
322 $query = $request->get_query_params();
323 if ( isset( $query['api_key'] ) ) {
324 return new WP_Error(
325 'wcpos_star_online_api_key_in_query',
326 __( 'The Star Online API key must be sent in the request body, not the query string.', 'woocommerce-pos' ),
327 array( 'status' => 400 )
328 );
329 }
330
331 $json = (array) $request->get_json_params();
332 $body = (array) $request->get_body_params();
333 $api_key = (string) ( $json['api_key'] ?? $body['api_key'] ?? '' );
334 $url = (string) ( $json['cloudprnt_url'] ?? $body['cloudprnt_url'] ?? '' );
335
336 $api_base = Star_Online_Client::api_base_from_cloudprnt_url( $url );
337 $group = Star_Online_Client::group_from_cloudprnt_url( $url );
338 if ( '' === $api_key || null === $api_base || '' === $group ) {
339 return new WP_Error(
340 'wcpos_star_online_invalid_request',
341 __( 'A Star Online API key and a valid stario.online CloudPRNT URL are required.', 'woocommerce-pos' ),
342 array( 'status' => 400 )
343 );
344 }
345
346 $result = ( new Star_Online_Client( $api_base, $api_key ) )->devices( $group );
347 if ( is_wp_error( $result ) ) {
348 return $result;
349 }
350
351 $devices = array();
352 foreach ( $result as $device ) {
353 if ( ! \is_array( $device ) || empty( $device['AccessIdentifier'] ) ) {
354 continue;
355 }
356 $state = 'unknown';
357 $status = isset( $device['Status'] ) && \is_array( $device['Status'] ) ? $device['Status'] : array();
358 if ( array_key_exists( 'Online', $status ) ) {
359 $state = $status['Online'] ? 'online' : 'offline';
360 }
361 $devices[] = array(
362 'id' => (string) $device['AccessIdentifier'],
363 'name' => (string) ( $device['ClientType'] ?? $device['AccessIdentifier'] ),
364 'state' => $state,
365 );
366 }
367
368 return new WP_REST_Response( array( 'devices' => $devices ), 200 );
369 }
370
371
372 /**
373 * List print jobs.
374 *
375 * @param WP_REST_Request $request Request.
376 *
377 * @return \WP_REST_Response
378 */
379 public function get_items( $request ) {
380 return rest_ensure_response(
381 $this->jobs->query(
382 array(
383 'printer_id' => $request->get_param( 'printer_id' ),
384 'status' => $request->get_param( 'status' ),
385 )
386 )
387 );
388 }
389
390 /**
391 * Get a print job.
392 *
393 * @param WP_REST_Request $request Request.
394 *
395 * @return \WP_REST_Response|WP_Error
396 */
397 public function get_item( $request ) {
398 $job = $this->jobs->get( (int) $request->get_param( 'id' ) );
399 if ( null === $job ) {
400 return new WP_Error(
401 'wcpos_print_job_not_found',
402 __( 'Print job not found.', 'woocommerce-pos' ),
403 array( 'status' => 404 )
404 );
405 }
406
407 return rest_ensure_response( $job );
408 }
409
410 /**
411 * Cancel a print job.
412 *
413 * @param WP_REST_Request $request Request.
414 *
415 * @return \WP_REST_Response|WP_Error
416 */
417 public function delete_item( $request ) {
418 $id = (int) $request->get_param( 'id' );
419 $job = $this->jobs->get( $id );
420 if ( null === $job ) {
421 return new WP_Error(
422 'wcpos_print_job_not_found',
423 __( 'Print job not found.', 'woocommerce-pos' ),
424 array( 'status' => 404 )
425 );
426 }
427
428 if ( ! $this->jobs->cancel_if_waiting( $id ) ) {
429 return new WP_Error(
430 'wcpos_print_job_not_cancellable',
431 __( 'Only pending or claimed print jobs can be cancelled.', 'woocommerce-pos' ),
432 array( 'status' => 409 )
433 );
434 }
435
436 return rest_ensure_response( $this->jobs->get( $id ) );
437 }
438
439 /**
440 * The admin queue view: paginated jobs (payloads stripped), status counts,
441 * and per-printer backlog with last-seen data for staleness banners.
442 *
443 * @param WP_REST_Request $request Request.
444 *
445 * @return \WP_REST_Response
446 */
447 public function get_queue( $request ) {
448 $per_page = (int) $request->get_param( 'per_page' );
449 $per_page = min( 100, max( 1, 0 === $per_page ? 20 : $per_page ) );
450 $page = max( 1, (int) $request->get_param( 'page' ) );
451 $status = $request->get_param( 'status' );
452 if ( 'active' === $status ) {
453 // The default queue view: everything not yet terminal-successful.
454 $status = array(
455 Print_Job_Service::STATUS_PENDING,
456 Print_Job_Service::STATUS_CLAIMED,
457 Print_Job_Service::STATUS_FAILED,
458 );
459 }
460 $filters = array(
461 'printer_id' => $request->get_param( 'printer_id' ),
462 'status' => $status,
463 );
464
465 $jobs = array_map(
466 function ( array $job ): array {
467 $order = $job['order_id'] ? wc_get_order( $job['order_id'] ) : false;
468 if ( $order ) {
469 $job['order_number'] = (string) $order->get_order_number();
470 $job['order_edit_url'] = $order->get_edit_order_url();
471 }
472
473 return $job;
474 },
475 $this->jobs->query_rows(
476 array_merge(
477 $filters,
478 array(
479 'limit' => $per_page,
480 'page' => $page,
481 )
482 )
483 )
484 );
485
486 // One grouped query covers all status counts and every printer's
487 // backlog — the view refreshes every 30 s, so summary cost must not
488 // scale with printer count.
489 $summary = $this->jobs->status_summary();
490
491 $counts = array();
492 foreach ( array(
493 Print_Job_Service::STATUS_PENDING,
494 Print_Job_Service::STATUS_CLAIMED,
495 Print_Job_Service::STATUS_PRINTED,
496 Print_Job_Service::STATUS_FAILED,
497 Print_Job_Service::STATUS_CANCELLED,
498 ) as $status ) {
499 $counts[ $status ] = 0;
500 foreach ( $summary as $per_status ) {
501 $counts[ $status ] += isset( $per_status[ $status ] ) ? $per_status[ $status ]['count'] : 0;
502 }
503 }
504
505 $printers = array();
506 foreach ( $this->registry->get_printers() as $printer ) {
507 $printer_id = (string) ( $printer['id'] ?? '' );
508 if ( '' === $printer_id ) {
509 continue;
510 }
511 // Waiting = pending + claimed: a printer that fetched a job and
512 // then died leaves it claimed forever with zero pending — that
513 // backlog must still trip the stale banner.
514 $waiting = 0;
515 $oldest = '';
516 foreach ( array( Print_Job_Service::STATUS_PENDING, Print_Job_Service::STATUS_CLAIMED ) as $status ) {
517 if ( ! isset( $summary[ $printer_id ][ $status ] ) ) {
518 continue;
519 }
520 $waiting += $summary[ $printer_id ][ $status ]['count'];
521 $created = $summary[ $printer_id ][ $status ]['oldest_gmt'];
522 if ( '' !== $created && ( '' === $oldest || $created < $oldest ) ) {
523 $oldest = $created;
524 }
525 }
526 $printers[] = array(
527 'printer_id' => $printer_id,
528 'name' => (string) ( $printer['name'] ?? $printer_id ),
529 // Push providers (PrintNode, Star Online) never poll, so
530 // last-seen staleness is meaningless for them — the UI must
531 // not show a "never fetched" banner. A missing provider
532 // defaults to star-cloudprnt exactly like the print path, so
533 // legacy rows without the field keep their stale warnings.
534 'polling' => Provider::is_polling(
535 '' !== (string) ( $printer['provider'] ?? '' ) ? (string) $printer['provider'] : 'star-cloudprnt'
536 ),
537 'pending' => $waiting,
538 'oldest_pending_gmt' => $oldest,
539 'last_seen' => $this->registry->get_seen( $printer_id ),
540 );
541 }
542
543 return rest_ensure_response(
544 array(
545 'jobs' => $jobs,
546 'total' => $this->jobs->count( $filters ),
547 'page' => $page,
548 'per_page' => $per_page,
549 'summary' => array(
550 'counts' => $counts,
551 'printers' => $printers,
552 ),
553 )
554 );
555 }
556
557 /**
558 * Bulk-cancel waiting jobs by explicit ids or for a whole printer.
559 *
560 * @param WP_REST_Request $request Request.
561 *
562 * @return \WP_REST_Response
563 */
564 public function cancel_queue( $request ) {
565 $ids = $request->get_param( 'ids' );
566 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
567
568 $cancelled = $this->jobs->cancel_waiting(
569 array(
570 'ids' => \is_array( $ids ) ? $ids : array(),
571 'printer_id' => $printer_id,
572 )
573 );
574
575 return rest_ensure_response( array( 'cancelled' => $cancelled ) );
576 }
577
578 /**
579 * Reprint a print job by copying it to a new pending job.
580 *
581 * @param WP_REST_Request $request Request.
582 *
583 * @return \WP_REST_Response|WP_Error
584 */
585 public function reprint_item( $request ) {
586 $source = $this->jobs->get( (int) $request->get_param( 'id' ) );
587 if ( null === $source ) {
588 return new WP_Error(
589 'wcpos_print_job_not_found',
590 __( 'Print job not found.', 'woocommerce-pos' ),
591 array( 'status' => 404 )
592 );
593 }
594 if ( '' === $source['payload'] && '' === $source['template_id'] ) {
595 // A stripped raw job has nothing left to print — refuse loudly
596 // rather than queue a blank receipt.
597 return new WP_Error(
598 'wcpos_print_job_source_expired',
599 __( 'This job\'s stored receipt has been cleaned up and it has no template to re-render from.', 'woocommerce-pos' ),
600 array( 'status' => 410 )
601 );
602 }
603 $content_type = $source['content_type'];
604 if ( '' !== $source['template_id'] ) {
605 $printer = $this->registry->get_printer( (string) $source['printer_id'] );
606 if ( null !== $printer ) {
607 $content_type = Provider::content_type( (string) ( $printer['provider'] ?? '' ) );
608 }
609 }
610 $new_id = $this->jobs->create(
611 array(
612 'printer_id' => $source['printer_id'],
613 'content_type' => $content_type,
614 'payload' => $source['payload'],
615 'order_id' => $source['order_id'] ? $source['order_id'] : null,
616 'format' => $source['format'] ? $source['format'] : null,
617 // Template-backed jobs (auto-print) carry no stored payload —
618 // the render metadata must survive the copy or the reprint
619 // renders nothing.
620 'template_id' => '' !== $source['template_id'] ? $source['template_id'] : null,
621 'pn_kind' => '' !== $source['pn_kind'] ? $source['pn_kind'] : null,
622 'auto_open_drawer' => $source['auto_open_drawer'],
623 'drawer_connector' => $source['drawer_connector'],
624 )
625 );
626 if ( $new_id <= 0 ) {
627 return new WP_Error(
628 'wcpos_print_job_create_failed',
629 __( 'Print job could not be created.', 'woocommerce-pos' ),
630 array( 'status' => 500 )
631 );
632 }
633
634 // Push providers (PrintNode, Star Online) never poll the queue — their
635 // jobs only move when CRON_SUBMIT fires. Without this the replacement
636 // job stays pending forever and Retry silently does nothing.
637 $printer = $this->registry->get_printer( (string) $source['printer_id'] );
638 $provider = null !== $printer ? (string) ( $printer['provider'] ?? '' ) : '';
639 if ( Provider::requires_submit( $provider ) ) {
640 wp_schedule_single_event( time(), Cloud_Print_Trigger_Service::CRON_SUBMIT, array( $new_id ) );
641 }
642
643 $response = rest_ensure_response( $this->jobs->get( $new_id ) );
644 $response->set_status( 201 );
645
646 return $response;
647 }
648
649
650 /**
651 * Star CloudPRNT poll/fetch/confirm endpoint.
652 *
653 * @param WP_REST_Request $request Request.
654 *
655 * @return \WP_REST_Response|WP_Error
656 */
657 public function cloudprnt( $request ) {
658 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
659 $this->registry->record_seen( $printer_id );
660 $this->jobs->release_stale_claims( $printer_id );
661
662 if ( 'POST' === $request->get_method() ) {
663 if ( $this->jobs->find_active_claim( $printer_id ) ) {
664 return rest_ensure_response( array( 'jobReady' => false ) );
665 }
666
667 $job = $this->jobs->next_pending( $printer_id );
668 if ( null === $job ) {
669 return rest_ensure_response( array( 'jobReady' => false ) );
670 }
671
672 $content_type = $job['content_type'] ? $job['content_type'] : 'application/octet-stream';
673
674 // The CloudPRNT spec negotiates via the `mediaTypes` list: the
675 // printer compares it against its decodable set and fetches with
676 // its pick (or rejects pre-fetch with 510 when nothing matches).
677 // The singular `mediaType` is kept for older firmware.
678 return rest_ensure_response(
679 array(
680 'jobReady' => true,
681 'jobToken' => (string) $job['id'],
682 'mediaType' => $content_type,
683 'mediaTypes' => array( $content_type ),
684 )
685 );
686 }
687
688 $job = $this->get_cloud_job_for_request( $request, $printer_id );
689 if ( is_wp_error( $job ) ) {
690 return $job;
691 }
692
693 if ( 'DELETE' === $request->get_method() ) {
694 $code = sanitize_text_field( (string) $request->get_param( 'code' ) );
695 $status = '' === $code || '000' === $code || 1 === preg_match( '/^2\d{2,3}(?:\s|$)/', $code ) ? Print_Job_Service::STATUS_PRINTED : Print_Job_Service::STATUS_FAILED;
696 $this->jobs->set_status( (int) $job['id'], $status );
697
698 if ( Print_Job_Service::STATUS_FAILED === $status ) {
699 $this->log_printer_failure( $request, $printer_id, $code, (int) $job['id'] );
700 }
701
702 return rest_ensure_response( array( 'ok' => true ) );
703 }
704
705 $content_type = $job['content_type'] ? $job['content_type'] : 'application/octet-stream';
706
707 // The fetch GET names the printer's chosen media type. Serving a
708 // different format than requested puts undecodable bytes on the wire,
709 // so answer 415 (per the CloudPRNT spec) and leave the job unclaimed.
710 // Media types are compared case-insensitively (RFC 2045) and the
711 // logged value is length-capped: printers poll every few seconds, so
712 // a wedged loop must not flood the log with unbounded input.
713 $requested_type = sanitize_text_field( (string) $request->get_param( 'type' ) );
714 if ( '' !== $requested_type && strtolower( $requested_type ) !== strtolower( $content_type ) ) {
715 Logger::warning(
716 sprintf(
717 '%s: printer "%s" requested media type "%s" for print job %d but the job is "%s".',
718 $request->get_route(),
719 $printer_id,
720 substr( $requested_type, 0, 100 ),
721 (int) $job['id'],
722 $content_type
723 )
724 );
725
726 return new WP_Error(
727 'wcpos_print_job_incompatible_media_type',
728 __( 'The print job is not available in the requested media type.', 'woocommerce-pos' ),
729 array( 'status' => 415 )
730 );
731 }
732
733 if ( ! $this->jobs->try_claim( (int) $job['id'] ) ) {
734 return rest_ensure_response( array( 'jobReady' => false ) );
735 }
736
737 $payload = $this->jobs->render_payload( $job );
738 if ( '' === $payload ) {
739 Logger::error(
740 sprintf(
741 '%s: print job %d rendered an empty payload for printer "%s".',
742 $request->get_route(),
743 (int) $job['id'],
744 $printer_id
745 )
746 );
747 }
748
749 return $this->serve_raw( $payload, $content_type );
750 }
751
752 /**
753 * Permission check for printer-token routes.
754 *
755 * @param WP_REST_Request $request Request.
756 *
757 * @return bool|WP_Error
758 */
759 public function printer_token_permissions_check( $request ) {
760 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
761 $token = (string) $request->get_param( 'pt' );
762
763 if ( ! $this->registry->verify_token( $printer_id, $token ) ) {
764 Logger::warning(
765 sprintf(
766 '%s: authentication failed for printer "%s".',
767 $request->get_route(),
768 $printer_id
769 )
770 );
771
772 return new WP_Error(
773 'wcpos_print_job_invalid_token',
774 __( 'Invalid printer token.', 'woocommerce-pos' ),
775 array( 'status' => 401 )
776 );
777 }
778
779 return true;
780 }
781
782 /**
783 * Resolve and authorize a CloudPRNT job token.
784 *
785 * @param WP_REST_Request $request Request.
786 * @param string $printer_id Printer ID.
787 *
788 * @return array|WP_Error
789 */
790 private function get_cloud_job_for_request( WP_REST_Request $request, string $printer_id ) {
791 $job_id = (int) $request->get_param( 'token' );
792 $job = $this->jobs->get( $job_id );
793 if ( null === $job || $printer_id !== $job['printer_id'] ) {
794 Logger::warning(
795 sprintf(
796 '%s: print job "%d" was not found for printer "%s".',
797 $request->get_route(),
798 $job_id,
799 $printer_id
800 )
801 );
802
803 return new WP_Error(
804 'wcpos_print_job_not_found',
805 __( 'Print job not found.', 'woocommerce-pos' ),
806 array( 'status' => 404 )
807 );
808 }
809
810 return $job;
811 }
812
813 /**
814 * Log a printer-reported failure without request credentials or payloads.
815 *
816 * @param WP_REST_Request $request Request.
817 * @param string $printer_id Printer ID.
818 * @param string $code Failure code.
819 * @param int $job_id Print job ID.
820 */
821 private function log_printer_failure( WP_REST_Request $request, string $printer_id, string $code, int $job_id ): void {
822 Logger::error(
823 sprintf(
824 '%s: printer "%s" reported failure code "%s" for print job %d.',
825 $request->get_route(),
826 $printer_id,
827 $code,
828 $job_id
829 )
830 );
831 }
832
833 /**
834 * Serve raw bytes from a REST callback.
835 *
836 * @param string $body Response body.
837 * @param string $content_type Content type.
838 *
839 * @return \WP_REST_Response
840 */
841 private function serve_raw( string $body, string $content_type ) {
842 return Raw_Response::serve( $body, $content_type );
843 }
844
845
846 /**
847 * Epson Server Direct Print poll/result endpoint.
848 *
849 * @param WP_REST_Request $request Request.
850 *
851 * @return \WP_REST_Response
852 */
853 public function epson_sdp( $request ) {
854 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
855 $this->registry->record_seen( $printer_id );
856 $raw_body = (string) $request->get_body();
857 $soap = 'text/xml; charset=utf-8';
858 $ack = '<response success="true" code="" status=""/>';
859
860 $this->jobs->release_stale_claims( $printer_id );
861
862 if ( false !== strpos( $raw_body, 'success=' ) ) {
863 $claim = $this->jobs->find_active_claim( $printer_id );
864 if ( null !== $claim ) {
865 $ok = false !== strpos( $raw_body, 'success="true"' );
866 $this->jobs->set_status( (int) $claim['id'], $ok ? Print_Job_Service::STATUS_PRINTED : Print_Job_Service::STATUS_FAILED );
867
868 if ( ! $ok ) {
869 $code = 'unknown';
870 if ( 1 === preg_match( '/\bcode="([^"]*)"/', $raw_body, $matches ) ) {
871 $code = sanitize_text_field( $matches[1] );
872 }
873
874 $this->log_printer_failure( $request, $printer_id, $code, (int) $claim['id'] );
875 }
876 }
877
878 return $this->serve_raw( $ack, $soap );
879 }
880
881 if ( null !== $this->jobs->find_active_claim( $printer_id ) ) {
882 return $this->serve_raw( $ack, $soap );
883 }
884
885 $job = $this->jobs->next_pending( $printer_id );
886 if ( null === $job ) {
887 return $this->serve_raw( $ack, $soap );
888 }
889
890 if ( ! $this->jobs->try_claim( (int) $job['id'] ) ) {
891 return $this->serve_raw( $ack, $soap );
892 }
893 $epos = $this->jobs->render_payload( $job );
894
895 $envelope = '<?xml version="1.0" encoding="utf-8"?>';
896 $envelope .= '<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body>';
897 $envelope .= $epos;
898 $envelope .= '</s:Body></s:Envelope>';
899
900 return $this->serve_raw( $envelope, $soap );
901 }
902
903 /**
904 * Enqueue a print job (raw payload or order-based).
905 *
906 * @param WP_REST_Request $request Request.
907 *
908 * @return \WP_REST_Response|WP_Error
909 */
910 public function create_item( $request ) {
911 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
912 if ( '' === $printer_id ) {
913 return new WP_Error(
914 'wcpos_print_job_missing_printer',
915 __( 'A printer_id is required.', 'woocommerce-pos' ),
916 array( 'status' => 400 )
917 );
918 }
919
920 $payload = (string) $request->get_param( 'payload' );
921 $format = (string) $request->get_param( 'format' );
922 $template_id = sanitize_text_field( (string) $request->get_param( 'template_id' ) );
923 $order_id = (int) $request->get_param( 'order_id' );
924 $drawer_options = $this->drawer_options_from_request( $request );
925
926 $printer = $this->registry->get_printer( $printer_id );
927 $is_template_job = 0 !== $order_id && '' !== $template_id;
928 $validation = $this->validate_job_for_printer( $printer, $payload, $format, $is_template_job );
929 if ( is_wp_error( $validation ) ) {
930 return $validation;
931 }
932
933 $provider = null !== $printer ? (string) ( $printer['provider'] ?? '' ) : '';
934
935 // PrintNode never polls, so a raw payload could never be delivered — a
936 // PrintNode job must be order-based (rendered + submitted out-of-band).
937 if ( 'printnode' === $provider && ( 0 === $order_id || '' === $template_id ) ) {
938 return new WP_Error(
939 'wcpos_print_job_printnode_requires_template',
940 __( 'PrintNode print jobs require an order and a template.', 'woocommerce-pos' ),
941 array( 'status' => 400 )
942 );
943 }
944
945 // Order-based job: render server-side from the order + template, deriving
946 // the wire format from the printer's provider (shared with the auto-print
947 // trigger). Star/Epson are fetched on poll; PrintNode is submitted.
948 if ( 0 !== $order_id && '' !== $template_id ) {
949 if ( null === $printer ) {
950 // Without a known printer there is no provider to render for, and
951 // the job could never be polled/submitted — fail loudly rather
952 // than enqueue a job that silently never prints.
953 return new WP_Error(
954 'wcpos_print_job_unknown_printer',
955 __( 'Unknown printer.', 'woocommerce-pos' ),
956 array( 'status' => 404 )
957 );
958 }
959
960 return $this->create_order_job( $printer_id, $printer, $order_id, $template_id, $drawer_options );
961 }
962
963 $id = $this->jobs->create(
964 array(
965 'printer_id' => $printer_id,
966 'content_type' => (string) $request->get_param( 'content_type' ),
967 'payload' => $payload,
968 'order_id' => $order_id,
969 'format' => $format,
970 )
971 );
972 if ( $id <= 0 ) {
973 return new WP_Error(
974 'wcpos_print_job_create_failed',
975 __( 'Print job could not be created.', 'woocommerce-pos' ),
976 array( 'status' => 500 )
977 );
978 }
979
980 $response = rest_ensure_response( $this->jobs->get( $id ) );
981 $response->set_status( 201 );
982
983 return $response;
984 }
985
986 /**
987 * Enqueue an order-based job, deriving the wire format from the printer's
988 * provider via the shared trigger-service helper.
989 *
990 * @param string $printer_id Registered printer id.
991 * @param array $printer Registered printer config.
992 * @param int $order_id Order id to render.
993 * @param string $template_id Template id (numeric) or virtual slug.
994 * @param array $drawer_options Drawer options.
995 *
996 * @return \WP_REST_Response|WP_Error
997 */
998 private function create_order_job( string $printer_id, array $printer, int $order_id, string $template_id, array $drawer_options = array() ) {
999 if ( ! wc_get_order( $order_id ) ) {
1000 // Surface the bad order up front rather than enqueue a job that
1001 // render_payload() can only ever resolve to an empty (never-printing) payload.
1002 return new WP_Error(
1003 'wcpos_print_job_unknown_order',
1004 __( 'Unknown order.', 'woocommerce-pos' ),
1005 array( 'status' => 404 )
1006 );
1007 }
1008
1009 $template = Print_Job_Service::load_template( $template_id );
1010 if ( null === $template ) {
1011 return new WP_Error(
1012 'wcpos_print_job_unknown_template',
1013 __( 'Unknown template.', 'woocommerce-pos' ),
1014 array( 'status' => 400 )
1015 );
1016 }
1017
1018 $id = Cloud_Print_Trigger_Service::enqueue_order_job(
1019 $this->jobs,
1020 $printer_id,
1021 $printer,
1022 $order_id,
1023 $template_id,
1024 $template,
1025 $drawer_options
1026 );
1027 if ( $id <= 0 ) {
1028 return new WP_Error(
1029 'wcpos_print_job_template_not_printable',
1030 __( 'The selected template cannot be printed on this printer.', 'woocommerce-pos' ),
1031 array( 'status' => 400 )
1032 );
1033 }
1034
1035 $response = rest_ensure_response( $this->jobs->get( $id ) );
1036 $response->set_status( 201 );
1037
1038 return $response;
1039 }
1040
1041 /**
1042 * Enqueue a diagnostic test print for a registered printer.
1043 *
1044 * @param WP_REST_Request $request Request.
1045 *
1046 * @return \WP_REST_Response|WP_Error
1047 */
1048 public function test_print( $request ) {
1049 $printer_id = sanitize_text_field( (string) $request->get_param( 'printer_id' ) );
1050 $printer = $this->registry->get_printer( $printer_id );
1051 if ( null === $printer ) {
1052 return new WP_Error(
1053 'wcpos_print_job_unknown_printer',
1054 __( 'Unknown printer.', 'woocommerce-pos' ),
1055 array( 'status' => 404 )
1056 );
1057 }
1058
1059 $provider = (string) ( $printer['provider'] ?? '' );
1060
1061 if ( 'printnode' === $provider ) {
1062 return $this->test_print_printnode( $printer );
1063 }
1064
1065 if ( 'star-online' === $provider ) {
1066 return $this->test_print_star_online( $printer_id, $printer );
1067 }
1068
1069 try {
1070 $diag = ( new Cloud_Print_Diagnostic() )->build( (string) $printer['provider'], (string) $printer['name'] );
1071 } catch ( \RuntimeException $e ) {
1072 return new WP_Error(
1073 'wcpos_print_job_no_diagnostic',
1074 __( 'Test print is not available for this printer yet.', 'woocommerce-pos' ),
1075 array( 'status' => 400 )
1076 );
1077 }
1078
1079 $id = $this->jobs->create(
1080 array(
1081 'printer_id' => $printer_id,
1082 'content_type' => $diag['content_type'],
1083 'payload' => $diag['payload'],
1084 )
1085 );
1086 if ( $id <= 0 ) {
1087 return new WP_Error(
1088 'wcpos_print_job_create_failed',
1089 __( 'Print job could not be created.', 'woocommerce-pos' ),
1090 array( 'status' => 500 )
1091 );
1092 }
1093
1094 $response = rest_ensure_response( $this->jobs->get( $id ) );
1095 $response->set_status( 201 );
1096
1097 return $response;
1098 }
1099
1100 /**
1101 * Queue a Star Markup test receipt and submit it through the push pipeline.
1102 *
1103 * @param string $printer_id Registered printer id.
1104 * @param array $printer Registered star-online printer.
1105 *
1106 * @return \WP_REST_Response|WP_Error
1107 */
1108 private function test_print_star_online( string $printer_id, array $printer ) {
1109 $date = gmdate( 'Y-m-d H:i' );
1110 $markup = '[align: middle][bold: on]WCPOS[bold: off]' . "\n";
1111 $markup .= 'Cloud Print Test' . "\n" . '[align: left]';
1112 $markup .= 'Printer: ' . $this->star_escape( (string) $printer['name'] ) . "\n";
1113 $markup .= 'Date: ' . $date . "\n";
1114 $markup .= 'If you can read this, printing works!' . "\n";
1115 $markup .= '[feed][cut]';
1116
1117 $id = $this->jobs->create(
1118 array(
1119 'printer_id' => $printer_id,
1120 'content_type' => 'text/vnd.star.markup',
1121 'payload' => base64_encode( $markup ),
1122 )
1123 );
1124 if ( $id <= 0 ) {
1125 return new WP_Error(
1126 'wcpos_print_job_create_failed',
1127 __( 'Print job could not be created.', 'woocommerce-pos' ),
1128 array( 'status' => 500 )
1129 );
1130 }
1131
1132 wp_schedule_single_event( time(), Cloud_Print_Trigger_Service::CRON_SUBMIT, array( $id ) );
1133 ( new \WCPOS\WooCommercePOS\Services\Cloud_Print_Submit_Service() )->submit( $id );
1134
1135 $response = rest_ensure_response( $this->jobs->get( $id ) );
1136 $response->set_status( 201 );
1137
1138 return $response;
1139 }
1140
1141 /**
1142 * Escape brackets for Star Document Markup text.
1143 *
1144 * @param string $value Text.
1145 *
1146 * @return string
1147 */
1148 private function star_escape( string $value ): string {
1149 return str_replace( array( '[', ']' ), array( '[[', ']]' ), $value );
1150 }
1151
1152 /**
1153 * Submit a diagnostic PDF to a PrintNode printer.
1154 *
1155 * @param array $printer Registered PrintNode printer.
1156 *
1157 * @return \WP_REST_Response|WP_Error
1158 */
1159 private function test_print_printnode( array $printer ) {
1160 $api_key = (string) ( $printer['printnode_api_key'] ?? '' );
1161 $pn_printer_id = (int) ( $printer['printnode_printer_id'] ?? 0 );
1162 if ( '' === $api_key || 0 === $pn_printer_id ) {
1163 return new WP_Error(
1164 'wcpos_print_job_printnode_unconfigured',
1165 __( 'This PrintNode printer is missing its API key or printer id.', 'woocommerce-pos' ),
1166 array( 'status' => 400 )
1167 );
1168 }
1169
1170 try {
1171 $pdf = ( new Cloud_Print_Diagnostic() )->build_pdf( (string) $printer['name'] );
1172 } catch ( \Throwable $e ) {
1173 // Defense in depth: a Dompdf/font-cache/temp-dir failure must not
1174 // surface as an uncaught 500. Mirror the render_payload() guard.
1175 Logger::log( 'Cloud print: PrintNode diagnostic PDF render failed: ' . $e->getMessage() );
1176
1177 return new WP_Error(
1178 'wcpos_print_job_diagnostic_failed',
1179 __( 'Could not generate the test print.', 'woocommerce-pos' ),
1180 array( 'status' => 500 )
1181 );
1182 }
1183
1184 $result = ( new PrintNode_Client( $api_key ) )->submit_job(
1185 $pn_printer_id,
1186 'WCPOS Test Print',
1187 'pdf_base64',
1188 base64_encode( $pdf )
1189 );
1190
1191 if ( is_wp_error( $result ) ) {
1192 return new WP_Error(
1193 'wcpos_print_job_printnode_failed',
1194 $result->get_error_message(),
1195 array( 'status' => 502 )
1196 );
1197 }
1198
1199 return new WP_REST_Response(
1200 array(
1201 'submitted' => true,
1202 'external_provider' => 'printnode',
1203 'external_job_id' => (string) $result['id'],
1204 'external_state' => 'submitted',
1205 ),
1206 201
1207 );
1208 }
1209
1210 /**
1211 * Extract sanitized cash-drawer options from a REST request.
1212 *
1213 * @param WP_REST_Request $request Request.
1214 *
1215 * @return array{auto_open_drawer:bool, drawer_connector:string}
1216 */
1217 private function drawer_options_from_request( WP_REST_Request $request ): array {
1218 $auto = $request->get_param( 'autoOpenDrawer' );
1219 if ( null === $auto ) {
1220 $auto = $request->get_param( 'auto_open_drawer' );
1221 }
1222
1223 $connector = $request->get_param( 'drawerConnector' );
1224 if ( null === $connector ) {
1225 $connector = $request->get_param( 'drawer_connector' );
1226 }
1227
1228 return array(
1229 'auto_open_drawer' => rest_sanitize_boolean( $auto ),
1230 'drawer_connector' => Print_Job_Service::normalize_drawer_connector( (string) $connector ),
1231 );
1232 }
1233
1234 /**
1235 * Validate a job against the target printer's provider.
1236 *
1237 * @param array|null $printer Registered printer, or null when unknown.
1238 * @param string $payload Base64 payload (raw jobs).
1239 * @param string $format Render format (order-based jobs).
1240 * @param bool $is_template_job Whether this is an order/template job.
1241 *
1242 * @return true|WP_Error
1243 */
1244 private function validate_job_for_printer( ?array $printer, string $payload, string $format, bool $is_template_job ) {
1245 if ( null === $printer ) {
1246 return true;
1247 }
1248 $provider = $printer['provider'] ?? 'star-cloudprnt';
1249
1250 if ( 'epos-xml' === Provider::wire_format( $provider, 'thermal' ) ) {
1251 if ( '' !== $payload ) {
1252 return new WP_Error(
1253 'wcpos_print_job_incompatible',
1254 __( 'Epson Server Direct Print accepts order-based ePOS-Print jobs only, not raw payloads.', 'woocommerce-pos' ),
1255 array( 'status' => 400 )
1256 );
1257 }
1258 if ( '' !== $format && 'epos-xml' !== $format ) {
1259 return new WP_Error(
1260 'wcpos_print_job_incompatible',
1261 __( 'Epson Server Direct Print requires the epos-xml format.', 'woocommerce-pos' ),
1262 array( 'status' => 400 )
1263 );
1264 }
1265
1266 return true;
1267 }
1268
1269 if ( 'epos-xml' === $format ) {
1270 return new WP_Error(
1271 'wcpos_print_job_incompatible',
1272 __( 'Star CloudPRNT does not accept the epos-xml format.', 'woocommerce-pos' ),
1273 array( 'status' => 400 )
1274 );
1275 }
1276
1277 // The fixed-layout 'escpos' adapter emits a language StarPRNT-native
1278 // printers cannot decode, and the fixed-layout 'starprnt' adapter is a
1279 // placeholder that emits marker text, not wire bytes. Fail these jobs
1280 // loudly instead of queueing bytes the printer will reject.
1281 if ( ! $is_template_job && 'star-cloudprnt' === $provider && in_array( $format, array( 'escpos', 'starprnt' ), true ) ) {
1282 return new WP_Error(
1283 'wcpos_print_job_incompatible',
1284 __( 'Star CloudPRNT printers require order-based template jobs or a raw payload.', 'woocommerce-pos' ),
1285 array( 'status' => 400 )
1286 );
1287 }
1288
1289 return true;
1290 }
1291
1292 /**
1293 * Serve the pending relay verification token (public; consent callback).
1294 *
1295 * @return \WP_REST_Response|WP_Error
1296 */
1297 public function relay_verification() {
1298 $token = Cloud_Print_Relay_Service::pending_verification_token();
1299 if ( null === $token ) {
1300 return new WP_Error(
1301 'wcpos_relay_no_pending_verification',
1302 __( 'No relay verification is pending.', 'woocommerce-pos' ),
1303 array( 'status' => 404 )
1304 );
1305 }
1306
1307 return rest_ensure_response( array( 'token' => $token ) );
1308 }
1309
1310 /**
1311 * Register this site with the WCPOS Cloud Print relay.
1312 *
1313 * @return \WP_REST_Response|WP_Error
1314 */
1315 public function relay_register() {
1316 $result = Cloud_Print_Relay_Service::register_site();
1317
1318 return is_wp_error( $result ) ? $result : rest_ensure_response( $result );
1319 }
1320
1321 /**
1322 * Permission check for relay registration routes.
1323 *
1324 * Registering rotates the site's relay credentials, so it needs the
1325 * settings-management capability, not the cashier-level print capability.
1326 */
1327 public function relay_manage_permissions_check(): bool {
1328 return current_user_can( 'manage_woocommerce_pos' );
1329 }
1330
1331 /**
1332 * Check permissions for cashier-level print job actions.
1333 *
1334 * @param WP_REST_Request $request Request.
1335 *
1336 * @return bool|WP_Error
1337 */
1338 public function manage_permissions_check( $request ) {
1339 if ( ! current_user_can( 'access_woocommerce_pos' ) ) {
1340 return new WP_Error(
1341 'wcpos_rest_insufficient_permissions',
1342 __( 'Sorry, you cannot manage print jobs.', 'woocommerce-pos' ),
1343 array( 'status' => rest_authorization_required_code() )
1344 );
1345 }
1346
1347 return true;
1348 }
1349 }
1350