PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.7
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.7
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 / Services / Settings / Cloud_Print_Section.php

Cloud_Print_Section.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.7, at includes/Services/Settings/Cloud_Print_Section.php

436 lines 16.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Cloud Print Settings Section.
4 *
5 * @package WCPOS\WooCommercePOS
6 */
7
8 namespace WCPOS\WooCommercePOS\Services\Settings;
9
10 use WCPOS\WooCommercePOS\Services\Cloud_Print_Registry;
11 use WCPOS\WooCommercePOS\Services\Cloud_Print_Relay_Service;
12 use WCPOS\WooCommercePOS\Services\Cloud_Print_Trigger_Service;
13 use WCPOS\WooCommercePOS\Services\Print_Job_Service;
14 use WCPOS\WooCommercePOS\Services\Provider;
15 use WCPOS\WooCommercePOS\Services\Star_Online_Client;
16 use WP_Error;
17
18 /**
19 * The Cloud Print Settings Section: printer rows and store assignments.
20 *
21 * Owns the per-provider schema (PrintNode, Star CloudPRNT, Star Online),
22 * sanitization, secret redaction (poll_token_hash / printnode_api_key /
23 * star_api_key), preserve-on-omitted-key write semantics, and poll-token
24 * generation. Write is a full replacement of printers+assignments, not a
25 * PATCH; the persisted option shape is frozen (no date_modified_gmt stamp).
26 *
27 * read()/write() are wholesale overrides: Abstract_Section's
28 * migrate/compose/redact/sanitize template hooks, the
29 * woocommerce_pos_cloud_print_settings filter, and the pre_save/saved hooks
30 * do NOT run for this section. Registry replacement is the supported
31 * override mechanism.
32 */
33 class Cloud_Print_Section extends Abstract_Section {
34 /**
35 * Secret printer fields stripped from every public view. Future
36 * providers that add secrets must list them here — redaction is a
37 * one-place change.
38 *
39 * @var string[]
40 */
41 const SECRET_FIELDS = array( 'poll_token_hash', 'printnode_api_key', 'star_api_key' );
42 /**
43 * Section id. Option name: woocommerce_pos_settings_cloud_print.
44 */
45 public function id(): string {
46 return 'cloud_print';
47 }
48
49 /**
50 * Section defaults.
51 */
52 public function defaults(): array {
53 return array(
54 'printers' => array(),
55 'assignments' => array(),
56 );
57 }
58
59 /**
60 * Read the cloud-print view: enrich printers with runtime status,
61 * last-seen, and encoding fields; strip secrets.
62 */
63 public function read(): array {
64 $settings = wp_parse_args( $this->read_raw(), $this->defaults() );
65
66 $registry = new Cloud_Print_Registry();
67 $settings['printers'] = array_map(
68 function ( $printer ) use ( $registry ) {
69 if ( ! \is_array( $printer ) ) {
70 return $printer;
71 }
72 $id = (string) ( $printer['id'] ?? '' );
73 $seen = $registry->get_seen( $id );
74 $printer = $this->redact_printer( $printer );
75 $printer['status'] = $registry->status_for( $id );
76 $printer['last_seen'] = $seen > 0 ? $seen : null;
77 if ( 'blocked' === $printer['status'] ) {
78 $printer['status_detail'] = $registry->status_detail_for( $id );
79 } else {
80 unset( $printer['status_detail'] );
81 }
82
83 return $printer;
84 },
85 $settings['printers']
86 );
87 // Assignments saved before the copies/trigger fields existed have no
88 // such keys stored; normalize on read so the REST view always carries
89 // them. Legacy rows default to trigger=paid — receipts should not
90 // print until the customer has paid unless the merchant opts in.
91 $assignments = \is_array( $settings['assignments'] ) ? $settings['assignments'] : array();
92 $settings['assignments'] = array_map(
93 function ( $assignment ) {
94 if ( ! \is_array( $assignment ) ) {
95 return $assignment;
96 }
97 $assignment['copies'] = min( 5, max( 1, (int) ( $assignment['copies'] ?? 1 ) ) );
98 $assignment['trigger'] = Cloud_Print_Trigger_Service::normalize_trigger( $assignment['trigger'] ?? '' );
99
100 return $assignment;
101 },
102 $assignments
103 );
104 $settings['relay'] = Cloud_Print_Relay_Service::public_state();
105 // Read-only provider facts (currently: which template engines a
106 // provider can render). The admin app mirrors this in its own table;
107 // serving it makes the server the authority.
108 $settings['providers'] = Provider::public_capabilities();
109
110 return $settings;
111 }
112
113 /**
114 * Full replacement, not a merge: the incoming payload IS the write.
115 *
116 * Writes replace printers and assignments wholesale, and read() decorates
117 * the view with runtime-only fields (status, last_seen, relay) while
118 * stripping secrets. Merging that view into the payload would both write
119 * runtime fields into the option and resurrect printers the client just
120 * deleted, so this section opts out of the default array_replace_recursive
121 * PATCH strategy.
122 *
123 * @param array $existing Existing settings view.
124 * @param array $patch Incoming payload.
125 *
126 * @return array
127 */
128 public function merge( array $existing, array $patch ): array {
129 return $patch;
130 }
131
132 /**
133 * Replace cloud-print settings (full replacement, not PATCH).
134 *
135 * @param array $settings Payload with printers/assignments arrays.
136 *
137 * @return array|WP_Error On success: printers (redacted), assignments,
138 * and generated poll tokens keyed by printer id.
139 */
140 public function write( array $settings ) {
141 $printers = isset( $settings['printers'] ) && \is_array( $settings['printers'] ) ? array_values( $settings['printers'] ) : array();
142 $assigns = isset( $settings['assignments'] ) && \is_array( $settings['assignments'] ) ? array_values( $settings['assignments'] ) : array();
143
144 $existing = $this->read_raw();
145 $existing_hashes = array();
146 $existing_keys = array();
147 $existing_star_keys = array();
148 $existing_ids = array();
149 if ( isset( $existing['printers'] ) && \is_array( $existing['printers'] ) ) {
150 foreach ( $existing['printers'] as $printer ) {
151 if ( ! empty( $printer['id'] ) ) {
152 $existing_ids[] = $printer['id'];
153 }
154 if ( ! empty( $printer['id'] ) && ! empty( $printer['poll_token_hash'] ) ) {
155 $existing_hashes[ $printer['id'] ] = $printer['poll_token_hash'];
156 }
157 if ( ! empty( $printer['id'] ) && ! empty( $printer['printnode_api_key'] ) ) {
158 $existing_keys[ $printer['id'] ] = $printer['printnode_api_key'];
159 }
160 if ( ! empty( $printer['id'] ) && ! empty( $printer['star_api_key'] ) ) {
161 $existing_star_keys[ $printer['id'] ] = $printer['star_api_key'];
162 }
163 }
164 }
165
166 $generated = array();
167 $clean_printers = array();
168 $seen_ids = array();
169 foreach ( $printers as $printer ) {
170 $printer = $this->sanitize_printer( $printer );
171
172 if ( '' === $printer['id'] ) {
173 $printer['id'] = Cloud_Print_Registry::derive_id( $printer['name'], array_merge( $existing_ids, array_keys( $seen_ids ) ) );
174 }
175 $id = $printer['id'];
176
177 // Preserve a previously stored PrintNode API key when the incoming
178 // payload omits it (GET strips the key, so the React app re-POSTs
179 // printers without it when toggling other fields). A non-empty
180 // incoming key still overwrites, letting users rotate it.
181 if ( 'printnode' === $printer['provider'] && '' === $printer['printnode_api_key'] && ! empty( $existing_keys[ $id ] ) ) {
182 $printer['printnode_api_key'] = $existing_keys[ $id ];
183 }
184
185 if ( 'star-online' === $printer['provider'] && '' === $printer['star_api_key'] && ! empty( $existing_star_keys[ $id ] ) ) {
186 $printer['star_api_key'] = $existing_star_keys[ $id ];
187 }
188
189 if ( 'star-online' === $printer['provider'] ) {
190 $api_base = Star_Online_Client::api_base_from_cloudprnt_url( (string) $printer['star_cloudprnt_url'] );
191 $group = Star_Online_Client::group_from_cloudprnt_url( (string) $printer['star_cloudprnt_url'] );
192 if ( '' === $printer['star_api_key'] || null === $api_base || '' === $group || '' === $printer['star_device_id'] ) {
193 return new WP_Error(
194 'wcpos_cloud_print_star_online_invalid',
195 __( 'Star Online printers need an API key, a valid stario.online CloudPRNT URL, and a device.', 'woocommerce-pos' ),
196 array( 'status' => 400 )
197 );
198 }
199 }
200
201 if ( isset( $seen_ids[ $id ] ) ) {
202 return new WP_Error(
203 'wcpos_cloud_print_duplicate_printer_id',
204 __( 'Duplicate printer id.', 'woocommerce-pos' ),
205 array( 'status' => 400 )
206 );
207 }
208 $seen_ids[ $id ] = true;
209
210 $regenerate = ! empty( $printer['regenerate_token'] );
211 unset( $printer['regenerate_token'] );
212
213 if ( Provider::is_polling( $printer['provider'] ) ) {
214 if ( $regenerate || empty( $existing_hashes[ $id ] ) ) {
215 $token = Cloud_Print_Registry::generate_token();
216 $printer['poll_token_hash'] = Cloud_Print_Registry::hash_token( $token );
217 $generated[ $id ] = $token;
218 } else {
219 $printer['poll_token_hash'] = $existing_hashes[ $id ];
220 }
221 }
222
223 $clean_printers[] = $printer;
224 }
225
226 $clean = array(
227 'printers' => $clean_printers,
228 'assignments' => array_map( array( $this, 'sanitize_assignment' ), $assigns ),
229 );
230
231 $clean['assignments'] = $this->clear_unrenderable_templates( $clean['assignments'], $clean_printers );
232
233 update_option( $this->option_name(), $clean );
234
235 // Drop per-printer runtime state for printers that were removed, so a
236 // reused id cannot inherit a deleted printer's status or capabilities.
237 $registry = new Cloud_Print_Registry();
238 $registry->prune_seen( array_keys( $seen_ids ) );
239 $registry->prune_capabilities( array_keys( $seen_ids ) );
240
241 $response_printers = array_map(
242 function ( $printer ) {
243 return $this->redact_printer( $printer );
244 },
245 $clean_printers
246 );
247
248 return array(
249 'printers' => $response_printers,
250 'assignments' => $clean['assignments'],
251 'generated' => $generated,
252 // Repeated from the GET shape: the admin app rebuilds its cache
253 // from this response, so a field served only on GET would be
254 // dropped by the first save.
255 'providers' => Provider::public_capabilities(),
256 );
257 }
258
259 /**
260 * Sanitize a cloud printer entry.
261 *
262 * @param mixed $printer Printer.
263 */
264 private function sanitize_printer( $printer ): array {
265 $printer = \is_array( $printer ) ? $printer : array();
266 $provider = Provider::normalize( \is_string( $printer['provider'] ?? null ) ? $printer['provider'] : null );
267
268 $clean = array(
269 'id' => sanitize_text_field( $printer['id'] ?? '' ),
270 'name' => sanitize_text_field( $printer['name'] ?? '' ),
271 'provider' => $provider,
272 'store_id' => isset( $printer['store_id'] ) ? (int) $printer['store_id'] : 0,
273 'regenerate_token' => ! empty( $printer['regenerate_token'] ),
274 );
275 if ( 'printnode' === $provider ) {
276 $clean['printnode_api_key'] = sanitize_text_field( $printer['printnode_api_key'] ?? '' );
277 $clean['printnode_printer_id'] = isset( $printer['printnode_printer_id'] ) ? (int) $printer['printnode_printer_id'] : 0;
278 $clean['printnode_format'] = \in_array( $printer['printnode_format'] ?? '', array( 'pdf', 'raw' ), true )
279 ? $printer['printnode_format'] : 'pdf';
280 }
281 if ( 'star-cloudprnt' === $provider ) {
282 $encoding_fields = array_intersect_key(
283 $printer,
284 array_flip( array( 'columns', 'language', 'autoCut', 'fullReceiptRaster' ) )
285 );
286 $clean = $this->with_encoding_fields(
287 array_merge( $clean, $encoding_fields )
288 );
289 }
290 if ( 'star-online' === $provider ) {
291 $clean['star_api_key'] = sanitize_text_field( $printer['star_api_key'] ?? '' );
292 $clean['star_cloudprnt_url'] = esc_url_raw( $printer['star_cloudprnt_url'] ?? '' );
293 $clean['star_device_id'] = sanitize_text_field( $printer['star_device_id'] ?? '' );
294 $clean['star_client_type'] = sanitize_text_field( $printer['star_client_type'] ?? '' );
295 }
296
297 return $clean;
298 }
299
300 /**
301 * Public printer view: server-owned encoding fields added, secrets
302 * stripped.
303 *
304 * @param array $printer Printer row.
305 *
306 * @return array
307 */
308 private function redact_printer( array $printer ): array {
309 $printer = $this->with_encoding_fields( $printer );
310 foreach ( self::SECRET_FIELDS as $field ) {
311 unset( $printer[ $field ] );
312 }
313
314 return $printer;
315 }
316
317 /**
318 * Add server-owned client encoding fields for Star CloudPRNT printers.
319 *
320 * These fields let POS clients synthesize read-only cloud printer targets
321 * without guessing how to render raw payloads before CloudPRNT delivery.
322 *
323 * @param array $printer Printer row.
324 */
325 private function with_encoding_fields( array $printer ): array {
326 if ( 'star-cloudprnt' !== ( $printer['provider'] ?? '' ) ) {
327 return $printer;
328 }
329
330 // No Star CloudPRNT printer decodes ESC/POS (the TSP100IV prints the
331 // command bytes as literal text), so 'esc-pos' is not an accepted
332 // value — including rows where earlier releases materialized it into
333 // the stored option as the old default. Only 'star-line' survives as
334 // an explicit choice, for Line Mode-only models (TSP650II et al.).
335 // There is no admin toggle; sites that need a different fallback set
336 // it in code via the woocommerce_pos_cloud_printer_default_language filter.
337 $accepted = array( 'star-prnt', 'star-line' );
338 $language = $printer['language'] ?? '';
339 if ( ! \in_array( $language, $accepted, true ) ) {
340 $language = (string) apply_filters( 'woocommerce_pos_cloud_printer_default_language', 'star-prnt', $printer );
341 if ( ! \in_array( $language, $accepted, true ) ) {
342 $language = 'star-prnt';
343 }
344 }
345 $columns = isset( $printer['columns'] ) ? (int) $printer['columns'] : 42;
346 if ( ! \in_array( $columns, array( 32, 42, 48 ), true ) ) {
347 $columns = 42;
348 }
349
350 $printer['columns'] = $columns;
351 $printer['language'] = $language;
352 $printer['autoCut'] = array_key_exists( 'autoCut', $printer ) ? rest_sanitize_boolean( $printer['autoCut'] ) : true;
353 $printer['fullReceiptRaster'] = array_key_exists( 'fullReceiptRaster', $printer ) ? rest_sanitize_boolean( $printer['fullReceiptRaster'] ) : false;
354
355 return $printer;
356 }
357
358 /**
359 * Blank any assignment whose template its printer cannot render.
360 *
361 * A provider that declares a single template engine (Epson SDP and the Star
362 * providers all speak only 'thermal') renders nothing for any other engine,
363 * so the pairing has to be dealt with here rather than discovered as a
364 * receipt that never prints.
365 *
366 * Refusing the whole save was worse than the bug: one unrenderable row —
367 * including one the admin cannot see, because the template picker filters
368 * by engine and so cannot display the stored value — blocked every
369 * subsequent settings write and silently reverted the screen. Clearing the
370 * template instead always saves, and leaves the rule visibly incomplete:
371 * Cloud_Print_Trigger_Service skips assignments with no template, so the
372 * rule stops firing rather than queuing jobs that print nothing.
373 *
374 * Only resolvable templates are judged. A template_id that is empty or no
375 * longer exists cannot be classified and is left alone.
376 *
377 * @param array $assignments Sanitized assignments.
378 * @param array $printers Sanitized printers being written.
379 *
380 * @return array Assignments with unrenderable templates cleared.
381 */
382 private function clear_unrenderable_templates( array $assignments, array $printers ): array {
383 $providers = array();
384 foreach ( $printers as $printer ) {
385 if ( ! empty( $printer['id'] ) ) {
386 $providers[ $printer['id'] ] = (string) ( $printer['provider'] ?? '' );
387 }
388 }
389
390 return array_map(
391 function ( array $assignment ) use ( $providers ): array {
392 $printer_id = (string) ( $assignment['printer_id'] ?? '' );
393 $template_id = (string) ( $assignment['template_id'] ?? '' );
394 if ( '' === $template_id || ! isset( $providers[ $printer_id ] ) ) {
395 return $assignment;
396 }
397
398 $supported = Provider::template_engines( $providers[ $printer_id ] );
399 if ( 'all' === $supported ) {
400 return $assignment;
401 }
402
403 $template = Print_Job_Service::load_template( $template_id );
404 if ( null === $template ) {
405 return $assignment;
406 }
407
408 if ( (string) ( $template['engine'] ?? '' ) !== $supported ) {
409 $assignment['template_id'] = '';
410 }
411
412 return $assignment;
413 },
414 $assignments
415 );
416 }
417
418 /**
419 * Sanitize a cloud assignment entry.
420 *
421 * @param mixed $assignment Assignment.
422 */
423 public function sanitize_assignment( $assignment ): array {
424 $assignment = \is_array( $assignment ) ? $assignment : array();
425
426 return array(
427 'printer_id' => sanitize_text_field( $assignment['printer_id'] ?? '' ),
428 'store_id' => isset( $assignment['store_id'] ) ? (int) $assignment['store_id'] : 0,
429 'scope' => \in_array( $assignment['scope'] ?? '', array( 'every', 'pos', 'online' ), true ) ? $assignment['scope'] : 'every',
430 'template_id' => sanitize_text_field( (string) ( $assignment['template_id'] ?? '' ) ),
431 'copies' => min( 5, max( 1, (int) ( $assignment['copies'] ?? 1 ) ) ),
432 'trigger' => Cloud_Print_Trigger_Service::normalize_trigger( $assignment['trigger'] ?? '' ),
433 );
434 }
435 }
436