PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.12
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.12
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 / Cloud_Print_Registry.php

Cloud_Print_Registry.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.12, at includes/Services/Cloud_Print_Registry.php

507 lines 15.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Cloud printer registry (reads woocommerce_pos_settings_cloud_print).
4 *
5 * @package WCPOS\WooCommercePOS\Services
6 */
7
8 namespace WCPOS\WooCommercePOS\Services;
9
10 /**
11 * Cloud_Print_Registry class.
12 */
13 class Cloud_Print_Registry {
14 const OPTION = 'woocommerce_pos_settings_cloud_print';
15
16 const RUNTIME_OPTION = 'woocommerce_pos_cloud_print_runtime';
17 const SEEN_TTL = 150; // Seconds; connected if seen within this window.
18 const PN_STATUS_TTL = 60; // Seconds; PrintNode live-status cache window.
19
20 /**
21 * Per-printer CloudPRNT client capabilities, keyed by printer id.
22 *
23 * Separate from RUNTIME_OPTION, whose values are bare last-seen timestamps.
24 */
25 const CAPABILITIES_OPTION = 'woocommerce_pos_cloud_print_capabilities';
26
27 /**
28 * Seconds before a printer's capability answers are asked for again.
29 *
30 * Star's own WooCommerce plugin re-asks every 120 seconds; matching it keeps
31 * a swapped-out printer on a reused id from serving formats the new hardware
32 * cannot decode for longer than a couple of minutes.
33 */
34 const CAPABILITIES_TTL = 120;
35
36 /**
37 * Mutex guarding read-modify-write of the shared capabilities option.
38 */
39 const CAPABILITIES_LOCK = 'wcpos_cloud_print_caps_lock';
40
41 /**
42 * Seconds after which a capabilities lock is treated as abandoned.
43 *
44 * Short on purpose: the critical section is one option write, so a lock
45 * older than this belongs to a request that died holding it.
46 */
47 const CAPABILITIES_LOCK_TTL = 10;
48
49 /**
50 * All registered cloud printers.
51 *
52 * @return array<int, array>
53 */
54 public function get_printers(): array {
55 $settings = get_option( self::OPTION, array() );
56
57 return isset( $settings['printers'] ) && \is_array( $settings['printers'] ) ? $settings['printers'] : array();
58 }
59
60 /**
61 * Get a registered cloud printer by id.
62 *
63 * @param string $printer_id Printer id.
64 *
65 * @return array|null
66 */
67 public function get_printer( string $printer_id ): ?array {
68 foreach ( $this->get_printers() as $printer ) {
69 if ( isset( $printer['id'] ) && hash_equals( (string) $printer['id'], $printer_id ) ) {
70 return $printer;
71 }
72 }
73
74 return null;
75 }
76
77 /**
78 * Verify a printer's poll token (constant-time).
79 *
80 * @param string $printer_id Printer id.
81 * @param string $token Presented token.
82 */
83 public function verify_token( string $printer_id, string $token ): bool {
84 $printer = $this->get_printer( $printer_id );
85 if ( null === $printer || empty( $printer['poll_token_hash'] ) || '' === $token ) {
86 return false;
87 }
88
89 return hash_equals( (string) $printer['poll_token_hash'], self::hash_token( $token ) );
90 }
91
92 /**
93 * Generate a cryptographically strong poll token (returned to the admin once).
94 */
95 public static function generate_token(): string {
96 return bin2hex( random_bytes( 24 ) );
97 }
98
99 /**
100 * Hash a poll token for at-rest storage (we never persist the plaintext).
101 *
102 * @param string $token Token.
103 */
104 public static function hash_token( string $token ): string {
105 return hash( 'sha256', $token );
106 }
107
108 /**
109 * Derive a stable, URL-safe printer id from a display name, unique against existing ids.
110 *
111 * @param string $name Display name.
112 * @param array<string> $existing_ids Already-used ids.
113 *
114 * @return string
115 */
116 public static function derive_id( string $name, array $existing_ids ): string {
117 $base = sanitize_title( $name );
118 if ( '' === $base ) {
119 $base = 'printer';
120 }
121 $candidate = $base;
122 $suffix = 2;
123 while ( \in_array( $candidate, $existing_ids, true ) ) {
124 $candidate = $base . '-' . $suffix;
125 ++$suffix;
126 }
127
128 return $candidate;
129 }
130
131 /**
132 * Record that a printer polled just now.
133 *
134 * @param string $printer_id Printer id.
135 */
136 public function record_seen( string $printer_id ): void {
137 $runtime = get_option( self::RUNTIME_OPTION, array() );
138 $runtime = \is_array( $runtime ) ? $runtime : array();
139 $runtime[ $printer_id ] = time();
140 update_option( self::RUNTIME_OPTION, $runtime, false ); // Autoload no.
141 }
142
143 /**
144 * Get a printer's last-seen unix timestamp (0 if never).
145 *
146 * @param string $printer_id Printer id.
147 *
148 * @return int
149 */
150 public function get_seen( string $printer_id ): int {
151 $runtime = get_option( self::RUNTIME_OPTION, array() );
152
153 return ( \is_array( $runtime ) && isset( $runtime[ $printer_id ] ) ) ? (int) $runtime[ $printer_id ] : 0;
154 }
155
156 /**
157 * Drop runtime last-seen entries for printer ids that no longer exist.
158 *
159 * Prevents the runtime option from growing unbounded as printers are
160 * removed, and stops a recreated id (slug reuse) from inheriting a deleted
161 * printer's stale status.
162 *
163 * @param array<string> $keep_ids Printer ids to retain.
164 */
165 public function prune_seen( array $keep_ids ): void {
166 $runtime = get_option( self::RUNTIME_OPTION, array() );
167 if ( ! \is_array( $runtime ) ) {
168 return;
169 }
170 $pruned = array_intersect_key( $runtime, array_flip( $keep_ids ) );
171 if ( $pruned !== $runtime ) {
172 update_option( self::RUNTIME_OPTION, $pruned, false );
173 }
174 }
175
176 /**
177 * A printer's cached CloudPRNT client capabilities.
178 *
179 * Answers are returned however stale they are: a printer's decodable format
180 * list does not change while it sits on a shelf, and serving the last known
181 * answer beats falling back to "offer everything" on every cold cache. The
182 * TTL governs how often we ask again, not how long an answer is believed.
183 *
184 * @param string $printer_id Printer id.
185 *
186 * @return array{client_type:string, client_version:string, encodings:array<int, string>, status_code:string, updated:int, asked:int}
187 */
188 public function get_capabilities( string $printer_id ): array {
189 $all = get_option( self::CAPABILITIES_OPTION, array() );
190
191 return self::hydrate( \is_array( $all ) ? ( $all[ $printer_id ] ?? array() ) : array() );
192 }
193
194 /**
195 * Normalize a stored capability entry into the full record shape.
196 *
197 * @param mixed $stored The raw stored entry, or anything at all — the option
198 * is hand-editable and predates this shape.
199 *
200 * @return array{client_type:string, client_version:string, encodings:array<int, string>, status_code:string, updated:int, asked:int}
201 */
202 private static function hydrate( $stored ): array {
203 $stored = \is_array( $stored ) ? $stored : array();
204
205 return array(
206 'client_type' => (string) ( $stored['client_type'] ?? '' ),
207 'client_version' => (string) ( $stored['client_version'] ?? '' ),
208 'encodings' => \is_array( $stored['encodings'] ?? null ) ? array_values( array_map( 'strval', $stored['encodings'] ) ) : array(),
209 'status_code' => (string) ( $stored['status_code'] ?? '' ),
210 'updated' => (int) ( $stored['updated'] ?? 0 ),
211 'asked' => (int) ( $stored['asked'] ?? 0 ),
212 );
213 }
214
215 /**
216 * Store the answers a printer gave to our `clientAction` questions.
217 *
218 * Only the fields the printer actually answered are overwritten — a poll
219 * that carries `ClientType` but not `Encodings` must not wipe an encodings
220 * list we already know.
221 *
222 * @param string $printer_id Printer id.
223 * @param array<string, string> $answers Answers keyed by request name.
224 * @param string $status_code The printer's reported status code.
225 *
226 * @return bool Whether anything was stored. False also when a concurrent
227 * poll held the write lock; see mutate_capabilities().
228 */
229 public function record_capabilities( string $printer_id, array $answers, string $status_code = '' ): bool {
230 return $this->mutate_printer_record(
231 $printer_id,
232 static function ( array $record ) use ( $answers, $status_code ): ?array {
233 $changed = false;
234
235 foreach ( $answers as $name => $value ) {
236 switch ( $name ) {
237 case 'ClientType':
238 $record['client_type'] = $value;
239 $changed = true;
240 break;
241 case 'ClientVersion':
242 $record['client_version'] = $value;
243 $changed = true;
244 break;
245 case 'Encodings':
246 $record['encodings'] = self::parse_encodings( $value );
247 $changed = true;
248 break;
249 }
250 }
251
252 if ( '' !== $status_code && $status_code !== $record['status_code'] ) {
253 $record['status_code'] = $status_code;
254 $changed = true;
255 }
256
257 if ( ! $changed ) {
258 return null;
259 }
260
261 $record['updated'] = time();
262
263 return $record;
264 }
265 );
266 }
267
268 /**
269 * Whether the printer should be asked for its capabilities on this poll.
270 *
271 * @param string $printer_id Printer id.
272 *
273 * @return bool
274 */
275 public function should_request_capabilities( string $printer_id ): bool {
276 $record = $this->get_capabilities( $printer_id );
277
278 return ( time() - $record['asked'] ) >= self::CAPABILITIES_TTL;
279 }
280
281 /**
282 * Record that this poll response carried capability questions.
283 *
284 * Written even when the printer never answers, so firmware that ignores
285 * `clientAction` is asked once per TTL rather than on every poll.
286 *
287 * @param string $printer_id Printer id.
288 */
289 public function record_capability_request( string $printer_id ): void {
290 $this->mutate_printer_record(
291 $printer_id,
292 static function ( array $record ): array {
293 $record['asked'] = time();
294
295 return $record;
296 }
297 );
298 }
299
300 /**
301 * Drop cached capabilities for printer ids that no longer exist.
302 *
303 * @param array<string> $keep_ids Printer ids to retain.
304 */
305 public function prune_capabilities( array $keep_ids ): void {
306 $this->mutate_capabilities(
307 static function ( array $all ) use ( $keep_ids ): ?array {
308 $pruned = array_intersect_key( $all, array_flip( $keep_ids ) );
309
310 return $pruned === $all ? null : $pruned;
311 }
312 );
313 }
314
315 /**
316 * Read-modify-write one printer's capability record under the mutex.
317 *
318 * The callback receives the record as it stands *inside* the lock, never a
319 * snapshot taken before it. Assembling the record first and handing the
320 * finished thing to a locked setter would leave same-printer polls racing:
321 * two overlapping polls would each build from the same pre-lock read, and
322 * the second write would undo the first — restoring a stale `asked` stamp or
323 * erasing encodings that had just arrived.
324 *
325 * @param string $printer_id Printer id.
326 * @param callable $mutate Receives the current record, returns the new
327 * one, or null to write nothing.
328 *
329 * @return bool Whether the record was written.
330 */
331 private function mutate_printer_record( string $printer_id, callable $mutate ): bool {
332 return $this->mutate_capabilities(
333 static function ( array $all ) use ( $printer_id, $mutate ): ?array {
334 $next = $mutate( self::hydrate( $all[ $printer_id ] ?? array() ) );
335 if ( null === $next ) {
336 return null;
337 }
338
339 $all[ $printer_id ] = $next;
340
341 return $all;
342 }
343 );
344 }
345
346 /**
347 * Read-modify-write the shared capabilities option under a mutex.
348 *
349 * Every registered printer polls into the same option, so an unguarded
350 * read-modify-write lets one printer's write discard another's: both read
351 * the same snapshot, each sets its own key, and the second write wins.
352 * The mutex plus a cache-bypassing read inside it makes the sequence
353 * atomic across concurrent requests.
354 *
355 * A poll that cannot take the lock skips its write rather than queueing.
356 * The record is a cache with a CAPABILITIES_TTL refresh, so the cost of a
357 * skipped write is one refresh cycle, never a wrong answer — and blocking
358 * a printer's poll to persist a cache would be the worse trade.
359 *
360 * @param callable $mutate Receives the current map, returns the new map, or
361 * null to write nothing.
362 *
363 * @return bool Whether the option was written.
364 */
365 private function mutate_capabilities( callable $mutate ): bool {
366 if ( ! self::acquire_capabilities_lock() ) {
367 return false;
368 }
369
370 try {
371 // Drop this request's cached copy so the read reflects writes other
372 // requests made while we were waiting for the lock.
373 wp_cache_delete( self::CAPABILITIES_OPTION, 'options' );
374
375 $all = get_option( self::CAPABILITIES_OPTION, array() );
376 $all = \is_array( $all ) ? $all : array();
377
378 $next = $mutate( $all );
379 if ( null === $next ) {
380 return false;
381 }
382
383 update_option( self::CAPABILITIES_OPTION, $next, false ); // Autoload no.
384 } finally {
385 delete_option( self::CAPABILITIES_LOCK );
386 }
387
388 return true;
389 }
390
391 /**
392 * Take the capabilities mutex.
393 *
394 * The atomic primitive is add_option(): the options table's unique index on
395 * option_name means exactly one concurrent caller can create the row. This
396 * mirrors Print_Job_Service's lifecycle lock.
397 *
398 * @return bool Whether the lock was taken.
399 */
400 private static function acquire_capabilities_lock(): bool {
401 $now = time();
402
403 if ( add_option( self::CAPABILITIES_LOCK, (string) $now, '', false ) ) {
404 return true;
405 }
406
407 $locked_at = (int) get_option( self::CAPABILITIES_LOCK, 0 );
408 if ( $locked_at > 0 && ( $now - $locked_at ) > self::CAPABILITIES_LOCK_TTL ) {
409 delete_option( self::CAPABILITIES_LOCK );
410
411 return add_option( self::CAPABILITIES_LOCK, (string) $now, '', false );
412 }
413
414 return false;
415 }
416
417 /**
418 * Read a printer's `Encodings` answer into a list of media types.
419 *
420 * The answer is a delimited string of the media types the client can decode
421 * (Star's own plugin substring-matches it). Splitting on both `,` and `;`
422 * and then keeping only `type/subtype` tokens drops MIME parameters such as
423 * `charset=utf-8` without needing to know which delimiter the firmware used.
424 *
425 * @param string $value The raw `Encodings` answer.
426 *
427 * @return array<int, string> Lower-cased media types, in the printer's order.
428 */
429 private static function parse_encodings( string $value ): array {
430 $tokens = preg_split( '/[,;\r\n\t ]+/', strtolower( $value ) );
431 if ( false === $tokens ) {
432 return array();
433 }
434
435 $types = array();
436 foreach ( $tokens as $token ) {
437 $token = trim( $token );
438 if ( 1 === preg_match( '#^[a-z0-9][a-z0-9!\#$&^_.+-]*/[a-z0-9][a-z0-9!\#$&^_.+-]*$#', $token ) ) {
439 $types[] = $token;
440 }
441 }
442
443 return array_values( array_unique( $types ) );
444 }
445
446 /**
447 * Connection status for a printer.
448 *
449 * For PrintNode printers this returns PrintNode's live vocabulary
450 * ('online'|'offline'|'unknown'), cached briefly. For polling printers
451 * (Star/Epson) it returns 'waiting' (never polled), 'connected' (polled
452 * within SEEN_TTL), or 'offline' (polled, but stale).
453 *
454 * @param string $printer_id Printer id.
455 *
456 * @return string
457 */
458 public function status_for( string $printer_id ): string {
459 $printer = $this->get_printer( $printer_id );
460 if ( null !== $printer ) {
461 $provider = Provider::normalize( \is_string( $printer['provider'] ?? null ) ? $printer['provider'] : null );
462 $adapter = Provider::adapter( $provider );
463 if ( null !== $adapter ) {
464 return $adapter->status(
465 $printer,
466 array(
467 'now' => time(),
468 'seen' => $this->get_seen( $printer_id ),
469 'seen_ttl' => self::SEEN_TTL,
470 'cache_ttl' => self::PN_STATUS_TTL,
471 'relay_status' => Provider::is_polling( $provider ) ? Cloud_Print_Relay_Service::status( $printer_id ) : null,
472 )
473 );
474 }
475 }
476
477 /*
478 * Shared fallthrough, kept from the pre-adapter implementation: a
479 * printer_id with a recorded poll but no registry row — or a row whose
480 * provider is unrecognised — is still reported from its last-seen
481 * timestamp, not as 'waiting'. Returning early here instead reported a
482 * recently-polled printer as never-seen.
483 * `test_status_connected_when_recently_seen` pins this.
484 */
485 $seen = $this->get_seen( $printer_id );
486 if ( 0 === $seen ) {
487 return 'waiting';
488 }
489
490 return ( time() - $seen ) <= self::SEEN_TTL ? 'connected' : 'offline';
491 }
492
493 /**
494 * The relay's block signal for a printer, when it reports one.
495 *
496 * Delegates to the relay service's transient-cached status, so this is
497 * safe to call in any order relative to status_for().
498 *
499 * @param string $printer_id Printer ID.
500 *
501 * @return string|null
502 */
503 public function status_detail_for( string $printer_id ): ?string {
504 return Cloud_Print_Relay_Service::status_detail( $printer_id );
505 }
506 }
507