\d+)/prepare', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'prepare' ), 'permission_callback' => array( $this, 'can_edit_product' ), 'args' => array( 'id' => array( 'type' => 'integer', 'minimum' => 1, 'required' => true, ), // Страница со скриптом прежней версии типа не присылает — это фото на модели. 'kind' => array( 'type' => 'string', 'enum' => Photo_Kinds::ALL, 'default' => Photo_Kinds::ON_MODEL, 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'rest_sanitize_request_arg', ), ), ) ); register_rest_route( self::ROUTE_NAMESPACE, '/products/(?P\d+)/photos', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'product_photos' ), 'permission_callback' => array( $this, 'can_edit_product' ), 'args' => array( 'id' => array( 'type' => 'integer', 'minimum' => 1, 'required' => true, ), 'photoSessionId' => self::photo_session_arg(), 'attachmentIds' => array( 'type' => 'array', 'maxItems' => Product_Photos::MAX_PHOTOS, 'required' => true, 'items' => array( 'type' => 'integer', 'minimum' => 1, ), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'rest_sanitize_request_arg', ), ), ) ); register_rest_route( self::ROUTE_NAMESPACE, '/products/(?P\d+)/photos/upload', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'upload_product_photo' ), 'permission_callback' => array( $this, 'can_edit_product' ), 'args' => array( 'id' => array( 'type' => 'integer', 'minimum' => 1, 'required' => true, ), 'photoSessionId' => self::photo_session_arg(), 'attachmentId' => array( 'type' => 'integer', 'minimum' => 1, 'required' => true, 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'rest_sanitize_request_arg', ), 'slot' => array( 'type' => 'string', 'enum' => Source_Photo_Links::SLOTS, 'required' => true, 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'rest_sanitize_request_arg', ), ), ) ); register_rest_route( self::ROUTE_NAMESPACE, '/products/(?P\d+)/state', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'product_state' ), 'permission_callback' => array( $this, 'can_edit_product' ), 'args' => array( 'id' => array( 'type' => 'integer', 'minimum' => 1, 'required' => true, ), 'photoSessionId' => self::photo_session_arg(), 'featuredId' => array( 'type' => 'integer', 'minimum' => 0, 'required' => true, 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'rest_sanitize_request_arg', ), 'galleryIds' => array( 'type' => 'array', 'maxItems' => Product_State::MAX_PHOTOS, 'required' => true, 'items' => array( 'type' => 'integer', 'minimum' => 1, ), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'rest_sanitize_request_arg', ), ), ) ); register_rest_route( self::ROUTE_NAMESPACE, '/products/(?P\d+)/save', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'save' ), 'permission_callback' => array( $this, 'can_edit_product' ), 'args' => array( 'id' => array( 'type' => 'integer', 'minimum' => 1, 'required' => true, ), 'photoSessionId' => self::photo_session_arg(), 'fileFormat' => array( 'type' => 'string', 'enum' => Settings::FILE_FORMATS, 'required' => true, 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'rest_sanitize_request_arg', ), 'image' => array( 'type' => 'object', 'required' => true, 'additionalProperties' => false, 'properties' => array( 'imageId' => array( 'type' => 'string', 'pattern' => self::UUID_PATTERN, 'required' => true, ), 'watermark' => array( 'type' => 'boolean', 'required' => true, ), 'aiLabel' => array( 'type' => 'boolean', 'required' => true, ), ), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'rest_sanitize_request_arg', ), 'target' => array( 'type' => 'string', 'enum' => Product_Saver::TARGETS, 'required' => true, 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'rest_sanitize_request_arg', ), // Код атрибута вариаций → код значения; null — товар целиком. Коды сверяются // с атрибутами этого товара уже в обработчике: схема их не знает. 'variation' => array( 'type' => array( 'object', 'null' ), 'default' => null, 'additionalProperties' => array( 'type' => 'string', 'minLength' => 1, 'maxLength' => 200, ), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'rest_sanitize_request_arg', ), 'addToGallery' => array( 'type' => 'boolean', 'default' => false, 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'rest_sanitize_request_arg', ), ), ) ); register_rest_route( self::ROUTE_NAMESPACE, '/products/(?P\d+)/images', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'product_images' ), 'permission_callback' => array( $this, 'can_edit_product' ), 'args' => array( 'id' => array( 'type' => 'integer', 'minimum' => 1, 'required' => true, ), ), ) ); register_rest_route( self::ROUTE_NAMESPACE, '/products/(?P\d+)/images', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'save_product_images' ), 'permission_callback' => array( $this, 'can_edit_product' ), 'args' => array( 'id' => array( 'type' => 'integer', 'minimum' => 1, 'required' => true, ), 'mainId' => array( 'type' => 'integer', 'minimum' => 1, 'required' => true, 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'rest_sanitize_request_arg', ), 'galleryIds' => array( 'type' => 'array', 'maxItems' => Images_Layout::MAX_GALLERY, 'required' => true, 'items' => array( 'type' => 'integer', 'minimum' => 1, ), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'rest_sanitize_request_arg', ), // Id вариации → вложение; 0 — снять изображение вариации. Что вариации // этого товара, а вложения — его фото, проверяет обработчик: схема их не знает. 'variationImages' => array( 'type' => 'object', 'default' => array(), 'additionalProperties' => array( 'type' => 'integer', 'minimum' => 0, ), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'rest_sanitize_request_arg', ), ), ) ); register_rest_route( self::ROUTE_NAMESPACE, '/storefront-block', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'save_storefront_block' ), 'permission_callback' => array( $this, 'can_edit_products' ), 'args' => array( 'collapsed' => array( 'type' => 'boolean', 'required' => true, 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'rest_sanitize_request_arg', ), ), ) ); register_rest_route( self::ROUTE_NAMESPACE, '/connection-check', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'check_connection' ), 'permission_callback' => array( $this, 'can_manage_settings' ), 'args' => array( 'key' => array( 'type' => 'string', 'required' => false, 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); } /** * Может ли вошедший редактировать товар из адреса. * * @param WP_REST_Request $request Запрос. * @return bool */ public function can_edit_product( WP_REST_Request $request ): bool { $product_id = (int) $request['id']; return $product_id > 0 && current_user_can( 'edit_products' ) && current_user_can( 'edit_product', $product_id ); } /** * Может ли вошедший редактировать товары вообще — для адреса, который не про один товар. * * @return bool */ public function can_edit_products(): bool { return current_user_can( 'edit_products' ); } /** * Может ли вошедший менять настройки магазина. * * @return bool */ public function can_manage_settings(): bool { return current_user_can( 'manage_woocommerce' ); } /** * `POST products/{id}/prepare`: найти или завести фотосессию этого типа, получить пропуск * и вернуть адрес окна (Documents/WPPlugin-HLD.md §2.7, §2.9). Фото товара здесь * не отправляются: их раскладывает человек в окне. * * @param WP_REST_Request $request Запрос. * @return WP_REST_Response|WP_Error */ public function prepare( WP_REST_Request $request ) { $product_id = (int) $request['id']; $product = wc_get_product( $product_id ); if ( ! $product ) { return self::error( 'aimodelka_no_product', __( 'The product was not found. Reload the page.', 'aimodelka' ), 404 ); } $client = Client::from_config(); if ( null === $client ) { return self::error( 'aimodelka_no_key', __( 'Add your aiModelka key in WooCommerce → aiModelka first.', 'aimodelka' ), 400 ); } try { $result = ( new Session_Preparer( $client ) )->prepare( $product, self::page_origin( $request ), (string) $request['kind'] ); } catch ( Action_Failed $failure ) { return self::error( 'aimodelka_prepare_failed', $failure->getMessage(), $failure->temporary ? 503 : 400 ); } catch ( \Throwable $throwable ) { Logger::error( 'Preparation crashed', array( 'product_id' => $product_id, 'error' => $throwable->getMessage(), 'where' => $throwable->getFile() . ':' . $throwable->getLine(), ) ); return self::error( 'aimodelka_prepare_crashed', __( 'Something went wrong on the shop server while preparing the photos. Try again; if it repeats, look at WooCommerce → Status → Logs, source “aimodelka”.', 'aimodelka' ), 500 ); } return new WP_REST_Response( $result, 200 ); } /** * `POST products/{id}/photos`: фото товара для шага «Фото товара» окна * (Documents/WPPlugin-HLD.md §2.2, §4.4). * * Id вложений приходят из текущей формы товара: главное изображение, потом галерея. Ответ — * по каждому фото товара адрес миниатюры, имя файла и фото вещи, если оно уже в съёмке * (Media\Product_Photos). Скрипт страницы отдаёт его окну сообщением «product-photos». * * @param WP_REST_Request $request Запрос. * @return WP_REST_Response|WP_Error */ public function product_photos( WP_REST_Request $request ) { $product_id = (int) $request['id']; $session_id = (string) $request['photoSessionId']; $refused = self::refuse_foreign_window( $product_id, $session_id, 'photos' ); if ( null !== $refused ) { return $refused; } $client = Client::from_config(); if ( null === $client ) { return self::error( 'aimodelka_no_key', __( 'Add your aiModelka key in WooCommerce → aiModelka first.', 'aimodelka' ), 400 ); } try { $photos = ( new Product_Photos( $client ) )->list_for_window( $product_id, $session_id, array_map( 'intval', (array) $request['attachmentIds'] ) ); } catch ( Action_Failed $failure ) { return self::error( 'aimodelka_photos_failed', $failure->getMessage(), $failure->temporary ? 503 : 400 ); } catch ( \Throwable $throwable ) { Logger::error( 'Listing product photos crashed', array( 'product_id' => $product_id, 'error' => $throwable->getMessage(), 'where' => $throwable->getFile() . ':' . $throwable->getLine(), ) ); return self::error( 'aimodelka_photos_crashed', __( 'The shop server could not list the product photos. Close the aiModelka window and click the same button in the product again.', 'aimodelka' ), 500 ); } return new WP_REST_Response( array( 'photos' => $photos ), 200 ); } /** * `POST products/{id}/state`: что в товаре сейчас — для пометок на карточках и вопроса * о вариации в окне (Documents/WPPlugin-HLD.md §2.4, §4.4). * * Изображение товара и галерея — из текущей формы товара (id вложений присылает страница), * вариации и их изображения — из сохранённого товара, атрибуты вариаций и настройки * сохранения — Media\Product_State. К aiModelka плагин при этом не обращается, поэтому * и ключ здесь не нужен. Скрипт страницы отдаёт ответ окну сообщением «product-state». * * @param WP_REST_Request $request Запрос. * @return WP_REST_Response|WP_Error */ public function product_state( WP_REST_Request $request ) { $product_id = (int) $request['id']; $session_id = (string) $request['photoSessionId']; $refused = self::refuse_foreign_window( $product_id, $session_id, 'state' ); if ( null !== $refused ) { return $refused; } $product = wc_get_product( $product_id ); if ( ! $product ) { return self::error( 'aimodelka_no_product', __( 'The product was not found. Reload the page.', 'aimodelka' ), 404 ); } try { $state = Product_State::for_window( $product, (int) $request['featuredId'], array_map( 'intval', (array) $request['galleryIds'] ) ); } catch ( \Throwable $throwable ) { Logger::error( 'Listing the product state crashed', array( 'product_id' => $product_id, 'error' => $throwable->getMessage(), 'where' => $throwable->getFile() . ':' . $throwable->getLine(), ) ); return self::error( 'aimodelka_state_crashed', __( 'The shop server could not list the product photos. Close the aiModelka window and click the same button in the product again.', 'aimodelka' ), 500 ); } return new WP_REST_Response( $state, 200 ); } /** * `POST products/{id}/photos/upload`: загрузить одно фото товара в фотосессию — в слот, * который назвало окно (Documents/WPPlugin-HLD.md §2.2, §4.4). * * Ответ 200 — «загружено» (фото вещи, куда фото легло) или «не прошло проверку» * (Rest\Upload_Reply). Сбой — ошибка с текстом для человека: окно оставит фото в слоте * и повторит. Человек может закрыть страницу товара, пока файл уходит, — работа от этого * не обрывается. * * @param WP_REST_Request $request Запрос. * @return WP_REST_Response|WP_Error */ public function upload_product_photo( WP_REST_Request $request ) { $product_id = (int) $request['id']; $session_id = (string) $request['photoSessionId']; $attachment_id = (int) $request['attachmentId']; $refused = self::refuse_foreign_window( $product_id, $session_id, 'upload' ); if ( null !== $refused ) { return $refused; } $client = Client::from_config(); if ( null === $client ) { return self::error( 'aimodelka_no_key', __( 'Add your aiModelka key in WooCommerce → aiModelka first.', 'aimodelka' ), 400 ); } ignore_user_abort( true ); try { $reply = ( new Product_Photos( $client ) )->upload( $product_id, $session_id, $attachment_id, (string) $request['slot'] ); } catch ( Action_Failed $failure ) { return self::error( 'aimodelka_upload_failed', $failure->getMessage(), $failure->temporary ? 503 : 400 ); } catch ( \Throwable $throwable ) { Logger::error( 'Product photo upload crashed', array( 'product_id' => $product_id, 'attachment_id' => $attachment_id, 'error' => $throwable->getMessage(), 'where' => $throwable->getFile() . ':' . $throwable->getLine(), ) ); return self::error( 'aimodelka_upload_crashed', __( 'The shop server stopped while sending the photo. Click “Continue” in the aiModelka window again.', 'aimodelka' ), 500 ); } return new WP_REST_Response( $reply, 200 ); } /** * `POST products/{id}/save`: сохранить в товар одно фото из окна — в галерею или изображением * товара, целиком или вариации (Documents/WPPlugin-HLD.md §2.5, §4.4). * * Выбор вариации сверяется с атрибутами вариаций этого товара: чужой атрибут или чужое * значение — отказ словами, сохранять нечего. Затем фото и назначение записываются как * ожидающее сохранение и обрабатываются. Ответ — всё, что нужно скрипту: изображение товара * и галерея для блоков на странице, изображения вариаций (если менялись), состояние товара * для окна и строка о товаре под кнопкой aiModelka (savedText). * * @param WP_REST_Request $request Запрос. * @return WP_REST_Response|WP_Error */ public function save( WP_REST_Request $request ) { $product_id = (int) $request['id']; $session_id = (string) $request['photoSessionId']; $refused = self::refuse_foreign_window( $product_id, $session_id, 'save' ); if ( null !== $refused ) { return $refused; } $image_arg = (array) $request['image']; $image = array( 'imageId' => (string) $image_arg['imageId'], 'watermark' => (bool) $image_arg['watermark'], 'aiLabel' => (bool) $image_arg['aiLabel'], ); $target = (string) $request['target']; $variation = null; if ( Product_Saver::TARGET_MAIN === $target && is_array( $request['variation'] ) && array() !== $request['variation'] ) { $product = wc_get_product( $product_id ); $allowed = $product ? Product_State::attribute_values( $product ) : array(); if ( array() === $allowed ) { return self::error( 'aimodelka_no_variations', __( 'This product has no variations: the photo can only become the image of the whole product.', 'aimodelka' ), 400 ); } $variation = array(); foreach ( $request['variation'] as $code => $value ) { $code = (string) $code; $value = (string) $value; if ( ! isset( $allowed[ $code ] ) ) { return self::error( 'aimodelka_unknown_attribute', sprintf( /* translators: %s: attribute code sent by the aiModelka window. */ __( 'The product’s variations have no attribute “%s”. Reload the product page and try again.', 'aimodelka' ), $code ), 400 ); } if ( ! in_array( $value, $allowed[ $code ], true ) ) { return self::error( 'aimodelka_unknown_attribute_value', sprintf( /* translators: 1: attribute value code, 2: attribute code — both sent by the aiModelka window. */ __( 'The attribute “%2$s” of the product’s variations has no value “%1$s”. Reload the product page and try again.', 'aimodelka' ), $value, $code ), 400 ); } $variation[ $code ] = $value; } } Product_Saver::record_pending( $product_id, $session_id, (string) $request['fileFormat'], $image, $target, $variation, null !== $variation && (bool) $request['addToGallery'] ); // Человек может закрыть страницу товара, пока файлы скачиваются, — обработка // не должна обрываться из-за этого. ignore_user_abort( true ); try { $result = Product_Saver::process( $product_id ); } catch ( Action_Failed $failure ) { $message = $failure->getMessage(); if ( $failure->temporary ) { $message .= ' ' . __( 'The shop will try again in the background in a few minutes.', 'aimodelka' ); } return self::error( 'aimodelka_save_failed', $message, $failure->temporary ? 503 : 400 ); } catch ( \Throwable $throwable ) { Logger::error( 'Save crashed; the background job will retry', array( 'product_id' => $product_id, 'error' => $throwable->getMessage(), 'where' => $throwable->getFile() . ':' . $throwable->getLine(), ) ); return self::error( 'aimodelka_save_crashed', __( 'Something went wrong on the shop server while saving the photos. The shop will try again in the background in a few minutes.', 'aimodelka' ), 500 ); } // Свежая строка о товаре под кнопкой — «N photos in the product · дата» на языке // админки: скрипт ставит её в блок без перезагрузки страницы. $result['savedText'] = Saved_Photos::product_box_text( $product_id ); return new WP_REST_Response( $result, 200 ); } /** * `GET products/{id}/images`: фото карточки товара для окошка «Упорядочить фото» на витрине * (Documents/WPPlugin-HLD.md §2.10) — изображение товара, галерея по порядку сайта, * изображения вариаций. Читается из сохранённого товара; к aiModelka не обращается. * * @param WP_REST_Request $request Запрос. * @return WP_REST_Response|WP_Error */ public function product_images( WP_REST_Request $request ) { $product_id = (int) $request['id']; $product = wc_get_product( $product_id ); if ( ! $product ) { return self::error( 'aimodelka_no_product', __( 'The product was not found. Reload the page.', 'aimodelka' ), 404 ); } try { $state = Product_Images::state( $product ); } catch ( \Throwable $throwable ) { Logger::error( 'Listing the product images crashed', array( 'product_id' => $product_id, 'error' => $throwable->getMessage(), 'where' => $throwable->getFile() . ':' . $throwable->getLine(), ) ); return self::error( 'aimodelka_images_crashed', __( 'Could not load the product photos. Try again.', 'aimodelka' ), 500 ); } return new WP_REST_Response( $state, 200 ); } /** * `POST products/{id}/images`: записать новый порядок карточки из окошка «Упорядочить фото» * (Documents/WPPlugin-HLD.md §2.10) — изображение товара, галерею, изображения вариаций. * * Сначала проверка (Media\Images_Layout): все вложения — фото, которые у товара уже есть, * изображение товара задано и не повторяется в галерее, вариации — этого товара. Чужое * вложение или чужая вариация — отказ словами, в товар ничего не пишется. Затем запись * (Media\Product_Images::apply) и в ответ — новое состояние, как у GET. * * @param WP_REST_Request $request Запрос. * @return WP_REST_Response|WP_Error */ public function save_product_images( WP_REST_Request $request ) { $product_id = (int) $request['id']; $product = wc_get_product( $product_id ); if ( ! $product ) { return self::error( 'aimodelka_no_product', __( 'The product was not found. Reload the page.', 'aimodelka' ), 404 ); } $check = Images_Layout::check( array( 'mainId' => $request['mainId'], 'galleryIds' => (array) $request['galleryIds'], 'variationImages' => (array) $request['variationImages'], ), Product_Images::known_ids( $product ), Product_Images::variation_ids( $product ) ); if ( true !== $check['ok'] ) { Logger::warning( 'Product images request refused', array( 'product_id' => $product_id, 'reason' => $check['error'], ) ); return self::error( 'aimodelka_images_' . $check['error'], self::images_error_text( $check['error'] ), 400 ); } try { $state = Product_Images::apply( $product, $check['mainId'], $check['galleryIds'], $check['variationImages'] ); } catch ( \Throwable $throwable ) { Logger::error( 'Saving the product images crashed', array( 'product_id' => $product_id, 'error' => $throwable->getMessage(), 'where' => $throwable->getFile() . ':' . $throwable->getLine(), ) ); return self::error( 'aimodelka_images_save_crashed', __( 'Something went wrong on the shop server while saving the photo order. Try again; if it repeats, look at WooCommerce → Status → Logs, source “aimodelka”.', 'aimodelka' ), 500 ); } return new WP_REST_Response( $state, 200 ); } /** * Текст отказа проверки порядка карточки — по коду Images_Layout. * * Почти все отказы — «перезагрузите страницу»: окошко показывает карточку такой, какой она * была в момент открытия, а чужое вложение или чужая вариация в запросе означают, что товар * с тех пор поменяли. * * @param string $code Код отказа — константа ERROR_* Images_Layout. * @return string */ private static function images_error_text( string $code ): string { switch ( $code ) { case Images_Layout::ERROR_MAIN_MISSING: return __( 'Choose the product image.', 'aimodelka' ); case Images_Layout::ERROR_DUPLICATE_GALLERY: return __( 'A photo is listed twice. Reload the page and try again.', 'aimodelka' ); case Images_Layout::ERROR_MAIN_IN_GALLERY: return __( 'The product image can’t also be in the gallery. Reload the page and try again.', 'aimodelka' ); case Images_Layout::ERROR_TOO_MANY: return sprintf( /* translators: %s: maximum number of gallery photos. */ __( 'The gallery can have at most %s photos.', 'aimodelka' ), number_format_i18n( Images_Layout::MAX_GALLERY ) ); case Images_Layout::ERROR_UNKNOWN_VARIATION: return __( 'One of the variations does not belong to this product. Reload the page and try again.', 'aimodelka' ); default: return __( 'One of the photos is not in this product any more. Reload the page and try again.', 'aimodelka' ); } } /** * `POST storefront-block`: свернуть или развернуть блок на витрине (Documents/WPPlugin-HLD.md §2.1). * Состояние пишется вошедшему сотруднику, чужое изменить нельзя: id берётся из входа, а не из запроса. * * @param WP_REST_Request $request Запрос. * @return WP_REST_Response|WP_Error */ public function save_storefront_block( WP_REST_Request $request ) { $user_id = get_current_user_id(); $collapsed = (bool) $request['collapsed']; Collapsed_State::save( $user_id, $collapsed ); // Запись меты отвечает false и тогда, когда значение не изменилось, поэтому сверяется итог. if ( Collapsed_State::for_user( $user_id ) !== $collapsed ) { Logger::error( 'Storefront block state was not saved', array( 'user_id' => $user_id, 'collapsed' => $collapsed, ) ); return self::error( 'aimodelka_storefront_block_failed', __( 'Could not save the state of the aiModelka buttons. Try again.', 'aimodelka' ), 500 ); } Logger::info( 'Storefront block state saved', array( 'user_id' => $user_id, 'collapsed' => $collapsed, ) ); return new WP_REST_Response( array( 'collapsed' => $collapsed ), 200 ); } /** * `POST connection-check`: «Проверить связь» — четыре разных ответа словами * (Documents/WPPlugin-HLD.md §2.8): связь есть (почта аккаунта и баланс); ключ неверен * или отозван; сервис временно недоступен; сервер магазина не может достучаться * до aimodelka.com. Различаются по кодам ответа, а не по тексту. * * Проверяется ключ из поля страницы, если человек его ввёл, — до сохранения настроек; * иначе действующий. Ответ всегда 200: результат проверки — это данные, а не ошибка запроса. * * @param WP_REST_Request $request Запрос. * @return WP_REST_Response */ public function check_connection( WP_REST_Request $request ): WP_REST_Response { $typed_key = Config::api_key_in_constant() ? '' : trim( (string) $request->get_param( 'key' ) ); $checked_key = '' === $typed_key ? Config::api_key() : $typed_key; $is_active = Config::api_key() === $checked_key; $client = Client::from_config( $checked_key ); if ( null === $client ) { return new WP_REST_Response( array( 'ok' => false, 'result' => 'no_key', 'message' => __( 'Enter the key first.', 'aimodelka' ), ), 200 ); } try { $connection = $client->get_connection(); } catch ( Api_Exception $exception ) { if ( $is_active && Error_Kind::KeyInvalid === $exception->kind ) { Notices::flag_key_rejected(); } return new WP_REST_Response( array( 'ok' => false, 'result' => $exception->kind->value, 'message' => Texts::for_error( $exception->kind ), ), 200 ); } if ( $is_active ) { Notices::clear_key_rejected(); } return new WP_REST_Response( array( 'ok' => true, 'result' => 'ok', 'message' => Texts::connection_ok( (string) ( $connection['email'] ?? '' ), (int) ( $connection['creditBalance'] ?? 0 ) ), ), 200 ); } /** * Отказ, если товара нет или окно — не от фотосессии этого товара. * * Своя фотосессия — записанная в мете товара под любым из трёх типов. Окно от другой * фотосессии бывает, когда товар успел получить новую (прежнюю удалили в кабинете) * или человек открыл старое окно. Класть в товар или в фотосессию товара что-либо * из чужого окна нельзя — человек работает не в том окне. * * @param int $product_id Id товара. * @param string $session_id Фотосессия окна. * @param string $action Что просили — для журнала: photos, upload, state, save. * @return WP_Error|null Отказ или null, если всё сходится. */ private static function refuse_foreign_window( int $product_id, string $session_id, string $action ): ?WP_Error { if ( ! wc_get_product( $product_id ) ) { return self::error( 'aimodelka_no_product', __( 'The product was not found. Reload the page.', 'aimodelka' ), 404 ); } if ( null !== Photo_Kinds::of_session( $product_id, $session_id ) ) { return null; } Logger::warning( 'Request refused: the window belongs to another photo session', array( 'product_id' => $product_id, 'photo_session_id' => $session_id, 'action' => $action, ) ); return self::error( 'aimodelka_wrong_session', __( 'This aiModelka window belongs to another photo session. Close it and click the aiModelka button in the product again.', 'aimodelka' ), 409 ); } /** * Описание поля photoSessionId: id фотосессии окна, UUID. * * @return array */ private static function photo_session_arg(): array { return array( 'type' => 'string', 'pattern' => self::UUID_PATTERN, 'required' => true, 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'rest_sanitize_request_arg', ); } /** * Адрес страницы товара, с которой открыто окно, — редактора в админке или витрины: * схема, домен и порт. Окно отправляет сообщения только на этот адрес и только с него * принимает ответы (в контексте окна он называется `adminOrigin`). * * Берётся из заголовка Origin запроса: его ставит браузер, и это ровно тот адрес, на котором * открыта страница, — и редактор, и витрина, даже если сайт доступен и с «www», и без. * Нет заголовка — адрес админки из настроек WordPress: у витрины он тот же, что у сайта, * если админка не вынесена на отдельный домен. * * @param WP_REST_Request $request Запрос. * @return string */ private static function page_origin( WP_REST_Request $request ): string { $header = trim( (string) $request->get_header( 'origin' ) ); if ( '' !== $header && Origins::is_bare_origin( $header ) ) { return (string) Origins::of( $header ); } return (string) Origins::of( admin_url() ); } /** * Ошибка для скрипта: код, текст для человека, код ответа. * * @param string $code Код ошибки плагина. * @param string $message Текст для человека. * @param int $status Код ответа HTTP. * @return WP_Error */ private static function error( string $code, string $message, int $status ): WP_Error { return new WP_Error( $code, $message, array( 'status' => $status ) ); } }