# woocommerce-pos/1.10.20/includes/Services/Settings/Cloud_Print_Section.php

WCPOS – Point of Sale (POS) plugin for WooCommerce, version 1.10.20. 436 lines.

- Page: https://pluginprobe.com/plugins/woocommerce-pos/1.10.20/code/includes/Services/Settings/Cloud_Print_Section.php
- Raw: https://pluginprobe.com/plugins/woocommerce-pos/1.10.20/raw/includes/Services/Settings/Cloud_Print_Section.php
- Modified: 2026-08-29T23:58:28+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/woocommerce-pos/1.10.20/code/includes/Services/Settings/Cloud_Print_Section.php#L10-L20`.

```php
<?php
/**
 * Cloud Print Settings Section.
 *
 * @package WCPOS\WooCommercePOS
 */

namespace WCPOS\WooCommercePOS\Services\Settings;

use WCPOS\WooCommercePOS\Services\Cloud_Print_Registry;
use WCPOS\WooCommercePOS\Services\Cloud_Print_Relay_Service;
use WCPOS\WooCommercePOS\Services\Cloud_Print_Trigger_Service;
use WCPOS\WooCommercePOS\Services\Print_Job_Service;
use WCPOS\WooCommercePOS\Services\Provider;
use WCPOS\WooCommercePOS\Services\Star_Online_Client;
use WP_Error;

/**
 * The Cloud Print Settings Section: printer rows and store assignments.
 *
 * Owns the per-provider schema (PrintNode, Star CloudPRNT, Star Online),
 * sanitization, secret redaction (poll_token_hash / printnode_api_key /
 * star_api_key), preserve-on-omitted-key write semantics, and poll-token
 * generation. Write is a full replacement of printers+assignments, not a
 * PATCH; the persisted option shape is frozen (no date_modified_gmt stamp).
 *
 * read()/write() are wholesale overrides: Abstract_Section's
 * migrate/compose/redact/sanitize template hooks, the
 * woocommerce_pos_cloud_print_settings filter, and the pre_save/saved hooks
 * do NOT run for this section. Registry replacement is the supported
 * override mechanism.
 */
class Cloud_Print_Section extends Abstract_Section {
	/**
	 * Secret printer fields stripped from every public view. Future
	 * providers that add secrets must list them here — redaction is a
	 * one-place change.
	 *
	 * @var string[]
	 */
	const SECRET_FIELDS = array( 'poll_token_hash', 'printnode_api_key', 'star_api_key' );
	/**
	 * Section id. Option name: woocommerce_pos_settings_cloud_print.
	 */
	public function id(): string {
		return 'cloud_print';
	}

	/**
	 * Section defaults.
	 */
	public function defaults(): array {
		return array(
			'printers'    => array(),
			'assignments' => array(),
		);
	}

	/**
	 * Read the cloud-print view: enrich printers with runtime status,
	 * last-seen, and encoding fields; strip secrets.
	 */
	public function read(): array {
		$settings = wp_parse_args( $this->read_raw(), $this->defaults() );

		$registry             = new Cloud_Print_Registry();
		$settings['printers'] = array_map(
			function ( $printer ) use ( $registry ) {
				if ( ! \is_array( $printer ) ) {
					return $printer;
				}
				$id                   = (string) ( $printer['id'] ?? '' );
				$seen                 = $registry->get_seen( $id );
				$printer              = $this->redact_printer( $printer );
				$printer['status']    = $registry->status_for( $id );
				$printer['last_seen'] = $seen > 0 ? $seen : null;
				if ( 'blocked' === $printer['status'] ) {
					$printer['status_detail'] = $registry->status_detail_for( $id );
				} else {
					unset( $printer['status_detail'] );
				}

				return $printer;
			},
			$settings['printers']
		);
		// Assignments saved before the copies/trigger fields existed have no
		// such keys stored; normalize on read so the REST view always carries
		// them. Legacy rows default to trigger=paid — receipts should not
		// print until the customer has paid unless the merchant opts in.
		$assignments             = \is_array( $settings['assignments'] ) ? $settings['assignments'] : array();
		$settings['assignments'] = array_map(
			function ( $assignment ) {
				if ( ! \is_array( $assignment ) ) {
					return $assignment;
				}
				$assignment['copies']  = min( 5, max( 1, (int) ( $assignment['copies'] ?? 1 ) ) );
				$assignment['trigger'] = Cloud_Print_Trigger_Service::normalize_trigger( $assignment['trigger'] ?? '' );

				return $assignment;
			},
			$assignments
		);
		$settings['relay'] = Cloud_Print_Relay_Service::public_state();
		// Read-only provider facts (currently: which template engines a
		// provider can render). The admin app mirrors this in its own table;
		// serving it makes the server the authority.
		$settings['providers'] = Provider::public_capabilities();

		return $settings;
	}

	/**
	 * Full replacement, not a merge: the incoming payload IS the write.
	 *
	 * Writes replace printers and assignments wholesale, and read() decorates
	 * the view with runtime-only fields (status, last_seen, relay) while
	 * stripping secrets. Merging that view into the payload would both write
	 * runtime fields into the option and resurrect printers the client just
	 * deleted, so this section opts out of the default array_replace_recursive
	 * PATCH strategy.
	 *
	 * @param array $existing Existing settings view.
	 * @param array $patch    Incoming payload.
	 *
	 * @return array
	 */
	public function merge( array $existing, array $patch ): array {
		return $patch;
	}

	/**
	 * Replace cloud-print settings (full replacement, not PATCH).
	 *
	 * @param array $settings Payload with printers/assignments arrays.
	 *
	 * @return array|WP_Error On success: printers (redacted), assignments,
	 *                        and generated poll tokens keyed by printer id.
	 */
	public function write( array $settings ) {
		$printers = isset( $settings['printers'] ) && \is_array( $settings['printers'] ) ? array_values( $settings['printers'] ) : array();
		$assigns  = isset( $settings['assignments'] ) && \is_array( $settings['assignments'] ) ? array_values( $settings['assignments'] ) : array();

		$existing        = $this->read_raw();
		$existing_hashes = array();
		$existing_keys      = array();
		$existing_star_keys = array();
		$existing_ids       = array();
		if ( isset( $existing['printers'] ) && \is_array( $existing['printers'] ) ) {
			foreach ( $existing['printers'] as $printer ) {
				if ( ! empty( $printer['id'] ) ) {
					$existing_ids[] = $printer['id'];
				}
				if ( ! empty( $printer['id'] ) && ! empty( $printer['poll_token_hash'] ) ) {
					$existing_hashes[ $printer['id'] ] = $printer['poll_token_hash'];
				}
				if ( ! empty( $printer['id'] ) && ! empty( $printer['printnode_api_key'] ) ) {
					$existing_keys[ $printer['id'] ] = $printer['printnode_api_key'];
				}
				if ( ! empty( $printer['id'] ) && ! empty( $printer['star_api_key'] ) ) {
					$existing_star_keys[ $printer['id'] ] = $printer['star_api_key'];
				}
			}
		}

		$generated      = array();
		$clean_printers = array();
		$seen_ids       = array();
		foreach ( $printers as $printer ) {
			$printer = $this->sanitize_printer( $printer );

			if ( '' === $printer['id'] ) {
				$printer['id'] = Cloud_Print_Registry::derive_id( $printer['name'], array_merge( $existing_ids, array_keys( $seen_ids ) ) );
			}
			$id = $printer['id'];

			// Preserve a previously stored PrintNode API key when the incoming
			// payload omits it (GET strips the key, so the React app re-POSTs
			// printers without it when toggling other fields). A non-empty
			// incoming key still overwrites, letting users rotate it.
			if ( 'printnode' === $printer['provider'] && '' === $printer['printnode_api_key'] && ! empty( $existing_keys[ $id ] ) ) {
				$printer['printnode_api_key'] = $existing_keys[ $id ];
			}

			if ( 'star-online' === $printer['provider'] && '' === $printer['star_api_key'] && ! empty( $existing_star_keys[ $id ] ) ) {
				$printer['star_api_key'] = $existing_star_keys[ $id ];
			}

			if ( 'star-online' === $printer['provider'] ) {
				$api_base = Star_Online_Client::api_base_from_cloudprnt_url( (string) $printer['star_cloudprnt_url'] );
				$group    = Star_Online_Client::group_from_cloudprnt_url( (string) $printer['star_cloudprnt_url'] );
				if ( '' === $printer['star_api_key'] || null === $api_base || '' === $group || '' === $printer['star_device_id'] ) {
					return new WP_Error(
						'wcpos_cloud_print_star_online_invalid',
						__( 'Star Online printers need an API key, a valid stario.online CloudPRNT URL, and a device.', 'woocommerce-pos' ),
						array( 'status' => 400 )
					);
				}
			}

			if ( isset( $seen_ids[ $id ] ) ) {
				return new WP_Error(
					'wcpos_cloud_print_duplicate_printer_id',
					__( 'Duplicate printer id.', 'woocommerce-pos' ),
					array( 'status' => 400 )
				);
			}
			$seen_ids[ $id ] = true;

			$regenerate = ! empty( $printer['regenerate_token'] );
			unset( $printer['regenerate_token'] );

			if ( Provider::is_polling( $printer['provider'] ) ) {
				if ( $regenerate || empty( $existing_hashes[ $id ] ) ) {
					$token                      = Cloud_Print_Registry::generate_token();
					$printer['poll_token_hash'] = Cloud_Print_Registry::hash_token( $token );
					$generated[ $id ]           = $token;
				} else {
					$printer['poll_token_hash'] = $existing_hashes[ $id ];
				}
			}

			$clean_printers[] = $printer;
		}

		$clean = array(
			'printers'    => $clean_printers,
			'assignments' => array_map( array( $this, 'sanitize_assignment' ), $assigns ),
		);

		$clean['assignments'] = $this->clear_unrenderable_templates( $clean['assignments'], $clean_printers );

		update_option( $this->option_name(), $clean );

		// Drop per-printer runtime state for printers that were removed, so a
		// reused id cannot inherit a deleted printer's status or capabilities.
		$registry = new Cloud_Print_Registry();
		$registry->prune_seen( array_keys( $seen_ids ) );
		$registry->prune_capabilities( array_keys( $seen_ids ) );

		$response_printers = array_map(
			function ( $printer ) {
				return $this->redact_printer( $printer );
			},
			$clean_printers
		);

		return array(
			'printers'    => $response_printers,
			'assignments' => $clean['assignments'],
			'generated'   => $generated,
			// Repeated from the GET shape: the admin app rebuilds its cache
			// from this response, so a field served only on GET would be
			// dropped by the first save.
			'providers'   => Provider::public_capabilities(),
		);
	}

	/**
	 * Sanitize a cloud printer entry.
	 *
	 * @param mixed $printer Printer.
	 */
	private function sanitize_printer( $printer ): array {
		$printer  = \is_array( $printer ) ? $printer : array();
		$provider = Provider::normalize( \is_string( $printer['provider'] ?? null ) ? $printer['provider'] : null );

		$clean = array(
			'id'               => sanitize_text_field( $printer['id'] ?? '' ),
			'name'             => sanitize_text_field( $printer['name'] ?? '' ),
			'provider'         => $provider,
			'store_id'         => isset( $printer['store_id'] ) ? (int) $printer['store_id'] : 0,
			'regenerate_token' => ! empty( $printer['regenerate_token'] ),
		);
		if ( 'printnode' === $provider ) {
			$clean['printnode_api_key']    = sanitize_text_field( $printer['printnode_api_key'] ?? '' );
			$clean['printnode_printer_id'] = isset( $printer['printnode_printer_id'] ) ? (int) $printer['printnode_printer_id'] : 0;
			$clean['printnode_format']     = \in_array( $printer['printnode_format'] ?? '', array( 'pdf', 'raw' ), true )
				? $printer['printnode_format'] : 'pdf';
		}
		if ( 'star-cloudprnt' === $provider ) {
			$encoding_fields = array_intersect_key(
				$printer,
				array_flip( array( 'columns', 'language', 'autoCut', 'fullReceiptRaster' ) )
			);
			$clean           = $this->with_encoding_fields(
				array_merge( $clean, $encoding_fields )
			);
		}
		if ( 'star-online' === $provider ) {
			$clean['star_api_key']       = sanitize_text_field( $printer['star_api_key'] ?? '' );
			$clean['star_cloudprnt_url'] = esc_url_raw( $printer['star_cloudprnt_url'] ?? '' );
			$clean['star_device_id']     = sanitize_text_field( $printer['star_device_id'] ?? '' );
			$clean['star_client_type']   = sanitize_text_field( $printer['star_client_type'] ?? '' );
		}

		return $clean;
	}

	/**
	 * Public printer view: server-owned encoding fields added, secrets
	 * stripped.
	 *
	 * @param array $printer Printer row.
	 *
	 * @return array
	 */
	private function redact_printer( array $printer ): array {
		$printer = $this->with_encoding_fields( $printer );
		foreach ( self::SECRET_FIELDS as $field ) {
			unset( $printer[ $field ] );
		}

		return $printer;
	}

	/**
	 * Add server-owned client encoding fields for Star CloudPRNT printers.
	 *
	 * These fields let POS clients synthesize read-only cloud printer targets
	 * without guessing how to render raw payloads before CloudPRNT delivery.
	 *
	 * @param array $printer Printer row.
	 */
	private function with_encoding_fields( array $printer ): array {
		if ( 'star-cloudprnt' !== ( $printer['provider'] ?? '' ) ) {
			return $printer;
		}

		// No Star CloudPRNT printer decodes ESC/POS (the TSP100IV prints the
		// command bytes as literal text), so 'esc-pos' is not an accepted
		// value — including rows where earlier releases materialized it into
		// the stored option as the old default. Only 'star-line' survives as
		// an explicit choice, for Line Mode-only models (TSP650II et al.).
		// There is no admin toggle; sites that need a different fallback set
		// it in code via the woocommerce_pos_cloud_printer_default_language filter.
		$accepted = array( 'star-prnt', 'star-line' );
		$language = $printer['language'] ?? '';
		if ( ! \in_array( $language, $accepted, true ) ) {
			$language = (string) apply_filters( 'woocommerce_pos_cloud_printer_default_language', 'star-prnt', $printer );
			if ( ! \in_array( $language, $accepted, true ) ) {
				$language = 'star-prnt';
			}
		}
		$columns  = isset( $printer['columns'] ) ? (int) $printer['columns'] : 42;
		if ( ! \in_array( $columns, array( 32, 42, 48 ), true ) ) {
			$columns = 42;
		}

		$printer['columns']           = $columns;
		$printer['language']          = $language;
		$printer['autoCut']           = array_key_exists( 'autoCut', $printer ) ? rest_sanitize_boolean( $printer['autoCut'] ) : true;
		$printer['fullReceiptRaster'] = array_key_exists( 'fullReceiptRaster', $printer ) ? rest_sanitize_boolean( $printer['fullReceiptRaster'] ) : false;

		return $printer;
	}

	/**
	 * Blank any assignment whose template its printer cannot render.
	 *
	 * A provider that declares a single template engine (Epson SDP and the Star
	 * providers all speak only 'thermal') renders nothing for any other engine,
	 * so the pairing has to be dealt with here rather than discovered as a
	 * receipt that never prints.
	 *
	 * Refusing the whole save was worse than the bug: one unrenderable row —
	 * including one the admin cannot see, because the template picker filters
	 * by engine and so cannot display the stored value — blocked every
	 * subsequent settings write and silently reverted the screen. Clearing the
	 * template instead always saves, and leaves the rule visibly incomplete:
	 * Cloud_Print_Trigger_Service skips assignments with no template, so the
	 * rule stops firing rather than queuing jobs that print nothing.
	 *
	 * Only resolvable templates are judged. A template_id that is empty or no
	 * longer exists cannot be classified and is left alone.
	 *
	 * @param array $assignments Sanitized assignments.
	 * @param array $printers    Sanitized printers being written.
	 *
	 * @return array Assignments with unrenderable templates cleared.
	 */
	private function clear_unrenderable_templates( array $assignments, array $printers ): array {
		$providers = array();
		foreach ( $printers as $printer ) {
			if ( ! empty( $printer['id'] ) ) {
				$providers[ $printer['id'] ] = (string) ( $printer['provider'] ?? '' );
			}
		}

		return array_map(
			function ( array $assignment ) use ( $providers ): array {
				$printer_id  = (string) ( $assignment['printer_id'] ?? '' );
				$template_id = (string) ( $assignment['template_id'] ?? '' );
				if ( '' === $template_id || ! isset( $providers[ $printer_id ] ) ) {
					return $assignment;
				}

				$supported = Provider::template_engines( $providers[ $printer_id ] );
				if ( 'all' === $supported ) {
					return $assignment;
				}

				$template = Print_Job_Service::load_template( $template_id );
				if ( null === $template ) {
					return $assignment;
				}

				if ( (string) ( $template['engine'] ?? '' ) !== $supported ) {
					$assignment['template_id'] = '';
				}

				return $assignment;
			},
			$assignments
		);
	}

	/**
	 * Sanitize a cloud assignment entry.
	 *
	 * @param mixed $assignment Assignment.
	 */
	public function sanitize_assignment( $assignment ): array {
		$assignment = \is_array( $assignment ) ? $assignment : array();

		return array(
			'printer_id'  => sanitize_text_field( $assignment['printer_id'] ?? '' ),
			'store_id'    => isset( $assignment['store_id'] ) ? (int) $assignment['store_id'] : 0,
			'scope'       => \in_array( $assignment['scope'] ?? '', array( 'every', 'pos', 'online' ), true ) ? $assignment['scope'] : 'every',
			'template_id' => sanitize_text_field( (string) ( $assignment['template_id'] ?? '' ) ),
			'copies'      => min( 5, max( 1, (int) ( $assignment['copies'] ?? 1 ) ) ),
			'trigger'     => Cloud_Print_Trigger_Service::normalize_trigger( $assignment['trigger'] ?? '' ),
		);
	}
}

```
