array( 'png', 'image/png' ), IMAGETYPE_JPEG => array( 'jpg', 'image/jpeg' ), IMAGETYPE_WEBP => array( 'webp', 'image/webp' ), ); private const CLEANUP_HOOK = STORED['cron_hooks']['cleanup_designs']; /** Largest preview a browser makes is the stage at 2x; anything bigger is not a preview. */ private const MAX_PREVIEW_SIDE = 4096; private const MAX_PREVIEW_PIXELS = 16 * 1000 * 1000; /** Default design saves an hour: per shopper session, and per IP (shops behind a proxy see one IP for everyone). */ private const SAVES_PER_SESSION = 30; private const SAVES_PER_IP = 300; /** @var int[]|null order_ids() for this request, until a design is ordered or deleted. */ private static $order_ids = null; public static function register(): void { add_action( 'init', array( __CLASS__, 'register_post_type' ) ); add_action( 'rest_api_init', array( __CLASS__, 'register_route' ) ); add_filter( 'rest_authentication_errors', array( __CLASS__, 'authenticate_route' ) ); add_action( 'admin_post_printblob_print_file', array( __CLASS__, 'download_print_file' ) ); add_action( self::CLEANUP_HOOK, array( __CLASS__, 'cleanup' ), 10, 0 ); add_action( self::CLEANUP_HOOK, array( __CLASS__, 'mark_unprinted' ), 10, 0 ); add_filter( 'site_status_tests', array( __CLASS__, 'site_health_test' ) ); add_action( 'init', static function () { if ( ! wp_next_scheduled( self::CLEANUP_HOOK ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'twicedaily', self::CLEANUP_HOOK ); } } ); } public static function register_post_type(): void { register_post_type( self::POST_TYPE, array( 'label' => __( 'Customer designs', 'printblob' ), 'public' => false, 'show_ui' => false, 'show_in_rest' => false, 'rewrite' => false, 'query_var' => false, 'supports' => array( 'title' ), 'capability_type' => 'post', ) ); } public static function register_route(): void { register_rest_route( self::REST_NAMESPACE, self::REST_ROUTE, array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( __CLASS__, 'create_from_request' ), 'permission_callback' => array( __CLASS__, 'verify_store_nonce' ), 'args' => array( 'product_id' => array( 'type' => 'integer', 'required' => true, 'minimum' => 1, ), 'design' => array( 'type' => 'string', 'required' => true, ), ), ) ); } /** * The designs route is authenticated as WooCommerce's Store API is (StoreApi\Authentication::check_authentication(), * WooCommerce 11): the shopper's cookies, whoever they are, with the `wc_store_api` nonce checked by * verify_store_nonce(). Without this, WordPress's cookie check runs a signed-in shopper's request as a visitor * (there is no `wp_rest` nonce), while the Store API made the nonce for the signed-in user: the save was refused as * an expired session for anyone signed in (17 Oct 2026). * * @param \WP_Error|mixed $result An earlier authentication's answer, null when nothing has answered. * @return \WP_Error|mixed */ public static function authenticate_route( $result ) { if ( ! empty( $result ) ) { return $result; } $route = (string) ( $GLOBALS['wp']->query_vars['rest_route'] ?? '' ); return '/' . self::REST_NAMESPACE . self::REST_ROUTE === untrailingslashit( $route ) ? true : $result; } /** * Same check the Store API applies to cart writes: a `Nonce` header for the `wc_store_api` action, * verified against the shopper's WooCommerce session. */ public static function verify_store_nonce( \WP_REST_Request $request ) { wc_load_cart(); $nonce = $request->get_header( 'Nonce' ); if ( ! $nonce || ! wp_verify_nonce( $nonce, 'wc_store_api' ) ) { return new \WP_Error( 'printblob_invalid_nonce', __( 'Your session expired. Reload the page and try again.', 'printblob' ), array( 'status' => 403 ) ); } return true; } public static function owner_id(): string { return WC()->session ? (string) WC()->session->get_customer_id() : ''; } public static function create_from_request( \WP_REST_Request $request ) { $product = wc_get_product( (int) $request['product_id'] ); if ( ! $product || ! Product_Settings::is_enabled( $product ) ) { return new \WP_Error( 'printblob_bad_product', __( 'This product cannot be designed.', 'printblob' ), array( 'status' => 400 ) ); } if ( ! self::allow_save( self::owner_id(), self::client_ip() ) ) { return new \WP_Error( 'printblob_too_many_designs', __( 'Too many designs saved in a short time. Wait a few minutes and try again.', 'printblob' ), array( 'status' => 429 ) ); } $json = (string) $request['design']; if ( strlen( $json ) > self::MAX_JSON || null === json_decode( $json, true ) ) { return new \WP_Error( 'printblob_bad_design', __( 'The design data could not be read.', 'printblob' ), array( 'status' => 400 ) ); } $files = $request->get_file_params(); $max_uploads = (int) Designer_Data::numbers()['max_uploads']; if ( count( preg_grep( '/_upload_\d+$/', array_keys( $files ) ) ) > $max_uploads ) { /* translators: %d: how many images a design can keep. */ return new \WP_Error( 'printblob_too_many_uploads', sprintf( __( 'A design can keep at most %d of your images. Remove some and try again.', 'printblob' ), $max_uploads ), array( 'status' => 400 ) ); } $sides = array(); foreach ( Mockups::sides_for( $product ) as $side => $spec ) { if ( empty( $files[ "{$side}_preview" ] ) ) { continue; } $uploads = array(); for ( $index = 0; isset( $files[ "{$side}_upload_{$index}" ] ); $index++ ) { $upload = self::read_upload( $files[ "{$side}_upload_{$index}" ] ); if ( is_wp_error( $upload ) ) { return $upload; } $uploads[] = $upload; } $preview = self::read_png( $files[ "{$side}_preview" ], self::MAX_PREVIEW ); if ( is_wp_error( $preview ) ) { return $preview; } if ( ! self::preview_size_ok( $preview['width'], $preview['height'] ) ) { return new \WP_Error( 'printblob_bad_preview', __( 'The design preview has the wrong size.', 'printblob' ), array( 'status' => 400 ) ); } $print = null; if ( ! empty( $files[ "{$side}_print" ] ) ) { $print = self::read_png( $files[ "{$side}_print" ], self::MAX_PRINT ); $print = is_wp_error( $print ) ? $print : self::check_print_size( $print, $spec ); if ( is_wp_error( $print ) ) { return $print; } } $sides[ $side ] = array( 'print' => $print, 'preview' => $preview, 'uploads' => $uploads, ); } if ( ! $sides ) { return new \WP_Error( 'printblob_empty_design', __( 'Add artwork or text to the front or back first.', 'printblob' ), array( 'status' => 400 ) ); } $token = wp_generate_uuid4(); $post_id = wp_insert_post( array( 'post_type' => self::POST_TYPE, 'post_status' => 'private', 'post_name' => $token, 'post_title' => sprintf( 'Design %s', $token ), ), true ); if ( is_wp_error( $post_id ) ) { return $post_id; } $saved = array(); foreach ( $sides as $side => $data ) { $preview_id = self::save_preview( $post_id, $token, $side, $data['preview']['tmp'] ); $print_path = is_wp_error( $preview_id ) || ! $data['print'] ? '' : self::save_print( $token, $side, $data['print']['file'], 'wp_handle_upload' ); if ( is_wp_error( $preview_id ) || is_wp_error( $print_path ) ) { // Everything already written for this design goes with it, this side's preview included. if ( ! is_wp_error( $preview_id ) ) { $saved[ $side ] = array( 'preview_id' => $preview_id, 'print_path' => '', ); } update_post_meta( $post_id, self::META_SIDES, $saved ); self::delete( $post_id ); return is_wp_error( $preview_id ) ? $preview_id : $print_path; } $saved[ $side ] = array( 'preview_id' => $preview_id, 'print_path' => $print_path, ); foreach ( $data['uploads'] as $index => $upload ) { $path = self::save_private( $token, "{$side}-upload-{$index}", $upload['file'], array( $upload['extension'] => $upload['type'] ), 'wp_handle_upload' ); if ( is_wp_error( $path ) ) { update_post_meta( $post_id, self::META_SIDES, $saved ); self::delete( $post_id ); return $path; } $saved[ $side ]['uploads'][] = $path; } } // A guest's first action may be saving a design: start their WooCommerce session now so the // design and the cart that follows belong to the same shopper. WC()->session->set_customer_session_cookie( true ); update_post_meta( $post_id, self::META_OWNER, self::owner_id() ); update_post_meta( $post_id, self::META_PRODUCT, $product->get_id() ); update_post_meta( $post_id, self::META_SIDES, $saved ); update_post_meta( $post_id, self::META_JSON, wp_slash( $json ) ); return rest_ensure_response( array( 'token' => $token, 'sides' => array_keys( $saved ), ) ); } /** Whether a preview's pixel size is one a browser makes (checked from the header, before decoding it). */ public static function preview_size_ok( int $width, int $height ): bool { return $width > 0 && $height > 0 && $width <= self::MAX_PREVIEW_SIDE && $height <= self::MAX_PREVIEW_SIDE && $width * $height <= self::MAX_PREVIEW_PIXELS; } /** The visitor's IP as the server sees it (a proxy's, when the site runs behind one). */ private static function client_ip(): string { return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; } /** * Counts a design save for the shopper's session and IP, and says whether it is within the hourly limits. * Saving a design stores files on the shop's server, so it is limited even though anyone may do it. */ public static function allow_save( string $session, string $ip ): bool { /** * Filters how many designs may be saved an hour, per shopper session and per IP. * * @param array{session:int, ip:int} $limits */ $limits = (array) apply_filters( 'printblob_design_save_limits', array( 'session' => self::SAVES_PER_SESSION, 'ip' => self::SAVES_PER_IP, ) ); $counts = array(); foreach ( array( 'session' => $session, 'ip' => $ip, ) as $kind => $who ) { if ( '' === $who ) { continue; } $key = STORED['transient_prefixes']['saves'] . $kind . '_' . md5( $who ) . '_' . gmdate( 'YmdH' ); $counts[ $key ] = array( (int) get_transient( $key ) + 1, (int) ( $limits[ $kind ] ?? 0 ) ); } foreach ( $counts as list( $count, $limit ) ) { if ( $limit > 0 && $count > $limit ) { return false; } } foreach ( $counts as $key => list( $count ) ) { set_transient( $key, $count, HOUR_IN_SECONDS ); } return true; } /** * @param array|null $file Uploaded file entry from $_FILES, or a file of this server's in the same shape. * @param int $max_bytes The largest it may be. * @param bool $uploaded Whether it must have come with this request. * @return array{file:array,tmp:string,width:int,height:int}|\WP_Error */ private static function read_png( $file, int $max_bytes, bool $uploaded = true ) { $missing = self::missing_file( $file, $uploaded ); if ( $missing ) { return $missing; } if ( (int) $file['size'] > $max_bytes ) { return new \WP_Error( 'printblob_file_too_large', __( 'The design is too large to upload.', 'printblob' ), array( 'status' => 413 ) ); } $info = getimagesize( $file['tmp_name'] ); if ( ! $info || IMAGETYPE_PNG !== $info[2] ) { return new \WP_Error( 'printblob_not_png', __( 'The design file is not a valid image.', 'printblob' ), array( 'status' => 400 ) ); } return array( 'file' => $file, 'tmp' => $file['tmp_name'], 'width' => (int) $info[0], 'height' => (int) $info[1], ); } /** * Why a file of the request cannot be read, or null when it can: a file over PHP's own size limit * (upload_max_filesize) is refused as too large, since sending it again cannot help. * * @param array|null $file Uploaded file entry from $_FILES, or a file of this server's in the same shape. * @param bool $uploaded Whether it must have come with this request. */ private static function missing_file( $file, bool $uploaded ): ?\WP_Error { $error = (int) ( $file['error'] ?? 0 ); if ( UPLOAD_ERR_INI_SIZE === $error || UPLOAD_ERR_FORM_SIZE === $error ) { return new \WP_Error( 'printblob_file_too_large', __( 'Part of the design is larger than this shop’s server accepts. Use smaller images.', 'printblob' ), array( 'status' => 413 ) ); } if ( empty( $file ) || $error || empty( $file['tmp_name'] ) || ! ( $uploaded ? is_uploaded_file( $file['tmp_name'] ) : is_readable( $file['tmp_name'] ) ) ) { return new \WP_Error( 'printblob_missing_file', __( 'Part of the design did not upload. Try again.', 'printblob' ), array( 'status' => 400 ) ); } return null; } /** * A print file read by read_png(), if it has the side's print size in pixels (a pixel of rounding either way). * * @param array $png read_png()'s answer. * @param array $spec The side, as Mockups::sides_for() gives it. * @return array|\WP_Error */ private static function check_print_size( array $png, array $spec ) { if ( abs( $png['width'] - (int) $spec['printPx']['width'] ) > 2 || abs( $png['height'] - (int) $spec['printPx']['height'] ) > 2 ) { return new \WP_Error( 'printblob_bad_print', __( 'The print file has the wrong size.', 'printblob' ), array( 'status' => 400 ) ); } return $png; } /** * One of the customer's own images, as they uploaded it: PNG, JPEG or WebP, within the shop's upload limit. * * @param array|null $file Uploaded file entry from $_FILES. * @return array{file:array,extension:string,type:string}|\WP_Error */ private static function read_upload( $file ) { $missing = self::missing_file( $file, true ); if ( $missing ) { return $missing; } if ( (int) $file['size'] > Settings::max_upload_bytes() ) { return new \WP_Error( 'printblob_file_too_large', __( 'One of your images is larger than this shop accepts.', 'printblob' ), array( 'status' => 413 ) ); } $info = getimagesize( $file['tmp_name'] ); if ( ! $info || ! isset( self::UPLOAD_TYPES[ $info[2] ] ) ) { return new \WP_Error( 'printblob_bad_upload', __( 'Use PNG, JPG or WebP images.', 'printblob' ), array( 'status' => 400 ) ); } list( $extension, $type ) = self::UPLOAD_TYPES[ $info[2] ]; return array( 'file' => $file, 'extension' => $extension, 'type' => $type, ); } private static function save_preview( int $post_id, string $token, string $side, string $tmp ) { require_once ABSPATH . 'wp-admin/includes/image.php'; $upload = wp_upload_bits( "printblob-{$token}-{$side}.png", null, (string) file_get_contents( $tmp ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents if ( ! empty( $upload['error'] ) ) { return new \WP_Error( 'printblob_save_failed', $upload['error'], array( 'status' => 500 ) ); } $attachment_id = wp_insert_attachment( array( 'post_mime_type' => 'image/png', /* translators: %s: printed side, e.g. Front. */ 'post_title' => sprintf( __( 'Design, %s', 'printblob' ), Product_Settings::side_label( $side ) ), 'post_status' => 'inherit', ), $upload['file'], $post_id, true ); if ( is_wp_error( $attachment_id ) ) { return $attachment_id; } wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $upload['file'] ) ); return $attachment_id; } public static function print_root(): string { $uploads = wp_get_upload_dir(); return trailingslashit( $uploads['basedir'] ) . STORED['print_folder']; } /** * The private print folder, created if needed and kept out of directory listings and direct web access * (its .htaccess, for Apache; Site Health checks the folder on servers that ignore it). Returns its path. */ public static function ensure_print_root(): string { $root = self::print_root(); if ( ! is_dir( $root ) ) { wp_mkdir_p( $root ); } foreach ( array( 'index.php' => " "Require all denied\n", ) as $name => $content ) { if ( ! file_exists( $root . '/' . $name ) ) { file_put_contents( $root . '/' . $name, $content ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents } } return $root; } /** * Stores an uploaded print file in the private print folder. * * @param string $token The design's token. * @param string $side The side. * @param array $file Uploaded file entry from $_FILES, or a file of this server's in the same shape. * @param string $handler 'wp_handle_upload' for a file that came with the request, 'wp_handle_sideload' for one of this server's. * @return string|\WP_Error Path relative to the print folder. */ private static function save_print( string $token, string $side, array $file, string $handler ) { return self::save_private( $token, $side, $file, array( 'png' => 'image/png' ), $handler ); } /** * Stores an uploaded file in the private print folder, in a folder of its own, through WordPress's upload * handling pointed at that folder for this one file. * * @param string $token The design's token. * @param string $name The file's name, without its extension. * @param array $file Uploaded file entry from $_FILES, or a file of this server's in the same shape. * @param string[] $mimes The one type it may be: extension => MIME type. * @param string $handler 'wp_handle_upload' or 'wp_handle_sideload': named, not passed as a callable, because * wp-admin/includes/file.php, which defines both, is loaded only here (a REST request has not loaded it). * @return string|\WP_Error Path relative to the print folder. */ private static function save_private( string $token, string $name, array $file, array $mimes, string $handler ) { require_once ABSPATH . 'wp-admin/includes/file.php'; $root = self::ensure_print_root(); $folder = $token . '-' . wp_generate_password( 20, false ); $redirect = static function ( array $dirs ) use ( $root, $folder ): array { $dirs['basedir'] = $root; $dirs['baseurl'] = ''; $dirs['subdir'] = '/' . $folder; $dirs['path'] = $root . '/' . $folder; $dirs['url'] = ''; return $dirs; }; $file['name'] = $name . '.' . (string) array_key_first( $mimes ); add_filter( 'upload_dir', $redirect ); $overrides = array( 'test_form' => false, 'mimes' => $mimes, 'unique_filename_callback' => static fn( $dir, $filename, $ext ) => $name . $ext, ); $result = $handler( $file, $overrides ); remove_filter( 'upload_dir', $redirect ); if ( ! empty( $result['error'] ) || empty( $result['file'] ) ) { return new \WP_Error( 'printblob_save_failed', __( 'Your design could not be saved. Try again.', 'printblob' ), array( 'status' => 500 ) ); } return $folder . '/' . basename( $result['file'] ); } public static function find_by_token( string $token ): ?\WP_Post { return self::designs_by_token( array( $token ) )[ $token ] ?? null; } /** * Designs by their tokens, in one query that reads their meta with them. * * @param string[] $tokens Tokens; any that is not a design's is left out. * @return array */ public static function designs_by_token( array $tokens ): array { $tokens = array_values( array_unique( array_filter( array_map( 'strval', $tokens ), static fn( string $token ): bool => wp_is_uuid( $token, 4 ) ) ) ); if ( ! $tokens ) { return array(); } $posts = get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => 'private', 'post_name__in' => $tokens, 'numberposts' => count( $tokens ), ) ); return array_column( $posts, null, 'post_name' ); } /** * The design tokens of an order's items. * * @param \WC_Order $order The order. * @return array Item ID => token. */ public static function order_tokens( \WC_Order $order ): array { $tokens = array(); foreach ( $order->get_items() as $item_id => $item ) { $token = $item instanceof \WC_Order_Item_Product ? (string) $item->get_meta( Order::ITEM_META ) : ''; if ( '' !== $token ) { $tokens[ (int) $item_id ] = $token; } } return $tokens; } /** @return array */ public static function sides( int $design_id ): array { $sides = get_post_meta( $design_id, self::META_SIDES, true ); return is_array( $sides ) ? $sides : array(); } /** The saved design: its colour, size, options and each side's serialized canvas, its images' sources left out (compactDesign() in the designer). */ public static function design_json( int $design_id ): array { $design = json_decode( (string) get_post_meta( $design_id, self::META_JSON, true ), true ); return is_array( $design ) ? $design : array(); } /** An absolute path of the print folder's, if `$relative` names a file inside it that exists. */ private static function private_path( string $relative ): string { return '' !== $relative ? self::inside_print_root( self::print_root() . '/' . $relative ) : ''; } /** `$path` resolved, if it is a readable file inside the print folder; '' otherwise. */ private static function inside_print_root( string $path ): string { $root = realpath( self::print_root() ); $real = realpath( $path ); return $root && $real && str_starts_with( $real, $root . DIRECTORY_SEPARATOR ) && is_readable( $real ) ? $real : ''; } /** The absolute path of a side's print file, or '' when it has none (or its path leads outside the print folder). */ public static function print_file_path( int $design_id, string $side ): string { return self::private_path( (string) ( self::sides( $design_id )[ $side ]['print_path'] ?? '' ) ); } public static function has_print_file( int $design_id, string $side ): bool { return '' !== self::print_file_path( $design_id, $side ); } /** The product a design was made on, if it still exists. */ public static function product_of( int $design_id ): ?\WC_Product { $product = wc_get_product( (int) get_post_meta( $design_id, self::META_PRODUCT, true ) ); return $product instanceof \WC_Product ? $product : null; } /** A side of the design's product, as Mockups::sides_for() gives it, or null when the product no longer has it. */ public static function side_spec( int $design_id, string $side ): ?array { $product = self::product_of( $design_id ); return $product ? ( Mockups::sides_for( $product )[ $side ] ?? null ) : null; } /** The order a design was ordered with, 0 while it is in no order. */ public static function order_id( int $design_id ): int { return (int) get_post_meta( $design_id, self::META_ORDER, true ); } /** * The customer's images on a side, as they uploaded them, keyed by the number the saved design gives each * (`printblobUpload`); an original whose file is missing has no key. * * @return array Absolute paths. */ public static function upload_paths( int $design_id, string $side ): array { return array_filter( array_map( static fn( $upload ): string => self::private_path( (string) $upload ), (array) ( self::sides( $design_id )[ $side ]['uploads'] ?? array() ) ) ); } /** * Stores a side's print file, made on this server or sent by the shop manager's browser, in the private print folder, * in place of the one the side had. `$tmp_file` is moved there. * * @param int $design_id The design. * @param string $side One of its designed sides. * @param string $tmp_file A PNG of the side's print size in pixels. * @return true|\WP_Error */ public static function add_print_file( int $design_id, string $side, string $tmp_file ) { $sides = self::sides( $design_id ); $spec = self::side_spec( $design_id, $side ); if ( self::POST_TYPE !== get_post_type( $design_id ) || ! isset( $sides[ $side ] ) || ! $spec ) { return new \WP_Error( 'printblob_bad_side', __( 'This design has no such printed side.', 'printblob' ), array( 'status' => 400 ) ); } $file = array( 'name' => $side . '.png', 'tmp_name' => $tmp_file, 'size' => is_readable( $tmp_file ) ? (int) filesize( $tmp_file ) : 0, 'error' => 0, ); $print = self::read_png( $file, self::MAX_PRINT, false ); $print = is_wp_error( $print ) ? $print : self::check_print_size( $print, $spec ); if ( is_wp_error( $print ) ) { return $print; } $path = self::save_print( (string) get_post_field( 'post_name', $design_id ), $side, $file, 'wp_handle_sideload' ); if ( is_wp_error( $path ) ) { return $path; } $previous = (string) ( $sides[ $side ]['print_path'] ?? '' ); $sides[ $side ]['print_path'] = $path; update_post_meta( $design_id, self::META_SIDES, $sides ); if ( '' !== $previous ) { self::delete_print_folder( $previous ); } if ( self::order_id( $design_id ) ) { self::note_unprinted( $design_id ); } return true; } /** * Ordered designs with a designed side that has no print file, newest first: those marked so (META_UNPRINTED), after * marking a few seconds' worth of designs ordered before the mark existed. * * @param int $limit At most this many. * @return int[] Design IDs. */ public static function designs_without_print_files( int $limit ): array { self::mark_unprinted( 5 ); return array_map( 'intval', get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => 'private', 'numberposts' => $limit, 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'DESC', 'meta_key' => self::META_UNPRINTED, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- One indexed key, at most $limit rows. 'meta_value' => '1', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- The rows of that key that are marked. ) ) ); } /** Records on an ordered design whether one of its sides has no print file (META_UNPRINTED). */ private static function note_unprinted( int $design_id ): void { update_post_meta( $design_id, self::META_UNPRINTED, self::missing_print_files( $design_id ) > 0 ? '1' : '0' ); } /** * Marks ordered designs that have no META_UNPRINTED yet (ordered before PrintBlob 1.4.1), in batches until none * is left or the run has taken `$seconds`. Returns how many were marked. */ public static function mark_unprinted( int $seconds = 60 ): int { $until = microtime( true ) + $seconds; $marked = 0; do { $ids = get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => 'private', 'numberposts' => 100, 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'DESC', 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Ordered designs not marked yet: none once the designs ordered before the mark have been marked. array( 'key' => self::META_ORDER, 'compare' => 'EXISTS', ), array( 'key' => self::META_UNPRINTED, 'compare' => 'NOT EXISTS', ), ), ) ); update_meta_cache( 'post', $ids ); foreach ( $ids as $id ) { self::note_unprinted( (int) $id ); ++$marked; } } while ( $ids && microtime( true ) < $until ); return $marked; } /** How many of a design's sides have no print file. */ public static function missing_print_files( int $design_id ): int { return count( array_filter( array_keys( self::sides( $design_id ) ), static fn( $side ): bool => ! self::has_print_file( $design_id, (string) $side ) ) ); } /** * The name a download is saved under: its order, its order item (an order with several designed items gets * a name per item, as PrintBlob Pro's print queue ZIP names them), its side, and for a customer's image its * number on that side, from 1. */ public static function download_name( int $order_id, int $item_id, string $side, ?int $image = null, string $extension = 'png' ): string { $name = 'order-' . $order_id . ( $item_id ? '-item-' . $item_id : '' ) . '-' . $side; return null === $image ? $name . '-print.png' : $name . '-image-' . ( $image + 1 ) . '.' . $extension; } /** Raw (unescaped) URL; escape with esc_url() where it is printed. */ public static function print_download_url( int $design_id, string $side, int $item_id = 0 ): string { return add_query_arg( array( 'action' => 'printblob_print_file', 'design' => $design_id, 'side' => $side, 'item' => $item_id, '_wpnonce' => wp_create_nonce( "printblob_print_{$design_id}_{$side}_{$item_id}" ), ), admin_url( 'admin-post.php' ) ); } public static function download_print_file(): void { $design_id = isset( $_GET['design'] ) ? absint( $_GET['design'] ) : 0; $side = isset( $_GET['side'] ) ? sanitize_key( wp_unslash( $_GET['side'] ) ) : ''; $item_id = isset( $_GET['item'] ) ? absint( $_GET['item'] ) : 0; check_admin_referer( "printblob_print_{$design_id}_{$side}_{$item_id}" ); if ( ! current_user_can( 'edit_shop_orders' ) ) { wp_die( esc_html__( 'You are not allowed to download print files.', 'printblob' ), 403 ); } $order_id = self::order_id( $design_id ); self::serve( self::print_file_path( $design_id, $side ), self::download_name( $order_id ? $order_id : $design_id, $item_id, $side ) ); } /** * Sends a file of the private print folder as a download, only from inside that folder, and ends the request. * * @param string $path An absolute path, as print_file_path() and upload_paths() give them. * @param string $name The name it is saved under. */ public static function serve( string $path, string $name ): void { $path = '' !== $path ? self::inside_print_root( $path ) : ''; if ( '' === $path ) { wp_die( esc_html__( 'This print file does not exist.', 'printblob' ), 404 ); } $type = wp_check_filetype( $path )['type']; nocache_headers(); header( 'Content-Type: ' . ( $type ? $type : 'application/octet-stream' ) ); header( 'Content-Length: ' . filesize( $path ) ); header( 'Content-Disposition: attachment; filename="' . $name . '"' ); readfile( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile exit; } public static function mark_ordered( int $design_id, int $order_id ): void { update_post_meta( $design_id, self::META_ORDER, $order_id ); self::note_unprinted( $design_id ); self::$order_ids = null; } /** Notes that a cart was seen with the design now, at most once an hour (the cleanup counts days). */ public static function touch( int $design_id ): void { if ( (int) get_post_meta( $design_id, self::META_SEEN, true ) < time() - HOUR_IN_SECONDS ) { update_post_meta( $design_id, self::META_SEEN, time() ); } } /** How many orders have a design. */ public static function order_count(): int { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT meta_value) FROM {$wpdb->postmeta} WHERE meta_key = %s", self::META_ORDER ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- No WordPress API counts distinct meta values, and the answer changes with every order. } /** * Every order with a design, newest (highest ID) first. * * @return int[] Order IDs. */ public static function order_ids(): array { global $wpdb; if ( null !== self::$order_ids ) { return self::$order_ids; } $ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT CAST(meta_value AS UNSIGNED) AS order_id FROM {$wpdb->postmeta} WHERE meta_key = %s ORDER BY order_id DESC", self::META_ORDER ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The distinct orders the designs are saved with: no WordPress API lists distinct meta values, and the answer changes with every order. self::$order_ids = array_values( array_filter( array_map( 'intval', $ids ) ) ); return self::$order_ids; } /** * The orders with a design among WooCommerce orders matching a query. * * @param array $query wc_get_orders() arguments (status, date_created). * @return int[] Order IDs, newest first. */ public static function orders_where( array $query ): array { $matching = wc_get_orders( array( 'return' => 'ids', 'limit' => -1, ) + $query ); return array_values( array_intersect( self::order_ids(), array_map( 'intval', (array) $matching ) ) ); } /** * The designs ordered with any of some orders, by token, their meta read with them in one query. * * @param int[] $order_ids The orders. * @return array */ public static function designs_in_orders( array $order_ids ): array { if ( ! $order_ids ) { return array(); } $designs = get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => 'private', 'numberposts' => -1, 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- One indexed key, the designs of the orders asked for. array( 'key' => self::META_ORDER, 'value' => array_map( 'intval', $order_ids ), 'compare' => 'IN', 'type' => 'NUMERIC', ), ), ) ); return array_column( $designs, null, 'post_name' ); } /** * Each designed item of an order and its print files: [ item ID => [ design ID, sides ] ]. * * @param \WC_Order $order The order. * @param array $by_token Its designs by token where the caller has them (designs_in_orders()); * the others are looked up in one query. * @return array */ public static function order_designs( \WC_Order $order, array $by_token = array() ): array { $tokens = self::order_tokens( $order ); $by_token += self::designs_by_token( array_diff( $tokens, array_keys( $by_token ) ) ); $designs = array(); foreach ( $tokens as $item_id => $token ) { if ( isset( $by_token[ $token ] ) ) { $designs[ $item_id ] = array( 'design' => $by_token[ $token ]->ID, 'sides' => self::sides( $by_token[ $token ]->ID ), ); } } return $designs; } /** * How many print files an order has: one per printed side of each designed item that has one made. * * @param \WC_Order $order The order. * @param array $by_token Its designs by token where the caller has them (designs_in_orders()). */ public static function print_file_count( \WC_Order $order, array $by_token = array() ): int { return array_sum( array_map( static fn( array $design ): int => count( $design['sides'] ) - self::missing_print_files( $design['design'] ), self::order_designs( $order, $by_token ) ) ); } /** * Removes designs that never made it into an order and that no cart was seen with for the days a design is kept * (counted from when it was saved, or from when a cart was last seen with it), in batches until none are left or the run has taken * `$seconds`; the next run (twice a day) carries on. Returns how many were removed. */ public static function cleanup( int $seconds = 60 ): int { $until = microtime( true ) + $seconds; $before = time() - Settings::keep_days() * DAY_IN_SECONDS; $removed = 0; do { $stale = get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => 'private', 'numberposts' => 100, 'fields' => 'ids', 'orderby' => 'date', 'order' => 'ASC', 'date_query' => array( array( 'before' => gmdate( 'Y-m-d H:i:s', $before ), 'column' => 'post_date_gmt', ), ), 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Designs no order took and no cart was seen with lately: one batch in each twice-daily cleanup. array( 'key' => self::META_ORDER, 'compare' => 'NOT EXISTS', ), array( 'relation' => 'OR', array( 'key' => self::META_SEEN, 'compare' => 'NOT EXISTS', ), array( 'key' => self::META_SEEN, 'value' => $before, 'compare' => '<', 'type' => 'NUMERIC', ), ), ), ) ); foreach ( $stale as $design_id ) { self::delete( (int) $design_id ); ++$removed; } } while ( $stale && microtime( true ) < $until ); return $removed; } /** Site Health: print files must not be reachable from the web (nginx ignores the folder's .htaccess). */ public static function site_health_test( array $tests ): array { $tests['direct']['printblob_print_files'] = array( 'label' => __( 'PrintBlob print files are private', 'printblob' ), 'test' => array( __CLASS__, 'site_health_result' ), ); return $tests; } /** Puts a canary file in the print folder and asks the site for it over HTTP. */ public static function site_health_result(): array { $root = self::ensure_print_root(); $canary = 'site-health-' . wp_generate_password( 12, false ) . '.txt'; file_put_contents( $root . '/' . $canary, 'private' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents $uploads = wp_get_upload_dir(); $url = trailingslashit( $uploads['baseurl'] ) . STORED['print_folder'] . '/' . $canary; $response = wp_remote_get( $url, array( 'timeout' => 10, 'sslverify' => apply_filters( 'https_local_ssl_verify', false, $url ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core's own filter, applied as WP_Site_Health applies it. ) ); wp_delete_file( $root . '/' . $canary ); $code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response ); $exposed = 200 === $code && 'private' === wp_remote_retrieve_body( $response ); $rule = '
location ^~ /wp-content/uploads/' . esc_html( STORED['print_folder'] ) . '/ { deny all; }
'; $result = array( 'label' => __( 'PrintBlob print files are private', 'printblob' ), 'status' => 'good', 'badge' => array( 'label' => 'PrintBlob', 'color' => 'blue', ), 'description' => '

' . esc_html__( 'Customers’ print files cannot be downloaded from the web; shop managers download them from the order screen.', 'printblob' ) . '

', 'test' => 'printblob_print_files', ); if ( $exposed ) { $result['label'] = __( 'PrintBlob print files can be downloaded from the web', 'printblob' ); $result['status'] = 'critical'; $result['description'] = '

' . esc_html__( 'This server does not apply the .htaccess file that protects the print folder (nginx ignores it). Anyone who learns a print file’s address could download it. Ask your host to add this rule to the site’s nginx configuration:', 'printblob' ) . '

' . $rule; } elseif ( 0 === $code || $code >= 500 ) { // The site could not ask itself (many hosts block requests to their own address): unknown, not good. $result['label'] = __( 'PrintBlob could not check that print files are private', 'printblob' ); $result['status'] = 'recommended'; $result['description'] = '

' . esc_html__( 'The site could not request a test file from its own print folder, so it cannot tell whether print files are protected. On Apache the folder’s .htaccess protects them; on nginx, make sure the site’s configuration has this rule:', 'printblob' ) . '

' . $rule; } return $result; } /** Deletes a design with its previews and print files. */ public static function delete_design( int $design_id ): void { self::delete( $design_id ); } private static function delete( int $design_id ): void { foreach ( self::sides( $design_id ) as $side ) { if ( ! empty( $side['preview_id'] ) ) { wp_delete_attachment( (int) $side['preview_id'], true ); } if ( ! empty( $side['print_path'] ) ) { self::delete_print_folder( $side['print_path'] ); } foreach ( (array) ( $side['uploads'] ?? array() ) as $upload ) { self::delete_print_folder( (string) $upload ); } } wp_delete_post( $design_id, true ); self::$order_ids = null; } /** Deletes the folder holding one design's print file, only ever inside the print folder. */ private static function delete_print_folder( string $relative ): void { global $wp_filesystem; $root = realpath( self::print_root() ); $folder = realpath( dirname( self::print_root() . '/' . $relative ) ); if ( ! $root || ! $folder || ! str_starts_with( $folder, $root . DIRECTORY_SEPARATOR ) ) { return; } require_once ABSPATH . 'wp-admin/includes/file.php'; if ( WP_Filesystem() ) { $wp_filesystem->delete( $folder, true ); } } }