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'] ?? '' ), ); } }