PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.1
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.1
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.1, at includes/Services/Settings/Cloud_Print_Section.php

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