'jpg', 'image/png' => 'png', 'image/webp' => 'webp', ); /** * Only wp-config.php may point the download elsewhere (local development), like Config::api_base(). */ public static function allowed_host(): string { if ( defined( 'CORTEXCOMMERCE_IMAGE_HOST' ) && is_string( CORTEXCOMMERCE_IMAGE_HOST ) && '' !== CORTEXCOMMERCE_IMAGE_HOST ) { return CORTEXCOMMERCE_IMAGE_HOST; } return self::ALLOWED_HOST; } /** * @param array{url: string, alt: string, image_id: string} $new_image As returned by SyncApi::fields(). * @return array{image_id: string, attachment_id: int, url: string}|null The main image when it is ours, null when * a merchant photo was left alone. * @throws SyncWriteException image_write_failed. */ public static function write( int $product_id, array $new_image ): ?array { try { return self::place( $product_id, $new_image ); } catch ( SyncWriteException $e ) { throw $e; } catch ( \Throwable $e ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message for logs, not rendered output. throw new SyncWriteException( 'image_write_failed', get_class( $e ) ); } } /** * @param array{url: string, alt: string, image_id: string} $new_image The approved image. * @return array{image_id: string, attachment_id: int, url: string}|null * @throws SyncWriteException image_write_failed. */ private static function place( int $product_id, array $new_image ): ?array { $image_id = $new_image['image_id']; $main = self::main_image( $product_id ); if ( 0 !== $main ) { return self::settled( $main, $image_id ); } if ( self::has_gallery( $product_id ) ) { return null; } $attachment = self::left_behind( $product_id, $image_id ); if ( 0 === $attachment ) { $attachment = self::sideload( $product_id, $new_image['url'], $image_id ); } else { self::restore_size( $attachment ); } // update_post_meta() unslashes; slash first so backslashes survive. False means "unchanged", not an error. update_post_meta( $attachment, SyncFieldWriter::ALT_META, wp_slash( SyncFieldWriter::plain( $new_image['alt'] ) ) ); // The download took a while: a photo added in the meantime wins. $main = self::main_image( $product_id ); if ( 0 !== $main ) { return self::settled( $main, $image_id ); } if ( self::has_gallery( $product_id ) ) { return null; } $product = wc_get_product( $product_id ); if ( ! $product instanceof \WC_Product ) { throw new SyncWriteException( 'image_write_failed', 'no product' ); } $product->set_image_id( $attachment ); $product->save(); if ( self::main_image( $product_id ) !== $attachment ) { throw new SyncWriteException( 'image_write_failed', 'main image not stored' ); } return self::report( $attachment, $image_id ); } /** * The product's main image as it is in the database now, or 0. * * @throws SyncWriteException image_write_failed. */ private static function main_image( int $product_id ): int { clean_post_cache( $product_id ); $product = wc_get_product( $product_id ); if ( ! $product instanceof \WC_Product ) { throw new SyncWriteException( 'image_write_failed', 'no product' ); } return (int) $product->get_image_id( 'edit' ); } /** * Whether this image is the product's main image now. */ public static function is_main_image( int $product_id, string $image_id ): bool { clean_post_cache( $product_id ); $product = wc_get_product( $product_id ); $main = $product instanceof \WC_Product ? (int) $product->get_image_id( 'edit' ) : 0; return 0 !== $main && get_post_meta( $main, self::META, true ) === $image_id; } /** * Whether the product has gallery pictures. Read right after main_image(), which cleared the cache. */ private static function has_gallery( int $product_id ): bool { $product = wc_get_product( $product_id ); return $product instanceof \WC_Product && array() !== $product->get_gallery_image_ids( 'edit' ); } /** * A process that died during thumbnail generation leaves an attachment without metadata. Its size is read back and * stored; thumbnails are not generated, because that is what may have killed the process. */ private static function restore_size( int $attachment ): void { $file = get_attached_file( $attachment ); if ( ! empty( wp_get_attachment_metadata( $attachment ) ) || ! is_string( $file ) || ! file_exists( $file ) ) { return; } $size = wp_getimagesize( $file ); if ( is_array( $size ) ) { wp_update_attachment_metadata( $attachment, array( 'width' => (int) $size[0], 'height' => (int) $size[1], 'file' => _wp_relative_upload_path( $file ), ) ); } } /** * The answer when the product already has a main image: ours, or someone else's to leave alone. * * @return array{image_id: string, attachment_id: int, url: string}|null * @throws SyncWriteException image_write_failed. */ private static function settled( int $main, string $image_id ): ?array { return get_post_meta( $main, self::META, true ) === $image_id ? self::report( $main, $image_id ) : null; } /** * What the backend hashes: the URL WooCommerce's REST products controller gives as images[].src. * * @return array{image_id: string, attachment_id: int, url: string} * @throws SyncWriteException image_write_failed. */ private static function report( int $attachment, string $image_id ): array { $src = wp_get_attachment_image_src( $attachment, 'full' ); if ( ! is_array( $src ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message for logs, not rendered output. throw new SyncWriteException( 'image_write_failed', 'no url for attachment ' . $attachment ); } return array( 'image_id' => $image_id, 'attachment_id' => $attachment, 'url' => $src[0], ); } /** * An attachment an earlier try left for this image, not parented to another product, or 0. */ private static function left_behind( int $product_id, string $image_id ): int { $ids = get_posts( array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_mime_type' => 'image', 'post_parent__in' => array( 0, $product_id ), 'numberposts' => 1, 'orderby' => 'ID', 'order' => 'ASC', 'fields' => 'ids', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- One row, on a rare path: there is no main image yet. 'meta_query' => array( array( 'key' => self::META, 'value' => $image_id, ), ), ) ); return array() === $ids ? 0 : (int) $ids[0]; } /** * @return int The new attachment. * @throws SyncWriteException image_write_failed. */ private static function sideload( int $product_id, string $url, string $image_id ): int { // Action Scheduler runs outside admin. require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; // Redirects are never followed, and a body over the limit is cut one byte past it, so the check below refuses it. $limits = static function ( $args ) { $args['redirection'] = 0; $args['limit_response_size'] = self::MAX_BYTES + 1; return $args; }; add_filter( 'http_request_args', $limits ); try { $temp = download_url( $url, 60 ); } finally { remove_filter( 'http_request_args', $limits ); } if ( is_wp_error( $temp ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message for logs, not rendered output. throw new SyncWriteException( 'image_write_failed', 'download: ' . $temp->get_error_code() ); } // The meta goes on as the row is inserted: a process killed during thumbnail generation still leaves a findable // attachment. Only the first insert: the hook may also see attachments that other plugins make from this one. $marker = static function ( $attachment ) use ( $image_id, &$marker ) { remove_action( 'add_attachment', $marker ); update_post_meta( (int) $attachment, self::META, wp_slash( $image_id ) ); }; try { clearstatcache( true, $temp ); $size = filesize( $temp ); if ( false === $size || $size > self::MAX_BYTES ) { throw new SyncWriteException( 'image_write_failed', 'too large' ); } $extension = self::EXTENSIONS[ (string) wp_get_image_mime( $temp ) ] ?? ''; if ( '' === $extension ) { throw new SyncWriteException( 'image_write_failed', 'not an allowed image type' ); } add_action( 'add_attachment', $marker ); $attachment = media_handle_sideload( array( 'name' => sanitize_file_name( $image_id . '.' . $extension ), 'tmp_name' => $temp, ), $product_id ); if ( is_wp_error( $attachment ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message for logs, not rendered output. throw new SyncWriteException( 'image_write_failed', 'sideload: ' . $attachment->get_error_code() ); } return (int) $attachment; } finally { remove_action( 'add_attachment', $marker ); if ( file_exists( $temp ) ) { wp_delete_file( $temp ); } } } }