'POST', 'callback' => array( self::class, 'create_photo' ), 'permission_callback' => array( self::class, 'check_header' ), ) ); register_rest_route( self::NAMESPACE, '/photos/(?P[A-Za-z0-9._-]{1,128})', array( 'methods' => 'DELETE', 'callback' => array( self::class, 'delete_photo' ), 'permission_callback' => array( self::class, 'check_header' ), ) ); register_rest_route( self::NAMESPACE, '/tryons', array( 'methods' => 'POST', 'callback' => array( self::class, 'create_tryon' ), 'permission_callback' => array( self::class, 'check_header' ), 'args' => array( 'photo' => array( 'type' => 'string', 'required' => true, ), 'product_id' => array( 'type' => 'integer', 'required' => true, 'minimum' => 1, ), 'variation_id' => array( 'type' => 'integer', 'default' => 0, 'minimum' => 0, ), ), ) ); register_rest_route( self::NAMESPACE, '/tryons/(?P' . self::UUID . ')', array( array( 'methods' => 'GET', 'callback' => array( self::class, 'get_tryon' ), 'permission_callback' => array( self::class, 'check_header' ), ), array( 'methods' => 'DELETE', 'callback' => array( self::class, 'delete_tryon' ), 'permission_callback' => array( self::class, 'check_header' ), ), ) ); register_rest_route( self::NAMESPACE, '/tryons/(?P' . self::UUID . ')/image', array( 'methods' => 'GET', 'callback' => array( self::class, 'get_image' ), // An cannot send headers; the signed token (bound to the visitor cookie) protects it. 'permission_callback' => '__return_true', 'args' => array( 'expires' => array( 'type' => 'integer', 'required' => true, ), 'token' => array( 'type' => 'string', 'required' => true, ), ), ) ); } /** * Permission callback: the CSRF header must be present. * * @param WP_REST_Request $request Request. * @return true|WP_Error */ public static function check_header( WP_REST_Request $request ) { if ( '1' === $request->get_header( self::HEADER ) ) { return true; } return self::error( new ApiError( ApiError::BAD_REQUEST ) ); } /** * POST /photos (multipart, field "photo"). * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public static function create_photo( WP_REST_Request $request ) { return self::handle( static function ( TryOnService $service ) use ( $request ): WP_REST_Response { $bytes = self::uploaded_bytes( $request->get_file_params() ); return new WP_REST_Response( $service->upload_photo( $bytes, Visitor::ensure(), Visitor::ip_hash() ), 201 ); } ); } /** * DELETE /photos/{photo}. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public static function delete_photo( WP_REST_Request $request ) { return self::handle( static function ( TryOnService $service ) use ( $request ): WP_REST_Response { $service->delete_photo( (string) $request->get_param( 'photo' ), Visitor::current() ); return new WP_REST_Response( null, 204 ); } ); } /** * POST /tryons. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public static function create_tryon( WP_REST_Request $request ) { return self::handle( static function ( TryOnService $service ) use ( $request ): WP_REST_Response { $result = $service->start( Visitor::current(), Visitor::ip_hash(), get_current_user_id(), (string) $request->get_param( 'photo' ), (int) $request->get_param( 'product_id' ), (int) $request->get_param( 'variation_id' ) ); InlineRun::after_response( $result['id'] ); return new WP_REST_Response( $result, 202 ); } ); } /** * GET /tryons/{id}. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public static function get_tryon( WP_REST_Request $request ) { return self::handle( static function ( TryOnService $service ) use ( $request ): WP_REST_Response { $id = (string) $request->get_param( 'id' ); $status = $service->status( $id, Visitor::current(), get_current_user_id() ); // No background worker took it: run it in this request (the claim keeps it to one run). if ( 'queued' === $status['status'] ) { InlineRun::if_stalled( $id ); $status = $service->status( $id, Visitor::current(), get_current_user_id() ); } return new WP_REST_Response( array( 'id' => $status['id'], 'status' => $status['status'], 'error' => null === $status['error'] ? null : array( 'code' => $status['error'], 'message' => Messages::failure( $status['error'] ), ), 'image_url' => null === $status['image'] ? null : add_query_arg( array( 'expires' => $status['image']['expires'], 'token' => $status['image']['token'], ), rest_url( self::NAMESPACE . '/tryons/' . $id . '/image' ) ), ) ); } ); } /** * DELETE /tryons/{id}. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public static function delete_tryon( WP_REST_Request $request ) { return self::handle( static function ( TryOnService $service ) use ( $request ): WP_REST_Response { $service->discard( (string) $request->get_param( 'id' ), Visitor::current(), get_current_user_id() ); return new WP_REST_Response( null, 204 ); } ); } /** * GET /tryons/{id}/image: streams the JPEG and ends the request. * * @param WP_REST_Request $request Request. * @return WP_Error Only when the link is not valid. */ public static function get_image( WP_REST_Request $request ) { $service = self::service(); if ( $service instanceof WP_Error ) { return $service; } try { $file = $service->image( (string) $request->get_param( 'id' ), Visitor::current(), get_current_user_id(), (int) $request->get_param( 'expires' ), (string) $request->get_param( 'token' ) ); } catch ( ApiError $e ) { return self::error( $e ); } header( 'Content-Type: image/jpeg' ); header( 'Content-Length: ' . (string) filesize( $file ) ); header( 'Content-Disposition: inline; filename="try-on.jpg"' ); header( 'Cache-Control: private, no-store, max-age=0' ); header( 'X-Content-Type-Options: nosniff' ); header( "Content-Security-Policy: default-src 'none'" ); header( 'Referrer-Policy: no-referrer' ); readfile( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Streams a private file that only PHP may serve. exit; } /** * Keeps shared caches from storing any Fitzaro response: they are per-shopper. * * @param mixed $response Response. * @param mixed $server Server. * @param mixed $request Request. * @return mixed */ public static function no_store( $response, $server, $request ) { if ( $response instanceof WP_REST_Response && $request instanceof WP_REST_Request && 0 === strpos( $request->get_route(), '/' . self::NAMESPACE . '/' ) ) { $response->header( 'Cache-Control', 'private, no-store, max-age=0' ); } return $response; } /** * Runs a handler with the service, turning ApiError into WP_Error. * * @param callable $handler Receives TryOnService, returns WP_REST_Response. * @return WP_REST_Response|WP_Error */ private static function handle( callable $handler ) { $service = self::service(); if ( $service instanceof WP_Error ) { return $service; } try { return $handler( $service ); } catch ( ApiError $e ) { if ( ApiError::LIMIT_REACHED === $e->code() && Limits::BUDGET === $e->detail() ) { BudgetNotice::record(); } return self::error( $e ); } } /** * The service, or an error when try-on cannot run on this site. * * @return TryOnService|WP_Error */ private static function service() { $files = Storage::store(); if ( null === $files || ! Settings::enabled() || ! SystemCheck::is_ready() ) { return self::error( new ApiError( ApiError::UNAVAILABLE ) ); } return new TryOnService( Jobs::requests(), $files, new Sanitizer(), Storage::signer(), new ActionSchedulerQueue(), array( ProductCheck::class, 'problem' ), array( Settings::class, 'provider_id' ), new Limits( Jobs::requests(), new Throttle( 'get_transient', 'set_transient', 'time' ), Settings::limits(), 'time' ), 'time' ); } /** * Bytes of the uploaded "photo" field. * * @param array $files $request->get_file_params(). * @throws ApiError When there is no usable upload. */ private static function uploaded_bytes( array $files ): string { $file = $files['photo'] ?? null; if ( ! is_array( $file ) || ! isset( $file['tmp_name'], $file['error'] ) || ! is_string( $file['tmp_name'] ) ) { throw new ApiError( ApiError::INVALID_PHOTO, InvalidImage::EMPTY ); } $error = (int) $file['error']; if ( UPLOAD_ERR_INI_SIZE === $error || UPLOAD_ERR_FORM_SIZE === $error ) { throw new ApiError( ApiError::INVALID_PHOTO, InvalidImage::FILE_TOO_LARGE ); } if ( UPLOAD_ERR_OK !== $error || ! is_uploaded_file( $file['tmp_name'] ) ) { throw new ApiError( ApiError::INVALID_PHOTO, InvalidImage::EMPTY ); } $size = filesize( $file['tmp_name'] ); if ( false === $size || $size > Sanitizer::MAX_BYTES ) { throw new ApiError( ApiError::INVALID_PHOTO, InvalidImage::FILE_TOO_LARGE ); } $bytes = file_get_contents( $file['tmp_name'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- PHP's own upload temp file. return false === $bytes ? '' : $bytes; } /** * WP_Error for an ApiError. * * @param ApiError $error Error. */ private static function error( ApiError $error ): WP_Error { return new WP_Error( 'fitzaro_' . $error->code(), Messages::api( $error->code(), $error->detail() ), array( 'status' => $error->status(), 'detail' => $error->detail(), ) ); } }